@vincemakes/kiso-code 0.39.2 → 0.40.1

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/attachments.d.ts +1 -1
  3. package/dist/attachments.js +11 -4
  4. package/dist/builtin.d.ts +12 -0
  5. package/dist/builtin.js +22 -1
  6. package/dist/chat.d.ts +31 -3
  7. package/dist/chat.js +169 -30
  8. package/dist/config.d.ts +11 -1
  9. package/dist/config.js +34 -3
  10. package/dist/dispatch.d.ts +12 -2
  11. package/dist/dispatch.js +165 -24
  12. package/dist/floor.d.ts +80 -0
  13. package/dist/floor.js +652 -0
  14. package/dist/index.d.ts +21 -2
  15. package/dist/index.js +463 -46
  16. package/dist/mode.d.ts +12 -4
  17. package/dist/mode.js +32 -10
  18. package/dist/projects.d.ts +106 -0
  19. package/dist/projects.js +212 -0
  20. package/dist/protected-shell.d.ts +88 -0
  21. package/dist/protected-shell.js +406 -0
  22. package/dist/protected-writes.d.ts +29 -0
  23. package/dist/protected-writes.js +54 -0
  24. package/dist/provider-label.d.ts +32 -0
  25. package/dist/provider-label.js +53 -0
  26. package/dist/readonly-shell.d.ts +58 -0
  27. package/dist/readonly-shell.js +590 -0
  28. package/dist/replay.d.ts +53 -0
  29. package/dist/replay.js +158 -0
  30. package/dist/resume-tail.js +4 -1
  31. package/dist/resume.js +3 -3
  32. package/dist/retries.d.ts +9 -0
  33. package/dist/retries.js +16 -0
  34. package/dist/session-cards.d.ts +33 -4
  35. package/dist/session-cards.js +44 -3
  36. package/dist/session-migration.d.ts +104 -0
  37. package/dist/session-migration.js +319 -0
  38. package/dist/shell-words.d.ts +134 -0
  39. package/dist/shell-words.js +628 -0
  40. package/dist/skill-invoke.d.ts +58 -0
  41. package/dist/skill-invoke.js +139 -0
  42. package/dist/state.d.ts +80 -1
  43. package/dist/state.js +113 -2
  44. package/dist/trust-ui.js +29 -5
  45. package/package.json +15 -15
@@ -0,0 +1,590 @@
1
+ /**
2
+ * 0.40.0 — the read-only shell allow (launch-weekend plan §3; the design
3
+ * is kiso-doc plan-readonly-shell-2026-09-18).
4
+ *
5
+ * A shell call that can be PROVEN read-only runs without asking in the
6
+ * asking modes. "Proven" is the whole contract: this module answers
7
+ * `allow` only for a command line it has read completely — every segment
8
+ * of every pipeline a command on the table below, every flag on that
9
+ * command's list, every path inside the workspace, every redirection one
10
+ * of the few that write nothing — and `abstain` for everything else. It
11
+ * never asks and never denies, so anything it does not understand meets
12
+ * exactly the approval it met before this module existed.
13
+ *
14
+ * Flags are ALLOW-listed per command. A deny-list of the dangerous ones
15
+ * would be a list of the dangerous ones known on the day it was written:
16
+ * rg grew `--hostname-bin` (which runs a program) long after `--pre`.
17
+ *
18
+ * Two path predicates, one resolver (`resolveShellPath`, shared with the
19
+ * floor):
20
+ * - SCOPE, for every path: the canonical path — symlinks followed — is
21
+ * the workspace or under it, read_file's rule;
22
+ * - CONTENT, for a path whose bytes the command prints: scope, and not a
23
+ * credential by name (`isCredentialName`, the search corpus's rule).
24
+ * Stricter than read_file, which reads `.env` on purpose; a shell read
25
+ * of one asks.
26
+ *
27
+ * Recursive search is deliberately NOT on the table (grep -r, rg over a
28
+ * directory). search_text is allowed unasked BECAUSE its walk skips
29
+ * credential files; rg skips hidden files but reads `id_rsa` and `*.pem`,
30
+ * and grep -r reads everything, so an unasked search for "KEY" would hand
31
+ * a private key to the provider. The model has search_text for that.
32
+ */
33
+ import { statSync } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { basename, isAbsolute, join, relative } from "node:path";
36
+ import { isCredentialName } from "@vincemakes/kiso-tools-node";
37
+ import { getMode } from "./mode.js";
38
+ import { HOME_SUBTREES, parseShell, realCase, resolveShellPath } from "./shell-words.js";
39
+ // ── the predicates ──────────────────────────────────────────────────────
40
+ function scope(ctx, word) {
41
+ if (word === "-")
42
+ return null; // stdin
43
+ for (const cwd of ctx.cwds) {
44
+ const { canonical, inside } = resolveShellPath(ctx.root, cwd, word);
45
+ if (!inside)
46
+ return `${word} is outside the workspace`;
47
+ // B4: under a protected directory NOTHING is read or listed unasked —
48
+ // kiso's own home, and ~/.ssh ~/.config ~/.aws ~/.gnupg ~/.kiso when
49
+ // the workspace is the home directory. Over-asking is an ask.
50
+ for (const p of ctx.protectedRoots) {
51
+ const rel = relative(p, canonical);
52
+ if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)))
53
+ return `${word} is inside a protected directory (${p})`;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ /** B4: credentials by the shell's own list — names the search corpus's
59
+ * rule does not cover, on the canonical path. (The corpus rule itself,
60
+ * shared with read_file and search_text, is not moved before the freeze:
61
+ * finding RO-F4, 0.41.0.) */
62
+ const SHELL_CREDENTIAL_NAMES = new Set([".npmrc", ".pypirc", ".git-credentials", ".htpasswd"]);
63
+ const SHELL_CREDENTIAL_PATHS = ["/.aws/credentials", "/.config/gh/hosts.yml", "/.docker/config.json", "/.kube/config"];
64
+ function shellCredential(canonical) {
65
+ const folded = canonical.toLowerCase();
66
+ return SHELL_CREDENTIAL_NAMES.has(basename(folded)) || SHELL_CREDENTIAL_PATHS.some((s) => folded.endsWith(s));
67
+ }
68
+ function content(ctx, word) {
69
+ const s = scope(ctx, word);
70
+ if (s !== null)
71
+ return s;
72
+ if (word === "-")
73
+ return null;
74
+ // B2: the disk is case-insensitive and the names are not — `.ENV` and
75
+ // `ID_RSA` are `.env` and `id_rsa` to the file system, so to the rule
76
+ if (isCredentialName(basename(word).toLowerCase()))
77
+ return `${word} is a credential file`;
78
+ for (const cwd of ctx.cwds) {
79
+ const { canonical } = resolveShellPath(ctx.root, cwd, word);
80
+ if (isCredentialName(basename(canonical).toLowerCase()) || shellCredential(canonical))
81
+ return `${word} leads to a credential file`;
82
+ }
83
+ return null;
84
+ }
85
+ /** For rg: a directory operand makes it recurse, and so does none. */
86
+ function regularFile(ctx, word) {
87
+ const c = content(ctx, word);
88
+ if (c !== null)
89
+ return c;
90
+ for (const cwd of ctx.cwds) {
91
+ try {
92
+ if (statSync(resolveShellPath(ctx.root, cwd, word).canonical).isDirectory())
93
+ return `${word} is a directory (a recursive search)`;
94
+ }
95
+ catch {
96
+ // a missing file: the command fails on its own
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+ /** Walk the arguments against an option spec. Every option must be on it;
102
+ * operands are returned for the command's own checks. Options after
103
+ * operands are still checked as options — stricter than a BSD tool, which
104
+ * would take them as operands, and never looser. */
105
+ function parseOpts(args, spec) {
106
+ const operands = [];
107
+ const values = new Map();
108
+ const note = (k, v) => {
109
+ values.set(k, [...(values.get(k) ?? []), v]);
110
+ };
111
+ let i = 0;
112
+ let rest = false;
113
+ while (i < args.length) {
114
+ const a = args[i];
115
+ i += 1;
116
+ if (rest || a === "-" || !a.startsWith("-")) {
117
+ operands.push(a);
118
+ continue;
119
+ }
120
+ if (a === "--") {
121
+ rest = true;
122
+ continue;
123
+ }
124
+ if (a.startsWith("--")) {
125
+ const eq = a.indexOf("=");
126
+ const name = eq < 0 ? a.slice(2) : a.slice(2, eq);
127
+ if (spec.longArg?.includes(name)) {
128
+ if (eq >= 0)
129
+ note(name, a.slice(eq + 1));
130
+ else {
131
+ if (i >= args.length)
132
+ return { why: `--${name} needs a value` };
133
+ note(name, args[i]);
134
+ i += 1;
135
+ }
136
+ continue;
137
+ }
138
+ if (spec.longOptArg?.includes(name)) {
139
+ if (eq >= 0)
140
+ note(name, a.slice(eq + 1));
141
+ continue;
142
+ }
143
+ if (spec.long?.includes(name) && eq < 0)
144
+ continue;
145
+ return { why: `the option --${name}` };
146
+ }
147
+ if (spec.numeric === true && /^-\d+$/.test(a))
148
+ continue;
149
+ if (spec.shortPatterns?.some((re) => re.test(a)))
150
+ continue;
151
+ let j = 1;
152
+ while (j < a.length) {
153
+ const f = a[j];
154
+ if (spec.short?.includes(f)) {
155
+ j += 1;
156
+ continue;
157
+ }
158
+ if (spec.shortArg?.includes(f)) {
159
+ const attached = a.slice(j + 1);
160
+ if (attached !== "")
161
+ note(f, attached);
162
+ else {
163
+ if (i >= args.length)
164
+ return { why: `-${f} needs a value` };
165
+ note(f, args[i]);
166
+ i += 1;
167
+ }
168
+ j = a.length;
169
+ continue;
170
+ }
171
+ return { why: `the option -${f}` };
172
+ }
173
+ }
174
+ return { operands, values };
175
+ }
176
+ const failed = (p) => "why" in p;
177
+ /** Options only; the operands need no check of their own. */
178
+ function optionsOnly(args, spec) {
179
+ const p = parseOpts(args, spec);
180
+ return failed(p) ? p.why : null;
181
+ }
182
+ /** Parse, then run `check` on every operand. */
183
+ function operandsAll(args, spec, check) {
184
+ const p = parseOpts(args, spec);
185
+ if (failed(p))
186
+ return p.why;
187
+ for (const w of p.operands) {
188
+ const e = check(w);
189
+ if (e !== null)
190
+ return e;
191
+ }
192
+ return null;
193
+ }
194
+ const HEAD_TAIL = { short: "qvr", shortArg: "nc", long: ["quiet", "silent", "verbose"], longArg: ["lines", "bytes"], numeric: true };
195
+ const LOG_LIKE_LONG = [
196
+ "oneline", "graph", "decorate", "all", "branches", "tags", "remotes", "stat", "shortstat", "numstat", "name-only", "name-status",
197
+ "patch", "no-patch", "follow", "reverse", "no-merges", "merges", "first-parent", "abbrev-commit", "no-abbrev", "no-color",
198
+ "cached", "staged", "minimal", "patience", "histogram", "check", "summary", "full-index", "binary", "text", "raw", "no-renames",
199
+ "merge-base", "exit-code", "quiet", "no-index", "compact-summary", "left-right", "cherry-pick", "cherry-mark", "boundary",
200
+ "topo-order", "date-order", "author-date-order", "simplify-by-decoration", "source", "no-notes", "full-diff", "parents",
201
+ "children", "no-prefix", "mailmap", "no-mailmap", "use-mailmap", "regexp-ignore-case", "invert-grep", "all-match",
202
+ "ignore-all-space", "ignore-space-change", "ignore-blank-lines", "ignore-space-at-eol", "relative", "no-relative",
203
+ "no-textconv", "no-ext-diff", "walk-reflogs", "remerge-diff", "no-diff-merges", "cc", "expand-tabs", "no-expand-tabs", "log-size",
204
+ "no-decorate",
205
+ ];
206
+ /** Every git option that takes a value, accepted ONLY attached
207
+ * (`--author=x`). git consumes the next argument for some of these when
208
+ * the value is not attached; taking none of them that way means the next
209
+ * argument is always checked as an operand here — never looser than git. */
210
+ const LOG_LIKE_VALUED = [
211
+ "format", "pretty", "since", "after", "until", "before", "author", "committer", "grep", "date", "abbrev", "color", "max-count",
212
+ "skip", "unified", "word-diff-regex", "diff-filter", "stat-width", "stat-name-width", "encoding", "diff-merges", "line-prefix",
213
+ "src-prefix", "dst-prefix", "decorate-refs", "decorate-refs-exclude", "submodule", "decorate", "stat", "word-diff",
214
+ "find-renames", "find-copies", "dirstat", "relative", "color-words",
215
+ ];
216
+ /** Short forms for log/show/diff, taken whole. A bare `-n`, `-U`, `-S`,
217
+ * `-G` consumes nothing here, so its value is checked as an operand (a
218
+ * count or a search string resolves inside). `-L` (a range in a FILE) and
219
+ * `-O` (an orderfile) are not here: both name a path inside another
220
+ * argument, where the resolver would not find it. */
221
+ const LOG_LIKE_SHORT = [/^-\d+$/, /^-[puswiEFPbzRWaqmctr]+$/, /^-[MCB](\d+%?)?$/, /^-[nUSG]$/, /^-[nU]\d+$/, /^-[SG].+$/];
222
+ function gitLogLike(args, ctx) {
223
+ const spec = { long: LOG_LIKE_LONG, longOptArg: LOG_LIKE_VALUED, shortPatterns: LOG_LIKE_SHORT };
224
+ const noIndex = args.includes("--no-index");
225
+ // B3 (the lead's review): these PRINT what they name — `git diff .env`,
226
+ // `git log -p -- .env`, `git show HEAD:.env` — so an operand takes the
227
+ // CONTENT predicate, not only the scope one. A revision resolves to a
228
+ // path that does not exist and passes; in `rev:path` the path is what
229
+ // is printed. Pathspec MAGIC (`:(top)x`, `:/x`) is read by git, not by
230
+ // this resolver. `--no-index` over a directory compares every file in
231
+ // it. Stated residual, not refused: `git log -p` / `git show` with no
232
+ // pathspec prints every tracked file — a committed credential is the
233
+ // repository's own exposure.
234
+ return operandsAll(args, spec, (w) => {
235
+ if (w.startsWith(":"))
236
+ return `the pathspec ${w}`;
237
+ const colon = w.indexOf(":");
238
+ const path = colon > 0 ? w.slice(colon + 1) : w;
239
+ if (path === "")
240
+ return null;
241
+ if (noIndex) {
242
+ for (const cwd of ctx.cwds) {
243
+ try {
244
+ if (statSync(resolveShellPath(ctx.root, cwd, path).canonical).isDirectory())
245
+ return `git diff --no-index over the directory ${path}`;
246
+ }
247
+ catch {
248
+ // missing: git fails on its own
249
+ }
250
+ }
251
+ }
252
+ return content(ctx, path);
253
+ });
254
+ }
255
+ function gitBranch(args) {
256
+ const takesCommit = ["merged", "no-merged", "contains", "no-contains", "points-at"];
257
+ const p = parseOpts(args, {
258
+ short: "arvl",
259
+ long: ["all", "remotes", "verbose", "list", "show-current", "no-color", "no-column", "ignore-case", "omit-empty", ...takesCommit],
260
+ longOptArg: ["sort", "format", "color", "column"],
261
+ });
262
+ if (failed(p))
263
+ return p.why;
264
+ // The LIST forms only. A positional is a pattern after --list/-l, or
265
+ // the commit of a filter option; anywhere else it names a branch to
266
+ // CREATE (`git branch x`) — and -d/-m/-c/-f/-u are simply not listed.
267
+ const listing = args.includes("--list") || args.some((a) => /^-[arv]*l[arv]*$/.test(a));
268
+ const filters = args.filter((a) => takesCommit.includes(a.replace(/^--/, ""))).length;
269
+ if (!listing && p.operands.length > filters)
270
+ return "git branch with a name creates a branch";
271
+ return null;
272
+ }
273
+ const GIT_SUB = {
274
+ status: (args, ctx) => operandsAll(args, {
275
+ short: "sbvz",
276
+ long: ["short", "branch", "long", "verbose", "no-column", "ahead-behind", "no-ahead-behind", "renames", "no-renames", "show-stash"],
277
+ longOptArg: ["porcelain", "ignored", "untracked-files", "column"],
278
+ shortPatterns: [/^-u(no|normal|all)?$/],
279
+ }, (w) => (w.startsWith(":") ? `the pathspec ${w}` : scope(ctx, w))),
280
+ log: gitLogLike,
281
+ show: gitLogLike,
282
+ diff: gitLogLike,
283
+ branch: (args) => gitBranch(args),
284
+ // Operands are revisions; a `rev:path` is resolved inside the repository.
285
+ "rev-parse": (args) => optionsOnly(args, {
286
+ short: "q",
287
+ long: ["show-toplevel", "verify", "quiet", "git-dir", "absolute-git-dir", "git-common-dir", "is-inside-work-tree", "is-inside-git-dir", "is-bare-repository", "show-prefix", "show-cdup", "symbolic", "symbolic-full-name", "show-superproject-working-tree"],
288
+ longOptArg: ["abbrev-ref", "short"],
289
+ }),
290
+ "ls-files": (args, ctx) => operandsAll(args, {
291
+ short: "cdmoiskutvz",
292
+ long: ["cached", "deleted", "modified", "others", "ignored", "stage", "unmerged", "killed", "exclude-standard", "directory", "no-empty-directory", "full-name", "error-unmatch", "recurse-submodules", "eol", "debug"],
293
+ longOptArg: ["exclude", "abbrev"],
294
+ }, (w) => (w.startsWith(":") ? `the pathspec ${w}` : scope(ctx, w))),
295
+ };
296
+ function git(args, ctx) {
297
+ let i = 0;
298
+ let cwds = ctx.cwds;
299
+ // Global options. `-c` is config injection — `-c core.fsmonitor=cmd`
300
+ // runs `cmd` on `git status` — and --git-dir/--work-tree/--exec-path
301
+ // move git somewhere this resolver did not look; none is listed.
302
+ while (i < args.length && args[i].startsWith("-")) {
303
+ const a = args[i];
304
+ if (a === "--no-pager" || a === "-P" || a === "--no-optional-locks") {
305
+ i += 1;
306
+ continue;
307
+ }
308
+ if (a === "-C" && i + 1 < args.length) {
309
+ const dir = args[i + 1];
310
+ const s = scope({ ...ctx, cwds }, dir);
311
+ if (s !== null)
312
+ return s;
313
+ cwds = cwds.map((c) => resolveShellPath(ctx.root, c, dir).canonical);
314
+ i += 2;
315
+ continue;
316
+ }
317
+ if (a === "--version" && args.length === 1)
318
+ return null;
319
+ return `the git option ${a}`;
320
+ }
321
+ const sub = args[i];
322
+ if (sub === undefined)
323
+ return "git with no subcommand";
324
+ const rule = GIT_SUB[sub];
325
+ if (rule === undefined)
326
+ return `git ${sub} is not read-only here`;
327
+ return rule(args.slice(i + 1), { ...ctx, cwds });
328
+ }
329
+ function find(args, ctx) {
330
+ let i = 0;
331
+ // BSD options before the paths; -L/-H follow symbolic links out.
332
+ while (i < args.length && /^-[Exsd]+$/.test(args[i]))
333
+ i += 1;
334
+ while (i < args.length && !args[i].startsWith("-") && args[i] !== "!" && args[i] !== "(" && args[i] !== ")") {
335
+ const s = scope(ctx, args[i]);
336
+ if (s !== null)
337
+ return s;
338
+ i += 1;
339
+ }
340
+ const noArg = new Set(["-print", "-print0", "-prune", "-empty", "-not", "!", "-o", "-a", "-and", "-or", "(", ")", "-ls", "-depth", "-d", "-true", "-false", "-readable", "-writable", "-executable", "-nouser", "-nogroup", "-xdev", "-mount", "-quit"]);
341
+ const oneArg = new Set(["-name", "-iname", "-path", "-ipath", "-wholename", "-iwholename", "-type", "-maxdepth", "-mindepth", "-size", "-mtime", "-mmin", "-atime", "-amin", "-ctime", "-cmin", "-regex", "-iregex", "-perm", "-user", "-group", "-links", "-printf"]);
342
+ const fileArg = new Set(["-newer", "-samefile"]);
343
+ while (i < args.length) {
344
+ const a = args[i];
345
+ if (noArg.has(a)) {
346
+ i += 1;
347
+ continue;
348
+ }
349
+ if (oneArg.has(a) || fileArg.has(a)) {
350
+ if (i + 1 >= args.length)
351
+ return `${a} needs a value`;
352
+ if (fileArg.has(a)) {
353
+ const s = scope(ctx, args[i + 1]);
354
+ if (s !== null)
355
+ return s;
356
+ }
357
+ i += 2;
358
+ continue;
359
+ }
360
+ // -exec -execdir -ok -okdir -delete -fprint* -fls -follow, and any
361
+ // primary this table does not know
362
+ return `the find primary ${a}`;
363
+ }
364
+ return null;
365
+ }
366
+ function grep(args, ctx) {
367
+ const p = parseOpts(args, {
368
+ short: "ivnclLwxohHsqEFGPaIzZbU",
369
+ shortArg: "eABCm",
370
+ numeric: true,
371
+ long: ["ignore-case", "invert-match", "line-number", "count", "files-with-matches", "files-without-match", "word-regexp", "line-regexp", "only-matching", "no-filename", "with-filename", "no-messages", "quiet", "silent", "extended-regexp", "fixed-strings", "basic-regexp", "perl-regexp", "text", "null", "null-data", "byte-offset", "line-buffered"],
372
+ longArg: ["regexp", "after-context", "before-context", "context", "max-count", "binary-files", "label"],
373
+ longOptArg: ["color", "colour"],
374
+ });
375
+ if (failed(p))
376
+ return p.why;
377
+ // No -r/-R/-d: see the header. The first operand is the pattern unless
378
+ // -e gave one.
379
+ const files = p.values.has("e") || p.values.has("regexp") ? p.operands : p.operands.slice(1);
380
+ for (const w of files) {
381
+ const e = content(ctx, w);
382
+ if (e !== null)
383
+ return e;
384
+ }
385
+ return null;
386
+ }
387
+ function rg(args, ctx) {
388
+ if (args.length === 1 && args[0] === "--version")
389
+ return null;
390
+ // `rg --files [dir]` lists NAMES under the ignore rules, reads no content
391
+ if (args[0] === "--files")
392
+ return operandsAll(args.slice(1), {}, (w) => scope(ctx, w));
393
+ const p = parseOpts(args, {
394
+ short: "iSswxnNlcvFoHIUP0q",
395
+ shortArg: "etTABCmgM",
396
+ long: ["ignore-case", "smart-case", "case-sensitive", "word-regexp", "line-regexp", "line-number", "no-line-number", "files-with-matches", "files-without-match", "count", "count-matches", "invert-match", "fixed-strings", "only-matching", "no-filename", "with-filename", "no-heading", "heading", "json", "vimgrep", "column", "trim", "multiline", "multiline-dotall", "pcre2", "null", "stats", "quiet", "no-messages", "byte-offset", "passthru"],
397
+ longArg: ["regexp", "type", "type-not", "after-context", "before-context", "context", "max-count", "glob", "iglob", "color", "colors", "sort", "sortr", "max-columns", "replace", "encoding", "max-filesize"],
398
+ });
399
+ if (failed(p))
400
+ return p.why;
401
+ const files = p.values.has("e") || p.values.has("regexp") ? p.operands : p.operands.slice(1);
402
+ // S1: past a pipeline's first command, rg with no file reads stdin
403
+ if (files.length === 0)
404
+ return ctx.stage > 0 ? null : "rg with no file searches the whole tree";
405
+ for (const w of files) {
406
+ const e = regularFile(ctx, w);
407
+ if (e !== null)
408
+ return e;
409
+ }
410
+ return null;
411
+ }
412
+ function tail(args, ctx) {
413
+ // -f/-F/--follow never return; they are simply not listed.
414
+ return operandsAll(args, HEAD_TAIL, (w) => (/^\+\d+$/.test(w) ? null : content(ctx, w)));
415
+ }
416
+ const VERSION_ONLY = new Set(["--version"]);
417
+ const version = (...forms) => (args) => args.length === 1 && (VERSION_ONLY.has(args[0]) || forms.includes(args[0])) ? null : "only the version query is read-only";
418
+ const TABLE = {
419
+ ls: (args, ctx) => operandsAll(args, {
420
+ shortPatterns: [/^-[A-Za-z1@%,]+$/],
421
+ long: ["all", "almost-all", "human-readable", "recursive", "reverse", "directory", "inode", "numeric-uid-gid", "size", "si", "literal", "dereference", "full-time", "escape", "file-type", "no-group", "kibibytes", "author", "ignore-backups", "context", "zero", "group-directories-first"],
422
+ longArg: ["sort", "time", "format", "ignore", "hide", "indicator-style", "width", "quoting-style", "time-style", "block-size"],
423
+ longOptArg: ["color", "classify", "hyperlink"],
424
+ }, (w) => scope(ctx, w)),
425
+ cat: (args, ctx) => operandsAll(args, { short: "benstuvAET", long: ["number", "number-nonblank", "squeeze-blank", "show-all", "show-ends", "show-tabs", "show-nonprinting"] }, (w) => content(ctx, w)),
426
+ head: (args, ctx) => operandsAll(args, HEAD_TAIL, (w) => content(ctx, w)),
427
+ tail,
428
+ wc: (args, ctx) => operandsAll(args, { short: "lwcmL", long: ["lines", "words", "bytes", "chars", "max-line-length"] }, (w) => content(ctx, w)),
429
+ pwd: (args) => (args.every((a) => a === "-L" || a === "-P") ? null : "pwd takes no operands"),
430
+ which: (args) => optionsOnly(args, { short: "as" }),
431
+ file: (args, ctx) => operandsAll(args, { short: "bihLkNr0", long: ["brief", "mime", "mime-type", "mime-encoding", "dereference", "no-dereference", "keep-going", "raw", "print0"] }, (w) => scope(ctx, w)),
432
+ stat: (args, ctx) => operandsAll(args, { short: "LnqrsxlF", shortArg: "fct", long: ["dereference", "terse", "file-system"], longArg: ["format", "printf"] }, (w) => scope(ctx, w)),
433
+ du: (args, ctx) => operandsAll(args, { short: "shackmgxAHP0", shortArg: "dBI", long: ["summarize", "human-readable", "all", "total", "apparent-size", "one-file-system", "si", "null", "bytes"], longArg: ["max-depth", "exclude", "block-size", "threshold"] }, (w) => scope(ctx, w)),
434
+ // df reports filesystem usage and nothing else; its operands only pick
435
+ // which filesystem, so they are not held to the workspace.
436
+ df: (args) => optionsOnly(args, { short: "hkmgHilPTaY", shortArg: "tx", long: ["human-readable", "si", "inodes", "local", "portability", "print-type", "all", "total"], longArg: ["type", "exclude-type", "block-size"], longOptArg: ["output"] }),
437
+ find,
438
+ tree: (args, ctx) => operandsAll(args,
439
+ // -o writes a file; -R and -H write per-directory HTML; -l follows
440
+ // links out; --fromfile reads a listing. None is listed.
441
+ {
442
+ short: "adfiqNQpugshDFrtcvUCnASJX",
443
+ shortArg: "LIP",
444
+ long: ["noreport", "gitignore", "dirsfirst", "du", "si", "prune", "matchdirs", "ignore-case", "inodes", "device"],
445
+ longArg: ["filelimit", "charset", "sort", "timefmt"],
446
+ }, (w) => scope(ctx, w)),
447
+ grep,
448
+ rg,
449
+ git,
450
+ // Version queries: the program runs, and only prints what it is. npx is
451
+ // not here — it can fetch a package to answer.
452
+ node: version("-v"),
453
+ npm: (args, ctx) => {
454
+ if (args[0] === "ls" || args[0] === "list") {
455
+ return optionsOnly(args.slice(1), { short: "aglps", long: ["all", "json", "long", "parseable", "prod", "production", "dev", "global", "link", "unicode", "silent"], longArg: ["depth", "omit", "include"] });
456
+ }
457
+ return version("-v")(args, ctx);
458
+ },
459
+ pnpm: version("-v"),
460
+ yarn: version("-v"),
461
+ bun: version("-v"),
462
+ deno: version("-V"),
463
+ python: version("-V"),
464
+ python3: version("-V"),
465
+ pip: version("-V"),
466
+ pip3: version("-V"),
467
+ cargo: version("-V"),
468
+ rustc: version("-V"),
469
+ go: (args) => (args.length === 1 && args[0] === "version" ? null : "only `go version` is read-only"),
470
+ // Pipeline filters.
471
+ sort: (args, ctx) => operandsAll(args,
472
+ // -o/--output write; --compress-program RUNS one; -T/--files0-from
473
+ // point elsewhere. None is listed.
474
+ { short: "rnufhVbdiMRszcCg", shortArg: "kt", long: ["reverse", "numeric-sort", "unique", "ignore-case", "human-numeric-sort", "version-sort", "stable", "zero-terminated", "general-numeric-sort", "month-sort", "random-sort", "check", "dictionary-order", "ignore-leading-blanks"], longArg: ["key", "field-separator"] }, (w) => content(ctx, w)),
475
+ uniq: (args, ctx) => {
476
+ const p = parseOpts(args, { short: "cdui", shortArg: "fsw", long: ["count", "repeated", "unique", "ignore-case"], longArg: ["skip-fields", "skip-chars", "check-chars"] });
477
+ if (failed(p))
478
+ return p.why;
479
+ // a SECOND operand is the output file
480
+ if (p.operands.length > 1)
481
+ return "uniq with an output file";
482
+ return p.operands.length === 1 ? content(ctx, p.operands[0]) : null;
483
+ },
484
+ cut: (args, ctx) => operandsAll(args, { short: "snz", shortArg: "bcfd", long: ["complement", "only-delimited", "zero-terminated"], longArg: ["bytes", "characters", "fields", "delimiter", "output-delimiter"] }, (w) => content(ctx, w)),
485
+ tr: (args) => optionsOnly(args, { short: "dscCu" }),
486
+ // NOT echo: it reads nothing, and the approval gates use `echo x` as
487
+ // their command that asks.
488
+ };
489
+ /** The table's commands — the gate that every one has a case reads this. */
490
+ export const READ_ONLY_COMMANDS = Object.keys(TABLE);
491
+ // ── redirections ────────────────────────────────────────────────────────
492
+ function redirect(r, ctx) {
493
+ if ((r.op === ">" || r.op === ">>") && (r.fd === null || r.fd === 1 || r.fd === 2 || r.fd === "&") && r.target === "/dev/null")
494
+ return null;
495
+ if (r.op === ">&" && (r.fd === null || r.fd === 1 || r.fd === 2) && (r.target === "1" || r.target === "2"))
496
+ return null;
497
+ if (r.op === "<" && (r.fd === null || r.fd === 0))
498
+ return content(ctx, r.target);
499
+ return `a redirection to ${r.target}`;
500
+ }
501
+ function command(cmd, ctx) {
502
+ for (const r of cmd.redirects) {
503
+ const e = redirect(r, ctx);
504
+ if (e !== null)
505
+ return e;
506
+ }
507
+ const [name, ...args] = cmd.argv;
508
+ const rule = name !== undefined && Object.hasOwn(TABLE, name) ? TABLE[name] : undefined;
509
+ if (rule === undefined)
510
+ return `${name ?? "?"} is not on the read-only table`;
511
+ return rule(args, ctx);
512
+ }
513
+ /**
514
+ * Is this command line read-only, provably? `workspaceRoot` is the
515
+ * shell's working directory (the shell tool runs every command there);
516
+ * nothing under `protectedRoots` is read unasked.
517
+ */
518
+ export function classifyReadOnly(commandLine, workspaceRoot, protectedRoots = []) {
519
+ const parsed = parseShell(commandLine);
520
+ if (!parsed.ok)
521
+ return { allow: false, why: parsed.why };
522
+ const canonicalProtected = protectedRoots.map(realCase);
523
+ let cwds = [workspaceRoot];
524
+ // S2: where the next command runs after `cd x` depends on the joiner —
525
+ // `&&` only if the cd worked (x), `||` only if it failed (where it was),
526
+ // `;` either. Held until a joiner other than `&&` settles it.
527
+ // the `as` keeps the flow type the full union (a closure-free loop
528
+ // otherwise narrows it to null for good)
529
+ let lastCd = null;
530
+ for (const pipeline of parsed.list) {
531
+ if (lastCd !== null) {
532
+ cwds = pipeline.joinedBy === "&&" ? lastCd.to : pipeline.joinedBy === "||" ? lastCd.from : [...new Set([...lastCd.to, ...lastCd.from])];
533
+ if (pipeline.joinedBy !== "&&")
534
+ lastCd = null;
535
+ }
536
+ const ctx = { root: workspaceRoot, protectedRoots: canonicalProtected, cwds, stage: 0 };
537
+ const first = pipeline.stages[0];
538
+ if (first.argv[0] === "cd") {
539
+ // `cd DIR` on its own, into the workspace
540
+ if (pipeline.stages.length !== 1 || first.argv.length !== 2 || first.redirects.length > 0)
541
+ return { allow: false, why: "a cd that is not `cd DIR` on its own" };
542
+ const dir = first.argv[1];
543
+ if (dir.startsWith("-"))
544
+ return { allow: false, why: `cd ${dir}` };
545
+ const s = scope(ctx, dir);
546
+ if (s !== null)
547
+ return { allow: false, why: s };
548
+ lastCd = { to: [...new Set(cwds.map((c) => resolveShellPath(workspaceRoot, c, dir).canonical))], from: lastCd?.from ?? cwds };
549
+ continue;
550
+ }
551
+ for (const [stage, cmd] of pipeline.stages.entries()) {
552
+ const e = command(cmd, { ...ctx, stage });
553
+ if (e !== null)
554
+ return { allow: false, why: e };
555
+ }
556
+ }
557
+ return { allow: true };
558
+ }
559
+ /** The modes in which a provable read runs unasked. dontAsk is one: an
560
+ * allow is what it still honours. Manual promises that every tool asks;
561
+ * plan denies shell outright; bypass allows everything already — this
562
+ * member has nothing to add to any of them. */
563
+ const ALLOWING_MODES = new Set(["default", "accept-edits", "dontAsk"]);
564
+ const ABSTAIN = { action: "abstain" };
565
+ /**
566
+ * The chain member. ALLOW or ABSTAIN, never anything else: an allow here
567
+ * outranks the tier's ask (deny > allow > ask) and is recorded as
568
+ * `decidedBy: "read-only-shell"`, so the audit says why a shell call ran
569
+ * unasked; a deny anywhere — plan's, the breaker's, a user extension's —
570
+ * still wins over it.
571
+ */
572
+ export function readOnlyShellExtension(roots) {
573
+ return {
574
+ name: "read-only-shell",
575
+ approvals: [
576
+ {
577
+ decide: (call) => {
578
+ if (call.name !== "shell" || !ALLOWING_MODES.has(getMode()))
579
+ return ABSTAIN;
580
+ const command = call.input.command;
581
+ if (typeof command !== "string")
582
+ return ABSTAIN;
583
+ const { workspaceRoot, excludeRoots } = roots();
584
+ const protectedRoots = [...excludeRoots, ...HOME_SUBTREES.map((s) => join(homedir(), s))];
585
+ return classifyReadOnly(command, workspaceRoot, protectedRoots).allow ? { action: "allow" } : ABSTAIN;
586
+ },
587
+ },
588
+ ],
589
+ };
590
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 4c — a resumed session's history, replayed into cells.
3
+ *
4
+ * REL-0152-D5's tail printed the last two turns as text lines — a raw
5
+ * block, not cells — so nothing earlier could be read, and a fold line
6
+ * naming a key would have promised a read the product could not deliver.
7
+ * This drives the Body's own mutations over the durable events — the same
8
+ * calls a live run makes — so the turns render through the committed-cell
9
+ * renderer, route B's reprint prints them again on a resize, and the
10
+ * earlier turns sit in ONE fold row the ctrl+r viewer reads.
11
+ *
12
+ * Still a projection: it reads the log the session already loaded, writes
13
+ * nothing durable, and holds no state of its own.
14
+ *
15
+ * The shape (owner-ruled, option A):
16
+ * - the last two turns in full;
17
+ * - every earlier turn in one fold row, "N earlier turns · ctrl+r to read";
18
+ * - with a compaction, the latest checkpoint in place of the fold — its
19
+ * summary (what the model sees next) and the turns it covers, read in
20
+ * the viewer — then any uncovered earlier turns folded as above.
21
+ */
22
+ /** The Body surface the replay drives — the live run's own mutations. */
23
+ export interface ReplayBody {
24
+ userLine(text: string): void;
25
+ thinkingAppend(text: string): void;
26
+ thinkingEnd(): void;
27
+ textAppend(text: string): void;
28
+ textEnd(): void;
29
+ toolStart(name: string, callId: string, input: Record<string, unknown>): void;
30
+ toolResult(callId: string, result: {
31
+ content: string;
32
+ isError: boolean;
33
+ reason?: string | null;
34
+ untimed?: boolean;
35
+ }): void;
36
+ notice(text: string): void;
37
+ endTurn(thoughtSeconds: number): void;
38
+ fold(label: string, replay: () => void, summary?: string | null): void;
39
+ raw(lines: string[]): void;
40
+ }
41
+ type Ev = {
42
+ readonly type: string;
43
+ readonly seq?: number;
44
+ } & Record<string, unknown>;
45
+ /** Split events into turns: each starts at a user turn's input; anything
46
+ * before the first one belongs to no turn and is dropped. */
47
+ export declare function turnsOf(events: readonly Ev[]): Ev[][];
48
+ /**
49
+ * Replay a session's durable events into the body. Returns the number of
50
+ * turns it found (0: a fresh session — nothing is drawn).
51
+ */
52
+ export declare function replayInto(body: ReplayBody, events: readonly Ev[], W?: number): number;
53
+ export {};