agent-from-zero 0.34.2 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,7 @@
1
1
  import path from "node:path"; // used to resolve and split file paths
2
+ import fs from "node:fs"; // resolve symlinks before auto mode grants file access
3
+ import os from "node:os"; // `cd ~` in the recoverable-shell analyzer
4
+ import { execFileSync } from "node:child_process"; // ask git whether a deletion target is disposable
2
5
  import { CONFIG } from "./config.js"; // user-configured allow/deny rules from settings files
3
6
  // ---- The no-fly zone --------------------------------------------------------
4
7
  // Writes here are never OK, in any mode. .git can destroy history, shell rc
@@ -23,10 +26,11 @@ function noFlyHit(p) {
23
26
  // ---- Bash command analysis --------------------------------------------------
24
27
  // Input-aware safety: run_bash is not one tool, it is a thousand tools wearing
25
28
  // a coat. "ls" and "rm -rf" deserve different treatment, so we look at the input.
29
+ const GIT_DIR_RULE = "touches the .git directory";
26
30
  // Hard stops. Not a question — these never run.
27
31
  const BASH_DENY = [
28
32
  [/\brm\s+(-[a-zA-Z]+\s+)*['"]?(\/|~)['"]?(\s|$)/, "rm targeting / or ~ — catastrophic"], // rm -rf / and rm -rf ~
29
- [/(^|[\s;&|/])\.git(\/|\s|$)/, "touches the .git directory"], // any command naming .git — the / in the prefix class catches absolute paths like /repo/.git
33
+ [/(^|[\s;&|/])\.git(\/|\s|$)/, GIT_DIR_RULE], // any command naming .git — the / in the prefix class catches absolute paths like /repo/.git
30
34
  [/(^|[\s;&|/])\.env(\s|$|[;&|])/, ".env holds secrets"], // any command that names .env
31
35
  [/(^|[\s;&|/])\.ssh(\/|\s|$)/, ".ssh holds credentials"], // any command that names .ssh — same / fix
32
36
  ];
@@ -52,24 +56,421 @@ function userBashRules() {
52
56
  deny: CONFIG.permissions.deny.filter((r) => !r.startsWith("tool:")), // command substrings to block
53
57
  };
54
58
  }
59
+ // Split into simple commands on && || ; | and newlines. null = syntax we do not
60
+ // model: subshells, background &, command/process substitution, file redirection.
61
+ function shellSegments(command) {
62
+ // Heredoc bodies are data (commit messages), not commands. A quoted delimiter
63
+ // disables expansion; an unquoted one would run $(...) inside, so forbid it.
64
+ // The usual multi-line commit message, `-m "$(cat <<'EOF' … EOF\n)"`, is a
65
+ // literal too: with a quoted delimiter nothing inside expands, and `cat` of a
66
+ // heredoc only echoes it. Collapse it to a plain word before tokenizing; any
67
+ // other command substitution still falls through to review.
68
+ command = command.replace(/"\$\(cat <<-?\s*(['"])([A-Za-z_][\w-]*)\1\n[\s\S]*?\n\t*\2\n\s*\)"/g, '"heredoc-text"');
69
+ const lines = command.split("\n");
70
+ const kept = [];
71
+ for (let i = 0; i < lines.length; i++) {
72
+ kept.push(lines[i]);
73
+ const doc = /<<-?\s*(['"]?)([A-Za-z_][\w-]*)\1/.exec(lines[i]);
74
+ if (!doc)
75
+ continue;
76
+ let end = i + 1;
77
+ while (end < lines.length && lines[end].replace(/^\t+/, "") !== doc[2])
78
+ end++;
79
+ if (end === lines.length)
80
+ return null; // unterminated heredoc
81
+ if (!doc[1] && /[$`]/.test(lines.slice(i + 1, end).join("\n")))
82
+ return null;
83
+ i = end;
84
+ }
85
+ const text = kept.join("\n");
86
+ const segments = [[]];
87
+ let word = null; // "as": closures below assign it, which narrowing cannot see
88
+ const cur = () => (word ??= { text: "", dynamic: false, glob: false });
89
+ const flush = () => { if (word)
90
+ segments[segments.length - 1].push(word); word = null; };
91
+ for (let i = 0; i < text.length; i++) {
92
+ const c = text[i];
93
+ if (c === "'") { // single quotes: literal
94
+ const end = text.indexOf("'", i + 1);
95
+ if (end < 0)
96
+ return null;
97
+ cur().text += text.slice(i + 1, end);
98
+ i = end;
99
+ }
100
+ else if (c === '"') { // double quotes: literal except $ expansion
101
+ const w = cur();
102
+ let j = i + 1;
103
+ for (; j < text.length && text[j] !== '"'; j++) {
104
+ if (text[j] === "`" || (text[j] === "$" && text[j + 1] === "("))
105
+ return null;
106
+ if (text[j] === "$")
107
+ w.dynamic = true;
108
+ if (text[j] === "\\")
109
+ j++;
110
+ w.text += text[j] ?? "";
111
+ }
112
+ if (j >= text.length)
113
+ return null;
114
+ i = j;
115
+ }
116
+ else if (c === "\\") {
117
+ if (text[i + 1] !== "\n" && i + 1 < text.length)
118
+ cur().text += text[i + 1];
119
+ i++;
120
+ }
121
+ else if (c === ">" || c === "<" || (c === "&" && text[i + 1] === ">")) {
122
+ if (word && /^\d+$/.test(word.text))
123
+ word = null; // "2>": the fd, not an argument
124
+ else
125
+ flush();
126
+ let op = c === "&" ? "&" : "";
127
+ if (c === "&")
128
+ i++;
129
+ op += text[i];
130
+ while (/[<>&-]/.test(text[i + 1] ?? ""))
131
+ op += text[++i];
132
+ while (text[i + 1] === " " || text[i + 1] === "\t")
133
+ i++;
134
+ let target = "";
135
+ while (i + 1 < text.length && !/[\s;&|<>]/.test(text[i + 1]))
136
+ target += text[++i];
137
+ if (target.includes("("))
138
+ return null; // <(...) process substitution runs a command
139
+ if (op.startsWith("<"))
140
+ continue; // input redirection and heredocs only read
141
+ if (op.endsWith("&") ? /^[12]$/.test(target) : target.replace(/^['"]|['"]$/g, "") === "/dev/null")
142
+ continue;
143
+ return null; // redirection into a file writes it
144
+ }
145
+ else if (c === "`" || c === "(" || c === ")" || c === "{" || c === "}") {
146
+ return null;
147
+ }
148
+ else if (c === "$") {
149
+ if (text[i + 1] === "(")
150
+ return null;
151
+ cur().dynamic = true;
152
+ cur().text += c;
153
+ }
154
+ else if (c === "*" || c === "?" || c === "[") {
155
+ cur().glob = true;
156
+ cur().text += c;
157
+ }
158
+ else if (c === " " || c === "\t") {
159
+ flush();
160
+ }
161
+ else if (c === "\n" || c === ";" || c === "|" || c === "&") {
162
+ flush();
163
+ if (c === "&" && text[i + 1] !== "&")
164
+ return null; // backgrounding outlives the review
165
+ if ((c === "&" || c === "|") && text[i + 1] === c)
166
+ i++;
167
+ segments.push([]);
168
+ }
169
+ else {
170
+ cur().text += c;
171
+ }
172
+ }
173
+ flush();
174
+ return segments.filter((words) => words.length > 0);
175
+ }
176
+ // Pure readers. Flags that turn one into a writer or a code runner are listed.
177
+ const READ_ONLY_COMMANDS = new Set(["ls", "pwd", "cat", "head", "tail", "wc", "grep", "egrep", "fgrep", "rg", "du", "df", "file", "stat",
178
+ "which", "whereis", "type", "echo", "printf", "date", "sort", "cut", "tr", "diff", "cmp", "tree", "jq", "basename", "dirname",
179
+ "realpath", "readlink", "true", "nl", "column", "whoami", "uname", "hostname", "sleep"]);
180
+ const WRITING_FLAG = /^(-o|--output(=.*)?|-s|--set(=.*)?|--pre(=.*)?)$/; // sort/tree -o, date -s, rg --pre
181
+ const inside = (root, target) => {
182
+ const rel = path.relative(root, target);
183
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
184
+ };
185
+ // Recoverable ground is not just the directory the agent started in: one
186
+ // session works across repos (a monorepo, its worktrees) and scratch files.
187
+ // Any git work tree is recoverable ground, and temp directories are disposable
188
+ // by definition. A repo rooted at home or / (a dotfiles repo) does not count,
189
+ // or it would turn the whole home directory into "project".
190
+ const TEMP_DIRS = [...new Set([os.tmpdir(), "/tmp"].flatMap((dir) => { try {
191
+ return [fs.realpathSync(dir)];
192
+ }
193
+ catch {
194
+ return [];
195
+ } }))];
196
+ export const inTempDir = (target) => TEMP_DIRS.some((dir) => inside(dir, target));
197
+ const topLevels = new Map(); // directory → its git work tree, per session
198
+ export function gitWorkTree(target) {
199
+ let dir;
200
+ try {
201
+ dir = resolveFileTarget(target);
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ while (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
207
+ dir = path.dirname(dir);
208
+ if (!topLevels.has(dir)) {
209
+ let top = null;
210
+ try {
211
+ top = fs.realpathSync(execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }).trim());
212
+ }
213
+ catch { /* not in a work tree (or inside .git itself) */ }
214
+ topLevels.set(dir, top === os.homedir() || top === path.parse(dir).root ? null : top);
215
+ }
216
+ return topLevels.get(dir);
217
+ }
218
+ // A deletion is recoverable when the target is scratch (inside a temp dir) or
219
+ // git calls it disposable in its own repo: ignored (build output, caches) with
220
+ // nothing tracked beneath it. Symlinks are left to review: `rm -r link/`
221
+ // deletes what the link points at.
222
+ function disposable(target) {
223
+ try {
224
+ const real = path.join(resolveFileTarget(path.dirname(target)), path.basename(target));
225
+ if (noFlyHit(target) || noFlyHit(real))
226
+ return false;
227
+ if (fs.existsSync(real) && fs.lstatSync(real).isSymbolicLink())
228
+ return false;
229
+ if (inTempDir(real))
230
+ return true;
231
+ const top = gitWorkTree(real);
232
+ if (!top || !inside(top, real))
233
+ return false;
234
+ const git = (args) => execFileSync("git", args, { cwd: top, encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] });
235
+ const rel = path.relative(top, real);
236
+ git(["check-ignore", "-q", "--", rel]); // exit 1 (throws) unless ignored
237
+ return git(["ls-files", "--", rel]).trim() === "";
238
+ }
239
+ catch {
240
+ return false;
241
+ }
242
+ }
243
+ // gh subcommands that only read GitHub. `gh api` reads unless a method or a
244
+ // field flag (which switches it to POST) is present; tokens are never printed.
245
+ function ghEffect(words) {
246
+ const [noun, verb] = words;
247
+ // Additive, and closable or deletable afterwards, like sending a message.
248
+ if ((noun === "pr" || noun === "issue") && (verb === "create" || verb === "comment"))
249
+ return "write";
250
+ if (noun === "api")
251
+ return words.slice(1).some((w) => /^(-X|--method|-f|-F|--field|--raw-field|--input)(=|$)/.test(w)) ? null : "read";
252
+ if (noun === "search" || (noun === "--version" && words.length === 1))
253
+ return "read";
254
+ if (noun === "auth")
255
+ return verb === "status" && !words.some((w) => /^(-t|--show-token)$/.test(w)) ? "read" : null;
256
+ return ["pr", "issue", "run", "repo", "release", "workflow", "label"].includes(noun) && ["view", "list", "diff", "checks", "status"].includes(verb) ? "read" : null;
257
+ }
258
+ // git subcommands by effect: reads, or writes that git itself can undo
259
+ // (reflog, index, a normal push that a revert can follow). Discarding
260
+ // uncommitted work and rewriting remote history are never here.
261
+ function gitEffect(words, inWorkTree) {
262
+ const [sub, ...rest] = words[0] === "--no-pager" ? words.slice(1) : words;
263
+ const flags = rest.filter((w) => w.startsWith("-"));
264
+ const plain = rest.filter((w) => !w.startsWith("-"));
265
+ const write = inWorkTree ? "write" : null; // reflog and index exist only inside a work tree
266
+ switch (sub) {
267
+ case "status":
268
+ case "log":
269
+ case "diff":
270
+ case "show":
271
+ case "blame":
272
+ case "ls-files":
273
+ case "ls-remote":
274
+ case "rev-parse":
275
+ case "shortlog":
276
+ case "describe":
277
+ case "grep":
278
+ case "cat-file":
279
+ case "merge-base":
280
+ case "rev-list":
281
+ case "for-each-ref":
282
+ case "show-ref":
283
+ case "name-rev":
284
+ case "ls-tree":
285
+ case "check-ignore":
286
+ case "cherry":
287
+ case "range-diff":
288
+ return flags.some((f) => /^(--output(=.*)?|-O.*|--open-files-in-pager.*)$/.test(f)) ? null : "read";
289
+ case "remote": return rest.every((w) => /^(-v|--verbose)$/.test(w)) || (plain[0] === "get-url" && plain.length === 2) ? "read" : null;
290
+ case "config":
291
+ if (rest.length === 1 && /^[\w.-]+$/.test(rest[0]) && !/token|password|secret/i.test(rest[0]))
292
+ return "read"; // `git config user.name`
293
+ return rest.some((w) => /^(--get|--get-all|--get-regexp|--list|-l)$/.test(w))
294
+ && flags.every((f) => /^(--get|--get-all|--get-regexp|--list|-l|--global|--local|--show-origin|--name-only)$/.test(f)) ? "read" : null;
295
+ case "reflog": return rest.length === 0 || plain[0] === "show" ? "read" : null;
296
+ case "branch":
297
+ case "tag":
298
+ if (flags.every((f) => /^(-a|-r|-v|-vv|--all|--remotes|--list|-l|--show-current|--contains|--merged|--no-merged|--sort=.*|--format=.*)$/.test(f))
299
+ && (plain.length === 0 || flags.some((f) => /^(--list|-l|--contains|--merged|--no-merged)$/.test(f))))
300
+ return "read";
301
+ return flags.length === 0 && plain.length <= 2 ? write : null; // create a branch/tag
302
+ case "stash":
303
+ if (plain[0] === "list" || plain[0] === "show")
304
+ return "read";
305
+ return rest.length === 0 || plain[0] === "push" || plain[0] === "save" ? write : null;
306
+ case "add":
307
+ case "commit":
308
+ case "fetch":
309
+ case "pull": return write;
310
+ case "mv":
311
+ case "rm": return flags.some((f) => /^(-f|--force)$/.test(f) || /^-[a-z]*f/.test(f)) ? null : write; // git refuses to lose changes without -f
312
+ case "switch": return plain.length === 1 && flags.every((f) => /^(-c|--create)$/.test(f)) ? write : null;
313
+ case "checkout": return /^(-b|-B)$/.test(rest[0] ?? "") && rest.length <= 3 ? write : null; // `checkout <path>` discards edits
314
+ case "reset": return flags.some((f) => /^--(hard|merge|keep)$/.test(f)) ? null : write;
315
+ case "worktree": // remove refuses a dirty worktree without --force, and its branch survives
316
+ if (plain[0] === "list")
317
+ return "read";
318
+ return plain[0] === "add" || plain[0] === "prune" || (plain[0] === "remove" && !flags.some((f) => /^(-f|--force)$/.test(f))) ? write : null;
319
+ case "push": {
320
+ // A named remote only (a URL could send the code anywhere); no force,
321
+ // no deletion, no +refspec that rewrites the remote's history.
322
+ const [remote, ...refs] = plain;
323
+ return flags.every((f) => /^(-u|--set-upstream|--tags|--follow-tags|-q|--quiet|-v|--verbose)$/.test(f))
324
+ && (!remote || /^[\w.-]+$/.test(remote)) && refs.every((r) => /^[\w./-]+(:[\w./-]+)?$/.test(r)) ? write : null;
325
+ }
326
+ default: return null;
327
+ }
328
+ }
329
+ // npm verbs that only query the registry or the local tree. `config` is left
330
+ // out: `npm config get` can print an auth token.
331
+ const NPM_READ = new Set(["view", "info", "show", "v", "whoami", "ls", "list", "outdated", "search", "ping", "help", "explain", "why"]);
332
+ // awk as a filter only: no system(), getline, pipes or output redirection.
333
+ const awkFilter = (words) => !words.some((w) => /system|getline|fflush|close\s*\(|[|>]/.test(w));
334
+ // null → review. Otherwise the command is recoverable, with readOnly telling
335
+ // plan mode whether it changes anything at all.
336
+ export function recoverableShell(command) {
337
+ let root;
338
+ try {
339
+ root = fs.realpathSync(process.cwd());
340
+ }
341
+ catch {
342
+ return null;
343
+ }
344
+ // Agents open with `cd "$(git rev-parse --show-toplevel)" &&` or `cd "$(pwd)"`.
345
+ // Both substitutions only read, and at the very start they run in the
346
+ // process cwd, so evaluate them here; later ones stay unknown (→ review).
347
+ const lead = /^\s*cd\s+"\$\((git rev-parse --show-toplevel|pwd)\)"\s*(?=&&|;|$)/.exec(command);
348
+ if (lead) {
349
+ let dir = root;
350
+ if (lead[1] !== "pwd") {
351
+ try {
352
+ dir = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }).trim();
353
+ }
354
+ catch {
355
+ return null;
356
+ }
357
+ }
358
+ if (/['\n]/.test(dir))
359
+ return null;
360
+ command = `cd '${dir}'${command.slice(lead[0].length)}`;
361
+ }
362
+ const segments = shellSegments(command);
363
+ if (!segments?.length)
364
+ return null;
365
+ let cwd = root; // `cd` changes where later segments act
366
+ let readOnly = true;
367
+ for (const [cmd, ...args] of segments) {
368
+ if (cmd.dynamic || cmd.glob || cmd.text.includes("="))
369
+ return null; // VAR=value prefixes change behavior
370
+ const words = args.map((w) => w.text);
371
+ // Reading is recoverable, leaking is not: once a secret is printed it is in
372
+ // every later request. Credential-looking paths go to review.
373
+ if (words.some((w) => SECRET_FILE_RE.test(w) || /(^|\/)(\.aws|\.gnupg|\.kube|\.docker|\.netrc|\.npmrc|\.pypirc)(\/|$)|credentials|\.git\/config/.test(w)))
374
+ return null;
375
+ let effect;
376
+ switch (cmd.text) {
377
+ case "cd": {
378
+ if (args.length > 1 || args.some((w) => w.dynamic || w.glob) || words[0] === "-")
379
+ return null;
380
+ const target = words[0] ?? "~";
381
+ cwd = target === "~" || target.startsWith("~/") ? path.join(os.homedir(), target.slice(1)) : path.resolve(cwd, target);
382
+ continue;
383
+ }
384
+ case "find":
385
+ effect = words.some((w) => /^-(delete|exec|execdir|ok|okdir|fprint0?|fprintf|fls)$/.test(w)) ? null : "read";
386
+ break;
387
+ case "sed":
388
+ effect = words[0] === "-n" && /^\d+(,(\d+|\$))?p$/.test(words[1] ?? "") && words.slice(2).every((w) => !w.startsWith("-")) ? "read" : null;
389
+ break;
390
+ case "mkdir":
391
+ case "touch":
392
+ effect = "write";
393
+ break;
394
+ case "rm": {
395
+ const targets = [];
396
+ let options = true;
397
+ effect = "write";
398
+ for (const w of args) {
399
+ if (options && w.text === "--") {
400
+ options = false;
401
+ continue;
402
+ }
403
+ if (options && w.text.startsWith("-")) {
404
+ if (!/^-[rRfv]+$/.test(w.text))
405
+ effect = null;
406
+ continue;
407
+ }
408
+ if (w.dynamic || w.glob || !w.text)
409
+ effect = null;
410
+ targets.push(path.resolve(cwd, w.text));
411
+ }
412
+ if (!targets.length || !targets.every((t) => disposable(t)))
413
+ effect = null;
414
+ break;
415
+ }
416
+ case "git":
417
+ effect = gitEffect(words, gitWorkTree(cwd) !== null);
418
+ break;
419
+ case "gh":
420
+ effect = ghEffect(words);
421
+ break;
422
+ case "npm":
423
+ effect = NPM_READ.has(words[0] ?? "") || (words.length === 1 && words[0] === "--version") ? "read" : null;
424
+ break;
425
+ case "awk":
426
+ effect = awkFilter(words) ? "read" : null;
427
+ break;
428
+ case "command":
429
+ effect = words[0] === "-v" && words.length === 2 ? "read" : null;
430
+ break; // `command -v gh`: is it installed?
431
+ default:
432
+ if (words.length === 1 && words[0] === "--version" && !cmd.text.includes("/")) {
433
+ effect = "read";
434
+ break;
435
+ } // an installed tool's version
436
+ effect = READ_ONLY_COMMANDS.has(cmd.text) && !words.some((w) => WRITING_FLAG.test(w)) ? "read" : null;
437
+ }
438
+ if (!effect)
439
+ return null;
440
+ if (effect === "write")
441
+ readOnly = false;
442
+ }
443
+ return readOnly ? { readOnly, reason: "read-only command" } : { readOnly, reason: "recoverable: git can undo it, or it only removes gitignored or temp files" };
444
+ }
55
445
  // Decide what a bash command deserves: deny, ask, or allow.
56
- function checkBash(command) {
446
+ function checkBash(command, auto = false) {
57
447
  const summary = command.trim(); // what the user will see in the prompt
58
448
  const user = userBashRules(); // the user's configured additions
59
449
  // Order is the security model: built-in deny, then user deny, then ask,
60
450
  // then allow. A user allow can never jump this queue — allow is checked last.
451
+ // Reading inside .git (hook listings, `-not -path '*/.git/*'` filters) harms
452
+ // nothing, so the .git rule spares provably read-only commands; .git/config
453
+ // (it may embed tokens) is never read-only there. .env/.ssh stay absolute.
454
+ const readOnly = recoverableShell(command)?.readOnly === true;
61
455
  for (const [re, why] of BASH_DENY)
62
- if (re.test(command))
456
+ if (re.test(command) && !(readOnly && why === GIT_DIR_RULE))
63
457
  return { decision: "deny", reason: why, summary };
64
458
  for (const d of user.deny)
65
459
  if (command.toLowerCase().includes(d.toLowerCase()))
66
460
  return { decision: "deny", reason: `denied by your settings ("${d}")`, summary };
461
+ // node/npm/npx, git aliases and tool-wide grants can run arbitrary code.
462
+ // Auto mode must inspect the actual command, including background commands.
463
+ if (auto) {
464
+ const recoverable = recoverableShell(command);
465
+ return recoverable ? { decision: "allow", reason: recoverable.reason, summary, readOnly: recoverable.readOnly }
466
+ : { decision: "ask", reason: "auto mode reviews commands whose effects it cannot establish", summary };
467
+ }
67
468
  for (const [re, why] of BASH_ASK)
68
469
  if (re.test(command))
69
470
  return { decision: "ask", reason: why, summary };
70
471
  // Compound commands (&&, ;, |, $(), ``) are too hard to reason about — ask.
71
- if (/[;&|]|\$\(|`/.test(command))
72
- return { decision: "ask", reason: "compound command", summary };
472
+ if (/[;&|>]|\$\(|`/.test(command))
473
+ return { decision: "ask", reason: "compound command or redirection", summary }; // `cat a > b` writes b
73
474
  const words = summary.split(/\s+/); // tokenize to inspect the first word
74
475
  if (words[0] === "git" && GIT_READONLY.has(words[1] ?? ""))
75
476
  return { decision: "allow", reason: "read-only git", summary }; // safe git subcommands
@@ -116,7 +517,7 @@ function planSafe(toolName, verdict) {
116
517
  return true;
117
518
  case "run_bash":
118
519
  case "run_bash_background":
119
- return verdict.decision === "allow"; // only the read-only commands the classifier cleared
520
+ return verdict.decision === "allow" && verdict.readOnly !== false; // auto mode also allows recoverable writes; plan mode must not
120
521
  default:
121
522
  return false; // write_file, edit_file, MCP and unknown tools: all mutate-or-unknown
122
523
  }
@@ -126,8 +527,10 @@ function planSafe(toolName, verdict) {
126
527
  // applied as an outer filter: it can only TIGHTEN the base decision (downgrade a
127
528
  // mutating allow/ask to deny), never loosen one — a base "deny" keeps its more
128
529
  // specific reason, because deny always wins.
129
- export function checkPermission(toolName, argsJson) {
130
- const base = basePermission(toolName, argsJson);
530
+ export function checkPermission(toolName, argsJson, auto = false) {
531
+ // Bypass skips ordinary approvals, not rule evaluation. Use the stricter
532
+ // path/argument checks too, including resolved symlink targets.
533
+ const base = basePermission(toolName, argsJson, auto || CONFIG.bypassPermissions);
131
534
  if (planMode && base.decision !== "deny" && !planSafe(toolName, base)) {
132
535
  return {
133
536
  decision: "deny",
@@ -135,10 +538,13 @@ export function checkPermission(toolName, argsJson) {
135
538
  summary: base.summary,
136
539
  };
137
540
  }
541
+ if (CONFIG.bypassPermissions && base.decision === "ask" && toolName !== "exit_plan_mode" && toolName !== "ask_user") {
542
+ return { decision: "allow", reason: "permission approvals bypassed for this run", summary: base.summary };
543
+ }
138
544
  return base;
139
545
  }
140
546
  // The underlying rules, plan-mode-agnostic. Wrapped by checkPermission above.
141
- function basePermission(toolName, argsJson) {
547
+ function basePermission(toolName, argsJson, auto) {
142
548
  // A user-configured tool block beats everything, including built-in allows.
143
549
  if (CONFIG.permissions.deny.includes(`tool:${toolName}`)) {
144
550
  return { decision: "deny", reason: `tool blocked by your settings ("tool:${toolName}")`, summary: toolName };
@@ -146,11 +552,37 @@ function basePermission(toolName, argsJson) {
146
552
  let args; // the parsed tool arguments
147
553
  try {
148
554
  args = JSON.parse(argsJson); // arguments arrive as a raw JSON string from the model
555
+ if (auto && (typeof args !== "object" || args === null || Array.isArray(args))) {
556
+ return { decision: "deny", reason: "tool arguments must be an object", summary: toolName };
557
+ }
149
558
  }
150
559
  catch {
151
560
  // Let dispatch produce the proper JSON error for the model.
561
+ if (auto)
562
+ return { decision: "deny", reason: "invalid JSON arguments", summary: toolName };
152
563
  return { decision: "allow", reason: "unparseable args", summary: toolName };
153
564
  }
565
+ if (auto && ["read_file", "write_file", "edit_file"].includes(toolName)) {
566
+ if (typeof args.path !== "string" || !args.path.trim())
567
+ return { decision: "deny", reason: "a non-empty file path is required", summary: toolName };
568
+ try {
569
+ const resolved = resolveFileTarget(args.path);
570
+ const reason = toolName === "read_file"
571
+ ? (SECRET_FILE_RE.test(path.resolve(args.path)) || SECRET_FILE_RE.test(resolved) ? "file path contains secrets" : null)
572
+ : noFlyHit(args.path) || noFlyHit(resolved);
573
+ if (reason)
574
+ return { decision: "deny", reason, summary: args.path };
575
+ const relative = path.relative(fs.realpathSync(process.cwd()), resolved);
576
+ // Outside the start directory is still recoverable in another git work
577
+ // tree or a temp dir (git, /undo); anywhere else a human decides.
578
+ if (toolName !== "read_file" && (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) && !inTempDir(resolved) && !gitWorkTree(resolved)) {
579
+ return { decision: "ask", requiresHuman: true, reason: "resolved write target is outside the project", summary: `${args.path} → ${resolved}` };
580
+ }
581
+ }
582
+ catch {
583
+ return { decision: "deny", reason: "cannot resolve the file target safely", summary: args.path };
584
+ }
585
+ }
154
586
  switch (toolName) {
155
587
  case "search":
156
588
  return { decision: "allow", reason: "read-only", summary: "search" }; // searching never mutates anything
@@ -208,9 +640,13 @@ function basePermission(toolName, argsJson) {
208
640
  // The user may pre-approve write tools ("tool:edit_file") to skip the
209
641
  // prompt — note this runs AFTER the no-fly check, so it widens "ask",
210
642
  // never "deny".
211
- if (CONFIG.permissions.allow.includes(`tool:${toolName}`)) {
643
+ if (!auto && CONFIG.permissions.allow.includes(`tool:${toolName}`)) {
212
644
  return { decision: "allow", reason: "pre-approved by your settings", summary: p };
213
645
  }
646
+ // Auto mode: the target already resolved inside the project (checked
647
+ // above), where git and /undo can take any edit back.
648
+ if (auto)
649
+ return { decision: "allow", reason: "recoverable: edit in a git work tree or temp dir (git, /undo)", summary: p };
214
650
  return { decision: "ask", reason: "writes to your filesystem", summary: p }; // normal writes need a human yes
215
651
  }
216
652
  case "run_bash":
@@ -218,12 +654,14 @@ function basePermission(toolName, argsJson) {
218
654
  // Backgrounding changes WHEN output comes back, never WHAT runs — so a
219
655
  // background command gets the exact same input-aware analysis as a
220
656
  // foreground one. The danger is in the command, not the blocking.
221
- const verdict = checkBash(args.command ?? ""); // bash gets input-aware analysis
657
+ if (auto && typeof args.command !== "string")
658
+ return { decision: "deny", reason: "a shell command is required", summary: toolName };
659
+ const verdict = checkBash(args.command ?? "", auto); // bash gets input-aware analysis
222
660
  // "Don't ask again for run_bash this session" (chosen in the approval menu)
223
661
  // upgrades an ASK to ALLOW — but a hard DENY (rm -rf /, .git, .env…) always
224
662
  // stands. Convenience never overrides the no-fly rules. A grant for either
225
663
  // shell tool covers the other: the command is what was vouched for.
226
- if (verdict.decision === "ask" && (CONFIG.permissions.allow.includes(`tool:${toolName}`) || CONFIG.permissions.allow.includes("tool:run_bash"))) {
664
+ if (!auto && verdict.decision === "ask" && (CONFIG.permissions.allow.includes(`tool:${toolName}`) || CONFIG.permissions.allow.includes("tool:run_bash"))) {
227
665
  return { decision: "allow", reason: "bash pre-approved for this session", summary: verdict.summary };
228
666
  }
229
667
  return verdict;
@@ -255,9 +693,26 @@ function basePermission(toolName, argsJson) {
255
693
  // gets the most suspicious treatment, not the least: ask. The user can
256
694
  // pre-approve a trusted MCP tool with "tool:mcp__server__tool" in
257
695
  // settings, or block one with "tool:..." in deny (checked at the top).
696
+ // The grant holds in auto mode too: the user vouched for the tool itself,
697
+ // e.g. a message send they can delete. Shell grants are never honored in
698
+ // auto mode (see run_bash) — there the command, not the tool, decides.
258
699
  if (CONFIG.permissions.allow.includes(`tool:${toolName}`)) {
259
700
  return { decision: "allow", reason: "pre-approved by your settings", summary: toolName };
260
701
  }
261
702
  return { decision: "ask", reason: toolName.startsWith("mcp__") ? "external MCP tool" : "unknown tool", summary: toolName };
262
703
  }
263
704
  }
705
+ // New files need their nearest existing parent resolved too: a symlinked
706
+ // directory must not hide a write into .git or another protected location.
707
+ function resolveFileTarget(target) {
708
+ let current = path.resolve(target);
709
+ const missing = [];
710
+ while (!fs.existsSync(current)) {
711
+ const parent = path.dirname(current);
712
+ if (parent === current)
713
+ throw new Error("No existing ancestor");
714
+ missing.unshift(path.basename(current));
715
+ current = parent;
716
+ }
717
+ return path.join(fs.realpathSync(current), ...missing);
718
+ }
package/dist/print.js ADDED
@@ -0,0 +1,16 @@
1
+ export function formatPrintResult(format, data) {
2
+ if (format === "text")
3
+ return data.result.finalText ? `${data.result.finalText}\n` : "";
4
+ return JSON.stringify({ type: "result", is_error: data.result.reason !== "done", reason: data.result.reason,
5
+ result: data.result.finalText ?? "", session_id: data.sessionId, model: data.model, effort: data.effort,
6
+ usage: { input_tokens: data.usage.inputUncached + data.usage.inputCached, cached_input_tokens: data.usage.inputCached,
7
+ output_tokens: data.usage.output, model_calls: data.usage.calls }, estimated_cost_usd: data.usage.cost }) + "\n";
8
+ }
9
+ // stdout is a protocol in print mode, even when an MCP startup or hook helper
10
+ // logs directly. Reserve the original writer for the result and route all
11
+ // incidental output to stderr. Only the CLI entry point installs this guard.
12
+ export function reservePrintOutput() {
13
+ const original = process.stdout.write;
14
+ process.stdout.write = process.stderr.write.bind(process.stderr);
15
+ return { write: (text) => { original.call(process.stdout, text); }, restore: () => { process.stdout.write = original; } };
16
+ }
package/dist/prompt.js CHANGED
@@ -58,11 +58,14 @@ const MEMORY_CAP_CHARS = 8000; // hard cap — a runaway memory file dilutes att
58
58
  // Build the complete system message for this session: the constitution, then
59
59
  // optional AGENT.md instructions, then optional long-term memory. All three are
60
60
  // resolved once per session for a stable, cacheable prefix.
61
- export function buildSystemMessage() {
61
+ export function readProjectInstructions() {
62
+ const p = path.join(process.cwd(), "AGENT.md");
63
+ return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
64
+ }
65
+ export function buildSystemMessage(projectInstructions = readProjectInstructions()) {
62
66
  let msg = SYSTEM_PROMPT;
63
- const p = path.join(process.cwd(), "AGENT.md"); // only the current directory — V1 keeps lookup simple
64
- if (fs.existsSync(p)) {
65
- const agentMd = fs.readFileSync(p, "utf8").slice(0, MEMORY_CAP_CHARS); // read and cap
67
+ if (projectInstructions) {
68
+ const agentMd = projectInstructions.slice(0, MEMORY_CAP_CHARS); // main prompt retains its existing cap
66
69
  // The OVERRIDE/MUST wrapper is deliberate: advisory phrasing gets partial
67
70
  // compliance; imperative wrappers measurably do better.
68
71
  msg += `\n\nThe user's project instructions from AGENT.md. These OVERRIDE any default behavior and you MUST follow them exactly as written:\n\n${agentMd}`;