@vincemakes/kiso-code 0.39.2 → 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/README.md +1 -1
- package/dist/attachments.d.ts +1 -1
- package/dist/attachments.js +11 -4
- package/dist/builtin.d.ts +12 -0
- package/dist/builtin.js +22 -1
- package/dist/chat.d.ts +28 -2
- package/dist/chat.js +143 -25
- package/dist/config.d.ts +11 -1
- package/dist/config.js +34 -3
- package/dist/dispatch.d.ts +10 -2
- package/dist/dispatch.js +118 -20
- package/dist/floor.d.ts +80 -0
- package/dist/floor.js +652 -0
- package/dist/index.d.ts +21 -2
- package/dist/index.js +397 -44
- package/dist/mode.d.ts +12 -4
- package/dist/mode.js +32 -10
- package/dist/projects.d.ts +106 -0
- package/dist/projects.js +212 -0
- package/dist/protected-shell.d.ts +88 -0
- package/dist/protected-shell.js +406 -0
- package/dist/protected-writes.d.ts +29 -0
- package/dist/protected-writes.js +54 -0
- package/dist/readonly-shell.d.ts +58 -0
- package/dist/readonly-shell.js +590 -0
- package/dist/replay.d.ts +53 -0
- package/dist/replay.js +158 -0
- package/dist/resume-tail.js +4 -1
- package/dist/resume.js +3 -3
- package/dist/retries.d.ts +9 -0
- package/dist/retries.js +16 -0
- package/dist/session-cards.d.ts +33 -4
- package/dist/session-cards.js +44 -3
- package/dist/session-migration.d.ts +104 -0
- package/dist/session-migration.js +319 -0
- package/dist/shell-words.d.ts +134 -0
- package/dist/shell-words.js +628 -0
- package/dist/skill-invoke.d.ts +58 -0
- package/dist/skill-invoke.js +139 -0
- package/dist/state.d.ts +67 -1
- package/dist/state.js +98 -2
- package/dist/trust-ui.js +29 -5
- package/package.json +15 -15
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
|
+
}
|