delegation.mjs
1,250 lines 41.0 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 11 days ago
1 /**
2 * Agent delegation gate — identity registry, consent proposals, grant mint/revoke,
3 * audit append, and delegation chain validation (Phase 7C-6).
4 *
5 * Canonical records: agent_identity, delegation_consent, delegation_grant,
6 * delegation_audit. All gated by `DELEGATION_ENABLED` (default **off**).
7 *
8 * @see docs/AGENT-DELEGATION-V0-SPEC.md
9 */
10
11 import fs from 'fs';
12 import path from 'path';
13 import { createHash, randomBytes } from 'crypto';
14
15 export const DELEGATION_POLICY_FILE = 'hub_delegation_policy.json';
16 export const DELEGATION_IDENTITIES_FILE = 'hub_delegation_identities.json';
17 export const DELEGATION_CONSENTS_FILE = 'hub_delegation_consents.json';
18 export const DELEGATION_GRANTS_FILE = 'hub_delegation_grants.json';
19 export const DELEGATION_AUDIT_FILE = 'hub_delegation_audit.json';
20
21 export const AGENT_IDENTITY_SCHEMA = 'knowtation.agent_identity/v0';
22 export const DELEGATION_CONSENT_SCHEMA = 'knowtation.delegation_consent/v0';
23 export const DELEGATION_GRANT_SCHEMA = 'knowtation.delegation_grant/v0';
24 export const DELEGATION_AUDIT_SCHEMA = 'knowtation.delegation_audit/v0';
25 export const DELEGATION_GRANT_MINT_SCHEMA = 'knowtation.delegation_grant_mint/v0';
26
27 export const AGENT_ID_PREFIX = 'agent_';
28 export const CONSENT_ID_PREFIX = 'dcons_';
29 export const GRANT_ID_PREFIX = 'dgrnt_';
30 export const AUDIT_ID_PREFIX = 'daud_';
31 export const GRANT_BEARER_PREFIX = 'dgrnt_bearer_';
32
33 export const DEFAULT_TTL_SECONDS = 3600;
34 export const MAX_TTL_SECONDS = 86400;
35
36 export const DELEGATION_PROPOSAL_SOURCE = 'delegation';
37 export const DELEGATION_REVIEW_QUEUE = 'delegation';
38
39 /** @typedef {'personal' | 'project' | 'org'} Scope */
40 /** @typedef {'user_owned' | 'org_owned' | 'delegate' | 'external_provider'} AgentKind */
41 /** @typedef {'active' | 'suspended' | 'revoked'} IdentityStatus */
42 /** @typedef {'advance_step' | 'complete_task' | 'propose_outcome' | 'invoke_tool' | 'mint_subgrant' | 'external_claim' | 'external_complete' | 'external_needs_input' | 'external_boundary_stop'} AuditAction */
43 /** @typedef {'local' | 'hosted' | 'hybrid'} ExecutionLocation */
44
45 const AGENT_KINDS = new Set(['user_owned', 'org_owned', 'delegate', 'external_provider']);
46 const IDENTITY_STATUSES = new Set(['active', 'suspended', 'revoked']);
47 const SCOPES = new Set(['personal', 'project', 'org']);
48 const AUDIT_ACTIONS = new Set([
49 'advance_step',
50 'complete_task',
51 'propose_outcome',
52 'invoke_tool',
53 'mint_subgrant',
54 'external_claim',
55 'external_complete',
56 'external_needs_input',
57 'external_boundary_stop',
58 ]);
59 const EXECUTION_LOCATIONS = new Set(['local', 'hosted', 'hybrid']);
60
61 const ID_TOKEN_RE = /^[a-z0-9_]{8,48}$/;
62 const SEMVER_RE = /^\d+\.\d+\.\d+(-[a-zA-Z0-9._-]+)?(\+[a-zA-Z0-9._-]+)?$/;
63
64 /**
65 * @param {unknown} v
66 * @returns {boolean|null}
67 */
68 function envTriState(v) {
69 if (v === '1' || v === 'true') return true;
70 if (v === '0' || v === 'false') return false;
71 return null;
72 }
73
74 /**
75 * @param {string} dataDir
76 * @returns {object}
77 */
78 export function readDelegationPolicyFile(dataDir) {
79 if (!dataDir) return {};
80 const fp = path.join(dataDir, DELEGATION_POLICY_FILE);
81 try {
82 if (!fs.existsSync(fp)) return {};
83 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
84 return j && typeof j === 'object' ? j : {};
85 } catch {
86 return {};
87 }
88 }
89
90 /**
91 * @param {string} dataDir
92 * @returns {boolean}
93 */
94 export function getDelegationEnabled(dataDir) {
95 const fromEnv = envTriState(process.env.DELEGATION_ENABLED);
96 if (fromEnv !== null) return fromEnv;
97 const policy = readDelegationPolicyFile(dataDir);
98 const d = policy.delegation;
99 if (d && typeof d === 'object' && typeof d.enabled === 'boolean') {
100 return d.enabled;
101 }
102 return false;
103 }
104
105 /**
106 * @param {string} dataDir
107 * @returns {boolean}
108 */
109 export function getDelegationPolicyForbidden(dataDir) {
110 const fromEnv = envTriState(process.env.DELEGATION_POLICY_FORBIDDEN);
111 if (fromEnv !== null) return fromEnv;
112 const policy = readDelegationPolicyFile(dataDir);
113 const d = policy.delegation;
114 if (d && typeof d === 'object' && typeof d.forbidden === 'boolean') {
115 return d.forbidden;
116 }
117 return false;
118 }
119
120 /**
121 * @param {string} dataDir
122 * @returns {{ defaultTtlSeconds: number, maxTtlSeconds: number }}
123 */
124 export function readVaultDelegationPolicy(dataDir) {
125 const policy = readDelegationPolicyFile(dataDir);
126 const d = policy.delegation && typeof policy.delegation === 'object' ? policy.delegation : {};
127 const defaultTtl =
128 typeof d.default_ttl_seconds === 'number' && d.default_ttl_seconds > 0
129 ? d.default_ttl_seconds
130 : DEFAULT_TTL_SECONDS;
131 const maxTtl =
132 typeof d.max_ttl_seconds === 'number' && d.max_ttl_seconds > 0
133 ? d.max_ttl_seconds
134 : MAX_TTL_SECONDS;
135 return { defaultTtlSeconds: defaultTtl, maxTtlSeconds: maxTtl };
136 }
137
138 /**
139 * Derive hashed principal ref from verified session user id — never accept client principal.
140 *
141 * @param {string} userId
142 * @returns {string}
143 */
144 export function hashPrincipalRef(userId) {
145 const trimmed = typeof userId === 'string' ? userId.trim() : '';
146 const hash = createHash('sha256').update(trimmed, 'utf8').digest('hex');
147 return `uid_hash:${hash}`;
148 }
149
150 /**
151 * @param {Scope} a
152 * @param {Scope} b
153 * @returns {Scope|null}
154 */
155 export function intersectScope(a, b) {
156 const order = { personal: 0, project: 1, org: 2 };
157 const oa = order[a];
158 const ob = order[b];
159 if (oa === undefined || ob === undefined) return null;
160 return oa <= ob ? a : b;
161 }
162
163 /**
164 * @param {Scope} ceiling
165 * @param {Scope} requested
166 * @returns {Scope|null}
167 */
168 export function effectiveScope(ceiling, requested) {
169 return intersectScope(ceiling, requested);
170 }
171
172 /**
173 * @param {string} bearer
174 * @returns {string}
175 */
176 export function hashGrantBearer(bearer) {
177 return createHash('sha256').update(bearer, 'utf8').digest('hex');
178 }
179
180 /**
181 * @param {string} prefix
182 * @param {number} [byteLen]
183 * @returns {string}
184 */
185 function randomToken(prefix, byteLen = 16) {
186 const token = randomBytes(byteLen)
187 .toString('base64url')
188 .replace(/[^a-z0-9]/gi, '')
189 .toLowerCase()
190 .slice(0, 24);
191 return prefix + (token.length >= 8 ? token : token.padEnd(8, '0'));
192 }
193
194 /**
195 * @param {string} id
196 * @param {string} prefix
197 * @returns {boolean}
198 */
199 function isValidId(id, prefix) {
200 if (typeof id !== 'string' || !id.startsWith(prefix)) return false;
201 const token = id.slice(prefix.length);
202 return ID_TOKEN_RE.test(token);
203 }
204
205 /**
206 * @param {string} ref
207 * @returns {boolean}
208 */
209 function isValidOwnerRef(ref) {
210 if (typeof ref !== 'string') return false;
211 if (ref.startsWith('uid_hash:')) {
212 return /^uid_hash:[0-9a-f]{64}$/.test(ref);
213 }
214 if (ref.startsWith('org_ref:')) {
215 return ref.length > 'org_ref:'.length;
216 }
217 return false;
218 }
219
220 /**
221 * @param {object} record
222 * @returns {{ ok: true } | { ok: false, error: string }}
223 */
224 export function validateAgentIdentityRecord(record) {
225 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
226 if (record.schema !== AGENT_IDENTITY_SCHEMA) return { ok: false, error: 'schema mismatch' };
227 if (!isValidId(record.agent_id, AGENT_ID_PREFIX)) return { ok: false, error: 'invalid agent_id' };
228 if (!AGENT_KINDS.has(record.kind)) return { ok: false, error: 'invalid kind' };
229 if (!isValidOwnerRef(record.owner_ref)) return { ok: false, error: 'invalid owner_ref' };
230 if (typeof record.vault_id !== 'string' || !record.vault_id.trim()) {
231 return { ok: false, error: 'invalid vault_id' };
232 }
233 if (!SCOPES.has(record.scope_ceiling)) return { ok: false, error: 'invalid scope_ceiling' };
234 if (!IDENTITY_STATUSES.has(record.status)) return { ok: false, error: 'invalid status' };
235 if (typeof record.created !== 'string' || typeof record.updated !== 'string') {
236 return { ok: false, error: 'invalid timestamps' };
237 }
238 return { ok: true };
239 }
240
241 /**
242 * @param {object} record
243 * @returns {{ ok: true } | { ok: false, error: string }}
244 */
245 export function validateConsentRecord(record) {
246 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
247 if (record.schema !== DELEGATION_CONSENT_SCHEMA) return { ok: false, error: 'schema mismatch' };
248 if (!isValidId(record.consent_id, CONSENT_ID_PREFIX)) return { ok: false, error: 'invalid consent_id' };
249 if (!isValidOwnerRef(record.principal_ref)) return { ok: false, error: 'invalid principal_ref' };
250 if (!isValidId(record.delegate_agent_id, AGENT_ID_PREFIX)) {
251 return { ok: false, error: 'invalid delegate_agent_id' };
252 }
253 if (!SCOPES.has(record.scope)) return { ok: false, error: 'invalid scope' };
254 if (record.scope === 'project' || record.scope === 'org') {
255 if (typeof record.workspace_id !== 'string' || !record.workspace_id.trim()) {
256 return { ok: false, error: 'workspace_id required for project/org scope' };
257 }
258 }
259 if (record.revoked_at !== null && typeof record.revoked_at !== 'string') {
260 return { ok: false, error: 'invalid revoked_at' };
261 }
262 if (typeof record.evidence_ref !== 'string' || !record.evidence_ref.startsWith('proposal:')) {
263 return { ok: false, error: 'invalid evidence_ref' };
264 }
265 if (typeof record.created !== 'string') return { ok: false, error: 'invalid created' };
266 return { ok: true };
267 }
268
269 /**
270 * @param {object} record
271 * @returns {{ ok: true } | { ok: false, error: string }}
272 */
273 export function validateGrantRecord(record) {
274 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
275 if (record.schema !== DELEGATION_GRANT_SCHEMA) return { ok: false, error: 'schema mismatch' };
276 if (!isValidId(record.grant_id, GRANT_ID_PREFIX)) return { ok: false, error: 'invalid grant_id' };
277 if (!isValidId(record.consent_id, CONSENT_ID_PREFIX)) return { ok: false, error: 'invalid consent_id' };
278 if (!isValidId(record.actor_agent_id, AGENT_ID_PREFIX)) {
279 return { ok: false, error: 'invalid actor_agent_id' };
280 }
281 if (!isValidOwnerRef(record.principal_ref)) return { ok: false, error: 'invalid principal_ref' };
282 if (!SCOPES.has(record.scope)) return { ok: false, error: 'invalid scope' };
283 if (record.flow_id && !record.flow_version) return { ok: false, error: 'flow_version required' };
284 if (record.flow_version && !SEMVER_RE.test(record.flow_version)) {
285 return { ok: false, error: 'invalid flow_version' };
286 }
287 if (record.revoked_at !== null && typeof record.revoked_at !== 'string') {
288 return { ok: false, error: 'invalid revoked_at' };
289 }
290 if (typeof record.action_count !== 'number' || record.action_count < 0) {
291 return { ok: false, error: 'invalid action_count' };
292 }
293 if (typeof record.expires_at !== 'string' || typeof record.issued_at !== 'string') {
294 return { ok: false, error: 'invalid timestamps' };
295 }
296 return { ok: true };
297 }
298
299 /**
300 * @param {object} record
301 * @returns {{ ok: true } | { ok: false, error: string }}
302 */
303 export function validateAuditRecord(record) {
304 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
305 if (record.schema !== DELEGATION_AUDIT_SCHEMA) return { ok: false, error: 'schema mismatch' };
306 if (!isValidId(record.audit_id, AUDIT_ID_PREFIX)) return { ok: false, error: 'invalid audit_id' };
307 if (!isValidId(record.grant_id, GRANT_ID_PREFIX)) return { ok: false, error: 'invalid grant_id' };
308 if (!isValidId(record.actor_agent_id, AGENT_ID_PREFIX)) {
309 return { ok: false, error: 'invalid actor_agent_id' };
310 }
311 if (!isValidOwnerRef(record.principal_ref)) return { ok: false, error: 'invalid principal_ref' };
312 if (!AUDIT_ACTIONS.has(record.action)) return { ok: false, error: 'invalid action' };
313 if (!Array.isArray(record.evidence_refs)) return { ok: false, error: 'invalid evidence_refs' };
314 if (typeof record.occurred_at !== 'string') return { ok: false, error: 'invalid occurred_at' };
315 if (
316 record.execution_location != null &&
317 !EXECUTION_LOCATIONS.has(record.execution_location)
318 ) {
319 return { ok: false, error: 'invalid execution_location' };
320 }
321 return { ok: true };
322 }
323
324 /**
325 * @param {object} consent
326 * @returns {'active' | 'revoked' | 'expired'}
327 */
328 export function resolveConsentStatus(consent) {
329 if (consent.revoked_at) return 'revoked';
330 if (consent.expires_at) {
331 const exp = Date.parse(consent.expires_at);
332 if (Number.isFinite(exp) && Date.now() > exp) return 'expired';
333 }
334 return 'active';
335 }
336
337 /**
338 * @param {object} grant
339 * @returns {'active' | 'revoked' | 'expired' | 'exhausted'}
340 */
341 export function resolveGrantStatus(grant) {
342 if (grant.revoked_at) return 'revoked';
343 const exp = Date.parse(grant.expires_at);
344 if (!Number.isFinite(exp) || Date.now() > exp) return 'expired';
345 if (
346 typeof grant.max_actions === 'number' &&
347 grant.max_actions > 0 &&
348 grant.action_count >= grant.max_actions
349 ) {
350 return 'exhausted';
351 }
352 return 'active';
353 }
354
355 /**
356 * Strip internal fields from stored grant for client responses.
357 *
358 * @param {object} stored
359 * @returns {object}
360 */
361 export function grantForClient(stored) {
362 const { grant_bearer_hash: _b, ...grant } = stored;
363 return grant;
364 }
365
366 /**
367 * @param {string} dataDir
368 * @param {string} filename
369 * @returns {{ vaults: Record<string, object> }}
370 */
371 function loadVaultStore(dataDir, filename) {
372 const fp = path.join(dataDir, filename);
373 if (!fs.existsSync(fp)) return { vaults: {} };
374 try {
375 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
376 if (!j || typeof j !== 'object') return { vaults: {} };
377 return { vaults: j.vaults && typeof j.vaults === 'object' ? j.vaults : {} };
378 } catch {
379 return { vaults: {} };
380 }
381 }
382
383 /**
384 * @param {string} dataDir
385 * @param {string} filename
386 * @param {{ vaults: Record<string, object> }} store
387 */
388 function saveVaultStore(dataDir, filename, store) {
389 const fp = path.join(dataDir, filename);
390 fs.mkdirSync(path.dirname(fp), { recursive: true });
391 fs.writeFileSync(fp, JSON.stringify(store, null, 2), 'utf8');
392 }
393
394 /**
395 * @param {string} dataDir
396 * @returns {{ vaults: Record<string, { identities: object[] }> }}
397 */
398 export function loadIdentitiesStore(dataDir) {
399 const raw = loadVaultStore(dataDir, DELEGATION_IDENTITIES_FILE);
400 for (const v of Object.values(raw.vaults)) {
401 if (!Array.isArray(v.identities)) v.identities = [];
402 }
403 return /** @type {{ vaults: Record<string, { identities: object[] }> }} */ (raw);
404 }
405
406 /**
407 * @param {string} dataDir
408 * @param {{ vaults: Record<string, { identities: object[] }> }} store
409 */
410 export function saveIdentitiesStore(dataDir, store) {
411 saveVaultStore(dataDir, DELEGATION_IDENTITIES_FILE, store);
412 }
413
414 /**
415 * @param {string} dataDir
416 * @returns {{ vaults: Record<string, { consents: object[] }> }}
417 */
418 export function loadConsentsStore(dataDir) {
419 const raw = loadVaultStore(dataDir, DELEGATION_CONSENTS_FILE);
420 for (const v of Object.values(raw.vaults)) {
421 if (!Array.isArray(v.consents)) v.consents = [];
422 }
423 return /** @type {{ vaults: Record<string, { consents: object[] }> }} */ (raw);
424 }
425
426 /**
427 * @param {string} dataDir
428 * @param {{ vaults: Record<string, { consents: object[] }> }} store
429 */
430 export function saveConsentsStore(dataDir, store) {
431 saveVaultStore(dataDir, DELEGATION_CONSENTS_FILE, store);
432 }
433
434 /**
435 * @param {string} dataDir
436 * @returns {{ vaults: Record<string, { grants: object[] }> }}
437 */
438 export function loadGrantsStore(dataDir) {
439 const raw = loadVaultStore(dataDir, DELEGATION_GRANTS_FILE);
440 for (const v of Object.values(raw.vaults)) {
441 if (!Array.isArray(v.grants)) v.grants = [];
442 }
443 return /** @type {{ vaults: Record<string, { grants: object[] }> }} */ (raw);
444 }
445
446 /**
447 * @param {string} dataDir
448 * @param {{ vaults: Record<string, { grants: object[] }> }} store
449 */
450 export function saveGrantsStore(dataDir, store) {
451 saveVaultStore(dataDir, DELEGATION_GRANTS_FILE, store);
452 }
453
454 /**
455 * @param {string} dataDir
456 * @returns {{ vaults: Record<string, { audits: object[] }> }}
457 */
458 export function loadAuditStore(dataDir) {
459 const raw = loadVaultStore(dataDir, DELEGATION_AUDIT_FILE);
460 for (const v of Object.values(raw.vaults)) {
461 if (!Array.isArray(v.audits)) v.audits = [];
462 }
463 return /** @type {{ vaults: Record<string, { audits: object[] }> }} */ (raw);
464 }
465
466 /**
467 * @param {string} dataDir
468 * @param {{ vaults: Record<string, { audits: object[] }> }} store
469 */
470 export function saveAuditStore(dataDir, store) {
471 saveVaultStore(dataDir, DELEGATION_AUDIT_FILE, store);
472 }
473
474 /**
475 * @param {string} dataDir
476 * @param {string} vaultId
477 * @param {string} agentId
478 * @returns {object|null}
479 */
480 export function getAgentIdentity(dataDir, vaultId, agentId) {
481 const store = loadIdentitiesStore(dataDir);
482 const vault = store.vaults[vaultId];
483 if (!vault) return null;
484 return vault.identities.find((i) => i.agent_id === agentId) ?? null;
485 }
486
487 /**
488 * @param {string} dataDir
489 * @param {string} vaultId
490 * @param {string} consentId
491 * @returns {object|null}
492 */
493 export function getConsent(dataDir, vaultId, consentId) {
494 const store = loadConsentsStore(dataDir);
495 const vault = store.vaults[vaultId];
496 if (!vault) return null;
497 return vault.consents.find((c) => c.consent_id === consentId) ?? null;
498 }
499
500 /**
501 * @param {string} dataDir
502 * @param {string} vaultId
503 * @param {string} grantId
504 * @returns {object|null}
505 */
506 export function getGrant(dataDir, vaultId, grantId) {
507 const store = loadGrantsStore(dataDir);
508 const vault = store.vaults[vaultId];
509 if (!vault) return null;
510 return vault.grants.find((g) => g.grant_id === grantId) ?? null;
511 }
512
513 /**
514 * @param {object} ctx
515 * @returns {{ ok: false, status: number, error: string, code: string }}
516 */
517 function refuse(status, code, error) {
518 return { ok: false, status, error, code };
519 }
520
521 /**
522 * Gate check shared by mutating handlers.
523 *
524 * @param {string} dataDir
525 * @returns {{ ok: true } | { ok: false, status: number, error: string, code: string }}
526 */
527 function checkDelegationGate(dataDir) {
528 if (getDelegationPolicyForbidden(dataDir)) {
529 return refuse(403, 'DELEGATION_POLICY_FORBIDDEN', 'Delegation forbidden by policy');
530 }
531 if (!getDelegationEnabled(dataDir)) {
532 return refuse(403, 'DELEGATION_DISABLED', 'Delegation gate is disabled');
533 }
534 return { ok: true };
535 }
536
537 /**
538 * @param {string[]} allowlist
539 * @param {string} value
540 * @returns {boolean}
541 */
542 function allowlistPermits(allowlist, value) {
543 if (!Array.isArray(allowlist) || allowlist.length === 0) return true;
544 return allowlist.includes(value);
545 }
546
547 /**
548 * Validate delegation chain reconstructability per spec §2.
549 *
550 * @param {{
551 * dataDir: string,
552 * vaultId: string,
553 * actorAgentId: string,
554 * principalRef: string,
555 * grantId?: string,
556 * taskRef?: string,
557 * runRef?: string,
558 * flowId?: string,
559 * flowVersion?: string,
560 * requireGrant?: boolean,
561 * }} input
562 * @returns {{ ok: true, grant?: object, identity: object, consent?: object } | { ok: false, status: number, code: string }}
563 */
564 export function validateChain(input) {
565 const gate = checkDelegationGate(input.dataDir);
566 if (!gate.ok) return { ok: false, status: gate.status, code: gate.code };
567
568 const actorId = typeof input.actorAgentId === 'string' ? input.actorAgentId.trim() : '';
569 const principalRef =
570 typeof input.principalRef === 'string' ? input.principalRef.trim() : '';
571 if (!actorId || !principalRef) {
572 return { ok: false, status: 403, code: 'DELEGATION_CHAIN_INVALID' };
573 }
574
575 const identity = getAgentIdentity(input.dataDir, input.vaultId, actorId);
576 if (!identity || identity.status !== 'active') {
577 return { ok: false, status: 403, code: 'DELEGATION_IDENTITY_DENIED' };
578 }
579
580 const actorIsOwner =
581 identity.kind === 'user_owned' && identity.owner_ref === principalRef;
582 const grantRequired =
583 input.requireGrant === true || (!actorIsOwner && identity.kind !== 'user_owned');
584
585 if (!grantRequired && actorIsOwner) {
586 return { ok: true, identity };
587 }
588
589 const grantId = typeof input.grantId === 'string' ? input.grantId.trim() : '';
590 if (!grantId) {
591 return { ok: false, status: 403, code: 'DELEGATION_CONSENT_REQUIRED' };
592 }
593
594 const stored = getGrant(input.dataDir, input.vaultId, grantId);
595 if (!stored) {
596 return { ok: false, status: 404, code: 'unknown_grant' };
597 }
598
599 const grantStatus = resolveGrantStatus(stored);
600 if (grantStatus === 'revoked') {
601 return { ok: false, status: 403, code: 'DELEGATION_GRANT_REVOKED' };
602 }
603 if (grantStatus === 'expired') {
604 return { ok: false, status: 403, code: 'DELEGATION_GRANT_EXPIRED' };
605 }
606 if (grantStatus === 'exhausted') {
607 return { ok: false, status: 403, code: 'DELEGATION_GRANT_EXHAUSTED' };
608 }
609
610 if (stored.actor_agent_id !== actorId) {
611 return { ok: false, status: 403, code: 'DELEGATION_ACTOR_MISMATCH' };
612 }
613 if (stored.principal_ref !== principalRef) {
614 return { ok: false, status: 403, code: 'DELEGATION_PRINCIPAL_MISMATCH' };
615 }
616
617 const consent = getConsent(input.dataDir, input.vaultId, stored.consent_id);
618 if (!consent) {
619 return { ok: false, status: 403, code: 'DELEGATION_CHAIN_INVALID' };
620 }
621 const consentStatus = resolveConsentStatus(consent);
622 if (consentStatus === 'revoked') {
623 return { ok: false, status: 403, code: 'DELEGATION_CONSENT_REVOKED' };
624 }
625 if (consentStatus === 'expired') {
626 return { ok: false, status: 403, code: 'DELEGATION_CONSENT_EXPIRED' };
627 }
628
629 const effective = effectiveScope(identity.scope_ceiling, stored.scope);
630 if (!effective || effective !== stored.scope) {
631 return { ok: false, status: 403, code: 'DELEGATION_IDENTITY_SCOPE_DENIED' };
632 }
633
634 if (input.taskRef && stored.task_ref && stored.task_ref !== input.taskRef.trim()) {
635 return { ok: false, status: 403, code: 'DELEGATION_TASK_DENIED' };
636 }
637 if (input.runRef && stored.run_ref && stored.run_ref !== input.runRef.trim()) {
638 return { ok: false, status: 403, code: 'DELEGATION_CHAIN_INVALID' };
639 }
640 if (input.flowId && stored.flow_id && stored.flow_id !== input.flowId.trim()) {
641 return { ok: false, status: 403, code: 'DELEGATION_GRANT_FLOW_MISMATCH' };
642 }
643 if (
644 input.flowVersion &&
645 stored.flow_version &&
646 stored.flow_version !== input.flowVersion.trim()
647 ) {
648 return { ok: false, status: 403, code: 'DELEGATION_GRANT_FLOW_MISMATCH' };
649 }
650
651 return { ok: true, grant: grantForClient(stored), identity, consent };
652 }
653
654 /**
655 * @param {{
656 * dataDir: string,
657 * vaultId: string,
658 * userId: string,
659 * kind: AgentKind,
660 * agentId?: string,
661 * label?: string,
662 * scopeCeiling?: Scope,
663 * ownerRef?: string,
664 * createProposal: (dataDir: string, input: object) => object | Promise<object>,
665 * }} input
666 */
667 export async function handleAgentIdentityRegisterProposeRequest(input) {
668 const gate = checkDelegationGate(input.dataDir);
669 if (!gate.ok) return gate;
670
671 const principalRef = hashPrincipalRef(input.userId);
672 const kind = input.kind;
673 if (!AGENT_KINDS.has(kind)) {
674 return refuse(400, 'BAD_REQUEST', 'kind must be user_owned, org_owned, or delegate');
675 }
676
677 const agentId =
678 typeof input.agentId === 'string' && input.agentId.trim()
679 ? input.agentId.trim()
680 : randomToken(AGENT_ID_PREFIX);
681 if (!isValidId(agentId, AGENT_ID_PREFIX)) {
682 return refuse(400, 'BAD_REQUEST', 'agent_id format invalid');
683 }
684
685 const scopeCeiling = input.scopeCeiling ?? 'personal';
686 if (!SCOPES.has(scopeCeiling)) {
687 return refuse(400, 'BAD_REQUEST', 'invalid scope_ceiling');
688 }
689
690 const ownerRef =
691 typeof input.ownerRef === 'string' && input.ownerRef.trim()
692 ? input.ownerRef.trim()
693 : principalRef;
694 if (!isValidOwnerRef(ownerRef)) {
695 return refuse(400, 'BAD_REQUEST', 'invalid owner_ref');
696 }
697
698 const now = new Date().toISOString();
699 const identityPayload = {
700 schema: AGENT_IDENTITY_SCHEMA,
701 agent_id: agentId,
702 kind,
703 owner_ref: ownerRef,
704 vault_id: input.vaultId,
705 scope_ceiling: scopeCeiling,
706 label: typeof input.label === 'string' ? input.label.slice(0, 256) : undefined,
707 status: 'active',
708 created: now,
709 updated: now,
710 };
711
712 const validation = validateAgentIdentityRecord(identityPayload);
713 if (!validation.ok) {
714 return refuse(400, 'BAD_REQUEST', validation.error);
715 }
716
717 const proposal = await Promise.resolve(
718 input.createProposal(input.dataDir, {
719 path: `meta/agents/${agentId.replace(/^agent_/, '')}.md`,
720 body: JSON.stringify(identityPayload, null, 2),
721 frontmatter: { agent_id: agentId, kind },
722 intent: 'agent_identity_register',
723 source: DELEGATION_PROPOSAL_SOURCE,
724 vault_id: input.vaultId,
725 proposed_by: input.userId?.trim() || undefined,
726 review_queue: DELEGATION_REVIEW_QUEUE,
727 delegation_meta: { record_kind: 'agent_identity', agent_id: agentId },
728 }),
729 );
730
731 return {
732 ok: true,
733 payload: {
734 schema: 'knowtation.delegation_proposal/v0',
735 proposal_id: proposal.proposal_id,
736 intent: 'agent_identity_register',
737 agent_id: agentId,
738 },
739 };
740 }
741
742 /**
743 * @param {{
744 * dataDir: string,
745 * vaultId: string,
746 * userId: string,
747 * delegateAgentId: string,
748 * scope: Scope,
749 * workspaceId?: string,
750 * allowedFlowIds?: string[],
751 * allowedTaskKinds?: string[],
752 * allowedTaskIds?: string[],
753 * expiresAt?: string,
754 * createProposal: (dataDir: string, input: object) => object | Promise<object>,
755 * }} input
756 */
757 export async function handleDelegationConsentProposeRequest(input) {
758 const gate = checkDelegationGate(input.dataDir);
759 if (!gate.ok) return gate;
760
761 const principalRef = hashPrincipalRef(input.userId);
762 const delegateAgentId =
763 typeof input.delegateAgentId === 'string' ? input.delegateAgentId.trim() : '';
764 if (!isValidId(delegateAgentId, AGENT_ID_PREFIX)) {
765 return refuse(400, 'BAD_REQUEST', 'delegate_agent_id required');
766 }
767
768 const identity = getAgentIdentity(input.dataDir, input.vaultId, delegateAgentId);
769 if (!identity || identity.status !== 'active') {
770 return refuse(403, 'DELEGATION_IDENTITY_DENIED', 'Delegate agent not found or inactive');
771 }
772
773 const scope = input.scope;
774 if (!SCOPES.has(scope)) {
775 return refuse(400, 'BAD_REQUEST', 'invalid scope');
776 }
777 const effective = effectiveScope(identity.scope_ceiling, scope);
778 if (!effective || effective !== scope) {
779 return refuse(403, 'DELEGATION_IDENTITY_SCOPE_DENIED', 'Scope exceeds agent ceiling');
780 }
781
782 if ((scope === 'project' || scope === 'org') && !input.workspaceId?.trim()) {
783 return refuse(400, 'BAD_REQUEST', 'workspace_id required for project/org scope');
784 }
785
786 const consentId = randomToken(CONSENT_ID_PREFIX);
787 const now = new Date().toISOString();
788 const consentPayload = {
789 schema: DELEGATION_CONSENT_SCHEMA,
790 consent_id: consentId,
791 principal_ref: principalRef,
792 delegate_agent_id: delegateAgentId,
793 scope,
794 workspace_id: input.workspaceId?.trim() || undefined,
795 allowed_flow_ids: input.allowedFlowIds?.length ? [...input.allowedFlowIds] : undefined,
796 allowed_task_kinds: input.allowedTaskKinds?.length ? [...input.allowedTaskKinds] : undefined,
797 allowed_task_ids: input.allowedTaskIds?.length ? [...input.allowedTaskIds] : undefined,
798 expires_at: input.expiresAt || undefined,
799 revoked_at: null,
800 evidence_ref: 'proposal:pending',
801 created: now,
802 };
803
804 const proposal = await Promise.resolve(
805 input.createProposal(input.dataDir, {
806 path: `meta/delegation/consents/${consentId.replace(/^dcons_/, '')}.md`,
807 body: JSON.stringify(consentPayload, null, 2),
808 frontmatter: { consent_id: consentId, delegate_agent_id: delegateAgentId },
809 intent: 'delegation_consent_create',
810 source: DELEGATION_PROPOSAL_SOURCE,
811 vault_id: input.vaultId,
812 proposed_by: input.userId?.trim() || undefined,
813 review_queue: DELEGATION_REVIEW_QUEUE,
814 delegation_meta: { record_kind: 'delegation_consent', consent_id: consentId },
815 }),
816 );
817
818 consentPayload.evidence_ref = `proposal:${proposal.proposal_id}`;
819
820 return {
821 ok: true,
822 payload: {
823 schema: 'knowtation.delegation_proposal/v0',
824 proposal_id: proposal.proposal_id,
825 intent: 'delegation_consent_create',
826 consent_id: consentId,
827 consent_preview: consentPayload,
828 },
829 };
830 }
831
832 /**
833 * @param {string} dataDir
834 * @param {object} proposal
835 * @returns {{ ok: true, vaultId: string, recordKind: string, record: object } | { ok: false, status: number, error: string, code: string }}
836 */
837 export function precheckApprovedDelegationProposal(dataDir, proposal) {
838 if (proposal.source !== DELEGATION_PROPOSAL_SOURCE) {
839 return refuse(400, 'BAD_REQUEST', 'Not a delegation proposal');
840 }
841 const meta = proposal.delegation_meta;
842 if (!meta || typeof meta !== 'object' || typeof meta.record_kind !== 'string') {
843 return refuse(400, 'BAD_REQUEST', 'Missing delegation_meta');
844 }
845
846 let record;
847 try {
848 record = JSON.parse(proposal.body ?? '{}');
849 } catch {
850 return refuse(400, 'BAD_REQUEST', 'Proposal body is not valid JSON');
851 }
852
853 const vaultId =
854 typeof proposal.vault_id === 'string' && proposal.vault_id.trim()
855 ? proposal.vault_id.trim()
856 : 'default';
857
858 if (meta.record_kind === 'agent_identity') {
859 const v = validateAgentIdentityRecord(record);
860 if (!v.ok) return refuse(400, 'BAD_REQUEST', v.error);
861 const existing = getAgentIdentity(dataDir, vaultId, record.agent_id);
862 if (existing) {
863 return refuse(409, 'CONFLICT', 'Agent identity already registered');
864 }
865 } else if (meta.record_kind === 'delegation_consent') {
866 const v = validateConsentRecord(record);
867 if (!v.ok) return refuse(400, 'BAD_REQUEST', v.error);
868 record.evidence_ref = `proposal:${proposal.proposal_id}`;
869 const identity = getAgentIdentity(dataDir, vaultId, record.delegate_agent_id);
870 if (!identity || identity.status !== 'active') {
871 return refuse(403, 'DELEGATION_IDENTITY_DENIED', 'Delegate agent not active');
872 }
873 } else {
874 return refuse(400, 'BAD_REQUEST', 'Unknown delegation record kind');
875 }
876
877 return { ok: true, vaultId, recordKind: meta.record_kind, record };
878 }
879
880 /**
881 * @param {string} dataDir
882 * @param {{ vaultId: string, recordKind: string, record: object }} apply
883 */
884 export function applyDelegationProposalToIndex(dataDir, apply) {
885 if (apply.recordKind === 'agent_identity') {
886 const store = loadIdentitiesStore(dataDir);
887 if (!store.vaults[apply.vaultId]) store.vaults[apply.vaultId] = { identities: [] };
888 store.vaults[apply.vaultId].identities.push(apply.record);
889 saveIdentitiesStore(dataDir, store);
890 return;
891 }
892 if (apply.recordKind === 'delegation_consent') {
893 const store = loadConsentsStore(dataDir);
894 if (!store.vaults[apply.vaultId]) store.vaults[apply.vaultId] = { consents: [] };
895 store.vaults[apply.vaultId].consents.push(apply.record);
896 saveConsentsStore(dataDir, store);
897 }
898 }
899
900 /**
901 * @param {{ dataDir: string, vaultId: string, kind?: AgentKind, status?: IdentityStatus }} input
902 */
903 export function handleAgentIdentityListRequest(input) {
904 const gate = checkDelegationGate(input.dataDir);
905 if (!gate.ok) return gate;
906
907 const store = loadIdentitiesStore(input.dataDir);
908 const vault = store.vaults[input.vaultId];
909 let identities = vault && Array.isArray(vault.identities) ? vault.identities : [];
910
911 if (input.kind && AGENT_KINDS.has(input.kind)) {
912 identities = identities.filter((i) => i.kind === input.kind);
913 }
914 if (input.status && IDENTITY_STATUSES.has(input.status)) {
915 identities = identities.filter((i) => i.status === input.status);
916 }
917
918 return {
919 ok: true,
920 payload: {
921 schema: 'knowtation.agent_identity_list/v0',
922 vault_id: input.vaultId,
923 identities,
924 },
925 };
926 }
927
928 /**
929 * @param {{
930 * dataDir: string,
931 * vaultId: string,
932 * consentId: string,
933 * actorAgentId: string,
934 * taskRef?: string,
935 * runRef?: string,
936 * flowId?: string,
937 * flowVersion?: string,
938 * ttlSeconds?: number,
939 * maxActions?: number,
940 * }} input
941 */
942 export function handleDelegationGrantMintRequest(input) {
943 const gate = checkDelegationGate(input.dataDir);
944 if (!gate.ok) return gate;
945
946 const consentId = typeof input.consentId === 'string' ? input.consentId.trim() : '';
947 const actorAgentId =
948 typeof input.actorAgentId === 'string' ? input.actorAgentId.trim() : '';
949 if (!isValidId(consentId, CONSENT_ID_PREFIX) || !isValidId(actorAgentId, AGENT_ID_PREFIX)) {
950 return refuse(400, 'BAD_REQUEST', 'consent_id and actor_agent_id required');
951 }
952
953 const consent = getConsent(input.dataDir, input.vaultId, consentId);
954 if (!consent) {
955 return refuse(404, 'unknown_consent', 'unknown_consent');
956 }
957
958 const consentStatus = resolveConsentStatus(consent);
959 if (consentStatus === 'revoked') {
960 return refuse(403, 'DELEGATION_CONSENT_REVOKED', 'Consent revoked');
961 }
962 if (consentStatus === 'expired') {
963 return refuse(403, 'DELEGATION_CONSENT_EXPIRED', 'Consent expired');
964 }
965
966 if (consent.delegate_agent_id !== actorAgentId) {
967 return refuse(403, 'DELEGATION_ACTOR_MISMATCH', 'Actor does not match consent');
968 }
969
970 const identity = getAgentIdentity(input.dataDir, input.vaultId, actorAgentId);
971 if (!identity || identity.status !== 'active') {
972 return refuse(403, 'DELEGATION_IDENTITY_DENIED', 'Actor identity denied');
973 }
974
975 const scope = effectiveScope(identity.scope_ceiling, consent.scope);
976 if (!scope || scope !== consent.scope) {
977 return refuse(403, 'DELEGATION_IDENTITY_SCOPE_DENIED', 'Scope denied');
978 }
979
980 const taskRef = typeof input.taskRef === 'string' ? input.taskRef.trim() : '';
981 if (taskRef && !allowlistPermits(consent.allowed_task_ids, taskRef)) {
982 return refuse(403, 'DELEGATION_TASK_DENIED', 'Task not on consent allowlist');
983 }
984
985 const flowId = typeof input.flowId === 'string' ? input.flowId.trim() : '';
986 const flowVersion = typeof input.flowVersion === 'string' ? input.flowVersion.trim() : '';
987 if (flowId && !flowVersion) {
988 return refuse(400, 'BAD_REQUEST', 'flow_version required when flow_id set');
989 }
990 if (flowId && !allowlistPermits(consent.allowed_flow_ids, flowId)) {
991 return refuse(403, 'DELEGATION_FLOW_DENIED', 'Flow not on consent allowlist');
992 }
993
994 const runRef = typeof input.runRef === 'string' ? input.runRef.trim() : '';
995
996 const vaultPolicy = readVaultDelegationPolicy(input.dataDir);
997 const ttlRequested =
998 typeof input.ttlSeconds === 'number' && input.ttlSeconds > 0
999 ? Math.min(input.ttlSeconds, vaultPolicy.maxTtlSeconds)
1000 : vaultPolicy.defaultTtlSeconds;
1001 const ttl = Math.min(ttlRequested, vaultPolicy.maxTtlSeconds);
1002 const now = new Date();
1003 const expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
1004
1005 const grantId = randomToken(GRANT_ID_PREFIX);
1006 const bearer = randomToken(GRANT_BEARER_PREFIX, 24);
1007
1008 const grant = {
1009 schema: DELEGATION_GRANT_SCHEMA,
1010 grant_id: grantId,
1011 consent_id: consentId,
1012 actor_agent_id: actorAgentId,
1013 principal_ref: consent.principal_ref,
1014 scope: consent.scope,
1015 workspace_id: consent.workspace_id,
1016 task_ref: taskRef || undefined,
1017 run_ref: runRef || undefined,
1018 flow_id: flowId || undefined,
1019 flow_version: flowVersion || undefined,
1020 expires_at: expiresAt,
1021 revoked_at: null,
1022 max_actions:
1023 typeof input.maxActions === 'number' && input.maxActions >= 0 ? input.maxActions : undefined,
1024 action_count: 0,
1025 issued_at: now.toISOString(),
1026 };
1027
1028 const store = loadGrantsStore(input.dataDir);
1029 if (!store.vaults[input.vaultId]) store.vaults[input.vaultId] = { grants: [] };
1030 store.vaults[input.vaultId].grants.push({
1031 ...grant,
1032 grant_bearer_hash: hashGrantBearer(bearer),
1033 });
1034 saveGrantsStore(input.dataDir, store);
1035
1036 return {
1037 ok: true,
1038 payload: {
1039 schema: DELEGATION_GRANT_MINT_SCHEMA,
1040 grant: grantForClient(grant),
1041 bearer,
1042 expires_at: expiresAt,
1043 },
1044 };
1045 }
1046
1047 /**
1048 * @param {{ dataDir: string, vaultId: string, grantId: string }} input
1049 */
1050 export function handleDelegationGrantRevokeRequest(input) {
1051 const gate = checkDelegationGate(input.dataDir);
1052 if (!gate.ok) return gate;
1053
1054 const grantId = typeof input.grantId === 'string' ? input.grantId.trim() : '';
1055 if (!isValidId(grantId, GRANT_ID_PREFIX)) {
1056 return refuse(400, 'BAD_REQUEST', 'grant_id required');
1057 }
1058
1059 const store = loadGrantsStore(input.dataDir);
1060 const vault = store.vaults[input.vaultId];
1061 if (!vault || !Array.isArray(vault.grants)) {
1062 return refuse(404, 'unknown_grant', 'unknown_grant');
1063 }
1064
1065 const idx = vault.grants.findIndex((g) => g.grant_id === grantId);
1066 if (idx < 0) {
1067 return refuse(404, 'unknown_grant', 'unknown_grant');
1068 }
1069
1070 vault.grants[idx] = {
1071 ...vault.grants[idx],
1072 revoked_at: new Date().toISOString(),
1073 };
1074 saveGrantsStore(input.dataDir, store);
1075
1076 return { ok: true, payload: grantForClient(vault.grants[idx]) };
1077 }
1078
1079 /**
1080 * @param {{ dataDir: string, vaultId: string, actorAgentId?: string }} input
1081 */
1082 export function handleDelegationGrantListRequest(input) {
1083 const gate = checkDelegationGate(input.dataDir);
1084 if (!gate.ok) return gate;
1085
1086 const store = loadGrantsStore(input.dataDir);
1087 const vault = store.vaults[input.vaultId];
1088 let grants = vault && Array.isArray(vault.grants) ? vault.grants : [];
1089
1090 const actorFilter =
1091 typeof input.actorAgentId === 'string' ? input.actorAgentId.trim() : '';
1092 if (actorFilter) {
1093 grants = grants.filter((g) => g.actor_agent_id === actorFilter);
1094 }
1095
1096 return {
1097 ok: true,
1098 payload: {
1099 schema: 'knowtation.delegation_grant_list/v0',
1100 vault_id: input.vaultId,
1101 grants: grants.map(grantForClient),
1102 },
1103 };
1104 }
1105
1106 /**
1107 * @param {{ dataDir: string, vaultId: string, consentId: string, userId: string }} input
1108 */
1109 export function handleDelegationConsentRevokeRequest(input) {
1110 const gate = checkDelegationGate(input.dataDir);
1111 if (!gate.ok) return gate;
1112
1113 const consentId = typeof input.consentId === 'string' ? input.consentId.trim() : '';
1114 if (!isValidId(consentId, CONSENT_ID_PREFIX)) {
1115 return refuse(400, 'BAD_REQUEST', 'consent_id required');
1116 }
1117
1118 const store = loadConsentsStore(input.dataDir);
1119 const vault = store.vaults[input.vaultId];
1120 if (!vault || !Array.isArray(vault.consents)) {
1121 return refuse(404, 'unknown_consent', 'unknown_consent');
1122 }
1123
1124 const idx = vault.consents.findIndex((c) => c.consent_id === consentId);
1125 if (idx < 0) {
1126 return refuse(404, 'unknown_consent', 'unknown_consent');
1127 }
1128
1129 const principalRef = hashPrincipalRef(input.userId);
1130 if (vault.consents[idx].principal_ref !== principalRef) {
1131 return refuse(403, 'DELEGATION_PRINCIPAL_MISMATCH', 'Principal mismatch');
1132 }
1133
1134 vault.consents[idx] = {
1135 ...vault.consents[idx],
1136 revoked_at: new Date().toISOString(),
1137 };
1138 saveConsentsStore(input.dataDir, store);
1139
1140 return { ok: true, payload: vault.consents[idx] };
1141 }
1142
1143 /**
1144 * @param {{
1145 * dataDir: string,
1146 * vaultId: string,
1147 * grantId: string,
1148 * actorAgentId: string,
1149 * principalRef: string,
1150 * action: AuditAction,
1151 * evidenceRefs: string[],
1152 * taskRef?: string,
1153 * runRef?: string,
1154 * flowId?: string,
1155 * flowVersion?: string,
1156 * stepId?: string,
1157 * executionLocation?: ExecutionLocation,
1158 * }} input
1159 */
1160 export function handleDelegationAuditAppendRequest(input) {
1161 const gate = checkDelegationGate(input.dataDir);
1162 if (!gate.ok) return gate;
1163
1164 const chain = validateChain({
1165 dataDir: input.dataDir,
1166 vaultId: input.vaultId,
1167 actorAgentId: input.actorAgentId,
1168 principalRef: input.principalRef,
1169 grantId: input.grantId,
1170 taskRef: input.taskRef,
1171 runRef: input.runRef,
1172 flowId: input.flowId,
1173 flowVersion: input.flowVersion,
1174 requireGrant: true,
1175 });
1176 if (!chain.ok) {
1177 return refuse(chain.status, chain.code, chain.code);
1178 }
1179
1180 const action = input.action;
1181 if (!AUDIT_ACTIONS.has(action)) {
1182 return refuse(400, 'BAD_REQUEST', 'invalid action');
1183 }
1184
1185 const evidenceRefs = Array.isArray(input.evidenceRefs)
1186 ? input.evidenceRefs.filter((r) => typeof r === 'string' && r.trim()).slice(0, 32)
1187 : [];
1188 if (evidenceRefs.length === 0) {
1189 return refuse(400, 'BAD_REQUEST', 'evidence_refs required');
1190 }
1191
1192 const auditId = randomToken(AUDIT_ID_PREFIX);
1193 const audit = {
1194 schema: DELEGATION_AUDIT_SCHEMA,
1195 audit_id: auditId,
1196 grant_id: input.grantId,
1197 actor_agent_id: input.actorAgentId,
1198 principal_ref: input.principalRef,
1199 task_ref: input.taskRef?.trim() || undefined,
1200 run_ref: input.runRef?.trim() || undefined,
1201 flow_id: input.flowId?.trim() || undefined,
1202 flow_version: input.flowVersion?.trim() || undefined,
1203 step_id: input.stepId?.trim() || undefined,
1204 action,
1205 evidence_refs: evidenceRefs,
1206 occurred_at: new Date().toISOString(),
1207 execution_location: input.executionLocation,
1208 };
1209
1210 const validation = validateAuditRecord(audit);
1211 if (!validation.ok) {
1212 return refuse(400, 'BAD_REQUEST', validation.error);
1213 }
1214
1215 const grantStore = loadGrantsStore(input.dataDir);
1216 const vault = grantStore.vaults[input.vaultId];
1217 const grantIdx = vault?.grants?.findIndex((g) => g.grant_id === input.grantId) ?? -1;
1218 if (grantIdx >= 0) {
1219 vault.grants[grantIdx].action_count = (vault.grants[grantIdx].action_count ?? 0) + 1;
1220 saveGrantsStore(input.dataDir, grantStore);
1221 }
1222
1223 const auditStore = loadAuditStore(input.dataDir);
1224 if (!auditStore.vaults[input.vaultId]) auditStore.vaults[input.vaultId] = { audits: [] };
1225 auditStore.vaults[input.vaultId].audits.push(audit);
1226 saveAuditStore(input.dataDir, auditStore);
1227
1228 return { ok: true, payload: audit };
1229 }
1230
1231 /**
1232 * Seed identity + consent directly for tests (bypasses proposal when gate on in test fixtures).
1233 *
1234 * @param {string} dataDir
1235 * @param {string} vaultId
1236 * @param {object} identity
1237 * @param {object} [consent]
1238 */
1239 export function seedDelegationFixtures(dataDir, vaultId, identity, consent) {
1240 const idStore = loadIdentitiesStore(dataDir);
1241 if (!idStore.vaults[vaultId]) idStore.vaults[vaultId] = { identities: [] };
1242 idStore.vaults[vaultId].identities.push(identity);
1243 saveIdentitiesStore(dataDir, idStore);
1244 if (consent) {
1245 const cStore = loadConsentsStore(dataDir);
1246 if (!cStore.vaults[vaultId]) cStore.vaults[vaultId] = { consents: [] };
1247 cStore.vaults[vaultId].consents.push(consent);
1248 saveConsentsStore(dataDir, cStore);
1249 }
1250 }
File History 2 commits
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 11 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 31 days ago