@axtary/actionpass 0.1.0 → 0.3.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/README.md +92 -6
- package/dist/caep.d.ts +89 -0
- package/dist/caep.d.ts.map +1 -0
- package/dist/caep.js +258 -0
- package/dist/caep.js.map +1 -0
- package/dist/dev-key.d.ts +54 -0
- package/dist/dev-key.d.ts.map +1 -0
- package/dist/dev-key.js +104 -0
- package/dist/dev-key.js.map +1 -0
- package/dist/explain.d.ts +12 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +156 -0
- package/dist/explain.js.map +1 -0
- package/dist/index.d.ts +1012 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1727 -65
- package/dist/index.js.map +1 -1
- package/dist/postgres.d.ts +21 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +112 -0
- package/dist/postgres.js.map +1 -0
- package/dist/provenance.d.ts +59 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +67 -0
- package/dist/provenance.js.map +1 -0
- package/dist/status-list.d.ts +167 -0
- package/dist/status-list.d.ts.map +1 -0
- package/dist/status-list.js +450 -0
- package/dist/status-list.js.map +1 -0
- package/package.json +3 -1
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 {
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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),
|
|
@@ -211,15 +425,50 @@ export const LedgerExecutionOutcomeSchema = z
|
|
|
211
425
|
resourceLabel: z.string().min(1).optional(),
|
|
212
426
|
failureReason: z.string().min(1).optional(),
|
|
213
427
|
errorClass: z.string().min(1).optional(),
|
|
428
|
+
// Approval↔execution equivalence proof: when the executed action carried
|
|
429
|
+
// approval evidence, the runtime stamps the hash the approval was bound
|
|
430
|
+
// to and the hash of the payload it actually executed. Both fields are
|
|
431
|
+
// present together or not at all; export verification fails closed on an
|
|
432
|
+
// incomplete pair, on `executedPayloadHash` disagreeing with the record's
|
|
433
|
+
// own hash-chained `payloadHash`, and on `approved !== executed`.
|
|
434
|
+
approvedPayloadHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),
|
|
435
|
+
executedPayloadHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),
|
|
214
436
|
sideEffectProof: z.record(z.string(), JsonValueSchema).default({}),
|
|
215
437
|
})
|
|
216
438
|
.strict();
|
|
439
|
+
export const DelegationLedgerEdgeSchema = z.object({
|
|
440
|
+
parentPassId: z.string().min(1),
|
|
441
|
+
childPassId: z.string().min(1),
|
|
442
|
+
depth: z.number().int().positive(),
|
|
443
|
+
rootPassId: z.string().min(1),
|
|
444
|
+
});
|
|
445
|
+
export const BudgetLedgerEventSchema = z.object({
|
|
446
|
+
reservationId: z.string().min(1).nullable(),
|
|
447
|
+
scope: z.string().min(1),
|
|
448
|
+
status: z.enum(["denied", "pending", "committed", "rolled_back"]),
|
|
449
|
+
expiresAt: z.string().datetime().nullable(),
|
|
450
|
+
cost: BudgetAmountSchema,
|
|
451
|
+
limit: BudgetAmountSchema,
|
|
452
|
+
committedBefore: BudgetAmountSchema,
|
|
453
|
+
committedAfter: BudgetAmountSchema,
|
|
454
|
+
reservedAfter: BudgetAmountSchema,
|
|
455
|
+
});
|
|
456
|
+
export const LedgerAuditContextSchema = z.object({
|
|
457
|
+
tenant: z.string().min(1).nullable(),
|
|
458
|
+
agentId: z.string().min(1),
|
|
459
|
+
humanOwner: z.string().min(1),
|
|
460
|
+
taskId: z.string().min(1),
|
|
461
|
+
tool: z.string().min(1),
|
|
462
|
+
resource: z.string().min(1),
|
|
463
|
+
});
|
|
217
464
|
export const LedgerRecordSchema = z.object({
|
|
218
465
|
schemaVersion: z.literal(LEDGER_SCHEMA_VERSION),
|
|
219
466
|
id: z.string().min(1),
|
|
220
467
|
occurredAt: z.string().datetime(),
|
|
468
|
+
auditContext: LedgerAuditContextSchema.optional(),
|
|
221
469
|
actionHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
222
470
|
payloadHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
471
|
+
provenanceHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),
|
|
223
472
|
decision: AxtaryDecisionSchema,
|
|
224
473
|
reasons: z.array(z.string().min(1)),
|
|
225
474
|
policy: PolicyDecisionSchema.shape.policy,
|
|
@@ -227,6 +476,8 @@ export const LedgerRecordSchema = z.object({
|
|
|
227
476
|
executionOutcome: LedgerExecutionOutcomeSchema.optional(),
|
|
228
477
|
traceId: z.string().min(1).optional(),
|
|
229
478
|
actionPassId: z.string().min(1).nullable(),
|
|
479
|
+
delegation: DelegationLedgerEdgeSchema.optional(),
|
|
480
|
+
budget: BudgetLedgerEventSchema.optional(),
|
|
230
481
|
correlationId: z.string().min(1).optional(),
|
|
231
482
|
previousLedgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(),
|
|
232
483
|
ledgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
@@ -358,6 +609,7 @@ export function createApprovalArtifact(input) {
|
|
|
358
609
|
id: input.id ?? `approval_${randomUUID()}`,
|
|
359
610
|
mode: input.mode,
|
|
360
611
|
approvedBy: input.approvedBy,
|
|
612
|
+
approverRoles: input.approverRoles,
|
|
361
613
|
approvedAt: (input.approvedAt ?? new Date()).toISOString(),
|
|
362
614
|
actionHash: hashAction(action),
|
|
363
615
|
payloadHash: hashPayload(action.capability.payload),
|
|
@@ -374,6 +626,7 @@ export function createApprovalArtifact(input) {
|
|
|
374
626
|
approval: {
|
|
375
627
|
mode: artifact.mode,
|
|
376
628
|
approvedBy: artifact.approvedBy,
|
|
629
|
+
approverRoles: artifact.approverRoles,
|
|
377
630
|
approvedAt: artifact.approvedAt,
|
|
378
631
|
approvalArtifact: artifact.id,
|
|
379
632
|
approvalArtifactHash: artifactHash,
|
|
@@ -383,6 +636,122 @@ export function createApprovalArtifact(input) {
|
|
|
383
636
|
};
|
|
384
637
|
}
|
|
385
638
|
export async function issueActionPass(input) {
|
|
639
|
+
return issueActionPassProfile(input, ACTIONPASS_V0_VERSION);
|
|
640
|
+
}
|
|
641
|
+
export async function issueActionPassV1(input) {
|
|
642
|
+
const publicJwk = publicOnlyJwk(input.holderPublicJwk);
|
|
643
|
+
const jkt = await calculateJwkThumbprint(publicJwk, "sha256");
|
|
644
|
+
return (await issueActionPassProfile(input, ACTIONPASS_V1_VERSION, jkt));
|
|
645
|
+
}
|
|
646
|
+
export async function issueActionPassV2(input) {
|
|
647
|
+
const publicJwk = publicOnlyJwk(input.holderPublicJwk);
|
|
648
|
+
const jkt = await calculateJwkThumbprint(publicJwk, "sha256");
|
|
649
|
+
return (await issueActionPassProfile(input, ACTIONPASS_V2_VERSION, jkt));
|
|
650
|
+
}
|
|
651
|
+
export async function delegateActionPassV1(input) {
|
|
652
|
+
const delegationNow = input.now ?? input.parentChain.at(-1)?.currentDate ?? new Date();
|
|
653
|
+
const verifiedChain = await verifyDelegationChain({
|
|
654
|
+
chain: input.parentChain,
|
|
655
|
+
currentDate: delegationNow,
|
|
656
|
+
revocation: input.revocation,
|
|
657
|
+
});
|
|
658
|
+
if (!verifiedChain.valid) {
|
|
659
|
+
throw new Error(verifiedChain.reason);
|
|
660
|
+
}
|
|
661
|
+
const parentClaims = verifiedChain.claims.at(-1);
|
|
662
|
+
const parentAction = verifiedChain.actions.at(-1);
|
|
663
|
+
if (!parentClaims || !parentAction) {
|
|
664
|
+
throw new Error("delegation_parent_required");
|
|
665
|
+
}
|
|
666
|
+
const childAction = parseNormalizedAction(input.childAction);
|
|
667
|
+
if (input.tenant !== parentClaims.tenant) {
|
|
668
|
+
throw new Error("delegation_scope_broadened");
|
|
669
|
+
}
|
|
670
|
+
const attenuationError = validateDelegationAttenuation(parentAction, childAction);
|
|
671
|
+
if (attenuationError) {
|
|
672
|
+
throw new Error(attenuationError);
|
|
673
|
+
}
|
|
674
|
+
const depth = parentClaims.delegation
|
|
675
|
+
? parentClaims.delegation.depth + 1
|
|
676
|
+
: 1;
|
|
677
|
+
const rootPassId = parentClaims.delegation?.rootPassId ?? parentClaims.jti;
|
|
678
|
+
const now = delegationNow;
|
|
679
|
+
const requestedTtl = input.expiresInSeconds ?? input.childDecision.constraints.expiresInSeconds;
|
|
680
|
+
const parentRemainingTtl = parentClaims.exp - toNumericDate(now);
|
|
681
|
+
if (parentRemainingTtl < 1) {
|
|
682
|
+
throw new Error("delegation_parent_expired");
|
|
683
|
+
}
|
|
684
|
+
const issued = await issueActionPassV1({
|
|
685
|
+
...input,
|
|
686
|
+
action: childAction,
|
|
687
|
+
decision: input.childDecision,
|
|
688
|
+
holderPublicJwk: input.childHolderPublicJwk,
|
|
689
|
+
parentPassId: parentClaims.jti,
|
|
690
|
+
delegation: {
|
|
691
|
+
depth,
|
|
692
|
+
rootPassId,
|
|
693
|
+
},
|
|
694
|
+
expiresInSeconds: Math.min(requestedTtl, parentRemainingTtl),
|
|
695
|
+
});
|
|
696
|
+
const delegation = DelegationLedgerEdgeSchema.parse({
|
|
697
|
+
parentPassId: parentClaims.jti,
|
|
698
|
+
childPassId: issued.claims.jti,
|
|
699
|
+
depth,
|
|
700
|
+
rootPassId,
|
|
701
|
+
});
|
|
702
|
+
return {
|
|
703
|
+
...issued,
|
|
704
|
+
delegation,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
export async function delegateActionPassV2(input) {
|
|
708
|
+
const delegationNow = input.now ?? input.parentChain.at(-1)?.currentDate ?? new Date();
|
|
709
|
+
const verifiedChain = await verifyDelegationChain({
|
|
710
|
+
chain: input.parentChain,
|
|
711
|
+
currentDate: delegationNow,
|
|
712
|
+
revocation: input.revocation,
|
|
713
|
+
});
|
|
714
|
+
if (!verifiedChain.valid)
|
|
715
|
+
throw new Error(verifiedChain.reason);
|
|
716
|
+
if (verifiedChain.claims.some((claims) => claims.apv !== ACTIONPASS_V2_VERSION)) {
|
|
717
|
+
throw new Error("delegation_requires_actionpass_v2");
|
|
718
|
+
}
|
|
719
|
+
const parentClaims = verifiedChain.claims.at(-1);
|
|
720
|
+
const parentAction = verifiedChain.actions.at(-1);
|
|
721
|
+
const childAction = parseNormalizedAction(input.childAction);
|
|
722
|
+
if (input.tenant !== parentClaims.tenant) {
|
|
723
|
+
throw new Error("delegation_scope_broadened");
|
|
724
|
+
}
|
|
725
|
+
const attenuationError = validateDelegationAttenuation(parentAction, childAction);
|
|
726
|
+
if (attenuationError)
|
|
727
|
+
throw new Error(attenuationError);
|
|
728
|
+
const depth = parentClaims.delegation
|
|
729
|
+
? parentClaims.delegation.depth + 1
|
|
730
|
+
: 1;
|
|
731
|
+
const rootPassId = parentClaims.delegation?.rootPassId ?? parentClaims.jti;
|
|
732
|
+
const parentRemainingTtl = parentClaims.exp - toNumericDate(delegationNow);
|
|
733
|
+
if (parentRemainingTtl < 1)
|
|
734
|
+
throw new Error("delegation_parent_expired");
|
|
735
|
+
const requestedTtl = input.expiresInSeconds ?? input.childDecision.constraints.expiresInSeconds;
|
|
736
|
+
const issued = await issueActionPassV2({
|
|
737
|
+
...input,
|
|
738
|
+
action: childAction,
|
|
739
|
+
decision: input.childDecision,
|
|
740
|
+
holderPublicJwk: input.childHolderPublicJwk,
|
|
741
|
+
parentPassId: parentClaims.jti,
|
|
742
|
+
delegation: { depth, rootPassId },
|
|
743
|
+
status: input.childStatus,
|
|
744
|
+
expiresInSeconds: Math.min(requestedTtl, parentRemainingTtl),
|
|
745
|
+
});
|
|
746
|
+
const delegation = DelegationLedgerEdgeSchema.parse({
|
|
747
|
+
parentPassId: parentClaims.jti,
|
|
748
|
+
childPassId: issued.claims.jti,
|
|
749
|
+
depth,
|
|
750
|
+
rootPassId,
|
|
751
|
+
});
|
|
752
|
+
return { ...issued, delegation };
|
|
753
|
+
}
|
|
754
|
+
async function issueActionPassProfile(input, version, holderJkt) {
|
|
386
755
|
const action = parseNormalizedAction(input.action);
|
|
387
756
|
const decision = PolicyDecisionSchema.parse(input.decision);
|
|
388
757
|
if (decision.decision !== "allow") {
|
|
@@ -392,7 +761,7 @@ export async function issueActionPass(input) {
|
|
|
392
761
|
const issuedAt = toNumericDate(now);
|
|
393
762
|
const expiresInSeconds = input.expiresInSeconds ?? decision.constraints.expiresInSeconds;
|
|
394
763
|
const expiresAt = issuedAt + expiresInSeconds;
|
|
395
|
-
const passId = `ap_${randomUUID()}`;
|
|
764
|
+
const passId = input.passId ?? `ap_${randomUUID()}`;
|
|
396
765
|
const actionHash = hashAction(action);
|
|
397
766
|
const payloadHash = hashPayload(action.capability.payload);
|
|
398
767
|
const approval = bindApproval({
|
|
@@ -404,7 +773,7 @@ export async function issueActionPass(input) {
|
|
|
404
773
|
now,
|
|
405
774
|
});
|
|
406
775
|
const claims = ActionPassClaimsSchema.parse({
|
|
407
|
-
apv:
|
|
776
|
+
apv: version,
|
|
408
777
|
iss: input.issuer,
|
|
409
778
|
sub: action.actor.agentId,
|
|
410
779
|
aud: action.capability.resource,
|
|
@@ -425,6 +794,19 @@ export async function issueActionPass(input) {
|
|
|
425
794
|
reasons: decision.reasons,
|
|
426
795
|
policy: decision.policy,
|
|
427
796
|
payloadHash,
|
|
797
|
+
provenanceHash: action.provenance
|
|
798
|
+
? hashProvenance(action.provenance)
|
|
799
|
+
: undefined,
|
|
800
|
+
budget: action.budget,
|
|
801
|
+
cnf: holderJkt ? { jkt: holderJkt } : undefined,
|
|
802
|
+
delegation: (version === ACTIONPASS_V1_VERSION || version === ACTIONPASS_V2_VERSION) &&
|
|
803
|
+
"delegation" in input &&
|
|
804
|
+
input.delegation
|
|
805
|
+
? input.delegation
|
|
806
|
+
: undefined,
|
|
807
|
+
status: version === ACTIONPASS_V2_VERSION && "status" in input
|
|
808
|
+
? input.status
|
|
809
|
+
: undefined,
|
|
428
810
|
approval,
|
|
429
811
|
audit: {
|
|
430
812
|
traceId: input.traceId ?? `trace_${randomUUID()}`,
|
|
@@ -441,7 +823,197 @@ export async function issueActionPass(input) {
|
|
|
441
823
|
.sign(input.signingKey);
|
|
442
824
|
return { token, claims };
|
|
443
825
|
}
|
|
826
|
+
export async function createDpopProof(input) {
|
|
827
|
+
const publicJwk = publicOnlyJwk(input.publicJwk);
|
|
828
|
+
const jkt = await calculateJwkThumbprint(publicJwk, "sha256");
|
|
829
|
+
const claims = DpopProofClaimsSchema.parse({
|
|
830
|
+
htm: normalizeDpopMethod(input.method),
|
|
831
|
+
htu: normalizeDpopUri(input.uri),
|
|
832
|
+
iat: toNumericDate(input.now ?? new Date()),
|
|
833
|
+
jti: input.proofId ?? `dpop_${randomUUID()}`,
|
|
834
|
+
ath: computeDpopAccessTokenHash(input.actionPass),
|
|
835
|
+
nonce: input.nonce,
|
|
836
|
+
});
|
|
837
|
+
const proof = await new SignJWT(claims)
|
|
838
|
+
.setProtectedHeader({
|
|
839
|
+
alg: input.algorithm ?? DEFAULT_SIGNING_ALGORITHM,
|
|
840
|
+
typ: DPOP_PROOF_TYP,
|
|
841
|
+
jwk: publicJwk,
|
|
842
|
+
})
|
|
843
|
+
.sign(input.signingKey);
|
|
844
|
+
return { proof, claims, jkt };
|
|
845
|
+
}
|
|
846
|
+
export class InMemoryDpopReplayStore {
|
|
847
|
+
entries = new Map();
|
|
848
|
+
consume(proofId, expiresAt, currentDate = new Date()) {
|
|
849
|
+
const now = currentDate.getTime();
|
|
850
|
+
for (const [id, expiry] of this.entries) {
|
|
851
|
+
if (expiry <= now) {
|
|
852
|
+
this.entries.delete(id);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (this.entries.has(proofId)) {
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
this.entries.set(proofId, expiresAt.getTime());
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Durable, inter-process replay store for DPoP proof `jti`s. A proof captured
|
|
864
|
+
* before a restart stays rejected after restart, for as long as it remains
|
|
865
|
+
* inside its own time window. Entries live only until their proof expires, so
|
|
866
|
+
* the on-disk set stays bounded to the in-flight window rather than growing
|
|
867
|
+
* unboundedly.
|
|
868
|
+
*
|
|
869
|
+
* This is a single-file local store, not a cross-host distributed one: it
|
|
870
|
+
* defends one machine's verifiers (shared across handlers/processes on that
|
|
871
|
+
* host). Authenticated cross-host distribution is out of scope here.
|
|
872
|
+
*
|
|
873
|
+
* Durability mirrors the local ledger: a `proper-lockfile` lease serializes the
|
|
874
|
+
* read-modify-write so `consume` is atomic across processes, appended lines are
|
|
875
|
+
* `fsync`ed before the lease releases, and the file is compacted in place once
|
|
876
|
+
* expired lines outnumber live ones so it cannot grow without bound.
|
|
877
|
+
*/
|
|
878
|
+
export class FileDpopReplayStore {
|
|
879
|
+
filePath;
|
|
880
|
+
options;
|
|
881
|
+
constructor(filePath, options = {}) {
|
|
882
|
+
this.filePath = filePath;
|
|
883
|
+
this.options = options;
|
|
884
|
+
}
|
|
885
|
+
async consume(proofId, expiresAt, currentDate = new Date()) {
|
|
886
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
887
|
+
const release = await this.acquireLock();
|
|
888
|
+
try {
|
|
889
|
+
const now = currentDate.getTime();
|
|
890
|
+
const { live, deadCount } = await this.readLiveEntries(now);
|
|
891
|
+
if (live.has(proofId)) {
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
live.set(proofId, expiresAt.getTime());
|
|
895
|
+
// Compact when expired tombstones dominate, otherwise append O(1). This
|
|
896
|
+
// keeps the file proportional to the in-flight proof window.
|
|
897
|
+
if (deadCount > live.size) {
|
|
898
|
+
await this.compact(live);
|
|
899
|
+
}
|
|
900
|
+
else {
|
|
901
|
+
await this.append({ i: proofId, e: expiresAt.getTime() });
|
|
902
|
+
}
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
finally {
|
|
906
|
+
await release();
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
async acquireLock() {
|
|
910
|
+
const lockOptions = this.options.lock ?? {};
|
|
911
|
+
let compromisedReason = null;
|
|
912
|
+
const release = await lock(this.filePath, {
|
|
913
|
+
realpath: false,
|
|
914
|
+
lockfilePath: `${this.filePath}.lock`,
|
|
915
|
+
stale: lockOptions.staleMs ?? 10_000,
|
|
916
|
+
update: lockOptions.updateMs ?? 5_000,
|
|
917
|
+
retries: {
|
|
918
|
+
retries: lockOptions.retries ?? 100,
|
|
919
|
+
factor: 1.2,
|
|
920
|
+
minTimeout: lockOptions.minTimeoutMs ?? 10,
|
|
921
|
+
maxTimeout: lockOptions.maxTimeoutMs ?? 100,
|
|
922
|
+
},
|
|
923
|
+
onCompromised: (error) => {
|
|
924
|
+
compromisedReason = error.message;
|
|
925
|
+
},
|
|
926
|
+
});
|
|
927
|
+
if (compromisedReason !== null) {
|
|
928
|
+
await release();
|
|
929
|
+
throw new Error(`dpop_replay_store_lock_compromised:${compromisedReason}`);
|
|
930
|
+
}
|
|
931
|
+
return release;
|
|
932
|
+
}
|
|
933
|
+
async readLiveEntries(now) {
|
|
934
|
+
const live = new Map();
|
|
935
|
+
let deadCount = 0;
|
|
936
|
+
let text;
|
|
937
|
+
try {
|
|
938
|
+
text = await readFile(this.filePath, "utf8");
|
|
939
|
+
}
|
|
940
|
+
catch (error) {
|
|
941
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
942
|
+
return { live, deadCount };
|
|
943
|
+
}
|
|
944
|
+
throw error;
|
|
945
|
+
}
|
|
946
|
+
for (const line of text.split("\n")) {
|
|
947
|
+
const trimmed = line.trim();
|
|
948
|
+
if (!trimmed)
|
|
949
|
+
continue;
|
|
950
|
+
let entry;
|
|
951
|
+
try {
|
|
952
|
+
entry = JSON.parse(trimmed);
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
// A torn final line from a crash mid-append is ignored; the lease
|
|
956
|
+
// holder rewrites a clean file on the next compaction.
|
|
957
|
+
deadCount += 1;
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
if (typeof entry?.i !== "string" ||
|
|
961
|
+
typeof entry?.e !== "number" ||
|
|
962
|
+
entry.e <= now) {
|
|
963
|
+
deadCount += 1;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
// Last write wins for a repeated id; the older line becomes a tombstone.
|
|
967
|
+
if (live.has(entry.i)) {
|
|
968
|
+
deadCount += 1;
|
|
969
|
+
}
|
|
970
|
+
live.set(entry.i, entry.e);
|
|
971
|
+
}
|
|
972
|
+
return { live, deadCount };
|
|
973
|
+
}
|
|
974
|
+
async append(entry) {
|
|
975
|
+
const handle = await open(this.filePath, "a", 0o600);
|
|
976
|
+
try {
|
|
977
|
+
await handle.chmod(0o600);
|
|
978
|
+
await handle.writeFile(`${JSON.stringify(entry)}\n`, "utf8");
|
|
979
|
+
await handle.sync();
|
|
980
|
+
}
|
|
981
|
+
finally {
|
|
982
|
+
await handle.close();
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
async compact(live) {
|
|
986
|
+
const lines = [...live.entries()]
|
|
987
|
+
.map(([i, e]) => `${JSON.stringify({ i, e })}\n`)
|
|
988
|
+
.join("");
|
|
989
|
+
const tmpPath = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
990
|
+
let renamed = false;
|
|
991
|
+
try {
|
|
992
|
+
const handle = await open(tmpPath, "wx", 0o600);
|
|
993
|
+
try {
|
|
994
|
+
await handle.writeFile(lines, "utf8");
|
|
995
|
+
await handle.sync();
|
|
996
|
+
}
|
|
997
|
+
finally {
|
|
998
|
+
await handle.close();
|
|
999
|
+
}
|
|
1000
|
+
await rename(tmpPath, this.filePath);
|
|
1001
|
+
renamed = true;
|
|
1002
|
+
}
|
|
1003
|
+
finally {
|
|
1004
|
+
if (!renamed) {
|
|
1005
|
+
await rm(tmpPath, { force: true });
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
444
1010
|
export async function verifyActionPass(input) {
|
|
1011
|
+
return verifyActionPassInternal(input, true);
|
|
1012
|
+
}
|
|
1013
|
+
export async function verifyActionPassArtifact(input) {
|
|
1014
|
+
return verifyActionPassInternal(input, false);
|
|
1015
|
+
}
|
|
1016
|
+
async function verifyActionPassInternal(input, requireDpop) {
|
|
445
1017
|
try {
|
|
446
1018
|
const action = parseNormalizedAction(input.action);
|
|
447
1019
|
const verificationKey = await resolveVerificationKey(input);
|
|
@@ -461,6 +1033,14 @@ export async function verifyActionPass(input) {
|
|
|
461
1033
|
reason: bindingError,
|
|
462
1034
|
};
|
|
463
1035
|
}
|
|
1036
|
+
if ((claims.apv === ACTIONPASS_V1_VERSION ||
|
|
1037
|
+
claims.apv === ACTIONPASS_V2_VERSION) &&
|
|
1038
|
+
requireDpop) {
|
|
1039
|
+
const dpopError = await validateDpopProof(input, claims);
|
|
1040
|
+
if (dpopError) {
|
|
1041
|
+
return { valid: false, reason: dpopError };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
464
1044
|
const revocationError = await validateRevocation(input, claims);
|
|
465
1045
|
if (revocationError) {
|
|
466
1046
|
return {
|
|
@@ -468,6 +1048,12 @@ export async function verifyActionPass(input) {
|
|
|
468
1048
|
reason: revocationError,
|
|
469
1049
|
};
|
|
470
1050
|
}
|
|
1051
|
+
if (claims.apv === ACTIONPASS_V2_VERSION) {
|
|
1052
|
+
const statusError = await validateDistributedStatus(input, claims);
|
|
1053
|
+
if (statusError) {
|
|
1054
|
+
return { valid: false, reason: statusError };
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
471
1057
|
return {
|
|
472
1058
|
valid: true,
|
|
473
1059
|
claims,
|
|
@@ -481,6 +1067,61 @@ export async function verifyActionPass(input) {
|
|
|
481
1067
|
};
|
|
482
1068
|
}
|
|
483
1069
|
}
|
|
1070
|
+
export async function verifyDelegationChain(input) {
|
|
1071
|
+
if (input.chain.length === 0) {
|
|
1072
|
+
return { valid: false, reason: "delegation_parent_required" };
|
|
1073
|
+
}
|
|
1074
|
+
const claims = [];
|
|
1075
|
+
const actions = [];
|
|
1076
|
+
for (let index = 0; index < input.chain.length; index += 1) {
|
|
1077
|
+
const entry = input.chain[index];
|
|
1078
|
+
const revocation = input.revocation;
|
|
1079
|
+
const verified = await verifyActionPassInternal({
|
|
1080
|
+
...entry,
|
|
1081
|
+
currentDate: input.currentDate ??
|
|
1082
|
+
input.chain.at(-1)?.currentDate ??
|
|
1083
|
+
entry.currentDate,
|
|
1084
|
+
revokedPassIds: revocation?.revokedPassIds ?? entry.revokedPassIds,
|
|
1085
|
+
revocations: revocation?.revocations ?? entry.revocations,
|
|
1086
|
+
isRevoked: revocation?.isRevoked ?? entry.isRevoked,
|
|
1087
|
+
getStatus: revocation?.getStatus ?? entry.getStatus,
|
|
1088
|
+
}, index === input.chain.length - 1);
|
|
1089
|
+
if (!verified.valid) {
|
|
1090
|
+
return verified;
|
|
1091
|
+
}
|
|
1092
|
+
if (verified.claims.apv !== ACTIONPASS_V1_VERSION &&
|
|
1093
|
+
verified.claims.apv !== ACTIONPASS_V2_VERSION) {
|
|
1094
|
+
return { valid: false, reason: "delegation_requires_actionpass_v1" };
|
|
1095
|
+
}
|
|
1096
|
+
const action = parseNormalizedAction(entry.action);
|
|
1097
|
+
const currentClaims = verified.claims;
|
|
1098
|
+
if (index === 0) {
|
|
1099
|
+
if (currentClaims.audit.parentPassId !== null ||
|
|
1100
|
+
currentClaims.delegation !== undefined) {
|
|
1101
|
+
return { valid: false, reason: "delegation_chain_root_invalid" };
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
else {
|
|
1105
|
+
const parentClaims = claims[index - 1];
|
|
1106
|
+
const parentAction = actions[index - 1];
|
|
1107
|
+
const expectedDepth = index;
|
|
1108
|
+
const expectedRootPassId = claims[0].jti;
|
|
1109
|
+
if (currentClaims.apv !== parentClaims.apv ||
|
|
1110
|
+
currentClaims.audit.parentPassId !== parentClaims.jti ||
|
|
1111
|
+
currentClaims.delegation?.depth !== expectedDepth ||
|
|
1112
|
+
currentClaims.delegation.rootPassId !== expectedRootPassId) {
|
|
1113
|
+
return { valid: false, reason: "delegation_chain_link_invalid" };
|
|
1114
|
+
}
|
|
1115
|
+
const attenuationError = validateDelegationAttenuation(parentAction, action);
|
|
1116
|
+
if (attenuationError) {
|
|
1117
|
+
return { valid: false, reason: attenuationError };
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
claims.push(currentClaims);
|
|
1121
|
+
actions.push(action);
|
|
1122
|
+
}
|
|
1123
|
+
return { valid: true, claims, actions };
|
|
1124
|
+
}
|
|
484
1125
|
export function revokeActionPass(input) {
|
|
485
1126
|
return ActionPassRevocationSchema.parse({
|
|
486
1127
|
schemaVersion: ACTIONPASS_REVOCATION_VERSION,
|
|
@@ -571,8 +1212,19 @@ export function recordDecision(input) {
|
|
|
571
1212
|
schemaVersion: LEDGER_SCHEMA_VERSION,
|
|
572
1213
|
id: `ledger_${randomUUID()}`,
|
|
573
1214
|
occurredAt: (input.occurredAt ?? new Date()).toISOString(),
|
|
1215
|
+
auditContext: {
|
|
1216
|
+
tenant: action.actor.tenant ?? null,
|
|
1217
|
+
agentId: action.actor.agentId,
|
|
1218
|
+
humanOwner: action.actor.humanOwner,
|
|
1219
|
+
taskId: action.intent.taskId,
|
|
1220
|
+
tool: action.capability.tool,
|
|
1221
|
+
resource: action.capability.resource,
|
|
1222
|
+
},
|
|
574
1223
|
actionHash,
|
|
575
1224
|
payloadHash,
|
|
1225
|
+
provenanceHash: action.provenance
|
|
1226
|
+
? hashProvenance(action.provenance)
|
|
1227
|
+
: undefined,
|
|
576
1228
|
decision: decision.decision,
|
|
577
1229
|
reasons: decision.reasons,
|
|
578
1230
|
policy: decision.policy,
|
|
@@ -580,6 +1232,12 @@ export function recordDecision(input) {
|
|
|
580
1232
|
executionOutcome: input.executionOutcome,
|
|
581
1233
|
traceId,
|
|
582
1234
|
actionPassId: input.actionPassId ?? null,
|
|
1235
|
+
delegation: input.delegation
|
|
1236
|
+
? DelegationLedgerEdgeSchema.parse(input.delegation)
|
|
1237
|
+
: undefined,
|
|
1238
|
+
budget: input.budget
|
|
1239
|
+
? BudgetLedgerEventSchema.parse(input.budget)
|
|
1240
|
+
: undefined,
|
|
583
1241
|
correlationId: input.correlationId ?? undefined,
|
|
584
1242
|
previousLedgerHash: input.previousLedgerHash ?? null,
|
|
585
1243
|
};
|
|
@@ -588,55 +1246,558 @@ export function recordDecision(input) {
|
|
|
588
1246
|
ledgerHash: hashJson(recordWithoutHash),
|
|
589
1247
|
});
|
|
590
1248
|
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Canonical reason token marking a ledger record as the tamper-evident
|
|
1251
|
+
* revocation event for its pass. Matches the forensics `cascade_containment`
|
|
1252
|
+
* convention (spec §9.5) and the trust-store revocation authority (spec §7).
|
|
1253
|
+
*/
|
|
1254
|
+
export const REVOCATION_LEDGER_REASON = "actionpass_revoked";
|
|
1255
|
+
/**
|
|
1256
|
+
* Build a tamper-evident revocation event for a previously recorded pass. The
|
|
1257
|
+
* event is a `deny` record naming the pass with `actionpass_revoked`, reusing
|
|
1258
|
+
* the revoked record's action/payload hashes, policy, and audit context so the
|
|
1259
|
+
* revocation is bound to the exact action it cancels. It carries no delegation
|
|
1260
|
+
* edge or budget event (those describe the original action, not the
|
|
1261
|
+
* revocation). The durable enforcement authority remains the trust store (§7);
|
|
1262
|
+
* this record is the auditable logbook entry, not the enforcement source.
|
|
1263
|
+
*/
|
|
1264
|
+
export function recordActionPassRevocationEvent(input) {
|
|
1265
|
+
const source = LedgerRecordSchema.parse(input.revokedRecord);
|
|
1266
|
+
if (!source.actionPassId) {
|
|
1267
|
+
throw new Error("revocation_requires_action_pass_id");
|
|
1268
|
+
}
|
|
1269
|
+
const reasons = [REVOCATION_LEDGER_REASON];
|
|
1270
|
+
if (input.revokedBy)
|
|
1271
|
+
reasons.push(`revoked_by:${input.revokedBy}`);
|
|
1272
|
+
if (input.reason)
|
|
1273
|
+
reasons.push(`revocation_reason:${input.reason}`);
|
|
1274
|
+
const recordWithoutHash = {
|
|
1275
|
+
schemaVersion: LEDGER_SCHEMA_VERSION,
|
|
1276
|
+
id: `ledger_${randomUUID()}`,
|
|
1277
|
+
occurredAt: (input.occurredAt ?? new Date()).toISOString(),
|
|
1278
|
+
auditContext: source.auditContext,
|
|
1279
|
+
actionHash: source.actionHash,
|
|
1280
|
+
payloadHash: source.payloadHash,
|
|
1281
|
+
provenanceHash: source.provenanceHash,
|
|
1282
|
+
decision: "deny",
|
|
1283
|
+
reasons,
|
|
1284
|
+
policy: source.policy,
|
|
1285
|
+
traceId: `trace_${randomUUID()}`,
|
|
1286
|
+
actionPassId: source.actionPassId,
|
|
1287
|
+
correlationId: source.actionPassId,
|
|
1288
|
+
previousLedgerHash: input.previousLedgerHash ?? null,
|
|
1289
|
+
};
|
|
1290
|
+
return LedgerRecordSchema.parse({
|
|
1291
|
+
...recordWithoutHash,
|
|
1292
|
+
ledgerHash: hashJson(recordWithoutHash),
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
591
1295
|
export function hashPayload(payload) {
|
|
592
1296
|
return hashJson(JsonValueSchema.parse(payload));
|
|
593
1297
|
}
|
|
1298
|
+
export function hashProvenance(provenance) {
|
|
1299
|
+
return hashJson(ActionProvenanceSchema.parse(provenance));
|
|
1300
|
+
}
|
|
1301
|
+
function validateDelegationAttenuation(parent, child) {
|
|
1302
|
+
const parentRemaining = parent.intent.maxDelegationDepth ?? 0;
|
|
1303
|
+
const childRemaining = child.intent.maxDelegationDepth ?? 0;
|
|
1304
|
+
if (parentRemaining < 1 || childRemaining > parentRemaining - 1) {
|
|
1305
|
+
return "delegation_depth_exceeded";
|
|
1306
|
+
}
|
|
1307
|
+
if (child.actor.humanOwner !== parent.actor.humanOwner ||
|
|
1308
|
+
child.actor.tenant !== parent.actor.tenant ||
|
|
1309
|
+
child.intent.taskId !== parent.intent.taskId ||
|
|
1310
|
+
child.capability.tool !== parent.capability.tool ||
|
|
1311
|
+
child.capability.resource !== parent.capability.resource ||
|
|
1312
|
+
stableStringify(child.toolDefinition) !==
|
|
1313
|
+
stableStringify(parent.toolDefinition) ||
|
|
1314
|
+
!isJsonScopeSubset(child.capability.payload, parent.capability.payload, []) ||
|
|
1315
|
+
!areConstraintsAttenuated(parent.capability.constraints, child.capability.constraints)) {
|
|
1316
|
+
return "delegation_scope_broadened";
|
|
1317
|
+
}
|
|
1318
|
+
return null;
|
|
1319
|
+
}
|
|
1320
|
+
function isJsonScopeSubset(child, parent, path) {
|
|
1321
|
+
if (stableStringify(child) === stableStringify(parent)) {
|
|
1322
|
+
return true;
|
|
1323
|
+
}
|
|
1324
|
+
if (typeof child === "string" && typeof parent === "string") {
|
|
1325
|
+
return isPathLikeKey(path.at(-1)) && isPathInsideScope(child, parent);
|
|
1326
|
+
}
|
|
1327
|
+
if (Array.isArray(child) && Array.isArray(parent)) {
|
|
1328
|
+
return child.every((childEntry) => parent.some((parentEntry) => isJsonScopeSubset(childEntry, parentEntry, path)));
|
|
1329
|
+
}
|
|
1330
|
+
if (isJsonObject(child) && isJsonObject(parent)) {
|
|
1331
|
+
return Object.entries(child).every(([key, value]) => key in parent &&
|
|
1332
|
+
isJsonScopeSubset(value, parent[key], [...path, key]));
|
|
1333
|
+
}
|
|
1334
|
+
return false;
|
|
1335
|
+
}
|
|
1336
|
+
function areConstraintsAttenuated(parent, child) {
|
|
1337
|
+
if (!parent) {
|
|
1338
|
+
return true;
|
|
1339
|
+
}
|
|
1340
|
+
if (!child) {
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
return Object.entries(parent).every(([key, value]) => key in child &&
|
|
1344
|
+
stableStringify(child[key]) === stableStringify(value));
|
|
1345
|
+
}
|
|
1346
|
+
function isPathLikeKey(key) {
|
|
1347
|
+
return Boolean(key && /(path|file|directory|prefix)/i.test(key));
|
|
1348
|
+
}
|
|
1349
|
+
function isPathInsideScope(child, parent) {
|
|
1350
|
+
if (parent.endsWith("/**")) {
|
|
1351
|
+
const prefix = parent.slice(0, -2);
|
|
1352
|
+
return child.startsWith(prefix);
|
|
1353
|
+
}
|
|
1354
|
+
if (parent.endsWith("/")) {
|
|
1355
|
+
return child.startsWith(parent);
|
|
1356
|
+
}
|
|
1357
|
+
return false;
|
|
1358
|
+
}
|
|
594
1359
|
export function hashAction(action) {
|
|
595
1360
|
return hashJson(parseNormalizedAction(action));
|
|
596
1361
|
}
|
|
1362
|
+
export const GITHUB_NATIVE_CONNECTOR_GOVERNANCE = {
|
|
1363
|
+
provider: "github",
|
|
1364
|
+
connector: "github-rest",
|
|
1365
|
+
mode: "rest",
|
|
1366
|
+
configSchema: GitHubNativeConnectorConfigSchema,
|
|
1367
|
+
defaultConfig: DEFAULT_GITHUB_NATIVE_CONNECTOR_CONFIG,
|
|
1368
|
+
requiredScopes: [
|
|
1369
|
+
"contents:write",
|
|
1370
|
+
"metadata:read",
|
|
1371
|
+
"pull_requests:write",
|
|
1372
|
+
"checks:write",
|
|
1373
|
+
"issues:write",
|
|
1374
|
+
],
|
|
1375
|
+
smokeCheck: "GET /user + X-Accepted-GitHub-Permissions",
|
|
1376
|
+
tools: [
|
|
1377
|
+
{
|
|
1378
|
+
capability: {
|
|
1379
|
+
id: "github-pr-create",
|
|
1380
|
+
provider: "github",
|
|
1381
|
+
connector: "github-rest",
|
|
1382
|
+
label: "Create pull request",
|
|
1383
|
+
tool: "github.pull_requests.create",
|
|
1384
|
+
operation: "write",
|
|
1385
|
+
status: "supported",
|
|
1386
|
+
supportedModes: ["fake", "rest", "app"],
|
|
1387
|
+
requiresActionPass: true,
|
|
1388
|
+
defaultPolicy: "step_up",
|
|
1389
|
+
payloadFields: [
|
|
1390
|
+
"title",
|
|
1391
|
+
"baseBranch",
|
|
1392
|
+
"filesChanged",
|
|
1393
|
+
"testsPassed",
|
|
1394
|
+
"touchesProduction",
|
|
1395
|
+
],
|
|
1396
|
+
approvalTriggers: [
|
|
1397
|
+
"protected path",
|
|
1398
|
+
"production impact",
|
|
1399
|
+
"base branch mismatch",
|
|
1400
|
+
"missing tests",
|
|
1401
|
+
"large file set",
|
|
1402
|
+
],
|
|
1403
|
+
credentialBoundary: "GitHub token stays in the proxy/adapter; the agent receives an ActionPass-bound result.",
|
|
1404
|
+
smokeCheck: "GET /user",
|
|
1405
|
+
},
|
|
1406
|
+
normalizeEvidence: (action, payload) => githubPullRequestEvidence(action, payload),
|
|
1407
|
+
},
|
|
1408
|
+
{
|
|
1409
|
+
capability: {
|
|
1410
|
+
id: "github-content-read",
|
|
1411
|
+
provider: "github",
|
|
1412
|
+
connector: "github-rest",
|
|
1413
|
+
label: "Read repository content",
|
|
1414
|
+
tool: "github.contents.read",
|
|
1415
|
+
operation: "read",
|
|
1416
|
+
status: "supported",
|
|
1417
|
+
supportedModes: ["fake", "rest", "app"],
|
|
1418
|
+
requiresActionPass: true,
|
|
1419
|
+
defaultPolicy: "deny_until_scoped",
|
|
1420
|
+
payloadFields: ["path", "ref"],
|
|
1421
|
+
approvalTriggers: ["denied secret or production path"],
|
|
1422
|
+
credentialBoundary: "Repository content is fetched by the adapter after policy checks deny protected paths.",
|
|
1423
|
+
smokeCheck: "GET /user",
|
|
1424
|
+
},
|
|
1425
|
+
normalizeEvidence: (action, payload) => githubContentEvidence(action, payload, "content_read"),
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
capability: {
|
|
1429
|
+
id: "github-branch-create",
|
|
1430
|
+
provider: "github",
|
|
1431
|
+
connector: "github-rest",
|
|
1432
|
+
label: "Create branch",
|
|
1433
|
+
tool: "github.branches.create",
|
|
1434
|
+
operation: "write",
|
|
1435
|
+
status: "supported",
|
|
1436
|
+
supportedModes: ["fake", "rest", "app"],
|
|
1437
|
+
requiresActionPass: true,
|
|
1438
|
+
defaultPolicy: "allow",
|
|
1439
|
+
payloadFields: ["branch", "baseBranch", "sha", "allowExisting"],
|
|
1440
|
+
approvalTriggers: ["base branch mismatch"],
|
|
1441
|
+
credentialBoundary: "Branch creation runs through the proxy and is bound to the same ActionPass verification path.",
|
|
1442
|
+
smokeCheck: "GET /user",
|
|
1443
|
+
},
|
|
1444
|
+
normalizeEvidence: (action, payload) => githubBranchEvidence(action, payload),
|
|
1445
|
+
},
|
|
1446
|
+
{
|
|
1447
|
+
capability: {
|
|
1448
|
+
id: "github-content-write",
|
|
1449
|
+
provider: "github",
|
|
1450
|
+
connector: "github-rest",
|
|
1451
|
+
label: "Write repository content",
|
|
1452
|
+
tool: "github.contents.write",
|
|
1453
|
+
operation: "write",
|
|
1454
|
+
status: "supported",
|
|
1455
|
+
supportedModes: ["fake", "rest", "app"],
|
|
1456
|
+
requiresActionPass: true,
|
|
1457
|
+
defaultPolicy: "step_up",
|
|
1458
|
+
payloadFields: ["path", "branch", "message", "sha", "content"],
|
|
1459
|
+
approvalTriggers: ["protected path"],
|
|
1460
|
+
credentialBoundary: "File content is never written until policy and ActionPass verification both pass.",
|
|
1461
|
+
smokeCheck: "GET /user",
|
|
1462
|
+
},
|
|
1463
|
+
normalizeEvidence: (action, payload) => githubContentEvidence(action, payload, "content_write"),
|
|
1464
|
+
},
|
|
1465
|
+
{
|
|
1466
|
+
capability: {
|
|
1467
|
+
id: "github-review-comment-create",
|
|
1468
|
+
provider: "github",
|
|
1469
|
+
connector: "github-rest",
|
|
1470
|
+
label: "Create inline review comment",
|
|
1471
|
+
tool: "github.pull_request_review_comments.create",
|
|
1472
|
+
operation: "write",
|
|
1473
|
+
status: "supported",
|
|
1474
|
+
supportedModes: ["app"],
|
|
1475
|
+
requiresActionPass: true,
|
|
1476
|
+
defaultPolicy: "step_up",
|
|
1477
|
+
payloadFields: [
|
|
1478
|
+
"pullNumber",
|
|
1479
|
+
"body",
|
|
1480
|
+
"commitId",
|
|
1481
|
+
"path",
|
|
1482
|
+
"line",
|
|
1483
|
+
"side",
|
|
1484
|
+
],
|
|
1485
|
+
approvalTriggers: ["protected path review"],
|
|
1486
|
+
credentialBoundary: "The exact inline comment target and body are authorized before GitHub receives the write.",
|
|
1487
|
+
smokeCheck: "GET /user + Pull requests: write",
|
|
1488
|
+
},
|
|
1489
|
+
normalizeEvidence: (action, payload) => githubReviewCommentEvidence(action, payload),
|
|
1490
|
+
},
|
|
1491
|
+
{
|
|
1492
|
+
capability: {
|
|
1493
|
+
id: "github-check-run-create",
|
|
1494
|
+
provider: "github",
|
|
1495
|
+
connector: "github-rest",
|
|
1496
|
+
label: "Create check run",
|
|
1497
|
+
tool: "github.check_runs.create",
|
|
1498
|
+
operation: "write",
|
|
1499
|
+
status: "supported",
|
|
1500
|
+
supportedModes: ["rest", "app"],
|
|
1501
|
+
requiresActionPass: true,
|
|
1502
|
+
defaultPolicy: "allow",
|
|
1503
|
+
payloadFields: [
|
|
1504
|
+
"name",
|
|
1505
|
+
"headSha",
|
|
1506
|
+
"status",
|
|
1507
|
+
"conclusion",
|
|
1508
|
+
"output",
|
|
1509
|
+
],
|
|
1510
|
+
approvalTriggers: [],
|
|
1511
|
+
credentialBoundary: "Check name, commit SHA, status, conclusion, and output are payload-bound before execution.",
|
|
1512
|
+
smokeCheck: "GET /user + Checks: write",
|
|
1513
|
+
},
|
|
1514
|
+
normalizeEvidence: (action, payload) => githubCheckRunEvidence(action, payload),
|
|
1515
|
+
},
|
|
1516
|
+
{
|
|
1517
|
+
capability: {
|
|
1518
|
+
id: "github-issue-comment-create",
|
|
1519
|
+
provider: "github",
|
|
1520
|
+
connector: "github-rest",
|
|
1521
|
+
label: "Create issue comment",
|
|
1522
|
+
tool: "github.issue_comments.create",
|
|
1523
|
+
operation: "external_message",
|
|
1524
|
+
status: "supported",
|
|
1525
|
+
supportedModes: ["rest", "app"],
|
|
1526
|
+
requiresActionPass: true,
|
|
1527
|
+
defaultPolicy: "allow",
|
|
1528
|
+
payloadFields: ["issueNumber", "body"],
|
|
1529
|
+
approvalTriggers: [],
|
|
1530
|
+
credentialBoundary: "The exact issue number and outbound comment body are bound into the ActionPass.",
|
|
1531
|
+
smokeCheck: "GET /user + Issues: write",
|
|
1532
|
+
},
|
|
1533
|
+
normalizeEvidence: (action, payload) => githubIssueCommentEvidence(action, payload),
|
|
1534
|
+
},
|
|
1535
|
+
],
|
|
1536
|
+
};
|
|
1537
|
+
export const LINEAR_NATIVE_CONNECTOR_GOVERNANCE = {
|
|
1538
|
+
provider: "linear",
|
|
1539
|
+
connector: "linear-graphql",
|
|
1540
|
+
mode: "graphql",
|
|
1541
|
+
configSchema: LinearNativeConnectorConfigSchema,
|
|
1542
|
+
defaultConfig: DEFAULT_LINEAR_NATIVE_CONNECTOR_CONFIG,
|
|
1543
|
+
requiredScopes: ["read", "write"],
|
|
1544
|
+
smokeCheck: "viewer",
|
|
1545
|
+
tools: [
|
|
1546
|
+
{
|
|
1547
|
+
capability: {
|
|
1548
|
+
id: "linear-issue-read",
|
|
1549
|
+
provider: "linear",
|
|
1550
|
+
connector: "linear-graphql",
|
|
1551
|
+
label: "Read issue",
|
|
1552
|
+
tool: "linear.issues.read",
|
|
1553
|
+
operation: "read",
|
|
1554
|
+
status: "supported",
|
|
1555
|
+
supportedModes: ["fake", "graphql"],
|
|
1556
|
+
requiresActionPass: true,
|
|
1557
|
+
defaultPolicy: "allow",
|
|
1558
|
+
payloadFields: ["issueKey", "projectKey"],
|
|
1559
|
+
approvalTriggers: [],
|
|
1560
|
+
credentialBoundary: "Linear API key stays adapter-side; policy pins issue access by project key.",
|
|
1561
|
+
smokeCheck: "viewer",
|
|
1562
|
+
},
|
|
1563
|
+
normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "linear", "issue_read"),
|
|
1564
|
+
},
|
|
1565
|
+
{
|
|
1566
|
+
capability: {
|
|
1567
|
+
id: "linear-comment-create",
|
|
1568
|
+
provider: "linear",
|
|
1569
|
+
connector: "linear-graphql",
|
|
1570
|
+
label: "Create issue comment",
|
|
1571
|
+
tool: "linear.comments.create",
|
|
1572
|
+
operation: "write",
|
|
1573
|
+
status: "supported",
|
|
1574
|
+
supportedModes: ["fake", "graphql"],
|
|
1575
|
+
requiresActionPass: true,
|
|
1576
|
+
defaultPolicy: "allow",
|
|
1577
|
+
payloadFields: ["issueKey", "projectKey", "body"],
|
|
1578
|
+
approvalTriggers: [],
|
|
1579
|
+
credentialBoundary: "Linear comments execute only through proxy policy and ActionPass verification.",
|
|
1580
|
+
smokeCheck: "viewer",
|
|
1581
|
+
},
|
|
1582
|
+
normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "linear", "comment_create"),
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
capability: {
|
|
1586
|
+
id: "linear-issue-update",
|
|
1587
|
+
provider: "linear",
|
|
1588
|
+
connector: "linear-graphql",
|
|
1589
|
+
label: "Update issue",
|
|
1590
|
+
tool: "linear.issues.update",
|
|
1591
|
+
operation: "write",
|
|
1592
|
+
status: "supported",
|
|
1593
|
+
supportedModes: ["fake", "graphql"],
|
|
1594
|
+
requiresActionPass: true,
|
|
1595
|
+
defaultPolicy: "step_up",
|
|
1596
|
+
payloadFields: [
|
|
1597
|
+
"issueKey",
|
|
1598
|
+
"projectKey",
|
|
1599
|
+
"status",
|
|
1600
|
+
"title",
|
|
1601
|
+
"description",
|
|
1602
|
+
"priority",
|
|
1603
|
+
],
|
|
1604
|
+
approvalTriggers: ["protected status"],
|
|
1605
|
+
credentialBoundary: "Issue mutations stay behind adapter credentials and project/status policy checks.",
|
|
1606
|
+
smokeCheck: "viewer",
|
|
1607
|
+
},
|
|
1608
|
+
normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "linear", "issue_update"),
|
|
1609
|
+
},
|
|
1610
|
+
],
|
|
1611
|
+
};
|
|
1612
|
+
export const JIRA_NATIVE_CONNECTOR_GOVERNANCE = {
|
|
1613
|
+
provider: "jira",
|
|
1614
|
+
connector: "jira-rest",
|
|
1615
|
+
mode: "rest",
|
|
1616
|
+
configSchema: JiraNativeConnectorConfigSchema,
|
|
1617
|
+
defaultConfig: DEFAULT_JIRA_NATIVE_CONNECTOR_CONFIG,
|
|
1618
|
+
requiredScopes: ["read:jira-work", "write:jira-work"],
|
|
1619
|
+
smokeCheck: "GET /myself",
|
|
1620
|
+
tools: [
|
|
1621
|
+
{
|
|
1622
|
+
capability: {
|
|
1623
|
+
id: "jira-issue-read",
|
|
1624
|
+
provider: "jira",
|
|
1625
|
+
connector: "jira-rest",
|
|
1626
|
+
label: "Read issue",
|
|
1627
|
+
tool: "jira.issues.read",
|
|
1628
|
+
operation: "read",
|
|
1629
|
+
status: "supported",
|
|
1630
|
+
supportedModes: ["fake", "rest"],
|
|
1631
|
+
requiresActionPass: true,
|
|
1632
|
+
defaultPolicy: "allow",
|
|
1633
|
+
payloadFields: ["issueKey", "projectKey"],
|
|
1634
|
+
approvalTriggers: [],
|
|
1635
|
+
credentialBoundary: "Jira credentials stay adapter-side; policy pins issue access by project key.",
|
|
1636
|
+
smokeCheck: "GET /myself",
|
|
1637
|
+
},
|
|
1638
|
+
normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "jira", "issue_read"),
|
|
1639
|
+
},
|
|
1640
|
+
{
|
|
1641
|
+
capability: {
|
|
1642
|
+
id: "jira-comment-create",
|
|
1643
|
+
provider: "jira",
|
|
1644
|
+
connector: "jira-rest",
|
|
1645
|
+
label: "Create issue comment",
|
|
1646
|
+
tool: "jira.comments.create",
|
|
1647
|
+
operation: "write",
|
|
1648
|
+
status: "supported",
|
|
1649
|
+
supportedModes: ["fake", "rest"],
|
|
1650
|
+
requiresActionPass: true,
|
|
1651
|
+
defaultPolicy: "allow",
|
|
1652
|
+
payloadFields: ["issueKey", "projectKey", "body"],
|
|
1653
|
+
approvalTriggers: [],
|
|
1654
|
+
credentialBoundary: "Jira comments are converted to Atlassian Document Format only after policy and ActionPass verification.",
|
|
1655
|
+
smokeCheck: "GET /myself",
|
|
1656
|
+
},
|
|
1657
|
+
normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "jira", "comment_create"),
|
|
1658
|
+
},
|
|
1659
|
+
{
|
|
1660
|
+
capability: {
|
|
1661
|
+
id: "jira-issue-update",
|
|
1662
|
+
provider: "jira",
|
|
1663
|
+
connector: "jira-rest",
|
|
1664
|
+
label: "Update issue",
|
|
1665
|
+
tool: "jira.issues.update",
|
|
1666
|
+
operation: "write",
|
|
1667
|
+
status: "supported",
|
|
1668
|
+
supportedModes: ["fake", "rest"],
|
|
1669
|
+
requiresActionPass: true,
|
|
1670
|
+
defaultPolicy: "step_up",
|
|
1671
|
+
payloadFields: [
|
|
1672
|
+
"issueKey",
|
|
1673
|
+
"projectKey",
|
|
1674
|
+
"status",
|
|
1675
|
+
"title",
|
|
1676
|
+
"description",
|
|
1677
|
+
"priorityId",
|
|
1678
|
+
],
|
|
1679
|
+
approvalTriggers: ["protected status"],
|
|
1680
|
+
credentialBoundary: "Jira field edits and workflow transitions execute only after exact-payload authorization.",
|
|
1681
|
+
smokeCheck: "GET /myself",
|
|
1682
|
+
},
|
|
1683
|
+
normalizeEvidence: (action, payload) => issueTrackerEvidence(action, payload, "jira", "issue_update"),
|
|
1684
|
+
},
|
|
1685
|
+
],
|
|
1686
|
+
};
|
|
1687
|
+
export const POSTGRES_NATIVE_CONNECTOR_GOVERNANCE = {
|
|
1688
|
+
provider: "postgres",
|
|
1689
|
+
connector: "postgres-native",
|
|
1690
|
+
mode: "postgres",
|
|
1691
|
+
configSchema: PostgresNativeConnectorConfigSchema,
|
|
1692
|
+
defaultConfig: DEFAULT_POSTGRES_NATIVE_CONNECTOR_CONFIG,
|
|
1693
|
+
requiredScopes: [
|
|
1694
|
+
"LOGIN role",
|
|
1695
|
+
"SELECT on allowed tables",
|
|
1696
|
+
"NOBYPASSRLS",
|
|
1697
|
+
"read-only transactions",
|
|
1698
|
+
],
|
|
1699
|
+
smokeCheck: "SELECT current_database/current_user + transaction_read_only + table RLS metadata",
|
|
1700
|
+
tools: [
|
|
1701
|
+
{
|
|
1702
|
+
capability: {
|
|
1703
|
+
id: "postgres-query-select",
|
|
1704
|
+
provider: "postgres",
|
|
1705
|
+
connector: "postgres-native",
|
|
1706
|
+
label: "Run scoped SELECT",
|
|
1707
|
+
tool: "postgres.query.select",
|
|
1708
|
+
operation: "read",
|
|
1709
|
+
status: "supported",
|
|
1710
|
+
supportedModes: ["postgres"],
|
|
1711
|
+
requiresActionPass: true,
|
|
1712
|
+
defaultPolicy: "deny_until_scoped",
|
|
1713
|
+
payloadFields: ["statement", "parameters", "maxRows"],
|
|
1714
|
+
approvalTriggers: ["unpredicated table scan"],
|
|
1715
|
+
credentialBoundary: "The DSN stays in the local adapter; only the exact parameterized SELECT and bounded result cross the authorization boundary.",
|
|
1716
|
+
smokeCheck: "current role/database, read-only state, and RLS metadata for configured tables",
|
|
1717
|
+
},
|
|
1718
|
+
normalizeEvidence: (action, payload) => postgresSelectEvidence(action, payload),
|
|
1719
|
+
},
|
|
1720
|
+
],
|
|
1721
|
+
};
|
|
1722
|
+
export const DRIVE_NATIVE_CONNECTOR_GOVERNANCE = {
|
|
1723
|
+
provider: "drive",
|
|
1724
|
+
connector: "google-drive-rest",
|
|
1725
|
+
mode: "rest",
|
|
1726
|
+
configSchema: DriveNativeConnectorConfigSchema,
|
|
1727
|
+
defaultConfig: DEFAULT_DRIVE_NATIVE_CONNECTOR_CONFIG,
|
|
1728
|
+
requiredScopes: ["https://www.googleapis.com/auth/drive.file"],
|
|
1729
|
+
smokeCheck: "GET selected file metadata including isAppAuthorized (no content)",
|
|
1730
|
+
tools: [
|
|
1731
|
+
{
|
|
1732
|
+
capability: {
|
|
1733
|
+
id: "drive-file-read",
|
|
1734
|
+
provider: "drive",
|
|
1735
|
+
connector: "google-drive-rest",
|
|
1736
|
+
label: "Read selected Drive file",
|
|
1737
|
+
tool: "drive.files.read",
|
|
1738
|
+
operation: "read",
|
|
1739
|
+
status: "supported",
|
|
1740
|
+
supportedModes: ["rest"],
|
|
1741
|
+
requiresActionPass: true,
|
|
1742
|
+
defaultPolicy: "deny_until_scoped",
|
|
1743
|
+
payloadFields: ["fileId", "maxBytes"],
|
|
1744
|
+
approvalTriggers: [
|
|
1745
|
+
"file not policy-scoped",
|
|
1746
|
+
"file not selected through Google Picker",
|
|
1747
|
+
"byte limit exceeded",
|
|
1748
|
+
],
|
|
1749
|
+
credentialBoundary: "The OAuth token and file content stay in the local enforcement plane; only a Picker-authorized, policy-scoped file can be returned.",
|
|
1750
|
+
smokeCheck: "selected-file metadata and provider isAppAuthorized state",
|
|
1751
|
+
},
|
|
1752
|
+
normalizeEvidence: (action, payload) => driveFileReadEvidence(action, payload),
|
|
1753
|
+
},
|
|
1754
|
+
],
|
|
1755
|
+
};
|
|
1756
|
+
export const NATIVE_CONNECTOR_GOVERNANCE_REGISTRY = [
|
|
1757
|
+
GITHUB_NATIVE_CONNECTOR_GOVERNANCE,
|
|
1758
|
+
LINEAR_NATIVE_CONNECTOR_GOVERNANCE,
|
|
1759
|
+
JIRA_NATIVE_CONNECTOR_GOVERNANCE,
|
|
1760
|
+
POSTGRES_NATIVE_CONNECTOR_GOVERNANCE,
|
|
1761
|
+
DRIVE_NATIVE_CONNECTOR_GOVERNANCE,
|
|
1762
|
+
];
|
|
1763
|
+
const providerEvidenceNormalizers = new Map([
|
|
1764
|
+
[
|
|
1765
|
+
"slack.chat.postMessage",
|
|
1766
|
+
(action, payload) => slackMessageEvidence(action, payload),
|
|
1767
|
+
],
|
|
1768
|
+
[
|
|
1769
|
+
"docs.documents.search",
|
|
1770
|
+
(action, payload) => docsEvidence(action, payload, "document_search"),
|
|
1771
|
+
],
|
|
1772
|
+
[
|
|
1773
|
+
"docs.documents.read",
|
|
1774
|
+
(action, payload) => docsEvidence(action, payload, "document_read"),
|
|
1775
|
+
],
|
|
1776
|
+
["mcp.tool.call", (action, payload) => mcpEvidence(action, payload)],
|
|
1777
|
+
[
|
|
1778
|
+
"aws.identity.get",
|
|
1779
|
+
(action, payload) => awsEvidence(action, payload, "identity_get"),
|
|
1780
|
+
],
|
|
1781
|
+
[
|
|
1782
|
+
"aws.s3.objects.list",
|
|
1783
|
+
(action, payload) => awsEvidence(action, payload, "s3_objects_list"),
|
|
1784
|
+
],
|
|
1785
|
+
[
|
|
1786
|
+
"gcp.projects.get",
|
|
1787
|
+
(action, payload) => gcpEvidence(action, payload, "project_get"),
|
|
1788
|
+
],
|
|
1789
|
+
[
|
|
1790
|
+
"gcp.storage.objects.list",
|
|
1791
|
+
(action, payload) => gcpEvidence(action, payload, "storage_objects_list"),
|
|
1792
|
+
],
|
|
1793
|
+
...NATIVE_CONNECTOR_GOVERNANCE_REGISTRY.flatMap((descriptor) => descriptor.tools.map((tool) => [
|
|
1794
|
+
tool.capability.tool,
|
|
1795
|
+
tool.normalizeEvidence,
|
|
1796
|
+
])),
|
|
1797
|
+
]);
|
|
597
1798
|
export function providerEvidenceForAction(actionInput) {
|
|
598
1799
|
const action = parseNormalizedAction(actionInput);
|
|
599
|
-
|
|
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
|
-
}
|
|
1800
|
+
return providerEvidenceNormalizers.get(action.capability.tool)?.(action, action.capability.payload);
|
|
640
1801
|
}
|
|
641
1802
|
export function hashJson(value) {
|
|
642
1803
|
return `sha256:${createHash("sha256")
|
|
@@ -659,15 +1820,33 @@ function validatePassBinding(claims, action, payloadHash) {
|
|
|
659
1820
|
if (claims.intent.taskId !== action.intent.taskId) {
|
|
660
1821
|
return "task_id_mismatch";
|
|
661
1822
|
}
|
|
1823
|
+
if (stableStringify(claims.intent) !== stableStringify(action.intent)) {
|
|
1824
|
+
return "intent_mismatch";
|
|
1825
|
+
}
|
|
662
1826
|
if (claims.capability.tool !== action.capability.tool) {
|
|
663
1827
|
return "tool_mismatch";
|
|
664
1828
|
}
|
|
665
1829
|
if (claims.capability.resource !== action.capability.resource) {
|
|
666
1830
|
return "resource_mismatch";
|
|
667
1831
|
}
|
|
1832
|
+
if (stableStringify(claims.capability.constraints) !==
|
|
1833
|
+
stableStringify(action.capability.constraints)) {
|
|
1834
|
+
return "constraints_mismatch";
|
|
1835
|
+
}
|
|
1836
|
+
if (stableStringify(claims.budget) !== stableStringify(action.budget)) {
|
|
1837
|
+
return "budget_mismatch";
|
|
1838
|
+
}
|
|
668
1839
|
if (claims.payloadHash !== payloadHash) {
|
|
669
1840
|
return "payload_hash_mismatch";
|
|
670
1841
|
}
|
|
1842
|
+
const provenanceHash = action.provenance
|
|
1843
|
+
? hashProvenance(action.provenance)
|
|
1844
|
+
: undefined;
|
|
1845
|
+
if (claims.provenanceHash !== provenanceHash) {
|
|
1846
|
+
return action.provenance
|
|
1847
|
+
? "provenance_hash_mismatch"
|
|
1848
|
+
: "unexpected_provenance_hash";
|
|
1849
|
+
}
|
|
671
1850
|
if (claims.approval?.payloadHash && claims.approval.payloadHash !== payloadHash) {
|
|
672
1851
|
return "approval_payload_hash_mismatch";
|
|
673
1852
|
}
|
|
@@ -676,6 +1855,100 @@ function validatePassBinding(claims, action, payloadHash) {
|
|
|
676
1855
|
}
|
|
677
1856
|
return null;
|
|
678
1857
|
}
|
|
1858
|
+
async function validateDpopProof(input, claims) {
|
|
1859
|
+
if (!input.dpopProof)
|
|
1860
|
+
return "dpop_proof_required";
|
|
1861
|
+
if (!input.dpopMethod)
|
|
1862
|
+
return "dpop_method_required";
|
|
1863
|
+
if (!input.dpopUri)
|
|
1864
|
+
return "dpop_uri_required";
|
|
1865
|
+
try {
|
|
1866
|
+
const header = decodeProtectedHeader(input.dpopProof);
|
|
1867
|
+
if (header.typ !== DPOP_PROOF_TYP)
|
|
1868
|
+
return "dpop_type_mismatch";
|
|
1869
|
+
if (!header.jwk || typeof header.jwk !== "object") {
|
|
1870
|
+
return "dpop_public_jwk_required";
|
|
1871
|
+
}
|
|
1872
|
+
const proofJwk = publicOnlyJwk(header.jwk);
|
|
1873
|
+
const algorithm = header.alg;
|
|
1874
|
+
const algorithms = input.dpopAlgorithms ?? [DEFAULT_SIGNING_ALGORITHM];
|
|
1875
|
+
if (!algorithms.includes(algorithm))
|
|
1876
|
+
return "dpop_algorithm_not_allowed";
|
|
1877
|
+
const key = await importJWK(proofJwk, algorithm);
|
|
1878
|
+
const { payload } = await jwtVerify(input.dpopProof, key, {
|
|
1879
|
+
algorithms,
|
|
1880
|
+
currentDate: input.currentDate,
|
|
1881
|
+
});
|
|
1882
|
+
const proof = DpopProofClaimsSchema.parse(payload);
|
|
1883
|
+
const proofJkt = await calculateJwkThumbprint(proofJwk, "sha256");
|
|
1884
|
+
if (proofJkt !== claims.cnf.jkt)
|
|
1885
|
+
return "dpop_key_mismatch";
|
|
1886
|
+
if (proof.htm !== normalizeDpopMethod(input.dpopMethod)) {
|
|
1887
|
+
return "dpop_method_mismatch";
|
|
1888
|
+
}
|
|
1889
|
+
if (proof.htu !== normalizeDpopUri(input.dpopUri)) {
|
|
1890
|
+
return "dpop_uri_mismatch";
|
|
1891
|
+
}
|
|
1892
|
+
if (proof.ath !== computeDpopAccessTokenHash(input.token)) {
|
|
1893
|
+
return "dpop_actionpass_hash_mismatch";
|
|
1894
|
+
}
|
|
1895
|
+
if (input.dpopNonce !== undefined && proof.nonce !== input.dpopNonce) {
|
|
1896
|
+
return "dpop_nonce_mismatch";
|
|
1897
|
+
}
|
|
1898
|
+
const nowSeconds = toNumericDate(input.currentDate ?? new Date());
|
|
1899
|
+
const maxAge = input.dpopMaxAgeSeconds ?? DEFAULT_DPOP_MAX_AGE_SECONDS;
|
|
1900
|
+
const skew = input.dpopClockSkewSeconds ?? DEFAULT_DPOP_CLOCK_SKEW_SECONDS;
|
|
1901
|
+
if (proof.iat > nowSeconds + skew)
|
|
1902
|
+
return "dpop_proof_from_future";
|
|
1903
|
+
if (proof.iat < nowSeconds - maxAge - skew)
|
|
1904
|
+
return "dpop_proof_stale";
|
|
1905
|
+
if (!input.dpopReplayStore)
|
|
1906
|
+
return "dpop_replay_store_required";
|
|
1907
|
+
const accepted = await input.dpopReplayStore.consume(proof.jti, new Date((proof.iat + maxAge + skew) * 1000), input.currentDate);
|
|
1908
|
+
if (!accepted)
|
|
1909
|
+
return "dpop_replay_detected";
|
|
1910
|
+
return null;
|
|
1911
|
+
}
|
|
1912
|
+
catch (error) {
|
|
1913
|
+
return error instanceof Error && error.message.startsWith("dpop_")
|
|
1914
|
+
? error.message
|
|
1915
|
+
: "dpop_proof_invalid";
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
export function computeDpopAccessTokenHash(token) {
|
|
1919
|
+
return createHash("sha256").update(token, "ascii").digest("base64url");
|
|
1920
|
+
}
|
|
1921
|
+
export function normalizeDpopMethod(method) {
|
|
1922
|
+
const normalized = method.trim().toUpperCase();
|
|
1923
|
+
if (!normalized)
|
|
1924
|
+
throw new Error("dpop_method_required");
|
|
1925
|
+
return normalized;
|
|
1926
|
+
}
|
|
1927
|
+
export function normalizeDpopUri(uri) {
|
|
1928
|
+
const parsed = new URL(uri);
|
|
1929
|
+
parsed.search = "";
|
|
1930
|
+
parsed.hash = "";
|
|
1931
|
+
return parsed.toString();
|
|
1932
|
+
}
|
|
1933
|
+
export function actionPassDpopTarget(actionInput) {
|
|
1934
|
+
const action = parseNormalizedAction(actionInput);
|
|
1935
|
+
return {
|
|
1936
|
+
method: "POST",
|
|
1937
|
+
uri: `https://local.axtary.dev/actions/${encodeURIComponent(action.capability.tool)}`,
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
function publicOnlyJwk(jwk) {
|
|
1941
|
+
if (!jwk.kty || jwk.kty === "oct") {
|
|
1942
|
+
throw new Error("dpop_public_jwk_invalid");
|
|
1943
|
+
}
|
|
1944
|
+
const publicJwk = { ...jwk };
|
|
1945
|
+
for (const member of ["d", "p", "q", "dp", "dq", "qi", "oth", "k"]) {
|
|
1946
|
+
if (member in publicJwk) {
|
|
1947
|
+
throw new Error("dpop_public_jwk_invalid");
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
return publicJwk;
|
|
1951
|
+
}
|
|
679
1952
|
async function readTrustStore(filePath) {
|
|
680
1953
|
const text = await readTrustStoreFile(filePath);
|
|
681
1954
|
if (!text.trim()) {
|
|
@@ -691,6 +1964,7 @@ async function writeTrustStore(filePath, data) {
|
|
|
691
1964
|
await writeFile(tmpPath, `${JSON.stringify(data, null, 2)}\n`, {
|
|
692
1965
|
encoding: "utf8",
|
|
693
1966
|
flag: "wx",
|
|
1967
|
+
mode: 0o600,
|
|
694
1968
|
});
|
|
695
1969
|
await rename(tmpPath, filePath);
|
|
696
1970
|
}
|
|
@@ -752,7 +2026,13 @@ async function validateRevocation(input, claims) {
|
|
|
752
2026
|
}
|
|
753
2027
|
}
|
|
754
2028
|
if (input.isRevoked) {
|
|
755
|
-
|
|
2029
|
+
let result;
|
|
2030
|
+
try {
|
|
2031
|
+
result = await input.isRevoked(claims);
|
|
2032
|
+
}
|
|
2033
|
+
catch {
|
|
2034
|
+
return "actionpass_revocation_check_failed";
|
|
2035
|
+
}
|
|
756
2036
|
if (result === true) {
|
|
757
2037
|
return "actionpass_revoked";
|
|
758
2038
|
}
|
|
@@ -762,6 +2042,27 @@ async function validateRevocation(input, claims) {
|
|
|
762
2042
|
}
|
|
763
2043
|
return null;
|
|
764
2044
|
}
|
|
2045
|
+
async function validateDistributedStatus(input, claims) {
|
|
2046
|
+
if (!input.getStatus) {
|
|
2047
|
+
return "actionpass_status_check_required";
|
|
2048
|
+
}
|
|
2049
|
+
let status;
|
|
2050
|
+
try {
|
|
2051
|
+
status = await input.getStatus(claims);
|
|
2052
|
+
}
|
|
2053
|
+
catch {
|
|
2054
|
+
return "actionpass_status_check_failed";
|
|
2055
|
+
}
|
|
2056
|
+
if (status === 0)
|
|
2057
|
+
return null;
|
|
2058
|
+
if (status === 1)
|
|
2059
|
+
return "actionpass_revoked";
|
|
2060
|
+
if (status === 2)
|
|
2061
|
+
return "actionpass_suspended";
|
|
2062
|
+
if (typeof status === "string" && status.length > 0)
|
|
2063
|
+
return status;
|
|
2064
|
+
return "actionpass_status_invalid";
|
|
2065
|
+
}
|
|
765
2066
|
function bindApproval(input) {
|
|
766
2067
|
const approval = input.approval
|
|
767
2068
|
? ApprovalSchema.parse(input.approval)
|
|
@@ -803,6 +2104,7 @@ function bindApproval(input) {
|
|
|
803
2104
|
return ApprovalSchema.parse({
|
|
804
2105
|
mode: approval?.mode ?? artifact.mode,
|
|
805
2106
|
approvedBy: approval?.approvedBy ?? artifact.approvedBy,
|
|
2107
|
+
approverRoles: approval?.approverRoles ?? artifact.approverRoles,
|
|
806
2108
|
approvedAt: approval?.approvedAt ?? artifact.approvedAt,
|
|
807
2109
|
approvalArtifact: approval?.approvalArtifact ?? artifact.id,
|
|
808
2110
|
approvalArtifactHash: artifactHash,
|
|
@@ -813,12 +2115,16 @@ function bindApproval(input) {
|
|
|
813
2115
|
export function validateApprovalArtifact(artifactInput, actionInput, now = new Date()) {
|
|
814
2116
|
const artifact = ApprovalArtifactSchema.parse(artifactInput);
|
|
815
2117
|
const action = parseNormalizedAction(actionInput);
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
2118
|
+
// Payload before action: a swapped payload changes both hashes, and the
|
|
2119
|
+
// payload mismatch is the precise, demoable reason. Matches the verify-time
|
|
2120
|
+
// claim order and spec §6; the action-hash check still catches every
|
|
2121
|
+
// non-payload mutation.
|
|
819
2122
|
if (artifact.payloadHash !== hashPayload(action.capability.payload)) {
|
|
820
2123
|
return "approval_payload_hash_mismatch";
|
|
821
2124
|
}
|
|
2125
|
+
if (artifact.actionHash !== hashAction(action)) {
|
|
2126
|
+
return "approval_action_hash_mismatch";
|
|
2127
|
+
}
|
|
822
2128
|
if (artifact.taskId !== action.intent.taskId) {
|
|
823
2129
|
return "approval_task_id_mismatch";
|
|
824
2130
|
}
|
|
@@ -913,6 +2219,103 @@ function githubBranchEvidence(action, payload) {
|
|
|
913
2219
|
}),
|
|
914
2220
|
});
|
|
915
2221
|
}
|
|
2222
|
+
function githubReviewCommentEvidence(action, payload) {
|
|
2223
|
+
const path = getString(payload.path) ?? "unknown";
|
|
2224
|
+
const pullNumber = getNumber(payload.pullNumber);
|
|
2225
|
+
const line = getNumber(payload.line);
|
|
2226
|
+
return LedgerProviderEvidenceSchema.parse({
|
|
2227
|
+
schemaVersion: LEDGER_PROVIDER_EVIDENCE_VERSION,
|
|
2228
|
+
provider: "github",
|
|
2229
|
+
tool: action.capability.tool,
|
|
2230
|
+
operation: "pull_request_review_comment_create",
|
|
2231
|
+
resource: providerResource("github", "pull_request_line", `${action.capability.resource}:pull:${pullNumber ?? "unknown"}:${path}:${line ?? "unknown"}`),
|
|
2232
|
+
normalized: compactJsonRecord({
|
|
2233
|
+
pullNumber,
|
|
2234
|
+
commitId: getString(payload.commitId),
|
|
2235
|
+
path,
|
|
2236
|
+
line,
|
|
2237
|
+
side: getString(payload.side),
|
|
2238
|
+
startLine: getNumber(payload.startLine),
|
|
2239
|
+
startSide: getString(payload.startSide),
|
|
2240
|
+
body: redactCredentialLikeText(getString(payload.body) ?? ""),
|
|
2241
|
+
}),
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
function githubCheckRunEvidence(action, payload) {
|
|
2245
|
+
const name = getString(payload.name) ?? "unknown";
|
|
2246
|
+
const headSha = getString(payload.headSha) ?? "unknown";
|
|
2247
|
+
const output = isJsonObject(payload.output) ? payload.output : {};
|
|
2248
|
+
return LedgerProviderEvidenceSchema.parse({
|
|
2249
|
+
schemaVersion: LEDGER_PROVIDER_EVIDENCE_VERSION,
|
|
2250
|
+
provider: "github",
|
|
2251
|
+
tool: action.capability.tool,
|
|
2252
|
+
operation: "check_run_create",
|
|
2253
|
+
resource: providerResource("github", "commit", `${action.capability.resource}:${headSha}`, name),
|
|
2254
|
+
normalized: compactJsonRecord({
|
|
2255
|
+
name,
|
|
2256
|
+
headSha,
|
|
2257
|
+
status: getString(payload.status),
|
|
2258
|
+
conclusion: getString(payload.conclusion),
|
|
2259
|
+
detailsUrl: getString(payload.detailsUrl),
|
|
2260
|
+
externalId: getString(payload.externalId),
|
|
2261
|
+
outputTitle: getString(output.title),
|
|
2262
|
+
outputSummary: redactCredentialLikeText(getString(output.summary) ?? ""),
|
|
2263
|
+
}),
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
function githubIssueCommentEvidence(action, payload) {
|
|
2267
|
+
const issueNumber = getNumber(payload.issueNumber);
|
|
2268
|
+
return LedgerProviderEvidenceSchema.parse({
|
|
2269
|
+
schemaVersion: LEDGER_PROVIDER_EVIDENCE_VERSION,
|
|
2270
|
+
provider: "github",
|
|
2271
|
+
tool: action.capability.tool,
|
|
2272
|
+
operation: "issue_comment_create",
|
|
2273
|
+
resource: providerResource("github", "issue", `${action.capability.resource}:issue:${issueNumber ?? "unknown"}`),
|
|
2274
|
+
normalized: compactJsonRecord({
|
|
2275
|
+
issueNumber,
|
|
2276
|
+
body: redactCredentialLikeText(getString(payload.body) ?? ""),
|
|
2277
|
+
}),
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
function postgresSelectEvidence(action, payload) {
|
|
2281
|
+
const statement = typeof payload.statement === "string" ? payload.statement : "";
|
|
2282
|
+
const analysis = analyzePostgresSelect(statement);
|
|
2283
|
+
const parameters = Array.isArray(payload.parameters) ? payload.parameters : [];
|
|
2284
|
+
const maxRows = typeof payload.maxRows === "number" && Number.isInteger(payload.maxRows)
|
|
2285
|
+
? payload.maxRows
|
|
2286
|
+
: null;
|
|
2287
|
+
return LedgerProviderEvidenceSchema.parse({
|
|
2288
|
+
schemaVersion: LEDGER_PROVIDER_EVIDENCE_VERSION,
|
|
2289
|
+
provider: "postgres",
|
|
2290
|
+
tool: action.capability.tool,
|
|
2291
|
+
operation: "query_select",
|
|
2292
|
+
resource: providerResource("postgres", "database", action.capability.resource),
|
|
2293
|
+
normalized: {
|
|
2294
|
+
statementHash: analysis.ok ? analysis.analysis.statementHash : null,
|
|
2295
|
+
predicateHash: analysis.ok ? analysis.analysis.predicateHash : null,
|
|
2296
|
+
tables: analysis.ok ? analysis.analysis.tables : [],
|
|
2297
|
+
functions: analysis.ok ? analysis.analysis.functions : [],
|
|
2298
|
+
parameterCount: analysis.ok ? analysis.analysis.parameterCount : 0,
|
|
2299
|
+
parameterHash: hashPayload(parameters),
|
|
2300
|
+
maxRows,
|
|
2301
|
+
},
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
function driveFileReadEvidence(action, payload) {
|
|
2305
|
+
const fileId = getString(payload.fileId) ?? "unknown";
|
|
2306
|
+
return LedgerProviderEvidenceSchema.parse({
|
|
2307
|
+
schemaVersion: LEDGER_PROVIDER_EVIDENCE_VERSION,
|
|
2308
|
+
provider: "drive",
|
|
2309
|
+
tool: action.capability.tool,
|
|
2310
|
+
operation: "file_read",
|
|
2311
|
+
resource: providerResource("drive", "file", `drive:file:${fileId}`, fileId),
|
|
2312
|
+
normalized: compactJsonRecord({
|
|
2313
|
+
fileId,
|
|
2314
|
+
scope: "https://www.googleapis.com/auth/drive.file",
|
|
2315
|
+
maxBytes: getNumber(payload.maxBytes),
|
|
2316
|
+
}),
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
916
2319
|
function slackMessageEvidence(action, payload) {
|
|
917
2320
|
const channel = getString(payload.channel) ?? "unknown";
|
|
918
2321
|
const message = getString(payload.text) ?? getString(payload.message) ?? "";
|
|
@@ -937,9 +2340,6 @@ function slackMessageEvidence(action, payload) {
|
|
|
937
2340
|
}),
|
|
938
2341
|
});
|
|
939
2342
|
}
|
|
940
|
-
function linearIssueEvidence(action, payload, operation) {
|
|
941
|
-
return issueTrackerEvidence(action, payload, "linear", operation);
|
|
942
|
-
}
|
|
943
2343
|
function issueTrackerEvidence(action, payload, provider, operation) {
|
|
944
2344
|
const issueKey = getString(payload.issueKey) ?? getString(payload.issueId) ?? "unknown";
|
|
945
2345
|
const projectKey = getString(payload.projectKey) ?? issueKey.split("-", 1)[0];
|
|
@@ -956,11 +2356,12 @@ function issueTrackerEvidence(action, payload, provider, operation) {
|
|
|
956
2356
|
status: getString(payload.status),
|
|
957
2357
|
stateId: getString(payload.stateId),
|
|
958
2358
|
priority: getNumber(payload.priority),
|
|
2359
|
+
priorityId: getString(payload.priorityId),
|
|
959
2360
|
commentBody: operation === "comment_create"
|
|
960
2361
|
? redactCredentialLikeText(getString(payload.body) ?? "")
|
|
961
2362
|
: null,
|
|
962
2363
|
}),
|
|
963
|
-
fieldChanges:
|
|
2364
|
+
fieldChanges: issueTrackerFieldChanges(payload),
|
|
964
2365
|
});
|
|
965
2366
|
}
|
|
966
2367
|
function docsEvidence(action, payload, operation) {
|
|
@@ -1089,13 +2490,14 @@ function changedFilesFromPayload(payload) {
|
|
|
1089
2490
|
.filter((path) => Boolean(path));
|
|
1090
2491
|
return [...new Set([...getStringArray(payload.filesChanged), ...changes])];
|
|
1091
2492
|
}
|
|
1092
|
-
function
|
|
2493
|
+
function issueTrackerFieldChanges(payload) {
|
|
1093
2494
|
return [
|
|
1094
2495
|
fieldChange("title", payload.fromTitle, payload.title),
|
|
1095
2496
|
fieldChange("description", payload.fromDescription, payload.description),
|
|
1096
2497
|
fieldChange("status", payload.fromStatus, payload.status),
|
|
1097
2498
|
fieldChange("stateId", payload.fromStateId, payload.stateId),
|
|
1098
2499
|
fieldChange("priority", payload.fromPriority, payload.priority),
|
|
2500
|
+
fieldChange("priorityId", payload.fromPriorityId, payload.priorityId),
|
|
1099
2501
|
].filter((change) => change !== null);
|
|
1100
2502
|
}
|
|
1101
2503
|
function fieldChange(field, before, after) {
|
|
@@ -1187,7 +2589,9 @@ function canonicalize(value) {
|
|
|
1187
2589
|
if (isPlainObject(value)) {
|
|
1188
2590
|
return Object.fromEntries(Object.entries(value)
|
|
1189
2591
|
.filter(([, entryValue]) => entryValue !== undefined)
|
|
1190
|
-
|
|
2592
|
+
// UTF-16 code unit order per RFC 8785 (JCS); locale-sensitive sorts
|
|
2593
|
+
// would make hashes non-portable across environments.
|
|
2594
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
1191
2595
|
.map(([key, entryValue]) => [key, canonicalize(entryValue)]));
|
|
1192
2596
|
}
|
|
1193
2597
|
return value;
|
|
@@ -1201,4 +2605,262 @@ function isPlainObject(value) {
|
|
|
1201
2605
|
function isNodeError(error) {
|
|
1202
2606
|
return error instanceof Error && "code" in error;
|
|
1203
2607
|
}
|
|
2608
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2609
|
+
// SDK facade (PRD §8.2)
|
|
2610
|
+
//
|
|
2611
|
+
// The ergonomic developer entry point. It exposes the five PRD verbs —
|
|
2612
|
+
// `authorize`, `verify`, `record`, `revoke`, `explain` — over a flat request
|
|
2613
|
+
// shape and delegates to the tested core functions above. It adds no new
|
|
2614
|
+
// authorization logic and changes no token/ledger/policy shape; it only maps the
|
|
2615
|
+
// flat request to a NormalizedAction, threads a local hash-linked ledger head,
|
|
2616
|
+
// and promotes the deterministic reason prose (`explainDecision`).
|
|
2617
|
+
//
|
|
2618
|
+
// Keys: if no signing key is configured, the facade resolves a persistent dev
|
|
2619
|
+
// keypair (`devKeypair()` — env-backed or a 0600 local keyring file) so the
|
|
2620
|
+
// quickstart runs with zero key plumbing and its passes verify across process
|
|
2621
|
+
// restarts. Configure { signingKey, verificationKey } (or a hosted issuer) for
|
|
2622
|
+
// production.
|
|
2623
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2624
|
+
export const AXTARY_DEFAULT_ISSUER = "axtary-dev";
|
|
2625
|
+
export const AXTARY_DEFAULT_TENANT = "local";
|
|
2626
|
+
export const AXTARY_DEFAULT_RUNTIME = "sdk";
|
|
2627
|
+
function isNormalizedActionShape(value) {
|
|
2628
|
+
return (typeof value === "object" &&
|
|
2629
|
+
value !== null &&
|
|
2630
|
+
"actor" in value &&
|
|
2631
|
+
"capability" in value &&
|
|
2632
|
+
"intent" in value);
|
|
2633
|
+
}
|
|
2634
|
+
function isAuthorizeResultShape(value) {
|
|
2635
|
+
return (typeof value === "object" &&
|
|
2636
|
+
value !== null &&
|
|
2637
|
+
"action" in value &&
|
|
2638
|
+
"decision" in value &&
|
|
2639
|
+
typeof value.decision === "object");
|
|
2640
|
+
}
|
|
2641
|
+
function isAxtaryPassShape(value) {
|
|
2642
|
+
return (typeof value === "object" &&
|
|
2643
|
+
value !== null &&
|
|
2644
|
+
"token" in value &&
|
|
2645
|
+
"claims" in value);
|
|
2646
|
+
}
|
|
2647
|
+
export class Axtary {
|
|
2648
|
+
issuer;
|
|
2649
|
+
tenant;
|
|
2650
|
+
runtime;
|
|
2651
|
+
defaultPolicy;
|
|
2652
|
+
algorithm;
|
|
2653
|
+
quiet;
|
|
2654
|
+
configuredSigningKey;
|
|
2655
|
+
configuredKeyId;
|
|
2656
|
+
configuredVerificationKey;
|
|
2657
|
+
keyPath;
|
|
2658
|
+
signerPromise;
|
|
2659
|
+
ledgerHead = null;
|
|
2660
|
+
revocations = new Map();
|
|
2661
|
+
constructor(config = {}) {
|
|
2662
|
+
this.issuer = config.issuer ?? AXTARY_DEFAULT_ISSUER;
|
|
2663
|
+
this.tenant = config.tenant ?? AXTARY_DEFAULT_TENANT;
|
|
2664
|
+
this.runtime = config.runtime ?? AXTARY_DEFAULT_RUNTIME;
|
|
2665
|
+
this.defaultPolicy = config.policy;
|
|
2666
|
+
this.algorithm = config.algorithm ?? DEFAULT_SIGNING_ALGORITHM;
|
|
2667
|
+
this.quiet = config.quiet ?? false;
|
|
2668
|
+
this.configuredSigningKey = config.signingKey;
|
|
2669
|
+
this.configuredKeyId = config.signingKeyId;
|
|
2670
|
+
this.configuredVerificationKey = config.verificationKey;
|
|
2671
|
+
this.keyPath = config.keyPath;
|
|
2672
|
+
}
|
|
2673
|
+
/** Current local ledger head (hash of the last record this instance wrote). */
|
|
2674
|
+
get ledgerHash() {
|
|
2675
|
+
return this.ledgerHead;
|
|
2676
|
+
}
|
|
2677
|
+
async resolveSigner() {
|
|
2678
|
+
if (!this.signerPromise) {
|
|
2679
|
+
this.signerPromise = (async () => {
|
|
2680
|
+
if (this.configuredSigningKey) {
|
|
2681
|
+
return {
|
|
2682
|
+
signingKey: this.configuredSigningKey,
|
|
2683
|
+
keyId: this.configuredKeyId,
|
|
2684
|
+
verificationKey: this.configuredVerificationKey,
|
|
2685
|
+
algorithm: this.algorithm,
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
const dev = await devKeypair({ path: this.keyPath });
|
|
2689
|
+
if (!this.quiet) {
|
|
2690
|
+
const origin = dev.source === "env"
|
|
2691
|
+
? `from ${AXTARY_DEV_SIGNING_KEY_ENV}`
|
|
2692
|
+
: `at ${dev.path}`;
|
|
2693
|
+
console.warn("[axtary] No signing key configured — issuing ActionPasses with a " +
|
|
2694
|
+
`persistent local dev key (${origin}). These passes verify across ` +
|
|
2695
|
+
"process restarts on this machine. Pass { signingKey, " +
|
|
2696
|
+
"verificationKey } to createAxtary() (or use a hosted issuer) for " +
|
|
2697
|
+
"production.");
|
|
2698
|
+
}
|
|
2699
|
+
return {
|
|
2700
|
+
signingKey: dev.signingKey,
|
|
2701
|
+
keyId: dev.signingKeyId,
|
|
2702
|
+
verificationKey: dev.verificationKey,
|
|
2703
|
+
algorithm: dev.algorithm,
|
|
2704
|
+
};
|
|
2705
|
+
})();
|
|
2706
|
+
}
|
|
2707
|
+
return this.signerPromise;
|
|
2708
|
+
}
|
|
2709
|
+
toNormalizedAction(request) {
|
|
2710
|
+
if (isNormalizedActionShape(request)) {
|
|
2711
|
+
return parseNormalizedAction(request);
|
|
2712
|
+
}
|
|
2713
|
+
return parseNormalizedAction({
|
|
2714
|
+
schemaVersion: ACTION_SCHEMA_VERSION,
|
|
2715
|
+
actor: {
|
|
2716
|
+
agentId: request.agent,
|
|
2717
|
+
humanOwner: request.human,
|
|
2718
|
+
runtime: request.runtime ?? this.runtime,
|
|
2719
|
+
tenant: request.tenant ?? this.tenant,
|
|
2720
|
+
},
|
|
2721
|
+
intent: {
|
|
2722
|
+
taskId: request.task ?? `task_${randomUUID()}`,
|
|
2723
|
+
declaredGoal: request.intent,
|
|
2724
|
+
maxDelegationDepth: request.maxDelegationDepth,
|
|
2725
|
+
},
|
|
2726
|
+
capability: {
|
|
2727
|
+
tool: request.tool,
|
|
2728
|
+
resource: request.resource,
|
|
2729
|
+
payload: request.payload ?? {},
|
|
2730
|
+
constraints: request.constraints,
|
|
2731
|
+
},
|
|
2732
|
+
toolDefinition: request.toolDefinition,
|
|
2733
|
+
provenance: request.provenance,
|
|
2734
|
+
budget: request.budget,
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Decide an action and, for an allow, issue an ActionPass. Always returns a
|
|
2739
|
+
* decision (the policy evaluation is keyless); a deny/step-up returns
|
|
2740
|
+
* `pass: null`. Threads the local ledger head so successive calls chain.
|
|
2741
|
+
*/
|
|
2742
|
+
async authorize(request, options = {}) {
|
|
2743
|
+
const signer = await this.resolveSigner();
|
|
2744
|
+
const action = this.toNormalizedAction(request);
|
|
2745
|
+
const result = await authorize({
|
|
2746
|
+
action,
|
|
2747
|
+
issuer: this.issuer,
|
|
2748
|
+
tenant: this.tenant,
|
|
2749
|
+
signingKey: signer.signingKey,
|
|
2750
|
+
keyId: signer.keyId,
|
|
2751
|
+
algorithm: signer.algorithm,
|
|
2752
|
+
policy: options.policy ?? this.defaultPolicy,
|
|
2753
|
+
approval: options.approval,
|
|
2754
|
+
approvalArtifact: options.approvalArtifact,
|
|
2755
|
+
previousLedgerHash: this.ledgerHead,
|
|
2756
|
+
now: options.now,
|
|
2757
|
+
});
|
|
2758
|
+
this.ledgerHead = result.ledger.ledgerHash;
|
|
2759
|
+
return {
|
|
2760
|
+
status: result.decision.decision,
|
|
2761
|
+
decision: result.decision,
|
|
2762
|
+
reasons: result.decision.reasons,
|
|
2763
|
+
explanation: explainDecision(result.decision),
|
|
2764
|
+
payloadHash: result.payloadHash,
|
|
2765
|
+
action: result.action,
|
|
2766
|
+
pass: result.actionPass,
|
|
2767
|
+
ledger: result.ledger,
|
|
2768
|
+
};
|
|
2769
|
+
}
|
|
2770
|
+
/**
|
|
2771
|
+
* Verify a previously issued pass against an action. Accepts an authorize
|
|
2772
|
+
* result, a pass object, or a raw token; the action defaults to the result's
|
|
2773
|
+
* action when one is supplied. Fails closed for a pass this instance revoked.
|
|
2774
|
+
*/
|
|
2775
|
+
async verify(pass, action) {
|
|
2776
|
+
let token;
|
|
2777
|
+
let actionInput = action;
|
|
2778
|
+
if (typeof pass === "string") {
|
|
2779
|
+
token = pass;
|
|
2780
|
+
}
|
|
2781
|
+
else if (isAuthorizeResultShape(pass)) {
|
|
2782
|
+
token = pass.pass?.token ?? null;
|
|
2783
|
+
actionInput = actionInput ?? pass.action;
|
|
2784
|
+
}
|
|
2785
|
+
else if (isAxtaryPassShape(pass)) {
|
|
2786
|
+
token = pass.token;
|
|
2787
|
+
}
|
|
2788
|
+
else {
|
|
2789
|
+
token = null;
|
|
2790
|
+
}
|
|
2791
|
+
if (!token) {
|
|
2792
|
+
return { valid: false, reason: "actionpass_not_issued" };
|
|
2793
|
+
}
|
|
2794
|
+
if (!actionInput) {
|
|
2795
|
+
return { valid: false, reason: "verify_requires_action" };
|
|
2796
|
+
}
|
|
2797
|
+
const signer = await this.resolveSigner();
|
|
2798
|
+
if (!signer.verificationKey) {
|
|
2799
|
+
return { valid: false, reason: "verification_key_required" };
|
|
2800
|
+
}
|
|
2801
|
+
return verifyActionPass({
|
|
2802
|
+
token,
|
|
2803
|
+
action: this.toNormalizedAction(actionInput),
|
|
2804
|
+
verificationKey: signer.verificationKey,
|
|
2805
|
+
issuer: this.issuer,
|
|
2806
|
+
algorithms: [signer.algorithm],
|
|
2807
|
+
revocations: this.revocations.values(),
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
/**
|
|
2811
|
+
* Append an execution result to the local ledger, chained after the decision
|
|
2812
|
+
* record from `authorize`. Returns the tamper-evident record; durable
|
|
2813
|
+
* persistence is the proxy/CLI's responsibility.
|
|
2814
|
+
*/
|
|
2815
|
+
record(input) {
|
|
2816
|
+
const action = this.toNormalizedAction(input.action);
|
|
2817
|
+
const passId = typeof input.pass === "string"
|
|
2818
|
+
? input.pass
|
|
2819
|
+
: (input.pass?.claims.jti ?? null);
|
|
2820
|
+
const executionOutcome = input.outcome
|
|
2821
|
+
? LedgerExecutionOutcomeSchema.parse({
|
|
2822
|
+
schemaVersion: LEDGER_EXECUTION_OUTCOME_VERSION,
|
|
2823
|
+
...input.outcome,
|
|
2824
|
+
})
|
|
2825
|
+
: undefined;
|
|
2826
|
+
const record = recordDecision({
|
|
2827
|
+
action,
|
|
2828
|
+
decision: input.decision,
|
|
2829
|
+
actionPassId: passId,
|
|
2830
|
+
executionOutcome,
|
|
2831
|
+
previousLedgerHash: input.previousLedgerHash ?? this.ledgerHead,
|
|
2832
|
+
correlationId: input.correlationId ?? passId,
|
|
2833
|
+
});
|
|
2834
|
+
this.ledgerHead = record.ledgerHash;
|
|
2835
|
+
return record;
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* Revoke a pass by id. The revocation is held in this instance so a later
|
|
2839
|
+
* `verify` of that pass fails closed. Durable cross-process revocation is the
|
|
2840
|
+
* CLI/trust-store path (`axtary revoke`).
|
|
2841
|
+
*/
|
|
2842
|
+
revoke(passId, options = {}) {
|
|
2843
|
+
const revocation = revokeActionPass({
|
|
2844
|
+
passId,
|
|
2845
|
+
revokedBy: options.revokedBy,
|
|
2846
|
+
reason: options.reason,
|
|
2847
|
+
revokedAt: options.revokedAt,
|
|
2848
|
+
});
|
|
2849
|
+
this.revocations.set(passId, revocation);
|
|
2850
|
+
return revocation;
|
|
2851
|
+
}
|
|
2852
|
+
/** Deterministic, human-readable explanation of a decision (no model call). */
|
|
2853
|
+
explain(decision) {
|
|
2854
|
+
const policyDecision = isAuthorizeResultShape(decision)
|
|
2855
|
+
? decision.decision
|
|
2856
|
+
: decision;
|
|
2857
|
+
return explainDecision(policyDecision);
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
/** Create an Axtary SDK instance. */
|
|
2861
|
+
export function createAxtary(config = {}) {
|
|
2862
|
+
return new Axtary(config);
|
|
2863
|
+
}
|
|
2864
|
+
/** Default Axtary SDK instance (PRD §8.2 `import { axtary }`). */
|
|
2865
|
+
export const axtary = createAxtary();
|
|
1204
2866
|
//# sourceMappingURL=index.js.map
|