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.
- package/README.md +0 -96
- package/dist/account-status.js +172 -0
- package/dist/agent-progress.js +39 -0
- package/dist/agent.js +159 -34
- package/dist/auto-budget.js +15 -0
- package/dist/auto-context.js +125 -0
- package/dist/auto-debug.js +87 -0
- package/dist/auto-providers.js +154 -0
- package/dist/auto-review.js +113 -0
- package/dist/auto.js +295 -0
- package/dist/background.js +2 -1
- package/dist/cli.js +83 -0
- package/dist/clipboard.js +88 -0
- package/dist/completion.js +65 -0
- package/dist/config.js +8 -0
- package/dist/context.js +9 -3
- package/dist/cost.js +4 -0
- package/dist/effort.js +81 -0
- package/dist/follow-up.js +22 -0
- package/dist/form.js +8 -6
- package/dist/images.js +43 -0
- package/dist/ink/activity.js +62 -0
- package/dist/ink/agents.js +24 -0
- package/dist/ink/app.js +384 -171
- package/dist/ink/chat.js +12 -1
- package/dist/ink/commands.js +16 -3
- package/dist/ink/completion.js +43 -0
- package/dist/ink/setup.js +9 -4
- package/dist/ink/sink.js +16 -6
- package/dist/ink/viewport.js +15 -0
- package/dist/loop.js +251 -106
- package/dist/mcp.js +1 -0
- package/dist/menu.js +50 -8
- package/dist/output.js +1 -1
- package/dist/pasted-text.js +32 -0
- package/dist/permissions.js +467 -12
- package/dist/print.js +16 -0
- package/dist/prompt.js +7 -4
- package/dist/status.js +80 -0
- package/dist/team.js +9 -3
- package/dist/tools.js +13 -1
- package/dist/tui.js +31 -15
- package/dist/ui.js +4 -6
- package/package.json +3 -3
package/dist/permissions.js
CHANGED
|
@@ -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|$)/,
|
|
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 (/[
|
|
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"; //
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
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}`;
|