Use UserBinDir for PATH checks and pass aliases as not required under
system install without treating that as active interception.
Co-authored-by: Cursor <cursoragent@cursor.com>
Tighten shim detection, profile repair, and install ordering while
trimming over-specific doctor/info hints from the system-install path.
Co-authored-by: Cursor <cursoragent@cursor.com>
Install shared shims, managed configuration, and login-shell PATH integration so golden images and multi-user hosts can protect package installs for every user.
Co-authored-by: Cursor <cursoragent@cursor.com>
* 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>
* fix(cloud): surface real backend errors from pmg cloud sync
runSync wrapped every DrainToCloud failure as a network error, masking
the actual cause — an entitlement failure surfaced as "check your
network connectivity", which made backend issues very hard to diagnose.
Classify the error first (usefulerror gRPC converters map backend
statuses to authentication, entitlement, quota and server errors) and
pass it through. The network-flavored message remains only as the
fallback when nothing can classify the error. Bump safedep/dry to pick
up nested-Any ErrorInfo extraction so entitlement classification also
works against control-tower versions that re-wrap status details.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHdvpFXb2shyMzUDyR8QKA
* chore: bump safedep/dry to latest branch commit
Picks up the review follow-up in dry#128 (skip unmarshalling unknown
detail types when unwrapping Any).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHdvpFXb2shyMzUDyR8QKA
* chore: bump safedep/dry to post-merge main
Replaces the dry#128 branch pseudo-version with the squashed main
commit now that the nested-Any ErrorInfo fix has merged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHdvpFXb2shyMzUDyR8QKA
---------
Co-authored-by: Claude <noreply@anthropic.com>
* docs(specs): add custom block messages and package blocklist spec
* docs(specs): add custom block messages and package blocklist implementation plan
* feat(config): add blocked_packages list and custom block messages
* feat(audit): add package_blocklist_blocked event and blocklist model
* feat(proxy): block blocklisted packages in the policy gate before analysis
* feat(guard): block blocklisted packages before trust skip and analysis
* feat(ui): render blocklist blocks and custom messages, fix silent-mode block output
* feat(proxy): append custom messages to malware and go-cooldown block bodies
* test(proxye2e): cover blocklist enforcement and custom block messages
* docs(specs): remove spec and plan documents
* refactor: drop guard-flow blocklist enforcement and trim docs
Guard mode is being deprecated; the blocklist is enforced in proxy mode
only. Remove the trusted_packages mirroring references outside the docs.
* refactor(config): consolidate blocklist and block message under top-level block section
Replace dependency_cooldown.message, malware.message and blocked_packages
with a single block section: block.message is appended to every block
output regardless of which control blocked, and block.packages is the
package blocklist.
* fix(ui): render block.message as info note with clean spacing
* fix(ui): indent wrapped continuation lines in block reasons and messages
* update config template
* refactor(config): replace block section with top-level advisory_message
Remove the package blocklist (will be implemented as part of policies in
the future) and replace block.message with an optional top-level
advisory_message appended to every block output.
* chore(config): move advisory_message near top-level scalar configs in template
* fix: fail fast with clear error when package manager is not installed
When PMG shims intercept a package manager that isn't installed (e.g. a
clean JAMF-provisioned laptop where PMG is set up before dev tooling),
real-binary resolution failed with a generic "unknown" error and a
bug-report link, making it look like PMG itself had crashed.
Introduce a typed BinaryNotFoundError that exits with code 127 (standard
"command not found") and maps to a new PackageManagerNotFound error code
with an actionable message instead of the Unknown classification.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: improve missing package manager help message
Use a concise, dynamic install hint instead of explaining PMG's
PATH forwarding internals.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: mention PATH in missing package manager help text
Covers the common case where a package manager is installed but its bin
directory is not on PATH yet (e.g. after curl | bash install).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: add experimental Go module support via pmg go
Adds Go modules as a proxy-guarded ecosystem, opt-in only: the command
runs solely when invoked explicitly as `pmg go ...` and is deliberately
excluded from setup aliases and PATH shims so existing users are
unaffected.
- packagemanager: goPackageManager with fail-safe command classification
(vet/fix excluded from non-download since they can fetch on a cold
cache) and pinned-version extraction where only canonical semver
counts as explicit.
- GOPROXY normalization (fail-closed): effective GOPROXY read via
`go env` (honors go env -w), rebuilt comma-joined with `direct`
dropped so a 403 block is terminal and nothing silently falls back to
unanalyzed VCS fetches. GOPRIVATE/GONOPROXY surface a warning;
GOINSECURE is cleared. Contributed to the proxy flow through a new
ProxyRoutingProvider hook (extra child env + dynamic MITM hosts).
- Go interceptor with dynamic host matching from the user's effective
GOPROXY via InterceptorContext.GoProxyHosts. Malware analysis runs on
.zip only (the sole endpoint that delivers code); .info/.mod/@latest/
list pass through; /sumdb/ traffic and sum.golang.org are never
touched so checksum-db verification stays intact; golang.org/toolchain
is allowed on Go's own checksum verification.
- Dependency cooldown: publish time captured from .info responses
(body unmodified), in-window .zip blocked with 403; fails open for
cooldown only when the publish time was never observed.
- Cert gate: on macOS/Windows `pmg go` fails fast with actionable
guidance unless the persisted PMG CA is OS-trusted (Go ignores
SSL_CERT_FILE there); Linux works via the injected bundle.
- proxye2e: GOPROXY-protocol mock registry, Go driver and 10 hermetic
cases (allow/block/confirm, case-escaped paths, cooldown block and
fail-open, toolchain, sumdb passthrough).
Verified end-to-end on Linux: `pmg go get github.com/google/uuid@v1.6.0`
MITMs proxy.golang.org, analyzes the decoded module at the .zip fetch,
and go.sum verification succeeds through the tunneled checksum db.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xuhBeTVpfU4SdqVaarvuK
* fix(go): address review findings on experimental Go support
- Drop fmt/clean from NonDownloadCommands: both load packages via go
list and can download modules on a cold cache, which would bypass the
proxy under install_only.
- Support GOPROXY entries with a base path (e.g. corp Athens/JFrog at
https://corp/goproxy): the interceptor now receives host -> base URL
and strips the path prefix before parsing module URLs, so verdicts
and cooldown key on the real module path.
- Default unschemed GOPROXY entries to https, matching go's own
behavior, so corp mirrors configured as bare hosts are intercepted
instead of silently unanalyzed.
- Memoize the final verdict per module zip: go re-requests a failed
zip during go get's load phase, which double-recorded stats (the
report showed the same blocked module twice) and would have
re-prompted on Confirm verdicts.
- Fetch .info out-of-band on a cooldown cache miss: go serves .info
from its local module cache on any machine that used go before PMG,
which silently disabled cooldown. Failure of the side-fetch still
fails open for cooldown only.
- Move the noop package resolver into packagemanager.
Verified live: cold-cache cooldown block now records once; warm-cache
rerun is blocked via the side-fetch instead of failing open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xuhBeTVpfU4SdqVaarvuK
* docs: collapse Go proxy-mode details by default
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xuhBeTVpfU4SdqVaarvuK
---------
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>
* ci: add E2E for pmg action server-mode
Exercises the safedep/pmg action with server-mode enabled: the action
starts the proxy daemon and injects HTTP_PROXY + CA env vars, then bare
npm/pip installs are intercepted. Asserts benign installs pass, malicious
npm/pip installs are blocked, and 'pmg proxy stop --fail-on-violation'
fails the job on the blocks.
Scoped via paths to action.yml and the proxy server sources so it only
runs when relevant. Complements persistent-proxy-e2e.yml, which covers
the branch's proxy internals via raw pmg proxy commands.
* ci: extract shared proxy E2E assertions into composite action
Both the action server-mode E2E and the persistent-proxy E2E run the same
benign/malicious install assertions. Factor them into a local composite
action (.github/actions/proxy-e2e-assertions) so the assertions live in one
place and both workflows exercise the composite itself.
Proxy teardown (pmg proxy stop --fail-on-violation) stays inline in each
workflow since the setup/teardown differs per binary-under-test (released
binary via the action vs. branch build via make).
* feat(proxy): emit session summary on persistent proxy shutdown
The persistent proxy daemon never ran the per-invocation flow that calls
LogSessionComplete, so no session summary reached the cloud for server-mode
runs. Emit one from the daemon's aggregate stats collector on shutdown,
before the final cloud flush, so it is delivered with the run's events and
carries the CI/invocation context.
Extract the event emission into a shared LogSessionSummary(SessionData); both
the per-invocation flow and the daemon now use it. The daemon serves every
package manager, so the summary carries no single package manager.
* chore(action): shorten default cloud endpoint id prefix to gha/
* refactor(flows): extract SetupCACertificate for reuse
Move the CA load/generate/merge logic out of proxyFlow into an exported
flows.SetupCACertificate so the persistent proxy server can reuse it.
* feat(proxy): add persistent proxy server with start/stop/env/status
Introduces 'pmg proxy' commands backed by internal/proxyserver: a long-lived
MITM proxy that intercepts package managers via env vars (no shims). Supports
--daemon (Unix), --state, --port; generic 'env' output that skips cert vars
when the CA is OS-trusted; opt-in 'stop --fail-on-violation' (fail-closed on
crash) with a synchronous cloud event flush; and the malysis analysis cache.
* feat(action): add server-mode for persistent proxy
When server-mode=true the action starts the proxy daemon and injects proxy
env vars into the job instead of installing shims.
* test(proxy): add persistent proxy server E2E workflow
* docs(readme): document persistent proxy server mode
* fix(proxy): create cache dir before writing state file and daemon log
On a fresh CI runner the cache directory does not exist yet; os.OpenFile and
os.WriteFile do not create parent dirs, so 'pmg proxy start --daemon' failed
with 'no such file or directory'. MkdirAll the parent before writing.
* docs: add persistent proxy server architecture doc
* refactor proxyserver
* fix(proxy): always emit cert env vars instead of skipping on OS-trust status
npm/pip/yarn/requests trust the MITM CA inconsistently across tools, versions,
and configs; many still use bundled CA stores. Always emitting the cert-path
env vars is the conservative choice that works regardless, and is harmless for
tools that read the OS store (they ignore the vars). Skipping them when a
system CA exists would silently break any tool still on a bundled store.
* refactor(proxy): drop redundant audit init in daemon; rely on main.go
main.go's PersistentPreRun already initializes the audit pipeline for every
command (including the daemon's re-exec'd child) and closes it at process exit.
Re-initializing in proxyserver.Run created a second auditor and a second
cloud-sync WAL connection, orphaning the first. Removing it makes the daemon
consistent with the normal proxy flow, which never self-initializes audit.
* fix(proxy): bypass proxy env when flushing events to cloud on stop
pmg proxy stop inherits HTTP(S)_PROXY (injected by 'pmg proxy env') pointing at
the PMG proxy it just shut down. The cloud sync gRPC client honored those vars
and routed api.safedep.io through the dead proxy, failing with 'connection
refused' so no events were delivered. Clear the proxy env vars before the sync
so PMG's own cloud traffic goes direct.
* chore(proxy): address review feedback
- configurable bind host via proxy.server.listen_host (default loopback)
- proxy commands use ui.ErrorExit instead of returning errors to cobra
- rename errcode to ProxyPolicyViolation (covers malware + cooldown)
- share cloud sync via audit.DrainToCloud (de-dup with cmd/cloud/sync)
- centralize proxy CA bundle path in certmanager
- docs: persistent proxy cert trust + bind address
* fix(proxy): show real message on fail-on-violation error
stopExitError set only WithMsg, but ui.ErrorExit renders HumanError, so the
framed error showed 'no human-readable message available'. Set both from one
string, and emit the framed error before the stdout summary so the blocked
count is stated once.
* fix(proxy): flush cloud events from the daemon, not stop
The stop process inherits HTTP_PROXY (from 'pmg proxy env'), so its cloud
client routed api.safedep.io through the already-stopped proxy and failed with
connection refused. Move the flush into the daemon's shutdown, which has no
proxy env (it started before env injection) and dials SafeDep directly.
- daemon flushes on shutdown via audit.DrainToCloud and records the result in
the state file; stop surfaces it (on both success and fail-on-violation
paths) since the daemon's own logs aren't visible to stop
- coordinate stop's wait with the daemon shutdown budget; on timeout, error
out without reading stale state or deleting the file (fail-closed)
- persist blocked count before the flush so the gate stays correct if the
flush hangs or the daemon is killed mid-flush
- remove now-redundant cloud_flush.go
* disable auto-sync for proxy cmds
* feat(proxy): periodic cloud sync + move proxy env vars to packagemanager
- daemon runs a periodic cloud-sync ticker so the shutdown flush stays small;
the run total is reported by stop, and shutdown timeouts are coordinated
- move EnvVarForProxy from config to packagemanager (it is package-manager
knowledge); the shared function now builds the proxy URL and NO_PROXY itself,
removing the duplicated construction in the per-command and persistent paths
- relocate the #319 yarn and #339 IPv6 regression tests alongside the function
- enable cloud sync in the persistent-proxy E2E workflow and fix the stale
internal/proxystate path filter
* refactor(proxy): rename cloudFlushLockTimeout to cloudFlushLockWait
Consistent timeout naming: *LockWait is the lock-acquire bound, *Timeout is
the sync-RPC bound. Previously the final-flush pair was cloudFlushLockTimeout
vs cloudFlushTimeout — two lookalike names for different operations.
* refactor(proxy): extract cloudFlush and trim duplicate shutdown comments
The shutdown's final-flush block is now a cloudFlush helper, symmetric with
startCloudSyncLoop (one-shot vs loop). Removed the triplicated ticker/lock
contention comments, keeping the contract on the function doc and one-line
pointers at the call sites.
* docs: update persistent proxy cloud sync to daemon-owned model
The daemon now owns cloud delivery (periodic sync while serving + final flush
on shutdown); stop signals it, waits, and reports the result. Rewrite the Cloud
event sync section, fix stop attributions, add the cloud_sync state field, and
update the sequence diagram.
* docs: move Usage section up below How it works
Put the copy-paste recipes near the top so users find them before the internals.
* refactor(proxy): address PR review feedback
- configurable bind host/port via --host/--port flags + config (listen_host,
listen_port), bound directly to config fields per PMG's flag pattern
- daemon log path via --log-file and readiness timeout in ProxyDaemonConfig;
Daemonize no longer owns path policy (caller validates, fails fast)
- gate periodic cloud sync on auto_sync; suppress detached background sync for
proxy commands instead of flipping the flag
- pmg proxy env --export emits shell-quoted lines for eval (spaces survive)
- extract shared flows.BuildCachedMalysisAnalyzer, dropping the analyzer+cache
duplication between proxy flow and proxy server
- add internal/proxyserver/doc.go documenting the package + boundary vs flows
- E2E: assert malicious installs are blocked (drop continue-on-error)
- docs: trim Commands/State-file to user contracts; refresh bind address
* refactor(proxy): proactive alignment fixes from whole-PR review
- gate the shutdown cloud flush on auto_sync too, matching the periodic ticker
(auto_sync consistently controls all daemon-driven cloud delivery)
- ResolveStatePath takes cacheDir instead of *RuntimeConfig, keeping state.go
free of config dependency
- drop the empty-host comment in listenAddr; keep the loopback guard so a blank
host never silently binds all interfaces
* fix: Decouple localdb with malysis analyser construction
* fix: Persist global args before proxy server daemon exec
* fix: GitHub Action for cloud auto-sync in server mode
---------
Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
* feat(cooldown): respect trusted_packages in dependency cooldown
Trusted packages are now treated as a superset waiver that bypasses every
PMG control (malware analysis, cooldown, and any future controls). A
globally trusted package is automatically exempt from the cooldown window
and no longer needs a duplicate entry in dependency_cooldown.skip.
The skip list remains the narrower, cooldown-only waiver for packages
that must bypass the cooldown wait but still be malware-scanned.
* refactor(cooldown): tag skip reason and audit-log skipped packages
Address review feedback on #342:
- Restore cooldownSkip to a pure single-list function (SRP); the merge
into trusted_packages now happens in a separate mergeCooldownSkip step,
driven by the exported CooldownSkip wrapper.
- Extend CooldownSkipInfo with a CooldownSkipReason (TrustedPackage /
CooldownSkipList) on both SkipAll and per-version entries, so callers
can tell apart the broad waiver from the cooldown-only one. When both
lists match the same package, trusted_packages wins.
- Add audit.LogCooldownSkipped and emit it from the npm and PyPI
interceptors on the SkipAll path, alongside the existing info log,
carrying the source list as the reason.
* refactor(cooldown): inline list merge, audit per-version exemptions
Address further review feedback:
- Drop the separate mergeCooldownSkip helper; cooldownSkip now writes
into a shared *CooldownSkipInfo and is called twice from CooldownSkip
(cooldown skip list first, trusted_packages on top so trusted entries
override the reason on overlap).
- Audit log every exemption, not just SkipAll: a new auditCooldownSkip
helper in proxy/interceptors/cooldown.go emits one event per match
(package-wide or per-version), each tagged with its source list.
LogCooldownSkipped gains a version argument for the per-version case.
- Cover the trusted_packages reason path in TestCooldownSkip.
* fix(cooldown): avoid double-auditing trusted package exemptions
auditCooldownSkip now only emits EventTypeCooldownSkipped for entries
that came from dependency_cooldown.skip. Trusted-package exemptions
already get an EventTypeInstallTrustedAllowed event at tarball-download
time (proxy/interceptors/base_registry.go), so emitting a cooldown event
for them too would double-count the same waiver.
* emit trusted and cooldown skip events to cloud
* fix tests
* refactor(cooldown): return value from collectCooldownSkip, short-circuit on trusted SkipAll
Address PR review feedback:
- Rename cooldownSkip to collectCooldownSkip and return CooldownSkipInfo
instead of mutating an input pointer.
- Add mergeCooldownSkip to combine per-list results with trusted_packages
taking precedence on overlap.
- CooldownSkip now consults trusted_packages first and returns immediately
on a package-wide trusted exemption (DC skip list cannot add anything).
- Extend tests to cover disjoint pinned entries across both lists and the
case where DC version-less subsumes a trusted pinned entry.
* fix(audit): address cooldown review feedback
* fix(cooldown): audit cooldown skips at download time with concrete version
Backend rejects PackageVersion messages without a version, and audit logs
should reflect the runtime fact (a specific version was skipped) rather
than the config rule. Move the audit emission from metadata-request
handling to download-request handling, where the concrete version is
known, and require version in LogCooldownSkipped.
* chore(audit): drop dead scope assignment in LogCooldownSkipped
* refactor(cooldown): move skip-list logic into cooldown handlers
Registry interceptors no longer compute CooldownSkip or branch on SkipAll;
they just call HandleMetadataRequest. The npm and pypi cooldown handlers
own the skip lookup, the package-wide exemption short-circuit, and (for
pypi) the canonical-name denormalization. Also align LogCooldownSkipped
with other LogXxx signatures by taking *packagev1.PackageVersion.
* fix: Simplify audit logging for dependency cooldown skip
* refactor: Simplify cooldown handling and maintain separation of concepts for trusted and DC skip packages
* fix: Code review fixes
* fix: Emit cooldown skipped audit event ONLY when an in-window version is skipped
---------
Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
The runtime --sandbox-allow CLI override path never expanded the supported
sandbox variables (${CWD}, ${HOME}, ${TMPDIR}), so a value like
write='${CWD}/**' was treated as a literal path segment and the allow rule
never matched. Profile-loaded sandbox paths already expand these via
sandbox/util.ExpandVariables.
Expand the variables in resolveToAbsolute, the shared chokepoint for
read/write/exec overrides, before resolving to an absolute path. Glob
characters are preserved through expansion and filepath.Clean.
Fixes#257
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Let dependency cooldown respect an explicit skip list so first-party /
internal packages that must be installed the moment they are published
(e.g. to sanity-test a freshly released version) are not held back by the
cooldown window.
Per review, this is a per-control skip list — NOT a second definition of
"trusted package". There remains a single top-level `trusted_packages`
(which waives malware analysis); `dependency_cooldown.skip` waives ONLY
the cooldown wait, so a fast-tracked package is still malware-scanned.
Matching:
- a PURL without a version skips cooldown for all versions of the package
(package-level) — the metadata passes through unmodified;
- a PURL with a version skips cooldown for that version only — that
version is preserved during stripping while other recent versions are
still held.
- config: DependencyCooldownConfig.Skip + CooldownSkip()/CooldownSkipInfo.
- npm/pypi interceptors: bypass on package-level skip; thread per-version
exemptions into the cooldown stripper so pinned versions survive.
- docs + config template; unit tests for the matcher (package/version
level, precedence, mismatches) and the skip-vs-trusted independence.
Signed-off-by: dmdhrumilmistry <56185972+dmdhrumilmistry@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(proxy): do not override explicitly-set CI env var
pmg forces CI=true for non-interactive (non-PTY) proxy runs so package
managers behave non-interactively. This clobbered a CI value the user set
explicitly (e.g. CI=false on a build server), changing downstream tool
behavior unexpectedly.
Only inject CI=true when CI is not already present in the environment,
preserving the user's intent. mergeEnv override semantics are left intact
since other overrides (HTTP_PROXY, etc.) must clobber.
Fixes#335
* test(proxy): snapshot/restore CI env explicitly in override test
Address review feedback: make the unset-CI subtest's intent explicit by
snapshotting the original CI value, unsetting it for the test, and
restoring it in t.Cleanup instead of relying on t.Setenv cleanup.
---------
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(pty): treat background jobs as non-interactive to avoid SIGTTOU stop
IsInteractiveTerminal only checked that stdin/stdout are TTYs. A background
job (pmg npm run test &) still has the TTY on stdin/stdout, so pmg picked
PTY mode and called tcsetattr to enter raw mode. Changing terminal modes
from a background process group makes the kernel stop the process with
SIGTTOU, leaving the job hanging in Stopped state.
Check that the process group is the terminal's foreground process group
(tcgetpgrp == getpgrp) before treating the terminal as interactive, so
background jobs fall through to direct execution.
Fixes#322https://claude.ai/code/session_01PBBo5CKkzg68MMGrgCTQcY
* test(pty): fail on output copy timeout to avoid racy buffer read
Reading the output buffer after a silent select timeout races with the
io.Copy goroutine still writing to it. Fail the test on timeout instead.
Also fix a grammar nit in the IsInteractiveTerminal doc comment.
https://claude.ai/code/session_01PBBo5CKkzg68MMGrgCTQcY
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(shim): recognize shims at arbitrary paths via PMG_SHIM_PATH
The recursion guard in FilterPMGFromPath hardcoded the `/.pmg/bin`
suffix, so shims placed anywhere else (e.g. `/usr/local/lib/pmg/bin`,
`/shims`, or any future system-wide location) would not be stripped
from PATH when PMG resolved the real package manager. The shim would
resolve back to itself and PMG would re-exec it in an infinite loop.
This blocks moving shims out of `~/.pmg/bin` — needed for a future
`pmg setup install --system` (#317) — and also any user attempt to
relocate shims manually.
Have the shim export its own path before exec'ing pmg, and let the
filter use that to strip the exact dir at runtime. Keep the legacy
suffix check as a fallback so already-installed shims keep working
until they are regenerated.
Also drop `PMG_SHIM_PATH` from the env passed to the real package
manager so child processes don't inherit a stale marker.
* docs(shim): clarify PMG_SHIM_PATH is internal and unsupported to set manually
* remove comment
* update comment
* 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(proxy): harden MITM proxy reliability and scale for bulk installs
Deep-dive analysis of dropped connections during large installs (5000+
packages with concurrent downloads) surfaced three issues, each verified
with a reproduction test before fixing.
1. Transient upstream errors tore down whole keep-alive tunnels.
goproxy returns false (closing the entire MITM client tunnel) when a
single upstream round-trip errors. Under load, CDN-fronted registries
(e.g. Cloudflare for registry.npmjs.org) intermittently reset
connections, so one transient reset dropped a pooled keep-alive socket
and surfaced to the package manager as ECONNRESET / "socket hang up".
Fix: route upstream round-trips through a resilient round tripper that
retries idempotent, body-less requests with bounded linear backoff,
absorbing transient resets and keeping the tunnel alive. A reproduction
test shows the tunnel count drop from 3 to 1 across a transient failure.
2. Head-of-line amplification against the external analysis service.
Concurrent requests for the same package version each issued their own
gRPC call. Fix: de-duplicate in-flight analyses with singleflight so a
burst of identical requests collapses into one upstream call.
3. Per-request goproxy verbose logging on the hot path.
proxy.Verbose was always on, formatting several log lines per request
even when discarded below debug level. Fix: enable goproxy verbose
logging only when PMG runs at debug level.
Note: the hypothesis that the http.Server Read/WriteTimeout leaks onto
hijacked CONNECT tunnels was investigated and disproven (Go clears the
deadlines on Hijack); the behavioral guard tests for long-lived
connections and slow transfers are retained.
* fix: Type assertion error handling
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: authenticated Malysis analyzer with tenant exclusion support
When SafeDep Cloud credentials are available (keychain or environment),
PMG now uses an authenticated malware analysis query against
api.safedep.io instead of the unauthenticated community endpoint
(community-api.safedep.io). The API key and tenant ID are supplied via
the gRPC connection.
The authenticated response may carry a tenant-specific malicious package
exclusion. This is honored as an opt-in trust signal: a flagged package
is downgraded to allow only when a concrete exclusion (non-empty ID) is
present for the exact package version queried. Exclusions are never
honored for community queries and never weaken the verdict for packages
that were not flagged. Allowed-by-exclusion packages are surfaced as a
warning so the trust decision is never silent.
Changes are additive; non-authenticated usage is unchanged. Credential
resolution is extracted into internal/cloudauth and reused by both the
analyzer factory and the existing cloud sync client.
* fix: surface tenant exclusions in proxy mode; clarify comments
- Warn when proxy interceptor allows a flagged package due to a tenant
exclusion, matching the guard flow so the trust decision is not silent.
- Remove stray doc comment above warnIfExcluded.
- Clarify that a verified-malware verdict can be downgraded by an
exclusion in applyExclusion.
---------
Co-authored-by: Claude <noreply@anthropic.com>