Skip to content

feat(analysis): track CallContext field usage per builtin#533

Merged
julesmcrt merged 23 commits into
mainfrom
jules.macret/callctx-analysis
Jun 19, 2026
Merged

feat(analysis): track CallContext field usage per builtin#533
julesmcrt merged 23 commits into
mainfrom
jules.macret/callctx-analysis

Conversation

@julesmcrt

@julesmcrt julesmcrt commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the static analysis extension proposed in #532: each builtin now has a declared allowlist of CallContext function fields it may access. CI fails if a builtin touches a field outside its declared set.

Key security invariants now statically verified:

  • Only truncate may access callCtx.Truncate (the sole write-capable field)
  • Only find/xargs may access RunCommand/RunCommandWithStdin
  • Only cd may access ChangeDir
  • Only read may access SetVar

Implementation

analysis/symbols_builtins.go

  • callCtxAllFields []string — the complete tracked set of CallContext function-typed fields. Plain data fields (Stdin, Stdout, Stderr, Now, InLoop, LastExitCode, Proc) are excluded as they carry no elevated capability.
  • builtinPerCommandCallContextFields map[string][]string — per-builtin allowlists, one field per line, sorted, derived from actual source analysis of all 29 builtins.

analysis/structural.go

  • checkFileCallCtxFields(f, allFields, holders, allowedFields, usedFields, report) — AST walker detecting all three access patterns (see Design notes).
  • findCallCtxHolderNames(f) — scans function parameter lists and struct field declarations for names typed *CallContext or *<pkg>.CallContext. Returns the complete set of identifier names statically known to hold a *CallContext value.
  • findCallCtxLocalAliases(f, holders) — scans assignment statements for local variables assigned from a holder expression; iterates to stable for transitive chains (cc := callCtx; dd := cc).
  • isCallCtxValueExpr(expr, holders) — reports whether expr evaluates to a *CallContext value (bare holder ident, or selector whose field name is a holder).
  • selectorChainHasHolder(expr, holders) — walks a chained selector and returns true if any intermediate name is in the holder set.
  • isStarCallContext(expr) — recognises *CallContext and *<pkg>.CallContext AST nodes.

analysis/symbols_test.go

  • callCtxFieldConfig struct and checkCallCtxFields(t, cfg) harness. Phase 1 parses all files per builtin and collects declaration-based holders (params + bridge fields). Phase 2 expands holders with per-file local aliases, then checks each file against the allowlist. Checks: (1) per-command fields ⊆ AllFields; (2) files only access declared fields; (3) every builtin dir has an entry.

analysis/symbols_builtins_test.go

  • builtinsCallCtxCheckConfig() and TestBuiltinCallContextFields

analysis/symbols_builtins_verification_test.go

  • 7 verification tests: clean pass, unauthorized Truncate in cat (depth-1), unlisted SetVar in cat, invalid field name in allowlist, missing builtin entry, unauthorized Truncate via a bridge field (depth-2), and unauthorized Truncate via a local alias (cc := callCtx; cc.Truncate).

Design notes

Three-tier access detection: The checker identifies *CallContext holders through static analysis — no type-checker, pure AST — then flags any access to a tracked field through a holder:

  1. Depth-1: callCtx.Field — holder is a function parameter or struct field typed *CallContext.
  2. Depth-N: ec.callCtx.Field — holder name appears anywhere in the selector chain. Works because bridge field names (struct fields typed *CallContext) are in the holder set.
  3. Local alias: cc := callCtx; cc.Field — local variables assigned from a holder expression are added to the per-file holder set before checking. Iterated to stable for transitive chains.

Precision: Only identifiers statically known to hold *CallContext are flagged. Other local variables that happen to share a method name with a tracked field are not flagged (e.g. dh.ReadDir on an fs.ReadDirFile is correctly ignored; ReadDir was removed from du's allowlist as a result). The remaining gap is variables obtained from function return values, which requires a full type-checker.

No unused check: Unlike builtinPerCommandSymbols, the CallContext field check does not enforce that every declared field is used by at least one file, since not all accesses are detectable at the AST level.

Test plan

  • go test ./analysis/ -run TestBuiltinCallContextFields passes on clean codebase
  • TestVerificationCallCtxCleanPass — no errors on unmodified copy
  • TestVerificationCallCtxUnauthorizedAccess — detects injected Truncate in cat (depth-1)
  • TestVerificationCallCtxUnlistedField — detects injected SetVar in cat
  • TestVerificationCallCtxFieldNotInAllFields — detects invalid field in allowlist map
  • TestVerificationCallCtxMissingBuiltinEntry — detects missing echo entry
  • TestVerificationCallCtxLocalAlias — detects cc := callCtx; cc.Truncate in cat
  • TestVerificationCallCtxDepth2 — detects Truncate via a *builtins.CallContext bridge field
  • Full go test ./analysis/ suite passes

🤖 Generated with Claude Code

julesmcrt and others added 18 commits June 10, 2026 14:32
…n series)

The sandbox previously rejected every non-O_RDONLY open as a blanket
defense. This change replaces that with a precise flag-mask check:
only bits in allowedOpenFlags (O_RDONLY|O_WRONLY|O_RDWR|O_APPEND|
O_CREATE|O_EXCL|O_TRUNC) are accepted; unknown bits still return
ErrPermission. Write opens through os.Root are now allowed for paths
inside the allowlist.

TOCTOU defense: the cross-root symlink fallback continues to be
read-only. A symlink that escapes its os.Root is followed for reads
but rejected for writes, preventing a malicious link target from being
swapped between resolution and open.

Nothing in the interpreter uses write opens yet — output redirections
are still blocked at parse time. This is a pure capability layer
change; wiring is PR C.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…ns [PR C]

In remediation mode, output redirections (>, >>, 2>, &>, &>>) open through
the AllowedPaths sandbox instead of being blocked at parse time. /dev/null
is still short-circuited to io.Discard. <> stays blocked in all modes.

- interp/validate.go: validateNode/validateRedirect accept remediationMode bool;
  file-target output redirect cases return nil (pass) when true
- interp/api.go: pass r.remediationMode to validateNode
- interp/runner_redir.go: in remediation mode, open file targets via the sandbox
  with O_WRONLY|O_CREATE|O_TRUNC (>) or O_WRONLY|O_CREATE|O_APPEND (>>);
  add rejectNonRegularRedirectTarget guard to prevent hangs on FIFOs
- analysis/symbols_interp.go: add os.O_APPEND, O_CREATE, O_TRUNC, O_WRONLY
  and io/fs.ModeType to the allowed symbol list
- tests/scenarios_test.go: add remediation_mode field to input struct
- tests/scenarios/shell/redirections/file_target/: 8 new scenarios covering
  truncate, overwrite, append, 2>, &>, &>>, sandbox-blocked, and > file 2>&1

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…ionMode() with Mode()

Makes the Go API mirror the CLI's --mode flag: callers now write
interp.Mode(interp.ModeRemediation) instead of the asymmetric
interp.RemediationMode().

- type Mode string → type ExecutionMode string (frees "Mode" as a
  function name)
- RemediationMode() RunnerOption → Mode(m ExecutionMode) RunnerOption
  (validates the value, rejects unknown modes)
- cmd/rshell/main.go: interp.Mode(mode) type-conv → interp.ExecutionMode(mode);
  always pass interp.Mode(opts.mode) so read-only is explicit too
- tests/scenarios_test.go: updated call site

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Proves the mode gate (not just the sandbox) blocks writes: AllowedPaths
is configured so the sandbox would permit writes, but without remediation
mode the validator rejects >, >>, and &> at parse time (exit 2).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Resolve 5 conflicts:
- allowedpaths/sandbox.go: take PR C's readOnly default + SetWritable() +
  tightened allowedOpenFlags (removes O_RDWR/O_EXCL)
- allowedpaths/sandbox_test.go: take PR C's TestSandboxDefaultReadOnly and
  updated SetWritable-gated write tests
- analysis/symbols_allowedpaths.go: drop O_EXCL/O_RDWR (removed from sandbox)
- interp/api.go: keep PR D's Mode(ExecutionMode) API; add SetWritable() call
  in New() when remediationMode is true
- README.md: keep PR D's AllowedPaths paragraph (includes remediation mode docs)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
os.O_APPEND, os.O_CREATE, os.O_TRUNC, os.O_WRONLY, and io/fs.ModeType
were added to the interpAllowedSymbols ceiling but omitted from
interpPerModeSymbols["read-only"]. runner_redir.go (not a _remediation.go
file) uses all five, so TestInterpPerModeSymbols failed.

Add them to the "read-only" list. The runtime guard (r.remediationMode)
still prevents actual writes in read-only mode; these are flag constants
whose capability gate is allowedpaths.Sandbox.Open.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Move write-capable code (openWriteRedirect, openWriteAllRedirect,
rejectNonRegularRedirectTarget) out of runner_redir.go and into a new
_remediation.go file, so the write-flag symbols (os.O_WRONLY, os.O_CREATE,
os.O_TRUNC, os.O_APPEND, io/fs.ModeType, syntax.RedirOperator) can be listed
in interpPerModeSymbols["remediation"] only — not in "read-only". This restores
the static guarantee that write-capable symbols cannot appear in non-remediation
interp/ files.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
rejectNonRegularRedirectTarget previously returned nil when r.sandbox == nil,
silently skipping the check. Opening a FIFO with O_WRONLY blocks until a reader
connects, so a sandboxless remediation-mode runner would hang for up to 30s on
any named-pipe redirect target.

Extract a statFileMode helper that uses r.sandbox.Stat when a sandbox is
present, and falls back to os.Stat otherwise. The check now always runs,
regardless of whether AllowedPaths is configured.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Covers the one file-target redirect operator that had no dedicated test:
stderr append (2>>). Follows the same pattern as append.yaml and
stderr_redirect.yaml — appends stderr twice and verifies both lines appear.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Extract the repeated `redirectTargetIsDevNull(rd) || remediationMode`
condition into a redirectAllowed helper, removing the duplication across
all four write-redirect cases in validateRedirect (suggested by AlexandreYang).

Add clobber.yaml to cover the >| operator, the one file-target redirect
with no dedicated scenario.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
On Windows, Go's filepath treats /tmp/evil.txt as the relative path
tmp\evil.txt rather than an absolute path. The open therefore fails with
"no such file or directory" before the sandbox can reject it with
"permission denied". Add stderr_contains_windows to accept the
Windows-specific error while keeping the stricter check on Linux/macOS.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
SHELL_FEATURES.md still referenced interp.RemediationMode() which was
renamed to interp.Mode(interp.ModeRemediation) in this PR. Fix the doc.

sandbox.Open() returns ErrPermission immediately when called on a nil
receiver (sandbox.go:368-370), so r.open() always fails before touching
a FIFO when r.sandbox == nil. The os.Stat fallback in statFileMode was
therefore dead code — a nil sandbox already prevents any blocking open.
Replace it with an early return of os.ErrNotExist so the FIFO type-check
is skipped (the subsequent open failure surfaces the real error), and
remove os.Stat from the symbol allowlists.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
PR #524 reviewer explicitly asked for type Mode with ModeReadOnly and
ModeRemediation constants. PR #526 had renamed the type to ExecutionMode
to avoid a naming conflict with the Mode() RunnerOption function, but
that went against the prior review feedback.

Restore type Mode string (as requested in PR #524) and rename the
RunnerOption from Mode() to WithMode(), which reads naturally at the
call site (interp.WithMode(interp.ModeRemediation)) and eliminates the
awkward inversion where the setter and the type had swapped names.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Adds `truncate -s SIZE [-c] FILE...` as a new builtin that is only
wired when the interpreter is in remediation mode. The primary use
case is zeroing log files in place to reclaim disk space without
breaking processes that hold the file open.

Key design decisions:
- Sandbox.Truncate opens with O_NONBLOCK so FIFOs return ENXIO
  immediately instead of blocking, and fstats the fd (not the path)
  before ftruncate to close the TOCTOU race window.
- The callCtx.Truncate field is nil outside remediation mode, making
  the mode guard a nil-pointer check rather than a separate flag.
- Relative-size modifiers (+/-/<>//%/) are rejected with an explicit
  hint rather than silently treated as invalid numbers.
- Z/Y/R/Q suffixes (zetta+) are rejected; their multipliers exceed
  int64.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Switch readonly_mode and sandbox_blocked scenarios from stderr_contains
  to exact expect.stderr (AGENTS.md prefers exact match when deterministic)
- Add comment to Sandbox.Truncate explaining why O_NONBLOCK bypasses
  allowedOpenFlags (hardcoded, not user-derived — different risk model)
- Add comment to parseSize explaining ParseInt vs ParseUint choice
- Add TestTruncatePentestFIFOTarget (unix-only) exercising the O_NONBLOCK
  FIFO-blocking guard, which is a key security property of Sandbox.Truncate

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Sandbox.Open checks s == nil and s.readOnly before proceeding; Sandbox.Truncate
did not, so a direct library caller on a default-mode (read-only) sandbox could
still write. Mirror the same guards so Truncate is consistent with Open as a
public API.

The interp wiring already guards against this at the runner level (callCtx.Truncate
is nil unless remediationMode && sandbox != nil), but the sandbox itself should
enforce its own invariants.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Extend the static analysis system with a new per-builtin check that
enforces which CallContext function fields each builtin is permitted to
access directly.

Key security invariants now statically verified:
- Only the "truncate" builtin may access callCtx.Truncate (the sole
  write-capable CallContext field).
- Only "find" and "xargs" may access RunCommand/RunCommandWithStdin.
- Only "cd" may access ChangeDir.
- Only "read" may access SetVar.

Implementation:
- callCtxAllFields (symbols_builtins.go): the complete set of tracked
  CallContext function-typed fields (plain data fields like Stdin/Now/Proc
  are excluded as they carry no elevated capability).
- builtinPerCommandCallContextFields (symbols_builtins.go): per-builtin
  allowlists, backfilled from actual source analysis.
- checkFileCallCtxFields (structural.go): AST walker that detects depth-1
  selector expressions (bareIdent.Field) where bareIdent is not a package
  import alias and Field is a known CallContext field.
- checkCallCtxFields (symbols_test.go): harness that validates allowlist
  integrity (no unknown fields) and per-builtin file compliance.
- TestBuiltinCallContextFields + 5 verification tests covering: clean
  pass, unauthorized Truncate access, unlisted field, field not in global
  set, and missing builtin entry.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Comment thread analysis/symbols_builtins.go Outdated
julesmcrt and others added 3 commits June 19, 2026 13:08
…er detection

The depth-1 check previously flagged any bare-ident selector whose field
name matched a tracked CallContext field, regardless of the variable's
actual type. This caused a false-positive in du: dh.ReadDir(1) (where dh
is an fs.ReadDirFile) was detected as a CallContext.ReadDir access, forcing
ReadDir into du's allowlist even though du never accesses callCtx.ReadDir.

Fix: replace the import-alias exclusion heuristic with precise holder
detection. findCallCtxHolderNames scans each file for identifiers statically
known to hold a *CallContext value — function parameters and struct fields
typed *CallContext or *<pkg>.CallContext. The depth-1 check now flags only
bare idents in this set; local variables of other types are ignored.

Consequences:
- dh.ReadDir (fs.ReadDirFile) is no longer flagged — ReadDir removed from du
- The false-positive note is removed from the map comment
- fileImportNames is deleted (no longer needed)
- findCallCtxBridgeFields is replaced by findCallCtxHolderNames (superset)
- Both depth-1 and depth-N use the same holders set

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…ecker

A developer could bypass the checker by aliasing callCtx to a local
variable (cc := callCtx; cc.Truncate) since only declaration-based
names (function params and struct fields typed *CallContext) were in
the holder set.

Fix: findCallCtxLocalAliases scans AssignStmt nodes in each file and
adds local variable names whose RHS is a *CallContext value expression
(bare holder ident or x.bridgeField selector). Iterated to stable so
transitive chains are covered (cc := callCtx; dd := cc).

Local aliases are computed per-file in phase 2 (not merged globally)
so a name aliased in file A does not pollute the holder set for file B.

New TestVerificationCallCtxLocalAlias injects cc := callCtxProbe;
cc.Truncate into cat and confirms detection.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Base automatically changed from jules.macret/truncate-builtin to main June 19, 2026 11:58
All conflicts were in files from the truncate-builtin base branch
(scenario YAMLs, interp/, README, SHELL_FEATURES). Our branch did not
touch these files; taking main's versions which include the
remediation_mode -> mode rename and follow-up fixes.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@datadog-prod-us1-6

Copy link
Copy Markdown
Contributor

Pipelines

Fix all issues with BitsAI

⚠️ Warnings

🚦 1 Pipeline job failed

Static Analysis Verification | Static Analysis Label Check   View in Datadog   GitHub Actions

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: ec27b2b | Docs | Datadog PR Page | Give us feedback!

@julesmcrt julesmcrt added the verified/analysis Human-reviewed static analysis changes label Jun 19, 2026
@julesmcrt
julesmcrt marked this pull request as ready for review June 19, 2026 12:09
@julesmcrt
julesmcrt requested a review from thieman as a code owner June 19, 2026 12:09

@AlexandreYang AlexandreYang left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice, thanks

@julesmcrt
julesmcrt added this pull request to the merge queue Jun 19, 2026
Merged via the queue into main with commit f6e8372 Jun 19, 2026
41 of 42 checks passed
@julesmcrt
julesmcrt deleted the jules.macret/callctx-analysis branch June 19, 2026 12:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

verified/analysis Human-reviewed static analysis changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants