@tea-agent/loop-agent 0.22.0 → 0.24.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.
Files changed (103) hide show
  1. package/AGENTS.md +42 -108
  2. package/CHANGELOG.md +85 -0
  3. package/README.md +8 -5
  4. package/bin/agent-worker.js +0 -0
  5. package/dist/application/context-usage/skill-resolution-stats.js +263 -0
  6. package/dist/application/dag/generate-task-dag.js +17 -3
  7. package/dist/cli/command-definitions.js +8 -7
  8. package/dist/cli/program.js +17 -15
  9. package/dist/commands/doctor.js +269 -18
  10. package/dist/commands/init.js +101 -86
  11. package/dist/commands/stats.js +40 -11
  12. package/dist/executors/shell-executor.js +20 -7
  13. package/dist/shared/operator/capabilities.js +486 -3
  14. package/dist/worker/console/app-data.js +6 -0
  15. package/dist/worker/console/chat/artifact-card.js +23 -0
  16. package/dist/worker/console/chat/chat-event-store.js +495 -0
  17. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  18. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  19. package/dist/worker/console/chat/context-panel.js +54 -0
  20. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  21. package/dist/worker/console/chat/explore-tools.js +299 -0
  22. package/dist/worker/console/chat/human-gate-card.js +37 -0
  23. package/dist/worker/console/chat/instruction-skills.js +217 -0
  24. package/dist/worker/console/chat/interview-adapter.js +136 -0
  25. package/dist/worker/console/chat/model-resolver.js +106 -0
  26. package/dist/worker/console/chat/operation-card.js +23 -0
  27. package/dist/worker/console/chat/pi-console-config.js +158 -0
  28. package/dist/worker/console/chat/pi-runtime.js +1143 -0
  29. package/dist/worker/console/chat/repo-browser.js +140 -0
  30. package/dist/worker/console/chat/repo-walk.js +116 -0
  31. package/dist/worker/console/chat/resource-loader.js +67 -0
  32. package/dist/worker/console/chat/routes.js +1646 -0
  33. package/dist/worker/console/chat/runtime-context.js +24 -0
  34. package/dist/worker/console/chat/runtime-selection.js +37 -0
  35. package/dist/worker/console/chat/session-store.js +437 -0
  36. package/dist/worker/console/chat/shortcuts.js +15 -0
  37. package/dist/worker/console/chat/tool-adapter.js +125 -0
  38. package/dist/worker/console/chat/tools.js +195 -0
  39. package/dist/worker/console/chat/usage.js +37 -0
  40. package/dist/worker/console/chat/workspace-landing.js +56 -0
  41. package/dist/worker/console/dag-confirmation.js +42 -8
  42. package/dist/worker/console/human-gate-token.js +130 -0
  43. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  44. package/dist/worker/console/operation-runner.js +6 -2
  45. package/dist/worker/console/operation-sse.js +26 -0
  46. package/dist/worker/console/operator-actions.js +420 -7
  47. package/dist/worker/console/server.js +68 -1
  48. package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
  49. package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
  50. package/dist/worker/console/static/index.html +2 -2
  51. package/dist/worker/feature/profile-schema.js +1 -1
  52. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
  53. package/dist/workflows/dag/backend-test-result-contract.js +229 -0
  54. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  55. package/dist/workflows/dag/init-hybrid.js +71 -22
  56. package/dist/workflows/dag/node-execution.js +38 -1
  57. package/dist/workflows/dag/output-protocol.js +89 -0
  58. package/dist/workflows/dag/prompt.js +35 -1
  59. package/dist/workflows/dag/recovery-recommendation.js +45 -0
  60. package/dist/workflows/dag/report.js +28 -1
  61. package/dist/workflows/dag/rerun-task.js +1 -1
  62. package/dist/workflows/dag/scheduler.js +9 -0
  63. package/dist/workflows/dag/types.js +12 -0
  64. package/dist/workflows/dag/validate.js +55 -0
  65. package/docs/README.md +73 -156
  66. package/docs/architecture/README.md +7 -6
  67. package/docs/architecture/dag-execution.md +2 -2
  68. package/docs/architecture/evolution.md +16 -14
  69. package/docs/architecture/system-overview.md +1 -1
  70. package/docs/architecture/worker-and-feature.md +3 -3
  71. package/docs/governance/README.md +15 -0
  72. package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
  73. package/docs/init-surface.manifest.json +22 -4
  74. package/docs/operations/README.md +12 -0
  75. package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
  76. package/docs/skills/vetted-skill-registry.md +23 -3
  77. package/docs/templates/README.md +55 -0
  78. package/docs/templates/backend-test-dag.json +2 -2
  79. package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
  80. package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
  81. package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
  82. package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
  83. package/docs/templates/hybrid-dag.json +1 -1
  84. package/docs/templates/progress-log.md +9 -2
  85. package/harness.json +4 -4
  86. package/package.json +5 -5
  87. package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
  88. package/skills/agent-worker/SKILL.md +1 -1
  89. package/skills/grill-with-docs/SKILL.md +44 -52
  90. package/skills/grill-with-docs/adr-format.md +37 -26
  91. package/skills/grill-with-docs/context-format.md +18 -26
  92. package/skills/loop-agent/SKILL.md +28 -112
  93. package/skills/loop-agent/references/command-reference.md +9 -3
  94. package/skills/loop-agent/references/harness-policy.md +3 -3
  95. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  96. package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
  97. package/skills/loop-agent/references/task-workflow.md +2 -0
  98. package/skills/systematic-debugging/SKILL.md +20 -4
  99. package/skills/test-driven-development/SKILL.md +10 -3
  100. package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
  101. package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
  102. /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
  103. /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Operator Chat — closed tool whitelist (design §7.5 / roadmap M0).
3
+ *
4
+ * The Chat exposes the model-callable structured operator surface. Repository
5
+ * exploration is deliberately limited to safe-read/safe-grep/git-status/
6
+ * git-diff plus built-in find/ls; the bare SDK bash/read/grep tools are denied
7
+ * because they bypass the M0-A write and sensitive-data boundaries.
8
+ *
9
+ * Dynamic discovery derives model-callable operator actions directly from the
10
+ * capabilities registry. Explicit denial remains the defense-in-depth layer:
11
+ * a future resource-loader or SDK drift cannot reactivate removed coding or
12
+ * explore tools merely by changing the active-tool list.
13
+ */
14
+ import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
15
+ import { OFFICIAL_DENIED_TOOL_IDS } from "../resource-loader.js";
16
+ import { EXECUTABLE_GATE_ONLY_ACTIONS } from "./tool-adapter.js";
17
+ const EXECUTABLE_GATE_ONLY = new Set(EXECUTABLE_GATE_ONLY_ACTIONS);
18
+ /**
19
+ * Actions the model MAY invoke. Derived from the registry but filtered by the
20
+ * modelCallable policy: modelCallable="never" actions (e.g. confirmDagConfirmation)
21
+ * are the human confirmation itself and MUST NOT be model-callable (roadmap
22
+ * M0-B / G03). They are never registered as Pi tools AND denied at the gate.
23
+ */
24
+ export const OPERATOR_CHAT_ALLOWED_TOOLS = Object.freeze(buildOperatorCapabilitiesDocument()
25
+ .actions.filter((a) => a.modelCallable !== "never" && !EXECUTABLE_GATE_ONLY.has(a.action))
26
+ .map((a) => a.action));
27
+ /**
28
+ * Operator actions that MUST NOT be model-callable (roadmap M0-B / G03).
29
+ * Derived from the registry's modelCallable="never" policy. These are the
30
+ * human confirmation actions (confirmDagConfirmation) — the model can only
31
+ * prepare them; the browser mutation gate executes the confirm with a
32
+ * server-signed confirmation token.
33
+ */
34
+ export const OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS = Object.freeze(buildOperatorCapabilitiesDocument()
35
+ .actions.filter((a) => a.modelCallable === "never" || EXECUTABLE_GATE_ONLY.has(a.action))
36
+ .map((a) => a.action));
37
+ /**
38
+ * Operator actions denied by the legacy MVP boundary. Kept EMPTY since the
39
+ * owner decided (2026-07-25) that the full operator surface is Chat-callable.
40
+ * Retained (empty) so downstream guards referencing it keep compiling and
41
+ * stay defensive against future additions.
42
+ */
43
+ export const OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS = Object.freeze([]);
44
+ // Coding / generic tool ids that must never appear in a Chat session. M0-A
45
+ // removes bare bash (write-via-redirect) and SDK read/grep (no sensitive-file
46
+ // boundary); find/ls remain the only SDK explore builtins. Safe content probes
47
+ // are custom tools, not generic SDK tools.
48
+ const _ALLOWED_SDK_EXPLORE_TOOLS = new Set(["find", "ls"]);
49
+ const _REMOVED_SDK_EXPLORE_TOOLS = ["bash", "read", "grep"];
50
+ // Operator actions that OFFICIAL_DENIED_TOOL_IDS carries for Interview safety
51
+ // but that the Chat now deliberately allows. We compare on a COMPACTED form
52
+ // (strip - and _) because OFFICIAL lists both camelCase (`contractApply`) and
53
+ // snake_case (`contract_apply`) variants, and authorize() also normalizes —
54
+ // leaving any variant in would re-deny the action via normalization.
55
+ const _ALLOWED_OPERATOR_ACTIONS_COMPACT = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase().replace(/[-_]/g, "")));
56
+ export const OPERATOR_CHAT_DENIED_TOOLS = Object.freeze([
57
+ ...new Set([
58
+ ...OFFICIAL_DENIED_TOOL_IDS.filter((id) => {
59
+ const lower = id.toLowerCase();
60
+ if (_ALLOWED_SDK_EXPLORE_TOOLS.has(lower))
61
+ return false;
62
+ if (_ALLOWED_OPERATOR_ACTIONS_COMPACT.has(lower.replace(/[-_]/g, ""))) {
63
+ return false;
64
+ }
65
+ return true;
66
+ }),
67
+ ..._REMOVED_SDK_EXPLORE_TOOLS,
68
+ ]),
69
+ ]);
70
+ /**
71
+ * Operator actions denied by model policy (modelCallable="never"). These are
72
+ * human-only actions (the confirmation itself) — derived from the registry so
73
+ * adding a "never" policy there is the only edit needed. Kept separate from
74
+ * OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS (legacy empty MVP boundary list) to
75
+ * preserve the two distinct concerns (model policy vs MVP mutation boundary).
76
+ */
77
+ const DENIED_MODEL_POLICY = new Set(OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS.map((a) => a.toLowerCase()));
78
+ const ALLOWED = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase()));
79
+ const DENIED_ACTIONS = new Set(OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS.map((a) => a.toLowerCase()));
80
+ const DENIED_TOOLS = new Set(OPERATOR_CHAT_DENIED_TOOLS.map((t) => t.toLowerCase()));
81
+ export function isOperatorChatToolAllowed(toolId) {
82
+ return ALLOWED.has(toolId.trim().toLowerCase());
83
+ }
84
+ export function isOperatorChatToolDenied(toolId) {
85
+ const id = toolId.trim().toLowerCase();
86
+ if (DENIED_TOOLS.has(id))
87
+ return true;
88
+ if (DENIED_ACTIONS.has(id))
89
+ return true;
90
+ // modelCallable="never" actions are always denied to the model (roadmap G03).
91
+ if (DENIED_MODEL_POLICY.has(id))
92
+ return true;
93
+ // normalize snake/camel/kebab
94
+ const compact = id.replace(/[-_]/g, "");
95
+ for (const d of DENIED_TOOLS) {
96
+ if (d.replace(/[-_]/g, "") === compact)
97
+ return true;
98
+ }
99
+ for (const d of DENIED_ACTIONS) {
100
+ if (d.replace(/[-_]/g, "") === compact)
101
+ return true;
102
+ }
103
+ for (const d of DENIED_MODEL_POLICY) {
104
+ if (d.replace(/[-_]/g, "") === compact)
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+ /**
110
+ * Three-gate authorization (design §7.5): session create / setActiveTools /
111
+ * per-tool-call. Server-side only — UI hiding is insufficient.
112
+ */
113
+ export function authorizeOperatorChatTool(toolId) {
114
+ const id = toolId.trim();
115
+ if (!id) {
116
+ return {
117
+ ok: false,
118
+ code: "tool-unknown",
119
+ toolId: id,
120
+ message: "empty tool id is not allowed in General Operator Chat",
121
+ };
122
+ }
123
+ if (isOperatorChatToolDenied(id)) {
124
+ return {
125
+ ok: false,
126
+ code: "tool-denied",
127
+ toolId: id,
128
+ message: `tool "${id}" is denied in General Operator Chat (no bash/read/grep/edit/write/apply_patch/full-tools/shell/coding-chat)`,
129
+ };
130
+ }
131
+ if (!isOperatorChatToolAllowed(id)) {
132
+ return {
133
+ ok: false,
134
+ code: "tool-unknown",
135
+ toolId: id,
136
+ message: `tool "${id}" is not registered for General Operator Chat (new operator actions default to deny)`,
137
+ };
138
+ }
139
+ return { ok: true, toolId: id };
140
+ }
141
+ /**
142
+ * Filter a requested tool list against the Chat whitelist.
143
+ * Returns allowed + denied (for diagnostics / red-team evidence).
144
+ */
145
+ export function filterOperatorChatTools(requested) {
146
+ const allowed = [];
147
+ const denied = [];
148
+ for (const toolId of requested) {
149
+ const decision = authorizeOperatorChatTool(toolId);
150
+ if (decision.ok)
151
+ allowed.push(decision.toolId);
152
+ else
153
+ denied.push(toolId);
154
+ }
155
+ return { allowed, denied };
156
+ }
157
+ /**
158
+ * Assert that a requested tool set contains no prohibited coding or removed
159
+ * SDK explore tool. Used by red-team / boot checks (V12 / M0-A). bash is
160
+ * explicitly forbidden: accepting it would recreate the redirect write escape.
161
+ */
162
+ export function assertNoWriteToolInList(requested) {
163
+ const writeVariants = [
164
+ "edit",
165
+ "write",
166
+ "apply_patch",
167
+ "apply-patch",
168
+ "full-tools",
169
+ "full_tools",
170
+ "coding-chat",
171
+ "coding_chat",
172
+ "shell",
173
+ "bash",
174
+ "read",
175
+ "grep",
176
+ ];
177
+ const lower = new Set(requested.map((t) => t.trim().toLowerCase()));
178
+ for (const v of writeVariants) {
179
+ if (lower.has(v)) {
180
+ throw new Error(`file-writing tool "${v}" must not appear in Operator Chat tool list (V12 closed coding surface)`);
181
+ }
182
+ }
183
+ }
184
+ /**
185
+ * Back-compat alias kept for callers that still reference the old name. It
186
+ * also rejects removed SDK explore tools, including bash.
187
+ * @deprecated use {@link assertNoWriteToolInList}.
188
+ */
189
+ export function assertNoBashInToolList(requested) {
190
+ assertNoWriteToolInList(requested);
191
+ }
192
+ /** Canonical sorted tool list to hand to setActiveToolsByName. */
193
+ export function canonicalOperatorChatToolList() {
194
+ return [...OPERATOR_CHAT_ALLOWED_TOOLS].sort();
195
+ }
@@ -0,0 +1,37 @@
1
+ function record(value) {
2
+ return value && typeof value === "object" ? value : undefined;
3
+ }
4
+ function numberAt(source, keys) {
5
+ for (const key of keys)
6
+ if (typeof source[key] === "number" && Number.isFinite(source[key]))
7
+ return Math.max(0, source[key]);
8
+ return undefined;
9
+ }
10
+ export function extractUsageSample(value) {
11
+ const event = record(value);
12
+ if (!event)
13
+ return undefined;
14
+ const message = record(event.message);
15
+ const source = record(event.usage) ?? record(message?.usage) ?? record(event.tokenUsage);
16
+ if (!source)
17
+ return undefined;
18
+ const inputTokens = numberAt(source, ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"]);
19
+ const outputTokens = numberAt(source, ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"]);
20
+ const totalTokens = numberAt(source, ["total_tokens", "totalTokens"]) ?? (inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
21
+ if (inputTokens === undefined && outputTokens === undefined && totalTokens === undefined)
22
+ return undefined;
23
+ const responseKey = [event.responseId, message?.id, source.responseId].find((item) => typeof item === "string");
24
+ return { inputTokens, outputTokens, totalTokens, ...(responseKey ? { responseKey } : {}) };
25
+ }
26
+ export function aggregateUsage(samples) {
27
+ const keyed = new Map();
28
+ const anonymous = [];
29
+ for (const sample of samples)
30
+ sample.responseKey ? keyed.set(sample.responseKey, sample) : anonymous.push(sample);
31
+ const selected = [...keyed.values(), ...anonymous];
32
+ return selected.reduce((sum, sample) => ({
33
+ inputTokens: sum.inputTokens + (sample.inputTokens ?? 0), outputTokens: sum.outputTokens + (sample.outputTokens ?? 0), totalTokens: sum.totalTokens + (sample.totalTokens ?? ((sample.inputTokens ?? 0) + (sample.outputTokens ?? 0))),
34
+ cost: sample.cost === undefined ? sum.cost : (sum.cost ?? 0) + sample.cost,
35
+ estimated: Boolean(sum.estimated || sample.estimated),
36
+ }), { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
37
+ }
@@ -0,0 +1,56 @@
1
+ export const ACTIVE_CHAT_SESSION_STORAGE_KEY = "loop-console.operator-chat.active-session.v1";
2
+ export const DEFAULT_LANDING_STORAGE_KEY = "loop-console.operator-chat.default-landing.v1";
3
+ function configuredLanding(value) {
4
+ const normalized = value?.trim().toLowerCase();
5
+ if (normalized === "chat")
6
+ return "chat";
7
+ if (normalized === "tasks")
8
+ return "tasks";
9
+ return undefined;
10
+ }
11
+ function configuredWorkspace(value) {
12
+ const normalized = value?.trim().toLowerCase();
13
+ if (normalized === "chat" || normalized === "tasks" || normalized === "recovery")
14
+ return normalized;
15
+ return undefined;
16
+ }
17
+ function workspaceFromLocation(location) {
18
+ if (!location)
19
+ return undefined;
20
+ const query = new URLSearchParams(location.search);
21
+ const queryValue = configuredLanding(query.get("landing") ?? query.get("workspace"));
22
+ if (queryValue)
23
+ return queryValue;
24
+ const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
25
+ return configuredLanding(new URLSearchParams(hash).get("workspace"));
26
+ }
27
+ export function resolveDefaultLanding(input) {
28
+ return workspaceFromLocation(input.location) ??
29
+ configuredLanding(input.storage?.getItem(DEFAULT_LANDING_STORAGE_KEY)) ??
30
+ configuredLanding(input.env) ??
31
+ "tasks";
32
+ }
33
+ export function buildWorkspaceHref(workspace, params = {}) {
34
+ const query = new URLSearchParams({ workspace });
35
+ for (const [key, value] of Object.entries(params)) {
36
+ if (value?.trim())
37
+ query.set(key, value.trim());
38
+ }
39
+ return `/?${query.toString()}`;
40
+ }
41
+ export function parseWorkspaceHref(location) {
42
+ const query = new URLSearchParams(location.search);
43
+ const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
44
+ const hashQuery = new URLSearchParams(hash);
45
+ const workspace = configuredWorkspace(query.get("workspace") ?? query.get("landing") ?? hashQuery.get("workspace"));
46
+ if (!workspace)
47
+ return null;
48
+ const read = (key) => query.get(key)?.trim() || undefined;
49
+ return {
50
+ workspace,
51
+ ...(read("taskId") ? { taskId: read("taskId") } : {}),
52
+ ...(read("dagRunId") ? { dagRunId: read("dagRunId") } : {}),
53
+ ...(read("fromNodeId") ? { fromNodeId: read("fromNodeId") } : {}),
54
+ ...(read("featureId") ? { featureId: read("featureId") } : {}),
55
+ };
56
+ }
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { readJsonIfExists, sha256Utf8, writeSecureJson, } from "./app-data.js";
4
+ import { confirmationPayloadHash, issueHumanGateToken, verifyHumanGateToken, } from "./human-gate-token.js";
4
5
  export const ALL_CONFIRMATION_CHALLENGES = [
5
6
  "source",
6
7
  "writer-scope",
@@ -88,8 +89,15 @@ function detectBindingDrift(rec, current) {
88
89
  }
89
90
  export class DagConfirmationStore {
90
91
  appData;
91
- constructor(appData) {
92
+ humanGateSecret;
93
+ constructor(appData,
94
+ /**
95
+ * M0-B: per-process HMAC secret for human-gate tokens
96
+ * (BootCapabilityToken.confirmationToken). Required for prepare/confirm.
97
+ */
98
+ humanGateSecret) {
92
99
  this.appData = appData;
100
+ this.humanGateSecret = humanGateSecret;
93
101
  }
94
102
  async get(confirmationId) {
95
103
  const rec = await readJsonIfExists(confirmationPath(this.appData, confirmationId));
@@ -114,15 +122,28 @@ export class DagConfirmationStore {
114
122
  const now = input.now ?? new Date();
115
123
  const ttl = input.ttlMs ?? DEFAULT_CONFIRMATION_TTL_MS;
116
124
  const confirmationId = newConfirmationId();
125
+ const dagSha256 = hashDagBytes(input.dagBytes);
126
+ const expiresAt = new Date(now.getTime() + ttl).toISOString();
127
+ // M0-B: issue a server-signed human-gate token bound to the confirmation's
128
+ // payloadHash. The browser must present it at confirm; dispatch verifies
129
+ // the signature so a model cannot self-confirm.
130
+ const payloadHash = confirmationPayloadHash({
131
+ dagSha256,
132
+ sourceBindingSha256: input.sourceBindingSha256,
133
+ taskContractBindingCanonical: JSON.stringify(input.taskContractBinding),
134
+ controllerFingerprint: input.controllerFingerprint,
135
+ validationResultSha256: input.validationResultSha256,
136
+ });
137
+ const humanGateToken = issueHumanGateToken({ confirmationId, payloadHash, expiresAt }, this.humanGateSecret, now);
117
138
  const rec = {
118
139
  schemaVersion: 1,
119
140
  confirmationId,
120
141
  state: "prepared",
121
142
  preparedAt: now.toISOString(),
122
- expiresAt: new Date(now.getTime() + ttl).toISOString(),
143
+ expiresAt,
123
144
  operatorSessionId: input.operatorSessionId,
124
145
  dagHandle: input.dagHandle,
125
- dagSha256: hashDagBytes(input.dagBytes),
146
+ dagSha256,
126
147
  dagBytesPath: input.dagBytesPath,
127
148
  sourceBindingSha256: input.sourceBindingSha256,
128
149
  taskContractBinding: input.taskContractBinding,
@@ -130,6 +151,7 @@ export class DagConfirmationStore {
130
151
  validationResultSha256: input.validationResultSha256,
131
152
  confirmedWriterScopes: input.confirmedWriterScopes ?? [],
132
153
  confirmedChallenges: [],
154
+ humanGateToken,
133
155
  reviewPacket: input.reviewPacket,
134
156
  };
135
157
  await writeSecureJson(confirmationPath(this.appData, confirmationId), rec);
@@ -195,14 +217,25 @@ export class DagConfirmationStore {
195
217
  confirmation: stale,
196
218
  };
197
219
  }
198
- const required = new Set(ALL_CONFIRMATION_CHALLENGES);
199
- for (const c of input.challenges)
200
- required.delete(c);
201
- if (required.size > 0) {
220
+ // M0-B / roadmap G03: verify the human-gate token's HMAC signature. The
221
+ // old fixed-string challenge set (`source`/`writer-scope`/...) was a fake
222
+ // gate — a model could fill those strings directly. Now the browser must
223
+ // present the server-signed token issued at prepare; the signature binds
224
+ // (confirmationId + payloadHash + expiresAt) and is verified with the
225
+ // per-process boot secret. A model-side call has no way to forge it.
226
+ const payloadHash = confirmationPayloadHash({
227
+ dagSha256: current.dagSha256,
228
+ sourceBindingSha256: current.sourceBindingSha256,
229
+ taskContractBindingCanonical: JSON.stringify(current.taskContractBinding),
230
+ controllerFingerprint: current.controllerFingerprint,
231
+ validationResultSha256: current.validationResultSha256,
232
+ });
233
+ const tokenCheck = verifyHumanGateToken(input.humanGateToken, { confirmationId: input.confirmationId, payloadHash }, this.humanGateSecret, input.now);
234
+ if (!tokenCheck.ok) {
202
235
  return {
203
236
  ok: false,
204
237
  code: "HUMAN_CONFIRMATION_REQUIRED",
205
- message: `missing confirmation challenges: ${[...required].join(", ")}`,
238
+ message: `human-gate token verification failed: ${tokenCheck.code} — ${tokenCheck.message}`,
206
239
  confirmation: rec,
207
240
  };
208
241
  }
@@ -212,6 +245,7 @@ export class DagConfirmationStore {
212
245
  state: "confirmed",
213
246
  confirmedAt: now.toISOString(),
214
247
  confirmedChallenges: [...ALL_CONFIRMATION_CHALLENGES],
248
+ humanGateToken: input.humanGateToken ?? rec.humanGateToken,
215
249
  };
216
250
  await writeSecureJson(confirmationPath(this.appData, rec.confirmationId), confirmed);
217
251
  return { ok: true, confirmation: confirmed };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Operator Chat — Human Gate confirmation token (roadmap M0-B / G03).
3
+ *
4
+ * Closes the "fake human gate" gap (roadmap §3.2 risk 1): the old
5
+ * confirmDagConfirmation only checked that a FIXED set of challenge strings
6
+ * (`source`/`writer-scope`/`verification`/`human-gates`) was present — a model
7
+ * could fill those in directly and self-confirm. This module issues a
8
+ * server-signed token bound to (confirmationId, payloadHash, expiresAt) and
9
+ * verifies its HMAC signature at dispatch time, so confirmation can only
10
+ * originate from the browser mutation gate that received the signed token.
11
+ *
12
+ * The HMAC key is the per-process boot confirmation secret
13
+ * (BootCapabilityToken.confirmationToken). This binds the human-gate token to
14
+ * the same Console boot session that the browser established via the HttpOnly
15
+ * cookie — a model-side tool call cannot fabricate it.
16
+ */
17
+ import { createHmac, timingSafeEqual } from "node:crypto";
18
+ /** Canonical message string covered by the signature. */
19
+ export function canonicalTokenMessage(p) {
20
+ return [
21
+ `v1`,
22
+ `confirmationId=${p.confirmationId}`,
23
+ `payloadHash=${p.payloadHash}`,
24
+ `expiresAt=${p.expiresAt}`,
25
+ ].join("\n");
26
+ }
27
+ function b64url(buf) {
28
+ return buf.toString("base64url");
29
+ }
30
+ /** Issue a signed token. `secret` = BootCapabilityToken.confirmationToken. */
31
+ export function issueHumanGateToken(payload, secret, now = new Date()) {
32
+ const sig = createHmac("sha256", secret).update(canonicalTokenMessage(payload)).digest();
33
+ return {
34
+ ...payload,
35
+ signature: b64url(sig),
36
+ issuedAt: now.toISOString(),
37
+ };
38
+ }
39
+ /**
40
+ * Verify a presented token. Checks signature, expiry, and that the token is
41
+ * bound to the given confirmationId + current payloadHash. Constant-time
42
+ * signature compare. Returns a typed failure (no thrown secrets).
43
+ */
44
+ export function verifyHumanGateToken(presented, expected, secret, now = new Date()) {
45
+ if (!presented || typeof presented !== "object") {
46
+ return { ok: false, code: "MISSING_TOKEN", message: "no human-gate token presented" };
47
+ }
48
+ if (presented.confirmationId !== expected.confirmationId) {
49
+ return {
50
+ ok: false,
51
+ code: "CONFIRMATION_MISMATCH",
52
+ message: "token is bound to a different confirmationId",
53
+ };
54
+ }
55
+ if (presented.payloadHash !== expected.payloadHash) {
56
+ return {
57
+ ok: false,
58
+ code: "PAYLOAD_MISMATCH",
59
+ message: "token payloadHash does not match the current confirmation bindings",
60
+ };
61
+ }
62
+ const expectedSig = createHmac("sha256", secret)
63
+ .update(canonicalTokenMessage(presented))
64
+ .digest();
65
+ let presentedSig;
66
+ try {
67
+ presentedSig = Buffer.from(presented.signature, "base64url");
68
+ }
69
+ catch {
70
+ return { ok: false, code: "INVALID_SIGNATURE", message: "signature is not valid base64url" };
71
+ }
72
+ if (presentedSig.length !== expectedSig.length ||
73
+ !timingSafeEqual(presentedSig, expectedSig)) {
74
+ return { ok: false, code: "INVALID_SIGNATURE", message: "signature verification failed" };
75
+ }
76
+ if (Date.parse(presented.expiresAt) <= now.getTime()) {
77
+ return { ok: false, code: "EXPIRED", message: "token has expired" };
78
+ }
79
+ return { ok: true, token: presented };
80
+ }
81
+ /**
82
+ * Compute the payloadHash for a confirmation receipt: SHA-256 over the binding
83
+ * snapshot (dagSha256 + sourceBindingSha256 + taskContractBinding canonical +
84
+ * controllerFingerprint + validationResultSha256). Changing any binding
85
+ * invalidates the token (defense against drift-then-confirm).
86
+ */
87
+ import { createHash } from "node:crypto";
88
+ import { sha256Utf8 } from "./app-data.js";
89
+ export function confirmationPayloadHash(b) {
90
+ const canonical = [
91
+ `dag=${b.dagSha256}`,
92
+ `source=${b.sourceBindingSha256}`,
93
+ `contract=${sha256Utf8(b.taskContractBindingCanonical)}`,
94
+ `controller=${b.controllerFingerprint}`,
95
+ `validation=${b.validationResultSha256}`,
96
+ ].join("\n");
97
+ return createHash("sha256").update(canonical).digest("hex");
98
+ }
99
+ /** Stable contract-apply binding; revision and observed hash prevent drift-then-confirm. */
100
+ export function contractApplyPayloadHash(b) {
101
+ const canonical = [
102
+ "v1-contract-apply",
103
+ `taskId=${b.taskId}`,
104
+ `revision=${b.revision}`,
105
+ `canonicalHash=${b.canonicalHash}`,
106
+ `draftSha256=${b.draftSha256}`,
107
+ `expectedObservedHash=${b.expectedObservedHash}`,
108
+ ].join("\n");
109
+ return createHash("sha256").update(canonical).digest("hex");
110
+ }
111
+ /** Stable mutation-gate binding; params are canonical-JSON with sorted keys. */
112
+ export function mutationGatePayloadHash(b) {
113
+ const paramsCanonical = stableJson(b.actionParams);
114
+ const canonical = [
115
+ "v1-mutation-gate",
116
+ `action=${b.action}`,
117
+ `operatorSessionId=${b.operatorSessionId}`,
118
+ `actionParams=${paramsCanonical}`,
119
+ ].join("\n");
120
+ return createHash("sha256").update(canonical).digest("hex");
121
+ }
122
+ function stableJson(value) {
123
+ if (value === null || typeof value !== "object")
124
+ return JSON.stringify(value);
125
+ if (Array.isArray(value))
126
+ return `[${value.map((entry) => stableJson(entry)).join(",")}]`;
127
+ const obj = value;
128
+ const keys = Object.keys(obj).sort();
129
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(obj[key])}`).join(",")}}`;
130
+ }