@amenophis1er/foreman 0.1.0

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.
Files changed (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. package/ui/dist/index.html +14 -0
package/src/policy.ts ADDED
@@ -0,0 +1,599 @@
1
+ /**
2
+ * Permission policy — decides which tool calls run silently and which are
3
+ * routed to the human as approval cards.
4
+ *
5
+ * Rules, in order:
6
+ * 1. Foreman's own MCP tools (spawn_worker, …) are always allowed — they are
7
+ * the director's control surface, already governed by charter + budget.
8
+ * 2. A fixed allowlist of read-only/reversible tools is auto-allowed.
9
+ * 3. Writes/edits inside the mission's `.foreman/` directory are auto-allowed
10
+ * (the mission doc is Foreman bookkeeping, not user code).
11
+ * 4. Playwright browser tools (headless, isolated profile) are auto-allowed
12
+ * EXCEPT navigation to non-local URLs, which prompts — the browser can
13
+ * freely exercise the app under test but going out to the internet is a
14
+ * human decision.
15
+ * 5. Tools granted by the human — "always allow" for this run, or an
16
+ * 'allow' in the Settings tool policy — are auto-allowed. A matched
17
+ * ask-rule still forces a prompt; the SDK's heuristic decisionReason
18
+ * does not, since it fires on ordinary shell work (loops, expansions,
19
+ * background processes) and the real guardrails are enforced below.
20
+ * 6. The per-run tool policy (Settings, snapshotted at run start) applies:
21
+ * 'allow' runs silently, 'deny' blocks with guidance, 'ask' prompts.
22
+ * Defaults are autonomy-first (Bash/Write/Edit/WebFetch allowed).
23
+ * 7. A write into a TEMP directory (/tmp, $TMPDIR, os.tmpdir()) is denied
24
+ * outright with a redirect to `WORK_DIR` — never asked. See
25
+ * `tempDirDenial` for why this is the one outside location whose right
26
+ * answer is always known in advance.
27
+ * 8. Write/Edit outside the mission folder ALWAYS prompts, whatever the
28
+ * policy says — grants never bypass the job-site boundary. The same
29
+ * boundary applies to Bash: a command that would WRITE outside the
30
+ * folder (`cd /tmp && npm install`, `mkdir -p /tmp/x`, `> ~/.zshrc`)
31
+ * prompts even under a blanket 'allow'; reads of outside paths
32
+ * (`cat /etc/hosts`, `which node`) stay silent. Shell cannot be analysed
33
+ * exactly, so this is the conservative heuristic in `bashEscapesFolder`.
34
+ * The one way past the boundary is a PATH grant, not a tool grant: the
35
+ * human approves "always" on an escape card and the caller adds that
36
+ * card's `escapedPath` to `allowedRoots`, after which paths under it
37
+ * count as inside. "Always allow Bash" alone never opens the filesystem.
38
+ * 9. Everything else prompts.
39
+ */
40
+ import os from 'node:os';
41
+ import path from 'node:path';
42
+ import type { CanUseTool, PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
43
+ import type { ToolPolicy } from './types.js';
44
+
45
+ /**
46
+ * Autonomy-first defaults: a mission runs hands-off inside its folder.
47
+ * Users tighten these per project (or globally) in Settings.
48
+ */
49
+ export const DEFAULT_TOOL_POLICY: ToolPolicy = {
50
+ Bash: 'allow', Write: 'allow', Edit: 'allow', WebFetch: 'allow',
51
+ };
52
+
53
+ /**
54
+ * The sanctioned scratch space inside a mission folder, relative to it.
55
+ *
56
+ * A director that wants a verification script, a screenshot, a helper's
57
+ * node_modules or some temp output has a correct instinct — keep the project
58
+ * root clean — and, before this existed, no in-workspace place to act on it.
59
+ * So it reached for /tmp. This directory is the habit's outlet: inside the
60
+ * folder (so no prompt, no stall), gitignored (so the root stays clean), and
61
+ * named by path in both charters and in every denial (so the model has a
62
+ * place, not a principle). The orchestrator creates it at every run start so
63
+ * "it does not exist yet" is never the reason to go elsewhere.
64
+ *
65
+ * Defined here rather than in the orchestrator because the policy's temp-dir
66
+ * denial names it and the orchestrator imports the policy — one constant, no
67
+ * import cycle. The orchestrator re-exports it for its callers.
68
+ */
69
+ export const WORK_DIR = '.foreman/work';
70
+
71
+ export const AUTO_ALLOW_TOOLS: ReadonlySet<string> = new Set([
72
+ 'Read', 'Glob', 'Grep', 'TodoWrite', 'Task',
73
+ 'WebFetch', 'WebSearch', 'NotebookRead', 'ListMcpResources',
74
+ ]);
75
+
76
+ /** Hostnames the headless browser may navigate to without asking. */
77
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '0.0.0.0']);
78
+
79
+ /**
80
+ * Decides whether a Playwright tool call is silently allowed. Everything is
81
+ * (clicks, typing, screenshots act on the already-open page) except
82
+ * navigation to a non-local URL.
83
+ */
84
+ export function browserToolDecision(
85
+ toolName: string, input: Record<string, unknown>,
86
+ ): 'allow' | 'ask' | null {
87
+ if (!toolName.startsWith('mcp__playwright__')) return null;
88
+ if (toolName === 'mcp__playwright__browser_navigate') {
89
+ const url = typeof input.url === 'string' ? input.url : '';
90
+ try {
91
+ const host = new URL(url).hostname;
92
+ return LOCAL_HOSTS.has(host) ? 'allow' : 'ask';
93
+ } catch {
94
+ return 'ask'; // unparseable target: a human should look at it
95
+ }
96
+ }
97
+ return 'allow';
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Bash folder-boundary heuristic
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Verbs that create, delete or modify filesystem entries, mapped to how their
106
+ * path arguments are found. Anything not listed here is treated as read-only
107
+ * (`cat`, `ls`, `grep`, `node`, `curl` without `-o`, …) and never escapes on
108
+ * its own — a read of an outside path is exactly what we want to keep silent.
109
+ *
110
+ * 'all' every path-like argument is a write target (`mkdir a b`, `rm -rf x`)
111
+ * 'last' only the final argument is (`cp SRC DST`, `mv`, `ln`, `install`);
112
+ * reading an outside SRC into the folder is legitimate
113
+ * 'opts' only the value of a directory/output option is (`npm --prefix`,
114
+ * `tar -C`, `unzip -d`, `curl -o`); positional args are packages,
115
+ * URLs or archive members, not write targets
116
+ *
117
+ * `dirs` marks verbs whose targets ARE directories (or whole subtrees), so the
118
+ * path itself is the natural grant root — see `BashEscape.grant`. Verbs left
119
+ * unmarked write files, whose parent directory is the grant.
120
+ */
121
+ type ArgMode = 'all' | 'last' | 'opts';
122
+ const WRITE_VERBS: Record<string, { mode: ArgMode; opts?: string[]; does: string; dirs?: boolean }> = {
123
+ mkdir: { mode: 'all', does: 'creates a path', dirs: true },
124
+ touch: { mode: 'all', does: 'creates a path' },
125
+ // rm's target is the whole thing being removed; granting its PARENT after a
126
+ // `rm -rf /opt/homebrew` would be far wider than what the human approved.
127
+ rm: { mode: 'all', does: 'deletes a path', dirs: true },
128
+ rmdir: { mode: 'all', does: 'deletes a path', dirs: true },
129
+ chmod: { mode: 'all', does: 'changes a path' },
130
+ chown: { mode: 'all', does: 'changes a path' },
131
+ truncate: { mode: 'all', does: 'writes to a path' },
132
+ tee: { mode: 'all', does: 'writes to a path' },
133
+ mv: { mode: 'last', does: 'moves into a path' },
134
+ cp: { mode: 'last', does: 'copies into a path' },
135
+ ln: { mode: 'last', does: 'links into a path' },
136
+ install: { mode: 'last', does: 'installs into a path' },
137
+ rsync: { mode: 'last', does: 'syncs into a path' },
138
+ npm: { mode: 'opts', opts: ['--prefix', '-C', '--cwd', '--global-dir'], does: 'installs into a path' },
139
+ pnpm: { mode: 'opts', opts: ['--prefix', '-C', '--dir', '--cwd'], does: 'installs into a path' },
140
+ yarn: { mode: 'opts', opts: ['--cwd', '--modules-folder'], does: 'installs into a path' },
141
+ pip: { mode: 'opts', opts: ['--prefix', '--target', '-t', '--root'], does: 'installs into a path' },
142
+ pip3: { mode: 'opts', opts: ['--prefix', '--target', '-t', '--root'], does: 'installs into a path' },
143
+ unzip: { mode: 'opts', opts: ['-d'], does: 'extracts into a path' },
144
+ tar: { mode: 'opts', opts: ['-C', '--directory'], does: 'extracts into a path' },
145
+ curl: { mode: 'opts', opts: ['-o', '--output', '--output-dir'], does: 'downloads into a path' },
146
+ wget: { mode: 'opts', opts: ['-O', '-P', '--output-document', '--directory-prefix'], does: 'downloads into a path' },
147
+ };
148
+ /**
149
+ * Among the 'opts' options above, the ones whose value is a FILE rather than
150
+ * a directory (`curl -o f`, `wget -O f`). Every other option in the table
151
+ * (`--prefix`, `-C`, `-d`, `-P`, `--target`, …) names a directory.
152
+ */
153
+ const FILE_OPTS = new Set(['-o', '--output', '-O', '--output-document']);
154
+ /** Prefixes that merely wrap the real verb (`sudo rm`, `env FOO=1 mkdir`). */
155
+ const WRAPPERS = new Set(['sudo', 'env', 'command', 'exec', 'nohup', 'time', 'nice', 'builtin']);
156
+ /** Pseudo-devices: writing to them touches nothing on disk. */
157
+ const DEV_SINKS = new Set(['/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty']);
158
+ const URL_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
159
+ /** `> f`, `>> f`, `2> f`, `&> f` — but not `2>&1` / `>&2` (fd duplication). */
160
+ const REDIRECT_RE = /(?:&>>?|\d*>>?)(?!&)\s*(\S+)/g;
161
+
162
+ /** Strips one layer of matching quotes and shell grouping punctuation. */
163
+ function bare(token: string): string {
164
+ let t = token.replace(/^[(\\]+|[)]+$/g, '');
165
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) t = t.slice(1, -1);
166
+ return t;
167
+ }
168
+
169
+ /**
170
+ * Turns a token into an absolute path when it looks like one — absolute,
171
+ * `~`, `$HOME`, or dot-relative (`./x`, `../x`) resolved against the virtual
172
+ * cwd. Plain words (`build`, `-p`, `playwright`) return null: they are
173
+ * flags, package names or paths relative to a cwd we already account for by
174
+ * tracking `cd`. URLs are never paths even though they contain slashes.
175
+ */
176
+ function asPath(token: string, cwd: string): string | null {
177
+ let t = bare(token);
178
+ // `--prefix=/tmp/x`, `of=/tmp/img`: the value is what matters.
179
+ const eq = t.indexOf('=');
180
+ if (eq > 0 && !t.startsWith('/')) t = t.slice(eq + 1);
181
+ if (URL_RE.test(t)) return null;
182
+ const home = os.homedir();
183
+ if (t === '~' || t.startsWith('~/')) return path.resolve(home + t.slice(1));
184
+ const m = /^\$\{?HOME\}?(\/.*)?$/.exec(t);
185
+ if (m) return path.resolve(home + (m[1] ?? ''));
186
+ if (t.startsWith('/')) return path.resolve(t);
187
+ if (t === '.' || t === '..' || t.startsWith('./') || t.startsWith('../')) return path.resolve(cwd, t);
188
+ return null;
189
+ }
190
+
191
+ /**
192
+ * True when `p` is one of `roots` or lies beneath one. Trailing-separator
193
+ * prefix comparison, so `/Users/x/proj-backup` is NOT under `/Users/x/proj`.
194
+ * Roots are resolved here so callers can hand over raw strings (the human's
195
+ * grants arrive as whatever the card showed).
196
+ */
197
+ export function underAnyRoot(p: string, roots: Iterable<string>): boolean {
198
+ for (const r of roots) {
199
+ const root = path.resolve(r);
200
+ if (p === root || p.startsWith(root + path.sep)) return true;
201
+ }
202
+ return false;
203
+ }
204
+
205
+ /** What `bashEscape` found: the reason for the card plus the path behind it. */
206
+ export interface BashEscape {
207
+ /** One-line human-readable reason, e.g. `cd /tmp — writes after this land outside the mission folder`. */
208
+ reason: string;
209
+ /** The resolved outside path that triggered the escape (`/tmp/x/y` for `mkdir -p /tmp/x/y`). */
210
+ path: string;
211
+ /**
212
+ * The DIRECTORY a human would grant to stop being asked about this command
213
+ * and the siblings that follow it. When `path` is itself a directory target
214
+ * (`cd X`, `mkdir X`, `tar -C X`, `npm --prefix X`, `git clone … X`) it is
215
+ * `path`; when it is a file (`> X/out.txt`, `touch X/a`, `sed -i X/f`,
216
+ * `curl -o X/f`) it is `dirname(path)`. Granting the bare file would make
217
+ * the very next `touch X/b` prompt again, which is exactly the loop this
218
+ * exists to break; granting the directory covers the work the human just
219
+ * looked at without widening to its parent.
220
+ */
221
+ grant: string;
222
+ }
223
+
224
+ /**
225
+ * Conservative check: would this shell command WRITE outside `folder` (and
226
+ * outside every directory in `extraRoots`, the human's per-run path grants)?
227
+ * Returns the reason plus the offending path, or null when nothing in the
228
+ * command looks like an outside write. `bashEscapesFolder` is the same check
229
+ * reduced to the reason string.
230
+ *
231
+ * Shell is not statically analysable, so this deliberately errs towards
232
+ * prompting: the cost of a false positive is one approval card, the cost of
233
+ * a false negative is 25 packages in /tmp. What it does:
234
+ *
235
+ * - Splits on `&&`, `||`, `;`, `|`, `&` and newlines and walks the segments
236
+ * in order with a virtual cwd that starts at `folder`. `cd`/`pushd` move
237
+ * it, so `cd sub && mkdir ../x` resolves correctly; a `cd` that lands
238
+ * outside (`cd /tmp`, `cd ..`, bare `cd`) escapes by itself, because every
239
+ * later relative write and cwd-writer (`npm install`, `git init`) would
240
+ * land there — no need to inspect what follows.
241
+ * - Redirections (`>`, `>>`, `2>`, `&>`) and `tee` targeting an outside path
242
+ * escape. `/dev/null` & co. do not.
243
+ * - Write verbs (`WRITE_VERBS`) escape when their write-target argument —
244
+ * per-verb: all args, only the destination, or only a directory option —
245
+ * resolves outside. `find` escapes only with `-delete` or `-exec <write
246
+ * verb>`; `sed` only with `-i`; `git` only for `clone`/`init`/`worktree`
247
+ * with an outside path; `dd` only for `of=`.
248
+ * - Everything else is read-only by construction: `cat /etc/hosts`,
249
+ * `ls ~/.foreman`, `/opt/homebrew/bin/node script.js`, `find / -name x`
250
+ * return null. System locations (/usr, /opt, /etc, …) need no special
251
+ * casing — they are only flagged when a write verb targets them directly
252
+ * (`rm -rf /opt/homebrew` DOES prompt).
253
+ *
254
+ * Known blind spots (all resolve to "not flagged", i.e. the old behaviour):
255
+ * paths built from variables or command substitution (`rm -rf "$DIR"`,
256
+ * `cd $(mktemp -d)`), `eval`, scripts invoked by name (`./setup.sh` may do
257
+ * anything), `xargs`, interpreters given inline code (`node -e`, `python
258
+ * -c`), `cd -`, paths with spaces (the tokenizer splits on whitespace),
259
+ * `git -C <outside> commit`, and any tool not in `WRITE_VERBS`.
260
+ */
261
+ export function bashEscape(
262
+ command: string, folder: string, extraRoots: Iterable<string> = [],
263
+ ): BashEscape | null {
264
+ const root = path.resolve(folder);
265
+ // Snapshot the grants once per call: the caller's set is mutable and may be
266
+ // appended to while we walk, and one command must see one consistent view.
267
+ const roots = [root, ...extraRoots];
268
+ const outside = (p: string): boolean => !DEV_SINKS.has(p) && !underAnyRoot(p, roots);
269
+ const label = (seg: string): string => (seg.length > 80 ? seg.slice(0, 77) + '…' : seg);
270
+ const hit = (segment: string, does: string, p: string, isDir: boolean): BashEscape => ({
271
+ reason: `${label(segment)} — ${does} outside the mission folder`,
272
+ path: p,
273
+ grant: isDir ? p : path.dirname(p),
274
+ });
275
+
276
+ let cwd = root; // virtual cwd, so `cd sub && rm -rf ../../x` resolves correctly
277
+
278
+ const segments = command
279
+ .split(/&&|\|\||;|\n|\|(?!\|)|(?<![&>\d])&(?![&>])/)
280
+ .map((s) => s.trim())
281
+ .filter(Boolean);
282
+
283
+ for (const segment of segments) {
284
+ // Redirections first — they apply whatever the verb is (`echo x > ~/.zshrc`).
285
+ for (const m of segment.matchAll(REDIRECT_RE)) {
286
+ const p = asPath(m[1], cwd);
287
+ if (p !== null && outside(p)) return hit(segment, 'redirects output', p, false);
288
+ }
289
+ const words = segment.replace(REDIRECT_RE, ' ').split(/\s+/).filter(Boolean).map(bare);
290
+ // Peel wrappers and leading VAR=value assignments to reach the real verb.
291
+ let i = 0;
292
+ while (i < words.length && (WRAPPERS.has(words[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))) i++;
293
+ if (i >= words.length) continue;
294
+ const verb = path.basename(words[i]);
295
+ const args = words.slice(i + 1);
296
+ const flagless = args.filter((a) => !a.startsWith('-'));
297
+
298
+ if (verb === 'cd' || verb === 'pushd') {
299
+ const target = flagless[0] ?? '~'; // bare `cd` goes home
300
+ const p = asPath(target, cwd) ?? path.resolve(cwd, target);
301
+ cwd = p;
302
+ // Enough on its own: whatever follows (relative writes, `npm install`,
303
+ // `git init`) lands there, so we need not look at later segments.
304
+ if (outside(p)) return hit(segment, 'writes after this land', p, true);
305
+ continue;
306
+ }
307
+
308
+ // Which arguments are write targets for this verb, and is each a
309
+ // directory (grant it as-is) or a file (grant its parent)?
310
+ let targets: Array<{ tok: string; dir: boolean }> = [];
311
+ let does = 'writes to a path';
312
+ const spec = WRITE_VERBS[verb];
313
+ if (spec) {
314
+ does = spec.does;
315
+ if (spec.mode === 'all') targets = args.map((tok) => ({ tok, dir: spec.dirs === true }));
316
+ // A destination is a directory only when spelled as one (`cp a /tmp/x/`);
317
+ // otherwise assume a file, so the grant is its parent — the human is
318
+ // about to be asked about more copies into the same place.
319
+ else if (spec.mode === 'last') targets = flagless.slice(-1).map((tok) => ({ tok, dir: tok.endsWith('/') }));
320
+ else {
321
+ for (let k = 0; k < args.length; k++) {
322
+ const opt = spec.opts!.find((o) => args[k] === o || args[k].startsWith(o + '='));
323
+ if (!opt) continue;
324
+ targets.push({ tok: args[k].includes('=') ? args[k] : (args[k + 1] ?? ''), dir: !FILE_OPTS.has(opt) });
325
+ }
326
+ }
327
+ } else if (verb === 'sed' && args.some((a) => /^-[a-zA-Z]*i|^--in-place/.test(a))) {
328
+ does = 'edits a file'; targets = flagless.map((tok) => ({ tok, dir: false }));
329
+ } else if (verb === 'find' && (args.includes('-delete') ||
330
+ args.some((a, k) => /^-(exec|execdir|ok)$/.test(a) && WRITE_VERBS[path.basename(args[k + 1] ?? '')]))) {
331
+ does = 'modifies paths'; targets = flagless.map((tok) => ({ tok, dir: true })); // find's start path is a tree
332
+ } else if (verb === 'git' && /^(clone|init|worktree)$/.test(flagless[0] ?? '')) {
333
+ does = 'creates a repository'; targets = flagless.slice(1).map((tok) => ({ tok, dir: true }));
334
+ } else if (verb === 'dd') {
335
+ does = 'writes to a path'; targets = args.filter((a) => a.startsWith('of=')).map((tok) => ({ tok, dir: false }));
336
+ }
337
+
338
+ for (const t of targets) {
339
+ const p = asPath(t.tok, cwd);
340
+ if (p !== null && outside(p)) return hit(segment, does, p, t.dir);
341
+ }
342
+ }
343
+ return null;
344
+ }
345
+
346
+ /**
347
+ * `bashEscape` reduced to its reason string — the original shape, kept so
348
+ * existing callers and tests read unchanged. `extraRoots` are directories the
349
+ * human has already granted for this run (see `escapedPath`).
350
+ */
351
+ export function bashEscapesFolder(
352
+ command: string, folder: string, extraRoots?: Iterable<string>,
353
+ ): string | null {
354
+ return bashEscape(command, folder, extraRoots)?.reason ?? null;
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Temp directories: deny with a redirect, never ask
359
+ // ---------------------------------------------------------------------------
360
+
361
+ /**
362
+ * The temp directories a write is refused into without a card.
363
+ *
364
+ * `/tmp`, `/private/tmp`, `$TMPDIR` and `os.tmpdir()` — the places a model
365
+ * reaches for when it wants scratch space and has not been told where scratch
366
+ * goes. On macOS `/tmp` and `/var` are symlinks into `/private`, and a command
367
+ * may spell either form (`cd /tmp/x`, `Write /private/tmp/x/f`); the policy
368
+ * resolves paths lexically, not through the filesystem, so both spellings of
369
+ * every root are listed rather than trusting realpath on a path that may not
370
+ * exist yet. Computed once: the environment does not change under a run.
371
+ */
372
+ export function tempRoots(env: NodeJS.ProcessEnv = process.env): string[] {
373
+ const raw = ['/tmp', '/private/tmp', env.TMPDIR, os.tmpdir()].filter((r): r is string => Boolean(r));
374
+ const out = new Set<string>();
375
+ for (const r of raw) {
376
+ const p = path.resolve(r);
377
+ out.add(p);
378
+ if (p.startsWith('/private/')) out.add(p.slice('/private'.length));
379
+ else out.add('/private' + p);
380
+ }
381
+ return [...out];
382
+ }
383
+ const TEMP_ROOTS = tempRoots();
384
+
385
+ /** True when `p` is a temp directory or lies under one — see `tempRoots`. */
386
+ export function isTempPath(p: string): boolean {
387
+ return underAnyRoot(path.resolve(p), TEMP_ROOTS);
388
+ }
389
+
390
+ /**
391
+ * The message a temp-dir write is denied with. Names the outside path, the
392
+ * in-workspace place to redo it, and why that place exists — the deny is only
393
+ * useful if the model's next attempt lands inside, so the redirect is the
394
+ * substance and the refusal is the packaging.
395
+ */
396
+ export function tempDirDenial(p: string, folder: string): string {
397
+ return `Denied: ${p} is outside the mission folder. Scratch work belongs under ` +
398
+ `${folder}/${WORK_DIR}/ — it is gitignored and keeps the project root clean without ` +
399
+ 'leaving the project. Redo this there.';
400
+ }
401
+
402
+ /**
403
+ * The sentence every escape card ends with, so the meaning of the "Always"
404
+ * button is stated where the human reads it — it grants THIS PATH, not the
405
+ * tool. Exported for the UI/tests to match on rather than retype.
406
+ */
407
+ export const ESCAPE_GRANT_HINT =
408
+ 'Approve "always" to allow this path for the rest of the run; other paths outside the folder will still ask.';
409
+
410
+ /** A pending approval routed to the UI; resolved by the human's decision. */
411
+ export interface PendingPermission {
412
+ resolve: (r: PermissionResult) => void;
413
+ toolName: string;
414
+ suggestions?: PermissionUpdate[];
415
+ /**
416
+ * Present only when the ask was raised by the folder boundary (an outside
417
+ * Write/Edit, or a Bash command `bashEscape` flagged). It is the absolute
418
+ * DIRECTORY to grant — for a file tool, `dirname(file_path)`; for Bash,
419
+ * `BashEscape.grant` (the target itself when it is a directory, its parent
420
+ * when it is a file), chosen so the grant covers the sibling commands the
421
+ * human is about to be asked about, not just the one path on this card.
422
+ *
423
+ * CONTRACT with the orchestrator: on an `allow_always` decision for a
424
+ * pending permission that HAS `escapedPath`, add `escapedPath` to the run's
425
+ * `allowedRoots` set INSTEAD OF adding `toolName` to `runAllowed`. The human
426
+ * clicked "always" on a card about a path, so that is what they granted;
427
+ * a tool grant would be ignored by the boundary check anyway (by design —
428
+ * "always allow Bash" must not open the filesystem) and the next sibling
429
+ * command would prompt again, which is the loop this field breaks. When
430
+ * `escapedPath` is absent, `allow_always` keeps its old meaning (tool grant).
431
+ */
432
+ escapedPath?: string;
433
+ }
434
+
435
+ export interface PolicyHooks {
436
+ /** Announce a silent allow (for the transcript). `reason` is the SDK's
437
+ * decision reason when one was present but overridden by a grant. */
438
+ onAutoAllow(agent: string, toolName: string, reason?: string): void;
439
+ /**
440
+ * Announce a silent deny (for the transcript) — today only the temp-dir
441
+ * redirect. Optional so existing callers and tests keep their shape; a
442
+ * caller that does not listen still gets the deny, just not the line.
443
+ */
444
+ onAutoDeny?(agent: string, toolName: string, reason: string): void;
445
+ /** Present an approval card; the returned promise resolves on decision. */
446
+ onAsk(agent: string, id: string, request: {
447
+ toolName: string;
448
+ input: Record<string, unknown>;
449
+ title?: string;
450
+ description?: string;
451
+ decisionReason?: string;
452
+ /** Same value as `PendingPermission.escapedPath`, so the card can show what "always" will grant. */
453
+ escapedPath?: string;
454
+ }): void;
455
+ /** Register/unregister the pending resolution for HTTP lookup. */
456
+ register(id: string, pending: PendingPermission): void;
457
+ unregister(id: string): boolean;
458
+ }
459
+
460
+ /**
461
+ * Builds the `canUseTool` callback for one agent (director or worker).
462
+ *
463
+ * @param agent Label shown on cards and transcript entries.
464
+ * @param folder The mission's working directory (absolute).
465
+ * @param runAllowed Mutable per-run set of tools the human granted "always".
466
+ * Never consulted for a path that leaves the folder.
467
+ * @param allowedRoots Mutable per-run set of absolute directories the human
468
+ * granted "always" on an escape card (see
469
+ * `PendingPermission.escapedPath`). Paths under any of
470
+ * them count as inside the folder for both file tools and
471
+ * Bash. Owned by the caller, like `runAllowed`; read on
472
+ * every call so a grant takes effect on the next command.
473
+ */
474
+ export function makePolicy(
475
+ agent: string,
476
+ folder: string,
477
+ runAllowed: Set<string>,
478
+ allowedRoots: Set<string>,
479
+ hooks: PolicyHooks,
480
+ settings?: { toolPolicy?: ToolPolicy; autoAllowReadOnly?: boolean },
481
+ ): CanUseTool {
482
+ const foremanDir = path.join(folder, '.foreman') + path.sep;
483
+ const toolPolicy = { ...DEFAULT_TOOL_POLICY, ...settings?.toolPolicy };
484
+ const autoReadOnly = settings?.autoAllowReadOnly !== false;
485
+
486
+ return async (toolName, input, opts) => {
487
+ // An explicit grant (Settings policy or the human's "Always" click) is a
488
+ // deliberate decision about a TOOL, so a heuristic decisionReason like
489
+ // "contains shell syntax that cannot be statically analyzed" must not
490
+ // override it — that reason fires on loops, expansions and background
491
+ // processes, i.e. on most real work. A matched ask-rule is a configured
492
+ // instruction rather than a heuristic, so it still forces a prompt, as
493
+ // does the folder boundary checked below.
494
+ const routine = !opts.matchedAskRule;
495
+ const filePath = typeof input.file_path === 'string' ? path.resolve(input.file_path) : null;
496
+ const isMissionDocWrite =
497
+ (toolName === 'Write' || toolName === 'Edit') &&
498
+ filePath !== null &&
499
+ filePath.startsWith(foremanDir);
500
+ // A file edit outside the job site always prompts, whatever the TOOL
501
+ // policy says — blanket grants must not bypass the folder boundary. Only
502
+ // a PATH grant (`allowedRoots`) widens it, and only for that subtree.
503
+ const outsideFolder =
504
+ (toolName === 'Write' || toolName === 'Edit' || toolName === 'NotebookEdit') &&
505
+ filePath !== null &&
506
+ !underAnyRoot(filePath, [folder, ...allowedRoots]);
507
+ // The shell is the other door out of the job site: a command that would
508
+ // write outside the folder prompts under the same rule, so a blanket
509
+ // `Bash: allow` (or an "Always" click) cannot quietly `cd /tmp && npm i`.
510
+ const escape =
511
+ toolName === 'Bash' && typeof input.command === 'string'
512
+ ? bashEscape(input.command, folder, allowedRoots)
513
+ : null;
514
+ const leavesFolder = outsideFolder || escape !== null;
515
+ // What "always" on this card grants — see `PendingPermission.escapedPath`.
516
+ // For a file tool the parent directory: the worker that wrote
517
+ // `/tmp/x/a.ts` is about to write `/tmp/x/b.ts`, and a grant of the
518
+ // single file would re-prompt on it.
519
+ const escapedPath = escape?.grant ?? (outsideFolder ? path.dirname(filePath!) : undefined);
520
+
521
+ const granted = toolPolicy[toolName] === 'allow' || runAllowed.has(toolName);
522
+
523
+ if (toolPolicy[toolName] === 'deny') {
524
+ return {
525
+ behavior: 'deny',
526
+ message: `${toolName} is denied by this project's tool policy. ` +
527
+ 'Escalate via mcp__foreman__ask_human if the mission cannot proceed without it.',
528
+ };
529
+ }
530
+
531
+ const browser = browserToolDecision(toolName, input);
532
+ if (
533
+ !leavesFolder && (
534
+ browser === 'allow' ||
535
+ toolName.startsWith('mcp__foreman__') ||
536
+ (autoReadOnly && AUTO_ALLOW_TOOLS.has(toolName)) ||
537
+ isMissionDocWrite ||
538
+ (granted && routine && browser !== 'ask')
539
+ )
540
+ ) {
541
+ // Carry the reason through so the log shows what was waved past.
542
+ hooks.onAutoAllow(agent, toolName, opts.decisionReason);
543
+ return { behavior: 'allow' };
544
+ }
545
+
546
+ // A write into a temp directory is the one outside location whose right
547
+ // answer is always known in advance: the model wanted scratch space, and
548
+ // scratch space exists inside the folder at WORK_DIR. Asking is therefore
549
+ // pure cost — a planned mission once sat most of an hour on a /tmp write
550
+ // waiting for a human to say what this line says in a millisecond, and
551
+ // would have died on it had the human been asleep. So it is denied with
552
+ // the redirect and never reaches a card. Everything ELSE outside the
553
+ // folder (a sibling repo, a deploy dir, ~/.config) might be legitimate
554
+ // and stays a human call below — with allowed roots so one "always"
555
+ // covers the siblings, and the orchestrator's unattended timeout so an
556
+ // unanswered card does not hold the run open indefinitely.
557
+ const outsidePath = escape?.path ?? (outsideFolder ? filePath : null);
558
+ if (
559
+ leavesFolder && outsidePath !== null && isTempPath(outsidePath) &&
560
+ (toolName === 'Bash' || toolName === 'Write' || toolName === 'Edit' || toolName === 'NotebookEdit')
561
+ ) {
562
+ const message = tempDirDenial(outsidePath, folder);
563
+ hooks.onAutoDeny?.(agent, toolName, message);
564
+ return { behavior: 'deny', message };
565
+ }
566
+
567
+ const id = opts.toolUseID ?? opts.requestId;
568
+ // On an escape card the description names the path "always" will grant
569
+ // and ends with `ESCAPE_GRANT_HINT`, because the button's label alone
570
+ // ("Always (run)") reads as a tool grant — which it is not, here.
571
+ let title = opts.title;
572
+ let description = opts.description;
573
+ if (escape !== null) {
574
+ title = 'Shell command leaves the mission folder';
575
+ description = `${escape.reason}. Path: ${escapedPath}. ${ESCAPE_GRANT_HINT}`;
576
+ } else if (outsideFolder) {
577
+ title = 'File edit leaves the mission folder';
578
+ description = `${filePath} is outside the mission folder. Path: ${escapedPath}. ${ESCAPE_GRANT_HINT}`;
579
+ }
580
+ hooks.onAsk(agent, id, {
581
+ toolName,
582
+ input,
583
+ title,
584
+ description,
585
+ decisionReason: opts.decisionReason ??
586
+ (leavesFolder ? `Path is outside the mission folder (${folder})` : undefined),
587
+ escapedPath,
588
+ });
589
+
590
+ return new Promise<PermissionResult>((resolve) => {
591
+ hooks.register(id, { resolve, toolName, suggestions: opts.suggestions, escapedPath });
592
+ opts.signal.addEventListener('abort', () => {
593
+ if (hooks.unregister(id)) {
594
+ resolve({ behavior: 'deny', message: 'Run was interrupted.' });
595
+ }
596
+ });
597
+ });
598
+ };
599
+ }