@axtary/actionpass 0.1.0 → 0.2.0

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/dist/index.js CHANGED
@@ -1,10 +1,25 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
- import { decodeProtectedHeader, SignJWT, jwtVerify, } from "jose";
4
+ import { lock } from "proper-lockfile";
5
+ import { decodeProtectedHeader, calculateJwkThumbprint, importJWK, SignJWT, jwtVerify, } from "jose";
5
6
  import { z } from "zod";
7
+ import { ActionPassStatusClaimSchema, } from "./status-list.js";
8
+ import { ActionProvenanceSchema, } from "./provenance.js";
9
+ import { analyzePostgresSelect } from "./postgres.js";
10
+ import { explainDecision, } from "./explain.js";
11
+ import { AXTARY_DEV_SIGNING_KEY_ENV, devKeypair, } from "./dev-key.js";
12
+ export * from "./caep.js";
13
+ export * from "./dev-key.js";
14
+ export * from "./explain.js";
15
+ export * from "./postgres.js";
16
+ export * from "./provenance.js";
17
+ export * from "./status-list.js";
6
18
  export const ACTION_SCHEMA_VERSION = "axtary.action.v0";
7
- export const ACTIONPASS_VERSION = "axtary.actionpass.v0";
19
+ export const ACTIONPASS_V0_VERSION = "axtary.actionpass.v0";
20
+ export const ACTIONPASS_V1_VERSION = "axtary.actionpass.v1";
21
+ export const ACTIONPASS_V2_VERSION = "axtary.actionpass.v2";
22
+ export const ACTIONPASS_VERSION = ACTIONPASS_V0_VERSION;
8
23
  export const APPROVAL_ARTIFACT_VERSION = "axtary.approval.v0";
9
24
  export const ACTIONPASS_REVOCATION_VERSION = "axtary.actionpass_revocation.v0";
10
25
  export const ACTIONPASS_TRUST_STORE_VERSION = "axtary.actionpass_trust_store.v0";
@@ -13,6 +28,9 @@ export const LEDGER_PROVIDER_EVIDENCE_VERSION = "axtary.ledger_provider_evidence
13
28
  export const LEDGER_EXECUTION_OUTCOME_VERSION = "axtary.ledger_execution_outcome.v0";
14
29
  export const DEFAULT_EXPIRES_IN_SECONDS = 600;
15
30
  export const DEFAULT_SIGNING_ALGORITHM = "ES256";
31
+ export const DPOP_PROOF_TYP = "dpop+jwt";
32
+ export const DEFAULT_DPOP_MAX_AGE_SECONDS = 60;
33
+ export const DEFAULT_DPOP_CLOCK_SKEW_SECONDS = 5;
16
34
  const JsonValueSchema = z.lazy(() => z.union([
17
35
  z.string(),
18
36
  z.number().finite(),
@@ -21,6 +39,13 @@ const JsonValueSchema = z.lazy(() => z.union([
21
39
  z.array(JsonValueSchema),
22
40
  z.record(z.string(), JsonValueSchema),
23
41
  ]));
42
+ export const BudgetAmountSchema = z.record(z.string().min(1), z.number().finite().nonnegative());
43
+ export const ActionBudgetSchema = z.object({
44
+ reservationId: z.string().min(1),
45
+ cost: BudgetAmountSchema,
46
+ limit: BudgetAmountSchema,
47
+ commitStatus: z.enum(["pending", "committed", "rolled_back"]),
48
+ });
24
49
  export const AxtaryDecisionSchema = z.enum(["allow", "deny", "step_up"]);
25
50
  export const NormalizedActionSchema = z.object({
26
51
  schemaVersion: z.literal(ACTION_SCHEMA_VERSION).default(ACTION_SCHEMA_VERSION),
@@ -46,15 +71,18 @@ export const NormalizedActionSchema = z.object({
46
71
  serverIdentity: z.string().min(1),
47
72
  schemaVersion: z.string().min(1),
48
73
  definitionHash: z.string().min(1),
74
+ // Optional signed-publisher provenance (MCP signed definitions, M5.5).
75
+ // Absent for hash-only (unsigned) tools, so existing actions hash
76
+ // identically; present, they bind publisher identity + version lineage
77
+ // into the ActionPass and ledger.
78
+ publisher: z.string().min(1).optional(),
79
+ publisherKeyId: z.string().min(1).optional(),
80
+ semver: z.string().min(1).optional(),
81
+ priorDefinitionHash: z.string().min(1).optional(),
49
82
  })
50
83
  .optional(),
51
- budget: z
52
- .object({
53
- reservationId: z.string().min(1).optional(),
54
- limit: z.record(z.string(), JsonValueSchema).optional(),
55
- commitStatus: z.enum(["pending", "committed", "rolled_back"]).optional(),
56
- })
57
- .optional(),
84
+ provenance: ActionProvenanceSchema.optional(),
85
+ budget: ActionBudgetSchema.optional(),
58
86
  });
59
87
  export const PolicyDecisionSchema = z.object({
60
88
  decision: AxtaryDecisionSchema,
@@ -70,10 +98,37 @@ export const PolicyDecisionSchema = z.object({
70
98
  maxFilesChanged: z.number().int().nonnegative(),
71
99
  blockedPaths: z.array(z.string().min(1)),
72
100
  }),
101
+ rule: z
102
+ .object({
103
+ id: z.string().min(1),
104
+ matchedRuleIds: z.array(z.string().min(1)).default([]),
105
+ })
106
+ .optional(),
107
+ obligations: z
108
+ .object({
109
+ requiredApproverRoles: z.array(z.string().min(1)).default([]),
110
+ timeWindow: z
111
+ .object({
112
+ startHourUtc: z.number().int().min(0).max(23),
113
+ endHourUtc: z.number().int().min(0).max(23),
114
+ daysOfWeek: z.array(z.number().int().min(0).max(6)).default([]),
115
+ })
116
+ .optional(),
117
+ rateLimit: z
118
+ .object({
119
+ ruleId: z.string().min(1),
120
+ maxActions: z.number().int().positive(),
121
+ windowSeconds: z.number().int().positive(),
122
+ scope: z.enum(["actor", "tool", "resource", "tenant"]),
123
+ })
124
+ .optional(),
125
+ })
126
+ .optional(),
73
127
  });
74
128
  export const ApprovalSchema = z.object({
75
129
  mode: z.enum(["none", "human", "policy_override"]),
76
130
  approvedBy: z.string().min(1).optional(),
131
+ approverRoles: z.array(z.string().min(1)).optional(),
77
132
  approvalArtifact: z.string().min(1).optional(),
78
133
  approvalArtifactHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),
79
134
  actionHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),
@@ -85,6 +140,7 @@ export const ApprovalArtifactSchema = z.object({
85
140
  id: z.string().min(1),
86
141
  mode: z.enum(["human", "policy_override"]),
87
142
  approvedBy: z.string().min(1),
143
+ approverRoles: z.array(z.string().min(1)).optional(),
88
144
  approvedAt: z.string().datetime(),
89
145
  actionHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
90
146
  payloadHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
@@ -94,8 +150,7 @@ export const ApprovalArtifactSchema = z.object({
94
150
  reason: z.string().min(1).optional(),
95
151
  expiresAt: z.string().datetime().optional(),
96
152
  });
97
- export const ActionPassClaimsSchema = z.object({
98
- apv: z.literal(ACTIONPASS_VERSION),
153
+ const ActionPassBaseClaimsSchema = z.object({
99
154
  iss: z.string().min(1),
100
155
  sub: z.string().min(1),
101
156
  aud: z.union([z.string().min(1), z.array(z.string().min(1))]),
@@ -116,6 +171,8 @@ export const ActionPassClaimsSchema = z.object({
116
171
  reasons: z.array(z.string().min(1)),
117
172
  policy: PolicyDecisionSchema.shape.policy,
118
173
  payloadHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
174
+ provenanceHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),
175
+ budget: ActionBudgetSchema.optional(),
119
176
  approval: ApprovalSchema.optional(),
120
177
  audit: z.object({
121
178
  traceId: z.string().min(1),
@@ -123,6 +180,50 @@ export const ActionPassClaimsSchema = z.object({
123
180
  ledgerHash: z.string().min(1).nullable(),
124
181
  }),
125
182
  });
183
+ export const ActionPassV0ClaimsSchema = z.object({
184
+ apv: z.literal(ACTIONPASS_V0_VERSION),
185
+ ...ActionPassBaseClaimsSchema.shape,
186
+ });
187
+ export const ActionPassV1ClaimsSchema = z.object({
188
+ apv: z.literal(ACTIONPASS_V1_VERSION),
189
+ ...ActionPassBaseClaimsSchema.shape,
190
+ cnf: z.object({
191
+ jkt: z.string().min(1),
192
+ }),
193
+ delegation: z
194
+ .object({
195
+ depth: z.number().int().positive(),
196
+ rootPassId: z.string().min(1),
197
+ })
198
+ .optional(),
199
+ });
200
+ export const ActionPassV2ClaimsSchema = z.object({
201
+ apv: z.literal(ACTIONPASS_V2_VERSION),
202
+ ...ActionPassBaseClaimsSchema.shape,
203
+ cnf: z.object({
204
+ jkt: z.string().min(1),
205
+ }),
206
+ delegation: z
207
+ .object({
208
+ depth: z.number().int().positive(),
209
+ rootPassId: z.string().min(1),
210
+ })
211
+ .optional(),
212
+ status: ActionPassStatusClaimSchema,
213
+ });
214
+ export const ActionPassClaimsSchema = z.union([
215
+ ActionPassV0ClaimsSchema,
216
+ ActionPassV1ClaimsSchema,
217
+ ActionPassV2ClaimsSchema,
218
+ ]);
219
+ export const DpopProofClaimsSchema = z.object({
220
+ htm: z.string().min(1),
221
+ htu: z.string().url(),
222
+ iat: z.number().int().positive(),
223
+ jti: z.string().min(1),
224
+ ath: z.string().min(1),
225
+ nonce: z.string().min(1).optional(),
226
+ });
126
227
  export const ActionPassRevocationSchema = z.object({
127
228
  schemaVersion: z.literal(ACTIONPASS_REVOCATION_VERSION),
128
229
  passId: z.string().min(1),
@@ -167,10 +268,12 @@ export const LedgerProviderSchema = z.enum([
167
268
  "slack",
168
269
  "linear",
169
270
  "docs",
271
+ "drive",
170
272
  "mcp",
171
273
  "aws",
172
274
  "gcp",
173
275
  "jira",
276
+ "postgres",
174
277
  ]);
175
278
  export const LedgerProviderEvidenceDiffSchema = z.object({
176
279
  provider: LedgerProviderSchema,
@@ -200,6 +303,117 @@ export const LedgerProviderEvidenceSchema = z.object({
200
303
  fieldChanges: z.array(LedgerProviderEvidenceFieldChangeSchema).default([]),
201
304
  diff: LedgerProviderEvidenceDiffSchema.optional(),
202
305
  });
306
+ export const GitHubNativeConnectorConfigSchema = z.object({
307
+ mode: z.enum(["fake", "rest", "app"]).default("fake"),
308
+ tokenEnv: z.string().min(1).default("GITHUB_TOKEN"),
309
+ apiBaseUrl: z.string().url().default("https://api.github.com"),
310
+ userAgent: z.string().min(1).default("axtary-local-proxy"),
311
+ appId: z.string().min(1).optional(),
312
+ appIdEnv: z.string().min(1).default("AXTARY_GITHUB_APP_ID"),
313
+ installationId: z.string().min(1).optional(),
314
+ installationIdEnv: z
315
+ .string()
316
+ .min(1)
317
+ .default("AXTARY_GITHUB_INSTALLATION_ID"),
318
+ privateKeyEnv: z
319
+ .string()
320
+ .min(1)
321
+ .default("AXTARY_GITHUB_APP_PRIVATE_KEY"),
322
+ });
323
+ export const DEFAULT_GITHUB_NATIVE_CONNECTOR_CONFIG = {
324
+ mode: "fake",
325
+ tokenEnv: "GITHUB_TOKEN",
326
+ apiBaseUrl: "https://api.github.com",
327
+ userAgent: "axtary-local-proxy",
328
+ appIdEnv: "AXTARY_GITHUB_APP_ID",
329
+ installationIdEnv: "AXTARY_GITHUB_INSTALLATION_ID",
330
+ privateKeyEnv: "AXTARY_GITHUB_APP_PRIVATE_KEY",
331
+ };
332
+ export const LinearNativeConnectorConfigSchema = z.object({
333
+ mode: z.enum(["fake", "graphql"]).default("fake"),
334
+ tokenEnv: z.string().min(1).default("LINEAR_API_KEY"),
335
+ apiUrl: z.string().url().default("https://api.linear.app/graphql"),
336
+ });
337
+ export const DEFAULT_LINEAR_NATIVE_CONNECTOR_CONFIG = {
338
+ mode: "fake",
339
+ tokenEnv: "LINEAR_API_KEY",
340
+ apiUrl: "https://api.linear.app/graphql",
341
+ };
342
+ export const JiraNativeConnectorConfigSchema = z.object({
343
+ mode: z.enum(["fake", "rest"]).default("fake"),
344
+ auth: z.enum(["api_token", "oauth"]).default("api_token"),
345
+ tokenEnv: z.string().min(1).default("JIRA_API_TOKEN"),
346
+ emailEnv: z.string().min(1).default("JIRA_EMAIL"),
347
+ siteUrl: z.string().url().default("https://example.atlassian.net"),
348
+ cloudId: z.string().min(1).optional(),
349
+ cloudIdEnv: z.string().min(1).default("AXTARY_JIRA_CLOUD_ID"),
350
+ });
351
+ export const DEFAULT_JIRA_NATIVE_CONNECTOR_CONFIG = {
352
+ mode: "fake",
353
+ auth: "api_token",
354
+ tokenEnv: "JIRA_API_TOKEN",
355
+ emailEnv: "JIRA_EMAIL",
356
+ siteUrl: "https://example.atlassian.net",
357
+ cloudIdEnv: "AXTARY_JIRA_CLOUD_ID",
358
+ };
359
+ export const PostgresNativeConnectorConfigSchema = z.object({
360
+ mode: z.enum(["off", "postgres"]).default("off"),
361
+ dsnEnv: z.string().min(1).default("AXTARY_POSTGRES_DSN"),
362
+ allowedTables: z.array(z.string().min(1)).default([]),
363
+ allowedFunctions: z.array(z.string().min(1)).default([]),
364
+ requireRls: z.boolean().default(true),
365
+ maxRows: z.number().int().positive().max(10_000).default(100),
366
+ statementTimeoutMs: z.number().int().positive().default(5_000),
367
+ lockTimeoutMs: z.number().int().positive().default(1_000),
368
+ idleTransactionTimeoutMs: z.number().int().positive().default(5_000),
369
+ });
370
+ export const DEFAULT_POSTGRES_NATIVE_CONNECTOR_CONFIG = {
371
+ mode: "off",
372
+ dsnEnv: "AXTARY_POSTGRES_DSN",
373
+ allowedTables: [],
374
+ allowedFunctions: [],
375
+ requireRls: true,
376
+ maxRows: 100,
377
+ statementTimeoutMs: 5_000,
378
+ lockTimeoutMs: 1_000,
379
+ idleTransactionTimeoutMs: 5_000,
380
+ };
381
+ export const DriveNativeConnectorConfigSchema = z.object({
382
+ mode: z.enum(["off", "rest"]).default("off"),
383
+ tokenEnv: z.string().min(1).default("AXTARY_DRIVE_ACCESS_TOKEN"),
384
+ selectedFileId: z.string().min(1).optional(),
385
+ apiBaseUrl: z
386
+ .string()
387
+ .url()
388
+ .default("https://www.googleapis.com/drive/v3"),
389
+ allowedMimeTypes: z
390
+ .array(z.string().min(1))
391
+ .default([
392
+ "application/vnd.google-apps.document",
393
+ "text/plain",
394
+ "text/markdown",
395
+ "text/html",
396
+ "text/csv",
397
+ "application/json",
398
+ "application/xml",
399
+ ]),
400
+ maxReadBytes: z.number().int().positive().max(10_000_000).default(100_000),
401
+ });
402
+ export const DEFAULT_DRIVE_NATIVE_CONNECTOR_CONFIG = {
403
+ mode: "off",
404
+ tokenEnv: "AXTARY_DRIVE_ACCESS_TOKEN",
405
+ apiBaseUrl: "https://www.googleapis.com/drive/v3",
406
+ allowedMimeTypes: [
407
+ "application/vnd.google-apps.document",
408
+ "text/plain",
409
+ "text/markdown",
410
+ "text/html",
411
+ "text/csv",
412
+ "application/json",
413
+ "application/xml",
414
+ ],
415
+ maxReadBytes: 100_000,
416
+ };
203
417
  export const LedgerExecutionOutcomeSchema = z
204
418
  .object({
205
419
  schemaVersion: z.literal(LEDGER_EXECUTION_OUTCOME_VERSION),
@@ -214,12 +428,39 @@ export const LedgerExecutionOutcomeSchema = z
214
428
  sideEffectProof: z.record(z.string(), JsonValueSchema).default({}),
215
429
  })
216
430
  .strict();
431
+ export const DelegationLedgerEdgeSchema = z.object({
432
+ parentPassId: z.string().min(1),
433
+ childPassId: z.string().min(1),
434
+ depth: z.number().int().positive(),
435
+ rootPassId: z.string().min(1),
436
+ });
437
+ export const BudgetLedgerEventSchema = z.object({
438
+ reservationId: z.string().min(1).nullable(),
439
+ scope: z.string().min(1),
440
+ status: z.enum(["denied", "pending", "committed", "rolled_back"]),
441
+ expiresAt: z.string().datetime().nullable(),
442
+ cost: BudgetAmountSchema,
443
+ limit: BudgetAmountSchema,
444
+ committedBefore: BudgetAmountSchema,
445
+ committedAfter: BudgetAmountSchema,
446
+ reservedAfter: BudgetAmountSchema,
447
+ });
448
+ export const LedgerAuditContextSchema = z.object({
449
+ tenant: z.string().min(1).nullable(),
450
+ agentId: z.string().min(1),
451
+ humanOwner: z.string().min(1),
452
+ taskId: z.string().min(1),
453
+ tool: z.string().min(1),
454
+ resource: z.string().min(1),
455
+ });
217
456
  export const LedgerRecordSchema = z.object({
218
457
  schemaVersion: z.literal(LEDGER_SCHEMA_VERSION),
219
458
  id: z.string().min(1),
220
459
  occurredAt: z.string().datetime(),
460
+ auditContext: LedgerAuditContextSchema.optional(),
221
461
  actionHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
222
462
  payloadHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
463
+ provenanceHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),
223
464
  decision: AxtaryDecisionSchema,
224
465
  reasons: z.array(z.string().min(1)),
225
466
  policy: PolicyDecisionSchema.shape.policy,
@@ -227,6 +468,8 @@ export const LedgerRecordSchema = z.object({
227
468
  executionOutcome: LedgerExecutionOutcomeSchema.optional(),
228
469
  traceId: z.string().min(1).optional(),
229
470
  actionPassId: z.string().min(1).nullable(),
471
+ delegation: DelegationLedgerEdgeSchema.optional(),
472
+ budget: BudgetLedgerEventSchema.optional(),
230
473
  correlationId: z.string().min(1).optional(),
231
474
  previousLedgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(),
232
475
  ledgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
@@ -358,6 +601,7 @@ export function createApprovalArtifact(input) {
358
601
  id: input.id ?? `approval_${randomUUID()}`,
359
602
  mode: input.mode,
360
603
  approvedBy: input.approvedBy,
604
+ approverRoles: input.approverRoles,
361
605
  approvedAt: (input.approvedAt ?? new Date()).toISOString(),
362
606
  actionHash: hashAction(action),
363
607
  payloadHash: hashPayload(action.capability.payload),
@@ -374,6 +618,7 @@ export function createApprovalArtifact(input) {
374
618
  approval: {
375
619
  mode: artifact.mode,
376
620
  approvedBy: artifact.approvedBy,
621
+ approverRoles: artifact.approverRoles,
377
622
  approvedAt: artifact.approvedAt,
378
623
  approvalArtifact: artifact.id,
379
624
  approvalArtifactHash: artifactHash,
@@ -383,6 +628,122 @@ export function createApprovalArtifact(input) {
383
628
  };
384
629
  }
385
630
  export async function issueActionPass(input) {
631
+ return issueActionPassProfile(input, ACTIONPASS_V0_VERSION);
632
+ }
633
+ export async function issueActionPassV1(input) {
634
+ const publicJwk = publicOnlyJwk(input.holderPublicJwk);
635
+ const jkt = await calculateJwkThumbprint(publicJwk, "sha256");
636
+ return (await issueActionPassProfile(input, ACTIONPASS_V1_VERSION, jkt));
637
+ }
638
+ export async function issueActionPassV2(input) {
639
+ const publicJwk = publicOnlyJwk(input.holderPublicJwk);
640
+ const jkt = await calculateJwkThumbprint(publicJwk, "sha256");
641
+ return (await issueActionPassProfile(input, ACTIONPASS_V2_VERSION, jkt));
642
+ }
643
+ export async function delegateActionPassV1(input) {
644
+ const delegationNow = input.now ?? input.parentChain.at(-1)?.currentDate ?? new Date();
645
+ const verifiedChain = await verifyDelegationChain({
646
+ chain: input.parentChain,
647
+ currentDate: delegationNow,
648
+ revocation: input.revocation,
649
+ });
650
+ if (!verifiedChain.valid) {
651
+ throw new Error(verifiedChain.reason);
652
+ }
653
+ const parentClaims = verifiedChain.claims.at(-1);
654
+ const parentAction = verifiedChain.actions.at(-1);
655
+ if (!parentClaims || !parentAction) {
656
+ throw new Error("delegation_parent_required");
657
+ }
658
+ const childAction = parseNormalizedAction(input.childAction);
659
+ if (input.tenant !== parentClaims.tenant) {
660
+ throw new Error("delegation_scope_broadened");
661
+ }
662
+ const attenuationError = validateDelegationAttenuation(parentAction, childAction);
663
+ if (attenuationError) {
664
+ throw new Error(attenuationError);
665
+ }
666
+ const depth = parentClaims.delegation
667
+ ? parentClaims.delegation.depth + 1
668
+ : 1;
669
+ const rootPassId = parentClaims.delegation?.rootPassId ?? parentClaims.jti;
670
+ const now = delegationNow;
671
+ const requestedTtl = input.expiresInSeconds ?? input.childDecision.constraints.expiresInSeconds;
672
+ const parentRemainingTtl = parentClaims.exp - toNumericDate(now);
673
+ if (parentRemainingTtl < 1) {
674
+ throw new Error("delegation_parent_expired");
675
+ }
676
+ const issued = await issueActionPassV1({
677
+ ...input,
678
+ action: childAction,
679
+ decision: input.childDecision,
680
+ holderPublicJwk: input.childHolderPublicJwk,
681
+ parentPassId: parentClaims.jti,
682
+ delegation: {
683
+ depth,
684
+ rootPassId,
685
+ },
686
+ expiresInSeconds: Math.min(requestedTtl, parentRemainingTtl),
687
+ });
688
+ const delegation = DelegationLedgerEdgeSchema.parse({
689
+ parentPassId: parentClaims.jti,
690
+ childPassId: issued.claims.jti,
691
+ depth,
692
+ rootPassId,
693
+ });
694
+ return {
695
+ ...issued,
696
+ delegation,
697
+ };
698
+ }
699
+ export async function delegateActionPassV2(input) {
700
+ const delegationNow = input.now ?? input.parentChain.at(-1)?.currentDate ?? new Date();
701
+ const verifiedChain = await verifyDelegationChain({
702
+ chain: input.parentChain,
703
+ currentDate: delegationNow,
704
+ revocation: input.revocation,
705
+ });
706
+ if (!verifiedChain.valid)
707
+ throw new Error(verifiedChain.reason);
708
+ if (verifiedChain.claims.some((claims) => claims.apv !== ACTIONPASS_V2_VERSION)) {
709
+ throw new Error("delegation_requires_actionpass_v2");
710
+ }
711
+ const parentClaims = verifiedChain.claims.at(-1);
712
+ const parentAction = verifiedChain.actions.at(-1);
713
+ const childAction = parseNormalizedAction(input.childAction);
714
+ if (input.tenant !== parentClaims.tenant) {
715
+ throw new Error("delegation_scope_broadened");
716
+ }
717
+ const attenuationError = validateDelegationAttenuation(parentAction, childAction);
718
+ if (attenuationError)
719
+ throw new Error(attenuationError);
720
+ const depth = parentClaims.delegation
721
+ ? parentClaims.delegation.depth + 1
722
+ : 1;
723
+ const rootPassId = parentClaims.delegation?.rootPassId ?? parentClaims.jti;
724
+ const parentRemainingTtl = parentClaims.exp - toNumericDate(delegationNow);
725
+ if (parentRemainingTtl < 1)
726
+ throw new Error("delegation_parent_expired");
727
+ const requestedTtl = input.expiresInSeconds ?? input.childDecision.constraints.expiresInSeconds;
728
+ const issued = await issueActionPassV2({
729
+ ...input,
730
+ action: childAction,
731
+ decision: input.childDecision,
732
+ holderPublicJwk: input.childHolderPublicJwk,
733
+ parentPassId: parentClaims.jti,
734
+ delegation: { depth, rootPassId },
735
+ status: input.childStatus,
736
+ expiresInSeconds: Math.min(requestedTtl, parentRemainingTtl),
737
+ });
738
+ const delegation = DelegationLedgerEdgeSchema.parse({
739
+ parentPassId: parentClaims.jti,
740
+ childPassId: issued.claims.jti,
741
+ depth,
742
+ rootPassId,
743
+ });
744
+ return { ...issued, delegation };
745
+ }
746
+ async function issueActionPassProfile(input, version, holderJkt) {
386
747
  const action = parseNormalizedAction(input.action);
387
748
  const decision = PolicyDecisionSchema.parse(input.decision);
388
749
  if (decision.decision !== "allow") {
@@ -392,7 +753,7 @@ export async function issueActionPass(input) {
392
753
  const issuedAt = toNumericDate(now);
393
754
  const expiresInSeconds = input.expiresInSeconds ?? decision.constraints.expiresInSeconds;
394
755
  const expiresAt = issuedAt + expiresInSeconds;
395
- const passId = `ap_${randomUUID()}`;
756
+ const passId = input.passId ?? `ap_${randomUUID()}`;
396
757
  const actionHash = hashAction(action);
397
758
  const payloadHash = hashPayload(action.capability.payload);
398
759
  const approval = bindApproval({
@@ -404,7 +765,7 @@ export async function issueActionPass(input) {
404
765
  now,
405
766
  });
406
767
  const claims = ActionPassClaimsSchema.parse({
407
- apv: ACTIONPASS_VERSION,
768
+ apv: version,
408
769
  iss: input.issuer,
409
770
  sub: action.actor.agentId,
410
771
  aud: action.capability.resource,
@@ -425,6 +786,19 @@ export async function issueActionPass(input) {
425
786
  reasons: decision.reasons,
426
787
  policy: decision.policy,
427
788
  payloadHash,
789
+ provenanceHash: action.provenance
790
+ ? hashProvenance(action.provenance)
791
+ : undefined,
792
+ budget: action.budget,
793
+ cnf: holderJkt ? { jkt: holderJkt } : undefined,
794
+ delegation: (version === ACTIONPASS_V1_VERSION || version === ACTIONPASS_V2_VERSION) &&
795
+ "delegation" in input &&
796
+ input.delegation
797
+ ? input.delegation
798
+ : undefined,
799
+ status: version === ACTIONPASS_V2_VERSION && "status" in input
800
+ ? input.status
801
+ : undefined,
428
802
  approval,
429
803
  audit: {
430
804
  traceId: input.traceId ?? `trace_${randomUUID()}`,
@@ -441,7 +815,194 @@ export async function issueActionPass(input) {
441
815
  .sign(input.signingKey);
442
816
  return { token, claims };
443
817
  }
818
+ export async function createDpopProof(input) {
819
+ const publicJwk = publicOnlyJwk(input.publicJwk);
820
+ const jkt = await calculateJwkThumbprint(publicJwk, "sha256");
821
+ const claims = DpopProofClaimsSchema.parse({
822
+ htm: normalizeDpopMethod(input.method),
823
+ htu: normalizeDpopUri(input.uri),
824
+ iat: toNumericDate(input.now ?? new Date()),
825
+ jti: input.proofId ?? `dpop_${randomUUID()}`,
826
+ ath: computeDpopAccessTokenHash(input.actionPass),
827
+ nonce: input.nonce,
828
+ });
829
+ const proof = await new SignJWT(claims)
830
+ .setProtectedHeader({
831
+ alg: input.algorithm ?? DEFAULT_SIGNING_ALGORITHM,
832
+ typ: DPOP_PROOF_TYP,
833
+ jwk: publicJwk,
834
+ })
835
+ .sign(input.signingKey);
836
+ return { proof, claims, jkt };
837
+ }
838
+ export class InMemoryDpopReplayStore {
839
+ entries = new Map();
840
+ consume(proofId, expiresAt, currentDate = new Date()) {
841
+ const now = currentDate.getTime();
842
+ for (const [id, expiry] of this.entries) {
843
+ if (expiry <= now) {
844
+ this.entries.delete(id);
845
+ }
846
+ }
847
+ if (this.entries.has(proofId)) {
848
+ return false;
849
+ }
850
+ this.entries.set(proofId, expiresAt.getTime());
851
+ return true;
852
+ }
853
+ }
854
+ /**
855
+ * Durable, inter-process replay store for DPoP proof `jti`s. A proof captured
856
+ * before a restart stays rejected after restart, for as long as it remains
857
+ * inside its own time window. Entries live only until their proof expires, so
858
+ * the on-disk set stays bounded to the in-flight window rather than growing
859
+ * unboundedly.
860
+ *
861
+ * This is a single-file local store, not a cross-host distributed one: it
862
+ * defends one machine's verifiers (shared across handlers/processes on that
863
+ * host). Authenticated cross-host distribution is out of scope here.
864
+ *
865
+ * Durability mirrors the local ledger: a `proper-lockfile` lease serializes the
866
+ * read-modify-write so `consume` is atomic across processes, appended lines are
867
+ * `fsync`ed before the lease releases, and the file is compacted in place once
868
+ * expired lines outnumber live ones so it cannot grow without bound.
869
+ */
870
+ export class FileDpopReplayStore {
871
+ filePath;
872
+ options;
873
+ constructor(filePath, options = {}) {
874
+ this.filePath = filePath;
875
+ this.options = options;
876
+ }
877
+ async consume(proofId, expiresAt, currentDate = new Date()) {
878
+ await mkdir(dirname(this.filePath), { recursive: true });
879
+ const release = await this.acquireLock();
880
+ try {
881
+ const now = currentDate.getTime();
882
+ const { live, deadCount } = await this.readLiveEntries(now);
883
+ if (live.has(proofId)) {
884
+ return false;
885
+ }
886
+ live.set(proofId, expiresAt.getTime());
887
+ // Compact when expired tombstones dominate, otherwise append O(1). This
888
+ // keeps the file proportional to the in-flight proof window.
889
+ if (deadCount > live.size) {
890
+ await this.compact(live);
891
+ }
892
+ else {
893
+ await this.append({ i: proofId, e: expiresAt.getTime() });
894
+ }
895
+ return true;
896
+ }
897
+ finally {
898
+ await release();
899
+ }
900
+ }
901
+ async acquireLock() {
902
+ const lockOptions = this.options.lock ?? {};
903
+ let compromisedReason = null;
904
+ const release = await lock(this.filePath, {
905
+ realpath: false,
906
+ lockfilePath: `${this.filePath}.lock`,
907
+ stale: lockOptions.staleMs ?? 10_000,
908
+ update: lockOptions.updateMs ?? 5_000,
909
+ retries: {
910
+ retries: lockOptions.retries ?? 100,
911
+ factor: 1.2,
912
+ minTimeout: lockOptions.minTimeoutMs ?? 10,
913
+ maxTimeout: lockOptions.maxTimeoutMs ?? 100,
914
+ },
915
+ onCompromised: (error) => {
916
+ compromisedReason = error.message;
917
+ },
918
+ });
919
+ if (compromisedReason !== null) {
920
+ await release();
921
+ throw new Error(`dpop_replay_store_lock_compromised:${compromisedReason}`);
922
+ }
923
+ return release;
924
+ }
925
+ async readLiveEntries(now) {
926
+ const live = new Map();
927
+ let deadCount = 0;
928
+ let text;
929
+ try {
930
+ text = await readFile(this.filePath, "utf8");
931
+ }
932
+ catch (error) {
933
+ if (isNodeError(error) && error.code === "ENOENT") {
934
+ return { live, deadCount };
935
+ }
936
+ throw error;
937
+ }
938
+ for (const line of text.split("\n")) {
939
+ const trimmed = line.trim();
940
+ if (!trimmed)
941
+ continue;
942
+ let entry;
943
+ try {
944
+ entry = JSON.parse(trimmed);
945
+ }
946
+ catch {
947
+ // A torn final line from a crash mid-append is ignored; the lease
948
+ // holder rewrites a clean file on the next compaction.
949
+ deadCount += 1;
950
+ continue;
951
+ }
952
+ if (typeof entry?.i !== "string" ||
953
+ typeof entry?.e !== "number" ||
954
+ entry.e <= now) {
955
+ deadCount += 1;
956
+ continue;
957
+ }
958
+ // Last write wins for a repeated id; the older line becomes a tombstone.
959
+ if (live.has(entry.i)) {
960
+ deadCount += 1;
961
+ }
962
+ live.set(entry.i, entry.e);
963
+ }
964
+ return { live, deadCount };
965
+ }
966
+ async append(entry) {
967
+ const handle = await open(this.filePath, "a", 0o600);
968
+ try {
969
+ await handle.chmod(0o600);
970
+ await handle.writeFile(`${JSON.stringify(entry)}\n`, "utf8");
971
+ await handle.sync();
972
+ }
973
+ finally {
974
+ await handle.close();
975
+ }
976
+ }
977
+ async compact(live) {
978
+ const lines = [...live.entries()]
979
+ .map(([i, e]) => `${JSON.stringify({ i, e })}\n`)
980
+ .join("");
981
+ const tmpPath = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
982
+ let renamed = false;
983
+ try {
984
+ const handle = await open(tmpPath, "wx", 0o600);
985
+ try {
986
+ await handle.writeFile(lines, "utf8");
987
+ await handle.sync();
988
+ }
989
+ finally {
990
+ await handle.close();
991
+ }
992
+ await rename(tmpPath, this.filePath);
993
+ renamed = true;
994
+ }
995
+ finally {
996
+ if (!renamed) {
997
+ await rm(tmpPath, { force: true });
998
+ }
999
+ }
1000
+ }
1001
+ }
444
1002
  export async function verifyActionPass(input) {
1003
+ return verifyActionPassInternal(input, true);
1004
+ }
1005
+ async function verifyActionPassInternal(input, requireDpop) {
445
1006
  try {
446
1007
  const action = parseNormalizedAction(input.action);
447
1008
  const verificationKey = await resolveVerificationKey(input);
@@ -461,6 +1022,14 @@ export async function verifyActionPass(input) {
461
1022
  reason: bindingError,
462
1023
  };
463
1024
  }
1025
+ if ((claims.apv === ACTIONPASS_V1_VERSION ||
1026
+ claims.apv === ACTIONPASS_V2_VERSION) &&
1027
+ requireDpop) {
1028
+ const dpopError = await validateDpopProof(input, claims);
1029
+ if (dpopError) {
1030
+ return { valid: false, reason: dpopError };
1031
+ }
1032
+ }
464
1033
  const revocationError = await validateRevocation(input, claims);
465
1034
  if (revocationError) {
466
1035
  return {
@@ -468,6 +1037,12 @@ export async function verifyActionPass(input) {
468
1037
  reason: revocationError,
469
1038
  };
470
1039
  }
1040
+ if (claims.apv === ACTIONPASS_V2_VERSION) {
1041
+ const statusError = await validateDistributedStatus(input, claims);
1042
+ if (statusError) {
1043
+ return { valid: false, reason: statusError };
1044
+ }
1045
+ }
471
1046
  return {
472
1047
  valid: true,
473
1048
  claims,
@@ -481,6 +1056,61 @@ export async function verifyActionPass(input) {
481
1056
  };
482
1057
  }
483
1058
  }
1059
+ export async function verifyDelegationChain(input) {
1060
+ if (input.chain.length === 0) {
1061
+ return { valid: false, reason: "delegation_parent_required" };
1062
+ }
1063
+ const claims = [];
1064
+ const actions = [];
1065
+ for (let index = 0; index < input.chain.length; index += 1) {
1066
+ const entry = input.chain[index];
1067
+ const revocation = input.revocation;
1068
+ const verified = await verifyActionPassInternal({
1069
+ ...entry,
1070
+ currentDate: input.currentDate ??
1071
+ input.chain.at(-1)?.currentDate ??
1072
+ entry.currentDate,
1073
+ revokedPassIds: revocation?.revokedPassIds ?? entry.revokedPassIds,
1074
+ revocations: revocation?.revocations ?? entry.revocations,
1075
+ isRevoked: revocation?.isRevoked ?? entry.isRevoked,
1076
+ getStatus: revocation?.getStatus ?? entry.getStatus,
1077
+ }, index === input.chain.length - 1);
1078
+ if (!verified.valid) {
1079
+ return verified;
1080
+ }
1081
+ if (verified.claims.apv !== ACTIONPASS_V1_VERSION &&
1082
+ verified.claims.apv !== ACTIONPASS_V2_VERSION) {
1083
+ return { valid: false, reason: "delegation_requires_actionpass_v1" };
1084
+ }
1085
+ const action = parseNormalizedAction(entry.action);
1086
+ const currentClaims = verified.claims;
1087
+ if (index === 0) {
1088
+ if (currentClaims.audit.parentPassId !== null ||
1089
+ currentClaims.delegation !== undefined) {
1090
+ return { valid: false, reason: "delegation_chain_root_invalid" };
1091
+ }
1092
+ }
1093
+ else {
1094
+ const parentClaims = claims[index - 1];
1095
+ const parentAction = actions[index - 1];
1096
+ const expectedDepth = index;
1097
+ const expectedRootPassId = claims[0].jti;
1098
+ if (currentClaims.apv !== parentClaims.apv ||
1099
+ currentClaims.audit.parentPassId !== parentClaims.jti ||
1100
+ currentClaims.delegation?.depth !== expectedDepth ||
1101
+ currentClaims.delegation.rootPassId !== expectedRootPassId) {
1102
+ return { valid: false, reason: "delegation_chain_link_invalid" };
1103
+ }
1104
+ const attenuationError = validateDelegationAttenuation(parentAction, action);
1105
+ if (attenuationError) {
1106
+ return { valid: false, reason: attenuationError };
1107
+ }
1108
+ }
1109
+ claims.push(currentClaims);
1110
+ actions.push(action);
1111
+ }
1112
+ return { valid: true, claims, actions };
1113
+ }
484
1114
  export function revokeActionPass(input) {
485
1115
  return ActionPassRevocationSchema.parse({
486
1116
  schemaVersion: ACTIONPASS_REVOCATION_VERSION,
@@ -571,8 +1201,19 @@ export function recordDecision(input) {
571
1201
  schemaVersion: LEDGER_SCHEMA_VERSION,
572
1202
  id: `ledger_${randomUUID()}`,
573
1203
  occurredAt: (input.occurredAt ?? new Date()).toISOString(),
1204
+ auditContext: {
1205
+ tenant: action.actor.tenant ?? null,
1206
+ agentId: action.actor.agentId,
1207
+ humanOwner: action.actor.humanOwner,
1208
+ taskId: action.intent.taskId,
1209
+ tool: action.capability.tool,
1210
+ resource: action.capability.resource,
1211
+ },
574
1212
  actionHash,
575
1213
  payloadHash,
1214
+ provenanceHash: action.provenance
1215
+ ? hashProvenance(action.provenance)
1216
+ : undefined,
576
1217
  decision: decision.decision,
577
1218
  reasons: decision.reasons,
578
1219
  policy: decision.policy,
@@ -580,6 +1221,12 @@ export function recordDecision(input) {
580
1221
  executionOutcome: input.executionOutcome,
581
1222
  traceId,
582
1223
  actionPassId: input.actionPassId ?? null,
1224
+ delegation: input.delegation
1225
+ ? DelegationLedgerEdgeSchema.parse(input.delegation)
1226
+ : undefined,
1227
+ budget: input.budget
1228
+ ? BudgetLedgerEventSchema.parse(input.budget)
1229
+ : undefined,
583
1230
  correlationId: input.correlationId ?? undefined,
584
1231
  previousLedgerHash: input.previousLedgerHash ?? null,
585
1232
  };
@@ -588,55 +1235,558 @@ export function recordDecision(input) {
588
1235
  ledgerHash: hashJson(recordWithoutHash),
589
1236
  });
590
1237
  }
1238
+ /**
1239
+ * Canonical reason token marking a ledger record as the tamper-evident
1240
+ * revocation event for its pass. Matches the forensics `cascade_containment`
1241
+ * convention (spec §9.5) and the trust-store revocation authority (spec §7).
1242
+ */
1243
+ export const REVOCATION_LEDGER_REASON = "actionpass_revoked";
1244
+ /**
1245
+ * Build a tamper-evident revocation event for a previously recorded pass. The
1246
+ * event is a `deny` record naming the pass with `actionpass_revoked`, reusing
1247
+ * the revoked record's action/payload hashes, policy, and audit context so the
1248
+ * revocation is bound to the exact action it cancels. It carries no delegation
1249
+ * edge or budget event (those describe the original action, not the
1250
+ * revocation). The durable enforcement authority remains the trust store (§7);
1251
+ * this record is the auditable logbook entry, not the enforcement source.
1252
+ */
1253
+ export function recordActionPassRevocationEvent(input) {
1254
+ const source = LedgerRecordSchema.parse(input.revokedRecord);
1255
+ if (!source.actionPassId) {
1256
+ throw new Error("revocation_requires_action_pass_id");
1257
+ }
1258
+ const reasons = [REVOCATION_LEDGER_REASON];
1259
+ if (input.revokedBy)
1260
+ reasons.push(`revoked_by:${input.revokedBy}`);
1261
+ if (input.reason)
1262
+ reasons.push(`revocation_reason:${input.reason}`);
1263
+ const recordWithoutHash = {
1264
+ schemaVersion: LEDGER_SCHEMA_VERSION,
1265
+ id: `ledger_${randomUUID()}`,
1266
+ occurredAt: (input.occurredAt ?? new Date()).toISOString(),
1267
+ auditContext: source.auditContext,
1268
+ actionHash: source.actionHash,
1269
+ payloadHash: source.payloadHash,
1270
+ provenanceHash: source.provenanceHash,
1271
+ decision: "deny",
1272
+ reasons,
1273
+ policy: source.policy,
1274
+ traceId: `trace_${randomUUID()}`,
1275
+ actionPassId: source.actionPassId,
1276
+ correlationId: source.actionPassId,
1277
+ previousLedgerHash: input.previousLedgerHash ?? null,
1278
+ };
1279
+ return LedgerRecordSchema.parse({
1280
+ ...recordWithoutHash,
1281
+ ledgerHash: hashJson(recordWithoutHash),
1282
+ });
1283
+ }
591
1284
  export function hashPayload(payload) {
592
1285
  return hashJson(JsonValueSchema.parse(payload));
593
1286
  }
1287
+ export function hashProvenance(provenance) {
1288
+ return hashJson(ActionProvenanceSchema.parse(provenance));
1289
+ }
1290
+ function validateDelegationAttenuation(parent, child) {
1291
+ const parentRemaining = parent.intent.maxDelegationDepth ?? 0;
1292
+ const childRemaining = child.intent.maxDelegationDepth ?? 0;
1293
+ if (parentRemaining < 1 || childRemaining > parentRemaining - 1) {
1294
+ return "delegation_depth_exceeded";
1295
+ }
1296
+ if (child.actor.humanOwner !== parent.actor.humanOwner ||
1297
+ child.actor.tenant !== parent.actor.tenant ||
1298
+ child.intent.taskId !== parent.intent.taskId ||
1299
+ child.capability.tool !== parent.capability.tool ||
1300
+ child.capability.resource !== parent.capability.resource ||
1301
+ stableStringify(child.toolDefinition) !==
1302
+ stableStringify(parent.toolDefinition) ||
1303
+ !isJsonScopeSubset(child.capability.payload, parent.capability.payload, []) ||
1304
+ !areConstraintsAttenuated(parent.capability.constraints, child.capability.constraints)) {
1305
+ return "delegation_scope_broadened";
1306
+ }
1307
+ return null;
1308
+ }
1309
+ function isJsonScopeSubset(child, parent, path) {
1310
+ if (stableStringify(child) === stableStringify(parent)) {
1311
+ return true;
1312
+ }
1313
+ if (typeof child === "string" && typeof parent === "string") {
1314
+ return isPathLikeKey(path.at(-1)) && isPathInsideScope(child, parent);
1315
+ }
1316
+ if (Array.isArray(child) && Array.isArray(parent)) {
1317
+ return child.every((childEntry) => parent.some((parentEntry) => isJsonScopeSubset(childEntry, parentEntry, path)));
1318
+ }
1319
+ if (isJsonObject(child) && isJsonObject(parent)) {
1320
+ return Object.entries(child).every(([key, value]) => key in parent &&
1321
+ isJsonScopeSubset(value, parent[key], [...path, key]));
1322
+ }
1323
+ return false;
1324
+ }
1325
+ function areConstraintsAttenuated(parent, child) {
1326
+ if (!parent) {
1327
+ return true;
1328
+ }
1329
+ if (!child) {
1330
+ return false;
1331
+ }
1332
+ return Object.entries(parent).every(([key, value]) => key in child &&
1333
+ stableStringify(child[key]) === stableStringify(value));
1334
+ }
1335
+ function isPathLikeKey(key) {
1336
+ return Boolean(key && /(path|file|directory|prefix)/i.test(key));
1337
+ }
1338
+ function isPathInsideScope(child, parent) {
1339
+ if (parent.endsWith("/**")) {
1340
+ const prefix = parent.slice(0, -2);
1341
+ return child.startsWith(prefix);
1342
+ }
1343
+ if (parent.endsWith("/")) {
1344
+ return child.startsWith(parent);
1345
+ }
1346
+ return false;
1347
+ }
594
1348
  export function hashAction(action) {
595
1349
  return hashJson(parseNormalizedAction(action));
596
1350
  }
1351
+ export const GITHUB_NATIVE_CONNECTOR_GOVERNANCE = {
1352
+ provider: "github",
1353
+ connector: "github-rest",
1354
+ mode: "rest",
1355
+ configSchema: GitHubNativeConnectorConfigSchema,
1356
+ defaultConfig: DEFAULT_GITHUB_NATIVE_CONNECTOR_CONFIG,
1357
+ requiredScopes: [
1358
+ "contents:write",
1359
+ "metadata:read",
1360
+ "pull_requests:write",
1361
+ "checks:write",
1362
+ "issues:write",
1363
+ ],
1364
+ smokeCheck: "GET /user + X-Accepted-GitHub-Permissions",
1365
+ tools: [
1366
+ {
1367
+ capability: {
1368
+ id: "github-pr-create",
1369
+ provider: "github",
1370
+ connector: "github-rest",
1371
+ label: "Create pull request",
1372
+ tool: "github.pull_requests.create",
1373
+ operation: "write",
1374
+ status: "supported",
1375
+ supportedModes: ["fake", "rest", "app"],
1376
+ requiresActionPass: true,
1377
+ defaultPolicy: "step_up",
1378
+ payloadFields: [
1379
+ "title",
1380
+ "baseBranch",
1381
+ "filesChanged",
1382
+ "testsPassed",
1383
+ "touchesProduction",
1384
+ ],
1385
+ approvalTriggers: [
1386
+ "protected path",
1387
+ "production impact",
1388
+ "base branch mismatch",
1389
+ "missing tests",
1390
+ "large file set",
1391
+ ],
1392
+ credentialBoundary: "GitHub token stays in the proxy/adapter; the agent receives an ActionPass-bound result.",
1393
+ smokeCheck: "GET /user",
1394
+ },
1395
+ normalizeEvidence: (action, payload) => githubPullRequestEvidence(action, payload),
1396
+ },
1397
+ {
1398
+ capability: {
1399
+ id: "github-content-read",
1400
+ provider: "github",
1401
+ connector: "github-rest",
1402
+ label: "Read repository content",
1403
+ tool: "github.contents.read",
1404
+ operation: "read",
1405
+ status: "supported",
1406
+ supportedModes: ["fake", "rest", "app"],
1407
+ requiresActionPass: true,
1408
+ defaultPolicy: "deny_until_scoped",
1409
+ payloadFields: ["path", "ref"],
1410
+ approvalTriggers: ["denied secret or production path"],
1411
+ credentialBoundary: "Repository content is fetched by the adapter after policy checks deny protected paths.",
1412
+ smokeCheck: "GET /user",
1413
+ },
1414
+ normalizeEvidence: (action, payload) => githubContentEvidence(action, payload, "content_read"),
1415
+ },
1416
+ {
1417
+ capability: {
1418
+ id: "github-branch-create",
1419
+ provider: "github",
1420
+ connector: "github-rest",
1421
+ label: "Create branch",
1422
+ tool: "github.branches.create",
1423
+ operation: "write",
1424
+ status: "supported",
1425
+ supportedModes: ["fake", "rest", "app"],
1426
+ requiresActionPass: true,
1427
+ defaultPolicy: "allow",
1428
+ payloadFields: ["branch", "baseBranch", "sha", "allowExisting"],
1429
+ approvalTriggers: ["base branch mismatch"],
1430
+ credentialBoundary: "Branch creation runs through the proxy and is bound to the same ActionPass verification path.",
1431
+ smokeCheck: "GET /user",
1432
+ },
1433
+ normalizeEvidence: (action, payload) => githubBranchEvidence(action, payload),
1434
+ },
1435
+ {
1436
+ capability: {
1437
+ id: "github-content-write",
1438
+ provider: "github",
1439
+ connector: "github-rest",
1440
+ label: "Write repository content",
1441
+ tool: "github.contents.write",
1442
+ operation: "write",
1443
+ status: "supported",
1444
+ supportedModes: ["fake", "rest", "app"],
1445
+ requiresActionPass: true,
1446
+ defaultPolicy: "step_up",
1447
+ payloadFields: ["path", "branch", "message", "sha", "content"],
1448
+ approvalTriggers: ["protected path"],
1449
+ credentialBoundary: "File content is never written until policy and ActionPass verification both pass.",
1450
+ smokeCheck: "GET /user",
1451
+ },
1452
+ normalizeEvidence: (action, payload) => githubContentEvidence(action, payload, "content_write"),
1453
+ },
1454
+ {
1455
+ capability: {
1456
+ id: "github-review-comment-create",
1457
+ provider: "github",
1458
+ connector: "github-rest",
1459
+ label: "Create inline review comment",
1460
+ tool: "github.pull_request_review_comments.create",
1461
+ operation: "write",
1462
+ status: "supported",
1463
+ supportedModes: ["app"],
1464
+ requiresActionPass: true,
1465
+ defaultPolicy: "step_up",
1466
+ payloadFields: [
1467
+ "pullNumber",
1468
+ "body",
1469
+ "commitId",
1470
+ "path",
1471
+ "line",
1472
+ "side",
1473
+ ],
1474
+ approvalTriggers: ["protected path review"],
1475
+ credentialBoundary: "The exact inline comment target and body are authorized before GitHub receives the write.",
1476
+ smokeCheck: "GET /user + Pull requests: write",
1477
+ },
1478
+ normalizeEvidence: (action, payload) => githubReviewCommentEvidence(action, payload),
1479
+ },
1480
+ {
1481
+ capability: {
1482
+ id: "github-check-run-create",
1483
+ provider: "github",
1484
+ connector: "github-rest",
1485
+ label: "Create check run",
1486
+ tool: "github.check_runs.create",
1487
+ operation: "write",
1488
+ status: "supported",
1489
+ supportedModes: ["rest", "app"],
1490
+ requiresActionPass: true,
1491
+ defaultPolicy: "allow",
1492
+ payloadFields: [
1493
+ "name",
1494
+ "headSha",
1495
+ "status",
1496
+ "conclusion",
1497
+ "output",
1498
+ ],
1499
+ approvalTriggers: [],
1500
+ credentialBoundary: "Check name, commit SHA, status, conclusion, and output are payload-bound before execution.",
1501
+ smokeCheck: "GET /user + Checks: write",
1502
+ },
1503
+ normalizeEvidence: (action, payload) => githubCheckRunEvidence(action, payload),
1504
+ },
1505
+ {
1506
+ capability: {
1507
+ id: "github-issue-comment-create",
1508
+ provider: "github",
1509
+ connector: "github-rest",
1510
+ label: "Create issue comment",
1511
+ tool: "github.issue_comments.create",
1512
+ operation: "external_message",
1513
+ status: "supported",
1514
+ supportedModes: ["rest", "app"],
1515
+ requiresActionPass: true,
1516
+ defaultPolicy: "allow",
1517
+ payloadFields: ["issueNumber", "body"],
1518
+ approvalTriggers: [],
1519
+ credentialBoundary: "The exact issue number and outbound comment body are bound into the ActionPass.",
1520
+ smokeCheck: "GET /user + Issues: write",
1521
+ },
1522
+ normalizeEvidence: (action, payload) => githubIssueCommentEvidence(action, payload),
1523
+ },
1524
+ ],
1525
+ };
1526
+ export const LINEAR_NATIVE_CONNECTOR_GOVERNANCE = {
1527
+ provider: "linear",
1528
+ connector: "linear-graphql",
1529
+ mode: "graphql",
1530
+ configSchema: LinearNativeConnectorConfigSchema,
1531
+ defaultConfig: DEFAULT_LINEAR_NATIVE_CONNECTOR_CONFIG,
1532
+ requiredScopes: ["read", "write"],
1533
+ smokeCheck: "viewer",
1534
+ tools: [
1535
+ {
1536
+ capability: {
1537
+ id: "linear-issue-read",
1538
+ provider: "linear",
1539
+ connector: "linear-graphql",
1540
+ label: "Read issue",
1541
+ tool: "linear.issues.read",
1542
+ operation: "read",
1543
+ status: "supported",
1544
+ supportedModes: ["fake", "graphql"],
1545
+ requiresActionPass: true,
1546
+ defaultPolicy: "allow",
1547
+ payloadFields: ["issueKey", "projectKey"],
1548
+ approvalTriggers: [],
1549
+ credentialBoundary: "Linear API key stays adapter-side; policy pins issue access by project key.",
1550
+ smokeCheck: "viewer",
1551
+ },
1552
+ normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "linear", "issue_read"),
1553
+ },
1554
+ {
1555
+ capability: {
1556
+ id: "linear-comment-create",
1557
+ provider: "linear",
1558
+ connector: "linear-graphql",
1559
+ label: "Create issue comment",
1560
+ tool: "linear.comments.create",
1561
+ operation: "write",
1562
+ status: "supported",
1563
+ supportedModes: ["fake", "graphql"],
1564
+ requiresActionPass: true,
1565
+ defaultPolicy: "allow",
1566
+ payloadFields: ["issueKey", "projectKey", "body"],
1567
+ approvalTriggers: [],
1568
+ credentialBoundary: "Linear comments execute only through proxy policy and ActionPass verification.",
1569
+ smokeCheck: "viewer",
1570
+ },
1571
+ normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "linear", "comment_create"),
1572
+ },
1573
+ {
1574
+ capability: {
1575
+ id: "linear-issue-update",
1576
+ provider: "linear",
1577
+ connector: "linear-graphql",
1578
+ label: "Update issue",
1579
+ tool: "linear.issues.update",
1580
+ operation: "write",
1581
+ status: "supported",
1582
+ supportedModes: ["fake", "graphql"],
1583
+ requiresActionPass: true,
1584
+ defaultPolicy: "step_up",
1585
+ payloadFields: [
1586
+ "issueKey",
1587
+ "projectKey",
1588
+ "status",
1589
+ "title",
1590
+ "description",
1591
+ "priority",
1592
+ ],
1593
+ approvalTriggers: ["protected status"],
1594
+ credentialBoundary: "Issue mutations stay behind adapter credentials and project/status policy checks.",
1595
+ smokeCheck: "viewer",
1596
+ },
1597
+ normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "linear", "issue_update"),
1598
+ },
1599
+ ],
1600
+ };
1601
+ export const JIRA_NATIVE_CONNECTOR_GOVERNANCE = {
1602
+ provider: "jira",
1603
+ connector: "jira-rest",
1604
+ mode: "rest",
1605
+ configSchema: JiraNativeConnectorConfigSchema,
1606
+ defaultConfig: DEFAULT_JIRA_NATIVE_CONNECTOR_CONFIG,
1607
+ requiredScopes: ["read:jira-work", "write:jira-work"],
1608
+ smokeCheck: "GET /myself",
1609
+ tools: [
1610
+ {
1611
+ capability: {
1612
+ id: "jira-issue-read",
1613
+ provider: "jira",
1614
+ connector: "jira-rest",
1615
+ label: "Read issue",
1616
+ tool: "jira.issues.read",
1617
+ operation: "read",
1618
+ status: "supported",
1619
+ supportedModes: ["fake", "rest"],
1620
+ requiresActionPass: true,
1621
+ defaultPolicy: "allow",
1622
+ payloadFields: ["issueKey", "projectKey"],
1623
+ approvalTriggers: [],
1624
+ credentialBoundary: "Jira credentials stay adapter-side; policy pins issue access by project key.",
1625
+ smokeCheck: "GET /myself",
1626
+ },
1627
+ normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "jira", "issue_read"),
1628
+ },
1629
+ {
1630
+ capability: {
1631
+ id: "jira-comment-create",
1632
+ provider: "jira",
1633
+ connector: "jira-rest",
1634
+ label: "Create issue comment",
1635
+ tool: "jira.comments.create",
1636
+ operation: "write",
1637
+ status: "supported",
1638
+ supportedModes: ["fake", "rest"],
1639
+ requiresActionPass: true,
1640
+ defaultPolicy: "allow",
1641
+ payloadFields: ["issueKey", "projectKey", "body"],
1642
+ approvalTriggers: [],
1643
+ credentialBoundary: "Jira comments are converted to Atlassian Document Format only after policy and ActionPass verification.",
1644
+ smokeCheck: "GET /myself",
1645
+ },
1646
+ normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "jira", "comment_create"),
1647
+ },
1648
+ {
1649
+ capability: {
1650
+ id: "jira-issue-update",
1651
+ provider: "jira",
1652
+ connector: "jira-rest",
1653
+ label: "Update issue",
1654
+ tool: "jira.issues.update",
1655
+ operation: "write",
1656
+ status: "supported",
1657
+ supportedModes: ["fake", "rest"],
1658
+ requiresActionPass: true,
1659
+ defaultPolicy: "step_up",
1660
+ payloadFields: [
1661
+ "issueKey",
1662
+ "projectKey",
1663
+ "status",
1664
+ "title",
1665
+ "description",
1666
+ "priorityId",
1667
+ ],
1668
+ approvalTriggers: ["protected status"],
1669
+ credentialBoundary: "Jira field edits and workflow transitions execute only after exact-payload authorization.",
1670
+ smokeCheck: "GET /myself",
1671
+ },
1672
+ normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "jira", "issue_update"),
1673
+ },
1674
+ ],
1675
+ };
1676
+ export const POSTGRES_NATIVE_CONNECTOR_GOVERNANCE = {
1677
+ provider: "postgres",
1678
+ connector: "postgres-native",
1679
+ mode: "postgres",
1680
+ configSchema: PostgresNativeConnectorConfigSchema,
1681
+ defaultConfig: DEFAULT_POSTGRES_NATIVE_CONNECTOR_CONFIG,
1682
+ requiredScopes: [
1683
+ "LOGIN role",
1684
+ "SELECT on allowed tables",
1685
+ "NOBYPASSRLS",
1686
+ "read-only transactions",
1687
+ ],
1688
+ smokeCheck: "SELECT current_database/current_user + transaction_read_only + table RLS metadata",
1689
+ tools: [
1690
+ {
1691
+ capability: {
1692
+ id: "postgres-query-select",
1693
+ provider: "postgres",
1694
+ connector: "postgres-native",
1695
+ label: "Run scoped SELECT",
1696
+ tool: "postgres.query.select",
1697
+ operation: "read",
1698
+ status: "supported",
1699
+ supportedModes: ["postgres"],
1700
+ requiresActionPass: true,
1701
+ defaultPolicy: "deny_until_scoped",
1702
+ payloadFields: ["statement", "parameters", "maxRows"],
1703
+ approvalTriggers: ["unpredicated table scan"],
1704
+ credentialBoundary: "The DSN stays in the local adapter; only the exact parameterized SELECT and bounded result cross the authorization boundary.",
1705
+ smokeCheck: "current role/database, read-only state, and RLS metadata for configured tables",
1706
+ },
1707
+ normalizeEvidence: (action, payload) => postgresSelectEvidence(action, payload),
1708
+ },
1709
+ ],
1710
+ };
1711
+ export const DRIVE_NATIVE_CONNECTOR_GOVERNANCE = {
1712
+ provider: "drive",
1713
+ connector: "google-drive-rest",
1714
+ mode: "rest",
1715
+ configSchema: DriveNativeConnectorConfigSchema,
1716
+ defaultConfig: DEFAULT_DRIVE_NATIVE_CONNECTOR_CONFIG,
1717
+ requiredScopes: ["https://www.googleapis.com/auth/drive.file"],
1718
+ smokeCheck: "GET selected file metadata including isAppAuthorized (no content)",
1719
+ tools: [
1720
+ {
1721
+ capability: {
1722
+ id: "drive-file-read",
1723
+ provider: "drive",
1724
+ connector: "google-drive-rest",
1725
+ label: "Read selected Drive file",
1726
+ tool: "drive.files.read",
1727
+ operation: "read",
1728
+ status: "supported",
1729
+ supportedModes: ["rest"],
1730
+ requiresActionPass: true,
1731
+ defaultPolicy: "deny_until_scoped",
1732
+ payloadFields: ["fileId", "maxBytes"],
1733
+ approvalTriggers: [
1734
+ "file not policy-scoped",
1735
+ "file not selected through Google Picker",
1736
+ "byte limit exceeded",
1737
+ ],
1738
+ credentialBoundary: "The OAuth token and file content stay in the local enforcement plane; only a Picker-authorized, policy-scoped file can be returned.",
1739
+ smokeCheck: "selected-file metadata and provider isAppAuthorized state",
1740
+ },
1741
+ normalizeEvidence: (action, payload) => driveFileReadEvidence(action, payload),
1742
+ },
1743
+ ],
1744
+ };
1745
+ export const NATIVE_CONNECTOR_GOVERNANCE_REGISTRY = [
1746
+ GITHUB_NATIVE_CONNECTOR_GOVERNANCE,
1747
+ LINEAR_NATIVE_CONNECTOR_GOVERNANCE,
1748
+ JIRA_NATIVE_CONNECTOR_GOVERNANCE,
1749
+ POSTGRES_NATIVE_CONNECTOR_GOVERNANCE,
1750
+ DRIVE_NATIVE_CONNECTOR_GOVERNANCE,
1751
+ ];
1752
+ const providerEvidenceNormalizers = new Map([
1753
+ [
1754
+ "slack.chat.postMessage",
1755
+ (action, payload) => slackMessageEvidence(action, payload),
1756
+ ],
1757
+ [
1758
+ "docs.documents.search",
1759
+ (action, payload) => docsEvidence(action, payload, "document_search"),
1760
+ ],
1761
+ [
1762
+ "docs.documents.read",
1763
+ (action, payload) => docsEvidence(action, payload, "document_read"),
1764
+ ],
1765
+ ["mcp.tool.call", (action, payload) => mcpEvidence(action, payload)],
1766
+ [
1767
+ "aws.identity.get",
1768
+ (action, payload) => awsEvidence(action, payload, "identity_get"),
1769
+ ],
1770
+ [
1771
+ "aws.s3.objects.list",
1772
+ (action, payload) => awsEvidence(action, payload, "s3_objects_list"),
1773
+ ],
1774
+ [
1775
+ "gcp.projects.get",
1776
+ (action, payload) => gcpEvidence(action, payload, "project_get"),
1777
+ ],
1778
+ [
1779
+ "gcp.storage.objects.list",
1780
+ (action, payload) => gcpEvidence(action, payload, "storage_objects_list"),
1781
+ ],
1782
+ ...NATIVE_CONNECTOR_GOVERNANCE_REGISTRY.flatMap((descriptor) => descriptor.tools.map((tool) => [
1783
+ tool.capability.tool,
1784
+ tool.normalizeEvidence,
1785
+ ])),
1786
+ ]);
597
1787
  export function providerEvidenceForAction(actionInput) {
598
1788
  const action = parseNormalizedAction(actionInput);
599
- const payload = action.capability.payload;
600
- switch (action.capability.tool) {
601
- case "github.pull_requests.create":
602
- return githubPullRequestEvidence(action, payload);
603
- case "github.contents.read":
604
- return githubContentEvidence(action, payload, "content_read");
605
- case "github.contents.write":
606
- return githubContentEvidence(action, payload, "content_write");
607
- case "github.branches.create":
608
- return githubBranchEvidence(action, payload);
609
- case "slack.chat.postMessage":
610
- return slackMessageEvidence(action, payload);
611
- case "linear.issues.read":
612
- return linearIssueEvidence(action, payload, "issue_read");
613
- case "linear.comments.create":
614
- return linearIssueEvidence(action, payload, "comment_create");
615
- case "linear.issues.update":
616
- return linearIssueEvidence(action, payload, "issue_update");
617
- case "jira.issues.read":
618
- return issueTrackerEvidence(action, payload, "jira", "issue_read");
619
- case "jira.comments.create":
620
- return issueTrackerEvidence(action, payload, "jira", "comment_create");
621
- case "jira.issues.update":
622
- return issueTrackerEvidence(action, payload, "jira", "issue_update");
623
- case "docs.documents.search":
624
- return docsEvidence(action, payload, "document_search");
625
- case "docs.documents.read":
626
- return docsEvidence(action, payload, "document_read");
627
- case "mcp.tool.call":
628
- return mcpEvidence(action, payload);
629
- case "aws.identity.get":
630
- return awsEvidence(action, payload, "identity_get");
631
- case "aws.s3.objects.list":
632
- return awsEvidence(action, payload, "s3_objects_list");
633
- case "gcp.projects.get":
634
- return gcpEvidence(action, payload, "project_get");
635
- case "gcp.storage.objects.list":
636
- return gcpEvidence(action, payload, "storage_objects_list");
637
- default:
638
- return undefined;
639
- }
1789
+ return providerEvidenceNormalizers.get(action.capability.tool)?.(action, action.capability.payload);
640
1790
  }
641
1791
  export function hashJson(value) {
642
1792
  return `sha256:${createHash("sha256")
@@ -659,15 +1809,33 @@ function validatePassBinding(claims, action, payloadHash) {
659
1809
  if (claims.intent.taskId !== action.intent.taskId) {
660
1810
  return "task_id_mismatch";
661
1811
  }
1812
+ if (stableStringify(claims.intent) !== stableStringify(action.intent)) {
1813
+ return "intent_mismatch";
1814
+ }
662
1815
  if (claims.capability.tool !== action.capability.tool) {
663
1816
  return "tool_mismatch";
664
1817
  }
665
1818
  if (claims.capability.resource !== action.capability.resource) {
666
1819
  return "resource_mismatch";
667
1820
  }
1821
+ if (stableStringify(claims.capability.constraints) !==
1822
+ stableStringify(action.capability.constraints)) {
1823
+ return "constraints_mismatch";
1824
+ }
1825
+ if (stableStringify(claims.budget) !== stableStringify(action.budget)) {
1826
+ return "budget_mismatch";
1827
+ }
668
1828
  if (claims.payloadHash !== payloadHash) {
669
1829
  return "payload_hash_mismatch";
670
1830
  }
1831
+ const provenanceHash = action.provenance
1832
+ ? hashProvenance(action.provenance)
1833
+ : undefined;
1834
+ if (claims.provenanceHash !== provenanceHash) {
1835
+ return action.provenance
1836
+ ? "provenance_hash_mismatch"
1837
+ : "unexpected_provenance_hash";
1838
+ }
671
1839
  if (claims.approval?.payloadHash && claims.approval.payloadHash !== payloadHash) {
672
1840
  return "approval_payload_hash_mismatch";
673
1841
  }
@@ -676,6 +1844,100 @@ function validatePassBinding(claims, action, payloadHash) {
676
1844
  }
677
1845
  return null;
678
1846
  }
1847
+ async function validateDpopProof(input, claims) {
1848
+ if (!input.dpopProof)
1849
+ return "dpop_proof_required";
1850
+ if (!input.dpopMethod)
1851
+ return "dpop_method_required";
1852
+ if (!input.dpopUri)
1853
+ return "dpop_uri_required";
1854
+ try {
1855
+ const header = decodeProtectedHeader(input.dpopProof);
1856
+ if (header.typ !== DPOP_PROOF_TYP)
1857
+ return "dpop_type_mismatch";
1858
+ if (!header.jwk || typeof header.jwk !== "object") {
1859
+ return "dpop_public_jwk_required";
1860
+ }
1861
+ const proofJwk = publicOnlyJwk(header.jwk);
1862
+ const algorithm = header.alg;
1863
+ const algorithms = input.dpopAlgorithms ?? [DEFAULT_SIGNING_ALGORITHM];
1864
+ if (!algorithms.includes(algorithm))
1865
+ return "dpop_algorithm_not_allowed";
1866
+ const key = await importJWK(proofJwk, algorithm);
1867
+ const { payload } = await jwtVerify(input.dpopProof, key, {
1868
+ algorithms,
1869
+ currentDate: input.currentDate,
1870
+ });
1871
+ const proof = DpopProofClaimsSchema.parse(payload);
1872
+ const proofJkt = await calculateJwkThumbprint(proofJwk, "sha256");
1873
+ if (proofJkt !== claims.cnf.jkt)
1874
+ return "dpop_key_mismatch";
1875
+ if (proof.htm !== normalizeDpopMethod(input.dpopMethod)) {
1876
+ return "dpop_method_mismatch";
1877
+ }
1878
+ if (proof.htu !== normalizeDpopUri(input.dpopUri)) {
1879
+ return "dpop_uri_mismatch";
1880
+ }
1881
+ if (proof.ath !== computeDpopAccessTokenHash(input.token)) {
1882
+ return "dpop_actionpass_hash_mismatch";
1883
+ }
1884
+ if (input.dpopNonce !== undefined && proof.nonce !== input.dpopNonce) {
1885
+ return "dpop_nonce_mismatch";
1886
+ }
1887
+ const nowSeconds = toNumericDate(input.currentDate ?? new Date());
1888
+ const maxAge = input.dpopMaxAgeSeconds ?? DEFAULT_DPOP_MAX_AGE_SECONDS;
1889
+ const skew = input.dpopClockSkewSeconds ?? DEFAULT_DPOP_CLOCK_SKEW_SECONDS;
1890
+ if (proof.iat > nowSeconds + skew)
1891
+ return "dpop_proof_from_future";
1892
+ if (proof.iat < nowSeconds - maxAge - skew)
1893
+ return "dpop_proof_stale";
1894
+ if (!input.dpopReplayStore)
1895
+ return "dpop_replay_store_required";
1896
+ const accepted = await input.dpopReplayStore.consume(proof.jti, new Date((proof.iat + maxAge + skew) * 1000), input.currentDate);
1897
+ if (!accepted)
1898
+ return "dpop_replay_detected";
1899
+ return null;
1900
+ }
1901
+ catch (error) {
1902
+ return error instanceof Error && error.message.startsWith("dpop_")
1903
+ ? error.message
1904
+ : "dpop_proof_invalid";
1905
+ }
1906
+ }
1907
+ export function computeDpopAccessTokenHash(token) {
1908
+ return createHash("sha256").update(token, "ascii").digest("base64url");
1909
+ }
1910
+ export function normalizeDpopMethod(method) {
1911
+ const normalized = method.trim().toUpperCase();
1912
+ if (!normalized)
1913
+ throw new Error("dpop_method_required");
1914
+ return normalized;
1915
+ }
1916
+ export function normalizeDpopUri(uri) {
1917
+ const parsed = new URL(uri);
1918
+ parsed.search = "";
1919
+ parsed.hash = "";
1920
+ return parsed.toString();
1921
+ }
1922
+ export function actionPassDpopTarget(actionInput) {
1923
+ const action = parseNormalizedAction(actionInput);
1924
+ return {
1925
+ method: "POST",
1926
+ uri: `https://local.axtary.dev/actions/${encodeURIComponent(action.capability.tool)}`,
1927
+ };
1928
+ }
1929
+ function publicOnlyJwk(jwk) {
1930
+ if (!jwk.kty || jwk.kty === "oct") {
1931
+ throw new Error("dpop_public_jwk_invalid");
1932
+ }
1933
+ const publicJwk = { ...jwk };
1934
+ for (const member of ["d", "p", "q", "dp", "dq", "qi", "oth", "k"]) {
1935
+ if (member in publicJwk) {
1936
+ throw new Error("dpop_public_jwk_invalid");
1937
+ }
1938
+ }
1939
+ return publicJwk;
1940
+ }
679
1941
  async function readTrustStore(filePath) {
680
1942
  const text = await readTrustStoreFile(filePath);
681
1943
  if (!text.trim()) {
@@ -691,6 +1953,7 @@ async function writeTrustStore(filePath, data) {
691
1953
  await writeFile(tmpPath, `${JSON.stringify(data, null, 2)}\n`, {
692
1954
  encoding: "utf8",
693
1955
  flag: "wx",
1956
+ mode: 0o600,
694
1957
  });
695
1958
  await rename(tmpPath, filePath);
696
1959
  }
@@ -752,7 +2015,13 @@ async function validateRevocation(input, claims) {
752
2015
  }
753
2016
  }
754
2017
  if (input.isRevoked) {
755
- const result = await input.isRevoked(claims);
2018
+ let result;
2019
+ try {
2020
+ result = await input.isRevoked(claims);
2021
+ }
2022
+ catch {
2023
+ return "actionpass_revocation_check_failed";
2024
+ }
756
2025
  if (result === true) {
757
2026
  return "actionpass_revoked";
758
2027
  }
@@ -762,6 +2031,27 @@ async function validateRevocation(input, claims) {
762
2031
  }
763
2032
  return null;
764
2033
  }
2034
+ async function validateDistributedStatus(input, claims) {
2035
+ if (!input.getStatus) {
2036
+ return "actionpass_status_check_required";
2037
+ }
2038
+ let status;
2039
+ try {
2040
+ status = await input.getStatus(claims);
2041
+ }
2042
+ catch {
2043
+ return "actionpass_status_check_failed";
2044
+ }
2045
+ if (status === 0)
2046
+ return null;
2047
+ if (status === 1)
2048
+ return "actionpass_revoked";
2049
+ if (status === 2)
2050
+ return "actionpass_suspended";
2051
+ if (typeof status === "string" && status.length > 0)
2052
+ return status;
2053
+ return "actionpass_status_invalid";
2054
+ }
765
2055
  function bindApproval(input) {
766
2056
  const approval = input.approval
767
2057
  ? ApprovalSchema.parse(input.approval)
@@ -803,6 +2093,7 @@ function bindApproval(input) {
803
2093
  return ApprovalSchema.parse({
804
2094
  mode: approval?.mode ?? artifact.mode,
805
2095
  approvedBy: approval?.approvedBy ?? artifact.approvedBy,
2096
+ approverRoles: approval?.approverRoles ?? artifact.approverRoles,
806
2097
  approvedAt: approval?.approvedAt ?? artifact.approvedAt,
807
2098
  approvalArtifact: approval?.approvalArtifact ?? artifact.id,
808
2099
  approvalArtifactHash: artifactHash,
@@ -913,6 +2204,103 @@ function githubBranchEvidence(action, payload) {
913
2204
  }),
914
2205
  });
915
2206
  }
2207
+ function githubReviewCommentEvidence(action, payload) {
2208
+ const path = getString(payload.path) ?? "unknown";
2209
+ const pullNumber = getNumber(payload.pullNumber);
2210
+ const line = getNumber(payload.line);
2211
+ return LedgerProviderEvidenceSchema.parse({
2212
+ schemaVersion: LEDGER_PROVIDER_EVIDENCE_VERSION,
2213
+ provider: "github",
2214
+ tool: action.capability.tool,
2215
+ operation: "pull_request_review_comment_create",
2216
+ resource: providerResource("github", "pull_request_line", `${action.capability.resource}:pull:${pullNumber ?? "unknown"}:${path}:${line ?? "unknown"}`),
2217
+ normalized: compactJsonRecord({
2218
+ pullNumber,
2219
+ commitId: getString(payload.commitId),
2220
+ path,
2221
+ line,
2222
+ side: getString(payload.side),
2223
+ startLine: getNumber(payload.startLine),
2224
+ startSide: getString(payload.startSide),
2225
+ body: redactCredentialLikeText(getString(payload.body) ?? ""),
2226
+ }),
2227
+ });
2228
+ }
2229
+ function githubCheckRunEvidence(action, payload) {
2230
+ const name = getString(payload.name) ?? "unknown";
2231
+ const headSha = getString(payload.headSha) ?? "unknown";
2232
+ const output = isJsonObject(payload.output) ? payload.output : {};
2233
+ return LedgerProviderEvidenceSchema.parse({
2234
+ schemaVersion: LEDGER_PROVIDER_EVIDENCE_VERSION,
2235
+ provider: "github",
2236
+ tool: action.capability.tool,
2237
+ operation: "check_run_create",
2238
+ resource: providerResource("github", "commit", `${action.capability.resource}:${headSha}`, name),
2239
+ normalized: compactJsonRecord({
2240
+ name,
2241
+ headSha,
2242
+ status: getString(payload.status),
2243
+ conclusion: getString(payload.conclusion),
2244
+ detailsUrl: getString(payload.detailsUrl),
2245
+ externalId: getString(payload.externalId),
2246
+ outputTitle: getString(output.title),
2247
+ outputSummary: redactCredentialLikeText(getString(output.summary) ?? ""),
2248
+ }),
2249
+ });
2250
+ }
2251
+ function githubIssueCommentEvidence(action, payload) {
2252
+ const issueNumber = getNumber(payload.issueNumber);
2253
+ return LedgerProviderEvidenceSchema.parse({
2254
+ schemaVersion: LEDGER_PROVIDER_EVIDENCE_VERSION,
2255
+ provider: "github",
2256
+ tool: action.capability.tool,
2257
+ operation: "issue_comment_create",
2258
+ resource: providerResource("github", "issue", `${action.capability.resource}:issue:${issueNumber ?? "unknown"}`),
2259
+ normalized: compactJsonRecord({
2260
+ issueNumber,
2261
+ body: redactCredentialLikeText(getString(payload.body) ?? ""),
2262
+ }),
2263
+ });
2264
+ }
2265
+ function postgresSelectEvidence(action, payload) {
2266
+ const statement = typeof payload.statement === "string" ? payload.statement : "";
2267
+ const analysis = analyzePostgresSelect(statement);
2268
+ const parameters = Array.isArray(payload.parameters) ? payload.parameters : [];
2269
+ const maxRows = typeof payload.maxRows === "number" && Number.isInteger(payload.maxRows)
2270
+ ? payload.maxRows
2271
+ : null;
2272
+ return LedgerProviderEvidenceSchema.parse({
2273
+ schemaVersion: LEDGER_PROVIDER_EVIDENCE_VERSION,
2274
+ provider: "postgres",
2275
+ tool: action.capability.tool,
2276
+ operation: "query_select",
2277
+ resource: providerResource("postgres", "database", action.capability.resource),
2278
+ normalized: {
2279
+ statementHash: analysis.ok ? analysis.analysis.statementHash : null,
2280
+ predicateHash: analysis.ok ? analysis.analysis.predicateHash : null,
2281
+ tables: analysis.ok ? analysis.analysis.tables : [],
2282
+ functions: analysis.ok ? analysis.analysis.functions : [],
2283
+ parameterCount: analysis.ok ? analysis.analysis.parameterCount : 0,
2284
+ parameterHash: hashPayload(parameters),
2285
+ maxRows,
2286
+ },
2287
+ });
2288
+ }
2289
+ function driveFileReadEvidence(action, payload) {
2290
+ const fileId = getString(payload.fileId) ?? "unknown";
2291
+ return LedgerProviderEvidenceSchema.parse({
2292
+ schemaVersion: LEDGER_PROVIDER_EVIDENCE_VERSION,
2293
+ provider: "drive",
2294
+ tool: action.capability.tool,
2295
+ operation: "file_read",
2296
+ resource: providerResource("drive", "file", `drive:file:${fileId}`, fileId),
2297
+ normalized: compactJsonRecord({
2298
+ fileId,
2299
+ scope: "https://www.googleapis.com/auth/drive.file",
2300
+ maxBytes: getNumber(payload.maxBytes),
2301
+ }),
2302
+ });
2303
+ }
916
2304
  function slackMessageEvidence(action, payload) {
917
2305
  const channel = getString(payload.channel) ?? "unknown";
918
2306
  const message = getString(payload.text) ?? getString(payload.message) ?? "";
@@ -937,9 +2325,6 @@ function slackMessageEvidence(action, payload) {
937
2325
  }),
938
2326
  });
939
2327
  }
940
- function linearIssueEvidence(action, payload, operation) {
941
- return issueTrackerEvidence(action, payload, "linear", operation);
942
- }
943
2328
  function issueTrackerEvidence(action, payload, provider, operation) {
944
2329
  const issueKey = getString(payload.issueKey) ?? getString(payload.issueId) ?? "unknown";
945
2330
  const projectKey = getString(payload.projectKey) ?? issueKey.split("-", 1)[0];
@@ -956,11 +2341,12 @@ function issueTrackerEvidence(action, payload, provider, operation) {
956
2341
  status: getString(payload.status),
957
2342
  stateId: getString(payload.stateId),
958
2343
  priority: getNumber(payload.priority),
2344
+ priorityId: getString(payload.priorityId),
959
2345
  commentBody: operation === "comment_create"
960
2346
  ? redactCredentialLikeText(getString(payload.body) ?? "")
961
2347
  : null,
962
2348
  }),
963
- fieldChanges: linearFieldChanges(payload),
2349
+ fieldChanges: issueTrackerFieldChanges(payload),
964
2350
  });
965
2351
  }
966
2352
  function docsEvidence(action, payload, operation) {
@@ -1089,13 +2475,14 @@ function changedFilesFromPayload(payload) {
1089
2475
  .filter((path) => Boolean(path));
1090
2476
  return [...new Set([...getStringArray(payload.filesChanged), ...changes])];
1091
2477
  }
1092
- function linearFieldChanges(payload) {
2478
+ function issueTrackerFieldChanges(payload) {
1093
2479
  return [
1094
2480
  fieldChange("title", payload.fromTitle, payload.title),
1095
2481
  fieldChange("description", payload.fromDescription, payload.description),
1096
2482
  fieldChange("status", payload.fromStatus, payload.status),
1097
2483
  fieldChange("stateId", payload.fromStateId, payload.stateId),
1098
2484
  fieldChange("priority", payload.fromPriority, payload.priority),
2485
+ fieldChange("priorityId", payload.fromPriorityId, payload.priorityId),
1099
2486
  ].filter((change) => change !== null);
1100
2487
  }
1101
2488
  function fieldChange(field, before, after) {
@@ -1187,7 +2574,9 @@ function canonicalize(value) {
1187
2574
  if (isPlainObject(value)) {
1188
2575
  return Object.fromEntries(Object.entries(value)
1189
2576
  .filter(([, entryValue]) => entryValue !== undefined)
1190
- .sort(([left], [right]) => left.localeCompare(right))
2577
+ // UTF-16 code unit order per RFC 8785 (JCS); locale-sensitive sorts
2578
+ // would make hashes non-portable across environments.
2579
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
1191
2580
  .map(([key, entryValue]) => [key, canonicalize(entryValue)]));
1192
2581
  }
1193
2582
  return value;
@@ -1201,4 +2590,262 @@ function isPlainObject(value) {
1201
2590
  function isNodeError(error) {
1202
2591
  return error instanceof Error && "code" in error;
1203
2592
  }
2593
+ // ─────────────────────────────────────────────────────────────────────────────
2594
+ // SDK facade (PRD §8.2)
2595
+ //
2596
+ // The ergonomic developer entry point. It exposes the five PRD verbs —
2597
+ // `authorize`, `verify`, `record`, `revoke`, `explain` — over a flat request
2598
+ // shape and delegates to the tested core functions above. It adds no new
2599
+ // authorization logic and changes no token/ledger/policy shape; it only maps the
2600
+ // flat request to a NormalizedAction, threads a local hash-linked ledger head,
2601
+ // and promotes the deterministic reason prose (`explainDecision`).
2602
+ //
2603
+ // Keys: if no signing key is configured, the facade resolves a persistent dev
2604
+ // keypair (`devKeypair()` — env-backed or a 0600 local keyring file) so the
2605
+ // quickstart runs with zero key plumbing and its passes verify across process
2606
+ // restarts. Configure { signingKey, verificationKey } (or a hosted issuer) for
2607
+ // production.
2608
+ // ─────────────────────────────────────────────────────────────────────────────
2609
+ export const AXTARY_DEFAULT_ISSUER = "axtary-dev";
2610
+ export const AXTARY_DEFAULT_TENANT = "local";
2611
+ export const AXTARY_DEFAULT_RUNTIME = "sdk";
2612
+ function isNormalizedActionShape(value) {
2613
+ return (typeof value === "object" &&
2614
+ value !== null &&
2615
+ "actor" in value &&
2616
+ "capability" in value &&
2617
+ "intent" in value);
2618
+ }
2619
+ function isAuthorizeResultShape(value) {
2620
+ return (typeof value === "object" &&
2621
+ value !== null &&
2622
+ "action" in value &&
2623
+ "decision" in value &&
2624
+ typeof value.decision === "object");
2625
+ }
2626
+ function isAxtaryPassShape(value) {
2627
+ return (typeof value === "object" &&
2628
+ value !== null &&
2629
+ "token" in value &&
2630
+ "claims" in value);
2631
+ }
2632
+ export class Axtary {
2633
+ issuer;
2634
+ tenant;
2635
+ runtime;
2636
+ defaultPolicy;
2637
+ algorithm;
2638
+ quiet;
2639
+ configuredSigningKey;
2640
+ configuredKeyId;
2641
+ configuredVerificationKey;
2642
+ keyPath;
2643
+ signerPromise;
2644
+ ledgerHead = null;
2645
+ revocations = new Map();
2646
+ constructor(config = {}) {
2647
+ this.issuer = config.issuer ?? AXTARY_DEFAULT_ISSUER;
2648
+ this.tenant = config.tenant ?? AXTARY_DEFAULT_TENANT;
2649
+ this.runtime = config.runtime ?? AXTARY_DEFAULT_RUNTIME;
2650
+ this.defaultPolicy = config.policy;
2651
+ this.algorithm = config.algorithm ?? DEFAULT_SIGNING_ALGORITHM;
2652
+ this.quiet = config.quiet ?? false;
2653
+ this.configuredSigningKey = config.signingKey;
2654
+ this.configuredKeyId = config.signingKeyId;
2655
+ this.configuredVerificationKey = config.verificationKey;
2656
+ this.keyPath = config.keyPath;
2657
+ }
2658
+ /** Current local ledger head (hash of the last record this instance wrote). */
2659
+ get ledgerHash() {
2660
+ return this.ledgerHead;
2661
+ }
2662
+ async resolveSigner() {
2663
+ if (!this.signerPromise) {
2664
+ this.signerPromise = (async () => {
2665
+ if (this.configuredSigningKey) {
2666
+ return {
2667
+ signingKey: this.configuredSigningKey,
2668
+ keyId: this.configuredKeyId,
2669
+ verificationKey: this.configuredVerificationKey,
2670
+ algorithm: this.algorithm,
2671
+ };
2672
+ }
2673
+ const dev = await devKeypair({ path: this.keyPath });
2674
+ if (!this.quiet) {
2675
+ const origin = dev.source === "env"
2676
+ ? `from ${AXTARY_DEV_SIGNING_KEY_ENV}`
2677
+ : `at ${dev.path}`;
2678
+ console.warn("[axtary] No signing key configured — issuing ActionPasses with a " +
2679
+ `persistent local dev key (${origin}). These passes verify across ` +
2680
+ "process restarts on this machine. Pass { signingKey, " +
2681
+ "verificationKey } to createAxtary() (or use a hosted issuer) for " +
2682
+ "production.");
2683
+ }
2684
+ return {
2685
+ signingKey: dev.signingKey,
2686
+ keyId: dev.signingKeyId,
2687
+ verificationKey: dev.verificationKey,
2688
+ algorithm: dev.algorithm,
2689
+ };
2690
+ })();
2691
+ }
2692
+ return this.signerPromise;
2693
+ }
2694
+ toNormalizedAction(request) {
2695
+ if (isNormalizedActionShape(request)) {
2696
+ return parseNormalizedAction(request);
2697
+ }
2698
+ return parseNormalizedAction({
2699
+ schemaVersion: ACTION_SCHEMA_VERSION,
2700
+ actor: {
2701
+ agentId: request.agent,
2702
+ humanOwner: request.human,
2703
+ runtime: request.runtime ?? this.runtime,
2704
+ tenant: request.tenant ?? this.tenant,
2705
+ },
2706
+ intent: {
2707
+ taskId: request.task ?? `task_${randomUUID()}`,
2708
+ declaredGoal: request.intent,
2709
+ maxDelegationDepth: request.maxDelegationDepth,
2710
+ },
2711
+ capability: {
2712
+ tool: request.tool,
2713
+ resource: request.resource,
2714
+ payload: request.payload ?? {},
2715
+ constraints: request.constraints,
2716
+ },
2717
+ toolDefinition: request.toolDefinition,
2718
+ provenance: request.provenance,
2719
+ budget: request.budget,
2720
+ });
2721
+ }
2722
+ /**
2723
+ * Decide an action and, for an allow, issue an ActionPass. Always returns a
2724
+ * decision (the policy evaluation is keyless); a deny/step-up returns
2725
+ * `pass: null`. Threads the local ledger head so successive calls chain.
2726
+ */
2727
+ async authorize(request, options = {}) {
2728
+ const signer = await this.resolveSigner();
2729
+ const action = this.toNormalizedAction(request);
2730
+ const result = await authorize({
2731
+ action,
2732
+ issuer: this.issuer,
2733
+ tenant: this.tenant,
2734
+ signingKey: signer.signingKey,
2735
+ keyId: signer.keyId,
2736
+ algorithm: signer.algorithm,
2737
+ policy: options.policy ?? this.defaultPolicy,
2738
+ approval: options.approval,
2739
+ approvalArtifact: options.approvalArtifact,
2740
+ previousLedgerHash: this.ledgerHead,
2741
+ now: options.now,
2742
+ });
2743
+ this.ledgerHead = result.ledger.ledgerHash;
2744
+ return {
2745
+ status: result.decision.decision,
2746
+ decision: result.decision,
2747
+ reasons: result.decision.reasons,
2748
+ explanation: explainDecision(result.decision),
2749
+ payloadHash: result.payloadHash,
2750
+ action: result.action,
2751
+ pass: result.actionPass,
2752
+ ledger: result.ledger,
2753
+ };
2754
+ }
2755
+ /**
2756
+ * Verify a previously issued pass against an action. Accepts an authorize
2757
+ * result, a pass object, or a raw token; the action defaults to the result's
2758
+ * action when one is supplied. Fails closed for a pass this instance revoked.
2759
+ */
2760
+ async verify(pass, action) {
2761
+ let token;
2762
+ let actionInput = action;
2763
+ if (typeof pass === "string") {
2764
+ token = pass;
2765
+ }
2766
+ else if (isAuthorizeResultShape(pass)) {
2767
+ token = pass.pass?.token ?? null;
2768
+ actionInput = actionInput ?? pass.action;
2769
+ }
2770
+ else if (isAxtaryPassShape(pass)) {
2771
+ token = pass.token;
2772
+ }
2773
+ else {
2774
+ token = null;
2775
+ }
2776
+ if (!token) {
2777
+ return { valid: false, reason: "actionpass_not_issued" };
2778
+ }
2779
+ if (!actionInput) {
2780
+ return { valid: false, reason: "verify_requires_action" };
2781
+ }
2782
+ const signer = await this.resolveSigner();
2783
+ if (!signer.verificationKey) {
2784
+ return { valid: false, reason: "verification_key_required" };
2785
+ }
2786
+ return verifyActionPass({
2787
+ token,
2788
+ action: this.toNormalizedAction(actionInput),
2789
+ verificationKey: signer.verificationKey,
2790
+ issuer: this.issuer,
2791
+ algorithms: [signer.algorithm],
2792
+ revocations: this.revocations.values(),
2793
+ });
2794
+ }
2795
+ /**
2796
+ * Append an execution result to the local ledger, chained after the decision
2797
+ * record from `authorize`. Returns the tamper-evident record; durable
2798
+ * persistence is the proxy/CLI's responsibility.
2799
+ */
2800
+ record(input) {
2801
+ const action = this.toNormalizedAction(input.action);
2802
+ const passId = typeof input.pass === "string"
2803
+ ? input.pass
2804
+ : (input.pass?.claims.jti ?? null);
2805
+ const executionOutcome = input.outcome
2806
+ ? LedgerExecutionOutcomeSchema.parse({
2807
+ schemaVersion: LEDGER_EXECUTION_OUTCOME_VERSION,
2808
+ ...input.outcome,
2809
+ })
2810
+ : undefined;
2811
+ const record = recordDecision({
2812
+ action,
2813
+ decision: input.decision,
2814
+ actionPassId: passId,
2815
+ executionOutcome,
2816
+ previousLedgerHash: input.previousLedgerHash ?? this.ledgerHead,
2817
+ correlationId: input.correlationId ?? passId,
2818
+ });
2819
+ this.ledgerHead = record.ledgerHash;
2820
+ return record;
2821
+ }
2822
+ /**
2823
+ * Revoke a pass by id. The revocation is held in this instance so a later
2824
+ * `verify` of that pass fails closed. Durable cross-process revocation is the
2825
+ * CLI/trust-store path (`axtary revoke`).
2826
+ */
2827
+ revoke(passId, options = {}) {
2828
+ const revocation = revokeActionPass({
2829
+ passId,
2830
+ revokedBy: options.revokedBy,
2831
+ reason: options.reason,
2832
+ revokedAt: options.revokedAt,
2833
+ });
2834
+ this.revocations.set(passId, revocation);
2835
+ return revocation;
2836
+ }
2837
+ /** Deterministic, human-readable explanation of a decision (no model call). */
2838
+ explain(decision) {
2839
+ const policyDecision = isAuthorizeResultShape(decision)
2840
+ ? decision.decision
2841
+ : decision;
2842
+ return explainDecision(policyDecision);
2843
+ }
2844
+ }
2845
+ /** Create an Axtary SDK instance. */
2846
+ export function createAxtary(config = {}) {
2847
+ return new Axtary(config);
2848
+ }
2849
+ /** Default Axtary SDK instance (PRD §8.2 `import { axtary }`). */
2850
+ export const axtary = createAxtary();
1204
2851
  //# sourceMappingURL=index.js.map