@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.
- package/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- 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
|
+
}
|