feat(agent-proxy): add sandboxed local run mode#335
Conversation
Prepare the agent proxy for single-machine, single-user use (one developer running one agent locally) without changing any existing remote behavior. - proxy.go: split Start into New/Serve/Shutdown so the caller owns the listener, serving, and teardown; Serve accepts any net.Listener (loopback :0 or a pathname unix socket); Shutdown drains in-flight requests, stops the poll/lease loops, and drops cached credential values (idempotent). Start stays a thin wrapper so the remote command is unchanged. Add Options.Local: no Proxy-Authorization (fixed startup scope), always refuse egress to the Infisical API host, and honor an --allow-host allowlist under block mode. - ca.go: add a self-signed in-memory ECDSA P-256 root that mints leaves directly and never calls out; RootPEM exposes the public cert only. - cache.go: extract the shared list-to-services resolution; the existing resolver delegates with identical semantics; add close(). - local.go: a single-snapshot resolver for the fixed scope that uses one developer token for discovery and value-fetch, gates on Read Value only, skips dynamic secrets, and fails closed on auth error. Existing tests pass unchanged; new tests cover the local CA, resolver lifecycle, auth-free local serving vs the remote challenge, the API-host block, the allow-host allowlist, and the New/Serve/Shutdown lifecycle.
The trust boundary for local mode: it keeps the untrusted agent from reading the developer's real credentials even though they live on the same machine. - spec.go / sandbox.go: the SandboxSpec policy, the Backend interface, platform selection, and an uncontained passthrough backend for --no-sandbox. - seatbelt.go / seatbelt_profile.go (macOS): generate an SBPL profile and exec sandbox-exec. (deny default), loopback-only egress to the proxy port, broad read minus credential deny paths, and the keychain services omitted so the agent cannot read the login token; profile passed inline, never written to disk. - bwrap.go / bwrap_args.go / bridge.go (Linux): build a bubblewrap argv. Default hard fence is an empty network namespace with a small in-namespace bridge (loopback TCP -> the proxy's pathname unix socket); auto-falls back to shared host networking with a warning when unprivileged user namespaces are restricted. No --new-session, so the interactive TTY is preserved. - sandbox_unsupported.go: platforms without a sandbox report unsupported. Golden tests cover the SBPL profile and both bwrap argv shapes; an env-gated live test exercises sandbox-exec enforcement on macOS.
Run an untrusted agent on your own machine with Infisical secrets brokered on the wire and an OS sandbox as the trust boundary: infisical secrets agent-proxy run --env <e> --path <p> -- <cmd>. Pipeline: resolve the developer's own login (keyring or --token; no machine identity) -> list proxied services (Read Value gate) -> start an ephemeral proxy on loopback or a unix socket with a self-signed local CA -> build a scrubbed child env -> sandbox-wrap -> run with inherited stdio and signal forwarding -> tear down and propagate the exit code. - The child's proxy URL is credential-free; the token and scope stay in the parent. The child env drops INFISICAL_TOKEN, INFISICAL_DOMAIN, and secret-shaped vars (--pass-env re-admits, --set-env injects a literal). - Sandbox toggle resolves from flag or env only, never .infisical.json. - Non-refreshable credentials warn at startup when brokering will stop. - Supported agents' own state dirs (~/.claude, ~/.claude.json, ~/.codex) are writable by default so interactive sessions persist. - Hidden __sandbox-supervisor subcommand runs the in-namespace bridge on Linux. Tests cover the env scrub (no token leak), the credential-free proxy URL, the expiry parse, the secret-shaped-name heuristic, and the agent-state defaults.
- Linux: mask credential files with a /dev/null bind and directories with an empty tmpfs. Mounting a tmpfs onto an existing file aborts bwrap at startup (ENOTDIR), which would have blocked the sandbox for anyone with a regular-file deny path such as ~/.docker/config.json or ~/.netrc. buildBwrapArgv takes an injected file classifier so it stays a pure, golden-testable function; the Linux backend passes a real os.Stat-based one. - Linux: the in-namespace supervisor overrides the root PersistentPreRun with a no-op, so the internal re-exec no longer runs the human-facing preamble (update check, notices, keyring read) — a network call — inside the empty network namespace, where it wasted the timeout and printed stray notices. - run: clean up the per-run temp dir explicitly at every exit path. os.Exit and util.HandleError skip deferred funcs, so the previous `defer os.RemoveAll` never fired and a temp dir leaked on every run. - run: drop a stale comment describing the removed machine-identity refresh path. Golden test covers the file-vs-directory masking. Builds on darwin and linux; existing suites and the macOS live enforcement tests pass.
Cut the verbose explanatory comments added with the feature down to short notes only where the code isn't self-evident, matching the surrounding style. Also remove two bits of dead code found while trimming: the unused sandbox.CurrentOS helper and the SandboxSpec.AllowHosts field (host allow-listing is enforced by the proxy via Options.AllowedHosts, not the sandbox). No behavior change.
…terminal `run` wraps an interactive agent that owns the terminal, but it inherited the engine's per-request activity logging (brokered/blocked/passthrough/error), which interleaved with the agent's output. Route the engine's ongoing zerolog output to a file instead: `--log-file` picks a stable path, otherwise a per-run temp file (removed on exit; tail it live to watch brokering). One-time startup notices and lifecycle errors still go to stderr, and HandleError/PrintWarning are unaffected (they write to stderr independently). This is the deliberate difference from `start`, whose activity log IS its output and streams to console.
…t trusted CA macOS Go CLIs (e.g. gh) verify TLS through Security.framework, which ignores the injected CA env var, so they couldn't be brokered. Fix it without weakening the credential boundary by using a persistent local root that's trusted once in the keychain, and by allowing only the cert-evaluation service in the sandbox. - agentproxy: add a persistent local root (newPersistentLocalCaManager) stored under a caller-chosen dir (load-or-create, self-heals when missing/corrupt/near expiry, key written 0600, atomic writes, flock so concurrent first-runs don't race). LocalOptions.CADir selects it; empty keeps the ephemeral in-memory root. - sandbox: SandboxSpec.AllowTrustd gates the trustd (cert-trust) service in the SBPL profile. securityd/SecurityServer (keychain secret reads) stay omitted regardless, so the login token remains unreadable in the box. - cmd: on macOS, persist the root under ~/.infisical/agent-proxy (already a sandbox-denied path, so the agent can't read the key), trust it once in the login keychain (one-time prompt; self-heals on removal), and set AllowTrustd. Trust-install is non-fatal: env-CA tools (Claude Code, Codex, curl) work regardless. Linux keeps the ephemeral root + SSL_CERT_FILE (no keychain). Verified: persistent-CA lifecycle (reuse/heal/mint) and the trustd toggle (trustd allowed, keychain services still omitted) via unit/golden tests; the trustd-allowed / securityd-blocked split (Go TLS verifies, keychain read fails) empirically on macOS. NOT yet verified end-to-end: the keychain trust-install (needs the interactive prompt) and gh brokering through a live proxy.
Found by running the bwrap path on real Ubuntu 22.04/24.04 (never
exercised on hardware before):
- Skip deny paths that don't exist. Masking a missing path with
--tmpfs/--ro-bind aborted bwrap ("Can't mkdir ...: Read-only file
system") because the mountpoint can't be created under the read-only
root bind, so every run on a fresh account died before the agent
started.
- Emit deny mounts after the cwd/tempdir/write binds so they always win.
A cwd bind that is an ancestor of a deny path (e.g. launching the
agent from $HOME) previously re-exposed ~/.aws, ~/.ssh, etc.
- Deny ~/infisical-keyring, the file-vault credential store that is the
default on headless Linux; it held the login JWT and backup key and
was readable from inside the sandbox.
- Preflight: when unprivileged user namespaces are fully restricted
(e.g. Ubuntu 24.04 AppArmor), report a clear, actionable error instead
of falling back to shared-net and dying with a raw
"bwrap: setting up uid map: Permission denied".
…rding - bringLoopbackUp: bwrap already brings lo up in the new netns, and the SIOCSIFFLAGS write is refused with EPERM there even with CAP_NET_ADMIN. Treating that as fatal made the hard fence fall back to the weaker shared-net path on every Linux host. Treat an already-up lo as success so the empty-netns fence actually engages. - Forward only SIGINT/SIGTERM/SIGHUP/SIGQUIT to the sandboxed child rather than every signal. signal.Notify with no filter also relayed SIGURG (the Go runtime's async-preemption signal) and SIGCHLD, which raced the child's exit path and intermittently corrupted a clean exit 0 into 255 (and occasionally killed the child mid-run). Terminal-generated signals still reach the child directly through the shared controlling-terminal foreground group. Same fix applied to the non-sandboxed and remote agent-proxy paths.
- Drop the "Local coupled mode" notice: internal jargon, not actionable. - Replace the routine token-expiry warning with a fail-fast error only when the token is already expired; no "expires in Xh" banner on every run. Matches auth-bearing CLIs (kubectl, cloud CLIs) that surface an error at use rather than pre-warning. - Only print the proxy activity-log path when --log-file pins it to a stable location; the default log lives in the per-run tempdir and is removed on exit, so printing its path was useless.
infisicalAPIHost() used the lenient url.Parse, so a scheme-less --domain (e.g. "app.infisical.com/api") yielded an empty host and the control-plane egress fence silently turned off. Parse strictly with url.ParseRequestURI (the pattern user.go already uses) and abort the run with a clear error when the host can't be determined, rather than starting with the fence disabled.
|
💬 Discussion in Slack: #pr-review-cli-335-feat-agent-proxy-add-sandboxed-local-run-mode Posted by Review Police — reviews, comments, new commits, and CI failures will stream into this channel. |
PR overviewThis pull request adds a sandboxed local run mode to the agent proxy, including construction of the environment passed to the local agent process. Five previously identified issues have been addressed, but one credential-isolation issue remains. The untrusted agent can inherit sensitive parent-process environment values whose names evade the current secret heuristic, potentially exposing database credentials, service tokens, or CI identity tokens. Open issues (1)
Fixed/addressed: 5 · PR risk: 6/10 |
|
| Filename | Overview |
|---|---|
| packages/cmd/agent_proxy_run.go | Main entry point for the new local run mode; sound overall — credential scrubbing, token resolution, sandbox preflight, and proxy/listener wiring are all correct. Minor: only uppercase NO_PROXY is set in the child env; no_proxy (lowercase) is omitted. |
| packages/agentproxy/proxy.go | Local mode integration: requestScope short-circuits auth for the fixed startup scope; TRACE/TRACK blocking, hop-by-hop header stripping, and CONNECT MITM all look correct. Control-plane block cleanly removed. |
| packages/agentproxy/local.go | Single-scope resolver with fail-closed auth-error handling; minor benign TOCTOU (concurrent !valid callers trigger duplicate resolveSnapshot) but no security impact. |
| packages/agentproxy/local_ca_store.go | Persistent CA management with gofrs/flock (cross-platform) and atomic temp-then-rename writes; 0700 dir and 0600 key are correct. Previously flagged unix.Flock Windows issue resolved. |
| packages/sandbox/spec.go | Expanded DefaultDenyPaths now includes .config/gh, .git-credentials, .npmrc, .gnupg as requested. Struct and constants are clean. |
| packages/sandbox/seatbelt_profile.go | macOS SBPL profile with (deny default), egress fenced to loopback proxy port, credential paths denied for both reads and writes (re-deny after write allows); write-deny on credential paths is the newly addressed finding. |
| packages/sandbox/bwrap.go | Linux bubblewrap backend with hard-fence/shared-net preflight probing; graceful fallback on Ubuntu 24.04 AppArmor restrictions with clear user warning. |
| packages/sandbox/bwrap_args.go | Deny mounts ordered after write-bind mounts so cwd/write-path ancestors never re-expose a masked credential dir. Pure function, golden-testable. |
| packages/sandbox/bridge.go | In-namespace TCP-to-Unix bridge for the hard-fence path; loopback bring-up tolerates EPERM correctly; signal forwarding is narrow (no SIGURG/SIGCHLD spam). |
| packages/cmd/agent_proxy.go | authAgentEnvKeys now includes SSH_AUTH_SOCK, SSH_AGENT_PID, GPG_AGENT_INFO — previously raised finding resolved. CA trust vars, proxy keys, and credential keys all look correct. |
Reviews (2): Last reviewed commit: "fix(agent-proxy): address PR review find..." | Re-trigger Greptile
…nfig #319) Resolve packages/agentproxy/proxy.go, keeping both sides: - local coupled mode (New/Serve/Shutdown, serviceResolver, control-plane fence, graceful pollLoop stop) from this branch. - proxied-service usage reporting (recordUsage/flushUsage) from #322. Also flush usage on shutdown: `run` is frequently shorter than one poll interval, so the poll-tick flush alone would never report a short session's usage. The daemon (`start`) path still flushes each tick.
Use util.ResolveEnvironmentName/ResolveSecretPath so `run` honors INFISICAL_ENVIRONMENT / INFISICAL_SECRET_PATH and .infisical.json defaults (defaultEnvironment/defaultSecretPath), matching `agent-proxy connect` (#319).
- Scrub SSH_AUTH_SOCK / SSH_AGENT_PID / GPG_AGENT_INFO from the child so an agent can't use the developer's ssh/gpg agent as a signing oracle. - macOS: deny file-write* on credential paths (not just reads), so a run from $HOME can't write into ~/.ssh and friends. - Add .config/gh, .git-credentials, .npmrc, .gnupg to the default deny paths. - Use gofrs/flock for the local CA lock so the file builds on Windows. - Drop the redundant control-plane egress block: the child holds no token (env scrubbed, keyring/config masked), so reaching Infisical does nothing; the block broke legit use (Infisical as a proxied service, or --unmatched-host=allow) and was IP-bypassable anyway.
| if stale[key] || isSecretShapedEnvName(key) { | ||
| continue | ||
| } | ||
| env[key] = val |
There was a problem hiding this comment.
Medium: Parent credentials inherited by the untrusted agent
This copies variables that evade the secret-name heuristic into the untrusted child, allowing it to steal values such as credentials embedded in DATABASE_URL, SENTRY_DSN, or CI_JOB_JWT. Build the default environment from a minimal allowlist of runtime variables and require --pass-env for everything else.
Description 📣
Adds
infisical secrets agent-proxy run --env <e> --path <p> -- <cmd>— a local, sandboxed mode of the agent proxy for running an untrusted agent (Claude Code,gh,curl, etc.) on your own machine. Secrets are brokered on the wire to the hosts they're scoped to, while an OS sandbox stops the agent from reading your real credentials off disk, out of the environment, or from the keyring.Unlike
agent-proxy start(a daemon that brokers for remote agents via machine identities),runis local coupled mode: one developer, one agent, one scope. The proxy acts with your own token, the child process gets none of it, and the OS sandbox is the boundary that keeps the agent from reading credentials directly.Brokering
--projectId/--env/--pathand injects each proxied service's credential only on requests to its matched host; the agent itself only ever holds fake placeholder values.gh, etc.) are brokered too: on macOS via a persistent local CA trusted once in the keychain, on Linux viaSSL_CERT_FILEand the other CA-bundle env vars.Sandbox
(deny default)— egress fenced to the proxy's loopback port, keychain (securityd) reads blocked, credential paths denied.Credential lockdown
~/.infisical,~/.config/infisical,~/infisical-keyring,~/.aws,~/.ssh,~/.config/gcloud,~/.azure,~/.kube,~/.docker/config.json,~/.netrc.infisical export/secretsits way to the raw values even if it somehow held a token.--no-sandboxruns uncontained (with a warning) as an escape hatch on hosts where the sandbox can't start.Validated end-to-end on macOS and on real Ubuntu 22.04 + 24.04 (both the hard fence and the 24.04 user-namespace fallback).
Type ✨
Tests 🛠️
# Here's some code block to paste some code snippets