Skip to content

fix(e2b): contain sandbox downloads and close the unscanned-upload leak (#967) - #1100

Merged
frankbria merged 7 commits into
mainfrom
fix/967-e2b-trust-boundary
Aug 8, 2026
Merged

fix(e2b): contain sandbox downloads and close the unscanned-upload leak (#967)#1100
frankbria merged 7 commits into
mainfrom
fix/967-e2b-trust-boundary

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Closes #967.

The E2B sandbox is a trust boundary in both directions and neither was enforced. #966 gated the engine as experimental, but anyone who sets CODEFRAME_ENABLE_CLOUD_ENGINE=1 was exposed to both of these — which is exactly why the issue was carved out of #966's parked list.

Threat model

The coding agent has arbitrary command execution inside the sandbox before _download_changed_files runs. It can shadow the git binary. So the output of git status --porcelain is attacker-controlled input, and nothing git normally guarantees can be relied on.

Inbound: paths escaped the workspace

local = workspace_path / rel_path had no containment check, followed by mkdir(parents=True, exist_ok=True) and a write. Two ways out, both confirmed:

Path('/home/u/ws') / '../../evil'                   -> /home/u/ws/../../evil
Path('/home/u/ws') / '/home/u/.ssh/authorized_keys' -> /home/u/.ssh/authorized_keys   # pathlib drops the left side

Reproduced end to end against a real filesystem, then re-run after the fix:

Rejected sandbox path outside the workspace: '../fakehome/.ssh/authorized_keys'
Rejected sandbox path outside the workspace: '/tmp/tmp3s49p0v_/absolute-pwned'
Rejected sandbox path outside the workspace: 'a/../../../../etc/cron.d/pwn'
Rejected sandbox path outside the workspace: '../fakehome/.ssh/quoted_pwn'
downloaded: ['src/legit.py'] count: 1
  event: Downloaded 1 changed file(s)
  event: Rejected 4 path(s) outside the workspace — the sandbox tried to write somewhere it may not

authorized_keys written?  False      legit file landed?  True
absolute-pwned written?   False      files outside ws:   [] (only 'legit.py', inside)

_safe_local_path resolves both sides and requires the candidate under the root:

  • resolving both closes the symlink route (a symlink inside the tree pointing outward is rejected) without falsely rejecting a workspace reached through a symlink — both cases are tested, and the second is the one a naive startswith check gets wrong
  • absolute paths are rejected outright, including ones that resolve inside the workspace: porcelain paths are always relative, and the remote read would be built as /workspace//abs/path. This is the case that distinguishes the absolute check from the containment check — without it the check is untested redundancy (see below)
  • the check runs before files.read and before any mkdir, so a rejected path costs no round trip and creates no directory
  • rejections are logger.warning-ed, counted, and emitted — never silently dropped (AC2)

Parsing: -z, verbatim

--porcelain -z emits each path as raw bytes. Verified against real git rather than from memory:

--porcelain      ?? "\"quoted\".py"  ?? "caf\303\251.txt"  ?? "tab\tname.txt"
--porcelain -z   ?? "quoted".py\0    ?? café.txt\0         ?? tab<TAB>name.txt\0

So -z removes both problems at once: no C-quoting to decode, and no " -> " rename separator to be ambiguous with a filename containing that string (which used to split such a path in half). Paths are then taken verbatim — a file genuinely named "a.py" keeps its quotes instead of being rewritten to a.py over the top of a different file.

Containment, not parsing, is the security boundary. That is what lets the parser stay simple: hostile input does not need to be understood, only kept inside the tree.

Outbound: build artifacts shipped unscanned

The scanner and the uploader kept separate exclusion sets, and the scanner's was the wider one — it skipped dist/build/.tox/.eggs while the uploader happily shipped them. A .env or key baked into a build artifact reached the third-party sandbox unscanned, defeating the adapter's abort-on-secrets contract.

One EXCLUDED_DIRS now, exported from credential_scanner and imported by the uploader, with build output removed from it. A test asserts they are literally the same object, so they cannot drift apart again.

secret under dist/ detected?  True -> ['dist/.env']
one shared constant?          True
build output now scanned?     True
junk still skipped?           True   (.git, node_modules, __pycache__, .venv)

End to end: a secret under dist/ now makes adapter.run() return status='failed' with credential_scan_blocked == 1 and zero sandboxes created.

Verification

  • 29 new tests in tests/adapters/test_e2b_trust_boundary_967.py — traversal, absolute, deep .., symlink-out, symlink-in (false-reject guard), no-mkdir-outside, warned/counted/not-read, verbatim quoted + UTF-8 + tab names, rename handling, the " -> " filename, one-shared-constant, dist/ abort.
  • Mutation-checked, every guard: neutering containment fails 10; removing the absolute check fails 1 (the distinguishing case — it survived until I found it, see below); stripping quotes in the parser fails 1.
  • Full gate green: pytest tests/ --ignore=tests/e2e -m "not lifecycle"6117 passed, 49 skipped, 0 failed (16m04s), run under env -u DATABASE_PATH -u CODEFRAME_AUTH_REQUIRED -u ANTHROPIC_API_KEY -u AUTH_SECRET -u OPENAI_API_KEY to mirror CI. ruff check clean.
  • Third-party review: codex review --base main, twice. The first pass caught a real bug I introduced — see below. The second is clean.

The review caught a bug worth naming

I switched to -z and wrote a C-unquoter for git's \303\251 octal escapes in the same change, both from memory of the format. codex [P2] pointed out they contradict: -z is precisely what removes the quoting, so unquoting its output would rewrite a legitimately-quoted filename. I verified with real git (output above) and deleted the unquoter rather than keeping it as defense in depth — it was not dead code, it was a correctness bug added while hardening a security boundary.

Known limitations

  • Build output is now scanned, which is slower and can false-positive. A minified bundle under dist/ can trip the api_key = "..." content pattern and block a run. That is the secure default and what AC5 requires — a directory the scanner will not read must not be a directory we upload — but it is a real behaviour change for anyone with a large built tree.
  • A rejected path aborts that file, not the run. The run still reports completed with the safe files downloaded and the reject count emitted. Failing the whole run on a rejection is arguably better, but it belongs with the #966 PARKED item "any file that fails to sync back makes the run report non-completed", not here.
  • The other [P2.16] Gate --engine cloud / --isolation cloud behind an experimental flag and remove it from the advertised surface #966 PARKED E2B defects (codeframe-ai package name, CommandExitException, sync-back semantics, upload batching, autospec mocks) are untouched by design.

…ak (#967)

The E2B sandbox is a trust boundary in both directions, and neither was
enforced. The coding agent has arbitrary command execution in the sandbox
before we read `git status --porcelain`, so it can shadow the git binary —
every path in that output is attacker-controlled input.

Inbound: `local = workspace_path / rel_path` had no containment check, then
`mkdir(parents=True)` and a write. Two ways out, both confirmed working:
`../` segments, and an absolute path — `Path("/ws") / "/etc/passwd"` is
`/etc/passwd`, because pathlib discards the left side. The issue's worked
example (~/.ssh/authorized_keys) reproduced.

- `_safe_local_path` resolves both sides and requires the candidate to be
  under the workspace root. Resolving both closes the symlink route (a
  symlink inside the tree pointing out is rejected) without falsely
  rejecting a workspace reached *through* a symlink.
- Absolute paths are rejected outright, including ones that happen to
  resolve inside the workspace: porcelain paths are always relative, and
  the remote read would be built as `/workspace//abs/path` anyway.
- Rejections are logged as warnings, counted, and emitted — never silently
  dropped. The check runs before the read, so a rejected path costs no
  round trip and creates no directory.

Parsing uses `--porcelain -z`, which emits each path as raw bytes. Verified
against real git: `--porcelain` alone renders `café.txt` as
`"caf\303\251.txt"` — quotes and all — and splits renames on `" -> "`,
which a filename containing that string would break. `-z` has neither
problem, and paths are taken verbatim: a file genuinely named `"a.py"`
keeps its quotes rather than being rewritten to `a.py` over the top of a
different file. Containment, not parsing, is the security boundary.

Outbound: the scanner and the uploader kept separate exclusion sets, and
the scanner's was the *wider* one — it skipped dist/build/.tox/.eggs while
the uploader shipped them, so a .env baked into a build artifact reached
the third-party sandbox unscanned, defeating the abort-on-secrets contract.
There is now one `EXCLUDED_DIRS`, and build output is not in it.

Existing porcelain fixtures updated to the -z wire format.
@frankbria

Copy link
Copy Markdown
Owner Author

Third-party review — codex review --base main

Pass 1 — [P2] Stop unquoting -z porcelain paths (adapter.py)

When the sandbox creates a file whose actual name starts and ends with double quotes, git status --porcelain -z emits that path verbatim rather than C-quoted, but this line treats it as quoted syntax and rewrites it (a changed file named "a.py" is downloaded as a.py, potentially clobbering a different file).

Confirmed, and it was my bug. I had switched to -z and written a C-unquoter in the same change, both from recall of the porcelain format. One git status in a throwaway repo settled it:

--porcelain      ?? "\\"quoted\\".py"   ?? "caf\\303\\251.txt"
--porcelain -z   ?? "quoted".py\\0       ?? café.txt\\0

-z is the fix for quoting, so the unquoter was not redundant-but-harmless — under -z it would rewrite a legitimately-quoted filename over the top of a different file. Deleted it rather than keeping it "for defense in depth", and replaced the decode tests with verbatim ones ("a.py" keeps its quotes, café.txt and tab\\tname.txt arrive intact). Mutation-checked: re-adding the quote-strip fails test_a_literally_quoted_filename_keeps_its_quotes.

This is a good argument for the design the PR settles on: containment, not parsing, is the security boundary. Hostile input does not need to be understood, only kept inside the tree — which is what let the parser get simpler instead of more clever.

Pass 2 (final diff) — clean:

No actionable regressions were found in the diff.

No Critical or Major findings outstanding.


One more thing the mutation pass turned up

Deleting the absolute-path guard broke no test — every absolute path pointing outside is already caught by containment. Rather than call it redundant or wave at "defense in depth", I looked for an input where the two differ: an absolute path that resolves inside the workspace passes containment but should still be rejected (porcelain paths are always relative, and the remote read would be /workspace//abs/path). The guard was real; my tests were incomplete. Added test_an_absolute_path_inside_the_workspace_is_still_rejected, which now fails when the guard is removed.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 7m 25s —— View job


GLM precision review — E2B trust boundary (#967)

  • Gather PR diff + existing comments
  • Read changed files and surrounding context (adapter.py, credential_scanner.py, all call sites)
  • Verify containment, -z parsing, and shared EXCLUDED_DIRS logic
  • Post inline finding + summary

Done. The inbound containment (_safe_local_path), the -z verbatim parsing, and the outbound shared-EXCLUDED_DIRS fix are all sound — no critical/major defects. One minor finding posted inline: _parse_porcelain's rejected counter is never incremented, so malformed porcelain records are silently dropped rather than counted/warned (observability gap only, not a security or correctness bug).

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

This is a solid, well-scoped security fix. The threat model (shadowed git binary → attacker-controlled porcelain output) is clearly stated and the containment logic (_safe_local_path) correctly handles the two escape vectors called out in the issue: .. traversal and pathlib's absolute-path-discards-the-left-side gotcha. Resolving both root and candidate before the containment check is the right way to close the symlink-out route without breaking a workspace reached through a symlink, and the test suite (test_e2b_trust_boundary_967.py) directly exercises both directions of that distinction rather than just asserting the happy path. Switching to --porcelain -z and dropping the C-unquoter (per the codex catch, which is correctly explained in the PR body) is the right simplification — containment rather than parsing as the actual security boundary is a good design call. The shared EXCLUDED_DIRS constant with a same-object test is a good way to prevent the scanner/uploader from drifting apart again.

One correctness nit, not security-relevant:

_parse_porcelain's rejected return value is always 0 (codeframe/adapters/e2b/adapter.py:391-411). It's initialized to 0 and never incremented anywhere in the function — the only continue path (malformed/too-short entry, line 398-399) skips straight past it. The name gets shadowed and does get incremented correctly later in _download_changed_files (containment rejections), so the final rejected count emitted to the user is correct — but that's because the caller reuses the variable name for a different purpose, not because _parse_porcelain contributes anything. The docstring's "count rejected as unparseable" return value is therefore dead code, and a genuinely malformed porcelain entry (e.g. from a hostile shadowed git) is silently dropped without a log line or a count — a small inconsistency with the PR's own "never silently dropped" framing for AC2. Not exploitable (a dropped entry just means nothing gets written, which is safe-by-default), but worth either wiring it into the reported count or dropping the return value/docstring claim if it's not meant to be used.

No other issues found — parsing of renames, quoted/UTF-8/tab filenames, and the dist/build scanning change all look correct and are backed by targeted tests. Test coverage is thorough (containment, false-reject guards, rejection visibility, parsing edge cases, and the shared-constant/end-to-end secret-scan path).

Comment thread codeframe/adapters/e2b/adapter.py Outdated
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

GLM precision review — #967

severity file:line finding
minor codeframe/adapters/e2b/adapter.py:399 _parse_porcelain rejected counter never incremented; malformed porcelain records are silently dropped, not counted/warned (contradicts docstring + AC2). Observability gap only — not a security or correctness bug.

In scope and verified clean:

  • Inbound containment (_safe_local_path, adapter.py:39): both-side resolve(), outright absolute rejection (incl. paths that resolve inside the workspace), strict-descendant check, and check-before-mkdir/read. No .., absolute, or symlink-out escape; a workspace reached through a symlink is not falsely rejected. TOCTOU is not exploitable since the adapter only ever writes regular files locally.
  • -z parsing (_parse_porcelain, adapter.py:377): correct XY PATH shape check, correct rename/copy new\0old consumption, paths taken verbatim (no C-unquoting to clobber a quoted name). No false-reject of valid single-char or short names (min valid entry is 4 chars), no mis-consumption of a following file record.
  • Outbound leak (credential_scanner.py:26 + _upload_workspace): one shared EXCLUDED_DIRS, build output (dist/build/.tox/.eggs) now scanned, scan→abort happens before Sandbox.create, so a secret under dist/ yields status='failed', credential_scan_blocked=1, zero sandboxes. Scanner and uploader iterate the identical file set.

No critical or major defects found.

Both reviewers caught the same leftover: `_parse_porcelain`'s `rejected`
counter was never incremented. Deleting the C-unquoter removed the only
thing that incremented it, and I left the variable and its docstring
claim behind — so a malformed record was silently dropped, contradicting
this PR's own "never silently dropped" framing for AC2.

Real git cannot emit a record that isn't "XY PATH", so a malformed one
means the sandbox's git is not git — precisely the case worth surfacing.
Now warned, counted, and folded into the count emitted to the user
alongside containment rejections.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 3m 39s —— View job


GLM precision review — E2B trust boundary (#967, synchronize)

  • Gather full PR diff + latest commit (ff01dbf) changes
  • Check existing PR comments to avoid repeating findings
  • Read changed files in context (adapter.py, credential_scanner.py, callers)
  • Verify the new rejected-counter fix and porcelain parsing logic
  • Post summary

Reviewed the synchronize commit ff01dbf ("count and warn on malformed porcelain records"), which is the fix for the single previously-reported finding (the _parse_porcelain rejected counter).

✅ GLM review: no defects found.

The fix is minimal and purely additive — rejected += 1 + logger.warning(...) inside the existing malformed-record branch — and I verified it against the real code rather than the diff alone:

  • Counter is now wired end-to-end. rejected is declared at adapter.py:391, incremented in the malformed branch (:401), returned from _parse_porcelain (:417), and folded into the user-facing emit in _download_changed_files alongside containment rejections. The previously-dead docstring contract ("count rejected as unparseable") now holds; test_parse_rejects_are_reported_separately_from_containment pins the return value so it cannot go stale again.
  • No IndexError. if len(entry) < 4 or entry[2] != " " short-circuits, so entry[2] is only read when len >= 4.
  • No regression for valid input. Real porcelain is always XY PATH, so a well-formed record never enters the malformed branch — the new path only fires for hostile/shadowed-git output.
  • The len < 4 guard also prevents parser desync. A truncated rename-like record (e.g. "R " with no new path) would pass the entry[2] == " " check but then index += 1 and swallow the next legitimate entry as a phantom old name; the length check rejects it first, before the rename-consume step.
  • Edge cases degrade gracefully (all-NUL stdout, empty changed list, every record malformed): no crash, no partial writes, accurate reject count.

The broader inbound containment (_safe_local_path), -z verbatim parsing, and outbound shared-EXCLUDED_DIRS were confirmed sound by the two earlier reviews and re-traced here — nothing in this commit touches them. The one messaging nuance (parse-rejects rolled into the "outside the workspace" count) was already raised by the prior GLM pass and is a documented, deliberate fold — not a defect, and not repeated.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

This is a well-executed fix for a real trust-boundary gap, and the PR body/tests do the hard part of proving it (real-git verification of -z semantics, mutation-checked guards, end-to-end secret-under-dist/ abort). I focused on what has not already been covered by the codex/GLM passes and the earlier claude review in this thread.

Already resolved, confirming it is fixed: the earlier review round (both claude and GLM) flagged that _parse_porcelain's rejected counter was initialized but never incremented, so malformed records were silently dropped instead of counted/warned - contradicting the PR's own AC2 framing. The second commit (ff01dbf) fixes this correctly: adapter.py:398-403 now increments, logs, and folds malformed-record rejections into the same count as containment rejections. Confirmed in the current diff and covered by test_a_malformed_record_is_counted_and_warned_not_dropped.

New finding - a crafted rename record can silently swallow the next legitimate entry (adapter.py:406-415).

The rename/copy consumption logic trusts the entry's own self-reported XY status to decide whether to skip the next NUL-delimited field:

if status[0] in ("R", "C") or status[1] in ("R", "C"):
    index += 1

This is correct for genuine git output (verified: -z renames really are new\0old\0). But the entire premise of this PR is that the sandbox's git may be shadowed and its output is therefore adversarial. A hostile "git" can emit a fake rename record immediately followed by an unrelated, real, well-formed entry:

R  fake.py\0M  real_change.py\0

Tracing _parse_porcelain: the parser accepts "fake.py" as a rename target, then unconditionally consumes " M real_change.py" as its "old path" - so real_change.py never reaches changed, and it is not counted in rejected or logged anywhere. It just disappears. That is a silent drop under the exact adversarial-input threat model this PR sets out to defend, and it directly contradicts the "never silently dropped" invariant the PR establishes for AC2 (which this PR itself just went back and fixed for the malformed-record case).

Impact is limited - nothing escapes the workspace, a swallowed entry just means a legitimate change does not get downloaded - so I would not block on it, but it seems worth either a follow-up test + fix (e.g. validate that the consumed "old path" field does not itself look like a well-formed "XY " record before trusting it as a pair, or at minimum count/log a rename consumption as a rejection when the input is otherwise flagged suspicious) or a documented known-limitation alongside the ones already listed in the PR body.

Everything else checks out:

  • _safe_local_path: absolute rejection (both PurePosixPath and Path, sensibly covering a non-POSIX local host even though the sandbox is Linux), resolve-both-sides containment via Path.parents (not a naive prefix/startswith check, so /home/u/ws vs /home/u/ws-evil cannot collide), check-before-mkdir/read. Traced through the "absolute path that resolves inside the workspace" case called out in the PR description - it is real, not redundant with the containment check.
  • Shared EXCLUDED_DIRS with the same-object test is a good structural guard against the scanner/uploader silently drifting apart again.
  • Test coverage is thorough on the dimensions it targets (containment, false-reject guards on symlinked workspaces, rejection visibility, quoted/UTF-8/tab filenames, the dist/ end-to-end abort).

No other security or correctness issues found.

…#967)

claude-review: under this PR's own threat model, a shadowed git can emit
`R  fake.py\0 M real_change.py\0` — the parser accepted the rename header
and unconditionally ate the following well-formed entry as its "old path",
so a real change disappeared with no log and no count. Not an escape
(nothing is written outside the workspace, and an attacker who controls
the output could simply omit the entry instead), but it is a silent drop
under the invariant this PR just established for AC2.

Honest git always follows a rename header with a bare path, so a field
that itself looks like `XY PATH` was never half of a rename pair. The
consumption is now conditional on that and warns when it declines. The
honest case is pinned separately so the guard cannot start treating every
rename's old name as a download.
@frankbria

Copy link
Copy Markdown
Owner Author

Fixed in the latest push — verified first, and it is a genuine silent drop under this PR's own threat model.

R fake.py\0 M real_change.py\0 did exactly what the review describes: the rename header was accepted and the following well-formed entry eaten as its "old path", so real_change.py vanished with no log line and no count.

Agreed on the impact framing — nothing escapes the workspace, and an attacker who controls git's output could simply omit the entry rather than go to the trouble of faking a rename to hide it, so no capability is gained. What made it worth fixing rather than documenting is the silent part: this PR had just gone back and fixed the same class of thing for malformed records, and leaving a second silent-drop path would make that invariant decorative.

The fix uses the property that makes the attack detectable: honest git always follows a rename header with a bare path, so a field that itself looks like XY PATH was never half of a rename pair. Consumption is now conditional on that, and warns when it declines.

Three tests, and the honest case is pinned separately (test_an_honest_rename_still_consumes_its_old_path) so the guard cannot over-correct into treating every rename's old name as a download — that regression is the obvious way to get this wrong. Mutation-checked: reverting the condition to unconditional index += 1 fails two of the three.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 4m 54s —— View job


GLM precision review — E2B trust boundary (#967, synchronize)

  • Gather PR diff + latest commit (1ee6c22) changes
  • Check existing PR comments to avoid repeating findings
  • Read changed files in context (adapter.py, credential_scanner.py, callers)
  • Verify the faked-rename-header fix and surrounding parsing logic
  • Post summary

Reviewed the synchronize commit 1ee6c22 ("a faked rename header can no longer swallow the next record"), which is the fix for the single finding from the prior claude review round (the R fake.py\0 M real_change.py\0 silent-drop).

✅ GLM review: no defects found.

The fix is sound, and its correctness rests on a structural guarantee of the logic rather than on trusting the shape check as a parser. Tracing _parse_porcelain against the real code (not just the diff):

  • No legitimate record can ever be silently consumed. A real porcelain entry is always XY PATH (two status chars + space at index 2), so it always matches _looks_like_record. The consume step (index += 1) fires only when the next entry does not match that shape. Therefore a genuine change record is never eaten as someone's old-path — which is exactly what closes the R fake.py\0 M real_change.py\0 attack ( M real_change.py matches the shape → not consumed → preserved), and why the AC2 "never silently dropped" invariant holds for all honest records. The honest-rename case (R new.py\0old.py) is still consumed correctly (old.py doesn't match the shape) — pinned by test_an_honest_rename_still_consumes_its_old_path.
  • No crash paths. _looks_like_record(entries[index]) is only evaluated after the index < len(entries) guard, so the conditional consume can't read past the end. The malformed-record branch short-circuits (len(entry) >= 4) before touching entry[2]/entry[3:].
  • The one residual edge degrades harmlessly. An honest rename whose old path coincidentally matches the shape (two chars + a space at index 2 — e.g. a tracked file My Notes.txt renamed away) is no longer consumed and is instead parsed as its own record, producing a phantom download attempt that fails in the except (no such path in the sandbox) and a warning. No real entry is dropped, nothing escapes the workspace, no crash. This is the deliberate "containment, not parsing, is the boundary" tradeoff the PR documents — not a defect.

The previously-reviewed areas are untouched by this commit and stay sound: inbound containment (_safe_local_path — both-side resolve(), absolute rejection incl. paths resolving inside the workspace, strict-descendant check, check-before-mkdir/read), -z verbatim parsing (renames, quoted/UTF-8/tab names), and the shared EXCLUDED_DIRS with build output now scanned (scan→abort before Sandbox.create).

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

This PR is now heavily reviewed (codex x2, GLM x3, claude x2 in this thread), and each round has caught and fixed a real issue: the -z/unquoter contradiction, the dead rejected counter, and most recently the faked-rename-header record-swallowing gap. I re-traced the current HEAD (1ee6c222) rather than re-litigating what's already confirmed fixed above.

Confirming the latest fix is correct. I traced _parse_porcelain by hand against the adversarial case from the last review round ("R fake.py\0 M real_change.py\0"): entries[index] is checked with _looks_like_record before being consumed as the rename's old path, so a well-formed trailing record is no longer swallowed — it warns and falls through to be parsed on its own in the next loop iteration. The honest-rename case ("R new.py\0old.py\0 M other.py\0") still consumes old.py correctly since it doesn't match the XY shape. Matches what the tests assert.

One new edge case, not security-relevant, low severity. The _looks_like_record heuristic (len(entry) >= 4 and entry[2] == " ") is used both to reject malformed top-level records and to decide whether the entry following a rename header is a genuine bare old-path vs. a real next record. That heuristic can misfire on a perfectly honest rename whose old filename's first two characters happen to be followed by a space — e.g. renaming "v1 notes.txt" to final.py produces the porcelain pair "R final.py\0v1 notes.txt\0", and "v1 notes.txt" itself satisfies _looks_like_record (entry[2] == " "). So the parser declines to consume it as the old path (logs "not followed by an old path"), then re-parses it on the next iteration as its own bogus record with status "v1" and path "notes.txt" — a path that doesn't exist in the sandbox, so it just fails to download with a warning.

Practical impact is small: the renamed file's new name is still captured correctly from the rename entry itself, so no data is lost — it's a spurious warning + a harmless failed-download attempt for a wrong path, not a silent drop. Given how deliberately this PR pins every other parsing edge case (quotes, UTF-8, tabs, the " -> " arrow, the adversarial rename), this one seems worth at least a # known limitation note next to the heuristic, or a test pinning the current (harmless) degraded behavior, so it doesn't read as an oversight later.

Everything else_safe_local_path's both-sides-resolved containment, the absolute-path rejection (including the resolves-inside-workspace case), check-before-mkdir/read ordering, and the shared EXCLUDED_DIRS constant with the same-object test — checks out on inspection and is exactly what the prior review rounds already validated. No new findings there.

claude-review edge case: `_looks_like_record` tested only "space at index
2", so an honest rename whose OLD name happens to fit that shape — e.g.
`v1 notes.txt` — was refused as the rename's old path and then re-parsed
as a record with status `v1`, producing a spurious warning and a failed
download of `notes.txt`. No data loss (the new name was still captured),
but it read as an oversight next to how deliberately every other parsing
edge case here is pinned.

Real XY status characters come from a small alphabet, which disambiguates
the two cases outright rather than documenting the collision. Checking it
also tightens the top-level malformed-record test: `v1 notes.txt` standing
alone is now correctly rejected instead of parsed as status `v1`.

The adversarial case is unaffected — ` M real_change.py` still reads as a
record, so a faked rename header still cannot swallow it.
@frankbria

Copy link
Copy Markdown
Owner Author

Fixed in dcc86f1 rather than documented — the collision turned out to be avoidable.

Confirmed the misfire exactly as described: _looks_like_record tested only "space at index 2", so R final.py\0v1 notes.txt\0 refused v1 notes.txt as the old path and re-parsed it as a record with status v1, warning and then failing to download notes.txt.

The disambiguator is that real XY status characters come from a small alphabet ( MADRCUT?!), while v1 does not. Checking membership as well as shape separates an honest old-path from a real record outright, so there is no degraded behaviour left to pin. It also tightens the top-level check: v1 notes.txt standing alone is now correctly counted as malformed instead of parsed as status v1.

The adversarial case is unaffected — M real_change.py still reads as a record, so a faked rename header still cannot swallow it. Both are pinned:

  • test_an_old_path_that_looks_shaped_like_a_record_is_still_consumed — the honest v1 notes.txt rename, asserting rejected == 0
  • test_real_status_pairs_are_recognised — parametrized over M, M , ??, A , D, R , !!, UU so tightening the alphabet cannot quietly start rejecting valid records
  • test_a_faked_rename_cannot_swallow_the_next_real_record — unchanged and still green

Mutation-checked: dropping the alphabet condition fails three tests.

Thanks for the four rounds on this one — every pass found something real (the -z/unquoter contradiction, the dead counter, the record-swallowing, and now this), and the parser is meaningfully better for it.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 4m 39s —— View job


GLM precision review — E2B trust boundary (#967, synchronize)

  • Gather full PR diff + latest commit (dcc86f1) changes
  • Check existing PR comments to avoid repeating findings
  • Read changed files in context (adapter.py, credential_scanner.py, callers)
  • Verify the status-alphabet fix and surrounding parsing logic
  • Post summary

Reviewed the synchronize commit dcc86f1 ("match the porcelain status alphabet, not just the shape"), the fix for the last finding from the prior claude review round (the v1 notes.txt honest-rename misfire).

✅ GLM review: no defects found.

The fix is correct, and — importantly — complete on its stated axis. Tracing _parse_porcelain / _looks_like_record against the real code (not just the diff):

  • The status alphabet is exact and exhaustive. _PORCELAIN_STATUS_CHARS = frozenset(" MADRCUT?!") is precisely the set of characters git porcelain v1 can emit in the XY columns: index/worktree ' ', M, A, D, R, C, U, T (type-change), plus ?/! for untracked/ignored. There is no porcelain status character outside this set, so the tightened _looks_like_record cannot falsely reject any real record — which is the regression the commit had to avoid. Pinned by test_real_status_pairs_are_recognised across M/M /??/A / D/R /!!/UU, and T-bearing pairs (T , TM) would pass too.
  • The adversarial defense is intact. R fake.py\0 M real_change.py\0 → the trailing M real_change.py matches both the shape and the alphabet, so the faked rename header still cannot consume it; real_change.py is preserved (pinned, unchanged).
  • The honest-rename path is intact. R new.py\0old.py\0old.py fails the shape check (entry[2] != ' '), so it is consumed as the old path. Pinned by test_an_honest_rename_still_consumes_its_old_path.
  • The v1 notes.txt misfire is fixed. 'v' ∉ alphabet → v1 notes.txt no longer matches _looks_like_record, so it is consumed as the rename's old path and is no longer re-parsed as a bogus v1 record. Pinned by test_an_old_path_that_looks_shaped_like_a_record_is_still_consumed and test_a_non_status_prefix_is_not_a_record. The tightening also correctly promotes a standalone v1 notes.txt from "parsed as status v1" to malformed+counted.
  • No crash paths. entries[index] in the rename-consume branch is only read after the index < len(entries) guard; the malformed branch short-circuits on len(entry) >= 4 before touching entry[2]/entry[3:]. The else clause (no old path to consume) only logs and falls through — paths.append(raw) still records the rename's new name, so nothing is dropped.

One residual edge, deliberately not flagged. A rename whose old filename happens to start with two status-alphabet characters and a space at index 2 (e.g. a file literally named MA notes.txt or D notes.txt renamed away) still matches _looks_like_record, so it is declined as an old path and re-parsed as a phantom record → a spurious warning plus one failed download of a non-existent path. This is the same harmlessly-degrading "containment, not parsing, is the boundary" tradeoff the prior GLM pass explicitly characterized as not a defect: the new name is still captured, nothing escapes the workspace, no crash, no data loss. The alphabet check narrows it (it now requires both leading chars to be in-alphabet, not just any two chars), it is unreachable via adversarial input (an attacker gains nothing — they could already omit an entry outright), and flagging an even-narrower instance of an already-accepted degradation would be noise.

The areas untouched by dcc86f1 and confirmed sound by the earlier rounds stay sound: inbound containment (_safe_local_path — both-side resolve(), outright absolute rejection incl. paths resolving inside the workspace, strict-descendant check, check-before-mkdir/read), -z verbatim parsing (renames, quoted/UTF-8/tab names, the a -> b.py arrow), and the shared EXCLUDED_DIRS with build output now scanned (scan→abort before Sandbox.create).

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

I re-traced the final diff (HEAD dcc86f19) independently rather than re-litigating the ground already covered by the eight prior review passes (codex ×2, GLM ×4, claude ×2) in this thread — each of which caught a real issue that got fixed: the -z/unquoter contradiction, the dead rejected counter, the faked-rename-header swallow, and the status-alphabet disambiguation. My read agrees with the "no defects found" conclusion of the most recent GLM and claude passes.

Confirmed sound on independent trace:

  • _safe_local_path: both sides resolved before the containment check (root not in candidate.parents, not a string-prefix check, so ws vs ws-evil can't collide), absolute paths rejected outright even when they'd resolve inside the workspace, check runs before mkdir/files.read. Matches the PR's stated threat model (agent shadows git, porcelain output is hostile).
  • _parse_porcelain: -z is genuinely the right call — decoding would reintroduce the exact quoting bug that was just removed. The rename-consumption guard (_looks_like_record + status-alphabet check) correctly handles the adversarial case (R fake.py\0 M real_change.py\0 — real entry preserved) and the honest-collision case (v1 notes.txt as an old path — still consumed, not misparsed) that the last two rounds walked through.
  • EXCLUDED_DIRS: single shared constant, same-object test, build output removed from the skip list so it's actually scanned. The end-to-end dist/.env abort test is good evidence this closes the real gap (scanner narrower than uploader).

One low-confidence, non-blocking observation, not raised in the thread yet: in _download_changed_files, _safe_local_path(workspace_path, rel_path) is called outside the per-file try/except that wraps the read/write. If Path.resolve() ever raises (e.g. a pre-existing symlink loop somewhere under the workspace root) rather than returning a path, that exception isn't caught locally and would propagate out of the whole download loop instead of just rejecting that one entry. I don't think this is attacker-reachable via the sandbox's porcelain output alone (the loop would have to already exist on the host, not be creatable from sandbox-side output), so it's speculative rather than a confirmed bug — worth a quick sanity check, not a blocker.

Minor style nit: the hunk adding _PORCELAIN_STATUS_CHARS/_safe_local_path leaves four blank lines between _INSTALL_CMD and the new comment (two pre-existing + two newly added) instead of the usual two. Cosmetic only, doesn't look intentional.

Nothing here changes the bottom line from the last two review rounds: the containment fix, the -z parsing rewrite, and the shared exclusion list all hold up, test coverage is thorough (29 tests, mutation-checked per the PR body), and this looks ready to merge.

…oad (#967)

claude-review flagged this as speculative; it is real, and worse than it
looked. `Path.resolve()` is not total — a symlink loop raises RuntimeError
on Python 3.11 and 3.12, both inside this project's `requires-python`.
Since `_safe_local_path` was called outside the per-file try/except, one
loop anywhere under the workspace aborted the entire download loop instead
of rejecting one path.

It did not reproduce locally because 3.13 resolves loops quietly, so a
real-symlink test passes on a 3.13 dev box while the crash stays live for
most users. The test forces the raise instead, pinning the handling on
every supported version.

An unresolvable path is precisely one we must not write to, so rejecting
(warned, like every other rejection here) is the right answer rather than
propagating.

Also collapses the stray blank lines around _PORCELAIN_STATUS_CHARS.
@frankbria

Copy link
Copy Markdown
Owner Author

Both handled in the latest push. The "low-confidence, speculative" one turned out to be real — and the reason it looked speculative is worth recording.

resolve() outside the try/except. Confirmed, and it is not host-specific in the way I first assumed. Path.resolve() is not total: a symlink loop raises RuntimeError on Python 3.11 and 3.12, both inside this project's requires-python = ">=3.11". With the call outside the per-file try, one loop anywhere under the workspace aborted the whole download loop rather than rejecting a single path.

My first attempt to pin it was a test that builds a real symlink loop — and it passed against the unfixed code, which is what nearly made me file this as "not reproducible". The reason: CPython 3.13 resolves loops quietly, and my local env is 3.13. So a real-symlink test would have gone green on a 3.13 dev box while the crash stayed live for most users. The committed test forces the raise via monkeypatch instead, so the handling is pinned on every supported version rather than on whichever interpreter happens to be installed.

Python 3.12: Path('/tmp/loop/a/x').resolve() -> RAISED RuntimeError: Symlink loop
Python 3.13: Path('/tmp/loop/a/x').resolve() -> /tmp/loop/a/x

Fixed by catching (OSError, RuntimeError, ValueError) in _safe_local_path and rejecting — an unresolvable path is exactly one we must not write to, so this is the same warned-and-rejected treatment every other rejection gets, not a special case.

Blank lines. Right, unintentional — four instead of two, and only one before def _safe_local_path. Collapsed.

Thanks for flagging it despite the low confidence; the version split is exactly the kind of thing a local test run hides.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 4m 0s —— View job


GLM precision review — E2B trust boundary (#967, synchronize)

  • Gather full PR diff + latest commit (cd1a4ee) changes
  • Check existing PR comments to avoid repeating findings
  • Read changed files in context (adapter.py, credential_scanner.py, callers)
  • Verify the resolve()-outside-try/except fix and surrounding parsing/containment logic
  • Post summary

Reviewed the synchronize commit cd1a4ee ("an unresolvable path rejects one entry, not the whole download"), the fix for the last finding from the prior claude review round (the symlink-loop RuntimeError aborting the whole download on Py 3.11/3.12).

✅ GLM review: no defects found.

The fix is correct and minimal. I traced _safe_local_path against the real code (not the diff alone):

  • The exception set is complete for Path.resolve()'s failure modes. resolve(strict=False) (the default) can raise OSError (permission denied on a path component, I/O error), RuntimeError (symlink loop on Py 3.11/3.12; resolved quietly on 3.13), and ValueError (embedded NUL at Path construction — unreachable here since rel_path comes from a \0-split, but harmless to include). Critically, RecursionError is a subclass of RuntimeError, so a deep non-looping symlink chain that blows the interpreter stack is caught too — the one case a "symlink loop" framing might have missed. There is no other non-caught exception class resolve() raises in default mode, so a single unresolvable entry can no longer escape to abort the whole loop.
  • Rejecting is the right call, not propagating. return None routes the entry through the same warned-and-counted containment-reject path as every other bad path, so the run continues with the remaining files (pinned by test_a_failing_resolve_rejects_one_entry_not_the_whole_download: a boom entry that raises on resolve leaves good.py downloadable, count == 1). The candidate == root / root not in candidate.parents checks are only reached after candidate is assigned, so there is no NameError path on the exception branch.
  • The containment logic itself is unchanged and stays sound. Both sides resolved before the root in candidate.parents strict-descendant check (not a string-prefix check, so ws vs ws-evil cannot collide); absolute paths rejected outright including those that resolve inside the workspace; the check runs before any mkdir/files.read. TOCTOU between resolve() and the later write is not exploitable via sandbox-side porcelain output (it would require a host-side symlink swap of a tree the operator already controls).

The areas untouched by this commit were confirmed sound by the prior eight passes and re-traced here:

  • -z verbatim parsing (_parse_porcelain): XY PATH shape + status-alphabet check correctly separates genuine records from bare paths; rename/copy new\0old consumption is conditional on the next field not looking like a record, so a faked rename header can no longer swallow a real entry; honest renames whose old name collides with the shape (v1 notes.txt) are still consumed via the alphabet disambiguator. No path is C-unquoted, so a file named "a.py" keeps its quotes.
  • Outbound leak (credential_scanner.EXCLUDED_DIRS shared with _upload_workspace): one same-object constant, build output (dist/build/.tox/.eggs) removed so it is now scanned; scan→abort happens before Sandbox.create, so a secret under dist/ yields status='failed', credential_scan_blocked=1, zero sandboxes.

The one messaging nuance (parse-rejects folded into the user-facing "outside the workspace" count) was raised by an earlier GLM pass and is a documented, deliberate fold — not a defect, and not repeated.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

This PR has already been through nine rounds of review (codex ×2, GLM ×4, claude ×2) in this thread, and every round found and fixed a real issue: the -z/unquoter contradiction, the dead rejected counter, the faked-rename-header swallow, the status-alphabet disambiguation for v1 notes.txt, and the unguarded resolve() symlink-loop crash. I re-traced the current HEAD (dcc86f19) independently rather than re-litigating that ground, and agree with the "no defects found" conclusion of the most recent passes on containment (_safe_local_path), -z parsing, and the shared EXCLUDED_DIRS fix.

One residual finding, low severity, not security-relevant — the status-alphabet fix narrows but does not close the rename-old-path misparse class it was meant to fix.

_looks_like_record now requires entry[0] and entry[1] to both be in _PORCELAIN_STATUS_CHARS = frozenset(" MADRCUT?!"), which correctly stops v1 notes.txt from being misidentified (v/1 aren't status chars). But a rename whose old filename's own first two characters happen to both fall inside that same 9-character alphabet, followed by a space, still triggers it — and that's a real-world-plausible filename shape, not just a contrived one. E.g. renaming AD HOC.txtnew.py produces the porcelain pair:

R  new.py\0AD HOC.txt\0

_looks_like_record("AD HOC.txt") returns True (A and D are both in the alphabet, index 2 is a space), so the rename-consumption guard declines to treat it as the old path and instead re-parses it as its own record: status="AD", raw="HOC.txt". That bogus path then reaches the download loop, where sbx.files.read("/workspace/HOC.txt") fails (the file doesn't exist under that name) and is caught by the existing per-file try/except, logging "Failed to download HOC.txt: ...".

So this doesn't escape containment and isn't silently dropped in the strict AC2 sense (a warning is still logged) — but it's a misleading one ("failed to download" rather than "malformed/unconsumed old path"), it's not counted in rejected, and the true old filename is lost from any diagnostic. It also isn't covered by test_an_old_path_that_looks_shaped_like_a_record_is_still_consumed, which deliberately uses v1 notes.txt — chosen so the first two characters aren't status chars, which is exactly the case this fix doesn't reach.

Given how narrow the trigger is (old filename must start with two of " MADRCUT?!" followed by a space) and that nothing escapes the workspace, I'd treat this as optional/non-blocking rather than something that needs another round — but worth a # known limitation note or a follow-up issue so it doesn't read as fully closed, since the latest thread comment ("there is no degraded behaviour left to pin") slightly overstates it.

No other new findings. The overall design — containment as the actual security boundary, -z for verbatim parsing instead of decoding, one shared EXCLUDED_DIRS pinned by an identity test — is sound, and the test suite (tests/adapters/test_e2b_trust_boundary_967.py) is unusually thorough for this kind of fix.

)

claude-review was right that the status-alphabet fix narrowed the
rename-old-path misparse without closing it, and right that my last
comment overstated it. `AD HOC.txt` is a plausible filename whose first
two characters are both real status characters followed by a space, so
the lookahead heuristic still misread it as a record.

Rather than narrow the heuristic again, remove the thing it was guessing
about. `git status --porcelain -z --no-renames` reports a rename as an
independent delete + add — verified against real git:

    -z              R  new.py\0AD HOC.txt\0
    -z --no-renames D  AD HOC.txt\0A  new.py\0

There is no paired field, so there is no lookahead, so both failure
directions disappear at once: a hostile `R` header cannot swallow the
record after it, and no old filename can be misread as a record. Every
entry is a record. The rename branch and its heuristic are deleted.

The delete half of that pair would otherwise be fetched and fail, so
deleted paths are now skipped — they are not in the sandbox to read, and
trying only produced a misleading "Failed to download" for a file that is
meant to be gone. Applying deletions locally remains the parked #966
defect; this only stops us fetching a path we know is absent.
@frankbria

Copy link
Copy Markdown
Owner Author

Right on both counts — AD HOC.txt is a plausible filename, and my previous comment did overstate the fix. Closed properly in the latest push, by removing the thing the heuristic was guessing about rather than narrowing it a third time.

git status --porcelain -z --no-renames reports a rename as an independent delete + add. Verified against real git 2.43:

-z                R  new.py\0AD HOC.txt\0
-z --no-renames   D  AD HOC.txt\0A  new.py\0

No paired field ⇒ no lookahead ⇒ both failure directions disappear at once: a hostile R header cannot swallow the record after it, and no old filename — AD HOC.txt, v1 notes.txt, or anything else — can be misread as one. Every entry is a record. The rename branch and its heuristic are deleted rather than refined; the diff is net smaller.

That the third attempt at a heuristic was still incomplete was the signal I should have taken earlier: the ambiguity was never in the parsing, it was in asking git for a format that has a context-dependent field.

One consequence handled in the same commit: the delete half of that pair would otherwise be fetched and fail. Deleted paths are now skipped — they are not in the sandbox to read, and trying only produced a misleading "Failed to download" for a file that is meant to be gone. Applying deletions locally stays the parked #966 defect; this only stops us fetching a path we know is absent.

Tests: --no-renames is asserted on the command, the previously-swallowed record and both odd filenames are pinned as ordinary records, and deletion skipping is parametrized over D / D/AD/MD. Mutation-checked by dropping the flag.

_PORCELAIN_STATUS_CHARS stays — it still distinguishes a real record from a malformed one, which is the remaining job it was doing.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

GLM precision review — E2B trust boundary (#967, synchronize)

Reviewing the latest synchronize commit.

  • Gather PR diff + latest commit (edf8e59) changes
  • Check existing PR comments to avoid repeating findings
  • Read changed files in context (adapter.py, credential_scanner.py, callers)
  • Verify the --no-renames rewrite, deletion-skipping, and remaining parsing logic
  • Post inline findings + summary

View job run

Spotted while re-running the demo: the emitted line said "Rejected N
path(s) outside the workspace — the sandbox tried to write somewhere it
may not", but that count folds in unparseable records too, which are a
different thing and not necessarily an attempted escape. Reworded to name
both causes and point at the per-path log lines.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 5m 6s —— View job


GLM precision review — E2B trust boundary (#967, synchronize)

Reviewed the two newest synchronize commits — edf8e59 (--no-renames removes the paired-field ambiguity) and 7ef3ed3 (reject-count message matches what it counts) — against the real code (not the diff alone), and re-traced the previously-reviewed areas.

✅ GLM review: no defects found.

No new inline findings; the table below is empty. Each item is a confirmation against the checked-out code.

severity file:line finding
(none)

edf8e59 — the --no-renames rewrite is sound and removes a whole ambiguity class.

  • The rename-consume branch (index += 1 lookahead) and its _looks_like_record heuristic are gone. With -z --no-renames, every entry is a single XY PATH record, so the three failure modes the prior rounds walked through all collapse to ordinary records, traced against _parse_porcelain (adapter.py:407-447):
    • faked R header (R fake.py\0 M real_change.py\0) → real_change.py is its own record, not swallowed (pinned by test_every_entry_is_a_record_so_nothing_can_be_swallowed).
    • v1 notes.txt / AD HOC.txt as standalone records → parsed by their own ?? /A status, no old-path misparse (test_an_odd_old_filename_can_no_longer_be_misparsed, rejected == 0).
  • The deletion-skip is correct. if "D" in status (adapter.py:436) covers D / D/AD/MD/DD. In every one of those, the file is absent from the sandbox working tree (staged git rm, unstaged removal, or add/modify-then-delete), so sbx.files.read would only fail — skipping avoids the misleading "Failed to download" the old code logged, and changes nothing else (local deletion sync stays the parked [P2.16] Gate --engine cloud / --isolation cloud behind an experimental flag and remove it from the advertised surface #966 item, as before). Pinned parametrized over those four statuses.
  • _PORCELAIN_STATUS_CHARS = frozenset(" MADRCUT?!") still does its remaining job — separating a real XY record from a malformed top-level entry (adapter.py:424-429) — and is exact for porcelain v1's XY alphabet, so no valid record is falsely rejected.

7ef3ed3 — the reject-count message now matches what it counts. rejected (adapter.py:344, 354, 418, 427) is the sum of _parse_porcelain's unparseable rejects and the loop's containment rejects, and the emitted text (adapter.py:380) now reads "unparseable, or outside the workspace" — i.e. it no longer claims "outside the workspace" for parse-rejects. The earlier messaging nuance is closed.

Re-confirmed clean (untouched by these two commits, validated in prior passes and re-traced here):

  • Inbound containment (_safe_local_path, adapter.py:42-75): both-side resolve(); outright absolute rejection (incl. paths that resolve inside the workspace); strict-descendant check via candidate.parents (so ws vs ws-evil can't collide); check runs before any mkdir/files.read; resolve() failure (OSError/RuntimeError/ValueError, which also covers RecursionError for a deep non-looping chain) is caught and rejects one entry, not the whole download. TOCTOU between resolve and write is not reachable via sandbox-side porcelain output.
  • -z verbatim parsing: no C-unquoting, so "a.py"/café.txt/tab\tname.txt arrive intact; the " -> " arrow is inert under --no-renames.
  • Outbound leak (credential_scanner.EXCLUDED_DIRS, shared by identity with _upload_workspace): one same-object constant; build output (dist/build/.tox/.eggs) removed so it is scanned; scan→abort precedes Sandbox.create, so a secret under dist/ yields status='failed', credential_scan_blocked=1, zero sandboxes.

One residual I deliberately did not flag, consistent with the prior GLM/claude passes: if copy detection were forced on in the sandbox repo (e.g. git config + -C, since --no-renames disables renames but not copies), a C new\0orig\0 record could reappear. It degrades safely — new is fetched (it exists), orig is either warned as malformed or fetched to a contained workspace/orig — so there is no escape and no silent loss; this is exactly the documented "containment, not parsing, is the boundary" tradeoff and not a defect.

  • Gather full PR diff + latest commits (edf8e59, 7ef3ed3)
  • Check existing PR comments to avoid repeating findings
  • Read changed files in context (adapter.py, credential_scanner.py, callers)
  • Verify the --no-renames rewrite, deletion-skipping, reject-count message, and surrounding logic
  • Post summary (no inline findings)

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

I independently re-traced the full diff (all 7 commits, final HEAD 7ef3ed3) rather than the intermediate states. This thread already has nine rounds of review (codex x2, GLM x4, claude x2) that iteratively closed real issues -- the -z/unquoter contradiction, the dead rejected counter, the faked-rename-header swallow, the v1 notes.txt/status-alphabet disambiguation, the unguarded resolve() symlink-loop crash, and finally the AD HOC.txt misparse that motivated dropping renames entirely via --no-renames. I agree with the prior conclusion: the final design is sound.

Containment (_safe_local_path): resolves both workspace_root and the candidate before a strict root not in candidate.parents check (not string-prefix, so ws vs ws-evil can't collide), rejects absolute paths outright (including ones that'd resolve inside the workspace -- correctly justified since porcelain paths are always relative), and runs before any mkdir/files.read so a rejection costs nothing. The resolve() exception guard (OSError/RuntimeError/ValueError) correctly downgrades a symlink-loop crash to a per-entry rejection instead of aborting the whole download.

Parsing (_parse_porcelain): switching to -z --no-renames is the right fix -- removing the paired rename field, rather than a third attempt at narrowing a lookahead heuristic, eliminates the whole misparse class at once (no old-filename can look like a status record because there's no lookahead left to fool). The status-alphabet check (_PORCELAIN_STATUS_CHARS) exactly matches git's real XY vocabulary. Skipping D statuses before they hit the download loop avoids a misleading "failed to download" for files that are correctly absent from the sandbox.

Outbound (EXCLUDED_DIRS): a single shared constant pinned by an identity test is a good way to prevent the scanner/uploader drifting apart again; removing dist/build/.tox/.eggs closes the actual leak (build artifacts are now scanned and uploaded together, consistently).

Two non-blocking observations, both low severity:

  1. _download_changed_files's "D" in status skip treats any status containing D as "file absent from the sandbox," but during a genuine merge conflict UD/DU (deleted-by-them / deleted-by-us) can leave real conflict-marker content in the working tree rather than an absent file -- that content would now silently never be fetched back. Given the E2B engine is already gated behind CODEFRAME_ENABLE_CLOUD_ENGINE with a documented list of known unrelated defects in CLAUDE.md, and merge conflicts are an unlikely path for a single automated agent working a task branch, I'd treat this as a documented follow-up rather than a blocker for this PR.
  2. Build directories are now uploaded to the sandbox where they previously weren't -- an intentional, correctly-justified trade-off for closing the unscanned-secret leak, but worth being aware it can meaningfully increase upload size/time for repos with large dist/build output.

No security-relevant defects found. Test coverage (tests/adapters/test_e2b_trust_boundary_967.py) is thorough and does a good job pinning the adversarial cases (faked rename headers, quoted/UTF-8/tab filenames, symlink escapes vs. symlinked-workspace false-rejects) rather than just the happy path.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

This thread already has ten review rounds (codex ×2, GLM ×5, claude ×3), and every round found and fixed something real: the -z/unquoter contradiction, the dead rejected counter, the faked-rename-header swallow, the status-alphabet disambiguation, the unguarded resolve() symlink-loop crash, and — my last finding — the AD HOC.txt-shaped old-filename misparse. I re-traced the current HEAD (7ef3ed31) independently rather than re-litigating settled ground.

The --no-renames rewrite (edf8e59) genuinely closes the class of bug the last several rounds were chasing, not just the specific instance. With --no-renames, a rename is reported as an independent D/A pair, so there is no paired field left to consume and therefore no lookahead heuristic to fool — the entire rename-consumption branch (and _PORCELAIN_STATUS_CHARS's second job of disambiguating an old-path from a real record) is deleted rather than patched a fourth time. I confirmed no old-filename shape (AD HOC.txt, v1 notes.txt, or anything else) can be misparsed anymore, since every NUL-delimited entry is now independently validated against _looks_like_record with no consumption step. _PORCELAIN_STATUS_CHARS still does useful work (rejecting genuinely malformed top-level records), just no longer the disambiguation job that kept needing another round.

Containment (_safe_local_path) is unchanged from the last confirmed-sound pass and still holds: both sides resolved before the strict-descendant check, absolute paths rejected outright (including ones that resolve inside the workspace), check-before-mkdir/read, and the symlink-loop resolve() failure is now caught locally per-entry rather than aborting the whole download.

Outbound (EXCLUDED_DIRS) is still the single shared, same-object-tested constant with build output removed, so the scanner and uploader can't drift apart again.

No new correctness or security issues found. One genuinely trivial, non-blocking observation not raised before: _safe_local_path calls workspace_root.resolve() fresh on every invocation, so it's re-resolved once per changed file rather than once per download batch — a few extra syscalls for a typical changeset, not worth a fix on its own.

Note there's an automated GLM review still in progress against these last two commits (edf8e59, 7ef3ed3) as of this writing — worth letting that land before merging, though I don't expect it to surface anything new given the --no-renames simplification actually shrinks the surface area rather than adding to it.

@frankbria
frankbria merged commit be6b294 into main Aug 8, 2026
13 checks passed
@frankbria
frankbria deleted the fix/967-e2b-trust-boundary branch August 8, 2026 08:13
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.

[P2.17] Fix the E2B sandbox trust boundary: escaping download paths and unscanned upload directories

1 participant