zigmini 1.0.0-beta.3 → 1.1.0-beta.2

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 (138) hide show
  1. package/README.ko.md +29 -2
  2. package/README.md +29 -2
  3. package/assets/agent/gateway-request-rules.md +9 -2
  4. package/assets/agent/gateway-rules.md +13 -6
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/assets/document-reader/document-reader +0 -0
  9. package/assets/document-reader/host-selector +0 -0
  10. package/assets/document-reader/manifest.json +27 -0
  11. package/docs/manual.ko.md +114 -10
  12. package/docs/manual.md +117 -11
  13. package/host/document-reader/DocumentRead.swift +370 -0
  14. package/host/document-reader/HostSelector.swift +192 -0
  15. package/host/document-reader/Protocol.swift +647 -0
  16. package/host/document-reader/main.swift +22 -0
  17. package/host/omo-bridge/agent-aside-tool.ts +119 -0
  18. package/host/omo-bridge/agent-aside.ts +522 -0
  19. package/host/omo-bridge/agent-collaboration-files.ts +282 -0
  20. package/host/omo-bridge/agent-collaboration.ts +385 -0
  21. package/host/omo-bridge/agent-control.ts +400 -28
  22. package/host/omo-bridge/agent-execution.sb +42 -0
  23. package/host/omo-bridge/agent-execution.ts +158 -0
  24. package/host/omo-bridge/agent-tool-adapters.ts +388 -0
  25. package/host/omo-bridge/agent-tool-permissions.ts +96 -0
  26. package/host/omo-bridge/aside-tabs.ts +41 -0
  27. package/host/omo-bridge/attachment-read.ts +100 -0
  28. package/host/omo-bridge/bridge.ts +10 -0
  29. package/host/omo-bridge/managed-call.ts +46 -0
  30. package/host/omo-bridge/task-control.ts +39 -5
  31. package/host/omo-check/patches/attachments-293/README.md +138 -0
  32. package/host/omo-check/patches/attachments-293/document-content.js +234 -0
  33. package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
  34. package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
  35. package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
  36. package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
  37. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
  38. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  39. package/package.json +5 -3
  40. package/src/agent/api.ts +345 -20
  41. package/src/agent/artifacts.ts +196 -0
  42. package/src/agent/aside-target-routes.ts +369 -0
  43. package/src/agent/aside-targets.ts +215 -0
  44. package/src/agent/collaboration-admission.ts +47 -0
  45. package/src/agent/collaboration-read.ts +117 -0
  46. package/src/agent/collaboration-session.ts +196 -0
  47. package/src/agent/collaboration-types.ts +258 -0
  48. package/src/agent/collaboration.ts +399 -0
  49. package/src/agent/definition.ts +65 -3
  50. package/src/agent/fields.ts +9 -3
  51. package/src/agent/file-core.ts +325 -0
  52. package/src/agent/files.ts +87 -8
  53. package/src/agent/list.ts +3 -3
  54. package/src/agent/managed-call.ts +144 -0
  55. package/src/agent/tool-catalog.ts +184 -0
  56. package/src/agent/tool-delivery.ts +359 -0
  57. package/src/agent/tool-permission-types.ts +81 -0
  58. package/src/agent/tool-permissions.ts +1366 -0
  59. package/src/attachments/api.ts +161 -0
  60. package/src/attachments/document-reader.ts +325 -0
  61. package/src/attachments/host-selector.ts +410 -0
  62. package/src/attachments/managed-binding.ts +83 -0
  63. package/src/attachments/prepare.ts +214 -0
  64. package/src/attachments/selector-port.ts +28 -0
  65. package/src/attachments/service.ts +729 -0
  66. package/src/attachments/store.ts +698 -0
  67. package/src/attachments/types.ts +123 -0
  68. package/src/cli/doctor.ts +30 -10
  69. package/src/decisions.ts +109 -10
  70. package/src/engine/apply-patch.ts +21 -0
  71. package/src/engine/compat.json +2 -1
  72. package/src/engine/install.ts +45 -4
  73. package/src/engine/sandbox-cell.ts +2 -0
  74. package/src/gc.ts +33 -225
  75. package/src/housekeeping/api.ts +163 -0
  76. package/src/housekeeping/audit.ts +61 -0
  77. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  78. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  79. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  80. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  81. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  82. package/src/housekeeping/collectors/db-backup.ts +25 -0
  83. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  84. package/src/housekeeping/collectors/docker-image.ts +135 -0
  85. package/src/housekeeping/collectors/omo-version.ts +32 -0
  86. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  87. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  88. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  89. package/src/housekeeping/collectors/shared.ts +149 -0
  90. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  91. package/src/housekeeping/collectors/update-file.ts +25 -0
  92. package/src/housekeeping/collectors/worktree.ts +193 -0
  93. package/src/housekeeping/engine.ts +125 -0
  94. package/src/housekeeping/reconcile/work.ts +130 -0
  95. package/src/housekeeping/registry.ts +63 -0
  96. package/src/housekeeping/trash.ts +59 -0
  97. package/src/housekeeping/types.ts +31 -0
  98. package/src/hub.ts +1095 -41
  99. package/src/main.ts +228 -30
  100. package/src/message-references.ts +14 -5
  101. package/src/profile-prompt.ts +13 -1
  102. package/src/protocol.ts +41 -6
  103. package/src/security-allow.ts +79 -2
  104. package/src/security-check.ts +1 -1
  105. package/src/store.ts +1946 -23
  106. package/src/test-crash.ts +20 -0
  107. package/src/work-watch.ts +55 -7
  108. package/src/work.ts +192 -3
  109. package/web/DESIGN.md +33 -0
  110. package/web/agents.d.ts +189 -12
  111. package/web/agents.js +898 -39
  112. package/web/app.js +2634 -210
  113. package/web/attachments.d.ts +91 -0
  114. package/web/attachments.js +422 -0
  115. package/web/collaboration.d.ts +87 -0
  116. package/web/collaboration.js +290 -0
  117. package/web/decisions.d.ts +39 -2
  118. package/web/decisions.js +354 -2
  119. package/web/gc.d.ts +26 -5
  120. package/web/gc.js +113 -13
  121. package/web/i18n/agents.d.ts +4 -0
  122. package/web/i18n/agents.js +200 -8
  123. package/web/i18n/attachments.js +78 -0
  124. package/web/i18n/decisions.d.ts +4 -0
  125. package/web/i18n/decisions.js +68 -0
  126. package/web/i18n/gc.d.ts +4 -0
  127. package/web/i18n/gc.js +184 -2
  128. package/web/i18n/main.js +80 -8
  129. package/web/i18n/tour.d.ts +4 -0
  130. package/web/i18n/tour.js +37 -0
  131. package/web/index.html +20 -3
  132. package/web/profile.d.ts +2 -2
  133. package/web/profile.js +11 -6
  134. package/web/style.css +342 -21
  135. package/web/tooltip.d.ts +2 -0
  136. package/web/tooltip.js +12 -0
  137. package/web/tour.d.ts +57 -0
  138. package/web/tour.js +450 -0
@@ -0,0 +1,1366 @@
1
+ /**
2
+ * 294 persistent agent tool permission ledger (plan C2/C3/C5/C10) and the owner caller helper (C3).
3
+ *
4
+ * The ledger lives in the existing Store connection. Grant (request -> granted|denied|cancelled), execution
5
+ * (ready -> reserved -> started -> succeeded|failed|outcome_unknown, ready/reserved -> cancelled) and resume
6
+ * (queued -> received -> continued) are separate rows with separate states. Only an owner answer that carries the
7
+ * server's proposal digest and a verified OwnerPrincipal creates a grant; the answer, the grant, the policy revision
8
+ * and the execution/resume intent are written by one Store transaction (Store.answerAgentToolDecision).
9
+ */
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import type { Database } from "bun:sqlite";
12
+ import type { DeviceRecord } from "../store.ts";
13
+ import type { AgentAccessView, ToolKey } from "./tool-permission-types.ts";
14
+ import type { LegacyScopeItem } from "./definition.ts";
15
+
16
+ export type { AgentAccessView } from "./tool-permission-types.ts";
17
+
18
+ export const TOOL_PURPOSES = ["agent_tool_grant", "agent_tool_action", "agent_tool_migration"] as const;
19
+ export type DecisionPurpose = "ordinary" | "native_permission" | (typeof TOOL_PURPOSES)[number];
20
+
21
+ /** Verified owner of a permission answer or edit. Built only at the caller boundary (AgentToolClients.principal). */
22
+ export interface OwnerPrincipal {
23
+ readonly ownerRef: "owner";
24
+ readonly client: "web" | "android";
25
+ readonly credentialRevision: string;
26
+ readonly sessionRef: string;
27
+ }
28
+
29
+ /** Structured answer for the tool purposes. Free text, confirm true or value never become a grant. */
30
+ export interface AgentToolGrantAnswer {
31
+ readonly expectedRevision: number;
32
+ readonly submissionId: string;
33
+ readonly mode: "answer";
34
+ readonly response: { readonly toolGrant: { readonly choice: "allow" | "deny"; readonly proposalDigest: string } } | { readonly cancelled: true }
35
+ | { readonly toolMigration: { readonly proposalDigest: string; readonly items: Readonly<Record<string, unknown>> } };
36
+ }
37
+
38
+ /** The migration card of one agent and lifecycle (purpose agent_tool_migration, plan C9). */
39
+ export interface AgentToolMigrationCard {
40
+ readonly decisionId: string;
41
+ readonly agentId: string;
42
+ readonly incarnation: number;
43
+ readonly proposalDigest: string;
44
+ readonly items: readonly LegacyScopeItem[];
45
+ readonly state: "pending" | "confirmed" | "declined" | "applied";
46
+ }
47
+
48
+ /** Same shape as 292 CollaborationPermissionOutcome (292 contract N6). */
49
+ export interface CollaborationPermissionOutcome {
50
+ readonly decisionId: string;
51
+ readonly decisionRevision: number;
52
+ readonly outcome: "allowed" | "denied" | "cancelled";
53
+ readonly logicalOperationId: string;
54
+ readonly continuationId: string;
55
+ readonly collaborationRequestId: string;
56
+ readonly blockedActivationId: string;
57
+ readonly originInputSeq: number;
58
+ readonly lifecycleEpoch: number;
59
+ readonly authorityEpoch: number;
60
+ readonly policyRevision: number;
61
+ }
62
+
63
+ export interface AgentToolRequestInput {
64
+ readonly agentId: string;
65
+ readonly convId: string;
66
+ readonly originInputSeq: number;
67
+ readonly originKind: "user" | "agent_collaboration";
68
+ readonly originAgentId?: string | null;
69
+ readonly collaborationRequestId?: string | null;
70
+ readonly activationId?: string | null;
71
+ readonly durableSessionId: string;
72
+ readonly logicalCallId: string;
73
+ readonly sourceToolCallId: string;
74
+ readonly toolKey: ToolKey;
75
+ readonly operation: string;
76
+ readonly args: unknown;
77
+ readonly scope: unknown;
78
+ }
79
+
80
+ export interface AgentToolRequestRecord {
81
+ readonly id: string;
82
+ readonly agentId: string;
83
+ readonly incarnation: number;
84
+ readonly convId: string;
85
+ readonly decisionId: string;
86
+ readonly proposalDigest: string;
87
+ readonly argsDigest: string;
88
+ readonly state: "pending" | "granted" | "denied" | "cancelled" | "superseded";
89
+ readonly revision: number;
90
+ }
91
+
92
+ export type InvocationState = "ready" | "reserved" | "started" | "succeeded" | "failed" | "outcome_unknown" | "cancelled";
93
+
94
+ export interface AgentToolInvocation {
95
+ readonly id: string;
96
+ readonly requestId: string | null;
97
+ readonly agentId: string;
98
+ readonly incarnation: number;
99
+ readonly grantId: string | null;
100
+ readonly grantRevision: number | null;
101
+ readonly authorityEpoch: number;
102
+ readonly policyRevision: number;
103
+ readonly executorGeneration: number;
104
+ readonly state: InvocationState;
105
+ readonly revision: number;
106
+ readonly resultRef: string | null;
107
+ }
108
+
109
+ export interface AgentToolGrantRow {
110
+ readonly id: string;
111
+ readonly agentId: string;
112
+ readonly incarnation: number;
113
+ readonly authorityEpoch: number;
114
+ readonly toolKey: ToolKey;
115
+ readonly operations: readonly string[];
116
+ readonly scope: unknown;
117
+ readonly originDecisionId: string;
118
+ readonly state: "active" | "revoked" | "suspended";
119
+ readonly revision: number;
120
+ /** The exact resources the owner approved (scope.resources of the request). null: a legacy grant with no stored resources. */
121
+ readonly resources: readonly string[] | null;
122
+ }
123
+
124
+ export type ToolPermissionErrorCode =
125
+ | "tool_request_invalid" | "tool_request_not_found" | "tool_request_conflict" | "args_conflict" | "proposal_digest_mismatch"
126
+ | "lifecycle_epoch_mismatch" | "authority_epoch_mismatch" | "agent_access_blocked" | "grant_not_found" | "grant_conflict"
127
+ | "policy_conflict" | "invocation_not_found" | "invocation_conflict" | "operation_conflict" | "cursor_invalid" | "cursor_stale";
128
+
129
+ const STATUS: Record<ToolPermissionErrorCode, number> = {
130
+ tool_request_invalid: 400, cursor_invalid: 400, tool_request_not_found: 404, grant_not_found: 404, invocation_not_found: 404,
131
+ tool_request_conflict: 409, args_conflict: 409, proposal_digest_mismatch: 409, lifecycle_epoch_mismatch: 409,
132
+ authority_epoch_mismatch: 409, grant_conflict: 409, policy_conflict: 409, invocation_conflict: 409, operation_conflict: 409,
133
+ cursor_stale: 409, agent_access_blocked: 403,
134
+ };
135
+
136
+ export class AgentToolPermissionError extends Error {
137
+ readonly status: number;
138
+ constructor(readonly code: ToolPermissionErrorCode, status: number = STATUS[code]) {
139
+ super(code);
140
+ this.status = status;
141
+ }
142
+ }
143
+
144
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
145
+ const PAGE = 50;
146
+
147
+ function record(value: unknown): value is Record<string, unknown> {
148
+ return typeof value === "object" && value !== null && !Array.isArray(value);
149
+ }
150
+
151
+ /** Key-sorted JSON, so the same structure always has the same digest. */
152
+ export function canonicalJson(value: unknown): string {
153
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
154
+ if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
155
+ return JSON.stringify(value ?? null);
156
+ }
157
+
158
+ const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");
159
+
160
+ /**
161
+ * The resources a request asks the owner to approve: `scope.resources`, sorted and de-duplicated. The proposal digest
162
+ * already binds the scope, so the stored list is exactly what the owner saw. null when the scope names no resources;
163
+ * a malformed list is invalid (never silently dropped, which would store a grant without its limits).
164
+ */
165
+ export function approvedResources(scope: unknown): string[] | null {
166
+ if (!record(scope) || scope.resources === undefined) return null;
167
+ const list = scope.resources;
168
+ if (!Array.isArray(list) || list.length === 0 ||
169
+ !list.every((entry) => typeof entry === "string" && entry.length > 0 && !entry.includes("\0"))) throw new AgentToolPermissionError("tool_request_invalid");
170
+ return [...new Set(list as string[])].sort();
171
+ }
172
+
173
+ const resourcesJson = (scope: unknown): string | null => {
174
+ const resources = approvedResources(scope);
175
+ return resources === null ? null : JSON.stringify(resources);
176
+ };
177
+
178
+ /** Scope keys that name the target itself (opaque handles). An edit changes limits, never the target. */
179
+ const OPAQUE_SCOPE_KEYS = ["kind", "selection", "targetHandle", "targetId", "targetLabel", "targetTitle", "account", "host"] as const;
180
+
181
+ /** POST .../tool-permissions/:grantId/edit body: the complete post-edit operations and scope. */
182
+ export interface GrantEditInput {
183
+ readonly operationId: string;
184
+ readonly expectedRevision: number;
185
+ readonly operations: readonly string[];
186
+ readonly scope: Readonly<Record<string, unknown>>;
187
+ }
188
+
189
+ export type GrantEditResult =
190
+ | { readonly status: 200; readonly body: { grant: AgentToolGrantRow; policyRevision: number; cancelled: string[]; running: string[] } }
191
+ | { readonly status: 202; readonly body: { decisionId: string; proposalDigest: string; oldGrant: AgentToolGrantRow } };
192
+
193
+ /** Data of the agent.tool_permissions.updated event (C10). */
194
+ export interface PolicyUpdatedData {
195
+ readonly agentId: string;
196
+ readonly policyRevision: number;
197
+ readonly authorityEpoch: number;
198
+ readonly accessState: "open" | "blocked";
199
+ }
200
+
201
+ export function parseGrantEdit(value: unknown): GrantEditInput {
202
+ const keys = ["operationId", "expectedRevision", "operations", "scope"];
203
+ if (!record(value) || Object.keys(value).length !== keys.length || !keys.every((key) => Object.hasOwn(value, key))) throw new AgentToolPermissionError("tool_request_invalid");
204
+ const { operationId, expectedRevision, operations, scope } = value;
205
+ if (typeof operationId !== "string" || !UUID.test(operationId) || typeof expectedRevision !== "number" || !Number.isSafeInteger(expectedRevision) ||
206
+ expectedRevision < 1 || !Array.isArray(operations) || operations.length === 0 || !record(scope) ||
207
+ !operations.every((operation) => typeof operation === "string" && operation.length > 0 && !operation.includes("\0"))) {
208
+ throw new AgentToolPermissionError("tool_request_invalid");
209
+ }
210
+ return { operationId, expectedRevision, operations: [...new Set(operations as string[])].sort(), scope };
211
+ }
212
+
213
+ const isStringList = (value: unknown): value is string[] => Array.isArray(value) && value.every((entry) => typeof entry === "string");
214
+
215
+ /**
216
+ * The server's own narrow/widen decision (C8). Widening: an added operation or list value, a dropped scope key or a
217
+ * changed single value. Narrowing: a removed operation or list value, or a new restriction key. A mixed edit widens.
218
+ */
219
+ export function classifyGrantEdit(before: { readonly operations: readonly string[]; readonly scope: unknown },
220
+ after: { readonly operations: readonly string[]; readonly scope: unknown }): "widen" | "narrow" | "same" {
221
+ if (after.operations.some((operation) => !before.operations.includes(operation))) return "widen";
222
+ const was = record(before.scope) ? before.scope : {};
223
+ const now = record(after.scope) ? after.scope : {};
224
+ let narrowed = before.operations.some((operation) => !after.operations.includes(operation));
225
+ for (const [key, old] of Object.entries(was)) {
226
+ if (!Object.hasOwn(now, key)) return "widen";
227
+ const value = now[key];
228
+ if (isStringList(old) && isStringList(value)) {
229
+ if (value.some((entry) => !old.includes(entry))) return "widen";
230
+ if (old.some((entry) => !value.includes(entry))) narrowed = true;
231
+ } else if (canonicalJson(old) !== canonicalJson(value)) return "widen";
232
+ }
233
+ if (Object.keys(now).some((key) => !Object.hasOwn(was, key))) narrowed = true;
234
+ return narrowed ? "narrow" : "same";
235
+ }
236
+
237
+ const policyUpdatedData = (view: AgentAccessView): PolicyUpdatedData =>
238
+ ({ agentId: view.agentId, policyRevision: view.policyRevision, authorityEpoch: view.authorityEpoch, accessState: view.accessState });
239
+
240
+ /**
241
+ * 292 owns agent_lifecycle.epoch. Until 292 is on dev the table does not exist; then every agent is in its first
242
+ * lifecycle (292 starts each agent at 1 with INSERT OR IGNORE ... 1), so 1 is the same value 292 will write.
243
+ */
244
+ export function lifecycleEpoch(tx: Database, agentId: string): number {
245
+ const table = tx.query<{ found: number }, []>("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'agent_lifecycle'").get();
246
+ if (!table) return 1;
247
+ return tx.query<{ epoch: number }, [string]>("SELECT epoch FROM agent_lifecycle WHERE agent_id = ?").get(agentId)?.epoch ?? 1;
248
+ }
249
+
250
+ /** 292/293 read this inside their own Store writer transaction, just before a release. Read only. */
251
+ export function access(tx: Database, agentId: string): AgentAccessView {
252
+ const row = tx.query<{ policy_revision: number; authority_epoch: number; access_state: "open" | "blocked" }, [string]>(
253
+ "SELECT policy_revision, authority_epoch, access_state FROM agent_permission_epochs WHERE agent_id = ?",
254
+ ).get(agentId);
255
+ return {
256
+ agentId, lifecycleEpoch: lifecycleEpoch(tx, agentId),
257
+ accessState: row?.access_state ?? "open", authorityEpoch: row?.authority_epoch ?? 1, policyRevision: row?.policy_revision ?? 0,
258
+ };
259
+ }
260
+
261
+ /** The stored owner outcome of a collaboration permission decision, exactly as committed with the answer; else null. */
262
+ export function permissionOutcome(tx: Database, decisionId: string): CollaborationPermissionOutcome | null {
263
+ const row = tx.query<{ data: string }, [string]>(
264
+ "SELECT data FROM agent_tool_outcomes WHERE decision_id = ? AND json_extract(data, '$.collaborationRequestId') IS NOT NULL",
265
+ ).get(decisionId);
266
+ return row ? JSON.parse(row.data) as CollaborationPermissionOutcome : null;
267
+ }
268
+
269
+ /** Parses the tool grant answer. Returns null when the body is not a tool grant shape (the caller decides the error). */
270
+ export function parseToolGrantAnswer(value: unknown): AgentToolGrantAnswer | null {
271
+ if (!record(value) || value.mode !== "answer" || !record(value.response) ||
272
+ Object.keys(value).some((key) => !["expectedRevision", "submissionId", "mode", "response"].includes(key))) return null;
273
+ if (typeof value.expectedRevision !== "number" || !Number.isSafeInteger(value.expectedRevision) || value.expectedRevision < 1 ||
274
+ typeof value.submissionId !== "string" || !UUID.test(value.submissionId)) return null;
275
+ const common = { expectedRevision: value.expectedRevision, submissionId: value.submissionId, mode: "answer" as const };
276
+ const response = value.response;
277
+ if (response.cancelled === true && Object.keys(response).length === 1) return { ...common, response: { cancelled: true } };
278
+ const migration = response.toolMigration;
279
+ if (Object.keys(response).length === 1 && record(migration) && Object.keys(migration).length === 2 && record(migration.items) &&
280
+ typeof migration.proposalDigest === "string" && /^[0-9a-f]{64}$/.test(migration.proposalDigest)) {
281
+ return { ...common, response: { toolMigration: { proposalDigest: migration.proposalDigest, items: { ...migration.items } } } };
282
+ }
283
+ const grant = response.toolGrant;
284
+ if (Object.keys(response).length !== 1 || !record(grant) || Object.keys(grant).length !== 2 ||
285
+ (grant.choice !== "allow" && grant.choice !== "deny") || typeof grant.proposalDigest !== "string" ||
286
+ !/^[0-9a-f]{64}$/.test(grant.proposalDigest)) return null;
287
+ return { ...common, response: { toolGrant: { choice: grant.choice, proposalDigest: grant.proposalDigest } } };
288
+ }
289
+
290
+ type RequestRow = {
291
+ id: string; agent_id: string; incarnation: number; conv_id: string; origin_input_seq: number; origin_kind: string;
292
+ collaboration_request_id: string | null; activation_id: string | null; durable_session_id: string; tool_key_json: string;
293
+ operation: string; args_json: string; args_digest: string; scope_json: string; proposal_digest: string; decision_id: string;
294
+ state: AgentToolRequestRecord["state"]; revision: number;
295
+ };
296
+ type InvocationRow = {
297
+ id: string; request_id: string | null; agent_id: string; incarnation: number; grant_id: string | null; grant_revision: number | null;
298
+ authority_epoch: number; policy_revision: number; executor_generation: number; state: InvocationState; revision: number; result_ref: string | null;
299
+ };
300
+ type EditRow = {
301
+ decision_id: string; grant_id: string; agent_id: string; base_revision: number; operations_json: string; scope_json: string;
302
+ proposal_digest: string; state: "pending" | "allowed" | "denied" | "cancelled" | "superseded";
303
+ };
304
+ type GrantRow = {
305
+ id: string; agent_id: string; incarnation: number; authority_epoch: number; tool_key_json: string; operations_json: string;
306
+ scope_json: string; origin_decision_id: string; state: AgentToolGrantRow["state"]; revision: number; resources_json: string | null;
307
+ };
308
+
309
+ const requestRecord = (row: RequestRow): AgentToolRequestRecord => ({
310
+ id: row.id, agentId: row.agent_id, incarnation: row.incarnation, convId: row.conv_id, decisionId: row.decision_id,
311
+ proposalDigest: row.proposal_digest, argsDigest: row.args_digest, state: row.state, revision: row.revision,
312
+ });
313
+ const invocationRecord = (row: InvocationRow): AgentToolInvocation => ({
314
+ id: row.id, requestId: row.request_id, agentId: row.agent_id, incarnation: row.incarnation, grantId: row.grant_id,
315
+ grantRevision: row.grant_revision, authorityEpoch: row.authority_epoch, policyRevision: row.policy_revision,
316
+ executorGeneration: row.executor_generation, state: row.state, revision: row.revision, resultRef: row.result_ref,
317
+ });
318
+ const grantRecord = (row: GrantRow): AgentToolGrantRow => ({
319
+ id: row.id, agentId: row.agent_id, incarnation: row.incarnation, authorityEpoch: row.authority_epoch,
320
+ toolKey: JSON.parse(row.tool_key_json), operations: JSON.parse(row.operations_json), scope: JSON.parse(row.scope_json),
321
+ originDecisionId: row.origin_decision_id, state: row.state, revision: row.revision,
322
+ resources: row.resources_json === null ? null : JSON.parse(row.resources_json),
323
+ });
324
+
325
+ export interface AnswerResult {
326
+ readonly outcome: "allowed" | "denied" | "cancelled";
327
+ readonly grantId: string | null;
328
+ readonly invocationId: string | null;
329
+ }
330
+
331
+ /**
332
+ * Ledger rows on the Store's own connection. Every method here runs inside a transaction its caller (Store) owns or
333
+ * opens its own; none opens a second connection or file.
334
+ */
335
+ export class AgentToolPermissionStore {
336
+ /**
337
+ * Executor generations that reserved through this ledger instance, i.e. inside this gateway process. The Store is the
338
+ * database's only writer, so a reservation of any other generation was left by a gateway process that has ended (C5).
339
+ */
340
+ private readonly liveGenerations = new Set<number>();
341
+ constructor(private readonly db: Database) {}
342
+
343
+ migrate(): void {
344
+ this.db.transaction(() => {
345
+ // Purpose is a side table, not a decisions column: existing positional INSERTs and table rebuilds keep working.
346
+ // A decision without a row here follows its permission boolean (ordinary or native_permission).
347
+ this.db.exec(`
348
+ CREATE TABLE IF NOT EXISTS decision_purposes (
349
+ decision_id TEXT PRIMARY KEY,
350
+ purpose TEXT NOT NULL CHECK(purpose IN ('agent_tool_grant','agent_tool_action','agent_tool_migration'))
351
+ );
352
+ CREATE TABLE IF NOT EXISTS agent_permission_epochs (
353
+ agent_id TEXT PRIMARY KEY, policy_revision INTEGER NOT NULL DEFAULT 0, authority_epoch INTEGER NOT NULL DEFAULT 1,
354
+ access_state TEXT NOT NULL DEFAULT 'open' CHECK(access_state IN ('open','blocked')),
355
+ mode TEXT NOT NULL DEFAULT 'legacy' CHECK(mode IN ('legacy','managed'))
356
+ );
357
+ CREATE TABLE IF NOT EXISTS agent_tool_grants (
358
+ id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, incarnation INTEGER NOT NULL, authority_epoch INTEGER NOT NULL,
359
+ tool_key_json TEXT NOT NULL, operations_json TEXT NOT NULL, scope_json TEXT NOT NULL,
360
+ origin_decision_id TEXT NOT NULL, origin_submission_id TEXT NOT NULL,
361
+ state TEXT NOT NULL CHECK(state IN ('active','revoked','suspended')), revision INTEGER NOT NULL CHECK(revision > 0),
362
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, reason TEXT
363
+ );
364
+ CREATE INDEX IF NOT EXISTS agent_tool_grants_agent ON agent_tool_grants(agent_id, state);
365
+ CREATE TABLE IF NOT EXISTS agent_tool_requests (
366
+ id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, incarnation INTEGER NOT NULL, conv_id TEXT NOT NULL,
367
+ origin_input_seq INTEGER NOT NULL, origin_kind TEXT NOT NULL CHECK(origin_kind IN ('user','agent_collaboration')),
368
+ origin_agent_id TEXT, collaboration_request_id TEXT, activation_id TEXT,
369
+ durable_session_id TEXT NOT NULL, logical_call_id TEXT NOT NULL, source_tool_call_id TEXT NOT NULL,
370
+ tool_key_json TEXT NOT NULL, operation TEXT NOT NULL, args_json TEXT NOT NULL, args_digest TEXT NOT NULL,
371
+ scope_json TEXT NOT NULL, proposal_digest TEXT NOT NULL, decision_id TEXT NOT NULL UNIQUE,
372
+ state TEXT NOT NULL CHECK(state IN ('pending','granted','denied','cancelled','superseded')),
373
+ revision INTEGER NOT NULL CHECK(revision > 0),
374
+ UNIQUE(agent_id, incarnation, conv_id, origin_input_seq, logical_call_id)
375
+ );
376
+ CREATE TABLE IF NOT EXISTS agent_tool_invocations (
377
+ id TEXT PRIMARY KEY, request_id TEXT, agent_id TEXT NOT NULL, incarnation INTEGER NOT NULL,
378
+ logical_operation_key TEXT NOT NULL UNIQUE, tool_key_json TEXT NOT NULL, args_json TEXT NOT NULL, args_digest TEXT NOT NULL,
379
+ target_identity_json TEXT NOT NULL, authority_source TEXT NOT NULL, authority_ref TEXT NOT NULL, resource_revision INTEGER,
380
+ grant_id TEXT, grant_revision INTEGER, policy_revision INTEGER NOT NULL, authority_epoch INTEGER NOT NULL,
381
+ action_ticket_id TEXT UNIQUE, executor_generation INTEGER NOT NULL DEFAULT 0,
382
+ state TEXT NOT NULL CHECK(state IN ('ready','reserved','started','succeeded','failed','outcome_unknown','cancelled')),
383
+ revision INTEGER NOT NULL CHECK(revision > 0), result_ref TEXT, started_at TEXT, settled_at TEXT
384
+ );
385
+ CREATE TABLE IF NOT EXISTS agent_tool_continuations (
386
+ id TEXT PRIMARY KEY, invocation_id TEXT NOT NULL UNIQUE, conv_id TEXT NOT NULL, origin_input_seq INTEGER NOT NULL,
387
+ durable_session_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('queued','received','continued','cancelled')),
388
+ revision INTEGER NOT NULL CHECK(revision > 0), accepted_input_seq INTEGER
389
+ );
390
+ CREATE TABLE IF NOT EXISTS agent_tool_action_tickets (
391
+ id TEXT PRIMARY KEY, origin_decision_id TEXT NOT NULL UNIQUE, agent_id TEXT NOT NULL, incarnation INTEGER NOT NULL,
392
+ authority_epoch INTEGER NOT NULL, args_digest TEXT NOT NULL, target_digest TEXT NOT NULL,
393
+ state TEXT NOT NULL CHECK(state IN ('unused','consumed','revoked')), invocation_id TEXT UNIQUE,
394
+ revision INTEGER NOT NULL CHECK(revision > 0)
395
+ );
396
+ CREATE TABLE IF NOT EXISTS agent_tool_session_policy (
397
+ durable_session_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, incarnation INTEGER NOT NULL,
398
+ executor_generation INTEGER NOT NULL, desired_revision INTEGER NOT NULL, applied_revision INTEGER, status TEXT NOT NULL
399
+ );
400
+ CREATE TABLE IF NOT EXISTS agent_tool_outcomes (
401
+ decision_id TEXT PRIMARY KEY, request_id TEXT NOT NULL UNIQUE,
402
+ outcome TEXT NOT NULL CHECK(outcome IN ('allowed','denied','cancelled')),
403
+ data TEXT NOT NULL, principal TEXT NOT NULL, created_at TEXT NOT NULL
404
+ );
405
+ CREATE TABLE IF NOT EXISTS agent_tool_operations (
406
+ operation_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, kind TEXT NOT NULL,
407
+ request_json TEXT NOT NULL, result_json TEXT NOT NULL, created_at TEXT NOT NULL
408
+ );
409
+ `);
410
+ // Step 2 (294 resources): the approved resources of each grant. Existing rows stay NULL (legacy, no resources), so
411
+ // an upgrade never widens them: a scoped tool class denies a grant without resources.
412
+ const grantColumns = this.db.query<{ name: string }, []>("PRAGMA table_info(agent_tool_grants)").all();
413
+ if (!grantColumns.some((column) => column.name === "resources_json")) this.db.exec("ALTER TABLE agent_tool_grants ADD COLUMN resources_json TEXT");
414
+ // Step 3 (294 item 11): one migration card per agent and lifecycle; a cancelled card leaves room for a new one.
415
+ this.db.exec(`
416
+ CREATE TABLE IF NOT EXISTS agent_tool_migrations (
417
+ id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, incarnation INTEGER NOT NULL, conv_id TEXT NOT NULL,
418
+ decision_id TEXT NOT NULL UNIQUE, items_json TEXT NOT NULL, proposal_digest TEXT NOT NULL,
419
+ state TEXT NOT NULL CHECK(state IN ('pending','confirmed','declined','applied','cancelled')), choices_json TEXT,
420
+ revision INTEGER NOT NULL CHECK(revision > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL
421
+ );
422
+ CREATE INDEX IF NOT EXISTS agent_tool_migrations_agent ON agent_tool_migrations(agent_id, incarnation, state);
423
+ `);
424
+ // Step 4 (294 grant edit): a widening waits on its inbox card; the stored scope is what an allow applies.
425
+ this.db.exec(`
426
+ CREATE TABLE IF NOT EXISTS agent_tool_grant_edits (
427
+ decision_id TEXT PRIMARY KEY, grant_id TEXT NOT NULL, agent_id TEXT NOT NULL, base_revision INTEGER NOT NULL,
428
+ operations_json TEXT NOT NULL, scope_json TEXT NOT NULL, proposal_digest TEXT NOT NULL,
429
+ state TEXT NOT NULL CHECK(state IN ('pending','allowed','denied','cancelled','superseded')),
430
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
431
+ );
432
+ CREATE INDEX IF NOT EXISTS agent_tool_grant_edits_grant ON agent_tool_grant_edits(grant_id, state);
433
+ `);
434
+ }).immediate();
435
+ }
436
+
437
+ purpose(decisionId: string): DecisionPurpose | null {
438
+ return this.db.query<{ purpose: DecisionPurpose }, [string]>(
439
+ `SELECT COALESCE(p.purpose, CASE WHEN d.permission = 1 THEN 'native_permission' ELSE 'ordinary' END) AS purpose
440
+ FROM decisions d LEFT JOIN decision_purposes p ON p.decision_id = d.id WHERE d.id = ?`,
441
+ ).get(decisionId)?.purpose ?? null;
442
+ }
443
+
444
+ isToolPurpose(decisionId: string): boolean {
445
+ const purpose = this.purpose(decisionId);
446
+ return purpose !== null && (TOOL_PURPOSES as readonly string[]).includes(purpose);
447
+ }
448
+
449
+ private epochs(agentId: string): { policy_revision: number; authority_epoch: number; access_state: "open" | "blocked" } {
450
+ this.db.query("INSERT OR IGNORE INTO agent_permission_epochs (agent_id) VALUES (?)").run(agentId);
451
+ return this.db.query<{ policy_revision: number; authority_epoch: number; access_state: "open" | "blocked" }, [string]>(
452
+ "SELECT policy_revision, authority_epoch, access_state FROM agent_permission_epochs WHERE agent_id = ?",
453
+ ).get(agentId)!;
454
+ }
455
+
456
+ private bumpPolicy(agentId: string): number {
457
+ this.epochs(agentId);
458
+ this.db.query("UPDATE agent_permission_epochs SET policy_revision = policy_revision + 1, mode = 'managed' WHERE agent_id = ?").run(agentId);
459
+ const revision = this.epochs(agentId).policy_revision;
460
+ // Apply intent: every live session of this agent must apply this revision before it may run a new tool.
461
+ this.db.query("UPDATE agent_tool_session_policy SET desired_revision = ? WHERE agent_id = ?").run(revision, agentId);
462
+ return revision;
463
+ }
464
+
465
+ private requestRow(where: string, ...values: (string | number)[]): RequestRow | null {
466
+ return this.db.query<RequestRow, (string | number)[]>(`SELECT * FROM agent_tool_requests WHERE ${where}`).get(...values);
467
+ }
468
+
469
+ requestByDecision(decisionId: string): AgentToolRequestRecord | null {
470
+ const row = this.requestRow("decision_id = ?", decisionId);
471
+ return row ? requestRecord(row) : null;
472
+ }
473
+
474
+ /** The existing request for the same logical call, or null. Same call with other args or tool is a conflict (409). */
475
+ existingRequest(input: AgentToolRequestInput): AgentToolRequestRecord | null {
476
+ const incarnation = lifecycleEpoch(this.db, input.agentId);
477
+ const row = this.requestRow("agent_id = ? AND incarnation = ? AND conv_id = ? AND origin_input_seq = ? AND logical_call_id = ?",
478
+ input.agentId, incarnation, input.convId, input.originInputSeq, input.logicalCallId);
479
+ if (!row) return null;
480
+ if (row.args_digest !== sha256(canonicalJson(input.args)) || row.tool_key_json !== canonicalJson(input.toolKey) ||
481
+ row.operation !== input.operation || row.scope_json !== canonicalJson(input.scope)) throw new AgentToolPermissionError("args_conflict");
482
+ return requestRecord(row);
483
+ }
484
+
485
+ /**
486
+ * Validates the request and computes the server proposal. The digest binds agent, lifecycle, tool, operation, scope and args;
487
+ * an external action card (C8) also binds its purpose, so a grant answer can never confirm an action or the reverse.
488
+ */
489
+ proposal(input: AgentToolRequestInput, purpose: "agent_tool_grant" | "agent_tool_action" = "agent_tool_grant"): { requestId: string; incarnation: number; argsDigest: string; proposalDigest: string; payload: Record<string, unknown> } {
490
+ if (!input.agentId || !input.convId || !Number.isSafeInteger(input.originInputSeq) || input.originInputSeq < 1 ||
491
+ !input.durableSessionId || !input.logicalCallId || !input.sourceToolCallId || !input.operation ||
492
+ !record(input.toolKey) || !input.toolKey.originalName || !input.toolKey.registrationDigest ||
493
+ (input.originKind === "agent_collaboration") !== Boolean(input.collaborationRequestId && input.activationId)) {
494
+ throw new AgentToolPermissionError("tool_request_invalid");
495
+ }
496
+ approvedResources(input.scope);
497
+ const view = this.epochs(input.agentId);
498
+ if (view.access_state === "blocked") throw new AgentToolPermissionError("agent_access_blocked");
499
+ const incarnation = lifecycleEpoch(this.db, input.agentId);
500
+ const argsDigest = sha256(canonicalJson(input.args));
501
+ const proposalDigest = sha256(canonicalJson({
502
+ agentId: input.agentId, incarnation, authorityEpoch: view.authority_epoch, toolKey: input.toolKey,
503
+ operation: input.operation, scope: input.scope ?? null, argsDigest, ...(purpose === "agent_tool_action" ? { purpose } : {}),
504
+ }));
505
+ return {
506
+ requestId: crypto.randomUUID(), incarnation, argsDigest, proposalDigest,
507
+ payload: {
508
+ purpose, agentId: input.agentId, toolKey: input.toolKey, operation: input.operation,
509
+ scope: input.scope ?? null, proposalDigest, originKind: input.originKind,
510
+ ...(input.originAgentId ? { originAgentId: input.originAgentId } : {}),
511
+ },
512
+ };
513
+ }
514
+
515
+ insertRequest(input: AgentToolRequestInput, proposal: ReturnType<AgentToolPermissionStore["proposal"]>, decisionId: string,
516
+ purpose: "agent_tool_grant" | "agent_tool_action" = "agent_tool_grant"): AgentToolRequestRecord {
517
+ this.db.query("INSERT INTO decision_purposes (decision_id, purpose) VALUES (?, ?)").run(decisionId, purpose);
518
+ this.db.query("UPDATE decisions SET reason = ? WHERE id = ?").run(purpose === "agent_tool_action" ? "agent_tool_action" : "agent_tool_permission", decisionId);
519
+ this.db.query(`INSERT INTO agent_tool_requests (id, agent_id, incarnation, conv_id, origin_input_seq, origin_kind, origin_agent_id,
520
+ collaboration_request_id, activation_id, durable_session_id, logical_call_id, source_tool_call_id, tool_key_json, operation,
521
+ args_json, args_digest, scope_json, proposal_digest, decision_id, state, revision)
522
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1)`).run(
523
+ proposal.requestId, input.agentId, proposal.incarnation, input.convId, input.originInputSeq, input.originKind,
524
+ input.originAgentId ?? null, input.collaborationRequestId ?? null, input.activationId ?? null, input.durableSessionId,
525
+ input.logicalCallId, input.sourceToolCallId, canonicalJson(input.toolKey), input.operation, canonicalJson(input.args),
526
+ proposal.argsDigest, canonicalJson(input.scope), proposal.proposalDigest, decisionId);
527
+ return requestRecord(this.requestRow("id = ?", proposal.requestId)!);
528
+ }
529
+
530
+ /** The owner answer to any tool purpose card, in the caller's transaction: a widening edit card, else answer(). */
531
+ answerToolDecision(decisionId: string, decisionRevision: number, answer: AgentToolGrantAnswer, principal: OwnerPrincipal): AnswerResult {
532
+ const edit = this.db.query<EditRow, [string]>("SELECT * FROM agent_tool_grant_edits WHERE decision_id = ?").get(decisionId);
533
+ if (edit) return this.answerEdit(edit, answer);
534
+ return this.answer(decisionId, decisionRevision, answer, principal);
535
+ }
536
+
537
+ /**
538
+ * Records the owner answer in the caller's transaction: request state, grant, policy revision, execution intent
539
+ * (ready invocation) and the stored owner outcome. Throws before any write when the answer does not match.
540
+ */
541
+ answer(decisionId: string, decisionRevision: number, answer: AgentToolGrantAnswer, principal: OwnerPrincipal): AnswerResult {
542
+ if (this.purpose(decisionId) === "agent_tool_migration") return this.answerMigration(decisionId, answer);
543
+ const row = this.requestRow("decision_id = ?", decisionId);
544
+ if (!row) throw new AgentToolPermissionError("tool_request_not_found");
545
+ if (row.state !== "pending") throw new AgentToolPermissionError("tool_request_conflict");
546
+ if ("toolMigration" in answer.response) throw new AgentToolPermissionError("tool_request_invalid");
547
+ let outcome: AnswerResult["outcome"] = "cancelled";
548
+ if ("toolGrant" in answer.response) {
549
+ if (answer.response.toolGrant.proposalDigest !== row.proposal_digest) throw new AgentToolPermissionError("proposal_digest_mismatch");
550
+ outcome = answer.response.toolGrant.choice === "allow" ? "allowed" : "denied";
551
+ }
552
+ const lifecycle = lifecycleEpoch(this.db, row.agent_id);
553
+ if (outcome === "allowed" && lifecycle !== row.incarnation) throw new AgentToolPermissionError("lifecycle_epoch_mismatch");
554
+ let view = this.epochs(row.agent_id);
555
+ let grantId: string | null = null;
556
+ let invocationId: string | null = null;
557
+ const now = new Date().toISOString();
558
+ if (outcome === "allowed" && this.purpose(decisionId) === "agent_tool_action") {
559
+ ({ grantId, invocationId } = this.issueActionTicket(row, decisionId, view));
560
+ } else if (outcome === "allowed") {
561
+ // An explicit allow after a revoke-all re-opens access at the current epoch; older grants stay revoked.
562
+ if (view.access_state === "blocked") this.db.query("UPDATE agent_permission_epochs SET access_state = 'open' WHERE agent_id = ?").run(row.agent_id);
563
+ const policyRevision = this.bumpPolicy(row.agent_id);
564
+ view = this.epochs(row.agent_id);
565
+ const existing = this.db.query<GrantRow, [string, number, number, string, string]>(
566
+ `SELECT * FROM agent_tool_grants WHERE agent_id = ? AND incarnation = ? AND authority_epoch = ? AND tool_key_json = ?
567
+ AND scope_json = ? AND state = 'active' ORDER BY created_at LIMIT 1`,
568
+ ).get(row.agent_id, row.incarnation, view.authority_epoch, row.tool_key_json, row.scope_json);
569
+ let grantRevision: number;
570
+ if (existing && (JSON.parse(existing.operations_json) as string[]).includes(row.operation)) {
571
+ grantId = existing.id;
572
+ grantRevision = existing.revision;
573
+ } else {
574
+ grantId = crypto.randomUUID();
575
+ grantRevision = 1;
576
+ this.db.query(`INSERT INTO agent_tool_grants (id, agent_id, incarnation, authority_epoch, tool_key_json, operations_json,
577
+ scope_json, origin_decision_id, origin_submission_id, state, revision, created_at, updated_at, resources_json)
578
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 1, ?, ?, ?)`).run(grantId, row.agent_id, row.incarnation, view.authority_epoch,
579
+ row.tool_key_json, JSON.stringify([row.operation]), row.scope_json, decisionId, answer.submissionId, now, now,
580
+ resourcesJson(JSON.parse(row.scope_json)));
581
+ }
582
+ invocationId = crypto.randomUUID();
583
+ this.db.query(`INSERT INTO agent_tool_invocations (id, request_id, agent_id, incarnation, logical_operation_key, tool_key_json,
584
+ args_json, args_digest, target_identity_json, authority_source, authority_ref, grant_id, grant_revision, policy_revision,
585
+ authority_epoch, state, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'persistent_tool', ?, ?, ?, ?, ?, 'ready', 1)`).run(
586
+ invocationId, row.id, row.agent_id, row.incarnation, `request:${row.id}`, row.tool_key_json, row.args_json, row.args_digest,
587
+ row.scope_json, grantId, grantId, grantRevision, policyRevision, view.authority_epoch);
588
+ }
589
+ this.db.query("UPDATE agent_tool_requests SET state = ?, revision = revision + 1 WHERE id = ?")
590
+ .run(outcome === "allowed" ? "granted" : outcome, row.id);
591
+ const stored = {
592
+ decisionId, decisionRevision, outcome, logicalOperationId: `request:${row.id}`,
593
+ continuationId: crypto.randomUUID(), collaborationRequestId: row.collaboration_request_id,
594
+ blockedActivationId: row.activation_id, originInputSeq: row.origin_input_seq, lifecycleEpoch: lifecycle,
595
+ authorityEpoch: view.authority_epoch, policyRevision: view.policy_revision,
596
+ };
597
+ this.db.query("INSERT INTO agent_tool_outcomes (decision_id, request_id, outcome, data, principal, created_at) VALUES (?, ?, ?, ?, ?, ?)")
598
+ .run(decisionId, row.id, outcome, JSON.stringify(stored),
599
+ JSON.stringify({ client: principal.client, credentialRevision: principal.credentialRevision, sessionRef: principal.sessionRef }), now);
600
+ return { outcome, grantId, invocationId };
601
+ }
602
+
603
+ /**
604
+ * The active grant of this exact tool registration that names the catalog operation, at the agent's current lifecycle
605
+ * and authority epoch; null when there is none. An external action needs it on top of its one-shot ticket (C8).
606
+ */
607
+ actionGrant(agentId: string, toolKey: unknown, operation: string): AgentToolGrantRow | null {
608
+ const view = this.epochs(agentId);
609
+ if (view.access_state === "blocked") return null;
610
+ return this.db.query<GrantRow, [string, number, number, string]>(
611
+ `SELECT * FROM agent_tool_grants WHERE agent_id = ? AND incarnation = ? AND authority_epoch = ? AND tool_key_json = ? AND state = 'active'
612
+ ORDER BY created_at, id`,
613
+ ).all(agentId, lifecycleEpoch(this.db, agentId), view.authority_epoch, canonicalJson(toolKey)).map(grantRecord)
614
+ .find((grant) => grant.operations.includes(operation)) ?? null;
615
+ }
616
+
617
+ /**
618
+ * C8 owner allow of an agent_tool_action card, in the answer transaction: one unused ticket bound to the exact args and
619
+ * target digest and one ready execution that names it. No grant or policy revision changes; start consumes the ticket.
620
+ */
621
+ private issueActionTicket(row: RequestRow, decisionId: string, view: { authority_epoch: number; policy_revision: number; access_state: "open" | "blocked" }):
622
+ { grantId: string; invocationId: string } {
623
+ if (view.access_state === "blocked") throw new AgentToolPermissionError("agent_access_blocked");
624
+ const grant = this.actionGrant(row.agent_id, JSON.parse(row.tool_key_json), row.operation);
625
+ if (!grant) throw new AgentToolPermissionError("grant_conflict");
626
+ const ticketId = `act_${crypto.randomUUID()}`;
627
+ this.db.query(`INSERT INTO agent_tool_action_tickets (id, origin_decision_id, agent_id, incarnation, authority_epoch, args_digest, target_digest, state, revision)
628
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'unused', 1)`).run(ticketId, decisionId, row.agent_id, row.incarnation, view.authority_epoch, row.args_digest, sha256(row.scope_json));
629
+ const invocationId = crypto.randomUUID();
630
+ this.db.query(`INSERT INTO agent_tool_invocations (id, request_id, agent_id, incarnation, logical_operation_key, tool_key_json,
631
+ args_json, args_digest, target_identity_json, authority_source, authority_ref, grant_id, grant_revision, policy_revision,
632
+ authority_epoch, action_ticket_id, state, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'action_ticket', ?, ?, ?, ?, ?, ?, 'ready', 1)`).run(
633
+ invocationId, row.id, row.agent_id, row.incarnation, `request:${row.id}`, row.tool_key_json, row.args_json, row.args_digest,
634
+ row.scope_json, ticketId, grant.id, grant.revision, view.policy_revision, view.authority_epoch, ticketId);
635
+ return { grantId: grant.id, invocationId };
636
+ }
637
+
638
+ /** What the delivery carries to the session for an action execution: the stored args and the ticket id; null for other executions. */
639
+ actionCarry(invocationId: string): { args: unknown; ticket: string } | null {
640
+ const row = this.db.query<{ args_json: string; action_ticket_id: string | null }, [string]>(
641
+ "SELECT args_json, action_ticket_id FROM agent_tool_invocations WHERE id = ?").get(invocationId);
642
+ return row?.action_ticket_id ? { args: JSON.parse(row.args_json), ticket: row.action_ticket_id } : null;
643
+ }
644
+
645
+ mode(agentId: string): "legacy" | "managed" {
646
+ return this.db.query<{ mode: "legacy" | "managed" }, [string]>("SELECT mode FROM agent_permission_epochs WHERE agent_id = ?").get(agentId)?.mode ?? "legacy";
647
+ }
648
+
649
+ /** A new agent, clone or new-id restore: managed with zero grants from the start (C9). Never called for an existing id. */
650
+ startManaged(agentId: string): void {
651
+ this.db.transaction(() => {
652
+ this.epochs(agentId);
653
+ this.db.query("UPDATE agent_permission_epochs SET mode = 'managed' WHERE agent_id = ?").run(agentId);
654
+ }).immediate();
655
+ }
656
+
657
+ /** The open (not cancelled) migration card of the agent's current lifecycle, or null. */
658
+ currentMigration(agentId: string): AgentToolMigrationCard | null {
659
+ const row = this.db.query<{ decision_id: string; agent_id: string; incarnation: number; proposal_digest: string; items_json: string; state: AgentToolMigrationCard["state"] }, [string, number]>(
660
+ `SELECT decision_id, agent_id, incarnation, proposal_digest, items_json, state FROM agent_tool_migrations
661
+ WHERE agent_id = ? AND incarnation = ? AND state != 'cancelled' ORDER BY created_at DESC, rowid DESC LIMIT 1`,
662
+ ).get(agentId, lifecycleEpoch(this.db, agentId));
663
+ if (!row) return null;
664
+ return { decisionId: row.decision_id, agentId: row.agent_id, incarnation: row.incarnation, proposalDigest: row.proposal_digest,
665
+ items: JSON.parse(row.items_json), state: row.state };
666
+ }
667
+
668
+ /** The card payload and its digest, which binds agent, lifecycle and every listed scope. */
669
+ migrationProposal(agentId: string, items: readonly LegacyScopeItem[]): { incarnation: number; proposalDigest: string; payload: Record<string, unknown> } {
670
+ const incarnation = lifecycleEpoch(this.db, agentId);
671
+ const proposalDigest = sha256(canonicalJson({ purpose: "agent_tool_migration", agentId, incarnation, items }));
672
+ return { incarnation, proposalDigest, payload: { purpose: "agent_tool_migration", agentId, items, proposalDigest } };
673
+ }
674
+
675
+ insertMigration(agentId: string, convId: string, items: readonly LegacyScopeItem[], proposal: ReturnType<AgentToolPermissionStore["migrationProposal"]>, decisionId: string): AgentToolMigrationCard {
676
+ this.db.query("INSERT INTO decision_purposes (decision_id, purpose) VALUES (?, 'agent_tool_migration')").run(decisionId);
677
+ this.db.query("UPDATE decisions SET reason = 'agent_tool_migration' WHERE id = ?").run(decisionId);
678
+ const now = new Date().toISOString();
679
+ this.db.query(`INSERT INTO agent_tool_migrations (id, agent_id, incarnation, conv_id, decision_id, items_json, proposal_digest, state, revision, created_at, updated_at)
680
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 1, ?, ?)`).run(crypto.randomUUID(), agentId, proposal.incarnation, convId, decisionId,
681
+ JSON.stringify(items), proposal.proposalDigest, now, now);
682
+ return this.currentMigration(agentId)!;
683
+ }
684
+
685
+ /**
686
+ * The owner answer to a migration card, in the caller's transaction. Every listed scope needs its own allow or deny;
687
+ * only allowed scopes become grants (with their exact resources). The mode stays legacy: the running turn finishes with
688
+ * its legacy scope, and applyMigrationAtIdle switches at the next idle boundary. A cancel keeps legacy and grants nothing.
689
+ */
690
+ private answerMigration(decisionId: string, answer: AgentToolGrantAnswer): AnswerResult {
691
+ const row = this.db.query<{ id: string; agent_id: string; incarnation: number; items_json: string; proposal_digest: string; state: string }, [string]>(
692
+ "SELECT id, agent_id, incarnation, items_json, proposal_digest, state FROM agent_tool_migrations WHERE decision_id = ?").get(decisionId);
693
+ if (!row) throw new AgentToolPermissionError("tool_request_not_found");
694
+ if (row.state !== "pending") throw new AgentToolPermissionError("tool_request_conflict");
695
+ const response = answer.response;
696
+ if ("toolGrant" in response) throw new AgentToolPermissionError("tool_request_invalid");
697
+ const now = new Date().toISOString();
698
+ if (!("toolMigration" in response)) {
699
+ this.db.query("UPDATE agent_tool_migrations SET state = 'cancelled', revision = revision + 1, updated_at = ? WHERE id = ?").run(now, row.id);
700
+ return { outcome: "cancelled", grantId: null, invocationId: null };
701
+ }
702
+ if (response.toolMigration.proposalDigest !== row.proposal_digest) throw new AgentToolPermissionError("proposal_digest_mismatch");
703
+ const items = JSON.parse(row.items_json) as LegacyScopeItem[];
704
+ const choices = response.toolMigration.items;
705
+ if (Object.keys(choices).length !== items.length || !items.every((item) => choices[item.id] === "allow" || choices[item.id] === "deny")) {
706
+ throw new AgentToolPermissionError("tool_request_invalid");
707
+ }
708
+ const allowed = items.filter((item) => choices[item.id] === "allow");
709
+ if (allowed.length > 0 && lifecycleEpoch(this.db, row.agent_id) !== row.incarnation) throw new AgentToolPermissionError("lifecycle_epoch_mismatch");
710
+ const view = this.epochs(row.agent_id);
711
+ for (const item of allowed) {
712
+ for (const grant of item.grants) {
713
+ this.db.query(`INSERT INTO agent_tool_grants (id, agent_id, incarnation, authority_epoch, tool_key_json, operations_json,
714
+ scope_json, origin_decision_id, origin_submission_id, state, revision, created_at, updated_at, resources_json)
715
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 1, ?, ?, ?)`).run(crypto.randomUUID(), row.agent_id, row.incarnation, view.authority_epoch,
716
+ canonicalJson(grant.toolKey), JSON.stringify(grant.operations), canonicalJson({ migration: item.id, resources: grant.resources }),
717
+ decisionId, answer.submissionId, now, now, JSON.stringify(grant.resources));
718
+ }
719
+ }
720
+ if (allowed.length > 0) {
721
+ this.db.query("UPDATE agent_permission_epochs SET policy_revision = policy_revision + 1 WHERE agent_id = ?").run(row.agent_id);
722
+ this.db.query("UPDATE agent_tool_session_policy SET desired_revision = ? WHERE agent_id = ?").run(this.epochs(row.agent_id).policy_revision, row.agent_id);
723
+ }
724
+ this.db.query("UPDATE agent_tool_migrations SET state = ?, choices_json = ?, revision = revision + 1, updated_at = ? WHERE id = ?")
725
+ .run(allowed.length > 0 ? "confirmed" : "declined", canonicalJson(choices), now, row.id);
726
+ return { outcome: allowed.length > 0 ? "allowed" : "denied", grantId: null, invocationId: null };
727
+ }
728
+
729
+ /** At an idle boundary: an answered (confirmed or declined) card switches the agent to managed. True when it switched. */
730
+ applyMigrationAtIdle(agentId: string): boolean {
731
+ return this.db.transaction(() => {
732
+ const row = this.db.query<{ id: string }, [string]>(
733
+ "SELECT id FROM agent_tool_migrations WHERE agent_id = ? AND state IN ('confirmed','declined') ORDER BY created_at DESC, rowid DESC LIMIT 1").get(agentId);
734
+ if (!row) return false;
735
+ // answerMigration created the epochs row when it recorded the answer.
736
+ this.db.query("UPDATE agent_permission_epochs SET mode = 'managed' WHERE agent_id = ?").run(agentId);
737
+ this.db.query("UPDATE agent_tool_migrations SET state = 'applied', revision = revision + 1, updated_at = ? WHERE id = ?").run(new Date().toISOString(), row.id);
738
+ return true;
739
+ }).immediate();
740
+ }
741
+
742
+ grant(id: string): AgentToolGrantRow | null {
743
+ const row = this.db.query<GrantRow, [string]>("SELECT * FROM agent_tool_grants WHERE id = ?").get(id);
744
+ return row ? grantRecord(row) : null;
745
+ }
746
+
747
+ grants(agentId: string): AgentToolGrantRow[] {
748
+ return this.db.query<GrantRow, [string]>("SELECT * FROM agent_tool_grants WHERE agent_id = ? ORDER BY created_at, id").all(agentId).map(grantRecord);
749
+ }
750
+
751
+ invocation(id: string): AgentToolInvocation | null {
752
+ const row = this.db.query<InvocationRow, [string]>("SELECT * FROM agent_tool_invocations WHERE id = ?").get(id);
753
+ return row ? invocationRecord(row) : null;
754
+ }
755
+
756
+ invocationByRequest(requestId: string): AgentToolInvocation | null {
757
+ const row = this.db.query<InvocationRow, [string]>("SELECT * FROM agent_tool_invocations WHERE request_id = ?").get(requestId);
758
+ return row ? invocationRecord(row) : null;
759
+ }
760
+
761
+ /** The access view and every active grant of the agent: a full read, never a page (the engine policy must be complete). */
762
+ policyGrants(agentId: string): { view: AgentAccessView; grants: AgentToolGrantRow[] } {
763
+ return this.db.transaction(() => ({
764
+ view: access(this.db, agentId),
765
+ grants: this.db.query<GrantRow, [string]>("SELECT * FROM agent_tool_grants WHERE agent_id = ? AND state = 'active' ORDER BY created_at, id").all(agentId).map(grantRecord),
766
+ }))();
767
+ }
768
+
769
+ /** The engine revision of the current policy. The engine requires >= 1, so no change yet is revision 1 on both sides. */
770
+ private currentRevision(agentId: string): number {
771
+ return Math.max(access(this.db, agentId).policyRevision, 1);
772
+ }
773
+
774
+ /**
775
+ * Persists the engine's ACK of the current policy revision for one durable session (agent_tool_session_policy, created by
776
+ * migrate). Another revision is policy_conflict: an ACK of an older policy never authorizes a start.
777
+ */
778
+ ackPolicy(durableSessionId: string, agentId: string, revision: number): { revision: number } {
779
+ return this.db.transaction(() => {
780
+ const current = this.currentRevision(agentId);
781
+ if (!durableSessionId || revision !== current) throw new AgentToolPermissionError("policy_conflict");
782
+ this.db.query(`INSERT INTO agent_tool_session_policy (durable_session_id, agent_id, incarnation, executor_generation, desired_revision, applied_revision, status)
783
+ VALUES (?, ?, ?, 0, ?, ?, 'applied') ON CONFLICT(durable_session_id) DO UPDATE SET agent_id = excluded.agent_id, incarnation = excluded.incarnation,
784
+ desired_revision = excluded.desired_revision, applied_revision = excluded.applied_revision, status = 'applied'`)
785
+ .run(durableSessionId, agentId, lifecycleEpoch(this.db, agentId), current, revision);
786
+ return { revision };
787
+ }).immediate();
788
+ }
789
+
790
+ appliedRevision(durableSessionId: string, agentId: string): number | null {
791
+ return this.db.query<{ applied_revision: number | null }, [string, string]>(
792
+ "SELECT applied_revision FROM agent_tool_session_policy WHERE durable_session_id = ? AND agent_id = ?").get(durableSessionId, agentId)?.applied_revision ?? null;
793
+ }
794
+
795
+ /** Where a granted execution is delivered. The operation id is the engine's held operation (the request's logical call). */
796
+ deliveryTarget(invocationId: string): { invocationId: string; operationId: string; agentId: string; convId: string; durableSessionId: string; collaboration: boolean } | null {
797
+ const row = this.db.query<{ logical_call_id: string; agent_id: string; conv_id: string; durable_session_id: string; collaboration_request_id: string | null }, [string]>(
798
+ `SELECT r.logical_call_id, r.agent_id, r.conv_id, r.durable_session_id, r.collaboration_request_id
799
+ FROM agent_tool_invocations i JOIN agent_tool_requests r ON r.id = i.request_id WHERE i.id = ?`).get(invocationId);
800
+ return row ? { invocationId, operationId: row.logical_call_id, agentId: row.agent_id, convId: row.conv_id,
801
+ durableSessionId: row.durable_session_id, collaboration: row.collaboration_request_id !== null } : null;
802
+ }
803
+
804
+ /**
805
+ * Owner-only executions of a conversation still owed a delivery, oldest first: ready rows, reservations of a generation
806
+ * that is not live in this process (reserve takes them over), and stored results whose continuation is still queued
807
+ * (C5: only the stored result is delivered again; nothing re-runs). Delivered when the conversation's session opens.
808
+ */
809
+ readyInvocations(convId: string): string[] {
810
+ return this.db.query<{ id: string; state: InvocationState; executor_generation: number }, [string]>(
811
+ `SELECT i.id, i.state, i.executor_generation FROM agent_tool_invocations i JOIN agent_tool_requests r ON r.id = i.request_id
812
+ LEFT JOIN agent_tool_continuations c ON c.invocation_id = i.id
813
+ WHERE r.conv_id = ? AND r.collaboration_request_id IS NULL
814
+ AND (i.state IN ('ready','reserved') OR (i.state IN ('succeeded','failed') AND c.state = 'queued')) ORDER BY i.rowid`).all(convId)
815
+ .filter((row) => row.state !== "reserved" || !this.liveGenerations.has(row.executor_generation)).map((row) => row.id);
816
+ }
817
+
818
+ /** Owner-only executions of a conversation a generation that is not live in this process started without a result (C5: unknown). */
819
+ staleStarted(convId: string): string[] {
820
+ return this.db.query<{ id: string; executor_generation: number }, [string]>(
821
+ `SELECT i.id, i.executor_generation FROM agent_tool_invocations i JOIN agent_tool_requests r ON r.id = i.request_id
822
+ WHERE r.conv_id = ? AND r.collaboration_request_id IS NULL AND i.state = 'started' ORDER BY i.rowid`).all(convId)
823
+ .filter((row) => !this.liveGenerations.has(row.executor_generation)).map((row) => row.id);
824
+ }
825
+
826
+ /** C4 queued -> continued: the engine accepted the continuation (or already had it). Persisted, so a restart never re-delivers it. */
827
+ continued(continuationId: string): boolean {
828
+ return this.db.query("UPDATE agent_tool_continuations SET state = 'continued', revision = revision + 1 WHERE id = ? AND state = 'queued'")
829
+ .run(continuationId).changes === 1;
830
+ }
831
+
832
+ /** C5: a started execution whose result cannot be proven becomes outcome_unknown; it is never run or offered again. */
833
+ markOutcomeUnknown(invocationId: string): boolean {
834
+ return this.db.query("UPDATE agent_tool_invocations SET state = 'outcome_unknown', revision = revision + 1, settled_at = ? WHERE id = ? AND state = 'started'")
835
+ .run(new Date().toISOString(), invocationId).changes === 1;
836
+ }
837
+
838
+ /** Same operation id and body returns the stored result; another body is 409. */
839
+ private operation<T>(operationId: string, agentId: string, kind: string, request: unknown, run: () => T): T {
840
+ if (!UUID.test(operationId)) throw new AgentToolPermissionError("tool_request_invalid");
841
+ const previous = this.db.query<{ agent_id: string; kind: string; request_json: string; result_json: string }, [string]>(
842
+ "SELECT agent_id, kind, request_json, result_json FROM agent_tool_operations WHERE operation_id = ?").get(operationId);
843
+ if (previous) {
844
+ if (previous.agent_id !== agentId || previous.kind !== kind || previous.request_json !== canonicalJson(request)) {
845
+ throw new AgentToolPermissionError("operation_conflict");
846
+ }
847
+ return JSON.parse(previous.result_json) as T;
848
+ }
849
+ const result = run();
850
+ this.db.query("INSERT INTO agent_tool_operations VALUES (?, ?, ?, ?, ?, ?)")
851
+ .run(operationId, agentId, kind, canonicalJson(request), JSON.stringify(result), new Date().toISOString());
852
+ return result;
853
+ }
854
+
855
+ /** Cancels unstarted executions of the grants; returns started ones, which keep running until their result or abort ACK. */
856
+ private stopExecutions(where: string, ...values: string[]): { cancelled: string[]; running: string[] } {
857
+ const rows = this.db.query<{ id: string; state: InvocationState }, string[]>(
858
+ `SELECT id, state FROM agent_tool_invocations WHERE ${where} AND state IN ('ready','reserved','started') ORDER BY id`).all(...values);
859
+ const cancelled = rows.filter((row) => row.state !== "started").map((row) => row.id);
860
+ for (const id of cancelled) {
861
+ this.db.query("UPDATE agent_tool_invocations SET state = 'cancelled', revision = revision + 1, settled_at = ? WHERE id = ?")
862
+ .run(new Date().toISOString(), id);
863
+ }
864
+ return { cancelled, running: rows.filter((row) => row.state === "started").map((row) => row.id) };
865
+ }
866
+
867
+ revokeGrant(agentId: string, grantId: string, input: { operationId: string; expectedRevision: number }, principal: OwnerPrincipal):
868
+ { grant: AgentToolGrantRow; policyRevision: number; cancelled: string[]; running: string[] } {
869
+ void principal;
870
+ return this.db.transaction(() => this.operation(input.operationId, agentId, "revoke", { grantId, expectedRevision: input.expectedRevision }, () => {
871
+ const grant = this.grant(grantId);
872
+ if (!grant || grant.agentId !== agentId) throw new AgentToolPermissionError("grant_not_found");
873
+ if (grant.revision !== input.expectedRevision || grant.state === "revoked") throw new AgentToolPermissionError("grant_conflict");
874
+ const now = new Date().toISOString();
875
+ this.db.query("UPDATE agent_tool_grants SET state = 'revoked', revision = revision + 1, revoked_at = ?, updated_at = ?, reason = 'owner_revoke' WHERE id = ?")
876
+ .run(now, now, grantId);
877
+ const policyRevision = this.bumpPolicy(agentId);
878
+ const stopped = this.stopExecutions("grant_id = ?", grantId);
879
+ this.supersedeEdits("grant_id = ?", grantId);
880
+ return { grant: this.grant(grantId)!, policyRevision, ...stopped };
881
+ })).immediate();
882
+ }
883
+
884
+ /**
885
+ * C10 revoke-all: blocks access, raises authority epoch and policy revision, revokes every persistent grant, cancels
886
+ * unstarted executions and pending requests (closing their inbox cards). Started executions are returned as running.
887
+ */
888
+ revokeAll(agentId: string, input: { operationId: string; expectedPolicyRevision: number }, principal: OwnerPrincipal):
889
+ { view: AgentAccessView; revokedGrants: string[]; cancelled: string[]; running: string[]; closedDecisions: string[] } {
890
+ void principal;
891
+ return this.db.transaction(() => this.operation(input.operationId, agentId, "revoke_all", { expectedPolicyRevision: input.expectedPolicyRevision }, () => {
892
+ const before = this.epochs(agentId);
893
+ if (before.policy_revision !== input.expectedPolicyRevision) throw new AgentToolPermissionError("policy_conflict");
894
+ const now = new Date().toISOString();
895
+ const revokedGrants = this.db.query<{ id: string }, [string]>("SELECT id FROM agent_tool_grants WHERE agent_id = ? AND state != 'revoked' ORDER BY id")
896
+ .all(agentId).map((row) => row.id);
897
+ this.db.query("UPDATE agent_tool_grants SET state = 'revoked', revision = revision + 1, revoked_at = ?, updated_at = ?, reason = 'revoke_all' WHERE agent_id = ? AND state != 'revoked'")
898
+ .run(now, now, agentId);
899
+ this.db.query("UPDATE agent_permission_epochs SET access_state = 'blocked', authority_epoch = authority_epoch + 1 WHERE agent_id = ?").run(agentId);
900
+ this.bumpPolicy(agentId);
901
+ const stopped = this.stopExecutions("agent_id = ?", agentId);
902
+ const pending = this.db.query<{ id: string; decision_id: string }, [string]>(
903
+ "SELECT id, decision_id FROM agent_tool_requests WHERE agent_id = ? AND state = 'pending' ORDER BY id").all(agentId);
904
+ for (const request of pending) {
905
+ this.db.query("UPDATE agent_tool_requests SET state = 'cancelled', revision = revision + 1 WHERE id = ?").run(request.id);
906
+ this.db.query(`UPDATE decisions SET status = 'closed', reason = 'agent_access_revoked', revision = revision + 1, updated_at = ?
907
+ WHERE id = ? AND status IN ('pending','needs_review')`).run(now, request.decision_id);
908
+ }
909
+ const closedEdits = this.supersedeEdits("agent_id = ?", agentId);
910
+ return { view: access(this.db, agentId), revokedGrants, ...stopped, closedDecisions: [...pending.map((request) => request.decision_id), ...closedEdits] };
911
+ })).immediate();
912
+ }
913
+
914
+ /**
915
+ * C3/C8 grant scope edit, in the caller's (Store) transaction. The server classifies the complete post-edit values:
916
+ * a narrowing applies at once (C5); a widening opens an agent_tool_grant inbox card through `createDecision` and the
917
+ * old grant stays valid and unchanged until the card is answered. Same operation id and payload replays the response.
918
+ */
919
+ editGrant(agentId: string, grantId: string, input: GrantEditInput, principal: OwnerPrincipal,
920
+ createDecision: (ownerConv: string, sourceRequestId: string, payload: Record<string, unknown>) => string): GrantEditResult {
921
+ void principal;
922
+ const operations = [...new Set(input.operations)].sort();
923
+ const request = { grantId, expectedRevision: input.expectedRevision, operations, scope: input.scope };
924
+ return this.operation(input.operationId, agentId, "edit", request, (): GrantEditResult => {
925
+ const grant = this.grant(grantId);
926
+ if (!grant || grant.agentId !== agentId) throw new AgentToolPermissionError("grant_not_found");
927
+ const view = this.epochs(agentId);
928
+ if (view.access_state === "blocked") throw new AgentToolPermissionError("agent_access_blocked", 409);
929
+ if (grant.state !== "active" || grant.revision !== input.expectedRevision) throw new AgentToolPermissionError("grant_conflict");
930
+ if (grant.authorityEpoch !== view.authority_epoch || grant.incarnation !== lifecycleEpoch(this.db, agentId)) throw new AgentToolPermissionError("policy_conflict");
931
+ const was = record(grant.scope) ? grant.scope : {};
932
+ if (OPAQUE_SCOPE_KEYS.some((key) => Object.hasOwn(was, key) !== Object.hasOwn(input.scope, key) || canonicalJson(was[key]) !== canonicalJson(input.scope[key]))) {
933
+ throw new AgentToolPermissionError("tool_request_invalid");
934
+ }
935
+ approvedResources(input.scope);
936
+ const kind = classifyGrantEdit(grant, { operations, scope: input.scope });
937
+ if (kind === "same") return { status: 200, body: { grant, policyRevision: view.policy_revision, cancelled: [], running: [] } };
938
+ this.supersedeEdits("grant_id = ?", grantId);
939
+ if (kind === "narrow") return { status: 200, body: this.applyGrantScope(grant, operations, input.scope) };
940
+ const proposalDigest = sha256(canonicalJson({
941
+ purpose: "agent_tool_grant_edit", agentId, incarnation: grant.incarnation, authorityEpoch: view.authority_epoch, grantId,
942
+ baseRevision: grant.revision, toolKey: grant.toolKey, operations, scope: input.scope,
943
+ }));
944
+ const ownerConv = this.db.query<{ owner_conv: string }, [string]>("SELECT owner_conv FROM decisions WHERE id = ?").get(grant.originDecisionId)?.owner_conv ?? "main";
945
+ const decisionId = createDecision(ownerConv, `agent-tool-edit:${grantId}:${input.operationId}`, {
946
+ purpose: "agent_tool_grant", agentId, toolKey: grant.toolKey, operation: operations[0], operations, scope: input.scope, proposalDigest,
947
+ originKind: "user", edit: { grantId, baseRevision: grant.revision, operations: grant.operations, scope: grant.scope },
948
+ });
949
+ this.db.query("INSERT INTO decision_purposes (decision_id, purpose) VALUES (?, 'agent_tool_grant')").run(decisionId);
950
+ this.db.query("UPDATE decisions SET reason = 'agent_tool_permission' WHERE id = ?").run(decisionId);
951
+ const now = new Date().toISOString();
952
+ this.db.query(`INSERT INTO agent_tool_grant_edits (decision_id, grant_id, agent_id, base_revision, operations_json, scope_json, proposal_digest, state, created_at, updated_at)
953
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`).run(decisionId, grantId, agentId, grant.revision, JSON.stringify(operations), canonicalJson(input.scope), proposalDigest, now, now);
954
+ return { status: 202, body: { decisionId, proposalDigest, oldGrant: grant } };
955
+ });
956
+ }
957
+
958
+ /**
959
+ * Replaces the grant's operations, scope and resources (revision +1, policy revision bump). Unstarted executions still
960
+ * inside the new scope move to the new revision; the others are cancelled. Started ones outside it keep running (C5).
961
+ */
962
+ private applyGrantScope(grant: AgentToolGrantRow, operations: readonly string[], scope: unknown):
963
+ { grant: AgentToolGrantRow; policyRevision: number; cancelled: string[]; running: string[] } {
964
+ const now = new Date().toISOString();
965
+ this.db.query("UPDATE agent_tool_grants SET operations_json = ?, scope_json = ?, resources_json = ?, revision = revision + 1, updated_at = ? WHERE id = ?")
966
+ .run(JSON.stringify(operations), canonicalJson(scope), resourcesJson(scope), now, grant.id);
967
+ const policyRevision = this.bumpPolicy(grant.agentId);
968
+ const updated = this.grant(grant.id)!;
969
+ const rows = this.db.query<{ id: string; state: InvocationState; operation: string | null; target_identity_json: string }, [string]>(
970
+ `SELECT i.id, i.state, r.operation, i.target_identity_json FROM agent_tool_invocations i LEFT JOIN agent_tool_requests r ON r.id = i.request_id
971
+ WHERE i.grant_id = ? AND i.state IN ('ready','reserved','started') ORDER BY i.id`).all(grant.id);
972
+ const cancelled: string[] = [];
973
+ const running: string[] = [];
974
+ for (const row of rows) {
975
+ const inside = row.operation !== null &&
976
+ classifyGrantEdit({ operations, scope }, { operations: [row.operation], scope: JSON.parse(row.target_identity_json) }) !== "widen";
977
+ if (row.state === "started") {
978
+ if (!inside) running.push(row.id);
979
+ } else if (inside) {
980
+ this.db.query("UPDATE agent_tool_invocations SET grant_revision = ?, policy_revision = ?, revision = revision + 1 WHERE id = ?").run(updated.revision, policyRevision, row.id);
981
+ } else {
982
+ this.db.query("UPDATE agent_tool_invocations SET state = 'cancelled', revision = revision + 1, settled_at = ? WHERE id = ?").run(now, row.id);
983
+ cancelled.push(row.id);
984
+ }
985
+ }
986
+ return { grant: updated, policyRevision, cancelled, running };
987
+ }
988
+
989
+ /** The owner answer to a widening card: deny or cancel changes nothing; allow applies the stored scope if the grant is unchanged. */
990
+ private answerEdit(edit: EditRow, answer: AgentToolGrantAnswer): AnswerResult {
991
+ if (edit.state !== "pending") throw new AgentToolPermissionError("tool_request_conflict");
992
+ const response = answer.response;
993
+ if ("toolMigration" in response) throw new AgentToolPermissionError("tool_request_invalid");
994
+ let outcome: AnswerResult["outcome"] = "cancelled";
995
+ if ("toolGrant" in response) {
996
+ if (response.toolGrant.proposalDigest !== edit.proposal_digest) throw new AgentToolPermissionError("proposal_digest_mismatch");
997
+ outcome = response.toolGrant.choice === "allow" ? "allowed" : "denied";
998
+ }
999
+ const setState = () => this.db.query("UPDATE agent_tool_grant_edits SET state = ?, updated_at = ? WHERE decision_id = ?")
1000
+ .run(outcome, new Date().toISOString(), edit.decision_id);
1001
+ if (outcome !== "allowed") {
1002
+ setState();
1003
+ return { outcome, grantId: null, invocationId: null };
1004
+ }
1005
+ // editGrant stored this row for an existing grant, and grant rows are never deleted (revoke only changes state).
1006
+ const grant = this.grant(edit.grant_id)!;
1007
+ const view = this.epochs(edit.agent_id);
1008
+ if (grant.state !== "active" || grant.revision !== edit.base_revision || view.access_state === "blocked" ||
1009
+ grant.authorityEpoch !== view.authority_epoch || grant.incarnation !== lifecycleEpoch(this.db, edit.agent_id)) {
1010
+ throw new AgentToolPermissionError("grant_conflict");
1011
+ }
1012
+ this.applyGrantScope(grant, JSON.parse(edit.operations_json), JSON.parse(edit.scope_json));
1013
+ setState();
1014
+ return { outcome: "allowed", grantId: grant.id, invocationId: null };
1015
+ }
1016
+
1017
+ /** Closes the pending widening cards the condition matches (the grant changed or was revoked); returns their decision ids. */
1018
+ private supersedeEdits(where: string, value: string): string[] {
1019
+ const ids = this.db.query<{ decision_id: string }, [string]>(
1020
+ `SELECT decision_id FROM agent_tool_grant_edits WHERE ${where} AND state = 'pending' ORDER BY decision_id`).all(value).map((row) => row.decision_id);
1021
+ const now = new Date().toISOString();
1022
+ for (const id of ids) {
1023
+ this.db.query("UPDATE agent_tool_grant_edits SET state = 'superseded', updated_at = ? WHERE decision_id = ?").run(now, id);
1024
+ this.db.query(`UPDATE decisions SET status = 'closed', reason = 'agent_tool_edit_superseded', revision = revision + 1, updated_at = ?
1025
+ WHERE id = ? AND status IN ('pending','needs_review')`).run(now, id);
1026
+ }
1027
+ return ids;
1028
+ }
1029
+
1030
+ /** The event data for the agent's current policy. */
1031
+ policyEvent(agentId: string): PolicyUpdatedData {
1032
+ return policyUpdatedData(access(this.db, agentId));
1033
+ }
1034
+
1035
+ /** After a widening card was answered (allow, deny or cancel): the agent's policy event data; otherwise null. */
1036
+ answeredEditPolicy(decisionId: string): PolicyUpdatedData | null {
1037
+ const row = this.db.query<{ agent_id: string }, [string]>(
1038
+ "SELECT agent_id FROM agent_tool_grant_edits WHERE decision_id = ? AND state IN ('allowed','denied','cancelled')").get(decisionId);
1039
+ return row ? this.policyEvent(row.agent_id) : null;
1040
+ }
1041
+
1042
+ /**
1043
+ * One executor takes the execution. Not yet permission to run: start re-checks everything. A cancelled row is returned
1044
+ * unchanged (a revoke that committed first wins; start reports it). C5 recovery: a reservation of a generation that is not
1045
+ * live in this process never started (start is its own durable transition), so this generation takes it over; the start
1046
+ * CAS still fences on the generation, so the old executor can never start it and the call runs at most once.
1047
+ */
1048
+ reserve(invocationId: string, executorGeneration: number): AgentToolInvocation {
1049
+ return this.db.transaction(() => {
1050
+ const current = this.invocation(invocationId);
1051
+ if (!current) throw new AgentToolPermissionError("invocation_not_found");
1052
+ if (!Number.isSafeInteger(executorGeneration) || executorGeneration < 1) throw new AgentToolPermissionError("invocation_conflict");
1053
+ if (current.state === "cancelled") return current;
1054
+ if (current.state === "reserved" && current.executorGeneration === executorGeneration) {
1055
+ this.liveGenerations.add(executorGeneration);
1056
+ return current;
1057
+ }
1058
+ const takeover = current.state === "reserved" && !this.liveGenerations.has(current.executorGeneration);
1059
+ if (current.state !== "ready" && !takeover) throw new AgentToolPermissionError("invocation_conflict");
1060
+ this.db.query("UPDATE agent_tool_invocations SET state = 'reserved', executor_generation = ?, revision = revision + 1 WHERE id = ? AND state = ? AND executor_generation = ?")
1061
+ .run(executorGeneration, invocationId, current.state, current.executorGeneration);
1062
+ this.liveGenerations.add(executorGeneration);
1063
+ return this.invocation(invocationId)!;
1064
+ }).immediate();
1065
+ }
1066
+
1067
+ /**
1068
+ * Start CAS. Access, authority epoch, lifecycle and the exact grant revision are re-checked in the same transaction a
1069
+ * revoke uses, so whichever commits first wins: revoke first means zero executions.
1070
+ */
1071
+ start(invocationId: string, executorGeneration: number, ack?: { readonly durableSessionId: string }): { readonly state: "started" } | { readonly state: "cancelled"; readonly reason: string } {
1072
+ return this.db.transaction(() => {
1073
+ const current = this.invocation(invocationId);
1074
+ if (!current) throw new AgentToolPermissionError("invocation_not_found");
1075
+ // A revoke that committed first wins over every other check, including the stale session ACK it caused (C5).
1076
+ if (current.state === "cancelled") return { state: "cancelled" as const, reason: "cancelled" };
1077
+ // With an ACK requirement the session must have applied the current revision, checked in the start transaction.
1078
+ if (ack && this.appliedRevision(ack.durableSessionId, current.agentId) !== this.currentRevision(current.agentId)) {
1079
+ throw new AgentToolPermissionError("policy_conflict");
1080
+ }
1081
+ if (current.state === "started" && current.executorGeneration === executorGeneration) return { state: "started" as const };
1082
+ if (current.state !== "reserved" || current.executorGeneration !== executorGeneration) throw new AgentToolPermissionError("invocation_conflict");
1083
+ const view = access(this.db, current.agentId);
1084
+ const grant = current.grantId ? this.grant(current.grantId) : null;
1085
+ const reason = view.accessState === "blocked" ? "agent_access_blocked"
1086
+ : view.authorityEpoch !== current.authorityEpoch ? "authority_epoch_mismatch"
1087
+ : view.lifecycleEpoch !== current.incarnation ? "lifecycle_epoch_mismatch"
1088
+ : !grant || grant.state !== "active" || grant.revision !== current.grantRevision ? "grant_not_active" : null;
1089
+ const now = new Date().toISOString();
1090
+ if (reason || !this.consumeActionTicket(invocationId, current)) {
1091
+ this.db.query("UPDATE agent_tool_invocations SET state = 'cancelled', revision = revision + 1, settled_at = ? WHERE id = ?").run(now, invocationId);
1092
+ return { state: "cancelled" as const, reason: reason ?? "action_ticket_required" };
1093
+ }
1094
+ this.db.query("UPDATE agent_tool_invocations SET state = 'started', revision = revision + 1, started_at = ? WHERE id = ?").run(now, invocationId);
1095
+ return { state: "started" as const };
1096
+ }).immediate();
1097
+ }
1098
+
1099
+ /**
1100
+ * C8, inside the start transaction: an execution without a ticket passes; an action execution passes only by consuming its
1101
+ * own unused ticket of the same agent, lifecycle and authority epoch. A used, revoked or foreign ticket refuses the start.
1102
+ */
1103
+ private consumeActionTicket(invocationId: string, current: AgentToolInvocation): boolean {
1104
+ const ticketId = this.db.query<{ action_ticket_id: string | null }, [string]>("SELECT action_ticket_id FROM agent_tool_invocations WHERE id = ?").get(invocationId)?.action_ticket_id;
1105
+ if (!ticketId) return true;
1106
+ return this.db.query(`UPDATE agent_tool_action_tickets SET state = 'consumed', invocation_id = ?, revision = revision + 1
1107
+ WHERE id = ? AND state = 'unused' AND invocation_id IS NULL AND agent_id = ? AND incarnation = ? AND authority_epoch = ?`)
1108
+ .run(invocationId, ticketId, current.agentId, current.incarnation, current.authorityEpoch).changes === 1;
1109
+ }
1110
+
1111
+ /** Records the owned result once and the resume intent (queued continuation) in the same transaction. */
1112
+ result(invocationId: string, executorGeneration: number, outcome: { state: "succeeded" | "failed" | "outcome_unknown"; resultRef: string }):
1113
+ { invocation: AgentToolInvocation; continuationId: string } {
1114
+ return this.db.transaction(() => {
1115
+ const current = this.invocation(invocationId);
1116
+ if (!current) throw new AgentToolPermissionError("invocation_not_found");
1117
+ const continuation = () => this.db.query<{ id: string }, [string]>("SELECT id FROM agent_tool_continuations WHERE invocation_id = ?").get(invocationId);
1118
+ if (current.state === outcome.state && current.resultRef === outcome.resultRef && current.executorGeneration === executorGeneration) {
1119
+ return { invocation: current, continuationId: continuation()!.id };
1120
+ }
1121
+ if (current.state !== "started" || current.executorGeneration !== executorGeneration) throw new AgentToolPermissionError("invocation_conflict");
1122
+ this.db.query("UPDATE agent_tool_invocations SET state = ?, result_ref = ?, revision = revision + 1, settled_at = ? WHERE id = ?")
1123
+ .run(outcome.state, outcome.resultRef, new Date().toISOString(), invocationId);
1124
+ const request = current.requestId ? this.requestRow("id = ?", current.requestId) : null;
1125
+ if (!request) throw new AgentToolPermissionError("tool_request_not_found");
1126
+ const id = crypto.randomUUID();
1127
+ this.db.query(`INSERT INTO agent_tool_continuations (id, invocation_id, conv_id, origin_input_seq, durable_session_id, state, revision)
1128
+ VALUES (?, ?, ?, ?, ?, 'queued', 1)`).run(id, invocationId, request.conv_id, request.origin_input_seq, request.durable_session_id);
1129
+ return { invocation: this.invocation(invocationId)!, continuationId: id };
1130
+ }).immediate();
1131
+ }
1132
+
1133
+ /** GET /api/agents/:id/tool-permissions page: 50 grants per page, cursor bound to agent and policy revision. */
1134
+ list(agentId: string, cursor: string | null): { items: AgentToolGrantRow[]; pending: AgentToolRequestRecord[]; policy: AgentAccessView; nextCursor: string | null } {
1135
+ return this.db.transaction(() => {
1136
+ const policy = access(this.db, agentId);
1137
+ let offset = 0;
1138
+ if (cursor !== null) {
1139
+ let parsed: unknown;
1140
+ try {
1141
+ parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
1142
+ } catch {
1143
+ throw new AgentToolPermissionError("cursor_invalid");
1144
+ }
1145
+ if (!record(parsed) || parsed.agentId !== agentId || typeof parsed.offset !== "number" || !Number.isSafeInteger(parsed.offset) || parsed.offset < 0) {
1146
+ throw new AgentToolPermissionError("cursor_invalid");
1147
+ }
1148
+ if (parsed.policyRevision !== policy.policyRevision) throw new AgentToolPermissionError("cursor_stale");
1149
+ offset = parsed.offset;
1150
+ }
1151
+ const rows = this.db.query<GrantRow, [string, number, number]>(
1152
+ "SELECT * FROM agent_tool_grants WHERE agent_id = ? AND state != 'revoked' ORDER BY created_at, id LIMIT ? OFFSET ?",
1153
+ ).all(agentId, PAGE + 1, offset);
1154
+ const items = rows.slice(0, PAGE).map(grantRecord);
1155
+ const nextCursor = rows.length > PAGE
1156
+ ? Buffer.from(JSON.stringify({ agentId, policyRevision: policy.policyRevision, offset: offset + PAGE })).toString("base64url") : null;
1157
+ const pending = this.db.query<RequestRow, [string]>("SELECT * FROM agent_tool_requests WHERE agent_id = ? AND state = 'pending' ORDER BY id")
1158
+ .all(agentId).map(requestRecord);
1159
+ return { items, pending, policy, nextCursor };
1160
+ })();
1161
+ }
1162
+ }
1163
+
1164
+ /** Where the request came in: the trusted (web) listener, or the app port with its gated device. */
1165
+ export interface AgentToolRequestContext {
1166
+ readonly trusted: boolean;
1167
+ readonly device: DeviceRecord | null;
1168
+ }
1169
+
1170
+ export interface AgentToolClientDeps {
1171
+ readonly webOrigins: readonly string[];
1172
+ /** Random per gateway process; a restart invalidates every web client session. */
1173
+ readonly generation: string;
1174
+ /** The current device row (existing Store device registry). */
1175
+ readonly currentDevice: (id: string) => DeviceRecord | null;
1176
+ /** Existing device credential revision (src/attachments/service.ts deviceRevision). */
1177
+ readonly deviceRevision: (device: DeviceRecord) => string;
1178
+ }
1179
+
1180
+ export const AGENT_TOOL_COOKIE = "zigmini_agent_tool";
1181
+ export const AGENT_TOOL_CLIENT_HEADER = "x-zigmini-agent-tool-client";
1182
+ export const AGENT_TOOL_NONCE_HEADER = "x-zigmini-page-nonce";
1183
+ const PAGES_PER_SESSION = 32;
1184
+
1185
+ const refuse = (error: string, status: number) => Response.json({ error }, { status, headers: { "Cache-Control": "no-store" } });
1186
+
1187
+ function cookie(headers: Headers): string | null {
1188
+ for (const part of (headers.get("cookie") ?? "").split(";")) {
1189
+ const [name, value] = part.trim().split("=");
1190
+ if (name === AGENT_TOOL_COOKIE && value) return value;
1191
+ }
1192
+ return null;
1193
+ }
1194
+
1195
+ /**
1196
+ * C3 caller helper. Web: HttpOnly SameSite=Strict cookie bound to this gateway generation, a page nonce bound to that
1197
+ * session, exact Origin and Host, and a custom header (CSRF). Android: the existing approved device credential, re-read
1198
+ * on every call. Sessions live in memory only; a restart drops them and nothing is replayed.
1199
+ */
1200
+ export class AgentToolClients {
1201
+ private readonly sessions = new Map<string, { generation: string; pages: string[] }>();
1202
+ private readonly hosts: ReadonlySet<string>;
1203
+
1204
+ constructor(private readonly deps: AgentToolClientDeps) {
1205
+ this.hosts = new Set(deps.webOrigins.map((origin) => new URL(origin).host));
1206
+ }
1207
+
1208
+ private webRefusal(req: Request, ctx: AgentToolRequestContext): Response | null {
1209
+ if (!ctx.trusted || ctx.device) return refuse("forbidden", 403);
1210
+ if (!this.hosts.has(req.headers.get("host") ?? "")) return refuse("forbidden_host", 403);
1211
+ // D8: browsers send no Origin on a same-origin GET. Such a GET still needs the Host above, the custom header below,
1212
+ // and the Strict cookie and page nonce (principal). A sent Origin must match, a sent Sec-Fetch-Site must be
1213
+ // same-origin, and every other method keeps requiring the matching Origin.
1214
+ const site = req.headers.get("sec-fetch-site");
1215
+ if (site !== null && site !== "same-origin") return refuse("forbidden_origin", 403);
1216
+ const origin = req.headers.get("origin");
1217
+ if (origin === null ? req.method !== "GET" : !this.deps.webOrigins.includes(origin)) return refuse("forbidden_origin", 403);
1218
+ if (req.headers.get(AGENT_TOOL_CLIENT_HEADER) !== "1") return refuse("custom_header_required", 403);
1219
+ return null;
1220
+ }
1221
+
1222
+ private device(ctx: AgentToolRequestContext): { device: DeviceRecord; revision: string } | null {
1223
+ if (ctx.trusted || !ctx.device) return null;
1224
+ const current = this.deps.currentDevice(ctx.device.id);
1225
+ if (!current || current.status !== "approved") return null;
1226
+ const revision = this.deps.deviceRevision(current);
1227
+ return revision === this.deps.deviceRevision(ctx.device) ? { device: current, revision } : null;
1228
+ }
1229
+
1230
+ /** POST /api/agent-tool-client. Never exposed to tools: the internal listener routes do not reach it. */
1231
+ issue(req: Request, ctx: AgentToolRequestContext): Response {
1232
+ if (req.method !== "POST") return refuse("method_not_allowed", 405);
1233
+ if (!ctx.trusted) {
1234
+ const device = this.device(ctx);
1235
+ if (!device) return refuse("device_not_approved", 401);
1236
+ return Response.json({ client: "android", credentialRevision: device.revision }, { headers: { "Cache-Control": "no-store" } });
1237
+ }
1238
+ const denied = this.webRefusal(req, ctx);
1239
+ if (denied) return denied;
1240
+ let value = cookie(req.headers);
1241
+ let session = value ? this.sessions.get(sha256(value)) : undefined;
1242
+ let setCookie: string | null = null;
1243
+ if (!value || !session || session.generation !== this.deps.generation) {
1244
+ value = randomBytes(32).toString("hex");
1245
+ session = { generation: this.deps.generation, pages: [] };
1246
+ this.sessions.set(sha256(value), session);
1247
+ setCookie = `${AGENT_TOOL_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict`;
1248
+ }
1249
+ const pageNonce = randomBytes(24).toString("base64url");
1250
+ session.pages.push(pageNonce);
1251
+ if (session.pages.length > PAGES_PER_SESSION) session.pages.shift();
1252
+ const headers: Record<string, string> = { "Cache-Control": "no-store" };
1253
+ if (setCookie) headers["set-cookie"] = setCookie;
1254
+ return Response.json({ client: "web", pageNonce, generation: this.deps.generation }, { headers });
1255
+ }
1256
+
1257
+ /** The verified owner of this request, or the refusal to return. */
1258
+ principal(req: Request, ctx: AgentToolRequestContext): OwnerPrincipal | Response {
1259
+ if (!ctx.trusted) {
1260
+ const device = this.device(ctx);
1261
+ if (!device) return refuse("device_not_approved", 401);
1262
+ return { ownerRef: "owner", client: "android", credentialRevision: device.revision, sessionRef: `device:${device.device.id}` };
1263
+ }
1264
+ const denied = this.webRefusal(req, ctx);
1265
+ if (denied) return denied;
1266
+ const value = cookie(req.headers);
1267
+ const key = value ? sha256(value) : null;
1268
+ const session = key ? this.sessions.get(key) : undefined;
1269
+ if (!key || !session || session.generation !== this.deps.generation) return refuse("agent_tool_client_required", 403);
1270
+ const nonce = req.headers.get(AGENT_TOOL_NONCE_HEADER);
1271
+ if (!nonce || !session.pages.includes(nonce)) return refuse("page_nonce_invalid", 403);
1272
+ return { ownerRef: "owner", client: "web", credentialRevision: this.deps.generation, sessionRef: `web:${key}` };
1273
+ }
1274
+
1275
+ /** Explicit client revoke (browser session end). */
1276
+ revoke(sessionRef: string): boolean {
1277
+ return sessionRef.startsWith("web:") && this.sessions.delete(sessionRef.slice(4));
1278
+ }
1279
+ }
1280
+
1281
+ export interface AgentToolHttpDeps {
1282
+ readonly ledger: AgentToolPermissionStore;
1283
+ readonly clients: AgentToolClients;
1284
+ /** Publishes inbox changes (closed cards after revoke-all). */
1285
+ readonly changed: () => void;
1286
+ /** The agent's migration card (created once, then the same card), or null for a managed agent (Hub.ensureToolMigration). */
1287
+ readonly migration?: (agentId: string) => AgentToolMigrationCard | null;
1288
+ /** The grant scope edit in one Store transaction (Store.editAgentToolGrant). Without it the edit route is 404. */
1289
+ readonly editGrant?: (agentId: string, grantId: string, input: GrantEditInput, principal: OwnerPrincipal) => GrantEditResult;
1290
+ /** Publishes agent.tool_permissions.updated (C10). */
1291
+ readonly policyChanged?: (data: PolicyUpdatedData) => void;
1292
+ }
1293
+
1294
+ export function agentToolRoute(pathname: string): boolean {
1295
+ return pathname === "/api/agent-tool-client" || /^\/api\/agents\/[^/]+\/tool-permissions(?:\/|$)/.test(pathname);
1296
+ }
1297
+
1298
+ const BODY_LIMIT = 16 * 1024;
1299
+
1300
+ async function body(req: Request): Promise<Record<string, unknown>> {
1301
+ if (Number(req.headers.get("content-length") ?? 0) > BODY_LIMIT) throw new AgentToolPermissionError("tool_request_invalid");
1302
+ const text = await req.text();
1303
+ if (Buffer.byteLength(text) > BODY_LIMIT) throw new AgentToolPermissionError("tool_request_invalid");
1304
+ let value: unknown = null;
1305
+ try {
1306
+ value = JSON.parse(text);
1307
+ } catch {
1308
+ throw new AgentToolPermissionError("tool_request_invalid");
1309
+ }
1310
+ if (!record(value)) throw new AgentToolPermissionError("tool_request_invalid");
1311
+ return value;
1312
+ }
1313
+
1314
+ /** Owner routes of C3: client nonce, list, edit, revoke, revoke-all and migrate. */
1315
+ export async function agentToolHttp(req: Request, ctx: AgentToolRequestContext, deps: AgentToolHttpDeps): Promise<Response> {
1316
+ const url = new URL(req.url);
1317
+ if (url.pathname === "/api/agent-tool-client") return deps.clients.issue(req, ctx);
1318
+ const migrate = /^\/api\/agents\/([^/]+)\/tool-permissions\/migrate$/.exec(url.pathname);
1319
+ const route = migrate ? null : /^\/api\/agents\/([^/]+)\/tool-permissions(?:\/(revoke-all)|\/([^/]+)\/(revoke|edit))?$/.exec(url.pathname);
1320
+ if ((!route && !(migrate && deps.migration)) || (route?.[4] === "edit" && !deps.editGrant)) return refuse("not_found", 404);
1321
+ const principal = deps.clients.principal(req, ctx);
1322
+ if (principal instanceof Response) return principal;
1323
+ try {
1324
+ if (migrate && deps.migration) {
1325
+ // C3 migrate: shows the existing scopes on an inbox card. It never grants anything by itself.
1326
+ if (req.method !== "POST") return refuse("method_not_allowed", 405);
1327
+ const input = await body(req);
1328
+ if (typeof input.operationId !== "string" || !UUID.test(input.operationId)) throw new AgentToolPermissionError("tool_request_invalid");
1329
+ const card = deps.migration(decodeURIComponent(migrate[1]!));
1330
+ if (!card) return refuse("agent_managed", 409);
1331
+ deps.changed();
1332
+ return Response.json(card, { headers: { "Cache-Control": "no-store" } });
1333
+ }
1334
+ if (!route) return refuse("not_found", 404);
1335
+ const agentId = decodeURIComponent(route[1]!);
1336
+ if (!route[2] && !route[4]) {
1337
+ if (req.method !== "GET") return refuse("method_not_allowed", 405);
1338
+ return Response.json(deps.ledger.list(agentId, url.searchParams.get("cursor")), { headers: { "Cache-Control": "no-store" } });
1339
+ }
1340
+ if (req.method !== "POST") return refuse("method_not_allowed", 405);
1341
+ const input = await body(req);
1342
+ if (typeof input.operationId !== "string") throw new AgentToolPermissionError("tool_request_invalid");
1343
+ if (route[2]) {
1344
+ if (typeof input.expectedPolicyRevision !== "number") throw new AgentToolPermissionError("tool_request_invalid");
1345
+ const result = deps.ledger.revokeAll(agentId, { operationId: input.operationId, expectedPolicyRevision: input.expectedPolicyRevision }, principal);
1346
+ deps.changed();
1347
+ deps.policyChanged?.(deps.ledger.policyEvent(agentId));
1348
+ return Response.json(result);
1349
+ }
1350
+ if (route[4] === "edit" && deps.editGrant) {
1351
+ const edited = deps.editGrant(agentId, decodeURIComponent(route[3]!), parseGrantEdit(input), principal);
1352
+ deps.changed();
1353
+ if (edited.status === 200) deps.policyChanged?.(deps.ledger.policyEvent(agentId));
1354
+ return Response.json(edited.body, { status: edited.status, headers: { "Cache-Control": "no-store" } });
1355
+ }
1356
+ if (typeof input.expectedRevision !== "number") throw new AgentToolPermissionError("tool_request_invalid");
1357
+ const result = deps.ledger.revokeGrant(agentId, decodeURIComponent(route[3]!), { operationId: input.operationId, expectedRevision: input.expectedRevision }, principal);
1358
+ deps.changed();
1359
+ deps.policyChanged?.(deps.ledger.policyEvent(agentId));
1360
+ return Response.json(result);
1361
+ } catch (error) {
1362
+ if (error instanceof AgentToolPermissionError) return refuse(error.code, error.status);
1363
+ if (error instanceof URIError) return refuse("tool_request_invalid", 400);
1364
+ throw error;
1365
+ }
1366
+ }