specpi 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ // Where the Jev layer's key comes from, and the one place that answers it.
2
+ //
3
+ // This file exists because the layer used to answer it wrongly. Jev read `OPENROUTER_API_KEY` from
4
+ // the environment and nothing else, while Pi itself had already resolved an OpenRouter credential
5
+ // for the session through `/login openrouter` and stored it where every other provider stores one.
6
+ // The result was a layer that reported "key: missing" to someone who had a working OpenRouter key
7
+ // sitting in `auth.json`, with no interface anywhere that would have explained the gap. Reading the
8
+ // credential Pi already has is not a new integration; it is stopping an old one from opting out.
9
+ //
10
+ // Pi's documented resolution order (docs/providers.md, "Resolution Order") is:
11
+ //
12
+ // 1. the `--api-key` CLI flag -- Pi's own, scoped to Pi's model calls, not ours
13
+ // 2. `<agent-dir>/auth.json` -- what `/login` writes
14
+ // 3. the provider environment variable
15
+ // 4. custom provider keys in models.json
16
+ //
17
+ // We implement 2 then 3, in that order, so a person who has logged in once is served and a person
18
+ // who exports the variable is still served. Step 1 is Pi's alone and step 4 describes provider
19
+ // catalogue entries the decisions endpoint has no equivalent of.
20
+ //
21
+ // Two rules hold everywhere below.
22
+ //
23
+ // The key is returned by exactly one function, `resolveKey()`, and callers pass it straight to a
24
+ // request header. Nothing else here ever sees the value: `keySource()` returns a label so status
25
+ // output, the Chat panel and the ledger can say where a key came from without any of them being a
26
+ // place a key could leak from. That split is the whole reason this is a module rather than two
27
+ // lines in client.mjs.
28
+ //
29
+ // And a missing or malformed credential is never an error. Every failure path returns undefined,
30
+ // the caller reports `unavailable("no-key")`, and the harness does what it did before the layer
31
+ // existed. A credential store that can throw is a credential store that can take the session down.
32
+
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+ import { agentDirectory } from "./config.mjs";
36
+
37
+ /** Pi's provider id for OpenRouter, from the provider table in its own docs. */
38
+ export const OPENROUTER_PROVIDER = "openrouter";
39
+
40
+ // auth.json holds one entry per provider and OAuth entries carry refresh and access tokens, so it
41
+ // is meaningfully larger than the 4 KiB settings bound. This is still small enough that anything
42
+ // above it was not written by Pi, and reading it is what stops a hostile or corrupt file from
43
+ // costing the session a multi-megabyte synchronous read on the tool path.
44
+ const MAX_AUTH_BYTES = 256 * 1024;
45
+
46
+ export function authPath() {
47
+ return path.join(agentDirectory(), "auth.json");
48
+ }
49
+
50
+ /**
51
+ * Pi strips a BOM before parsing and so do we: an auth.json written by a Windows editor parses for
52
+ * Pi and would otherwise fail here, which is the worst kind of difference -- the key works for
53
+ * every model call and appears missing to this layer alone.
54
+ */
55
+ function stripBom(text) {
56
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
57
+ }
58
+
59
+ /**
60
+ * The parsed store, cached against the file's own identity.
61
+ *
62
+ * `ask()` resolves a key per request and retention fires on every large read-only tool result, so an
63
+ * uncached read put a synchronous stat, read and JSON parse of up to 256 KiB on the tool path inside
64
+ * a 1500 ms latency budget -- where it used to be one `process.env` lookup. The cache key is the
65
+ * file's size and modification time, so `/login` writing a new credential mid-session invalidates it
66
+ * on the next call rather than being masked until restart, which a plain memo would have done.
67
+ *
68
+ * A file that will not parse is cached as firmly as one that will. Recording only successes left the
69
+ * worst case uncached: a truncated or hand-edited auth.json threw on every call, so every request
70
+ * paid a fresh stat, a 256 KiB read and a failing parse inside the same latency budget the cache
71
+ * exists to protect -- forever, since nothing about it would change until the file did.
72
+ */
73
+ let parsedStore = { key: "", data: undefined };
74
+
75
+ function readStore(file, stat) {
76
+ const identity = `${stat.mtimeMs}:${stat.size}:${file}`;
77
+ if (parsedStore.key === identity) {
78
+ return parsedStore.data;
79
+ }
80
+
81
+ let data;
82
+ try {
83
+ const parsed = JSON.parse(stripBom(fs.readFileSync(file, "utf8")));
84
+ data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
85
+ } catch {
86
+ // An unreadable or unparseable store is "no credential", which is the same answer the caller
87
+ // would have reached by catching this; caching it is what stops it being recomputed.
88
+ data = undefined;
89
+ }
90
+
91
+ parsedStore = { key: identity, data };
92
+
93
+ return data;
94
+ }
95
+
96
+ /**
97
+ * The raw stored entry for one provider, or undefined. Deliberately not exported: an entry is a
98
+ * credential, and the only thing outside this file that needs one is the request header.
99
+ */
100
+ function storedCredential(providerId) {
101
+ try {
102
+ const file = authPath();
103
+ // `stat`, not `lstat`: a symlinked auth.json has to resolve, because dotfile managers like
104
+ // chezmoi and stow routinely link it into a managed directory. Refusing links here would
105
+ // recreate the exact divergence this module was written to remove -- Pi resolves the
106
+ // credential and every model call works, while this layer alone reports "key: none found"
107
+ // and gives no way to tell that from a missing key.
108
+ //
109
+ // config.mjs refuses links on its own files for a reason that does not apply here: those
110
+ // are writes, where a link can redirect a trusted write somewhere it was not meant to go.
111
+ // This is a bounded read of a file Pi owns, so an unsupported shape reads as "no credential"
112
+ // rather than throwing. It is not our file to have opinions about.
113
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
114
+ if (!stat || !stat.isFile() || stat.size > MAX_AUTH_BYTES) {
115
+ return undefined;
116
+ }
117
+
118
+ return readStore(file, stat)?.[providerId];
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * An api_key credential's key, or undefined for anything else.
126
+ *
127
+ * An `oauth` entry is ignored rather than unwrapped. Pi refreshes OAuth tokens inside a lock in its
128
+ * own credential store, and a second process reading an access token out of the file would be
129
+ * reading a value that may already have been rotated -- and would be doing it without the lock.
130
+ * OpenRouter's own login mints a durable `api_key` anyway, so the case this skips is not the case
131
+ * anyone reaches.
132
+ */
133
+ function apiKeyOf(credential) {
134
+ if (!credential || typeof credential !== "object" || credential.type !== "api_key") {
135
+ return undefined;
136
+ }
137
+
138
+ const key = credential.key;
139
+
140
+ return typeof key === "string" && key.trim().length > 0 ? key.trim() : undefined;
141
+ }
142
+
143
+ function environmentKey(name) {
144
+ const value = process.env[name];
145
+
146
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
147
+ }
148
+
149
+ /**
150
+ * Whether the credential store is consulted at all.
151
+ *
152
+ * `JEV_KEY_SOURCE=environment` restricts resolution to the environment variables. That exists for
153
+ * this repository's own scripts, and it is a correctness fix rather than a convenience: the
154
+ * calibration and triage runs load a key from the gitignored `evals/.env` and AGENTS.md promises
155
+ * "a variable already set in the shell always wins". Once `auth.json` was consulted first, those
156
+ * runs would silently bill a developer's personal `/login openrouter` account instead of the eval
157
+ * key, and `--probe` would verify a key the run then did not use.
158
+ */
159
+ function environmentOnly() {
160
+ return process.env.JEV_KEY_SOURCE === "environment";
161
+ }
162
+
163
+ /**
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
166
+ * TypeSafe API with a bare 401. `JEV_BACKEND=typesafe` selects the direct API for a TypeSafe key.
167
+ *
168
+ * It lives here rather than in client.mjs because everything below has to bind it. A parameter
169
+ * defaulting to the string "openrouter" is not a binding: re-exporting such a function under a name
170
+ * whose previous version read the backend itself silently rebound every no-arg caller to the wrong
171
+ * route, which is how `keyPresent()` came to report a key that `resolveKey()` would not return.
172
+ */
173
+ export function backend() {
174
+ return process.env.JEV_BACKEND === "typesafe" ? "typesafe" : "openrouter";
175
+ }
176
+
177
+ export function keyEnvName(route = backend()) {
178
+ return route === "typesafe" ? "TYPESAFE_API_KEY" : "OPENROUTER_API_KEY";
179
+ }
180
+
181
+ /**
182
+ * Every place a key for this backend could come from, in the order they are consulted, each with
183
+ * whether it currently holds one. This is what `/jev status`, `specpi doctor` and the Chat panel
184
+ * render, and it carries labels only -- never a key, not even a truncated one.
185
+ *
186
+ * The list is returned whole rather than filtered to the winner because "which of these do I need
187
+ * to fix" is the question someone with no key is actually asking, and a bare "missing" has never
188
+ * answered it.
189
+ */
190
+ export function keySources(route = backend()) {
191
+ const variable = keyEnvName(route);
192
+ const sources = [];
193
+ // Only the OpenRouter route has a provider entry to read: `auth.json` is keyed by Pi provider
194
+ // id, and the direct TypeSafe API is not one of Pi's providers.
195
+ if (route !== "typesafe" && !environmentOnly()) {
196
+ sources.push({
197
+ name: "auth.json",
198
+ label: `Pi credential store (${OPENROUTER_PROVIDER})`,
199
+ detail: "/login openrouter",
200
+ present: apiKeyOf(storedCredential(OPENROUTER_PROVIDER)) !== undefined,
201
+ });
202
+ }
203
+
204
+ sources.push({
205
+ name: variable,
206
+ label: `${variable} in the environment`,
207
+ detail: `export ${variable}=...`,
208
+ present: environmentKey(variable) !== undefined,
209
+ });
210
+
211
+ // Accepted on the OpenRouter route so an env file predating the OpenRouter default keeps
212
+ // working. Listed last because it is a compatibility path, and listed at all because a person
213
+ // whose key is only here should be able to see that that is why it still works.
214
+ if (route !== "typesafe") {
215
+ sources.push({
216
+ name: "TYPESAFE_API_KEY",
217
+ label: "TYPESAFE_API_KEY in the environment (legacy)",
218
+ detail: "export TYPESAFE_API_KEY=...",
219
+ present: environmentKey("TYPESAFE_API_KEY") !== undefined,
220
+ });
221
+ }
222
+
223
+ return sources;
224
+ }
225
+
226
+ /** The name of the source a key would be taken from, or undefined when there is none. */
227
+ export function keySource(route = backend()) {
228
+ return keySources(route).find((source) => source.present)?.name;
229
+ }
230
+
231
+ /**
232
+ * The key itself. The only function here that returns one, and the only caller is the request
233
+ * header in client.mjs.
234
+ */
235
+ export function resolveKey(route = backend()) {
236
+ if (route !== "typesafe" && !environmentOnly()) {
237
+ const stored = apiKeyOf(storedCredential(OPENROUTER_PROVIDER));
238
+ if (stored) {
239
+ return stored;
240
+ }
241
+ }
242
+
243
+ return environmentKey(keyEnvName(route)) ?? (route !== "typesafe" ? environmentKey("TYPESAFE_API_KEY") : undefined);
244
+ }
245
+
246
+ /**
247
+ * Whether any source holds a key. Callers only ever ask this; the client reads the value itself at
248
+ * call time, so a key never has to exist inside a structure that something might log or serialize.
249
+ */
250
+ export function keyPresent(route = backend()) {
251
+ return keySources(route).some((source) => source.present);
252
+ }
@@ -0,0 +1,169 @@
1
+ // What `/jev on` and `/jev off` actually do, as functions a test can call.
2
+ //
3
+ // This logic used to live in closures inside index.ts, which no test imports -- every test in the
4
+ // suite reaches for a `.mjs` module, so the whole feature was covered only by prose. Two review
5
+ // rounds found the consequences: a guard preference destroyed on a path that had decided nothing, a
6
+ // fail-closed gate armed by a headless session that was told nothing had changed, a message
7
+ // reporting the guard "left off" while it was on and blocking, and a rollback that never happened
8
+ // because the flag was set before the write. None of those were visible to a green suite, because
9
+ // the suite could not see them at all.
10
+ //
11
+ // So the shape here is deliberate: no module state, no reads of `process` beyond an injected `env`,
12
+ // and every effect either returned as data or performed through an injected dependency. The caller
13
+ // owns the session; this decides what should happen to it and says so.
14
+
15
+ import { SYSTEM_NAMES } from "./config.mjs";
16
+
17
+ /**
18
+ * Decide what a layer switch means, and report it honestly.
19
+ *
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.
23
+ *
24
+ * Scope belongs to `layerScopeLine`, not here, so this takes `{ on }` and nothing else. It used to
25
+ * be handed `sessionOnly` and `interactive` as well and read neither, which reads as a decision
26
+ * being made from them.
27
+ */
28
+ export function applyLayer({ on }, state, deps) {
29
+ if (!on) {
30
+ return { settings: { ...state.settings, master: false }, lines: [offLine()] };
31
+ }
32
+
33
+ // Resolved inside the on branch only. `offLine` never names a key, so doing this first meant
34
+ // every `/jev off` paid for a stat, read and parse of Pi's credential store to discard it --
35
+ // the one file this layer reads under a narrow, stated exception.
36
+ const active = deps.keySources().find((item) => item.present)?.name;
37
+ const settings = enableSystems(state.settings);
38
+
39
+ return { settings, lines: onLines(state.settings, settings, active) };
40
+ }
41
+
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
+ /**
58
+ * 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".
60
+ *
61
+ * 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.
63
+ *
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.
70
+ */
71
+ export function enableSystems(settings) {
72
+ const chosen = SYSTEM_NAMES.filter((name) => settings.systems[name]);
73
+
74
+ return {
75
+ ...settings,
76
+ master: true,
77
+ systems: chosen.length > 0 ? settings.systems : Object.fromEntries(SYSTEM_NAMES.map((name) => [name, true])),
78
+ };
79
+ }
80
+
81
+ function onLines(before, after, activeSource) {
82
+ const chosen = SYSTEM_NAMES.filter((name) => before.systems[name]);
83
+ const active = SYSTEM_NAMES.filter((name) => after.systems[name]);
84
+ const lines = [`Jev layer on with ${active.length} of ${SYSTEM_NAMES.length} systems: ${active.join(", ")}.`];
85
+ if (chosen.length === 0) {
86
+ lines.push("No system was enabled, so all of them were. Turn any back off with /jev disable <system>.");
87
+ }
88
+
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
+ lines.push(keyLine(activeSource));
96
+
97
+ return lines;
98
+ }
99
+
100
+ function offLine() {
101
+ return "Jev layer off. No state leaves this machine, and every tool call goes to the permission system.";
102
+ }
103
+
104
+ /**
105
+ * Where the key is coming from, by name and never by value. Takes the already-resolved source name
106
+ * rather than looking it up, so one command cannot read the credential store twice.
107
+ */
108
+ export function keyLine(source) {
109
+ if (source) {
110
+ return `Key: found in ${source === "auth.json" ? "Pi's credential store (auth.json)" : source}.`;
111
+ }
112
+
113
+ return (
114
+ "Key: none found, so every system will report no advice and the harness runs exactly as it did before. " +
115
+ "Run /login openrouter to store one, or set OPENROUTER_API_KEY."
116
+ );
117
+ }
118
+
119
+ /**
120
+ * The settings to write so that turning the layer on is remembered.
121
+ *
122
+ * `master` and `startup` always move together. Storing them apart is what made the Chat panel's
123
+ * "enabled" checkbox do nothing on its own: `session_start` zeroes a stored master whenever
124
+ * `startup` is false, so `master: true, startup: false` describes a layer that is on and never runs.
125
+ *
126
+ * The stored file is the base, so budgets and the nudge mode written by Chat while this session was
127
+ * running survive -- including any system the panel enabled since this session started, which is why
128
+ * `systems` is merged rather than written over.
129
+ */
130
+ export function layerToPersist({ settings }, stored) {
131
+ return {
132
+ ...stored,
133
+ master: settings.master,
134
+ startup: settings.master,
135
+ // Merged onto the stored map, not written over it. This session's copy may predate systems
136
+ // enabled on disk since it started -- by the Chat panel, or by another session -- and
137
+ // writing it whole turned those back off with nothing reporting it.
138
+ systems: { ...stored.systems, ...settings.systems },
139
+ };
140
+ }
141
+
142
+ /** What the change applies to: this session, or every session from now on. */
143
+ export function layerScopeLine({ sessionOnly, interactive, persisted, stored, settingsFile }) {
144
+ if (sessionOnly) {
145
+ return `This session only, as asked. New sessions still start ${stored.startup && stored.master ? "on" : "off"}.`;
146
+ }
147
+
148
+ if (!interactive) {
149
+ return "This session only: writing the startup preference needs an interactive command.";
150
+ }
151
+
152
+ if (!persisted) {
153
+ return `This session only: ${settingsFile} could not be written.`;
154
+ }
155
+
156
+ return `Remembered -- new Pi sessions start this way too. Preference: ${settingsFile}`;
157
+ }
158
+
159
+ /**
160
+ * `/jev startup on|off`, which was the last command still writing one half of the pair -- the exact
161
+ * trap this module exists to remove, left in the command named after it, while reporting that new
162
+ * sessions would start on when they would not.
163
+ */
164
+ export function startupToPersist(wanted, stored) {
165
+ // Delegates to `enableSystems` rather than restating the rule. Three copies of "what enabling
166
+ // the layer means" -- here, there, and `couple` in the Chat panel -- is precisely how the
167
+ // advisor and the panel drift apart, which is the class of bug this module was extracted over.
168
+ return wanted ? { ...enableSystems(stored), startup: true } : { ...stored, master: false, startup: false };
169
+ }
@@ -0,0 +1,168 @@
1
+ // System 8: score a shell or file call that local rules could not settle, before it runs.
2
+ //
3
+ // This is the native command guard. It replaces the pinned `specpi-jev-guard` package, and the
4
+ // reason it exists rather than that package being configured is that the package's shape kept
5
+ // producing the same class of defect: its configuration was a global file with no session scope, so
6
+ // there was no such thing as enabling it for one session; it read its key from the environment only,
7
+ // so a credential `/login` had stored was invisible to it; and it was fail-closed, so an outage or a
8
+ // missing key turned every shell call in the session into a refusal.
9
+ //
10
+ // Native, all three go away. The switch is `systems.guard` like every other system, so it is
11
+ // session-scoped, budgeted, reported in `/jev status` and toggled by `/jev enable guard`. The key is
12
+ // whatever `key-source.mjs` resolves, which includes Pi's own credential store. And the failure
13
+ // posture is inverted, deliberately:
14
+ //
15
+ // FAIL OPEN. Jev unreachable, unconfident, out of budget, or without a key means the call goes to
16
+ // @gotgenes/pi-permission-system exactly as it did before this layer existed.
17
+ //
18
+ // That is the rule the rest of this extension already follows -- "nothing here is ever the reason a
19
+ // tool is blocked" -- and applying it here is not a weakening. The guard sits *in front of* the
20
+ // permission system, which still decides every call it defers. Fail-closed meant an outage stopped
21
+ // work; fail-open means an outage returns policy to the component that owned it anyway. The guard
22
+ // adds blocking for confident, dangerous verdicts, and adds nothing else.
23
+ //
24
+ // What it sends is bounded by sanitize.mjs like every other system: a redacted digest, never the raw
25
+ // environment, and never more than the shared 1 KB budget.
26
+
27
+ import { score } from "../client.mjs";
28
+ import { THRESHOLDS, scoreLevel } from "../gate.mjs";
29
+
30
+ /** How a decision was reached, for the ledger and for `/jev status`. */
31
+ export const SOURCES = Object.freeze(["local", "jev", "human", "deferred"]);
32
+
33
+ /**
34
+ * One Score, not a Noul.
35
+ *
36
+ * A Noul returns a bare probability with no confidence field, and this is the one system whose
37
+ * action takes a tool call away from the person. Gating that on a number with no reported confidence
38
+ * is exactly what the calibration work found unreachable elsewhere: without a confidence the gate
39
+ * either fires on coin flips or never fires at all. A Score reports both, so "confident this is
40
+ * destructive" and "unsure" are distinguishable, and only the first can block.
41
+ */
42
+ export function questions(input) {
43
+ return {
44
+ risk: score(
45
+ "Rate how likely this call is to destroy data, exfiltrate a credential, or hand control " +
46
+ "of the machine to someone else, given what the person asked for. Judge the call as " +
47
+ "written, not the worst command that shares its first word. Routine development work " +
48
+ "-- building, testing, installing declared dependencies, editing source, committing -- " +
49
+ "is not destructive merely because it writes.",
50
+ [
51
+ "harmless: reads, or writes only within the project in the ordinary course of the task",
52
+ "ordinary: changes state the person would expect this task to change",
53
+ "risky: destroys work, rewrites history, or reaches outside the project, but plausibly intended",
54
+ "destructive: irreversible loss, credential exposure, or remote code execution that the request does not call for",
55
+ ],
56
+ ),
57
+ intended: score("Rate how well this call matches what the person actually asked for.", [
58
+ "unrelated",
59
+ "tangential",
60
+ "plausible",
61
+ "clearly what was asked",
62
+ ]),
63
+ ...(input?.protected === true
64
+ ? {
65
+ credential: score(
66
+ "Rate how likely this write targets a real secret rather than a template, " +
67
+ "example, fixture or documentation file that merely looks like one.",
68
+ ["template or example", "unclear", "likely a real secret", "certainly a real secret"],
69
+ ),
70
+ }
71
+ : {}),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Turn the answers into one of three outcomes.
77
+ *
78
+ * `block` needs a confident destructive reading *and* a confident reading that the request does not
79
+ * account for the call. Both, because the single most likely way to be wrong here is a genuinely
80
+ * destructive-looking command that the person asked for in as many words -- `rm -rf node_modules`, a
81
+ * force push to a branch they named. Requiring the intent answer to actively disagree is what keeps
82
+ * those working.
83
+ *
84
+ * "Both" means both, including when the intent answer does not survive its own confidence gate. An
85
+ * earlier version read a missing intent answer as agreement, which mattered far more than it sounds:
86
+ * `gate.mjs`'s own calibration records a score coverage near 0.2, so roughly four answers in five are
87
+ * ungated and a confident risk=3 would have blocked essentially unconditionally -- turning the one
88
+ * stated safeguard into a clause that almost never applied.
89
+ *
90
+ * `ask` is the middle band, and only when there is a human to ask. Without a UI it becomes `defer`,
91
+ * because a question nobody can answer is a block wearing a friendlier word.
92
+ *
93
+ * Everything else defers to the permission system.
94
+ */
95
+ export function decide(answers, { hasUI = false } = {}) {
96
+ const risk = answers?.risk;
97
+ const intended = answers?.intended;
98
+ if (!risk) {
99
+ return { action: "defer", source: "deferred", reason: "no answer" };
100
+ }
101
+
102
+ const level = scoreLevel(risk, "guard");
103
+ const wanted = intended ? scoreLevel(intended, "guard") : undefined;
104
+
105
+ // A confident secret verdict blocks on its own: a write to a real credential file is not made
106
+ // acceptable by having been asked for, and the fixture case is what the question separates.
107
+ const credential = answers?.credential ? scoreLevel(answers.credential, "guard") : undefined;
108
+ if (credential === 3) {
109
+ return { action: "block", source: "jev", reason: "writes a real credential file" };
110
+ }
111
+
112
+ if (level === 3 && wanted !== undefined && wanted <= 1) {
113
+ return { action: "block", source: "jev", reason: "destructive and not what was asked for" };
114
+ }
115
+
116
+ if (level === 3 || level === 2) {
117
+ return hasUI
118
+ ? { action: "ask", source: "human", reason: level === 3 ? "destructive" : "risky" }
119
+ : { action: "defer", source: "deferred", reason: "no interface to ask" };
120
+ }
121
+
122
+ return { action: "defer", source: "deferred", reason: "below the bar" };
123
+ }
124
+
125
+ /** The two answers the guard's confirmation dialog offers, in the order it offers them. */
126
+ export const CHOICES = Object.freeze({ run: "Run it", block: "Block it" });
127
+
128
+ /**
129
+ * Whether a human's answer to that dialog is consent.
130
+ *
131
+ * Only the affirmative is. Escape, a dismissed picker, a host that resolves with nothing and a host
132
+ * that throws all produce something that is not `CHOICES.run`, and every one of them has to mean
133
+ * "not approved" -- reaching this point means the verdict already said the call needs a person's
134
+ * approval, and an unanswerable question resolved as yes is what a confirmation dialog exists to
135
+ * rule out. It lives here rather than in the handler so that it is a rule with a test, not a
136
+ * comparison inside a closure no test imports.
137
+ */
138
+ export function approved(choice) {
139
+ return choice === CHOICES.run;
140
+ }
141
+
142
+ /** The gate's own numbers, named so `/jev status` and the tests read the same source. */
143
+ export const GATE = Object.freeze({
144
+ scoreConfidence: THRESHOLDS.guard.scoreConfidence,
145
+ boundary: THRESHOLDS.guard.boundary,
146
+ });
147
+
148
+ /**
149
+ * The state the question is asked against, bounded like every other system's.
150
+ *
151
+ * What the model needs is the call, what the person asked for, and enough recent history to tell a
152
+ * cleanup step from a first move. What it must not receive is the environment, the file's contents,
153
+ * or anything the sanitiser has not seen: `buildState` applies the shared redaction and the 1 KB
154
+ * ceiling, so a long command arrives truncated rather than in full.
155
+ */
156
+ export function buildInput({ tool, subject, protectedTarget, objective, recent, cwd }) {
157
+ return {
158
+ system: "command-guard",
159
+ tool: String(tool ?? ""),
160
+ call: String(subject ?? ""),
161
+ protectedTarget: protectedTarget === true,
162
+ // Why the call was made matters as much as what it does: the same command is routine in one
163
+ // task and destructive in another, and the intent question has nothing to weigh without it.
164
+ objective: String(objective ?? ""),
165
+ recent: Array.isArray(recent) ? recent.slice(-6).map((item) => `${item.tool}:${item.outcome}`) : [],
166
+ cwd: String(cwd ?? ""),
167
+ };
168
+ }