Skip to content

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

Merged
saifsmailbox98 merged 26 commits into
mainfrom
saif/age2-36-add-local-sandboxing-for-same-host-agent-vault-runs
Jul 30, 2026
Merged

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

Conversation

@saifsmailbox98

@saifsmailbox98 saifsmailbox98 commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Description 📣

Adds infisical secrets agent-proxy run, which brokers secrets for an agent running on the developer's own machine.

start + connect needs two hosts: the proxy holds the real credentials and the agent is kept away from them by being on a different machine. On a laptop there is no second machine, so an OS sandbox takes that role.

infisical secrets agent-proxy run --env=dev --path=/coding-agent -- claude

The CLI resolves config and secret values with the developer's own login, starts an ephemeral proxy on loopback, and launches the agent inside a sandbox with a scrubbed environment. The agent gets placeholders, the proxy swaps in the real values on the wire. Everything dies with the command, nothing is left behind.

The sandbox

macOS uses Seatbelt (sandbox-exec), Linux uses bubblewrap.

  • Filesystem — broad read, but ~/.ssh, ~/.aws, ~/.infisical, ~/.config/gh, ~/.npmrc, /run/user/<uid> and friends are denied for read and write. Writable: cwd, a per-run temp dir, /tmp, and the agent's own state dirs.
  • Network — the proxy is the only way out. On Linux that is an empty network namespace, and since 127.0.0.1 inside it is not the host's loopback, the proxy listens on a unix socket with a small in-namespace bridge forwarding to it. Where a network namespace is unavailable the run falls back to shared host networking with a warning, and if the sandbox cannot start at all it refuses to run rather than running uncontained.
  • Environment — secret-shaped names, INFISICAL_TOKEN / INFISICAL_JWT, and the SSH/GPG/DBUS agent socket addresses are dropped. --pass-env re-admits a specific one.
  • --new-session is passed so a sandboxed process cannot inject keystrokes into the caller's terminal via TIOCSTI.

Permissions

No agent identity, everything runs as the developer. Read Value is the gate for static secrets, Lease for dynamic ones. Dynamic secrets are minted lazily, renewed on the poll loop and revoked on exit.

Certificates

Local mode mints its own root and never touches the org CA. On macOS it is persisted under ~/.infisical/agent-proxy (a denied path) and trusted once in the login keychain, so Go-based tools like gh that ignore SSL_CERT_FILE are brokered too.

Flags

--no-sandbox, --allow-read, --allow-write, --allow-host, --pass-env, --set-env, --unmatched-host, --log-file

Notes for review

  • seatbelt_profile.go and bwrap_args.go are pure functions of a Spec, so the Linux argv and its mount ordering are unit-tested from any platform. Mount order is load-bearing: deny mounts must come after the write binds, or a run from $HOME re-exposes the credential paths underneath it.
  • seatbelt_live_test.go spawns real sandbox-exec and asserts the boundary actually holds (credential reads denied, egress denied, keychain denied, read exceptions precise). Gated behind INFISICAL_SANDBOX_LIVE=1.
  • Known gaps: SSH from inside the sandbox does not work (the proxy terminates TLS and ~/.ssh is denied, use HTTPS with a brokered token), and non-HTTP protocols are not brokered.

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

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 7 · PR risk: 0/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.
Comment thread packages/cmd/agent_proxy_run.go
Structure:
- Add packages/sandbox/proc.go with newInheritedCmd, ForwardTerminationSignals
  and WaitExitCode, collapsing four copies of the exec/stdio wiring and three
  copies of the signal-forwarding block, whose SIGURG/SIGCHLD rationale now
  lives in exactly one place.
- Rename SandboxSpec to sandbox.Spec and its Sandbox field to Enabled,
  proxyServer.cache to resolver, and caManager's intermediate{Key,Cert,Exp} to
  signing*. The remote-only resign path keeps the intermediate naming, where it
  is accurate.
- Replace the hand-rolled itoa with strconv.Itoa, move dedupeSorted out of the
  Seatbelt file, share bwrapBaseArgs between the real argv and Preflight's
  probes so a probe cannot certify flags the real run does not use, and reuse a
  single serveTestProxy harness in the agentproxy tests.

Fixes:
- Wire --allow-read, which previously parsed into a field nothing consumed, so
  the flag silently did nothing. It now re-opens a path inside the denied set,
  read-only, on both backends.
- Self-heal a mismatched local CA key/cert pair. A crash between the two file
  writes left a new cert beside the old key; both parsed and neither was
  expired, so the store never regenerated and every leaf was signed by a key
  the cert did not match.
- Keep both bridge directions alive until each finishes, half-closing per
  direction, so a client that half-closes its request side no longer sees a
  truncated response.
- Set lowercase http_proxy/https_proxy/no_proxy alongside the uppercase forms
  via a shared setProxyEnv. curl honours only the lowercase form for
  plain-HTTP URLs, so those requests were resolving DNS instead of reaching the
  proxy, then failing closed against the egress fence.

Also record that the DNS omission in the Seatbelt profile is deliberate (the
proxy resolves on the host, and a missing resolver keeps proxy bypass loud and
closes a DNS exfiltration channel), and state plainly in the shared-net
fallback warning that proxy routing becomes advisory rather than enforced.

Tests: read exceptions on both backends including precedence and read-only
enforcement, the CA pair check (confirmed to fail without the fix), and a live
test pinning the egress invariant, namely no resolver, no raw route out, and
the proxy as the only way to reach a hostname.
Dynamic secrets were skipped in local mode with a warning, but nothing about
the lease machinery was ever mode-specific: newLeaseStore already receives the
developer's token (newProxyServer defaults ProxyToken to Local.UserToken),
refreshLoop already runs, revokeAll already fires on shutdown, and value()
already mints lazily. Two unwired spots in the local resolver were the whole
limitation:

- registerDynamic returned nil, so resolveServices dropped the credential.
- activeJWTs returned an empty map, which is the liveness gate in refreshPass,
  so even a minted lease would never have been renewed and would have been
  dropped at its first expiry.

The resolver now holds the lease store, registers a real lease, and reports the
single liveness key derived from the empty wire JWT plus the fixed scope, which
matches the registered leaseKey. It reports nothing while the snapshot is
invalid, so a dropped authorization stops lease renewal as well as credential
serving.

Leases are gated on the API's per-credential callerCanLease, which the backend
computes from the caller's Lease permission on that dynamic secret. Locally the
caller is the minter, so that is the right gate, and skipping up front with one
warning naming the secret beats registering a lease that fails on every
request. Note this reads the opposite way from `connect`, where the same flag
being true is a misconfiguration, because there the agent is a separate
identity that could mint for itself and bypass the proxy. Locally there is no
second identity and the sandbox is what prevents that.

Tests: resolveSnapshot against a stubbed proxied-services API asserts the
leasable credential is registered in the run's scope and the unleasable one is
dropped; an end-to-end request asserts the minted value reaches the wire,
minted once. The existing "local mode must register no lease JWTs" assertion
encoded the old limitation and now asserts the liveness contract in both
directions.
Comment thread packages/sandbox/seatbelt_profile.go
A pass over every comment this branch introduced. 284 comment lines down to 229,
with no loss of the reasoning that is actually load-bearing.

What went:
- The 13-line header on buildBwrapArgv, which restated what the inline comments
  at each mount step already said. The specific rationale now sits next to the
  code it explains (tmpfs-onto-a-file aborts with ENOTDIR; a missing deny path
  cannot get a mountpoint under the read-only root bind).
- Speculation and editorialising: "so a future refresher can rotate it", the
  comparison to how kubectl surfaces auth errors.
- Comments restating an obvious signature, e.g. containsString and a test helper
  whose name already said it.
- Duplicated explanations of the macOS keychain trust, which appeared twice in
  the same function.

What stayed, deliberately:
- The SIGURG/SIGCHLD rationale in proc.go. It documents a real bug (exit status
  corrupted to 255) and now lives in one place instead of three.
- The CallerCanLease inversion in local.go: the same flag means the opposite
  thing in `connect`, which reads like a bug unless explained.
- The loopback EPERM workaround in bridge.go, and the SBPL last-match-wins
  ordering rules, both of which are silently breakable.

Comments that state a constraint the code cannot show are kept. Comments that
narrate the code are gone.
Went through every file in the diff looking for dead code, unused symbols,
unused parameters and leftover scaffolding. Four small things, plus one
finding worth recording rather than "fixing".

Fixed:
- errUnsupportedPlatform was declared in sandbox.go but only ever used by the
  !darwin && !linux build, so it was dead weight on the two platforms we
  actually ship the sandbox for. Moved it next to its only user, which also
  drops the now-unneeded errors import from sandbox.go.
- writeFileAtomic ignored three error returns implicitly. The calls are
  deliberately best-effort (cleanup, and Close on an already-failing path), so
  they now say so with an explicit `_ =` instead of tripping errcheck.
- Dropped two redundant cobra fields from the supervisor command:
  DisableFlagParsing was set to its own default, and FParseErrWhitelist was not
  needed because the agent's argv sits after `--` and is never flag-parsed.
  Verified on a real Linux host that an agent command containing flags
  (`ls -la`, `curl -sS -o ... -w ...`) still runs correctly through the
  supervisor.

Recorded, not fixed:
- staticcheck on darwin reports buildBwrapArgv, bwrapBaseArgs, containsString
  and underAny as unused, because it discounts test-only callers and the Linux
  backend is compiled out. The fix it implies, a //go:build linux tag, would
  stop bwrap_args_test.go running on macOS, and being able to unit-test the
  Linux argv from a Mac already caught a real ordering bug in this branch. Left
  untagged with a comment at the top of the file so nobody "fixes" it later.

Verified clean: gofmt; builds on darwin, linux and windows; staticcheck clean on
linux and windows; no unused exported symbols, no unused Spec fields, no unused
function parameters, no unused test helpers; go mod tidy is a no-op; all suites
and the live macOS enforcement tests pass.
…l leaks

Three findings from the security review, all verified against a real Linux host
before and after the change.

1. Sandbox escape via TIOCSTI (High, Linux only)

   The bwrap flags deliberately omitted --new-session, so the agent kept the
   caller's controlling terminal and could push characters into its input queue
   with the TIOCSTI ioctl. The host shell then executes them once the sandbox
   exits, outside every protection this feature adds. Reproduced on Ubuntu
   22.04 (kernel 5.15, before the 6.2 dev.tty.legacy_tiocsti switch):

       before:  TIOCSTI *** SUCCEEDED (escape possible) ***
       after:   TIOCSTI blocked: PermissionError

   The flag was omitted on the belief that setsid breaks interactive TUIs. That
   was wrong, and measured: stdin and stdout stay ttys, only the controlling
   terminal goes, and TUIs read keys in raw mode. Claude Code was run end to end
   under the flag, including Ctrl-C and clean exit. Anthropic's sandbox-runtime
   passes it unconditionally with no PTY handling, which is the same bet.

   macOS was never affected: Seatbelt already denies the ioctl.

   The test that asserted --new-session must never appear now asserts the
   opposite, with the reason.

2. INFISICAL_JWT reached the agent

   It authenticates a JWT/OIDC machine identity, so it is as good as a token,
   and it matched none of the secret-shaped name patterns. Now scrubbed, along
   with INFISICAL_OIDC_AUTH_JWT.

3. Host IPC endpoints reachable

   XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS were passed through, and
   /run/user/<uid> was readable. Unix sockets ignore the network namespace, so
   the agent could reach the session bus, where systemd --user
   StartTransientUnit starts a process outside the sandbox. Same family as the
   SSH_AUTH_SOCK hole closed earlier. The variables are scrubbed and the
   runtime directory is masked; Linux only, macOS has neither.

Verified on the droplet after the change: all of INFISICAL_JWT,
XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS, INFISICAL_TOKEN and SSH_AUTH_SOCK
report SCRUBBED, and /run/user is empty inside the sandbox.

Declined from the same review: restricting network-bind to loopback. The agent
never holds a credential, so an inbound listener can only carry data, which is
outside what this feature protects. On the Linux hard fence it is moot anyway,
since an empty netns has no interface to bind.
@saifsmailbox98
saifsmailbox98 requested a review from akhilmhdh July 28, 2026 04:10
The message a Windows user gets said the sandbox is "macOS and Linux only in
v1". That is design-discussion language in a user-facing string: there is no
"v1" the user can see (the CLI ships as 0.43.x), it implies Windows support is
planned, and it would go stale silently if that ever shipped.

Now names the platform actually detected instead of "this platform", and says
what --no-sandbox still gives you. The old wording made it sound like turning
the sandbox off gives up everything, when brokering and environment scrubbing
are unaffected.

    the OS sandbox is not available on windows; re-run with --no-sandbox to run
    the agent uncontained (credentials are still brokered and the environment is
    still scrubbed)

Checked the rest of the PR for the same kind of language (v1/v2, TODO, "for
now", "planned", "coming soon"): this was the only occurrence.
…of the CLI

The messages on `run` had drifted into internal vocabulary and were longer than
anything else in the CLI. Rewritten to match the existing patterns: lowercase
terse flag help with "(can be specified multiple times)", warnings in the
"Unable to X, doing Y" shape, and short HandleError labels.

- Command summary was "Run an untrusted agent locally with secrets brokered on
  the wire and an OS sandbox as the trust boundary". Three pieces of jargon in
  one line, now "Launch an agent on this machine, sandboxed, with credentials
  brokered on the wire", which reads alongside `start` and `connect`.
- --env and --path now state their env-var and .infisical.json fallbacks, which
  `connect` already did and `run` did not.
- Dropped meta-commentary from flag help ("prints a warning", "used as-is; not
  refreshed") and swapped "repeatable" for the house phrasing.
- The shared-networking warning was 60 words of hard-fence / env-scrub /
  keyring-block vocabulary, and its remediation advice was wrong: it pointed at
  an AppArmor setting that actually produces a refusal to start, not this
  fallback. Now says what happened and what it costs, nothing else.
- Removed detail that only applied to the machines this was developed against:
  a distro-specific install command, a named distro and its AppArmor knob, and a
  list of specific tools that would or would not be brokered. Messages state the
  condition and the action, not the environment they were found in.
- Errors no longer leak internals ("bind the ephemeral proxy on its unix
  socket", "wrap the agent command", "preflight") since none of it is
  actionable.
…ed usage report

`run` opened a log file unconditionally, defaulting to one inside the per-run
tempdir. The path was never printed and the directory is removed on exit, so
the warnings that matter most (a dynamic secret that cannot be leased, a
dropped authorization) were written somewhere the operator could not find and
then deleted. On distributions where /tmp is a tmpfs it was also memory.

Without --log-file nothing is written now: the logger goes to stderr filtered
to warnings and errors, so problems surface while per-request activity is
dropped and an agent's TUI stays intact. An explicit --log-level lifts the
filter for debugging without a file.

A rejected usage report logged at debug and vanished with it, so a role
missing Report Usage silently stopped stamping last-used. It now warns once
per run, naming the permission, with the per-attempt detail left at debug.
go.mod and go.sum conflicted: main added the LDAP, MySQL, and MSSQL drivers with
their indirects and bumped golang.org/x/*, while this branch had promoted
gofrs/flock to a direct dependency for the local CA store's lockfile. Kept both,
with go mod tidy regenerating the sums.
No conflicts this time: main only bumped klauspost/compress, grpc, and pion/dtls
for CVEs, and tidied e2e's go.mod to match. The gofrs/flock promotion this branch
needs is untouched.
@saifsmailbox98
saifsmailbox98 merged commit 0503399 into main Jul 30, 2026
30 checks passed
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.

2 participants