delegation.mjs
1,308 lines 43.5 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 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 const DELEGATION_AUTHOR_CHARSET_RE = /^[A-Za-z0-9:_@.\-]+$/;
64 const UID_HASH_PRINCIPAL_RE = /^uid_hash:[0-9a-f]{64}$/;
65
66 /**
67 * @param {unknown} v
68 * @returns {boolean|null}
69 */
70 function envTriState(v) {
71 if (v === '1' || v === 'true') return true;
72 if (v === '0' || v === 'false') return false;
73 return null;
74 }
75
76 /**
77 * @param {string} dataDir
78 * @returns {object}
79 */
80 export function readDelegationPolicyFile(dataDir) {
81 if (!dataDir) return {};
82 const fp = path.join(dataDir, DELEGATION_POLICY_FILE);
83 try {
84 if (!fs.existsSync(fp)) return {};
85 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
86 return j && typeof j === 'object' ? j : {};
87 } catch {
88 return {};
89 }
90 }
91
92 /**
93 * @param {string} dataDir
94 * @returns {boolean}
95 */
96 export function getDelegationEnabled(dataDir) {
97 const fromEnv = envTriState(process.env.DELEGATION_ENABLED);
98 if (fromEnv !== null) return fromEnv;
99 const policy = readDelegationPolicyFile(dataDir);
100 const d = policy.delegation;
101 if (d && typeof d === 'object' && typeof d.enabled === 'boolean') {
102 return d.enabled;
103 }
104 return false;
105 }
106
107 /**
108 * @param {string} dataDir
109 * @returns {boolean}
110 */
111 export function getDelegationPolicyForbidden(dataDir) {
112 const fromEnv = envTriState(process.env.DELEGATION_POLICY_FORBIDDEN);
113 if (fromEnv !== null) return fromEnv;
114 const policy = readDelegationPolicyFile(dataDir);
115 const d = policy.delegation;
116 if (d && typeof d === 'object' && typeof d.forbidden === 'boolean') {
117 return d.forbidden;
118 }
119 return false;
120 }
121
122 /**
123 * @param {string} dataDir
124 * @returns {{ defaultTtlSeconds: number, maxTtlSeconds: number }}
125 */
126 export function readVaultDelegationPolicy(dataDir) {
127 const policy = readDelegationPolicyFile(dataDir);
128 const d = policy.delegation && typeof policy.delegation === 'object' ? policy.delegation : {};
129 const defaultTtl =
130 typeof d.default_ttl_seconds === 'number' && d.default_ttl_seconds > 0
131 ? d.default_ttl_seconds
132 : DEFAULT_TTL_SECONDS;
133 // SEC-KN-5 / P12: never let a vault policy file raise the ceiling above SD-10's 24h cap.
134 const maxTtlRaw =
135 typeof d.max_ttl_seconds === 'number' && d.max_ttl_seconds > 0
136 ? d.max_ttl_seconds
137 : MAX_TTL_SECONDS;
138 const maxTtl = Math.min(maxTtlRaw, MAX_TTL_SECONDS);
139 return { defaultTtlSeconds: defaultTtl, maxTtlSeconds: maxTtl };
140 }
141
142 /**
143 * Derive hashed principal ref from verified session user id — never accept client principal.
144 *
145 * @param {string} userId
146 * @returns {string}
147 */
148 export function hashPrincipalRef(userId) {
149 const trimmed = typeof userId === 'string' ? userId.trim() : '';
150 const hash = createHash('sha256').update(trimmed, 'utf8').digest('hex');
151 return `uid_hash:${hash}`;
152 }
153
154 /**
155 * @param {Scope} a
156 * @param {Scope} b
157 * @returns {Scope|null}
158 */
159 export function intersectScope(a, b) {
160 const order = { personal: 0, project: 1, org: 2 };
161 const oa = order[a];
162 const ob = order[b];
163 if (oa === undefined || ob === undefined) return null;
164 return oa <= ob ? a : b;
165 }
166
167 /**
168 * @param {Scope} ceiling
169 * @param {Scope} requested
170 * @returns {Scope|null}
171 */
172 export function effectiveScope(ceiling, requested) {
173 return intersectScope(ceiling, requested);
174 }
175
176 /**
177 * @param {string} bearer
178 * @returns {string}
179 */
180 export function hashGrantBearer(bearer) {
181 return createHash('sha256').update(bearer, 'utf8').digest('hex');
182 }
183
184 /**
185 * @param {string} prefix
186 * @param {number} [byteLen]
187 * @returns {string}
188 */
189 function randomToken(prefix, byteLen = 16) {
190 const token = randomBytes(byteLen)
191 .toString('base64url')
192 .replace(/[^a-z0-9]/gi, '')
193 .toLowerCase()
194 .slice(0, 24);
195 return prefix + (token.length >= 8 ? token : token.padEnd(8, '0'));
196 }
197
198 /**
199 * @param {string} id
200 * @param {string} prefix
201 * @returns {boolean}
202 */
203 function isValidId(id, prefix) {
204 if (typeof id !== 'string' || !id.startsWith(prefix)) return false;
205 const token = id.slice(prefix.length);
206 return ID_TOKEN_RE.test(token);
207 }
208
209 /**
210 * @param {string} ref
211 * @returns {boolean}
212 */
213 function isValidOwnerRef(ref) {
214 if (typeof ref !== 'string') return false;
215 if (ref.startsWith('uid_hash:')) {
216 return /^uid_hash:[0-9a-f]{64}$/.test(ref);
217 }
218 if (ref.startsWith('org_ref:')) {
219 return ref.length > 'org_ref:'.length;
220 }
221 return false;
222 }
223
224 /**
225 * @param {object} record
226 * @returns {{ ok: true } | { ok: false, error: string }}
227 */
228 export function validateAgentIdentityRecord(record) {
229 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
230 if (record.schema !== AGENT_IDENTITY_SCHEMA) return { ok: false, error: 'schema mismatch' };
231 if (!isValidId(record.agent_id, AGENT_ID_PREFIX)) return { ok: false, error: 'invalid agent_id' };
232 if (!AGENT_KINDS.has(record.kind)) return { ok: false, error: 'invalid kind' };
233 if (!isValidOwnerRef(record.owner_ref)) return { ok: false, error: 'invalid owner_ref' };
234 if (typeof record.vault_id !== 'string' || !record.vault_id.trim()) {
235 return { ok: false, error: 'invalid vault_id' };
236 }
237 if (!SCOPES.has(record.scope_ceiling)) return { ok: false, error: 'invalid scope_ceiling' };
238 if (!IDENTITY_STATUSES.has(record.status)) return { ok: false, error: 'invalid status' };
239 if (typeof record.created !== 'string' || typeof record.updated !== 'string') {
240 return { ok: false, error: 'invalid timestamps' };
241 }
242 return { ok: true };
243 }
244
245 /**
246 * @param {object} record
247 * @returns {{ ok: true } | { ok: false, error: string }}
248 */
249 export function validateConsentRecord(record) {
250 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
251 if (record.schema !== DELEGATION_CONSENT_SCHEMA) return { ok: false, error: 'schema mismatch' };
252 if (!isValidId(record.consent_id, CONSENT_ID_PREFIX)) return { ok: false, error: 'invalid consent_id' };
253 if (!isValidOwnerRef(record.principal_ref)) return { ok: false, error: 'invalid principal_ref' };
254 if (!isValidId(record.delegate_agent_id, AGENT_ID_PREFIX)) {
255 return { ok: false, error: 'invalid delegate_agent_id' };
256 }
257 if (!SCOPES.has(record.scope)) return { ok: false, error: 'invalid scope' };
258 if (record.scope === 'project' || record.scope === 'org') {
259 if (typeof record.workspace_id !== 'string' || !record.workspace_id.trim()) {
260 return { ok: false, error: 'workspace_id required for project/org scope' };
261 }
262 }
263 if (record.revoked_at !== null && typeof record.revoked_at !== 'string') {
264 return { ok: false, error: 'invalid revoked_at' };
265 }
266 if (typeof record.evidence_ref !== 'string' || !record.evidence_ref.startsWith('proposal:')) {
267 return { ok: false, error: 'invalid evidence_ref' };
268 }
269 if (typeof record.created !== 'string') return { ok: false, error: 'invalid created' };
270 return { ok: true };
271 }
272
273 /**
274 * @param {object} record
275 * @returns {{ ok: true } | { ok: false, error: string }}
276 */
277 export function validateGrantRecord(record) {
278 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
279 if (record.schema !== DELEGATION_GRANT_SCHEMA) return { ok: false, error: 'schema mismatch' };
280 if (!isValidId(record.grant_id, GRANT_ID_PREFIX)) return { ok: false, error: 'invalid grant_id' };
281 if (!isValidId(record.consent_id, CONSENT_ID_PREFIX)) return { ok: false, error: 'invalid consent_id' };
282 if (!isValidId(record.actor_agent_id, AGENT_ID_PREFIX)) {
283 return { ok: false, error: 'invalid actor_agent_id' };
284 }
285 if (!isValidOwnerRef(record.principal_ref)) return { ok: false, error: 'invalid principal_ref' };
286 if (!SCOPES.has(record.scope)) return { ok: false, error: 'invalid scope' };
287 if (record.flow_id && !record.flow_version) return { ok: false, error: 'flow_version required' };
288 if (record.flow_version && !SEMVER_RE.test(record.flow_version)) {
289 return { ok: false, error: 'invalid flow_version' };
290 }
291 if (record.revoked_at !== null && typeof record.revoked_at !== 'string') {
292 return { ok: false, error: 'invalid revoked_at' };
293 }
294 if (typeof record.action_count !== 'number' || record.action_count < 0) {
295 return { ok: false, error: 'invalid action_count' };
296 }
297 if (typeof record.expires_at !== 'string' || typeof record.issued_at !== 'string') {
298 return { ok: false, error: 'invalid timestamps' };
299 }
300 return { ok: true };
301 }
302
303 /**
304 * @param {object} record
305 * @returns {{ ok: true } | { ok: false, error: string }}
306 */
307 export function validateAuditRecord(record) {
308 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
309 if (record.schema !== DELEGATION_AUDIT_SCHEMA) return { ok: false, error: 'schema mismatch' };
310 if (!isValidId(record.audit_id, AUDIT_ID_PREFIX)) return { ok: false, error: 'invalid audit_id' };
311 if (!isValidId(record.grant_id, GRANT_ID_PREFIX)) return { ok: false, error: 'invalid grant_id' };
312 if (!isValidId(record.actor_agent_id, AGENT_ID_PREFIX)) {
313 return { ok: false, error: 'invalid actor_agent_id' };
314 }
315 if (!isValidOwnerRef(record.principal_ref)) return { ok: false, error: 'invalid principal_ref' };
316 if (!AUDIT_ACTIONS.has(record.action)) return { ok: false, error: 'invalid action' };
317 if (!Array.isArray(record.evidence_refs)) return { ok: false, error: 'invalid evidence_refs' };
318 if (typeof record.occurred_at !== 'string') return { ok: false, error: 'invalid occurred_at' };
319 if (
320 record.execution_location != null &&
321 !EXECUTION_LOCATIONS.has(record.execution_location)
322 ) {
323 return { ok: false, error: 'invalid execution_location' };
324 }
325 return { ok: true };
326 }
327
328 /**
329 * @param {object} consent
330 * @returns {'active' | 'revoked' | 'expired'}
331 */
332 export function resolveConsentStatus(consent) {
333 if (consent.revoked_at) return 'revoked';
334 if (consent.expires_at) {
335 const exp = Date.parse(consent.expires_at);
336 if (Number.isFinite(exp) && Date.now() > exp) return 'expired';
337 }
338 return 'active';
339 }
340
341 /**
342 * @param {object} grant
343 * @returns {'active' | 'revoked' | 'expired' | 'exhausted'}
344 */
345 export function resolveGrantStatus(grant) {
346 if (grant.revoked_at) return 'revoked';
347 const exp = Date.parse(grant.expires_at);
348 if (!Number.isFinite(exp) || Date.now() > exp) return 'expired';
349 if (
350 typeof grant.max_actions === 'number' &&
351 grant.max_actions > 0 &&
352 grant.action_count >= grant.max_actions
353 ) {
354 return 'exhausted';
355 }
356 return 'active';
357 }
358
359 /**
360 * Strip internal fields from stored grant for client responses.
361 *
362 * @param {object} stored
363 * @returns {object}
364 */
365 export function grantForClient(stored) {
366 const { grant_bearer_hash: _b, ...grant } = stored;
367 return grant;
368 }
369
370 /**
371 * @param {string} dataDir
372 * @param {string} filename
373 * @returns {{ vaults: Record<string, object> }}
374 */
375 function loadVaultStore(dataDir, filename) {
376 const fp = path.join(dataDir, filename);
377 if (!fs.existsSync(fp)) return { vaults: {} };
378 try {
379 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
380 if (!j || typeof j !== 'object') return { vaults: {} };
381 return { vaults: j.vaults && typeof j.vaults === 'object' ? j.vaults : {} };
382 } catch {
383 return { vaults: {} };
384 }
385 }
386
387 /**
388 * @param {string} dataDir
389 * @param {string} filename
390 * @param {{ vaults: Record<string, object> }} store
391 */
392 function saveVaultStore(dataDir, filename, store) {
393 const fp = path.join(dataDir, filename);
394 fs.mkdirSync(path.dirname(fp), { recursive: true });
395 fs.writeFileSync(fp, JSON.stringify(store, null, 2), 'utf8');
396 }
397
398 /**
399 * @param {string} dataDir
400 * @returns {{ vaults: Record<string, { identities: object[] }> }}
401 */
402 export function loadIdentitiesStore(dataDir) {
403 const raw = loadVaultStore(dataDir, DELEGATION_IDENTITIES_FILE);
404 for (const v of Object.values(raw.vaults)) {
405 if (!Array.isArray(v.identities)) v.identities = [];
406 }
407 return /** @type {{ vaults: Record<string, { identities: object[] }> }} */ (raw);
408 }
409
410 /**
411 * @param {string} dataDir
412 * @param {{ vaults: Record<string, { identities: object[] }> }} store
413 */
414 export function saveIdentitiesStore(dataDir, store) {
415 saveVaultStore(dataDir, DELEGATION_IDENTITIES_FILE, store);
416 }
417
418 /**
419 * @param {string} dataDir
420 * @returns {{ vaults: Record<string, { consents: object[] }> }}
421 */
422 export function loadConsentsStore(dataDir) {
423 const raw = loadVaultStore(dataDir, DELEGATION_CONSENTS_FILE);
424 for (const v of Object.values(raw.vaults)) {
425 if (!Array.isArray(v.consents)) v.consents = [];
426 }
427 return /** @type {{ vaults: Record<string, { consents: object[] }> }} */ (raw);
428 }
429
430 /**
431 * @param {string} dataDir
432 * @param {{ vaults: Record<string, { consents: object[] }> }} store
433 */
434 export function saveConsentsStore(dataDir, store) {
435 saveVaultStore(dataDir, DELEGATION_CONSENTS_FILE, store);
436 }
437
438 /**
439 * @param {string} dataDir
440 * @returns {{ vaults: Record<string, { grants: object[] }> }}
441 */
442 export function loadGrantsStore(dataDir) {
443 const raw = loadVaultStore(dataDir, DELEGATION_GRANTS_FILE);
444 for (const v of Object.values(raw.vaults)) {
445 if (!Array.isArray(v.grants)) v.grants = [];
446 }
447 return /** @type {{ vaults: Record<string, { grants: object[] }> }} */ (raw);
448 }
449
450 /**
451 * @param {string} dataDir
452 * @param {{ vaults: Record<string, { grants: object[] }> }} store
453 */
454 export function saveGrantsStore(dataDir, store) {
455 saveVaultStore(dataDir, DELEGATION_GRANTS_FILE, store);
456 }
457
458 /**
459 * @param {string} dataDir
460 * @returns {{ vaults: Record<string, { audits: object[] }> }}
461 */
462 export function loadAuditStore(dataDir) {
463 const raw = loadVaultStore(dataDir, DELEGATION_AUDIT_FILE);
464 for (const v of Object.values(raw.vaults)) {
465 if (!Array.isArray(v.audits)) v.audits = [];
466 }
467 return /** @type {{ vaults: Record<string, { audits: object[] }> }} */ (raw);
468 }
469
470 /**
471 * @param {string} dataDir
472 * @param {{ vaults: Record<string, { audits: object[] }> }} store
473 */
474 export function saveAuditStore(dataDir, store) {
475 saveVaultStore(dataDir, DELEGATION_AUDIT_FILE, store);
476 }
477
478 /**
479 * @param {string} dataDir
480 * @param {string} vaultId
481 * @param {string} agentId
482 * @returns {object|null}
483 */
484 export function getAgentIdentity(dataDir, vaultId, agentId) {
485 const store = loadIdentitiesStore(dataDir);
486 const vault = store.vaults[vaultId];
487 if (!vault) return null;
488 return vault.identities.find((i) => i.agent_id === agentId) ?? null;
489 }
490
491 /**
492 * @param {string} dataDir
493 * @param {string} vaultId
494 * @param {string} consentId
495 * @returns {object|null}
496 */
497 export function getConsent(dataDir, vaultId, consentId) {
498 const store = loadConsentsStore(dataDir);
499 const vault = store.vaults[vaultId];
500 if (!vault) return null;
501 return vault.consents.find((c) => c.consent_id === consentId) ?? null;
502 }
503
504 /**
505 * @param {string} dataDir
506 * @param {string} vaultId
507 * @param {string} grantId
508 * @returns {object|null}
509 */
510 export function getGrant(dataDir, vaultId, grantId) {
511 const store = loadGrantsStore(dataDir);
512 const vault = store.vaults[vaultId];
513 if (!vault) return null;
514 return vault.grants.find((g) => g.grant_id === grantId) ?? null;
515 }
516
517 /**
518 * @param {object} ctx
519 * @returns {{ ok: false, status: number, error: string, code: string }}
520 */
521 function refuse(status, code, error) {
522 return { ok: false, status, error, code };
523 }
524
525 /**
526 * Gate check shared by mutating handlers.
527 *
528 * @param {string} dataDir
529 * @returns {{ ok: true } | { ok: false, status: number, error: string, code: string }}
530 */
531 function checkDelegationGate(dataDir) {
532 if (getDelegationPolicyForbidden(dataDir)) {
533 return refuse(403, 'DELEGATION_POLICY_FORBIDDEN', 'Delegation forbidden by policy');
534 }
535 if (!getDelegationEnabled(dataDir)) {
536 return refuse(403, 'DELEGATION_DISABLED', 'Delegation gate is disabled');
537 }
538 return { ok: true };
539 }
540
541 /**
542 * @param {string[]} allowlist
543 * @param {string} value
544 * @returns {boolean}
545 */
546 function allowlistPermits(allowlist, value) {
547 if (!Array.isArray(allowlist) || allowlist.length === 0) return true;
548 return allowlist.includes(value);
549 }
550
551 /**
552 * Validate delegation chain reconstructability per spec §2.
553 *
554 * @param {{
555 * dataDir: string,
556 * vaultId: string,
557 * actorAgentId: string,
558 * principalRef: string,
559 * grantId?: string,
560 * taskRef?: string,
561 * runRef?: string,
562 * flowId?: string,
563 * flowVersion?: string,
564 * requireGrant?: boolean,
565 * }} input
566 * @returns {{ ok: true, grant?: object, identity: object, consent?: object } | { ok: false, status: number, code: string }}
567 */
568 export function validateChain(input) {
569 const gate = checkDelegationGate(input.dataDir);
570 if (!gate.ok) return { ok: false, status: gate.status, code: gate.code };
571
572 const actorId = typeof input.actorAgentId === 'string' ? input.actorAgentId.trim() : '';
573 const principalRef =
574 typeof input.principalRef === 'string' ? input.principalRef.trim() : '';
575 if (!actorId || !principalRef) {
576 return { ok: false, status: 403, code: 'DELEGATION_CHAIN_INVALID' };
577 }
578
579 const identity = getAgentIdentity(input.dataDir, input.vaultId, actorId);
580 if (!identity || identity.status !== 'active') {
581 return { ok: false, status: 403, code: 'DELEGATION_IDENTITY_DENIED' };
582 }
583
584 const actorIsOwner =
585 identity.kind === 'user_owned' && identity.owner_ref === principalRef;
586 const grantRequired =
587 input.requireGrant === true || (!actorIsOwner && identity.kind !== 'user_owned');
588
589 if (!grantRequired && actorIsOwner) {
590 return { ok: true, identity };
591 }
592
593 const grantId = typeof input.grantId === 'string' ? input.grantId.trim() : '';
594 if (!grantId) {
595 return { ok: false, status: 403, code: 'DELEGATION_CONSENT_REQUIRED' };
596 }
597
598 const stored = getGrant(input.dataDir, input.vaultId, grantId);
599 if (!stored) {
600 return { ok: false, status: 404, code: 'unknown_grant' };
601 }
602
603 const grantStatus = resolveGrantStatus(stored);
604 if (grantStatus === 'revoked') {
605 return { ok: false, status: 403, code: 'DELEGATION_GRANT_REVOKED' };
606 }
607 if (grantStatus === 'expired') {
608 return { ok: false, status: 403, code: 'DELEGATION_GRANT_EXPIRED' };
609 }
610 if (grantStatus === 'exhausted') {
611 return { ok: false, status: 403, code: 'DELEGATION_GRANT_EXHAUSTED' };
612 }
613
614 if (stored.actor_agent_id !== actorId) {
615 return { ok: false, status: 403, code: 'DELEGATION_ACTOR_MISMATCH' };
616 }
617 if (stored.principal_ref !== principalRef) {
618 return { ok: false, status: 403, code: 'DELEGATION_PRINCIPAL_MISMATCH' };
619 }
620
621 const consent = getConsent(input.dataDir, input.vaultId, stored.consent_id);
622 if (!consent) {
623 return { ok: false, status: 403, code: 'DELEGATION_CHAIN_INVALID' };
624 }
625 const consentStatus = resolveConsentStatus(consent);
626 if (consentStatus === 'revoked') {
627 return { ok: false, status: 403, code: 'DELEGATION_CONSENT_REVOKED' };
628 }
629 if (consentStatus === 'expired') {
630 return { ok: false, status: 403, code: 'DELEGATION_CONSENT_EXPIRED' };
631 }
632
633 const effective = effectiveScope(identity.scope_ceiling, stored.scope);
634 if (!effective || effective !== stored.scope) {
635 return { ok: false, status: 403, code: 'DELEGATION_IDENTITY_SCOPE_DENIED' };
636 }
637
638 if (input.taskRef && stored.task_ref && stored.task_ref !== input.taskRef.trim()) {
639 return { ok: false, status: 403, code: 'DELEGATION_TASK_DENIED' };
640 }
641 if (input.runRef && stored.run_ref && stored.run_ref !== input.runRef.trim()) {
642 return { ok: false, status: 403, code: 'DELEGATION_CHAIN_INVALID' };
643 }
644 if (input.flowId && stored.flow_id && stored.flow_id !== input.flowId.trim()) {
645 return { ok: false, status: 403, code: 'DELEGATION_GRANT_FLOW_MISMATCH' };
646 }
647 if (
648 input.flowVersion &&
649 stored.flow_version &&
650 stored.flow_version !== input.flowVersion.trim()
651 ) {
652 return { ok: false, status: 403, code: 'DELEGATION_GRANT_FLOW_MISMATCH' };
653 }
654
655 return { ok: true, grant: grantForClient(stored), identity, consent };
656 }
657
658 /**
659 * @param {{
660 * dataDir: string,
661 * vaultId: string,
662 * userId: string,
663 * kind: AgentKind,
664 * agentId?: string,
665 * label?: string,
666 * scopeCeiling?: Scope,
667 * createProposal: (dataDir: string, input: object) => object | Promise<object>,
668 * }} input
669 */
670 export async function handleAgentIdentityRegisterProposeRequest(input) {
671 const gate = checkDelegationGate(input.dataDir);
672 if (!gate.ok) return gate;
673
674 const principalRef = hashPrincipalRef(input.userId);
675 const kind = input.kind;
676 if (!AGENT_KINDS.has(kind)) {
677 return refuse(400, 'BAD_REQUEST', 'kind must be user_owned, org_owned, or delegate');
678 }
679
680 const agentId =
681 typeof input.agentId === 'string' && input.agentId.trim()
682 ? input.agentId.trim()
683 : randomToken(AGENT_ID_PREFIX);
684 if (!isValidId(agentId, AGENT_ID_PREFIX)) {
685 return refuse(400, 'BAD_REQUEST', 'agent_id format invalid');
686 }
687
688 const scopeCeiling = input.scopeCeiling ?? 'personal';
689 if (!SCOPES.has(scopeCeiling)) {
690 return refuse(400, 'BAD_REQUEST', 'invalid scope_ceiling');
691 }
692
693 const ownerRef = 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 * @param {{ author: string }} context — server-recorded proposal author; REQUIRED
836 * @returns {{ ok: true, vaultId: string, recordKind: string, record: object } | { ok: false, status: number, error: string, code: string }}
837 */
838 export function precheckApprovedDelegationProposal(dataDir, proposal, context) {
839 const gate = checkDelegationGate(dataDir);
840 if (!gate.ok) return gate;
841
842 if (proposal.source !== DELEGATION_PROPOSAL_SOURCE) {
843 return refuse(400, 'BAD_REQUEST', 'Not a delegation proposal');
844 }
845 const meta = proposal.delegation_meta;
846 if (!meta || typeof meta !== 'object' || typeof meta.record_kind !== 'string') {
847 return refuse(400, 'BAD_REQUEST', 'Missing delegation_meta');
848 }
849
850 let record;
851 try {
852 record = JSON.parse(proposal.body ?? '{}');
853 } catch {
854 return refuse(400, 'BAD_REQUEST', 'Proposal body is not valid JSON');
855 }
856
857 const vaultId =
858 typeof proposal.vault_id === 'string' && proposal.vault_id.trim()
859 ? proposal.vault_id.trim()
860 : 'default';
861
862 if (!context || typeof context !== 'object') {
863 return refuse(403, 'DELEGATION_AUTHOR_UNVERIFIED', 'Proposal author unverified');
864 }
865 if (proposal._knowtation_backup_json_unparseable) {
866 return refuse(403, 'DELEGATION_AUTHOR_UNVERIFIED', 'Proposal author unverified');
867 }
868 const author = typeof context.author === 'string' ? context.author.trim() : '';
869 if (!author) {
870 return refuse(403, 'DELEGATION_AUTHOR_UNVERIFIED', 'Proposal author unverified');
871 }
872 if (author.length > 128) {
873 return refuse(403, 'DELEGATION_AUTHOR_UNVERIFIED', 'Proposal author unverified');
874 }
875 if (!DELEGATION_AUTHOR_CHARSET_RE.test(author)) {
876 return refuse(403, 'DELEGATION_AUTHOR_UNVERIFIED', 'Proposal author unverified');
877 }
878
879 const derived = hashPrincipalRef(author);
880
881 const bodyPrincipalRef =
882 typeof record.principal_ref === 'string' ? record.principal_ref.trim() : '';
883 const bodyOwnerRef = typeof record.owner_ref === 'string' ? record.owner_ref.trim() : '';
884 // R5 is checked on both refs for every record kind, matching the frozen wording
885 // ("the body's principal_ref or owner_ref"). Scoping each ref to its own kind would
886 // be behaviourally equivalent today only because the per-kind validators ignore the
887 // other field; checking both keeps the refusal independent of that coupling.
888 if (bodyPrincipalRef.startsWith('org_ref:') || bodyOwnerRef.startsWith('org_ref:')) {
889 return refuse(403, 'DELEGATION_ORG_REF_UNSUPPORTED', 'org_ref authority refs not supported in v0');
890 }
891
892 if (meta.record_kind === 'delegation_consent') {
893 if (bodyPrincipalRef && bodyPrincipalRef !== derived) {
894 return refuse(
895 403,
896 'DELEGATION_PRINCIPAL_REBIND_MISMATCH',
897 'principal_ref does not match proposal author',
898 );
899 }
900 record.principal_ref = derived;
901 } else if (meta.record_kind === 'agent_identity') {
902 if (bodyOwnerRef && bodyOwnerRef !== derived) {
903 return refuse(
904 403,
905 'DELEGATION_OWNER_REBIND_MISMATCH',
906 'owner_ref does not match proposal author',
907 );
908 }
909 record.owner_ref = derived;
910 }
911
912 if (meta.record_kind === 'agent_identity') {
913 const v = validateAgentIdentityRecord(record);
914 if (!v.ok) return refuse(400, 'BAD_REQUEST', v.error);
915 const existing = getAgentIdentity(dataDir, vaultId, record.agent_id);
916 if (existing) {
917 return refuse(409, 'CONFLICT', 'Agent identity already registered');
918 }
919 } else if (meta.record_kind === 'delegation_consent') {
920 const v = validateConsentRecord(record);
921 if (!v.ok) return refuse(400, 'BAD_REQUEST', v.error);
922 record.evidence_ref = `proposal:${proposal.proposal_id}`;
923 const identity = getAgentIdentity(dataDir, vaultId, record.delegate_agent_id);
924 if (!identity || identity.status !== 'active') {
925 return refuse(403, 'DELEGATION_IDENTITY_DENIED', 'Delegate agent not active');
926 }
927 } else {
928 return refuse(400, 'BAD_REQUEST', 'Unknown delegation record kind');
929 }
930
931 return { ok: true, vaultId, recordKind: meta.record_kind, record };
932 }
933
934 /**
935 * @param {string} dataDir
936 * @param {{ vaultId: string, recordKind: string, record: object }} apply
937 */
938 export function applyDelegationProposalToIndex(dataDir, apply) {
939 if (apply.recordKind === 'agent_identity') {
940 const store = loadIdentitiesStore(dataDir);
941 if (!store.vaults[apply.vaultId]) store.vaults[apply.vaultId] = { identities: [] };
942 store.vaults[apply.vaultId].identities.push(apply.record);
943 saveIdentitiesStore(dataDir, store);
944 return;
945 }
946 if (apply.recordKind === 'delegation_consent') {
947 const store = loadConsentsStore(dataDir);
948 if (!store.vaults[apply.vaultId]) store.vaults[apply.vaultId] = { consents: [] };
949 store.vaults[apply.vaultId].consents.push(apply.record);
950 saveConsentsStore(dataDir, store);
951 }
952 }
953
954 /**
955 * @param {{ dataDir: string, vaultId: string, kind?: AgentKind, status?: IdentityStatus }} input
956 */
957 export function handleAgentIdentityListRequest(input) {
958 const gate = checkDelegationGate(input.dataDir);
959 if (!gate.ok) return gate;
960
961 const store = loadIdentitiesStore(input.dataDir);
962 const vault = store.vaults[input.vaultId];
963 let identities = vault && Array.isArray(vault.identities) ? vault.identities : [];
964
965 if (input.kind && AGENT_KINDS.has(input.kind)) {
966 identities = identities.filter((i) => i.kind === input.kind);
967 }
968 if (input.status && IDENTITY_STATUSES.has(input.status)) {
969 identities = identities.filter((i) => i.status === input.status);
970 }
971
972 return {
973 ok: true,
974 payload: {
975 schema: 'knowtation.agent_identity_list/v0',
976 vault_id: input.vaultId,
977 identities,
978 },
979 };
980 }
981
982 /**
983 * @param {{
984 * dataDir: string,
985 * vaultId: string,
986 * consentId: string,
987 * actorAgentId: string,
988 * taskRef?: string,
989 * runRef?: string,
990 * flowId?: string,
991 * flowVersion?: string,
992 * ttlSeconds?: number,
993 * maxActions?: number,
994 * }} input
995 */
996 export function handleDelegationGrantMintRequest(input) {
997 const gate = checkDelegationGate(input.dataDir);
998 if (!gate.ok) return gate;
999
1000 const consentId = typeof input.consentId === 'string' ? input.consentId.trim() : '';
1001 const actorAgentId =
1002 typeof input.actorAgentId === 'string' ? input.actorAgentId.trim() : '';
1003 if (!isValidId(consentId, CONSENT_ID_PREFIX) || !isValidId(actorAgentId, AGENT_ID_PREFIX)) {
1004 return refuse(400, 'BAD_REQUEST', 'consent_id and actor_agent_id required');
1005 }
1006
1007 const consent = getConsent(input.dataDir, input.vaultId, consentId);
1008 if (!consent) {
1009 return refuse(404, 'unknown_consent', 'unknown_consent');
1010 }
1011
1012 const consentStatus = resolveConsentStatus(consent);
1013 if (consentStatus === 'revoked') {
1014 return refuse(403, 'DELEGATION_CONSENT_REVOKED', 'Consent revoked');
1015 }
1016 if (consentStatus === 'expired') {
1017 return refuse(403, 'DELEGATION_CONSENT_EXPIRED', 'Consent expired');
1018 }
1019
1020 if (!UID_HASH_PRINCIPAL_RE.test(consent.principal_ref)) {
1021 return refuse(403, 'DELEGATION_CONSENT_PRINCIPAL_INVALID', 'Consent principal invalid');
1022 }
1023
1024 if (consent.delegate_agent_id !== actorAgentId) {
1025 return refuse(403, 'DELEGATION_ACTOR_MISMATCH', 'Actor does not match consent');
1026 }
1027
1028 const identity = getAgentIdentity(input.dataDir, input.vaultId, actorAgentId);
1029 if (!identity || identity.status !== 'active') {
1030 return refuse(403, 'DELEGATION_IDENTITY_DENIED', 'Actor identity denied');
1031 }
1032
1033 const scope = effectiveScope(identity.scope_ceiling, consent.scope);
1034 if (!scope || scope !== consent.scope) {
1035 return refuse(403, 'DELEGATION_IDENTITY_SCOPE_DENIED', 'Scope denied');
1036 }
1037
1038 const taskRef = typeof input.taskRef === 'string' ? input.taskRef.trim() : '';
1039 if (taskRef && !allowlistPermits(consent.allowed_task_ids, taskRef)) {
1040 return refuse(403, 'DELEGATION_TASK_DENIED', 'Task not on consent allowlist');
1041 }
1042
1043 const flowId = typeof input.flowId === 'string' ? input.flowId.trim() : '';
1044 const flowVersion = typeof input.flowVersion === 'string' ? input.flowVersion.trim() : '';
1045 if (flowId && !flowVersion) {
1046 return refuse(400, 'BAD_REQUEST', 'flow_version required when flow_id set');
1047 }
1048 if (flowId && !allowlistPermits(consent.allowed_flow_ids, flowId)) {
1049 return refuse(403, 'DELEGATION_FLOW_DENIED', 'Flow not on consent allowlist');
1050 }
1051
1052 const runRef = typeof input.runRef === 'string' ? input.runRef.trim() : '';
1053
1054 const vaultPolicy = readVaultDelegationPolicy(input.dataDir);
1055 const ttlRequested =
1056 typeof input.ttlSeconds === 'number' && input.ttlSeconds > 0
1057 ? Math.min(input.ttlSeconds, vaultPolicy.maxTtlSeconds)
1058 : vaultPolicy.defaultTtlSeconds;
1059 const ttl = Math.min(ttlRequested, vaultPolicy.maxTtlSeconds);
1060 const now = new Date();
1061 const expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
1062
1063 const grantId = randomToken(GRANT_ID_PREFIX);
1064 const bearer = randomToken(GRANT_BEARER_PREFIX, 24);
1065
1066 const grant = {
1067 schema: DELEGATION_GRANT_SCHEMA,
1068 grant_id: grantId,
1069 consent_id: consentId,
1070 actor_agent_id: actorAgentId,
1071 principal_ref: consent.principal_ref,
1072 scope: consent.scope,
1073 workspace_id: consent.workspace_id,
1074 task_ref: taskRef || undefined,
1075 run_ref: runRef || undefined,
1076 flow_id: flowId || undefined,
1077 flow_version: flowVersion || undefined,
1078 expires_at: expiresAt,
1079 revoked_at: null,
1080 max_actions:
1081 typeof input.maxActions === 'number' && input.maxActions >= 0 ? input.maxActions : undefined,
1082 action_count: 0,
1083 issued_at: now.toISOString(),
1084 };
1085
1086 const store = loadGrantsStore(input.dataDir);
1087 if (!store.vaults[input.vaultId]) store.vaults[input.vaultId] = { grants: [] };
1088 store.vaults[input.vaultId].grants.push({
1089 ...grant,
1090 grant_bearer_hash: hashGrantBearer(bearer),
1091 });
1092 saveGrantsStore(input.dataDir, store);
1093
1094 return {
1095 ok: true,
1096 payload: {
1097 schema: DELEGATION_GRANT_MINT_SCHEMA,
1098 grant: grantForClient(grant),
1099 bearer,
1100 expires_at: expiresAt,
1101 },
1102 };
1103 }
1104
1105 /**
1106 * @param {{ dataDir: string, vaultId: string, grantId: string }} input
1107 */
1108 export function handleDelegationGrantRevokeRequest(input) {
1109 const gate = checkDelegationGate(input.dataDir);
1110 if (!gate.ok) return gate;
1111
1112 const grantId = typeof input.grantId === 'string' ? input.grantId.trim() : '';
1113 if (!isValidId(grantId, GRANT_ID_PREFIX)) {
1114 return refuse(400, 'BAD_REQUEST', 'grant_id required');
1115 }
1116
1117 const store = loadGrantsStore(input.dataDir);
1118 const vault = store.vaults[input.vaultId];
1119 if (!vault || !Array.isArray(vault.grants)) {
1120 return refuse(404, 'unknown_grant', 'unknown_grant');
1121 }
1122
1123 const idx = vault.grants.findIndex((g) => g.grant_id === grantId);
1124 if (idx < 0) {
1125 return refuse(404, 'unknown_grant', 'unknown_grant');
1126 }
1127
1128 vault.grants[idx] = {
1129 ...vault.grants[idx],
1130 revoked_at: new Date().toISOString(),
1131 };
1132 saveGrantsStore(input.dataDir, store);
1133
1134 return { ok: true, payload: grantForClient(vault.grants[idx]) };
1135 }
1136
1137 /**
1138 * @param {{ dataDir: string, vaultId: string, actorAgentId?: string }} input
1139 */
1140 export function handleDelegationGrantListRequest(input) {
1141 const gate = checkDelegationGate(input.dataDir);
1142 if (!gate.ok) return gate;
1143
1144 const store = loadGrantsStore(input.dataDir);
1145 const vault = store.vaults[input.vaultId];
1146 let grants = vault && Array.isArray(vault.grants) ? vault.grants : [];
1147
1148 const actorFilter =
1149 typeof input.actorAgentId === 'string' ? input.actorAgentId.trim() : '';
1150 if (actorFilter) {
1151 grants = grants.filter((g) => g.actor_agent_id === actorFilter);
1152 }
1153
1154 return {
1155 ok: true,
1156 payload: {
1157 schema: 'knowtation.delegation_grant_list/v0',
1158 vault_id: input.vaultId,
1159 grants: grants.map(grantForClient),
1160 },
1161 };
1162 }
1163
1164 /**
1165 * @param {{ dataDir: string, vaultId: string, consentId: string, userId: string }} input
1166 */
1167 export function handleDelegationConsentRevokeRequest(input) {
1168 const gate = checkDelegationGate(input.dataDir);
1169 if (!gate.ok) return gate;
1170
1171 const consentId = typeof input.consentId === 'string' ? input.consentId.trim() : '';
1172 if (!isValidId(consentId, CONSENT_ID_PREFIX)) {
1173 return refuse(400, 'BAD_REQUEST', 'consent_id required');
1174 }
1175
1176 const store = loadConsentsStore(input.dataDir);
1177 const vault = store.vaults[input.vaultId];
1178 if (!vault || !Array.isArray(vault.consents)) {
1179 return refuse(404, 'unknown_consent', 'unknown_consent');
1180 }
1181
1182 const idx = vault.consents.findIndex((c) => c.consent_id === consentId);
1183 if (idx < 0) {
1184 return refuse(404, 'unknown_consent', 'unknown_consent');
1185 }
1186
1187 const principalRef = hashPrincipalRef(input.userId);
1188 if (vault.consents[idx].principal_ref !== principalRef) {
1189 return refuse(403, 'DELEGATION_PRINCIPAL_MISMATCH', 'Principal mismatch');
1190 }
1191
1192 vault.consents[idx] = {
1193 ...vault.consents[idx],
1194 revoked_at: new Date().toISOString(),
1195 };
1196 saveConsentsStore(input.dataDir, store);
1197
1198 return { ok: true, payload: vault.consents[idx] };
1199 }
1200
1201 /**
1202 * @param {{
1203 * dataDir: string,
1204 * vaultId: string,
1205 * grantId: string,
1206 * actorAgentId: string,
1207 * principalRef: string,
1208 * action: AuditAction,
1209 * evidenceRefs: string[],
1210 * taskRef?: string,
1211 * runRef?: string,
1212 * flowId?: string,
1213 * flowVersion?: string,
1214 * stepId?: string,
1215 * executionLocation?: ExecutionLocation,
1216 * }} input
1217 */
1218 export function handleDelegationAuditAppendRequest(input) {
1219 const gate = checkDelegationGate(input.dataDir);
1220 if (!gate.ok) return gate;
1221
1222 const chain = validateChain({
1223 dataDir: input.dataDir,
1224 vaultId: input.vaultId,
1225 actorAgentId: input.actorAgentId,
1226 principalRef: input.principalRef,
1227 grantId: input.grantId,
1228 taskRef: input.taskRef,
1229 runRef: input.runRef,
1230 flowId: input.flowId,
1231 flowVersion: input.flowVersion,
1232 requireGrant: true,
1233 });
1234 if (!chain.ok) {
1235 return refuse(chain.status, chain.code, chain.code);
1236 }
1237
1238 const action = input.action;
1239 if (!AUDIT_ACTIONS.has(action)) {
1240 return refuse(400, 'BAD_REQUEST', 'invalid action');
1241 }
1242
1243 const evidenceRefs = Array.isArray(input.evidenceRefs)
1244 ? input.evidenceRefs.filter((r) => typeof r === 'string' && r.trim()).slice(0, 32)
1245 : [];
1246 if (evidenceRefs.length === 0) {
1247 return refuse(400, 'BAD_REQUEST', 'evidence_refs required');
1248 }
1249
1250 const auditId = randomToken(AUDIT_ID_PREFIX);
1251 const audit = {
1252 schema: DELEGATION_AUDIT_SCHEMA,
1253 audit_id: auditId,
1254 grant_id: input.grantId,
1255 actor_agent_id: input.actorAgentId,
1256 principal_ref: input.principalRef,
1257 task_ref: input.taskRef?.trim() || undefined,
1258 run_ref: input.runRef?.trim() || undefined,
1259 flow_id: input.flowId?.trim() || undefined,
1260 flow_version: input.flowVersion?.trim() || undefined,
1261 step_id: input.stepId?.trim() || undefined,
1262 action,
1263 evidence_refs: evidenceRefs,
1264 occurred_at: new Date().toISOString(),
1265 execution_location: input.executionLocation,
1266 };
1267
1268 const validation = validateAuditRecord(audit);
1269 if (!validation.ok) {
1270 return refuse(400, 'BAD_REQUEST', validation.error);
1271 }
1272
1273 const grantStore = loadGrantsStore(input.dataDir);
1274 const vault = grantStore.vaults[input.vaultId];
1275 const grantIdx = vault?.grants?.findIndex((g) => g.grant_id === input.grantId) ?? -1;
1276 if (grantIdx >= 0) {
1277 vault.grants[grantIdx].action_count = (vault.grants[grantIdx].action_count ?? 0) + 1;
1278 saveGrantsStore(input.dataDir, grantStore);
1279 }
1280
1281 const auditStore = loadAuditStore(input.dataDir);
1282 if (!auditStore.vaults[input.vaultId]) auditStore.vaults[input.vaultId] = { audits: [] };
1283 auditStore.vaults[input.vaultId].audits.push(audit);
1284 saveAuditStore(input.dataDir, auditStore);
1285
1286 return { ok: true, payload: audit };
1287 }
1288
1289 /**
1290 * Seed identity + consent directly for tests (bypasses proposal when gate on in test fixtures).
1291 *
1292 * @param {string} dataDir
1293 * @param {string} vaultId
1294 * @param {object} identity
1295 * @param {object} [consent]
1296 */
1297 export function seedDelegationFixtures(dataDir, vaultId, identity, consent) {
1298 const idStore = loadIdentitiesStore(dataDir);
1299 if (!idStore.vaults[vaultId]) idStore.vaults[vaultId] = { identities: [] };
1300 idStore.vaults[vaultId].identities.push(identity);
1301 saveIdentitiesStore(dataDir, idStore);
1302 if (consent) {
1303 const cStore = loadConsentsStore(dataDir);
1304 if (!cStore.vaults[vaultId]) cStore.vaults[vaultId] = { consents: [] };
1305 cStore.vaults[vaultId].consents.push(consent);
1306 saveConsentsStore(dataDir, cStore);
1307 }
1308 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago