@evoclock/pi-agentic-driver 0.5.0 → 0.7.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.
@@ -0,0 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ export default async function attendedAuthorityGuard(pi) {
5
+ const module = await import(new URL("../scripts/enforcement/attended_authority_guard.js", import.meta.url).href);
6
+ return module.registerAttendedAuthorityGuard(pi);
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoclock/pi-agentic-driver",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
@@ -34,6 +34,8 @@
34
34
  "governance"
35
35
  ],
36
36
  "files": [
37
+ "extensions/attended-authority-guard.ts",
38
+ "scripts/enforcement/attended_authority_guard.js",
37
39
  "extensions/code-phage.js",
38
40
  "extensions/herdr-communication.ts",
39
41
  "extensions/herdr-dispatch.ts",
@@ -57,6 +59,7 @@
57
59
  "extensions/linux-microvm.ts",
58
60
  "scripts/enforcement/linux_microvm_cutover_pi.js",
59
61
  "scripts/enforcement/native_tui_context.js",
62
+ "scripts/enforcement/guest_containment_taxonomy.v1.json",
60
63
  "scripts/enforcement/linux_microvm_remote_fixture.sh",
61
64
  "PROVENANCE.md",
62
65
  "extensions/aidr.ts",
@@ -0,0 +1,223 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ // Attended-authority destructive-command guard for Pi.
5
+ //
6
+ // Ported observable behavior (private reference: scripts/enforcement/
7
+ // pi_attended_authority.js + cc_destructive_command_guard_hook.py):
8
+ // - Intercept tool calls before execution via the `tool_call` event.
9
+ // - Destructive shell/Git operations require native confirmation.
10
+ // - Safe reads/builds/tests pass through untouched.
11
+ // - An explicit denial returns a clear reason and the session continues;
12
+ // no hidden retry and no automatic re-ask.
13
+ // - Headless / no-confirmation contexts refuse destructive operations
14
+ // fail-closed.
15
+ // - No bypasses, no model-supplied authority, no silent escalation. The
16
+ // model cannot mark a call safe; classification is structural only.
17
+
18
+ import { isNativeTuiContext } from "./native_tui_context.js";
19
+
20
+ export const GUARD_SCHEMA = "pi-agentic-driver.attended-authority.v1";
21
+
22
+ // Tool names that can mutate the workspace.
23
+ const MUTATING_TOOLS = new Set(["bash", "write", "edit"]);
24
+
25
+ // Read-only / build / test commands that always pass through.
26
+ const SAFE_COMMAND_PREFIXES = [
27
+ "cat", "ls", "head", "tail", "grep", "rg", "find", "sed -n", "awk",
28
+ "wc", "file", "stat", "which", "echo", "pwd", "date", "env",
29
+ "node --check", "node --test", "npm test", "npm run",
30
+ "python3 -m pytest", "pytest", "make", "cmake",
31
+ "git status", "git log", "git diff", "git show", "git branch",
32
+ "git remote", "git rev-parse", "git blame", "git describe",
33
+ "git config --get", "git ls-files",
34
+ ];
35
+
36
+ // Arbitrary-code interpreter execution: any code payload can perform a
37
+ // destructive operation, so these forms are classified fail-closed.
38
+ const DESTRUCTIVE_SHELL_PATTERNS = [
39
+ { pattern: /\brm\b[^|;&]*\s(-[a-z]*[rf][a-z]*\s|--recursive|--force)/, kind: "recursive or forced file deletion" },
40
+ { pattern: /\bsudo\s+rm\b/, kind: "privileged file deletion" },
41
+ { pattern: /\brmdir\b|\bunlink\b/, kind: "file or directory deletion" },
42
+ { pattern: /\bmkfs\b|\bshred\b|\bdd\b\s+if=/, kind: "irreversible disk or file operation" },
43
+ { pattern: /\btruncate\s+-s\s*0\b/, kind: "file truncation" },
44
+ { pattern: /\bkill\b\s+-9\b|\bpkill\b/, kind: "forced process termination" },
45
+ { pattern: /\bchmod\s+-R\b|\bchown\s+-R\b/, kind: "recursive permission change" },
46
+ { pattern: /\bnode\s+(-e|--eval)\b/, kind: "arbitrary JavaScript execution via node -e" },
47
+ { pattern: /\bpython3?\s+-c\b/, kind: "arbitrary Python execution via python -c" },
48
+ { pattern: /\bnpx\b/, kind: "arbitrary package execution via npx" },
49
+ ];
50
+
51
+ // Scope note: plain `rm file` (no -r/-f) is intentionally NOT classified
52
+ // destructive here; the guard targets recursive/forced deletion and
53
+ // irreversible operations. Any `git push`, forced or not, is treated
54
+ // conservatively as destructive because pushes publish history to remotes.
55
+
56
+ // Destructive Git operations.
57
+ const DESTRUCTIVE_GIT_PATTERNS = [
58
+ { pattern: /\bgit\s+push\b[^|;&]*(--force|-f\b)/, kind: "forced Git push" },
59
+ { pattern: /\bgit\s+push\b/, kind: "Git push" },
60
+ { pattern: /\bgit\s+reset\s+--hard\b/, kind: "hard Git reset" },
61
+ { pattern: /\bgit\s+reset\b/, kind: "Git reset" },
62
+ { pattern: /\bgit\s+clean\b/, kind: "Git clean" },
63
+ { pattern: /\bgit\s+checkout\s+--\s/, kind: "Git working-tree discard" },
64
+ { pattern: /\bgit\s+restore\b/, kind: "Git working-tree discard" },
65
+ { pattern: /\bgit\s+stash\s+(drop|clear|pop)\b/, kind: "Git stash mutation" },
66
+ { pattern: /\bgit\s+stash\b/, kind: "Git stash mutation" },
67
+ { pattern: /\bgit\s+rebase\b/, kind: "Git history rewrite" },
68
+ { pattern: /\bgit\s+filter-(branch|repo)\b/, kind: "Git history rewrite" },
69
+ { pattern: /\bgit\s+commit\b[^|;&]*--amend\b/, kind: "Git history rewrite" },
70
+ { pattern: /\bgit\s+branch\s+(-D|-d)\b/, kind: "protected branch deletion" },
71
+ { pattern: /\bgit\s+tag\s+-d\b/, kind: "protected tag deletion" },
72
+ { pattern: /\bgit\s+cherry-pick\b|\bgit\s+revert\b/, kind: "Git history mutation" },
73
+ ];
74
+
75
+ // Paths whose deletion or overwrite is always treated as destructive.
76
+ const PROTECTED_PATHS = [
77
+ ".env", ".ssh", ".gnupg", "node_modules", ".git",
78
+ "package-lock.json", "pnpm-lock.yaml", "Cargo.lock", "poetry.lock",
79
+ ];
80
+
81
+ // Quote-aware tokenization: normalizes shell quoting so `rm '-rf'`,
82
+ // `git "push"`, and split/embedded quoting such as `r'm' '-rf'` still
83
+ // expose their destructive tokens to the patterns. Escapes are also
84
+ // stripped (`\rm` -> `rm`) so an escaped destructive token cannot dodge
85
+ // classification. Normalization is only used for the destructive
86
+ // patterns, never to widen the safe-prefix list.
87
+ function unquoteTokens(text) {
88
+ return String(text ?? "")
89
+ .split(/[\s\n]+/)
90
+ .map((token) => token
91
+ .replace(/^"/, "").replace(/"$/, "")
92
+ .replace(/^'/, "").replace(/'$/, "")
93
+ .replace(/["']/g, "")
94
+ .replace(/\\(.)/g, "$1"))
95
+ .join(" ");
96
+ }
97
+
98
+ export function classifyBashCommand(command) {
99
+ const text = String(command ?? "").trim();
100
+ if (!text) return { destructive: false };
101
+ // A safe prefix wins only when the whole command is that single simple
102
+ // command (no chaining, redirection, or command substitution), so a
103
+ // destructive payload cannot hide behind a safe-looking prefix.
104
+ const compound = /[;&|>`]|\$\(|\n/.test(text);
105
+ const redirection = /(^|\s)(>{1,2}|<)/.test(text);
106
+ // Structural destructive verdicts win over safe-looking prefixes, so a
107
+ // destructive form such as `git branch -D` cannot hide behind a safe
108
+ // prefix such as `git branch`.
109
+ for (const { pattern, kind } of DESTRUCTIVE_SHELL_PATTERNS) {
110
+ if (pattern.test(text) || pattern.test(unquoteTokens(text))) return { destructive: true, kind };
111
+ }
112
+ for (const { pattern, kind } of DESTRUCTIVE_GIT_PATTERNS) {
113
+ if (pattern.test(text) || pattern.test(unquoteTokens(text))) return { destructive: true, kind };
114
+ }
115
+ if (!compound && !redirection) {
116
+ for (const prefix of SAFE_COMMAND_PREFIXES) {
117
+ if (text === prefix || text.startsWith(`${prefix} `) || text.startsWith(`${prefix}\t`)) {
118
+ return { destructive: false };
119
+ }
120
+ }
121
+ }
122
+ // Output redirection overwrites an existing file in place.
123
+ const redirect = text.match(/(?:^|\s)>{1,2}\s*([^\s;&|]+)\s*$/);
124
+ if (redirect) {
125
+ const target = redirect[1].replace(/^["']|["']$/g, "");
126
+ return {
127
+ destructive: true,
128
+ kind: `file overwrite via redirection to ${target}`,
129
+ };
130
+ }
131
+ // Unrecognized non-safe commands are not destructive by default; the
132
+ // guard only intercepts structurally destructive operations.
133
+ return { destructive: false };
134
+ }
135
+
136
+ function touchesProtectedPath(path) {
137
+ const normalized = String(path ?? "").replace(/\\/g, "/");
138
+ return PROTECTED_PATHS.some((entry) =>
139
+ normalized === entry
140
+ || normalized.endsWith(`/${entry}`)
141
+ || normalized.includes(`/${entry}/`)
142
+ || normalized.startsWith(`./${entry}`),
143
+ );
144
+ }
145
+
146
+ // Existing-file overwrite detection is injected so tests stay filesystem-free.
147
+ export function classifyWriteCall(toolName, input, existsSync) {
148
+ if (toolName !== "write" && toolName !== "edit") {
149
+ return { destructive: false };
150
+ }
151
+ const path = input?.path ?? input?.file_path ?? "";
152
+ if (touchesProtectedPath(path)) {
153
+ return { destructive: true, kind: `protected path write to ${path}` };
154
+ }
155
+ if (toolName === "write" && typeof existsSync === "function" && path && existsSync(path)) {
156
+ return { destructive: true, kind: `overwrite of existing file ${path}` };
157
+ }
158
+ return { destructive: false };
159
+ }
160
+
161
+ export function confirmationBody(toolName, input, kind) {
162
+ return [
163
+ `Attended-authority guard: ${kind}.`,
164
+ `Tool: ${toolName}`,
165
+ toolName === "bash" ? `Command: ${input?.command}` : `Path: ${input?.path ?? input?.file_path ?? ""}`,
166
+ "",
167
+ "Allow this destructive operation?",
168
+ ].join("\n");
169
+ }
170
+
171
+ export function denialReason(toolName, kind, context) {
172
+ if (!isNativeTuiContext(context)) {
173
+ return `${GUARD_SCHEMA}: destructive ${toolName} operation (${kind}) refused fail-closed: no native confirmation surface in this headless context`;
174
+ }
175
+ return `${GUARD_SCHEMA}: destructive ${toolName} operation (${kind}) denied by user; the session continues and this call is not retried`;
176
+ }
177
+
178
+ /**
179
+ * Core guard for one tool call. Returns undefined to allow the call, or
180
+ * `{ block: true, reason }` to refuse it. Confirmation is native-only:
181
+ * `ctx.ui.confirm` in an interactive TUI. Headless contexts never confirm.
182
+ * Model-supplied fields on the event can never grant authority.
183
+ */
184
+ export async function guardToolCall(event, ctx, options = {}) {
185
+ const toolName = String(event?.toolName ?? "").toLowerCase();
186
+ if (!MUTATING_TOOLS.has(toolName)) return undefined;
187
+ const input = event?.input && typeof event.input === "object" ? event.input : {};
188
+
189
+ const bash = toolName === "bash"
190
+ ? classifyBashCommand(input.command)
191
+ : { destructive: false };
192
+ const write = toolName === "bash"
193
+ ? { destructive: false }
194
+ : classifyWriteCall(toolName, input, options.existsSync);
195
+ const verdict = bash.destructive ? bash : write;
196
+ if (!verdict.destructive) return undefined;
197
+
198
+ const context = options.context ?? ctx;
199
+ if (!isNativeTuiContext(context)) {
200
+ return { block: true, reason: denialReason(toolName, verdict.kind, context) };
201
+ }
202
+ const confirmed = await context.ui.confirm(
203
+ "Destructive operation",
204
+ confirmationBody(toolName, input, verdict.kind),
205
+ );
206
+ if (confirmed === true) return undefined;
207
+ return { block: true, reason: denialReason(toolName, verdict.kind, context) };
208
+ }
209
+
210
+ /**
211
+ * Register the guard on a Pi host instance. Duplicate registration on the
212
+ * same instance is ignored.
213
+ */
214
+ const REGISTERED = new WeakSet();
215
+
216
+ export function registerAttendedAuthorityGuard(pi, options = {}) {
217
+ if (!pi || REGISTERED.has(pi)) return undefined;
218
+ REGISTERED.add(pi);
219
+ pi.on?.("tool_call", (event, ctx) => guardToolCall(event, ctx, options));
220
+ return { registered: true, schema: GUARD_SCHEMA };
221
+ }
222
+
223
+ export default guardToolCall;
@@ -0,0 +1,26 @@
1
+ {
2
+ "schema": "guest-containment-taxonomy.v1",
3
+ "description": "Deny-by-default rule taxonomy for the guest containment monitor (design GUEST_CONTAINMENT_DESIGN.md section 2). One rule per line; IDs are never reused; new rules append. disposition records whether a live detector exists in the fixture (design sections 1.3, 3) or the rule is armed by the taxonomy only.",
4
+ "rules": [
5
+ {"id":"GC-PKG-001","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-manager-install: invocation of npm install, npm ci, pip install, yarn add, pnpm add, gem install, cargo install, apk add, apt install","evidence":"METR autonomous-replication evaluation work (arXiv:2403.13793); HF incident dependency-install persistence step","since":"v1","disposition":"detector:shim-argv"},
6
+ {"id":"GC-PKG-002","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"registry-write-command: npm publish, npm token, .npmrc writes, pip config, keyring writes","evidence":"HF incident: agent attempted package publication","since":"v1","disposition":"detector:shim-argv"},
7
+ {"id":"GC-PKG-003","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-metadata-tamper: writes to package.json, package-lock.json, pyproject.toml, requirements*.txt outside the job scratch space","evidence":"METR evaluation logs: dependency-manifest tampering as persistence step","since":"v1","disposition":"detector:fs-path+shim-argv"},
8
+ {"id":"GC-FSW-001","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"write-outside-allowlist: create/append/truncate on any path outside /tmp/session, detected by inotifyd or find -newer sweeps","evidence":"METR evaluation logs: out-of-bounds file writes during evaluation runs","since":"v1","disposition":"detector:fs-watcher"},
9
+ {"id":"GC-FSW-002","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"self-code-modification: writes under the agent job install/execution directories including the shim tree /shims/","evidence":"HF incident: agent rewrote the repository it was working in; monitor self-rewrite defense","since":"v1","disposition":"detector:fs-watcher"},
10
+ {"id":"GC-FSW-003","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"file-growth-anomaly: unexpected growth in cache/package directories (~/.npm, ~/.cache/pip, /var/cache/*) beyond the size-delta threshold","evidence":"METR evaluation logs: bulk cache staging of payloads","since":"v1","disposition":"detector:fs-sweep size-delta sampling (gc_cache_growth_sample; aggregate ELEVATED, GC_CACHE_GROWTH_KIB per sweep)"},
11
+ {"id":"GC-SHR-001","class":"GC-SHR","severity":"HIGH","action":"deny","match":"shared-cache-write: writes to paths shared with other agents or the coordinator; with host-share absent any such attempt is a deny","evidence":"receipt proves host-share=absent; writes would indicate boundary violation","since":"v1","disposition":"detector:fs-path; share paths auto-arm via gc_share_mounted (/proc/mounts); shared-cache roots classify as SHR when unarmed"},
12
+ {"id":"GC-SHR-002","class":"GC-SHR","severity":"HIGH","action":"deny","match":"cross-agent-channel: creation of or writes to inter-agent coordination paths (maildirs, task-queue files, agent state files)","evidence":"HF incident multi-agent escalation; METR coordination/sabotage threat model","since":"v1","disposition":"detector:fs-path+shim-argv heuristics (Maildir, task-queue, agent-channel, coordination paths)"},
13
+ {"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep"},
14
+ {"id":"GC-NET-002","class":"GC-NET","severity":"HIGH","action":"deny","match":"network-tool-invocation: shim-detects wget, curl, nc, ssh, telnet, ping regardless of arguments","evidence":"METR self-replication exfiltration patterns; HF incident remote control","since":"v1","disposition":"detector:shim-argv"},
15
+ {"id":"GC-CRED-001","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"credential-path-read: opens of ~/.ssh/, ~/.aws/, ~/.netrc, ~/.git-credentials, token files","evidence":"credential theft is step one in every reported agent-escape chain","since":"v1","disposition":"detector:shim-argv"},
16
+ {"id":"GC-CRED-002","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"env-secret-probing: attempts to dump or scan the environment for secret-shaped keys","evidence":"METR evaluation logs; HF incident postmortem credential harvest attempts","since":"v1","disposition":"detector:shim-argv"},
17
+ {"id":"GC-LOG-001","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"transcript-write: writes to the console transcript, the monitor log, or the receipt channel by any path other than the supervisor","evidence":"log is opened append-only by the supervisor; other writers are evidence tampering","since":"v1","disposition":"detector:fs-path+shim-argv (transcript/receipt-channel paths); the in-guest supervisor never writes these, so any event is a non-supervisor write"},
18
+ {"id":"GC-LOG-002","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"log-truncate-or-rename: >, rm, mv, truncate, sed -i targeting monitor/agent logs","evidence":"METR report: models attempting to edit their own logs","since":"v1","disposition":"detector:shim-argv"},
19
+ {"id":"GC-TOOL-001","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"receipt-forgery: job printing AGENTIC_MICROVM_PROBE:* or AGENTIC_MICROVM_RECEIPT:* lines","evidence":"host closed-receipt parsing rejects forged output; guest-side denial closes the loop","since":"v1","disposition":"detector:shim-argv"},
20
+ {"id":"GC-TOOL-002","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"marker-injection: writes to /dev/console, /dev/ttyS0, or other devtmpfs write targets that could inject marker text","evidence":"METR tool-call spoofing family; serial stream integrity","since":"v1","disposition":"detector:shim-argv+fs-path; known gap: char-device writes may not update mtime for the sweep"},
21
+ {"id":"GC-PROBE-001","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"containment-introspection: reads or probes of /gc/** (core.sh, taxonomy, state), /shims/**, or session-dir metadata outside sanctioned /tmp/session writes","evidence":"METR report: agents research the monitoring environment before acting; HF incident agents probed sandbox infrastructure. Individually ambiguous (a job may stat its own directories), so aggregate ELEVATED rather than immediate-kill","since":"v1","disposition":"detector:fs-path+shim-argv"},
22
+ {"id":"GC-PROBE-002","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"monitor-enumeration: ps/proc access patterns targeting watcher discovery (inspection looking for monitor PIDs, inotifyd, or the shim layer), shim/proc-watcher detected","evidence":"Design section 9.1 adversarial pass: monitor enumeration precedes timing and evasion attempts; aggregate ELEVATED avoids false-positive kills on process inspection","since":"v1","disposition":"detector:shim-argv"},
23
+ {"id":"GC-PROBE-003","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"concealment-index anomaly: aggregated probe-frequency signal (GC-PROBE events plus weighted unknown-class denials) surfaced as the receipt concealmentIndex","evidence":"Timing probes and repeated no-op shim invocations to time detection are aggregate signals, not single-action violations; see design appendix formula","since":"v1","disposition":"detector:evidence-aggregate"}
24
+ ],
25
+ "residual": {"class":"unknown","tier":"ANOMALY","action":"deny","note":"everything not allowlisted and not classified above; accumulates toward the ANOMALY threshold"}
26
+ }
@@ -20,7 +20,9 @@ import { isNativeTuiContext } from "./native_tui_context.js";
20
20
  export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
21
21
  export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
22
22
  export const WORKER_DISPATCH_MODES = Object.freeze(["continuous", "turn-by-turn"]);
23
+ export const WORKER_DISPATCH_AUTONOMY_MODES = Object.freeze(["confirmed-default", "autonomous"]);
23
24
  export const DEFAULT_MODE = "continuous";
25
+ export const DEFAULT_AUTONOMY = "confirmed-default";
24
26
  const DEFAULT_JOURNEY_STEPS = 50;
25
27
  const MAX_JOURNEY_STEPS = 200;
26
28
  const MAX_REPORT_BYTES = 32 * 1024;
@@ -39,9 +41,22 @@ export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
39
41
  action: { type: "string", enum: ["dispatch", "pulse"] },
40
42
  role: { type: "string", pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", maxLength: 64 },
41
43
  mode: { type: "string", enum: WORKER_DISPATCH_MODES },
44
+ autonomy: { type: "string", enum: WORKER_DISPATCH_AUTONOMY_MODES },
42
45
  maxSteps: { type: "integer", minimum: 1, maximum: MAX_JOURNEY_STEPS },
43
46
  stepPrompt: { type: "string", minLength: 1, maxLength: 8192 },
44
47
  model: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]{0,63}(?:\\/[a-z0-9][a-z0-9._-]{0,127})*$", maxLength: 192 },
48
+ cast: {
49
+ type: "object",
50
+ properties: {
51
+ roles: { type: "array", items: { type: "string", pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" }, maxItems: 8 },
52
+ models: {
53
+ type: "object",
54
+ patternProperties: { "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$": { type: "string", pattern: "^[a-z0-9][a-z0-9._-]{0,63}(?:\\/[a-z0-9][a-z0-9._-]{0,127})*$", maxLength: 192 } },
55
+ additionalProperties: false,
56
+ },
57
+ },
58
+ additionalProperties: false,
59
+ },
45
60
  },
46
61
  required: ["action", "role"],
47
62
  allOf: [
@@ -83,6 +98,53 @@ function failure(action, error) {
83
98
  };
84
99
  }
85
100
 
101
+ function frozenCastEntries(source) {
102
+ if (Array.isArray(source)) {
103
+ return source.map((entry) => ({
104
+ role: entry?.role,
105
+ models: Array.isArray(entry?.models) ? entry.models : [entry?.model],
106
+ }));
107
+ }
108
+ if (source && typeof source === "object" && Array.isArray(source.roles)) {
109
+ return source.roles.map((role) => ({
110
+ role,
111
+ models: Array.isArray(source.models?.[role])
112
+ ? source.models[role]
113
+ : [source.models?.[role]],
114
+ }));
115
+ }
116
+ return [];
117
+ }
118
+
119
+ function materializeFrozenCast(params, options) {
120
+ const source = params.cast ?? options.cast;
121
+ const entries = source === undefined
122
+ ? [{ role: params.role, models: [params.model ?? options.model] }]
123
+ : frozenCastEntries(source);
124
+ const roles = [];
125
+ const models = {};
126
+ for (const entry of entries) {
127
+ if (typeof entry.role !== "string" || roles.includes(entry.role)) continue;
128
+ roles.push(entry.role);
129
+ models[entry.role] = Object.freeze(
130
+ entry.models.filter((model, index, values) => typeof model === "string" && values.indexOf(model) === index),
131
+ );
132
+ }
133
+ return Object.freeze({ roles: Object.freeze(roles), models: Object.freeze(models) });
134
+ }
135
+
136
+ function checkFrozenCast(cast, role, model) {
137
+ const roleAuthorized = Array.isArray(cast?.roles) && cast.roles.includes(role);
138
+ const modelSet = roleAuthorized ? cast.models?.[role] : undefined;
139
+ const modelAuthorized = roleAuthorized && Array.isArray(modelSet) && modelSet.some((candidate) => candidate === model);
140
+ return Object.freeze({
141
+ authorized: modelAuthorized,
142
+ role,
143
+ model,
144
+ reason: modelAuthorized ? "in-cast" : "outside-cast",
145
+ });
146
+ }
147
+
86
148
  // Worker pulse: liveness, current state, and dispatch eligibility, observed
87
149
  // through the existing non-authorizing get seam. Grants no authority.
88
150
  export async function workerPulse(role, context, options = {}, signal) {
@@ -146,6 +208,8 @@ function journeyReceipt(journey) {
146
208
  const body = [
147
209
  "[WORKER_JOURNEY_REPORT_BEGIN]",
148
210
  `mode: ${journey.mode}`,
211
+ `autonomy: ${journey.autonomy}`,
212
+ ...(journey.cast ? [`cast: ${JSON.stringify(journey.cast)}`] : []),
149
213
  `role: ${journey.role}`,
150
214
  `steps: ${journey.steps.length}`,
151
215
  `status: ${journey.status}`,
@@ -167,6 +231,7 @@ function journeyReceipt(journey) {
167
231
  // dispatch purposes and ends the journey explicitly as worker-unresponsive.
168
232
  export async function runWorkerJourney(params, context, options = {}, signal) {
169
233
  const mode = params.mode ?? DEFAULT_MODE;
234
+ const autonomy = params.autonomy ?? DEFAULT_AUTONOMY;
170
235
  const maxSteps = params.maxSteps ?? DEFAULT_JOURNEY_STEPS;
171
236
  if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > MAX_JOURNEY_STEPS) {
172
237
  return failure("dispatch", dispatchError("max-steps-invalid",
@@ -176,15 +241,18 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
176
241
  const stepPrompt = params.stepPrompt;
177
242
  const taskStore = options.taskStore;
178
243
  const spawnReplacement = typeof options.spawnReplacement === "function" ? options.spawnReplacement : null;
179
- const journey = { mode, role, steps: [], status: "failed", code: null, handoff: null };
244
+ const journey = { mode, autonomy, role, steps: [], status: "failed", code: null, handoff: null };
180
245
  const dispatched = new Set();
181
246
  const communicationOptions = options.communication ?? options;
182
-
247
+ const replacementRole = options.replacementRole ?? role;
248
+ const replacementModel = options.replacementModel ?? options.model ?? params.model;
183
249
  const finish = (status) => ({
184
250
  schema: WORKER_DISPATCH_SCHEMA,
185
251
  ok: status === "completed" || status === "exhausted" || status === "waiting-approval",
186
252
  action: "dispatch",
187
253
  mode,
254
+ autonomy,
255
+ ...(journey.cast ? { cast: journey.cast } : {}),
188
256
  role,
189
257
  status,
190
258
  steps: journey.steps,
@@ -209,28 +277,51 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
209
277
  journey.handoff = { attempted: false, reason: "replacement spawn is not available in this context" };
210
278
  return finish("worker-unresponsive");
211
279
  }
212
- if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
213
- journey.handoff = { attempted: false, reason: "native TUI confirmation unavailable for replacement spawn" };
214
- return finish("worker-unresponsive");
215
- }
216
- let confirmed;
217
- try {
218
- confirmed = await context.ui.confirm("Spin up replacement worker", [
219
- `Agent session for role ${role} became unresponsive (${reason}).`,
220
- "Spin up one replacement worker through the guarded herdr-lifecycle spawn boundary?",
221
- "The replacement resumes the same pending task sequence; existing task cards are reused, never duplicated.",
222
- ].join("\n"));
223
- } catch (error) {
224
- journey.handoff = { attempted: false, reason: `confirmation failed: ${error.message}` };
280
+ const replacementRole = options.replacementRole ?? role;
281
+ const replacementModel = options.replacementModel ?? options.model ?? params.model;
282
+ if (autonomy === "autonomous" && castCheck.authorized !== true) {
283
+ journey.handoff = {
284
+ attempted: true,
285
+ ok: false,
286
+ spawned: false,
287
+ role: replacementRole,
288
+ model: replacementModel,
289
+ reason: "outside-cast",
290
+ castCheck,
291
+ };
225
292
  return finish("worker-unresponsive");
226
293
  }
227
- if (confirmed !== true) {
228
- journey.handoff = { attempted: false, reason: "native confirmation was not granted for the replacement spawn" };
229
- return finish("worker-unresponsive");
294
+ if (autonomy !== "autonomous") {
295
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
296
+ journey.handoff = { attempted: false, reason: "native TUI confirmation unavailable for replacement spawn" };
297
+ return finish("worker-unresponsive");
298
+ }
299
+ let confirmed;
300
+ try {
301
+ confirmed = await context.ui.confirm("Spin up replacement worker", [
302
+ `Agent session for role ${replacementRole} became unresponsive (${reason}).`,
303
+ "Spin up one replacement worker through the guarded herdr-lifecycle spawn boundary?",
304
+ "The replacement resumes the same pending task sequence; existing task cards are reused, never duplicated.",
305
+ ].join("\n"));
306
+ } catch (error) {
307
+ journey.handoff = { attempted: false, reason: `confirmation failed: ${error.message}` };
308
+ return finish("worker-unresponsive");
309
+ }
310
+ if (confirmed !== true) {
311
+ journey.handoff = { attempted: false, reason: "native confirmation was not granted for the replacement spawn" };
312
+ return finish("worker-unresponsive");
313
+ }
230
314
  }
231
315
  let spawned;
232
316
  try {
233
- spawned = await spawnReplacement({ role, repository: options.repository, model: options.model, context, signal });
317
+ spawned = await spawnReplacement({
318
+ role: replacementRole,
319
+ repository: options.repository,
320
+ model: replacementModel,
321
+ context,
322
+ signal,
323
+ castCheck,
324
+ });
234
325
  } catch (error) {
235
326
  journey.handoff = { attempted: true, ok: false, error: String(error?.message || error).slice(0, 256) };
236
327
  return finish("worker-unresponsive");
@@ -238,9 +329,10 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
238
329
  journey.handoff = {
239
330
  attempted: true,
240
331
  ok: spawned?.ok === true,
241
- role: spawned?.role ?? role,
332
+ role: spawned?.role ?? replacementRole,
242
333
  repository: spawned?.repository,
243
334
  modelArgv: spawned?.modelArgv,
335
+ castCheck,
244
336
  nonAuthorizing: true,
245
337
  };
246
338
  return finish("worker-unresponsive");
@@ -250,6 +342,34 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
250
342
  if (mode !== "continuous" && mode !== "turn-by-turn") {
251
343
  return failure("dispatch", dispatchError("mode-invalid", "dispatch mode must be continuous or turn-by-turn", "denied"));
252
344
  }
345
+ if (!WORKER_DISPATCH_AUTONOMY_MODES.includes(autonomy)) {
346
+ return failure("dispatch", dispatchError("autonomy-invalid", "autonomy must be confirmed-default or autonomous", "denied"));
347
+ }
348
+ if (params.cast) {
349
+ const castRoles = Array.isArray(params.cast)
350
+ ? params.cast.map((entry) => entry?.role).filter((role) => typeof role === "string")
351
+ : Array.isArray(params.cast.roles) ? params.cast.roles : [];
352
+ if (!castRoles.length) {
353
+ return failure("dispatch", dispatchError("cast-invalid", "the cast must include at least one role", "denied"));
354
+ }
355
+ for (const castRole of castRoles) {
356
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(castRole) || castRole.length > 64) {
357
+ return failure("dispatch", dispatchError("cast-invalid", `invalid cast role name: ${castRole}`, "denied"));
358
+ }
359
+ }
360
+ const castModels = Array.isArray(params.cast)
361
+ ? Object.fromEntries(params.cast.map((entry) => [entry?.role, entry?.model]).filter(([role]) => typeof role === "string"))
362
+ : params.cast.models ?? {};
363
+ for (const [castRoleName, castModel] of Object.entries(castModels)) {
364
+ if (typeof castModel === "string" && !/^[a-z0-9][a-z0-9._-]{0,63}(?:\/[a-z0-9][a-z0-9._-]{0,127})*$/.test(castModel)) {
365
+ return failure("dispatch", dispatchError("cast-invalid", `invalid cast model for ${castRoleName}: ${castModel}`, "denied"));
366
+ }
367
+ }
368
+ }
369
+ if (autonomy === "autonomous") journey.cast = materializeFrozenCast(params, options);
370
+ const castCheck = autonomy === "autonomous"
371
+ ? checkFrozenCast(journey.cast, replacementRole, replacementModel)
372
+ : null;
253
373
  if (!taskStore || typeof taskStore.list !== "function") {
254
374
  return failure("dispatch", dispatchError("task-store-invalid", "a read-only task store is required", "denied"));
255
375
  }
@@ -304,29 +424,32 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
304
424
  return finish("waiting-approval");
305
425
  }
306
426
 
307
- // Consequential dispatch requires native confirmation, once per step.
308
- if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
309
- journey.status = "failed";
310
- journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: "native TUI confirmation unavailable" });
311
- return finish("failed");
312
- }
313
- let confirmed;
314
- try {
315
- confirmed = await context.ui.confirm("Dispatch task to worker", [
316
- `Dispatch one bounded step to role ${role}?`,
317
- `Task: ${task.id}${task.subject ? ` — ${task.subject}` : ""}`,
318
- `Mode: ${mode} (step ${stepIndex} of at most ${maxSteps})`,
319
- "One prompt exchange, no retries; the worker returns one marked report.",
320
- ].join("\n"));
321
- } catch (error) {
322
- journey.status = "failed";
323
- journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: `confirmation failed: ${error.message}` });
324
- return finish("failed");
325
- }
326
- if (confirmed !== true) {
327
- journey.status = "cancelled";
328
- journey.steps.push({ step: stepIndex, taskId: task.id, status: "cancelled", error: "native confirmation was not granted" });
329
- return finish("cancelled");
427
+ // Consequential dispatch requires native confirmation, once per step,
428
+ // unless the initial dispatch explicitly authorized autonomous progression.
429
+ if (autonomy !== "autonomous") {
430
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
431
+ journey.status = "failed";
432
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: "native TUI confirmation unavailable" });
433
+ return finish("failed");
434
+ }
435
+ let confirmed;
436
+ try {
437
+ confirmed = await context.ui.confirm("Dispatch task to worker", [
438
+ `Dispatch one bounded step to role ${role}?`,
439
+ `Task: ${task.id}${task.subject ? ` ${task.subject}` : ""}`,
440
+ `Mode: ${mode} (step ${stepIndex} of at most ${maxSteps})`,
441
+ "One prompt exchange, no retries; the worker returns one marked report.",
442
+ ].join("\n"));
443
+ } catch (error) {
444
+ journey.status = "failed";
445
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: `confirmation failed: ${error.message}` });
446
+ return finish("failed");
447
+ }
448
+ if (confirmed !== true) {
449
+ journey.status = "cancelled";
450
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: "cancelled", error: "native confirmation was not granted" });
451
+ return finish("cancelled");
452
+ }
330
453
  }
331
454
 
332
455
  // One prompt exchange. Any failure is terminal for the journey; there is
@@ -338,8 +461,50 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
338
461
  signal,
339
462
  );
340
463
  if (exchange.ok !== true) {
341
- const unresponsive = exchange.code === "prompt_stalled" || exchange.code === "process_timeout";
464
+ const unresponsive = exchange.code === "prompt_stalled" || exchange.code === "prompt_delivery_unknown" || exchange.code === "process_timeout";
342
465
  if (unresponsive) {
466
+ if (autonomy === "autonomous" && castCheck?.authorized) {
467
+ // Autonomous replacement spawn: in-cast roles are replaced
468
+ // automatically through the guarded seam. The replacement's first
469
+ // prompt includes the mandatory gap-analysis instruction.
470
+ const replacementPrompt = `${stepPrompt}\n\nMANDATORY GAP-ANALYSIS PHASE: you are a replacement agent. Before resuming implementation work: (1) read the task spec; (2) inspect the repository state (code, tests, working tree) — not what prior reports claim; (3) consult the journey history for prior step reports, handoffs, and progress judgments; (4) produce a gap analysis: remaining work and the next concrete sub-step you will execute. You may not resume implementation until this phase is complete.`;
471
+ const replacement = await executeHerdrCommunication(
472
+ { action: "prompt", role, prompt: replacementPrompt, timeoutMs: 120000 },
473
+ context,
474
+ communicationOptions,
475
+ signal,
476
+ );
477
+ const gapAnalysis = replacement.ok === true
478
+ ? String(replacement.report ?? "").slice(0, 512)
479
+ : undefined;
480
+ const previousScope = journey.steps.filter(s => s.gapAnalysis).at(-1)?.gapAnalysis;
481
+ const progressCredited = replacement.ok === true && (!previousScope || gapAnalysis !== previousScope);
482
+ journey.steps.push({
483
+ step: journey.steps.length + 1,
484
+ taskId: task.id,
485
+ status: replacement.ok === true ? "replaced" : "worker-unresponsive",
486
+ error: replacement.ok === true ? undefined : String(replacement.code),
487
+ gapAnalysis,
488
+ progressCredited,
489
+ handoff: {
490
+ timestamp: new Date().toISOString(),
491
+ role,
492
+ triggerCode: exchange.code,
493
+ gapOutcome: gapAnalysis ? "produced" : "failed",
494
+ progressJudgment: progressCredited ? "credited" : "not-credited",
495
+ },
496
+ });
497
+ if (!progressCredited) {
498
+ journey.status = "worker-unresponsive-exhausted";
499
+ return finish("worker-unresponsive-exhausted");
500
+ }
501
+ // The replacement demonstrated progress: continue the journey with
502
+ // the next task instead of finishing. The replacement's exchange
503
+ // outcome determines whether the current task is retried or skipped.
504
+ if (replacement.ok !== true) {
505
+ return finish("worker-unresponsive");
506
+ }
507
+ }
343
508
  return handoffToReplacement(`exchange ended with ${exchange.code}`, task.id);
344
509
  }
345
510
  journey.status = exchange.code === "role_blocked" ? "role-blocked" : "failed";