* feat(sandbox): Seatbelt lockdown translation confines outbound to the PMG proxy
Under network_via_proxy_only the Seatbelt profile now denies all
network-outbound (with a target=direct violation marker) and allows only
the loopback proxy port. SBPL is last-match-wins, so the broad deny is
emitted first, specific allows after, and the allow_network_bind rules
last — keeping loopback-to-loopback dev traffic working under lockdown.
allow_direct_dns re-opens the /var/run/mDNSResponder unix socket that
the deny otherwise covers.
Replaces the temporary fail-closed rejection in the Seatbelt driver with
the real translation; non-lockdown profiles translate byte-identically
to before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* test(sandbox): assert deny marker presence before ordering comparison
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* feat(sandbox): render lockdown profiles without a proxy as deny-only with runtime note
pmg sandbox profile show renders profiles for debugging and must not
fail on lockdown profiles. Without a running proxy the translator keeps
the broad deny (rendered profile stays fail-closed, never looser than
runtime) and documents the runtime-only proxy-port allow in an SBPL
comment instead of fabricating a port. Execution is unaffected: the
driver validates the proxy address before translating.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(sandbox): ExecutionContext plumbing and fail-closed lockdown contract
Network lockdown needs the PMG proxy's address, which is only known at
spawn time. Thread an ExecutionContext from the proxy flow through the
runner and executor into every sandbox driver, and enforce the
network_via_proxy_only fail-closed contract: lockdown without a running
loopback proxy, or on a driver that cannot enforce it, is a hard error —
never a silent fallback to unrestricted network.
- sandbox.ExecutionContext{ProxyAddr} + 4-arg Sandbox.Execute
- sandbox.ValidateLockdown validates the proxy address (loopback only)
with usefulerror code SandboxRequiresProxy
- Seatbelt validates lockdown before translation (translation itself
lands next); bubblewrap and landlock reject lockdown as unsupported
until Linux enforcement is implemented
- executor.WithExecutionContext, runner.ExecuteOptions.SandboxProxyAddr,
proxy flow passes the live proxy address
- ApplySandbox also validates centrally before invoking the driver
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* fix(sandbox): require numeric in-range proxy port in ValidateLockdown
The validated port string is embedded into generated sandbox profiles,
so service names, zero, and out-of-range ports are refused.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* fix(sandbox): fail closed on Seatbelt lockdown until translation lands
A lockdown policy that passed proxy validation would silently receive
the pre-lockdown network rules from the translator. Reject it until the
lockdown profile translation is implemented, keeping the window between
plumbing and enforcement fail-closed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* chore: review feedback — drop redundant comment, simplify stub help text
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(sandbox): add network_via_proxy_only and allow_direct_dns policy fields
Config surface for network lockdown: network_via_proxy_only confines a
sandboxed package manager's outbound network to the PMG proxy;
allow_direct_dns is its escape hatch re-opening direct DNS. Both follow
the existing pointer-bool inheritance pattern in MergeWithParent. Lint
warns when allow_direct_dns is set without network_via_proxy_only, where
it has no effect.
The fields are declared and inherited but unread; enforcement lands with
the ExecutionContext plumbing and Seatbelt lockdown translation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* fix(sandbox): deep-copy new pointer flags in profile resolution
expandPolicyPaths re-points the older pointer booleans so callers cannot
corrupt the registry-cached policy; the new NetworkViaProxyOnly and
AllowDirectDNS fields need the same isolation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
---------
Co-authored-by: Claude <noreply@anthropic.com>
* ci: add macOS test job
Darwin-tagged tests (Seatbelt translator, diagnostics, upcoming network
lockdown tests) never ran in CI: the test matrix covered ubuntu and
windows only. The M0 sandbox lockdown milestone is macOS-first, so macOS
coverage must exist before enforcement work lands.
Also establishes the convention that darwin integration tests requiring
sandbox-exec must fail (not skip) when running in CI, so missing tooling
cannot silently hide security tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* test(sandbox): make Seatbelt render golden test machine-independent
The golden file baked the authoring machine's HOME, CWD and TMPDIR parent
into the expected profile, so TestRenderSeatbelt_Golden could only pass on
that machine. It never ran in CI before the macOS job exposed it.
Normalize CWD, home and TMPDIR parents (raw and query-escaped forms) to
stable placeholders before comparison, longest path first so prefix
overlaps survive, and rewrite the golden accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* ci: make setup-go cache explicit in macOS job
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(uvx): add uvx (uv tool run) package executor
Adds support for `uvx`, implemented as a PyPI Executor alongside pipx.
uvx is an alias for `uv tool run`: it installs a tool into an ephemeral
environment and runs it, so it has no install/list subcommand and the
first positional argument (or --from) is the package to audit.
Parsing highlights:
- --from overrides the positional command as the package to audit
- --with packages are audited as additional environment dependencies
- name@version shorthand (ruff@0.3.0, ruff@latest) is normalized
- flag parsing stops at the tool name so the tool's own flags are not
misread as uvx options; uvx's value/boolean flags are registered so
none greedily consume the package positional
- VCS/URL/local-path specs are skipped for registry auditing
Wires up command registration, analytics, shell alias/shim, cloud audit
mapping, a dedicated `uvx` sandbox profile (UV_*/PIP_* env, uv cache and
tool dirs), config policy, docs, unit tests and an E2E workflow step.
Closes#326https://claude.ai/code/session_011hyLxq7oWJX5Dp4tCEfG19
* chore(uvx): align docs and base profile with uvx support
Incorporates the low-risk, non-parser improvements from the community
PR #345 (author non-responsive) into our implementation:
- list uvx (and the previously-missing pipx) as PyPI managers in the
pypi-restrictive base profile package_managers and its README, so the
base profile applies directly when selected via --sandbox-profile
- document uvx in docs/github-action.md and docs/proxy-mode.md
- add version / IsExplicitVersion assertions to the uvx parser tests
Our pflag-based parser is kept as-is: unlike #345 it audits --with
packages and handles all uvx short flags (e.g. -w), both of which the
community PR misses.
* fix(uvx): skip interpreter requests; use require in tests
Addresses review feedback on PR #357:
- uvx interpreter requests (`uvx python`, `uvx python@3.12`, `uvx pypy`,
...) launch an isolated interpreter rather than installing a PyPI tool.
Treating the positional as a package made the guard flow resolve/analyze
pkg:pypi/python (and python==3.12), which could wrongly block or fail a
valid invocation. Skip these for the positional; --with packages on the
same command are still audited.
- Use require.NoError / require.Len for fatal assertions in the uvx tests,
matching the repo's testing convention, so a failure stops the subtest
before a nil dereference instead of panicking.
* docs(uvx): document fail-open and --with-requirements trade-offs
Record the two deliberate parsing decisions raised in review as in-code
trade-off comments (no behavior change):
- unknown flags are tolerated (fail open), consistent with the other
executors; the residual gap only affects non-proxy guard mode since the
default proxy flow intercepts every registry download.
- --with-requirements / --with-editable values are consumed but not
expanded into audit targets; expanding them needs manifest-extractor and
guard changes, tracked as follow-up. Proxy mode still covers them.
* docs(uvx): drop --with-requirements limitation note
Per maintainer review: guard mode is being deprecated and auditing the
contents of an existing requirements file is a scanner's responsibility,
not PMG's. Remove the "known limitation / follow-up" note; the flags stay
registered only so their values are not mistaken for the tool positional.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(sandbox): support pnpm workspaces and macOS cache dir in pnpm profile
pnpm in a workspace (monorepo) creates a node_modules directory inside
every workspace package to symlink direct dependencies. The profile only
allowed writes to the root node_modules, so installs failed with EPERM
on mkdir of e.g. apps/mobile/node_modules.
pnpm on macOS also writes its cache (lockfile verification, metadata)
under ~/Library/Caches/pnpm, while the base profile only covers the XDG
path ~/.cache/pnpm.
Fixes are scoped to the pnpm leaf profile, not the shared
npm-restrictive base.
Ref: https://github.com/safedep/pmg/issues/329
* fix(sandbox): emit regex parent rule for nested-glob allow patterns on Seatbelt
For allow patterns ending in /**, the translator auto-allows the parent
directory so mkdir/stat of the directory itself succeeds. The rule was
always emitted as a literal, which can never match when the parent still
contains glob characters (e.g. ${CWD}/**/node_modules from a workspace
allowance) — silently leaving the directory's own creation denied.
Emit a regex rule for glob-bearing parents instead. This stays strictly
narrower than the Linux drivers (Bubblewrap binds the prefix before the
first /** read-write; Landlock grants the glob expansion or its parent),
and deny rules are emitted after allows, so mandatory credential denies
still override.
Ref: https://github.com/safedep/pmg/issues/329
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(sandbox): bind parent dir for globstar allow_write on bwrap
Fine-grained per-path mounts under read-only project binds broke pip
install into in-project .venv directories. Always mount the parent tree
for ** write rules instead.
Fixes#315
* test(sandbox): tighten globstar bind assertions and ensure ~/.npm exists for e2e
Strengthen TestBubblewrapAllowWriteGlobstarBindsParentOnly to verify the
parent dir is writably bound and the child path is read-only bound, not
just substring presence. Pre-create ~/.npm in the e2e harness so
bubblewrap --bind-try does not skip the npm cache dir on fresh runners.
* switch pnpm to /tmp in sandbox e2e
* test(sandbox): update glob ** test for parent-bind semantics
Globstar allow_write now binds the parent dir only (e2e740d), so the
test should assert the parent is writably bound and child subdirs are
not individually bound, instead of substring-matching subdir names.
* fix(sandbox): bind correct base dir for in-pattern globstar allow_write
Globstar allow_write previously used extractGlobParentDir, which walks past
the first ** and yields the wrong root for patterns like /a/b/**/d/**/e.
Introduce extractGlobstarWriteBaseDir, which takes the prefix before the
first /**, and use it in processWriteRule. Also dedup the coarse-fallback
parent-bind loop to mirror the read-rule fallback.
* fix(sandbox): classify helper-tool errors with usefulerror
Sandbox helper commands (profile lint/diff/show/init/list) used to bubble
up plain fmt.Errorf chains from the registry layer, which the TUI then
classified as Unknown and decorated with a bug-report link. Wrap each
error path at the cmd/sandbox boundary so the TUI prints NotFound,
InvalidArgument, or PermissionDenied with actionable hints instead.
Closes#269
* refactor(sandbox): classify registry errors via sentinel wrapping
Replace the fragile substring match in profileLoadError with errors.Is
against new sandbox.ErrProfileNotFound / sandbox.ErrProfileInvalid
sentinels. Every fmt.Errorf in registry.go that previously communicated
"missing" or "malformed" by message text now wraps the corresponding
sentinel, so the cmd layer can classify without inspecting strings.
* fix(sandbox): detect IO error class when wrapping helper errors
Replace static ErrCodeUnknown / ErrCodePermissionDenied wrappings with
ioErrorCode, which inspects the error chain for fs.ErrPermission and
fs.ErrNotExist before falling back. Applied to runProfileList (where an
unreadable user profile directory now classifies as PermissionDenied),
registryInitError, and the stat/MkdirAll/WriteFile paths in profile init.
Also drop redundant doc comments on helpers whose names are self-evident.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(sandbox): allow opt-out of mandatory deny via explicit allow rules
Mandatory deny patterns (.env, .aws, .ssh, .gcloud, .kube, .gnupg,
.docker/config.json, .git/config) can now be opted out by listing the
exact literal post-expansion path in policy filesystem.allow_read /
allow_write, OR via --sandbox-allow read=... / write=... at runtime.
Both channels are treated at par.
Suppression is exact-match. Listing the CWD-absolute or HOME-absolute
form of a dangerous file additionally suppresses its **/<file> glob
sibling on the same direction so a single opt-out is sufficient.
Broad globs (${CWD}/**) and relative paths in user allow lists do not
suppress. The unnamed absolute form remains denied. .git/hooks is
unconditional and never suppressible (arbitrary code execution risk).
GetMandatoryDenyPatterns now returns split DenyRead / DenyWrite
slices and reports SuppressedRead / SuppressedWrite for audit. Both
translators emit per-direction deny rules and log.Warnf each
suppression. On Linux/bubblewrap, the tmpfs hide is restricted to the
intersection of DenyRead and DenyWrite; one-sided suppression falls
back to /dev/null (write) or the user's allow_read --ro-bind (read).
bwrap has no primitive that allows writes while denying reads, so
write-only opt-outs warn that the read-side mandatory deny is
unenforceable.
Updates docs/sandbox.md to document the opt-out, exact-match
semantics, and the Linux platform limitation. Updates pmg-e2e.yml to
create ./.env so the sandbox e2e test exercises the BLOCK case.
Closes#232
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: Code review fixes
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: Add support for bubblewrap sandbox
* fix: Glob pattern expansion limit for linux
* fix: Bug in glob pattern expansion for bwrap
* fix: README on trust
* fix: Multiple bubblewrap translator fix
* test: Add E2E for linux sandbox
* fix: Refactor bwrap sandbox to use common dangerous files
* fix: Path test case
* fix: Non-existent path handling bug
* refactor: Misc cleanup
* fix: Avoid bind mount for non-existentent deny protection
* fix: Off by one bug in path depth handling
* ci: Disable AppArmor on GHA runner
* fix: Disable apparmor userns restrictions