@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
package/src/classes.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule classes: why the floor voted the tier it did. The taxonomy is closed and ships with the
|
|
3
|
+
* harness. A user layer may tag its own rules with one of these ids and configure how each class
|
|
4
|
+
* resolves; it cannot add an id or move a rule between classes.
|
|
5
|
+
*
|
|
6
|
+
* `risky` / `general` are not classes and not tier names. The tier names are "soft-deny"
|
|
7
|
+
* (engine `deny`) and "ask" (engine `ask`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const CLASS_IDS = [
|
|
11
|
+
"destroy-catastrophic",
|
|
12
|
+
"destroy-local",
|
|
13
|
+
"vcs-history",
|
|
14
|
+
"privilege",
|
|
15
|
+
"credential",
|
|
16
|
+
"control-plane",
|
|
17
|
+
"attack-shape",
|
|
18
|
+
"policy-fault",
|
|
19
|
+
"package-install",
|
|
20
|
+
"publish",
|
|
21
|
+
"remote-write",
|
|
22
|
+
"network-egress",
|
|
23
|
+
"exec-unknown",
|
|
24
|
+
"unmodelled",
|
|
25
|
+
"envelope-read",
|
|
26
|
+
"envelope-write",
|
|
27
|
+
"protected-file",
|
|
28
|
+
"workspace-write",
|
|
29
|
+
"mcp-lookup",
|
|
30
|
+
"mcp-action",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export type ClassId = (typeof CLASS_IDS)[number];
|
|
34
|
+
|
|
35
|
+
export type ClassInfo = {
|
|
36
|
+
/** Short name for prompts and the permissions view. */
|
|
37
|
+
readonly label: string;
|
|
38
|
+
/** Trusted, harness-written description. The soft-deny judge reads it as FLOOR context. */
|
|
39
|
+
readonly description: string;
|
|
40
|
+
/** UI warning colour only. It has no resolution meaning. */
|
|
41
|
+
readonly risky: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const CLASSES: Readonly<Record<ClassId, ClassInfo>> = {
|
|
45
|
+
"destroy-catastrophic": {
|
|
46
|
+
label: "Destroys the machine or home",
|
|
47
|
+
description: "Irrecoverable machine or home-scale destruction.",
|
|
48
|
+
risky: true,
|
|
49
|
+
},
|
|
50
|
+
"destroy-local": {
|
|
51
|
+
label: "Discards local work",
|
|
52
|
+
description: "Discards uncommitted or untracked work in the tree.",
|
|
53
|
+
risky: true,
|
|
54
|
+
},
|
|
55
|
+
"vcs-history": {
|
|
56
|
+
label: "Rewrites history",
|
|
57
|
+
description: "Rewrites or deletes refs and history, local or remote.",
|
|
58
|
+
risky: true,
|
|
59
|
+
},
|
|
60
|
+
privilege: {
|
|
61
|
+
label: "Elevates privilege",
|
|
62
|
+
description: "Elevates privilege, widens permissions, or changes machine state.",
|
|
63
|
+
risky: true,
|
|
64
|
+
},
|
|
65
|
+
credential: {
|
|
66
|
+
label: "Touches credentials",
|
|
67
|
+
description: "Reads, writes or exports secrets and credential stores.",
|
|
68
|
+
risky: true,
|
|
69
|
+
},
|
|
70
|
+
"control-plane": {
|
|
71
|
+
label: "Changes the controls on the agent",
|
|
72
|
+
description:
|
|
73
|
+
"Changes or bypasses the controls that govern the agent: harness or agent config, VCS hooks, hook bypass, CI workflows, policy files.",
|
|
74
|
+
risky: true,
|
|
75
|
+
},
|
|
76
|
+
"attack-shape": {
|
|
77
|
+
label: "Known attack shape",
|
|
78
|
+
description: "A known attack pattern: pipe-to-shell, reverse shell, exfiltration, suspicious network target.",
|
|
79
|
+
risky: true,
|
|
80
|
+
},
|
|
81
|
+
"policy-fault": {
|
|
82
|
+
label: "Policy fault",
|
|
83
|
+
description: "The gate itself failed: the policy did not compile, the check threw, or the scanner errored.",
|
|
84
|
+
risky: true,
|
|
85
|
+
},
|
|
86
|
+
"package-install": {
|
|
87
|
+
label: "Installs packages",
|
|
88
|
+
description: "Installs or removes packages, or runs foreign install scripts.",
|
|
89
|
+
risky: false,
|
|
90
|
+
},
|
|
91
|
+
publish: {
|
|
92
|
+
label: "Publishes",
|
|
93
|
+
description: "Publishes an artifact or content to a public or shared registry.",
|
|
94
|
+
risky: true,
|
|
95
|
+
},
|
|
96
|
+
"remote-write": {
|
|
97
|
+
label: "Changes remote state",
|
|
98
|
+
description: "Mutates remote state other than publishing: push, PR or issue edits, kubectl, a remote docker host.",
|
|
99
|
+
risky: true,
|
|
100
|
+
},
|
|
101
|
+
"network-egress": {
|
|
102
|
+
label: "Uses the network",
|
|
103
|
+
description: "Sends or fetches data over the network.",
|
|
104
|
+
risky: false,
|
|
105
|
+
},
|
|
106
|
+
"exec-unknown": {
|
|
107
|
+
label: "Runs code the floor cannot see",
|
|
108
|
+
description:
|
|
109
|
+
"Runs code whose effect the floor cannot see: unknown programs, interpreters, scripts, build tasks, eval, env-prefix, xargs, non-literal arguments.",
|
|
110
|
+
risky: false,
|
|
111
|
+
},
|
|
112
|
+
unmodelled: {
|
|
113
|
+
label: "Not precisely modelled",
|
|
114
|
+
description: "A known program whose verb or option the floor does not model precisely.",
|
|
115
|
+
risky: false,
|
|
116
|
+
},
|
|
117
|
+
"envelope-read": {
|
|
118
|
+
label: "Reads outside the workspace",
|
|
119
|
+
description: "Reads a path outside the enabled roots.",
|
|
120
|
+
risky: false,
|
|
121
|
+
},
|
|
122
|
+
"envelope-write": {
|
|
123
|
+
label: "Writes outside the workspace",
|
|
124
|
+
description: "Writes outside the enabled roots, or to an untyped output path.",
|
|
125
|
+
risky: true,
|
|
126
|
+
},
|
|
127
|
+
"protected-file": {
|
|
128
|
+
label: "Writes a protected file",
|
|
129
|
+
description: "Writes an in-tree file the policy carves out (manifests, lockfiles, Makefile).",
|
|
130
|
+
risky: false,
|
|
131
|
+
},
|
|
132
|
+
"workspace-write": {
|
|
133
|
+
label: "Writes in the workspace",
|
|
134
|
+
description: "Ordinary in-tree or scratch write.",
|
|
135
|
+
risky: false,
|
|
136
|
+
},
|
|
137
|
+
"mcp-lookup": {
|
|
138
|
+
label: "MCP look-up",
|
|
139
|
+
description: "An MCP tool whose name scores as a look-up.",
|
|
140
|
+
risky: false,
|
|
141
|
+
},
|
|
142
|
+
"mcp-action": {
|
|
143
|
+
label: "MCP action",
|
|
144
|
+
description: "Any other MCP or unknown tool.",
|
|
145
|
+
risky: false,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const IDS: ReadonlySet<string> = new Set(CLASS_IDS);
|
|
150
|
+
|
|
151
|
+
export function isClassId(value: unknown): value is ClassId {
|
|
152
|
+
return typeof value === "string" && IDS.has(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Tier names as users write them. The engine names stay `deny` / `ask`. */
|
|
156
|
+
export const TIER_NAMES = ["soft-deny", "ask"] as const;
|
package/src/compile.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile a policy.json document into CommandNode + path terms.
|
|
3
|
+
* `"--"` is a handle, not a subcommand: omitted `policy` inherits.
|
|
4
|
+
* Extra keys on a command object are refused — verbs live under `subcommands`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ErrorWithInvariant } from "@inhuman.tools/error-with-invariant";
|
|
8
|
+
|
|
9
|
+
import { isClassId, type ClassId } from "./classes.js";
|
|
10
|
+
import { GUARDS } from "./guards.js";
|
|
11
|
+
import { mergePathTerms, normalizePath, type PathTerm } from "./path-policy.js";
|
|
12
|
+
import type { ArgType, CommandNode, Disposition, FlagConstraint, Matcher } from "./types.js";
|
|
13
|
+
|
|
14
|
+
type PathRule = PathTerm["rules"][number];
|
|
15
|
+
|
|
16
|
+
export class PolicyCompileError extends ErrorWithInvariant {
|
|
17
|
+
override readonly name = "PolicyCompileError";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PolicyAssumeError extends ErrorWithInvariant {
|
|
21
|
+
override readonly name = "PolicyAssumeError";
|
|
22
|
+
readonly expected?: Disposition;
|
|
23
|
+
readonly actual?: Disposition;
|
|
24
|
+
readonly command?: string;
|
|
25
|
+
|
|
26
|
+
constructor(message: string, detail?: { expected: Disposition; actual: Disposition; command: string }) {
|
|
27
|
+
super(message);
|
|
28
|
+
if (detail !== undefined) {
|
|
29
|
+
this.expected = detail.expected;
|
|
30
|
+
this.actual = detail.actual;
|
|
31
|
+
this.command = detail.command;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DISPS = new Set(["policyApprove", "approve", "allow", "ask", "deny", "block"]);
|
|
37
|
+
const RESERVED = new Set([
|
|
38
|
+
"policy",
|
|
39
|
+
"class",
|
|
40
|
+
"family_args",
|
|
41
|
+
"arg_types",
|
|
42
|
+
"arg_rest_type",
|
|
43
|
+
"flag_paths",
|
|
44
|
+
"guard",
|
|
45
|
+
"matcher",
|
|
46
|
+
"--",
|
|
47
|
+
"subcommands",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
type AssumeSmoke = {
|
|
51
|
+
readonly command: string;
|
|
52
|
+
readonly expect: Disposition;
|
|
53
|
+
/** Asserted against the classes of the votes that decided the tier. */
|
|
54
|
+
readonly expectClass?: ClassId;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type CompiledDocument = {
|
|
58
|
+
readonly bash?: CommandNode;
|
|
59
|
+
readonly read?: PathTerm;
|
|
60
|
+
readonly write?: PathTerm;
|
|
61
|
+
readonly assume: readonly AssumeSmoke[];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function compilePolicy(document: unknown): CompiledDocument {
|
|
65
|
+
const raw = document;
|
|
66
|
+
if (raw === null || raw === undefined) return { assume: [] };
|
|
67
|
+
PolicyCompileError.invariant(isRecord(raw), "policy.json: document must be a mapping");
|
|
68
|
+
const unknown = Object.keys(raw).filter(
|
|
69
|
+
(key) =>
|
|
70
|
+
!key.startsWith(".") &&
|
|
71
|
+
![
|
|
72
|
+
"read_file",
|
|
73
|
+
"write_file",
|
|
74
|
+
"read_block",
|
|
75
|
+
"write_block",
|
|
76
|
+
"bash",
|
|
77
|
+
"assume",
|
|
78
|
+
"read_miss_class",
|
|
79
|
+
"write_miss_class",
|
|
80
|
+
].includes(key),
|
|
81
|
+
);
|
|
82
|
+
PolicyCompileError.invariant(unknown.length === 0, `policy.json: unknown top-level key ${unknown[0]}`);
|
|
83
|
+
const readMiss = parseClass(raw.read_miss_class, "read_miss_class") ?? "envelope-read";
|
|
84
|
+
const writeMiss = parseClass(raw.write_miss_class, "write_miss_class") ?? "envelope-write";
|
|
85
|
+
return {
|
|
86
|
+
bash: raw.bash === undefined ? undefined : parseCommandDef(raw.bash, "bash", true),
|
|
87
|
+
read: mergePathTerms(
|
|
88
|
+
"read",
|
|
89
|
+
readMiss,
|
|
90
|
+
parsePathList(raw.read_file, "read_file", "policyApprove", "envelope-read"),
|
|
91
|
+
parseBlockList(raw.read_block, "read_block", "credential"),
|
|
92
|
+
),
|
|
93
|
+
write: mergePathTerms(
|
|
94
|
+
"write",
|
|
95
|
+
writeMiss,
|
|
96
|
+
parsePathList(raw.write_file, "write_file", "ask", "workspace-write"),
|
|
97
|
+
parseBlockList(raw.write_block, "write_block", "control-plane"),
|
|
98
|
+
),
|
|
99
|
+
assume: parseAssume(raw.assume),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A class id from the closed taxonomy. An unknown id is a compile error, never a new class. */
|
|
104
|
+
function parseClass(value: unknown, path: string): ClassId | undefined {
|
|
105
|
+
if (value === undefined) return undefined;
|
|
106
|
+
PolicyCompileError.invariant(isClassId(value), `policy.json: unknown class ${String(value)} at ${path}`);
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A path item is a string, or `{ path, class }` to tag the rule with a dist class. */
|
|
111
|
+
function pathItem(item: unknown, field: string): { readonly pattern: string; readonly class?: ClassId } {
|
|
112
|
+
if (typeof item === "string") return { pattern: item };
|
|
113
|
+
PolicyCompileError.invariant(isRecord(item), `${field}: each pattern is a string or { path, class }`);
|
|
114
|
+
const extra = Object.keys(item).filter((key) => key !== "path" && key !== "class");
|
|
115
|
+
PolicyCompileError.invariant(extra.length === 0, `${field}: unknown key ${extra[0]}`);
|
|
116
|
+
PolicyCompileError.invariant(typeof item.path === "string", `${field}: { path } is a string`);
|
|
117
|
+
return { pattern: item.path, class: parseClass(item.class, `${field}.class`) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parsePathList(
|
|
121
|
+
value: unknown,
|
|
122
|
+
field: string,
|
|
123
|
+
enable: "policyApprove" | "ask",
|
|
124
|
+
enableClass: ClassId,
|
|
125
|
+
): PathRule[] | undefined {
|
|
126
|
+
if (value === undefined) return undefined;
|
|
127
|
+
PolicyCompileError.invariant(Array.isArray(value), `${field}: list of strings`);
|
|
128
|
+
return value.map((raw) => {
|
|
129
|
+
const { pattern: item, class: tagged } = pathItem(raw, field);
|
|
130
|
+
PolicyCompileError.invariant(
|
|
131
|
+
!item.startsWith("-"),
|
|
132
|
+
"path pattern: - is not a prefix; ! is deny on the enable list",
|
|
133
|
+
);
|
|
134
|
+
const deny = item.startsWith("!");
|
|
135
|
+
const stem = normalizePath(deny ? item.slice(1) : item);
|
|
136
|
+
PolicyCompileError.invariant(stem !== undefined, "path pattern: empty or unresolvable");
|
|
137
|
+
return {
|
|
138
|
+
id: `${field}:${deny ? "!" : ""}${stem}`,
|
|
139
|
+
polarity: deny ? "deny" : enable,
|
|
140
|
+
stem,
|
|
141
|
+
unanchored: stem !== "." && !stem.startsWith("/") && !stem.includes("/"),
|
|
142
|
+
class: tagged ?? (deny ? "protected-file" : enableClass),
|
|
143
|
+
} satisfies PathRule;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseBlockList(value: unknown, field: string, defaultClass: ClassId): PathRule[] | undefined {
|
|
148
|
+
if (value === undefined) return undefined;
|
|
149
|
+
PolicyCompileError.invariant(Array.isArray(value), `${field}: list of strings`);
|
|
150
|
+
return value.map((raw) => {
|
|
151
|
+
const { pattern: item, class: tagged } = pathItem(raw, field);
|
|
152
|
+
PolicyCompileError.invariant(
|
|
153
|
+
!item.startsWith("!") && !item.startsWith("-"),
|
|
154
|
+
"block list: stem without a polarity prefix",
|
|
155
|
+
);
|
|
156
|
+
const stem = normalizePath(item);
|
|
157
|
+
PolicyCompileError.invariant(stem !== undefined, "path pattern: empty or unresolvable");
|
|
158
|
+
return {
|
|
159
|
+
id: `${field}:${stem}`,
|
|
160
|
+
polarity: "block" as const,
|
|
161
|
+
stem,
|
|
162
|
+
unanchored: stem !== "." && !stem.startsWith("/") && !stem.includes("/"),
|
|
163
|
+
class: tagged ?? defaultClass,
|
|
164
|
+
} satisfies PathRule;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseAssume(value: unknown): AssumeSmoke[] {
|
|
169
|
+
if (value === undefined) return [];
|
|
170
|
+
PolicyCompileError.invariant(Array.isArray(value), "assume: list of { command, expect }");
|
|
171
|
+
return value.map((item, i) => {
|
|
172
|
+
PolicyCompileError.invariant(isRecord(item), `assume[${i}]: mapping`);
|
|
173
|
+
PolicyCompileError.invariant(typeof item.command === "string", `assume[${i}]: command is a string`);
|
|
174
|
+
const expect = asDisposition(item.expect);
|
|
175
|
+
PolicyCompileError.invariant(expect !== undefined, `assume[${i}]: expect is approve|ask|deny|block`);
|
|
176
|
+
const expectClass = parseClass(item.expect_class, `assume[${i}].expect_class`);
|
|
177
|
+
return { command: item.command, expect, ...(expectClass === undefined ? {} : { expectClass }) };
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseFamilyArgs(value: unknown, path: string): number {
|
|
182
|
+
if (value === undefined) return 0;
|
|
183
|
+
PolicyCompileError.invariant(
|
|
184
|
+
typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4,
|
|
185
|
+
`${path}.family_args: integer 0..4`,
|
|
186
|
+
);
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseCommandDef(value: unknown, path: string, policyRequired: boolean): CommandNode {
|
|
191
|
+
const disp = asDisposition(value);
|
|
192
|
+
if (disp !== undefined) {
|
|
193
|
+
return { policy: disp, familyArgs: 0, argTypes: [], flagPaths: {}, guards: [], matchers: [], subcommands: {} };
|
|
194
|
+
}
|
|
195
|
+
PolicyCompileError.invariant(isRecord(value), `${path}: command is a disposition or a mapping`);
|
|
196
|
+
const extra = Object.keys(value).filter((key) => !RESERVED.has(key));
|
|
197
|
+
PolicyCompileError.invariant(extra.length === 0, `${path}: unknown key ${extra[0]} — verbs go under subcommands`);
|
|
198
|
+
const policy = value.policy === undefined ? undefined : asDisposition(value.policy);
|
|
199
|
+
PolicyCompileError.invariant(
|
|
200
|
+
value.policy === undefined || policy !== undefined,
|
|
201
|
+
`${path}.policy: approve|ask|deny|block`,
|
|
202
|
+
);
|
|
203
|
+
PolicyCompileError.invariant(!policyRequired || policy !== undefined, `${path}: policy is required`);
|
|
204
|
+
const subcommands: Record<string, CommandNode> = {};
|
|
205
|
+
if (value.subcommands !== undefined) {
|
|
206
|
+
PolicyCompileError.invariant(isRecord(value.subcommands), `${path}.subcommands: mapping`);
|
|
207
|
+
for (const [name, def] of Object.entries(value.subcommands)) {
|
|
208
|
+
subcommands[String(name)] = parseCommandDef(def, `${path}.subcommands.${name}`, true);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
policy,
|
|
213
|
+
class: parseClass(value.class, `${path}.class`),
|
|
214
|
+
familyArgs: parseFamilyArgs(value.family_args, path),
|
|
215
|
+
argTypes: parseArgTypes(value.arg_types, `${path}.arg_types`),
|
|
216
|
+
argRestType: parseArgType(value.arg_rest_type, `${path}.arg_rest_type`),
|
|
217
|
+
flagPaths: parseFlagPaths(value.flag_paths, `${path}.flag_paths`),
|
|
218
|
+
guards: parseGuards(value.guard, `${path}.guard`),
|
|
219
|
+
matchers: parseMatchers(value.matcher, `${path}.matcher`),
|
|
220
|
+
dash: value["--"] === undefined ? undefined : parseCommandDef(value["--"], `${path}.--`, false),
|
|
221
|
+
subcommands,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function parseArgTypes(value: unknown, path: string): ArgType[] {
|
|
226
|
+
if (value === undefined) return [];
|
|
227
|
+
PolicyCompileError.invariant(Array.isArray(value), `${path}: list`);
|
|
228
|
+
return value.map((item, i) => {
|
|
229
|
+
const parsed = parseArgType(item, `${path}[${i}]`);
|
|
230
|
+
PolicyCompileError.invariant(parsed !== undefined, `${path}[${i}]: missing`);
|
|
231
|
+
return parsed;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseArgType(value: unknown, path: string): ArgType | undefined {
|
|
236
|
+
if (value === undefined) return undefined;
|
|
237
|
+
PolicyCompileError.invariant(value === "read_path" || value === "write_path", `${path}: read_path|write_path`);
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** `{ "--output": "write_path", "-o": "write_path" }`. Long names also match every unambiguous-prefix spelling. */
|
|
242
|
+
function parseFlagPaths(value: unknown, path: string): Record<string, ArgType> {
|
|
243
|
+
if (value === undefined) return {};
|
|
244
|
+
PolicyCompileError.invariant(isRecord(value), `${path}: mapping of option to read_path|write_path`);
|
|
245
|
+
const out: Record<string, ArgType> = {};
|
|
246
|
+
for (const [name, kind] of Object.entries(value)) {
|
|
247
|
+
PolicyCompileError.invariant(name.startsWith("-"), `${path}.${name}: option name`);
|
|
248
|
+
const parsed = parseArgType(kind, `${path}.${name}`);
|
|
249
|
+
PolicyCompileError.invariant(parsed !== undefined, `${path}.${name}: missing`);
|
|
250
|
+
for (const spelling of withPrefixes([name])) out[spelling] = parsed;
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseGuards(value: unknown, path: string): string[] {
|
|
256
|
+
const names = stringList(value, path);
|
|
257
|
+
for (const name of names) PolicyCompileError.invariant(GUARDS.has(name), `${path}: unknown guard ${name}`);
|
|
258
|
+
return names;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** getopt_long accepts any unique prefix of a long option; the policy treats every prefix as the option. */
|
|
262
|
+
function withPrefixes(names: readonly string[]): string[] {
|
|
263
|
+
const out = new Set(names);
|
|
264
|
+
for (const name of names) {
|
|
265
|
+
if (!name.startsWith("--")) continue;
|
|
266
|
+
for (let end = 3; end < name.length; end += 1) out.add(name.slice(0, end));
|
|
267
|
+
}
|
|
268
|
+
return [...out];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseMatchers(value: unknown, path: string): Matcher[] {
|
|
272
|
+
if (value === undefined) return [];
|
|
273
|
+
PolicyCompileError.invariant(Array.isArray(value), `${path}: list`);
|
|
274
|
+
return value.map((item, i) => {
|
|
275
|
+
const here = `${path}[${i}]`;
|
|
276
|
+
PolicyCompileError.invariant(isRecord(item), `${here}: mapping`);
|
|
277
|
+
const extra = Object.keys(item).filter(
|
|
278
|
+
(key) => !["flags", "glob", "tok", "matcher", "policy", "class"].includes(key),
|
|
279
|
+
);
|
|
280
|
+
PolicyCompileError.invariant(extra.length === 0, `${here}: unknown key ${extra[0]}`);
|
|
281
|
+
const policy = item.policy === undefined ? undefined : asDisposition(item.policy);
|
|
282
|
+
PolicyCompileError.invariant(
|
|
283
|
+
item.policy === undefined || policy !== undefined,
|
|
284
|
+
`${here}.policy: approve|ask|deny|block`,
|
|
285
|
+
);
|
|
286
|
+
return {
|
|
287
|
+
class: parseClass(item.class, `${here}.class`),
|
|
288
|
+
flags: parseFlags(item.flags, `${here}.flags`),
|
|
289
|
+
globs: stringList(item.glob, `${here}.glob`),
|
|
290
|
+
toks: stringList(item.tok, `${here}.tok`),
|
|
291
|
+
matchers: parseMatchers(item.matcher, `${here}.matcher`),
|
|
292
|
+
policy,
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseFlags(value: unknown, path: string): Record<string, FlagConstraint> {
|
|
298
|
+
if (value === undefined) return {};
|
|
299
|
+
PolicyCompileError.invariant(isRecord(value), `${path}: mapping`);
|
|
300
|
+
const out: Record<string, FlagConstraint> = {};
|
|
301
|
+
for (const [raw, spec] of Object.entries(value)) {
|
|
302
|
+
const canonical = String(raw);
|
|
303
|
+
out[canonical] = parseFlagConstraint(spec, canonical, `${path}.${canonical}`);
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function parseFlagConstraint(value: unknown, canonical: string, path: string): FlagConstraint {
|
|
309
|
+
if (value === true || value === null) {
|
|
310
|
+
return { names: [canonical], vals: [], takesValue: false };
|
|
311
|
+
}
|
|
312
|
+
PolicyCompileError.invariant(isRecord(value), `${path}: true or { alias, value, value_type }`);
|
|
313
|
+
const extra = Object.keys(value).filter((key) => !["alias", "value", "value_type", "abbrev"].includes(key));
|
|
314
|
+
PolicyCompileError.invariant(extra.length === 0, `${path}: unknown key ${extra[0]}`);
|
|
315
|
+
const aliases = stringList(value.alias, `${path}.alias`);
|
|
316
|
+
const vals = stringList(value.value, `${path}.value`);
|
|
317
|
+
const takesValue = vals.length > 0 || value.value_type !== undefined;
|
|
318
|
+
if (value.value_type !== undefined) {
|
|
319
|
+
PolicyCompileError.invariant(typeof value.value_type === "string", `${path}.value_type: string`);
|
|
320
|
+
}
|
|
321
|
+
PolicyCompileError.invariant(
|
|
322
|
+
value.abbrev === undefined || typeof value.abbrev === "boolean",
|
|
323
|
+
`${path}.abbrev: boolean`,
|
|
324
|
+
);
|
|
325
|
+
const names = [canonical, ...aliases];
|
|
326
|
+
return { names: value.abbrev === true ? withPrefixes(names) : names, vals, takesValue };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function stringList(value: unknown, path: string): string[] {
|
|
330
|
+
if (value === undefined) return [];
|
|
331
|
+
if (typeof value === "string") return [value];
|
|
332
|
+
PolicyCompileError.invariant(Array.isArray(value), `${path}: string or list of strings`);
|
|
333
|
+
return value.map((item, i) => {
|
|
334
|
+
PolicyCompileError.invariant(typeof item === "string", `${path}[${i}]: string`);
|
|
335
|
+
return item;
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function asDisposition(value: unknown): Disposition | undefined {
|
|
340
|
+
if (typeof value !== "string" || !DISPS.has(value)) return undefined;
|
|
341
|
+
if (value === "allow" || value === "approve" || value === "policyApprove") return "policyApprove";
|
|
342
|
+
return value as Disposition;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function flagVocab(node: CommandNode): Set<string> {
|
|
346
|
+
const names = new Set<string>();
|
|
347
|
+
addMatcherFlags(node.matchers, names, false);
|
|
348
|
+
return names;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function valueFlagNames(node: CommandNode): Set<string> {
|
|
352
|
+
const names = new Set<string>();
|
|
353
|
+
addMatcherFlags(node.matchers, names, true);
|
|
354
|
+
for (const name of Object.keys(node.flagPaths)) names.add(name);
|
|
355
|
+
return names;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function addMatcherFlags(matchers: readonly Matcher[], names: Set<string>, valueOnly: boolean): void {
|
|
359
|
+
for (const matcher of matchers) {
|
|
360
|
+
for (const flag of Object.values(matcher.flags)) {
|
|
361
|
+
if (valueOnly && !flag.takesValue) continue;
|
|
362
|
+
for (const name of flag.names) names.add(name);
|
|
363
|
+
}
|
|
364
|
+
addMatcherFlags(matcher.matchers, names, valueOnly);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
369
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
370
|
+
}
|
package/src/deadline.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Judge budget shared by the adapters. Crush's hook self-bounds at the same 20 s. */
|
|
2
|
+
export const JUDGE_DEADLINE_MS = 20_000;
|
|
3
|
+
|
|
4
|
+
/** Reject when `work` has not settled in `ms`; the caller maps a rejection to deny. */
|
|
5
|
+
export async function withDeadline<T>(work: Promise<T>, ms: number = JUDGE_DEADLINE_MS): Promise<T> {
|
|
6
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
7
|
+
const expired = new Promise<never>((_resolve, reject) => {
|
|
8
|
+
timer = setTimeout(() => reject(new Error("authorization timed out")), ms);
|
|
9
|
+
});
|
|
10
|
+
try {
|
|
11
|
+
return await Promise.race([work, expired]);
|
|
12
|
+
} finally {
|
|
13
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/floor.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { bindPolicy } from "./bind.js";
|
|
2
|
+
export { CLASS_IDS, CLASSES, isClassId, TIER_NAMES, type ClassId, type ClassInfo } from "./classes.js";
|
|
3
|
+
export { whichOnPath, type Which } from "./identity.js";
|
|
4
|
+
export { lattice } from "./lift.js";
|
|
5
|
+
export { classifierResources, policyEnvCapability, processorCapability } from "./processor.js";
|
|
6
|
+
export { reasonableDefaultsSource } from "./reasonable-defaults.js";
|
|
7
|
+
export {
|
|
8
|
+
classesOf,
|
|
9
|
+
type Disposition,
|
|
10
|
+
type FloorVerdict,
|
|
11
|
+
type FloorVote,
|
|
12
|
+
type LiftKind,
|
|
13
|
+
type Policy,
|
|
14
|
+
type UnitIdentity,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
export { APPROVE, cast, join } from "./verdict.js";
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lexical brace expansion (`.e{nv,x}`, `a{1..3}`, `{a,b}{c,d}`), the way bash does it before
|
|
3
|
+
* globbing. Used to judge a shell word as every word it could become. Bounded: past `limit`
|
|
4
|
+
* words the caller falls back to a wildcard superset.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LIMIT = 256;
|
|
8
|
+
|
|
9
|
+
type Group = { readonly start: number; readonly end: number; readonly items: readonly string[] };
|
|
10
|
+
|
|
11
|
+
export function expandBraces(
|
|
12
|
+
word: string,
|
|
13
|
+
limit: number = DEFAULT_LIMIT,
|
|
14
|
+
): { readonly words: readonly string[]; readonly overflow: boolean } {
|
|
15
|
+
const done: string[] = [];
|
|
16
|
+
const work: string[] = [word];
|
|
17
|
+
while (work.length > 0) {
|
|
18
|
+
const next = work.pop()!;
|
|
19
|
+
const group = firstGroup(next);
|
|
20
|
+
if (group === undefined) {
|
|
21
|
+
done.push(next);
|
|
22
|
+
} else {
|
|
23
|
+
const head = next.slice(0, group.start);
|
|
24
|
+
const tail = next.slice(group.end + 1);
|
|
25
|
+
for (const item of group.items) work.push(`${head}${item}${tail}`);
|
|
26
|
+
}
|
|
27
|
+
if (done.length + work.length > limit) return { words: [], overflow: true };
|
|
28
|
+
}
|
|
29
|
+
return { words: done.toReversed(), overflow: false };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The first top-level `{...}` that bash would expand (a comma list or a range), or undefined. */
|
|
33
|
+
function firstGroup(text: string): Group | undefined {
|
|
34
|
+
for (let start = 0; start < text.length; start += 1) {
|
|
35
|
+
if (text[start] === "\\") {
|
|
36
|
+
start += 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (text[start] !== "{") continue;
|
|
40
|
+
const end = matchingBrace(text, start);
|
|
41
|
+
if (end === undefined) continue;
|
|
42
|
+
const body = text.slice(start + 1, end);
|
|
43
|
+
const items = commaItems(body) ?? rangeItems(body);
|
|
44
|
+
if (items !== undefined) return { start, end, items };
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function matchingBrace(text: string, start: number): number | undefined {
|
|
50
|
+
let depth = 0;
|
|
51
|
+
for (let i = start; i < text.length; i += 1) {
|
|
52
|
+
const ch = text[i];
|
|
53
|
+
switch (ch) {
|
|
54
|
+
case "\\": {
|
|
55
|
+
i += 1;
|
|
56
|
+
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case "{": {
|
|
60
|
+
depth += 1;
|
|
61
|
+
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "}": {
|
|
65
|
+
depth -= 1;
|
|
66
|
+
if (depth === 0) return i;
|
|
67
|
+
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
// No default
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Items of a top-level comma list. Undefined when there is no top-level comma. */
|
|
77
|
+
function commaItems(body: string): string[] | undefined {
|
|
78
|
+
const items: string[] = [];
|
|
79
|
+
let depth = 0;
|
|
80
|
+
let from = 0;
|
|
81
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
82
|
+
const ch = body[i];
|
|
83
|
+
switch (ch) {
|
|
84
|
+
case "\\": {
|
|
85
|
+
i += 1;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "{": {
|
|
89
|
+
depth += 1;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "}": {
|
|
93
|
+
depth -= 1;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
if (ch === "," && depth === 0) {
|
|
98
|
+
items.push(body.slice(from, i));
|
|
99
|
+
from = i + 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (items.length === 0) return undefined;
|
|
104
|
+
items.push(body.slice(from));
|
|
105
|
+
return items;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const RANGE = /^(-?\d+|[A-Z])\.\.(-?\d+|[A-Z])(?:\.\.(-?\d+))?$/i;
|
|
109
|
+
const MAX_RANGE = 1024;
|
|
110
|
+
|
|
111
|
+
function rangeItems(body: string): string[] | undefined {
|
|
112
|
+
const match = RANGE.exec(body);
|
|
113
|
+
if (match === null) return undefined;
|
|
114
|
+
const [, a, b, stepText] = match;
|
|
115
|
+
const step = Math.max(1, Math.abs(Number(stepText ?? "1")) || 1);
|
|
116
|
+
const out: string[] = [];
|
|
117
|
+
if (/^-?\d+$/.test(a!) && /^-?\d+$/.test(b!)) {
|
|
118
|
+
const from = Number(a);
|
|
119
|
+
const to = Number(b);
|
|
120
|
+
const width =
|
|
121
|
+
/^-?0\d/.test(a!) || /^-?0\d/.test(b!) ? Math.max(a!.replace("-", "").length, b!.replace("-", "").length) : 0;
|
|
122
|
+
if (Math.abs(to - from) / step > MAX_RANGE) return Array.from({ length: MAX_RANGE + 1 }, (_, i) => String(i));
|
|
123
|
+
for (let n = from; from <= to ? n <= to : n >= to; n += from <= to ? step : -step) {
|
|
124
|
+
const text = String(Math.abs(n)).padStart(width, "0");
|
|
125
|
+
out.push(n < 0 ? `-${text}` : text);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
if (a!.length === 1 && b!.length === 1 && !/\d/.test(a! + b!)) {
|
|
130
|
+
const from = a!.codePointAt(0)!;
|
|
131
|
+
const to = b!.codePointAt(0)!;
|
|
132
|
+
for (let c = from; from <= to ? c <= to : c >= to; c += from <= to ? step : -step)
|
|
133
|
+
out.push(String.fromCodePoint(c));
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|