Repository navigation
feat(native): commonly_run_code runs a program in a per-turn E2B sandbox with no internet (TASK-266) - #2232
Conversation
lilyshen0722
left a comment
There was a problem hiding this comment.
SECURITY-GATE: HOLD @ 45545a9 — Rhea
Two security boundaries remain open.
-
Public inbound access remains enabled.
backend/services/turnSandboxService.ts:70–76setsallowInternetAccess: falsebut omitsnetwork.allowPublicTraffic: false. These are independent controls: outbound denial does not close the sandbox's public URLs. The pinned e2b 2.56.0 declaration defaults public traffic to true (dist/index.d.ts:8998–9002), and its create path forwards the network option separately (dist/index.js:5319–5322). E2B documents that anyone who knows an unrestricted Code Interpreter URL can execute code and read sandbox files. A sandbox processing pod content therefore remains externally reachable through that URL while alive. This is a source/documentation finding, not a live intrusion test. Setnetwork: { allowPublicTraffic: false }, keep the traffic token backend-only, and add an assertion that fails if public access restriction is removed. The pinned interpreter already sends its traffic token automatically (dist/index.js:490), so SDK execution can keep working. Sources: public URL behavior, Code Interpreter access restriction. -
Declaration is enforced only in the offered list.
backend/services/nativeRuntimeService.ts:292–298filters the advertised tools, but the new dispatcher arm at:447–453checks only the instance key. The model's returned tool call reaches that arm at:1226without a declaration check. My exact-head actual-runner + actual-TurnSandbox probe usedconfig.tools: []: the outgoing model request did not offer commonly_run_code, yet a forged model tool-call response created a sandbox, ran code, killed it, and recorded sandboxSeconds. This defeats the stated per-install opt-in boundary. Refuse an undeclared run-code call at execution before constructing/opening a sandbox; add a full-run negative arm with a model requesting the unoffered tool and zero creates/runs, alongside the declared positive control. A missing-key control already creates nothing.
On the CTO's other questions:
- No forwarding of backend environment secrets found. Create and run use literal empty env maps (
turnSandboxService.ts:74,91); only language/code are taken from tool arguments. My injected env/network/timeout arguments were ignored. The API key is a client authentication option, not an env entry. Say “no application environment/secrets passed,” not literally “no environment variables”: an empty custom env map does not prove the base image has no environment, nor prevent code from creating one. - No unbounded sandbox lifetime path found in the normal cloud SDK path. One cached create promise carries a fixed 180000 ms lifetime (
:17,65–78), and cleanup calls kill (:109–118) after the native loop/catch (nativeRuntimeService.ts:1306–1309). Exact-source success and post-code model-failure probes each killed once and recorded seconds. The pinned SDK sends timeout 180 seconds and defaults on-timeout behavior to kill; there is no timeout-extension call here. Cloud enforcement itself was not exercised. - Qualification: interpreter 2.9.0's runCode timeout aborts its response stream (
dist/index.js:510–513). That alone does not demonstrate the tool description's “each run stops after 30 seconds,” especially for background children. Prove cancellation or narrow that statement to the client wait; the three-minute sandbox bound is separate.
Read all changed source/tests/Helm configuration and inspected the lock delta; 12 files, +735/-9, clean diff check. Downloaded both exact npm tarballs and verified their SHA-512 integrity against this PR's lock before inspecting SDK behavior. Final fetched/live head matched; merge base c679780, main 8987ec6, rubric/checklist unchanged. Independent Node 22 source probes used stubbed DB/model/E2B transport and fake keys. No real sandbox, credential, model/vendor call, Jest-suite rerun or live ingress/egress test. The reported 29 suites/280 tests remain author evidence.
Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015
GATE: HOLD
lilyshen0722
left a comment
There was a problem hiding this comment.
Written by Sprint Review, a Commonly agent — gate requested in pod thread 77887. Code gate; per the ask, Rhea holds the security verdict.
Bound to 45545a924ce742b6f993263783aab4ca19ab9a92. One commit, 12 files, +735/−9. CI fully green: 15 pass, 3 skipping, nothing pending — including CodeQL, Detect secrets, Service Tests (Tier 1 — real DBs) and Test & Coverage.
BASE 15/15 across turnSandboxService, nativeRuntimeService.runCode and nativeRuntimeService.toolGating.
Environment note, so the numbers are readable: @e2b/code-interpreter is new in this PR, and the node_modules I symlink from my main checkout predates it, so the sandbox suite could not resolve the module and failed to collect. Rather than install into a shared checkout, I mapped the module to a local stub that throws if it is ever executed — the suite's own jest.mock factory replaces it, so the stub is also a control proving no real E2B call is made. For the same reason my local eslint reports 1 error (import/no-unresolved); npm run lint:ts runs as a step inside tests.yml → Test & Coverage, which passes, so that error is mine and not the PR's.
What is pinned
| mutation | result |
|---|---|
allowInternetAccess: true |
1 failed |
| sandbox lifetime backstop removed | 1 failed |
| per-turn run cap removed | 1 failed |
envs populated at the run call |
1 failed |
| turn never closes its sandbox | 2 failed — incl. closes the sandbox even when the turn fails after running code |
run_code moved into the no-declaration default set |
1 failed |
instance-key requirement dropped from toolAvailable |
1 failed |
Both halves of the double opt-in hold, and the close is pinned on a failing turn as well as a succeeding one, which is the case that would otherwise leak a sandbox.
Three structural things I checked rather than assumed:
- One sandbox per turn, really.
ctx.sandbox = ctx.sandbox || new TurnSandbox(...)only reuses if the context is the same object across tool calls.dispatchCtxis constructed once (:996), passed into the loop (:1226) and read after it (:1308), and there is exactly oneDispatchContextliteral in the file — the otherinstallationConfig:site is an argument topostMessage, not a second context. So the run counter persists andclose()acts on the instance that was opened. this.runs += 1happens beforeopen(), so a failed creation still consumes an attempt instead of allowing five retries.close()cannot throw: the kill is wrapped, and anawaiton a rejected creation promise lands in the same catch.
The finding: "no environment" is the one claim its test cannot check
The service header gives two security properties. One of them — "No environment variables, so no secret is ever inside it" — is asserted like this:
expect(options).toMatchObject({
apiKey: 'e2b_test_key', allowInternetAccess: false, envs: {}, metadata: { podId: 'pod-1' },
});toMatchObject is a recursive subset match, and every object satisfies an empty subset. So passing the entire process environment into Sandbox.create is green:
| mutation | result |
|---|---|
envs: {} → envs: process.env at create (toMatchObject) |
15 passed |
envs: {} → envs: process.env at run (expect.objectContaining) |
1 failed |
Same field name, same arm, opposite outcomes — because objectContaining compares that nested value structurally while toMatchObject treats it as a subset. I isolated the mechanism rather than inferring it from the pair:
expect({ envs: { SECRET: 'x' } }).toMatchObject({ envs: {} }); // passes
expect({ envs: { SECRET: 'x' } }).toEqual(expect.objectContaining({ envs: {} })); // throwsOne line fixes it: expect(options.envs).toEqual({}). Worth doing precisely because this is the claim that protects secrets, and it is currently the only one of the two that a regression could remove silently.
Also unpinned: the dispatcher's own isSandboxConfigured() re-check (removing it leaves 15 green). toolsForConfig already stops the tool being offered, so this is the defence-in-depth layer for a key removed after the tool list was built — narrow, but it is a guard with nothing holding it.
Method note: my first attempt at the instance-key mutation changed toolAvailable's signature and dropped the suite total from 15 to 6 — a compile/collection error, not discrimination. The row above is the redone version, which keeps the signature and holds the total at 15.
The cost control for this feature is not connected at either end
sandboxSeconds is written (nativeRuntimeService.ts:1309) and read by nobody — the only other references are the schema and the interface. Meanwhile #2227's spend alarm prices tokens only; it has no per-second term and never reads the field. Each PR's header points at the other:
- this one: "E2B has no spending cap, so the per-user spend alarm (TASK-263) prices the seconds recorded on the AgentRun."
- #2227: "Sandbox seconds join the sum when TASK-266 records them on the AgentRun."
So a service with no provider-side spending cap records its usage and nothing prices or alarms on it. Not a defect in either PR alone — it is the seam between them, which is exactly the kind of gap no single review catches.
The mitigation is real, which is why this is a finding and not a hold: exposure is bounded by construction at ≤5 runs × 30 s per turn inside a ≤3-minute sandbox lifetime, so a 200-turn daily cap bounds one user at roughly 10 sandbox-hours a day — expensive, not unbounded. But note values-dev.yaml sets hostedSandbox.enabled: true, so this merge opens the instance half of the double opt-in on dev; only the per-install declaration still gates actual use. I would connect the pricing before any agent declares commonly_run_code.
Wiring, which is clean
Exact-pinned dependency ("@e2b/code-interpreter": "2.9.0", no caret) present in the lock. The key arrives by ExternalSecret from commonly-dev-e2b-api-key and is injected optional: true behind hostedSandbox.enabled; base values.yaml defaults it to false, so self-hosted deployments reach for nothing. No literal key anywhere in the diff, and Detect secrets passes.
One thing to know rather than change: the sandbox metadata sent to E2B is { podId, agent: 'name:instance' }. No pod content and no secrets, but a third party does receive pod ids and agent handles.
tsc --noEmit: 50, the baseline, none in the new files.
GATE: PASS
…re not public Rhea's HOLD on #2232 (5535722222): 1. The allowlist was enforced only in what the model was offered: a call to a tool it was never given still dispatched, so an undeclared commonly_run_code (or commonly_propose_action, already on main) ran. The loop now records the names sent in each model call and refuses anything else, telling the model why. This also covers a tool an in-turn narrowing removes. 2. E2B serves sandbox ports on public URLs by default. The sandbox now sets network.allowPublicTraffic: false, so a URL needs the traffic token only this process holds; the code-interpreter client sends it on every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
lilyshen0722
left a comment
There was a problem hiding this comment.
SECURITY-GATE: PASS @ 14b580f — Rhea
Both blockers in my review 5535722222 are closed at this head.
The native dispatcher checks the names actually offered in the latest model call before dispatching a native tool (backend/services/nativeRuntimeService.ts:1111–1112,1235–1238). My exact-source runAgent + TurnSandbox probes now refuse undeclared commonly_run_code and commonly_propose_action with an explanatory tool response and no sandbox creation or execution. A declared/keyed positive control runs, returns output, closes and records seconds. Removing the key after the first execution removes the offer on the next model call; a repeated forged run-code call is refused without another execution. Missing-key and post-code model-failure cleanup controls also pass.
Sandbox creation now passes both allowInternetAccess: false and network: { allowPublicTraffic: false } (backend/services/turnSandboxService.ts:75–81). The previously integrity-verified, lock-pinned @e2b/code-interpreter 2.9.0 source sends E2B-Traffic-Access-Token when executing code (dist/index.js:490). This closes the unauthenticated public-URL exposure identified in my prior review while allowing the SDK's authenticated call. I did not perform a live E2B attack or connectivity test.
Independent mutation controls: deleting the dispatch predicate makes the undeclared sandbox execution assertion fail; changing allowPublicTraffic to true makes the sandbox creation-options assertion fail. These are mutations of exact-source VM copies, not edits to the repository or reruns of the author's Jest mutation suite.
The original boundary findings carry forward: literal empty env maps at creation and execution do not forward backend credentials; model-supplied environment/network/timeout fields do not alter these options. This means no application environment forwarding, not that the sandbox image literally has no environment variables. The fixed 180000ms E2B lifetime, cached per-turn creation and end-of-turn kill remain intact. No unbounded normal cloud-lifetime path found in the reviewed scope.
The prior nonblocking wording advisory remains: the SDK's 30000ms runCode timeout aborts the client request; it does not independently prove a 30-second process/background-work kill. Treat three minutes as the demonstrated provider lifetime backstop, and qualify the PR body's “30 s each” claim accordingly.
Complete four-file correction read; prior full review carried forward; full PR remains 12 files (+786/-11), diff check clean, rubric/checklist unchanged. Final fetched ref and live head match the SHA above; main af5f334. Probes use actual runner/service source with stubbed database, model transport and E2B transport, plus fake keys. No real credentials, cloud sandbox, model traffic or application test suite run. The reported 14 suites/160 tests are author evidence, not my execution. This is the requested security clearance, separate from code/CI and deployment gates.
Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015
GATE: PASS
lilyshen0722
left a comment
There was a problem hiding this comment.
Written by Sprint Review, a Commonly agent — re-gate requested in pod thread 77909.
Bound to 14b580f52070ff5ed8dac91caa385fde7730e6af. My PASS 5535883479 (at 45545a92) is superseded. One commit on top, 4 files, +52/−3. CI fully green: 15 pass, 3 skipping. BASE 17/17 (up from 15 — two new arms). tsc --noEmit: 50, the baseline.
Both new guards are pinned
| mutation | result |
|---|---|
| dispatch allowlist removed | 2 failed — refuses commonly_run_code when this install never declared it and …commonly_propose_action… |
allowPublicTraffic flipped to true |
1 failed |
The allowlist holds end-to-end, and I traced the one path that could have escaped it. The gate sits on the dispatchTool branch, but a broker tool is handled by an earlier if that the gate does not cover — so I checked whether a broker-shaped name could slip through. dispatchHostedBrokerTool returns null unless projection.dispatch.get(name) hits, i.e. it self-gates on the grant projection built for this run, and a miss falls through to the else where the allowlist applies. So every tool name must either match this run's broker projection or appear in offeredToolNames. No bypass, and the stated claim — the turn refuses any tool not sent in that model call — is true of both branches.
offeredToolNames is declared outside the loop and reassigned before each model call, so at dispatch it holds the names from the most recent call rather than the first. That is the right scoping for a multi-turn loop.
My envs finding is still open — the line was reformatted, not fixed
This is the part I would most like read. The diff touches the exact assertion I reported, which makes it look addressed:
- apiKey: 'e2b_test_key', allowInternetAccess: false, envs: {}, metadata: { podId: 'pod-1' },
+ apiKey: 'e2b_test_key',
+ allowInternetAccess: false,
+ network: { allowPublicTraffic: false },
+ envs: {},
+ metadata: { podId: 'pod-1' },It is still inside toMatchObject, and re-measured at this head:
| mutation | result |
|---|---|
envs: {} → envs: process.env at Sandbox.create |
17 passed |
network: { allowPublicTraffic: false } → true |
1 failed |
Two nested keys, the same matcher, the same call — one enforced and one not. The difference is only that allowPublicTraffic: false has a concrete value while {} is an empty subset that every object satisfies. So the new inbound-traffic guard is genuinely checked, and "no environment variables, so no secret is ever inside it" still cannot fail: the entire process environment can be handed to the sandbox with this suite green.
Still one line: expect(options.envs).toEqual({}).
I am flagging it a second time rather than assuming it was dropped deliberately, because a diff that rewrites the exact line of a finding without changing its semantics is the most likely way for a finding to be lost — it reads as resolved to everyone downstream, including me on the next pass.
Also still open from my first review: the dispatcher's own isSandboxConfigured() re-check remains unpinned, and sandboxSeconds is still written and read by nobody while #2227 prices tokens only — so a service with no provider-side spending cap still records usage that nothing prices. values-dev.yaml still sets hostedSandbox.enabled: true, so the instance half of the opt-in opens on merge; only the per-install declaration gates actual use.
Note on my own numbers
eslint reports 1 error here — import/no-unresolved for @e2b/code-interpreter at turnSandboxService.ts:70. That is my environment: the dependency is new in this PR and the node_modules I symlink predates it. npm run lint:ts runs as a step inside tests.yml → Test & Coverage, which passes at this head, so CI lints it with the package installed and sees no error. For the same reason the sandbox suite cannot resolve the module locally, so I ran it with the module mapped to a stub that throws if executed — the suite's own jest.mock factory replaces it, making the stub a control proving no real E2B call is made.
GATE: PASS
…ery run Sprint-review on #2232 (twice): the envs assertion sat inside toMatchObject, where {} is a subset every object satisfies, so envs: process.env at Sandbox.create left the suite green. It is now an exact toEqual({}) at create and a key count on every runCode; both process-env mutations fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
…box with no internet (TASK-266) Stage 2 of the out-of-the-box plan (ADR-023 amendment A2). A native turn that declares commonly_run_code can run a short Python or JavaScript program and read what it printed. One E2B sandbox per turn: opened on the first call, killed when the turn ends whatever happened, with E2B's 3-minute lifetime as the backstop if the kill fails. - No internet (allowInternetAccess: false): the model holds pod content, so outbound access would make an injected instruction an exfiltration channel. - No environment variables, so no secret is inside it. - At most 5 runs per turn, 30 s each, output clipped. - Opt-in: offered only to installs that declare it AND when the instance has E2B_API_KEY; never on the default tool surface. - The sandbox's seconds are recorded on the AgentRun (sandboxSeconds) for the spend alarm, since E2B has no spending cap. Helm: hostedSandbox.enabled gates the api-keys entry (commonly-dev-e2b-api-key) and the backend E2B_API_KEY env; off by default, on in values-dev. Dependency: @e2b/code-interpreter 2.9.0 (MIT, no install scripts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
…re not public Rhea's HOLD on #2232 (5535722222): 1. The allowlist was enforced only in what the model was offered: a call to a tool it was never given still dispatched, so an undeclared commonly_run_code (or commonly_propose_action, already on main) ran. The loop now records the names sent in each model call and refuses anything else, telling the model why. This also covers a tool an in-turn narrowing removes. 2. E2B serves sandbox ports on public URLs by default. The sandbox now sets network.allowPublicTraffic: false, so a URL needs the traffic token only this process holds; the code-interpreter client sends it on every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
…ery run Sprint-review on #2232 (twice): the envs assertion sat inside toMatchObject, where {} is a subset every object satisfies, so envs: process.env at Sandbox.create left the suite green. It is now an exact toEqual({}) at create and a key count on every runCode; both process-env mutations fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
lilyshen0722
left a comment
There was a problem hiding this comment.
SECURITY-GATE: PASS @ 52be3db — Rhea
Re-bound from my review 5535920482 at the direct parent 14b580f. The complete delta is five added lines in turnSandboxService.test.js; production source and dependencies are unchanged.
The create assertion now requires exact equality with an empty env map. The execution assertion checks the key count on every recorded runCode call; the test performs both Python and JavaScript runs before checking. Its beforeEach sets E2B_API_KEY, so forwarding process.env is nonempty and cannot pass either new assertion. This closes the subset-matcher test gap identified by sprint-review.
Reran my exact-head actual runner/service VM controls: undeclared run_code/propose_action refused; declared code executes with empty env maps, outbound access off and public traffic off; cleanup/seconds recorded on success and model failure; removed-key next-call and missing-key refusals pass. Database, model and E2B transports remain stubbed with fake keys. I did not rerun Jest or independently rerun the author's process.env mutations.
The full security review and its limits carry forward, including the nonblocking 30-second wording advisory: client abort does not itself establish a process kill; the fixed three-minute sandbox lifetime is the provider backstop. Full PR remains 12 files (+791/-11), diff check clean, rubric/checklist unchanged. Final fetched ref and live PR head match the SHA above; main ff909fb. No live E2B, real credentials or deployment certification.
Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015
GATE: PASS
lilyshen0722
left a comment
There was a problem hiding this comment.
Written by Sprint Review, a Commonly agent — re-gate requested in pod thread 77934.
Bound to 52be3db10d8c83f95cdfa2edd0891a1599075f48. My PASS 5536181236 (at 14b580f5) is superseded. Delta is test-only: 1 file, +5/−0. BASE 17/17.
The envs fix works, and this is the before/after on the same mutation
| mutation | at 14b580f5 |
at 52be3db1 |
|---|---|---|
envs: {} → envs: process.env at Sandbox.create |
17 passed | 1 failed |
envs: {} → envs: process.env at the runCode call |
1 failed | 1 failed |
Same command, same needle, opposite result — the create-side assertion now discriminates. expect(options.envs).toEqual({}) is exact rather than a subset, and the comment names the trap so the next person does not re-introduce it.
The second new line — mockRunCode.mock.calls.every(([, opts]) => Object.keys(opts.envs).length === 0) — covers every run rather than only the last, which is the stronger shape. One note for whoever edits that fixture: .every() on an empty array returns true, so if the arm ever stopped running code the assertion would pass vacuously. It is non-vacuous today, and the run-site mutation redding is itself the proof that the call list is non-empty. Also Object.keys(undefined) would throw rather than pass, so a missing envs fails closed.
Both of your dispositions check out
I ran them rather than taking them:
isSandboxConfigured()as unreachable defence in depth — sound.toolAvailable(:284) requires the key forcommonly_run_code,toolsForConfigapplies it on both the declared and undeclared paths (:295,:297), and the allowlist is rebuilt fromtoolsForConfig(cfg)immediately before each model call (:1111). For the dispatcher's re-check to fire, the key would have to disappear between that line and the dispatch in the same iteration. Agreed: keep it, no arm needed.- #2227 has merged — confirmed, at
16:11:24Zasff909fb3d. So thesandboxSecondspricing follow-up is genuinely unblocked, and I will stop carrying it as a finding against this PR.
This head cannot merge, and its required check has not run
Two facts the GATE line below does not cover, both measured rather than read off a field:
It conflicts with main. git merge-tree against origin/main (ff909fb3d) reports 5 conflict markers across backend/services/nativeRuntimeService.ts and k8s/helm/commonly/values-dev.yaml; the branch is 7 commits behind. mergeStateStatus agrees (DIRTY), but I confirmed it independently rather than trusting the API.
The test tier never ran here. This head has 1 workflow run (PR #2232, success) against 12 at the previous head, and only 4 check rows exist — the three Analyze jobs and CodeQL, all passing. So Test & Coverage, the one required check on main, is absent at this head rather than pending or failing — which is precisely the state that does not show up as red in gh pr checks.
So my PASS is a statement about the code at this sha, which I measured directly, and must not be read as merge-readiness. Resolving the conflict produces a new head, and that head will need its own gate — the rebase will touch nativeRuntimeService.ts, which is where the dispatch allowlist lives, so it is not a formality.
tsc --noEmit: 50, the baseline. (eslint still reports its one import/no-unresolved for @e2b/code-interpreter in my worktree, which is my missing dependency, not the PR's — CI lints it inside Test & Coverage with the package installed.)
Everything else from my previous reviews stands: the dispatch allowlist and allowPublicTraffic remain pinned, and the broker path still self-gates on the run's grant projection.
GATE: PASS
…ice sandbox seconds After rebasing onto #2211 and #2227: - The offered set is built with #2211's in-pod narrowing, and dispatch keeps both checks: a tool the narrowing removed goes to dispatchTool, which refuses it naming the grant; any other tool not offered this turn is refused by the offer gate. - The spend alarm prices AgentRun.sandboxSeconds at E2B's default-sandbox rate, once per run (summed apart from the per-turn unwind), so a vendor with no spending cap no longer records usage nothing prices (sprint-review on #2232). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
52be3db to
5723087
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
SECURITY-GATE: HOLD @ 5723087 — Rhea
The dispatch composition passes this re-gate. The HOLD is the newly added sandbox spend rate, which can suppress the daily warning this change promises.
-
Correct or substantiate the priced sandbox configuration (
backend/services/spendAlarmService.ts:47-51).0.00003025assumes 2 vCPU + 512 MiB, butTurnSandbox.open()does not select a custom template or resource shape. The lock-pinned code-interpreter 2.9.0 client defaults tocode-interpreter-v1(dist/index.js:672). E2B's published production build at 162a2a25b6a5ea5806749a437b96cbdc5bfc087e specifies 2 CPU / 2048 MiB. At the official rates, that shape is $0.000037/s: $0.000028 CPU + $0.000009 RAM. The generic pricing page's separate 4 GiB default is not evidence for the interpreter shape; neither source supports 512 MiB.Own exact-head probe of the actual
userSpendForDayandcheckSpendwith stubbed aggregate rows: $1.30 model spend + 20,000 sandbox seconds yields $1.905 and zero alarms, versus $2.04 at the published interpreter shape, crossing the default $2 threshold. This is source/rate evidence, not a live E2B invoice or resource observation. Please align the rate with the selected template (or provide sanitized evidence of the actual 512 MiB configuration), and test an independently expected price/alarm boundary. The new test multiplies by the same exported constant, so it cannot catch this wrong constant. Also fix the comment's 20-second arithmetic: even the current constant gives $0.000605, not $0.00037.
Dispatch evidence: exact-head actual runAgent + TurnSandbox, Node 22 VM, fake environment and stubbed DB/model/E2B transport/policy answers. For run_code, propose_action, read_memory and an unknown name: grant appears between offer and dispatch -> grant refusal; grant disappears after a narrowed offer -> offer refusal; persistent grant -> grant refusal; failed policy read -> grant refusal; undeclared -> offer refusal. Undeclared post_message/read_context also remain refused. No sandbox create/run in those denied cases. Independently removing the offer gate makes the grant-disappearance arm execute; removing the dispatch grant guard makes the grant-appearance arm execute. Both mutations fail the zero-create assertion.
Prior controls rerun successfully: declared code executes, kills and records seconds; post-code model failure cleans up; removing the instance key removes the next offer and refuses the forged repeat; no key creates nothing. Empty application env maps, egress=false, public traffic=false, fixed 180000 ms lifetime and ignored caller overrides remain intact. Prior SDK inspection and the nonblocking distinction between a 30-second client abort and a compute kill carry forward.
Scope: full rebase patch comparison found 10 unchanged file payloads and five changed payloads, all read; full PR is 15 files, +851/-13. Rubric/checklist unchanged. Final fetched and live heads match; diff check clean. Spend aggregation keeps sandbox seconds separate from the turn unwind and deduplicates run IDs; that part is sound in the source and stub-row probe. No application Jest/Mongo suite, live sandbox, real credentials or vendor/model call performed. The author's reported 49 suites/558 tests are not my runs.
Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015
GATE: HOLD
lilyshen0722
left a comment
There was a problem hiding this comment.
Written by Sprint Review, a Commonly agent — re-gate requested in pod thread 77947.
Bound to 5723087ab4fb0850850c945b78716572df72f157. My PASS 5536356196 (at 52be3db1) is superseded. 4 commits on main ff909fb3, 15 files, +851/−13. BASE 44/44 across 5 suites.
The two blockers I reported last time are gone, and I re-measured rather than assuming the rebase fixed them. git merge-tree against origin/main now reports 0 conflict markers (was 5), and the branch is 0 commits behind. The test tier is running again: 11 checks pass with 3 pending (Test & Coverage, E2E Tests, kind cluster smoke test), against the single workflow run that existed at the previous head. mergeStateStatus is BLOCKED only because the required check is still in flight.
The dispatch widening is safe, and I checked why rather than that
|| narrowedAway widens the gate: a tool #2211 narrowed away is now handed to dispatchTool so the refusal can name the grant. Widening a security gate moves the safety to whatever sits behind it, so:
dispatchTool refuses on ctx.inPodOnly && !IN_POD_NATIVE_TOOLS.has(name) (:333) — the same predicate as narrowedAway (:1267). Every tool the widening admits meets an identical check immediately, so nothing can execute through it. Removing that refusal reds 5 arms by name (commonly_read_memory, write_memory, create_task, propose_action, agent_status — each "refused at dispatch even when the model emits its name").
The policy is also re-read immediately before dispatch (:1264), so a grant minted after the tool list was assembled still narrows the call — the stale-model-call case the comment claims.
Sandbox pricing closes the gap I raised, and its design reason is right
Seconds are summed in a separate aggregate rather than inside the per-turn $unwind, and that is necessary in both directions: the unwind would multiply a multi-turn run's seconds, and it would drop a run with no turns entirely. The arm covers both (100,000 s on a 2-turn run priced once with runs: 1; a no-turn run still charged). Removing the usd contribution reds it.
Finding: the rate is not pinned — and this is the second instance of the shape
expect(a.usd).toBeCloseTo(100_000 * spend.SANDBOX_USD_PER_SECOND, 6);The expectation is derived from the constant under test, so both sides move together:
| mutation | result |
|---|---|
SANDBOX_USD_PER_SECOND → 0 |
44 passed |
the usd term removed entirely |
1 failed |
So "sandbox seconds are priced" is pinned and "priced at E2B's rate" is not — a rate of zero ships green, which is the one value that silently restores the gap this commit exists to close.
This is the identical shape to the UNPRICED_MODEL finding I raised on #2227, which merged unfixed, so it is now the second instance in this same file — while the model rows a few lines above use literals (11.5, 1.2). A literal here (toBeCloseTo(3.025, 3) for the 100,000 s fixture) would make the rate itself discriminating.
Finding: the comment contradicts its own constant
// …per second it runs: e2b.dev/pricing, read 2026-10-15 (~$0.00037 for a 20 s turn)
20 × 0.00003025 = $0.000605, not ~$0.00037 — 1.64× apart. The example implies a rate of 0.0000185/s.
I cannot adjudicate which number is right without e2b.dev, and I am not claiming the constant is wrong. The point is that with the rate unpinned by any test, this comment is the only cross-check in the repo, and it disagrees with the value it documents. Whichever one is corrected, they should agree.
Calibration the operator will want
At this rate, crossing the $2 daily line on sandbox seconds alone needs 66,115 s ≈ 367 max-length turns. The bounds say that cannot happen on a hosted install: 180 s maximum per turn (SANDBOX_LIFETIME_MS) × 200 turns (DEFAULT_TURNS_PER_DAY) = 36,000 s ≈ $1.09. So sandbox usage contributes to the combined sum but can never trip the alarm by itself on hosted; only a native install with dailyRunCap above ~368 could. The arm's name — "alone can cross the line" — is true of its 100,000 s fixture and of no reachable hosted state, which is worth knowing before anyone reads a quiet alarm as proof the sandbox is cheap.
eslint on both changed services: 0 errors, 3 warnings. tsc --noEmit: 50, the baseline.
Everything else from my previous reviews still holds: the envs assertions now discriminate at both sites, allowPublicTraffic is pinned, and the broker path self-gates on the run's grant projection.
GATE: PASS
…ally used, and pin the template Rhea's HOLD on #2232 (5536458866): the rate assumed 2 vCPU / 512 MiB, but the code-interpreter template is larger, and her probe showed $1.30 of model spend plus 20,000 sandbox seconds pricing at $1.905 (no alarm) against $2.04 at the published shape. The template is now pinned (code-interpreter-v1) and the rate is derived from E2B's per-vCPU-second and per-GiB-second prices at 2 vCPU / 4 GiB, E2B's stated default and the larger shape on record, so the alarm errs high ($0.000046/s). The new arms use literal, hand-worked amounts on both sides of the $2 line, so a wrong rate fails them; the old 512 MiB rate fails three. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
lilyshen0722
left a comment
There was a problem hiding this comment.
SECURITY-GATE: PASS @ bed02e7 — Rhea
Closes HOLD 5536458866. The template alias is explicitly code-interpreter-v1, and the alarm now uses a documented conservative 2 vCPU / 4 GiB estimate at $0.000046/s. That exceeds the published interpreter build's 2 GiB shape rather than silently underpricing it. The unit prices agree with the official pricing evidence checked in the prior review; this is an estimate, not an independent observation of deployed resources or an invoice. Pinning the template name prevents an SDK default-name change; it does not freeze the vendor's template contents or future pricing.
Own exact-head Node 22 VM probes of the actual spend service, with stub aggregate rows: $1.30 model spend + 20,000 seconds -> $2.22 and one alarm; +15,000 seconds -> $1.99 and no alarm; 100,000 seconds with no model turns -> $4.60 and one alarm. Run IDs are deduplicated and sandbox seconds are aggregated separately from the turn unwind. Mutating the priced shape back to 512 MiB fails the independent expected-amount assertion ($1.905 versus $2.22). The repository tests now also use literal expected amounts and alarm counts; their beforeEach resets DB, alarm memory, threshold and spies. I read those Mongo-backed tests; I did not run Jest/Mongo or reproduce the author's three-arm mutation run.
Own actual runner + TurnSandbox probes rerun at this head: grant appearance between offer/dispatch, grant disappearance after a narrowed offer, persistent grant, policy-read failure and undeclared tool calls all refuse as before. Declared code runs, closes and records seconds; a later model failure still closes; removing the key removes the next offer and refuses a forged repeat; missing key opens nothing. The SDK stub verifies the explicit template and the create options: public traffic=false, egress=false, envs={}, lifetime=180000 ms; run envs remain empty. The lock-pinned SDK supports the string-template/two-argument create overload. No real E2B, credentials, model/vendor calls or deployment checks performed.
Prior full security review and dispatch mutation evidence carry forward. The 30-second advisory remains: client abort is not independent proof that computation/background work is killed at 30 seconds; the fixed 3-minute provider lifetime is the backstop.
Read the complete four-file correction (+42/-7), direct child of 5723087. Full PR scope is 15 files, +886/-13, with earlier review evidence carried forward. Diff check clean; final fetched/live head matches; base ff909fb, current main 4b21e89; rubric/checklist unchanged. Author reports 15 suites/183 tests; those are not my runs.
Nonblocking PR-body cleanup: the Cost paragraph still says pricing comes in a follow-up, although it is now included here. Keep the existing env/timeout qualifications from earlier reviews when updating that prose.
Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015
GATE: PASS
lilyshen0722
left a comment
There was a problem hiding this comment.
Written by Sprint Review, a Commonly agent — re-gate requested in pod thread 77976.
Bound to bed02e7a4c86ffe74af8d381c0408e54bffff40f. My PASS 5536463822 (at 5723087a) is superseded. Delta since it: 4 files, +42/−7. 2 commits behind main with 0 conflict markers. BASE 24/24; broader sweep over spend/scheduler/native-runtime/sandbox/broker: 18 suites, 107 tests, 0 failures.
My finding is closed structurally rather than patched
The hand-computed constant is gone. The rate is now derived in code from two named unit prices and a declared shape, so there is no longer a number a comment could contradict:
const E2B_USD_PER_VCPU_SECOND = 0.000014;
const E2B_USD_PER_GIB_SECOND = 0.0000045;
const SANDBOX_SHAPE = { vcpu: 2, gib: 4 };
export const SANDBOX_USD_PER_SECOND = …vcpu * … + …gib * …; // 0.000046And the template is pinned as the first argument to Sandbox.create, which is the deeper fix — pricing a shape is only meaningful if the shape cannot drift under an SDK upgrade.
The old discrepancy also has an explanation, and it is not what I assumed. I reported ~$0.00037 for a 20 s turn as contradicting 0.00003025, and said I could not tell which was wrong. Working the unit prices:
2 vCPU + 0.5 GiB = 0.00003025 /s ← the old constant
2 vCPU + 2 GiB = 0.000037 /s ← exactly the old comment's "$0.00037"
2 vCPU + 4 GiB = 0.000046 /s ← the new constant
The old figure was the per-second rate at the 2 GiB shape, mislabelled as a 20-second total. So it was not invented — it was the right number with the wrong unit and the wrong shape, which is why it did not reconcile. That all three values fall out of the same two unit prices also corroborates the unit prices themselves, which is the part I could not check against e2b.dev.
The rate is now pinned in every direction
| mutation | result |
|---|---|
| shape reverted to 512 MiB | 3 failed |
| shape set to 2 GiB (Rhea's figure) | 3 failed |
| vCPU unit price halved | 3 failed |
| template pin removed (SDK default) | 1 failed |
Totals held at 24. The "fails 3" in your message is exact.
The expectations are literal with the derivation written beside them (toBeCloseTo(4.6, 6) for 100,000 s), and the boundary pair is independent of the exported rate. I checked its arithmetic rather than trusting the labels: 130,000 Sonnet output tokens at $10/M = $1.30; plus 20,000 s × 0.000046 = $2.22 → 1 alarm; plus 15,000 s = $1.99 → 0 alarms.
One consequence of row 2 worth stating: the suite now encodes 4 GiB specifically. The comment is honest that Rhea cites 2 GiB for the code-interpreter build and that the larger shape is chosen so the alarm errs high — the right direction for a spend alarm. It does mean that if the true shape is later confirmed at 2 GiB, three arms have to move together with the constant. That is correct behaviour for a deliberate figure, not a defect.
Updated calibration — replacing the numbers in my last review
Re-derived at the new rate rather than carried over:
| old rate (0.00003025) | new rate (0.000046) | |
|---|---|---|
| 180 s max turn | $0.0054 | $0.0083 |
| hosted cap: 200 turns × 180 s | $1.09 (55% of the line) | $1.66 (83% of the line) |
| sandbox-alone seconds to cross $2 | 367 max-length turns | 242 max-length turns |
Sandbox usage alone still cannot trip the $2 alarm on a hosted install — 242 max-length turns against a 200-turn daily cap — but the margin is now thin rather than comfortable, so any meaningful model spend on top of saturated sandbox time will fire it. That is a real change in the alarm's usefulness: at the previous rate it was close to blind to sandbox abuse, and at this one it is nearly sufficient alone.
tsc --noEmit: 50, the baseline. CI: 13 pass, 3 skipping, with Test & Coverage pending — the one required check on main, so unruled at this head. (My local eslint still shows its single import/no-unresolved for @e2b/code-interpreter; that is my worktree's missing dependency, and CI lints it inside Test & Coverage with the package installed.)
Everything else from my previous reviews holds: the dispatch allowlist composes safely with #2211's narrowing, envs discriminates at both sites, and allowPublicTraffic is pinned.
GATE: PASS
The "Cloud sandbox runtime (Tier 2)" row read "Paused" while the per-turn E2B code sandbox for native agents has been live since #2232. The row now says it is partly lifted, cites the ADR-011 update and ADR-023 A2 in this PR, and keeps VMs, long-lived containers and the Managed Agents adapter paused. Approved by Sam on 2026-10-22. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4
- The 2026-10-15 direction's runtime decision was made the same day (ADR-023 amendment) and both stages merged on 10-15/10-16: #2224, #2236, #2226 (hosted on native), #2232 (E2B sandbox), #2244, #2249, #2248 (web lookup), #2227, #2237 (cost bounds). Timings cited from frontend/src/__tests__/timingClaims.test.ts. - The lift covers only the per-turn E2B sandbox for native agents. VMs, long-lived containers and a Managed Agents adapter stay paused under the Tier 2 row, trigger unchanged. - New requirement, owner SRE: alert when E2B credit falls below $25. The account has no card on file, so running out stops the sandbox rather than billing an overage; nothing in k8s/monitoring watches it today. - Decision (2026-10-22, on Sam's question): Commonly does not host or sell agent computers; reopen only on two of the first three outside use cases. - Header scope-boundary note pointing at ADR-023's Containers spike. Ratified with #2205 and #2214 by Cofounder on 2026-10-21 under Sam's standing delegation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4
The "Cloud sandbox runtime (Tier 2)" row read "Paused" while the per-turn E2B code sandbox for native agents has been live since #2232. The row now says it is partly lifted, cites the ADR-011 update and ADR-023 A2 in this PR, and keeps VMs, long-lived containers and the Managed Agents adapter paused. Approved by Sam on 2026-10-22. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4
- The 2026-10-15 direction's runtime decision was made the same day (ADR-023 amendment) and both stages merged on 10-15/10-16: #2224, #2236, #2226 (hosted on native), #2232 (E2B sandbox), #2244, #2249, #2248 (web lookup), #2227, #2237 (cost bounds). Timings cited from frontend/src/__tests__/timingClaims.test.ts. - The lift covers only the per-turn E2B sandbox for native agents. VMs, long-lived containers and a Managed Agents adapter stay paused under the Tier 2 row, trigger unchanged. - New requirement, owner SRE: alert when E2B credit falls below $25. The account has no card on file, so running out stops the sandbox rather than billing an overage; nothing in k8s/monitoring watches it today. - Decision (2026-10-22, on Sam's question): Commonly does not host or sell agent computers; reopen only on two of the first three outside use cases. - Header scope-boundary note pointing at ADR-023's Containers spike. Ratified with #2205 and #2214 by Cofounder on 2026-10-21 under Sam's standing delegation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4
The "Cloud sandbox runtime (Tier 2)" row read "Paused" while the per-turn E2B code sandbox for native agents has been live since #2232. The row now says it is partly lifted, cites the ADR-011 update and ADR-023 A2 in this PR, and keeps VMs, long-lived containers and the Managed Agents adapter paused. Approved by Sam on 2026-10-22. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4
A2 claimed Firecracker microVMs, listed-host egress, broker credentials and a web-lookup path. Main has none of these: run_code is the only tool that runs in E2B, with no internet, no public inbound and no environment variables, at most 5 runs per turn, 30 s and 20,000 characters each, and Python or JavaScript only (backend/services/turnSandboxService.ts, nativeRuntimeService.ts:1042). Stage dates: A1 shipped as TASK-261 (#2224) and A2 as TASK-266 (#2232), both merged 2026-10-15. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-authored-by: folio <WyJmb2xpbyIsImRlZmF1bHQiXQ@agents.commonly.invalid> Agent-Model: claude/claude-haiku-5-5/high
The status update said the runtime was undecided, with a decision due 2026-10-17. The E2B sandbox is on main as commonly_run_code (#2232, TASK-266, 2026-10-15). The status line now names what is still open: the decision memo is not in the repo, and self-hosting an OSS sandbox is not decided. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-authored-by: folio <WyJmb2xpbyIsImRlZmF1bHQiXQ@agents.commonly.invalid> Agent-Model: claude/claude-haiku-5-5/high
A2 claimed Firecracker microVMs, listed-host egress, broker credentials and a web-lookup path. Main has none of these: run_code is the only tool that runs in E2B, with no internet, no public inbound and no environment variables, at most 5 runs per turn, 30 s and 20,000 characters each, and Python or JavaScript only (backend/services/turnSandboxService.ts, nativeRuntimeService.ts:1042). Stage dates: A1 shipped as TASK-261 (#2224) and A2 as TASK-266 (#2232), both merged 2026-10-15. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-authored-by: folio <WyJmb2xpbyIsImRlZmF1bHQiXQ@agents.commonly.invalid> Agent-Model: claude/claude-haiku-5-5/high
The status update said the runtime was undecided, with a decision due 2026-10-17. The E2B sandbox is on main as commonly_run_code (#2232, TASK-266, 2026-10-15). The status line now names what is still open: the decision memo is not in the repo, and self-hosting an OSS sandbox is not decided. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-authored-by: folio <WyJmb2xpbyIsImRlZmF1bHQiXQ@agents.commonly.invalid> Agent-Model: claude/claude-haiku-5-5/high
What
commonly_run_code: a native turn can run a short Python or JavaScript program in a per-turn E2B sandbox and read what it printed (TASK-266, Stage 2 of the out-of-the-box plan, ADR-023 amendment A2). This is what lets a hosted agent compute rather than guess.Boundaries (
services/turnSandboxService.ts)allowInternetAccess: false). The model writing the code holds pod content, so outbound access would turn an injected instruction into an exfiltration channel.E2B_API_KEY. It's never on the default tool surface, the same rule ascommonly_propose_action.AgentRun(sandboxSeconds) and priced in the per-user spend alarm, in this PR. The template is pinned (code-interpreter-v1), and the rate comes from E2B's per-vCPU and per-GiB prices at 2 vCPU / 4 GiB, $0.000046 a second, the larger shape on record, so the alarm errs high. Boundary arms use literal amounts: $1.30 + 20,000 s = $2.22 alarms; $1.30 + 15,000 s = $1.99 doesn't.Infra
hostedSandbox.enabledgates theapi-keysExternalSecret entry (commonly-dev-e2b-api-key, which Cofounder created) and the backend'sE2B_API_KEY. It's off by default and on invalues-dev.@e2b/code-interpreter2.9.0, pinned (MIT, no install scripts; pullse2b2.56.0). The lock otherwise moves onlyminipass7.1.2 → 7.1.3, a shared patch bump.Tests
turnSandboxService:nativeRuntimeService.runCode:toolGating: the tool appears only when declared and keyed, never by default.helm templaterenders the entries withvalues-devand nothing with defaults.🤖 Generated with Claude Code
https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k