@axtary/policy 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/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
- import { DEFAULT_EXPIRES_IN_SECONDS, NormalizedActionSchema, } from "@axtary/actionpass";
1
+ import { analyzePostgresSelect, DEFAULT_EXPIRES_IN_SECONDS, explainDecision, NormalizedActionSchema, } from "@axtary/actionpass";
2
2
  import { z } from "zod";
3
+ // The deterministic reason→prose explanation now lives in @axtary/actionpass so
4
+ // the SDK facade and the policy/CLI surfaces share one source of truth. Re-export
5
+ // it under the historical name for existing importers.
6
+ export { explainDecision as explainPolicyDecision } from "@axtary/actionpass";
3
7
  export const POLICY_SCHEMA_VERSION = "axtary.policy.v0";
4
8
  const DEFAULT_GITHUB_PULL_REQUEST_POLICY = {
5
9
  enabled: true,
@@ -48,8 +52,98 @@ const DEFAULT_DOCS_POLICY = {
48
52
  maxSearchResults: 10,
49
53
  maxReadBytes: 20_000,
50
54
  };
55
+ const DEFAULT_POSTGRES_READ_POLICY = {
56
+ enabled: true,
57
+ allowedDatabases: [],
58
+ allowedTables: [],
59
+ allowedFunctions: [],
60
+ maxRows: 100,
61
+ requirePredicate: true,
62
+ };
63
+ const DEFAULT_DRIVE_READ_POLICY = {
64
+ enabled: true,
65
+ allowedFileIds: [],
66
+ maxReadBytes: 100_000,
67
+ };
68
+ export const PROVENANCE_COVERAGE = {
69
+ "github.pull_requests.create": ["title", "body"],
70
+ "github.contents.write": ["content", "message"],
71
+ "github.pull_request_review_comments.create": ["body"],
72
+ "github.check_runs.create": ["output"],
73
+ "github.issue_comments.create": ["body"],
74
+ "slack.chat.postMessage": ["message", "text"],
75
+ "linear.comments.create": ["body"],
76
+ "linear.issues.update": ["title", "description", "status"],
77
+ "jira.comments.create": ["body"],
78
+ "jira.issues.update": ["title", "description", "status"],
79
+ "mcp.tool.call": ["arguments"],
80
+ };
81
+ const DEFAULT_PROVENANCE_POLICY = {
82
+ enabled: false,
83
+ unknownDecision: "deny",
84
+ untrustedDecision: "step_up",
85
+ };
86
+ // Behavioral novelty/sequence step-up (M5.6, risk-graph v0). Opt-in and
87
+ // escalate-only: these signals may raise an `allow` to `step_up`, and may never
88
+ // produce an `allow` or weaken a `deny`/`step_up` (PRD §4 non-goals fence).
89
+ const DEFAULT_BEHAVIOR_POLICY = {
90
+ enabled: false,
91
+ firstSeen: true,
92
+ abnormalSequence: true,
93
+ };
94
+ const StringMatcherSchema = z.union([
95
+ z.string().min(1),
96
+ z.array(z.string().min(1)).min(1),
97
+ ]);
98
+ export const PolicyRuleSchema = z.object({
99
+ id: z.string().min(1),
100
+ description: z.string().min(1).optional(),
101
+ enabled: z.boolean().default(true),
102
+ match: z
103
+ .object({
104
+ tools: StringMatcherSchema.optional(),
105
+ resources: StringMatcherSchema.optional(),
106
+ actors: z
107
+ .object({
108
+ agentIds: StringMatcherSchema.optional(),
109
+ humanOwners: StringMatcherSchema.optional(),
110
+ runtimes: StringMatcherSchema.optional(),
111
+ tenants: StringMatcherSchema.optional(),
112
+ })
113
+ .optional(),
114
+ })
115
+ .default({}),
116
+ effect: z.enum(["allow", "deny", "step_up"]),
117
+ reason: z.string().min(1).optional(),
118
+ obligations: z
119
+ .object({
120
+ expiresInSeconds: z.number().int().positive().optional(),
121
+ requiredApproverRoles: z.array(z.string().min(1)).default([]),
122
+ blockedPathPrefixes: z.array(z.string().min(1)).default([]),
123
+ timeWindow: z
124
+ .object({
125
+ startHourUtc: z.number().int().min(0).max(23),
126
+ endHourUtc: z.number().int().min(0).max(23),
127
+ daysOfWeek: z.array(z.number().int().min(0).max(6)).default([]),
128
+ })
129
+ .optional(),
130
+ rateLimit: z
131
+ .object({
132
+ maxActions: z.number().int().positive(),
133
+ windowSeconds: z.number().int().positive(),
134
+ scope: z.enum(["actor", "tool", "resource", "tenant"]).default("actor"),
135
+ })
136
+ .optional(),
137
+ })
138
+ .default({
139
+ requiredApproverRoles: [],
140
+ blockedPathPrefixes: [],
141
+ }),
142
+ });
51
143
  export const PolicyConfigSchema = z.object({
52
144
  version: z.string().min(1).default("2026-05-30"),
145
+ defaultDecision: z.literal("deny").default("deny"),
146
+ rules: z.array(PolicyRuleSchema).default([]),
53
147
  github: z
54
148
  .object({
55
149
  pullRequests: z
@@ -181,6 +275,54 @@ export const PolicyConfigSchema = z.object({
181
275
  .default({
182
276
  documents: DEFAULT_DOCS_POLICY,
183
277
  }),
278
+ postgres: z
279
+ .object({
280
+ reads: z
281
+ .object({
282
+ enabled: z.boolean().default(true),
283
+ allowedDatabases: z.array(z.string().min(1)).default([]),
284
+ allowedTables: z.array(z.string().min(1)).default([]),
285
+ allowedFunctions: z.array(z.string().min(1)).default([]),
286
+ maxRows: z.number().int().positive().max(10_000).default(100),
287
+ requirePredicate: z.boolean().default(true),
288
+ })
289
+ .default(DEFAULT_POSTGRES_READ_POLICY),
290
+ })
291
+ .default({
292
+ reads: DEFAULT_POSTGRES_READ_POLICY,
293
+ }),
294
+ drive: z
295
+ .object({
296
+ reads: z
297
+ .object({
298
+ enabled: z.boolean().default(true),
299
+ allowedFileIds: z.array(z.string().min(1)).default([]),
300
+ maxReadBytes: z
301
+ .number()
302
+ .int()
303
+ .positive()
304
+ .max(10_000_000)
305
+ .default(100_000),
306
+ })
307
+ .default(DEFAULT_DRIVE_READ_POLICY),
308
+ })
309
+ .default({
310
+ reads: DEFAULT_DRIVE_READ_POLICY,
311
+ }),
312
+ provenance: z
313
+ .object({
314
+ enabled: z.boolean().default(false),
315
+ unknownDecision: z.literal("deny").default("deny"),
316
+ untrustedDecision: z.enum(["deny", "step_up"]).default("step_up"),
317
+ })
318
+ .default(DEFAULT_PROVENANCE_POLICY),
319
+ behavior: z
320
+ .object({
321
+ enabled: z.boolean().default(false),
322
+ firstSeen: z.boolean().default(true),
323
+ abnormalSequence: z.boolean().default(true),
324
+ })
325
+ .default(DEFAULT_BEHAVIOR_POLICY),
184
326
  });
185
327
  export const PolicyFixtureInputSchema = z.object({
186
328
  id: z.string().min(1).optional(),
@@ -196,20 +338,101 @@ export const DEFAULT_POLICY_FIXTURE_SOURCE = {
196
338
  label: "package defaults from @axtary/policy",
197
339
  path: null,
198
340
  };
341
+ const TEMPLATE_ACTOR = {
342
+ agentId: "agent:codex-prod",
343
+ humanOwner: "user:asrar@company.com",
344
+ runtime: "codex-cli",
345
+ tenant: "org:company",
346
+ };
347
+ function templateAction(taskId, declaredGoal, tool, resource, payload) {
348
+ return NormalizedActionSchema.parse({
349
+ schemaVersion: "axtary.action.v0",
350
+ actor: TEMPLATE_ACTOR,
351
+ intent: { taskId, declaredGoal },
352
+ capability: { tool, resource, payload },
353
+ });
354
+ }
355
+ const DISABLE_NON_REPO_POLICY = {
356
+ slack: { messages: { enabled: false } },
357
+ issueTrackers: {
358
+ linear: { enabled: false },
359
+ jira: { enabled: false },
360
+ },
361
+ cloud: {
362
+ aws: { enabled: false },
363
+ gcp: { enabled: false },
364
+ },
365
+ docs: { documents: { enabled: false } },
366
+ postgres: { reads: { enabled: false } },
367
+ drive: { reads: { enabled: false } },
368
+ mcp: { enabled: false },
369
+ };
370
+ const DISABLE_NON_CLOUD_POLICY = {
371
+ github: {
372
+ pullRequests: { enabled: false },
373
+ contents: { enabled: false },
374
+ },
375
+ slack: { messages: { enabled: false } },
376
+ issueTrackers: {
377
+ linear: { enabled: false },
378
+ jira: { enabled: false },
379
+ },
380
+ docs: { documents: { enabled: false } },
381
+ postgres: { reads: { enabled: false } },
382
+ drive: { reads: { enabled: false } },
383
+ mcp: { enabled: false },
384
+ };
385
+ const DISABLE_NON_TICKET_POLICY = {
386
+ github: {
387
+ pullRequests: { enabled: false },
388
+ contents: { enabled: false },
389
+ },
390
+ slack: { messages: { enabled: false } },
391
+ cloud: {
392
+ aws: { enabled: false },
393
+ gcp: { enabled: false },
394
+ },
395
+ docs: { documents: { enabled: false } },
396
+ postgres: { reads: { enabled: false } },
397
+ drive: { reads: { enabled: false } },
398
+ mcp: { enabled: false },
399
+ };
400
+ const DISABLE_NON_DOCS_POLICY = {
401
+ github: {
402
+ pullRequests: { enabled: false },
403
+ contents: { enabled: false },
404
+ },
405
+ slack: { messages: { enabled: false } },
406
+ issueTrackers: {
407
+ linear: { enabled: false },
408
+ jira: { enabled: false },
409
+ },
410
+ cloud: {
411
+ aws: { enabled: false },
412
+ gcp: { enabled: false },
413
+ },
414
+ postgres: { reads: { enabled: false } },
415
+ drive: { reads: { enabled: false } },
416
+ mcp: { enabled: false },
417
+ };
199
418
  export const POLICY_TEMPLATES = [
200
419
  {
201
- id: "coding-agent-repo-guardrails",
202
- name: "Coding agent repo guardrails",
420
+ id: "repo-only-coding",
421
+ name: "Repo-only coding",
203
422
  category: "coding_agent",
204
- summary: "Allow routine PR work while denying secret reads and stepping up auth, billing, and production paths.",
423
+ summary: "Allow routine repository work while denying secret reads and stepping up auth, billing, and production paths.",
205
424
  tools: [
206
425
  "github.pull_requests.create",
207
426
  "github.branches.create",
208
427
  "github.contents.read",
209
428
  "github.contents.write",
429
+ "github.pull_request_review_comments.create",
430
+ "github.check_runs.create",
431
+ "github.issue_comments.create",
210
432
  ],
211
433
  decisionModel: "step_up",
212
434
  config: {
435
+ ...DISABLE_NON_REPO_POLICY,
213
436
  github: {
214
437
  pullRequests: {
215
438
  requiredBaseBranch: "main",
@@ -228,12 +451,48 @@ export const POLICY_TEMPLATES = [
228
451
  "Matches the current coding-agent wedge.",
229
452
  "Protected path changes require exact approval evidence before execution.",
230
453
  ],
454
+ fixtures: [
455
+ PolicyFixtureInputSchema.parse({
456
+ id: "repo-only-coding-allow-pr",
457
+ name: "allow a routine PR inside repo coding scope",
458
+ expectedDecision: "allow",
459
+ expectedReasons: ["github_pr_inside_policy"],
460
+ action: templateAction("AXT-TEMPLATE-1", "Open a routine source PR", "github.pull_requests.create", "repo:company/web-app", {
461
+ baseBranch: "main",
462
+ filesChanged: ["src/app/page.tsx"],
463
+ testsPassed: true,
464
+ touchesProduction: false,
465
+ }),
466
+ }),
467
+ PolicyFixtureInputSchema.parse({
468
+ id: "repo-only-coding-deny-secret-read",
469
+ name: "deny a repository secret read",
470
+ expectedDecision: "deny",
471
+ expectedReasons: ["content_read_denied_path"],
472
+ action: templateAction("AXT-TEMPLATE-2", "Read files for a coding task", "github.contents.read", "repo:company/web-app", { path: ".env.production" }),
473
+ }),
474
+ PolicyFixtureInputSchema.parse({
475
+ id: "repo-only-coding-step-up-prod",
476
+ name: "step up protected production paths",
477
+ expectedDecision: "step_up",
478
+ expectedReasons: [
479
+ "payload_touches_step_up_path",
480
+ "payload_declares_production_impact",
481
+ ],
482
+ action: templateAction("AXT-TEMPLATE-3", "Touch production infrastructure", "github.pull_requests.create", "repo:company/web-app", {
483
+ baseBranch: "main",
484
+ filesChanged: ["infra/prod/main.tf"],
485
+ testsPassed: true,
486
+ touchesProduction: true,
487
+ }),
488
+ }),
489
+ ],
231
490
  },
232
491
  {
233
- id: "staging-cloud-read-only",
234
- name: "Staging cloud read-only",
492
+ id: "staging-reads",
493
+ name: "Staging reads",
235
494
  category: "cloud_read",
236
- summary: "Permit bounded AWS/GCP inventory and object listing only after account, project, region, and bucket scopes are pinned.",
495
+ summary: "Permit bounded AWS/GCP staging inventory and object listing only for pinned accounts, projects, regions, and buckets.",
237
496
  tools: [
238
497
  "aws.identity.get",
239
498
  "aws.s3.objects.list",
@@ -242,97 +501,202 @@ export const POLICY_TEMPLATES = [
242
501
  ],
243
502
  decisionModel: "deny_until_scoped",
244
503
  config: {
504
+ ...DISABLE_NON_CLOUD_POLICY,
245
505
  cloud: {
246
506
  aws: {
247
507
  enabled: true,
248
- allowedAccountIds: [],
508
+ allowedAccountIds: ["111122223333"],
249
509
  allowedRegions: ["us-east-1"],
250
- allowedS3Buckets: [],
510
+ allowedS3Buckets: ["axtary-staging-logs"],
251
511
  denyObjectKeyPrefixes: ["secrets/", ".env"],
252
512
  },
253
513
  gcp: {
254
514
  enabled: true,
255
- allowedProjectIds: [],
256
- allowedStorageBuckets: [],
515
+ allowedProjectIds: ["axtary-staging"],
516
+ allowedStorageBuckets: ["axtary-staging-logs"],
257
517
  denyObjectNamePrefixes: ["secrets/", ".env"],
258
518
  },
259
519
  },
260
520
  },
261
521
  evidence: ["accountId", "region", "projectId", "bucket", "prefix"],
262
522
  notes: [
263
- "Empty allowlists intentionally fail closed until customer scopes are configured.",
523
+ "The scaffold uses sample staging scopes so policy tests pass locally; replace them with your own staging account, project, and bucket identifiers before real mode.",
264
524
  "Smoke checks stay read/auth-only.",
265
525
  ],
526
+ fixtures: [
527
+ PolicyFixtureInputSchema.parse({
528
+ id: "staging-reads-allow-aws-s3",
529
+ name: "allow a scoped staging S3 list",
530
+ expectedDecision: "allow",
531
+ expectedReasons: ["aws_s3_read_inside_policy"],
532
+ action: templateAction("AXT-TEMPLATE-4", "Inspect staging logs", "aws.s3.objects.list", "aws:s3:::axtary-staging-logs", {
533
+ accountId: "111122223333",
534
+ region: "us-east-1",
535
+ bucket: "axtary-staging-logs",
536
+ prefix: "logs/",
537
+ }),
538
+ }),
539
+ PolicyFixtureInputSchema.parse({
540
+ id: "staging-reads-deny-secret-prefix",
541
+ name: "deny a secret prefix in staging storage",
542
+ expectedDecision: "deny",
543
+ expectedReasons: ["aws_s3_prefix_denied"],
544
+ action: templateAction("AXT-TEMPLATE-5", "Inspect staging secrets", "aws.s3.objects.list", "aws:s3:::axtary-staging-logs", {
545
+ accountId: "111122223333",
546
+ region: "us-east-1",
547
+ bucket: "axtary-staging-logs",
548
+ prefix: "secrets/",
549
+ }),
550
+ }),
551
+ ],
266
552
  },
267
553
  {
268
- id: "production-impact-step-up",
269
- name: "Production impact step-up",
270
- category: "production_change",
271
- summary: "Route production-impact declarations and protected infrastructure paths through approval.",
272
- tools: ["github.pull_requests.create", "github.contents.write"],
273
- decisionModel: "step_up",
554
+ id: "incident-investigation",
555
+ name: "Incident investigation",
556
+ category: "docs_read",
557
+ summary: "Allow read-only investigation across repository content, tickets, and local docs while denying secret paths.",
558
+ tools: [
559
+ "github.contents.read",
560
+ "linear.issues.read",
561
+ "jira.issues.read",
562
+ "docs.documents.search",
563
+ "docs.documents.read",
564
+ ],
565
+ decisionModel: "deny_until_scoped",
274
566
  config: {
567
+ slack: { messages: { enabled: false } },
568
+ cloud: {
569
+ aws: { enabled: false },
570
+ gcp: { enabled: false },
571
+ },
572
+ postgres: { reads: { enabled: false } },
573
+ drive: { reads: { enabled: false } },
574
+ mcp: { enabled: false },
275
575
  github: {
276
576
  pullRequests: {
277
- stepUpPathPrefixes: ["infra/prod/", "deploy/", "auth/", "billing/"],
278
- requiresTests: true,
577
+ enabled: false,
578
+ },
579
+ contents: {
580
+ enabled: true,
581
+ denyReadPathPrefixes: [".env", "secrets/", "infra/prod/"],
582
+ },
583
+ },
584
+ issueTrackers: {
585
+ linear: {
586
+ enabled: true,
587
+ allowedProjectKeys: ["AXT", "INC"],
588
+ stepUpStatuses: ["Done", "Closed"],
589
+ },
590
+ jira: {
591
+ enabled: true,
592
+ allowedProjectKeys: ["AXT", "INC"],
593
+ stepUpStatuses: ["Done", "Closed"],
594
+ },
595
+ },
596
+ docs: {
597
+ documents: {
598
+ enabled: true,
599
+ allowedWorkspaces: ["local"],
600
+ allowedPathPrefixes: ["README.md", "docs/", "product/"],
601
+ denyPathPrefixes: [".env", "secrets/", "private/"],
602
+ maxSearchResults: 10,
603
+ maxReadBytes: 20_000,
279
604
  },
280
605
  },
281
606
  },
282
- evidence: ["touchesProduction", "filesChanged", "diff", "payloadHash"],
607
+ evidence: ["issueKey", "projectKey", "path", "query", "payloadHash"],
283
608
  notes: [
284
- "The native evaluator already steps up PR payloads with touchesProduction=true.",
285
- "Future cloud write tools should attach the same production-impact evidence before execution.",
609
+ "Investigation scope is read-oriented; ticket completion and production changes use separate templates.",
610
+ "Repository and docs secret paths remain deny-only.",
286
611
  ],
287
- },
288
- {
289
- id: "iam-mutation-deny",
290
- name: "IAM mutation deny",
291
- category: "identity_mutation",
292
- summary: "Keep identity and credential mutations denied until a scoped connector and reviewer workflow exist.",
293
- tools: [
294
- "aws.iam.roles.update",
295
- "aws.iam.policies.attach",
296
- "gcp.iam.bindings.set",
297
- "gcp.serviceAccounts.keys.create",
298
- ],
299
- decisionModel: "deny",
300
- config: {},
301
- evidence: ["resource", "role", "principal", "payloadHash"],
302
- notes: [
303
- "These tools are not registered executable handlers yet; unsupported tools fail closed.",
304
- "When added, they should require reviewer permissions and short-lived/federated credentials.",
612
+ fixtures: [
613
+ PolicyFixtureInputSchema.parse({
614
+ id: "incident-investigation-allow-ticket-read",
615
+ name: "allow an incident ticket read",
616
+ expectedDecision: "allow",
617
+ expectedReasons: ["jira_issue_inside_policy"],
618
+ action: templateAction("AXT-TEMPLATE-6", "Read the incident ticket", "jira.issues.read", "jira:site/company", { issueKey: "INC-104", projectKey: "INC" }),
619
+ }),
620
+ PolicyFixtureInputSchema.parse({
621
+ id: "incident-investigation-deny-secret-read",
622
+ name: "deny secret repository content during investigation",
623
+ expectedDecision: "deny",
624
+ expectedReasons: ["content_read_denied_path"],
625
+ action: templateAction("AXT-TEMPLATE-7", "Read incident environment details", "github.contents.read", "repo:company/web-app", { path: "secrets/prod.env" }),
626
+ }),
305
627
  ],
306
628
  },
307
629
  {
308
- id: "external-slack-step-up",
309
- name: "External Slack step-up",
630
+ id: "ticket-updates",
631
+ name: "Ticket updates",
310
632
  category: "external_communication",
311
- summary: "Allow approved internal channels and require approval when a message reaches external recipients.",
312
- tools: ["slack.chat.postMessage"],
633
+ summary: "Allow scoped ticket reads and comments while denying out-of-project updates and stepping up terminal status changes.",
634
+ tools: [
635
+ "linear.issues.read",
636
+ "linear.comments.create",
637
+ "linear.issues.update",
638
+ "jira.issues.read",
639
+ "jira.comments.create",
640
+ "jira.issues.update",
641
+ ],
313
642
  decisionModel: "step_up",
314
643
  config: {
315
- slack: {
316
- messages: {
317
- allowedChannels: ["#axtary-dev"],
318
- stepUpExternalRecipients: true,
644
+ ...DISABLE_NON_TICKET_POLICY,
645
+ issueTrackers: {
646
+ linear: {
647
+ enabled: true,
648
+ allowedProjectKeys: ["AXT"],
649
+ stepUpStatuses: ["Done", "Closed"],
650
+ },
651
+ jira: {
652
+ enabled: true,
653
+ allowedProjectKeys: ["AXT"],
654
+ stepUpStatuses: ["Done", "Closed"],
319
655
  },
320
656
  },
321
657
  },
322
- evidence: ["channel", "message", "externalRecipients", "payloadHash"],
658
+ evidence: ["issueKey", "projectKey", "status", "body", "payloadHash"],
323
659
  notes: [
324
- "Exact message payload is approval evidence.",
325
- "Unapproved channels are denied rather than stepped up.",
660
+ "Comments and non-terminal edits inside the configured project are allowed.",
661
+ "Terminal statuses require reviewer approval; other projects are denied.",
662
+ ],
663
+ fixtures: [
664
+ PolicyFixtureInputSchema.parse({
665
+ id: "ticket-updates-allow-comment",
666
+ name: "allow a scoped Linear comment",
667
+ expectedDecision: "allow",
668
+ expectedReasons: ["linear_issue_inside_policy"],
669
+ action: templateAction("AXT-TEMPLATE-8", "Comment on the source ticket", "linear.comments.create", "linear:workspace/company", {
670
+ issueKey: "AXT-418",
671
+ projectKey: "AXT",
672
+ body: "Investigation complete; PR is linked.",
673
+ }),
674
+ }),
675
+ PolicyFixtureInputSchema.parse({
676
+ id: "ticket-updates-deny-other-project",
677
+ name: "deny a ticket update outside the configured project",
678
+ expectedDecision: "deny",
679
+ expectedReasons: ["jira_project_not_allowed"],
680
+ action: templateAction("AXT-TEMPLATE-9", "Update an unrelated ticket", "jira.issues.update", "jira:site/company", { issueKey: "SEC-9", projectKey: "SEC", status: "In Progress" }),
681
+ }),
682
+ PolicyFixtureInputSchema.parse({
683
+ id: "ticket-updates-step-up-done",
684
+ name: "step up terminal ticket status changes",
685
+ expectedDecision: "step_up",
686
+ expectedReasons: ["linear_status_requires_step_up"],
687
+ action: templateAction("AXT-TEMPLATE-10", "Mark the source ticket done", "linear.issues.update", "linear:workspace/company", { issueKey: "AXT-418", projectKey: "AXT", status: "Done" }),
688
+ }),
326
689
  ],
327
690
  },
328
691
  {
329
- id: "local-docs-read-scoped",
330
- name: "Local docs read scoped",
692
+ id: "doc-search",
693
+ name: "Doc search",
331
694
  category: "docs_read",
332
- summary: "Permit local docs search and read actions only inside configured workspaces, paths, result caps, and byte limits.",
695
+ summary: "Permit local documentation search and bounded reads only inside configured workspaces and paths.",
333
696
  tools: ["docs.documents.search", "docs.documents.read"],
334
697
  decisionModel: "deny_until_scoped",
335
698
  config: {
699
+ ...DISABLE_NON_DOCS_POLICY,
336
700
  docs: {
337
701
  documents: {
338
702
  enabled: true,
@@ -349,8 +713,101 @@ export const POLICY_TEMPLATES = [
349
713
  "Local document roots are configured adapter-side, while policy controls normalized action scope.",
350
714
  "Returned content should be bounded and redacted by the connector before the agent sees it.",
351
715
  ],
716
+ fixtures: [
717
+ PolicyFixtureInputSchema.parse({
718
+ id: "doc-search-allow-query",
719
+ name: "allow a bounded docs search",
720
+ expectedDecision: "allow",
721
+ expectedReasons: ["docs_search_inside_policy"],
722
+ action: templateAction("AXT-TEMPLATE-11", "Search product docs", "docs.documents.search", "docs:local", { workspace: "local", query: "ActionPass ledger", maxResults: 5 }),
723
+ }),
724
+ PolicyFixtureInputSchema.parse({
725
+ id: "doc-search-deny-private-read",
726
+ name: "deny a private docs path read",
727
+ expectedDecision: "deny",
728
+ expectedReasons: ["docs_path_denied"],
729
+ action: templateAction("AXT-TEMPLATE-12", "Read private planning notes", "docs.documents.read", "docs:local", { workspace: "local", path: "private/roadmap.md", maxBytes: 1000 }),
730
+ }),
731
+ ],
732
+ },
733
+ {
734
+ id: "guarded-prod",
735
+ name: "Guarded production changes",
736
+ category: "production_change",
737
+ summary: "Allow routine repository changes, deny secret paths, and require approval for production-impact changes.",
738
+ tools: ["github.pull_requests.create", "github.contents.write", "github.contents.read"],
739
+ decisionModel: "step_up",
740
+ config: {
741
+ ...DISABLE_NON_REPO_POLICY,
742
+ github: {
743
+ pullRequests: {
744
+ requiredBaseBranch: "main",
745
+ maxFilesChanged: 8,
746
+ denyPathPrefixes: [".env", "secrets/"],
747
+ stepUpPathPrefixes: ["infra/prod/", "deploy/", "auth/", "billing/"],
748
+ requiresTests: true,
749
+ },
750
+ contents: {
751
+ denyReadPathPrefixes: [".env", "secrets/", "infra/prod/"],
752
+ },
753
+ },
754
+ },
755
+ evidence: ["touchesProduction", "filesChanged", "diff", "payloadHash"],
756
+ notes: [
757
+ "The native evaluator steps up PR payloads with touchesProduction=true.",
758
+ "Approval binds the exact production-impact payload before execution.",
759
+ ],
760
+ fixtures: [
761
+ PolicyFixtureInputSchema.parse({
762
+ id: "guarded-prod-allow-app-pr",
763
+ name: "allow a tested non-production PR",
764
+ expectedDecision: "allow",
765
+ expectedReasons: ["github_pr_inside_policy"],
766
+ action: templateAction("AXT-TEMPLATE-13", "Ship an application fix", "github.pull_requests.create", "repo:company/web-app", {
767
+ baseBranch: "main",
768
+ filesChanged: ["src/lib/cache.ts"],
769
+ testsPassed: true,
770
+ touchesProduction: false,
771
+ }),
772
+ }),
773
+ PolicyFixtureInputSchema.parse({
774
+ id: "guarded-prod-deny-secret-write",
775
+ name: "deny a secret file write",
776
+ expectedDecision: "deny",
777
+ expectedReasons: ["payload_touches_denied_path"],
778
+ action: templateAction("AXT-TEMPLATE-14", "Write a production secret", "github.contents.write", "repo:company/web-app", {
779
+ path: ".env.production",
780
+ branch: "main",
781
+ content: "SECRET=value",
782
+ message: "update production env",
783
+ }),
784
+ }),
785
+ PolicyFixtureInputSchema.parse({
786
+ id: "guarded-prod-step-up-prod-pr",
787
+ name: "step up production-impact PRs",
788
+ expectedDecision: "step_up",
789
+ expectedReasons: [
790
+ "payload_touches_step_up_path",
791
+ "payload_declares_production_impact",
792
+ ],
793
+ action: templateAction("AXT-TEMPLATE-15", "Change production deployment config", "github.pull_requests.create", "repo:company/web-app", {
794
+ baseBranch: "main",
795
+ filesChanged: ["deploy/prod.yml"],
796
+ testsPassed: true,
797
+ touchesProduction: true,
798
+ }),
799
+ }),
800
+ ],
352
801
  },
353
802
  ];
803
+ const POLICY_TEMPLATE_ALIASES = {
804
+ "coding-agent-repo-guardrails": "repo-only-coding",
805
+ "staging-cloud-read-only": "staging-reads",
806
+ "production-impact-step-up": "guarded-prod",
807
+ "iam-mutation-deny": "guarded-prod",
808
+ "external-slack-step-up": "ticket-updates",
809
+ "local-docs-read-scoped": "doc-search",
810
+ };
354
811
  export const DEFAULT_POLICY_REVIEW_FIXTURES = [
355
812
  PolicyFixtureInputSchema.parse({
356
813
  id: "allow-github-pr",
@@ -440,7 +897,8 @@ export function getPolicyTemplates() {
440
897
  return POLICY_TEMPLATES;
441
898
  }
442
899
  export function getPolicyTemplate(id) {
443
- return POLICY_TEMPLATES.find((template) => template.id === id) ?? null;
900
+ const resolvedId = POLICY_TEMPLATE_ALIASES[id] ?? id;
901
+ return POLICY_TEMPLATES.find((template) => template.id === resolvedId) ?? null;
444
902
  }
445
903
  export function mergePolicyConfig(baseInput = {}, patchInput = {}) {
446
904
  const base = PolicyConfigSchema.parse(baseInput);
@@ -454,27 +912,6 @@ export function applyPolicyTemplate(baseInput, templateId) {
454
912
  }
455
913
  return mergePolicyConfig(baseInput, template.config);
456
914
  }
457
- export function explainPolicyDecision(decisionInput) {
458
- const reviewerAction = decisionInput.decision === "allow"
459
- ? "none"
460
- : decisionInput.decision === "step_up"
461
- ? "approval_required"
462
- : "block";
463
- const label = decisionInput.decision === "allow"
464
- ? "Allowed"
465
- : decisionInput.decision === "step_up"
466
- ? "Step-up required"
467
- : "Denied";
468
- return {
469
- label,
470
- reviewerAction,
471
- summary: decisionSummary(decisionInput),
472
- reasonDetails: decisionInput.reasons.map((reason) => ({
473
- reason,
474
- detail: reasonDetail(reason),
475
- })),
476
- };
477
- }
478
915
  export function runPolicyFixtures(configInput = {}, fixtureInputs = DEFAULT_POLICY_REVIEW_FIXTURES) {
479
916
  const config = PolicyConfigSchema.parse(configInput);
480
917
  return fixtureInputs.map((fixtureInput, index) => {
@@ -495,7 +932,7 @@ export function runPolicyFixtures(configInput = {}, fixtureInputs = DEFAULT_POLI
495
932
  sourcePath: fixture.sourcePath ?? null,
496
933
  passed: decisionResult.decision === fixture.expectedDecision && reasonsPassed,
497
934
  decision: decisionResult,
498
- explanation: explainPolicyDecision(decisionResult),
935
+ explanation: explainDecision(decisionResult),
499
936
  cedar: toCedarRequest(action),
500
937
  opa: toOpaInput(action),
501
938
  };
@@ -563,9 +1000,38 @@ export function previewPolicyTemplates(configInput = {}, templates = getPolicyTe
563
1000
  };
564
1001
  });
565
1002
  }
566
- export function evaluatePolicy(actionInput, configInput = {}) {
1003
+ export function evaluatePolicy(actionInput, configInput = {}, context = {}) {
567
1004
  const action = NormalizedActionSchema.parse(actionInput);
568
1005
  const config = PolicyConfigSchema.parse(configInput);
1006
+ const baseDecision = config.rules.length > 0
1007
+ ? evaluateRulePolicy(action, config, context).decision
1008
+ : evaluateBuiltinPolicy(action, config);
1009
+ return applyBehaviorPolicy(config, context, applyProvenancePolicy(action, config, baseDecision));
1010
+ }
1011
+ export function simulatePolicy(actionInput, configInput = {}, context = {}) {
1012
+ const action = NormalizedActionSchema.parse(actionInput);
1013
+ const config = PolicyConfigSchema.parse(configInput);
1014
+ if (config.rules.length === 0) {
1015
+ return {
1016
+ schemaVersion: POLICY_SCHEMA_VERSION,
1017
+ action,
1018
+ mode: "builtin_preset",
1019
+ defaultDecision: "deny",
1020
+ decision: applyProvenancePolicy(action, config, evaluateBuiltinPolicy(action, config)),
1021
+ rules: [],
1022
+ };
1023
+ }
1024
+ const evaluated = evaluateRulePolicy(action, config, context);
1025
+ return {
1026
+ schemaVersion: POLICY_SCHEMA_VERSION,
1027
+ action,
1028
+ mode: "rules",
1029
+ defaultDecision: "deny",
1030
+ decision: applyBehaviorPolicy(config, context, applyProvenancePolicy(action, config, evaluated.decision)),
1031
+ rules: evaluated.traces,
1032
+ };
1033
+ }
1034
+ function evaluateBuiltinPolicy(action, config) {
569
1035
  switch (action.capability.tool) {
570
1036
  case "github.pull_requests.create":
571
1037
  return evaluateGitHubPullRequest(action, config);
@@ -575,6 +1041,12 @@ export function evaluatePolicy(actionInput, configInput = {}) {
575
1041
  return evaluateGitHubContentWrite(action, config);
576
1042
  case "github.branches.create":
577
1043
  return evaluateGitHubBranchCreate(action, config);
1044
+ case "github.pull_request_review_comments.create":
1045
+ return evaluateGitHubReviewComment(action, config);
1046
+ case "github.check_runs.create":
1047
+ return evaluateGitHubCheckRun(action, config);
1048
+ case "github.issue_comments.create":
1049
+ return evaluateGitHubIssueComment(action, config);
578
1050
  case "slack.chat.postMessage":
579
1051
  return evaluateSlackMessage(action, config);
580
1052
  case "linear.issues.read":
@@ -596,6 +1068,10 @@ export function evaluatePolicy(actionInput, configInput = {}) {
596
1068
  return evaluateDocsDocument(action, config);
597
1069
  case "mcp.tool.call":
598
1070
  return evaluateMcpToolCall(action, config);
1071
+ case "postgres.query.select":
1072
+ return evaluatePostgresSelect(action, config);
1073
+ case "drive.files.read":
1074
+ return evaluateDriveFileRead(action, config);
599
1075
  default:
600
1076
  return decision("deny", [`unsupported_tool:${action.capability.tool}`], config, {
601
1077
  maxFilesChanged: 0,
@@ -603,6 +1079,280 @@ export function evaluatePolicy(actionInput, configInput = {}) {
603
1079
  });
604
1080
  }
605
1081
  }
1082
+ function evaluateDriveFileRead(action, config) {
1083
+ const policy = config.drive.reads;
1084
+ const fileId = getString(action.capability.payload.fileId);
1085
+ const maxBytes = getPositiveInteger(action.capability.payload.maxBytes);
1086
+ if (!policy.enabled) {
1087
+ return decision("deny", ["drive_reads_disabled"], config, {
1088
+ maxFilesChanged: 0,
1089
+ blockedPaths: [],
1090
+ });
1091
+ }
1092
+ if (!fileId || !maxBytes) {
1093
+ return decision("deny", ["drive_read_payload_invalid"], config, {
1094
+ maxFilesChanged: 0,
1095
+ blockedPaths: [],
1096
+ });
1097
+ }
1098
+ if (action.capability.resource !== `drive:file:${fileId}`) {
1099
+ return decision("deny", ["drive_file_resource_mismatch"], config, {
1100
+ maxFilesChanged: 0,
1101
+ blockedPaths: [],
1102
+ });
1103
+ }
1104
+ if (!policy.allowedFileIds.includes(fileId)) {
1105
+ return decision("deny", ["drive_file_not_allowed"], config, {
1106
+ maxFilesChanged: 0,
1107
+ blockedPaths: [],
1108
+ });
1109
+ }
1110
+ if (maxBytes > policy.maxReadBytes) {
1111
+ return decision("deny", ["drive_read_byte_limit_exceeded"], config, {
1112
+ maxFilesChanged: 0,
1113
+ blockedPaths: [],
1114
+ });
1115
+ }
1116
+ return decision("allow", ["drive_file_inside_policy"], config, {
1117
+ maxFilesChanged: 0,
1118
+ blockedPaths: [],
1119
+ });
1120
+ }
1121
+ function evaluatePostgresSelect(action, config) {
1122
+ const policy = config.postgres.reads;
1123
+ const payload = action.capability.payload;
1124
+ const statement = getString(payload.statement);
1125
+ const parameters = Array.isArray(payload.parameters) ? payload.parameters : null;
1126
+ const maxRows = getPositiveInteger(payload.maxRows);
1127
+ if (!policy.enabled) {
1128
+ return decision("deny", ["postgres_reads_disabled"], config, {
1129
+ maxFilesChanged: 0,
1130
+ blockedPaths: [],
1131
+ });
1132
+ }
1133
+ if (!policy.allowedDatabases.includes(action.capability.resource)) {
1134
+ return decision("deny", ["postgres_database_not_allowed"], config, {
1135
+ maxFilesChanged: 0,
1136
+ blockedPaths: [],
1137
+ });
1138
+ }
1139
+ if (!statement || !parameters || !maxRows) {
1140
+ return decision("deny", ["postgres_query_payload_invalid"], config, {
1141
+ maxFilesChanged: 0,
1142
+ blockedPaths: [],
1143
+ });
1144
+ }
1145
+ const analyzed = analyzePostgresSelect(statement);
1146
+ if (!analyzed.ok) {
1147
+ return decision("deny", [analyzed.reason], config, {
1148
+ maxFilesChanged: 0,
1149
+ blockedPaths: [],
1150
+ });
1151
+ }
1152
+ const { analysis } = analyzed;
1153
+ if (analysis.parameterCount !== parameters.length) {
1154
+ return decision("deny", ["postgres_parameter_count_mismatch"], config, {
1155
+ maxFilesChanged: 0,
1156
+ blockedPaths: [],
1157
+ });
1158
+ }
1159
+ if (analysis.tables.some((table) => !policy.allowedTables.includes(table))) {
1160
+ return decision("deny", ["postgres_table_not_allowed"], config, {
1161
+ maxFilesChanged: 0,
1162
+ blockedPaths: analysis.tables,
1163
+ });
1164
+ }
1165
+ if (analysis.tables.some((table) => !table.includes("."))) {
1166
+ return decision("deny", ["postgres_table_must_be_schema_qualified"], config, {
1167
+ maxFilesChanged: 0,
1168
+ blockedPaths: analysis.tables,
1169
+ });
1170
+ }
1171
+ if (analysis.functions.some((functionName) => !policy.allowedFunctions.includes(functionName))) {
1172
+ return decision("deny", ["postgres_function_not_allowed"], config, {
1173
+ maxFilesChanged: 0,
1174
+ blockedPaths: analysis.functions,
1175
+ });
1176
+ }
1177
+ if (maxRows > policy.maxRows) {
1178
+ return decision("deny", ["postgres_row_limit_exceeded"], config, {
1179
+ maxFilesChanged: 0,
1180
+ blockedPaths: [],
1181
+ });
1182
+ }
1183
+ if (policy.requirePredicate && !analysis.hasPredicate) {
1184
+ return decision("step_up", ["postgres_unpredicated_scan_step_up"], config, {
1185
+ maxFilesChanged: 0,
1186
+ blockedPaths: [],
1187
+ });
1188
+ }
1189
+ return decision("allow", ["postgres_select_inside_policy"], config, {
1190
+ maxFilesChanged: 0,
1191
+ blockedPaths: analysis.tables,
1192
+ });
1193
+ }
1194
+ function applyProvenancePolicy(action, config, baseDecision) {
1195
+ if (baseDecision.decision === "deny")
1196
+ return baseDecision;
1197
+ const guard = evaluateProvenanceGuard(action, config);
1198
+ if (!guard)
1199
+ return baseDecision;
1200
+ if (guard.decision === "deny") {
1201
+ return { ...baseDecision, decision: "deny", reasons: [guard.reason] };
1202
+ }
1203
+ return {
1204
+ ...baseDecision,
1205
+ decision: "step_up",
1206
+ reasons: [...new Set([...baseDecision.reasons, guard.reason])],
1207
+ };
1208
+ }
1209
+ /**
1210
+ * Escalate-only behavior guard (M5.6). Maps the proxy-supplied novelty/sequence
1211
+ * signals to `step_up` reason codes. It may ONLY raise an `allow` to `step_up`;
1212
+ * it never returns `allow` and never weakens a `deny`/`step_up`. This is the
1213
+ * code-level enforcement of the PRD §4 fence: a signal can escalate, never
1214
+ * authorize.
1215
+ */
1216
+ function applyBehaviorPolicy(config, context, decision) {
1217
+ if (decision.decision !== "allow")
1218
+ return decision;
1219
+ const reasons = behaviorStepUpReasons(config, context.behaviorSignals);
1220
+ if (reasons.length === 0)
1221
+ return decision;
1222
+ return {
1223
+ ...decision,
1224
+ decision: "step_up",
1225
+ reasons: [...new Set([...decision.reasons, ...reasons])],
1226
+ };
1227
+ }
1228
+ function behaviorStepUpReasons(config, signals) {
1229
+ if (!config.behavior.enabled || !signals)
1230
+ return [];
1231
+ const reasons = [];
1232
+ if (config.behavior.firstSeen && signals.firstSeenCombo) {
1233
+ reasons.push("behavior_first_seen");
1234
+ }
1235
+ if (config.behavior.abnormalSequence && signals.abnormalSequence) {
1236
+ reasons.push("behavior_abnormal_sequence");
1237
+ }
1238
+ return reasons;
1239
+ }
1240
+ /**
1241
+ * The step-up reason codes the behavior guard would emit for the given signals
1242
+ * and config. Read-only; used by the proxy/tests to inspect the guard without
1243
+ * a full policy evaluation.
1244
+ */
1245
+ export function evaluateBehaviorGuard(configInput = {}, signals = undefined) {
1246
+ const config = PolicyConfigSchema.parse(configInput);
1247
+ return behaviorStepUpReasons(config, signals);
1248
+ }
1249
+ export function evaluateProvenanceGuard(actionInput, configInput = {}) {
1250
+ const action = NormalizedActionSchema.parse(actionInput);
1251
+ const config = PolicyConfigSchema.parse(configInput);
1252
+ if (!config.provenance.enabled)
1253
+ return null;
1254
+ const fields = PROVENANCE_COVERAGE[action.capability.tool];
1255
+ if (!fields)
1256
+ return null;
1257
+ const presentFields = fields.filter((field) => action.capability.payload[field] !== undefined);
1258
+ if (presentFields.length === 0)
1259
+ return null;
1260
+ const provenance = action.provenance;
1261
+ if (!provenance) {
1262
+ return { decision: "deny", reason: "provenance_unknown_egress" };
1263
+ }
1264
+ const sourcesById = new Map(provenance.sources.map((source) => [source.id, source]));
1265
+ let untrusted = false;
1266
+ for (const field of presentFields) {
1267
+ const path = `/capability/payload/${field}`;
1268
+ const binding = provenance.bindings.find((entry) => entry.path === path);
1269
+ if (!binding) {
1270
+ return { decision: "deny", reason: "provenance_unknown_egress" };
1271
+ }
1272
+ const sources = binding.sourceIds.map((id) => sourcesById.get(id));
1273
+ if (sources.some((source) => !source || source.integrity === "unknown")) {
1274
+ return { decision: "deny", reason: "provenance_unknown_egress" };
1275
+ }
1276
+ if (sources.some((source) => source?.integrity === "untrusted")) {
1277
+ untrusted = true;
1278
+ }
1279
+ }
1280
+ if (!untrusted)
1281
+ return null;
1282
+ return {
1283
+ decision: config.provenance.untrustedDecision,
1284
+ reason: "provenance_untrusted_egress",
1285
+ };
1286
+ }
1287
+ function evaluateRulePolicy(action, config, context) {
1288
+ const traces = config.rules.map((rule) => traceRule(rule, action));
1289
+ const matching = config.rules.filter((rule, index) => rule.enabled && traces[index]?.matched);
1290
+ if (matching.length === 0) {
1291
+ return {
1292
+ decision: decision(config.defaultDecision, ["policy_default_deny"], config, { maxFilesChanged: 0, blockedPaths: [] }, { ruleId: "default_deny", matchedRuleIds: [] }),
1293
+ traces,
1294
+ };
1295
+ }
1296
+ const selectedEffect = matching.some((rule) => rule.effect === "deny")
1297
+ ? "deny"
1298
+ : matching.some((rule) => rule.effect === "step_up")
1299
+ ? "step_up"
1300
+ : matching.some((rule) => rule.effect === "allow" &&
1301
+ rule.obligations.requiredApproverRoles.length > 0)
1302
+ ? "step_up"
1303
+ : "allow";
1304
+ const determining = matching.filter((rule) => {
1305
+ if (selectedEffect === "deny")
1306
+ return rule.effect === "deny";
1307
+ if (selectedEffect === "step_up") {
1308
+ return (rule.effect === "step_up" ||
1309
+ (rule.effect === "allow" &&
1310
+ rule.obligations.requiredApproverRoles.length > 0));
1311
+ }
1312
+ return rule.effect === "allow";
1313
+ });
1314
+ const obligations = mergeRuleObligations(matching);
1315
+ const blockedPath = actionPaths(action).find((path) => obligations.blockedPaths.some((prefix) => path.startsWith(prefix)));
1316
+ const now = context.now ?? new Date();
1317
+ const timeWindowClosed = obligations.timeWindow && !insideTimeWindow(now, obligations.timeWindow);
1318
+ const rateLimitExceeded = obligations.rateLimit &&
1319
+ (context.rateLimitCounts?.[obligations.rateLimit.ruleId] ?? 0) >=
1320
+ obligations.rateLimit.maxActions;
1321
+ let effect = selectedEffect;
1322
+ const reasons = determining.map((rule) => rule.reason ?? `policy_rule:${rule.id}`);
1323
+ if (blockedPath) {
1324
+ effect = "deny";
1325
+ reasons.splice(0, reasons.length, `obligation_blocked_path:${blockedPath}`);
1326
+ }
1327
+ else if (timeWindowClosed) {
1328
+ effect = "deny";
1329
+ reasons.splice(0, reasons.length, "obligation_time_window_closed");
1330
+ }
1331
+ else if (rateLimitExceeded) {
1332
+ effect = "deny";
1333
+ reasons.splice(0, reasons.length, "obligation_rate_limit_exceeded");
1334
+ }
1335
+ else if (effect === "step_up" &&
1336
+ obligations.requiredApproverRoles.length > 0 &&
1337
+ !reasons.includes("required_approver_role")) {
1338
+ reasons.push("required_approver_role");
1339
+ }
1340
+ const primaryRule = determining[0] ?? matching[0];
1341
+ return {
1342
+ decision: decision(effect, reasons, config, {
1343
+ maxFilesChanged: 0,
1344
+ blockedPaths: obligations.blockedPaths,
1345
+ }, {
1346
+ ruleId: primaryRule.id,
1347
+ matchedRuleIds: matching.map((rule) => rule.id),
1348
+ expiresInSeconds: obligations.expiresInSeconds,
1349
+ requiredApproverRoles: obligations.requiredApproverRoles,
1350
+ timeWindow: obligations.timeWindow,
1351
+ rateLimit: obligations.rateLimit,
1352
+ }),
1353
+ traces,
1354
+ };
1355
+ }
606
1356
  function evaluateGitHubContentWrite(action, config) {
607
1357
  const policy = config.github.pullRequests;
608
1358
  const path = getString(action.capability.payload.path);
@@ -657,6 +1407,86 @@ function evaluateGitHubBranchCreate(action, config) {
657
1407
  blockedPaths: [],
658
1408
  });
659
1409
  }
1410
+ function evaluateGitHubReviewComment(action, config) {
1411
+ const policy = config.github.pullRequests;
1412
+ const payload = action.capability.payload;
1413
+ const path = getString(payload.path);
1414
+ const body = getString(payload.body);
1415
+ const commitId = getString(payload.commitId);
1416
+ const side = getString(payload.side);
1417
+ const pullNumber = getPositiveInteger(payload.pullNumber);
1418
+ const line = getPositiveInteger(payload.line);
1419
+ if (!path || !body || !commitId || !pullNumber || !line) {
1420
+ return decision("deny", ["github_review_comment_payload_invalid"], config, {
1421
+ maxFilesChanged: 0,
1422
+ blockedPaths: policy.denyPathPrefixes,
1423
+ });
1424
+ }
1425
+ if (side !== "LEFT" && side !== "RIGHT") {
1426
+ return decision("deny", ["github_review_comment_side_invalid"], config, {
1427
+ maxFilesChanged: 0,
1428
+ blockedPaths: policy.denyPathPrefixes,
1429
+ });
1430
+ }
1431
+ if (pathsMatch([path], policy.denyPathPrefixes)) {
1432
+ return decision("deny", ["payload_touches_denied_path"], config, {
1433
+ maxFilesChanged: 0,
1434
+ blockedPaths: policy.denyPathPrefixes,
1435
+ });
1436
+ }
1437
+ if (pathsMatch([path], policy.stepUpPathPrefixes)) {
1438
+ return decision("step_up", ["github_review_protected_path_step_up"], config, {
1439
+ maxFilesChanged: 0,
1440
+ blockedPaths: [
1441
+ ...policy.denyPathPrefixes,
1442
+ ...policy.stepUpPathPrefixes,
1443
+ ],
1444
+ });
1445
+ }
1446
+ return decision("allow", ["github_review_comment_inside_policy"], config, {
1447
+ maxFilesChanged: 0,
1448
+ blockedPaths: [
1449
+ ...policy.denyPathPrefixes,
1450
+ ...policy.stepUpPathPrefixes,
1451
+ ],
1452
+ });
1453
+ }
1454
+ function evaluateGitHubCheckRun(action, config) {
1455
+ const payload = action.capability.payload;
1456
+ const name = getString(payload.name);
1457
+ const headSha = getString(payload.headSha);
1458
+ const status = getString(payload.status);
1459
+ const conclusion = getString(payload.conclusion);
1460
+ if (!name || !headSha) {
1461
+ return decision("deny", ["github_check_run_payload_invalid"], config, {
1462
+ maxFilesChanged: 0,
1463
+ blockedPaths: [],
1464
+ });
1465
+ }
1466
+ if (status === "completed" && !conclusion) {
1467
+ return decision("deny", ["github_check_run_conclusion_required"], config, {
1468
+ maxFilesChanged: 0,
1469
+ blockedPaths: [],
1470
+ });
1471
+ }
1472
+ return decision("allow", ["github_check_run_inside_policy"], config, {
1473
+ maxFilesChanged: 0,
1474
+ blockedPaths: [],
1475
+ });
1476
+ }
1477
+ function evaluateGitHubIssueComment(action, config) {
1478
+ const payload = action.capability.payload;
1479
+ if (!getPositiveInteger(payload.issueNumber) || !getString(payload.body)) {
1480
+ return decision("deny", ["github_issue_comment_payload_invalid"], config, {
1481
+ maxFilesChanged: 0,
1482
+ blockedPaths: [],
1483
+ });
1484
+ }
1485
+ return decision("allow", ["github_issue_comment_inside_policy"], config, {
1486
+ maxFilesChanged: 0,
1487
+ blockedPaths: [],
1488
+ });
1489
+ }
660
1490
  export function toCedarRequest(actionInput) {
661
1491
  const action = NormalizedActionSchema.parse(actionInput);
662
1492
  return {
@@ -1050,7 +1880,7 @@ function evaluateMcpToolCall(action, config) {
1050
1880
  blockedPaths: [],
1051
1881
  });
1052
1882
  }
1053
- function decision(value, reasons, config, constraints) {
1883
+ function decision(value, reasons, config, constraints, options) {
1054
1884
  return {
1055
1885
  decision: value,
1056
1886
  reasons,
@@ -1061,12 +1891,128 @@ function decision(value, reasons, config, constraints) {
1061
1891
  opaCompatible: true,
1062
1892
  },
1063
1893
  constraints: {
1064
- expiresInSeconds: DEFAULT_EXPIRES_IN_SECONDS,
1894
+ expiresInSeconds: options?.expiresInSeconds ?? DEFAULT_EXPIRES_IN_SECONDS,
1065
1895
  maxFilesChanged: constraints.maxFilesChanged,
1066
1896
  blockedPaths: constraints.blockedPaths,
1067
1897
  },
1898
+ rule: options
1899
+ ? {
1900
+ id: options.ruleId,
1901
+ matchedRuleIds: options.matchedRuleIds,
1902
+ }
1903
+ : undefined,
1904
+ obligations: options
1905
+ ? {
1906
+ requiredApproverRoles: options.requiredApproverRoles ?? [],
1907
+ timeWindow: options.timeWindow,
1908
+ rateLimit: options.rateLimit,
1909
+ }
1910
+ : undefined,
1911
+ };
1912
+ }
1913
+ function traceRule(rule, action) {
1914
+ const mismatches = [];
1915
+ if (!rule.enabled) {
1916
+ mismatches.push("rule_disabled");
1917
+ }
1918
+ if (rule.match.tools &&
1919
+ !matchesAny(action.capability.tool, asMatchers(rule.match.tools))) {
1920
+ mismatches.push("tool");
1921
+ }
1922
+ if (rule.match.resources &&
1923
+ !matchesAny(action.capability.resource, asMatchers(rule.match.resources))) {
1924
+ mismatches.push("resource");
1925
+ }
1926
+ const actor = rule.match.actors;
1927
+ if (actor?.agentIds && !matchesAny(action.actor.agentId, asMatchers(actor.agentIds))) {
1928
+ mismatches.push("actor.agentId");
1929
+ }
1930
+ if (actor?.humanOwners &&
1931
+ !matchesAny(action.actor.humanOwner, asMatchers(actor.humanOwners))) {
1932
+ mismatches.push("actor.humanOwner");
1933
+ }
1934
+ if (actor?.runtimes &&
1935
+ !matchesAny(action.actor.runtime, asMatchers(actor.runtimes))) {
1936
+ mismatches.push("actor.runtime");
1937
+ }
1938
+ if (actor?.tenants &&
1939
+ !matchesAny(action.actor.tenant ?? "", asMatchers(actor.tenants))) {
1940
+ mismatches.push("actor.tenant");
1941
+ }
1942
+ return {
1943
+ ruleId: rule.id,
1944
+ matched: mismatches.length === 0,
1945
+ effect: rule.effect,
1946
+ mismatches,
1947
+ obligations: rule.obligations,
1068
1948
  };
1069
1949
  }
1950
+ function asMatchers(value) {
1951
+ return Array.isArray(value) ? value : [value];
1952
+ }
1953
+ function matchesAny(value, patterns) {
1954
+ return patterns.some((pattern) => wildcardMatch(value, pattern));
1955
+ }
1956
+ function wildcardMatch(value, pattern) {
1957
+ if (pattern === "*") {
1958
+ return true;
1959
+ }
1960
+ const expression = pattern
1961
+ .split("*")
1962
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
1963
+ .join(".*");
1964
+ return new RegExp(`^${expression}$`).test(value);
1965
+ }
1966
+ function actionPaths(action) {
1967
+ return [
1968
+ ...getStringArray(action.capability.payload.filesChanged),
1969
+ ...getStringArray(action.capability.payload.paths),
1970
+ getString(action.capability.payload.path),
1971
+ getString(action.capability.payload.prefix),
1972
+ ].filter((value) => Boolean(value));
1973
+ }
1974
+ function mergeRuleObligations(rules) {
1975
+ const expires = rules
1976
+ .map((rule) => rule.obligations.expiresInSeconds)
1977
+ .filter((value) => value !== undefined);
1978
+ const timeWindows = rules
1979
+ .map((rule) => rule.obligations.timeWindow)
1980
+ .filter((value) => value !== undefined);
1981
+ const rateLimits = rules.flatMap((rule) => rule.obligations.rateLimit
1982
+ ? [
1983
+ {
1984
+ ruleId: rule.id,
1985
+ ...rule.obligations.rateLimit,
1986
+ },
1987
+ ]
1988
+ : []);
1989
+ return {
1990
+ expiresInSeconds: expires.length > 0 ? Math.min(...expires) : DEFAULT_EXPIRES_IN_SECONDS,
1991
+ requiredApproverRoles: [
1992
+ ...new Set(rules.flatMap((rule) => rule.obligations.requiredApproverRoles)),
1993
+ ].sort(),
1994
+ blockedPaths: [
1995
+ ...new Set(rules.flatMap((rule) => rule.obligations.blockedPathPrefixes)),
1996
+ ].sort(),
1997
+ timeWindow: timeWindows[0],
1998
+ rateLimit: rateLimits.sort((left, right) => left.maxActions / left.windowSeconds -
1999
+ right.maxActions / right.windowSeconds)[0],
2000
+ };
2001
+ }
2002
+ function insideTimeWindow(now, window) {
2003
+ if (window.daysOfWeek.length > 0 &&
2004
+ !window.daysOfWeek.includes(now.getUTCDay())) {
2005
+ return false;
2006
+ }
2007
+ const hour = now.getUTCHours();
2008
+ if (window.startHourUtc === window.endHourUtc) {
2009
+ return true;
2010
+ }
2011
+ if (window.startHourUtc < window.endHourUtc) {
2012
+ return hour >= window.startHourUtc && hour < window.endHourUtc;
2013
+ }
2014
+ return hour >= window.startHourUtc || hour < window.endHourUtc;
2015
+ }
1070
2016
  function resourceToCedar(resource) {
1071
2017
  const [kind, id] = resource.split(":", 2);
1072
2018
  return {
@@ -1083,87 +2029,6 @@ function isUnsafeRelativePath(path) {
1083
2029
  }
1084
2030
  return path.split(/[\\/]+/).some((segment) => segment === ".." || segment === "");
1085
2031
  }
1086
- function decisionSummary(decisionInput) {
1087
- if (decisionInput.decision === "allow") {
1088
- return "The normalized action is inside the active policy and can proceed through ActionPass issuance.";
1089
- }
1090
- if (decisionInput.decision === "step_up") {
1091
- return "The normalized action is in scope only after exact payload-bound reviewer approval.";
1092
- }
1093
- return "The normalized action is outside the active policy and must not execute.";
1094
- }
1095
- function reasonDetail(reason) {
1096
- if (reason === "github_pr_inside_policy") {
1097
- return "Base branch, changed paths, file count, and test evidence satisfy the GitHub PR rule.";
1098
- }
1099
- if (reason === "github_content_write_inside_policy") {
1100
- return "The write path and branch are inside the configured repository guardrails.";
1101
- }
1102
- if (reason === "github_branch_inside_policy") {
1103
- return "The branch creation request is tied to the configured base branch.";
1104
- }
1105
- if (reason === "content_read_denied_path") {
1106
- return "The requested file path matches a protected content-read prefix.";
1107
- }
1108
- if (reason === "payload_touches_denied_path") {
1109
- return "The payload touches a path prefix that policy blocks outright.";
1110
- }
1111
- if (reason === "payload_touches_step_up_path") {
1112
- return "The payload touches a protected path prefix that requires exact review.";
1113
- }
1114
- if (reason === "file_count_exceeds_policy_limit") {
1115
- return "The PR changes more files than the active policy allows without review.";
1116
- }
1117
- if (reason === "tests_required_before_pr") {
1118
- return "The PR payload does not include passing test evidence required by policy.";
1119
- }
1120
- if (reason === "payload_declares_production_impact") {
1121
- return "The action declares production impact, so reviewer approval is required.";
1122
- }
1123
- if (reason === "slack_channel_not_allowed") {
1124
- return "The Slack channel is not in the configured allowlist.";
1125
- }
1126
- if (reason === "slack_external_recipient_step_up") {
1127
- return "The message reaches external recipients and must be reviewed exactly.";
1128
- }
1129
- if (reason === "linear_status_requires_step_up") {
1130
- return "The Linear status transition targets a configured protected status.";
1131
- }
1132
- if (reason === "jira_status_requires_step_up") {
1133
- return "The Jira status transition targets a configured protected status.";
1134
- }
1135
- if (reason.endsWith("_project_not_allowed")) {
1136
- return "The issue project key is outside the configured tracker allowlist.";
1137
- }
1138
- if (reason === "docs_search_inside_policy") {
1139
- return "The document search request stays inside the configured workspace and result limit.";
1140
- }
1141
- if (reason === "docs_read_inside_policy") {
1142
- return "The document path and requested byte limit are inside the configured documentation scope.";
1143
- }
1144
- if (reason === "docs_workspace_not_allowed") {
1145
- return "The document workspace is outside the configured allowlist.";
1146
- }
1147
- if (reason === "docs_path_denied") {
1148
- return "The requested document path matches a protected documentation prefix.";
1149
- }
1150
- if (reason === "docs_path_not_allowed") {
1151
- return "The requested document path is outside the configured documentation prefixes.";
1152
- }
1153
- if (reason === "docs_result_limit_exceeds_policy") {
1154
- return "The search request asks for more document results than policy allows.";
1155
- }
1156
- if (reason === "docs_read_bytes_exceeds_policy") {
1157
- return "The read request asks for more document bytes than policy allows.";
1158
- }
1159
- if (reason.startsWith("unsupported_tool:")) {
1160
- return "The tool is not supported by the native policy evaluator and fails closed.";
1161
- }
1162
- if (reason.startsWith("base_branch_mismatch:")) {
1163
- return "The requested base branch differs from the configured branch and needs review.";
1164
- }
1165
- return "The active native policy emitted this reason for the normalized action.";
1166
- }
1167
2032
  function collectPatchPaths(input, prefix = "") {
1168
2033
  if (!isPlainObject(input)) {
1169
2034
  return prefix ? [prefix] : [];
@@ -1196,6 +2061,12 @@ function getString(value) {
1196
2061
  function getNumber(value) {
1197
2062
  return typeof value === "number" && Number.isFinite(value) ? value : null;
1198
2063
  }
2064
+ function getPositiveInteger(value) {
2065
+ const number = getNumber(value);
2066
+ return number !== null && Number.isInteger(number) && number > 0
2067
+ ? number
2068
+ : null;
2069
+ }
1199
2070
  function deepMerge(base, patch) {
1200
2071
  if (Array.isArray(base) || Array.isArray(patch)) {
1201
2072
  return (patch ?? base);