@amenophis1er/foreman 0.1.9 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/policy.test.ts +15 -0
- package/src/policy.ts +39 -5
- package/src/provider.test.ts +16 -0
- package/src/provider.ts +14 -0
package/README.md
CHANGED
|
@@ -230,7 +230,7 @@ result.
|
|
|
230
230
|
```sh
|
|
231
231
|
npm ci && npm run setup # dependencies, then the dashboard build
|
|
232
232
|
npm start # serves http://localhost:4177
|
|
233
|
-
npm test #
|
|
233
|
+
npm test # 365 tests, node:test
|
|
234
234
|
npm run typecheck # server and dashboard
|
|
235
235
|
npm run dev # API + Vite together
|
|
236
236
|
scripts/dev-restart.sh # restarts the server only when nothing would be lost
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amenophis1er/foreman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/policy.test.ts
CHANGED
|
@@ -167,6 +167,21 @@ test('operators inside quotes are text: a perl program with => is not a redirect
|
|
|
167
167
|
assert.equal(bashEscapesFolder(`echo hi > "${ROOT}/o u t.txt"`, FOLDER, [ROOT]), null);
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
+
test('the shell keeps its cwd between commands: a cd in one call places the next', () => {
|
|
171
|
+
const shell = { cwd: FOLDER };
|
|
172
|
+
assert.equal(bashEscape('cd examples/demo', FOLDER, [], shell), null);
|
|
173
|
+
assert.equal(shell.cwd, path.join(FOLDER, 'examples/demo'));
|
|
174
|
+
// The exact shape from a real mission: a log two levels up is inside the folder.
|
|
175
|
+
assert.equal(bashEscape('(npx tsx server.ts > ../../.foreman/work/demo/run1.log 2>&1 &) ; sleep 1', FOLDER, [], shell), null);
|
|
176
|
+
// A cd inside a subshell does not move the shell.
|
|
177
|
+
assert.equal(bashEscape('(cd .. && ls) && echo ok', FOLDER, [], shell), null);
|
|
178
|
+
assert.equal(shell.cwd, path.join(FOLDER, 'examples/demo'));
|
|
179
|
+
// From there, three levels up is out, and says so.
|
|
180
|
+
assert.match(bashEscape('touch ../../../x', FOLDER, [], shell)!.reason, /outside the mission folder/);
|
|
181
|
+
// Without a shell, the analysis starts at the folder as before.
|
|
182
|
+
assert.match(bashEscape('echo hi > ../../.foreman/work/demo/run1.log', FOLDER)!.reason, /redirects output outside/);
|
|
183
|
+
});
|
|
184
|
+
|
|
170
185
|
test('a shell -c body is analysed as a command, so quoting cannot hide a cd', () => {
|
|
171
186
|
assert.ok(bashEscapesFolder(`sh -c 'cd /tmp && npm install'`, FOLDER, [ROOT]));
|
|
172
187
|
assert.match(bashEscapesFolder(`bash -c "mkdir -p /tmp/x/y"`, FOLDER, [ROOT])!, /creates a path outside/);
|
package/src/policy.ts
CHANGED
|
@@ -297,6 +297,7 @@ export interface BashEscape {
|
|
|
297
297
|
*/
|
|
298
298
|
export function bashEscape(
|
|
299
299
|
command: string, folder: string, extraRoots: Iterable<string> = [],
|
|
300
|
+
shell?: { cwd?: string },
|
|
300
301
|
): BashEscape | null {
|
|
301
302
|
const root = path.resolve(folder);
|
|
302
303
|
// Snapshot the grants once per call: the caller's set is mutable and may be
|
|
@@ -310,7 +311,15 @@ export function bashEscape(
|
|
|
310
311
|
grant: isDir ? p : path.dirname(p),
|
|
311
312
|
});
|
|
312
313
|
|
|
313
|
-
|
|
314
|
+
// The virtual cwd, so `cd sub && rm -rf ../../x` resolves correctly. It
|
|
315
|
+
// starts where the agent's shell actually is: Claude Code's Bash tool keeps
|
|
316
|
+
// its working directory between commands, so a director that ran
|
|
317
|
+
// `cd examples/demo` one call ago and now writes `../../.foreman/work/x.log`
|
|
318
|
+
// is writing inside the folder — read from the root that looked like two
|
|
319
|
+
// levels out, and put an approval card in front of an in-folder log.
|
|
320
|
+
let cwd = shell?.cwd ? path.resolve(shell.cwd) : root;
|
|
321
|
+
// `cd` inside a subshell does not outlive it; only top-level moves persist.
|
|
322
|
+
let depth = 0;
|
|
314
323
|
|
|
315
324
|
const segments = maskQuoted(command)
|
|
316
325
|
.split(/&&|\|\||;|\n|\|(?!\|)|(?<![&>\d])&(?![&>])/)
|
|
@@ -323,7 +332,13 @@ export function bashEscape(
|
|
|
323
332
|
const p = asPath(m[1], cwd);
|
|
324
333
|
if (p !== null && outside(p)) return hit(segment, 'redirects output', p, false);
|
|
325
334
|
}
|
|
335
|
+
const opens = (segment.match(/\(/g) ?? []).length;
|
|
336
|
+
const closes = (segment.match(/\)/g) ?? []).length;
|
|
326
337
|
const words = segment.replace(REDIRECT_RE, ' ').split(/\s+/).filter(Boolean).map(bare);
|
|
338
|
+
// Depth as seen at this segment's verb: an opening paren on the segment
|
|
339
|
+
// itself puts its `cd` inside the subshell.
|
|
340
|
+
const depthHere = depth + (segment.trimStart().startsWith('(') ? 1 : 0);
|
|
341
|
+
depth = Math.max(0, depth + opens - closes);
|
|
327
342
|
// Peel wrappers and leading VAR=value assignments to reach the real verb.
|
|
328
343
|
let i = 0;
|
|
329
344
|
while (i < words.length && (WRAPPERS.has(words[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))) i++;
|
|
@@ -352,6 +367,7 @@ export function bashEscape(
|
|
|
352
367
|
// Enough on its own: whatever follows (relative writes, `npm install`,
|
|
353
368
|
// `git init`) lands there, so we need not look at later segments.
|
|
354
369
|
if (outside(p)) return hit(segment, 'writes after this land', p, true);
|
|
370
|
+
if (depthHere === 0 && shell) shell.cwd = p;
|
|
355
371
|
continue;
|
|
356
372
|
}
|
|
357
373
|
|
|
@@ -532,6 +548,10 @@ export function makePolicy(
|
|
|
532
548
|
const foremanDir = path.join(folder, '.foreman') + path.sep;
|
|
533
549
|
const toolPolicy = { ...DEFAULT_TOOL_POLICY, ...settings?.toolPolicy };
|
|
534
550
|
const autoReadOnly = settings?.autoAllowReadOnly !== false;
|
|
551
|
+
// Where this agent's shell is. One policy per agent session, and the SDK's
|
|
552
|
+
// Bash keeps its cwd across calls, so a top-level `cd` in an allowed
|
|
553
|
+
// command is where the next command starts. Reset only with the session.
|
|
554
|
+
const shell: { cwd?: string } = { cwd: folder };
|
|
535
555
|
|
|
536
556
|
return async (toolName, input, opts) => {
|
|
537
557
|
// An explicit grant (Settings policy or the human's "Always" click) is a
|
|
@@ -557,10 +577,15 @@ export function makePolicy(
|
|
|
557
577
|
// The shell is the other door out of the job site: a command that would
|
|
558
578
|
// write outside the folder prompts under the same rule, so a blanket
|
|
559
579
|
// `Bash: allow` (or an "Always" click) cannot quietly `cd /tmp && npm i`.
|
|
580
|
+
// Analysed from the shell's current directory; the walk records where a
|
|
581
|
+
// top-level `cd` leaves it. A command that escapes is not run unless the
|
|
582
|
+
// human allows it, so its `cd` is remembered only after an allow below.
|
|
583
|
+
const shellAfter = { cwd: shell.cwd };
|
|
560
584
|
const escape =
|
|
561
585
|
toolName === 'Bash' && typeof input.command === 'string'
|
|
562
|
-
? bashEscape(input.command, folder, allowedRoots)
|
|
586
|
+
? bashEscape(input.command, folder, allowedRoots, shellAfter)
|
|
563
587
|
: null;
|
|
588
|
+
const rememberCwd = () => { if (toolName === 'Bash') shell.cwd = shellAfter.cwd; };
|
|
564
589
|
const leavesFolder = outsideFolder || escape !== null;
|
|
565
590
|
// What "always" on this card grants — see `PendingPermission.escapedPath`.
|
|
566
591
|
// For a file tool the parent directory: the worker that wrote
|
|
@@ -590,6 +615,7 @@ export function makePolicy(
|
|
|
590
615
|
) {
|
|
591
616
|
// Carry the reason through so the log shows what was waved past.
|
|
592
617
|
hooks.onAutoAllow(agent, toolName, opts.decisionReason);
|
|
618
|
+
rememberCwd();
|
|
593
619
|
return { behavior: 'allow' };
|
|
594
620
|
}
|
|
595
621
|
|
|
@@ -632,13 +658,21 @@ export function makePolicy(
|
|
|
632
658
|
input,
|
|
633
659
|
title,
|
|
634
660
|
description,
|
|
635
|
-
|
|
636
|
-
|
|
661
|
+
// The card's one warning line. When the folder boundary is the reason,
|
|
662
|
+
// say that — the SDK's own heuristic ("contains shell syntax (&) that
|
|
663
|
+
// cannot be statically analyzed") is true of most real commands and
|
|
664
|
+
// tells the reader nothing about why THIS one stopped.
|
|
665
|
+
decisionReason: leavesFolder
|
|
666
|
+
? (escape?.reason ?? `${filePath} is outside the mission folder`)
|
|
667
|
+
: opts.decisionReason,
|
|
637
668
|
escapedPath,
|
|
638
669
|
});
|
|
639
670
|
|
|
640
671
|
return new Promise<PermissionResult>((resolve) => {
|
|
641
|
-
hooks.register(id, {
|
|
672
|
+
hooks.register(id, {
|
|
673
|
+
resolve: (r) => { if (r.behavior === 'allow') rememberCwd(); resolve(r); },
|
|
674
|
+
toolName, suggestions: opts.suggestions, escapedPath,
|
|
675
|
+
});
|
|
642
676
|
opts.signal.addEventListener('abort', () => {
|
|
643
677
|
if (hooks.unregister(id)) {
|
|
644
678
|
resolve({ behavior: 'deny', message: 'Run was interrupted.' });
|
package/src/provider.test.ts
CHANGED
|
@@ -364,3 +364,19 @@ test('a role provider carries the role’s model, so every alias resolves on its
|
|
|
364
364
|
assert.equal(withRoleModel(bare, '').model, undefined);
|
|
365
365
|
assert.equal(withRoleModel(bare, undefined), bare, 'no change returns the same object');
|
|
366
366
|
});
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
test('agents do not inherit the server\'s own environment: PORT and FOREMAN_* are dropped', async () => {
|
|
370
|
+
const saved = { PORT: process.env.PORT, FOREMAN_HOME: process.env.FOREMAN_HOME, FOREMAN_BIND: process.env.FOREMAN_BIND };
|
|
371
|
+
process.env.PORT = '4177'; process.env.FOREMAN_HOME = '/tmp/fh'; process.env.FOREMAN_BIND = 'all';
|
|
372
|
+
try {
|
|
373
|
+
const p = await resolveProvider({ kind: 'claude-code' }, '/tmp/foreman-test-root');
|
|
374
|
+
const env = providerEnv(p).env ?? {};
|
|
375
|
+
assert.equal(env.PORT, undefined, 'a dev server a worker starts must not bind the dashboard port');
|
|
376
|
+
assert.equal(env.FOREMAN_HOME, undefined);
|
|
377
|
+
assert.equal(env.FOREMAN_BIND, undefined);
|
|
378
|
+
assert.ok(env.PATH, 'the rest of the environment survives');
|
|
379
|
+
} finally {
|
|
380
|
+
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
381
|
+
}
|
|
382
|
+
});
|
package/src/provider.ts
CHANGED
|
@@ -427,8 +427,22 @@ export interface AgentEnv {
|
|
|
427
427
|
* programming error rather than a user error, and the failure mode it guards
|
|
428
428
|
* against is a leaked token, so it fails loudly instead of degrading.
|
|
429
429
|
*/
|
|
430
|
+
/**
|
|
431
|
+
* The server's own knobs, which an agent must not inherit. `PORT` is the one
|
|
432
|
+
* that bit: the service sets it for the dashboard, an agent's dev server
|
|
433
|
+
* honoured it, bound Foreman's port on a wildcard address and shadowed the
|
|
434
|
+
* dashboard on localhost until it exited. The rest are Foreman's operating
|
|
435
|
+
* settings — none of them means anything to a project's own tooling, and a
|
|
436
|
+
* worker reading `FOREMAN_HOME` would only find somewhere it should not be.
|
|
437
|
+
*/
|
|
438
|
+
const SERVER_ONLY_VARS = [
|
|
439
|
+
'PORT', 'FOREMAN_SERVICES_PORT', 'FOREMAN_HOME', 'FOREMAN_BIND', 'FOREMAN_BROWSER',
|
|
440
|
+
'FOREMAN_AUTH_MODE', 'FOREMAN_CLAUDE_CONFIG_DIR', 'FOREMAN_CLAUDE_EXECUTABLE', 'FOREMAN_NO_TELEGRAM', 'FOREMAN_LOG',
|
|
441
|
+
];
|
|
442
|
+
|
|
430
443
|
export function providerEnv(p: ResolvedProvider, gatewayUrl?: string): AgentEnv {
|
|
431
444
|
const env: Record<string, string | undefined> = { ...process.env };
|
|
445
|
+
for (const v of SERVER_ONLY_VARS) delete env[v];
|
|
432
446
|
env.CLAUDE_CONFIG_DIR = p.configDir;
|
|
433
447
|
|
|
434
448
|
if (p.wire === 'anthropic-native') {
|