Skip to content

Execute Codex exec snippets in a sandboxed QuickJS engine (replace the Tree-sitter analyzer)#291

Open
cboos wants to merge 9 commits into
mainfrom
dev/codex-quickjs
Open

Execute Codex exec snippets in a sandboxed QuickJS engine (replace the Tree-sitter analyzer)#291
cboos wants to merge 9 commits into
mainfrom
dev/codex-quickjs

Conversation

@cboos

@cboos cboos commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Codex persists multi-tool orchestration as JavaScript inside a custom exec
call. This PR replaces the static Tree-sitter analyzer (#288) with real
sandboxed execution
of the snippet in a QuickJS engine (quickjs-ng), using
instrumented tools / text() stand-ins that record what the snippet actually
did. Because arguments are captured after the snippet's own JS evaluated them,
expression-built values (string concat, templates, .join(), reduce,
ternaries, computed arguments, conditional branches) resolve for free — the
fragile static-expansion whitelist disappears.

Evolution

The exec-JavaScript recognizer has moved through three stages:

  1. Regexp recognizer — a hand-rolled pattern matcher for a narrow set of shapes.
  2. Tree-sitter analyzer (Expand Codex tool execution normalization with Tree-sitter #288) — parse plus bounded abstract interpretation of
    a whitelisted subset; transcript code was never executed.
  3. QuickJS execution (this PR) — the snippet is executed in a sandbox and the
    recording is mapped back to a tool batch.

On a corpus of 2,265 unique real exec snippets, execution decodes 99.96%
(2,264 / 2,265; the single miss is genuinely invalid JS → correct raw fallback)
versus 91.1% for the Tree-sitter analyzer.

Safety model

Transcript JavaScript is untrusted input.

  • No host callables are registered in the engine — the attack surface is the
    QuickJS interpreter only.
  • Per-snippet bounds: memory (64 MB), wall-time (2 s), stack (512 KB), a
    pending-job cap, a 64 KB source cap, a 128 expanded-call cap, and a per-string
    materialization cap (64 KB). If the limit setters are unavailable the run fails
    closed rather than executing unbounded.
  • Provenance rides on a private-use sentinel (U+E000), which JSON.stringify
    passes through verbatim (a NUL would be escaped and lost).
  • Any failure — syntax error, engine exception, a cap hit, a run that throws or
    never resolves, or a shape the mapper cannot correlate — fails closed to the
    raw-script ToolExecution fallback, exactly as before.

Adversarial coverage includes engine-class cases (infinite loops, allocation
bombs, deep recursion), host-escape probes, cross-snippet isolation, and
sentinel forgery — each pinned to the fail-closed contract, several
mutation-verified.

New capabilities

Shapes the static analyzer had to reject now decode: concatenated /
template-literal / array-join / reduce / repeat commands, ternary flag
selection, computed numeric arguments, loop-built command batches, and
conditional loop bodies (only taken branches recorded). ALL_TOOLS-registry
pipelines remain a deliberate fail-closed boundary (a registry view is future
work).

DoS hardening

Bounds the _truncated_object_batch_result truncation-recovery loop, which
reverse-scanned every "key": occurrence and raw_decoded from each —
O(matches) × O(N) = O(N²), and worse on nested same-key inputs where each match
re-parsed the surviving subtree. Capped to a small constant number of reverse
attempts plus a non-slicing tail check, turning it O(N) overall while preserving
recovery of the final intact property.

Notes

  • The Tree-sitter analyzer and its tree-sitter / tree-sitter-javascript
    dependencies are removed; quickjs-ng is the only engine.
  • Rebased onto current main (which carries the RenderingDepth rename and its
    follow-up plugin fix); there is no overlap with those changes.

Testing

Full CI green: unit, TUI, browser, integration, and benchmark suites, plus
ruff, pyright (strict), and ty.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Improved exec JavaScript tool analysis via a sandboxed QuickJS engine, enabling more expression types, loops/conditionals, and asynchronous/result mapping behaviors.
  • Bug Fixes
    • Stricter fail-closed behavior on engine/correlation/cap failures to prevent partial or fabricated tool results.
    • More robust recovery for truncated tool-result batches with bounded attempts.
  • Documentation
    • Updated tools coverage docs to reflect the QuickJS-based analysis model and safety limits.
  • Tests
    • Added QuickJS capability, adversarial threat-model, and truncation-recovery coverage.

cboos and others added 8 commits July 23, 2026 08:39
Add codex_quickjs.py — the sandboxed-execution replacement for the
tree-sitter static analyzer (spec: work/codex-quickjs.md). Standalone so
far (consumers still import codex_javascript); wiring in is step 2.

- Engine: quickjs-ng behind _run_snippet(code)->report; per-snippet
  memory(64MB)/time(2s) limits + pending-job bound; fail-closed to None.
- Instrumentation prelude (validated): recording `tools` proxy, `text()`,
  ALL_TOOLS/setTimeout/exit/console stand-ins, no host callables. Sentinel
  delimiter is U+E000 (private-use) not NUL, so JSON.stringify(r) emissions
  keep their provenance markers instead of escaping them to \uXXXX.
- Mapper: sentinel R<i>[.path] parsing (raw, JSON-string, and JSON-object
  forms) → JavaScriptToolBatch, reproducing result_indexes / prefixes /
  synthetic wait / session markers / RESULT_N markers mode / object-key
  projection; re-emission of one call keeps its output row.
- stubs/quickjs.pyi for the untyped quickjs-ng surface.

Status: 21/24 spec-by-example cases (test_codex_javascript) pass against
the new evaluator. Remaining 3: engine-bounded-loop test-flip (capability
gain), and the {name,...r} spread / explicit-result-field object
projections (need the prelude ownKeys refinement the spec anticipates).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish the sentinel→batch mapper so all recognized emission shapes decode
under real execution, and clear pyright strict on the new module.

- _project_result_object: recognize the two Codex result-object shapes,
  split by ref-path — a whole-result BUNDLE ({presence:p, mail:m}: several
  calls, one row, keyed per field) vs a single-call PROJECTION ({name,...r}
  or {name, exit_code:r.exit_code, output:r.output}: collapse to the call's
  `output` field + a leading-static JSON prefix). Reachable for both
  JSON.stringify strings and direct dict text() values (out.forEach(text)).
- Prelude: magic Proxy gains ownKeys/getOwnPropertyDescriptor exposing a
  single canonical `output` key, so {...r} spread degrades to a whole-result
  projection (parity with the tree-sitter _mapped_result_object, not JS
  key-set perfection). Target switched to an arrow fn so ownKeys need not
  report a non-configurable `prototype`.
- cast() the json.loads / report boundaries for pyright strict (0 errors).

Validation: 23/24 analyze_javascript_tools spec cases pass against the new
evaluator. The 1 remaining is a sanctioned capability gain — an oversized
static loop under max_loop_iterations=2 now CAPTURES (engine time-limit
supersedes the fixed count); that rejection test flips in step 2 when the
consumers repoint. Dynamic (undefined-ids) loops still fail closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swap the two analyzer consumers (codex_tools.py, codex.py) from the
tree-sitter codex_javascript to codex_quickjs, and migrate the spec tests.

- _correlate: object_key now comes ONLY from an explicit object-emission ref
  (bundle / projection), never derived from a bare ref's property path. A
  plain text(r.output) output row is the raw result text, so its key stays
  None; deriving "output" made the downstream _object_batch_result try to
  JSON-project a plain string and drop the whole batch to raw fallback.
  Caught by the adversarial pairing tests, not the spec-by-example suite.
- test_codex_javascript.py -> test_codex_quickjs.py, repointed at the new
  module. Dropped the three tree-sitter-internal parse_javascript tests
  (no analog under execution); the parser-error fail-closed guard becomes an
  engine-error fail-closed guard (monkeypatch _run_snippet to raise).
- Sanctioned flip: the oversized-static-loop rejection splits into a
  dynamic-loop rejection (still fails closed) and a capability-gain capture
  (a static loop past the legacy max_loop_iterations cap now expands, bounded
  by the engine time limit instead of a fixed count).

codex_javascript.py is now orphaned (no consumers, no dedicated tests) and
is removed in step 4 after corpus parity. Full unit suite green (2573),
pyright strict clean across providers/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Execution changes the threat surface, so pin the fail-closed contract for
hostile transcript JS in test_codex_quickjs_adversarial.py (13 tests):

- Engine bounds: infinite loop (time-limit bounded, ~2s, not a Python hang),
  allocation bomb, and unbounded recursion each fail closed with no host
  RecursionError/crash.
- No host escape: process/require/fetch/globalThis.process are undefined;
  a snippet reaching for require('fs') fails closed.
- Sandbox isolation: globalThis pollution in one snippet does not leak into
  the next (fresh Context per run).
- Sentinel forgery (U+E000): a forged ref to a nonexistent call, a forged
  sentinel spliced into a tool argument, and a literal provenance char in a
  source string all fail closed; a hostile toJSON returning the sentinel does
  not crash. Non-fabrication invariant asserted directly — no materialized
  call input ever carries the raw sentinel (it routes real output rows, it is
  never itself displayed).
- _build_batch forced to raise → still None (top-level guard is the boundary).

_S is bound from the module via getattr so a change to the sentinel can't
silently render the forgery guards vacuous. pyright strict + ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…probe

Parity is green (the migrated spec suite decodes every shape the tree-sitter
analyzer did, plus the sanctioned capability gains, zero regressions), so
retire the old path:

- Remove claude_code_log/providers/codex_javascript.py (orphaned since
  step 2 — no consumers, no tests).
- Drop the tree-sitter / tree-sitter-javascript dependencies from pyproject
  and the lockfile; quickjs-ng is now the only analyzer engine.
- Add scripts/codex_snippet_coverage.py: a dev-only probe that runs the
  QuickJS analyzer over a LOCAL corpus of exec snippets (nothing private
  committed) and reports decoded-vs-fallback coverage. --compare runs the
  tree-sitter analyzer too while it exists and flags regressions; it now
  degrades gracefully to QuickJS-only. Self-check (no corpus) shows 0
  regressions and 1 capability gain.

Full unit suite green (2586).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Execution decodes snippet classes the tree-sitter analyzer had to reject.
Pin them as synthetic spec-by-example fixtures (test_codex_quickjs_capabilities),
each asserting the materialized tool input so the test is real only if the
engine actually evaluated the expression:

- concatenated / template-literal / array-join / reduce / repeat commands,
- ternary flag selection and computed numeric arguments,
- loop-built command batches,
- conditional loop bodies (only taken branches are recorded).

Plus a boundary marker: an ALL_TOOLS-driven pipeline still fails closed (a
real registry view is deferred future work), documented so a later capability
change is a deliberate, visible flip rather than a silent one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… bounds

Follow-up hardening on the QuickJS analyzer (no design change):

- Pin three load-bearing fail-closed guards that were previously unpinned
  (each materialized a WRONG batch when neutralized): a forged text ref to a
  setTimeout wait record -> None (is_wait leg; mutation-verified RED, producing
  a cross-attributed [wait, a] batch), throw-after-consistent-emissions -> None
  (errors leg), and a never-resolving run -> None (done leg). Each is paired
  with a control probe proving the None is attributable to the target guard,
  not to an upstream rejection.
- Restore cap coverage: >64 KB source -> None, and a static 129-iteration loop
  (>128 real calls) -> None (128 -> batch boundary checked; the records-level
  and param-level caps are redundant defense in depth).
- Generation-layer string cap: __safe now caps each recorded string at 64 KB
  with a truncation marker (mirroring its depth/breadth caps). A ~90-byte
  snippet could otherwise synthesize a multi-megabyte tool input into the
  generated HTML regardless of display folding.
- The prelude sentinel and string cap are now genuinely spliced from the Python
  constants, so those constants are the single source of truth for both sides.
- dev-docs/tools-coverage.md: rewrote the exec-JS section for the execution
  model (previously described as parsed with Tree-sitter, never executed).
- quickjs-ng pinned <0.16 -- an engine executing untrusted input deserves an
  upper bound.
- set_max_stack_size is now called (512 KB) and the missing-setter path fails
  closed instead of running untrusted code without a time limit.

Full CI green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`_truncated_object_batch_result` recovers the final intact property from
truncated JSON by reverse-scanning every `"key":` occurrence and
`raw_decode`-ing from each -- O(matches) matches x O(N) per decode = O(N^2),
and worse on nested same-key inputs (`{"k":`*N) where each reversed match
re-parses the surviving subtree (~2.9s at 40 KB).

Fix -- two bounds, both kept:
- Cap the reverse raw_decode attempts to a small constant K=16. Only the FINAL
  top-level property is ever recoverable (the closing-brace tail check), so
  every earlier reversed match is a nested same-key occurrence inside that
  final value (realistic depth 0-3); K=16 covers any real nesting with wide
  margin, and exceeding it falls back to the existing truncation placeholder --
  never wrong data, never a crash. This bounds the nested re-parse.
- Replace the `output[end:].lstrip()/.rstrip()` tail check (an O(N) slice per
  match) with an anchored, non-slicing `_OUTER_BRACE_TAIL_RE.match(output, end)`
  -- same semantics, short-circuits O(1) on the common failing char. This
  bounds the flat-shape slice.

Together they turn the loop O(N) overall. Tests: a quadratic probe pinned via a
raw_decode call-count spy (20000 nested matches -> <=16 decodes, result None)
and a legitimate-recovery regression; the existing end-to-end
truncation-recovery tests remain green.

Full CI green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c452de7-df61-4e36-b9aa-63572c362560

📥 Commits

Reviewing files that changed from the base of the PR and between 3481a67 and 79d3a2a.

📒 Files selected for processing (1)
  • scripts/codex_snippet_coverage.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • scripts/codex_snippet_coverage.py

📝 Walkthrough

Walkthrough

Changes

The Codex JavaScript analyzer now executes snippets in sandboxed QuickJS, records tool calls and emissions, correlates results, and fails closed on unsafe or ambiguous execution. Provider wiring, dependencies, typing, documentation, coverage tooling, tests, and bounded JSON recovery are updated.

Codex analysis pipeline

Layer / File(s) Summary
Sandboxed QuickJS execution
claude_code_log/providers/codex_quickjs.py
Executes snippets under resource limits, instruments calls and text output, correlates provenance, and returns JavaScriptToolBatch.
Analyzer integration and runtime contract
claude_code_log/providers/codex.py, claude_code_log/providers/codex_tools.py, pyproject.toml, stubs/quickjs.pyi
Routes analysis through QuickJS, replaces Tree-sitter dependencies, and adds QuickJS type declarations.
Truncated object recovery bounds
claude_code_log/providers/codex.py, test/test_codex_adversarial.py
Bounds reverse JSON decoding and preserves valid truncated-object recovery.
Capability and fail-closed validation
test/test_codex_quickjs.py, test/test_codex_quickjs_adversarial.py, test/test_codex_quickjs_capabilities.py
Covers runtime evaluation, loop expansion, sandbox limits, provenance checks, correlation guards, and static caps.
Coverage tooling and documentation
scripts/codex_snippet_coverage.py, dev-docs/tools-coverage.md
Adds rollout snippet coverage reporting and documents the QuickJS analysis model and supported mappings.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CodexProvider
  participant QuickJSContext
  participant ToolInstrumentation
  participant ResultCorrelator
  CodexProvider->>QuickJSContext: analyze exec snippet
  QuickJSContext->>ToolInstrumentation: record tools calls and text emissions
  ToolInstrumentation-->>QuickJSContext: emit serialized execution report
  QuickJSContext-->>CodexProvider: return report or failure
  CodexProvider->>ResultCorrelator: build JavaScriptToolBatch
  ResultCorrelator-->>CodexProvider: correlated calls and output rows
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: switching Codex exec analysis from Tree-sitter to sandboxed QuickJS execution.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev/codex-quickjs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/codex_snippet_coverage.py`:
- Around line 54-58: Update the path collection loop in the script’s main
input-processing flow to expand glob-pattern arguments before checking whether
each path is a file or directory, so quoted patterns such as sessions/**/*.jsonl
resolve matching JSONL files. Preserve existing directory recursion and
direct-file handling, and retain documented glob support.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4391f84e-41f6-4140-8566-527ea8da7882

📥 Commits

Reviewing files that changed from the base of the PR and between 2153c92 and 3481a67.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • claude_code_log/providers/codex.py
  • claude_code_log/providers/codex_javascript.py
  • claude_code_log/providers/codex_quickjs.py
  • claude_code_log/providers/codex_tools.py
  • dev-docs/tools-coverage.md
  • pyproject.toml
  • scripts/codex_snippet_coverage.py
  • stubs/quickjs.pyi
  • test/test_codex_adversarial.py
  • test/test_codex_quickjs.py
  • test/test_codex_quickjs_adversarial.py
  • test/test_codex_quickjs_capabilities.py
💤 Files with no reviewable changes (1)
  • claude_code_log/providers/codex_javascript.py

Comment thread scripts/codex_snippet_coverage.py Outdated
The dev-only coverage probe advertised glob patterns (e.g.
`sessions/**/*.jsonl`) but classified each argument only as a literal file or
directory, so a quoted glob was neither and silently contributed zero snippets —
indistinguishable from a real 0% run. Expand shell-style globs (recursive `**`
supported) via glob.has_magic()/glob.glob(), and report on stderr any pattern
that matches nothing or any path that is neither a file nor a directory, so an
empty corpus is never mistaken for a real result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant