mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -10:00
* 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]>
434 lines
15 KiB
Go
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)
|
|
}
|