Files
zero/internal/sandbox/risk.go
Kevin CodexandClaude Fable 5 2749447cdc fix(tools): make apply_patch tolerant and re-expose edit_file (#956)
* fix(tools): make apply_patch tolerant and re-expose edit_file

Head-to-head benchmarking against Grok Build on grok-4.6 (3 tasks × 3 runs)
showed Zero solving every task but spending 2.7× the tokens and 2.3× the
time on the multi-file feature task. Every one of the 20 apply_patch calls
in those runs failed, after which the model degraded to whole-file
write_file rewrites and read→write verification loops.

Root causes and fixes:

- The sandbox and the tool both rejected an absolute path that lies inside
  the workspace ("must stay inside the workspace"). Models routinely echo
  the absolute path read_file showed them. Absolute paths now flow through
  the normal workspace-scope validation (inside → allowed, outside → still
  denied); unified-diff headers are rewritten to a/ b/ relative form before
  git apply, and platform symlink prefixes (/var → /private/var) are
  normalised the same way the other file tools do.
- The structured parser demanded a byte-exact "*** Begin Patch" /
  "*** End Patch"; grok-4.6 writes "*** Begin Patch ***". Decorated marker
  spellings are accepted, and a unified range after "@@" ("-12,4 +12,6",
  optionally with a heading) no longer becomes a literal context search.
- Parse errors now carry a one-line format reminder so a failed call is
  recoverable in one retry instead of a format-guessing loop.
- edit_file (exact search/replace with uniqueness guard, CRLF and fuzzy
  fallbacks) is advertised to the model again alongside apply_patch, and
  the apply_patch description shows the structured format.
- Writers (edit_file, write_file, apply_patch) recorded a file's line total
  with lineCount, which is one higher than read_file's count for a file
  ending in a newline. RecordSeenRange resets every observation when the
  total changes, so a partial read_file after a successful edit silently
  discarded whole-file knowledge and the next edit was refused with
  "content that has not been read exactly in this session". All writers
  now record the same total read_file reports (trackedLineTotal).
- read_file's padded "  N | " line prefix was ~19% of every read (about
  1.5k tokens per 800-line file) and was re-sent on every later call. It is
  now the compact "N→" prefix models already know; nothing parsed the old
  form.
- The editing-discipline prompt steers: edit_file for a targeted change,
  apply_patch for multi-hunk/multi-file changes, write_file only for new
  files or full rewrites; a successful edit result is confirmation, so the
  file is not re-read to verify it.

Tests: regression tests for decorated markers, writer/reader line-total
agreement (edit → partial read → edit far away still succeeds), range hunk headers,
absolute in-workspace paths (structured + unified), outside-workspace
rejection, header-line-only relativisation, and tool visibility; the
prompt and tool-budget tests updated to the new contract.

Benchmark (grok-4.6, 3 tasks x 3 runs, harness in demo/bench-zero-vs-grok):
Zero 0.8.0 617 s / 1,527k tokens -> with this change 346 s / 493k tokens;
Grok Build 407 s / 713k. Every run of every configuration solved its task.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): keep CRLF line endings when relativizing patch headers

relativizeUnifiedPatchPaths split on a normalized "\n" and rejoined with
"\n", so any CRLF patch that needed a header rewrite lost its line endings
everywhere; git apply could then fail to match a CRLF file. Split on "\n"
only and carry each line's trailing "\r" through, including on rewritten
headers.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(sandbox): share the structured-patch classifier with apply_patch

Review follow-ups for #956:

- The tool accepted decorated structured markers ("***Begin Patch") that
  the sandbox's applyPatchPaths did not recognise, so for that spelling the
  boundary scanned the patch as a unified diff, extracted no targets and
  validated nothing. The marker classifier now lives in the sandbox package
  (StructuredPatchMarker / IsStructuredPatch) and the tool delegates to it,
  so both sides accept exactly the same set; tests cover inside, traversal
  and outside targets for the no-space spelling at the boundary.
- Document the containment model at the apply site: structured patches are
  applied through an opened os.Root (descriptor-relative, no-follow);
  unified diffs keep the pre-existing git-apply pathname flow, which the
  absolute-path rewrite does not widen (they become the same root-relative
  form relative paths always used).
- OpenAI addendum: the one-file-per-call rule names read_file, edit_file and
  write_file explicitly and allows a single multi-file apply_patch.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(sandbox): compare slash-normalised patch paths so the check passes on Windows

structuredPatchHeaderPaths normalises separators to "/", so the absolute
outside-workspace path in the boundary test must be compared in its slash
form; on Windows the backslash original never matched and the Smoke job
failed.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(sandbox): validate the parsed patch path at the scope boundary

For each accepted marker spelling, pass the exact path applyPatchRequestPaths
produced to scope.validate: an outside path must be denied and an inside
path accepted, so a separator-handling regression between the parser and the
scope cannot slip past the test.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): apply unified diffs in-process through the workspace root

Review follow-up for #956. Unified diffs were validated by pathname and then
handed to git apply, which reopened the targets by name — a check-to-use
window a symlink swap could exploit — and the absolute-path rewrite added in
this PR normalised `diff --git` headers with strings.Fields, which splits a
git C-quoted path containing whitespace.

Both go away by not shelling out at all: parseUnifiedPatch translates a
unified diff into the same operations a structured patch produces, and both
formats are applied by applyPatchOperations through os.OpenRoot — every
stat, read, create and write is descriptor-relative and refuses to follow a
link out of the root. git is no longer required by apply_patch.

Unified-diff support: modifications, creations (--- /dev/null), deletions
(+++ /dev/null), multiple hunks per file, git "rename from/to" (a move,
hunks optional) and "copy from/to" (a new copy operation whose destination
inherits only the tracker credit the source had), "\ No newline at end of
file" on either side, a/ b/ prefixes, C-quoted paths, CRLF patches, and
git's diff/index/mode lines. Hunks are placed at their "@@ -a,b" range when
the expected lines match there and located by context otherwise (so a
stale range still applies and duplicate blocks are disambiguated by the
range). Binary patches are rejected.

Removed: the git apply flow, relativizeUnifiedPatchPaths,
recheckPatchWriteTargets, missingPatchTargets, completeCreatedPatchTargets
and recordCreatedPatchTargets. validatePatchPaths/changedFilesFromPatch stay
for mutation-target listing.

Tests: the existing unified-diff suite now runs through the in-process
applier unchanged; new coverage for quoted whitespace paths (inside applies,
outside is refused untouched), a workspace path swapped for a symlink that
escapes the root (both formats refused, outside file unmodified), create +
delete + no-newline markers, range hint vs context fallback, rename with a
hunk, and binary/empty rejection.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): harden unified-diff parsing and cover the copy operation

Review follow-ups for #956 (in-process unified applier):

- A hunk that ends before its declared line counts is reported as
  malformed instead of silently absorbing the next file's ---/+++ header
  pair (which then failed later against the wrong file). Both shapes are
  caught: leftover counts at a header or at end of input, and a --- line
  directly followed by +++ inside a hunk. The element after a patch's final
  newline is no longer counted as an empty context line, so a truncated
  hunk cannot look complete.
- "\ No newline at end of file" may only follow a file's final hunk; a
  marker before another hunk is rejected rather than leaking into that
  hunk's result.
- Zero-context insertions (git diff -U0) are documented as
  position-trusting by design, matching git apply without fuzz; hunks with
  context are verified at the range before it is used.

Tests: copy from/to (source kept, destination created with the hunk
applied and tracked with the source's whole-file state; existing
destination refused untouched; copy onto itself refused), over-declared and
truncated hunk counts, and the no-newline marker before vs after the final
hunk.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): require an @@ line before treating ---/+++ inside a hunk as a file boundary

A removed "-- x" line directly followed by an added "++ y" line is valid
hunk content that also matches the ---/+++ header shape; the boundary check
now additionally requires the following line to be a hunk header, and a
regression test keeps such a pair intact. End-of-input strictness for a hunk
cut short is documented as deliberate: applying a truncated hunk would make
a partial change (removals without their replacements).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* refactor(tools): share one line-count implementation between reader and writer

sourceLineCount and trackedLineTotal computed the same count; keeping two
copies invites exactly the reader/writer drift this PR fixed. The reader now
delegates to trackedLineTotal, which carries the tracker contract.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): verify a unified deletion's expected content before removing the file

A unified diff's "+++ /dev/null" hunks state the content being removed, but
the in-process applier dropped them and deleted unconditionally, so a stale
deletion removed a file whose current content the patch never described.
Unified deletions now keep their hunks, require at least one, and run them
through the same verifier as updates: the hunks must match the current file
and leave nothing behind, otherwise the deletion is refused and the file is
untouched. A structured "*** Delete File" keeps its unconditional meaning.

Hardening from the same review: every non-create change re-reads its source
through the opened root immediately before commit and refuses to proceed if
the bytes differ from what was planned.

Tests: matching deletion, stale deletion (file byte-for-byte unchanged),
partial deletion, header-only deletion, multi-hunk deletion with one stale
hunk (nothing removed) and all-good hunks, structured delete unchanged, and
a source changed between planning and commit for update and delete.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(tools): a move with a missing source is refused before publishing

The pre-commit recheck now refuses a move whose source vanished after
planning instead of publishing the destination and then failing; update the
test to the safer contract (error names the recheck, nothing is created).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): byte-exact unified deletions and git's header-only empty-file forms

Review follow-ups for #956:

- A unified deletion is verified byte for byte: the removed lines (with the
  removed side's "\ No newline at end of file" honoured) must equal the
  current file exactly, with only the file's own line-ending style
  normalised. None of the whitespace tolerance used to locate update hunks
  applies, and a whitespace-only remainder no longer passes; a deletion is
  not recoverable, so it fails closed.
- git describes an empty file's deletion or creation with header lines only
  ("diff --git", "deleted file mode"/"new file mode", "index") and no
  hunks. Both forms are recognised: the deletion is applied only when the
  current file is empty, the creation writes an empty file, and they compose
  with hunk-carrying files in the same patch.
- The pre-commit recheck test also covers the copy operation and asserts no
  destination is created on refusal.

Tests: whitespace/indentation/blank-line/space-only/missing-newline
mismatches all refused with the file unchanged, matching no-newline marker
and CRLF file accepted, header-only deletion of a non-empty file refused,
multi-file patch with header-only delete + create + a normal hunk.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(tools): remove the move source after planning via a deterministic hook

The refused-move test now creates source.txt, plans the move, and removes
the file through a test-only pre-commit hook (structuredPatchBeforeCommit)
so it exercises the post-planning disappearance path rather than a source
that was never there. Assertions unchanged: refused before publishing, no
destination created.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(tui): read_file fixtures use the compact N→ prefix

Three TUI fixtures still built fake read_file output in the legacy
"  N | text" shape; they pass either way but pinned an outdated contract.
They now mirror what appendReadFileRange emits, and the shape comment and
the expanded-card assertion are updated.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): name the committed files when a patch is partially applied

Review follow-up for #956. When a later change in a multi-file patch fails
(for example the pre-commit recheck refuses a file that changed after
planning), the error now lists exactly which files were already committed
and states that the rest are unchanged, instead of a generic "partially
applied" warning. The model can then re-read only what actually changed.

Test: a three-file structured patch whose second file is altered after
planning fails with "already committed: first.txt", first.txt holds the
patched content, and the other two files are untouched.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-25 20:23:48 +08:00

434 lines
15 KiB
Go

package sandbox
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
var (
// destructiveCommandPattern matches the highest-risk shell forms:
// - rm -rf (with combined/reordered r/f flags) targeting /, $HOME (bare,
// quoted, or ${HOME} braced), ~, or *, with an optional `--` before the
// target. Each target alternative tolerates optional surrounding quotes
// so `rm -rf "/"` / `rm -rf '/'` cannot slip past the gate.
// - chmod with combined/reordered flags and an octal-or-777 mode applied
// RECURSIVELY (a -R/-r flag) or to root / a sensitive SYSTEM tree
// (/, /etc, /usr, /bin, /var, … — e.g. chmod -Rf 777 /, chmod -R 0777 /,
// chmod 777 -R /etc, chmod 777 /etc). A single-file chmod 777 — including
// an absolute non-system path like `chmod 777 /tmp/build.sh` or a relative
// `chmod 777 script.sh` — is intentionally NOT flagged; the intent is
// recursive/directory-tree or system-tree chmod.
// - mkfs, dd if=, chown -R.
destructiveCommandPattern = regexp.MustCompile(`(?i)(\brm\s+(-[A-Za-z]*r[A-Za-z]*f|-rf|-fr)\s+(--\s+)?["']?(\$\{?HOME\}?|/|~|\*)["']?|\bmkfs\b|\bdd\s+if=|\bchmod\s+(-[A-Za-z]*[rR][A-Za-z]*\s+)+0?777\b|\bchmod\s+(-\S+\s+)*0?777\s+-[A-Za-z]*[rR][A-Za-z]*\b|\bchmod\s+(-\S+\s+)*0?777\s+["']?/(\s|$|["']|(etc|usr|bin|sbin|lib|lib64|var|boot|opt|root|sys|proc|dev)\b)|\bchown\s+-R\b)`)
// pipedInstallerPattern matches the fetch-and-execute idiom: a remote fetch
// (curl/wget/fetch/aria2c) piped into a POSIX shell, with or without a space
// and across sh/bash/zsh/ksh/dash (so `curl x|sh`, `wget url | bash`, `| zsh`).
// A purely local pipe into a shell (e.g. `printf … | sh`, `cat ./s | bash`)
// is NOT a piped installer and must not be flagged.
pipedInstallerPattern = regexp.MustCompile(`(?i)\b(curl|wget|fetch|aria2c)\b[^|]*\|\s*(ba|z|k|da)?sh\b`)
// unparseableNetworkPattern is used only after the shell parser fails. At
// that point the command is already marked too complex, so this intentionally
// favors catching obvious network programs over proving exact shell syntax.
unparseableNetworkPattern = regexp.MustCompile(`(?i)\b(curl|wget|fetch|aria2c|ssh|scp|sftp|rsync|nc|ncat|netcat|telnet|ftp|npx|http-server|vite|next|nuxt|astro)\b|\b(npm|pnpm|yarn|bun|pip|pip2|pip3)\s+(install|add|publish|login|start|serve|dev|preview|run\s+(start|serve|dev|preview)|exec|x|dlx)\b|\bgo\s+get\b|\bgit\s+clone\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`)
// destructiveExtraPatterns hold high-severity patterns that the legacy
// destructiveCommandPattern does not already cover. Folded in from the
// blueprint safe_bash.go without duplicating existing matches.
destructiveExtraPatterns = []*regexp.Regexp{
// Fork bomb (and minor spacing variants).
regexp.MustCompile(`:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:`),
// Writing to a raw block device (dd of=, redirect to /dev/sdX, etc.).
regexp.MustCompile(`(?i)>\s*/dev/(sd[a-z]+\d*|nvme\d+n\d+(p\d+)?|hd[a-z]+\d*|xvd[a-z]+\d*|mmcblk\d+)`),
regexp.MustCompile(`(?i)\bof=/dev/(sd[a-z]+\d*|nvme\d+n\d+(p\d+)?|hd[a-z]+\d*|xvd[a-z]+\d*|mmcblk\d+)`),
// rm targeting a dangerous root (/, /*, ~, $HOME, *) with ANY mix of
// short/long flags (incl. --no-preserve-root) in any order, an optional
// `--` separator, and optional surrounding quotes — so e.g.
// `rm --no-preserve-root -rf -- "/"` and `rm --no-preserve-root -rf "/"`
// cannot slip past the gate.
regexp.MustCompile(`(?i)\brm\s+(-{1,2}\S+\s+)*(--\s+)?["']?(/\*?|~|\$\{?HOME\}?|\*)["']?(\s|$)`),
// mkfs.<fstype> form (e.g. mkfs.ext4) not caught by the bare \bmkfs\b above when followed by a dot.
regexp.MustCompile(`(?i)\bmkfs\.[a-z0-9]+\b`),
}
)
func matchesDestructive(command string) bool {
if destructiveCommandPattern.MatchString(command) {
return true
}
for _, pattern := range destructiveExtraPatterns {
if pattern.MatchString(command) {
return true
}
}
return false
}
func matchesUnparseableNetwork(command string) bool {
return unparseableNetworkPattern.MatchString(command)
}
func Classify(request Request) Risk {
return classifyWithScope(request, nil)
}
func classifyWithScope(request Request, scope *Scope) Risk {
categories := map[string]bool{}
level := RiskLow
add := func(category string, risk RiskLevel) {
categories[category] = true
if riskRank(risk) > riskRank(level) {
level = risk
}
}
switch NormalizeSideEffect(request.SideEffect) {
case SideEffectRead:
add("read", RiskLow)
case SideEffectWrite:
add("write", RiskMedium)
case SideEffectShell:
add("shell", RiskHigh)
case SideEffectNetwork:
add("network", RiskHigh)
case SideEffectLocalControl:
add("local_control", RiskHigh)
case SideEffectLocalBrowser:
add("local_browser", RiskHigh)
case SideEffectLocalDesktop:
add("local_desktop", RiskHigh)
case SideEffectLocalTerminal:
add("local_terminal", RiskHigh)
case SideEffectOutOfWorkspace:
add("out_of_workspace", RiskCritical)
case SideEffectNone:
// Control-only tool (e.g. escalate_model): no read/write/shell/network
// effect, so it contributes no side-effect risk category and stays low.
}
// The bash tool accepts the command under any of these aliases; resolve the
// first non-empty so destructive/network/piped-installer classification
// cannot be bypassed by choosing a different alias key.
command := firstArgString(request.Args, "command", "cmd", "script", "shell")
if command != "" {
if matchesDestructive(command) {
add("destructive", RiskCritical)
}
if pipedInstallerPattern.MatchString(command) {
add("piped_installer", RiskCritical)
}
// AST second opinion (analyzer.go): walks the parsed shell tree, so it
// catches destructive/network programs the regexes miss — e.g. shred,
// fdisk, parted, and commands hidden behind sudo/env wrappers or a
// `sh -c <payload>` launcher — and flags an unparseable (obfuscated)
// script as elevated risk. It only ADDS categories, so a benign,
// parseable command is classified exactly as before.
analysis := AnalyzeCommand(command)
if analysis.Network {
add("network", RiskCritical)
}
if analysis.TooComplex && matchesUnparseableNetwork(command) {
add("network", RiskCritical)
}
if analysis.Destructive {
add("destructive", RiskCritical)
}
if analysis.TooComplex {
add("unparseable_command", RiskHigh)
}
}
for _, path := range requestPaths(request) {
if filepath.IsAbs(path) {
add("absolute_path", RiskMedium)
}
if path == ".." || strings.HasPrefix(filepath.ToSlash(filepath.Clean(path)), "../") {
add("path_escape", RiskCritical)
}
if request.WorkspaceRoot != "" {
var block *pathBlock
if scope != nil {
block = scope.validate(path)
} else {
block = validateWorkspacePath(request.WorkspaceRoot, path)
}
if block != nil {
switch block.Code {
case BlockSymlinkTraversal:
add("symlink_traversal", RiskCritical)
default:
add("out_of_workspace", RiskCritical)
}
}
}
}
names := make([]string, 0, len(categories))
for category := range categories {
names = append(names, category)
}
sort.Strings(names)
return Risk{
Level: level,
Categories: names,
Reason: riskReason(level, names),
}
}
func HasRiskCategory(risk Risk, category string) bool {
for _, candidate := range risk.Categories {
if candidate == category {
return true
}
}
return false
}
func riskRank(level RiskLevel) int {
switch level {
case RiskLow:
return 0
case RiskMedium:
return 1
case RiskHigh:
return 2
case RiskCritical:
return 3
default:
return 0
}
}
func riskReason(level RiskLevel, categories []string) string {
if len(categories) == 0 {
return string(level)
}
return fmt.Sprintf("%s risk: %s", level, strings.Join(categories, ", "))
}
func argString(args map[string]any, key string) string {
if args == nil {
return ""
}
value, ok := args[key]
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case fmt.Stringer:
return strings.TrimSpace(typed.String())
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
// firstArgString returns the first non-empty argument value among keys.
func firstArgString(args map[string]any, keys ...string) string {
for _, key := range keys {
if value := argString(args, key); value != "" {
return value
}
}
return ""
}
func requestPaths(request Request) []string {
paths := []string{}
// Keep this aligned with the path-arg alias lists the tools accept (see
// aliasedStringArg in write_file/edit_file/read_file/grep/glob/list). The
// sandbox gates by arg-key name, so any alias a tool resolves but the sandbox
// does not inspect would let a model route a write/read around the
// workspace+symlink boundary.
for _, key := range []string{"path", "file", "file_path", "filepath", "filename", "cwd", "workdir", "dir", "directory"} {
if value := argString(request.Args, key); value != "" {
paths = append(paths, value)
}
}
if request.ToolName == "apply_patch" {
paths = append(paths, applyPatchRequestPaths(request.Args)...)
}
return paths
}
func applyPatchRequestPaths(args map[string]any) []string {
patch := firstArgString(args, "patch", "diff")
if patch == "" {
return nil
}
cwd := firstArgString(args, "cwd")
var paths []string
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
if cwd != "" && filepath.Clean(cwd) != "." && !filepath.IsAbs(path) {
path = filepath.Join(cwd, path)
}
paths = append(paths, path)
}
return paths
}
func applyPatchPathBlock(request Request) *pathBlock {
if request.ToolName != "apply_patch" {
return nil
}
patch := firstArgString(request.Args, "patch", "diff")
if patch == "" {
return nil
}
// Only relative traversal is rejected up front. Absolute paths flow through
// the regular workspace-scope validation below (requestPaths), which accepts
// one inside the workspace and denies one outside — a model that echoes the
// absolute path read_file showed it must not be blocked for that alone.
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
if path == ".." || strings.HasPrefix(path, "../") {
return &pathBlock{
Code: BlockOutsideWorkspace,
Path: path,
Reason: fmt.Sprintf("patch path %q must stay inside the workspace", path),
}
}
}
return nil
}
// structuredPatchMarkerPattern is the single classifier for structured-patch
// markers, shared by the sandbox boundary and the apply_patch tool (which
// imports this package). It accepts the canonical "*** Begin Patch" /
// "*** End Patch" and the decorated spellings models emit ("*** Begin Patch ***",
// "***Begin Patch", trailing whitespace). Both sides must agree: a spelling the
// tool would apply but the sandbox did not recognise would make the sandbox
// scan the patch as a unified diff, extract no targets, and validate nothing.
var structuredPatchMarkerPattern = regexp.MustCompile(`^\*{3}\s*(Begin|End) Patch\s*\**\s*$`)
// StructuredPatchMarker classifies a line as the "begin" or "end" marker of a
// structured patch, or "" when it is neither.
func StructuredPatchMarker(line string) string {
match := structuredPatchMarkerPattern.FindStringSubmatch(strings.TrimSpace(line))
if match == nil {
return ""
}
return strings.ToLower(match[1])
}
// IsStructuredPatch reports whether patch opens with a structured begin marker.
// The tool applies exactly the patches this returns true for, so the sandbox
// extracts structured header paths for exactly the same set.
func IsStructuredPatch(patch string) bool {
first, _, _ := strings.Cut(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "\n")
return StructuredPatchMarker(first) == "begin"
}
func applyPatchPaths(patch string) []string {
if IsStructuredPatch(patch) {
return structuredPatchHeaderPaths(patch)
}
return patchHeaderPaths(patch)
}
// structuredPatchHeaderPaths is intentionally a small, conservative scanner for
// the sandbox boundary. The executor validates the complete hunk grammar before
// writing; the sandbox only needs every possible target path so it can reject an
// unsafe request before the tool runs.
func structuredPatchHeaderPaths(patch string) []string {
var paths []string
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
trimmed := strings.TrimSpace(line)
for _, prefix := range []string{"*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "} {
if path, ok := strings.CutPrefix(trimmed, prefix); ok {
path = strings.ReplaceAll(filepath.ToSlash(strings.TrimSpace(path)), "\\", "/")
if path != "" {
paths = append(paths, path)
}
break
}
}
}
return paths
}
func patchHeaderPaths(patch string) []string {
var paths []string
oldRemaining, newRemaining := 0, 0
inHunk := false
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
switch {
case strings.HasPrefix(line, "-"):
oldRemaining--
case strings.HasPrefix(line, "+"):
newRemaining--
case strings.HasPrefix(line, "\\"):
default:
oldRemaining--
newRemaining--
}
continue
}
inHunk = false
switch {
case strings.HasPrefix(line, "diff --git "):
fields := strings.Fields(line)
if len(fields) >= 4 {
paths = append(paths, stripPatchPrefix(fields[2]), stripPatchPrefix(fields[3]))
}
case strings.HasPrefix(line, "@@"):
oldRemaining, newRemaining = parsePatchHunkCounts(line)
inHunk = oldRemaining > 0 || newRemaining > 0
case strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "):
fields := strings.Fields(line)
if len(fields) >= 2 {
paths = append(paths, stripPatchPrefix(fields[1]))
}
}
}
return paths
}
func parsePatchHunkCounts(line string) (int, int) {
_, rest, ok := strings.Cut(line, "@@")
if !ok {
return 0, 0
}
rangeSection := rest
if before, _, ok := strings.Cut(rest, "@@"); ok {
rangeSection = before
}
old, next := 0, 0
for _, field := range strings.Fields(rangeSection) {
switch {
case strings.HasPrefix(field, "-"):
old = patchHunkCount(field[1:])
case strings.HasPrefix(field, "+"):
next = patchHunkCount(field[1:])
}
}
return old, next
}
func patchHunkCount(spec string) int {
if _, count, ok := strings.Cut(spec, ","); ok {
if n, err := strconv.Atoi(count); err == nil {
return n
}
return 0
}
return 1
}
func stripPatchPrefix(path string) string {
path = strings.TrimSpace(path)
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
path = path[2:]
}
return filepath.ToSlash(path)
}