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.
@@ -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
- // seven systems is a few hundred bytes, so the size check is a real one here.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Scope control and a human-selected harness improvement loop for Pi",
5
5
  "author": "Tanner Middleton",
6
6
  "repository": {
@@ -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 =
@@ -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 { basePackages, checkBasePackages, installBasePackages, packageChanges, runBrowserQA } from "./packages.mjs";
26
- import { applyConfig as applyGuardConfig } from "../extensions/jev-advisor/guard.mjs";
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 six pinned packages.
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
- if (!options.skipPackages || previous?.settingsChanges?.length || previous?.packageChanges?.length) {
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"),
@@ -6,7 +6,6 @@
6
6
  "npm:specpi-experiments@0.1.0",
7
7
  "npm:pi-goal-x@0.31.2",
8
8
  "npm:@sreetej510/pi-usage@0.10.0",
9
- "npm:@gotgenes/pi-permission-system@32.0.2",
10
- "npm:specpi-jev-guard@0.1.0"
9
+ "npm:@gotgenes/pi-permission-system@32.0.2"
11
10
  ]
12
11
  }