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.
@@ -1,11 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { SYSTEM_NAMES, keyPresent, loadSettings, saveSettings, settingsPath } from "./config.mjs";
3
+ import { SYSTEM_NAMES, loadSettings, saveSettings, settingsPath } from "./config.mjs";
4
+ import { keySources } from "./key-source.mjs";
5
+ import { applyLayer, guardWarning, layerScopeLine, layerToPersist, startupToPersist } from "./layer.mjs";
4
6
  import { consentPath, granted, revokeConsent } from "./consent.mjs";
5
7
  import { createBroker } from "./broker.mjs";
6
8
  import { ledgerPath, read as readLedger } from "./ledger.mjs";
7
9
  import { usagePath } from "./usage.mjs";
8
- import { applyConfig as applyGuardConfig, statusLine as guardStatusLine } from "./guard.mjs";
10
+ import { GATED_TOOLS, SHELL_TOOLS, callTargets, classifyCall, commandText } from "./risk.mjs";
9
11
  import * as retention from "./questions/retention.mjs";
10
12
  import * as compaction from "./questions/compaction.mjs";
11
13
  import * as gap from "./questions/gap.mjs";
@@ -13,13 +15,42 @@ import * as sources from "./questions/sources.mjs";
13
15
  import * as progress from "./questions/progress.mjs";
14
16
  import * as untrusted from "./questions/untrusted.mjs";
15
17
  import * as capabilities from "./questions/capabilities.mjs";
18
+ import * as guard from "./questions/guard.mjs";
16
19
 
17
20
  const MAX_RECENT = 8;
18
21
 
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
+
19
31
  function safeMessage(error: unknown) {
20
32
  return String((error as any)?.message ?? error ?? "unknown error").slice(0, 200);
21
33
  }
22
34
 
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
+
23
54
  export default function jevAdvisor(pi: ExtensionAPI) {
24
55
  // Session switches live in memory. A session toggle must never write the startup preference,
25
56
  // so the saved file is read once per session and only /jev startup ever writes it.
@@ -70,14 +101,12 @@ export default function jevAdvisor(pi: ExtensionAPI) {
70
101
  let capabilityAsked = false;
71
102
  const capabilityDeclined = new Set<string>();
72
103
 
73
- let guardEnabled = false;
74
- const syncGuard = () => {
104
+ /** Persist the session's switches, or report that it could not be done. */
105
+ const persistLayer = (result: { settings: any }) => {
75
106
  try {
76
- return applyGuardConfig(guardEnabled);
107
+ return saveSettings(layerToPersist(result, loadSettings()));
77
108
  } catch {
78
- // A guard that cannot be reconfigured keeps whatever posture it has, which
79
- // /jev status reports rather than hides.
80
- return { applied: false, reason: "unwritable" };
109
+ return undefined;
81
110
  }
82
111
  };
83
112
 
@@ -92,12 +121,6 @@ export default function jevAdvisor(pi: ExtensionAPI) {
92
121
  resetHistory();
93
122
  capabilityAsked = false;
94
123
  capabilityDeclined.clear();
95
- guardEnabled = settings.guard.startup === true;
96
- // Deliberately outside the master switch. The guard is a separate package with its own
97
- // gate, and whether it is inert is a property of the install rather than a feature of the
98
- // advisor, so its configuration is rewritten every session either way. Off is the default
99
- // and is a real written configuration, not an absence of one.
100
- syncGuard();
101
124
  });
102
125
 
103
126
  pi.on("session_shutdown", () => {
@@ -231,6 +254,111 @@ export default function jevAdvisor(pi: ExtensionAPI) {
231
254
  }
232
255
  });
233
256
 
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
+
234
362
  // System 1: condense a spent tool result before it is appended. Doing this after the fact would
235
363
  // rewrite a cached prefix; on arrival it never touches one.
236
364
  pi.on("tool_result", async (event: any, ctx: ExtensionContext) => {
@@ -251,6 +379,16 @@ export default function jevAdvisor(pi: ExtensionAPI) {
251
379
  }
252
380
  }
253
381
 
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.
387
+ recent.push({ tool: String(event?.toolName ?? ""), outcome: event?.isError === true ? "error" : "ok" });
388
+ if (recent.length > MAX_RECENT) {
389
+ recent.shift();
390
+ }
391
+
254
392
  // Two systems share this hook. Retention wants large read-only results; system 7 wants
255
393
  // externally fetched ones whatever their size, because an injected instruction can be two
256
394
  // hundred bytes. When both want the same result they are one call: questions are evaluated
@@ -310,11 +448,12 @@ export default function jevAdvisor(pi: ExtensionAPI) {
310
448
  }
311
449
 
312
450
  const { verdict, replacement } = result.decision;
313
- if (wantRetention) {
314
- recent.push({ tool: event.toolName, outcome: verdict.elide ? "spent" : "kept" });
315
- if (recent.length > MAX_RECENT) {
316
- recent.shift();
317
- }
451
+ // Retention knows something the bookkeeping above does not -- whether the result was
452
+ // spent -- so it refines its own entry rather than appending a second one for the same
453
+ // call. If anything has been recorded since, the entry is gone and so is the chance.
454
+ const latest = recent[recent.length - 1];
455
+ if (wantRetention && latest?.tool === event.toolName) {
456
+ latest.outcome = verdict.elide ? "spent" : "kept";
318
457
  }
319
458
 
320
459
  if (replacement === undefined) {
@@ -653,7 +792,7 @@ export default function jevAdvisor(pi: ExtensionAPI) {
653
792
  pi.registerCommand("jev", {
654
793
  description: "Show or change the Jev advisor: master switch, per-system switches and the transmission ledger",
655
794
  getArgumentCompletions: (prefix: string) =>
656
- ["status", "on", "off", "startup", "enable", "disable", "guard", "ledger", "forget"]
795
+ ["status", "on", "off", "startup", "enable", "disable", "ledger", "forget"]
657
796
  .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
658
797
  .map((value) => ({ value, label: value })),
659
798
  handler: async (args: string, ctx: ExtensionContext) => {
@@ -661,14 +800,33 @@ export default function jevAdvisor(pi: ExtensionAPI) {
661
800
  const action = actionRaw.toLowerCase();
662
801
  try {
663
802
  if (action === "on" || action === "off") {
664
- settings = { ...settings, master: action === "on" };
665
- const active = SYSTEM_NAMES.filter((name) => settings.systems[name]);
666
- ctx.ui.notify(
667
- action === "on"
668
- ? `Jev advisor on for this session with ${active.length} of ${SYSTEM_NAMES.length} systems enabled${active.length === 0 ? " (enable one with /jev enable <system>)" : `: ${active.join(", ")}`}.`
669
- : "Jev advisor off for this session. No state leaves this machine.",
670
- "info",
671
- );
803
+ const on = action === "on";
804
+ // `--session` is the old behaviour, kept for the case it was the right one: a
805
+ // one-off try that must not change what the next session does.
806
+ const sessionOnly = rest.some((value) => /^--?(session|once)$/u.test(value.toLowerCase()));
807
+ const unknown = rest.filter((value) => !/^--?(session|once)$/u.test(value.toLowerCase()));
808
+ if (unknown.length > 0) {
809
+ throw new Error(`Usage: /jev ${action} [--session]`);
810
+ }
811
+
812
+ const result = applyLayer({ on }, { settings }, { keySources: () => keySources() });
813
+ settings = result.settings;
814
+ // Persisting is the default because a switch that forgets is not a switch. The
815
+ // old rule -- that only /jev startup may write -- protected against a session
816
+ // toggle silently changing tomorrow's sessions, but the cost of that protection
817
+ // was a layer people turned on repeatedly and never actually ran.
818
+ const persisted = !sessionOnly && ctx.hasUI ? persistLayer(result) : undefined;
819
+ const lines = [
820
+ ...result.lines,
821
+ layerScopeLine({
822
+ sessionOnly,
823
+ interactive: ctx.hasUI,
824
+ persisted,
825
+ stored: loadSettings(),
826
+ settingsFile: settingsPath(),
827
+ }),
828
+ ];
829
+ ctx.ui.notify(lines.join("\n"), "info");
672
830
 
673
831
  return;
674
832
  }
@@ -680,14 +838,46 @@ export default function jevAdvisor(pi: ExtensionAPI) {
680
838
  throw new Error(`Usage: /jev ${action} <${SYSTEM_NAMES.join("|")}>`);
681
839
  }
682
840
 
683
- const systems = { ...settings.systems };
684
- for (const name of names) {
685
- systems[name] = action === "enable";
686
- }
687
-
688
- settings = { ...settings, systems };
841
+ const changes = Object.fromEntries(names.map((name) => [name, action === "enable"]));
842
+ const systems = { ...settings.systems, ...changes };
843
+ // Disabling the last system while the layer is on leaves it running and doing
844
+ // nothing -- the state `enableSystems`, `startupToPersist`, `couple` and the
845
+ // Chat panel's save check all exist to prevent, reachable through the one path
846
+ // that did not check it. Switching the layer off is the honest reading of
847
+ // "disable everything", and it is announced rather than inferred.
848
+ const emptied = settings.master && SYSTEM_NAMES.every((name) => !systems[name]);
849
+ settings = { ...settings, systems, master: emptied ? false : settings.master };
850
+ // Persisted like every other switch here, and merged into the stored map rather
851
+ // than overwriting it: this session's copy may predate systems enabled on disk
852
+ // since it started, and writing it whole turned those back off silently.
853
+ const kept = ctx.hasUI
854
+ ? (() => {
855
+ try {
856
+ const current = loadSettings();
857
+ const merged = { ...current.systems, ...changes };
858
+ const dead = current.master && SYSTEM_NAMES.every((name) => !merged[name]);
859
+
860
+ return saveSettings({
861
+ ...current,
862
+ systems: merged,
863
+ master: dead ? false : current.master,
864
+ startup: dead ? false : current.startup,
865
+ });
866
+ } catch {
867
+ return undefined;
868
+ }
869
+ })()
870
+ : 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;
689
875
  ctx.ui.notify(
690
- `${action === "enable" ? "Enabled" : "Disabled"} for this session: ${names.join(", ")}.${settings.master ? "" : " The master switch is still off; run /jev on."}`,
876
+ `${action === "enable" ? "Enabled" : "Disabled"}: ${names.join(", ")}.` +
877
+ `${kept ? " Remembered for new sessions." : " This session only."}` +
878
+ `${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()}` : ""}`,
691
881
  "info",
692
882
  );
693
883
 
@@ -697,8 +887,9 @@ export default function jevAdvisor(pi: ExtensionAPI) {
697
887
  if (action === "startup") {
698
888
  const [choice] = rest;
699
889
  if (!choice) {
890
+ const current = loadSettings();
700
891
  ctx.ui.notify(
701
- `Jev starts ${loadSettings().startup ? "on" : "off"} in new sessions. Preference: ${settingsPath()}`,
892
+ `Jev starts ${current.startup && current.master ? "on" : "off"} in new sessions. Preference: ${settingsPath()}`,
702
893
  "info",
703
894
  );
704
895
 
@@ -713,70 +904,21 @@ export default function jevAdvisor(pi: ExtensionAPI) {
713
904
  throw new Error("Usage: /jev startup [on|off]");
714
905
  }
715
906
 
716
- const saved = saveSettings({ ...loadSettings(), startup: choice.toLowerCase() === "on" });
717
- ctx.ui.notify(
718
- saved.startup
719
- ? "New Pi sessions will start with the Jev advisor on. This session is unchanged."
720
- : "New Pi sessions will start with the Jev advisor off. This session is unchanged.",
721
- "info",
722
- );
723
-
724
- return;
725
- }
726
-
727
- if (action === "guard") {
728
- const [verb, choice] = rest.map((value) => value.toLowerCase());
729
- if (!verb) {
730
- ctx.ui.notify(guardStatusLine(), "info");
731
-
732
- return;
733
- }
734
-
735
- if (verb === "on" || verb === "off") {
736
- guardEnabled = verb === "on";
737
- const result = syncGuard();
738
- ctx.ui.notify(
739
- result.reason === "not-installed"
740
- ? "specpi-jev-guard is not installed, so there is nothing to switch. Command policy stays with the permission system."
741
- : guardEnabled
742
- ? "Jev guard on for this session. It scores shell and file calls and defers to the permission system whenever Jev is unavailable or unconfident."
743
- : "Jev guard off for this session. Every tool call goes straight to the permission system.",
744
- "info",
745
- );
746
-
747
- return;
748
- }
749
-
750
- if (verb !== "startup") {
751
- throw new Error("Usage: /jev guard [on|off|startup [on|off]]");
752
- }
753
-
754
- if (!choice) {
755
- ctx.ui.notify(
756
- `The Jev guard starts ${loadSettings().guard.startup ? "on" : "off"} in new sessions.`,
757
- "info",
758
- );
759
-
760
- return;
761
- }
762
-
763
- if (!ctx.hasUI) {
764
- throw new Error("Startup changes require a human interactive command");
765
- }
766
-
767
- if (!["on", "off"].includes(choice)) {
768
- throw new Error("Usage: /jev guard startup [on|off]");
769
- }
770
-
771
- const stored = loadSettings();
772
- const saved = saveSettings({
773
- ...stored,
774
- guard: { ...stored.guard, startup: choice === "on" },
775
- });
907
+ // Both keys, and the systems with them. Writing `startup` alone was the whole
908
+ // two-keys-for-one-intention trap, left in the command named after it: the
909
+ // advisor's session_start keeps a stored `master` only when `startup` is true,
910
+ // so `startup: true, master: false` starts every future session with the layer
911
+ // off while this command cheerfully reported it would start on. And a layer
912
+ // that starts on with no system enabled runs and does nothing, so the same rule
913
+ // `/jev on` uses applies here: fill them in only when none are chosen.
914
+ const wanted = choice.toLowerCase() === "on";
915
+ const saved = saveSettings(startupToPersist(wanted, loadSettings()));
916
+ const enabled = SYSTEM_NAMES.filter((name) => saved.systems[name]);
776
917
  ctx.ui.notify(
777
- saved.guard.startup
778
- ? "New Pi sessions will start with the Jev guard on. This session is unchanged."
779
- : "New Pi sessions will start with the Jev guard off. This session is unchanged.",
918
+ 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()}` : ""}`
921
+ : "New Pi sessions will start with the Jev layer off. This session is unchanged.",
780
922
  "info",
781
923
  );
782
924
 
@@ -817,21 +959,29 @@ export default function jevAdvisor(pi: ExtensionAPI) {
817
959
 
818
960
  if (action !== "status") {
819
961
  throw new Error(
820
- "Usage: /jev [status|on|off|startup [on|off]|enable <system>|disable <system>|guard [on|off|startup [on|off]]|ledger [n]|forget]",
962
+ "Usage: /jev [status|on [--session]|off [--session]|startup [on|off]|enable <system>|disable <system>|ledger [n]|forget]",
821
963
  );
822
964
  }
823
965
 
824
966
  const state = broker.status();
967
+ const sources = keySources();
968
+ const activeSource = sources.find((source: { present: boolean }) => source.present)?.name;
969
+ const stored = loadSettings();
825
970
  const lines = [
826
- `master: ${settings.master ? "on" : "off"} (new sessions start ${loadSettings().startup ? "on" : "off"})`,
971
+ `master: ${settings.master ? "on" : "off"} (new sessions start ${stored.startup && stored.master ? "on" : "off"})`,
827
972
  ...SYSTEM_NAMES.map((name) => ` ${name}: ${settings.systems[name] ? "on" : "off"}`),
828
- // Both names, because the default route is OpenRouter and naming only the
829
- // other one sends a reader to set the key that returns a bare 401.
830
- `key: ${keyPresent() ? "present" : "missing"} (OPENROUTER_API_KEY, or TYPESAFE_API_KEY with JEV_BACKEND=typesafe)`,
973
+ // Every place a key could come from, in the order they are consulted, with the
974
+ // one in force marked. A bare "missing" was actively misleading here: it is
975
+ // what someone saw who had a perfectly good OpenRouter key stored by /login,
976
+ // and it gave them nothing to act on. Names only -- no key is ever printed.
977
+ `key: ${activeSource ? `in use from ${activeSource}` : "none found"}`,
978
+ ...sources.map(
979
+ (source: { name: string; label: string; detail: string; present: boolean }) =>
980
+ ` ${source.present ? "found" : " - "} ${source.label} (${source.detail})`,
981
+ ),
831
982
  `consent: ${granted() ? "granted" : "not granted"}`,
832
983
  `calls this session: ${state.callsUsed}/${state.budgets.total} total`,
833
984
  ...SYSTEM_NAMES.map((name) => ` ${name}: ${state.usedBySystem[name] ?? 0}/${state.budgets[name]}`),
834
- guardStatusLine(),
835
985
  `settings: ${settingsPath()}`,
836
986
  `consent file: ${consentPath()}`,
837
987
  `ledger: ${ledgerPath()}`,