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.
- package/CHANGELOG.md +13 -0
- package/NPM_RELEASE.md +1 -1
- package/README.md +1 -1
- package/SECURITY_MODEL.md +14 -12
- package/THIRD_PARTY.md +6 -5
- package/extensions/jev-advisor/broker.mjs +0 -1
- package/extensions/jev-advisor/config.mjs +66 -52
- package/extensions/jev-advisor/gate.mjs +0 -14
- package/extensions/jev-advisor/index.ts +7 -149
- package/extensions/jev-advisor/key-source.mjs +1 -1
- package/extensions/jev-advisor/layer.mjs +8 -31
- package/package.json +2 -1
- package/scripts/jev-guard.mjs +154 -0
- package/scripts/packages.mjs +0 -56
- package/scripts/specpi.mjs +24 -51
- package/templates/settings.json +2 -1
- package/extensions/jev-advisor/questions/guard.mjs +0 -168
- package/extensions/jev-advisor/risk.mjs +0 -442
|
@@ -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,
|
|
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.
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
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
|
-
*
|
|
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
|
|
22
|
-
*
|
|
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
|
|
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
|
|
48
|
+
* and `/jev off` then `/jev on` must not hand back the six they turned off.
|
|
63
49
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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.
|
|
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
|
+
}
|
package/scripts/packages.mjs
CHANGED
|
@@ -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 =
|
package/scripts/specpi.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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"),
|
package/templates/settings.json
CHANGED