@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,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kiso never serves its own credential store to a model — the SHELL half.
|
|
3
|
+
* The file tools refuse in tools-node, whose protected.ts holds the rule
|
|
4
|
+
* (what is protected, and how a path is matched to it).
|
|
5
|
+
*
|
|
6
|
+
* A chain member at the head, beside the floor, in EVERY mode including
|
|
7
|
+
* bypass: any word of any command in the line that resolves to a protected
|
|
8
|
+
* file DENIES. The `!` gesture runs the same check before it runs a line,
|
|
9
|
+
* because `!cmd` sends that line's output to the model.
|
|
10
|
+
*
|
|
11
|
+
* THIS IS A READING, NOT A GUARANTEE. It reads the line as the floor does:
|
|
12
|
+
* the loose reader, `cd` followed, subshells and `sh -c`/`eval` inner lines
|
|
13
|
+
* read, `~`, `$HOME` and `$KISO_HOME` expanded, `--opt=path` split, and the
|
|
14
|
+
* words the floor drops kept — a redirection's target, an assignment's
|
|
15
|
+
* value. A word is resolved by the KERNEL, as the command would open it:
|
|
16
|
+
* one `stat` of the directory it runs in joined to the word, compared by
|
|
17
|
+
* inode — so a symlink, a `..` through one, a case variant and a hard link
|
|
18
|
+
* under another name all reach the same file. A protected path that does
|
|
19
|
+
* not exist yet has no inode: until the file is created it is matched by
|
|
20
|
+
* its spelling, resolved against the directory the word runs in (so is a
|
|
21
|
+
* name-suffixed sibling, the writer's temp file). A glob is matched against
|
|
22
|
+
* the protected files themselves, and only when its literal directory
|
|
23
|
+
* holds one. A word it cannot resolve (a variable, a substitution, another
|
|
24
|
+
* user's `~name`), or a relative word read after a `cd` it could not
|
|
25
|
+
* follow, denies when it still contains a protected file's name. So does a
|
|
26
|
+
* command that names a directory holding a protected file AND that file's
|
|
27
|
+
* name, in two words (`find ~/.kiso -name auth.json`). A line it cannot
|
|
28
|
+
* read to the end — nested too deep, or past its budget of disk reads —
|
|
29
|
+
* denies.
|
|
30
|
+
*
|
|
31
|
+
* Its blind spots are stated, not hidden: a path assembled at run time
|
|
32
|
+
* (`base64 -d`, string slicing), a copy under another name made in an
|
|
33
|
+
* earlier call, a script that reads the file, a recursive walk over a
|
|
34
|
+
* directory that holds it without naming it (`grep -r x ~`). The
|
|
35
|
+
* guarantee is the sandbox, which stays post-launch.
|
|
36
|
+
*/
|
|
37
|
+
import { readdirSync, realpathSync, statSync } from "node:fs";
|
|
38
|
+
import { basename, dirname, resolve } from "node:path";
|
|
39
|
+
import { protectedIdentity, PROTECTED_REFUSAL } from "@vincemakes/kiso-tools-node";
|
|
40
|
+
import { innerLines, unwrap } from "./floor.js";
|
|
41
|
+
import { parseShellLooseChecked } from "./shell-words.js";
|
|
42
|
+
const ABSTAIN = { action: "abstain" };
|
|
43
|
+
/** How deep `sh -c`/`eval` lines are followed, and how many are read. */
|
|
44
|
+
const MAX_INNER_DEPTH = 16;
|
|
45
|
+
const MAX_INNER_LINES = 256;
|
|
46
|
+
/** Past this many candidate directories the walk stops tracking them one
|
|
47
|
+
* by one and falls back to the name check (`cd a; cd b; …` doubles). */
|
|
48
|
+
const MAX_CWDS = 32;
|
|
49
|
+
/** A directory spelled longer than any system opens is not a directory
|
|
50
|
+
* a command can run in. */
|
|
51
|
+
const MAX_PATH = 4096;
|
|
52
|
+
/** Path readings per line — one word from one candidate directory: at
|
|
53
|
+
* most one `stat` each. A line a person or a model writes needs a few
|
|
54
|
+
* hundred; 8,000 bounds the hostile ones to ~15 ms on a laptop, and under
|
|
55
|
+
* 100 ms on a loaded 2-core CI runner, where 20,000 measured 139 ms. A
|
|
56
|
+
* line that needs more is not read to the end, and a line not read to
|
|
57
|
+
* the end denies. */
|
|
58
|
+
const MAX_READINGS = 8_000;
|
|
59
|
+
/** Directory changes per line the check follows. Each one respells every
|
|
60
|
+
* candidate directory, so an unbounded run of `cd a;` is quadratic; a
|
|
61
|
+
* line a person or a model writes changes directory a handful of times. */
|
|
62
|
+
const MAX_CDS = 64;
|
|
63
|
+
export const NESTED_TOO_DEEP = "the line is nested deeper than the check reads";
|
|
64
|
+
export const TOO_LONG = "the line names more paths than the check reads";
|
|
65
|
+
export const TOO_MANY_CDS = "the line changes directory more times than the check follows";
|
|
66
|
+
class Unread extends Error {
|
|
67
|
+
}
|
|
68
|
+
const child = (s) => ({ cwds: s.cwds, lossy: s.lossy, pending: s.pending });
|
|
69
|
+
function cap(s, list) {
|
|
70
|
+
const fit = list.filter((d) => d.length <= MAX_PATH);
|
|
71
|
+
if (fit.length < list.length)
|
|
72
|
+
s.lossy = true;
|
|
73
|
+
const unique = [...new Set(fit)];
|
|
74
|
+
if (unique.length <= MAX_CWDS)
|
|
75
|
+
return unique;
|
|
76
|
+
s.lossy = true;
|
|
77
|
+
return unique.slice(0, MAX_CWDS);
|
|
78
|
+
}
|
|
79
|
+
const inode = (st) => `${st.dev}:${st.ino}`;
|
|
80
|
+
/** A shell glob as a whole-path test. `*` and `?` are taken to match a
|
|
81
|
+
* leading dot too — looser than the shell's default, on purpose: a
|
|
82
|
+
* line can turn `dotglob` on. */
|
|
83
|
+
function globRegex(glob) {
|
|
84
|
+
let re = "";
|
|
85
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
86
|
+
const c = glob[i];
|
|
87
|
+
if (c === "*")
|
|
88
|
+
re += "[^/]*";
|
|
89
|
+
else if (c === "?")
|
|
90
|
+
re += "[^/]";
|
|
91
|
+
else if (c === "[") {
|
|
92
|
+
const close = glob.indexOf("]", i + 2);
|
|
93
|
+
if (close < 0)
|
|
94
|
+
re += "\\[";
|
|
95
|
+
else {
|
|
96
|
+
const body = glob.slice(i + 1, close).replace(/^!/, "^").replace(/\\/g, "\\\\");
|
|
97
|
+
re += `[${body}]`;
|
|
98
|
+
i = close;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else
|
|
102
|
+
re += c.replace(/[.+^${}()|\\/]/g, "\\$&");
|
|
103
|
+
}
|
|
104
|
+
// case-folded: on a disk that folds case, `.KISO/*` is the same directory
|
|
105
|
+
return new RegExp(`^${re}$`, "i");
|
|
106
|
+
}
|
|
107
|
+
function probeFor(id) {
|
|
108
|
+
const files = new Set();
|
|
109
|
+
const dirs = new Set();
|
|
110
|
+
const targets = [];
|
|
111
|
+
for (const pf of id.paths) {
|
|
112
|
+
const dir = dirname(pf);
|
|
113
|
+
const base = basename(pf);
|
|
114
|
+
targets.push(pf);
|
|
115
|
+
try {
|
|
116
|
+
for (const name of readdirSync(dir))
|
|
117
|
+
if (name.startsWith(`${base}.`))
|
|
118
|
+
targets.push(`${dir}/${name}`);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// no directory yet — nothing in it to serve
|
|
122
|
+
}
|
|
123
|
+
for (let d = dir;; d = dirname(d)) {
|
|
124
|
+
const st = statSync(d, { throwIfNoEntry: false });
|
|
125
|
+
if (st?.isDirectory() === true)
|
|
126
|
+
dirs.add(inode(st));
|
|
127
|
+
if (dirname(d) === d)
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const t of targets) {
|
|
132
|
+
const st = statSync(t, { throwIfNoEntry: false });
|
|
133
|
+
if (st?.isFile() === true)
|
|
134
|
+
files.add(inode(st));
|
|
135
|
+
}
|
|
136
|
+
return { files, dirs, targets };
|
|
137
|
+
}
|
|
138
|
+
/** The pure check: does any word of the line name a protected file? */
|
|
139
|
+
export function protectedShellCheck(line, workspaceRoot, id, env) {
|
|
140
|
+
if (id.paths.length === 0)
|
|
141
|
+
return { hit: false };
|
|
142
|
+
const probe = probeFor(id);
|
|
143
|
+
const names = id.paths.map((p) => basename(p).toLowerCase());
|
|
144
|
+
const paths = id.paths.map((p) => p.toLowerCase());
|
|
145
|
+
const namesProtected = (t) => {
|
|
146
|
+
const low = t.toLowerCase();
|
|
147
|
+
return names.some((n) => low.includes(n));
|
|
148
|
+
};
|
|
149
|
+
let readings = 0;
|
|
150
|
+
const charge = () => {
|
|
151
|
+
if (++readings > MAX_READINGS)
|
|
152
|
+
throw new Unread(TOO_LONG);
|
|
153
|
+
};
|
|
154
|
+
const seenStat = new Map();
|
|
155
|
+
/** The kernel's answer for a path, as a command would open it: `..`
|
|
156
|
+
* after a symlink is the symlink target's parent, because the string
|
|
157
|
+
* is never normalized before the kernel reads it. */
|
|
158
|
+
const statAt = (p) => {
|
|
159
|
+
if (seenStat.has(p))
|
|
160
|
+
return seenStat.get(p);
|
|
161
|
+
let st;
|
|
162
|
+
try {
|
|
163
|
+
st = statSync(p, { throwIfNoEntry: false });
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
st = undefined; // too long, not a directory, no permission: nothing it opens
|
|
167
|
+
}
|
|
168
|
+
seenStat.set(p, st);
|
|
169
|
+
return st;
|
|
170
|
+
};
|
|
171
|
+
const at = (cwd, t) => (t.startsWith("/") ? t : `${cwd}/${t}`);
|
|
172
|
+
const regexes = new Map();
|
|
173
|
+
const realOf = new Map();
|
|
174
|
+
/** Whether a word's last component is a protected file's name, or one
|
|
175
|
+
* of its name-suffixed siblings'. */
|
|
176
|
+
const named = (t) => {
|
|
177
|
+
const leaf = t.slice(t.lastIndexOf("/") + 1).toLowerCase();
|
|
178
|
+
return names.some((n) => leaf === n || leaf.startsWith(`${n}.`));
|
|
179
|
+
};
|
|
180
|
+
const expand = (t, tilde) => {
|
|
181
|
+
let out = tilde && (t === "~" || t.startsWith("~/")) ? env.home + t.slice(1) : t;
|
|
182
|
+
out = out.replace(/\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/g, env.home);
|
|
183
|
+
return out.replace(/\$\{KISO_HOME\}|\$KISO_HOME(?![A-Za-z0-9_])/g, env.kisoHome);
|
|
184
|
+
};
|
|
185
|
+
/** One literal-or-glob path text, read from each candidate directory. */
|
|
186
|
+
const pathHits = (t, s) => {
|
|
187
|
+
if (t === "")
|
|
188
|
+
return false;
|
|
189
|
+
if (/[$`]/.test(t))
|
|
190
|
+
return namesProtected(t);
|
|
191
|
+
if (s.lossy && !t.startsWith("/") && namesProtected(t))
|
|
192
|
+
return true;
|
|
193
|
+
const glob = /[*?[]/.test(t);
|
|
194
|
+
for (const cwd of s.cwds) {
|
|
195
|
+
charge();
|
|
196
|
+
if (!glob) {
|
|
197
|
+
// by spelling first, which needs no disk (a sibling that does
|
|
198
|
+
// not exist yet) — only when the last component could be the
|
|
199
|
+
// file's name — then by inode
|
|
200
|
+
if (named(t)) {
|
|
201
|
+
const spelled = resolve(cwd, t).toLowerCase();
|
|
202
|
+
if (paths.some((pf) => spelled === pf || spelled.startsWith(`${pf}.`)))
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
const st = statAt(at(cwd, t));
|
|
206
|
+
if (st?.isFile() === true && probe.files.has(inode(st)))
|
|
207
|
+
return true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const g = t.search(/[*?[]/);
|
|
211
|
+
const cut = t.lastIndexOf("/", g);
|
|
212
|
+
const rest = t.slice(cut + 1);
|
|
213
|
+
// only where the literal directory HOLDS a protected file — a glob
|
|
214
|
+
// anywhere else is never expanded, and the disk is never walked
|
|
215
|
+
const dirText = cut < 0 ? cwd : at(cwd, t.slice(0, cut + 1));
|
|
216
|
+
const dst = statAt(dirText);
|
|
217
|
+
if (dst?.isDirectory() !== true || !probe.dirs.has(inode(dst)))
|
|
218
|
+
continue;
|
|
219
|
+
// a `..` after the glob walks out of what the pattern can say
|
|
220
|
+
if (/(^|\/)\.\.(\/|$)/.test(rest)) {
|
|
221
|
+
if (namesProtected(t))
|
|
222
|
+
return true;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
let real = realOf.get(dirText);
|
|
226
|
+
if (real === undefined) {
|
|
227
|
+
try {
|
|
228
|
+
real = realpathSync.native(dirText);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
realOf.set(dirText, real);
|
|
234
|
+
}
|
|
235
|
+
const pattern = `${real.endsWith("/") ? real : `${real}/`}${rest}`;
|
|
236
|
+
let re = regexes.get(pattern);
|
|
237
|
+
if (re === undefined)
|
|
238
|
+
regexes.set(pattern, (re = globRegex(pattern)));
|
|
239
|
+
if (probe.targets.some((p) => re.test(p)))
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
};
|
|
244
|
+
const wordHits = (w, s) => {
|
|
245
|
+
// `~name/…` is another account's home — or this one's, by name
|
|
246
|
+
if (w.tilde && /^~[^/]/.test(w.text))
|
|
247
|
+
return namesProtected(w.text);
|
|
248
|
+
const t = expand(w.text, w.tilde);
|
|
249
|
+
if (pathHits(t, s))
|
|
250
|
+
return true;
|
|
251
|
+
// `--config=path`, `if=path`, `X=path`: the value is a path too
|
|
252
|
+
const eq = t.indexOf("=");
|
|
253
|
+
return eq > 0 && pathHits(expand(t.slice(eq + 1), t[eq + 1] === "~"), s);
|
|
254
|
+
};
|
|
255
|
+
/** A directory holding a protected file — the file's own, or any
|
|
256
|
+
* directory above it — by inode. */
|
|
257
|
+
const holds = (w, s) => {
|
|
258
|
+
const t = expand(w.text, w.tilde);
|
|
259
|
+
if (t === "" || /[$`*?[]/.test(t) || (w.tilde && /^~[^/]/.test(w.text)))
|
|
260
|
+
return false;
|
|
261
|
+
return s.cwds.some((cwd) => {
|
|
262
|
+
charge();
|
|
263
|
+
const st = statAt(at(cwd, t));
|
|
264
|
+
return st?.isDirectory() === true && probe.dirs.has(inode(st));
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
/** One command naming a directory that holds a protected file AND that
|
|
268
|
+
* file's name, in two words: `find ~/.kiso -name auth.json`. */
|
|
269
|
+
const dirAndName = (words, s) => {
|
|
270
|
+
const named = words.find((w) => namesProtected(w.text));
|
|
271
|
+
return named !== undefined && words.some((w) => w !== named && holds(w, s)) ? named.text : null;
|
|
272
|
+
};
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
let cds = 0;
|
|
275
|
+
// a nest past what the check follows is thrown, not returned: it ends
|
|
276
|
+
// the whole read, and must never read as a word
|
|
277
|
+
const walk = (nodes, s, depth) => {
|
|
278
|
+
for (const node of nodes) {
|
|
279
|
+
// after `cd x`: `&&` runs in x, `||` where it was, `;` in either
|
|
280
|
+
if (s.pending !== null) {
|
|
281
|
+
s.cwds = cap(s, node.joinedBy === "&&" ? s.pending : node.joinedBy === "||" ? s.cwds : [...s.pending, ...s.cwds]);
|
|
282
|
+
if (node.joinedBy !== "&&")
|
|
283
|
+
s.pending = null;
|
|
284
|
+
}
|
|
285
|
+
if (node.kind === "group") {
|
|
286
|
+
// a subshell: its cd does not move the shell around it
|
|
287
|
+
const hit = walk(node.items, child(s), depth);
|
|
288
|
+
if (hit !== null)
|
|
289
|
+
return hit;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
// every word the command names, its own name included, and the
|
|
293
|
+
// words the floor drops (`< file`, `2> file`, `X=file`)
|
|
294
|
+
const words = [...node.argv, ...(node.dropped ?? [])];
|
|
295
|
+
for (const w of words)
|
|
296
|
+
if (wordHits(w, s))
|
|
297
|
+
return w.text;
|
|
298
|
+
const pair = dirAndName(words, s);
|
|
299
|
+
if (pair !== null)
|
|
300
|
+
return pair;
|
|
301
|
+
const argv = unwrap(node.argv);
|
|
302
|
+
if (argv.length === 0)
|
|
303
|
+
continue;
|
|
304
|
+
const inner = innerLines(argv, env.home);
|
|
305
|
+
if (inner !== null) {
|
|
306
|
+
if (depth >= MAX_INNER_DEPTH)
|
|
307
|
+
throw new Unread(NESTED_TOO_DEEP);
|
|
308
|
+
for (const l of inner.lines) {
|
|
309
|
+
if (seen.has(l))
|
|
310
|
+
continue;
|
|
311
|
+
if (seen.size >= MAX_INNER_LINES)
|
|
312
|
+
throw new Unread(NESTED_TOO_DEEP);
|
|
313
|
+
seen.add(l);
|
|
314
|
+
const read = parseShellLooseChecked(l, { keepDropped: true });
|
|
315
|
+
if (read.truncated)
|
|
316
|
+
throw new Unread(NESTED_TOO_DEEP);
|
|
317
|
+
// `sh -c` is a child shell; eval runs here, and its cd moves this one
|
|
318
|
+
const hit = walk(read.nodes, inner.subshell ? child(s) : s, depth + 1);
|
|
319
|
+
if (hit !== null)
|
|
320
|
+
return hit;
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const name = basename(argv[0].text);
|
|
325
|
+
if (name === "cd" || name === "pushd" || name === "popd") {
|
|
326
|
+
if (++cds > MAX_CDS)
|
|
327
|
+
throw new Unread(TOO_MANY_CDS);
|
|
328
|
+
const to = argv.slice(1).find((a) => !["--", "-P", "-L", "-e", "-@"].includes(a.text));
|
|
329
|
+
if (name === "popd" || to?.text === "-") {
|
|
330
|
+
// back to somewhere the check does not track
|
|
331
|
+
s.lossy = true;
|
|
332
|
+
s.pending = cap(s, [...s.cwds, env.home, workspaceRoot]);
|
|
333
|
+
}
|
|
334
|
+
else if (to === undefined)
|
|
335
|
+
s.pending = [env.home];
|
|
336
|
+
else {
|
|
337
|
+
const t = expand(to.text, to.tilde);
|
|
338
|
+
if (/[$`*?[]/.test(t) || (to.tilde && /^~[^/]/.test(to.text))) {
|
|
339
|
+
s.lossy = true;
|
|
340
|
+
s.pending = cap(s, [env.home, ...s.cwds]);
|
|
341
|
+
}
|
|
342
|
+
else
|
|
343
|
+
s.pending = cap(s, s.cwds.map((c) => at(c, t)));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
};
|
|
349
|
+
const top = parseShellLooseChecked(line, { keepDropped: true });
|
|
350
|
+
if (top.truncated)
|
|
351
|
+
return { hit: true, why: NESTED_TOO_DEEP, unread: true };
|
|
352
|
+
try {
|
|
353
|
+
const hit = walk(top.nodes, { cwds: [workspaceRoot], lossy: false, pending: null }, 0);
|
|
354
|
+
return hit === null ? { hit: false } : { hit: true, why: hit, unread: false };
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
if (err instanceof Unread)
|
|
358
|
+
return { hit: true, why: err.message, unread: true };
|
|
359
|
+
throw err;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** The refusal the MODEL reads. Permanent, as the file tools say it, so
|
|
363
|
+
* it does not retry under another spelling. */
|
|
364
|
+
export function protectedShellReason(v) {
|
|
365
|
+
if (v.unread)
|
|
366
|
+
return `${PROTECTED_REFUSAL}: ${v.why}, so kiso does not run it, in any mode.`;
|
|
367
|
+
return `${PROTECTED_REFUSAL}: ${v.why} names a protected file. This is permanent, in every mode: do not retry it, under this path or another.`;
|
|
368
|
+
}
|
|
369
|
+
/** The refusal the PERSON reads, for their own `!` line: what to do
|
|
370
|
+
* instead. */
|
|
371
|
+
export function protectedBangReason(v) {
|
|
372
|
+
return `not run — ${v.unread ? v.why : `${v.why} names a protected file`}. Run it in your own terminal: kiso never puts its credential store on a path to a model.`;
|
|
373
|
+
}
|
|
374
|
+
/** The whole question for one line, as both callers ask it. A line the
|
|
375
|
+
* check throws on is refused: a line it could not read is not a line it
|
|
376
|
+
* may let through. */
|
|
377
|
+
export function protectedShellVerdict(line, workspaceRoot, files, env, check = protectedShellCheck) {
|
|
378
|
+
if (files.length === 0)
|
|
379
|
+
return { refused: false };
|
|
380
|
+
try {
|
|
381
|
+
const v = check(line, workspaceRoot, protectedIdentity(files), env);
|
|
382
|
+
return v.hit ? { refused: true, why: v.why, unread: v.unread } : { refused: false };
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
return { refused: true, why: `the check could not read this line (${err instanceof Error ? err.name : "error"})`, unread: true };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** The chain member: deny-or-abstain, every mode. */
|
|
389
|
+
export function protectedShellExtension(opts) {
|
|
390
|
+
return {
|
|
391
|
+
name: "protected-files",
|
|
392
|
+
approvals: [
|
|
393
|
+
{
|
|
394
|
+
decide: (call) => {
|
|
395
|
+
if (call.name !== "shell")
|
|
396
|
+
return ABSTAIN;
|
|
397
|
+
const command = call.input.command;
|
|
398
|
+
if (typeof command !== "string")
|
|
399
|
+
return ABSTAIN;
|
|
400
|
+
const v = protectedShellVerdict(command, opts.workspaceRoot(), opts.files(), opts.env(), opts.check);
|
|
401
|
+
return v.refused ? { action: "deny", reason: protectedShellReason(v) } : ABSTAIN;
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
};
|
|
406
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.40.0 — writes into `.git/**` and `.kiso/**` always ask (lead's ruling on
|
|
3
|
+
* the read-only shell plan, 2026-09-18).
|
|
4
|
+
*
|
|
5
|
+
* Both directories hold configuration that RUNS. git executes commands its
|
|
6
|
+
* repository config names — `core.fsmonitor` on `git status`,
|
|
7
|
+
* `diff.external` and textconv on `git diff` — and a project's `.kiso/`
|
|
8
|
+
* holds extensions and config. Under accept-edits a model could write
|
|
9
|
+
* either unasked, and with the read-only shell allow a following
|
|
10
|
+
* `git status` would run what it wrote without a person seeing any of it.
|
|
11
|
+
*
|
|
12
|
+
* So a write_file or edit_file whose path — as written, or once symlinks
|
|
13
|
+
* are followed — passes through a `.git` or `.kiso` directory asks in
|
|
14
|
+
* every asking tier: accept-edits' allow does not cover it, and neither
|
|
15
|
+
* does a saved allow. dontAsk therefore denies it; plan denies it anyway;
|
|
16
|
+
* bypass runs it, as bypass's shell could.
|
|
17
|
+
*/
|
|
18
|
+
import type { PolicyCall } from "@vincemakes/kiso-core";
|
|
19
|
+
import type { KisoExtension } from "@vincemakes/kiso-runtime";
|
|
20
|
+
export declare function isProtectedWrite(call: PolicyCall, workspaceRoot: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* A saved allow ("yes, don't ask again for <tool>") allows by TOOL, so on
|
|
23
|
+
* its own it would carry every later call of that tool — including the
|
|
24
|
+
* ones kiso has decided must always reach a person. Wrapped wherever it
|
|
25
|
+
* enters the chain, it ABSTAINS for those, and the tier's ask stands.
|
|
26
|
+
* Everything else it decided, it still decides; its live `rules` handle
|
|
27
|
+
* (the grant path mutates it) is the same object.
|
|
28
|
+
*/
|
|
29
|
+
export declare function guardSavedAllow(ext: KisoExtension, neverInherited: (call: PolicyCall) => boolean): KisoExtension;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.40.0 — writes into `.git/**` and `.kiso/**` always ask (lead's ruling on
|
|
3
|
+
* the read-only shell plan, 2026-09-18).
|
|
4
|
+
*
|
|
5
|
+
* Both directories hold configuration that RUNS. git executes commands its
|
|
6
|
+
* repository config names — `core.fsmonitor` on `git status`,
|
|
7
|
+
* `diff.external` and textconv on `git diff` — and a project's `.kiso/`
|
|
8
|
+
* holds extensions and config. Under accept-edits a model could write
|
|
9
|
+
* either unasked, and with the read-only shell allow a following
|
|
10
|
+
* `git status` would run what it wrote without a person seeing any of it.
|
|
11
|
+
*
|
|
12
|
+
* So a write_file or edit_file whose path — as written, or once symlinks
|
|
13
|
+
* are followed — passes through a `.git` or `.kiso` directory asks in
|
|
14
|
+
* every asking tier: accept-edits' allow does not cover it, and neither
|
|
15
|
+
* does a saved allow. dontAsk therefore denies it; plan denies it anyway;
|
|
16
|
+
* bypass runs it, as bypass's shell could.
|
|
17
|
+
*/
|
|
18
|
+
import { relative, sep } from "node:path";
|
|
19
|
+
import { resolveShellPath } from "./shell-words.js";
|
|
20
|
+
const PROTECTED = new Set([".git", ".kiso"]);
|
|
21
|
+
const WRITERS = new Set(["write_file", "edit_file"]);
|
|
22
|
+
export function isProtectedWrite(call, workspaceRoot) {
|
|
23
|
+
if (!WRITERS.has(call.name))
|
|
24
|
+
return false;
|
|
25
|
+
const path = call.input.path;
|
|
26
|
+
if (typeof path !== "string")
|
|
27
|
+
return false;
|
|
28
|
+
// case folded: on a case-insensitive disk `.GIT/config` IS .git/config
|
|
29
|
+
if (path.split(/[\\/]/).some((seg) => PROTECTED.has(seg.toLowerCase())))
|
|
30
|
+
return true;
|
|
31
|
+
const { canonical } = resolveShellPath(workspaceRoot, workspaceRoot, path);
|
|
32
|
+
return relative(resolveShellPath(workspaceRoot, workspaceRoot, ".").canonical, canonical)
|
|
33
|
+
.split(sep)
|
|
34
|
+
.some((seg) => PROTECTED.has(seg.toLowerCase()));
|
|
35
|
+
}
|
|
36
|
+
const ABSTAIN = { action: "abstain" };
|
|
37
|
+
/**
|
|
38
|
+
* A saved allow ("yes, don't ask again for <tool>") allows by TOOL, so on
|
|
39
|
+
* its own it would carry every later call of that tool — including the
|
|
40
|
+
* ones kiso has decided must always reach a person. Wrapped wherever it
|
|
41
|
+
* enters the chain, it ABSTAINS for those, and the tier's ask stands.
|
|
42
|
+
* Everything else it decided, it still decides; its live `rules` handle
|
|
43
|
+
* (the grant path mutates it) is the same object.
|
|
44
|
+
*/
|
|
45
|
+
export function guardSavedAllow(ext, neverInherited) {
|
|
46
|
+
if (ext.name !== "dont-ask-again" || ext.approvals === undefined)
|
|
47
|
+
return ext;
|
|
48
|
+
return {
|
|
49
|
+
...ext,
|
|
50
|
+
approvals: ext.approvals.map((p) => ({
|
|
51
|
+
decide: (call, ctx) => (neverInherited(call) ? ABSTAIN : p.decide(call, ctx)),
|
|
52
|
+
})),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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 type { KisoExtension } from "@vincemakes/kiso-runtime";
|
|
34
|
+
export type ReadOnlyVerdict = {
|
|
35
|
+
readonly allow: true;
|
|
36
|
+
} | {
|
|
37
|
+
readonly allow: false;
|
|
38
|
+
readonly why: string;
|
|
39
|
+
};
|
|
40
|
+
/** The table's commands — the gate that every one has a case reads this. */
|
|
41
|
+
export declare const READ_ONLY_COMMANDS: readonly string[];
|
|
42
|
+
/**
|
|
43
|
+
* Is this command line read-only, provably? `workspaceRoot` is the
|
|
44
|
+
* shell's working directory (the shell tool runs every command there);
|
|
45
|
+
* nothing under `protectedRoots` is read unasked.
|
|
46
|
+
*/
|
|
47
|
+
export declare function classifyReadOnly(commandLine: string, workspaceRoot: string, protectedRoots?: readonly string[]): ReadOnlyVerdict;
|
|
48
|
+
/**
|
|
49
|
+
* The chain member. ALLOW or ABSTAIN, never anything else: an allow here
|
|
50
|
+
* outranks the tier's ask (deny > allow > ask) and is recorded as
|
|
51
|
+
* `decidedBy: "read-only-shell"`, so the audit says why a shell call ran
|
|
52
|
+
* unasked; a deny anywhere — plan's, the breaker's, a user extension's —
|
|
53
|
+
* still wins over it.
|
|
54
|
+
*/
|
|
55
|
+
export declare function readOnlyShellExtension(roots: () => {
|
|
56
|
+
readonly workspaceRoot: string;
|
|
57
|
+
readonly excludeRoots: readonly string[];
|
|
58
|
+
}): KisoExtension;
|