@crewhaus/policy-engine 0.1.4 → 0.1.5

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.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Catalog R8 `policy-engine` — side-effect classification + audit hooks.
3
+ *
4
+ * Composes with `permission-engine` (Section 7): permission grants
5
+ * "should this user be allowed to invoke this tool?" and policy grants
6
+ * "should the platform allow this side-effect class right now?". The
7
+ * gateway runs permission first, then policy; both must `allow` (or
8
+ * `audit-and-allow`) for the call to proceed.
9
+ *
10
+ * Side-effect classes:
11
+ * "none" — pure compute (e.g. ReadImage decoded in-process)
12
+ * "filesystem" — reads/writes inside the workspace
13
+ * "network" — makes external HTTP / SMTP / DNS calls
14
+ * "external" — any other observable side effect (default for
15
+ * tools without explicit flag — fail-closed)
16
+ * "messaging" — posts to a chat / email / external user surface
17
+ *
18
+ * Decision shape:
19
+ * "allow" — proceed
20
+ * "audit-and-allow" — proceed AND emit an audit-log record
21
+ * "deny" — refuse with reason
22
+ *
23
+ * Layer R8. Pairs with `permission-engine` (R8) and `audit-log` (R17).
24
+ */
25
+ import type { AuditLog } from "@crewhaus/audit-log";
26
+ import { CrewhausError } from "@crewhaus/errors";
27
+ export type SideEffect = "none" | "filesystem" | "network" | "external" | "messaging";
28
+ export type PolicyDecision = "allow" | "audit-and-allow" | "deny";
29
+ export type PolicyMode = "permissive" | "audit" | "strict";
30
+ export type PolicyRule = {
31
+ /** Glob over tool name; "*" matches every tool. */
32
+ readonly toolPattern: string;
33
+ /** Side-effect classes the rule applies to; ["*"] for any. */
34
+ readonly sideEffects: ReadonlyArray<SideEffect | "*">;
35
+ readonly action: PolicyDecision;
36
+ /** Free-text reason returned to the gateway. */
37
+ readonly reason?: string;
38
+ };
39
+ export type ToolCallContext = {
40
+ readonly toolName: string;
41
+ /**
42
+ * The runtime infers `sideEffect` from the tool's flags:
43
+ * readOnly + filesystem-only path → "filesystem"
44
+ * makes outbound network calls → "network"
45
+ * posts user-visible messages → "messaging"
46
+ * pure in-process compute → "none"
47
+ * anything else / unset → "external"
48
+ *
49
+ * The gateway forwards this hint via the call site; tools that
50
+ * declare `sideEffect` explicitly override the heuristic.
51
+ */
52
+ readonly sideEffect?: SideEffect;
53
+ readonly input: unknown;
54
+ readonly tenantId?: string;
55
+ };
56
+ export type EvaluatePolicyResult = {
57
+ readonly decision: PolicyDecision;
58
+ readonly reason?: string;
59
+ readonly matchedRule?: number;
60
+ };
61
+ export declare class PolicyEngineError extends CrewhausError {
62
+ readonly name = "PolicyEngineError";
63
+ constructor(message: string, cause?: unknown);
64
+ }
65
+ export type EvaluatePolicyOptions = {
66
+ readonly mode?: PolicyMode;
67
+ readonly rules?: ReadonlyArray<PolicyRule>;
68
+ readonly tenantPolicy?: ReadonlyArray<PolicyRule>;
69
+ };
70
+ /**
71
+ * Decide policy for one tool call. Pure with respect to `call`,
72
+ * `mode`, and the rules. The `audit-and-allow` decision SHOULD be
73
+ * paired with `auditPolicyDecision(...)` to actually write the
74
+ * record — keeping I/O out of the pure decider keeps tests trivial.
75
+ */
76
+ export declare function evaluatePolicy(call: ToolCallContext, opts?: EvaluatePolicyOptions): EvaluatePolicyResult;
77
+ /**
78
+ * Append a `policy_decision` audit record. Callers should invoke this
79
+ * AFTER `evaluatePolicy` whenever the decision is `audit-and-allow` or
80
+ * `deny`. `allow` outcomes don't generate an audit row by default to
81
+ * keep the chain readable; pass `auditAll: true` to override.
82
+ */
83
+ export declare function auditPolicyDecision(log: AuditLog, call: ToolCallContext, result: EvaluatePolicyResult, opts?: {
84
+ readonly auditAll?: boolean;
85
+ }): Promise<void>;
86
+ export declare const DEFAULT_POLICY_RULES: readonly PolicyRule[];
package/dist/index.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Catalog R8 `policy-engine` — side-effect classification + audit hooks.
3
+ *
4
+ * Composes with `permission-engine` (Section 7): permission grants
5
+ * "should this user be allowed to invoke this tool?" and policy grants
6
+ * "should the platform allow this side-effect class right now?". The
7
+ * gateway runs permission first, then policy; both must `allow` (or
8
+ * `audit-and-allow`) for the call to proceed.
9
+ *
10
+ * Side-effect classes:
11
+ * "none" — pure compute (e.g. ReadImage decoded in-process)
12
+ * "filesystem" — reads/writes inside the workspace
13
+ * "network" — makes external HTTP / SMTP / DNS calls
14
+ * "external" — any other observable side effect (default for
15
+ * tools without explicit flag — fail-closed)
16
+ * "messaging" — posts to a chat / email / external user surface
17
+ *
18
+ * Decision shape:
19
+ * "allow" — proceed
20
+ * "audit-and-allow" — proceed AND emit an audit-log record
21
+ * "deny" — refuse with reason
22
+ *
23
+ * Layer R8. Pairs with `permission-engine` (R8) and `audit-log` (R17).
24
+ */
25
+ import { CrewhausError } from "@crewhaus/errors";
26
+ export class PolicyEngineError extends CrewhausError {
27
+ name = "PolicyEngineError";
28
+ constructor(message, cause) {
29
+ super("config", message, cause);
30
+ }
31
+ }
32
+ const DEFAULT_RULES = [
33
+ // Allow read-only / pure compute everywhere.
34
+ { toolPattern: "*", sideEffects: ["none"], action: "allow" },
35
+ // Audit filesystem reads — they're the easy data-leak vector.
36
+ {
37
+ toolPattern: "*",
38
+ sideEffects: ["filesystem"],
39
+ action: "audit-and-allow",
40
+ reason: "filesystem side-effect",
41
+ },
42
+ // Audit network calls — same reasoning.
43
+ {
44
+ toolPattern: "*",
45
+ sideEffects: ["network"],
46
+ action: "audit-and-allow",
47
+ reason: "network side-effect",
48
+ },
49
+ {
50
+ toolPattern: "*",
51
+ sideEffects: ["messaging"],
52
+ action: "audit-and-allow",
53
+ reason: "messaging side-effect",
54
+ },
55
+ // Default for unclassified ("external") — deny in strict, audit elsewhere.
56
+ // This rule is only consulted in strict mode (we apply mode-specific
57
+ // overrides below). Keeping it last ensures explicit rules take precedence.
58
+ {
59
+ toolPattern: "*",
60
+ sideEffects: ["external"],
61
+ action: "audit-and-allow",
62
+ reason: "external side-effect",
63
+ },
64
+ ];
65
+ function patternMatches(pattern, value) {
66
+ if (pattern === "*")
67
+ return true;
68
+ if (pattern === value)
69
+ return true;
70
+ // Prefix wildcard: "Bash*" → starts with "Bash".
71
+ if (pattern.endsWith("*") && value.startsWith(pattern.slice(0, -1)))
72
+ return true;
73
+ return false;
74
+ }
75
+ function ruleMatches(rule, toolName, effect) {
76
+ if (!patternMatches(rule.toolPattern, toolName))
77
+ return false;
78
+ for (const e of rule.sideEffects) {
79
+ if (e === "*" || e === effect)
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+ function applyMode(decision, mode) {
85
+ if (mode === "permissive" && decision === "deny")
86
+ return "audit-and-allow";
87
+ if (mode === "strict" && decision === "audit-and-allow")
88
+ return "deny";
89
+ return decision;
90
+ }
91
+ /**
92
+ * Decide policy for one tool call. Pure with respect to `call`,
93
+ * `mode`, and the rules. The `audit-and-allow` decision SHOULD be
94
+ * paired with `auditPolicyDecision(...)` to actually write the
95
+ * record — keeping I/O out of the pure decider keeps tests trivial.
96
+ */
97
+ export function evaluatePolicy(call, opts = {}) {
98
+ const mode = opts.mode ?? "audit";
99
+ // Section 18 fail-closed default: tools without a sideEffect declaration
100
+ // are treated as "external" (the most-restrictive default class).
101
+ const effect = call.sideEffect ?? "external";
102
+ // Tenant rules win over global rules.
103
+ const ruleSets = [
104
+ opts.tenantPolicy ?? [],
105
+ opts.rules ?? DEFAULT_RULES,
106
+ ];
107
+ let idx = 0;
108
+ for (const set of ruleSets) {
109
+ for (const rule of set) {
110
+ if (ruleMatches(rule, call.toolName, effect)) {
111
+ const decision = applyMode(rule.action, mode);
112
+ return {
113
+ decision,
114
+ ...(rule.reason !== undefined ? { reason: rule.reason } : {}),
115
+ matchedRule: idx,
116
+ };
117
+ }
118
+ idx += 1;
119
+ }
120
+ }
121
+ // Fail-closed: no rule matched.
122
+ return {
123
+ decision: applyMode("deny", mode),
124
+ reason: `no policy rule matched ${call.toolName} (sideEffect=${effect})`,
125
+ };
126
+ }
127
+ /**
128
+ * Append a `policy_decision` audit record. Callers should invoke this
129
+ * AFTER `evaluatePolicy` whenever the decision is `audit-and-allow` or
130
+ * `deny`. `allow` outcomes don't generate an audit row by default to
131
+ * keep the chain readable; pass `auditAll: true` to override.
132
+ */
133
+ export async function auditPolicyDecision(log, call, result, opts = {}) {
134
+ if (result.decision === "allow" && opts.auditAll !== true)
135
+ return;
136
+ const payload = {
137
+ toolName: call.toolName,
138
+ sideEffect: call.sideEffect ?? "external",
139
+ decision: result.decision,
140
+ reason: result.reason,
141
+ tenantId: call.tenantId,
142
+ matchedRule: result.matchedRule,
143
+ };
144
+ await log.append({ kind: "policy_decision", payload });
145
+ }
146
+ export const DEFAULT_POLICY_RULES = DEFAULT_RULES;
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/policy-engine",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Side-effect classification + audit-and-allow policy decisions for the managed-daemon target",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/audit-log": "0.1.4",
16
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/audit-log": "0.1.5",
19
+ "@crewhaus/errors": "0.1.5"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }
package/src/index.test.ts DELETED
@@ -1,178 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { type AuditLog, openAuditLog } from "@crewhaus/audit-log";
6
- import { PolicyEngineError, type PolicyRule, auditPolicyDecision, evaluatePolicy } from "./index";
7
-
8
- let tmp: string;
9
- let log: AuditLog;
10
-
11
- beforeEach(async () => {
12
- tmp = mkdtempSync(join(tmpdir(), "policy-engine-"));
13
- log = await openAuditLog({ rootDir: tmp });
14
- });
15
-
16
- afterEach(() => {
17
- rmSync(tmp, { recursive: true, force: true });
18
- });
19
-
20
- describe("evaluatePolicy — defaults (audit mode)", () => {
21
- test("none side-effect → allow", () => {
22
- const r = evaluatePolicy({ toolName: "ReadImage", sideEffect: "none", input: {} });
23
- expect(r.decision).toBe("allow");
24
- });
25
-
26
- test("filesystem side-effect → audit-and-allow", () => {
27
- const r = evaluatePolicy({ toolName: "Read", sideEffect: "filesystem", input: {} });
28
- expect(r.decision).toBe("audit-and-allow");
29
- });
30
-
31
- test("network side-effect → audit-and-allow", () => {
32
- const r = evaluatePolicy({ toolName: "WebFetch", sideEffect: "network", input: {} });
33
- expect(r.decision).toBe("audit-and-allow");
34
- });
35
-
36
- test("messaging side-effect → audit-and-allow", () => {
37
- const r = evaluatePolicy({
38
- toolName: "SendMessage",
39
- sideEffect: "messaging",
40
- input: {},
41
- });
42
- expect(r.decision).toBe("audit-and-allow");
43
- });
44
-
45
- test("missing sideEffect defaults to external (fail-closed → audit-and-allow in audit mode)", () => {
46
- const r = evaluatePolicy({ toolName: "Mystery", input: {} });
47
- expect(r.decision).toBe("audit-and-allow");
48
- expect(r.reason).toMatch(/external/);
49
- });
50
- });
51
-
52
- describe("strict mode", () => {
53
- test("audit-and-allow demoted to deny", () => {
54
- const r = evaluatePolicy(
55
- { toolName: "WebFetch", sideEffect: "network", input: {} },
56
- { mode: "strict" },
57
- );
58
- expect(r.decision).toBe("deny");
59
- });
60
-
61
- test("none side-effect still allowed", () => {
62
- const r = evaluatePolicy(
63
- { toolName: "ReadImage", sideEffect: "none", input: {} },
64
- { mode: "strict" },
65
- );
66
- expect(r.decision).toBe("allow");
67
- });
68
- });
69
-
70
- describe("permissive mode", () => {
71
- test("explicit deny rule is upgraded to audit-and-allow", () => {
72
- const tenantPolicy: PolicyRule[] = [
73
- {
74
- toolPattern: "Bash",
75
- sideEffects: ["*"],
76
- action: "deny",
77
- reason: "no shell in this tenant",
78
- },
79
- ];
80
- const r = evaluatePolicy(
81
- { toolName: "Bash", sideEffect: "external", input: {} },
82
- { tenantPolicy, mode: "permissive" },
83
- );
84
- expect(r.decision).toBe("audit-and-allow");
85
- });
86
- });
87
-
88
- describe("tenant overrides win over defaults", () => {
89
- test("tenant deny wins over default audit-and-allow", () => {
90
- const tenantPolicy: PolicyRule[] = [
91
- {
92
- toolPattern: "*",
93
- sideEffects: ["network"],
94
- action: "deny",
95
- reason: "no egress in tenant-x",
96
- },
97
- ];
98
- const r = evaluatePolicy(
99
- { toolName: "WebFetch", sideEffect: "network", input: {} },
100
- { tenantPolicy },
101
- );
102
- expect(r.decision).toBe("deny");
103
- expect(r.reason).toMatch(/no egress/);
104
- });
105
-
106
- test("prefix glob matches", () => {
107
- const tenantPolicy: PolicyRule[] = [
108
- { toolPattern: "Web*", sideEffects: ["network"], action: "deny" },
109
- ];
110
- const r = evaluatePolicy(
111
- { toolName: "WebFetch", sideEffect: "network", input: {} },
112
- { tenantPolicy },
113
- );
114
- expect(r.decision).toBe("deny");
115
- });
116
- });
117
-
118
- describe("PolicyEngineError", () => {
119
- test("carries config code, stable name, and preserves the cause chain", () => {
120
- const cause = new Error("bad rule glob");
121
- const err = new PolicyEngineError("invalid policy config", cause);
122
- expect(err).toBeInstanceOf(Error);
123
- expect(err.name).toBe("PolicyEngineError");
124
- expect(err.code).toBe("config");
125
- expect(err.message).toBe("invalid policy config");
126
- expect(err.cause).toBe(cause);
127
- // Serializes its cause chain for the logging layer.
128
- expect(err.toJSON()).toMatchObject({
129
- name: "PolicyEngineError",
130
- code: "config",
131
- message: "invalid policy config",
132
- cause: { name: "Error", message: "bad rule glob" },
133
- });
134
- });
135
-
136
- test("constructs without a cause", () => {
137
- const err = new PolicyEngineError("no cause");
138
- expect(err.cause).toBeUndefined();
139
- expect(err.toJSON().cause).toBeUndefined();
140
- });
141
- });
142
-
143
- describe("auditPolicyDecision", () => {
144
- test("audit-and-allow appends a policy_decision record", async () => {
145
- const r = await auditPolicyDecision(
146
- log,
147
- { toolName: "Read", sideEffect: "filesystem", input: {} },
148
- { decision: "audit-and-allow", reason: "fs" },
149
- );
150
- expect(r).toBeUndefined();
151
- const records: unknown[] = [];
152
- for await (const rec of log.read()) records.push(rec);
153
- expect(records.length).toBe(1);
154
- });
155
-
156
- test("allow does NOT append by default", async () => {
157
- await auditPolicyDecision(
158
- log,
159
- { toolName: "ReadImage", sideEffect: "none", input: {} },
160
- { decision: "allow" },
161
- );
162
- const records: unknown[] = [];
163
- for await (const rec of log.read()) records.push(rec);
164
- expect(records.length).toBe(0);
165
- });
166
-
167
- test("auditAll: true appends even allow decisions", async () => {
168
- await auditPolicyDecision(
169
- log,
170
- { toolName: "ReadImage", sideEffect: "none", input: {} },
171
- { decision: "allow" },
172
- { auditAll: true },
173
- );
174
- const records: unknown[] = [];
175
- for await (const rec of log.read()) records.push(rec);
176
- expect(records.length).toBe(1);
177
- });
178
- });
package/src/index.ts DELETED
@@ -1,203 +0,0 @@
1
- /**
2
- * Catalog R8 `policy-engine` — side-effect classification + audit hooks.
3
- *
4
- * Composes with `permission-engine` (Section 7): permission grants
5
- * "should this user be allowed to invoke this tool?" and policy grants
6
- * "should the platform allow this side-effect class right now?". The
7
- * gateway runs permission first, then policy; both must `allow` (or
8
- * `audit-and-allow`) for the call to proceed.
9
- *
10
- * Side-effect classes:
11
- * "none" — pure compute (e.g. ReadImage decoded in-process)
12
- * "filesystem" — reads/writes inside the workspace
13
- * "network" — makes external HTTP / SMTP / DNS calls
14
- * "external" — any other observable side effect (default for
15
- * tools without explicit flag — fail-closed)
16
- * "messaging" — posts to a chat / email / external user surface
17
- *
18
- * Decision shape:
19
- * "allow" — proceed
20
- * "audit-and-allow" — proceed AND emit an audit-log record
21
- * "deny" — refuse with reason
22
- *
23
- * Layer R8. Pairs with `permission-engine` (R8) and `audit-log` (R17).
24
- */
25
-
26
- import type { AppendInput, AuditLog } from "@crewhaus/audit-log";
27
- import { CrewhausError } from "@crewhaus/errors";
28
-
29
- export type SideEffect = "none" | "filesystem" | "network" | "external" | "messaging";
30
-
31
- export type PolicyDecision = "allow" | "audit-and-allow" | "deny";
32
-
33
- export type PolicyMode = "permissive" | "audit" | "strict";
34
-
35
- export type PolicyRule = {
36
- /** Glob over tool name; "*" matches every tool. */
37
- readonly toolPattern: string;
38
- /** Side-effect classes the rule applies to; ["*"] for any. */
39
- readonly sideEffects: ReadonlyArray<SideEffect | "*">;
40
- readonly action: PolicyDecision;
41
- /** Free-text reason returned to the gateway. */
42
- readonly reason?: string;
43
- };
44
-
45
- export type ToolCallContext = {
46
- readonly toolName: string;
47
- /**
48
- * The runtime infers `sideEffect` from the tool's flags:
49
- * readOnly + filesystem-only path → "filesystem"
50
- * makes outbound network calls → "network"
51
- * posts user-visible messages → "messaging"
52
- * pure in-process compute → "none"
53
- * anything else / unset → "external"
54
- *
55
- * The gateway forwards this hint via the call site; tools that
56
- * declare `sideEffect` explicitly override the heuristic.
57
- */
58
- readonly sideEffect?: SideEffect;
59
- readonly input: unknown;
60
- readonly tenantId?: string;
61
- };
62
-
63
- export type EvaluatePolicyResult = {
64
- readonly decision: PolicyDecision;
65
- readonly reason?: string;
66
- readonly matchedRule?: number;
67
- };
68
-
69
- export class PolicyEngineError extends CrewhausError {
70
- override readonly name = "PolicyEngineError";
71
- constructor(message: string, cause?: unknown) {
72
- super("config", message, cause);
73
- }
74
- }
75
-
76
- const DEFAULT_RULES: ReadonlyArray<PolicyRule> = [
77
- // Allow read-only / pure compute everywhere.
78
- { toolPattern: "*", sideEffects: ["none"], action: "allow" },
79
- // Audit filesystem reads — they're the easy data-leak vector.
80
- {
81
- toolPattern: "*",
82
- sideEffects: ["filesystem"],
83
- action: "audit-and-allow",
84
- reason: "filesystem side-effect",
85
- },
86
- // Audit network calls — same reasoning.
87
- {
88
- toolPattern: "*",
89
- sideEffects: ["network"],
90
- action: "audit-and-allow",
91
- reason: "network side-effect",
92
- },
93
- {
94
- toolPattern: "*",
95
- sideEffects: ["messaging"],
96
- action: "audit-and-allow",
97
- reason: "messaging side-effect",
98
- },
99
- // Default for unclassified ("external") — deny in strict, audit elsewhere.
100
- // This rule is only consulted in strict mode (we apply mode-specific
101
- // overrides below). Keeping it last ensures explicit rules take precedence.
102
- {
103
- toolPattern: "*",
104
- sideEffects: ["external"],
105
- action: "audit-and-allow",
106
- reason: "external side-effect",
107
- },
108
- ];
109
-
110
- function patternMatches(pattern: string, value: string): boolean {
111
- if (pattern === "*") return true;
112
- if (pattern === value) return true;
113
- // Prefix wildcard: "Bash*" → starts with "Bash".
114
- if (pattern.endsWith("*") && value.startsWith(pattern.slice(0, -1))) return true;
115
- return false;
116
- }
117
-
118
- function ruleMatches(rule: PolicyRule, toolName: string, effect: SideEffect): boolean {
119
- if (!patternMatches(rule.toolPattern, toolName)) return false;
120
- for (const e of rule.sideEffects) {
121
- if (e === "*" || e === effect) return true;
122
- }
123
- return false;
124
- }
125
-
126
- function applyMode(decision: PolicyDecision, mode: PolicyMode): PolicyDecision {
127
- if (mode === "permissive" && decision === "deny") return "audit-and-allow";
128
- if (mode === "strict" && decision === "audit-and-allow") return "deny";
129
- return decision;
130
- }
131
-
132
- export type EvaluatePolicyOptions = {
133
- readonly mode?: PolicyMode;
134
- readonly rules?: ReadonlyArray<PolicyRule>;
135
- readonly tenantPolicy?: ReadonlyArray<PolicyRule>;
136
- };
137
-
138
- /**
139
- * Decide policy for one tool call. Pure with respect to `call`,
140
- * `mode`, and the rules. The `audit-and-allow` decision SHOULD be
141
- * paired with `auditPolicyDecision(...)` to actually write the
142
- * record — keeping I/O out of the pure decider keeps tests trivial.
143
- */
144
- export function evaluatePolicy(
145
- call: ToolCallContext,
146
- opts: EvaluatePolicyOptions = {},
147
- ): EvaluatePolicyResult {
148
- const mode = opts.mode ?? "audit";
149
- // Section 18 fail-closed default: tools without a sideEffect declaration
150
- // are treated as "external" (the most-restrictive default class).
151
- const effect: SideEffect = call.sideEffect ?? "external";
152
-
153
- // Tenant rules win over global rules.
154
- const ruleSets: ReadonlyArray<ReadonlyArray<PolicyRule>> = [
155
- opts.tenantPolicy ?? [],
156
- opts.rules ?? DEFAULT_RULES,
157
- ];
158
- let idx = 0;
159
- for (const set of ruleSets) {
160
- for (const rule of set) {
161
- if (ruleMatches(rule, call.toolName, effect)) {
162
- const decision = applyMode(rule.action, mode);
163
- return {
164
- decision,
165
- ...(rule.reason !== undefined ? { reason: rule.reason } : {}),
166
- matchedRule: idx,
167
- };
168
- }
169
- idx += 1;
170
- }
171
- }
172
- // Fail-closed: no rule matched.
173
- return {
174
- decision: applyMode("deny", mode),
175
- reason: `no policy rule matched ${call.toolName} (sideEffect=${effect})`,
176
- };
177
- }
178
-
179
- /**
180
- * Append a `policy_decision` audit record. Callers should invoke this
181
- * AFTER `evaluatePolicy` whenever the decision is `audit-and-allow` or
182
- * `deny`. `allow` outcomes don't generate an audit row by default to
183
- * keep the chain readable; pass `auditAll: true` to override.
184
- */
185
- export async function auditPolicyDecision(
186
- log: AuditLog,
187
- call: ToolCallContext,
188
- result: EvaluatePolicyResult,
189
- opts: { readonly auditAll?: boolean } = {},
190
- ): Promise<void> {
191
- if (result.decision === "allow" && opts.auditAll !== true) return;
192
- const payload: AppendInput["payload"] = {
193
- toolName: call.toolName,
194
- sideEffect: call.sideEffect ?? "external",
195
- decision: result.decision,
196
- reason: result.reason,
197
- tenantId: call.tenantId,
198
- matchedRule: result.matchedRule,
199
- };
200
- await log.append({ kind: "policy_decision", payload });
201
- }
202
-
203
- export const DEFAULT_POLICY_RULES = DEFAULT_RULES;