specpi 0.28.0 → 0.29.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.
@@ -2,12 +2,11 @@ import fs from "node:fs";
2
2
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { SYSTEM_NAMES, loadSettings, saveSettings, settingsPath } from "./config.mjs";
4
4
  import { keySources } from "./key-source.mjs";
5
- import { applyLayer, guardWarning, layerScopeLine, layerToPersist, startupToPersist } from "./layer.mjs";
5
+ import { applyLayer, layerScopeLine, layerToPersist, startupToPersist } from "./layer.mjs";
6
6
  import { consentPath, granted, revokeConsent } from "./consent.mjs";
7
7
  import { createBroker } from "./broker.mjs";
8
8
  import { ledgerPath, read as readLedger } from "./ledger.mjs";
9
9
  import { usagePath } from "./usage.mjs";
10
- import { GATED_TOOLS, SHELL_TOOLS, callTargets, classifyCall, commandText } from "./risk.mjs";
11
10
  import * as retention from "./questions/retention.mjs";
12
11
  import * as compaction from "./questions/compaction.mjs";
13
12
  import * as gap from "./questions/gap.mjs";
@@ -15,42 +14,13 @@ import * as sources from "./questions/sources.mjs";
15
14
  import * as progress from "./questions/progress.mjs";
16
15
  import * as untrusted from "./questions/untrusted.mjs";
17
16
  import * as capabilities from "./questions/capabilities.mjs";
18
- import * as guard from "./questions/guard.mjs";
19
17
 
20
18
  const MAX_RECENT = 8;
21
19
 
22
- /** One line of a call, for a notification or a block reason. Never a digest; never sent anywhere. */
23
- function short(value: string, limit: number) {
24
- const text = String(value ?? "")
25
- .replace(/\s+/gu, " ")
26
- .trim();
27
-
28
- return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
29
- }
30
-
31
20
  function safeMessage(error: unknown) {
32
21
  return String((error as any)?.message ?? error ?? "unknown error").slice(0, 200);
33
22
  }
34
23
 
35
- /**
36
- * Tell the person something, and never let the telling change what happens.
37
- *
38
- * `ctx.ui.notify` reaches the host over RPC and can throw -- a disconnected client, a torn-down UI,
39
- * a host without the method. Called inline inside the guard's fail-open catch, one such throw
40
- * unwound a decided refusal into an allow, so the announcement is isolated from the decision here.
41
- */
42
- function announce(ctx: ExtensionContext, message: string) {
43
- if (!ctx.hasUI) {
44
- return;
45
- }
46
-
47
- try {
48
- ctx.ui.notify(message, "error");
49
- } catch {
50
- // A failed notification is not a reason to run a command, or not to.
51
- }
52
- }
53
-
54
24
  export default function jevAdvisor(pi: ExtensionAPI) {
55
25
  // Session switches live in memory. A session toggle must never write the startup preference,
56
26
  // so the saved file is read once per session and only /jev startup ever writes it.
@@ -254,111 +224,6 @@ export default function jevAdvisor(pi: ExtensionAPI) {
254
224
  }
255
225
  });
256
226
 
257
- // System 8: the command guard, before a shell or file call runs.
258
- //
259
- // Fail open at every step. Local triage settles most calls for nothing; anything else is asked
260
- // about, and a call is blocked only on a confident verdict that the request does not account
261
- // for. Every other outcome -- no key, no budget, a timeout, an unconfident answer, no human to
262
- // ask -- returns the call to @gotgenes/pi-permission-system, which decides it exactly as it did
263
- // before this layer existed. The package this replaced was fail-closed, so an outage or a
264
- // missing key stopped work; that is the single behaviour most worth not reproducing.
265
- pi.on("tool_call", async (event: any, ctx: ExtensionContext) => {
266
- if (!enabled("guard") || !GATED_TOOLS.includes(event?.toolName)) {
267
- return undefined;
268
- }
269
-
270
- const shell = SHELL_TOOLS.includes(event.toolName);
271
- // Not `input.command`: `write_stdin` types into a live shell under another name, so reading
272
- // one key classified every such call as the empty string -- spending a guard call on nothing
273
- // while the text actually being run went unexamined.
274
- const command = shell ? commandText(event?.input) : "";
275
- // Every file the call names, because `multi_edit` and `apply_patch` do not carry one `path`
276
- // and a target the guard cannot see is a target it never asks the credential question about.
277
- const targets = callTargets(event?.input);
278
- const local = classifyCall({ tool: event.toolName, command, targets, cwd: ctx.cwd });
279
- const subject = shell
280
- ? command || "(command unknown)"
281
- : `${event.toolName} ${targets.join(", ") || "(target unknown)"}`;
282
-
283
- // Built once, and nothing inside it may throw. A refusal that has already been decided must
284
- // reach the harness: an exception raised while announcing it would unwind into the fail-open
285
- // catch below and turn the layer's only blocking action into an allow.
286
- const refuse = (reason: string) => {
287
- announce(ctx, `Jev guard blocked ${event.toolName}: ${reason}.`);
288
-
289
- return { block: true, reason: `Jev guard: ${reason}. Call: ${short(subject, 160)}` };
290
- };
291
-
292
- if (local.decision === "safe") {
293
- return undefined;
294
- }
295
-
296
- if (local.decision === "dangerous") {
297
- // Catastrophic and unambiguous, so it needs neither a network call nor a human. This is
298
- // the one path that blocks without asking Jev, which is why its rule list is tiny.
299
- return refuse(local.reason);
300
- }
301
-
302
- let verdict;
303
- try {
304
- const result = await broker.request({
305
- system: "guard",
306
- state: guard.buildInput({
307
- tool: event.toolName,
308
- subject,
309
- protectedTarget: local.reason === "writes to a protected path",
310
- objective,
311
- recent,
312
- cwd: ctx.cwd,
313
- }),
314
- questions: guard.questions({ protected: local.reason === "writes to a protected path" }),
315
- ctx,
316
- root: ctx.cwd,
317
- decide: (answers: any) => {
318
- const verdict = guard.decide(answers, { hasUI: ctx.hasUI });
319
-
320
- return { applied: verdict.action !== "defer", decision: verdict };
321
- },
322
- });
323
- if (!result.ok) {
324
- return undefined;
325
- }
326
-
327
- verdict = result.decision;
328
- } catch {
329
- // An advisor must never be the reason a tool call fails. Anything unexpected while
330
- // asking hands the call back to the permission system unchanged. The catch ends here, so
331
- // that everything the verdict then decides is outside it.
332
- return undefined;
333
- }
334
-
335
- if (verdict.action === "block") {
336
- return refuse(verdict.reason);
337
- }
338
-
339
- if (verdict.action === "ask" && ctx.hasUI) {
340
- let choice;
341
- try {
342
- choice = await ctx.ui.select({
343
- title: "Jev guard",
344
- message: `This looks ${verdict.reason}: ${short(subject, 300)}`,
345
- options: [guard.CHOICES.run, guard.CHOICES.block],
346
- });
347
- } catch {
348
- // The one failure in this file that does not fail open, and deliberately. Reaching
349
- // here means the verdict already said this call needs a person's approval; a host
350
- // that cannot ask has not obtained it, and an unanswerable question resolved as yes
351
- // is the failure mode a confirmation dialog exists to rule out.
352
- return refuse("this needs your approval and you could not be asked");
353
- }
354
-
355
- // `guard.approved` owns the rule; see it for why every non-answer is a refusal.
356
- return guard.approved(choice) ? undefined : refuse("not approved by you");
357
- }
358
-
359
- return undefined;
360
- });
361
-
362
227
  // System 1: condense a spent tool result before it is appended. Doing this after the fact would
363
228
  // rewrite a cached prefix; on arrival it never touches one.
364
229
  pi.on("tool_result", async (event: any, ctx: ExtensionContext) => {
@@ -379,11 +244,10 @@ export default function jevAdvisor(pi: ExtensionAPI) {
379
244
  }
380
245
  }
381
246
 
382
- // Every result, not only the ones retention asked about. This history is what lets the
383
- // command guard tell a cleanup step from a first move, and it was written in one place --
384
- // inside retention's success path -- so a session running the guard with retention off, or
385
- // with retention's budget spent, evaluated the block rule against an empty history for its
386
- // whole length while the question set said history was what the intent answer weighed.
247
+ // Every result, not only the ones retention asked about. It was written in one place --
248
+ // inside retention's success path -- so a session with retention off, or with retention's
249
+ // budget spent, handed every other system an empty history for its whole length while
250
+ // their question sets said history was what they weighed.
387
251
  recent.push({ tool: String(event?.toolName ?? ""), outcome: event?.isError === true ? "error" : "ok" });
388
252
  if (recent.length > MAX_RECENT) {
389
253
  recent.shift();
@@ -868,16 +732,11 @@ export default function jevAdvisor(pi: ExtensionAPI) {
868
732
  }
869
733
  })()
870
734
  : undefined;
871
- // The same disclosure `/jev on` makes, on the path that arms the guard by name.
872
- // Learning from a blocked call that calls can be blocked is the outcome that
873
- // rule exists to prevent, and which command did the arming does not change it.
874
- const armedGuard = action === "enable" && names.includes("guard") && settings.master;
875
735
  ctx.ui.notify(
876
736
  `${action === "enable" ? "Enabled" : "Disabled"}: ${names.join(", ")}.` +
877
737
  `${kept ? " Remembered for new sessions." : " This session only."}` +
878
738
  `${emptied ? " That was the last system, so the layer was switched off; it would otherwise run and do nothing." : ""}` +
879
- `${!emptied && !settings.master ? " The layer is still off; run /jev on." : ""}` +
880
- `${armedGuard ? `\n${guardWarning()}` : ""}`,
739
+ `${!emptied && !settings.master ? " The layer is still off; run /jev on." : ""}`,
881
740
  "info",
882
741
  );
883
742
 
@@ -916,8 +775,7 @@ export default function jevAdvisor(pi: ExtensionAPI) {
916
775
  const enabled = SYSTEM_NAMES.filter((name) => saved.systems[name]);
917
776
  ctx.ui.notify(
918
777
  saved.startup && saved.master
919
- ? `New Pi sessions will start with the Jev layer on, with ${enabled.length} of ${SYSTEM_NAMES.length} systems: ${enabled.join(", ")}. This session is unchanged; run /jev on to switch it on now.` +
920
- `${saved.systems.guard ? `\n${guardWarning()}` : ""}`
778
+ ? `New Pi sessions will start with the Jev layer on, with ${enabled.length} of ${SYSTEM_NAMES.length} systems: ${enabled.join(", ")}. This session is unchanged; run /jev on to switch it on now.`
921
779
  : "New Pi sessions will start with the Jev layer off. This session is unchanged.",
922
780
  "info",
923
781
  );
@@ -162,7 +162,7 @@ function environmentOnly() {
162
162
 
163
163
  /**
164
164
  * Jev is reached through OpenRouter by default: that is where it is published, it is what
165
- * the command guard uses, and an OpenRouter key (`sk-or-...`) is rejected by the direct
165
+ * specpi-jev-guard uses, and an OpenRouter key (`sk-or-...`) is rejected by the direct
166
166
  * TypeSafe API with a bare 401. `JEV_BACKEND=typesafe` selects the direct API for a TypeSafe key.
167
167
  *
168
168
  * It lives here rather than in client.mjs because everything below has to bind it. A parameter
@@ -18,8 +18,9 @@ import { SYSTEM_NAMES } from "./config.mjs";
18
18
  * Decide what a layer switch means, and report it honestly.
19
19
  *
20
20
  * `state` is `{ settings }` and is never mutated -- the next state comes back in the result. `deps`
21
- * supplies the outside world, which is now only `keySources`: the command guard used to need a
22
- * package's global configuration file arbitrated here, and as the eighth system it needs nothing.
21
+ * supplies the outside world, which is only `keySources`. The command guard is not arbitrated here
22
+ * and is not arbitrated anywhere in this extension: it is a separate package with its own switch,
23
+ * and nothing SpecPi does at session time touches it.
23
24
  *
24
25
  * Scope belongs to `layerScopeLine`, not here, so this takes `{ on }` and nothing else. It used to
25
26
  * be handed `sessionOnly` and `interactive` as well and read neither, which reads as a decision
@@ -39,34 +40,16 @@ export function applyLayer({ on }, state, deps) {
39
40
  return { settings, lines: onLines(state.settings, settings, active) };
40
41
  }
41
42
 
42
- /**
43
- * What arming the command guard means, in the words every path that arms it has to use.
44
- *
45
- * Seven of the eight systems only ever add advice; this one can refuse a tool call. Saying so is a
46
- * rule rather than a nicety, and it lives here because it was a rule `/jev on` honoured alone while
47
- * `/jev enable guard` and `/jev startup on` armed the same system in silence.
48
- */
49
- export function guardWarning() {
50
- return (
51
- "guard is the only system that can refuse a tool call: it blocks a call it reads as " +
52
- "destructive and not what was asked for, asks you about the uncertain ones, and hands " +
53
- "everything else to the permission system unchanged. Turn it off with /jev disable guard."
54
- );
55
- }
56
-
57
43
  /**
58
44
  * Enabling the layer enables its systems, because a layer with none on runs and does nothing -- the
59
- * state people kept arriving at, with the notification cheerfully reporting "0 of 8".
45
+ * state people kept arriving at, with the notification cheerfully reporting "0 of 7".
60
46
  *
61
47
  * Only when none are on. Someone deliberately running retention alone has expressed a preference,
62
- * and `/jev off` then `/jev on` must not hand back the seven they turned off.
48
+ * and `/jev off` then `/jev on` must not hand back the six they turned off.
63
49
  *
64
- * The command guard is one of the eight, and it is the only system that can refuse a tool call. That
65
- * is a deliberate answer to a question this file and `config.mjs` resolve differently on purpose:
66
- * `/jev on` is a person acting now, so it arms everything and `onLines` says in as many words that
67
- * one of them can refuse a command; `migrateToThree` runs without anyone present, so it arms
68
- * nothing new. Silence is the difference -- an unattended migration must not change what a session
69
- * is allowed to run, and an explicit command that reports what it did may.
50
+ * Every system it arms only ever adds advice, which is what makes arming all of them a reasonable
51
+ * default and why this switch needs no warning attached. Nothing the layer can turn on is able to
52
+ * refuse a tool call; the one component that can is a separate package with a separate switch.
70
53
  */
71
54
  export function enableSystems(settings) {
72
55
  const chosen = SYSTEM_NAMES.filter((name) => settings.systems[name]);
@@ -86,12 +69,6 @@ function onLines(before, after, activeSource) {
86
69
  lines.push("No system was enabled, so all of them were. Turn any back off with /jev disable <system>.");
87
70
  }
88
71
 
89
- // Said out loud every time, because seven of the eight only ever add advice and this one can
90
- // take a command away. Nobody should discover that from a blocked call.
91
- if (after.systems.guard) {
92
- lines.push(guardWarning());
93
- }
94
-
95
72
  lines.push(keyLine(activeSource));
96
73
 
97
74
  return lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Scope control and a human-selected harness improvement loop for Pi",
5
5
  "author": "Tanner Middleton",
6
6
  "repository": {
@@ -28,6 +28,7 @@
28
28
  "scripts/lib.mjs",
29
29
  "scripts/lock.mjs",
30
30
  "scripts/packages.mjs",
31
+ "scripts/jev-guard.mjs",
31
32
  "templates",
32
33
  "extensions",
33
34
  "skills",
@@ -0,0 +1,154 @@
1
+ // Install-time seam for specpi-jev-guard, the Jev-scored command gate.
2
+ //
3
+ // The guard is pinned in the base set but ships **inert**. Its own `DEFAULT_SETTINGS.enabled` is
4
+ // `true`, so leaving it alone would mean a fresh SpecPi install started gating shell and file calls
5
+ // through a third-party service on day one. SpecPi writes `enabled: false` instead.
6
+ //
7
+ // That is the whole of SpecPi's involvement, and the file lives under `scripts/` to say so. The
8
+ // guard is its own thing: it ships its own `/jev-guard setup | on | off [--global] | check | model
9
+ // | backend`, keeps its own configuration, resolves its own key, and is not part of the Jev layer.
10
+ // The advisor imports nothing from here, `/jev` does not mention it, and no switch in SpecPi turns
11
+ // it on. One command does -- the package's own.
12
+ //
13
+ // Be precise about what this cannot do. The guard is fail-closed by design: with no key, an
14
+ // unreachable endpoint, or an answer it cannot parse, the call does not go through, and in a
15
+ // session with no UI its `uncertain` default blocks the middle band too. There is no setting that
16
+ // hands the decision back to @gotgenes/pi-permission-system instead. So the honest posture is:
17
+ //
18
+ // - off (what SpecPi installs) -> the guard is not in the tool path at all, and the permission
19
+ // system decides every call exactly as it did before this package existed;
20
+ // - on -> the guard decides first, asks a human in the middle band when there is a UI, and
21
+ // blocks when it cannot reach Jev. An outage stops gated work until it is switched off.
22
+ //
23
+ // That trade is the user's to make, which is why SpecPi only ever writes the off side of it.
24
+ //
25
+ // ONE KEY, and not one SpecPi supplies. Since 0.3.0 the package reads Pi's saved login first and
26
+ // the environment second -- the same order the Jev advisor uses for the same provider entry -- so
27
+ // `/login openrouter` serves both without either knowing about the other.
28
+ //
29
+ // `auditDisplay` is deliberately not asserted below, although SpecPi Chat renders the counter that
30
+ // setting controls. It defaults to `status`, which is what publishes the line, and it is the user's
31
+ // to change: pinning it here would take a display preference away from them to guarantee a readout
32
+ // in one frontend. A session with it set to `off` simply shows no counter, which is what they
33
+ // asked for.
34
+
35
+ import fs from "node:fs";
36
+ import os from "node:os";
37
+ import path from "node:path";
38
+ import { agentDirectory, regularFile, writeFileAtomic } from "../extensions/jev-advisor/config.mjs";
39
+
40
+ export const GUARD_PACKAGE = "specpi-jev-guard";
41
+
42
+ /** Must match the pin in templates/settings.json. */
43
+ export const GUARD_PIN = "npm:specpi-jev-guard@0.4.0";
44
+
45
+ // The guard reads `<homedir>/.pi/jev-guard.json` globally, and a project copy under `<cwd>/.pi/`
46
+ // when the project is trusted. SpecPi writes only the global file: a project-local override is the
47
+ // user's to make, and writing one would put a security setting inside whatever repository happened
48
+ // to be open at the time.
49
+ const CONFIG_DIR_NAME = ".pi";
50
+ const SETTINGS_FILE = "jev-guard.json";
51
+
52
+ function guardRoot() {
53
+ return path.join(agentDirectory(), "npm", "node_modules", GUARD_PACKAGE);
54
+ }
55
+
56
+ function guardConfigFile() {
57
+ return path.join(os.homedir(), CONFIG_DIR_NAME, SETTINGS_FILE);
58
+ }
59
+
60
+ export function installed() {
61
+ try {
62
+ const manifest = path.join(guardRoot(), "package.json");
63
+ if (!fs.existsSync(manifest)) {
64
+ return { installed: false };
65
+ }
66
+
67
+ const parsed = JSON.parse(fs.readFileSync(manifest, "utf8"));
68
+ if (parsed?.name !== GUARD_PACKAGE) {
69
+ return { installed: false };
70
+ }
71
+
72
+ return { installed: true, version: typeof parsed.version === "string" ? parsed.version : "unknown" };
73
+ } catch {
74
+ return { installed: false };
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The fields SpecPi owns, in the guard's own schema. Anything absent here keeps the package's
80
+ * default, so its thresholds, command lists, protected paths and model settings stay its business.
81
+ *
82
+ * There is no `enabled: true` form of this, deliberately. SpecPi has no command that arms the
83
+ * guard, so a function that could produce an armed configuration would have no caller and one
84
+ * obvious wrong use.
85
+ */
86
+ export function desiredConfig() {
87
+ return {
88
+ // Off means the guard never enters the tool path, so no key is needed and nothing is sent.
89
+ enabled: false,
90
+ // Left at the guard's own default, and asserted so the two halves of the Jev story resolve
91
+ // the same credential: one `/login openrouter` serves the advisor and the guard.
92
+ backend: "openrouter",
93
+ // With a UI, a middle-band verdict asks rather than deciding on its own. Without one the
94
+ // guard fails closed; that is the package's design, disclosed rather than configured away.
95
+ uncertain: "ask",
96
+ };
97
+ }
98
+
99
+ export function readConfig() {
100
+ try {
101
+ const file = guardConfigFile();
102
+ if (!regularFile(file, "Jev guard settings")) {
103
+ return undefined;
104
+ }
105
+
106
+ return JSON.parse(fs.readFileSync(file, "utf8"));
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Write the inert posture, merged into whatever is already there.
114
+ *
115
+ * Merged, not replaced: a user's own thresholds, safe-command globs and protected paths survive,
116
+ * and only the three fields above are asserted. Idempotent, and it reports what it changed so the
117
+ * installer can say so.
118
+ *
119
+ * It runs on install and on update, and it always asserts `enabled: false`. Not only when the file
120
+ * is absent, which is the version of this that looks safer and is not: an install carrying a stale
121
+ * `enabled: true` from before this package was last unpinned would arm a fail-closed gate the
122
+ * moment the package came back, with nobody present to be told. So the rule is the blunt one --
123
+ * SpecPi never leaves an armed gate behind an installer run -- and `disarmed` is returned so the
124
+ * run can report the one case where that took something away from someone.
125
+ *
126
+ * This is an occasional, human-initiated act, which is what makes the blunt rule affordable. It
127
+ * deliberately does not run at session start: the package owns its switch between installs, and a
128
+ * per-session rewrite would mean `/jev-guard on --global` never survived a restart.
129
+ */
130
+ export function applyInertConfig() {
131
+ if (!installed().installed) {
132
+ return { applied: false, reason: "not-installed" };
133
+ }
134
+
135
+ const desired = desiredConfig();
136
+ const current = readConfig();
137
+ if (current && Object.entries(desired).every(([key, value]) => current[key] === value)) {
138
+ return { applied: false, reason: "already-current" };
139
+ }
140
+
141
+ writeFileAtomic(guardConfigFile(), `${JSON.stringify({ ...(current ?? {}), ...desired }, null, 4)}\n`);
142
+
143
+ return {
144
+ applied: true,
145
+ reason: current ? "updated" : "created",
146
+ // The one change worth announcing: everything else here is establishing a default, and this
147
+ // is switching off a gate a human had switched on.
148
+ disarmed: current?.enabled === true,
149
+ };
150
+ }
151
+
152
+ export function configPath() {
153
+ return guardConfigFile();
154
+ }
@@ -48,54 +48,6 @@ 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
-
99
51
  export function packageChanges(before, after) {
100
52
  return basePackages.map((source) => {
101
53
  const identity = packageIdentity(source);
@@ -182,14 +134,6 @@ export function installBasePackages(agentDir) {
182
134
 
183
135
  export function checkBasePackages(agentDir, settings) {
184
136
  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
-
193
137
  for (const source of basePackages) {
194
138
  const identity = packageIdentity(source);
195
139
  const entry =
@@ -22,14 +22,8 @@ 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
- basePackages,
27
- checkBasePackages,
28
- installBasePackages,
29
- packageChanges,
30
- removeRetiredPackages,
31
- runBrowserQA,
32
- } from "./packages.mjs";
25
+ import { basePackages, checkBasePackages, installBasePackages, packageChanges, runBrowserQA } from "./packages.mjs";
26
+ import { applyInertConfig as applyGuardConfig, configPath as guardConfigPath } from "./jev-guard.mjs";
33
27
 
34
28
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
35
29
  const VERSION = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
@@ -63,8 +57,6 @@ const resourcePaths = [
63
57
  "extensions/jev-advisor/client.mjs",
64
58
  "extensions/jev-advisor/key-source.mjs",
65
59
  "extensions/jev-advisor/layer.mjs",
66
- "extensions/jev-advisor/risk.mjs",
67
- "extensions/jev-advisor/questions/guard.mjs",
68
60
  "extensions/jev-advisor/broker.mjs",
69
61
  "extensions/jev-advisor/gate.mjs",
70
62
  "extensions/jev-advisor/questions/retention.mjs",
@@ -87,7 +79,7 @@ Usage:
87
79
  specpi doctor
88
80
  specpi uninstall [--yes]
89
81
 
90
- Installs /scope, the harness improvement loop, and seven pinned packages.
82
+ Installs /scope, the harness improvement loop, and eight pinned packages.
91
83
  The base is tested with Pi 0.84.4. Run specpi plan to see package versions.
92
84
  --skip-package-install installs only the core, or preserves an existing base on update.
93
85
  --skip-browser-install skips Chromium setup, not package acquisition or doctor checks.
@@ -260,32 +252,6 @@ function restoreLegacySettings(manifest, warnings) {
260
252
  writeJson(settingsPath, settings, existingMode(settingsPath, 0o600));
261
253
  }
262
254
 
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
-
289
255
  function removeLegacyShell(manifest) {
290
256
  if (manifest?.shellRc && fs.existsSync(manifest.shellRc)) {
291
257
  const result = removeManagedBlock(fs.readFileSync(manifest.shellRc, "utf8"), SHELL_START, SHELL_END);
@@ -340,16 +306,7 @@ async function mutate(options, operation) {
340
306
  ...files.map(([, target]) => target),
341
307
  ...Object.keys(previous?.files || {}),
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
+ if (!options.skipPackages || previous?.settingsChanges?.length || previous?.packageChanges?.length) {
353
310
  watched.push(settingsPath);
354
311
  }
355
312
 
@@ -370,10 +327,6 @@ async function mutate(options, operation) {
370
327
  const warnings = [];
371
328
  const preserveBase = operation !== "uninstall" && options.skipPackages && previous?.basePackages?.length;
372
329
  restoreLegacySettings(preserveBase ? { ...previous, packageChanges: [] } : previous, warnings);
373
- if (operation !== "uninstall") {
374
- retireBasePackages(warnings);
375
- }
376
-
377
330
  removeLegacyShell(previous);
378
331
  let packageState = preserveBase
379
332
  ? {
@@ -402,6 +355,26 @@ async function mutate(options, operation) {
402
355
  runBrowserQA(agentDir, "setup");
403
356
  }
404
357
 
358
+ // specpi-jev-guard's own default is enabled:true, so a freshly installed base would
359
+ // start gating shell and file calls through a third-party service before anyone asked
360
+ // for it — and with no key it fails closed, which means a first install that refuses to
361
+ // run commands. This is the only place SpecPi touches that package's configuration:
362
+ // between installer runs the guard's own /jev-guard commands own it, so the write does
363
+ // not repeat at session start and a saved `--global` choice survives every restart.
364
+ try {
365
+ const guard = applyGuardConfig();
366
+ if (guard.disarmed) {
367
+ // The one case where establishing the default takes something away. An
368
+ // installer run that silently switched off a gate the user had switched on is
369
+ // exactly the kind of quiet security change this file refuses to make.
370
+ warnings.push(
371
+ `Switched the Jev command guard off in ${guardConfigPath()}. SpecPi installs it inert and asserts that on every install and update; run /jev-guard on --global in Pi to turn it back on.`,
372
+ );
373
+ }
374
+ } catch (error) {
375
+ console.log(`SpecPi: could not write the Jev guard's inert settings: ${error.message}`);
376
+ }
377
+
405
378
  packageState = {
406
379
  basePackages,
407
380
  packagesKeyBeforeExists: Object.hasOwn(before, "packages"),
@@ -6,6 +6,7 @@
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"
9
+ "npm:@gotgenes/pi-permission-system@32.0.2",
10
+ "npm:specpi-jev-guard@0.4.0"
10
11
  ]
11
12
  }