Skip to content

Commit d8470d2

Browse files
deepagent-aiclaude
andauthored
V3.9.2 (#60)
### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** ### How did you verify your code works? ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ ### Checklist - [ ] I have tested my changes locally - [ ] I have not included unrelated changes in this PR _If you do not follow this template your PR will be automatically rejected._ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4e3f27 commit d8470d2

17 files changed

Lines changed: 895 additions & 106 deletions

File tree

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
export * as CommandIntent from "./command-intent"
2+
3+
// Command intent classification for the Plan Gate (U1/U9). The gate soft/hard-blocks MUTATING tools
4+
// while the plan latch is stale, but a shell command that only INSPECTS the world (ls, cat, grep,
5+
// git status, curl probes, …) must never be blocked — those are the agent's eyes, and blocking them
6+
// would make a stale plan impossible to diagnose and repair. This module decides whether a shell
7+
// command string is provably read-only.
8+
//
9+
// FAIL-SAFE CONTRACT (load-bearing): this classifier is used to RELAX the gate, so it must never
10+
// misclassify a mutating command as read-only. Any ambiguity — an unknown command, an unparseable
11+
// segment, a redirection, a write-capable operator, elevated privilege — resolves to `mutating`.
12+
// It is acceptable (merely slightly conservative) to classify a read-only command as mutating; it is
13+
// NOT acceptable to classify a mutating command as read-only. Every branch below is written so the
14+
// default answer is `mutating`.
15+
//
16+
// This is a pure, lexical analyzer (no tree-sitter): core has no shell-parser dependency, and a
17+
// lexical pass is the right tool for a fail-safe gate — it cannot "successfully parse" a hostile
18+
// command into a benign shape. deepagent-code's shell tool keeps its own tree-sitter path for
19+
// permission scanning; this is deliberately independent and stricter.
20+
21+
export type CommandIntent = "read_only" | "mutating"
22+
23+
// Prefix → arity: how many leading tokens define the "command" for a read-only match. Flags between
24+
// the command word and its subcommand are skipped by the matcher, so `git --no-pager status` still
25+
// matches the `git status` (arity 2) entry. Only commands that CANNOT mutate the filesystem, process
26+
// table, network state, or environment in a persistent way belong here. When in doubt, leave it out.
27+
const READ_ONLY_PREFIXES: ReadonlyArray<readonly string[]> = [
28+
// ── filesystem inspection ──
29+
["ls"],
30+
["cat"],
31+
["bat"],
32+
["head"],
33+
["tail"],
34+
["wc"],
35+
["file"],
36+
["stat"],
37+
["du"],
38+
["df"],
39+
["tree"],
40+
["realpath"],
41+
["readlink"],
42+
["basename"],
43+
["dirname"],
44+
["pwd"],
45+
["find"], // read-only UNLESS it carries an action flag (guarded separately below)
46+
// ── content search ──
47+
["grep"],
48+
["egrep"],
49+
["fgrep"],
50+
["rg"],
51+
["ag"],
52+
["ripgrep"],
53+
// ── environment / introspection (query forms only) ──
54+
["which"],
55+
["whereis"],
56+
["type"],
57+
["echo"],
58+
["printf"],
59+
["date"],
60+
["whoami"],
61+
["id"],
62+
["hostname"],
63+
["uname"],
64+
["env"], // read-only ONLY with no assignment/command args (guarded below)
65+
["printenv"],
66+
["locale"],
67+
["uptime"],
68+
["ps"],
69+
["top"],
70+
["htop"],
71+
["free"],
72+
["df"],
73+
["lsof"],
74+
["jobs"],
75+
["history"],
76+
// ── version / help probes ──
77+
["node", "--version"],
78+
["node", "-v"],
79+
["python", "--version"],
80+
["python3", "--version"],
81+
["go", "version"],
82+
["rustc", "--version"],
83+
["java", "-version"],
84+
// ── git (read-only subcommands only) ──
85+
["git", "status"],
86+
["git", "log"],
87+
["git", "diff"],
88+
["git", "show"],
89+
["git", "branch"],
90+
["git", "tag"],
91+
["git", "remote"],
92+
["git", "rev-parse"],
93+
["git", "rev-list"],
94+
["git", "describe"],
95+
["git", "blame"],
96+
["git", "ls-files"],
97+
["git", "ls-remote"],
98+
["git", "cat-file"],
99+
["git", "config", "--get"],
100+
["git", "config", "--list"],
101+
["git", "config", "-l"],
102+
["git", "shortlog"],
103+
["git", "reflog"],
104+
["git", "whatchanged"],
105+
// ── container / orchestration (query verbs only) ──
106+
["docker", "ps"],
107+
["docker", "images"],
108+
["docker", "logs"],
109+
["docker", "inspect"],
110+
["docker", "version"],
111+
["docker", "info"],
112+
["kubectl", "get"],
113+
["kubectl", "describe"],
114+
["kubectl", "logs"],
115+
["kubectl", "version"],
116+
// ── package-manager query verbs ──
117+
["npm", "ls"],
118+
["npm", "list"],
119+
["npm", "view"],
120+
["npm", "outdated"],
121+
["pip", "list"],
122+
["pip", "show"],
123+
["pip", "freeze"],
124+
["cargo", "tree"],
125+
["brew", "list"],
126+
["brew", "info"],
127+
// ── network probes (read-only unless they write a file; guarded below) ──
128+
["curl"], // mutating if it carries -o/-O/--output/--remote-name (guarded)
129+
]
130+
131+
// After fd-duplication spans (2>&1, 1>&2, >&2) are stripped, ANY remaining `>` is a file-writing
132+
// redirection (> truncate, >> append, <> read-write), which makes the segment mutating. Input `<`
133+
// alone is not mutating. We check for a bare `>` on the stripped text.
134+
135+
// Command words that are inherently mutating no matter their flags. Fast reject before prefix match.
136+
const MUTATING_COMMANDS = new Set<string>([
137+
"rm",
138+
"rmdir",
139+
"mv",
140+
"cp",
141+
"dd",
142+
"mkdir",
143+
"touch",
144+
"ln",
145+
"chmod",
146+
"chown",
147+
"chgrp",
148+
"truncate",
149+
"shred",
150+
"tee",
151+
"install",
152+
"sed", // sed alone is read-only, but `sed -i` mutates; treat as mutating unless we prove otherwise (guarded)
153+
"kill",
154+
"killall",
155+
"pkill",
156+
"reboot",
157+
"shutdown",
158+
"mkfs",
159+
"mount",
160+
"umount",
161+
"export",
162+
"unset",
163+
"set",
164+
"source",
165+
".",
166+
"eval",
167+
"exec",
168+
"apt",
169+
"apt-get",
170+
"yum",
171+
"dnf",
172+
"pacman",
173+
"systemctl",
174+
"service",
175+
"crontab",
176+
])
177+
178+
// Extract the shell segments that run as their own command. We split on the operators that separate
179+
// commands: && || ; | & (background) and newlines. This is intentionally coarse: a segment that
180+
// contains anything we cannot prove read-only makes the WHOLE command mutating.
181+
const SEGMENT_SPLIT = /(?:&&|\|\||[;\n|&])/
182+
183+
// Lexical tokenizer that keeps quoted spans intact (mirrors the tokenizer in tool/bash.ts).
184+
const tokenize = (segment: string): string[] => segment.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
185+
186+
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
187+
188+
// A token is a "flag" if it begins with `-` (short/long option).
189+
const isFlag = (token: string) => token.startsWith("-")
190+
191+
// Does the segment's leading command match a read-only prefix? Returns the matched prefix length (in
192+
// tokens consumed), or 0 if no read-only prefix matches. Intervening flags that are NOT part of the
193+
// prefix are skipped, so `git --no-pager status` still resolves to `git status`. A flag that IS the
194+
// next expected prefix component (e.g. the `--version` in `node --version`, the `--get` in
195+
// `git config --get`) is matched normally rather than skipped.
196+
const matchReadOnlyPrefix = (tokens: string[]): number => {
197+
const words = tokens.map(unquote)
198+
for (const prefix of READ_ONLY_PREFIXES) {
199+
let matched = 0
200+
let tokenCursor = 0
201+
for (; tokenCursor < words.length && matched < prefix.length; tokenCursor++) {
202+
const token = words[tokenCursor]
203+
if (token === prefix[matched]) {
204+
matched++
205+
continue
206+
}
207+
// Skip a non-matching flag only when the expected component is itself NOT a flag — otherwise a
208+
// flag-typed prefix component (--version/--get) could be silently skipped past.
209+
if (isFlag(token) && !isFlag(prefix[matched])) continue
210+
break
211+
}
212+
if (matched === prefix.length) return tokenCursor
213+
}
214+
return 0
215+
}
216+
217+
// `find` is read-only unless it carries an action predicate that executes or deletes.
218+
const FIND_MUTATING_ACTIONS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf"])
219+
220+
// `env` is read-only only as a bare query (`env`); `env FOO=bar cmd` runs a command with a mutated
221+
// environment, so anything past the command word makes it mutating.
222+
const isReadOnlySegment = (segment: string): boolean => {
223+
const trimmed = segment.trim()
224+
if (trimmed === "") return true // empty segment (e.g. trailing operator) is inert
225+
226+
// Any output redirection in the segment → mutating. Strip fd-duplication (2>&1, 1>&2, >&2) first,
227+
// which is not a file write, then look for a real `>`.
228+
const withoutFdDup = trimmed.replace(/\d*>&\d*/g, " ")
229+
if (withoutFdDup.includes(">")) return false
230+
231+
const tokens = tokenize(trimmed)
232+
if (tokens.length === 0) return false
233+
234+
// Command substitution / process substitution / backticks can smuggle an arbitrary command — we
235+
// cannot prove those read-only lexically, so fail safe.
236+
if (/\$\(|<\(|>\(|`/.test(trimmed)) return false
237+
238+
const head = unquote(tokens[0])
239+
240+
// A leading `VAR=value` assignment prefix (env inline) means a command runs with a mutated env, or
241+
// the assignment itself persists in the shell — fail safe.
242+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) return false
243+
244+
// Inherently mutating command word → mutating.
245+
if (MUTATING_COMMANDS.has(head)) return false
246+
247+
const prefixLen = matchReadOnlyPrefix(tokens)
248+
if (prefixLen === 0) return false // unknown/unlisted command → fail safe to mutating
249+
250+
// Command-specific guards for read-only prefixes that have mutating variants.
251+
const words = tokens.map(unquote)
252+
if (head === "find" && words.some((token) => FIND_MUTATING_ACTIONS.has(token))) return false
253+
if (head === "env") {
254+
// Only a bare `env` (optionally with -i/-u flags but no command/assignment) is read-only.
255+
const rest = words.slice(1).filter((token) => !isFlag(token))
256+
if (rest.length > 0) return false
257+
}
258+
if (head === "curl" && /\s-[oO]\b|--output\b|--remote-name\b/.test(trimmed)) return false
259+
260+
return true
261+
}
262+
263+
/**
264+
* Classify a shell command string as read-only or mutating for the Plan Gate.
265+
*
266+
* The command is split into segments on shell command separators; EVERY segment must be provably
267+
* read-only for the whole command to be read-only. Any segment that is mutating, unknown, or
268+
* unparseable makes the entire command `mutating` (fail-safe).
269+
*/
270+
export const classifyCommand = (command: string): CommandIntent => {
271+
if (typeof command !== "string" || command.trim() === "") return "mutating"
272+
// Mask fd-duplication spans (2>&1, 1>&2, >&2) before splitting so their internal `&` is not
273+
// mistaken for a background/`&&` command separator. Spans are captured by index and restored
274+
// exactly per-segment; the placeholder holds no separator or `>` char so it survives the split,
275+
// and isReadOnlySegment strips fd-dup again defensively.
276+
const fdSpans: string[] = []
277+
const masked = command.replace(/\d*>&\d*/g, (m) => {
278+
const token = " fd" + fdSpans.length + "fd "
279+
fdSpans.push(m)
280+
return token
281+
})
282+
const restore = (segment: string) => segment.replace(/ fd(\d+)fd /g, (_, idx) => fdSpans[Number(idx)] ?? "")
283+
const segments = masked.split(SEGMENT_SPLIT)
284+
for (const rawSegment of segments) {
285+
const segment = restore(rawSegment)
286+
if (!isReadOnlySegment(segment)) return "mutating"
287+
}
288+
return "read_only"
289+
}
290+
291+
export const isReadOnlyCommand = (command: string): boolean => classifyCommand(command) === "read_only"

packages/core/src/deepagent/hooks.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,31 @@ export const stopHookGate = (): HookHandler => (e) => {
6868
// U9 hard gate (high+): even with a fresh plan, a mutating tool must be BOUND to an active step.
6969
// hardGateMissBlocks=true (xhigh/max/ultra) -> block on a missing active step; false (high) -> warn.
7070
// The payload carries hardGate, hasActiveStep, hardGateMissBlocks.
71+
//
72+
// U1 anti-deadlock (production fix): the soft layer additionally downgrades a would-be block to a
73+
// WARN (tool executes, reminder attached) in two cases, so the gate can never permanently deny a
74+
// model its tools:
75+
// - staleReason === "user_appended": a new user message is a SOFT signal that the plan MIGHT no
76+
// longer match intent — nudge the model to re-align, but do not hard-block work the user is
77+
// actively asking for.
78+
// - graceRelease === true: the gate has already blocked this stale plan N times with no forward
79+
// progress (runtime-driven counter). Rather than loop forever against a model that will not
80+
// repair the plan, release with a strong reminder. This is the direct fix for the observed
81+
// deadlock (280 consecutive blocked bash calls).
7182
export const planGate = (): HookHandler => (e) => {
7283
if (e.name !== "before_tool_use") return { decision: "continue" }
7384
if (e.payload["isMutating"] !== true) return { decision: "allow" }
7485
// U1 soft layer: stale plan.
7586
if (e.payload["planStale"] === true) {
7687
const reason = "the plan is stale (reality changed); update the plan before further edits"
77-
return e.payload["lightweight"] === true
78-
? { decision: "warn", blockReason: reason }
79-
: { decision: "block", blockReason: reason }
88+
const graceReason =
89+
"the plan is stale and has not been updated after repeated attempts; this action is allowed to proceed, but you MUST call the `plan` tool to resync your plan with reality now"
90+
const userAppendedReason =
91+
"a new user message arrived; your plan may no longer match the request — review it and update the `plan` tool if the goal changed"
92+
if (e.payload["lightweight"] === true) return { decision: "warn", blockReason: reason }
93+
if (e.payload["graceRelease"] === true) return { decision: "warn", blockReason: graceReason }
94+
if (e.payload["staleReason"] === "user_appended") return { decision: "warn", blockReason: userAppendedReason }
95+
return { decision: "block", blockReason: reason }
8096
}
8197
// U9 hard layer: per-step binding (high+ only; lightweight never reaches here with hardGate set).
8298
if (e.payload["hardGate"] === true && e.payload["hasActiveStep"] !== true) {

0 commit comments

Comments
 (0)