@axtary/actionpass 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/package.json +35 -0
- package/src/index.ts +552 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @axtary/actionpass
|
|
2
|
+
|
|
3
|
+
Scoped, signed ActionPass artifacts for runtime-governed AI agent actions.
|
|
4
|
+
|
|
5
|
+
This is an early package reservation and v0 development build for Axtary. The API is not stable yet.
|
|
6
|
+
|
|
7
|
+
## What It Does
|
|
8
|
+
|
|
9
|
+
- Validates normalized agent actions at runtime.
|
|
10
|
+
- Evaluates a first deterministic GitHub PR policy.
|
|
11
|
+
- Produces canonical SHA-256 payload hashes.
|
|
12
|
+
- Issues signed ActionPass JWT/JWS artifacts for allowed actions.
|
|
13
|
+
- Verifies that a signed pass still matches the exact action payload.
|
|
14
|
+
- Records ledger entries with hashable decision evidence.
|
|
15
|
+
|
|
16
|
+
## Current Status
|
|
17
|
+
|
|
18
|
+
Version `0.0.1` is a placeholder/pre-release package. Do not use it for production authorization.
|
|
19
|
+
|
|
20
|
+
Before production use, Axtary still needs:
|
|
21
|
+
|
|
22
|
+
- A built `dist/` output instead of raw TypeScript exports.
|
|
23
|
+
- Stable schema versioning.
|
|
24
|
+
- Revocation and key rotation.
|
|
25
|
+
- Policy package extraction.
|
|
26
|
+
- External security review.
|
|
27
|
+
|
|
28
|
+
## Example Shape
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import {
|
|
32
|
+
authorize,
|
|
33
|
+
demoAction,
|
|
34
|
+
verifyActionPass,
|
|
35
|
+
} from "@axtary/actionpass";
|
|
36
|
+
|
|
37
|
+
const result = await authorize({
|
|
38
|
+
action: demoAction,
|
|
39
|
+
issuer: "https://api.axtary.dev",
|
|
40
|
+
tenant: "org:company",
|
|
41
|
+
signingKey,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (result.actionPass) {
|
|
45
|
+
const verified = await verifyActionPass({
|
|
46
|
+
token: result.actionPass.token,
|
|
47
|
+
action: demoAction,
|
|
48
|
+
verificationKey,
|
|
49
|
+
issuer: "https://api.axtary.dev",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Security Notes
|
|
55
|
+
|
|
56
|
+
ActionPass is designed to fail closed:
|
|
57
|
+
|
|
58
|
+
- Malformed actions fail schema validation.
|
|
59
|
+
- Denied and step-up actions do not receive passes.
|
|
60
|
+
- Verification rejects expired tokens.
|
|
61
|
+
- Verification rejects payload hash mismatches.
|
|
62
|
+
- Verification binds agent, human owner, runtime, task, tool, resource, and payload hash.
|
|
63
|
+
|
|
64
|
+
Signing currently defaults to `ES256`.
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@axtary/actionpass",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Scoped, signed ActionPass artifacts for runtime-governed AI agent actions.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "UNLICENSED",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/axtary/axtary.git",
|
|
11
|
+
"directory": "packages/actionpass"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"ai-agents",
|
|
15
|
+
"authorization",
|
|
16
|
+
"runtime-security",
|
|
17
|
+
"jwt",
|
|
18
|
+
"mcp"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"README.md",
|
|
25
|
+
"src/index.ts"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/index.ts"
|
|
29
|
+
},
|
|
30
|
+
"types": "./src/index.ts",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"jose": "^6.2.3",
|
|
33
|
+
"zod": "^4.4.3"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { SignJWT, jwtVerify, type JWK, type KeyObject } from "jose";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
export const ACTION_SCHEMA_VERSION = "axtary.action.v0";
|
|
6
|
+
export const ACTIONPASS_VERSION = "axtary.actionpass.v0";
|
|
7
|
+
export const LEDGER_SCHEMA_VERSION = "axtary.ledger.v0";
|
|
8
|
+
export const DEFAULT_EXPIRES_IN_SECONDS = 600;
|
|
9
|
+
export const DEFAULT_SIGNING_ALGORITHM = "ES256";
|
|
10
|
+
|
|
11
|
+
export type JsonValue =
|
|
12
|
+
| string
|
|
13
|
+
| number
|
|
14
|
+
| boolean
|
|
15
|
+
| null
|
|
16
|
+
| JsonValue[]
|
|
17
|
+
| { [key: string]: JsonValue };
|
|
18
|
+
|
|
19
|
+
const JsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
|
|
20
|
+
z.union([
|
|
21
|
+
z.string(),
|
|
22
|
+
z.number().finite(),
|
|
23
|
+
z.boolean(),
|
|
24
|
+
z.null(),
|
|
25
|
+
z.array(JsonValueSchema),
|
|
26
|
+
z.record(z.string(), JsonValueSchema),
|
|
27
|
+
])
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const AxtaryDecisionSchema = z.enum(["allow", "deny", "step_up"]);
|
|
31
|
+
export type AxtaryDecision = z.infer<typeof AxtaryDecisionSchema>;
|
|
32
|
+
|
|
33
|
+
export const NormalizedActionSchema = z.object({
|
|
34
|
+
schemaVersion: z.literal(ACTION_SCHEMA_VERSION).default(ACTION_SCHEMA_VERSION),
|
|
35
|
+
actor: z.object({
|
|
36
|
+
agentId: z.string().min(1),
|
|
37
|
+
humanOwner: z.string().min(1),
|
|
38
|
+
runtime: z.string().min(1),
|
|
39
|
+
tenant: z.string().min(1).optional(),
|
|
40
|
+
}),
|
|
41
|
+
intent: z.object({
|
|
42
|
+
taskId: z.string().min(1),
|
|
43
|
+
declaredGoal: z.string().min(1),
|
|
44
|
+
maxDelegationDepth: z.number().int().nonnegative().optional(),
|
|
45
|
+
}),
|
|
46
|
+
capability: z.object({
|
|
47
|
+
tool: z.string().min(1),
|
|
48
|
+
resource: z.string().min(1),
|
|
49
|
+
payload: z.record(z.string(), JsonValueSchema),
|
|
50
|
+
constraints: z.record(z.string(), JsonValueSchema).optional(),
|
|
51
|
+
}),
|
|
52
|
+
toolDefinition: z
|
|
53
|
+
.object({
|
|
54
|
+
serverIdentity: z.string().min(1),
|
|
55
|
+
schemaVersion: z.string().min(1),
|
|
56
|
+
definitionHash: z.string().min(1),
|
|
57
|
+
})
|
|
58
|
+
.optional(),
|
|
59
|
+
budget: z
|
|
60
|
+
.object({
|
|
61
|
+
reservationId: z.string().min(1).optional(),
|
|
62
|
+
limit: z.record(z.string(), JsonValueSchema).optional(),
|
|
63
|
+
commitStatus: z.enum(["pending", "committed", "rolled_back"]).optional(),
|
|
64
|
+
})
|
|
65
|
+
.optional(),
|
|
66
|
+
});
|
|
67
|
+
export type NormalizedAction = z.infer<typeof NormalizedActionSchema>;
|
|
68
|
+
|
|
69
|
+
export const PolicyDecisionSchema = z.object({
|
|
70
|
+
decision: AxtaryDecisionSchema,
|
|
71
|
+
reasons: z.array(z.string().min(1)),
|
|
72
|
+
policy: z.object({
|
|
73
|
+
nativeRule: z.string().min(1),
|
|
74
|
+
version: z.string().min(1),
|
|
75
|
+
cedarCompatible: z.boolean(),
|
|
76
|
+
opaCompatible: z.boolean(),
|
|
77
|
+
}),
|
|
78
|
+
constraints: z.object({
|
|
79
|
+
expiresInSeconds: z.number().int().positive(),
|
|
80
|
+
maxFilesChanged: z.number().int().nonnegative(),
|
|
81
|
+
blockedPaths: z.array(z.string().min(1)),
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
export type PolicyDecision = z.infer<typeof PolicyDecisionSchema>;
|
|
85
|
+
export type ActionPassDecision = PolicyDecision;
|
|
86
|
+
|
|
87
|
+
export const ApprovalSchema = z.object({
|
|
88
|
+
mode: z.enum(["none", "human", "policy_override"]),
|
|
89
|
+
approvedBy: z.string().min(1).optional(),
|
|
90
|
+
approvalArtifact: z.string().min(1).optional(),
|
|
91
|
+
approvedAt: z.string().datetime().optional(),
|
|
92
|
+
});
|
|
93
|
+
export type Approval = z.infer<typeof ApprovalSchema>;
|
|
94
|
+
|
|
95
|
+
export const ActionPassClaimsSchema = z.object({
|
|
96
|
+
apv: z.literal(ACTIONPASS_VERSION),
|
|
97
|
+
iss: z.string().min(1),
|
|
98
|
+
sub: z.string().min(1),
|
|
99
|
+
aud: z.union([z.string().min(1), z.array(z.string().min(1))]),
|
|
100
|
+
exp: z.number().int().positive(),
|
|
101
|
+
nbf: z.number().int().positive(),
|
|
102
|
+
iat: z.number().int().positive(),
|
|
103
|
+
jti: z.string().min(1),
|
|
104
|
+
tenant: z.string().min(1),
|
|
105
|
+
humanOwner: z.string().min(1),
|
|
106
|
+
agentRuntime: z.string().min(1),
|
|
107
|
+
intent: NormalizedActionSchema.shape.intent,
|
|
108
|
+
capability: z.object({
|
|
109
|
+
tool: z.string().min(1),
|
|
110
|
+
resource: z.string().min(1),
|
|
111
|
+
constraints: z.record(z.string(), JsonValueSchema).optional(),
|
|
112
|
+
}),
|
|
113
|
+
decision: AxtaryDecisionSchema,
|
|
114
|
+
reasons: z.array(z.string().min(1)),
|
|
115
|
+
policy: PolicyDecisionSchema.shape.policy,
|
|
116
|
+
payloadHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
117
|
+
approval: ApprovalSchema.optional(),
|
|
118
|
+
audit: z.object({
|
|
119
|
+
traceId: z.string().min(1),
|
|
120
|
+
parentPassId: z.string().min(1).nullable(),
|
|
121
|
+
ledgerHash: z.string().min(1).nullable(),
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
export type ActionPassClaims = z.infer<typeof ActionPassClaimsSchema>;
|
|
125
|
+
|
|
126
|
+
export const LedgerRecordSchema = z.object({
|
|
127
|
+
schemaVersion: z.literal(LEDGER_SCHEMA_VERSION),
|
|
128
|
+
id: z.string().min(1),
|
|
129
|
+
occurredAt: z.string().datetime(),
|
|
130
|
+
actionHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
131
|
+
payloadHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
132
|
+
decision: AxtaryDecisionSchema,
|
|
133
|
+
reasons: z.array(z.string().min(1)),
|
|
134
|
+
policy: PolicyDecisionSchema.shape.policy,
|
|
135
|
+
actionPassId: z.string().min(1).nullable(),
|
|
136
|
+
previousLedgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(),
|
|
137
|
+
ledgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
138
|
+
});
|
|
139
|
+
export type LedgerRecord = z.infer<typeof LedgerRecordSchema>;
|
|
140
|
+
|
|
141
|
+
export type SigningKey = CryptoKey | KeyObject | JWK | Uint8Array;
|
|
142
|
+
export type VerificationKey = CryptoKey | KeyObject | JWK | Uint8Array;
|
|
143
|
+
export type SigningAlgorithm = "ES256" | "EdDSA";
|
|
144
|
+
|
|
145
|
+
export type PolicyOptions = {
|
|
146
|
+
allowedTool?: string;
|
|
147
|
+
requiredBaseBranch?: string;
|
|
148
|
+
maxFilesChanged?: number;
|
|
149
|
+
blockedPathPrefixes?: string[];
|
|
150
|
+
policyVersion?: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type IssueActionPassInput = {
|
|
154
|
+
action: unknown;
|
|
155
|
+
decision: PolicyDecision;
|
|
156
|
+
issuer: string;
|
|
157
|
+
tenant: string;
|
|
158
|
+
signingKey: SigningKey;
|
|
159
|
+
keyId?: string;
|
|
160
|
+
algorithm?: SigningAlgorithm;
|
|
161
|
+
now?: Date;
|
|
162
|
+
expiresInSeconds?: number;
|
|
163
|
+
approval?: Approval;
|
|
164
|
+
parentPassId?: string | null;
|
|
165
|
+
ledgerHash?: string | null;
|
|
166
|
+
traceId?: string;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export type IssueActionPassResult = {
|
|
170
|
+
token: string;
|
|
171
|
+
claims: ActionPassClaims;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export type AuthorizeInput = {
|
|
175
|
+
action: unknown;
|
|
176
|
+
issuer: string;
|
|
177
|
+
tenant: string;
|
|
178
|
+
signingKey: SigningKey;
|
|
179
|
+
keyId?: string;
|
|
180
|
+
algorithm?: SigningAlgorithm;
|
|
181
|
+
now?: Date;
|
|
182
|
+
policy?: PolicyOptions;
|
|
183
|
+
previousLedgerHash?: string | null;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export type AuthorizeResult = {
|
|
187
|
+
action: NormalizedAction;
|
|
188
|
+
decision: PolicyDecision;
|
|
189
|
+
payloadHash: string;
|
|
190
|
+
actionPass: IssueActionPassResult | null;
|
|
191
|
+
ledger: LedgerRecord;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export type VerifyActionPassInput = {
|
|
195
|
+
token: string;
|
|
196
|
+
action: unknown;
|
|
197
|
+
verificationKey: VerificationKey;
|
|
198
|
+
issuer?: string;
|
|
199
|
+
audience?: string | string[];
|
|
200
|
+
algorithms?: SigningAlgorithm[];
|
|
201
|
+
clockTolerance?: string | number;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export type VerifyActionPassResult =
|
|
205
|
+
| {
|
|
206
|
+
valid: true;
|
|
207
|
+
claims: ActionPassClaims;
|
|
208
|
+
payloadHash: string;
|
|
209
|
+
}
|
|
210
|
+
| {
|
|
211
|
+
valid: false;
|
|
212
|
+
reason: string;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export const demoAction: NormalizedAction = NormalizedActionSchema.parse({
|
|
216
|
+
actor: {
|
|
217
|
+
agentId: "agent:codex-prod",
|
|
218
|
+
humanOwner: "user:asrar@company.com",
|
|
219
|
+
runtime: "codex-cli",
|
|
220
|
+
tenant: "org:company",
|
|
221
|
+
},
|
|
222
|
+
intent: {
|
|
223
|
+
taskId: "AXT-418",
|
|
224
|
+
declaredGoal: "Open a PR that fixes the auth redirect bug",
|
|
225
|
+
},
|
|
226
|
+
capability: {
|
|
227
|
+
tool: "github.pull_requests.create",
|
|
228
|
+
resource: "repo:company/web-app",
|
|
229
|
+
payload: {
|
|
230
|
+
baseBranch: "main",
|
|
231
|
+
filesChanged: ["src/app/login/page.tsx", "src/lib/auth/session.ts"],
|
|
232
|
+
touchesProduction: false,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
export function parseNormalizedAction(action: unknown): NormalizedAction {
|
|
238
|
+
return NormalizedActionSchema.parse(action);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function evaluateAction(
|
|
242
|
+
actionInput: unknown,
|
|
243
|
+
options: PolicyOptions = {}
|
|
244
|
+
): PolicyDecision {
|
|
245
|
+
const action = parseNormalizedAction(actionInput);
|
|
246
|
+
const payload = action.capability.payload;
|
|
247
|
+
const filesChanged = getStringArray(payload.filesChanged);
|
|
248
|
+
const requiredBaseBranch = options.requiredBaseBranch ?? "main";
|
|
249
|
+
const maxFilesChanged = options.maxFilesChanged ?? 12;
|
|
250
|
+
const blockedPathPrefixes = options.blockedPathPrefixes ?? [
|
|
251
|
+
"infra/prod/",
|
|
252
|
+
"billing/",
|
|
253
|
+
".env",
|
|
254
|
+
];
|
|
255
|
+
const policy = {
|
|
256
|
+
nativeRule: "github_pr_coding_agent_v0",
|
|
257
|
+
version: options.policyVersion ?? "2026-05-30",
|
|
258
|
+
cedarCompatible: true,
|
|
259
|
+
opaCompatible: true,
|
|
260
|
+
};
|
|
261
|
+
const constraints = {
|
|
262
|
+
expiresInSeconds: DEFAULT_EXPIRES_IN_SECONDS,
|
|
263
|
+
maxFilesChanged,
|
|
264
|
+
blockedPaths: ["infra/prod/**", "billing/**", ".env*"],
|
|
265
|
+
};
|
|
266
|
+
const stepUpReasons: string[] = [];
|
|
267
|
+
|
|
268
|
+
if (action.capability.tool !== (options.allowedTool ?? "github.pull_requests.create")) {
|
|
269
|
+
return {
|
|
270
|
+
decision: "deny",
|
|
271
|
+
reasons: [
|
|
272
|
+
`unsupported_tool: ${action.capability.tool} is not enabled by this policy`,
|
|
273
|
+
],
|
|
274
|
+
policy,
|
|
275
|
+
constraints,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (payload.baseBranch !== requiredBaseBranch) {
|
|
280
|
+
stepUpReasons.push(`base_branch_mismatch: expected ${requiredBaseBranch}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (filesChanged.length > maxFilesChanged) {
|
|
284
|
+
stepUpReasons.push("file_count_exceeds_policy_limit");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (
|
|
288
|
+
filesChanged.some((path) =>
|
|
289
|
+
blockedPathPrefixes.some((prefix) => path.startsWith(prefix))
|
|
290
|
+
)
|
|
291
|
+
) {
|
|
292
|
+
stepUpReasons.push("payload_touches_protected_path");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (payload.touchesProduction === true) {
|
|
296
|
+
stepUpReasons.push("payload_declares_production_impact");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (stepUpReasons.length > 0) {
|
|
300
|
+
return {
|
|
301
|
+
decision: "step_up",
|
|
302
|
+
reasons: stepUpReasons,
|
|
303
|
+
policy,
|
|
304
|
+
constraints,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
decision: "allow",
|
|
310
|
+
reasons: ["payload_is_inside_current_actionpass_constraints"],
|
|
311
|
+
policy,
|
|
312
|
+
constraints,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
|
|
317
|
+
const action = parseNormalizedAction(input.action);
|
|
318
|
+
const decision = evaluateAction(action, input.policy);
|
|
319
|
+
const payloadHash = hashPayload(action.capability.payload);
|
|
320
|
+
const actionPass =
|
|
321
|
+
decision.decision === "allow"
|
|
322
|
+
? await issueActionPass({
|
|
323
|
+
action,
|
|
324
|
+
decision,
|
|
325
|
+
issuer: input.issuer,
|
|
326
|
+
tenant: input.tenant,
|
|
327
|
+
signingKey: input.signingKey,
|
|
328
|
+
keyId: input.keyId,
|
|
329
|
+
algorithm: input.algorithm,
|
|
330
|
+
now: input.now,
|
|
331
|
+
})
|
|
332
|
+
: null;
|
|
333
|
+
const ledger = recordDecision({
|
|
334
|
+
action,
|
|
335
|
+
decision,
|
|
336
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
337
|
+
previousLedgerHash: input.previousLedgerHash ?? null,
|
|
338
|
+
occurredAt: input.now,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
action,
|
|
343
|
+
decision,
|
|
344
|
+
payloadHash,
|
|
345
|
+
actionPass,
|
|
346
|
+
ledger,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function issueActionPass(
|
|
351
|
+
input: IssueActionPassInput
|
|
352
|
+
): Promise<IssueActionPassResult> {
|
|
353
|
+
const action = parseNormalizedAction(input.action);
|
|
354
|
+
const decision = PolicyDecisionSchema.parse(input.decision);
|
|
355
|
+
|
|
356
|
+
if (decision.decision !== "allow") {
|
|
357
|
+
throw new Error("ActionPass can only be issued for an allow decision.");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const now = input.now ?? new Date();
|
|
361
|
+
const issuedAt = toNumericDate(now);
|
|
362
|
+
const expiresInSeconds =
|
|
363
|
+
input.expiresInSeconds ?? decision.constraints.expiresInSeconds;
|
|
364
|
+
const expiresAt = issuedAt + expiresInSeconds;
|
|
365
|
+
const passId = `ap_${randomUUID()}`;
|
|
366
|
+
const claims: ActionPassClaims = ActionPassClaimsSchema.parse({
|
|
367
|
+
apv: ACTIONPASS_VERSION,
|
|
368
|
+
iss: input.issuer,
|
|
369
|
+
sub: action.actor.agentId,
|
|
370
|
+
aud: action.capability.resource,
|
|
371
|
+
exp: expiresAt,
|
|
372
|
+
nbf: issuedAt,
|
|
373
|
+
iat: issuedAt,
|
|
374
|
+
jti: passId,
|
|
375
|
+
tenant: input.tenant,
|
|
376
|
+
humanOwner: action.actor.humanOwner,
|
|
377
|
+
agentRuntime: action.actor.runtime,
|
|
378
|
+
intent: action.intent,
|
|
379
|
+
capability: {
|
|
380
|
+
tool: action.capability.tool,
|
|
381
|
+
resource: action.capability.resource,
|
|
382
|
+
constraints: action.capability.constraints,
|
|
383
|
+
},
|
|
384
|
+
decision: decision.decision,
|
|
385
|
+
reasons: decision.reasons,
|
|
386
|
+
policy: decision.policy,
|
|
387
|
+
payloadHash: hashPayload(action.capability.payload),
|
|
388
|
+
approval: input.approval,
|
|
389
|
+
audit: {
|
|
390
|
+
traceId: input.traceId ?? `trace_${randomUUID()}`,
|
|
391
|
+
parentPassId: input.parentPassId ?? null,
|
|
392
|
+
ledgerHash: input.ledgerHash ?? null,
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const token = await new SignJWT(claims)
|
|
397
|
+
.setProtectedHeader({
|
|
398
|
+
alg: input.algorithm ?? DEFAULT_SIGNING_ALGORITHM,
|
|
399
|
+
typ: "axtary-actionpass+jwt",
|
|
400
|
+
kid: input.keyId,
|
|
401
|
+
})
|
|
402
|
+
.sign(input.signingKey);
|
|
403
|
+
|
|
404
|
+
return { token, claims };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export async function verifyActionPass(
|
|
408
|
+
input: VerifyActionPassInput
|
|
409
|
+
): Promise<VerifyActionPassResult> {
|
|
410
|
+
try {
|
|
411
|
+
const action = parseNormalizedAction(input.action);
|
|
412
|
+
const { payload } = await jwtVerify(input.token, input.verificationKey, {
|
|
413
|
+
issuer: input.issuer,
|
|
414
|
+
audience: input.audience ?? action.capability.resource,
|
|
415
|
+
algorithms: input.algorithms ?? [DEFAULT_SIGNING_ALGORITHM],
|
|
416
|
+
clockTolerance: input.clockTolerance,
|
|
417
|
+
});
|
|
418
|
+
const claims = ActionPassClaimsSchema.parse(payload);
|
|
419
|
+
const payloadHash = hashPayload(action.capability.payload);
|
|
420
|
+
const bindingError = validatePassBinding(claims, action, payloadHash);
|
|
421
|
+
|
|
422
|
+
if (bindingError) {
|
|
423
|
+
return {
|
|
424
|
+
valid: false,
|
|
425
|
+
reason: bindingError,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
valid: true,
|
|
431
|
+
claims,
|
|
432
|
+
payloadHash,
|
|
433
|
+
};
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return {
|
|
436
|
+
valid: false,
|
|
437
|
+
reason: error instanceof Error ? error.message : "verification_failed",
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function recordDecision(input: {
|
|
443
|
+
action: unknown;
|
|
444
|
+
decision: PolicyDecision;
|
|
445
|
+
actionPassId?: string | null;
|
|
446
|
+
previousLedgerHash?: string | null;
|
|
447
|
+
occurredAt?: Date;
|
|
448
|
+
}): LedgerRecord {
|
|
449
|
+
const action = parseNormalizedAction(input.action);
|
|
450
|
+
const decision = PolicyDecisionSchema.parse(input.decision);
|
|
451
|
+
const payloadHash = hashPayload(action.capability.payload);
|
|
452
|
+
const actionHash = hashJson(action);
|
|
453
|
+
const recordWithoutHash = {
|
|
454
|
+
schemaVersion: LEDGER_SCHEMA_VERSION,
|
|
455
|
+
id: `ledger_${randomUUID()}`,
|
|
456
|
+
occurredAt: (input.occurredAt ?? new Date()).toISOString(),
|
|
457
|
+
actionHash,
|
|
458
|
+
payloadHash,
|
|
459
|
+
decision: decision.decision,
|
|
460
|
+
reasons: decision.reasons,
|
|
461
|
+
policy: decision.policy,
|
|
462
|
+
actionPassId: input.actionPassId ?? null,
|
|
463
|
+
previousLedgerHash: input.previousLedgerHash ?? null,
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
return LedgerRecordSchema.parse({
|
|
467
|
+
...recordWithoutHash,
|
|
468
|
+
ledgerHash: hashJson(recordWithoutHash),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function hashPayload(payload: unknown): string {
|
|
473
|
+
return hashJson(JsonValueSchema.parse(payload));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function hashJson(value: unknown): string {
|
|
477
|
+
return `sha256:${createHash("sha256")
|
|
478
|
+
.update(stableStringify(value))
|
|
479
|
+
.digest("hex")}`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function stableStringify(value: unknown): string {
|
|
483
|
+
return JSON.stringify(canonicalize(value));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function validatePassBinding(
|
|
487
|
+
claims: ActionPassClaims,
|
|
488
|
+
action: NormalizedAction,
|
|
489
|
+
payloadHash: string
|
|
490
|
+
): string | null {
|
|
491
|
+
if (claims.sub !== action.actor.agentId) {
|
|
492
|
+
return "agent_id_mismatch";
|
|
493
|
+
}
|
|
494
|
+
if (claims.humanOwner !== action.actor.humanOwner) {
|
|
495
|
+
return "human_owner_mismatch";
|
|
496
|
+
}
|
|
497
|
+
if (claims.agentRuntime !== action.actor.runtime) {
|
|
498
|
+
return "agent_runtime_mismatch";
|
|
499
|
+
}
|
|
500
|
+
if (claims.intent.taskId !== action.intent.taskId) {
|
|
501
|
+
return "task_id_mismatch";
|
|
502
|
+
}
|
|
503
|
+
if (claims.capability.tool !== action.capability.tool) {
|
|
504
|
+
return "tool_mismatch";
|
|
505
|
+
}
|
|
506
|
+
if (claims.capability.resource !== action.capability.resource) {
|
|
507
|
+
return "resource_mismatch";
|
|
508
|
+
}
|
|
509
|
+
if (claims.payloadHash !== payloadHash) {
|
|
510
|
+
return "payload_hash_mismatch";
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function getStringArray(value: JsonValue | undefined): string[] {
|
|
517
|
+
if (!Array.isArray(value)) {
|
|
518
|
+
return [];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return value.filter((item): item is string => typeof item === "string");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function toNumericDate(date: Date): number {
|
|
525
|
+
return Math.floor(date.getTime() / 1000);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function canonicalize(value: unknown): unknown {
|
|
529
|
+
if (Array.isArray(value)) {
|
|
530
|
+
return value.map((item) => canonicalize(item));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (isPlainObject(value)) {
|
|
534
|
+
return Object.fromEntries(
|
|
535
|
+
Object.entries(value)
|
|
536
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
537
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
538
|
+
.map(([key, entryValue]) => [key, canonicalize(entryValue)])
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return value;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
546
|
+
return (
|
|
547
|
+
typeof value === "object" &&
|
|
548
|
+
value !== null &&
|
|
549
|
+
!Array.isArray(value) &&
|
|
550
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
551
|
+
);
|
|
552
|
+
}
|