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