bare-agent 0.7.0 → 0.9.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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +20 -16
- package/bin/cli.js +225 -36
- package/index.js +2 -4
- package/package.json +14 -7
- package/src/bareguard-adapter.js +93 -0
- package/src/errors.js +0 -14
- package/src/loop.js +17 -78
- package/src/mcp-bridge.js +130 -19
- package/src/mcp.js +2 -2
- package/src/retry.js +9 -1
- package/src/tools.js +11 -1
- package/tools/defer.js +203 -0
- package/tools/spawn.js +242 -0
- package/src/policy.js +0 -132
package/tools/defer.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* tools/defer.js — emit a deferred-action record to a JSONL queue.
|
|
5
|
+
*
|
|
6
|
+
* LLM-callable form: `defer({ action, when })` appends ONE JSONL record
|
|
7
|
+
* to the defer queue file and returns `{ id }`. bareagent does NOT wake
|
|
8
|
+
* up later — the running process exits when the loop ends. An external
|
|
9
|
+
* scheduler (cron + `examples/wake.sh`) reads the queue and fires due
|
|
10
|
+
* actions by re-invoking bareagent.
|
|
11
|
+
*
|
|
12
|
+
* Two-phase gate semantics (per bareagent PRD §10.7 + bareguard PRD §14):
|
|
13
|
+
* - At emit (this tool): one gate.check on `{ type: 'defer', args: { action, when }, _ctx }`
|
|
14
|
+
* runs the full pipeline (defer.ratePerMinute, tools.allowlist on `defer`,
|
|
15
|
+
* content.* over the JSON-serialized form). Bareguard does NOT extract
|
|
16
|
+
* args.action and run a second pipeline against it at emit time.
|
|
17
|
+
* - At fire (wake.sh invokes bareagent with the inner action): a separate
|
|
18
|
+
* gate.check runs the full pipeline against the inner action as a fresh
|
|
19
|
+
* action. Two distinct gate.check calls, two distinct audit lines,
|
|
20
|
+
* reconstructable via parent_run_id.
|
|
21
|
+
*
|
|
22
|
+
* Queue file format — one JSON record per line, append-only:
|
|
23
|
+
* { id, ts_emitted, when, action, parent_run_id, status }
|
|
24
|
+
* Status updates are appends, not edits: wake.sh appends
|
|
25
|
+
* { id, status: 'fired', ts }
|
|
26
|
+
* Reconstruction folds by `id` (latest wins).
|
|
27
|
+
*
|
|
28
|
+
* Default queue path: ./bareagent-defers.jsonl (cwd-only, project-scoped).
|
|
29
|
+
* Override via BAREAGENT_DEFER_QUEUE env var or createDeferTool({queuePath}).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const fsp = require('node:fs/promises');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const crypto = require('node:crypto');
|
|
36
|
+
|
|
37
|
+
const DEFAULT_QUEUE_PATH = './bareagent-defers.jsonl';
|
|
38
|
+
const ID_PREFIX = 'def_';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate a sortable, unique id. 9-char base36 timestamp + 20-char hex
|
|
42
|
+
* random. Lexicographically sortable by emit time; unique enough for any
|
|
43
|
+
* realistic defer rate. Same shape as the PRD's `def_01J...` sketch.
|
|
44
|
+
*/
|
|
45
|
+
function generateId() {
|
|
46
|
+
const ts = Date.now().toString(36).padStart(9, '0');
|
|
47
|
+
const rand = crypto.randomBytes(10).toString('hex');
|
|
48
|
+
return `${ID_PREFIX}${ts}_${rand}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the active queue path. Precedence:
|
|
53
|
+
* 1. Caller-supplied option (createDeferTool({ queuePath: '...' }))
|
|
54
|
+
* 2. BAREAGENT_DEFER_QUEUE env var
|
|
55
|
+
* 3. ./bareagent-defers.jsonl
|
|
56
|
+
*/
|
|
57
|
+
function resolveQueuePath(option) {
|
|
58
|
+
return option
|
|
59
|
+
|| process.env.BAREAGENT_DEFER_QUEUE
|
|
60
|
+
|| DEFAULT_QUEUE_PATH;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate a `when` field. Accepts an ISO 8601 timestamp string. Rejects
|
|
65
|
+
* past timestamps loosely (more than 60s in the past) — the wake script
|
|
66
|
+
* would fire them immediately, which is almost always not what the agent
|
|
67
|
+
* meant. Future timestamps within reason are accepted as-is.
|
|
68
|
+
*
|
|
69
|
+
* Returns { ok: true, iso } on success, { ok: false, error } on failure.
|
|
70
|
+
*/
|
|
71
|
+
function validateWhen(when) {
|
|
72
|
+
if (typeof when !== 'string' || !when) {
|
|
73
|
+
return { ok: false, error: 'when must be an ISO 8601 timestamp string' };
|
|
74
|
+
}
|
|
75
|
+
const t = Date.parse(when);
|
|
76
|
+
if (Number.isNaN(t)) {
|
|
77
|
+
return { ok: false, error: `when is not a valid ISO 8601 timestamp: ${when}` };
|
|
78
|
+
}
|
|
79
|
+
const driftMs = Date.now() - t;
|
|
80
|
+
if (driftMs > 60_000) {
|
|
81
|
+
return { ok: false, error: `when is more than 60s in the past (drift=${driftMs}ms) — would fire immediately` };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, iso: new Date(t).toISOString() };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate an `action` field. Must be an object with a string `type`.
|
|
88
|
+
* Anything else is the LLM either confused or trying to defer something
|
|
89
|
+
* meaningless.
|
|
90
|
+
*/
|
|
91
|
+
function validateAction(action) {
|
|
92
|
+
if (!action || typeof action !== 'object' || Array.isArray(action)) {
|
|
93
|
+
return { ok: false, error: 'action must be an object' };
|
|
94
|
+
}
|
|
95
|
+
if (typeof action.type !== 'string' || !action.type) {
|
|
96
|
+
return { ok: false, error: 'action.type must be a non-empty string' };
|
|
97
|
+
}
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Append one JSONL record to the queue file. fs.promises.appendFile is
|
|
103
|
+
* atomic for writes < PIPE_BUF on POSIX (4KB on Linux); a JSON record
|
|
104
|
+
* with a small action is well under that.
|
|
105
|
+
*/
|
|
106
|
+
async function appendRecord(queuePath, record) {
|
|
107
|
+
const dir = path.dirname(path.resolve(queuePath));
|
|
108
|
+
// Best-effort dir creation; ignore "already exists".
|
|
109
|
+
try { await fsp.mkdir(dir, { recursive: true }); } catch { /* fine */ }
|
|
110
|
+
const line = JSON.stringify(record) + '\n';
|
|
111
|
+
if (line.length > 4000) {
|
|
112
|
+
// Soft guard — if the action payload is huge, the audit-and-fire chain
|
|
113
|
+
// will still work but POSIX atomicity guarantee is gone. Warn.
|
|
114
|
+
process.stderr.write(`[defer] record is ${line.length}B (> ~4KB POSIX_PIPE_BUF) — atomicity not guaranteed\n`);
|
|
115
|
+
}
|
|
116
|
+
await fsp.appendFile(queuePath, line);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Read the queue and reconstruct the live status of each id by folding
|
|
121
|
+
* append-only status lines (latest wins). Exposed for tests + library
|
|
122
|
+
* users; the wake script does its own jq-based fold.
|
|
123
|
+
*/
|
|
124
|
+
async function readQueue(queuePath) {
|
|
125
|
+
const path = resolveQueuePath(queuePath);
|
|
126
|
+
try {
|
|
127
|
+
const text = await fsp.readFile(path, 'utf8');
|
|
128
|
+
const records = {};
|
|
129
|
+
for (const line of text.split('\n')) {
|
|
130
|
+
if (!line.trim()) continue;
|
|
131
|
+
let r;
|
|
132
|
+
try { r = JSON.parse(line); } catch { continue; }
|
|
133
|
+
if (!r.id) continue;
|
|
134
|
+
records[r.id] = { ...records[r.id], ...r };
|
|
135
|
+
}
|
|
136
|
+
return Object.values(records);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err.code === 'ENOENT') return [];
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {object} [options]
|
|
145
|
+
* @param {string} [options.queuePath] - Override queue file path.
|
|
146
|
+
* @returns {{tool: object, readQueue: Function}}
|
|
147
|
+
*/
|
|
148
|
+
function createDeferTool(options = {}) {
|
|
149
|
+
const queuePath = resolveQueuePath(options.queuePath);
|
|
150
|
+
|
|
151
|
+
const tool = {
|
|
152
|
+
name: 'defer',
|
|
153
|
+
description:
|
|
154
|
+
'Append a deferred action to the queue. The action will be fired at or after `when` by the external wake script (cron + examples/wake.sh). bareagent does NOT wake up — the queue is project-scoped JSONL on disk. Returns { id }. Use sparingly: defer.ratePerMinute caps emits per agent family (default 15/min in bareguard 0.2).',
|
|
155
|
+
parameters: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties: {
|
|
158
|
+
action: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
description: 'The action to fire. Must have a string `type` field naming a tool the wake-time agent can invoke (e.g. `{ type: "spawn", args: { config: "specialists/check-ci.json" } }`).',
|
|
161
|
+
},
|
|
162
|
+
when: {
|
|
163
|
+
type: 'string',
|
|
164
|
+
description: 'ISO 8601 timestamp for when to fire (e.g. "2026-04-30T18:00:00Z"). Must not be more than 60s in the past.',
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
required: ['action', 'when'],
|
|
168
|
+
},
|
|
169
|
+
execute: async ({ action, when }) => {
|
|
170
|
+
const a = validateAction(action);
|
|
171
|
+
if (!a.ok) throw new Error(`[defer] ${a.error}`);
|
|
172
|
+
const w = validateWhen(when);
|
|
173
|
+
if (!w.ok) throw new Error(`[defer] ${w.error}`);
|
|
174
|
+
|
|
175
|
+
const record = {
|
|
176
|
+
id: generateId(),
|
|
177
|
+
ts_emitted: new Date().toISOString(),
|
|
178
|
+
when: w.iso,
|
|
179
|
+
action,
|
|
180
|
+
parent_run_id:
|
|
181
|
+
process.env.BAREGUARD_RUN_ID
|
|
182
|
+
|| process.env.BAREGUARD_PARENT_RUN_ID
|
|
183
|
+
|| null,
|
|
184
|
+
status: 'pending',
|
|
185
|
+
};
|
|
186
|
+
await appendRecord(queuePath, record);
|
|
187
|
+
return { id: record.id };
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
tool,
|
|
193
|
+
readQueue: () => readQueue(queuePath),
|
|
194
|
+
queuePath,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
createDeferTool,
|
|
200
|
+
readQueue,
|
|
201
|
+
generateId, // exported for tests
|
|
202
|
+
resolveQueuePath, // exported for tests
|
|
203
|
+
};
|
package/tools/spawn.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* tools/spawn.js — fork a child bareagent process.
|
|
5
|
+
*
|
|
6
|
+
* LLM-callable form: `spawn({ config, input? })` blocks until the child
|
|
7
|
+
* exits and returns the child's final result. Per the PRD: LLMs don't
|
|
8
|
+
* manage handles across tool calls, so blocking is the only sane LLM
|
|
9
|
+
* surface. Library callers can use the lower-level `spawnChild()` export
|
|
10
|
+
* for fire-and-forget / handle-based use.
|
|
11
|
+
*
|
|
12
|
+
* The child is bareagent itself, invoked as:
|
|
13
|
+
* <node> <bin/cli.js> --config <config-path>
|
|
14
|
+
*
|
|
15
|
+
* Env-var threading (per bareguard 0.1.1+ stitching contract):
|
|
16
|
+
* - BAREGUARD_AUDIT_PATH — single audit file across the family
|
|
17
|
+
* - BAREGUARD_BUDGET_FILE — shared budget ledger
|
|
18
|
+
* - BAREGUARD_PARENT_RUN_ID — parent's run_id becomes child's parent
|
|
19
|
+
* - BAREGUARD_SPAWN_DEPTH — incremented; bareguard.limits.maxDepth caps it
|
|
20
|
+
*
|
|
21
|
+
* Stream model (per v0.9 §10.6 decision):
|
|
22
|
+
* ONE JSONL channel per child. Child stdout is the structured event
|
|
23
|
+
* stream. Child stderr is captured here and re-emitted as
|
|
24
|
+
* `{type: 'child:stderr', text, ts}` events on the parent's stream
|
|
25
|
+
* (if any). No two-channel split.
|
|
26
|
+
*
|
|
27
|
+
* Action shape sent to gate.check (when wired through wireGate):
|
|
28
|
+
* { type: 'spawn', args: { config, input }, _ctx }
|
|
29
|
+
* Bareguard treats `args` as opaque — content patterns scan the
|
|
30
|
+
* JSON-serialized form. spawn.ratePerMinute (bareguard 0.2+) caps emits
|
|
31
|
+
* per-family.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const { spawn: cpSpawn } = require('node:child_process');
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
const readline = require('node:readline');
|
|
37
|
+
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min — children should finish or be killed
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the bareagent CLI path. Prefers the local repo's bin/cli.js so
|
|
42
|
+
* the test suite + dev runs use the in-tree CLI; falls back to npx.
|
|
43
|
+
*/
|
|
44
|
+
function resolveCliPath() {
|
|
45
|
+
// tools/spawn.js → ../bin/cli.js (works in dev tree and when installed via npm)
|
|
46
|
+
return path.resolve(__dirname, '..', 'bin', 'cli.js');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Library-level: spawn a child and return a handle.
|
|
51
|
+
*
|
|
52
|
+
* Returns: {
|
|
53
|
+
* wait() — Promise<{ text, usage, cost, error, events }>
|
|
54
|
+
* onLine(fn) — subscribe to every JSONL event from child stdout
|
|
55
|
+
* kill(sig?) — terminate the child
|
|
56
|
+
* pid — child process id
|
|
57
|
+
* }
|
|
58
|
+
*
|
|
59
|
+
* Use this from library code; the LLM-callable tool below wraps it with blocking semantics.
|
|
60
|
+
*/
|
|
61
|
+
function spawnChild({ config, input, cliPath, timeoutMs, stream } = {}) {
|
|
62
|
+
if (typeof config !== 'string' || !config) {
|
|
63
|
+
throw new Error('[spawn] requires { config: <path> }');
|
|
64
|
+
}
|
|
65
|
+
const cli = cliPath || resolveCliPath();
|
|
66
|
+
const child = cpSpawn(process.execPath, [cli, '--config', config], {
|
|
67
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
68
|
+
env: {
|
|
69
|
+
...process.env,
|
|
70
|
+
BAREGUARD_AUDIT_PATH: process.env.BAREGUARD_AUDIT_PATH || '',
|
|
71
|
+
BAREGUARD_BUDGET_FILE: process.env.BAREGUARD_BUDGET_FILE || '',
|
|
72
|
+
BAREGUARD_PARENT_RUN_ID: process.env.BAREGUARD_RUN_ID
|
|
73
|
+
|| process.env.BAREGUARD_PARENT_RUN_ID
|
|
74
|
+
|| '',
|
|
75
|
+
BAREGUARD_SPAWN_DEPTH: String((Number(process.env.BAREGUARD_SPAWN_DEPTH) || 0) + 1),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (input !== undefined) {
|
|
80
|
+
child.stdin.write(JSON.stringify(input) + '\n');
|
|
81
|
+
}
|
|
82
|
+
child.stdin.end();
|
|
83
|
+
|
|
84
|
+
const events = [];
|
|
85
|
+
const lineSubscribers = [];
|
|
86
|
+
const onLine = (fn) => { lineSubscribers.push(fn); return () => {
|
|
87
|
+
const i = lineSubscribers.indexOf(fn);
|
|
88
|
+
if (i >= 0) lineSubscribers.splice(i, 1);
|
|
89
|
+
}; };
|
|
90
|
+
|
|
91
|
+
// stdout — JSONL events from the child loop
|
|
92
|
+
const outRl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
93
|
+
outRl.on('line', (line) => {
|
|
94
|
+
if (!line) return;
|
|
95
|
+
let event;
|
|
96
|
+
try { event = JSON.parse(line); }
|
|
97
|
+
catch {
|
|
98
|
+
// Not JSON — treat as raw text on the child's stdout (rare; surface as event)
|
|
99
|
+
event = { type: 'child:stdout_raw', text: line, ts: new Date().toISOString() };
|
|
100
|
+
}
|
|
101
|
+
events.push(event);
|
|
102
|
+
for (const fn of lineSubscribers) {
|
|
103
|
+
try { fn(event); } catch (err) {
|
|
104
|
+
// never let a subscriber kill the read loop
|
|
105
|
+
process.stderr.write(`[spawn] onLine subscriber threw: ${err.message}\n`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// stderr — re-emit as child:stderr events on the same JSONL channel.
|
|
111
|
+
// Per the v0.9 decision: one stream per child. Wake.sh captures everything
|
|
112
|
+
// (events + debug) by redirecting child stdout alone; stderr was the
|
|
113
|
+
// *parent's* problem to consolidate into the JSONL stream.
|
|
114
|
+
const errRl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
|
|
115
|
+
errRl.on('line', (line) => {
|
|
116
|
+
if (!line) return;
|
|
117
|
+
const event = { type: 'child:stderr', text: line, ts: new Date().toISOString() };
|
|
118
|
+
events.push(event);
|
|
119
|
+
if (stream) {
|
|
120
|
+
try { stream.emit(event); } catch { /* swallow */ }
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Pre-register close-event promises NOW (not lazily inside child.on('exit')).
|
|
125
|
+
// The close event can fire before the exit handler runs; attaching .once()
|
|
126
|
+
// after the fact would hang forever.
|
|
127
|
+
const outClosePromise = new Promise(r => outRl.once('close', r));
|
|
128
|
+
const errClosePromise = new Promise(r => errRl.once('close', r));
|
|
129
|
+
|
|
130
|
+
// Timeout: kill child if it overruns. The grace period after SIGTERM is 5s
|
|
131
|
+
// before SIGKILL — enough for the child to flush its final JSONL line.
|
|
132
|
+
let killTimer = null;
|
|
133
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
134
|
+
killTimer = setTimeout(() => {
|
|
135
|
+
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
136
|
+
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, 5000).unref();
|
|
137
|
+
}, timeoutMs);
|
|
138
|
+
killTimer.unref();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const exitPromise = new Promise((resolve) => {
|
|
142
|
+
child.on('exit', async (code, signal) => {
|
|
143
|
+
if (killTimer) clearTimeout(killTimer);
|
|
144
|
+
// Drain stdio readlines before resolving — last line may still be in buffer.
|
|
145
|
+
await Promise.all([outClosePromise, errClosePromise]);
|
|
146
|
+
resolve({ code, signal });
|
|
147
|
+
});
|
|
148
|
+
child.on('error', (err) => {
|
|
149
|
+
if (killTimer) clearTimeout(killTimer);
|
|
150
|
+
resolve({ code: null, signal: null, spawnError: err });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
async function wait() {
|
|
155
|
+
const { code, signal, spawnError } = await exitPromise;
|
|
156
|
+
if (spawnError) {
|
|
157
|
+
return {
|
|
158
|
+
text: '',
|
|
159
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
160
|
+
cost: 0,
|
|
161
|
+
error: `[spawn] failed to spawn child: ${spawnError.message}`,
|
|
162
|
+
events,
|
|
163
|
+
exitCode: null,
|
|
164
|
+
signal: null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// Pluck the final loop:done event — that's the canonical child result.
|
|
168
|
+
const done = events.findLast?.(e => e.type === 'loop:done')
|
|
169
|
+
|| [...events].reverse().find(e => e.type === 'loop:done');
|
|
170
|
+
if (done) {
|
|
171
|
+
return {
|
|
172
|
+
text: done.data?.text || '',
|
|
173
|
+
usage: done.data?.usage || { inputTokens: 0, outputTokens: 0 },
|
|
174
|
+
cost: done.data?.cost ?? 0,
|
|
175
|
+
error: done.data?.warning || null,
|
|
176
|
+
events,
|
|
177
|
+
exitCode: code,
|
|
178
|
+
signal,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// No loop:done — child exited abnormally or never reached the LLM.
|
|
182
|
+
const errEvent = events.find(e => e.type === 'loop:error' || e.type === 'error');
|
|
183
|
+
return {
|
|
184
|
+
text: '',
|
|
185
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
186
|
+
cost: 0,
|
|
187
|
+
error: errEvent?.data?.error || `[spawn] child exited (code=${code}, signal=${signal}) without loop:done`,
|
|
188
|
+
events,
|
|
189
|
+
exitCode: code,
|
|
190
|
+
signal,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function kill(sig = 'SIGTERM') {
|
|
195
|
+
try { child.kill(sig); } catch { /* already dead */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { wait, onLine, kill, pid: child.pid };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* LLM-callable spawn tool. Blocks; returns the child's final result.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} [options]
|
|
205
|
+
* @param {string} [options.cliPath] - Override the bareagent CLI path (default: ./bin/cli.js relative to this file).
|
|
206
|
+
* @param {number} [options.timeoutMs] - Force-kill child after this many ms (default 10 min).
|
|
207
|
+
* @param {object} [options.stream] - bareagent Stream instance — child:stderr events get re-emitted here.
|
|
208
|
+
* @returns {{tool: object, spawnChild: Function}}
|
|
209
|
+
*/
|
|
210
|
+
function createSpawnTool(options = {}) {
|
|
211
|
+
const tool = {
|
|
212
|
+
name: 'spawn',
|
|
213
|
+
description:
|
|
214
|
+
'Fork a child bareagent process with the given config file and optional JSON input. Blocks until the child finishes; returns its final {text, usage, cost, error, events}. Use this to delegate work to a specialist agent. Per-family limits (maxChildren, maxDepth, spawn.ratePerMinute) are enforced by bareguard.',
|
|
215
|
+
parameters: {
|
|
216
|
+
type: 'object',
|
|
217
|
+
properties: {
|
|
218
|
+
config: {
|
|
219
|
+
type: 'string',
|
|
220
|
+
description: 'Path to a bareagent config JSON file (specialist definition). Resolved relative to the parent process cwd.',
|
|
221
|
+
},
|
|
222
|
+
input: {
|
|
223
|
+
description: 'Optional JSON input passed to the child on stdin (any shape; the child config decides how to interpret it).',
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
required: ['config'],
|
|
227
|
+
},
|
|
228
|
+
execute: async ({ config, input }) => {
|
|
229
|
+
const handle = spawnChild({
|
|
230
|
+
config,
|
|
231
|
+
input,
|
|
232
|
+
cliPath: options.cliPath,
|
|
233
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
234
|
+
stream: options.stream,
|
|
235
|
+
});
|
|
236
|
+
return await handle.wait();
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
return { tool, spawnChild };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { createSpawnTool, spawnChild };
|
package/src/policy.js
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Policy helpers — composable predicates for `new Loop({ policy })`.
|
|
5
|
-
*
|
|
6
|
-
* Each helper returns an async function `(toolName, args, ctx) => true | string`
|
|
7
|
-
* matching bareagent's policy contract. `true` allows; anything else denies.
|
|
8
|
-
* A string is fed verbatim to the LLM as the deny reason.
|
|
9
|
-
*
|
|
10
|
-
* Compose multiple helpers with `combinePolicies(a, b, c)` — first non-`true`
|
|
11
|
-
* verdict wins, short-circuit semantics.
|
|
12
|
-
*
|
|
13
|
-
* Zero deps. Pure Node. Cross-platform.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const path = require('node:path');
|
|
17
|
-
|
|
18
|
-
function expandHome(p) {
|
|
19
|
-
if (!p || typeof p !== 'string') return p;
|
|
20
|
-
if (p === '~') return process.env.HOME || process.env.USERPROFILE || '';
|
|
21
|
-
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
22
|
-
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
23
|
-
return path.join(home, p.slice(2));
|
|
24
|
-
}
|
|
25
|
-
return p;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function normalize(p) {
|
|
29
|
-
try {
|
|
30
|
-
return path.resolve(expandHome(p));
|
|
31
|
-
} catch {
|
|
32
|
-
return p;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Allow/deny file-system paths used by tools like shell_read, shell_grep.
|
|
38
|
-
*
|
|
39
|
-
* Deny wins over allow. Paths containing `..` after normalization are denied
|
|
40
|
-
* unconditionally (prevents traversal bypassing the allow list).
|
|
41
|
-
*
|
|
42
|
-
* @param {object} options
|
|
43
|
-
* @param {string[]} [options.allow] - Path prefixes users may access. `~` expands.
|
|
44
|
-
* @param {string[]} [options.deny] - Path prefixes hard-denied. `~` expands.
|
|
45
|
-
* @param {string[]} [options.toolNames] - Only check these tool names. If omitted, checks any tool with an `args.path` string.
|
|
46
|
-
* @param {string} [options.argKey='path'] - Name of the args field to inspect.
|
|
47
|
-
* @returns {Function} policy predicate `(toolName, args) => true | string`
|
|
48
|
-
*/
|
|
49
|
-
function pathAllowlist({ allow = [], deny = [], toolNames, argKey = 'path' } = {}) {
|
|
50
|
-
const allowNorm = allow.map(normalize);
|
|
51
|
-
const denyNorm = deny.map(normalize);
|
|
52
|
-
const gatedTools = toolNames ? new Set(toolNames) : null;
|
|
53
|
-
|
|
54
|
-
return async function pathPolicy(toolName, args) {
|
|
55
|
-
if (gatedTools && !gatedTools.has(toolName)) return true;
|
|
56
|
-
const raw = args?.[argKey];
|
|
57
|
-
if (typeof raw !== 'string') return true; // nothing to check
|
|
58
|
-
const target = normalize(raw);
|
|
59
|
-
|
|
60
|
-
for (const d of denyNorm) {
|
|
61
|
-
if (target === d || target.startsWith(d + path.sep) || target.startsWith(d + '/')) {
|
|
62
|
-
return `Path denied: ${raw} is under a denied root (${d}).`;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
if (allowNorm.length === 0) return true;
|
|
66
|
-
for (const a of allowNorm) {
|
|
67
|
-
if (target === a || target.startsWith(a + path.sep) || target.startsWith(a + '/')) {
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return `Path denied: ${raw} is not under any allowed root.`;
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Allow/deny commands by their base name.
|
|
77
|
-
*
|
|
78
|
-
* For `shell_run` (argv-array): inspects `args.argv[0]`. Safe — no shell in path.
|
|
79
|
-
* For `shell_exec` (raw shell): inspects `args.command.split(/\s+/)[0]` BUT this
|
|
80
|
-
* is defeatable by shell metacharacters. Prefer gating `shell_run` with this helper
|
|
81
|
-
* and denying `shell_exec` entirely, or handling shell_exec with a custom policy
|
|
82
|
-
* that parses the command string carefully.
|
|
83
|
-
*
|
|
84
|
-
* Deny wins over allow.
|
|
85
|
-
*
|
|
86
|
-
* @param {object} options
|
|
87
|
-
* @param {string[]} [options.allow] - Base command names allowed.
|
|
88
|
-
* @param {string[]} [options.deny] - Base command names denied.
|
|
89
|
-
* @param {string} [options.toolName='shell_run'] - Which tool this helper gates.
|
|
90
|
-
* @returns {Function} policy predicate `(toolName, args) => true | string`
|
|
91
|
-
*/
|
|
92
|
-
function commandAllowlist({ allow = [], deny = [], toolName = 'shell_run' } = {}) {
|
|
93
|
-
const allowSet = new Set(allow);
|
|
94
|
-
const denySet = new Set(deny);
|
|
95
|
-
|
|
96
|
-
return async function commandPolicy(name, args) {
|
|
97
|
-
if (name !== toolName) return true;
|
|
98
|
-
let base;
|
|
99
|
-
if (name === 'shell_run') {
|
|
100
|
-
if (!Array.isArray(args?.argv) || typeof args.argv[0] !== 'string') return true;
|
|
101
|
-
base = args.argv[0];
|
|
102
|
-
} else {
|
|
103
|
-
if (typeof args?.command !== 'string') return true;
|
|
104
|
-
base = args.command.trim().split(/\s+/)[0];
|
|
105
|
-
}
|
|
106
|
-
if (denySet.has(base)) return `Command denied: ${base} is on the denylist.`;
|
|
107
|
-
if (allowSet.size > 0 && !allowSet.has(base)) {
|
|
108
|
-
return `Command denied: ${base} is not on the allowlist.`;
|
|
109
|
-
}
|
|
110
|
-
return true;
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Compose multiple policy predicates into one. First non-true verdict wins.
|
|
116
|
-
* Short-circuits on first deny — later predicates are not called.
|
|
117
|
-
*
|
|
118
|
-
* @param {...Function} policies - Any number of policy predicates.
|
|
119
|
-
* @returns {Function} combined policy predicate `(toolName, args, ctx) => true | string`
|
|
120
|
-
*/
|
|
121
|
-
function combinePolicies(...policies) {
|
|
122
|
-
const list = policies.filter(p => typeof p === 'function');
|
|
123
|
-
return async function combined(toolName, args, ctx) {
|
|
124
|
-
for (const p of list) {
|
|
125
|
-
const verdict = await p(toolName, args, ctx);
|
|
126
|
-
if (verdict !== true) return verdict;
|
|
127
|
-
}
|
|
128
|
-
return true;
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
module.exports = { pathAllowlist, commandAllowlist, combinePolicies };
|