@flowdular/sdk 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ai/platform-capabilities.md +2 -2
- package/.ai/policies/capabilities.yaml +30 -3
- package/.ai/references/catalog/module.json +1 -1
- package/.ai/references/catalog/package.json +2 -2
- package/.ai/references/catalog/spec/module.yaml +1 -1
- package/.ai/references/catalog.provenance.json +6 -6
- package/.ai/skills/cli-extension/SKILL.md +1 -1
- package/.ai/skills/deploy-operate/SKILL.md +7 -2
- package/modules/approvals/migrations/0005_approvals_grant_audit.down.sql +4 -0
- package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
- package/modules/approvals/module.json +1 -1
- package/modules/approvals/package.json +1 -1
- package/modules/approvals/spec/module.yaml +11 -2
- package/modules/approvals/src/domain/capability.ts +12 -0
- package/modules/approvals/src/domain/grant.ts +69 -0
- package/modules/approvals/src/domain/types.ts +14 -0
- package/modules/approvals/src/index.ts +9 -0
- package/modules/approvals/src/platform.ts +8 -0
- package/modules/approvals/src/server/runtime.ts +4 -0
- package/modules/approvals/src/services/approvals-service.ts +80 -0
- package/modules/approvals/src/services/database-repository.ts +65 -6
- package/modules/approvals/src/services/migration.ts +34 -0
- package/modules/approvals/src/services/repository.ts +8 -0
- package/modules/connectors/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
- package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
- package/modules/connectors/module.json +7 -3
- package/modules/connectors/package.json +2 -1
- package/modules/connectors/spec/module.yaml +2 -1
- package/modules/connectors/src/cli/commands.json +17 -0
- package/modules/connectors/src/cli/index.ts +126 -0
- package/modules/connectors/src/services/credential-rotation.ts +221 -0
- package/modules/connectors/src/services/credential-vault.ts +6 -0
- package/modules/connectors/src/services/migration.ts +36 -0
- package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
- package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
- package/modules/documents/module.json +7 -3
- package/modules/documents/package.json +2 -1
- package/modules/documents/spec/module.yaml +2 -1
- package/modules/documents/src/cli/commands.json +17 -0
- package/modules/documents/src/cli/index.ts +145 -0
- package/modules/documents/src/services/database-repository.ts +15 -4
- package/modules/documents/src/services/documents-service.ts +13 -9
- package/modules/documents/src/services/migration.ts +35 -0
- package/modules/documents/src/services/repository.ts +12 -2
- package/modules/documents/src/services/storage-rotation.ts +157 -0
- package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
- package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
- package/modules/exports/module.json +7 -3
- package/modules/exports/package.json +2 -1
- package/modules/exports/spec/module.yaml +2 -1
- package/modules/exports/src/cli/commands.json +17 -0
- package/modules/exports/src/cli/index.ts +145 -0
- package/modules/exports/src/server/index.ts +0 -1
- package/modules/exports/src/services/data-classes.ts +16 -13
- package/modules/exports/src/services/database-repository.ts +30 -32
- package/modules/exports/src/services/migration.ts +27 -0
- package/modules/exports/src/services/repository.ts +9 -10
- package/modules/exports/src/services/storage-rotation.ts +138 -0
- package/package.json +1 -1
- package/packages/contracts/src/index.ts +1 -1
- package/packages/database/src/backup.ts +1 -0
- package/packages/database/src/migrations.ts +7 -0
- package/packages/harness/src/runtime.ts +169 -10
- package/packages/harness/src/tool-adapters.ts +6 -13
- package/packages/kernel/src/approval-grant.ts +310 -0
- package/packages/kernel/src/index.ts +20 -0
- package/packages/storage/src/envelope.ts +70 -21
- package/packages/storage/src/index.ts +7 -1
- package/packages/storage/src/port.ts +12 -1
- package/packages/storage/src/reseal.ts +128 -0
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { AgentHarnessError } from './errors.ts';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
APPROVAL_GRANT_MAX_TOKEN_LENGTH,
|
|
5
|
+
approvalGrantKeyringFromEnvironment,
|
|
6
|
+
approvalInputDigest,
|
|
7
|
+
normalizeActor,
|
|
8
|
+
verifyApprovalGrant,
|
|
9
|
+
type Actor,
|
|
10
|
+
type ApprovalGrantKeyring,
|
|
11
|
+
type ApprovalGrantReason,
|
|
12
|
+
type UserActor,
|
|
13
|
+
} from '@flowdular/sdk/kernel';
|
|
4
14
|
import {
|
|
5
15
|
boundToolOutput,
|
|
6
16
|
toolTimeoutMs,
|
|
@@ -49,10 +59,16 @@ export interface AgentExecutionRequest {
|
|
|
49
59
|
readonly definition: AgentExecutionDefinition;
|
|
50
60
|
readonly permissionSnapshot: readonly string[];
|
|
51
61
|
readonly toolGrants: readonly string[];
|
|
62
|
+
/* Signed approval grants (`issueApprovalGrant`) for the external or
|
|
63
|
+
destructive tools this run may call, each bound to one tool id and one
|
|
64
|
+
input digest. Absent means no such tool is offered. */
|
|
65
|
+
readonly grants?: readonly string[];
|
|
52
66
|
/* Absent keeps the v1 text behavior. */
|
|
53
67
|
readonly outputContract?: AgentOutputContract;
|
|
54
68
|
}
|
|
55
69
|
|
|
70
|
+
export const MAX_APPROVAL_GRANTS = 16;
|
|
71
|
+
|
|
56
72
|
export type JsonPrimitive = string | number | boolean | null;
|
|
57
73
|
export type JsonValue =
|
|
58
74
|
| JsonPrimitive
|
|
@@ -153,6 +169,10 @@ export interface AgentTool {
|
|
|
153
169
|
readonly contractVersion?: number;
|
|
154
170
|
readonly outputSchema?: Readonly<Record<string, unknown>>;
|
|
155
171
|
readonly risk?: 'read' | 'workspace-write' | 'external' | 'destructive';
|
|
172
|
+
/* A CLI capability the runner admits in development and test only. The
|
|
173
|
+
harness reads FD_ENV or NODE_ENV the way the runner does and refuses it
|
|
174
|
+
elsewhere, grant or not. */
|
|
175
|
+
readonly localOnly?: boolean;
|
|
156
176
|
/* Run-time admission on top of the permission snapshot. A tool declaring one
|
|
157
177
|
is offered to the model as usual and refused per call when the gate says so. */
|
|
158
178
|
readonly consent?: AgentToolConsent;
|
|
@@ -285,6 +305,23 @@ function assertExecutionRequest(request: AgentExecutionRequest): void {
|
|
|
285
305
|
);
|
|
286
306
|
}
|
|
287
307
|
boundedText(request.input, 'input', 1, 100_000);
|
|
308
|
+
if (request.grants !== undefined) {
|
|
309
|
+
if (
|
|
310
|
+
!Array.isArray(request.grants) ||
|
|
311
|
+
request.grants.length > MAX_APPROVAL_GRANTS ||
|
|
312
|
+
request.grants.some(
|
|
313
|
+
(grant) =>
|
|
314
|
+
typeof grant !== 'string' ||
|
|
315
|
+
grant.length < 1 ||
|
|
316
|
+
grant.length > APPROVAL_GRANT_MAX_TOKEN_LENGTH,
|
|
317
|
+
)
|
|
318
|
+
) {
|
|
319
|
+
throw new AgentHarnessError(
|
|
320
|
+
'INVALID_INPUT',
|
|
321
|
+
`grants must be at most ${MAX_APPROVAL_GRANTS} tokens of at most ${APPROVAL_GRANT_MAX_TOKEN_LENGTH} characters.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
288
325
|
boundedText(request.definition.name, 'definition.name', 2, 120);
|
|
289
326
|
boundedText(request.definition.instructions, 'instructions', 8, 40_000);
|
|
290
327
|
providerReference(request.definition.provider);
|
|
@@ -387,13 +424,75 @@ function toolFailure(error: unknown): { code: string; message: string } {
|
|
|
387
424
|
}
|
|
388
425
|
|
|
389
426
|
/**
|
|
390
|
-
* `external` is the ceiling no unattended caller crosses: the CLI
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
* ever told the tool exists.
|
|
427
|
+
* `external` is the ceiling no unattended caller crosses on its own: the CLI
|
|
428
|
+
* runner, `defineCliAgentTool` for a destructive capability, and a hand-built
|
|
429
|
+
* tool object here all run only under a signed approval grant naming the tool
|
|
430
|
+
* and the input, before the model is ever told the tool exists.
|
|
394
431
|
*/
|
|
395
|
-
function
|
|
396
|
-
return
|
|
432
|
+
function requiresApprovalGrant(tool: AgentTool): boolean {
|
|
433
|
+
return (
|
|
434
|
+
tool.risk === 'external' ||
|
|
435
|
+
(tool.risk === 'destructive' && tool.transport === 'cli')
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
type GrantRefusal = ApprovalGrantReason | 'APPROVAL_GRANT_CONSUMED';
|
|
440
|
+
|
|
441
|
+
interface RunApprovalGrants {
|
|
442
|
+
/** Tokens that verify for this tenant, by the tool id they name. */
|
|
443
|
+
readonly byTool: ReadonlyMap<string, readonly string[]>;
|
|
444
|
+
/** Why the first refused token was refused, per tool, for the denial event. */
|
|
445
|
+
readonly refusals: ReadonlyMap<string, GrantRefusal>;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/* The same reading the CLI runner applies to a localOnly capability. */
|
|
449
|
+
function localOnlyRefused(tool: AgentTool): boolean {
|
|
450
|
+
const environment =
|
|
451
|
+
process.env.FD_ENV ?? process.env.NODE_ENV ?? 'development';
|
|
452
|
+
return (
|
|
453
|
+
tool.localOnly === true &&
|
|
454
|
+
environment !== 'development' &&
|
|
455
|
+
environment !== 'test'
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* Signature, key and tenant are settled once per run; the input digest is
|
|
460
|
+
settled per call, because the model chooses the input. A token that is
|
|
461
|
+
invalid or expired is so for every tool, and is recorded against each. */
|
|
462
|
+
function runApprovalGrants(
|
|
463
|
+
keyring: ApprovalGrantKeyring | undefined,
|
|
464
|
+
request: AgentExecutionRequest,
|
|
465
|
+
tools: ReadonlyMap<string, AgentTool>,
|
|
466
|
+
): RunApprovalGrants {
|
|
467
|
+
const byTool = new Map<string, string[]>();
|
|
468
|
+
const refusals = new Map<string, GrantRefusal>();
|
|
469
|
+
if (!keyring) return { byTool, refusals };
|
|
470
|
+
const gated = [...tools.values()].filter(requiresApprovalGrant);
|
|
471
|
+
for (const token of request.grants ?? []) {
|
|
472
|
+
let reason: ApprovalGrantReason = 'APPROVAL_GRANT_MISMATCH';
|
|
473
|
+
let matched = false;
|
|
474
|
+
for (const tool of gated) {
|
|
475
|
+
const verified = verifyApprovalGrant(keyring, token, {
|
|
476
|
+
tenantId: request.tenantId,
|
|
477
|
+
capabilityId: tool.id,
|
|
478
|
+
});
|
|
479
|
+
if (verified.ok) {
|
|
480
|
+
byTool.set(tool.id, [...(byTool.get(tool.id) ?? []), token]);
|
|
481
|
+
matched = true;
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
reason = verified.reason;
|
|
485
|
+
if (reason !== 'APPROVAL_GRANT_MISMATCH') break;
|
|
486
|
+
}
|
|
487
|
+
/* A token that verifies for no tool is refused for every gated tool: it
|
|
488
|
+
was signed for another tenant or capability, or not by this platform. */
|
|
489
|
+
if (!matched) {
|
|
490
|
+
for (const other of gated) {
|
|
491
|
+
if (!refusals.has(other.id)) refusals.set(other.id, reason);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return { byTool, refusals };
|
|
397
496
|
}
|
|
398
497
|
|
|
399
498
|
/* A refusal code comes from module code, so it is bounded to the shape an
|
|
@@ -466,12 +565,16 @@ export class AgentHarness {
|
|
|
466
565
|
readonly #tools: ReadonlyMap<string, AgentTool>;
|
|
467
566
|
readonly #authorizeToolAccess: AgentToolAccessAuthorizer;
|
|
468
567
|
readonly #tracer: AgentTracer | undefined;
|
|
568
|
+
readonly #approvalGrants: ApprovalGrantKeyring | undefined;
|
|
469
569
|
|
|
470
570
|
constructor(options: {
|
|
471
571
|
readonly providers: readonly AgentProvider[];
|
|
472
572
|
readonly tools?: readonly AgentTool[];
|
|
473
573
|
readonly authorizeToolAccess?: AgentToolAccessAuthorizer;
|
|
474
574
|
readonly tracer?: AgentTracer;
|
|
575
|
+
/* Verifies the approval grants a run carries. Absent reads
|
|
576
|
+
FD_APPROVAL_GRANT_KEY; without either, no external tool ever runs. */
|
|
577
|
+
readonly approvalGrants?: ApprovalGrantKeyring;
|
|
475
578
|
}) {
|
|
476
579
|
const providers = new Map<string, AgentProvider>();
|
|
477
580
|
for (const provider of options.providers) {
|
|
@@ -506,6 +609,8 @@ export class AgentHarness {
|
|
|
506
609
|
permanent credential. */
|
|
507
610
|
this.#authorizeToolAccess = options.authorizeToolAccess ?? (() => []);
|
|
508
611
|
this.#tracer = options.tracer;
|
|
612
|
+
this.#approvalGrants =
|
|
613
|
+
options.approvalGrants ?? approvalGrantKeyringFromEnvironment();
|
|
509
614
|
}
|
|
510
615
|
|
|
511
616
|
providers(): readonly string[] {
|
|
@@ -624,9 +729,17 @@ export class AgentHarness {
|
|
|
624
729
|
);
|
|
625
730
|
};
|
|
626
731
|
const initialPermissions = await livePermissions();
|
|
732
|
+
const grantKeyring = this.#approvalGrants;
|
|
733
|
+
const approvals = runApprovalGrants(grantKeyring, request, this.#tools);
|
|
734
|
+
const admissible = (tool: AgentTool): boolean =>
|
|
735
|
+
!localOnlyRefused(tool) &&
|
|
736
|
+
(!requiresApprovalGrant(tool) || approvals.byTool.has(tool.id));
|
|
737
|
+
/* One call per grant: a request approved once admits one invocation of
|
|
738
|
+
the tool it names, however many times the model asks. */
|
|
739
|
+
const consumedGrants = new Set<string>();
|
|
627
740
|
const availableTools = [...this.#tools.values()]
|
|
628
741
|
.filter((tool) => snapshotGrants.has(tool.id))
|
|
629
|
-
.filter(
|
|
742
|
+
.filter(admissible)
|
|
630
743
|
.filter((tool) =>
|
|
631
744
|
tool.requiredPermissions.every((permission) =>
|
|
632
745
|
initialPermissions.has(permission),
|
|
@@ -670,14 +783,26 @@ export class AgentHarness {
|
|
|
670
783
|
): Promise<unknown> => {
|
|
671
784
|
const ordinal = ++toolCallOrdinal;
|
|
672
785
|
const tool = this.#tools.get(id);
|
|
673
|
-
if (tool &&
|
|
786
|
+
if (tool && localOnlyRefused(tool)) {
|
|
787
|
+
emit('tool.denied', `Tool ${id} was denied.`, {
|
|
788
|
+
tool: id,
|
|
789
|
+
reason: 'TOOL_LOCAL_ONLY',
|
|
790
|
+
});
|
|
791
|
+
throw new AgentHarnessError(
|
|
792
|
+
'TOOL_LOCAL_ONLY',
|
|
793
|
+
`Tool ${id} is a local-only capability and cannot run in this environment.`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
if (tool && !admissible(tool)) {
|
|
797
|
+
const refusal = approvals.refusals.get(id);
|
|
674
798
|
emit('tool.denied', `Tool ${id} was denied.`, {
|
|
675
799
|
tool: id,
|
|
676
800
|
reason: 'TOOL_RISK_REFUSED',
|
|
801
|
+
...(refusal ? { grant: refusal } : {}),
|
|
677
802
|
});
|
|
678
803
|
throw new AgentHarnessError(
|
|
679
804
|
'TOOL_RISK_REFUSED',
|
|
680
|
-
`Tool ${id} declares
|
|
805
|
+
`Tool ${id} declares ${tool.risk} risk and runs only under an approval grant.`,
|
|
681
806
|
);
|
|
682
807
|
}
|
|
683
808
|
if (!tool || !grantedIds.has(id)) {
|
|
@@ -748,6 +873,40 @@ export class AgentHarness {
|
|
|
748
873
|
});
|
|
749
874
|
throw error;
|
|
750
875
|
}
|
|
876
|
+
if (requiresApprovalGrant(tool)) {
|
|
877
|
+
const expected = {
|
|
878
|
+
tenantId: request.tenantId,
|
|
879
|
+
capabilityId: id,
|
|
880
|
+
inputDigest: approvalInputDigest(input),
|
|
881
|
+
};
|
|
882
|
+
let refusal: GrantRefusal = 'APPROVAL_GRANT_MISMATCH';
|
|
883
|
+
const admitted =
|
|
884
|
+
grantKeyring !== undefined &&
|
|
885
|
+
(approvals.byTool.get(id) ?? []).some((token) => {
|
|
886
|
+
const verified = verifyApprovalGrant(grantKeyring, token, expected);
|
|
887
|
+
if (!verified.ok) {
|
|
888
|
+
refusal = verified.reason;
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
if (consumedGrants.has(verified.claims.requestId)) {
|
|
892
|
+
refusal = 'APPROVAL_GRANT_CONSUMED';
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
consumedGrants.add(verified.claims.requestId);
|
|
896
|
+
return true;
|
|
897
|
+
});
|
|
898
|
+
if (!admitted) {
|
|
899
|
+
emit('tool.denied', `Tool ${id} was denied.`, {
|
|
900
|
+
tool: id,
|
|
901
|
+
reason: 'TOOL_RISK_REFUSED',
|
|
902
|
+
grant: refusal,
|
|
903
|
+
});
|
|
904
|
+
throw new AgentHarnessError(
|
|
905
|
+
'TOOL_RISK_REFUSED',
|
|
906
|
+
`Tool ${id} has no approval grant for this input.`,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
751
910
|
if (tool.consent) {
|
|
752
911
|
/* Foreign code: a throw is a refusal, never a crash. The run deadline
|
|
753
912
|
already bounds it, because the provider call this runs inside races
|
|
@@ -30,6 +30,7 @@ export interface CliAgentToolDefinition extends AgentToolBase {
|
|
|
30
30
|
readonly capability: {
|
|
31
31
|
readonly id: string;
|
|
32
32
|
readonly risk: CapabilityRisk;
|
|
33
|
+
readonly localOnly?: boolean;
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -40,14 +41,11 @@ function dottedIdentifier(value: string, field: string): string {
|
|
|
40
41
|
return value;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/* An external tool builds as any other; the harness offers and runs it only
|
|
45
|
+
under an approval grant naming the tool and the input. */
|
|
43
46
|
export function defineApiAgentTool(
|
|
44
47
|
definition: ApiAgentToolDefinition,
|
|
45
48
|
): AgentTool {
|
|
46
|
-
if (definition.risk === 'external') {
|
|
47
|
-
throw new Error(
|
|
48
|
-
`Tool ${definition.id} declares external risk and cannot be registered as an unattended agent tool.`,
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
49
|
return Object.freeze({
|
|
52
50
|
id: dottedIdentifier(definition.id, 'Tool id'),
|
|
53
51
|
transport: 'api' as const,
|
|
@@ -80,17 +78,11 @@ export function defineApiAgentTool(
|
|
|
80
78
|
});
|
|
81
79
|
}
|
|
82
80
|
|
|
81
|
+
/* An external or destructive capability builds as any other; the harness runs
|
|
82
|
+
the tool only under an approval grant naming the tool and the input. */
|
|
83
83
|
export function defineCliAgentTool(
|
|
84
84
|
definition: CliAgentToolDefinition,
|
|
85
85
|
): AgentTool {
|
|
86
|
-
if (
|
|
87
|
-
definition.capability.risk === 'external' ||
|
|
88
|
-
definition.capability.risk === 'destructive'
|
|
89
|
-
) {
|
|
90
|
-
throw new Error(
|
|
91
|
-
`CLI capability ${definition.capability.id} requires an approval receipt and cannot be registered as an unattended agent tool.`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
86
|
return Object.freeze({
|
|
95
87
|
id: dottedIdentifier(definition.id, 'Tool id'),
|
|
96
88
|
transport: 'cli' as const,
|
|
@@ -104,6 +96,7 @@ export function defineCliAgentTool(
|
|
|
104
96
|
? {}
|
|
105
97
|
: { outputSchema: definition.outputSchema }),
|
|
106
98
|
...(definition.risk === undefined ? {} : { risk: definition.risk }),
|
|
99
|
+
...(definition.capability.localOnly === true ? { localOnly: true } : {}),
|
|
107
100
|
...(definition.consent === undefined
|
|
108
101
|
? {}
|
|
109
102
|
: { consent: definition.consent }),
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
KEYRING_KEY_BYTES,
|
|
4
|
+
KEYRING_MAX_PREVIOUS_KEYS,
|
|
5
|
+
keyFingerprint,
|
|
6
|
+
parsePreviousKeys,
|
|
7
|
+
type KeyringOptions,
|
|
8
|
+
} from './keyring.ts';
|
|
9
|
+
|
|
10
|
+
export const APPROVAL_GRANT_KEY_VARIABLE = 'FD_APPROVAL_GRANT_KEY';
|
|
11
|
+
export const APPROVAL_GRANT_PREVIOUS_KEY_VARIABLE =
|
|
12
|
+
'FD_APPROVAL_GRANT_KEY_PREVIOUS';
|
|
13
|
+
export const APPROVAL_GRANT_MAX_TOKEN_LENGTH = 2_048;
|
|
14
|
+
export const APPROVAL_GRANT_INPUT_DIGEST_LENGTH = 64;
|
|
15
|
+
|
|
16
|
+
const TOKEN_PREFIX = 'ag1';
|
|
17
|
+
const MAX_CLAIM_LENGTH = 128;
|
|
18
|
+
|
|
19
|
+
export type ApprovalGrantReason =
|
|
20
|
+
| 'APPROVAL_GRANT_INVALID'
|
|
21
|
+
| 'APPROVAL_GRANT_EXPIRED'
|
|
22
|
+
| 'APPROVAL_GRANT_MISMATCH';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What one approved request lets one caller do with one input. The harness
|
|
26
|
+
* admits one tool call per grant within a run; the CLI runner has no platform
|
|
27
|
+
* connection to record use on, so a grant passed to it is bounded by
|
|
28
|
+
* `expiresAt` alone and the same token runs the same invocation again until
|
|
29
|
+
* then. The window is the approving module's to keep short.
|
|
30
|
+
*/
|
|
31
|
+
export interface ApprovalGrantClaims {
|
|
32
|
+
readonly v: 1;
|
|
33
|
+
readonly tenantId: string;
|
|
34
|
+
readonly capabilityId: string;
|
|
35
|
+
/** `approvalInputDigest` of the invocation input the approval was asked for. */
|
|
36
|
+
readonly inputDigest: string;
|
|
37
|
+
readonly requestId: string;
|
|
38
|
+
readonly issuedAt: number;
|
|
39
|
+
readonly expiresAt: number;
|
|
40
|
+
readonly nonce: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ApprovalGrantInput = Omit<ApprovalGrantClaims, 'v'>;
|
|
44
|
+
|
|
45
|
+
export interface IssuedApprovalGrant {
|
|
46
|
+
readonly token: string;
|
|
47
|
+
readonly claims: ApprovalGrantClaims;
|
|
48
|
+
readonly keyId: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What a verifier holds the grant against; an absent digest is not checked. */
|
|
52
|
+
export interface ApprovalGrantExpectation {
|
|
53
|
+
readonly tenantId: string;
|
|
54
|
+
readonly capabilityId: string;
|
|
55
|
+
readonly inputDigest?: string | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ApprovalGrantVerification =
|
|
59
|
+
| { readonly ok: true; readonly claims: ApprovalGrantClaims }
|
|
60
|
+
| {
|
|
61
|
+
readonly ok: false;
|
|
62
|
+
readonly reason: ApprovalGrantReason;
|
|
63
|
+
readonly message: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The HMAC key set behind approval grants: the current key signs, and every
|
|
68
|
+
* key kept from before a rotation still verifies, looked up by the fingerprint
|
|
69
|
+
* the token names.
|
|
70
|
+
*/
|
|
71
|
+
export interface ApprovalGrantKeyring {
|
|
72
|
+
readonly keyId: string;
|
|
73
|
+
readonly previousKeyIds: readonly string[];
|
|
74
|
+
key(keyId: string): Buffer | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createApprovalGrantKeyring(
|
|
78
|
+
options: KeyringOptions,
|
|
79
|
+
): ApprovalGrantKeyring {
|
|
80
|
+
const previous = options.previous ?? [];
|
|
81
|
+
if (previous.length > KEYRING_MAX_PREVIOUS_KEYS) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`An approval grant keyring accepts at most ${KEYRING_MAX_PREVIOUS_KEYS} previous keys; ${previous.length} were given.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const keys = new Map<string, Buffer>();
|
|
87
|
+
const order: string[] = [];
|
|
88
|
+
for (const key of [options.current, ...previous]) {
|
|
89
|
+
if (key.byteLength !== KEYRING_KEY_BYTES) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`An approval grant key must be exactly ${KEYRING_KEY_BYTES} bytes.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const id = keyFingerprint(key);
|
|
95
|
+
if (keys.has(id)) continue;
|
|
96
|
+
keys.set(id, Buffer.from(key));
|
|
97
|
+
order.push(id);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
keyId: order[0]!,
|
|
101
|
+
previousKeyIds: order.slice(1),
|
|
102
|
+
key: (keyId) => keys.get(keyId),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decodeKey(value: string, variable: string): Buffer {
|
|
107
|
+
const key = Buffer.from(value.trim(), 'base64');
|
|
108
|
+
if (key.byteLength !== KEYRING_KEY_BYTES) {
|
|
109
|
+
key.fill(0);
|
|
110
|
+
throw new Error(
|
|
111
|
+
`${variable} must be a base64-encoded ${KEYRING_KEY_BYTES}-byte key.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return key;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Absent when no key is configured: nothing can then be issued or verified. */
|
|
118
|
+
export function approvalGrantKeyringFromEnvironment(
|
|
119
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
120
|
+
): ApprovalGrantKeyring | undefined {
|
|
121
|
+
const current = environment[APPROVAL_GRANT_KEY_VARIABLE];
|
|
122
|
+
if (!current) return undefined;
|
|
123
|
+
return createApprovalGrantKeyring({
|
|
124
|
+
current: decodeKey(current, APPROVAL_GRANT_KEY_VARIABLE),
|
|
125
|
+
previous: parsePreviousKeys(
|
|
126
|
+
environment[APPROVAL_GRANT_PREVIOUS_KEY_VARIABLE],
|
|
127
|
+
(entry) => decodeKey(entry, APPROVAL_GRANT_PREVIOUS_KEY_VARIABLE),
|
|
128
|
+
),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function canonical(value: unknown): string {
|
|
133
|
+
if (value === null || typeof value !== 'object') {
|
|
134
|
+
return JSON.stringify(value === undefined ? null : value);
|
|
135
|
+
}
|
|
136
|
+
if (Array.isArray(value)) {
|
|
137
|
+
return `[${value.map((entry) => canonical(entry)).join(',')}]`;
|
|
138
|
+
}
|
|
139
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
140
|
+
.filter(([, entry]) => entry !== undefined)
|
|
141
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
142
|
+
return `{${entries
|
|
143
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
|
|
144
|
+
.join(',')}}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The digest a grant binds to: the SHA-256 of the input serialised with
|
|
149
|
+
* object keys sorted, so the requester and the verifier agree on it
|
|
150
|
+
* whatever order either built the object in.
|
|
151
|
+
*/
|
|
152
|
+
export function approvalInputDigest(input: unknown): string {
|
|
153
|
+
return createHash('sha256').update(canonical(input), 'utf8').digest('hex');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function claimString(
|
|
157
|
+
value: unknown,
|
|
158
|
+
maximum = MAX_CLAIM_LENGTH,
|
|
159
|
+
): string | undefined {
|
|
160
|
+
return typeof value === 'string' &&
|
|
161
|
+
value.length >= 1 &&
|
|
162
|
+
value.length <= maximum &&
|
|
163
|
+
!/[\s\u0000]/.test(value)
|
|
164
|
+
? value
|
|
165
|
+
: undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function claimInteger(value: unknown): number | undefined {
|
|
169
|
+
return Number.isSafeInteger(value) && (value as number) >= 0
|
|
170
|
+
? (value as number)
|
|
171
|
+
: undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readClaims(value: unknown): ApprovalGrantClaims | undefined {
|
|
175
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const raw = value as Record<string, unknown>;
|
|
179
|
+
const tenantId = claimString(raw.tenantId);
|
|
180
|
+
const capabilityId = claimString(raw.capabilityId);
|
|
181
|
+
const inputDigest = claimString(
|
|
182
|
+
raw.inputDigest,
|
|
183
|
+
APPROVAL_GRANT_INPUT_DIGEST_LENGTH,
|
|
184
|
+
);
|
|
185
|
+
const requestId = claimString(raw.requestId);
|
|
186
|
+
const nonce = claimString(raw.nonce);
|
|
187
|
+
const issuedAt = claimInteger(raw.issuedAt);
|
|
188
|
+
const expiresAt = claimInteger(raw.expiresAt);
|
|
189
|
+
if (
|
|
190
|
+
raw.v !== 1 ||
|
|
191
|
+
tenantId === undefined ||
|
|
192
|
+
capabilityId === undefined ||
|
|
193
|
+
inputDigest === undefined ||
|
|
194
|
+
requestId === undefined ||
|
|
195
|
+
nonce === undefined ||
|
|
196
|
+
issuedAt === undefined ||
|
|
197
|
+
expiresAt === undefined ||
|
|
198
|
+
expiresAt <= issuedAt
|
|
199
|
+
) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
v: 1,
|
|
204
|
+
tenantId,
|
|
205
|
+
capabilityId,
|
|
206
|
+
inputDigest,
|
|
207
|
+
requestId,
|
|
208
|
+
issuedAt,
|
|
209
|
+
expiresAt,
|
|
210
|
+
nonce,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function signature(key: Buffer, signed: string): Buffer {
|
|
215
|
+
return createHmac('sha256', key).update(signed, 'utf8').digest();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function issueApprovalGrant(
|
|
219
|
+
keyring: ApprovalGrantKeyring,
|
|
220
|
+
input: ApprovalGrantInput,
|
|
221
|
+
): IssuedApprovalGrant {
|
|
222
|
+
const claims = readClaims({ v: 1, ...input });
|
|
223
|
+
if (!claims) {
|
|
224
|
+
throw new Error('Approval grant claims are out of bounds.');
|
|
225
|
+
}
|
|
226
|
+
const payload = Buffer.from(canonical(claims), 'utf8').toString('base64url');
|
|
227
|
+
const signed = `${TOKEN_PREFIX}.${keyring.keyId}.${payload}`;
|
|
228
|
+
const token = `${signed}.${signature(keyring.key(keyring.keyId)!, signed).toString('base64url')}`;
|
|
229
|
+
if (token.length > APPROVAL_GRANT_MAX_TOKEN_LENGTH) {
|
|
230
|
+
throw new Error('Approval grant token exceeds the token bound.');
|
|
231
|
+
}
|
|
232
|
+
return { token, claims, keyId: keyring.keyId };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function invalid(message: string): ApprovalGrantVerification {
|
|
236
|
+
return { ok: false, reason: 'APPROVAL_GRANT_INVALID', message };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Signature first, then lifetime, then binding: a token that does not verify
|
|
241
|
+
* says nothing about who it was for, and one that has run out is refused
|
|
242
|
+
* before its claims are compared.
|
|
243
|
+
*/
|
|
244
|
+
export function verifyApprovalGrant(
|
|
245
|
+
keyring: ApprovalGrantKeyring,
|
|
246
|
+
token: string,
|
|
247
|
+
expected: ApprovalGrantExpectation,
|
|
248
|
+
now: number = Date.now(),
|
|
249
|
+
): ApprovalGrantVerification {
|
|
250
|
+
if (
|
|
251
|
+
typeof token !== 'string' ||
|
|
252
|
+
token.length < 32 ||
|
|
253
|
+
token.length > APPROVAL_GRANT_MAX_TOKEN_LENGTH
|
|
254
|
+
) {
|
|
255
|
+
return invalid('The approval grant is not a token this platform issued.');
|
|
256
|
+
}
|
|
257
|
+
const [prefix, keyId, payload, supplied, extra] = token.split('.');
|
|
258
|
+
if (
|
|
259
|
+
prefix !== TOKEN_PREFIX ||
|
|
260
|
+
!keyId ||
|
|
261
|
+
!payload ||
|
|
262
|
+
!supplied ||
|
|
263
|
+
extra !== undefined
|
|
264
|
+
) {
|
|
265
|
+
return invalid('The approval grant is not a token this platform issued.');
|
|
266
|
+
}
|
|
267
|
+
const key = keyring.key(keyId);
|
|
268
|
+
if (!key) {
|
|
269
|
+
return invalid(
|
|
270
|
+
'The approval grant names a key this platform does not hold.',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const expectedSignature = signature(key, `${prefix}.${keyId}.${payload}`);
|
|
274
|
+
const suppliedSignature = Buffer.from(supplied, 'base64url');
|
|
275
|
+
if (
|
|
276
|
+
suppliedSignature.byteLength !== expectedSignature.byteLength ||
|
|
277
|
+
!timingSafeEqual(suppliedSignature, expectedSignature)
|
|
278
|
+
) {
|
|
279
|
+
return invalid('The approval grant signature does not verify.');
|
|
280
|
+
}
|
|
281
|
+
let parsed: unknown;
|
|
282
|
+
try {
|
|
283
|
+
parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
284
|
+
} catch {
|
|
285
|
+
return invalid('The approval grant payload is unreadable.');
|
|
286
|
+
}
|
|
287
|
+
const claims = readClaims(parsed);
|
|
288
|
+
if (!claims) return invalid('The approval grant payload is out of bounds.');
|
|
289
|
+
if (claims.expiresAt <= now) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
reason: 'APPROVAL_GRANT_EXPIRED',
|
|
293
|
+
message: 'The approval grant has expired.',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (
|
|
297
|
+
claims.tenantId !== expected.tenantId ||
|
|
298
|
+
claims.capabilityId !== expected.capabilityId ||
|
|
299
|
+
(expected.inputDigest !== undefined &&
|
|
300
|
+
claims.inputDigest !== expected.inputDigest)
|
|
301
|
+
) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
reason: 'APPROVAL_GRANT_MISMATCH',
|
|
305
|
+
message:
|
|
306
|
+
'The approval grant was issued for another workspace, capability or input.',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return { ok: true, claims };
|
|
310
|
+
}
|
|
@@ -63,6 +63,26 @@ export type {
|
|
|
63
63
|
ModuleCapabilityDeclaration,
|
|
64
64
|
PlatformCapabilityRegistry,
|
|
65
65
|
} from './capability-registry.ts';
|
|
66
|
+
export {
|
|
67
|
+
APPROVAL_GRANT_INPUT_DIGEST_LENGTH,
|
|
68
|
+
APPROVAL_GRANT_KEY_VARIABLE,
|
|
69
|
+
APPROVAL_GRANT_MAX_TOKEN_LENGTH,
|
|
70
|
+
APPROVAL_GRANT_PREVIOUS_KEY_VARIABLE,
|
|
71
|
+
approvalGrantKeyringFromEnvironment,
|
|
72
|
+
approvalInputDigest,
|
|
73
|
+
createApprovalGrantKeyring,
|
|
74
|
+
issueApprovalGrant,
|
|
75
|
+
verifyApprovalGrant,
|
|
76
|
+
} from './approval-grant.ts';
|
|
77
|
+
export type {
|
|
78
|
+
ApprovalGrantClaims,
|
|
79
|
+
ApprovalGrantExpectation,
|
|
80
|
+
ApprovalGrantInput,
|
|
81
|
+
ApprovalGrantKeyring,
|
|
82
|
+
ApprovalGrantReason,
|
|
83
|
+
ApprovalGrantVerification,
|
|
84
|
+
IssuedApprovalGrant,
|
|
85
|
+
} from './approval-grant.ts';
|
|
66
86
|
export { RegistryError } from './errors.ts';
|
|
67
87
|
export {
|
|
68
88
|
createKeyring,
|