@staix/agent-hub 0.14.0 → 0.16.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/CHANGELOG.md +42 -0
- package/README.md +23 -9
- package/docs/agent-notes/README.md +8 -0
- package/docs/agent-notes/adapters.md +21 -4
- package/docs/agent-notes/budget.md +6 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +20 -9
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +21 -4
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +19 -5
- package/docs/events.md +21 -7
- package/docs/memory.md +78 -10
- package/docs/operations.md +197 -39
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +16 -6
- package/docs/sdlc.md +17 -0
- package/docs/security.md +110 -21
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verified.json +40 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3109 -2046
- package/src/adapters/acp.ts +46 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +6 -2
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -5
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +8 -4
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +48 -3
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +30 -2
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +10 -7
- package/src/cli/identity.ts +3 -1
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +91 -19
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +40 -4
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +90 -0
- package/src/hub/action-policy.ts +40 -17
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +110 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +44 -7
- package/src/hub/bus.ts +6 -6
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +12 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +614 -104
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +5 -3
- package/src/hub/hub-tools.ts +22 -6
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +156 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +10 -1
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +18 -3
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +551 -75
- package/src/hub/token-window.ts +46 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/window-kind.ts +13 -0
- package/src/hub/workflow.ts +16 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +22 -12
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +215 -19
|
@@ -328,7 +328,7 @@ export function recordPath(stateDir: string): string {
|
|
|
328
328
|
return join(stateDir, RECORD_FILE);
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
/**
|
|
331
|
+
/** Resolve declared terminal roots without treating a launch candidate as daemon ownership proof. */
|
|
332
332
|
function executionRoot(peer: string, projectRoot: string, captured?: string): string {
|
|
333
333
|
const builtin = definitionOf(peer);
|
|
334
334
|
if (builtin?.session.launchRecord) return projectRoot;
|
|
@@ -336,11 +336,6 @@ function executionRoot(peer: string, projectRoot: string, captured?: string): st
|
|
|
336
336
|
const definition = catalog.definitionOf(peer, declarations);
|
|
337
337
|
if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
|
|
338
338
|
if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
|
|
339
|
-
// #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
|
|
340
|
-
if (definition.transport === "claude-channel") {
|
|
341
|
-
if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
|
|
342
|
-
return projectRoot;
|
|
343
|
-
}
|
|
344
339
|
const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles, catalog), catalog);
|
|
345
340
|
if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
|
|
346
341
|
return expected;
|
package/src/hub/action-class.ts
CHANGED
|
@@ -5,7 +5,8 @@ export type ActionClass = typeof ACTION_CLASSES[number];
|
|
|
5
5
|
|
|
6
6
|
const SHELL_TOOLS = new Set(["bash", "shell", "shell_command", "exec_command", "run_shell_command"]);
|
|
7
7
|
const LIMIT = 16_384;
|
|
8
|
-
|
|
8
|
+
/** `nested`: the commands of each substitution in the segment (`$(...)`, a backtick). They run in a subshell, so their `cd` never moves the outer cwd. */
|
|
9
|
+
type Command = { words: string[]; outputs: string[]; nested?: Command[][] };
|
|
9
10
|
|
|
10
11
|
/** Bound the whole input before accepting any prefix, including unsupported suffixes. */
|
|
11
12
|
function segmentLimit(text: string): boolean {
|
|
@@ -28,8 +29,79 @@ function segmentLimit(text: string): boolean {
|
|
|
28
29
|
return true;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* An expansion (`$VAR`, `${...}`, `$(...)`, a backtick) stands in a word as this one character. The input itself may not contain it
|
|
34
|
+
* (`commands` refuses a NUL), so a word has it only where the hub put it: the word's value is unknown (#762).
|
|
35
|
+
*/
|
|
36
|
+
const OPAQUE = "\0";
|
|
37
|
+
const opaque = (word: string | undefined): boolean => word !== undefined && word.includes(OPAQUE);
|
|
38
|
+
|
|
39
|
+
/** The index after the bracket that closes `text[at]` (`(` or `{`), quotes and nested expansions respected, or -1 when it never closes. */
|
|
40
|
+
function matching(text: string, at: number, nest = 0): number {
|
|
41
|
+
if (nest > 16) return -1;
|
|
42
|
+
const open = text[at]!, close = open === "(" ? ")" : "}";
|
|
43
|
+
let depth = 0;
|
|
44
|
+
for (let i = at; i < text.length; i++) {
|
|
45
|
+
const c = text[i]!;
|
|
46
|
+
if (c === "\\") { i++; continue; }
|
|
47
|
+
if (c === "'") { const end = text.indexOf("'", i + 1); if (end < 0) return -1; i = end; continue; }
|
|
48
|
+
if (c === "`") { const end = text.indexOf("`", i + 1); if (end < 0) return -1; i = end; continue; }
|
|
49
|
+
if (c === '"') {
|
|
50
|
+
for (i++; i < text.length && text[i] !== '"'; i++) {
|
|
51
|
+
if (text[i] === "\\") i++;
|
|
52
|
+
else if (text[i] === "$" && (text[i + 1] === "(" || text[i + 1] === "{")) { const end = matching(text, i + 1, nest + 1); if (end < 0) return -1; i = end - 1; }
|
|
53
|
+
}
|
|
54
|
+
if (i >= text.length) return -1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (c === "$" && (text[i + 1] === "(" || text[i + 1] === "{") && i + 1 !== at) { const end = matching(text, i + 1, nest + 1); if (end < 0) return -1; i = end - 1; continue; }
|
|
58
|
+
if (c === open) depth++;
|
|
59
|
+
else if (c === close && --depth === 0) return i + 1;
|
|
60
|
+
}
|
|
61
|
+
return -1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The expansion that starts at `text[at]` (a `$` or a backtick): where it ends, the command texts it runs (`inners`: `$(...)` and a backtick
|
|
66
|
+
* run their text; `${...}` runs only the substitutions nested in its parameter text, never the text itself), and `drop` for a `$"` that only
|
|
67
|
+
* opens a quote. Undefined for a `$` that is a literal dollar. An expansion that never closes ends at the end of the text.
|
|
68
|
+
*/
|
|
69
|
+
function expansion(text: string, at: number, quoted: boolean, nest = 0): { end: number; inners: string[]; drop?: true } | undefined {
|
|
70
|
+
if (text[at] === "`") {
|
|
71
|
+
for (let i = at + 1; i < text.length; i++) {
|
|
72
|
+
if (text[i] === "\\") { i++; continue; }
|
|
73
|
+
if (text[i] === "`") return { end: i + 1, inners: [text.slice(at + 1, i)] };
|
|
74
|
+
}
|
|
75
|
+
return { end: text.length, inners: [] };
|
|
76
|
+
}
|
|
77
|
+
const next = text[at + 1];
|
|
78
|
+
if (next === "(") {
|
|
79
|
+
const end = matching(text, at + 1);
|
|
80
|
+
return end < 0 ? { end: text.length, inners: [] } : { end, inners: [text.slice(at + 2, end - 1)] };
|
|
81
|
+
}
|
|
82
|
+
if (next === "{") { // parameter text is data: only the substitutions inside it run
|
|
83
|
+
const end = matching(text, at + 1);
|
|
84
|
+
const body = end < 0 ? text.slice(at + 2) : text.slice(at + 2, end - 1), inners: string[] = [];
|
|
85
|
+
for (let i = 0; i < body.length && nest < 4; i++) {
|
|
86
|
+
if (body[i] === "\\") { i++; continue; }
|
|
87
|
+
if (body[i] !== "$" && body[i] !== "`") continue;
|
|
88
|
+
const inner = expansion(body, i, false, nest + 1);
|
|
89
|
+
if (inner) { inners.push(...inner.inners); i = inner.end - 1; }
|
|
90
|
+
}
|
|
91
|
+
return { end: end < 0 ? text.length : end, inners };
|
|
92
|
+
}
|
|
93
|
+
if (next === "'") { // $'...': ANSI-C quoting, whose escapes build the word
|
|
94
|
+
for (let i = at + 2; i < text.length; i++) { if (text[i] === "\\") i++; else if (text[i] === "'") return { end: i + 1, inners: [] }; }
|
|
95
|
+
return { end: text.length, inners: [] };
|
|
96
|
+
}
|
|
97
|
+
if (next === '"') return quoted ? undefined : { end: at + 1, inners: [], drop: true };
|
|
98
|
+
if (next !== undefined && /[A-Za-z_]/.test(next)) { let end = at + 2; while (end < text.length && /[A-Za-z0-9_]/.test(text[end]!)) end++; return { end, inners: [] }; }
|
|
99
|
+
if (next !== undefined && /[0-9@*#?$!-]/.test(next)) return { end: at + 2, inners: [] };
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Shell words, literal where they can be read. An expansion becomes an opaque word and the commands it runs are read too; unsupported syntax discards only its unfinished segment. */
|
|
104
|
+
function commands(text: string, nest = 0): Command[] | undefined {
|
|
33
105
|
if (text.length > LIMIT || text.includes("\0") || !segmentLimit(text)) return;
|
|
34
106
|
const result: Command[] = [];
|
|
35
107
|
let words: string[] = [], outputs: string[] = [], word = "", active = false, quote = "";
|
|
@@ -40,7 +112,8 @@ function commands(text: string): Command[] | undefined {
|
|
|
40
112
|
else if (!redirect) words.push(word);
|
|
41
113
|
word = ""; active = false; redirect = false;
|
|
42
114
|
};
|
|
43
|
-
|
|
115
|
+
let nested: Command[][] = [];
|
|
116
|
+
const finish = () => { flush(); if (words.length || outputs.length || nested.length) result.push({ words, outputs, ...(nested.length ? { nested } : {}) }); words = []; outputs = []; nested = []; };
|
|
44
117
|
for (let i = 0; i < text.length; i++) {
|
|
45
118
|
const c = text[i]!;
|
|
46
119
|
if (quote === "'") { if (c === "'") quote = ""; else word += c; continue; }
|
|
@@ -51,7 +124,13 @@ function commands(text: string): Command[] | undefined {
|
|
|
51
124
|
if (next !== "\n") word += next;
|
|
52
125
|
active = true; continue;
|
|
53
126
|
}
|
|
54
|
-
if (c === "$" || c === "`")
|
|
127
|
+
if (c === "$" || c === "`") {
|
|
128
|
+
const found = expansion(text, i, quote === '"');
|
|
129
|
+
if (!found) { word += c; active = true; continue; } // a lone `$` is a literal dollar
|
|
130
|
+
if (nest < 4) for (const text of found.inners) { const inner = commands(text, nest + 1); if (inner?.length) nested.push(inner); }
|
|
131
|
+
if (!found.drop) { word += OPAQUE; active = true; }
|
|
132
|
+
i = found.end - 1; continue;
|
|
133
|
+
}
|
|
55
134
|
if (quote) { if (c === quote) quote = ""; else word += c; continue; }
|
|
56
135
|
if (c === "'" || c === '"') { quote = c; active = true; continue; }
|
|
57
136
|
if (c === "#" && !active) { while (i < text.length && text[i] !== "\n") i++; finish(); continue; }
|
|
@@ -97,6 +176,14 @@ function outside(target: string, root: string, cwd: string): boolean {
|
|
|
97
176
|
const rel = relative(resolve(root), resolve(cwd, target));
|
|
98
177
|
return rel === ".." || rel.startsWith("../") || isAbsolute(rel);
|
|
99
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* A word that starts with an expansion may expand to a dash word (`$OPTS` as `-rf`), so a class that depends on a flag counts it as that flag
|
|
181
|
+
* (#762). Only the words before `--` can be options. A word that merely has an expansion later (`build/$X`, `--name=$X`) cannot start one.
|
|
182
|
+
*/
|
|
183
|
+
function mayBeFlag(words: readonly string[]): boolean {
|
|
184
|
+
const end = words.indexOf("--");
|
|
185
|
+
return words.slice(0, end < 0 ? undefined : end).some(w => w.startsWith(OPAQUE));
|
|
186
|
+
}
|
|
100
187
|
function flags(words: string[], letter: string): boolean {
|
|
101
188
|
const end = words.indexOf("--");
|
|
102
189
|
return words.slice(0, end < 0 ? undefined : end).some(w => /^-[^-]/.test(w) && w.slice(1).includes(letter));
|
|
@@ -130,7 +217,7 @@ function commandHeads(words: string[], values: readonly string[], booleans: read
|
|
|
130
217
|
continue;
|
|
131
218
|
}
|
|
132
219
|
if (!uncertain) return [{ name: word, args: words.slice(i + 1) }];
|
|
133
|
-
if (actions.includes(word)) heads.push({ name: word, args: words.slice(i + 1) });
|
|
220
|
+
if (actions.includes(word) || opaque(word)) heads.push({ name: word, args: words.slice(i + 1) });
|
|
134
221
|
}
|
|
135
222
|
return heads;
|
|
136
223
|
}
|
|
@@ -153,6 +240,7 @@ function forcedClean(words: string[]): boolean {
|
|
|
153
240
|
if (flag === "n") dry = true;
|
|
154
241
|
}
|
|
155
242
|
}
|
|
243
|
+
else if (word.startsWith(OPAQUE)) force = true; // an expansion may be `-f`; a literal dry run still wins (#762)
|
|
156
244
|
}
|
|
157
245
|
return force && !dry;
|
|
158
246
|
}
|
|
@@ -182,12 +270,110 @@ function unwrapped(input: string[]): string[] {
|
|
|
182
270
|
else if (flag.startsWith("--") && !flag.includes("=") && !["--ignore-environment", "--foreground", "--preserve-status", "--no-run-if-empty", "--verbose"].includes(flag)) return [];
|
|
183
271
|
}
|
|
184
272
|
if (name === "timeout") {
|
|
185
|
-
|
|
273
|
+
const duration = words.shift() ?? "";
|
|
274
|
+
if (!/^\d+(?:\.\d+)?[smhd]?$/.test(duration) && !opaque(duration)) return [];
|
|
186
275
|
}
|
|
187
276
|
}
|
|
188
277
|
return [];
|
|
189
278
|
}
|
|
190
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Whether every definition of a GraphQL document is a read: a `query` (or shorthand `{...}`) or a `fragment`. Strings and comments are skipped, so
|
|
282
|
+
* a keyword inside one counts for nothing. A `mutation`, a `subscription`, any other top-level word, unbalanced text or no operation is false:
|
|
283
|
+
* unknown is never a read.
|
|
284
|
+
*/
|
|
285
|
+
function graphqlQueryOnly(text: string): boolean {
|
|
286
|
+
const skipString = (at: number): number => { // `at` is the opening quote; the index after the string, or -1 when it never ends
|
|
287
|
+
// A block string ends at its first `"""`: GraphQL's escaped `\"""` ends it later, so a lexer that ends early only turns a read into a write (the strict direction).
|
|
288
|
+
if (text.startsWith('"""', at)) { const end = text.indexOf('"""', at + 3); return end < 0 ? -1 : end + 3; }
|
|
289
|
+
// GraphQL's line terminators are LF, CR and CRLF, and none may appear in a single-line string: ending early is the strict direction.
|
|
290
|
+
for (let i = at + 1; i < text.length; i++) { if (text[i] === "\\") { i++; if (text[i] === "\n" || text[i] === "\r") return -1; } else if (text[i] === '"') return i + 1; else if (text[i] === "\n" || text[i] === "\r") return -1; }
|
|
291
|
+
return -1;
|
|
292
|
+
};
|
|
293
|
+
const skipComment = (at: number): number => { let i = at; while (i < text.length && text[i] !== "\n" && text[i] !== "\r") i++; return i; }; // a comment ends at LF or CR (so also CRLF), never later than GraphQL ends it
|
|
294
|
+
const closers: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
|
|
295
|
+
const skipBlock = (at: number): number => { // `at` is an opening bracket; the index after its matching close, or -1
|
|
296
|
+
const close = closers[text[at]!]!;
|
|
297
|
+
for (let i = at + 1; i < text.length; i++) {
|
|
298
|
+
const c = text[i]!;
|
|
299
|
+
if (c === '"') { const end = skipString(i); if (end < 0) return -1; i = end - 1; }
|
|
300
|
+
else if (c === "#") i = skipComment(i) - 1;
|
|
301
|
+
else if (c in closers) { const end = skipBlock(i); if (end < 0) return -1; i = end - 1; }
|
|
302
|
+
else if (c === "}" || c === ")" || c === "]") return c === close ? i + 1 : -1;
|
|
303
|
+
}
|
|
304
|
+
return -1;
|
|
305
|
+
};
|
|
306
|
+
let start = true, operations = 0; // `start`: the next token begins a top-level definition
|
|
307
|
+
for (let i = 0; i < text.length;) {
|
|
308
|
+
const c = text[i]!;
|
|
309
|
+
if (/[\s,\uFEFF]/.test(c)) { i++; continue; }
|
|
310
|
+
if (c === "#") { i = skipComment(i); continue; }
|
|
311
|
+
if (c === "{") { const end = skipBlock(i); if (end < 0) return false; if (start) operations++; i = end; start = true; continue; } // shorthand query, or the body that ends a definition
|
|
312
|
+
if (c === "(") { if (start) return false; const end = skipBlock(i); if (end < 0) return false; i = end; continue; }
|
|
313
|
+
const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(i, i + 256))?.[0];
|
|
314
|
+
if (name) {
|
|
315
|
+
if (start) { if (name === "query") operations++; else if (name !== "fragment") return false; start = false; }
|
|
316
|
+
i += name.length; continue;
|
|
317
|
+
}
|
|
318
|
+
if (!start && (c === "@" || c === ":")) { i++; continue; } // a directive, or `fragment X on Y`; nothing else is read at the top level
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
return start && operations > 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* `gh api graphql` with fields (#756): undefined when the call does not name the graphql endpoint, true only for ONE literal `query` field whose
|
|
326
|
+
* every operation is a read. A query from a file or stdin (`@file`, `--input`), a second `query` field, an option this does not know,
|
|
327
|
+
* another positional and every unparsable document are false: the caller keeps them external-write.
|
|
328
|
+
*/
|
|
329
|
+
function ghGraphqlRead(words: string[]): boolean | undefined {
|
|
330
|
+
const rest = words.slice(1); // `words` is `api ...`
|
|
331
|
+
if (!rest.includes("graphql")) return undefined;
|
|
332
|
+
const fieldOptions = ["-f", "-F", "--field", "--raw-field"];
|
|
333
|
+
const valued = ["-H", "--header", "--hostname", "-q", "--jq", "-t", "--template", "--cache", "-p", "--preview", "-X", "--method"];
|
|
334
|
+
const booleans = ["--paginate", "--slurp", "--silent", "-i", "--include", "--verbose"];
|
|
335
|
+
const positional: string[] = [], queries: string[] = [];
|
|
336
|
+
for (let i = 0; i < rest.length; i++) {
|
|
337
|
+
const word = rest[i]!;
|
|
338
|
+
if (word === "--" || !word.startsWith("-")) { positional.push(word); continue; }
|
|
339
|
+
let field: string | undefined;
|
|
340
|
+
if (fieldOptions.includes(word)) { field = rest[++i]; if (field === undefined) return false; }
|
|
341
|
+
else if (word.startsWith("--field=") || word.startsWith("--raw-field=")) field = word.slice(word.indexOf("=") + 1);
|
|
342
|
+
else if (/^-[fF][^=]/.test(word)) field = word.slice(2);
|
|
343
|
+
else if (valued.includes(word)) { if (++i >= rest.length) return false; }
|
|
344
|
+
else if (valued.some(key => key.startsWith("--") ? word.startsWith(`${key}=`) : /^-[HqtpX]./.test(word) && word.startsWith(key))) continue;
|
|
345
|
+
else if (!booleans.includes(word)) return false;
|
|
346
|
+
if (field !== undefined) {
|
|
347
|
+
const eq = field.indexOf("=");
|
|
348
|
+
if (eq < 0) return false;
|
|
349
|
+
if (field.slice(0, eq) === "query") queries.push(field.slice(eq + 1));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const method = option(rest, "-X", "--method");
|
|
353
|
+
if (method !== undefined && method.toUpperCase() !== "POST") return false;
|
|
354
|
+
const [query] = queries;
|
|
355
|
+
if (queries.some(opaque)) return false; // an expansion can close a brace or add an operation: unknown is never a read
|
|
356
|
+
if (positional.length !== 1 || positional[0] !== "graphql" || queries.length !== 1 || query === undefined || query.startsWith("@")) return false;
|
|
357
|
+
return graphqlQueryOnly(query);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* `gh api` with a word the hub cannot read where an option may stand (#762): an option name with an expansion in it, or a later operand that
|
|
362
|
+
* starts with one (`gh api repos/o/r $FLAGS`, which can hold `-X POST`). The endpoint (the first operand) and the values of the known valued
|
|
363
|
+
* options are data. Method, field and query values are judged by the ordinary rules, where an unknown method is never GET.
|
|
364
|
+
*/
|
|
365
|
+
function ghApiDynamic(words: string[]): boolean {
|
|
366
|
+
const valued = ["-H", "--header", "--hostname", "-q", "--jq", "-t", "--template", "--cache", "-p", "--preview", "-X", "--method", "-f", "-F", "--field", "--raw-field", "--input"];
|
|
367
|
+
let operands = 0;
|
|
368
|
+
for (let i = 1; i < words.length; i++) {
|
|
369
|
+
const word = words[i]!;
|
|
370
|
+
if (word === "--") return words.slice(i + 1).some((w, n) => opaque(w) && operands + n > 0);
|
|
371
|
+
if (word.startsWith("-")) { if (opaque(word)) return true; if (valued.includes(word)) i++; continue; }
|
|
372
|
+
if (operands++ > 0 && word.startsWith(OPAQUE)) return true;
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
191
377
|
/** Scan curl options once: data, headers and other option values never become options. */
|
|
192
378
|
function curlWrites(input: string[]): boolean {
|
|
193
379
|
const longValues = new Set([
|
|
@@ -249,7 +435,7 @@ function sequence(parsed: Command[], root: string, initial: string[], found: Set
|
|
|
249
435
|
const [program, ...args] = unwrapped(command.words);
|
|
250
436
|
const target = args[0] === "--" ? args[1] : args[0];
|
|
251
437
|
// Retain both outcomes: cd may fail, or be in a pipeline/conditional branch.
|
|
252
|
-
if (program === "cd" && (!target || target.startsWith("~"))) {
|
|
438
|
+
if (program === "cd" && (!target || target.startsWith("~") || opaque(target))) { // an unknown directory is an unknown cwd
|
|
253
439
|
if (!cwd.has("") && cwd.size >= 128) cwd.delete(cwd.values().next().value!);
|
|
254
440
|
cwd.add("");
|
|
255
441
|
}
|
|
@@ -262,6 +448,7 @@ function sequence(parsed: Command[], root: string, initial: string[], found: Set
|
|
|
262
448
|
}
|
|
263
449
|
}
|
|
264
450
|
function classify(command: Command, root: string, cwd: string[], found: Set<ActionClass>, depth = 0): void {
|
|
451
|
+
for (const group of command.nested ?? []) sequence(group, root, cwd, found, depth); // a substitution's own cwd state; only its classes come back
|
|
265
452
|
const words = unwrapped(command.words);
|
|
266
453
|
const program = basename(words.shift() ?? "");
|
|
267
454
|
if (["sh", "bash", "zsh"].includes(program) && depth < 2) {
|
|
@@ -270,7 +457,7 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
270
457
|
const mode = shell.shift();
|
|
271
458
|
if (mode && /^-[A-Za-z]*c[A-Za-z]*$/.test(mode)) {
|
|
272
459
|
if (shell[0] === "--") shell.shift();
|
|
273
|
-
const nested = shell[0] === undefined ? undefined : commands(shell[0]);
|
|
460
|
+
const nested = shell[0] === undefined ? undefined : commands(shell[0].replaceAll(OPAQUE, "$_"));
|
|
274
461
|
if (nested) sequence(nested, root, cwd, found, depth + 1);
|
|
275
462
|
}
|
|
276
463
|
}
|
|
@@ -278,11 +465,12 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
278
465
|
if (program === "git") {
|
|
279
466
|
const heads = commandHeads(words, ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"],
|
|
280
467
|
["-P", "-p", "--no-pager", "--paginate", "--literal-pathspecs", "--bare", "--no-optional-locks", "--no-replace-objects"], ["push", "reset", "clean", "branch"]);
|
|
468
|
+
if (heads.some(head => opaque(head.name))) { found.add("publish"); found.add("destroy"); } // `git $CMD`: the strictest classes git reaches (#762)
|
|
281
469
|
for (const { name: sub, args } of heads) {
|
|
282
470
|
if (sub === "push") found.add("publish");
|
|
283
|
-
if (sub === "reset" && args.some(w => w === "--hard" || w.startsWith("--hard="))) found.add("destroy");
|
|
471
|
+
if (sub === "reset" && (args.some(w => w === "--hard" || w.startsWith("--hard=")) || mayBeFlag(args))) found.add("destroy");
|
|
284
472
|
if (sub === "clean" && forcedClean(args)) found.add("destroy");
|
|
285
|
-
if (sub === "branch" && (flags(args, "D") || args.includes("--delete") && args.includes("--force"))) found.add("destroy");
|
|
473
|
+
if (sub === "branch" && (flags(args, "D") || args.includes("--delete") && args.includes("--force") || mayBeFlag(args))) found.add("destroy");
|
|
286
474
|
}
|
|
287
475
|
}
|
|
288
476
|
if (["npm", "cargo", "pnpm", "yarn", "bun"].includes(program)) {
|
|
@@ -300,8 +488,9 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
300
488
|
for (const head of heads) {
|
|
301
489
|
const candidates = program === "yarn" && head.name === "npm" ? commandHeads(head.args, values, booleans, actions) : [head];
|
|
302
490
|
for (const { name: sub, args } of candidates) {
|
|
491
|
+
if (opaque(sub) && program !== "bun") { found.add("publish"); found.add("install"); } // `npm $CMD`; bun runs a file by default, so its expansion is no subcommand (#762)
|
|
303
492
|
if (sub === "publish") found.add("publish");
|
|
304
|
-
const global = words.includes("--global") || flags(words, "g");
|
|
493
|
+
const global = words.includes("--global") || flags(words, "g") || mayBeFlag(words);
|
|
305
494
|
if ((program === "cargo" && sub === "install") || (["install", "i", "add"].includes(sub) && global) || (program === "yarn" && sub === "global" && args[0] === "add")) found.add("install");
|
|
306
495
|
}
|
|
307
496
|
}
|
|
@@ -310,20 +499,25 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
310
499
|
if (program === "gh") {
|
|
311
500
|
if (words[0] === "release" && ["create", "upload", "edit", "delete"].includes(words[1] ?? "")) found.add("publish");
|
|
312
501
|
if (["issue", "pr", "gist"].includes(words[0] ?? "") && ["create", "comment", "edit", "close", "reopen", "merge", "review", "delete"].includes(words[1] ?? "")) found.add("external-write");
|
|
313
|
-
if (words[0]
|
|
502
|
+
if (opaque(words[0])) { found.add("publish"); found.add("external-write"); } // `gh $GROUP ...`: the strictest classes gh reaches (#762)
|
|
503
|
+
else if (opaque(words[1]) && ["release"].includes(words[0] ?? "")) found.add("publish");
|
|
504
|
+
else if (opaque(words[1]) && ["issue", "pr", "gist", "run", "workflow", "repo", "secret", "variable", "label"].includes(words[0] ?? "")) found.add("external-write");
|
|
505
|
+
if (words[0] === "repo" && ["delete", "archive", "edit"].includes(words[1] ?? "") || ["secret", "variable"].includes(words[0] ?? "") && ["set", "delete"].includes(words[1] ?? "") || words[0] === "label" && ["create", "edit", "delete"].includes(words[1] ?? "")) found.add("external-write");
|
|
506
|
+
if (words[0] === "run" && ["rerun", "cancel", "delete"].includes(words[1] ?? "") || words[0] === "workflow" && ["run", "enable", "disable"].includes(words[1] ?? "")) found.add("external-write");
|
|
507
|
+
if (words[0] === "api" && ghGraphqlRead(words) !== true) { // a GraphQL document of queries only is a read (#756); everything else keeps the REST rule
|
|
314
508
|
const method = option(words, "-X", "--method");
|
|
315
509
|
const field = words.some(w => w === "-f" || w === "-F" || w.startsWith("-f") || w.startsWith("-F") || w === "--input" || w.startsWith("--input=") || w.startsWith("--field") || w.startsWith("--raw-field"));
|
|
316
|
-
if (method ? !["GET", "HEAD"].includes(method.toUpperCase()) : field) found.add("external-write");
|
|
510
|
+
if (method ? !["GET", "HEAD"].includes(method.toUpperCase()) : field || ghApiDynamic(words)) found.add("external-write");
|
|
317
511
|
}
|
|
318
512
|
}
|
|
319
513
|
if (program === "curl" && curlWrites(words)) found.add("external-write");
|
|
320
514
|
if (["http", "https"].includes(program) && words.some(w => /^(?:POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)$/.test(w))) found.add("external-write");
|
|
321
515
|
if (["mail", "mailx", "sendmail"].includes(program) && operands(words).length > 0) found.add("external-write");
|
|
322
516
|
if (["slack", "telegram", "discord"].includes(program) && words.some(w => ["send", "post", "chat.postMessage"].includes(w))) found.add("external-write");
|
|
323
|
-
if (program === "rm" && (flags(words, "r") || flags(words, "R") || words.includes("--recursive")) && operands(words).some(p => cwd.some(at => outside(p, root, at)))) found.add("destroy");
|
|
324
|
-
if (["cp", "mv"].includes(program) && (flags(words, "f") || words.includes("--force"))) {
|
|
517
|
+
if (program === "rm" && (flags(words, "r") || flags(words, "R") || words.includes("--recursive") || operands(words).length > 1 && mayBeFlag(words)) && operands(words).some(p => opaque(p) /* an expansion may hold `..` or an absolute path (#762) */ || cwd.some(at => outside(p, root, at)))) found.add("destroy");
|
|
518
|
+
if (["cp", "mv"].includes(program) && (flags(words, "f") || words.includes("--force") || words.length > 2 && mayBeFlag(words.slice(0, -1)))) { // with two words the first cannot be a flag: `cp -f x` has no target
|
|
325
519
|
const target = option(words, "-t", "--target-directory") ?? operands(words).at(-1);
|
|
326
|
-
if (target && cwd.some(at => outside(target, root, at))) found.add("destroy");
|
|
520
|
+
if (target && (opaque(target) || cwd.some(at => outside(target, root, at)))) found.add("destroy");
|
|
327
521
|
}
|
|
328
522
|
}
|
|
329
523
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ACTION_CLASSES, classifyAction, type ActionClass } from "./action-class.ts";
|
|
2
|
+
import { actionApprovalView, actionPolicyConfig, type ActionBudgets, type ActionPolicyConfig } from "./action-policy.ts";
|
|
3
|
+
import type { ActionGateVerdict } from "../adapters/acp.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where the always-ask policy (#636) meets the two surfaces that see a tool call before it runs: the Claude PreToolUse hook and ACP
|
|
7
|
+
* `session/request_permission`. The classifier (`action-class.ts`) and the budget ledger (`action-policy.ts`) decide; this file only
|
|
8
|
+
* connects them, and only ever adds an approval: an empty result keeps the native permission policy.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One native tool call as an adapter saw it: every name it is known by, the ACP tool kind when reported, and its arguments. */
|
|
12
|
+
export interface ActionCall { names: readonly string[]; kind?: string; input: unknown }
|
|
13
|
+
|
|
14
|
+
/** The classes of one call. ACP names a shell call `execute` by kind whatever its tool is called, so that kind is also read as a shell. */
|
|
15
|
+
export function classifyCall(call: ActionCall, root: string): ActionClass[] {
|
|
16
|
+
const names = new Set(call.names.filter(name => typeof name === "string" && name.length > 0));
|
|
17
|
+
if (call.kind === "execute") names.add("bash");
|
|
18
|
+
const found = new Set<ActionClass>();
|
|
19
|
+
for (const name of names) for (const key of classifyAction(name, call.input, root)) found.add(key);
|
|
20
|
+
return ACTION_CLASSES.filter(key => found.has(key));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The classes the Claude PreToolUse hook must `ask` about, in every permission mode. The budget never relaxes it: Claude's own dialog
|
|
25
|
+
* cannot carry a class-budget choice, so every classed Claude call asks (a count of zero approved calls, always). A handle-less request
|
|
26
|
+
* selects the classes without touching any session ledger.
|
|
27
|
+
*/
|
|
28
|
+
export function claudeActionClasses(budgets: ActionBudgets, tool: string, input: unknown, root: string): ActionClass[] {
|
|
29
|
+
const classes = classifyAction(tool, input, root);
|
|
30
|
+
if (!classes.length) return [];
|
|
31
|
+
const decision = budgets.request(undefined, classes);
|
|
32
|
+
return decision.kind === "ask" ? decision.classes : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The command line an approval shows: the shell command when there is one, else the arguments. Bounded by the caller's screen. */
|
|
36
|
+
export function commandText(input: unknown): string {
|
|
37
|
+
let value: unknown = input;
|
|
38
|
+
if (typeof input === "string") { try { value = JSON.parse(input) as unknown; } catch { return input; } }
|
|
39
|
+
if (typeof value === "string") return value;
|
|
40
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
41
|
+
const record = value as Record<string, unknown>, command = record.command ?? record.cmd;
|
|
42
|
+
if (typeof command === "string") return command;
|
|
43
|
+
if (Array.isArray(command) && command.every(word => typeof word === "string")) return (command as string[]).join(" ");
|
|
44
|
+
if (Array.isArray(record.args) && record.args.every(word => typeof word === "string")) return ["git", ...(record.args as string[])].join(" ");
|
|
45
|
+
}
|
|
46
|
+
try { return JSON.stringify(value) ?? ""; } catch { return ""; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ActionGatePorts<T> {
|
|
50
|
+
budgets: ActionBudgets;
|
|
51
|
+
policy(): ActionPolicyConfig | undefined;
|
|
52
|
+
/** The peer's execution root: a delete outside it is classed. */
|
|
53
|
+
root(peer: string): string;
|
|
54
|
+
/** The task the peer is working on, for the approval line. */
|
|
55
|
+
task(peer: string): T | undefined;
|
|
56
|
+
/** The task's public view (`Tasks.publicView`): the only way a task title reaches an approval. */
|
|
57
|
+
publicView(task: T): Record<string, unknown>;
|
|
58
|
+
/** A private turn or an open PII task: the command is withheld. */
|
|
59
|
+
isPrivate(peer: string): boolean;
|
|
60
|
+
/** The PII screen for text (`Tasks.nameable`). */
|
|
61
|
+
nameable(text: string): boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The ACP gate of one peer. It returns a verdict only when a person must be asked: a classed call with no session budget left. A call
|
|
66
|
+
* within an approved class budget, and every unclassed call, return undefined and follow the permission mode as before (the budget
|
|
67
|
+
* spends here, once per request).
|
|
68
|
+
*/
|
|
69
|
+
export function actionGate<T>(ports: ActionGatePorts<T>, peer: string): (call: ActionCall, session: string, onPremToolTitle?: string) => ActionGateVerdict | undefined {
|
|
70
|
+
return (call, session, onPremToolTitle) => {
|
|
71
|
+
const classes = classifyCall(call, ports.root(peer));
|
|
72
|
+
if (!classes.length) return undefined;
|
|
73
|
+
const decision = ports.budgets.request(ports.budgets.bindSession(peer, session), classes);
|
|
74
|
+
if (decision.kind !== "ask") return undefined; // native, or within an approved class budget
|
|
75
|
+
// Only the host's local/Pi permit callback supplies this full title. Match the exact executed tool form before bypassing public redaction.
|
|
76
|
+
const input = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? call.input as Record<string, unknown> : undefined;
|
|
77
|
+
const expected = call.names.includes("bash") && typeof input?.command === "string" ? `bash: ${input.command}`
|
|
78
|
+
: call.names.includes("git") && Array.isArray(input?.args) && input.args.every(word => typeof word === "string") ? `git ${input.args.join(" ")}` : undefined;
|
|
79
|
+
const complete = expected !== undefined && expected === onPremToolTitle;
|
|
80
|
+
const view = onPremToolTitle === undefined ? actionApprovalView(decision.classes, commandText(call.input), ports.task(peer), {
|
|
81
|
+
publicView: task => ports.publicView(task), private: ports.isPrivate(peer), commandView: command => ports.nameable(command) ? command : undefined })
|
|
82
|
+
: { title: `Action approval (${decision.classes.join(", ")})\n${complete ? onPremToolTitle : "[complete command unavailable]"}`, complete };
|
|
83
|
+
const { title } = view;
|
|
84
|
+
if (!view.complete) return { title, denyOnly: true };
|
|
85
|
+
const challenge = decision.challenge, limits = actionPolicyConfig(ports.policy()).budgets;
|
|
86
|
+
// A budget of one is this call alone: the ordinary allow already says that, so no second choice is offered for it.
|
|
87
|
+
if (!challenge || !decision.classes.some(key => limits[key] > 1)) return { title };
|
|
88
|
+
return { title, budget: { label: decision.classes.map(key => `${key} x${limits[key]}`).join(", "), approve: () => ports.budgets.approve(challenge) } };
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/hub/action-policy.ts
CHANGED
|
@@ -5,8 +5,7 @@ export interface ActionPolicyConfig { enabled: boolean; always_ask: ActionClass[
|
|
|
5
5
|
const DEFAULT_BUDGETS: Record<ActionClass, number> = { publish: 1, destroy: 3, "external-write": 5, install: 2 };
|
|
6
6
|
/** Defaults are host policy; overrides belong only in untracked config.local.json.
|
|
7
7
|
* ponytail: startup adopts that local policy without a retained person confirmation.
|
|
8
|
-
*
|
|
9
|
-
* confirmation or surface it as attention; slice 1 never enforces these grants.
|
|
8
|
+
* Status surfaces every weakening as attention; upgrade: retain a person-bound startup confirmation.
|
|
10
9
|
*/
|
|
11
10
|
export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
|
|
12
11
|
const fail = (): never => { throw new Error("action_policy: expected enabled, always_ask and class budgets (integers 0..1000)"); };
|
|
@@ -31,24 +30,25 @@ export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
|
|
|
31
30
|
export interface ActionSessionBudget { readonly peer: string; readonly session: string }
|
|
32
31
|
export interface ActionChallenge { readonly classes: readonly ActionClass[] }
|
|
33
32
|
export type ActionDecision = { kind: "native" } | { kind: "ask"; classes: ActionClass[]; challenge?: ActionChallenge } | { kind: "budget"; classes: ActionClass[] };
|
|
34
|
-
|
|
33
|
+
/** `grants` counts the class-budget approvals of this session and policy: a challenge remembers it, so a later approval can tell that another one landed first. */
|
|
34
|
+
interface Session { handle: ActionSessionBudget; policy: string; remaining: Record<ActionClass, number>; grants: Record<ActionClass, number> }
|
|
35
35
|
const empty = (): Record<ActionClass, number> => ({ publish: 0, destroy: 0, "external-write": 0, install: 0 });
|
|
36
36
|
/** Host-only ledger: session ids and policy come from current runtime ports, never a tool argument. */
|
|
37
37
|
export class ActionBudgets {
|
|
38
38
|
private sessions = new Map<string, Session>();
|
|
39
|
-
private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[] }>();
|
|
39
|
+
private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[]; grants: Record<ActionClass, number> }>();
|
|
40
40
|
constructor(private readonly config: () => ActionPolicyConfig | undefined, private readonly currentSession: (peer: string) => string | undefined) {}
|
|
41
41
|
bindSession(peer: string, session: string): ActionSessionBudget | undefined {
|
|
42
42
|
if (!session || this.currentSession(peer) !== session) return undefined;
|
|
43
43
|
const previous = this.sessions.get(peer);
|
|
44
44
|
if (previous?.handle.session === session) return previous.handle;
|
|
45
45
|
const handle = Object.freeze({ peer, session });
|
|
46
|
-
this.sessions.set(peer, { handle, policy: "", remaining: empty() });
|
|
46
|
+
this.sessions.set(peer, { handle, policy: "", remaining: empty(), grants: empty() });
|
|
47
47
|
return handle;
|
|
48
48
|
}
|
|
49
49
|
revoke(peer: string): void { this.sessions.delete(peer); }
|
|
50
50
|
policyChanged(): void {
|
|
51
|
-
for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); }
|
|
51
|
+
for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); state.grants = empty(); }
|
|
52
52
|
this.challenges = new WeakMap();
|
|
53
53
|
}
|
|
54
54
|
private current(handle: ActionSessionBudget | undefined): Session | undefined {
|
|
@@ -56,7 +56,7 @@ export class ActionBudgets {
|
|
|
56
56
|
const state = this.sessions.get(handle.peer);
|
|
57
57
|
if (state?.handle !== handle || this.currentSession(handle.peer) !== handle.session) return undefined;
|
|
58
58
|
const policy = JSON.stringify(actionPolicyConfig(this.config()));
|
|
59
|
-
if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); }
|
|
59
|
+
if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); state.grants = empty(); }
|
|
60
60
|
return state;
|
|
61
61
|
}
|
|
62
62
|
private selected(actions: readonly ActionClass[]): ActionClass[] {
|
|
@@ -70,37 +70,60 @@ export class ActionBudgets {
|
|
|
70
70
|
if (!state) return { kind: "ask", classes };
|
|
71
71
|
if (classes.some(key => state.remaining[key] <= 0)) {
|
|
72
72
|
const challenge = Object.freeze({ classes: Object.freeze([...classes]) });
|
|
73
|
-
this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes] });
|
|
73
|
+
this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes], grants: { ...state.grants } });
|
|
74
74
|
return { kind: "ask", classes, challenge };
|
|
75
75
|
}
|
|
76
76
|
for (const key of classes) state.remaining[key]--;
|
|
77
77
|
return { kind: "budget", classes };
|
|
78
78
|
}
|
|
79
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Explicit class-budget approval only, never an ordinary allow_once; this invocation consumes slot one. Requests run concurrently, so
|
|
81
|
+
* two challenges can be issued while a class is spent: the first approval opens the allowance (budget minus this call), and an approval of
|
|
82
|
+
* a challenge issued before it is a later call inside that allowance, which consumes one slot instead of opening another. With no slot
|
|
83
|
+
* left it is refused (the person asks again and sees a fresh challenge), so N approved calls is the most any budget lets through.
|
|
84
|
+
* Every class is checked before any is spent: a refusal changes nothing.
|
|
85
|
+
*/
|
|
80
86
|
approve(challenge: ActionChallenge | undefined): boolean {
|
|
81
87
|
if (!challenge) return false;
|
|
82
88
|
const grant = this.challenges.get(challenge); this.challenges.delete(challenge);
|
|
83
89
|
if (!grant) return false;
|
|
84
90
|
const state = this.current(grant.handle), config = actionPolicyConfig(this.config());
|
|
85
91
|
if (!state || state.policy !== grant.policy) return false;
|
|
86
|
-
|
|
92
|
+
const late = (key: ActionClass) => state.grants[key] !== grant.grants[key];
|
|
93
|
+
if (grant.classes.some(key => late(key) && state.remaining[key] <= 0)) return false;
|
|
94
|
+
for (const key of grant.classes) {
|
|
95
|
+
state.remaining[key] = late(key) ? state.remaining[key] - 1 : Math.max(0, config.budgets[key] - 1);
|
|
96
|
+
state.grants[key]++;
|
|
97
|
+
}
|
|
87
98
|
return true;
|
|
88
99
|
}
|
|
89
100
|
/** Read-only projection: no session is created, rebound or reset by a status poll. */
|
|
90
|
-
status(peer: string, enforced: boolean) {
|
|
101
|
+
status(peer: string, enforced: boolean, reason = "adapter integration pending") {
|
|
91
102
|
const config = actionPolicyConfig(this.config()), state = this.sessions.get(peer);
|
|
92
103
|
const bound = !!state && this.currentSession(peer) === state.handle.session && state.policy === JSON.stringify(config);
|
|
104
|
+
const weakened = !config.enabled || ACTION_CLASSES.some(key => !config.always_ask.includes(key) || config.budgets[key] > DEFAULT_BUDGETS[key]);
|
|
105
|
+
const attention = weakened ? "; attention: machine-local policy weakens defaults; no retained startup confirmation" : "";
|
|
93
106
|
return { enabled: config.enabled, alwaysAsk: [...config.always_ask], limits: { ...config.budgets }, remaining: bound ? { ...state.remaining } : empty(), sessionBound: bound,
|
|
94
|
-
enforceable: enforced, note: !enforced ? `always-ask not enforceable:
|
|
107
|
+
enforceable: enforced, note: (!enforced ? `always-ask not enforceable: ${reason}${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled") + attention };
|
|
95
108
|
}
|
|
96
109
|
}
|
|
97
110
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
111
|
+
type ActionApprovalPorts<T> = { publicView(task: T): Record<string, unknown>; private: boolean; commandView(command: string): string | undefined };
|
|
112
|
+
|
|
113
|
+
/** Public/native presentation: incomplete command views grant no approval, not even once. */
|
|
114
|
+
export function actionApprovalView<T>(classes: readonly ActionClass[], command: string, task: T | undefined,
|
|
115
|
+
ports: ActionApprovalPorts<T>): { title: string; complete: boolean } {
|
|
101
116
|
const view = task === undefined ? undefined : ports.publicView(task);
|
|
102
117
|
const title = typeof view?.title === "string" ? sanitize(view.title).replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 160) : undefined;
|
|
103
118
|
const shown = ports.private ? undefined : ports.commandView(command);
|
|
104
|
-
const
|
|
105
|
-
|
|
119
|
+
const full = shown === undefined ? undefined : Array.from(sanitize(shown).replace(/[\p{Cc}\p{Cf}]/gu, " "));
|
|
120
|
+
const marker = ` [cut, ${full?.length ?? 0} chars]`;
|
|
121
|
+
const line = full === undefined ? "[private command withheld]" : full.length > 512 ? full.slice(0, 512 - marker.length).join("") + marker : full.join("");
|
|
122
|
+
return { title: `Action approval (${ACTION_CLASSES.filter(key => classes.includes(key)).join(", ")})${title ? `; task ${title}` : ""}: ${line}`,
|
|
123
|
+
complete: shown === command && full !== undefined && full.length <= 512 && full.join("") === command };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Adapter integrations must pass the host's publicView and current command/private-turn screens. */
|
|
127
|
+
export function actionApprovalText<T>(classes: readonly ActionClass[], command: string, task: T | undefined, ports: ActionApprovalPorts<T>): string {
|
|
128
|
+
return actionApprovalView(classes, command, task, ports).title;
|
|
106
129
|
}
|
package/src/hub/ask.ts
CHANGED
|
@@ -104,7 +104,7 @@ export async function gather(question: string, d: AskDeps): Promise<{ evidence:
|
|
|
104
104
|
return { id: `task #${t.id}`, kind: "task", text: `[${t.class}] ${t.state}, owner ${t.owner ?? "none"}, reviewer ${t.reviewer ?? "none"}: ${text}`.slice(0, ITEM_CHARS) };
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
// A question
|
|
107
|
+
// A PII question never queries the shared memory view; local storage does not relax the hub privacy fence.
|
|
108
108
|
const memoryRows = d.memory && !piiQuestion ? related(d.memory, d.project, question, 2).catch(() => []) : Promise.resolve([]);
|
|
109
109
|
// The log carries message bodies and is written by agents too: a line with PII in it is treated like a PII task row.
|
|
110
110
|
const matching = thisRunsLog(d.logFile).filter((l) => hit(l) > 0).slice(-12);
|
|
@@ -119,12 +119,24 @@ export async function gather(question: string, d: AskDeps): Promise<{ evidence:
|
|
|
119
119
|
try { native = piiQuestion ? [] : (d.native?.(keys) ?? []).map((r) => ({ id: r.id, kind: "memory" as const, text: r.text.slice(0, ITEM_CHARS) })); } catch { /* fail-open */ }
|
|
120
120
|
const memory: Evidence[] = (await memoryRows)
|
|
121
121
|
.filter((r) => !r.title.startsWith(ASK_NOTE_TITLE))
|
|
122
|
-
.map((r) => ({ id: `#${r.id}`, kind: "memory" as const, text: `${r.time} ${r.type} ${r.title}`.slice(0, ITEM_CHARS) }));
|
|
122
|
+
.map((r) => ({ id: typeof r.id === "string" ? r.id : `#${r.id}`, kind: "memory" as const, text: `${r.time} ${r.type} ${r.title}`.slice(0, ITEM_CHARS) }));
|
|
123
123
|
|
|
124
|
+
// Native briefs keep short ids; the client supplies full ids for the same rows. Resolve only a unique
|
|
125
|
+
// prefix before dedupe/budget accounting. An ambiguous short id stays separate: it identifies neither full row.
|
|
126
|
+
const fullMemoryIds = new Map<string, Set<string>>();
|
|
127
|
+
for (const row of [...native, ...memory]) if (/^mem:[0-9a-f]{32}$/.test(row.id)) {
|
|
128
|
+
const prefix = row.id.slice(0, 12), ids = fullMemoryIds.get(prefix) ?? new Set<string>();
|
|
129
|
+
ids.add(row.id); fullMemoryIds.set(prefix, ids);
|
|
130
|
+
}
|
|
131
|
+
const memoryId = (row: Evidence): string => {
|
|
132
|
+
const full = row.kind === "memory" && /^mem:[0-9a-f]{8}$/.test(row.id) ? fullMemoryIds.get(row.id) : undefined;
|
|
133
|
+
return full?.size === 1 ? full.values().next().value! : row.id;
|
|
134
|
+
};
|
|
124
135
|
// Every item is capped, so one long row cannot empty the list; what does not fit is skipped, not everything after it.
|
|
125
136
|
const evidence: Evidence[] = [];
|
|
126
137
|
let size = 0;
|
|
127
|
-
for (const
|
|
138
|
+
for (const row of [...taskRows, ...native, ...memory, ...logRows]) {
|
|
139
|
+
const e = { ...row, id: memoryId(row) };
|
|
128
140
|
if (evidence.some((x) => x.id === e.id)) continue;
|
|
129
141
|
if (size + e.text.length + e.id.length > EVIDENCE_CHARS) continue;
|
|
130
142
|
size += e.text.length + e.id.length;
|
|
@@ -133,7 +145,7 @@ export async function gather(question: string, d: AskDeps): Promise<{ evidence:
|
|
|
133
145
|
return { evidence, pii: piiQuestion || piiShown, onCampus: campus };
|
|
134
146
|
}
|
|
135
147
|
|
|
136
|
-
const ID = /^(task #\d+|#\d+|mem:[0-9a-f]{8}|log \d\d-\d\d \d\d:\d\d:\d\d\.\d{3})$/;
|
|
148
|
+
const ID = /^(task #\d+|#\d+|mem:(?:[0-9a-f]{8}|[0-9a-f]{32})|log \d\d-\d\d \d\d:\d\d:\d\d\.\d{3})$/;
|
|
137
149
|
|
|
138
150
|
/** Every id the answer cites, grouped brackets included: `[#1, #2, task #3]`. */
|
|
139
151
|
export function citedIds(answer: string): string[] {
|