You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Reviewed issue #697 for correctness / performance / architecture / testing / cloud ops N/A / living docs / layers / cap governance.
Second pass (same day) re-grounded against main @ 4bc831b. Prior majors (optional bind, non-throwing daemonInfo, host-abs HOME/PWD omit, no stat in path_tools) stay locked.
Issue
Severity
Resolution
CreateAgentToolsOptions.bind required would break every existing createAgentTools test; only one of two runAgent/runAgentStream call sites was implied
Major
bind is optional. Missing bind: still emit cwd, permissions, capabilities, env. Omit id/name/slug/status/image lines; backend omitted. Thread bind at bothcreateAgentTools sites (runAgent ~286 and runAgentStream ~392).
daemonInfo / env exec throw (426, timeout) could fail the whole tool
Major
daemonInfo is non-throwing (like workspaceRoot) and never returns workspaceRoot. Env spawn errors → env: unavailable with a generic reason (timeout / exit=N / error) — no stdout/stderr echo (may contain R). Bind/cwd/caps still print.
Omit-name regex /^(DEK|AMK|KEK)\b/i misses DEK_WRAP (_ is a word char so \b does not fire); suffix $ misses AWS_ACCESS_KEY_ID
Major
Lock /^(DEK|AMK|KEK)(_|$)/i and /(_TOKEN|_SECRET|_KEY|_PASSWORD|_CIPHERTEXT)(_|$)/i. Explicit denylist unchanged. Test both names.
Env algorithm listed cap-then-omit
Minor
Parse → omit (name + value-contains-secret) → sort → cap. Secret keys must not occupy a 512 slot. env.omitted counts only non-omitted extras past the cap.
daemon.protocol vs daemon.version both 2 today — a swap would pass
After canonicalize, omit PWD/HOME/OLDPWD/TMPDIR if the value still starts with /. PATH-like arrays keep out-of-jail OS entries (/usr/bin).
path_tools must not list stat (not a model-facing tool)
Minor
capabilities.path_tools = tools the model actually has this turn. describeSandboxTools still lists stat (pre-existing display contract) plussandbox_info.
Baseline SHA stale (76678f1)
Nit
main @ 4bc831b.
Output id= vs bind field sandboxId; image source
Nit
Print id=${bind.sandboxId}. bind.image = resolved.value.resolvedImage (omit line when null/empty). Never pass whole resolved.value (would carry baseUrl / client / secrets).
Status: HANDOFF-READY Reviewed: 2026-08-19 (second pass: omit matchers + cap order + daemon field mapping)
none — additive read-only tool; no single-tenant bind
Production mutate?
no
Cloud ops path
N/A — no Production mutate
Living docs
docs/sandbox.md, docs/agent-stream.md, AGENTS.md (tool list / standing order), SECURITY.md (omit R / tokens / host inventory)
Summary
Add a read-only agent tool sandbox_info on the existing FS tool surface (createAgentTools). One call returns structured facts about the active bind: backend, name/slug/status, logical cwd, grant permissions, capabilities (path tools / exec / stdin / stat), daemon protocol+version when BYO, and a redacted env map whose PATH-like values are rewritten per colon-separated entry via workspaceAbsToRel — never by running the joined PATH= line through rewriteExecRootToRel (#468). Host-absolute jail root R is not in the payload. No new daemon route. Not inventory/switch (meta_sandbox_* / #330 stay as they are).
Goals
#
Goal
Success signal
1
Model can call sandbox_info (no args) with read grant and get backend, id, name, slug, status, optional image, logical cwd, permissions
Status line + labeled fields; pwd coordinate = cwd field
2
Capabilities are explicit so the model stops probing
exec, stdin (BYO daemon protocol ≥ 2 / Vercel false), stat, path-tool list withoutstat as a model tool name
3
BYO daemon facts from existing GET /health cache (no new route)
PATH / NODE_PATH / PYTHONPATH (and other PATH-like) are JSON string arrays of per-entry canonicalized values; /usr/bin:<R>/node_modules/.bin → ["/usr/bin","node_modules/.bin"] — notPATH=/usr/bin:node_modules/.bin
5
Secrets and jail root stay out
No SANDBOX_TOKEN / GITHUB_TOKEN / GH_TOKEN / Gateway / DEK/AMK; no host IP / droplet id / base_url; no R string; PWD/HOME under R become workspace-relative; remaining host-absolute PWD/HOME/OLDPWD/TMPDIR omitted
6
Soft-fail like other FS tools
Read deny; env exec failure omits env with env: unavailable; R null skips path rewrite; missing bind still returns cwd/permissions/env
7
Docs + standing orders teach this tool instead of exec env
docs/sandbox.md tool table; DEFAULT_AGENT_SYSTEM names sandbox_info; describeSandboxTools includes it
A) grow meta_sandbox_active B) new sandbox_info on FS surface
B
Active-bind introspection needs the live SandboxClient, cwdState, and workspaceRoot. meta_sandbox_* is grant/envelope inventory and must run when FS tools are skipped. #469 is complementary to #330, not a phase of it.
Env source
A) new /v1/env B) host-only execEnv (GH_TOKEN) C) internal client.exec({ cmd: 'env' }) then parse
C
Issue forbids a daemon route. Host execEnv is not a dump. Argv-only env is POSIX on both backends; argv is a constant (not model-controlled) so a read grant may spawn it.
PATH canonicalize
A) rewriteExecRootToRel on the joined line B) split on :, workspaceAbsToRel each entry, JSON array
B
#468: : is a path terminator, so PATH=/usr/bin:<R>/node_modules/.bin becomes PATH=/usr/bin:node_modules/.bin. Per-entry rewrite keeps /usr/bin and maps only entries under R.
Result shape
A) JSON blob B) labeled key=value like other FS tools; PATH-like as JSON arrays
B
Matches pwd / read_file status lines; arrays avoid :-joined corruption.
Bind metadata
A) omit name/slug B) thread from resolveAgentSandbox (select name/slug/status already on sandboxes)
B
Issue asks for name/slug/status. Columns exist (db/schema.tssandboxes). Resolve today drops them. bind is optional on the tool factory so unit tests without a route resolve still run.
Daemon facts
A) re-probe in the tool B) additive optional SandboxClient.daemonInfo() on the HTTP client (reuse health cache; omit workspaceRoot; non-throwing)
B
Host already has /health. Do not leak R. Vercel client omits the method → daemon=none. No protocol bump.
Secret env keys
A) redact values in place B) omit keys whose name matches a denylist / _*TOKEN/_*SECRET/_*KEY or whose value contains a secrets[] entry
B
Showing GH_TOKEN=[redacted] still teaches the key exists. finalize/redactSecrets remains a belt on the whole payload.
This is the #468 PATH bug; do not reuse it on joined env
workspaceAbsToRel maps one absolute under R → relative (. if equal)
workPath.ts ~87–109
Throws out-of-jail — catch and keep /usr/bin
ResolvedAgentSandbox
lib/tenancy/resolveSandbox.ts ~39–57
Has backend, sandboxId, permissions, workspaceRoot, resolvedImage. Query already selects status/image (~205–214) but notname/slug; return value drops status
sandboxes has name, slug, status, image
db/schema.ts ~86–103
verified
meta_sandbox_active
lib/agent/metaSandboxTools.ts
Inventory descriptor only; no env/cwd/client
describeSandboxTools READ_TOOLS
lib/tenancy/sandboxTools.ts ~22–26
list_dir, read_file, stat — no sandbox_info. stat is display-only (no model stat tool)
Filled by the route from a projection of resolved.value (extend ResolvedAgentSandbox with name, slug, status from the sandboxes row). Map image: resolved.value.resolvedImage. Print id=${bind.sandboxId} (not sandboxId=). Never pass the whole resolved.value into tools (it carries baseUrl, client, secrets, workspaceRoot). Never:baseUrl, token, droplet id, IP, workspaceRoot in the bind object.
When bind is omitted (unit tests, legacy inject): emit cwd, permissions, capabilities, env; skip id/name/slug/status/image; skip backend= line.
Cwd / permissions / capabilities
cwd = cwdState.current (same string pwd prints).
permissions.read / permissions.write from opts.permissions.
capabilities.exec = canWrite (model exec tool gate — not whether the internal env spawn ran).
capabilities.stdin = backend === 'byo'anddaemonInfo.version >= MIN_SANDBOX_PROTOCOL_STDIN; if backend omitted and daemonInfo present, use protocol ≥ 2; else false (Vercel SDK has no stdin).
capabilities.stat = true (both clients implement stat; mtime may still be omitted on Vercel — do not overclaim fingerprints).
capabilities.path_tools = names the model actually has this turn (list_dir/read_file/sandbox_info on read; plus write_file/str_replace/exec on write; always change_dir/pwd). Do not list stat.
Non-throwing: probe fail / 426 / abort → null → tool prints daemon: unavailable and still emits bind/cwd/caps.
Tool lines: daemon.protocol=${info.version} (protocol / HealthInfo.version); daemon.version=${info.daemonVersion} (revision). Do not swap — tests must use unequal values.
backend === 'vercel' short-circuits to daemon=none even if a mock implemented daemonInfo.
No /health contract change. No daemonVersion bump.
Env
If canRead: client.exec({ cmd: 'env', timeoutMs: SANDBOX_INFO_ENV_EXEC_TIMEOUT_MS }, { signal }).
Do not pass stdout through rewriteExecRootToRel.
Parse KEY=VALUE lines (^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ — lowercase z; a-Z is an invalid JS range). Skip malformed. First = splits. No printenv -0 in v1 (newline-in-value residual).
Omit a key when:
name is in SANDBOX_INFO_OMIT_ENV_KEYS (SANDBOX_TOKEN, GITHUB_TOKEN, GH_TOKEN, AI_GATEWAY_API_KEY, …) or matches /(_TOKEN|_SECRET|_KEY|_PASSWORD|_CIPHERTEXT)(_|$)/i or /^(DEK|AMK|KEK)(_|$)/i (DEK_WRAP and AWS_ACCESS_KEY_ID omitted; \b is wrong because _ is a word char)
or value contains a non-empty member of opts.secrets (length ≥ 4, same floor as redactSecrets)
Sort remaining keys. Cap at SANDBOX_INFO_ENV_MAX_KEYS; if truncated, env.omitted=<n> (count of kept-eligible extras past the cap — secret-omitted keys do not occupy a slot and do not increment env.omitted).
Canonicalize remaining values (canonicalizeEnvValue in sandboxInfo.ts):
PATH-like keys (PATH, NODE_PATH, PYTHONPATH, LD_LIBRARY_PATH, MANPATH, GOPATH, CDPATH): split on :, map each entry: if it starts with / and R is set, workspaceAbsToRel or keep on throw (out-of-jail /usr/bin); relative entries unchanged. Emit JSON array (JSON.stringify).
Single-path keys (PWD, HOME, OLDPWD, TMPDIR): one-shot workspaceAbsToRel. If the result still starts with /, omit the key.
Other values that are a single absolute path: same one-shot workspaceAbsToRel when under R; if still absolute, leave as-is (then redactSecrets on the formatted blob).
If R is null/empty: skip abs-to-rel; still omit secrets; PATH stays a JSON array of raw entries; single-path keys that start with / are still omitted (home-layout).
Exec fail / timeout / exitCode !== 0: env: unavailable (timeout|exit=N|error) — do not echo stdout/stderr. Do not fail the whole tool. Classify: result.timedOutor thrown abort/504 → (timeout); exitCode !== 0 → (exit=N); else (error). Exit 0 with empty stdout → no env.* lines (empty map), not unavailable.
Internal env spawn is not the model exec tool: read-only grants may call sandbox_info. Argv is fixed (cmd: 'env', no args). Do not forward model input into that spawn.
When backend/cwd missing, skip that bit. Count = number of env.KEY= lines (0 if unavailable). No env values. unavailable bit allowed. Errors: first line only (existing ERROR path).
Standing order
DEFAULT_AGENT_SYSTEM: add sandbox_info to the prefer-tools list. One clause: use sandbox_info for bind/cwd/caps/env — do notexec env / printenv / uname to learn the sandbox.
describeSandboxToolsREAD_TOOLS: append { name: 'sandbox_info', requiresPermission: 'read' } (backend note as the others). Tests in sandboxTools.test.ts update.
Edge cases
Case
Behavior
No read grant
ERROR sandbox_info: permission denied (need read)
No FS tools (soft path)
Tool absent — meta_sandbox_* still works
bind omitted
cwd + permissions + caps + env; no id/name/slug
R null
Bind + cwd + caps; PATH entries not rewritten; host-absolute HOME/PWD omitted; no R field
env binary missing / throw
env: unavailable (error)
env timeout
env: unavailable (timeout)
env exit ≠ 0
env: unavailable (exit=N)
Value contains a secret
Key omitted
PATH entry /usr/bin
Unchanged (out of jail)
PATH entry <R>/node_modules/.bin
node_modules/.bin
PWD=<R>
.
PWD=<R>/src
src
HOME=/home/foo (outside R)
Key omitted
Refresh / restore
Stateless per call; no SessionStore
Mobile ~390 px
L1 salient only in the harness header; L2 is existing body wrap — no new scroller
Caps table
Cap / ceiling
Value
Rationale
Code location
SANDBOX_INFO_ENV_MAX_KEYS
512 (NEW)
Generous vs a typical Unix env (~50–150). Whole payload still finalizes at 2M chars.
lib/agent/sandboxInfo.ts
SANDBOX_INFO_ENV_EXEC_TIMEOUT_MS
10_000 (NEW)
Same order as BYO health probe (HEALTH_PROBE_TIMEOUT_MS); must not use the 5 min exec default
Review notes (2026-08-19)
Reviewed issue
#697for correctness / performance / architecture / testing / cloud ops N/A / living docs / layers / cap governance.Second pass (same day) re-grounded against
main@4bc831b. Prior majors (optionalbind, non-throwingdaemonInfo, host-absHOME/PWDomit, nostatinpath_tools) stay locked.CreateAgentToolsOptions.bindrequired would break every existingcreateAgentToolstest; only one of tworunAgent/runAgentStreamcall sites was impliedbindis optional. Missing bind: still emit cwd, permissions, capabilities, env. Omitid/name/slug/status/imagelines;backendomitted. Threadbindat bothcreateAgentToolssites (runAgent~286 andrunAgentStream~392).daemonInfo/ envexecthrow (426, timeout) could fail the whole tooldaemonInfois non-throwing (likeworkspaceRoot) and never returnsworkspaceRoot. Env spawn errors →env: unavailablewith a generic reason (timeout/exit=N/error) — no stdout/stderr echo (may containR). Bind/cwd/caps still print./^(DEK|AMK|KEK)\b/imissesDEK_WRAP(_is a word char so\bdoes not fire); suffix$missesAWS_ACCESS_KEY_ID/^(DEK|AMK|KEK)(_|$)/iand/(_TOKEN|_SECRET|_KEY|_PASSWORD|_CIPHERTEXT)(_|$)/i. Explicit denylist unchanged. Test both names.env.omittedcounts only non-omitted extras past the cap.daemon.protocolvsdaemon.versionboth 2 today — a swap would passdaemon.protocol←HealthInfo.version(protocol);daemon.version←HealthInfo.daemonVersion(revision). Test 8 uses unequal values (e.g. protocol=2, daemonVersion=1).(error)ifthrewis checked firstresult.timedOutor thrown abort/504 →(timeout);exitCode !== 0→(exit=N); else(error). Never echo stdout/stderr/exception text.HOME/PWDremaining host-absolute leak home-layout paths (#469 non-goal)PWD/HOME/OLDPWD/TMPDIRif the value still starts with/. PATH-like arrays keep out-of-jail OS entries (/usr/bin).path_toolsmust not liststat(not a model-facing tool)capabilities.path_tools= tools the model actually has this turn.describeSandboxToolsstill listsstat(pre-existing display contract) plussandbox_info.76678f1)main@4bc831b.id=vs bind fieldsandboxId; image sourceid=${bind.sandboxId}.bind.image=resolved.value.resolvedImage(omit line when null/empty). Never pass wholeresolved.value(would carrybaseUrl/client/secrets).Status: HANDOFF-READY
Reviewed: 2026-08-19 (second pass: omit matchers + cap order + daemon field mapping)
Plan header
plan/sandbox-info-tooldocs/sandbox.md,docs/agent-stream.md,AGENTS.md(tool list / standing order),SECURITY.md(omit R / tokens / host inventory)Summary
Add a read-only agent tool
sandbox_infoon the existing FS tool surface (createAgentTools). One call returns structured facts about the active bind: backend, name/slug/status, logical cwd, grant permissions, capabilities (path tools / exec / stdin / stat), daemon protocol+version when BYO, and a redacted env map whosePATH-like values are rewritten per colon-separated entry viaworkspaceAbsToRel— never by running the joinedPATH=line throughrewriteExecRootToRel(#468). Host-absolute jail rootRis not in the payload. No new daemon route. Not inventory/switch (meta_sandbox_*/ #330 stay as they are).Goals
sandbox_info(no args) with read grant and get backend, id, name, slug, status, optional image, logical cwd, permissionspwdcoordinate =cwdfieldexec,stdin(BYO daemon protocol ≥ 2 / Vercel false),stat, path-tool list withoutstatas a model tool nameGET /healthcache (no new route)daemon.protocol+daemon.version+daemon.out_of_date; Vercel omits daemon (daemon=none)PATH/NODE_PATH/PYTHONPATH(and other PATH-like) are JSON string arrays of per-entry canonicalized values;/usr/bin:<R>/node_modules/.bin→["/usr/bin","node_modules/.bin"]— notPATH=/usr/bin:node_modules/.binSANDBOX_TOKEN/GITHUB_TOKEN/GH_TOKEN/ Gateway / DEK/AMK; no host IP / droplet id /base_url; noRstring;PWD/HOMEunderRbecome workspace-relative; remaining host-absolutePWD/HOME/OLDPWD/TMPDIRomittedenv: unavailable;Rnull skips path rewrite; missingbindstill returns cwd/permissions/envexec envdocs/sandbox.mdtool table;DEFAULT_AGENT_SYSTEMnamessandbox_info;describeSandboxToolsincludes itNon-goals / out of scope
meta_sandbox_list/_switch/ feat: agent-visible sandbox inventory + mid-session sandbox switch #330)meta_sandbox_active(that tool has no sandbox client, no cwd, no env, and must keep working on the FS-less selection-required path)export, persist-across-exec, setenv)/v1/envordaemonVersionbumpR, droplet ids, host IPs,base_url, or home-layout pathsstattool (stat stays an internal client call)rewriteExecRootToRelbehavior forexecstdout (that feat(agent): canonicalize exec absolute output to workspace-relative (exec pwd ≡ pwd) — #467 phase 1 #468 tradeoff stays)exec envthrough the feat(agent): canonicalize exec absolute output to workspace-relative (exec pwd ≡ pwd) — #467 phase 1 #468 rewriter and calling itsandbox_infoArchitectural decisions
meta_sandbox_activesandbox_infoon FS surfaceSandboxClient,cwdState, andworkspaceRoot.meta_sandbox_*is grant/envelope inventory and must run when FS tools are skipped. #469 is complementary to #330, not a phase of it./v1/envB) host-onlyexecEnv(GH_TOKEN) C) internalclient.exec({ cmd: 'env' })then parseexecEnvis not a dump. Argv-onlyenvis POSIX on both backends; argv is a constant (not model-controlled) so a read grant may spawn it.rewriteExecRootToRelon the joined line B) split on:,workspaceAbsToReleach entry, JSON array:is a path terminator, soPATH=/usr/bin:<R>/node_modules/.binbecomesPATH=/usr/bin:node_modules/.bin. Per-entry rewrite keeps/usr/binand maps only entries underR.key=valuelike other FS tools; PATH-like as JSON arrayspwd/read_filestatus lines; arrays avoid:-joined corruption.resolveAgentSandbox(selectname/slug/statusalready onsandboxes)db/schema.tssandboxes). Resolve today drops them.bindis optional on the tool factory so unit tests without a route resolve still run.SandboxClient.daemonInfo()on the HTTP client (reuse health cache; omitworkspaceRoot; non-throwing)/health. Do not leakR. Vercel client omits the method →daemon=none. No protocol bump._*TOKEN/_*SECRET/_*KEYor whose value contains asecrets[]entryGH_TOKEN=[redacted]still teaches the key exists.finalize/redactSecretsremains a belt on the whole payload.Rin payloadHOME/PWD/usr/bin.Layer placement
lib/agent/sandboxInfo.tslib/agent/tools.tscreateAgentToolspwdlib/tenancy/resolveSandbox.tsResolvedAgentSandboxsandboxes; today drops the columnslib/agent/runAgent.ts(both generate + stream sites),app/api/agent/route.tsresolved.valueRlib/sandbox/client.tsoptionaldaemonInfofetchHealthcache; catch →nulllib/tenancy/sandboxTools.tsREAD_TOOLSmeta_sandbox_activestay honestlib/agent/agentStream.tssalientToolBitslib/agent/runAgent.tsDEFAULT_AGENT_SYSTEMexec envdocs/sandbox.md,docs/agent-stream.md,AGENTS.md,SECURITY.mdCurrent baseline (live code)
main@4bc831b(merge #692).createAgentToolslib/agent/tools.ts~259{ pwd, change_dir, list_dir, read_file, write_file, str_replace, exec }(~772).pwdis read-only, empty schema (~285–299).execrequires write (~708–710) and rewrites stdout/stderr withrewriteExecRootToRel(~754–755)CreateAgentToolsOptionshas client, freshness, secrets, permissions, cwd,workspaceRoottools.ts~33–65tools.test.tsmockClientalready stubsexec)createAgentToolscall siteslib/agent/runAgent.ts~286 and ~392bindrewriteExecRootToReltreats:as a path terminatorlib/agent/workPath.tsisPathChar~121–128, rewrite ~161–205workspaceAbsToRelmaps one absolute underR→ relative (.if equal)workPath.ts~87–109/usr/binResolvedAgentSandboxlib/tenancy/resolveSandbox.ts~39–57backend,sandboxId,permissions,workspaceRoot,resolvedImage. Query already selectsstatus/image(~205–214) but notname/slug; return value drops statussandboxeshasname,slug,status,imagedb/schema.ts~86–103meta_sandbox_activelib/agent/metaSandboxTools.tsdescribeSandboxToolsREAD_TOOLSlib/tenancy/sandboxTools.ts~22–26list_dir,read_file,stat— nosandbox_info.statis display-only (no modelstattool)lib/sandbox/client.tsHealthInfo~37,workspaceRoot~380–386version,daemonVersion,workspaceRoot. No public daemon snapshot that omitsRlib/sandbox/vercelClient.tscheckDaemonCurrent/ daemon health.workspaceRoot()returns/vercel/workspace.execstdin 400 (~708–722)GET /healthdocs/sandbox.md~317{ ok, version, daemonVersion, workspaceRoot? }— no env routeredactSecrets/finalizelib/agent/redact.ts,tools.tsfinalizeTOOL_RESULT_MAX_CHARS(lib/sandbox/config.ts~10)DEFAULT_AGENT_SYSTEMlib/agent/runAgent.ts~158–167list_dir, read_file, write_file, str_replace, exec, change_dir, pwd— notsandbox_infosalientToolBitslib/agent/agentStream.ts~270pwd/read_file/execshapes; unknown names fall through to a slice — would leak env without a new branchworkspaceRoot+sandboxIdintorunParamsapp/api/agent/route.ts~520–531backend/ name / slugMIN_SANDBOX_PROTOCOL_STDINlib/sandbox/config.ts~31EXPECTED_SANDBOX_DAEMON_VERSIONlib/sandbox/daemonVersion.ts~8.github/workflows/ci.yml(vitest)Design
Tool
sandbox_infoincreateAgentTools, next topwd. Add it to the returned tool set.canRead(same deny string family aspwd). Write not required.additionalProperties: false), no args.ERROR sandbox_info: …/permission denied (need read).Bind (
CreateAgentToolsOptions.bind+RunAgentParams.bind) — optionalFilled by the route from a projection of
resolved.value(extendResolvedAgentSandboxwithname,slug,statusfrom thesandboxesrow). Mapimage: resolved.value.resolvedImage. Printid=${bind.sandboxId}(notsandboxId=). Never pass the wholeresolved.valueinto tools (it carriesbaseUrl,client,secrets,workspaceRoot). Never:baseUrl, token, droplet id, IP,workspaceRootin the bind object.When
bindis omitted (unit tests, legacy inject): emit cwd, permissions, capabilities, env; skipid/name/slug/status/image; skipbackend=line.Cwd / permissions / capabilities
cwd=cwdState.current(same stringpwdprints).permissions.read/permissions.writefromopts.permissions.capabilities.exec=canWrite(modelexectool gate — not whether the internalenvspawn ran).capabilities.stdin=backend === 'byo'anddaemonInfo.version >= MIN_SANDBOX_PROTOCOL_STDIN; if backend omitted anddaemonInfopresent, use protocol ≥ 2; else false (Vercel SDK has no stdin).capabilities.stat= true (both clients implementstat; mtime may still be omitted on Vercel — do not overclaim fingerprints).capabilities.path_tools= names the model actually has this turn (list_dir/read_file/sandbox_infoon read; pluswrite_file/str_replace/execon write; alwayschange_dir/pwd). Do not liststat.Daemon (BYO only)
Additive optional
SandboxClient.daemonInfo?(init?) => Promise<{ version: number; daemonVersion: number } | null>:fetchHealth; return{ version, daemonVersion }withoutworkspaceRoot.null→ tool printsdaemon: unavailableand still emits bind/cwd/caps.daemon.protocol=${info.version}(protocol /HealthInfo.version);daemon.version=${info.daemonVersion}(revision). Do not swap — tests must use unequal values.daemon.out_of_date=daemonVersion < EXPECTED_SANDBOX_DAEMON_VERSION.daemon=none.backend === 'vercel'short-circuits todaemon=noneeven if a mock implementeddaemonInfo.No
/healthcontract change. NodaemonVersionbump.Env
canRead:client.exec({ cmd: 'env', timeoutMs: SANDBOX_INFO_ENV_EXEC_TIMEOUT_MS }, { signal }).rewriteExecRootToRel.KEY=VALUElines (^([A-Za-z_][A-Za-z0-9_]*)=(.*)$— lowercasez;a-Zis an invalid JS range). Skip malformed. First=splits. Noprintenv -0in v1 (newline-in-value residual).SANDBOX_INFO_OMIT_ENV_KEYS(SANDBOX_TOKEN,GITHUB_TOKEN,GH_TOKEN,AI_GATEWAY_API_KEY, …) or matches/(_TOKEN|_SECRET|_KEY|_PASSWORD|_CIPHERTEXT)(_|$)/ior/^(DEK|AMK|KEK)(_|$)/i(DEK_WRAPandAWS_ACCESS_KEY_IDomitted;\bis wrong because_is a word char)opts.secrets(length ≥ 4, same floor asredactSecrets)SANDBOX_INFO_ENV_MAX_KEYS; if truncated,env.omitted=<n>(count of kept-eligible extras past the cap — secret-omitted keys do not occupy a slot and do not incrementenv.omitted).canonicalizeEnvValueinsandboxInfo.ts):PATH,NODE_PATH,PYTHONPATH,LD_LIBRARY_PATH,MANPATH,GOPATH,CDPATH): split on:, map each entry: if it starts with/andRis set,workspaceAbsToRelor keep on throw (out-of-jail/usr/bin); relative entries unchanged. Emit JSON array (JSON.stringify).PWD,HOME,OLDPWD,TMPDIR): one-shotworkspaceAbsToRel. If the result still starts with/, omit the key.workspaceAbsToRelwhen underR; if still absolute, leave as-is (thenredactSecretson the formatted blob).Ris null/empty: skip abs-to-rel; still omit secrets; PATH stays a JSON array of raw entries; single-path keys that start with/are still omitted (home-layout).exitCode !== 0:env: unavailable (timeout|exit=N|error)— do not echo stdout/stderr. Do not fail the whole tool. Classify:result.timedOutor thrown abort/504 →(timeout);exitCode !== 0→(exit=N); else(error). Exit 0 with empty stdout → noenv.*lines (empty map), not unavailable.Internal
envspawn is not the modelexectool: read-only grants may callsandbox_info. Argv is fixed (cmd: 'env', no args). Do not forward model input into that spawn.Output shape (locked)
Vercel:
daemon=none,capabilities.stdin=false. Empty/null image line omitted. Missing bind: nobackend=/id=/name=/slug=/status=lines.Then
finalize(..., secrets)(redact + 2M cap).L1 salient
New
salientToolBitsbranch before the generic fallback:sandbox_info · backend=byo · cwd=invincible · 24 envWhen backend/cwd missing, skip that bit. Count = number of
env.KEY=lines (0 if unavailable). No env values.unavailablebit allowed. Errors: first line only (existing ERROR path).Standing order
DEFAULT_AGENT_SYSTEM: addsandbox_infoto the prefer-tools list. One clause: usesandbox_infofor bind/cwd/caps/env — do notexec env/printenv/unameto learn the sandbox.describeSandboxToolsREAD_TOOLS: append{ name: 'sandbox_info', requiresPermission: 'read' }(backend note as the others). Tests insandboxTools.test.tsupdate.Edge cases
ERROR sandbox_info: permission denied (need read)bindomittedRnullHOME/PWDomitted; noRfieldenvbinary missing / throwenv: unavailable (error)envtimeoutenv: unavailable (timeout)envexit ≠ 0env: unavailable (exit=N)/usr/bin<R>/node_modules/.binnode_modules/.binPWD=<R>.PWD=<R>/srcsrcHOME=/home/foo(outside R)Caps table
SANDBOX_INFO_ENV_MAX_KEYSfinalizes at 2M chars.lib/agent/sandboxInfo.tsSANDBOX_INFO_ENV_EXEC_TIMEOUT_MSHEALTH_PROBE_TIMEOUT_MS); must not use the 5 min exec defaultlib/agent/sandboxInfo.ts/ tool executeTOOL_RESULT_MAX_CHARSlib/sandbox/config.tsMIN_SANDBOX_PROTOCOL_STDIN/EXPECTED_SANDBOX_DAEMON_VERSIONlib/sandbox/config.ts,daemonVersion.tsNo existing cap is raised or lowered.
Cloud ops path
N/A — no Production mutate.
Living docs plan
docs/sandbox.mdsandbox_info. State: structured active-bind facts + env; PATH rewritten per entry; notexec env;R/ tokens omitted.describeSandboxToolsbullet includes it. No/v1/envprotocol rowdocs/agent-stream.mdsandbox_infois backend · cwd · env-count, not the dumpAGENTS.mdsandbox_infoas the bind/env introspectorREADME.mdSECURITY.mdsandbox_infoomits tokens,R, host IPs, droplet ids,base_url.env.exampleImplementation order
lib/agent/sandboxInfo.ts— parse env, PATH-like split, omit keys, canonicalize; unit tests (no PGlite).lib/sandbox/client.ts— optionaldaemonInfo()on HTTP client (health cache, noR, non-throwing).lib/tenancy/resolveSandbox.ts— select + returnname,slug,status.runAgent(both sites) / route /CreateAgentToolsOptions— thread optionalbind.tools.ts—sandbox_infoexecute;sandboxTools.tsREAD_TOOLS;DEFAULT_AGENT_SYSTEM.salientToolBits+ tests.sandbox.md,agent-stream.md,AGENTS.md,SECURITY.md).npm run typecheck+vitest run --changed/ fullvitest run. No Zig.Testing
/usr/bin:<R>/node_modules/.bin→["/usr/bin","node_modules/.bin"](not the #468 joined rewrite)lib/agent/sandboxInfo.test.tsPWD=<R>→.;HOME=<R>/x→x;HOME=/home/fooomittedRnull → PATH entries unchanged; noRin output; host-absPWDomittedsecrets[]omitted — includingDEK_WRAPandAWS_ACCESS_KEY_IDenv: unavailabletools.test.tsmockclient.execrejecttools.test.tstools.test.tsdaemonInfo→ protocol/version/out_of_date; Vercel omit. Use unequal protocol vs daemonVersion so a field swap failsdaemonInfopresent vs absent /bind.backend=vercelsalientToolBitshas backend/cwd/count, notPATH=agentStream.test.tsdescribeSandboxToolsincludessandbox_infoon readsandboxTools.test.tsresolveSandbox.test.tsshape (inject connect — no new PGlite)sandbox_infoand forbids learning env viaexec envrunAgent.test.tsbindstill returns cwd + envtools.test.tsnpm run typecheck+vitest run(CI) /--changed(iteration)env.omitted=1; a secret key in the set does not consume a slotlib/agent/sandboxInfo.test.tsenv: unavailable (timeout)not(error)tools.test.tsNo
new PGlite()in new tests.resolveSandboxtests already use the shared DI connect seam — extend assertions only.Operator checklist (Preview; no Wasm rebuild required)
sandbox_infoshowsbackend=byo, cwd, daemon versions, PATH array with/usr/binintact and workspace bins relativedaemon=none,stdin=falseexec envstill exhibits feat(agent): canonicalize exec absolute output to workspace-relative (exec pwd ≡ pwd) — #467 phase 1 #468 (unchanged);sandbox_infodoes notbase_urlDefinition of done
sandbox_infotool on the FS surface, read-only, empty argsR, tokens, host IPs, droplet ids,base_urlin the resultenv: unavailable); tool still returns bind factsRnull skips rewrite; missingbinddoes not throwdescribeSandboxTools+DEFAULT_AGENT_SYSTEM+ L1 salient updatedRisks & mitigations
envis still a process spawn on a read grantenvonly; 10s timeout; not model-controlled; documentedenvmissing on a minimal imageenv: unavailable; bind facts still shipprintenv -0(Vercel/BYO variance). Malformed lines skippedRnull leaves host-absolute PATH entriesR. Still omit secrets and host-absHOME/PWD. Do not printRsalientToolBitsbranch + test; name ===sandbox_infofallback without bodymeta_sandbox_activesandbox_infoOpen questions
None — in-scope choices are locked.
References
:/PATH)docs/sandbox.mdprotocol + cwd tools