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