@staix/agent-hub 0.23.1 → 0.25.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 +24 -0
- package/README.md +32 -5
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +16 -2
- package/docs/agent-notes/tasks.md +9 -2
- package/docs/agent-notes/tests.md +3 -0
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +198 -5
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +19 -4
- package/docs/security.md +225 -8
- package/docs/specs/2026-09-19-agent-hub-design.md +54 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
- package/docs/verification/2026-10-31-0.24.0.md +63 -0
- package/docs/verification/2026-10-31-claude-implementer-2.1.316.md +65 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +31 -15
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5120 -4710
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/native-cli-claude-setup.ts +4 -0
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry.ts +1 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/autonomy-homes.ts +33 -0
- package/src/cli/autonomy-offline.ts +75 -0
- package/src/cli/autonomy-projects.ts +103 -0
- package/src/cli/autonomy.ts +44 -0
- package/src/cli/claude-homes.ts +123 -0
- package/src/cli/claude-profile-hook.ts +130 -0
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +8 -4
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +174 -33
- package/src/cli/output.ts +8 -5
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/setup-homes.ts +46 -0
- package/src/cli/setup.ts +1 -1
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +1 -0
- package/src/hub/action-class.ts +203 -14
- package/src/hub/action-enforce.ts +8 -6
- package/src/hub/action-policy.ts +95 -9
- package/src/hub/autonomy-consent.ts +173 -0
- package/src/hub/autonomy-defaults.ts +58 -0
- package/src/hub/autonomy.ts +232 -0
- package/src/hub/board.ts +871 -2
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/confirmation-matrix.ts +202 -0
- package/src/hub/control-client.ts +10 -1
- package/src/hub/daemon.ts +546 -27
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +12 -0
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/manager.ts +64 -57
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project.ts +14 -5
- package/src/hub/provenance.ts +30 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +445 -0
- package/src/hub/restart.ts +26 -0
- package/src/hub/schedules.ts +210 -9
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/settings.ts +105 -8
- package/src/hub/task-repository.ts +11 -6
- package/src/hub/tasks.ts +894 -22
- package/src/hub/ui.ts +15 -6
- package/src/memory/capture.ts +2 -2
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/presets/autonomous.json +161 -0
- package/templates/presets/confirmation-matrix.json +943 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ACTION_CLASSES, classifyAction, type ActionClass } from "./action-class.ts";
|
|
1
|
+
import { ACTION_CLASSES, classifyAction, type ActionClass, type FloorFacts } from "./action-class.ts";
|
|
2
2
|
import { actionApprovalView, actionPolicyConfig, type ActionBudgets, type ActionPolicyConfig } from "./action-policy.ts";
|
|
3
3
|
import type { ActionGateVerdict } from "../adapters/acp.ts";
|
|
4
4
|
|
|
@@ -12,11 +12,11 @@ import type { ActionGateVerdict } from "../adapters/acp.ts";
|
|
|
12
12
|
export interface ActionCall { names: readonly string[]; kind?: string; input: unknown }
|
|
13
13
|
|
|
14
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[] {
|
|
15
|
+
export function classifyCall(call: ActionCall, root: string, floor?: FloorFacts): ActionClass[] {
|
|
16
16
|
const names = new Set(call.names.filter(name => typeof name === "string" && name.length > 0));
|
|
17
17
|
if (call.kind === "execute") names.add("bash");
|
|
18
18
|
const found = new Set<ActionClass>();
|
|
19
|
-
for (const name of names) for (const key of classifyAction(name, call.input, root)) found.add(key);
|
|
19
|
+
for (const name of names) for (const key of classifyAction(name, call.input, root, floor)) found.add(key);
|
|
20
20
|
return ACTION_CLASSES.filter(key => found.has(key));
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -25,8 +25,8 @@ export function classifyCall(call: ActionCall, root: string): ActionClass[] {
|
|
|
25
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
26
|
* selects the classes without touching any session ledger.
|
|
27
27
|
*/
|
|
28
|
-
export function claudeActionClasses(budgets: ActionBudgets, tool: string, input: unknown, root: string): ActionClass[] {
|
|
29
|
-
const classes = classifyAction(tool, input, root);
|
|
28
|
+
export function claudeActionClasses(budgets: ActionBudgets, tool: string, input: unknown, root: string, floor?: FloorFacts): ActionClass[] {
|
|
29
|
+
const classes = classifyAction(tool, input, root, budgets.floorOn() ? floor : undefined);
|
|
30
30
|
if (!classes.length) return [];
|
|
31
31
|
const decision = budgets.request(undefined, classes);
|
|
32
32
|
return decision.kind === "ask" ? decision.classes : [];
|
|
@@ -51,6 +51,8 @@ export interface ActionGatePorts<T> {
|
|
|
51
51
|
policy(): ActionPolicyConfig | undefined;
|
|
52
52
|
/** The peer's execution root: a delete outside it is classed. */
|
|
53
53
|
root(peer: string): string;
|
|
54
|
+
/** The floor's facts (#999 T3), read per call. Absent or undefined means the floor is off or its facts are unknown, and every class asks as before. */
|
|
55
|
+
floor?(peer: string): FloorFacts | undefined;
|
|
54
56
|
/** The task the peer is working on, for the approval line. */
|
|
55
57
|
task(peer: string): T | undefined;
|
|
56
58
|
/** The task's public view (`Tasks.publicView`): the only way a task title reaches an approval. */
|
|
@@ -68,7 +70,7 @@ export interface ActionGatePorts<T> {
|
|
|
68
70
|
*/
|
|
69
71
|
export function actionGate<T>(ports: ActionGatePorts<T>, peer: string): (call: ActionCall, session: string, onPremToolTitle?: string) => ActionGateVerdict | undefined {
|
|
70
72
|
return (call, session, onPremToolTitle) => {
|
|
71
|
-
const classes = classifyCall(call, ports.root(peer));
|
|
73
|
+
const classes = classifyCall(call, ports.root(peer), ports.budgets.floorOn() ? ports.floor?.(peer) : undefined);
|
|
72
74
|
if (!classes.length) return undefined;
|
|
73
75
|
const decision = ports.budgets.request(ports.budgets.bindSession(peer, session), classes);
|
|
74
76
|
if (decision.kind !== "ask") return undefined; // native, or within an approved class budget
|
package/src/hub/action-policy.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import { ACTION_CLASSES, type ActionClass } from "./action-class.ts";
|
|
2
2
|
import { sanitize } from "./envelope.ts";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/** `floor` (#999 T3) lets ordinary branch pushes and own-repository pull requests run without asking. It is kept when present, true or false, so a recovery can tell that the project's file names the key at all. */
|
|
5
|
+
export interface ActionPolicyConfig { enabled: boolean; always_ask: ActionClass[]; budgets: Record<ActionClass, number>; floor?: boolean }
|
|
5
6
|
const DEFAULT_BUDGETS: Record<ActionClass, number> = { publish: 1, destroy: 3, "external-write": 5, install: 2 };
|
|
6
7
|
/** Defaults are host policy; overrides belong only in untracked config.local.json.
|
|
7
8
|
* ponytail: startup adopts that local policy without a retained person confirmation.
|
|
8
9
|
* Status surfaces every weakening as attention; upgrade: retain a person-bound startup confirmation.
|
|
9
10
|
*/
|
|
10
11
|
export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
|
|
11
|
-
const fail = (): never => { throw new Error("action_policy: expected enabled, always_ask and class budgets (integers 0..1000)"); };
|
|
12
|
+
const fail = (): never => { throw new Error("action_policy: expected enabled, always_ask, floor and class budgets (integers 0..1000)"); };
|
|
12
13
|
if (value === undefined) return { enabled: true, always_ask: [...ACTION_CLASSES], budgets: { ...DEFAULT_BUDGETS } };
|
|
13
14
|
if (!value || typeof value !== "object" || Array.isArray(value)) return fail();
|
|
14
15
|
const raw = value as Record<string, unknown>;
|
|
15
|
-
if (Object.keys(raw).some(key => !["enabled", "always_ask", "budgets"].includes(key))) return fail();
|
|
16
|
+
if (Object.keys(raw).some(key => !["enabled", "always_ask", "budgets", "floor"].includes(key))) return fail();
|
|
16
17
|
if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") return fail();
|
|
18
|
+
if (raw.floor !== undefined && typeof raw.floor !== "boolean") return fail();
|
|
17
19
|
const classes = raw.always_ask === undefined ? ACTION_CLASSES : raw.always_ask;
|
|
18
20
|
if (!Array.isArray(classes) || classes.length > ACTION_CLASSES.length || new Set(classes).size !== classes.length || classes.some(key => !ACTION_CLASSES.includes(key))) return fail();
|
|
19
21
|
const budgets = { ...DEFAULT_BUDGETS };
|
|
@@ -24,7 +26,82 @@ export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
|
|
|
24
26
|
budgets[key as ActionClass] = amount;
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
|
-
return { enabled: raw.enabled ?? true, always_ask: [...classes], budgets } as ActionPolicyConfig;
|
|
29
|
+
return { enabled: raw.enabled ?? true, always_ask: [...classes], budgets, ...(raw.floor !== undefined ? { floor: raw.floor } : {}) } as ActionPolicyConfig;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The classes the floor never lets always_ask drop: with the floor on, release-grade, destructive and foreign-write calls ask whatever the coarse selector says. */
|
|
33
|
+
export const FLOOR_ASKED: readonly ActionClass[] = Object.freeze(["publish", "external-write", "destroy"] as const);
|
|
34
|
+
/** The classes that ask under this policy: always_ask, plus every floor class while the floor is on. A disabled policy asks none. */
|
|
35
|
+
export function askedClasses(config: ActionPolicyConfig): ActionClass[] {
|
|
36
|
+
if (!config.enabled) return [];
|
|
37
|
+
return ACTION_CLASSES.filter(key => config.always_ask.includes(key) || config.floor === true && FLOOR_ASKED.includes(key));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The structural guards the floor rests on where the hub cannot see a call first (Codex in never-ask, Kimi in auto, Jcode: #999 T3). The hub names
|
|
42
|
+
* them and cannot verify them from here, so a status row says "not verified", never that they hold. A guard that is missing is the person's to report.
|
|
43
|
+
*/
|
|
44
|
+
export const FLOOR_GUARDS: readonly { readonly asks: string; readonly guard: string }[] = Object.freeze([
|
|
45
|
+
{ asks: "force push, a push to the default branch or of tags", guard: "GitHub branch protection on the default branch" },
|
|
46
|
+
{ asks: "gh release and a release tag", guard: "release only through ahub sdlc authorize-release and the OIDC tag workflow" },
|
|
47
|
+
{ asks: "deletion outside the project, a write to another repository", guard: "the peer's native sandbox" },
|
|
48
|
+
]);
|
|
49
|
+
export const floorGuardText = (): string => FLOOR_GUARDS.map(entry => `${entry.asks}: ${entry.guard}`).join("; ");
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One classification's budget for host reads (#999 T3). Every authority read the floor makes goes through `read`: an identical key is answered once from the memo, at most `calls`
|
|
53
|
+
* distinct reads run, and none is answered after `ms` has passed, so a valid-sized command that holds hundreds of pushes cannot hold the synchronous gate. Past the bound a fact is
|
|
54
|
+
* unknown (undefined), and an unknown fact keeps the call asking. `now` is injectable so a test proves the bound with a counter and a clock, never a subprocess storm.
|
|
55
|
+
* ponytail: the bound is per classification, not per daemon; a shared async fact cache would lift the ceiling without blocking the loop.
|
|
56
|
+
*/
|
|
57
|
+
export function floorReads(calls: number, ms: number, now: () => number = Date.now): { read: <T>(key: string, run: () => T) => T | undefined; readonly distinct: number } {
|
|
58
|
+
const started = now(), memo = new Map<string, unknown>();
|
|
59
|
+
return {
|
|
60
|
+
read: <T>(key: string, run: () => T): T | undefined => {
|
|
61
|
+
if (now() - started > ms) return undefined;
|
|
62
|
+
if (memo.has(key)) return memo.get(key) as T;
|
|
63
|
+
if (memo.size >= calls) return undefined;
|
|
64
|
+
const found = run();
|
|
65
|
+
memo.set(key, found);
|
|
66
|
+
return found;
|
|
67
|
+
},
|
|
68
|
+
get distinct() { return memo.size; },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The eight floor classes of #999 (one list with `templates/presets/autonomous.json` `floorClasses`; a test holds them equal): each maps to the action class
|
|
74
|
+
* that asks for it and to the structural guard it rests on where the hub cannot see the call first. `guard` null means none is named: the class is then
|
|
75
|
+
* unprotected there, and status says so.
|
|
76
|
+
*/
|
|
77
|
+
export const FLOOR_CLASS_TABLE: Readonly<Record<string, { readonly action: ActionClass; readonly asks: string; readonly guard: string | null }>> = Object.freeze({
|
|
78
|
+
"force-push": { action: "publish", asks: "a force or delete push", guard: "GitHub branch protection on the default branch" },
|
|
79
|
+
"default-branch-push": { action: "publish", asks: "a push to the default branch", guard: "GitHub branch protection on the default branch" },
|
|
80
|
+
"tag-push": { action: "publish", asks: "a push of a tag", guard: "release only through ahub sdlc authorize-release and the OIDC tag workflow" },
|
|
81
|
+
"release-publish": { action: "publish", asks: "gh release", guard: "release only through ahub sdlc authorize-release and the OIDC tag workflow" },
|
|
82
|
+
"package-publish": { action: "publish", asks: "npm, bun, pnpm, yarn or cargo publish", guard: null },
|
|
83
|
+
"destroy-outside-root": { action: "destroy", asks: "deletion outside the execution root", guard: "the peer's native sandbox" },
|
|
84
|
+
"foreign-repo-write": { action: "external-write", asks: "a write to another repository", guard: "the peer's native sandbox" },
|
|
85
|
+
"external-send": { action: "external-write", asks: "mail and posting", guard: null },
|
|
86
|
+
});
|
|
87
|
+
export const FLOOR_CLASSES: readonly string[] = Object.freeze(Object.keys(FLOOR_CLASS_TABLE));
|
|
88
|
+
export interface FloorEnforcementRow { readonly class: string; readonly status: "hub-enforced" | "not-enforced"; readonly reason: string }
|
|
89
|
+
/**
|
|
90
|
+
* What the hub can say about each floor class for one peer, from the running policy and where the hub sees that peer's calls (`enforcement`, as `actionEnforcement`
|
|
91
|
+
* in the daemon reports it). `hub-enforced`: the hub sees the call first and the class asks under this policy, with the floor on or off. `not-enforced`: it does not,
|
|
92
|
+
* or the policy does not ask for the class; the reason names the guard the class rests on and that the hub has not verified it, never that it holds. The hub verifies
|
|
93
|
+
* no structural guard, so no row is `structural-guard` here: that status needs evidence a person or a check supplies (`AutonomyPeer.structuralGuards`).
|
|
94
|
+
*/
|
|
95
|
+
export function floorEnforcement(policy: ActionPolicyConfig | undefined, enforcement: { readonly enforced: boolean; readonly reason?: string }): Record<string, FloorEnforcementRow> {
|
|
96
|
+
const config = actionPolicyConfig(policy), asked = askedClasses(config), rows: Record<string, FloorEnforcementRow> = {};
|
|
97
|
+
for (const [name, entry] of Object.entries(FLOOR_CLASS_TABLE)) {
|
|
98
|
+
const guard = entry.guard === null ? "no structural guard is named for it" : `it rests on ${entry.guard}, which the hub has not verified`;
|
|
99
|
+
if (!config.enabled) rows[name] = { class: name, status: "not-enforced", reason: `action_policy is disabled, so ${entry.asks} does not ask; ${guard}` };
|
|
100
|
+
else if (!asked.includes(entry.action)) rows[name] = { class: name, status: "not-enforced", reason: `action_policy always_ask omits ${entry.action}, so ${entry.asks} does not ask; ${guard}` };
|
|
101
|
+
else if (!enforcement.enforced) rows[name] = { class: name, status: "not-enforced", reason: `the hub does not see this peer's calls first${enforcement.reason ? ` (${enforcement.reason})` : ""}; ${guard}` };
|
|
102
|
+
else rows[name] = { class: name, status: "hub-enforced", reason: `${entry.asks} asks (class ${entry.action}${config.floor === true ? ", floor on" : ""})` };
|
|
103
|
+
}
|
|
104
|
+
return rows;
|
|
28
105
|
}
|
|
29
106
|
|
|
30
107
|
export interface ActionSessionBudget { readonly peer: string; readonly session: string }
|
|
@@ -59,9 +136,14 @@ export class ActionBudgets {
|
|
|
59
136
|
if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); state.grants = empty(); }
|
|
60
137
|
return state;
|
|
61
138
|
}
|
|
62
|
-
|
|
139
|
+
/** Whether the floor (#999 T3) is on: the policy is enabled and says `floor`. Callers read floor facts only through this, so facts alone never widen a policy that did not ask for them. */
|
|
140
|
+
floorOn(): boolean {
|
|
63
141
|
const config = actionPolicyConfig(this.config());
|
|
64
|
-
return config.enabled
|
|
142
|
+
return config.enabled && config.floor === true;
|
|
143
|
+
}
|
|
144
|
+
private selected(actions: readonly ActionClass[]): ActionClass[] {
|
|
145
|
+
const asked = askedClasses(actionPolicyConfig(this.config()));
|
|
146
|
+
return ACTION_CLASSES.filter(key => actions.includes(key) && asked.includes(key));
|
|
65
147
|
}
|
|
66
148
|
/** A budget decision consumes every selected class atomically; native never grants permission. */
|
|
67
149
|
request(handle: ActionSessionBudget | undefined, actions: readonly ActionClass[]): ActionDecision {
|
|
@@ -101,10 +183,14 @@ export class ActionBudgets {
|
|
|
101
183
|
status(peer: string, enforced: boolean, reason = "adapter integration pending") {
|
|
102
184
|
const config = actionPolicyConfig(this.config()), state = this.sessions.get(peer);
|
|
103
185
|
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]);
|
|
186
|
+
const weakened = !config.enabled || config.floor === true || ACTION_CLASSES.some(key => !config.always_ask.includes(key) || config.budgets[key] > DEFAULT_BUDGETS[key]);
|
|
105
187
|
const attention = weakened ? "; attention: machine-local policy weakens defaults; no retained startup confirmation" : "";
|
|
106
|
-
|
|
107
|
-
|
|
188
|
+
const floor = config.enabled && config.floor === true;
|
|
189
|
+
// Where the hub cannot see a call first, the floor rests on guards outside it: say which, and that the hub has not verified them.
|
|
190
|
+
const rests = floor && !enforced ? `; floor on: ${floorGuardText()} rest on structural guards the hub has not verified` : floor ? "; floor on: ordinary branch pushes and own-repository pull requests do not ask" : "";
|
|
191
|
+
return { enabled: config.enabled, alwaysAsk: askedClasses({ ...config, enabled: true }), limits: { ...config.budgets }, remaining: bound ? { ...state.remaining } : empty(), sessionBound: bound,
|
|
192
|
+
...(config.floor === true ? { floor: true, ...(floor && !enforced ? { floorGuards: FLOOR_GUARDS.map(entry => ({ ...entry })) } : {}) } : {}),
|
|
193
|
+
enforceable: enforced, note: (!enforced ? `always-ask not enforceable: ${reason}${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled") + rests + attention };
|
|
108
194
|
}
|
|
109
195
|
}
|
|
110
196
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeSync, type Stats } from "node:fs";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { types } from "node:util";
|
|
5
|
+
|
|
6
|
+
export type AutonomyConsentPurpose = "permission" | "ownership" | "undo";
|
|
7
|
+
export type AutonomyConsentValue = string;
|
|
8
|
+
export type AutonomyConsentValues = Record<string, Record<string, AutonomyConsentValue>>;
|
|
9
|
+
export interface AutonomyConsent {
|
|
10
|
+
confirmed(current: AutonomyConsentValues, trusted: boolean): Set<string>;
|
|
11
|
+
/** Host console-confirmation capability. Never exposed as a caller-selected operation. */
|
|
12
|
+
confirm(values: AutonomyConsentValues): void;
|
|
13
|
+
clear(): void;
|
|
14
|
+
}
|
|
15
|
+
export const AUTONOMY_CONSENT_DIR = "autonomy";
|
|
16
|
+
export const AUTONOMY_CONSENT_FILE = "autonomy-consent.json";
|
|
17
|
+
export const AUTONOMY_CONSENT_KEY_FILE = "autonomy-consent.key";
|
|
18
|
+
export const autonomyConsentStateDir = (home: string, projectId: string): string => join(home, AUTONOMY_CONSENT_DIR, createHash("sha256").update(projectId).digest("hex"));
|
|
19
|
+
const MAX_PEERS = 128, MAX_RECORD = 64 * 1024, KEY_BYTES = 32;
|
|
20
|
+
const HEX = /^[a-f0-9]{64}$/, PEER = /^[a-z][a-z0-9-]{0,63}$/;
|
|
21
|
+
const unavailable = (): Error => new Error("autonomy consent is unavailable");
|
|
22
|
+
interface SignedPeer { peer: string; digest: string; mac: string }
|
|
23
|
+
interface Document { version: 1; project: string; purpose: AutonomyConsentPurpose; peers: SignedPeer[] }
|
|
24
|
+
interface PrivateRead { bytes: Buffer; stat: Stats }
|
|
25
|
+
const plain = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value) && !types.isProxy(value)
|
|
26
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(value)) && Object.getOwnPropertySymbols(value).length === 0
|
|
27
|
+
&& Object.values(Object.getOwnPropertyDescriptors(value)).every(descriptor => Object.hasOwn(descriptor, "value"));
|
|
28
|
+
const sameFile = (left: Stats, right: Stats): boolean => left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
29
|
+
|
|
30
|
+
/** Digests bind exact mode keys or the purpose-specific witness digest, independent of insertion order. No values are retained in the receipt. */
|
|
31
|
+
function digests(value: AutonomyConsentValues, purpose: AutonomyConsentPurpose): Map<string, string> {
|
|
32
|
+
if (!plain(value) || Object.keys(value).length > MAX_PEERS) throw unavailable();
|
|
33
|
+
if (purpose !== "permission" && (Object.keys(value).length !== 1 || !Object.hasOwn(value, purpose))) throw unavailable();
|
|
34
|
+
const result = new Map<string, string>();
|
|
35
|
+
for (const peer of Object.keys(value).sort()) {
|
|
36
|
+
const values = value[peer];
|
|
37
|
+
if (!PEER.test(peer) || !plain(values)) throw unavailable();
|
|
38
|
+
const keys = Object.keys(values).sort();
|
|
39
|
+
if (purpose === "permission") {
|
|
40
|
+
if (!keys.length || keys.length > 2 || keys.some(key => key !== `permission_modes.${peer}` && key !== `permission.${peer}`)) throw unavailable();
|
|
41
|
+
} else if (keys.length !== 1 || keys[0] !== "digest" || typeof values.digest !== "string" || !HEX.test(values.digest)) throw unavailable();
|
|
42
|
+
const pairs = keys.map(key => {
|
|
43
|
+
const item = values[key];
|
|
44
|
+
if (typeof item !== "string" || !item || Buffer.byteLength(item) > 4096) throw unavailable();
|
|
45
|
+
return [key, item];
|
|
46
|
+
});
|
|
47
|
+
result.set(peer, createHash("sha256").update(JSON.stringify(pairs)).digest("hex"));
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** State is a host capability: filesystem guards and its HMAC key are outside writable project configuration. */
|
|
53
|
+
export function createAutonomyConsent(stateDir: string, projectId: string, purpose: AutonomyConsentPurpose = "permission"): AutonomyConsent {
|
|
54
|
+
if (typeof stateDir !== "string" || !isAbsolute(stateDir) || Buffer.byteLength(stateDir) > 4096 || typeof projectId !== "string" || !projectId || Buffer.byteLength(projectId) > 1024 || !["permission", "ownership", "undo"].includes(purpose)) throw unavailable();
|
|
55
|
+
const privateDir = join(stateDir, AUTONOMY_CONSENT_DIR);
|
|
56
|
+
const state = join(privateDir, AUTONOMY_CONSENT_FILE), keyFile = join(privateDir, AUTONOMY_CONSENT_KEY_FILE);
|
|
57
|
+
const owner = process.getuid?.();
|
|
58
|
+
const directory = (create = false): boolean => {
|
|
59
|
+
if (owner === undefined || typeof constants.O_NOFOLLOW !== "number") throw unavailable();
|
|
60
|
+
let stat = lstatSync(stateDir, { throwIfNoEntry: false });
|
|
61
|
+
if (!stat && create) { mkdirSync(stateDir, { recursive: true, mode: 0o700 }); stat = lstatSync(stateDir); }
|
|
62
|
+
if (!stat) return false;
|
|
63
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== owner || (stat.mode & 0o022) !== 0) throw unavailable();
|
|
64
|
+
let child = lstatSync(privateDir, { throwIfNoEntry: false });
|
|
65
|
+
if (!child && create) { mkdirSync(privateDir, { mode: 0o700 }); child = lstatSync(privateDir); }
|
|
66
|
+
if (!child) return false;
|
|
67
|
+
if (!child.isDirectory() || child.isSymbolicLink() || child.uid !== owner || (child.mode & 0o077) !== 0) throw unavailable();
|
|
68
|
+
return true;
|
|
69
|
+
};
|
|
70
|
+
const safe = (stat: Stats, max: number) => stat.isFile() && stat.uid === owner && stat.nlink === 1 && (stat.mode & 0o777) === 0o600 && stat.size > 0 && stat.size <= max;
|
|
71
|
+
const read = (file: string, max: number): PrivateRead | undefined => {
|
|
72
|
+
let fd: number;
|
|
73
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
74
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw unavailable(); }
|
|
75
|
+
try {
|
|
76
|
+
const before = fstatSync(fd);
|
|
77
|
+
if (!safe(before, max)) throw unavailable();
|
|
78
|
+
const bytes = Buffer.alloc(before.size); let offset = 0;
|
|
79
|
+
while (offset < bytes.length) { const size = readSync(fd, bytes, offset, bytes.length - offset, offset); if (!size) throw unavailable(); offset += size; }
|
|
80
|
+
const after = fstatSync(fd), current = lstatSync(file, { throwIfNoEntry: false });
|
|
81
|
+
if (!safe(after, max) || !sameFile(before, after) || !current || !safe(current, max) || !sameFile(after, current)) throw unavailable();
|
|
82
|
+
return { bytes, stat: after };
|
|
83
|
+
} finally { closeSync(fd); }
|
|
84
|
+
};
|
|
85
|
+
const mac = (key: Buffer, peer: string, digest: string): string => createHmac("sha256", key).update(JSON.stringify(["agent-hub.autonomy-consent/v1", purpose, projectId, peer, digest])).digest("hex");
|
|
86
|
+
const temporary = (bytes: Buffer): string => {
|
|
87
|
+
const file = join(privateDir, `.autonomy-consent-${randomBytes(16).toString("hex")}.tmp`);
|
|
88
|
+
const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
89
|
+
try { let offset = 0; while (offset < bytes.length) offset += writeSync(fd, bytes, offset); fsyncSync(fd); }
|
|
90
|
+
catch (error) { try { unlinkSync(file); } catch { /* Only this exclusive temporary file is ours. */ } throw error; }
|
|
91
|
+
finally { closeSync(fd); }
|
|
92
|
+
return file;
|
|
93
|
+
};
|
|
94
|
+
const write = (peers: SignedPeer[]): void => {
|
|
95
|
+
if (!directory()) throw unavailable();
|
|
96
|
+
read(state, MAX_RECORD); // Refuse unsafe existing paths; rename never follows a link.
|
|
97
|
+
const bytes = Buffer.from(`${JSON.stringify({ version: 1, project: projectId, purpose, peers } satisfies Document)}\n`);
|
|
98
|
+
if (bytes.length > MAX_RECORD) throw unavailable();
|
|
99
|
+
const temp = temporary(bytes);
|
|
100
|
+
try { renameSync(temp, state); } finally { if (existsSync(temp)) unlinkSync(temp); }
|
|
101
|
+
};
|
|
102
|
+
const key = (issue: boolean): Buffer | undefined => {
|
|
103
|
+
const present = read(keyFile, KEY_BYTES);
|
|
104
|
+
if (present) { if (present.bytes.length !== KEY_BYTES) throw unavailable(); return present.bytes; }
|
|
105
|
+
if (!issue) return undefined;
|
|
106
|
+
const generated = randomBytes(KEY_BYTES), temp = temporary(generated);
|
|
107
|
+
try {
|
|
108
|
+
try { linkSync(temp, keyFile); }
|
|
109
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
110
|
+
} finally { unlinkSync(temp); }
|
|
111
|
+
const published = read(keyFile, KEY_BYTES);
|
|
112
|
+
if (!published || published.bytes.length !== KEY_BYTES) throw unavailable();
|
|
113
|
+
return published.bytes;
|
|
114
|
+
};
|
|
115
|
+
const authenticated = (bytes: Buffer, secret: Buffer): { peers: SignedPeer[]; count: number } | undefined => {
|
|
116
|
+
const raw: unknown = JSON.parse(bytes.toString("utf8"));
|
|
117
|
+
if (!plain(raw) || raw.version !== 1 || Object.keys(raw).some(name => !["version", "project", "purpose", "peers"].includes(name)) || typeof raw.project !== "string" || typeof raw.purpose !== "string" || !Array.isArray(raw.peers) || raw.peers.length > MAX_PEERS) throw unavailable();
|
|
118
|
+
if (raw.project !== projectId || raw.purpose !== purpose) return undefined;
|
|
119
|
+
const peers: SignedPeer[] = [], seen = new Set<string>();
|
|
120
|
+
for (const value of raw.peers) {
|
|
121
|
+
if (!plain(value) || Object.keys(value).some(name => !["peer", "digest", "mac"].includes(name)) || typeof value.peer !== "string" || !PEER.test(value.peer) || typeof value.digest !== "string" || !HEX.test(value.digest) || typeof value.mac !== "string" || !HEX.test(value.mac) || seen.has(value.peer)) throw unavailable();
|
|
122
|
+
seen.add(value.peer);
|
|
123
|
+
if (timingSafeEqual(Buffer.from(value.mac, "hex"), Buffer.from(mac(secret, value.peer, value.digest), "hex"))) peers.push({ peer: value.peer, digest: value.digest, mac: value.mac });
|
|
124
|
+
}
|
|
125
|
+
return { peers, count: raw.peers.length };
|
|
126
|
+
};
|
|
127
|
+
const clear = (): void => {
|
|
128
|
+
try {
|
|
129
|
+
if (!directory()) return;
|
|
130
|
+
let failure = false;
|
|
131
|
+
// Retire the key first, including when a receipt is missing. Unlink never follows
|
|
132
|
+
// an owned alias or reads unsafe data; an old signed receipt cannot revive consent.
|
|
133
|
+
for (const file of [keyFile, state]) {
|
|
134
|
+
try {
|
|
135
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
136
|
+
if (!stat) continue;
|
|
137
|
+
if (stat.uid !== owner || !stat.isFile() && !stat.isSymbolicLink()) throw unavailable();
|
|
138
|
+
unlinkSync(file);
|
|
139
|
+
} catch { failure = true; }
|
|
140
|
+
}
|
|
141
|
+
if (failure) throw unavailable();
|
|
142
|
+
} catch { throw unavailable(); }
|
|
143
|
+
};
|
|
144
|
+
return {
|
|
145
|
+
confirmed(current, trusted) {
|
|
146
|
+
if (trusted !== true) { try { clear(); } catch { /* Unsafe state never proves consent. */ } return new Set(); }
|
|
147
|
+
try {
|
|
148
|
+
const expected = digests(current, purpose);
|
|
149
|
+
if (!directory()) return new Set();
|
|
150
|
+
const secret = key(false), receipt = read(state, MAX_RECORD);
|
|
151
|
+
if (!secret || !receipt) return new Set();
|
|
152
|
+
const stored = authenticated(receipt.bytes, secret);
|
|
153
|
+
if (!stored) return new Set();
|
|
154
|
+
const retained = stored.peers.filter(row => expected.get(row.peer) === row.digest);
|
|
155
|
+
const stillCurrent = read(state, MAX_RECORD);
|
|
156
|
+
if (!stillCurrent || !sameFile(receipt.stat, stillCurrent.stat)) return new Set();
|
|
157
|
+
// Copy only already authenticated approvals; an observation can revoke but never mint consent.
|
|
158
|
+
if (retained.length !== stored.count) write(retained);
|
|
159
|
+
return new Set(retained.map(row => row.peer));
|
|
160
|
+
} catch { return new Set(); }
|
|
161
|
+
},
|
|
162
|
+
confirm(values) {
|
|
163
|
+
try {
|
|
164
|
+
const requested = digests(values, purpose);
|
|
165
|
+
directory(true); read(state, MAX_RECORD);
|
|
166
|
+
const secret = key(true);
|
|
167
|
+
if (!secret) throw unavailable();
|
|
168
|
+
write([...requested].map(([peer, digest]) => ({ peer, digest, mac: mac(secret, peer, digest) })));
|
|
169
|
+
} catch { throw unavailable(); }
|
|
170
|
+
},
|
|
171
|
+
clear,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, type Stats } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { canonicalPath } from "./project.ts";
|
|
5
|
+
import { insideRoot } from "./path-policy.ts";
|
|
6
|
+
|
|
7
|
+
export interface AutonomyDefaultsRead { enabled: boolean; fingerprint: string; notice?: string }
|
|
8
|
+
const MAX_BYTES = 4096;
|
|
9
|
+
const same = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
10
|
+
const refusal = () => new Error("machine autonomy defaults are unsafe or changed; nothing was written");
|
|
11
|
+
const safeFile = (stat: Stats) => stat.isFile() && stat.nlink === 1 && stat.uid === process.getuid?.() && (stat.mode & 0o777) === 0o600 && stat.size <= MAX_BYTES;
|
|
12
|
+
function location(home: string, roots: readonly string[], create: boolean): Stats | undefined {
|
|
13
|
+
if (!isAbsolute(home) || roots.some(root => insideRoot(relative(canonicalPath(root), canonicalPath(home))))) throw refusal();
|
|
14
|
+
let parent = lstatSync(home, { throwIfNoEntry: false });
|
|
15
|
+
if (!parent && create) { mkdirSync(home, { recursive: true, mode: 0o700 }); parent = lstatSync(home); }
|
|
16
|
+
if (parent && (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== process.getuid?.() || (parent.mode & 0o022) !== 0)) throw refusal();
|
|
17
|
+
return parent;
|
|
18
|
+
}
|
|
19
|
+
function capture(home: string, roots: readonly string[]): { stat?: Stats; text?: string; fingerprint: string } {
|
|
20
|
+
if (!location(home, roots, false)) return { fingerprint: "absent" };
|
|
21
|
+
const file = join(home, "defaults.json");
|
|
22
|
+
let fd: number;
|
|
23
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
24
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { fingerprint: "absent" }; throw refusal(); }
|
|
25
|
+
try {
|
|
26
|
+
const before = fstatSync(fd);
|
|
27
|
+
if (!safeFile(before)) throw refusal();
|
|
28
|
+
const text = readFileSync(fd, "utf8"), after = fstatSync(fd), named = lstatSync(file);
|
|
29
|
+
if (Buffer.byteLength(text) > MAX_BYTES || !same(before, after) || !same(after, named)) throw refusal();
|
|
30
|
+
return { stat: after, text, fingerprint: createHash("sha256").update(`${after.dev}:${after.ino}:${after.ctimeMs}:`).update(text).digest("hex") };
|
|
31
|
+
} finally { closeSync(fd); }
|
|
32
|
+
}
|
|
33
|
+
/** Missing or untrusted machine files never change the shipped ask default. */
|
|
34
|
+
export function readAutonomyDefaults(home: string, roots: readonly string[] = []): AutonomyDefaultsRead {
|
|
35
|
+
try {
|
|
36
|
+
const held = capture(home, roots);
|
|
37
|
+
if (held.text === undefined) return { enabled: false, fingerprint: held.fingerprint };
|
|
38
|
+
const raw: unknown = JSON.parse(held.text);
|
|
39
|
+
const document = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : undefined;
|
|
40
|
+
if (!document || Object.keys(document).some(key => !["schema", "autonomy"].includes(key)) || document.schema !== 1 || typeof document.autonomy !== "boolean") throw refusal();
|
|
41
|
+
return { enabled: document.autonomy, fingerprint: held.fingerprint };
|
|
42
|
+
} catch { return { enabled: false, fingerprint: "untrusted", notice: "Machine autonomy defaults ignored: defaults.json must be an owner-only regular file with a valid schema, outside project roots." }; }
|
|
43
|
+
}
|
|
44
|
+
/** Person CLI only. Exact prior evidence is rechecked after confirmation and before replacement. */
|
|
45
|
+
export function writeAutonomyDefaults(home: string, enabled: boolean, expected: string, roots: readonly string[] = []): void {
|
|
46
|
+
const before = capture(home, roots);
|
|
47
|
+
if (before.fingerprint !== expected) throw refusal();
|
|
48
|
+
const parent = location(home, roots, true)!;
|
|
49
|
+
const file = join(home, "defaults.json"), temporary = join(home, `.defaults.${randomUUID()}.tmp`);
|
|
50
|
+
try {
|
|
51
|
+
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
52
|
+
try { writeFileSync(fd, `${JSON.stringify({ schema: 1, autonomy: enabled }, null, 2)}\n`); fsyncSync(fd); }
|
|
53
|
+
finally { closeSync(fd); }
|
|
54
|
+
const current = lstatSync(home);
|
|
55
|
+
if (current.dev !== parent.dev || current.ino !== parent.ino || capture(home, roots).fingerprint !== expected) throw refusal();
|
|
56
|
+
renameSync(temporary, file);
|
|
57
|
+
} finally { try { unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
|
|
58
|
+
}
|