@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
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission policy tests — the Bash folder-boundary heuristic and its wiring
|
|
3
|
+
* into `makePolicy`. Nothing here touches the filesystem: paths are strings
|
|
4
|
+
* fed to a pure function, and the policy hooks are recorded in memory.
|
|
5
|
+
*/
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
bashEscape, bashEscapesFolder, makePolicy, ESCAPE_GRANT_HINT, WORK_DIR, isTempPath, tempDirDenial, tempRoots,
|
|
12
|
+
type PendingPermission, type PolicyHooks,
|
|
13
|
+
} from './policy.js';
|
|
14
|
+
|
|
15
|
+
const FOLDER = '/Users/x/proj';
|
|
16
|
+
const ROOT = '/tmp/foreman-verify'; // the path from the live bug report
|
|
17
|
+
const escapes = (cmd: string) => bashEscapesFolder(cmd, FOLDER);
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// bashEscapesFolder — commands that must prompt
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
test('the real-world playwright install in /tmp escapes', () => {
|
|
24
|
+
const reason = escapes('mkdir -p /tmp/foreman-verify && cd /tmp/foreman-verify && npm init -y && npm install playwright');
|
|
25
|
+
assert.equal(reason, 'mkdir -p /tmp/foreman-verify — creates a path outside the mission folder');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('cd to an outside dir escapes on its own, even before any write', () => {
|
|
29
|
+
assert.equal(escapes('cd /tmp && npm install'), 'cd /tmp — writes after this land outside the mission folder');
|
|
30
|
+
assert.ok(escapes('cd ~ && ls'));
|
|
31
|
+
assert.ok(escapes('cd && ls'), 'bare cd goes home');
|
|
32
|
+
assert.ok(escapes('cd .. && ls'), 'parent of the folder is outside');
|
|
33
|
+
assert.ok(escapes('cd $HOME/.foreman'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a segment after `cd /tmp;` escapes (taint) with a relative write', () => {
|
|
37
|
+
const reason = escapes('cd /tmp; touch a');
|
|
38
|
+
assert.ok(reason);
|
|
39
|
+
assert.match(reason, /outside the mission folder/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('redirections to an outside path escape', () => {
|
|
43
|
+
assert.match(escapes('echo hi > /tmp/out.txt')!, /redirects output outside/);
|
|
44
|
+
assert.ok(escapes('> /tmp/out.txt'));
|
|
45
|
+
assert.ok(escapes('npm test >> ~/log.txt'));
|
|
46
|
+
assert.ok(escapes('npm test 2> /tmp/err.log'));
|
|
47
|
+
assert.ok(escapes('npm test &> $HOME/all.log'));
|
|
48
|
+
assert.ok(escapes('cat x | tee /tmp/copy.txt'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('write verbs on outside paths escape, including system locations', () => {
|
|
52
|
+
assert.equal(escapes('rm -rf /opt/homebrew'), 'rm -rf /opt/homebrew — deletes a path outside the mission folder');
|
|
53
|
+
assert.ok(escapes('touch ~/.zshrc'));
|
|
54
|
+
assert.ok(escapes('cp ./dist/a /usr/local/bin/a'));
|
|
55
|
+
assert.ok(escapes('mv build /tmp/build'));
|
|
56
|
+
assert.ok(escapes('ln -s ./bin/x ~/bin/x'));
|
|
57
|
+
assert.ok(escapes('chmod +x /usr/local/bin/foo'));
|
|
58
|
+
assert.ok(escapes('sed -i "s/a/b/" /etc/hosts'));
|
|
59
|
+
assert.ok(escapes('sudo mkdir /var/lib/foo'), 'wrappers are peeled');
|
|
60
|
+
assert.ok(escapes('FOO=1 /bin/rm -rf /tmp/x'), 'assignments peeled, verb by basename');
|
|
61
|
+
assert.ok(escapes('mkdir ../sibling'), 'dot-relative paths resolve against the folder');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('find escapes only when it deletes or execs a write verb', () => {
|
|
65
|
+
assert.equal(escapes('find / -name x'), null);
|
|
66
|
+
assert.equal(escapes('find /tmp -name "*.log"'), null);
|
|
67
|
+
assert.ok(escapes('find /tmp -delete'));
|
|
68
|
+
assert.ok(escapes('find /tmp -name "*.log" -exec rm {} \;'));
|
|
69
|
+
assert.equal(escapes('find /tmp -exec cat {} \;'), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('directory options of installers, archivers and downloaders count', () => {
|
|
73
|
+
assert.ok(escapes('npm install --prefix /tmp/x playwright'));
|
|
74
|
+
assert.ok(escapes('npm install --prefix=/tmp/x playwright'));
|
|
75
|
+
assert.ok(escapes('pip install -t /tmp/site requests'));
|
|
76
|
+
assert.ok(escapes('unzip a.zip -d /tmp/out'));
|
|
77
|
+
assert.ok(escapes('tar -xzf a.tgz -C /tmp/out'));
|
|
78
|
+
assert.ok(escapes('curl -o /tmp/f https://example.com/f'));
|
|
79
|
+
assert.ok(escapes('git clone https://github.com/a/b /tmp/b'));
|
|
80
|
+
assert.ok(escapes('git init /tmp/fresh'));
|
|
81
|
+
assert.ok(escapes('dd if=/dev/zero of=/tmp/img bs=1m count=1'));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// bashEscapesFolder — commands that must stay silent
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
test('reads of outside paths do not escape', () => {
|
|
89
|
+
assert.equal(escapes('cat /etc/hosts'), null);
|
|
90
|
+
assert.equal(escapes('ls ~/.foreman'), null);
|
|
91
|
+
assert.equal(escapes('ls $HOME/.foreman'), null);
|
|
92
|
+
assert.equal(escapes('which node'), null);
|
|
93
|
+
assert.equal(escapes('head -n 5 /var/log/system.log | grep foo'), null);
|
|
94
|
+
assert.equal(escapes('diff /etc/hosts ./hosts'), null);
|
|
95
|
+
assert.equal(escapes('stat /usr/bin/env && wc -l /etc/passwd'), null);
|
|
96
|
+
assert.equal(escapes('cp /etc/hosts ./hosts'), null, 'copying an outside SOURCE in is fine');
|
|
97
|
+
assert.equal(escapes('pip install -r /etc/reqs.txt'), null, 'reading a requirements file is fine');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('writes inside the folder do not escape', () => {
|
|
101
|
+
assert.equal(escapes('mkdir -p ./build'), null);
|
|
102
|
+
assert.equal(escapes('mkdir -p build'), null);
|
|
103
|
+
assert.equal(escapes(`mkdir -p ${FOLDER}/build`), null);
|
|
104
|
+
assert.equal(escapes(`cd ${FOLDER}/sub && rm -rf x`), null);
|
|
105
|
+
assert.equal(escapes('cd sub && rm -rf x'), null, 'relative cd stays inside');
|
|
106
|
+
assert.equal(escapes('cd sub && mkdir ../other'), null, 'dot-relative resolves against the virtual cwd');
|
|
107
|
+
assert.equal(escapes('npm install playwright && npm test'), null);
|
|
108
|
+
assert.equal(escapes('echo x > out.txt 2>&1'), null, 'fd duplication is not a path');
|
|
109
|
+
assert.equal(escapes('npm test > /dev/null 2>&1'), null);
|
|
110
|
+
assert.equal(escapes('cat x | tee out.txt'), null);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('URLs and outside binaries are not write targets', () => {
|
|
114
|
+
assert.equal(escapes('curl https://example.com/path/file'), null);
|
|
115
|
+
assert.equal(escapes('git clone https://github.com/a/b'), null);
|
|
116
|
+
assert.equal(escapes('/opt/homebrew/bin/node script.js'), null);
|
|
117
|
+
assert.equal(escapes('/usr/bin/env python3 -m pytest'), null);
|
|
118
|
+
assert.equal(escapes('npx --yes /opt/tools/cli --flag'), null);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('the folder itself counts as inside; a sibling with a shared prefix does not', () => {
|
|
122
|
+
assert.equal(escapes(`mkdir ${FOLDER}`), null);
|
|
123
|
+
assert.ok(escapes(`mkdir ${FOLDER}-backup`));
|
|
124
|
+
assert.ok(escapes(`mkdir ${path.join(os.homedir(), 'elsewhere')}`));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// allowed extra roots — the human's per-run path grants
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
test('a command under an allowed root does not escape', () => {
|
|
132
|
+
assert.equal(bashEscapesFolder(`cd ${ROOT} && ls`, FOLDER, [ROOT]), null);
|
|
133
|
+
assert.equal(bashEscapesFolder(`mkdir -p ${ROOT}/node_modules && cd ${ROOT} && npm install playwright`, FOLDER, [ROOT]), null);
|
|
134
|
+
assert.equal(bashEscapesFolder(`cd ${ROOT} && mkdir sub && cd sub && touch a`, FOLDER, [ROOT]), null, 'virtual cwd inside the root stays inside');
|
|
135
|
+
assert.equal(bashEscapesFolder(`mkdir ${ROOT}`, FOLDER, new Set([ROOT])), null, 'the root itself counts; any Iterable works');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a sibling outside path still escapes despite the root', () => {
|
|
139
|
+
assert.ok(bashEscapesFolder('cd /tmp/other', FOLDER, [ROOT]));
|
|
140
|
+
assert.ok(bashEscapesFolder(`mkdir ${ROOT}-2`, FOLDER, [ROOT]), 'shared prefix is not containment');
|
|
141
|
+
assert.ok(bashEscapesFolder('cd /tmp', FOLDER, [ROOT]), 'the PARENT of a root is not granted');
|
|
142
|
+
assert.ok(bashEscapesFolder(`cd ${ROOT} && cd .. && touch x`, FOLDER, [ROOT]), 'walking back out of the root escapes');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('a file write under the root does not escape', () => {
|
|
146
|
+
assert.equal(bashEscapesFolder(`echo hi > ${ROOT}/out.txt`, FOLDER, [ROOT]), null);
|
|
147
|
+
assert.equal(bashEscapesFolder(`npm test 2>> ${ROOT}/err.log`, FOLDER, [ROOT]), null);
|
|
148
|
+
assert.equal(bashEscapesFolder(`sed -i "s/a/b/" ${ROOT}/f`, FOLDER, [ROOT]), null);
|
|
149
|
+
assert.ok(bashEscapesFolder('echo hi > /tmp/out.txt', FOLDER, [ROOT]), 'outside the root still prompts');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('bashEscape carries the offending path and the directory to grant', () => {
|
|
153
|
+
// Directory-ish targets: the path itself is the grant.
|
|
154
|
+
assert.deepEqual(bashEscape('cd /tmp/x && ls', FOLDER), {
|
|
155
|
+
reason: 'cd /tmp/x — writes after this land outside the mission folder', path: '/tmp/x', grant: '/tmp/x',
|
|
156
|
+
});
|
|
157
|
+
assert.deepEqual(bashEscape('mkdir -p /tmp/x/y', FOLDER), {
|
|
158
|
+
reason: 'mkdir -p /tmp/x/y — creates a path outside the mission folder', path: '/tmp/x/y', grant: '/tmp/x/y',
|
|
159
|
+
});
|
|
160
|
+
assert.deepEqual(bashEscape('tar -xzf a.tgz -C /tmp/out', FOLDER), {
|
|
161
|
+
reason: 'tar -xzf a.tgz -C /tmp/out — extracts into a path outside the mission folder', path: '/tmp/out', grant: '/tmp/out',
|
|
162
|
+
});
|
|
163
|
+
assert.equal(bashEscape('npm install --prefix=/tmp/x playwright', FOLDER)!.grant, '/tmp/x');
|
|
164
|
+
assert.equal(bashEscape('git clone https://github.com/a/b /tmp/b', FOLDER)!.grant, '/tmp/b');
|
|
165
|
+
assert.equal(bashEscape('rm -rf /opt/homebrew', FOLDER)!.grant, '/opt/homebrew', 'rm grants the removed tree, not its parent');
|
|
166
|
+
// File targets: the parent directory is the grant.
|
|
167
|
+
assert.deepEqual(bashEscape('echo hi > /tmp/x/out.txt', FOLDER), {
|
|
168
|
+
reason: 'echo hi > /tmp/x/out.txt — redirects output outside the mission folder', path: '/tmp/x/out.txt', grant: '/tmp/x',
|
|
169
|
+
});
|
|
170
|
+
assert.equal(bashEscape('touch /tmp/x/a', FOLDER)!.grant, '/tmp/x');
|
|
171
|
+
assert.equal(bashEscape('sed -i "s/a/b/" /tmp/x/f', FOLDER)!.grant, '/tmp/x');
|
|
172
|
+
assert.equal(bashEscape('curl -o /tmp/x/f https://example.com/f', FOLDER)!.grant, '/tmp/x');
|
|
173
|
+
assert.equal(bashEscape('cp a /tmp/x/f', FOLDER)!.grant, '/tmp/x', 'a destination without a trailing slash is a file');
|
|
174
|
+
assert.equal(bashEscape('cp a /tmp/x/', FOLDER)!.grant, '/tmp/x', 'a destination spelled as a directory is one');
|
|
175
|
+
assert.equal(bashEscape('cd sub && mkdir ../../x', FOLDER)!.path, '/Users/x/x', 'path is resolved against the virtual cwd');
|
|
176
|
+
assert.equal(bashEscape('cat /etc/hosts', FOLDER), null);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// makePolicy wiring
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
// An outside path that is NOT a temp directory. The wiring tests below need a
|
|
184
|
+
// path that reaches the ask path; anything under /tmp (the live bug's
|
|
185
|
+
// /tmp/foreman-verify included) is now denied with a redirect before it can
|
|
186
|
+
// ask, and has its own tests at the end of this section.
|
|
187
|
+
const OUTSIDE = '/Users/x/foreman-verify';
|
|
188
|
+
|
|
189
|
+
function recordingHooks() {
|
|
190
|
+
const asks: Array<{ toolName: string; title?: string; description?: string; escapedPath?: string }> = [];
|
|
191
|
+
const registered: PendingPermission[] = [];
|
|
192
|
+
const allows: string[] = [];
|
|
193
|
+
const denies: Array<{ toolName: string; reason: string }> = [];
|
|
194
|
+
const hooks: PolicyHooks = {
|
|
195
|
+
onAutoAllow: (_agent, toolName) => { allows.push(toolName); },
|
|
196
|
+
onAutoDeny: (_agent, toolName, reason) => { denies.push({ toolName, reason }); },
|
|
197
|
+
onAsk: (_agent, _id, req) => {
|
|
198
|
+
asks.push({ toolName: req.toolName, title: req.title, description: req.description, escapedPath: req.escapedPath });
|
|
199
|
+
},
|
|
200
|
+
register: (_id, pending) => { registered.push(pending); },
|
|
201
|
+
unregister: () => true,
|
|
202
|
+
};
|
|
203
|
+
return { hooks, asks, allows, denies, registered };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function opts(signal: AbortSignal) {
|
|
207
|
+
return { signal, toolUseID: 'tu_1' } as unknown as Parameters<ReturnType<typeof makePolicy>>[2];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Raise an ask through the policy, then settle it via abort (no human here). */
|
|
211
|
+
async function askAndAbort(policy: ReturnType<typeof makePolicy>, toolName: string, input: Record<string, unknown>) {
|
|
212
|
+
const ac = new AbortController();
|
|
213
|
+
const pending = policy(toolName, input, opts(ac.signal));
|
|
214
|
+
ac.abort();
|
|
215
|
+
return (await pending)!;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
test('makePolicy: an escaping Bash command reaches onAsk even when Bash is allowed by policy AND granted for the run', async () => {
|
|
219
|
+
const { hooks, asks, allows } = recordingHooks();
|
|
220
|
+
const policy = makePolicy('director', FOLDER, new Set(['Bash']), new Set(), hooks, { toolPolicy: { Bash: 'allow' } });
|
|
221
|
+
const ac = new AbortController();
|
|
222
|
+
|
|
223
|
+
const pending = policy('Bash', { command: `cd ${OUTSIDE} && npm install playwright` }, opts(ac.signal));
|
|
224
|
+
assert.equal(asks.length, 1);
|
|
225
|
+
assert.equal(asks[0].toolName, 'Bash');
|
|
226
|
+
assert.equal(asks[0].title, 'Shell command leaves the mission folder');
|
|
227
|
+
assert.equal(asks[0].description,
|
|
228
|
+
`cd ${OUTSIDE} — writes after this land outside the mission folder. Path: ${OUTSIDE}. ${ESCAPE_GRANT_HINT}`);
|
|
229
|
+
assert.ok(asks[0].description!.endsWith(
|
|
230
|
+
'Approve "always" to allow this path for the rest of the run; other paths outside the folder will still ask.'));
|
|
231
|
+
assert.equal(allows.length, 0);
|
|
232
|
+
|
|
233
|
+
ac.abort(); // no human here; the abort path settles the promise
|
|
234
|
+
const result = (await pending)!;
|
|
235
|
+
assert.equal(result.behavior, 'deny');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('makePolicy: a read-only Bash command on an outside path is still auto-allowed', async () => {
|
|
239
|
+
const { hooks, asks, allows } = recordingHooks();
|
|
240
|
+
const policy = makePolicy('director', FOLDER, new Set(), new Set(), hooks);
|
|
241
|
+
const ac = new AbortController();
|
|
242
|
+
const result = (await policy('Bash', { command: 'cat /etc/hosts && ls ~/.foreman' }, opts(ac.signal)))!;
|
|
243
|
+
assert.equal(result.behavior, 'allow');
|
|
244
|
+
assert.deepEqual(allows, ['Bash']);
|
|
245
|
+
assert.equal(asks.length, 0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('makePolicy: Write outside the folder prompts regardless of grants (the rule Bash now mirrors)', async () => {
|
|
249
|
+
const { hooks, asks } = recordingHooks();
|
|
250
|
+
const policy = makePolicy('worker', FOLDER, new Set(['Write']), new Set(), hooks, { toolPolicy: { Write: 'allow' } });
|
|
251
|
+
const result = await askAndAbort(policy, 'Write', { file_path: `${OUTSIDE}/out.txt`, content: '' });
|
|
252
|
+
assert.equal(asks.length, 1);
|
|
253
|
+
assert.equal(result.behavior, 'deny');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('makePolicy: escapedPath reaches onAsk and the registered pending for an escaping Bash command', async () => {
|
|
257
|
+
const { hooks, asks, registered } = recordingHooks();
|
|
258
|
+
const policy = makePolicy('director', FOLDER, new Set(), new Set(), hooks);
|
|
259
|
+
await askAndAbort(policy, 'Bash', { command: `cd ${OUTSIDE} && npm install playwright` });
|
|
260
|
+
assert.equal(asks[0].escapedPath, OUTSIDE);
|
|
261
|
+
assert.equal(registered[0].escapedPath, OUTSIDE);
|
|
262
|
+
assert.equal(registered[0].toolName, 'Bash');
|
|
263
|
+
|
|
264
|
+
await askAndAbort(policy, 'Bash', { command: `echo hi > ${OUTSIDE}/out.txt` });
|
|
265
|
+
assert.equal(asks[1].escapedPath, OUTSIDE, 'a file target grants its directory');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('makePolicy: escapedPath for an outside Write is the directory, not the file', async () => {
|
|
269
|
+
const { hooks, asks, registered } = recordingHooks();
|
|
270
|
+
const policy = makePolicy('worker', FOLDER, new Set(), new Set(), hooks);
|
|
271
|
+
await askAndAbort(policy, 'Write', { file_path: `${OUTSIDE}/out.txt`, content: '' });
|
|
272
|
+
assert.equal(asks[0].escapedPath, OUTSIDE);
|
|
273
|
+
assert.equal(registered[0].escapedPath, OUTSIDE);
|
|
274
|
+
assert.equal(asks[0].title, 'File edit leaves the mission folder');
|
|
275
|
+
assert.ok(asks[0].description!.endsWith(ESCAPE_GRANT_HINT));
|
|
276
|
+
|
|
277
|
+
await askAndAbort(policy, 'Edit', { file_path: `${OUTSIDE}/sub/../a.ts`, old_string: '', new_string: '' });
|
|
278
|
+
assert.equal(asks[1].escapedPath, OUTSIDE, 'resolved before dirname');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('makePolicy: escapedPath is absent on an ordinary (non-escape) ask', async () => {
|
|
282
|
+
const { hooks, asks, registered } = recordingHooks();
|
|
283
|
+
const policy = makePolicy('director', FOLDER, new Set(), new Set(), hooks, { toolPolicy: { Bash: 'ask' } });
|
|
284
|
+
await askAndAbort(policy, 'Bash', { command: 'npm test' });
|
|
285
|
+
assert.equal(asks.length, 1);
|
|
286
|
+
assert.equal(asks[0].escapedPath, undefined);
|
|
287
|
+
assert.equal(registered[0].escapedPath, undefined);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('makePolicy: granting the escapedPath auto-allows the same command next time; a different outside path still asks', async () => {
|
|
291
|
+
const { hooks, asks, allows, registered } = recordingHooks();
|
|
292
|
+
const runAllowed = new Set<string>();
|
|
293
|
+
const allowedRoots = new Set<string>();
|
|
294
|
+
const policy = makePolicy('director', FOLDER, runAllowed, allowedRoots, hooks);
|
|
295
|
+
const command = `cd ${OUTSIDE} && npm install playwright`;
|
|
296
|
+
|
|
297
|
+
// First time: the card is raised and names the directory to grant.
|
|
298
|
+
await askAndAbort(policy, 'Bash', { command });
|
|
299
|
+
assert.equal(asks.length, 1);
|
|
300
|
+
// The orchestrator's side of the contract: allow_always on a pending WITH
|
|
301
|
+
// escapedPath adds the path, not the tool.
|
|
302
|
+
allowedRoots.add(registered[0].escapedPath!);
|
|
303
|
+
assert.equal(runAllowed.size, 0);
|
|
304
|
+
|
|
305
|
+
// Second time: the same command, and its siblings under the root, run silently.
|
|
306
|
+
const ac = new AbortController();
|
|
307
|
+
assert.equal((await policy('Bash', { command }, opts(ac.signal)))!.behavior, 'allow');
|
|
308
|
+
assert.equal((await policy('Bash', { command: `mkdir -p ${OUTSIDE}/dist && echo x > ${OUTSIDE}/dist/out.txt` }, opts(ac.signal)))!.behavior, 'allow');
|
|
309
|
+
assert.equal((await policy('Write', { file_path: `${OUTSIDE}/index.js`, content: '' }, opts(ac.signal)))!.behavior, 'allow',
|
|
310
|
+
'file tools honour the same root');
|
|
311
|
+
assert.deepEqual(allows, ['Bash', 'Bash', 'Write']);
|
|
312
|
+
assert.equal(asks.length, 1);
|
|
313
|
+
|
|
314
|
+
// A different outside path is not covered by the grant.
|
|
315
|
+
await askAndAbort(policy, 'Bash', { command: 'cd /Users/x/other && npm install' });
|
|
316
|
+
assert.equal(asks.length, 2);
|
|
317
|
+
assert.equal(asks[1].escapedPath, '/Users/x/other');
|
|
318
|
+
await askAndAbort(policy, 'Write', { file_path: '/Users/x/other/x.txt', content: '' });
|
|
319
|
+
assert.equal(asks.length, 3);
|
|
320
|
+
assert.equal(asks[2].escapedPath, '/Users/x/other');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('makePolicy: runAllowed.has("Bash") alone still does NOT bypass an escape', async () => {
|
|
324
|
+
const { hooks, asks, allows } = recordingHooks();
|
|
325
|
+
const allowedRoots = new Set<string>();
|
|
326
|
+
const policy = makePolicy('director', FOLDER, new Set(['Bash', 'Write']), allowedRoots, hooks);
|
|
327
|
+
await askAndAbort(policy, 'Bash', { command: `cd ${OUTSIDE} && npm install playwright` });
|
|
328
|
+
await askAndAbort(policy, 'Write', { file_path: `${OUTSIDE}/a.txt`, content: '' });
|
|
329
|
+
assert.equal(asks.length, 2, 'a tool grant is not a path grant');
|
|
330
|
+
assert.equal(allows.length, 0);
|
|
331
|
+
assert.equal(allowedRoots.size, 0, 'the policy never mutates the caller\'s set');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Temp directories: denied with a redirect, never asked
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
test('tempRoots covers /tmp, /private/tmp, $TMPDIR and os.tmpdir() in both macOS spellings', () => {
|
|
339
|
+
const roots = tempRoots({ TMPDIR: '/var/folders/ab/T/' });
|
|
340
|
+
for (const r of ['/tmp', '/private/tmp', '/var/folders/ab/T', '/private/var/folders/ab/T', os.tmpdir()]) {
|
|
341
|
+
assert.ok(roots.includes(path.resolve(r)), `${r} missing from ${roots.join(', ')}`);
|
|
342
|
+
}
|
|
343
|
+
assert.ok(isTempPath('/tmp/foreman-verify/out.txt'));
|
|
344
|
+
assert.ok(isTempPath('/private/tmp/x'));
|
|
345
|
+
assert.ok(isTempPath(path.join(os.tmpdir(), 'x')));
|
|
346
|
+
assert.equal(isTempPath('/tmpfoo/x'), false, 'shared prefix is not containment');
|
|
347
|
+
assert.equal(isTempPath('/Users/x/other-repo/f'), false);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('makePolicy: a Bash write into /tmp is denied with a redirect and never reaches onAsk', async () => {
|
|
351
|
+
const { hooks, asks, denies, registered } = recordingHooks();
|
|
352
|
+
const policy = makePolicy('director', FOLDER, new Set(['Bash']), new Set(), hooks, { toolPolicy: { Bash: 'allow' } });
|
|
353
|
+
const ac = new AbortController();
|
|
354
|
+
// The real-world shape: the playwright install that started all this.
|
|
355
|
+
const result = (await policy('Bash', { command: 'mkdir -p /tmp/foreman-verify && cd /tmp/foreman-verify && npm install playwright' }, opts(ac.signal)))!;
|
|
356
|
+
assert.equal(result.behavior, 'deny');
|
|
357
|
+
assert.equal((result as { message: string }).message,
|
|
358
|
+
`Denied: /tmp/foreman-verify is outside the mission folder. Scratch work belongs under ${FOLDER}/${WORK_DIR}/ — ` +
|
|
359
|
+
'it is gitignored and keeps the project root clean without leaving the project. Redo this there.');
|
|
360
|
+
assert.equal(asks.length, 0, 'no card');
|
|
361
|
+
assert.equal(registered.length, 0, 'nothing pending for a human');
|
|
362
|
+
assert.equal(denies.length, 1);
|
|
363
|
+
assert.equal(denies[0].toolName, 'Bash');
|
|
364
|
+
assert.match(denies[0].reason, /\.foreman\/work\//);
|
|
365
|
+
|
|
366
|
+
assert.equal((await policy('Bash', { command: 'cd /tmp/x' }, opts(ac.signal)))!.behavior, 'deny');
|
|
367
|
+
assert.equal((await policy('Bash', { command: `echo hi > ${path.join(os.tmpdir(), 'out.txt')}` }, opts(ac.signal)))!.behavior, 'deny');
|
|
368
|
+
assert.equal(denies.length, 3);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('makePolicy: Write/Edit into /private/tmp are denied with the redirect; a non-temp outside path still asks', async () => {
|
|
372
|
+
const { hooks, asks, denies } = recordingHooks();
|
|
373
|
+
const policy = makePolicy('worker', FOLDER, new Set(), new Set(), hooks);
|
|
374
|
+
const ac = new AbortController();
|
|
375
|
+
const w = (await policy('Write', { file_path: '/private/tmp/x/f', content: '' }, opts(ac.signal)))!;
|
|
376
|
+
assert.equal(w.behavior, 'deny');
|
|
377
|
+
assert.equal((w as { message: string }).message, tempDirDenial('/private/tmp/x/f', FOLDER));
|
|
378
|
+
const e = (await policy('Edit', { file_path: '/tmp/x/f', old_string: '', new_string: '' }, opts(ac.signal)))!;
|
|
379
|
+
assert.equal(e.behavior, 'deny');
|
|
380
|
+
assert.equal(asks.length, 0);
|
|
381
|
+
assert.deepEqual(denies.map((d) => d.toolName), ['Write', 'Edit']);
|
|
382
|
+
|
|
383
|
+
// Everything outside that is not a temp dir keeps the ask path — a sibling
|
|
384
|
+
// repo might be exactly where the mission needs to go.
|
|
385
|
+
await askAndAbort(policy, 'Write', { file_path: '/Users/x/other-repo/f', content: '' });
|
|
386
|
+
assert.equal(asks.length, 1);
|
|
387
|
+
assert.equal(asks[0].escapedPath, '/Users/x/other-repo');
|
|
388
|
+
assert.equal(denies.length, 2);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('makePolicy: reads of temp paths are untouched, and a temp path the human already granted is inside', async () => {
|
|
392
|
+
const { hooks, asks, allows, denies } = recordingHooks();
|
|
393
|
+
const policy = makePolicy('director', FOLDER, new Set(), new Set(['/tmp/granted']), hooks);
|
|
394
|
+
const ac = new AbortController();
|
|
395
|
+
assert.equal((await policy('Bash', { command: 'ls /tmp && cat /tmp/x/log' }, opts(ac.signal)))!.behavior, 'allow');
|
|
396
|
+
// A root granted before this rule landed (or on a resumed run) is a
|
|
397
|
+
// decision the human already made; the deny class never overrides a grant.
|
|
398
|
+
assert.equal((await policy('Bash', { command: 'touch /tmp/granted/a' }, opts(ac.signal)))!.behavior, 'allow');
|
|
399
|
+
assert.equal((await policy('Write', { file_path: '/tmp/granted/b', content: '' }, opts(ac.signal)))!.behavior, 'allow');
|
|
400
|
+
assert.equal(asks.length, 0);
|
|
401
|
+
assert.equal(denies.length, 0);
|
|
402
|
+
assert.deepEqual(allows, ['Bash', 'Bash', 'Write']);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('makePolicy: onAutoDeny is optional — the deny still happens without a listener', async () => {
|
|
406
|
+
const { hooks } = recordingHooks();
|
|
407
|
+
delete hooks.onAutoDeny;
|
|
408
|
+
const policy = makePolicy('director', FOLDER, new Set(), new Set(), hooks);
|
|
409
|
+
const ac = new AbortController();
|
|
410
|
+
assert.equal((await policy('Write', { file_path: '/tmp/f', content: '' }, opts(ac.signal)))!.behavior, 'deny');
|
|
411
|
+
});
|