@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
|
@@ -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
|
+
}
|
package/dist/replay.d.ts
ADDED
|
@@ -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 {};
|