@vincemakes/kiso-code 0.39.1 → 0.40.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/dist/floor.js ADDED
@@ -0,0 +1,652 @@
1
+ /**
2
+ * 0.40.0 — the catastrophe floor (launch-weekend plan §4, minimum shape;
3
+ * owner, 2026-09-17: "bypass stays bypass: `rm -rf /tmp/probe` runs. The
4
+ * floor refuses only unrecoverable targets"; the lead's review and rulings
5
+ * of 2026-09-18).
6
+ *
7
+ * A destructive command whose target cannot be recovered is REFUSED, in
8
+ * every mode, bypass included, as a precondition — a chain member that
9
+ * denies, and a deny is what nothing outranks. The destructive commands:
10
+ * `rm`; `git clean -f`, `git reset --hard`, `git checkout -- <paths>` and
11
+ * the same loss spelled `git checkout .` / `-f`, `git restore` touching
12
+ * the worktree, `git switch -f` (R5); `find … -delete` with no selecting
13
+ * primary before it (R6). The unrecoverable targets:
14
+ *
15
+ * - `/`, the home directory, the workspace root or any directory above it;
16
+ * - the workspace's `.git` (R7);
17
+ * - `~/.ssh`, `~/.config`, `~/.kiso`, `~/.gnupg`, `~/.aws`, or inside one;
18
+ * - a system root and what is inside it — except the temp family and the
19
+ * software prefixes (/opt/homebrew, /usr/local, linuxbrew), where only
20
+ * the root itself is, and /Volumes /Users /home /mnt /media, where the
21
+ * root and its direct children are (R8). Inside the workspace or the
22
+ * home directory is never a system root's;
23
+ * - a wildcard over any of those (`~/*`, `/*`, `*` at the workspace root —
24
+ * a component of nothing but globs; `*.log` ranges over SOME entries);
25
+ * - a target that is only a variable (`$DIR/`, `"$X"/*`) — empty, it is
26
+ * the root of whatever comes after it. `$HOME` and `$PWD` are known.
27
+ *
28
+ * This is a reading of the command line, not a guarantee: the sandbox is
29
+ * the guarantee, and it comes after the launch. So the reading errs toward
30
+ * seeing a command — it looks inside `$( )`, backticks, `( )`, `sh -c` and
31
+ * `eval`, through `sudo`/`env`/`timeout`/`command` and shell keywords, and
32
+ * follows `cd` by its joiner — and a line that knocks the reader out is
33
+ * denied. Documented, not refused: `xargs rm`, script files and heredocs,
34
+ * `find -exec rm`, `mv`, `chmod -R`, `rmdir`, `unlink`, `truncate`,
35
+ * `shred`, `>` truncation, `rsync --delete`, `npx rimraf`, `trash`,
36
+ * `fish -c`, `ssh`, `docker`, `node -e` / `python -c` / `perl -e`, brace
37
+ * expansion, `$'…'`, `~+`, popd past one level, nesting past 16.
38
+ *
39
+ * The same resolver as the read-only allow decides where a path goes.
40
+ * Default on; `"floor": "off"` in the USER config turns it off, and a
41
+ * project config cannot — a repository must not be able to lower it.
42
+ */
43
+ import { existsSync, statSync } from "node:fs";
44
+ import { homedir, tmpdir } from "node:os";
45
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
46
+ import { HOME_SUBTREES, looseCommands, parseShellLooseChecked, resolveShellPath } from "./shell-words.js";
47
+ /** R8 (the lead's ruling): a system root AND everything inside it. */
48
+ const SYSTEM_ROOTS = [
49
+ "/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/usr", "/var",
50
+ "/Applications", "/Library", "/System", "/cores", "/private",
51
+ ];
52
+ /** R8: roots whose DIRECT children are mounts or home directories — the
53
+ * root and each child are refused, deeper paths run (projects live
54
+ * there, WSL's included: /mnt/c/…). */
55
+ const MOUNT_ROOTS = ["/Volumes", "/Users", "/home", "/mnt", "/media"];
56
+ /** R8: the temp family — only the root itself; what is inside runs. */
57
+ const TEMP_ROOTS = ["/tmp", "/private/tmp", "/var/tmp", "/private/var/tmp", "/var/folders", "/private/var/folders"];
58
+ /** R8 (the lead's ruling, second pass): reinstallable software prefixes —
59
+ * only the prefix itself; what is inside runs (`brew` put it there and can
60
+ * again). */
61
+ const SOFTWARE_PREFIXES = ["/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"];
62
+ /** Memoized per (workspace, home): the roots do not move within a session,
63
+ * and resolving forty of them cost every shell call ~4 ms. */
64
+ const WHERE = new Map();
65
+ function where(root, home) {
66
+ const key = `${root}\0${home}`;
67
+ const hit = WHERE.get(key);
68
+ if (hit !== undefined)
69
+ return hit;
70
+ const real = (p) => resolveShellPath(root, "/", p).canonical;
71
+ const named = (names) => names.map((name) => ({ name, real: real(name) }));
72
+ const rootReal = real(root);
73
+ const w = {
74
+ root,
75
+ home,
76
+ rootReal,
77
+ homeReal: real(home),
78
+ gitReal: join(rootReal, ".git"),
79
+ sys: named(SYSTEM_ROOTS),
80
+ mounts: named(MOUNT_ROOTS),
81
+ temps: [...new Set([...TEMP_ROOTS, tmpdir()].map(real))],
82
+ prefixes: named(SOFTWARE_PREFIXES),
83
+ subtrees: HOME_SUBTREES.map((name) => ({ name, real: real(join(home, name)) })),
84
+ };
85
+ WHERE.set(key, w);
86
+ return w;
87
+ }
88
+ /** B6: the candidate directories a command may run in are UNIQUE and at
89
+ * most this many. `;`-joined cds doubled the set: 30 of them hung the
90
+ * event loop, 400 exhausted the heap. Past the bound the set collapses to
91
+ * the newest candidate, home and the workspace root — the three a
92
+ * catastrophe is judged against. */
93
+ const MAX_CWDS = 8;
94
+ function capCwds(w, list) {
95
+ const unique = [...new Set(list)];
96
+ return unique.length <= MAX_CWDS ? unique : [...new Set([unique[0], w.home, w.root])];
97
+ }
98
+ const canon = (w, cwd, p) => resolveShellPath(w.root, cwd, p).canonical;
99
+ const within = (parent, p) => {
100
+ const rel = relative(parent, p);
101
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
102
+ };
103
+ /** Why `p` (canonical) is unrecoverable, or null. `over` is true for a
104
+ * wildcard over `p` rather than `p` itself — the same set, since
105
+ * emptying a directory is removing everything it held. */
106
+ function unrecoverable(w, p, over) {
107
+ const what = (s) => (over ? `a wildcard over ${s}` : s);
108
+ if (p === "/")
109
+ return what("/");
110
+ if (p === w.homeReal)
111
+ return what("the home directory");
112
+ if (p === w.rootReal)
113
+ return what("the workspace root");
114
+ if (within(p, w.rootReal))
115
+ return what(`a directory above the workspace (${p})`);
116
+ // R7: the workspace's history — without a remote, gone
117
+ if (within(w.gitReal, p))
118
+ return what("the workspace's .git");
119
+ for (const sub of w.subtrees)
120
+ if (within(sub.real, p))
121
+ return what(`~/${sub.name}`);
122
+ // Inside the workspace or the home directory is the project's and the
123
+ // person's, never a system root's — or a workspace in /opt, or a root
124
+ // user's home in /root, would have every rm refused.
125
+ if ((w.rootReal !== "/" && within(w.rootReal, p)) || within(w.homeReal, p))
126
+ return null;
127
+ for (const t of w.temps) {
128
+ if (p === t)
129
+ return what(`a temp root (${t})`);
130
+ if (within(t, p))
131
+ return null;
132
+ }
133
+ for (const x of w.prefixes) {
134
+ if (p === x.real)
135
+ return what(`a software prefix (${x.name})`);
136
+ if (within(x.real, p))
137
+ return null;
138
+ }
139
+ for (const m of w.mounts) {
140
+ if (p === m.real)
141
+ return what(`a system root (${m.name})`);
142
+ if (dirname(p) === m.real)
143
+ return what(`a mount or home root (${p})`);
144
+ // deeper runs — by RESOLVED path: macOS's /home is
145
+ // /System/Volumes/Data/home, which the /System rule would refuse
146
+ if (within(m.real, p))
147
+ return null;
148
+ }
149
+ for (const s of w.sys) {
150
+ if (p === s.real)
151
+ return what(`a system root (${s.name})`);
152
+ if (within(s.real, p))
153
+ return what(`a path inside a system root (${s.name})`);
154
+ }
155
+ return null;
156
+ }
157
+ /** Where the first unknown character sits in `text`, and whether it is a
158
+ * glob — for a word whose text a known variable was just substituted in. */
159
+ function reUnknown(text, from) {
160
+ for (let i = from; i < text.length; i += 1) {
161
+ const c = text[i];
162
+ if (c === "*" || c === "?" || c === "[")
163
+ return { at: i, glob: true };
164
+ if (c === "$" || c === "`")
165
+ return { at: i, glob: false };
166
+ }
167
+ return { at: -1, glob: false };
168
+ }
169
+ /** One target word, from each directory the command might run in. */
170
+ function targetWhy(w, cwds, word) {
171
+ for (const cwd of cwds) {
172
+ let text = word.text;
173
+ let at = word.unknownAt;
174
+ let glob = word.unknownIsGlob;
175
+ let variableOnly = word.variableOnly;
176
+ // B8: $HOME and $PWD are KNOWN — `rm -rf $HOME/.ssh` is ~/.ssh
177
+ const known = at === 0 ? /^(\$HOME|\$\{HOME\}|\$PWD|\$\{PWD\})(?=\/|$)/.exec(text) : null;
178
+ if (known !== null) {
179
+ const value = known[1].includes("HOME") ? w.home : cwd;
180
+ text = value + text.slice(known[0].length);
181
+ ({ at, glob } = reUnknown(text, value.length));
182
+ variableOnly = false;
183
+ }
184
+ else if (word.tilde && (text === "~" || text.startsWith("~/"))) {
185
+ text = w.home + text.slice(1);
186
+ if (at >= 0)
187
+ at += w.home.length - 1;
188
+ }
189
+ if (variableOnly)
190
+ return `a target that is only a variable (${word.text})`;
191
+ if (at < 0) {
192
+ const why = unrecoverable(w, canon(w, cwd, text), false);
193
+ if (why !== null)
194
+ return why;
195
+ continue;
196
+ }
197
+ // A variable FIRST, with something literal after it (`$X/build`),
198
+ // can be anything at all; only a variable-only target is refused.
199
+ if (at === 0 && !glob)
200
+ continue;
201
+ const prefix = text.slice(0, at);
202
+ const lastSlash = prefix.lastIndexOf("/");
203
+ const dir = canon(w, cwd, lastSlash < 0 ? "." : prefix.slice(0, lastSlash + 1) || "/");
204
+ const head = prefix.slice(lastSlash + 1); // the literal start of the unknown component
205
+ const nextSlash = text.indexOf("/", at);
206
+ const component = text.slice(lastSlash + 1, nextSlash < 0 ? text.length : nextSlash);
207
+ const after = nextSlash < 0 ? "" : text.slice(nextSlash);
208
+ // B8: a wildcard INSIDE a protected subtree (`~/.ssh/id_*`), or at
209
+ // home reaching one by its name (`~/.ssh*`, `~/.c*`)
210
+ for (const sub of w.subtrees) {
211
+ if (within(sub.real, dir))
212
+ return `a wildcard inside ~/${sub.name}`;
213
+ if (glob && dir === w.homeReal && head !== "" && sub.name.toLowerCase().startsWith(head.toLowerCase()))
214
+ return `a wildcard that reaches ~/${sub.name}`;
215
+ }
216
+ // B9: over the directory ONLY when the unknown component is nothing
217
+ // but glob characters and dots (`*`, `.*`, `**`, `?`), or nothing but
218
+ // a variable — and what follows is only slashes, dots and globs.
219
+ // `*.log`, `a*`, `**/node_modules` range over SOME entries and run.
220
+ const overComponent = glob ? /^[*?.[\]!]+$/.test(component) : head === "" && /^(\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|\$\(.*\)|`.*`)$/.test(component);
221
+ // ...and that exemption holds only where ranging over SOME entries is
222
+ // ordinary work: inside the workspace, inside home (the subtrees were
223
+ // refused above), in the temp family. `/*.log`, `/**/build` are not.
224
+ const ordinary = within(w.rootReal, dir) || within(w.homeReal, dir) || w.temps.some((t) => within(t, dir));
225
+ if (ordinary && (!overComponent || !/^[/.*?[\]!]*$/.test(after)))
226
+ continue;
227
+ const why = unrecoverable(w, dir, true);
228
+ if (why !== null)
229
+ return why;
230
+ }
231
+ return null;
232
+ }
233
+ const WRAPPER_ARG = {
234
+ sudo: ["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-U"],
235
+ doas: ["-u", "-C"],
236
+ nice: ["-n"],
237
+ exec: ["-a"],
238
+ env: ["-u", "-C", "-S"],
239
+ timeout: ["-s", "-k", "--signal", "--kill-after"],
240
+ command: [],
241
+ builtin: [],
242
+ nohup: [],
243
+ time: [],
244
+ busybox: [],
245
+ };
246
+ /** B10: shell keywords that come before a command and take nothing */
247
+ const KEYWORD_BEFORE = new Set(["if", "then", "elif", "else", "while", "until", "do", "{", "!"]);
248
+ /** B10: keywords that end a construct — a command on their own */
249
+ const KEYWORD_ALONE = new Set(["fi", "done", "}", "esac", "in"]);
250
+ /** Past keywords, `sudo -u x`, `env A=1`, `timeout 5`, `command`, … to the
251
+ * command that runs. Matched by basename: `/usr/bin/env rm` is env. */
252
+ export function unwrap(argv) {
253
+ let a = argv;
254
+ for (;;) {
255
+ // a leading word that is only a variable may be EMPTY (`$SUDO rm …`,
256
+ // `eval "$X" rm …`): the command may be what follows it
257
+ while (a.length > 1 && a[0].variableOnly)
258
+ a = a.slice(1);
259
+ const name = basename(a[0]?.text ?? "");
260
+ if (a.length === 0 || KEYWORD_ALONE.has(name))
261
+ return [];
262
+ if (KEYWORD_BEFORE.has(name)) {
263
+ a = a.slice(1);
264
+ continue;
265
+ }
266
+ if (!Object.hasOwn(WRAPPER_ARG, name))
267
+ return a;
268
+ let i = 1;
269
+ while (i < a.length && (a[i].text.startsWith("-") || (name === "env" && a[i].text.includes("=")))) {
270
+ i += WRAPPER_ARG[name].includes(a[i].text) ? 2 : 1;
271
+ }
272
+ if (name === "timeout")
273
+ i += 1; // the duration
274
+ a = a.slice(i);
275
+ }
276
+ }
277
+ const operands = (args) => {
278
+ const out = [];
279
+ let rest = false;
280
+ for (const a of args) {
281
+ if (!rest && a.text === "--")
282
+ rest = true;
283
+ else if (rest || !a.text.startsWith("-"))
284
+ out.push(a);
285
+ }
286
+ return out;
287
+ };
288
+ const literal = (text) => ({ text, unknownAt: -1, unknownIsGlob: false, tilde: false, variableOnly: false });
289
+ /** `sh -c '…'`, `bash -lc '…'`, `eval …`: the command lines inside.
290
+ * eval CONCATENATES its words with spaces and parses the result again —
291
+ * that is its meaning, and `eval 'rm -rf ~'` is read that way. It is also
292
+ * read once more with each word re-quoted, so `eval sh -c 'rm -rf /'`
293
+ * keeps its script whole (B10, the lead's reading): either refusing is a
294
+ * refusal. In the re-quoted reading `~` is already expanded and a word
295
+ * holding an unknown stays raw — its value is unknown to eval too. */
296
+ export function innerLines(argv, home) {
297
+ const name = basename(argv[0]?.text ?? "");
298
+ const quote = (t) => `'${t.replaceAll("'", `'\\''`)}'`;
299
+ if (name === "eval") {
300
+ const words = argv.slice(1);
301
+ const requoted = words.map((a) => (a.tilde && (a.text === "~" || a.text.startsWith("~/")) ? quote(home + a.text.slice(1)) : a.unknownAt >= 0 ? a.text : quote(a.text)));
302
+ return { lines: [words.map((a) => a.text).join(" "), requoted.join(" ")], subshell: false };
303
+ }
304
+ if (!/^(ba|z|da|k)?sh$/.test(name))
305
+ return null;
306
+ const at = argv.findIndex((a) => /^-[a-z]*c[a-z]*$/.test(a.text));
307
+ const line = at < 0 ? undefined : argv[at + 1]?.text;
308
+ return line === undefined ? null : { lines: [line], subshell: true };
309
+ }
310
+ /** How deep command lines inside command lines are followed — `sh -c`,
311
+ * `eval` — and how many are read in all: eval's two readings would
312
+ * otherwise double at every level. */
313
+ const MAX_INNER_DEPTH = 16;
314
+ const MAX_INNER_LINES = 256;
315
+ const SELECTING = new Set([
316
+ "-name", "-iname", "-path", "-ipath", "-wholename", "-iwholename", "-regex", "-iregex", "-newer", "-mtime", "-mmin", "-atime",
317
+ "-amin", "-ctime", "-cmin", "-size", "-empty", "-user", "-group", "-perm", "-samefile", "-links",
318
+ ]);
319
+ /** R5: a checkout/restore operand is a path only when path-shaped — a
320
+ * branch name is never refused. */
321
+ const pathShaped = (t) => t === "." || t === "./" || t.startsWith("/") || t.startsWith("~") || t.includes("/") || t.startsWith(":");
322
+ /** B7: pathspec magic from the top of the repository */
323
+ const topMagic = (t) => t.startsWith(":/") || t.startsWith(":(top");
324
+ /** The destructive command's targets, or null when it is not destructive. */
325
+ function destructiveTargets(argv) {
326
+ const name = basename(argv[0]?.text ?? "");
327
+ const args = argv.slice(1);
328
+ if (name === "rm")
329
+ return { targets: operands(args) };
330
+ if (name === "find") {
331
+ if (!args.some((a) => a.text === "-delete"))
332
+ return null;
333
+ const paths = [];
334
+ let i = 0;
335
+ // B9: find's own options come first — -H -L -P -E -d -s -x -O<n> --,
336
+ // and -f <path> names a path
337
+ for (; i < args.length; i += 1) {
338
+ const t = args[i].text;
339
+ if (/^-[HLPEdsx]+$/.test(t) || /^-O\d*$/.test(t))
340
+ continue;
341
+ if (t === "-f" && i + 1 < args.length) {
342
+ paths.push(args[i + 1]);
343
+ i += 1;
344
+ continue;
345
+ }
346
+ if (t === "--")
347
+ i += 1;
348
+ break;
349
+ }
350
+ for (; i < args.length; i += 1) {
351
+ const t = args[i].text;
352
+ if (t.startsWith("-") || t === "!" || t === "(")
353
+ break;
354
+ paths.push(args[i]);
355
+ }
356
+ // R6 (the lead's ruling): with a selecting primary it ranges over SOME
357
+ // entries and runs; with none, over everything under its paths
358
+ // ...and only BEFORE -delete: `find . -delete -name x` deletes all.
359
+ // The home subtrees come first even so (`find ~/.ssh -name x -delete`)
360
+ const del = args.findIndex((a) => a.text === "-delete");
361
+ if (args.slice(i, del).some((a) => SELECTING.has(a.text)))
362
+ return { targets: paths.length > 0 ? paths : [literal(".")], subtreesOnly: true };
363
+ return { targets: paths.length > 0 ? paths : [literal(".")], label: "find with no selecting primary" };
364
+ }
365
+ if (name !== "git")
366
+ return null;
367
+ let i = 0;
368
+ // B7: several -C compose, each relative to the one before
369
+ const cds = [];
370
+ // B7: --work-tree X IS the tree a whole-tree command works on; --git-dir
371
+ // X is a target in itself (space and = forms both)
372
+ let workTree;
373
+ const gitDirs = [];
374
+ while (i < args.length && args[i].text.startsWith("-")) {
375
+ const t = args[i].text;
376
+ const eq = /^--(work-tree|git-dir)=(.*)$/.exec(t);
377
+ if (eq !== null) {
378
+ const value = { ...args[i], text: eq[2], unknownAt: -1, tilde: eq[2].startsWith("~") };
379
+ if (eq[1] === "work-tree")
380
+ workTree = value;
381
+ else
382
+ gitDirs.push(value);
383
+ }
384
+ if ((t === "-C" || t === "--work-tree" || t === "--git-dir") && i + 1 < args.length) {
385
+ if (t === "-C")
386
+ cds.push(args[i + 1]);
387
+ else if (t === "--work-tree")
388
+ workTree = args[i + 1];
389
+ else
390
+ gitDirs.push(args[i + 1]);
391
+ i += 2;
392
+ }
393
+ else if (t === "-c" || t === "--namespace" || t === "--exec-path")
394
+ i += 2;
395
+ else
396
+ i += 1;
397
+ }
398
+ const sub = args[i]?.text;
399
+ const rest = args.slice(i + 1);
400
+ const flags = new Set(rest.filter((a) => a.text.startsWith("-")).map((a) => a.text));
401
+ const as = (targets) => ({
402
+ targets: [...targets.map((t) => (t === "repo" && workTree !== undefined ? workTree : t)), ...gitDirs],
403
+ ...(cds.length > 0 ? { cds } : {}),
404
+ });
405
+ // a pathspec's glob is GIT's to expand, quoted from the shell or not:
406
+ // `git checkout -- '*'` is every file under the directory
407
+ const asGlob = (wd) => {
408
+ const g = wd.text.search(/[*?[]/);
409
+ return wd.unknownAt < 0 && g >= 0 ? { ...wd, unknownAt: g, unknownIsGlob: true } : wd;
410
+ };
411
+ const asPaths = (words) => words.map((wd) => (topMagic(wd.text) ? "repo" : asGlob(wd)));
412
+ if (sub === "reset")
413
+ return flags.has("--hard") ? as(["repo"]) : null;
414
+ if (sub === "clean") {
415
+ if (!rest.some((a) => a.text === "--force" || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(a.text)))
416
+ return null;
417
+ // a dry run deletes nothing, wherever its -n sits
418
+ if (rest.some((a) => a.text === "--dry-run" || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(a.text)))
419
+ return null;
420
+ const paths = operands(rest.filter((_, k) => !(k > 0 && (rest[k - 1].text === "-e" || rest[k - 1].text === "--exclude"))));
421
+ return as(paths.length > 0 ? asPaths(paths) : [literal(".")]);
422
+ }
423
+ if (sub === "checkout") {
424
+ // R5: `checkout -- <paths>`, a path-shaped operand, or -f with none
425
+ const dd = rest.findIndex((a) => a.text === "--");
426
+ const paths = dd >= 0 ? rest.slice(dd + 1) : operands(rest).filter((a) => pathShaped(a.text));
427
+ if (paths.length > 0)
428
+ return as(asPaths(paths));
429
+ return flags.has("-f") || flags.has("--force") ? as(["repo"]) : null;
430
+ }
431
+ if (sub === "restore") {
432
+ // R5: touching the worktree — no --staged, or -W / --worktree
433
+ const staged = [...flags].some((f) => f === "--staged" || /^-[a-zA-Z]*S[a-zA-Z]*$/.test(f));
434
+ const worktree = [...flags].some((f) => f === "--worktree" || /^-[a-zA-Z]*W[a-zA-Z]*$/.test(f));
435
+ if (staged && !worktree)
436
+ return null;
437
+ const paths = operands(rest.filter((_, k) => !(k > 0 && (rest[k - 1].text === "-s" || rest[k - 1].text === "--source"))));
438
+ return as(asPaths(paths));
439
+ }
440
+ if (sub === "switch")
441
+ return flags.has("-f") || flags.has("--force") || flags.has("--discard-changes") ? as(["repo"]) : null;
442
+ return null;
443
+ }
444
+ /** As git's own discovery judges it: a `.git` FILE (a worktree's link),
445
+ * or a `.git` directory with a HEAD. An empty `.git` is not a repository,
446
+ * and git walks on past it — so does the floor. */
447
+ function isGitDir(p) {
448
+ try {
449
+ const st = statSync(p);
450
+ return st.isFile() || (st.isDirectory() && existsSync(join(p, "HEAD")));
451
+ }
452
+ catch {
453
+ return false;
454
+ }
455
+ }
456
+ /** B7: the root of the repository a command in `cwd` works on — up to the
457
+ * nearest `.git` (a directory, or a worktree's file). With none found:
458
+ * inside the workspace, the workspace root; outside it, the directory
459
+ * itself — git would refuse to run there, and the floor does not bet on it. */
460
+ function repoRoot(w, cwd) {
461
+ const start = canon(w, cwd, ".");
462
+ for (let d = start;; d = dirname(d)) {
463
+ if (isGitDir(join(d, ".git")))
464
+ return d;
465
+ if (dirname(d) === d)
466
+ break;
467
+ }
468
+ return within(w.rootReal, start) ? w.rootReal : start;
469
+ }
470
+ /** A child shell starts where this node runs — the node's joiner has
471
+ * already been applied — and nothing it does comes back. */
472
+ const child = (s) => ({ cwds: [...s.cwds], lastCd: null, prev: s.prev });
473
+ /**
474
+ * Would the floor refuse this command line? `workspaceRoot` is where the
475
+ * shell runs it.
476
+ */
477
+ export function floorCheck(commandLine, workspaceRoot, home = homedir()) {
478
+ const w = where(workspaceRoot, home);
479
+ const seen = new Set();
480
+ const tilde = (word) => (word.tilde && (word.text === "~" || word.text.startsWith("~/")) ? home + word.text.slice(1) : word.text);
481
+ const walk = (nodes, state, depth) => {
482
+ for (const node of nodes) {
483
+ // Where this node runs: after `cd x`, `&&` means only if the cd
484
+ // worked (x), `||` only if it failed (where it was), else either.
485
+ if (state.lastCd !== null) {
486
+ const { to, from } = state.lastCd;
487
+ state.cwds = capCwds(w, node.joinedBy === "&&" ? to : node.joinedBy === "||" ? from : [...to, ...from]);
488
+ if (node.joinedBy !== "&&")
489
+ state.lastCd = null;
490
+ }
491
+ if (node.kind === "group") {
492
+ // B10: a subshell — `( … )`, `$( … )`, backticks — its cd does
493
+ // not move the shell around it
494
+ const v = walk(node.items, child(state), depth);
495
+ if (v.refused)
496
+ return v;
497
+ continue;
498
+ }
499
+ const argv = unwrap(node.argv);
500
+ if (argv.length === 0)
501
+ continue;
502
+ const name = basename(argv[0].text);
503
+ const inner = innerLines(argv, home);
504
+ if (inner !== null) {
505
+ if (depth < MAX_INNER_DEPTH) {
506
+ for (const line of inner.lines) {
507
+ if (seen.has(line) || seen.size >= MAX_INNER_LINES)
508
+ continue;
509
+ seen.add(line);
510
+ // `sh -c` is a child shell: its cd does not move this one.
511
+ // eval runs HERE, and its cd does — from this node's
512
+ // directory, with the outer chain's pending cd kept if the
513
+ // line itself does not cd
514
+ const read = parseShellLooseChecked(line);
515
+ if (read.truncated)
516
+ return { refused: true, why: NESTED_TOO_DEEP };
517
+ if (inner.subshell) {
518
+ const v = walk(read.nodes, child(state), depth + 1);
519
+ if (v.refused)
520
+ return v;
521
+ }
522
+ else {
523
+ const pending = state.lastCd;
524
+ state.lastCd = null;
525
+ const v = walk(read.nodes, state, depth + 1);
526
+ if (v.refused)
527
+ return v;
528
+ if (state.lastCd === null)
529
+ state.lastCd = pending;
530
+ }
531
+ }
532
+ }
533
+ continue;
534
+ }
535
+ if (name === "cd" || name === "pushd" || name === "popd") {
536
+ // B10: skip cd's own options; `cd -` and popd go back one
537
+ const args = argv.slice(1).filter((a) => !["--", "-P", "-L", "-e", "-@"].includes(a.text));
538
+ const to = args[0];
539
+ const from = state.lastCd?.from ?? state.cwds;
540
+ let dest;
541
+ if (name === "popd" || to?.text === "-")
542
+ dest = state.prev ?? state.cwds;
543
+ // `cd` with nothing, or with anything unknown, may land at home
544
+ else if (to === undefined || to.unknownAt >= 0 || to.variableOnly)
545
+ dest = capCwds(w, [w.home, ...state.cwds]);
546
+ else
547
+ dest = capCwds(w, state.cwds.map((c) => resolve(c, tilde(to))));
548
+ state.prev = state.cwds;
549
+ state.lastCd = { to: dest, from };
550
+ continue;
551
+ }
552
+ const d = destructiveTargets(argv);
553
+ if (d === null)
554
+ continue;
555
+ let here = state.cwds;
556
+ for (const cd of d.cds ?? [])
557
+ here = cd.unknownAt >= 0 ? capCwds(w, [...here, w.home]) : here.map((c) => resolve(c, tilde(cd)));
558
+ for (const t of d.targets) {
559
+ let why = null;
560
+ if (t === "repo") {
561
+ for (const c of here)
562
+ if ((why = unrecoverable(w, repoRoot(w, c), false)) !== null)
563
+ break;
564
+ }
565
+ else
566
+ why = targetWhy(w, here, t);
567
+ if (why !== null && d.subtreesOnly === true && !why.includes("~/."))
568
+ why = null;
569
+ if (why !== null) {
570
+ const command = argv.map((a) => a.text).join(" ");
571
+ return { refused: true, why: d.label !== undefined ? `${command} — ${d.label} over ${why}` : `${command} — its target is ${why}` };
572
+ }
573
+ }
574
+ }
575
+ return { refused: false };
576
+ };
577
+ const top = parseShellLooseChecked(commandLine);
578
+ if (top.truncated)
579
+ return { refused: true, why: NESTED_TOO_DEEP };
580
+ return walk(top.nodes, { cwds: [workspaceRoot], lastCd: null, prev: null }, 0);
581
+ }
582
+ /** B6, second pass: a line the reader stopped short on is a line it could
583
+ * not read — past the nesting it follows, bash still runs every level. */
584
+ const NESTED_TOO_DEEP = "the line is nested deeper than the floor reads";
585
+ /** Is there a destructive command anywhere in the line, whatever its
586
+ * target? A saved allow is never inherited by one (plan §4). */
587
+ export function isDestructive(commandLine) {
588
+ const seen = new Set();
589
+ const visit = (line, depth) => {
590
+ const read = parseShellLooseChecked(line);
591
+ return read.truncated || looseCommands(read.nodes).some(({ argv: raw }) => {
592
+ const argv = unwrap(raw);
593
+ const inner = innerLines(argv, homedir());
594
+ if (inner !== null) {
595
+ return (depth < MAX_INNER_DEPTH &&
596
+ inner.lines.some((l) => {
597
+ if (seen.has(l) || seen.size >= MAX_INNER_LINES)
598
+ return false;
599
+ seen.add(l);
600
+ return visit(l, depth + 1);
601
+ }));
602
+ }
603
+ return destructiveTargets(argv) !== null;
604
+ });
605
+ };
606
+ return visit(commandLine, 0);
607
+ }
608
+ const ABSTAIN = { action: "abstain" };
609
+ /** The chain member, at the chain's head: DENY or ABSTAIN. */
610
+ export function floorExtension(on, workspaceRoot, check = floorCheck) {
611
+ return {
612
+ name: "floor",
613
+ approvals: [
614
+ {
615
+ decide: (call) => {
616
+ if (call.name !== "shell" || !on())
617
+ return ABSTAIN;
618
+ const command = call.input.command;
619
+ if (typeof command !== "string")
620
+ return ABSTAIN;
621
+ // B6: a read that THROWS is not "a line it cannot follow" — it
622
+ // is a line that knocked the reader out, and letting the
623
+ // runtime degrade the throw to an ask handed it to bypass's
624
+ // allow. The floor denies what it could not read.
625
+ let v;
626
+ try {
627
+ v = check(command, workspaceRoot());
628
+ }
629
+ catch (err) {
630
+ return { action: "deny", reason: `the floor could not read this line (${err instanceof Error ? err.name : "error"}) — kiso does not run it.` };
631
+ }
632
+ return v.refused ? { action: "deny", reason: `the floor refused this: ${v.why}. kiso never runs it, in any mode.` } : ABSTAIN;
633
+ },
634
+ },
635
+ ],
636
+ };
637
+ }
638
+ /** Plan §4: "destructive commands never inherit a saved allow in the
639
+ * asking modes" — the predicate the saved-allow wrap
640
+ * (protected-writes.ts) is given, keyed on destructiveness, not on the
641
+ * target: a saved allow never runs even `rm -rf build` unasked. */
642
+ export function isDestructiveCall(call) {
643
+ if (call.name !== "shell" || typeof call.input.command !== "string")
644
+ return false;
645
+ // B6: unreadable is treated as destructive — the saved allow abstains
646
+ try {
647
+ return isDestructive(call.input.command);
648
+ }
649
+ catch {
650
+ return true;
651
+ }
652
+ }