@crewhaus/policy-engine 0.1.3 → 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.
- package/dist/index.d.ts +86 -0
- package/dist/index.js +146 -0
- package/package.json +10 -7
- package/src/index.test.ts +0 -178
- package/src/index.ts +0 -203
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
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": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
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.
|
|
16
|
-
"@crewhaus/errors": "0.1.
|
|
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": ["
|
|
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;
|