@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/guards.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument guards: what an option table cannot say. A guard sees the command
|
|
3
|
+
* node's positionals and flag bag and returns extra votes that join the node's
|
|
4
|
+
* own vote on the strict lattice, so a guard can tighten a verdict, never lift it.
|
|
5
|
+
*
|
|
6
|
+
* `flag_paths` (option values typed as paths, glued short values included) is
|
|
7
|
+
* a guard that every node gets for free.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { posix } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { explainPath, explainShellPath, type PathTerm } from "./path-policy.js";
|
|
13
|
+
import type { ArgType, CommandNode, Disposition, FloorVerdict } from "./types.js";
|
|
14
|
+
import { cast } from "./verdict.js";
|
|
15
|
+
|
|
16
|
+
export type GuardPaths = {
|
|
17
|
+
readonly read?: PathTerm;
|
|
18
|
+
readonly write?: PathTerm;
|
|
19
|
+
/** The shell would expand this question's words: judge braces and globs as what they could become. */
|
|
20
|
+
readonly expand?: boolean;
|
|
21
|
+
/** `dotglob` is set somewhere in the command: `*` may match a leading dot. */
|
|
22
|
+
readonly dotglob?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Flags = Readonly<Record<string, readonly string[]>>;
|
|
26
|
+
|
|
27
|
+
type Guard = (toks: readonly string[], flags: Flags, paths: GuardPaths) => FloorVerdict[];
|
|
28
|
+
|
|
29
|
+
export function guardVotes(
|
|
30
|
+
node: CommandNode,
|
|
31
|
+
toks: readonly string[],
|
|
32
|
+
flags: Flags,
|
|
33
|
+
paths: GuardPaths,
|
|
34
|
+
): FloorVerdict[] {
|
|
35
|
+
const positionals = toks.filter((tok) => tok !== "--");
|
|
36
|
+
return [
|
|
37
|
+
...flagPathVotes(node.flagPaths, flags, paths),
|
|
38
|
+
...node.guards.flatMap((name) => (GUARD_TABLE[name] ?? unknownGuard)(positionals, flags, paths)),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unknownGuard(): FloorVerdict[] {
|
|
43
|
+
return [cast("ask", "unmodelled", "guard:unknown")];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function pathVote(kind: ArgType, path: string, paths: GuardPaths): FloorVerdict {
|
|
47
|
+
const cluster = kind === "read_path" ? paths.read : paths.write;
|
|
48
|
+
if (cluster === undefined) return cast("ask", "unmodelled", `${kind === "read_path" ? "read" : "write"}:no-policy`);
|
|
49
|
+
return paths.expand === true
|
|
50
|
+
? explainShellPath(cluster, path, { dotglob: paths.dotglob })
|
|
51
|
+
: explainPath(cluster, path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `--output=F`, `--output F`, `-o F`, and glued `-oF`. An empty value (an
|
|
56
|
+
* option that lost its operand, or a letter cluster such as `-ofile`) is an
|
|
57
|
+
* unresolvable path and asks.
|
|
58
|
+
*/
|
|
59
|
+
function flagPathVotes(flagPaths: Readonly<Record<string, ArgType>>, flags: Flags, paths: GuardPaths): FloorVerdict[] {
|
|
60
|
+
const votes: FloorVerdict[] = [];
|
|
61
|
+
for (const [name, kind] of Object.entries(flagPaths)) {
|
|
62
|
+
for (const value of flags[name] ?? []) votes.push(pathVote(kind, value, paths));
|
|
63
|
+
if (name.startsWith("--") || name.length !== 2) continue;
|
|
64
|
+
for (const key of Object.keys(flags)) {
|
|
65
|
+
if (key.startsWith(name) && key.length > 2 && !key.startsWith("--")) {
|
|
66
|
+
votes.push(pathVote(kind, key.slice(2), paths));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return votes;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when `key` is `full` or an unambiguous long-option prefix of it (`--out` of `--output`). */
|
|
74
|
+
function isOption(key: string, full: string): boolean {
|
|
75
|
+
return key === full || (key.startsWith("--") && key.length >= 3 && full.startsWith(key));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const GIT_CONFIG_DENY = [
|
|
79
|
+
/^alias\./,
|
|
80
|
+
/^include(if)?\./,
|
|
81
|
+
/^core\.(hookspath|fsmonitor|sshcommand|pager|editor|askpass|gitproxy|attributesfile|excludesfile)$/,
|
|
82
|
+
/^diff\..*(external|textconv|command)$/,
|
|
83
|
+
/^(pager|credential|filter|merge|mergetool|difftool|man|web|browser|sequence|gpg|url|protocol|uploadpack|receivepack|safe|http|remote|submodule)\./,
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/** Keys whose value no git code path turns into a program, a file read, or a file write. */
|
|
87
|
+
const GIT_CONFIG_INERT = new Set([
|
|
88
|
+
"color.ui",
|
|
89
|
+
"color.diff",
|
|
90
|
+
"color.status",
|
|
91
|
+
"color.branch",
|
|
92
|
+
"color.log",
|
|
93
|
+
"core.quotepath",
|
|
94
|
+
"core.abbrev",
|
|
95
|
+
"core.precomposeunicode",
|
|
96
|
+
"core.filemode",
|
|
97
|
+
"core.whitespace",
|
|
98
|
+
"diff.renames",
|
|
99
|
+
"diff.context",
|
|
100
|
+
"diff.algorithm",
|
|
101
|
+
"log.date",
|
|
102
|
+
"log.abbrevcommit",
|
|
103
|
+
"status.showuntrackedfiles",
|
|
104
|
+
"status.branch",
|
|
105
|
+
"status.short",
|
|
106
|
+
"advice.statushints",
|
|
107
|
+
"user.name",
|
|
108
|
+
"user.email",
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `-c key=value` allowlist (case-insensitive: git folds the section and key).
|
|
113
|
+
* Exec/include keys deny, anything unknown asks. `--config-env` always asks.
|
|
114
|
+
*/
|
|
115
|
+
function gitConfig(_toks: readonly string[], flags: Flags): FloorVerdict[] {
|
|
116
|
+
const votes: FloorVerdict[] = [];
|
|
117
|
+
for (const raw of flags["-c"] ?? []) {
|
|
118
|
+
const key = raw.split("=", 1)[0]!.trim().toLowerCase();
|
|
119
|
+
if (GIT_CONFIG_DENY.some((rule) => rule.test(key))) votes.push(cast("deny", "exec-unknown", "guard:git-config"));
|
|
120
|
+
else if (!GIT_CONFIG_INERT.has(key)) votes.push(cast("ask", "unmodelled", "guard:git-config"));
|
|
121
|
+
}
|
|
122
|
+
if (Object.keys(flags).includes("--config-env")) votes.push(cast("ask", "exec-unknown", "guard:git-config-env"));
|
|
123
|
+
return votes;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const REV_SYNTAX = /[~^@{}]/;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* diff/log/show operands are revisions or paths. `--no-index` makes every
|
|
130
|
+
* operand a file git reads; otherwise a `rev:path` or bare operand that is
|
|
131
|
+
* not revision syntax is read as a path too (`git diff -- .env`, `git show HEAD:.env`).
|
|
132
|
+
*/
|
|
133
|
+
function gitReads(toks: readonly string[], flags: Flags, paths: GuardPaths): FloorVerdict[] {
|
|
134
|
+
const noIndex = Object.keys(flags).some((key) => isOption(key, "--no-index"));
|
|
135
|
+
if (noIndex) return toks.map((tok) => pathVote("read_path", tok, paths));
|
|
136
|
+
const votes: FloorVerdict[] = [];
|
|
137
|
+
for (const tok of toks) {
|
|
138
|
+
for (const part of tok.split(/\.{2,3}/)) {
|
|
139
|
+
const target = part.includes(":") ? part.slice(part.indexOf(":") + 1) : part;
|
|
140
|
+
if (target === "" || REV_SYNTAX.test(target)) continue;
|
|
141
|
+
votes.push(pathVote("read_path", target, paths));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return votes;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const SED_SAFE_FLAGS = [
|
|
148
|
+
"--quiet",
|
|
149
|
+
"--silent",
|
|
150
|
+
"--regexp-extended",
|
|
151
|
+
"--separate",
|
|
152
|
+
"--unbuffered",
|
|
153
|
+
"--null-data",
|
|
154
|
+
"--posix",
|
|
155
|
+
"--sandbox",
|
|
156
|
+
"--debug",
|
|
157
|
+
"--follow-symlinks",
|
|
158
|
+
"--expression",
|
|
159
|
+
];
|
|
160
|
+
const SED_OTHER_LONG = ["--file", "--in-place", "--line-length", "--help", "--version", "--binary"];
|
|
161
|
+
const SED_SAFE_SHORT = new Set(["-n", "-E", "-r", "-s", "-u", "-z", "-e"]);
|
|
162
|
+
|
|
163
|
+
function sedFlagVote(key: string): Disposition | undefined {
|
|
164
|
+
if (isOption(key, "--in-place")) return "deny";
|
|
165
|
+
if (key.startsWith("--")) {
|
|
166
|
+
const safe = SED_SAFE_FLAGS.some((full) => isOption(key, full));
|
|
167
|
+
const other = SED_OTHER_LONG.some((full) => isOption(key, full));
|
|
168
|
+
return safe && !other ? undefined : "ask";
|
|
169
|
+
}
|
|
170
|
+
if (key.startsWith("-i") || key.startsWith("-I")) return "deny";
|
|
171
|
+
if (key.startsWith("-e") && key.length > 2) return undefined; // glued script, validated below
|
|
172
|
+
return SED_SAFE_SHORT.has(key) ? undefined : "ask";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sedIsBoolean(key: string): boolean {
|
|
176
|
+
if (key.startsWith("--")) return sedFlagVote(key) === undefined && !isOption(key, "--expression");
|
|
177
|
+
return SED_SAFE_SHORT.has(key) && key !== "-e";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sedScripts(toks: readonly string[], flags: Flags): { scripts: string[]; files: readonly string[] } {
|
|
181
|
+
const scripts: string[] = [];
|
|
182
|
+
for (const [key, values] of Object.entries(flags)) {
|
|
183
|
+
if (key === "-e" || isOption(key, "--expression")) scripts.push(...values.filter((value) => value !== ""));
|
|
184
|
+
else if (key.startsWith("-e") && !key.startsWith("--") && key.length > 2) scripts.push(key.slice(2));
|
|
185
|
+
}
|
|
186
|
+
if (scripts.length > 0) return { scripts, files: toks };
|
|
187
|
+
return { scripts: toks.slice(0, 1), files: toks.slice(1) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* sed: in-place edits deny; a script that is anything but plain addressed
|
|
192
|
+
* `s`/`y`/`p`/`d`/`q`/`n`-style commands with benign `s` flags asks. That drops the file
|
|
193
|
+
* writers (`w`, `W`, `s///w`) and the program runners (`e`, `s///e`), and `r`/`R` file reads.
|
|
194
|
+
* File operands are reads.
|
|
195
|
+
*/
|
|
196
|
+
function sed(toks: readonly string[], flags: Flags, paths: GuardPaths): FloorVerdict[] {
|
|
197
|
+
const votes = Object.keys(flags).flatMap((key) => {
|
|
198
|
+
const vote = sedFlagVote(key);
|
|
199
|
+
if (vote === undefined) return [];
|
|
200
|
+
return [
|
|
201
|
+
vote === "deny"
|
|
202
|
+
? cast("deny", "workspace-write", "guard:sed-in-place")
|
|
203
|
+
: cast("ask", "exec-unknown", "guard:sed-flag"),
|
|
204
|
+
];
|
|
205
|
+
});
|
|
206
|
+
// `-n SCRIPT` also parses as `-n` taking SCRIPT as a value. The other reading (boolean flag, SCRIPT a
|
|
207
|
+
// positional) is enumerated and judged on its own, so this one has no script or files to inspect.
|
|
208
|
+
const swallowed = Object.entries(flags).some(([key, values]) => sedIsBoolean(key) && values.some((v) => v !== ""));
|
|
209
|
+
if (swallowed) return votes;
|
|
210
|
+
const { scripts, files } = sedScripts(toks, flags);
|
|
211
|
+
if (scripts.length === 0 || !scripts.every((script) => sedScriptIsPlain(script))) {
|
|
212
|
+
votes.push(cast("ask", "exec-unknown", "guard:sed-script"));
|
|
213
|
+
}
|
|
214
|
+
for (const file of files) votes.push(pathVote("read_path", file, paths));
|
|
215
|
+
return votes;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const SED_SIMPLE = new Set(["p", "P", "d", "D", "g", "G", "h", "H", "x", "n", "N", "=", "l", "q", "Q", "z"]);
|
|
219
|
+
|
|
220
|
+
/** Conservative sed parser: true only for scripts it fully understands and finds inert. */
|
|
221
|
+
export function sedScriptIsPlain(script: string): boolean {
|
|
222
|
+
let i = 0;
|
|
223
|
+
let depth = 0;
|
|
224
|
+
const n = script.length;
|
|
225
|
+
const blank = (c: string | undefined) => c === " " || c === "\t";
|
|
226
|
+
const digit = (c: string | undefined) => c !== undefined && c >= "0" && c <= "9";
|
|
227
|
+
const space = () => {
|
|
228
|
+
while (i < n && blank(script[i])) i += 1;
|
|
229
|
+
};
|
|
230
|
+
/** Consume up to the unescaped delimiter. Returns false when unterminated. */
|
|
231
|
+
const delimited = (delim: string): boolean => {
|
|
232
|
+
while (i < n) {
|
|
233
|
+
const c = script[i]!;
|
|
234
|
+
i += 1;
|
|
235
|
+
if (c === "\\") {
|
|
236
|
+
i += 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (c === delim) return true;
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
};
|
|
243
|
+
const address = (): boolean => {
|
|
244
|
+
const c = script[i];
|
|
245
|
+
if (c === "$") {
|
|
246
|
+
i += 1;
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
if (digit(c)) {
|
|
250
|
+
while (digit(script[i])) i += 1;
|
|
251
|
+
if (script[i] === "~") {
|
|
252
|
+
i += 1;
|
|
253
|
+
while (digit(script[i])) i += 1;
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
if (c === "/" || c === "\\") {
|
|
258
|
+
let delim = "/";
|
|
259
|
+
if (c === "\\") {
|
|
260
|
+
i += 1;
|
|
261
|
+
delim = script[i] ?? "";
|
|
262
|
+
if (delim === "" || delim === "\\" || delim === "\n") return false;
|
|
263
|
+
}
|
|
264
|
+
i += 1;
|
|
265
|
+
if (!delimited(delim)) return false;
|
|
266
|
+
while (script[i] === "I" || script[i] === "M") i += 1;
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
};
|
|
271
|
+
const ending = (): boolean => {
|
|
272
|
+
space();
|
|
273
|
+
const c = script[i];
|
|
274
|
+
return c === undefined || c === ";" || c === "\n" || c === "}";
|
|
275
|
+
};
|
|
276
|
+
while (i < n) {
|
|
277
|
+
while (i < n && (blank(script[i]) || script[i] === ";" || script[i] === "\n")) i += 1;
|
|
278
|
+
if (i >= n) break;
|
|
279
|
+
if (script[i] === "}") {
|
|
280
|
+
depth -= 1;
|
|
281
|
+
if (depth < 0) return false;
|
|
282
|
+
i += 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (address()) {
|
|
286
|
+
space();
|
|
287
|
+
if (script[i] === ",") {
|
|
288
|
+
i += 1;
|
|
289
|
+
space();
|
|
290
|
+
if (script[i] === "+" || script[i] === "~") i += 1;
|
|
291
|
+
if (!address()) return false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
space();
|
|
295
|
+
while (script[i] === "!") {
|
|
296
|
+
i += 1;
|
|
297
|
+
space();
|
|
298
|
+
}
|
|
299
|
+
const cmd = script[i];
|
|
300
|
+
if (cmd === undefined) return false;
|
|
301
|
+
i += 1;
|
|
302
|
+
if (cmd === "{") {
|
|
303
|
+
depth += 1;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (cmd === "s" || cmd === "y") {
|
|
307
|
+
const delim = script[i];
|
|
308
|
+
if (delim === undefined || delim === "\\" || delim === "\n" || blank(delim)) return false;
|
|
309
|
+
i += 1;
|
|
310
|
+
if (!delimited(delim) || !delimited(delim)) return false;
|
|
311
|
+
if (cmd === "s") {
|
|
312
|
+
while (i < n && "gpiImM0123456789".includes(script[i]!)) i += 1;
|
|
313
|
+
}
|
|
314
|
+
if (!ending()) return false;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (SED_SIMPLE.has(cmd)) {
|
|
318
|
+
if (cmd === "q" || cmd === "Q" || cmd === "l") {
|
|
319
|
+
space();
|
|
320
|
+
while (digit(script[i])) i += 1;
|
|
321
|
+
}
|
|
322
|
+
if (!ending()) return false;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
return depth === 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Catastrophic shapes: the few commands whose one run cannot be undone, so no mode may
|
|
332
|
+
* lift them (`block`, not `deny`). A guard only tightens, so the node keeps its soft vote
|
|
333
|
+
* for every other form and these arms add the hard one.
|
|
334
|
+
*
|
|
335
|
+
* Operands are read from positionals and from flag values alike: `rm -r /` parses both as
|
|
336
|
+
* a boolean `-r` plus operand `/` and as `-r` taking `/`, and either reading must see it.
|
|
337
|
+
*/
|
|
338
|
+
const ROOT_LIKE = new Set([
|
|
339
|
+
"/",
|
|
340
|
+
"/*",
|
|
341
|
+
"/.",
|
|
342
|
+
"~",
|
|
343
|
+
"~/",
|
|
344
|
+
"~/*",
|
|
345
|
+
"~/.",
|
|
346
|
+
"$HOME",
|
|
347
|
+
"$HOME/",
|
|
348
|
+
"$HOME/*",
|
|
349
|
+
"$HOME/.",
|
|
350
|
+
"${HOME}",
|
|
351
|
+
"${HOME}/",
|
|
352
|
+
"${HOME}/*",
|
|
353
|
+
"${HOME}/.",
|
|
354
|
+
]);
|
|
355
|
+
|
|
356
|
+
/** `/`, `/*`, `~`, `~/`, `$HOME` and their `//`, `/.` spellings. Not `/tmp/x`, not `./`. */
|
|
357
|
+
function isRootLike(word: string): boolean {
|
|
358
|
+
const squeezed = word.replaceAll(/\/{2,}/g, "/");
|
|
359
|
+
if (ROOT_LIKE.has(squeezed)) return true;
|
|
360
|
+
// `/.`, `/./`, `~/.`: no-op segments after the head.
|
|
361
|
+
const plain = squeezed.replaceAll(/(?<=.)\/\.(?=\/|$)/g, "");
|
|
362
|
+
return plain !== squeezed && ROOT_LIKE.has(plain);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function operands(toks: readonly string[], flags: Flags): string[] {
|
|
366
|
+
return [...toks, ...Object.values(flags).flat()];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** `-R`, `--recursive`, or a short cluster that carries `r`/`R` (`-rf`, `-fR`). */
|
|
370
|
+
function isRecursive(flags: Flags): boolean {
|
|
371
|
+
return Object.keys(flags).some((key) => isOption(key, "--recursive") || (/^-[A-Z]+$/i.test(key) && /r/i.test(key)));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function hasOption(flags: Flags, full: string): boolean {
|
|
375
|
+
return Object.keys(flags).some((key) => isOption(key, full));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** rm/chmod/chown: `--no-preserve-root`, or recursion over the filesystem root or the home directory. */
|
|
379
|
+
function recursiveRoot(toks: readonly string[], flags: Flags): FloorVerdict[] {
|
|
380
|
+
if (hasOption(flags, "--no-preserve-root")) return [catastrophe("guard:no-preserve-root")];
|
|
381
|
+
return isRecursive(flags) && operands(toks, flags).some(isRootLike) ? [catastrophe("guard:recursive-root")] : [];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function catastrophe(ruleId: string): FloorVerdict {
|
|
385
|
+
return cast("block", "destroy-catastrophic", ruleId);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Device nodes dd may write without harm. Everything else under /dev is a disk, a tty, or memory. */
|
|
389
|
+
const DD_HARMLESS_DEVICES = new Set(["/dev/null", "/dev/zero", "/dev/full", "/dev/stdout", "/dev/stderr", "/dev/tty"]);
|
|
390
|
+
|
|
391
|
+
/** dd: `of=` a device node. */
|
|
392
|
+
function ddDevice(toks: readonly string[], flags: Flags): FloorVerdict[] {
|
|
393
|
+
const hit = operands(toks, flags).some((word) => {
|
|
394
|
+
if (!word.startsWith("of=")) return false;
|
|
395
|
+
const target = posix.normalize(word.slice(3));
|
|
396
|
+
if (!target.startsWith("/dev/")) return false;
|
|
397
|
+
return !DD_HARMLESS_DEVICES.has(target) && !target.startsWith("/dev/fd/");
|
|
398
|
+
});
|
|
399
|
+
return hit ? [catastrophe("guard:dd-device")] : [];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const PARTED_READS = new Set(["print", "p", "free", "all", "list", "devices"]);
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Partition editors. The listing forms read; every other invocation (including the
|
|
406
|
+
* interactive ones, whose commands arrive on stdin) can rewrite the table.
|
|
407
|
+
*/
|
|
408
|
+
function diskWrite(toks: readonly string[], flags: Flags): FloorVerdict[] {
|
|
409
|
+
const readOnly =
|
|
410
|
+
["--list", "--show-size", "--version", "--help"].some((full) => hasOption(flags, full)) ||
|
|
411
|
+
["-l", "-s", "-V", "-h"].some((short) => Object.hasOwn(flags, short));
|
|
412
|
+
if (readOnly) return [];
|
|
413
|
+
// parted names the device, then commands: only the printing ones are a read.
|
|
414
|
+
const commands = toks.filter((tok) => !tok.startsWith("/dev/"));
|
|
415
|
+
const printing = commands.length > 0 && commands.every((tok) => PARTED_READS.has(tok));
|
|
416
|
+
return printing ? [] : [catastrophe("guard:disk-write")];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const GUARD_TABLE: Readonly<Record<string, Guard>> = {
|
|
420
|
+
"git-config": gitConfig,
|
|
421
|
+
"git-reads": gitReads,
|
|
422
|
+
sed,
|
|
423
|
+
"recursive-root": recursiveRoot,
|
|
424
|
+
"dd-device": ddDevice,
|
|
425
|
+
"disk-write": diskWrite,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
export const GUARDS: ReadonlySet<string> = new Set(Object.keys(GUARD_TABLE));
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executable identity for the floor. Path-form argv0 is the named command
|
|
3
|
+
* iff `which` returns the same path; otherwise it stays a path.
|
|
4
|
+
* No process-lifetime cache — PATH/cwd are part of identity.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
import { resolveKnown, type Which } from "@inhuman.tools/policy-bash-disassembler";
|
|
10
|
+
|
|
11
|
+
export function whichOnPath(name: string): string | undefined {
|
|
12
|
+
if (name === "" || name.includes("/") || name.includes("\0")) return undefined;
|
|
13
|
+
try {
|
|
14
|
+
const out = execFileSync("which", [name], { encoding: "utf8", timeout: 1000 }).trim();
|
|
15
|
+
return out === "" ? undefined : out.split("\n")[0];
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Path-form argv0 rewritten to the known name, or the original token. */
|
|
22
|
+
export function identityName(raw: string, known: ReadonlySet<string>, which: Which | undefined): string {
|
|
23
|
+
return resolveKnown(raw, known, which) ?? raw;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { type Which } from "@inhuman.tools/policy-bash-disassembler";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Plugin door: the dist floor and the authorization program. */
|
|
2
|
+
export { authorizationProgram } from "./authorize.js";
|
|
3
|
+
export { authorize } from "./run-authorization.js";
|
|
4
|
+
export { defaultPolicy } from "./bind.js";
|
|
5
|
+
export { JUDGE_DEADLINE_MS, withDeadline } from "./deadline.js";
|
|
6
|
+
export { checkToolPath, normalizeToolPath } from "./tool-path.js";
|
|
7
|
+
export {
|
|
8
|
+
acknowledgeRequest,
|
|
9
|
+
acknowledgeRequirement,
|
|
10
|
+
blockReason,
|
|
11
|
+
floorVerdictOf,
|
|
12
|
+
labelOf,
|
|
13
|
+
mcpOf,
|
|
14
|
+
mergeAcknowledge,
|
|
15
|
+
parseAcknowledge,
|
|
16
|
+
parseAcknowledgeText,
|
|
17
|
+
requestAcknowledgement,
|
|
18
|
+
type AckFlag,
|
|
19
|
+
type AckMatch,
|
|
20
|
+
type AckRequest,
|
|
21
|
+
type AckRequirement,
|
|
22
|
+
type AckSubject,
|
|
23
|
+
type Acknowledger,
|
|
24
|
+
} from "./acknowledge.js";
|
package/src/lift.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Disposition } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Match-all / pipeline lattice: any-block > any-deny > any-ask > all-approve. */
|
|
4
|
+
export function lattice(hits: readonly Disposition[]): Disposition {
|
|
5
|
+
switch (true) {
|
|
6
|
+
case hits.includes("block"):
|
|
7
|
+
return "block";
|
|
8
|
+
case hits.includes("deny"):
|
|
9
|
+
return "deny";
|
|
10
|
+
case hits.includes("ask"):
|
|
11
|
+
return "ask";
|
|
12
|
+
default:
|
|
13
|
+
return "policyApprove";
|
|
14
|
+
}
|
|
15
|
+
}
|