@flowdular/sdk 0.3.0 → 0.3.2

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.
Files changed (174) hide show
  1. package/.ai/platform-capabilities.md +4 -2
  2. package/.ai/policies/capabilities.yaml +30 -3
  3. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
  4. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
  5. package/.ai/references/catalog/module.json +11 -1
  6. package/.ai/references/catalog/package.json +2 -2
  7. package/.ai/references/catalog/spec/module.yaml +25 -4
  8. package/.ai/references/catalog/src/agent/tools.ts +19 -10
  9. package/.ai/references/catalog/src/api/endpoints.ts +150 -10
  10. package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
  11. package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
  12. package/.ai/references/catalog/src/client/api.ts +124 -36
  13. package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
  14. package/.ai/references/catalog/src/client/state.ts +169 -3
  15. package/.ai/references/catalog/src/domain/lists.ts +7 -0
  16. package/.ai/references/catalog/src/domain/types.ts +20 -0
  17. package/.ai/references/catalog/src/platform.ts +20 -0
  18. package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
  19. package/.ai/references/catalog/src/services/database-repository.ts +104 -17
  20. package/.ai/references/catalog/src/services/item-export.ts +81 -0
  21. package/.ai/references/catalog/src/services/migration.ts +27 -1
  22. package/.ai/references/catalog/src/services/repository.ts +31 -2
  23. package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
  24. package/.ai/references/catalog/tests/client-state.test.ts +124 -0
  25. package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
  26. package/.ai/references/catalog/tests/export.test.ts +134 -0
  27. package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
  28. package/.ai/references/catalog/tests/list.test.ts +217 -0
  29. package/.ai/references/catalog/tests/migrations.test.ts +58 -2
  30. package/.ai/references/catalog/tests/module.test.ts +2 -1
  31. package/.ai/references/catalog/tests/support/database.ts +14 -0
  32. package/.ai/references/catalog/translations/en.json +35 -4
  33. package/.ai/references/catalog/translations/pl.json +35 -4
  34. package/.ai/references/catalog.provenance.json +34 -26
  35. package/.ai/skills/cli-extension/SKILL.md +1 -1
  36. package/.ai/skills/deploy-operate/SKILL.md +7 -2
  37. package/README.md +1 -1
  38. package/assets/flowdular-banner.webp +0 -0
  39. package/modules/access/module.json +1 -1
  40. package/modules/access/spec/module.yaml +1 -1
  41. package/modules/agents/module.json +1 -1
  42. package/modules/agents/spec/module.yaml +1 -1
  43. package/modules/approvals/migrations/0005_approvals_grant_audit.down.sql +4 -0
  44. package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
  45. package/modules/approvals/module.json +2 -2
  46. package/modules/approvals/package.json +1 -1
  47. package/modules/approvals/spec/module.yaml +12 -3
  48. package/modules/approvals/src/domain/capability.ts +12 -0
  49. package/modules/approvals/src/domain/grant.ts +69 -0
  50. package/modules/approvals/src/domain/types.ts +14 -0
  51. package/modules/approvals/src/index.ts +9 -0
  52. package/modules/approvals/src/platform.ts +8 -0
  53. package/modules/approvals/src/server/runtime.ts +4 -0
  54. package/modules/approvals/src/services/approvals-service.ts +80 -0
  55. package/modules/approvals/src/services/database-repository.ts +65 -6
  56. package/modules/approvals/src/services/migration.ts +34 -0
  57. package/modules/approvals/src/services/repository.ts +8 -0
  58. package/modules/audit/module.json +1 -1
  59. package/modules/audit/spec/module.yaml +1 -1
  60. package/modules/auth/module.json +2 -2
  61. package/modules/auth/package.json +1 -1
  62. package/modules/auth/spec/module.yaml +2 -2
  63. package/modules/auth/src/middleware/authentication.ts +5 -1
  64. package/modules/auth/src/services/auth-service.ts +26 -0
  65. package/modules/auth/src/services/database-repository.ts +9 -0
  66. package/modules/auth/src/services/repository.ts +2 -0
  67. package/modules/automations/module.json +1 -1
  68. package/modules/automations/spec/module.yaml +1 -1
  69. package/modules/connectors/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
  70. package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
  71. package/modules/connectors/module.json +8 -4
  72. package/modules/connectors/package.json +2 -1
  73. package/modules/connectors/spec/module.yaml +3 -2
  74. package/modules/connectors/src/cli/commands.json +17 -0
  75. package/modules/connectors/src/cli/index.ts +126 -0
  76. package/modules/connectors/src/services/credential-rotation.ts +221 -0
  77. package/modules/connectors/src/services/credential-vault.ts +6 -0
  78. package/modules/connectors/src/services/migration.ts +36 -0
  79. package/modules/directory/module.json +1 -1
  80. package/modules/directory/spec/module.yaml +1 -1
  81. package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
  82. package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
  83. package/modules/documents/module.json +8 -4
  84. package/modules/documents/package.json +2 -1
  85. package/modules/documents/spec/module.yaml +3 -2
  86. package/modules/documents/src/cli/commands.json +17 -0
  87. package/modules/documents/src/cli/index.ts +145 -0
  88. package/modules/documents/src/services/database-repository.ts +15 -4
  89. package/modules/documents/src/services/documents-service.ts +13 -9
  90. package/modules/documents/src/services/migration.ts +35 -0
  91. package/modules/documents/src/services/repository.ts +12 -2
  92. package/modules/documents/src/services/storage-rotation.ts +157 -0
  93. package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
  94. package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
  95. package/modules/exports/module.json +8 -4
  96. package/modules/exports/package.json +2 -1
  97. package/modules/exports/spec/module.yaml +3 -2
  98. package/modules/exports/src/cli/commands.json +17 -0
  99. package/modules/exports/src/cli/index.ts +145 -0
  100. package/modules/exports/src/server/index.ts +0 -1
  101. package/modules/exports/src/services/data-classes.ts +16 -13
  102. package/modules/exports/src/services/database-repository.ts +30 -32
  103. package/modules/exports/src/services/migration.ts +27 -0
  104. package/modules/exports/src/services/repository.ts +9 -10
  105. package/modules/exports/src/services/storage-rotation.ts +138 -0
  106. package/modules/import/module.json +1 -1
  107. package/modules/import/spec/module.yaml +1 -1
  108. package/modules/metering/module.json +1 -1
  109. package/modules/metering/spec/module.yaml +1 -1
  110. package/modules/notifications/module.json +1 -1
  111. package/modules/notifications/spec/module.yaml +1 -1
  112. package/modules/reports/module.json +1 -1
  113. package/modules/reports/spec/module.yaml +1 -1
  114. package/modules/sandbox/module.json +1 -1
  115. package/modules/sandbox/spec/module.yaml +1 -1
  116. package/modules/search/module.json +1 -1
  117. package/modules/search/spec/module.yaml +1 -1
  118. package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
  119. package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
  120. package/modules/system/module.json +3 -2
  121. package/modules/system/package.json +2 -2
  122. package/modules/system/spec/module.yaml +98 -1
  123. package/modules/system/src/client/ModulesView.tsrx +101 -8
  124. package/modules/system/src/client/api.ts +52 -11
  125. package/modules/system/src/client/module-columns.tsrx +32 -6
  126. package/modules/system/src/client/state.ts +26 -0
  127. package/modules/system/src/domain/modules.ts +93 -0
  128. package/modules/system/src/index.ts +13 -0
  129. package/modules/system/src/platform.ts +41 -1
  130. package/modules/system/src/server/capability.ts +6 -0
  131. package/modules/system/src/server/endpoints.ts +125 -5
  132. package/modules/system/src/server/index.ts +26 -0
  133. package/modules/system/src/server/module-catalog.ts +19 -0
  134. package/modules/system/src/server/runtime.ts +102 -0
  135. package/modules/system/src/services/database-repository.ts +91 -0
  136. package/modules/system/src/services/migration.ts +39 -0
  137. package/modules/system/src/services/module-activation-service.ts +245 -0
  138. package/modules/system/src/services/repository.ts +17 -0
  139. package/modules/system/translations/en.json +17 -1
  140. package/modules/system/translations/pl.json +17 -1
  141. package/modules/users/module.json +2 -2
  142. package/modules/users/package.json +1 -1
  143. package/modules/users/spec/module.yaml +6 -9
  144. package/modules/users/src/client/UsersView.tsrx +27 -13
  145. package/modules/users/src/client/member-columns.tsrx +13 -4
  146. package/modules/users/src/client/state.ts +16 -0
  147. package/modules/users/src/services/users-service.ts +3 -0
  148. package/modules/users/translations/en.json +2 -1
  149. package/modules/users/translations/pl.json +2 -1
  150. package/modules/workflows/module.json +2 -2
  151. package/modules/workflows/package.json +1 -1
  152. package/modules/workflows/spec/module.yaml +2 -2
  153. package/modules/workflows/src/services/database-repository.ts +93 -53
  154. package/package.json +1 -1
  155. package/packages/client/package.json +1 -0
  156. package/packages/client/src/ApplicationShell.tsrx +46 -5
  157. package/packages/client/src/index.ts +5 -0
  158. package/packages/client/src/shell/modules.ts +49 -0
  159. package/packages/client/src/shell/types.ts +3 -0
  160. package/packages/contracts/src/index.ts +11 -1
  161. package/packages/database/src/backup.ts +1 -0
  162. package/packages/database/src/migrations.ts +7 -0
  163. package/packages/harness/src/runtime.ts +169 -10
  164. package/packages/harness/src/tool-adapters.ts +6 -13
  165. package/packages/kernel/src/approval-grant.ts +310 -0
  166. package/packages/kernel/src/index.ts +20 -0
  167. package/packages/server/src/endpoint.ts +17 -1
  168. package/packages/server/src/index.ts +10 -0
  169. package/packages/server/src/module-activation.ts +69 -0
  170. package/packages/storage/src/envelope.ts +70 -21
  171. package/packages/storage/src/index.ts +7 -1
  172. package/packages/storage/src/port.ts +12 -1
  173. package/packages/storage/src/reseal.ts +128 -0
  174. package/assets/flowdular-banner.png +0 -0
@@ -1,6 +1,16 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { AgentHarnessError } from './errors.ts';
3
- import { normalizeActor, type Actor, type UserActor } from '@flowdular/sdk/kernel';
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 runner
391
- * refuses it, `defineCliAgentTool` and `defineApiAgentTool` refuse to build it,
392
- * and a hand-built tool object is refused here as well, before the model is
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 admissibleRisk(tool: AgentTool): boolean {
396
- return tool.risk !== 'external';
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(admissibleRisk)
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 && !admissibleRisk(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 external risk and cannot run unattended.`,
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,
@@ -1,6 +1,7 @@
1
1
  import { ServerRoute, type Context } from '@octanejs/app-core';
2
2
  import { serverLogger } from './log.ts';
3
3
  import { serverMetrics } from './metrics.ts';
4
+ import { MODULE_INACTIVE, routeActiveForTenant } from './module-activation.ts';
4
5
  import {
5
6
  formatTraceParent,
6
7
  runWithTrace,
@@ -11,6 +12,8 @@ import { serverTracer, type Span } from './trace/tracer.ts';
11
12
  export interface EndpointIdentity {
12
13
  readonly subjectId: string;
13
14
  readonly permissions: ReadonlySet<string>;
15
+ /** The workspace the identity acts in; the module activation gate reads it. */
16
+ readonly tenantId?: string;
14
17
  }
15
18
 
16
19
  export interface EndpointExecutionContext {
@@ -131,6 +134,19 @@ export function defineEndpoint(
131
134
  traceparent,
132
135
  );
133
136
  }
137
+ if (
138
+ identity.tenantId !== undefined &&
139
+ !(await routeActiveForTenant(serverRoute, identity.tenantId))
140
+ ) {
141
+ status = 403;
142
+ return problem(
143
+ 403,
144
+ MODULE_INACTIVE,
145
+ 'This module is inactive in the workspace.',
146
+ requestId,
147
+ traceparent,
148
+ );
149
+ }
134
150
  }
135
151
 
136
152
  const response = await definition.handler({
@@ -171,7 +187,7 @@ export function defineEndpoint(
171
187
  }
172
188
  };
173
189
 
174
- const serverRoute = new ServerRoute({
190
+ const serverRoute: ServerRoute = new ServerRoute({
175
191
  path: definition.path,
176
192
  methods: definition.methods.map((method) => method.toUpperCase()),
177
193
  handler: (context) => {