specpi 0.27.0 → 0.28.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/CHANGELOG.md +37 -0
- package/README.md +2 -2
- package/SECURITY_MODEL.md +15 -9
- package/THIRD_PARTY.md +1 -2
- package/extensions/jev-advisor/broker.mjs +1 -0
- package/extensions/jev-advisor/client.mjs +17 -27
- package/extensions/jev-advisor/config.mjs +45 -29
- package/extensions/jev-advisor/gate.mjs +14 -0
- package/extensions/jev-advisor/index.ts +255 -105
- package/extensions/jev-advisor/key-source.mjs +252 -0
- package/extensions/jev-advisor/layer.mjs +169 -0
- package/extensions/jev-advisor/questions/guard.mjs +168 -0
- package/extensions/jev-advisor/risk.mjs +442 -0
- package/extensions/jev-advisor/usage.mjs +1 -1
- package/package.json +1 -1
- package/scripts/packages.mjs +56 -0
- package/scripts/specpi.mjs +53 -16
- package/templates/settings.json +1 -2
- package/extensions/jev-advisor/guard.mjs +0 -140
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// Local triage for shell and file calls, before anything is sent anywhere.
|
|
2
|
+
//
|
|
3
|
+
// This is the first half of the native command guard. Jev is the second half and the one that does
|
|
4
|
+
// the analysis; everything here exists to decide what Jev is asked about. Three answers:
|
|
5
|
+
//
|
|
6
|
+
// safe -- settled locally. Jev is never asked, and nothing else will look at this call.
|
|
7
|
+
// dangerous -- blocked locally, with no call and no human.
|
|
8
|
+
// unknown -- Jev is asked.
|
|
9
|
+
//
|
|
10
|
+
// THE THREE ARE NOT SYMMETRIC, and that asymmetry is the whole design:
|
|
11
|
+
//
|
|
12
|
+
// a wrong `safe` is a silent, permanent hole -- the one verdict with no second reader
|
|
13
|
+
// a wrong `unknown` costs one call out of a per-session budget of 208, and Jev decides
|
|
14
|
+
// a wrong `dangerous` blocks real work with no recourse but switching the guard off
|
|
15
|
+
//
|
|
16
|
+
// So the two lists below are maintained under opposite pressures, and the mistake worth naming is
|
|
17
|
+
// treating them as one thing called "the guard" and hardening both the same way.
|
|
18
|
+
//
|
|
19
|
+
// READ_ONLY is a BUDGET mechanism, not a safety one. The guard has 208 calls and each is a few
|
|
20
|
+
// hundred milliseconds awaited on the tool path, so a session that greps and cats a few hundred
|
|
21
|
+
// times would spend the lot and then defer everything for the rest of its life -- a guard that runs
|
|
22
|
+
// out is weaker than one with a fast path. The admission test is therefore NOT "is this harmless".
|
|
23
|
+
// It is: is this binary simple whatever flags it is given, and common enough to be worth it? A
|
|
24
|
+
// binary that can launch a program, write a file or change machine state under any flag is not
|
|
25
|
+
// simple, however harmless its name reads, and Jev parses it. `env` and `fd` launch things,
|
|
26
|
+
// `find` has -delete, `rg` has --pre, `date -s` sets the clock, `hostname` sets the hostname,
|
|
27
|
+
// `file -C` writes a compiled magic file. Every one of those was on this list, and every one was a
|
|
28
|
+
// general bypass that cost almost no budget to keep.
|
|
29
|
+
//
|
|
30
|
+
// CATASTROPHIC is deliberately tiny, and its only real job is the case where Jev is NOT THERE: no
|
|
31
|
+
// key, budget spent, a timeout. Jev catches everything this list would, and weighs intent besides,
|
|
32
|
+
// which a pattern cannot. So resist adding to it. A rule here fires with no model and no human, and
|
|
33
|
+
// a false positive is a blocked session whose only remedy is switching the whole guard off -- and a
|
|
34
|
+
// guard people switch off protects nobody. Ask whether you would stake "this is never legitimate"
|
|
35
|
+
// on it; if not, it belongs in the question set, where being wrong costs one call.
|
|
36
|
+
//
|
|
37
|
+
// And nothing here throws. A classifier that can fail is a classifier that can take a session down,
|
|
38
|
+
// so an unparseable command reads as `unknown` -- ask about it -- rather than as an error.
|
|
39
|
+
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Shell tools, under every name a harness gives them.
|
|
44
|
+
*
|
|
45
|
+
* `scripts/eval-proxy.mjs` already folds `pwsh`, `shell`, `exec`, `exec_command` and `write_stdin`
|
|
46
|
+
* into `bash`, which means those names reach real sessions. A guard that only knew `bash` would be
|
|
47
|
+
* bypassed by spelling, so the alias list lives here rather than being rediscovered later.
|
|
48
|
+
*/
|
|
49
|
+
export const SHELL_TOOLS = Object.freeze([
|
|
50
|
+
"bash",
|
|
51
|
+
"powershell",
|
|
52
|
+
"pwsh",
|
|
53
|
+
"shell",
|
|
54
|
+
"exec",
|
|
55
|
+
"exec_command",
|
|
56
|
+
"write_stdin",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Tools that write a file. The same list `questions/progress.mjs` uses for "the worktree changed",
|
|
61
|
+
* deliberately, because a write the guard cannot see is a write the credential question is never
|
|
62
|
+
* asked about -- and `multi_edit` reaching `~/.ssh/authorized_keys` is exactly that.
|
|
63
|
+
*/
|
|
64
|
+
export const WRITE_TOOLS = Object.freeze(["write", "edit", "multi_edit", "apply_patch", "create_file", "str_replace"]);
|
|
65
|
+
|
|
66
|
+
/** The tool calls this guard looks at. Everything else passes without inspection. */
|
|
67
|
+
export const GATED_TOOLS = Object.freeze([...SHELL_TOOLS, ...WRITE_TOOLS]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Simple binaries: ones that change nothing whatever flags they are given.
|
|
71
|
+
*
|
|
72
|
+
* "Whatever flags" is the whole test, and it is stricter than it sounds. `git` fails it obviously
|
|
73
|
+
* (`push`, `reset`, `clean`), and special-casing `git status` would only add a subcommand allowlist
|
|
74
|
+
* to maintain beside this one. These fail it less obviously, which is what made each of them a
|
|
75
|
+
* bypass worth having:
|
|
76
|
+
*
|
|
77
|
+
* env, fd launch another program outright -- `env rm -rf build`, `fd -x rm`
|
|
78
|
+
* find has -delete and -exec
|
|
79
|
+
* rg has --pre, which runs an arbitrary preprocessor per file
|
|
80
|
+
* sort, uniq name an output file (`sort -o`, `uniq in out`)
|
|
81
|
+
* date -s sets the system clock
|
|
82
|
+
* hostname with an argument, sets the hostname
|
|
83
|
+
* file -C compiles and writes a magic file
|
|
84
|
+
* printenv changes nothing and hands over a secret, which the risk question also asks about
|
|
85
|
+
*
|
|
86
|
+
* None of them reads as dangerous, and none of them was common enough for the fast path to be
|
|
87
|
+
* buying much. That is the trade: a rare binary on this list saves almost no budget and costs a
|
|
88
|
+
* silent hole, so when in doubt it comes off and Jev parses it.
|
|
89
|
+
*
|
|
90
|
+
* Being here is not a claim that a call is harmless. It is a claim that asking about it would spend
|
|
91
|
+
* the budget without learning anything -- which is why the arguments are still checked below, and
|
|
92
|
+
* `cat ~/.ssh/id_rsa` leaves the fast path even though `cat` never belongs anywhere else.
|
|
93
|
+
*/
|
|
94
|
+
const READ_ONLY = new Set([
|
|
95
|
+
"ls",
|
|
96
|
+
"dir",
|
|
97
|
+
"pwd",
|
|
98
|
+
"cd",
|
|
99
|
+
"cat",
|
|
100
|
+
"head",
|
|
101
|
+
"tail",
|
|
102
|
+
"wc",
|
|
103
|
+
"stat",
|
|
104
|
+
"du",
|
|
105
|
+
"df",
|
|
106
|
+
"whoami",
|
|
107
|
+
"uname",
|
|
108
|
+
"echo",
|
|
109
|
+
"printf",
|
|
110
|
+
"which",
|
|
111
|
+
"type",
|
|
112
|
+
"grep",
|
|
113
|
+
"diff",
|
|
114
|
+
"cut",
|
|
115
|
+
"tr",
|
|
116
|
+
"basename",
|
|
117
|
+
"dirname",
|
|
118
|
+
"realpath",
|
|
119
|
+
"true",
|
|
120
|
+
"false",
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Shell syntax that can turn a safe-looking command into any other command: chaining, substitution,
|
|
125
|
+
* redirection, background execution. Their presence disqualifies the fast path entirely rather than
|
|
126
|
+
* being parsed, because parsing a shell correctly is not something a guard should be attempting.
|
|
127
|
+
*/
|
|
128
|
+
const SHELL_CONTROL = /[;&|><`$(){}\n\r]|\|\||&&/u;
|
|
129
|
+
|
|
130
|
+
/** Targets that mean "everything": the filesystem root, or a bare home directory. */
|
|
131
|
+
const ROOT_TARGETS = new Set(["/", "/*", "~", "~/*", "$HOME", "$HOME/*", "%USERPROFILE%", "%USERPROFILE%\\*"]);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A recursive delete aimed at the root or a bare home.
|
|
135
|
+
*
|
|
136
|
+
* Written as a function rather than a regex because the regex it replaces anchored on the end of the
|
|
137
|
+
* string, so it matched `rm -rf /` -- which GNU `rm` refuses on its own -- and missed
|
|
138
|
+
* `rm -rf / --no-preserve-root`, which is the spelling that actually empties the disk. Flag order and
|
|
139
|
+
* position are not something a pattern should be asked to track.
|
|
140
|
+
*/
|
|
141
|
+
function removesEverything(value) {
|
|
142
|
+
const tokens = value.trim().split(/\s+/u);
|
|
143
|
+
const start = tokens[0] === "sudo" || tokens[0] === "doas" ? 1 : 0;
|
|
144
|
+
if (leadingBinary(tokens[start]) !== "rm") {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const args = tokens.slice(start + 1);
|
|
149
|
+
const recursive = args.some(
|
|
150
|
+
(token) => token === "--recursive" || (/^-[a-zA-Z]+$/u.test(token) && /[rR]/u.test(token)),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
recursive && args.some((token) => !token.startsWith("-") && ROOT_TARGETS.has(token.replace(/\/+$/u, "") || "/"))
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Catastrophic and unambiguous. Every entry here is something that destroys data or hands the
|
|
160
|
+
* machine to someone else, with no legitimate reading in an agent session.
|
|
161
|
+
*
|
|
162
|
+
* Kept short on purpose. These fire without a model and without a human, so a false positive here
|
|
163
|
+
* is a blocked session with no recourse but switching the guard off -- and a guard people switch
|
|
164
|
+
* off protects nobody. Everything requiring judgement is `unknown`.
|
|
165
|
+
*/
|
|
166
|
+
const CATASTROPHIC = Object.freeze([
|
|
167
|
+
{
|
|
168
|
+
// `rm -rf /`, `rm -rf / --no-preserve-root`, `rm -r -f ~`. Not `rm -rf ./build`.
|
|
169
|
+
test: removesEverything,
|
|
170
|
+
reason: "recursive delete of the filesystem root or home directory",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
// Writing a raw block device: mkfs, or dd with a device destination.
|
|
174
|
+
pattern: /\b(mkfs(\.\w+)?|fdisk|diskpart)\b|\bdd\b[^\n]*\bof=\/dev\/(sd|nvme|hd|disk)/u,
|
|
175
|
+
reason: "writing directly to a disk device",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
// Piping a downloaded script straight into a shell.
|
|
179
|
+
pattern: /\b(curl|wget|iwr|Invoke-WebRequest)\b[^\n|]*\|\s*(sudo\s+)?(ba|z|k|fi|da)?sh\b/u,
|
|
180
|
+
reason: "executing a downloaded script without inspecting it",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
// Recursive world-writable or ownership changes over a filesystem root.
|
|
184
|
+
pattern:
|
|
185
|
+
/\bchmod\s+(-[a-zA-Z]*\s+)*(-R|--recursive)\s+777\s+\/\s*$|\bchown\s+(-R|--recursive)\s+[^\s]+\s+\/\s*$/u,
|
|
186
|
+
reason: "recursive permission or ownership change over the filesystem root",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
pattern: /:\(\)\s*\{\s*:\|\s*:\s*&\s*\}\s*;\s*:/u,
|
|
190
|
+
reason: "fork bomb",
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
// Overwriting the shell history or a credential store with nothing is how a session hides
|
|
194
|
+
// what it did; blocking it is cheap and it is never a legitimate agent action.
|
|
195
|
+
pattern: /\b(history\s+-c|Clear-History)\b|>\s*~?\/?\.bash_history\b/u,
|
|
196
|
+
reason: "clearing shell history",
|
|
197
|
+
},
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Paths whose contents are credentials, keys or version-control internals.
|
|
202
|
+
*
|
|
203
|
+
* Every pattern is tested against a path already resolved against the working directory and written
|
|
204
|
+
* with forward slashes, so a segment is bounded by `/` or by the end of the string. Matching a
|
|
205
|
+
* directory matters as much as matching a file: `secrets/api.txt` is a secret, and the first version
|
|
206
|
+
* of this list -- which required the secret's name to be the last segment -- called it an ordinary
|
|
207
|
+
* project file.
|
|
208
|
+
*/
|
|
209
|
+
const PROTECTED = Object.freeze([
|
|
210
|
+
/(^|\/)\.env(\.|\/|$)/u,
|
|
211
|
+
/\.env$/u,
|
|
212
|
+
/(^|\/)\.git(\/|$)/u,
|
|
213
|
+
/(^|\/)\.ssh(\/|$)/u,
|
|
214
|
+
/(^|\/)\.aws(\/|$)/u,
|
|
215
|
+
/(^|\/)\.gnupg(\/|$)/u,
|
|
216
|
+
/(^|\/)(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.|$)/u,
|
|
217
|
+
/\.(pem|key|pfx|p12|keystore|jks)$/iu,
|
|
218
|
+
/(^|\/)(credentials?|secrets?)(\.|\/|$)/iu,
|
|
219
|
+
/(^|\/)auth\.json$/u,
|
|
220
|
+
/(^|\/)\.npmrc$/u,
|
|
221
|
+
/(^|\/)\.netrc$/u,
|
|
222
|
+
/(^|\/)\.pi(\/|$)/u,
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
function text(value) {
|
|
226
|
+
return typeof value === "string" ? value : "";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The binary a command starts with, lowercased and stripped of any path. */
|
|
230
|
+
export function leadingBinary(command) {
|
|
231
|
+
const trimmed = text(command).trim();
|
|
232
|
+
if (trimmed.length === 0) {
|
|
233
|
+
return "";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const first = trimmed.split(/\s+/u)[0];
|
|
237
|
+
|
|
238
|
+
return path.basename(first.replace(/\\/gu, "/")).toLowerCase();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Classify a shell command without asking anyone.
|
|
243
|
+
*
|
|
244
|
+
* Returns `{ decision, reason }` where decision is "safe", "dangerous" or "unknown". The order
|
|
245
|
+
* matters: dangerous is checked before safe, so a catastrophic command hidden behind a read-only
|
|
246
|
+
* binary cannot pass on the fast path.
|
|
247
|
+
*/
|
|
248
|
+
export function classifyCommand(command, cwd = process.cwd()) {
|
|
249
|
+
const value = text(command).trim();
|
|
250
|
+
if (value.length === 0) {
|
|
251
|
+
return { decision: "unknown", reason: "empty command" };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const rule of CATASTROPHIC) {
|
|
255
|
+
if (rule.test ? rule.test(value) : rule.pattern.test(value)) {
|
|
256
|
+
return { decision: "dangerous", reason: rule.reason };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A command with shell control characters is never taken on the fast path, however harmless its
|
|
261
|
+
// first word looks: `ls; rm -rf ~` begins with `ls`.
|
|
262
|
+
if (SHELL_CONTROL.test(value)) {
|
|
263
|
+
return { decision: "unknown", reason: "shell control characters" };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (READ_ONLY.has(leadingBinary(value))) {
|
|
267
|
+
// Read-only is a statement about what the binary does, not about what it is pointed at, and
|
|
268
|
+
// the question this guard asks names exfiltration beside destruction. `cat ~/.ssh/id_rsa`
|
|
269
|
+
// changes nothing and hands over a private key, so the fast path has to look at the
|
|
270
|
+
// arguments too or half of the question it asks is unreachable for the commands that answer
|
|
271
|
+
// it. A protected argument costs one question; every other read stays free.
|
|
272
|
+
return commandArguments(value).some((argument) => protectedPath(argument, cwd))
|
|
273
|
+
? { decision: "unknown", reason: "reads a protected path" }
|
|
274
|
+
: { decision: "safe", reason: "read-only command" };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { decision: "unknown", reason: "not a known read-only command" };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The non-flag arguments of a command, unquoted.
|
|
282
|
+
*
|
|
283
|
+
* Deliberately crude: this decides whether to ask a question, never whether to block, so a token it
|
|
284
|
+
* splits wrongly costs a question and nothing else.
|
|
285
|
+
*/
|
|
286
|
+
function commandArguments(value) {
|
|
287
|
+
return value
|
|
288
|
+
.split(/\s+/u)
|
|
289
|
+
.slice(1)
|
|
290
|
+
.filter((token) => !token.startsWith("-"))
|
|
291
|
+
.map((token) => token.replace(/^["']|["']$/gu, ""))
|
|
292
|
+
.filter((token) => token.length > 0);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Whether a write or edit target holds credentials or version-control internals.
|
|
297
|
+
*
|
|
298
|
+
* Relative targets are resolved against the working directory first, so `../../.ssh/config` is seen
|
|
299
|
+
* for what it is rather than for what it is spelled as.
|
|
300
|
+
*/
|
|
301
|
+
export function protectedPath(target, cwd = process.cwd()) {
|
|
302
|
+
const value = text(target).trim();
|
|
303
|
+
if (value.length === 0) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let resolved;
|
|
308
|
+
try {
|
|
309
|
+
resolved = path.resolve(cwd, value);
|
|
310
|
+
} catch {
|
|
311
|
+
resolved = value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const normalized = resolved.replaceAll("\\", "/");
|
|
315
|
+
|
|
316
|
+
return PROTECTED.some((pattern) => pattern.test(normalized));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Keys a harness uses for "the file this call writes", across the tools in `WRITE_TOOLS`. */
|
|
320
|
+
const TARGET_KEYS = Object.freeze(["path", "file_path", "filePath", "file", "target", "notebook_path"]);
|
|
321
|
+
|
|
322
|
+
/** Nested arrays of edits, each element carrying a target of its own. */
|
|
323
|
+
const TARGET_LISTS = Object.freeze(["edits", "files", "changes", "operations"]);
|
|
324
|
+
|
|
325
|
+
/** Header forms that name a file inside a patch body. */
|
|
326
|
+
const PATCH_TARGET = /^(?:\*\*\* (?:Add|Update|Delete) File: |--- (?:a\/)?|\+\+\+ (?:b\/)?)(.+)$/gmu;
|
|
327
|
+
|
|
328
|
+
function pushTarget(into, value) {
|
|
329
|
+
if (typeof value === "string" && value.trim().length > 0 && into.length < 64) {
|
|
330
|
+
into.push(value.trim());
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Every file a tool call names, from a tool input whose shape this module does not control.
|
|
336
|
+
*
|
|
337
|
+
* `write` and `edit` carry one `path`; `multi_edit` carries a list; `apply_patch` carries the paths
|
|
338
|
+
* inside a patch body and nowhere else. Reading only the first of those is how a write to a
|
|
339
|
+
* credential file reaches disk without the guard ever seeing a target -- so this reads all three,
|
|
340
|
+
* and returning nothing is itself a meaningful answer to `classifyCall`.
|
|
341
|
+
*/
|
|
342
|
+
export function callTargets(input) {
|
|
343
|
+
const found = [];
|
|
344
|
+
if (input === null || typeof input !== "object") {
|
|
345
|
+
return found;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
for (const key of TARGET_KEYS) {
|
|
349
|
+
pushTarget(found, input[key]);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const key of TARGET_LISTS) {
|
|
353
|
+
const list = input[key];
|
|
354
|
+
if (!Array.isArray(list)) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const item of list) {
|
|
359
|
+
if (typeof item === "string") {
|
|
360
|
+
pushTarget(found, item);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (item !== null && typeof item === "object") {
|
|
365
|
+
for (const key2 of TARGET_KEYS) {
|
|
366
|
+
pushTarget(found, item[key2]);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const patch = typeof input.patch === "string" ? input.patch : typeof input.diff === "string" ? input.diff : "";
|
|
373
|
+
if (patch.length > 0) {
|
|
374
|
+
// Bounded: a patch is arbitrary size and this runs on every call.
|
|
375
|
+
for (const match of patch.slice(0, 20_000).matchAll(PATCH_TARGET)) {
|
|
376
|
+
pushTarget(found, match[1].replace(/\t.*$/u, ""));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return found;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Keys a harness uses for "the text this call runs", across the tools in `SHELL_TOOLS`. */
|
|
384
|
+
const COMMAND_KEYS = Object.freeze(["command", "cmd", "script", "input", "text", "data", "stdin", "line"]);
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The text a shell call will run, from a tool input whose shape this module does not control.
|
|
388
|
+
*
|
|
389
|
+
* `bash` carries `command`; `write_stdin` carries the text it types into a live shell under some
|
|
390
|
+
* other name entirely. Reading `command` alone meant every `write_stdin` was classified as an empty
|
|
391
|
+
* command -- spending a guard call on the empty string while the `rm -rf ~` being typed went
|
|
392
|
+
* unexamined -- so the tool most worth reading was the one read as blank.
|
|
393
|
+
*/
|
|
394
|
+
export function commandText(input) {
|
|
395
|
+
if (typeof input === "string") {
|
|
396
|
+
return input;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (input === null || typeof input !== "object") {
|
|
400
|
+
return "";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const key of COMMAND_KEYS) {
|
|
404
|
+
if (typeof input[key] === "string" && input[key].trim().length > 0) {
|
|
405
|
+
return input[key];
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return "";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* What a gated tool call is, before Jev is involved.
|
|
414
|
+
*
|
|
415
|
+
* `write` and its siblings are only interesting when they target something protected: an ordinary
|
|
416
|
+
* source file being edited is the entire point of the agent, and asking about each one would spend a
|
|
417
|
+
* session's budget on the first directory it refactored.
|
|
418
|
+
*
|
|
419
|
+
* A write whose target could not be read is `unknown` rather than `safe`. That costs a question on a
|
|
420
|
+
* tool shape this module does not recognise, which is the right way round: the alternative is a
|
|
421
|
+
* silent hole that appears the moment a harness renames a field.
|
|
422
|
+
*/
|
|
423
|
+
export function classifyCall({ tool, command, target, targets, cwd }) {
|
|
424
|
+
if (!GATED_TOOLS.includes(tool)) {
|
|
425
|
+
return { decision: "safe", reason: "tool is not gated" };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (SHELL_TOOLS.includes(tool)) {
|
|
429
|
+
return classifyCommand(command, cwd);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const all = [...(Array.isArray(targets) ? targets : []), ...(typeof target === "string" ? [target] : [])].filter(
|
|
433
|
+
(item) => typeof item === "string" && item.trim().length > 0,
|
|
434
|
+
);
|
|
435
|
+
if (all.length === 0) {
|
|
436
|
+
return { decision: "unknown", reason: "write target could not be read" };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return all.some((item) => protectedPath(item, cwd))
|
|
440
|
+
? { decision: "unknown", reason: "writes to a protected path" }
|
|
441
|
+
: { decision: "safe", reason: "ordinary project file" };
|
|
442
|
+
}
|
|
@@ -80,7 +80,7 @@ export function readUsage() {
|
|
|
80
80
|
try {
|
|
81
81
|
const file = usagePath();
|
|
82
82
|
// regularFile refuses links, hard-linked files and anything over 4 KiB. A counts file for
|
|
83
|
-
//
|
|
83
|
+
// eight systems is a few hundred bytes, so the size check is a real one here.
|
|
84
84
|
if (!regularFile(file, "Jev usage")) {
|
|
85
85
|
return undefined;
|
|
86
86
|
}
|
package/package.json
CHANGED
package/scripts/packages.mjs
CHANGED
|
@@ -48,6 +48,54 @@ export function runBrowserQA(agentDir, command) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Packages a past SpecPi version pinned and this one no longer does.
|
|
53
|
+
*
|
|
54
|
+
* Dropping an entry from `templates/settings.json` stops new installs getting it and does nothing at
|
|
55
|
+
* all to a machine that already has it: the entry stays in `settings.json` and Pi keeps loading it.
|
|
56
|
+
* That is tolerable for a package that merely stopped being useful, and not tolerable for
|
|
57
|
+
* `specpi-jev-guard`, which fails closed -- an install left holding it after SpecPi deleted both the
|
|
58
|
+
* code that kept it inert and the `/jev guard off` command that could disarm it would block every
|
|
59
|
+
* shell call the moment its key or its endpoint went away, with nothing left to turn it off.
|
|
60
|
+
*
|
|
61
|
+
* So retirement is explicit, and it removes the entry rather than waiting for the restore path to.
|
|
62
|
+
*/
|
|
63
|
+
export const retiredPackages = Object.freeze(["npm:specpi-jev-guard"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Drop retired entries from a settings object, in place, returning what was removed.
|
|
67
|
+
*
|
|
68
|
+
* Every entry goes, whatever shape it has. That is a deliberate exception to the ownership rule the
|
|
69
|
+
* restore path applies elsewhere -- "a user-modified entry is theirs" -- and the exception is the
|
|
70
|
+
* reason the list is not open to additions. Preserving a modified `specpi-jev-guard` entry preserved
|
|
71
|
+
* a fail-closed gate that this release removed the controls for: the code that rewrote its global
|
|
72
|
+
* configuration to `enabled: false` at every session start is gone, and so is `/jev guard off`, so a
|
|
73
|
+
* preserved entry is a gate that refuses every shell call the moment its key or endpoint goes away,
|
|
74
|
+
* with nothing left to turn it off. Filters are worth less than that.
|
|
75
|
+
*
|
|
76
|
+
* The removed entry is returned verbatim so the caller can print it back, and the write it belongs
|
|
77
|
+
* to is inside the installer's transaction and backed up with everything else. Version is not
|
|
78
|
+
* consulted: the reason for retirement is the package.
|
|
79
|
+
*/
|
|
80
|
+
export function removeRetiredPackages(settings) {
|
|
81
|
+
if (!Array.isArray(settings?.packages)) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const removed = [];
|
|
86
|
+
settings.packages = settings.packages.filter((entry) => {
|
|
87
|
+
if (!retiredPackages.includes(packageIdentity(entry))) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
removed.push(packageSource(entry));
|
|
92
|
+
|
|
93
|
+
return false;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return removed;
|
|
97
|
+
}
|
|
98
|
+
|
|
51
99
|
export function packageChanges(before, after) {
|
|
52
100
|
return basePackages.map((source) => {
|
|
53
101
|
const identity = packageIdentity(source);
|
|
@@ -134,6 +182,14 @@ export function installBasePackages(agentDir) {
|
|
|
134
182
|
|
|
135
183
|
export function checkBasePackages(agentDir, settings) {
|
|
136
184
|
const errors = [];
|
|
185
|
+
for (const entry of Array.isArray(settings.packages) ? settings.packages : []) {
|
|
186
|
+
if (retiredPackages.includes(packageIdentity(entry))) {
|
|
187
|
+
errors.push(
|
|
188
|
+
`Retired base package still configured: ${packageSource(entry)}. Run specpi update to unpin it.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
137
193
|
for (const source of basePackages) {
|
|
138
194
|
const identity = packageIdentity(source);
|
|
139
195
|
const entry =
|
package/scripts/specpi.mjs
CHANGED
|
@@ -22,8 +22,14 @@ import {
|
|
|
22
22
|
import { validateCapabilityRegistry } from "../extensions/tool-wishlist/registry.mjs";
|
|
23
23
|
import { runValidator } from "../extensions/tool-wishlist/validators.mjs";
|
|
24
24
|
import { acquireSpecPiLock } from "./lock.mjs";
|
|
25
|
-
import {
|
|
26
|
-
|
|
25
|
+
import {
|
|
26
|
+
basePackages,
|
|
27
|
+
checkBasePackages,
|
|
28
|
+
installBasePackages,
|
|
29
|
+
packageChanges,
|
|
30
|
+
removeRetiredPackages,
|
|
31
|
+
runBrowserQA,
|
|
32
|
+
} from "./packages.mjs";
|
|
27
33
|
|
|
28
34
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
35
|
const VERSION = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
|
|
@@ -55,9 +61,12 @@ const resourcePaths = [
|
|
|
55
61
|
"extensions/jev-advisor/sanitize.mjs",
|
|
56
62
|
"extensions/jev-advisor/usage.mjs",
|
|
57
63
|
"extensions/jev-advisor/client.mjs",
|
|
64
|
+
"extensions/jev-advisor/key-source.mjs",
|
|
65
|
+
"extensions/jev-advisor/layer.mjs",
|
|
66
|
+
"extensions/jev-advisor/risk.mjs",
|
|
67
|
+
"extensions/jev-advisor/questions/guard.mjs",
|
|
58
68
|
"extensions/jev-advisor/broker.mjs",
|
|
59
69
|
"extensions/jev-advisor/gate.mjs",
|
|
60
|
-
"extensions/jev-advisor/guard.mjs",
|
|
61
70
|
"extensions/jev-advisor/questions/retention.mjs",
|
|
62
71
|
"extensions/jev-advisor/questions/compaction.mjs",
|
|
63
72
|
"extensions/jev-advisor/questions/gap.mjs",
|
|
@@ -78,7 +87,7 @@ Usage:
|
|
|
78
87
|
specpi doctor
|
|
79
88
|
specpi uninstall [--yes]
|
|
80
89
|
|
|
81
|
-
Installs /scope, the harness improvement loop, and
|
|
90
|
+
Installs /scope, the harness improvement loop, and seven pinned packages.
|
|
82
91
|
The base is tested with Pi 0.84.4. Run specpi plan to see package versions.
|
|
83
92
|
--skip-package-install installs only the core, or preserves an existing base on update.
|
|
84
93
|
--skip-browser-install skips Chromium setup, not package acquisition or doctor checks.
|
|
@@ -251,6 +260,32 @@ function restoreLegacySettings(manifest, warnings) {
|
|
|
251
260
|
writeJson(settingsPath, settings, existingMode(settingsPath, 0o600));
|
|
252
261
|
}
|
|
253
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Unpin a package a past version installed and this one has retired.
|
|
265
|
+
*
|
|
266
|
+
* Deliberately outside the `--skip-packages` guard. Skipping package acquisition means not
|
|
267
|
+
* downloading or re-pinning anything, and it has never meant leaving an entry SpecPi itself wrote
|
|
268
|
+
* pointing at code SpecPi has since removed the controls for -- `specpi-jev-guard` is fail-closed,
|
|
269
|
+
* so the machine that skipped packages is exactly the machine that would keep it armed forever.
|
|
270
|
+
*
|
|
271
|
+
* `settingsPath` is in the transaction's watched set for every non-uninstall operation, so this
|
|
272
|
+
* write is snapshotted, backed up and rolled back with everything else.
|
|
273
|
+
*/
|
|
274
|
+
function retireBasePackages(warnings) {
|
|
275
|
+
const settings = readJson(settingsPath, {});
|
|
276
|
+
const removed = removeRetiredPackages(settings);
|
|
277
|
+
if (removed.length === 0) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
writeJson(settingsPath, settings, existingMode(settingsPath, 0o600));
|
|
282
|
+
for (const source of removed) {
|
|
283
|
+
warnings.push(
|
|
284
|
+
`Unpinned retired package: ${source}. Its downloaded files stay in the agent npm directory, Pi no longer loads it, and any settings of your own on that entry are in this run's backup.`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
254
289
|
function removeLegacyShell(manifest) {
|
|
255
290
|
if (manifest?.shellRc && fs.existsSync(manifest.shellRc)) {
|
|
256
291
|
const result = removeManagedBlock(fs.readFileSync(manifest.shellRc, "utf8"), SHELL_START, SHELL_END);
|
|
@@ -305,7 +340,16 @@ async function mutate(options, operation) {
|
|
|
305
340
|
...files.map(([, target]) => target),
|
|
306
341
|
...Object.keys(previous?.files || {}),
|
|
307
342
|
];
|
|
308
|
-
|
|
343
|
+
// Watched whenever anything in this run can write it. `retireBasePackages` runs on every
|
|
344
|
+
// non-uninstall operation, including under `--skip-package-install`, so the narrower
|
|
345
|
+
// condition that used to guard this left that write outside the snapshot -- unbacked up, and
|
|
346
|
+
// not rolled back by a later failure in the same transaction.
|
|
347
|
+
if (
|
|
348
|
+
operation !== "uninstall" ||
|
|
349
|
+
!options.skipPackages ||
|
|
350
|
+
previous?.settingsChanges?.length ||
|
|
351
|
+
previous?.packageChanges?.length
|
|
352
|
+
) {
|
|
309
353
|
watched.push(settingsPath);
|
|
310
354
|
}
|
|
311
355
|
|
|
@@ -326,6 +370,10 @@ async function mutate(options, operation) {
|
|
|
326
370
|
const warnings = [];
|
|
327
371
|
const preserveBase = operation !== "uninstall" && options.skipPackages && previous?.basePackages?.length;
|
|
328
372
|
restoreLegacySettings(preserveBase ? { ...previous, packageChanges: [] } : previous, warnings);
|
|
373
|
+
if (operation !== "uninstall") {
|
|
374
|
+
retireBasePackages(warnings);
|
|
375
|
+
}
|
|
376
|
+
|
|
329
377
|
removeLegacyShell(previous);
|
|
330
378
|
let packageState = preserveBase
|
|
331
379
|
? {
|
|
@@ -354,17 +402,6 @@ async function mutate(options, operation) {
|
|
|
354
402
|
runBrowserQA(agentDir, "setup");
|
|
355
403
|
}
|
|
356
404
|
|
|
357
|
-
// specpi-jev-guard's own default is enabled:true, so a freshly installed base would
|
|
358
|
-
// start gating shell and file calls through a third-party service before anyone asked
|
|
359
|
-
// for it — and with no key it fails closed, which means a first install that refuses to
|
|
360
|
-
// run commands. The advisor rewrites this at every session start, but that only helps
|
|
361
|
-
// if the advisor loads, so the inert posture is established here at install time too.
|
|
362
|
-
try {
|
|
363
|
-
applyGuardConfig(false);
|
|
364
|
-
} catch (error) {
|
|
365
|
-
console.log(`SpecPi: could not write the Jev guard's inert settings: ${error.message}`);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
405
|
packageState = {
|
|
369
406
|
basePackages,
|
|
370
407
|
packagesKeyBeforeExists: Object.hasOwn(before, "packages"),
|
package/templates/settings.json
CHANGED