@inhuman.tools/policy 0.0.0-stage → 0.2.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/LICENSE.md +7 -0
- package/authorize.scm +4 -0
- package/dist/acknowledge.d.ts +70 -0
- package/dist/acknowledge.d.ts.map +1 -0
- package/dist/acknowledge.js +162 -0
- package/dist/acknowledge.js.map +1 -0
- package/dist/authorize.d.ts +8 -0
- package/dist/authorize.d.ts.map +1 -0
- package/dist/authorize.js +39 -0
- package/dist/authorize.js.map +1 -0
- package/dist/bind.d.ts +6 -0
- package/dist/bind.d.ts.map +1 -0
- package/dist/bind.js +45 -0
- package/dist/bind.js.map +1 -0
- package/dist/check.d.ts +30 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +389 -0
- package/dist/check.js.map +1 -0
- package/dist/classes.d.ts +23 -0
- package/dist/classes.d.ts.map +1 -0
- package/dist/classes.js +139 -0
- package/dist/classes.js.map +1 -0
- package/dist/compile.d.ts +40 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +311 -0
- package/dist/compile.js.map +1 -0
- package/dist/deadline.d.ts +5 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +17 -0
- package/dist/deadline.js.map +1 -0
- package/dist/floor.d.ts +9 -0
- package/dist/floor.d.ts.map +1 -0
- package/dist/floor.js +9 -0
- package/dist/floor.js.map +1 -0
- package/dist/glob-expand.d.ts +10 -0
- package/dist/glob-expand.d.ts.map +1 -0
- package/dist/glob-expand.js +132 -0
- package/dist/glob-expand.js.map +1 -0
- package/dist/guards.d.ts +26 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +419 -0
- package/dist/guards.js.map +1 -0
- package/dist/identity.d.ts +11 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +23 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/lift.d.ts +4 -0
- package/dist/lift.d.ts.map +1 -0
- package/dist/lift.js +14 -0
- package/dist/lift.js.map +1 -0
- package/dist/path-policy.d.ts +53 -0
- package/dist/path-policy.d.ts.map +1 -0
- package/dist/path-policy.js +248 -0
- package/dist/path-policy.js.map +1 -0
- package/dist/processor.d.ts +71 -0
- package/dist/processor.d.ts.map +1 -0
- package/dist/processor.js +132 -0
- package/dist/processor.js.map +1 -0
- package/dist/reasonable-defaults.d.ts +1448 -0
- package/dist/reasonable-defaults.d.ts.map +1 -0
- package/dist/reasonable-defaults.js +6 -0
- package/dist/reasonable-defaults.js.map +1 -0
- package/dist/reasonable-defaults.json +1662 -0
- package/dist/run-authorization.d.ts +8 -0
- package/dist/run-authorization.d.ts.map +1 -0
- package/dist/run-authorization.js +16 -0
- package/dist/run-authorization.js.map +1 -0
- package/dist/tool-path.d.ts +8 -0
- package/dist/tool-path.d.ts.map +1 -0
- package/dist/tool-path.js +38 -0
- package/dist/tool-path.js.map +1 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/dist/verdict.d.ts +15 -0
- package/dist/verdict.d.ts.map +1 -0
- package/dist/verdict.js +55 -0
- package/dist/verdict.js.map +1 -0
- package/package.json +62 -3
- package/src/acknowledge.ts +206 -0
- package/src/authorize.ts +38 -0
- package/src/bind.ts +58 -0
- package/src/check.ts +552 -0
- package/src/classes.ts +156 -0
- package/src/compile.ts +370 -0
- package/src/deadline.ts +15 -0
- package/src/floor.ts +16 -0
- package/src/glob-expand.ts +137 -0
- package/src/guards.ts +428 -0
- package/src/identity.ts +26 -0
- package/src/index.ts +24 -0
- package/src/lift.ts +15 -0
- package/src/path-policy.ts +280 -0
- package/src/processor.ts +187 -0
- package/src/reasonable-defaults.json +1662 -0
- package/src/reasonable-defaults.ts +8 -0
- package/src/run-authorization.ts +23 -0
- package/src/tool-path.ts +43 -0
- package/src/types.ts +102 -0
- package/src/verdict.ts +63 -0
- package/README.md +0 -3
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-access cluster: policy.json `read_file` / `write_file` enable lists
|
|
3
|
+
* and `read_block` / `write_block` hard-block lists.
|
|
4
|
+
* Enable `!` is deny (remainder carve-out). Block lists are unliftable.
|
|
5
|
+
* Suffix wildcard is implied — PAT matches itself and descendants.
|
|
6
|
+
* Miss is deny (closed). Unresolvable path is ask.
|
|
7
|
+
* Read enable is approve (silent in-tree). Write enable is ask: the list is
|
|
8
|
+
* what the agent may attempt, not a grant. Yolo rewrites that ask and deny.
|
|
9
|
+
* Realpath / cwd / ~ live on the host; this matcher sees the prepared string.
|
|
10
|
+
*
|
|
11
|
+
* Every rule carries a class (why it votes). Brace and glob words from the shell are expanded
|
|
12
|
+
* lexically against the block rules: if any expansion or match could hit one, the word is `block`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { posix } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { minimatch } from "minimatch";
|
|
18
|
+
|
|
19
|
+
import type { ClassId } from "./classes.js";
|
|
20
|
+
import { expandBraces } from "./glob-expand.js";
|
|
21
|
+
import type { Disposition, FloorVerdict, WriteRemainderKind } from "./types.js";
|
|
22
|
+
import { cast, join } from "./verdict.js";
|
|
23
|
+
|
|
24
|
+
type PathRule = {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly polarity: "policyApprove" | "ask" | "deny" | "block";
|
|
27
|
+
readonly stem: string;
|
|
28
|
+
readonly unanchored: boolean;
|
|
29
|
+
readonly class: ClassId;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type PathTerm = {
|
|
33
|
+
readonly cluster: "read" | "write";
|
|
34
|
+
readonly rules: PathRule[];
|
|
35
|
+
/** Class of a miss (deny: outside the enable roots). */
|
|
36
|
+
readonly missClass: ClassId;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function mergePathTerms(
|
|
40
|
+
cluster: "read" | "write",
|
|
41
|
+
missClass: ClassId,
|
|
42
|
+
enable: readonly PathRule[] | undefined,
|
|
43
|
+
block: readonly PathRule[] | undefined,
|
|
44
|
+
): PathTerm | undefined {
|
|
45
|
+
if (enable === undefined && block === undefined) return undefined;
|
|
46
|
+
return { cluster, missClass, rules: [...(enable ?? []), ...(block ?? [])] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function checkPath(term: PathTerm, path: string): Disposition {
|
|
50
|
+
return explainPath(term, path).disposition;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The disposition of one path with the rules that decided it. */
|
|
54
|
+
export function explainPath(term: PathTerm, path: string): FloorVerdict {
|
|
55
|
+
const resolved = normalizePath(path);
|
|
56
|
+
if (resolved === undefined) {
|
|
57
|
+
return cast("ask", "unmodelled", `${term.cluster}:unresolvable`, { path: { cluster: term.cluster, path } });
|
|
58
|
+
}
|
|
59
|
+
const hits = term.rules.filter((rule) => matchesRule(rule, resolved));
|
|
60
|
+
const where = { path: { cluster: term.cluster, path: resolved } } as const;
|
|
61
|
+
if (hits.length === 0) return cast("deny", term.missClass, `${term.cluster}:miss`, where);
|
|
62
|
+
return join(hits.map((rule) => cast(rule.polarity, rule.class, rule.id, where)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Shell words may carry braces and globs. A word the shell would expand is judged as every
|
|
67
|
+
* word it could expand to; a glob is judged as everything it could match. The result is the
|
|
68
|
+
* strictest of those and the word itself, so this can only tighten. Only block rules can be
|
|
69
|
+
* hit by a pattern (an enable root never needs a glob to be understood: the word asks).
|
|
70
|
+
*/
|
|
71
|
+
export function explainShellPath(
|
|
72
|
+
term: PathTerm,
|
|
73
|
+
word: string,
|
|
74
|
+
options: { readonly dotglob?: boolean } = {},
|
|
75
|
+
): FloorVerdict {
|
|
76
|
+
const plain = explainPath(term, word);
|
|
77
|
+
if (!/[{*?[]/.test(word)) return plain;
|
|
78
|
+
const { words: expandedWords, overflow } = expandBraces(word);
|
|
79
|
+
// `a/../.e{nv,x}`: collapse `..` lexically so the block rule sees the name it will open.
|
|
80
|
+
const words = [...expandedWords, ...expandedWords.map(collapseDots).filter((w) => !expandedWords.includes(w))];
|
|
81
|
+
const verdicts: FloorVerdict[] = [plain];
|
|
82
|
+
for (const expanded of words) {
|
|
83
|
+
if (!/[*?[]/.test(expanded)) {
|
|
84
|
+
if (expanded !== word) verdicts.push(explainPath(term, expanded));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const hit = globBlockHit(term, expanded, options.dotglob === true);
|
|
88
|
+
if (hit !== undefined)
|
|
89
|
+
verdicts.push(cast("block", hit.class, hit.id, { path: { cluster: term.cluster, path: word } }));
|
|
90
|
+
}
|
|
91
|
+
if (overflow) {
|
|
92
|
+
const hit = globBlockHit(term, wildcardBraces(word), options.dotglob === true);
|
|
93
|
+
if (hit !== undefined)
|
|
94
|
+
verdicts.push(cast("block", hit.class, hit.id, { path: { cluster: term.cluster, path: word } }));
|
|
95
|
+
}
|
|
96
|
+
return join(verdicts);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function collapseDots(word: string): string {
|
|
100
|
+
return word.includes("..") ? posix.normalize(word) : word;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every outermost `{...}` group becomes `*`: a superset of whatever the group could expand to. */
|
|
104
|
+
function wildcardBraces(word: string): string {
|
|
105
|
+
let depth = 0;
|
|
106
|
+
let out = "";
|
|
107
|
+
for (const ch of word) {
|
|
108
|
+
if (ch === "{") {
|
|
109
|
+
if (depth === 0) out += "*";
|
|
110
|
+
depth += 1;
|
|
111
|
+
} else if (ch === "}" && depth > 0) {
|
|
112
|
+
depth -= 1;
|
|
113
|
+
} else if (depth === 0) {
|
|
114
|
+
out += ch;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Could a path matching glob `pattern` be blocked? Segment by segment: `*` and `?` do not cross `/`
|
|
122
|
+
* and (like bash without `dotglob`) do not match a leading dot. Case and Unicode form are folded the
|
|
123
|
+
* way the block rules fold them.
|
|
124
|
+
*/
|
|
125
|
+
function globBlockHit(term: PathTerm, pattern: string, dotglob: boolean): PathRule | undefined {
|
|
126
|
+
const slash = pattern.replaceAll("\\", "/").normalize("NFC").toLowerCase();
|
|
127
|
+
const absolute = slash.startsWith("/");
|
|
128
|
+
const segments = slash.split("/").filter((seg) => seg !== "" && seg !== ".");
|
|
129
|
+
for (const rule of term.rules) {
|
|
130
|
+
if (rule.polarity !== "block") continue;
|
|
131
|
+
const stem = rule.stem.normalize("NFC").toLowerCase();
|
|
132
|
+
if (stem === ".") continue;
|
|
133
|
+
const stemSegments = stem.split("/").filter((seg) => seg !== "");
|
|
134
|
+
if (rule.unanchored) {
|
|
135
|
+
const name = stemSegments[0];
|
|
136
|
+
const at = name === undefined ? -1 : segments.findIndex((seg) => segmentMatches(seg, name, dotglob));
|
|
137
|
+
// `.claude/worktrees` is not under the `.claude` block, glob or not.
|
|
138
|
+
if (at !== -1 && !(rule.stem === ".claude" && segments[at + 1] === "worktrees")) return rule;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (stem.startsWith("/") !== absolute) continue;
|
|
142
|
+
if (prefixMatches(segments, stemSegments, dotglob)) return rule;
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `**` stands for any number of segments (zsh and bash with globstar). */
|
|
148
|
+
function prefixMatches(pattern: readonly string[], stem: readonly string[], dotglob: boolean): boolean {
|
|
149
|
+
if (stem.length === 0) return true;
|
|
150
|
+
const [head, ...rest] = pattern;
|
|
151
|
+
if (head === undefined) return false;
|
|
152
|
+
if (head === "**") {
|
|
153
|
+
return prefixMatches(rest, stem, dotglob) || prefixMatches(pattern, stem.slice(1), dotglob);
|
|
154
|
+
}
|
|
155
|
+
const [name, ...more] = stem;
|
|
156
|
+
return segmentMatches(head, name!, dotglob) && prefixMatches(rest, more, dotglob);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function segmentMatches(pattern: string, name: string, dotglob: boolean): boolean {
|
|
160
|
+
if (pattern === name) return true;
|
|
161
|
+
if (!/[*?[\]{}()!+@]/.test(pattern)) return false;
|
|
162
|
+
// A segment with nothing literal in it (`*`, `??`, `[ab]`) matches every file name; treating that as a hit
|
|
163
|
+
// would block `cat *`. It still reaches dot-files when `dotglob` is set.
|
|
164
|
+
const literal = pattern.replaceAll(/\[[^\]]*\]|[*?]/g, "");
|
|
165
|
+
if (literal === "" && !(dotglob && name.startsWith("."))) return false;
|
|
166
|
+
return minimatch(name, pattern, { dot: dotglob, nocase: true, nocomment: true, nonegate: true, noglobstar: true });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Write-deny remainder intern. Null when the floor is not deny.
|
|
171
|
+
* protected: enable `!` inside an enable root. outside: miss, left the
|
|
172
|
+
* enable set. risky: enable `!` and left the enable set. `write_block`
|
|
173
|
+
* never reaches this.
|
|
174
|
+
*/
|
|
175
|
+
export function writeRemainderKind(term: PathTerm, path: string): WriteRemainderKind | null {
|
|
176
|
+
const resolved = normalizePath(path);
|
|
177
|
+
switch (true) {
|
|
178
|
+
case resolved === undefined:
|
|
179
|
+
case checkPath(term, path) !== "deny":
|
|
180
|
+
return null;
|
|
181
|
+
case !term.rules.some((rule) => resolved !== undefined && rule.polarity === "deny" && matchesRule(rule, resolved)):
|
|
182
|
+
return "outside";
|
|
183
|
+
case term.rules.some(
|
|
184
|
+
(rule) =>
|
|
185
|
+
resolved !== undefined &&
|
|
186
|
+
(rule.polarity === "ask" || rule.polarity === "policyApprove") &&
|
|
187
|
+
matchesRule(rule, resolved),
|
|
188
|
+
):
|
|
189
|
+
return "protected";
|
|
190
|
+
default:
|
|
191
|
+
return "risky";
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Block / deny rules fold case and Unicode form: macOS (APFS) and Windows open `.ENV` and `.env`
|
|
197
|
+
* as one file, so a case variant must not walk past the rule. A false block is safe; a miss
|
|
198
|
+
* is not. Enable rules stay exact: folding them would approve paths the list never named.
|
|
199
|
+
*/
|
|
200
|
+
function fold(path: string): string {
|
|
201
|
+
return path.normalize("NFC").toLowerCase();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function matchesRule(rule: PathRule, exact: string): boolean {
|
|
205
|
+
const folded = rule.polarity === "block" || rule.polarity === "deny";
|
|
206
|
+
const resolved = folded ? fold(exact) : exact;
|
|
207
|
+
const stem = folded ? fold(rule.stem) : rule.stem;
|
|
208
|
+
switch (true) {
|
|
209
|
+
case rule.stem === ".":
|
|
210
|
+
return isRelative(resolved);
|
|
211
|
+
case rule.unanchored && hasSegment(resolved, stem):
|
|
212
|
+
case !rule.unanchored && isPrefix(resolved, stem):
|
|
213
|
+
return !(rule.polarity === "block" && rule.stem === ".claude" && isClaudeWorktree(resolved));
|
|
214
|
+
default:
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `.claude` write_block does not cover `.claude/worktrees` (any depth). */
|
|
220
|
+
function isClaudeWorktree(path: string): boolean {
|
|
221
|
+
switch (true) {
|
|
222
|
+
case path === ".claude/worktrees":
|
|
223
|
+
case path.startsWith(".claude/worktrees/"):
|
|
224
|
+
case path.includes("/.claude/worktrees/"):
|
|
225
|
+
case path.endsWith("/.claude/worktrees"):
|
|
226
|
+
return true;
|
|
227
|
+
default:
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isPrefix(path: string, stem: string): boolean {
|
|
233
|
+
switch (true) {
|
|
234
|
+
case path === stem:
|
|
235
|
+
return true;
|
|
236
|
+
case stem === "/":
|
|
237
|
+
return path.startsWith("/");
|
|
238
|
+
default:
|
|
239
|
+
return path.startsWith(`${stem}/`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function hasSegment(path: string, stem: string): boolean {
|
|
244
|
+
switch (true) {
|
|
245
|
+
case path === stem:
|
|
246
|
+
case path.startsWith(`${stem}/`):
|
|
247
|
+
case path.includes(`/${stem}/`):
|
|
248
|
+
case path.endsWith(`/${stem}`):
|
|
249
|
+
return true;
|
|
250
|
+
default:
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isRelative(path: string): boolean {
|
|
256
|
+
return path !== "/" && !path.startsWith("/");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Undefined = unresolvable (ask). No `..`, `~`, `$`, glob, NUL. */
|
|
260
|
+
export function normalizePath(raw: string): string | undefined {
|
|
261
|
+
const slash = raw.replaceAll("\\", "/");
|
|
262
|
+
const parts = slash.split("/").filter((seg) => seg !== "" && seg !== ".");
|
|
263
|
+
switch (true) {
|
|
264
|
+
case raw === "" || raw.includes("\0"):
|
|
265
|
+
case /[~$?*`\0]/.test(slash):
|
|
266
|
+
case parts.includes(".."):
|
|
267
|
+
return undefined;
|
|
268
|
+
case slash.startsWith("/") && parts.length === 0:
|
|
269
|
+
return "/";
|
|
270
|
+
case slash.startsWith("/"):
|
|
271
|
+
return `/${parts.join("/")}`;
|
|
272
|
+
case parts.length === 0:
|
|
273
|
+
return ".";
|
|
274
|
+
default:
|
|
275
|
+
return parts.join("/");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export { lattice } from "./lift.js";
|
|
280
|
+
export { APPROVE } from "./verdict.js";
|
package/src/processor.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Judge env: evidence cuts and sections, the call's floor verdict, and the sinks the host applies.
|
|
3
|
+
*
|
|
4
|
+
* `classifier/*` are sinks for the ask judge: they bind a scheme lambda. The host applies the
|
|
5
|
+
* matching one with the cluster payload (compact's applyCallback). Retrieval is the remainder
|
|
6
|
+
* program, not inlined in each lambda. The ask judge votes `risk` and `authorization`; the
|
|
7
|
+
* program allows only when authorization (plus `risk-margin`) reaches risk.
|
|
8
|
+
*
|
|
9
|
+
* `soft-deny/*` are sinks for the soft-deny judge ("did the user request this exactly?"). The host
|
|
10
|
+
* applies one with the round number (1, then 2 with the agent's last answer). Every section it
|
|
11
|
+
* reads is one line of JSON the host built, so agent text can never forge a label.
|
|
12
|
+
*
|
|
13
|
+
* `bypass/judge` is the sink of the unattended-run bypass check.
|
|
14
|
+
*/
|
|
15
|
+
import { EnvCapability } from "@here.build/arrival";
|
|
16
|
+
import { applyCallback, getCapabilityResources } from "@here.build/arrival/host-internals";
|
|
17
|
+
import { ErrorWithInvariant } from "@inhuman.tools/error-with-invariant";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
type Cluster = "bash" | "read" | "write" | "mcp";
|
|
21
|
+
|
|
22
|
+
class ProcessorError extends ErrorWithInvariant {
|
|
23
|
+
override readonly name = "ProcessorError";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ClassifierFn = Parameters<typeof applyCallback>[0];
|
|
27
|
+
|
|
28
|
+
export type ClassifierResources = {
|
|
29
|
+
bash: ClassifierFn | undefined;
|
|
30
|
+
read: ClassifierFn | undefined;
|
|
31
|
+
write: ClassifierFn | undefined;
|
|
32
|
+
mcp: ClassifierFn | undefined;
|
|
33
|
+
softDeny: Record<Cluster, ClassifierFn | undefined>;
|
|
34
|
+
bypass: ClassifierFn | undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function bind(cluster: Cluster) {
|
|
38
|
+
return function (this: { resources: ClassifierResources }, fn: ClassifierFn) {
|
|
39
|
+
this.resources[cluster] = fn;
|
|
40
|
+
return undefined;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function bindSoftDeny(cluster: Cluster) {
|
|
45
|
+
return function (this: { resources: ClassifierResources }, fn: ClassifierFn) {
|
|
46
|
+
this.resources.softDeny[cluster] = fn;
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const processorCapability = EnvCapability.define("policy/classifier", {
|
|
52
|
+
configuration: {
|
|
53
|
+
privileged: z.string().default(""),
|
|
54
|
+
priorTurn: z.string().default(""),
|
|
55
|
+
quarantined: z.string().default(""),
|
|
56
|
+
policy: z.string().default(""),
|
|
57
|
+
/** Ask judge: allow needs `authorization + riskMargin >= risk`. */
|
|
58
|
+
riskMargin: z.number().default(0),
|
|
59
|
+
floorJson: z.string().default(""),
|
|
60
|
+
userJson: z.string().default("[]"),
|
|
61
|
+
callJson: z.string().default(""),
|
|
62
|
+
agentAnswerJson: z.string().default(""),
|
|
63
|
+
callTier: z.string().default(""),
|
|
64
|
+
callClasses: z.string().default(""),
|
|
65
|
+
callRules: z.string().default(""),
|
|
66
|
+
taskJson: z.string().default(""),
|
|
67
|
+
restrictionsJson: z.string().default(""),
|
|
68
|
+
callsJson: z.string().default(""),
|
|
69
|
+
hintsJson: z.string().default(""),
|
|
70
|
+
notesJson: z.string().default(""),
|
|
71
|
+
},
|
|
72
|
+
resources: (): ClassifierResources => ({
|
|
73
|
+
bash: undefined,
|
|
74
|
+
read: undefined,
|
|
75
|
+
write: undefined,
|
|
76
|
+
mcp: undefined,
|
|
77
|
+
softDeny: { bash: undefined, read: undefined, write: undefined, mcp: undefined },
|
|
78
|
+
bypass: undefined,
|
|
79
|
+
}),
|
|
80
|
+
symbols: (symbol, sz) => ({
|
|
81
|
+
"risk-margin": symbol.rosetta`risk-margin: how far authorization may trail risk and still allow (0 by default)`(
|
|
82
|
+
{ input: [], output: [sz.number], provenance: "source" },
|
|
83
|
+
function () {
|
|
84
|
+
return this.configuration.riskMargin;
|
|
85
|
+
},
|
|
86
|
+
),
|
|
87
|
+
evidence: symbol.rosetta`evidence: named PIP cut or host-built section of the judge request`(
|
|
88
|
+
{ input: [sz.foldName], output: [sz.string], provenance: "source" },
|
|
89
|
+
function (name) {
|
|
90
|
+
switch (name) {
|
|
91
|
+
case "privileged":
|
|
92
|
+
return this.configuration.privileged;
|
|
93
|
+
case "prior-turn":
|
|
94
|
+
return this.configuration.priorTurn;
|
|
95
|
+
case "quarantined":
|
|
96
|
+
return this.configuration.quarantined;
|
|
97
|
+
case "policy":
|
|
98
|
+
return this.configuration.policy;
|
|
99
|
+
case "floor-json":
|
|
100
|
+
return this.configuration.floorJson;
|
|
101
|
+
case "user-json":
|
|
102
|
+
return this.configuration.userJson;
|
|
103
|
+
case "call-json":
|
|
104
|
+
return this.configuration.callJson;
|
|
105
|
+
case "agent-answer-json":
|
|
106
|
+
return this.configuration.agentAnswerJson;
|
|
107
|
+
case "task-json":
|
|
108
|
+
return this.configuration.taskJson;
|
|
109
|
+
case "restrictions-json":
|
|
110
|
+
return this.configuration.restrictionsJson;
|
|
111
|
+
case "calls-json":
|
|
112
|
+
return this.configuration.callsJson;
|
|
113
|
+
case "hints-json":
|
|
114
|
+
return this.configuration.hintsJson;
|
|
115
|
+
case "notes-json":
|
|
116
|
+
return this.configuration.notesJson;
|
|
117
|
+
default:
|
|
118
|
+
throw new ProcessorError(`evidence: unknown cut ${name}`);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
),
|
|
122
|
+
call: symbol.rosetta`call: the floor verdict of the call being judged (tier, classes, rules)`(
|
|
123
|
+
{ input: [sz.foldName], output: [sz.string], provenance: "source" },
|
|
124
|
+
function (name) {
|
|
125
|
+
switch (name) {
|
|
126
|
+
case "tier":
|
|
127
|
+
return this.configuration.callTier;
|
|
128
|
+
case "classes":
|
|
129
|
+
return this.configuration.callClasses;
|
|
130
|
+
case "rules":
|
|
131
|
+
return this.configuration.callRules;
|
|
132
|
+
default:
|
|
133
|
+
throw new ProcessorError(`call: unknown field ${name}`);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
),
|
|
137
|
+
"classifier/bash": symbol.rosetta`classifier/bash: remainder classifier for the shell cluster`(
|
|
138
|
+
{ input: [sz.lambda], output: [sz.undefinedResult], provenance: "sink" },
|
|
139
|
+
bind("bash"),
|
|
140
|
+
),
|
|
141
|
+
"classifier/read": symbol.rosetta`classifier/read: remainder classifier for the read cluster`(
|
|
142
|
+
{ input: [sz.lambda], output: [sz.undefinedResult], provenance: "sink" },
|
|
143
|
+
bind("read"),
|
|
144
|
+
),
|
|
145
|
+
"classifier/write": symbol.rosetta`classifier/write: remainder classifier for the write cluster`(
|
|
146
|
+
{ input: [sz.lambda], output: [sz.undefinedResult], provenance: "sink" },
|
|
147
|
+
bind("write"),
|
|
148
|
+
),
|
|
149
|
+
"classifier/mcp": symbol.rosetta`classifier/mcp: remainder classifier for MCP and unknown tools`(
|
|
150
|
+
{ input: [sz.lambda], output: [sz.undefinedResult], provenance: "sink" },
|
|
151
|
+
bind("mcp"),
|
|
152
|
+
),
|
|
153
|
+
"soft-deny/bash": symbol.rosetta`soft-deny/bash: soft-deny judge for the shell cluster; applied with the round`(
|
|
154
|
+
{ input: [sz.lambda], output: [sz.undefinedResult], provenance: "sink" },
|
|
155
|
+
bindSoftDeny("bash"),
|
|
156
|
+
),
|
|
157
|
+
"soft-deny/read": symbol.rosetta`soft-deny/read: soft-deny judge for the read cluster; applied with the round`(
|
|
158
|
+
{ input: [sz.lambda], output: [sz.undefinedResult], provenance: "sink" },
|
|
159
|
+
bindSoftDeny("read"),
|
|
160
|
+
),
|
|
161
|
+
"soft-deny/write": symbol.rosetta`soft-deny/write: soft-deny judge for the write cluster; applied with the round`(
|
|
162
|
+
{ input: [sz.lambda], output: [sz.undefinedResult], provenance: "sink" },
|
|
163
|
+
bindSoftDeny("write"),
|
|
164
|
+
),
|
|
165
|
+
"soft-deny/mcp": symbol.rosetta`soft-deny/mcp: soft-deny judge for MCP and unknown tools; applied with the round`(
|
|
166
|
+
{ input: [sz.lambda], output: [sz.undefinedResult], provenance: "sink" },
|
|
167
|
+
bindSoftDeny("mcp"),
|
|
168
|
+
),
|
|
169
|
+
"bypass/judge":
|
|
170
|
+
symbol.rosetta`bypass/judge: does this series of calls look like an attempt to bypass a restriction`(
|
|
171
|
+
{ input: [sz.lambda], output: [sz.undefinedResult], provenance: "sink" },
|
|
172
|
+
function (this: { resources: ClassifierResources }, fn: ClassifierFn) {
|
|
173
|
+
this.resources.bypass = fn;
|
|
174
|
+
return undefined;
|
|
175
|
+
},
|
|
176
|
+
),
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/** Same env as `processorCapability`. Prefer this name in-tree. */
|
|
181
|
+
export const policyEnvCapability = processorCapability;
|
|
182
|
+
|
|
183
|
+
export function classifierResources(runCtx: Parameters<typeof getCapabilityResources>[0]): ClassifierResources {
|
|
184
|
+
const bag = getCapabilityResources(runCtx, policyEnvCapability) as ClassifierResources | undefined;
|
|
185
|
+
ProcessorError.invariant(bag !== undefined, "classifier resources missing");
|
|
186
|
+
return bag;
|
|
187
|
+
}
|