|
| 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" |
0 commit comments