Skip to content

feat(agent-proxy): add sandboxed local run mode#335

Open
saifsmailbox98 wants to merge 14 commits into
mainfrom
saif/age2-36-add-local-sandboxing-for-same-host-agent-vault-runs
Open

feat(agent-proxy): add sandboxed local run mode#335
saifsmailbox98 wants to merge 14 commits into
mainfrom
saif/age2-36-add-local-sandboxing-for-same-host-agent-vault-runs

Conversation

@saifsmailbox98

Copy link
Copy Markdown
Contributor

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), run is 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

  • Starts an ephemeral proxy scoped to --projectId/--env/--path and injects each proxied service's credential only on requests to its matched host; the agent itself only ever holds fake placeholder values.
  • Go-native-trust tools (gh, etc.) are brokered too: on macOS via a persistent local CA trusted once in the keychain, on Linux via SSL_CERT_FILE and the other CA-bundle env vars.

Sandbox

  • macOS: Seatbelt (SBPL) (deny default) — egress fenced to the proxy's loopback port, keychain (securityd) reads blocked, credential paths denied.
  • Linux: bubblewrap. Hard fence by default — the agent runs in an empty network namespace and a small in-process bridge is its only route to the proxy. On distros that restrict unprivileged user namespaces (e.g. Ubuntu 24.04 AppArmor) it auto-falls-back to shared host networking with a one-time warning; credential controls are unchanged, only network isolation weakens.

Credential lockdown

  • Child env scrubbed of the token, domain, and secret-shaped variables; only fake placeholders are injected.
  • Credential files/dirs masked: ~/.infisical, ~/.config/infisical, ~/infisical-keyring, ~/.aws, ~/.ssh, ~/.config/gcloud, ~/.azure, ~/.kube, ~/.docker/config.json, ~/.netrc.
  • The Infisical control plane is never reachable from inside the sandbox, so the agent can't infisical export/secrets its way to the raw values even if it somehow held a token.
  • Filesystem is read-only outside the workspace; --no-sandbox runs 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 ✨

  • Bug fix
  • New feature
  • Improvement
  • Breaking change
  • Documentation

Tests 🛠️

# Here's some code block to paste some code snippets

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.
@linear

linear Bot commented Jul 25, 2026

Copy link
Copy Markdown

AGE2-36

@infisical-review-police

Copy link
Copy Markdown

💬 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.

Comment thread packages/agentproxy/proxy.go
Comment thread packages/cmd/agent_proxy_run.go
Comment thread packages/sandbox/seatbelt_profile.go
@veria-ai

veria-ai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

PR overview

This 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

@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces infisical secrets agent-proxy run, a local coupled mode that wraps an untrusted agent (Claude Code, gh, curl, etc.) in an OS sandbox while brokering credentials on the wire via an ephemeral MITM proxy. The developer's token never reaches the child; the agent only ever holds fake placeholder values and the credential-free proxy URL.

  • Sandbox: macOS uses sandbox-exec SBPL with (deny default), egress fenced to the proxy's loopback port, and write-denies re-applied on top of the broad write-allow for credential paths. Linux uses bubblewrap with an empty-netns hard fence (TCP-to-Unix bridge via re-exec'd supervisor) and auto-falls-back to shared-host networking with a warning when unprivileged user namespaces are restricted.
  • Credential lockdown: Child env is scrubbed of INFISICAL_TOKEN, INFISICAL_DOMAIN, INFISICAL_VAULT_FILE_PASSPHRASE, universal-auth vars, SSH_AUTH_SOCK, SSH_AGENT_PID, GPG_AGENT_INFO, and any secret-shaped name. Default deny paths now include .config/gh, .git-credentials, .npmrc, and .gnupg. The persistent local CA key lives under the sandbox-denied ~/.infisical/agent-proxy/, and the CA file lock was switched from unix.Flock to gofrs/flock for cross-platform portability.

Confidence Score: 5/5

Safe to merge; all previously raised security findings are correctly addressed, and no new blocking issues were found.

The core security controls — SBPL deny default profile with write-deny re-application, bwrap deny-mount ordering (deny mounts last so they win over cwd binds), SSH/GPG socket scrubbing, expanded deny-path list, and gofrs/flock CA lock — are all correctly implemented. The only identified gap is that no_proxy (lowercase) and http_proxy/https_proxy (lowercase) are stripped from the parent env but only the uppercase variants are injected into the child, which can cause proxy-unaware or NO_PROXY-unaware tools to misbehave without weakening the sandbox boundary.

Files Needing Attention: packages/cmd/agent_proxy_run.go — the lowercase proxy/no-proxy env var omission in buildLocalAgentEnv

Important Files Changed

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

Comment thread packages/agentproxy/proxy.go
Comment thread packages/agentproxy/proxy.go Outdated
Comment thread packages/cmd/agent_proxy_run.go
Comment thread packages/agentproxy/proxy.go
Comment thread packages/agentproxy/local_ca_store.go
…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).
Comment thread packages/sandbox/bwrap_args.go
Comment thread packages/cmd/agent_proxy_run.go
- 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant