@axtary/actionpass 0.0.1 → 0.1.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/src/index.ts DELETED
@@ -1,552 +0,0 @@
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
- }