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.
- package/CHANGELOG.md +37 -0
- package/README.md +2 -2
- package/SECURITY_MODEL.md +15 -9
- package/THIRD_PARTY.md +1 -2
- package/extensions/jev-advisor/broker.mjs +1 -0
- package/extensions/jev-advisor/client.mjs +17 -27
- package/extensions/jev-advisor/config.mjs +45 -29
- package/extensions/jev-advisor/gate.mjs +14 -0
- package/extensions/jev-advisor/index.ts +255 -105
- package/extensions/jev-advisor/key-source.mjs +252 -0
- package/extensions/jev-advisor/layer.mjs +169 -0
- package/extensions/jev-advisor/questions/guard.mjs +168 -0
- package/extensions/jev-advisor/risk.mjs +442 -0
- package/extensions/jev-advisor/usage.mjs +1 -1
- package/package.json +1 -1
- package/scripts/packages.mjs +56 -0
- package/scripts/specpi.mjs +53 -16
- package/templates/settings.json +1 -2
- package/extensions/jev-advisor/guard.mjs +0 -140
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.28.0 - 2026-09-18
|
|
4
|
+
|
|
5
|
+
- Read the key Pi already has. The layer resolved its credential from `OPENROUTER_API_KEY` and nothing else, while Pi had long since stored an OpenRouter key where it stores every provider credential — the `openrouter` entry that `/login openrouter` writes to `auth.json`. So a person who had logged in, whose every model call was working, was told `key: missing` by the one component that had opted out of Pi's credential store, and there was no interface anywhere that would have explained the gap. The advisor now follows Pi's own documented resolution order, the store first and then the environment, which is what makes `/login openrouter` the answer to "how do I configure the API key" rather than a second thing to set up. `/jev status` lists every source and marks the one in force, because "missing" with nothing to act on is what sent people looking for a key field that does not exist.
|
|
6
|
+
- Make turning the layer on turn the layer on. `/jev on` set the master switch and left all seven systems off, so the layer ran and did nothing; the notification said so and then asked for seven more commands. Enabling it now enables every system that is off, and only when none are on, so a deliberate subset survives being toggled. The command guard is one of the eight and comes on with them, and because it is the only system that can refuse a tool call, the notification now says so in as many words rather than listing it among seven that only ever add advice.
|
|
7
|
+
- Remember the switch. `/jev on` changed one session and wrote nothing, so the layer had to be re-enabled from scratch every time Pi started — the startup preference was a separate command most people never found. Turning the layer on or off now writes the preference, with `--session` for the one-off case that must not change tomorrow.
|
|
8
|
+
- Stop storing one intention as two switches that can cancel each other. `master` and `startup` are both written whenever either changes, because the advisor only acts when both are true: a file saying `master: true, startup: false` describes a layer that is on and never runs, which is exactly the state the Chat panel's two independent checkboxes made it easy to save.
|
|
9
|
+
- Drop the `specpi-jev-guard` package and build the command guard into the layer natively, as its eighth system. Four review rounds kept returning to the same handful of causes, and all of them were properties of that package rather than of the feature: its configuration was one global file with no session scope, so there was no such thing as enabling it for a session and `--session` could not scope it; it read its key from the environment only, so the credential `/login openrouter` had already stored was invisible to it; and it was fail-closed, so a missing or stale key turned every shell call in the session into a refusal. As `systems.guard` it is gated, budgeted, reported and toggled by exactly the same code as the other seven — `/jev enable guard`, one key, one budget, one switch — and the separate `guard: { enabled, startup }` pair is gone with the persistence disagreements it caused. Schema 3 migrates `guard.startup` into `systems.guard`, and the base drops from eight pinned packages to seven. An install that already has the package is unpinned on update, by name and only when the entry is the one SpecPi wrote, because dropping a line from the template would otherwise leave a fail-closed gate running on every machine that already had it with the command to disarm it deleted. `specpi doctor` reports a retired entry that is still configured. Its downloaded files stay where Pi put them; Pi stops loading it.
|
|
10
|
+
- Invert the failure posture. The native guard **fails open**: no key, no budget, a timeout, an unconfident answer or a middle-band verdict with nobody to ask all hand the call to `@gotgenes/pi-permission-system`, which decides it exactly as it did before the layer existed. That is not a weakening — the guard sits in front of the permission system, so fail-closed meant an outage stopped work while fail-open returns policy to the component that owned it anyway. It is also the rule the rest of the extension already followed.
|
|
11
|
+
- Hold the local allowlist to one rule: a binary is admitted only if it is simple whatever flags it is given. `rg --pre` runs an arbitrary preprocessor, `date -s` sets the clock, `hostname` with an argument sets the hostname, `file -C` writes a compiled magic file — none reads as dangerous, all four were admitted, and each was a general bypass. The list is a *budget* mechanism rather than a safety one, which is the thing that had gone unsaid: the guard has 208 calls, so a fast path exists to stop a session of `ls` and `grep` spending them all and leaving nothing for the calls that matter. That makes the admission test "would asking about this spend the budget without learning anything", and under it a rare binary is never worth a silent hole. Jev is the analyser; anything that merely looks simple belongs to it.
|
|
12
|
+
- Say why the two local lists are maintained under opposite pressures, in the file itself. A wrong `safe` is a silent permanent hole, because it is the one verdict with no second reader; a wrong `unknown` costs one call out of 208 and Jev decides; a wrong `dangerous` blocks real work with no recourse but switching the guard off. Six review rounds treated both lists as one thing called "the guard" and hardened them the same way, which is backwards.
|
|
13
|
+
- Stop writing fixture credentials in real vendor shapes. Every "secret" in the suite was a fixture and always had been, but they were spelled like genuine OpenRouter and Anthropic keys, so GitGuardian failed the pull request on them — the scanner working exactly as intended and the fixtures being wrong. They are named for what they are now, and a test refuses any tracked file containing a vendor prefix followed by a plausible key body, because a scanner that cries wolf on your own test data is one people learn to click past.
|
|
14
|
+
- Close the ways round the guard that its own first implementation left open. A read-only binary was judged by its name and never its arguments, so `cat ~/.ssh/id_rsa` and `grep -r . ~/.aws/credentials` took the free path — leaving the exfiltration half of the question the guard asks unreachable for the commands that answer it; `printenv` was on the same list for the same reason and is not any more. `env`, `find`, `fd`, `sort` and `uniq` sat there too, each one a general bypass: `env rm -rf build` begins with a binary that changes nothing. The catastrophic delete rule anchored on the end of the line, so it matched `rm -rf /`, which GNU `rm` refuses on its own, and missed `rm -rf / --no-preserve-root`, which does not. And a shell tool's text was read from `input.command` alone, so `write_stdin` — which types into a live shell — was classified as the empty string and spent a budgeted call asking about nothing.
|
|
15
|
+
- Stop two failures from turning into consent. `ctx.ui.notify` reaches the host over RPC and can throw; called inline inside the guard's fail-open catch, one failed notification unwound a decided refusal into an allow. And a `ctx.ui.select` that rejected — a host without the method, a disconnect, a cancel that throws rather than resolving — was swallowed by the same catch and read as approval, on the single path in the feature where a human was asked directly. The announcement can no longer change a decision, and a question that could not be put is a refusal. Which answer counts as consent is now one exported function with a test, rather than a string comparison inside a closure nothing imports.
|
|
16
|
+
- Say that the guard can refuse a command on every path that arms it, not only on `/jev on`. `/jev enable guard` and `/jev startup on` armed the same system in silence, so the first thing either taught you was a blocked call — the outcome the rule exists to prevent.
|
|
17
|
+
- Give the command guard the history it is documented to weigh. `recent` was appended to in exactly one place, inside retention's success path, so a session running the guard with retention off evaluated the intent half of the block rule against an empty history for its whole length. Every tool result is recorded now; retention refines its own entry rather than adding a second.
|
|
18
|
+
- Cache an unparseable `auth.json` as firmly as a parseable one. Recording only successful parses left the worst case uncached: a truncated store threw on every call, so every request paid a fresh stat, a 256 KiB read and a failing parse inside the same latency budget the cache was added to protect, forever.
|
|
19
|
+
- Unpin a retired package whatever shape its entry has, and keep that write inside the installer's transaction. Preserving a user-modified `specpi-jev-guard` entry preserved the fail-closed gate this release deleted the controls for — the deliberate exception to "a modified entry is yours", and the only one. The write also ran outside the watched set on the `--skip-package-install` path, so it was neither backed up nor rolled back when a later step in the same run failed.
|
|
20
|
+
- Stop the Chat panel re-arming eight systems when someone turns the last one off. `couple` read that as the broken dead-layer file it repairs and ticked every box back on, the blocking command guard included, with a note describing a file that never existed — while `/jev disable` read the identical situation as "switch the layer off". The panel now does the same, and still repairs a file that genuinely arrives dead.
|
|
21
|
+
- Give the guard its own gate thresholds. It asked under the name `gap`, which `thresholdsFor` resolves identically by falling through its default — so adding a `guard` entry, the natural change for the one system whose action takes a tool call away, would have changed nothing at all and said nothing about it.
|
|
22
|
+
- Stop `/jev off` reading Pi's credential store to discard the answer, and stop `applyLayer` accepting two arguments it never looked at.
|
|
23
|
+
- Settle most calls locally, for nothing. Read-only commands and ordinary project writes never leave the machine, and a deliberately tiny list of catastrophic, unambiguous commands is blocked with no call at all. A `ls` costs nothing; a command carrying any shell control character never takes the fast path, because `ls; rm -rf ~` begins with `ls`. Blocking on a Jev verdict needs two answers to agree — a confident destructive reading *and* a confident reading that the request does not account for the call — because the likeliest way to be wrong is a destructive-looking command the person asked for in as many words. Both answers must survive the confidence gate: reading a missing intent answer as agreement would have made the safeguard apply to about one call in five, since roughly four Score answers in five do not gate. The shell tools are gated under every alias the harness maps onto `bash`, the write tools under every name `multi_edit`, `apply_patch`, `create_file` and `str_replace` arrive as, and a write whose target cannot be read is asked about rather than waved through. When the guard asks a human, only the affirmative runs the call; dismissing the dialog is not consent.
|
|
24
|
+
- Make Chat and the advisor agree about a symlinked `auth.json`. One reader was fixed to follow links and the other was not, so `/jev status` reported "in use from auth.json" beside a panel reporting "No key anywhere" — the drift the pin test exists to catch, which it missed because it never created a link. It does now.
|
|
25
|
+
- Merge `/jev enable` and `/jev disable` into the stored systems rather than overwriting them with the session's copy, which silently turned off systems enabled on disk since the session started. Disabling the last system now switches the layer off and says so, instead of writing the dead-layer state that four other code paths exist to prevent.
|
|
26
|
+
- Cache the parsed credential store against the file's size and modification time. `ask()` resolves a key per request and retention fires on every large tool result, so the store was being stat'd, read and parsed inside a 1500 ms latency budget on the tool path, where it used to be one environment lookup.
|
|
27
|
+
- Move `/jev on` and `/jev off` out of `index.ts` and into a module a test can call. Nothing imported that file, so the ~200 lines that are this feature were covered by prose alone while the suite reported green — which is how two review rounds found, among other things, a stored preference destroyed by a command that had decided nothing about it, a notification reporting a gate "left off" while it was on and blocking every call, and a rollback that never ran because the flag was set before the write. `layer.mjs` takes its world as an argument and holds no state, so what `/jev on` means is now something a test can call rather than something the prose asserts.
|
|
28
|
+
- Bind the backend in `keyPresent`, `keySource`, `keySources` and `keyEnvName`, not just `apiKey`. All five were re-exported with a `"openrouter"` parameter default, so `keyPresent()` reported a key on the direct TypeSafe route that `resolveKey()` would never return. `backend()` moved into `key-source.mjs` so each one binds it rather than defaulting to a literal.
|
|
29
|
+
- Read a symlinked `auth.json`. Refusing links recreated the exact divergence this layer was written to remove: a dotfile manager links the file, Pi resolves the credential, every model call works, and this layer alone reports "key: none found". Links are refused where SpecPi *writes*; this is a bounded read of a file Pi owns.
|
|
30
|
+
- Stop the Chat panel throwing while opening the very file it exists to repair. A layer on with no systems was a validation error, so the panel rendered red with Save disabled before anything was touched, and the one mechanism that could fix it skipped the case. The rule now lives where the write happens: the form repairs and announces it, and the host refuses it on save.
|
|
31
|
+
- Persist `/jev enable` and `/jev disable`. They announced a session-scoped change that a later `/jev on` copied to disk anyway, so a choice described as temporary became permanent through an unrelated command.
|
|
32
|
+
- Write both halves of the startup preference in `/jev startup`, which had been the one command still writing `startup` without `master` — the exact two-keys-for-one-intention trap this release removes, left in the command named after it, while its own notification claimed new sessions would start on. It also fills in the systems when none are chosen, for the same reason `/jev on` does.
|
|
33
|
+
- Bind `apiKey()` to the active backend again. Re-exporting the resolver under that name rebound every no-arg caller — the calibration and triage scripts among them — to the OpenRouter default, so with `JEV_BACKEND=typesafe` a script's `if (!apiKey())` guard passed on a stored OpenRouter key while every request underneath it returned `no-key`.
|
|
34
|
+
- Keep measured runs off a personal account. With the credential store consulted first, `scripts/jev-calibrate.mjs` and `scripts/jev-triage.mjs` would have billed a developer's own `/login openrouter` credential rather than the key in `evals/.env`, and `--probe` would have verified a key the run did not use. Both now set `JEV_KEY_SOURCE=environment`, which restricts resolution to the environment.
|
|
35
|
+
- Refuse a layer that is switched on with nothing to run. The Chat panel filled in the systems in its form, but the full-configuration textarea bypassed that, so the dead-layer state this release exists to remove stayed one hand-edit from disk. It is refused rather than silently corrected.
|
|
36
|
+
- Report the key from one place in the Chat panel. `saveJev` resolved `auth.json` from a different agent directory than `loadJev`, so with a workspace-relative `PI_CODING_AGENT_DIR` pressing Save flipped a working panel to "No key anywhere"; and the report named the credential store even on the direct TypeSafe API, which has no entry there. Both now follow the advisor's own conditions.
|
|
37
|
+
- Stop the advisor's tests from writing to the real agent directory, which they had been doing for as long as any of them were async. `withAgentDir` wrapped its callback in `try/finally` around a bare `return run(dir)`, and an async callback returns its promise at the first `await` — so the cleanup ran there, restoring `PI_CODING_AGENT_DIR`, `HOME` and `USERPROFILE` to the developer's real values and deleting the temporary directory while the test body was still going. Nineteen tests in that file pass an async callback, so most of the suite was isolated only up to its first suspension point. It destroyed a real credential store before it was found: a fixture `auth.json` written after an `await` landed in a developer's own `~/.pi/agent`, and because Pi's `AuthStorage` merges onto whatever it reads, one write replaced three logged-in providers with the single fake entry the fixture held. The suite reported every test passing while doing it, which is what kept it invisible. Cleanup now waits for a returned promise, and the fixture writer refuses any path outside the temporary directory: OAuth tokens cannot be recovered, so a failed test is the only acceptable cost of that check.
|
|
38
|
+
- Isolate the key variables in the advisor's tests. `withAgentDir` restored `OPENROUTER_API_KEY` afterwards but never cleared it for the body of a test, so "a missing key reads as unavailable" passed or failed depending on the shell the suite was started from.
|
|
39
|
+
|
|
3
40
|
## 0.27.0 - 2026-09-18
|
|
4
41
|
|
|
5
42
|
- Put a measured number on the rule the whole layer is built around. "Any tool-set decision happens once, before the first request, or not at all" rested on a cache argument that was reasoned about here for months and never weighed. Three arms on `t3-cascade-ledger` differing only in when Browser QA's fourteen tools reach the request settle it: flipping them on at turn 6 collapsed cached tokens to 3,200 at the very next request in three attempts out of three — from 25,984, 17,792 and 21,120 — while the prompt kept climbing, one request going from 3,673 fresh tokens to 31,337. The re-warm cost 14.6%, 21.6% and 23.9% of the attempt, against a 10% threshold written down before the run. Arming the same group from turn 1 cost 16% more than never arming it, against 47% for flipping mid-session, so paying up front is about three times cheaper than paying when the need appears. Recorded to `evals/runs/cache-probe/`; `scripts/cache-probe.mjs` reproduces the analysis.
|
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ Everything it touches is written down, versioned, and easy to undo.
|
|
|
44
44
|
|
|
45
45
|
## Measured context
|
|
46
46
|
|
|
47
|
-
This chart shows first-call context from a clean install: all seven pinned packages, the working agreement, and the skills Pi finds. It
|
|
47
|
+
This chart shows first-call context from a clean install: all seven pinned packages, the working agreement, and the skills Pi finds. It has not been re-measured since the command guard became a native system rather than an eighth pinned package. "Enabled" means browser QA, delegation, and web access are switched on, with no goal, scope, or improvement selection active.
|
|
48
48
|
|
|
49
49
|
The solid rows are measured by us, from the request each setup actually sends through one local test provider. That includes OpenCode, the DeepSeek Harness, and Oh My Pi, all measured as installed. The faded Codex CLI and Claude Code rows come from HarnessTax's published numbers, measured under their own setup. Treat those as a rough reference, not a head-to-head test. These are character counts. They say nothing about tokens, cost, or how well each tool does the job. The research page [breaks down the enabled setup by feature](https://tannermidd.github.io/SpecPi/research/#specpi), so you can see what each switch costs on its own.
|
|
50
50
|
|
|
@@ -123,7 +123,7 @@ Full setup options, package details, and requirements: [website](https://tannerm
|
|
|
123
123
|
|
|
124
124
|
| | |
|
|
125
125
|
| --- | --- |
|
|
126
|
-
| [Packages](https://tannermidd.github.io/SpecPi/#packages) | The
|
|
126
|
+
| [Packages](https://tannermidd.github.io/SpecPi/#packages) | The seven pinned packages and what each provides |
|
|
127
127
|
| [Scope control](https://tannermidd.github.io/SpecPi/wiki/#scope) | `/scope` commands and drift monitoring |
|
|
128
128
|
| [Improvement loop](https://tannermidd.github.io/SpecPi/#loop) | Local wishlist, `/harness-improvement`, and retirement with evidence |
|
|
129
129
|
| [SpecPi Chat](https://tannermidd.github.io/SpecPi/#vscode-chat) | VS Code frontend and VSIX install · [Chat guide](https://github.com/TannerMidd/SpecPi/blob/main/vscode/README.md) |
|
package/SECURITY_MODEL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Security model
|
|
2
2
|
|
|
3
|
-
SpecPi provides scope monitoring, an explicit harness improvement loop and an optional Jev advisor, and installs
|
|
3
|
+
SpecPi provides scope monitoring, an explicit harness improvement loop and an optional Jev advisor, and installs seven pinned packages as its default base. Extensions run as trusted code with Pi's permissions. Scope is not an OS sandbox or a general command guard. Use OS isolation for hostile code.
|
|
4
4
|
|
|
5
5
|
## Scope monitoring
|
|
6
6
|
|
|
@@ -30,23 +30,29 @@ The advisor also keeps `<agent-dir>/specpi/jev/usage.json`, the running call cou
|
|
|
30
30
|
|
|
31
31
|
The advisor holds no authority. It never grants a capability, never calls a tool and never allows one. Its only blocking action is to refuse a capability-gap report that looks like it carries a credential, which asks the model to rewrite its own text and discards nothing. Failure is silent, not closed: a timeout, HTTP error, missing key, missing consent, an exhausted per-system or session budget, or ungated confidence produces no advice, and the existing code path runs unchanged. Tool-result retention can shorten a large read-only result before it is appended; it never alters a write, edit or error result, and the replacement says the output is recoverable by re-running.
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
Three of the eight systems do something the other five do not, and all three are off by default like everything else here.
|
|
34
34
|
|
|
35
35
|
**System 5, progress detection**, is the only one that can change what the model does. On a confident verdict that a session has stopped making progress it adds one fixed, code-written line at a turn boundary. It ships set to `notify`, which tells the person and cannot change the session; `message`, which appends the line for the model to read, is a deliberate opt-in. It is written once per session and never retracted, it never aborts or blocks, and it is suppressed entirely when the same call reports that a person would have to answer something first. Nothing the model produced ever enters the transcript: the line is chosen from a fixed table by failure mode.
|
|
36
36
|
|
|
37
|
+
**System 8, the command guard**, is the only one that can refuse a tool call, and it has its own section below. It is named here because the rest of this layer never blocks anything, and a reader who took "the advisor holds no authority" to cover every system would be wrong about exactly one of them. `/jev on` enables it with the other seven and says in the same notification that it can refuse a command; `/jev disable guard` turns it off. An unattended schema migration never arms it.
|
|
38
|
+
|
|
37
39
|
**System 7, untrusted-content classification**, prepends a fixed warning line to externally fetched content that confidently reads as instructions addressed to an agent. It applies only to web and browser tool results, never to the agent's own shell output or file reads. It is defence in depth and explicitly not a control: it never blocks, it has no authority over what the model then does, and it should not be relied on to stop prompt injection. Its value is that untrusted content in a fetched page is currently owned by nothing at all.
|
|
38
40
|
|
|
39
41
|
An earlier version of this document said its false-positive rate was measurable on tier 5 of the eval suite. It is not, and the re-run that checked found out why: **this system has never been called once**. It rides on a web or browser tool result, no task in any tier produces one, and the run that would have exercised Browser QA keeps it withdrawn because capability arming needs an interactive human. So the honest statement is that system 7 ships unmeasured, and the eval suite as it stands cannot measure it. That is a gap in the suite, not a property of the system, and it is stated here rather than left as an absent number that would read as a zero.
|
|
40
42
|
|
|
41
|
-
Settings live in `<agent-dir>/specpi/jev/settings.json`, written atomically with owner-only permissions. Links, irregular files, oversize files, an unrecognised schema and unparseable contents all read as off. The one exception is the layer's own previous schema: a `schema: 1` file is migrated forward rather than read as unrecognised, because "collapse to all-off" is a rule for corrupt input and applying it to our own earlier version would silently disable a layer the user had switched on. Call budgets are per system under a session total, so one busy system cannot exhaust the allowance of the others and leave them dead for the rest of the session with event ordering deciding which one won.
|
|
43
|
+
Settings live in `<agent-dir>/specpi/jev/settings.json`, written atomically with owner-only permissions. Links, irregular files, oversize files, an unrecognised schema and unparseable contents all read as off. The one exception is the layer's own previous schema: a `schema: 1` file is migrated forward rather than read as unrecognised, because "collapse to all-off" is a rule for corrupt input and applying it to our own earlier version would silently disable a layer the user had switched on. Call budgets are per system under a session total, so one busy system cannot exhaust the allowance of the others and leave them dead for the rest of the session with event ordering deciding which one won. Turning the layer on or off writes that preference, because a switch that forgets is not a switch; `--session` is how a one-off change is kept out of the file. The key is resolved the way Pi resolves every provider credential, in Pi's own documented order: the `openrouter` entry that `/login openrouter` writes to `<agent-dir>/auth.json`, then the environment variable. That is a deliberate, narrow exception to the rule that Pi state is never read, and it is bounded to exactly one question — the one `api_key` entry for the one provider this layer calls. Be precise about when: the key's *value* is read only on a call path the master switch already gates, but whether an entry *exists* is checked whenever status is displayed, including with the layer off, because that is the answer someone needs in order to configure it in the first place. A presence check reports a source name and nothing else. An `oauth` entry is ignored rather than unwrapped, because Pi refreshes those under its own lock and a second reader would race a rotation. The value is returned by a single function straight into a request header; nothing else in the layer receives it, and status output, the ledger and the Chat panel carry source names only. Pi trust decisions, sessions, missions and history are never read. `JEV_KEY_SOURCE=environment` restricts resolution to the environment, which is what this repository's own eval scripts set so a measured run cannot silently bill a developer's personal account.
|
|
42
44
|
|
|
43
45
|
## Command policy and the Jev guard
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
The command guard is the layer's eighth system, native since schema 3, and ships off like the other seven. It was a pinned third-party package until that package's shape proved to be its own source of defects: a global configuration file with no session scope, a key read from the environment only — so a credential `/login` had stored was invisible to it — and a fail-closed posture, which meant an outage or a missing key stopped shell work rather than degrading. `@gotgenes/pi-permission-system` stays pinned and decides every call the guard defers, which is every call it does not confidently judge destructive.
|
|
48
|
+
|
|
49
|
+
The guard **fails open**, which is the deliberate inversion of what it replaced. Local rules settle most calls without sending anything: simple commands and ordinary project writes pass for nothing, and a very short list of catastrophic, unambiguous commands is blocked with no call at all. The allowlist is a budget mechanism and not a safety one — it exists so a session of `ls` and `grep` cannot spend the guard's whole call budget and leave nothing for the calls that matter — so a binary is admitted only when it is simple whatever flags it is given. Anything that can launch a program, write a file or change machine state under any flag goes to the model, however harmless its name reads. The catastrophic list is separate and deliberately tiny: its real job is the case where Jev is unreachable, since a model that can weigh intent catches everything a pattern would and more. Anything else is scored, and a call is blocked only on a confident destructive verdict that the request does not account for — both, because a destructive-looking command the person asked for in as many words is the likeliest way to be wrong. A confident verdict that a write targets a real credential file blocks on its own. Everything else — no key, no budget, a timeout, an unconfident answer, or a middle-band verdict with no human to ask — hands the call to `@gotgenes/pi-permission-system`, which decides it exactly as it did before this layer existed. Deferring is not allowing.
|
|
50
|
+
|
|
51
|
+
Both halves of the block rule must clear the same confidence gate. A destructive verdict alone is not enough and neither is an absent intent answer read as agreement: roughly four Score answers in five do not gate, so treating "no answer" as "not what was asked for" would have made the second half apply to about one call in five. When there is a human and the guard asks, only the affirmative reply runs the call; a dismissed dialog is not consent.
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
What is gated is stated rather than implied: the shell tools under every alias the harness maps onto `bash`, and the write tools under every name — `write`, `edit`, `multi_edit`, `apply_patch`, `create_file`, `str_replace`. A shell call's text is read from whichever field carries it, so a tool that types into a live shell is judged on what it types rather than on an empty string. A write whose target cannot be read from the tool input is asked about rather than passed, because a field this layer does not recognise should cost a question and not open a hole. A protected path is matched anywhere in a resolved path, directory as well as file, so `secrets/api.txt` is a secret and not an ordinary project file, and a read-only command pointed at one — `cat ~/.ssh/id_rsa` changes nothing and still hands over a key — leaves the free path and is asked about like anything else. Neither the guard nor the advisor is an OS sandbox.
|
|
48
54
|
|
|
49
|
-
|
|
55
|
+
Two failures do not fail open, and both are about a person. An announcement that cannot be delivered never changes a decision already taken; and once a verdict says a call needs human approval, a host that cannot present the question has not obtained it, so the call is refused rather than run. Everything that fails open fails open *before* anyone was asked.
|
|
50
56
|
|
|
51
57
|
## Improvement authority and evidence
|
|
52
58
|
|
|
@@ -60,11 +66,11 @@ Wishlist records remain local under `<agent-dir>/specpi/`. Sanitization and salt
|
|
|
60
66
|
|
|
61
67
|
Package acquisition requests exact npm dependency saves through the child process environment and verifies the installed top-level versions before completing the transaction. A mismatch fails the operation and triggers managed-state rollback. Upstream transitive dependency ranges remain outside this pinning guarantee.
|
|
62
68
|
|
|
63
|
-
`plan` is read-only. Installation, updates, and removal require confirmation or `--yes`. SpecPi manages its two first-party extension families, improvement skill, manifest, AGENTS marker block, and the
|
|
69
|
+
`plan` is read-only. Installation, updates, and removal require confirmation or `--yes`. SpecPi manages its two first-party extension families, improvement skill, manifest, AGENTS marker block, and the seven package entries listed in `templates/settings.json`. Normal install/update runs `pi install npm:<name>@<pin>` for every default package. Only package entries are merged; existing resource filters and unrelated settings are retained. `--skip-package-install` allows a core-only install or preserves an already configured base during update, skipping Chromium setup too. Confirmed install/update otherwise invokes the installed Browser QA Node bin with bounded subprocesses to download Chromium and check readiness. `--skip-browser-install` skips that setup only; doctor still checks readiness. SpecPi never passes `--with-deps`, auto-installs OS libraries, or installs/removes Bun. No new shell profile integration is installed.
|
|
64
70
|
|
|
65
71
|
Managed configuration and files are locked and backed up before mutation; first-party writes are atomic and checksum-tracked. Failure restores the saved configuration and first-party files. Package acquisition runs upstream package-manager scripts and may leave downloads, dependency changes, or external script effects even after configuration rollback. Those effects, including downloaded browser-cache bytes, are outside SpecPi's transaction and survive uninstall. Updates require `--force` before replacing modified retained resources. Retired resources are backed up before deactivation; pre-install files are restored where ownership records identify them. Old runtime directories are moved into backups without inspecting their contents. Backups and private evidence remain after uninstall and can contain sensitive local material.
|
|
66
72
|
|
|
67
|
-
Legacy migration restores only recorded settings ownership, preserves differing user values, and removes only the SpecPi shell marker block before applying the new base. The installer does not enumerate or modify authentication, provider credential stores, trust, sessions, missions, history, or unrelated private evidence. Normal updates deliberately reapply the default package pins, retaining the original entry for removal. Uninstall restores only package entries that still match the last installed value; user edits are preserved. Downloaded packages and tools are not deleted. Resources that SpecPi never owned require separate human management.
|
|
73
|
+
Legacy migration restores only recorded settings ownership, preserves differing user values, and removes only the SpecPi shell marker block before applying the new base. The installer does not enumerate or modify authentication, provider credential stores, trust, sessions, missions, history, or unrelated private evidence. Normal updates deliberately reapply the default package pins, retaining the original entry for removal. A package a past version pinned and this one has retired is unpinned by name on install and update, including under `--skip-package-install`, because skipping acquisition has never meant leaving an entry SpecPi wrote pointing at code whose controls SpecPi has removed; `specpi doctor` reports one that is still configured. Its downloaded files remain, as every other package's do. Uninstall restores only package entries that still match the last installed value; user edits are preserved. Downloaded packages and tools are not deleted. Resources that SpecPi never owned require separate human management.
|
|
68
74
|
|
|
69
75
|
## Browser QA package
|
|
70
76
|
|
|
@@ -72,7 +78,7 @@ Legacy migration restores only recorded settings ownership, preserves differing
|
|
|
72
78
|
|
|
73
79
|
## VS Code frontend
|
|
74
80
|
|
|
75
|
-
SpecPi Chat is a separate VSIX, retained alongside the npm harness. It launches Pi only in a trusted filesystem workspace and communicates over local RPC. Provider credentials stay with Pi. Sign-in is delegated rather than implemented: Chat can start the configured Pi executable in a user-visible VS Code terminal, without RPC flags or a session, and Pi alone prompts, runs any OAuth flow, and writes `auth.json`. Chat sends no input to that terminal,
|
|
81
|
+
SpecPi Chat is a separate VSIX, retained alongside the npm harness. It launches Pi only in a trusted filesystem workspace and communicates over local RPC. Provider credentials stay with Pi. Sign-in is delegated rather than implemented: Chat can start the configured Pi executable in a user-visible VS Code terminal, without RPC flags or a session, and Pi alone prompts, runs any OAuth flow, and writes `auth.json`. Chat sends no input to that terminal, runs no Pi auth subcommand, and accepts no credential over RPC; it reads `auth.json` for one question only — whether an `openrouter` api_key entry is present — and the host sends the webview a boolean per source and never a value, so the panel reports where a key would come from without being a place one could leak from; it observes only the terminal's closure, and restarts the connection so Pi re-resolves its catalogue. Its sign-in prompt is derived from Pi's own available-model list and missing-credential error text. The frontend manages only its own workspace-storage conversation catalog and sessions for user-directed history, branching, and export; it does not import unrelated Pi histories. Attachments, images, webview messages, and file navigation are validated and bounded. Rendered model and tool output is untrusted text, never executable HTML.
|
|
76
82
|
|
|
77
83
|
Approval replies are tied to the current conversation, client, and request ID. Disconnect, cancellation, and expiry never grant permission. Multiline package approval context is shown in the dialog body; requests exceeding the display budget are cancelled rather than approved against incomplete context. The Permissions editor can change only the package's documented global/project `config.json`, with bounded UTF-8/schema validation, explicit native confirmation, connection/scope binding, stale-revision checks, private backups, and atomic replacement. It reads no permission logs, trust decisions, credentials, or agent frontmatter. Symlinks, hardlinks, and special files are refused. Backups remain local and may contain sensitive policy; another same-user process can still race filesystem operations. Saving does not claim runtime activation: the user can restart the selected chat and inspect `/permission-system show`. Global/project changes may affect other chats as upstream reloads them; session approvals are cleared by restart. Delegated agent activity comes from a read-only, connection-local widget the delegation package publishes; Chat registers no tools or commands of its own for it. The VSIX does not install packages or duplicate their enforcement; only Permission System configuration has an explicit editing UI. TUI-only custom components are outside Pi's RPC rendering support.
|
|
78
84
|
|
package/THIRD_PARTY.md
CHANGED
|
@@ -24,7 +24,6 @@ SpecPi requests exact npm dependency saves for these pins and checks installed t
|
|
|
24
24
|
| pi-goal-x | 0.31.2 | [tmonk/pi-goal-x](https://github.com/tmonk/pi-goal-x) |
|
|
25
25
|
| @sreetej510/pi-usage | 0.10.0 | [Sreetej510/pi-extensions](https://github.com/Sreetej510/pi-extensions) |
|
|
26
26
|
| @gotgenes/pi-permission-system | 32.0.2 | [gotgenes/pi-packages](https://github.com/gotgenes/pi-packages) |
|
|
27
|
-
| specpi-jev-guard | 0.1.0 | [TannerMidd/specpi-jev-guard](https://github.com/TannerMidd/specpi-jev-guard) |
|
|
28
27
|
|
|
29
28
|
Transitive dependencies and their notices remain in Pi's npm installation tree. Top-level pins do not freeze upstream dependency ranges or constitute a full transitive security audit. Pi invokes npm with its upstream package-management semantics, including dependency lifecycle scripts. Browser QA has no install hook: confirmed SpecPi install/update explicitly invokes its installed Node setup bin, unless acquisition or browser setup is skipped. It downloads package-pinned Chromium without installing OS libraries. BetterWright is optional/manual and retains its own Bun-based setup; SpecPi neither removes Bun nor deletes user-owned tools. Usage reporting and web packages make their own provider/service connections. Consult upstream licenses and security policies before redistributing their components.
|
|
30
29
|
|
|
@@ -34,7 +33,7 @@ The combined base is tested with Pi 0.84.4. Pi Goal X declares Pi `>=0.83.0 <0.8
|
|
|
34
33
|
|
|
35
34
|
## Network services
|
|
36
35
|
|
|
37
|
-
The optional Jev
|
|
36
|
+
The optional Jev layer, command guard included, reaches TypeSafe's System One endpoint. It is not a bundled dependency: it is first-party source that uses Node's built-in `fetch` and declares no package. The guard used to be a separate pinned package; it is now the layer's eighth system, so one switch, one budget and one key serve the whole thing. That key is resolved the way every other Pi provider credential is resolved, in Pi's own documented order: the `openrouter` entry that `/login openrouter` writes to `<agent-dir>/auth.json`, then `OPENROUTER_API_KEY` from the environment (`TYPESAFE_API_KEY` when `JEV_BACKEND=typesafe` selects the direct API). The two keys are not interchangeable: the other service rejects the wrong one with a bare 401. SpecPi never provisions or stores a provider credential, and reads one only to place it in the request header of the call the user has switched on.
|
|
38
37
|
|
|
39
38
|
Requests go to `openrouter.ai` by default, or to `api.typesafe.ai` on the direct backend, unless `TYPESAFE_BASE_URL` redirects them, which exists so tests never reach the network and the eval proxy can price advisor traffic. Retention and processing of what is sent are governed by TypeSafe and, on the default route, by OpenRouter as well -- not by SpecPi; the advisor ships off, asks before its first transmission and records a hash of every payload locally. The frozen eval price list carries `jev-1.13.0` at $0.042/MTok input and free output so a measured SpecPi + Jev row never reports a cost that excludes its own advisor.
|
|
40
39
|
|
|
@@ -28,6 +28,7 @@ export const SYSTEM_LABELS = Object.freeze({
|
|
|
28
28
|
progress: "Progress and thrash detection",
|
|
29
29
|
untrusted: "Untrusted-content classification",
|
|
30
30
|
capability: "Turn-zero capability arming",
|
|
31
|
+
guard: "Command guard",
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
export function createBroker(options = {}) {
|
|
@@ -6,6 +6,22 @@
|
|
|
6
6
|
// the path it would have run before this extension existed. That is fail-silent, not fail-closed —
|
|
7
7
|
// nothing here is ever the reason a tool is blocked.
|
|
8
8
|
|
|
9
|
+
import { backend, resolveKey } from "./key-source.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Where a key comes from is resolved in key-source.mjs, which follows Pi's own order: the
|
|
13
|
+
* `auth.json` entry `/login openrouter` writes, then the environment variable.
|
|
14
|
+
*
|
|
15
|
+
* Every one of these defaults its route to `backend()` rather than to the string "openrouter", so a
|
|
16
|
+
* no-arg call resolves against the backend actually in force. That matters because it briefly did
|
|
17
|
+
* not: when `apiKey` was first replaced by a re-export of a function whose parameter defaulted to a
|
|
18
|
+
* literal, `apiKey()` returned a stored OpenRouter key under `JEV_BACKEND=typesafe` -- a script's
|
|
19
|
+
* `if (!apiKey())` guard passed and it spent a run, while every request underneath came back
|
|
20
|
+
* `no-key`. The fix belongs at the definition, which is where it now is; a wrapper here would only
|
|
21
|
+
* have hidden that three sibling exports had the same defect.
|
|
22
|
+
*/
|
|
23
|
+
export { backend, keyEnvName, keyPresent, keySource, keySources, resolveKey as apiKey } from "./key-source.mjs";
|
|
24
|
+
|
|
9
25
|
export const DEFAULT_MODEL = "jev-1.13.0";
|
|
10
26
|
export const OPENROUTER_MODEL = "typesafe/jev-1.13";
|
|
11
27
|
// Measured round trip is ~250-400ms through OpenRouter. 800ms left no headroom for a slow call,
|
|
@@ -14,32 +30,6 @@ export const OPENROUTER_MODEL = "typesafe/jev-1.13";
|
|
|
14
30
|
export const DEFAULT_TIMEOUT_MS = 1500;
|
|
15
31
|
const MAX_TIMEOUT_MS = 5000;
|
|
16
32
|
|
|
17
|
-
/**
|
|
18
|
-
* Jev is reached through OpenRouter by default: that is where it is published, it is what
|
|
19
|
-
* specpi-jev-guard already uses, and an OpenRouter key (`sk-or-...`) is rejected by the direct
|
|
20
|
-
* TypeSafe API with a bare 401. `JEV_BACKEND=typesafe` selects the direct API for a TypeSafe key.
|
|
21
|
-
*/
|
|
22
|
-
export function backend() {
|
|
23
|
-
return process.env.JEV_BACKEND === "typesafe" ? "typesafe" : "openrouter";
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The key variable follows the backend, matching specpi-jev-guard's own `keyEnvName`, so one key
|
|
28
|
-
* serves the whole layer. TYPESAFE_API_KEY is still accepted on the OpenRouter path so an existing
|
|
29
|
-
* env file keeps working.
|
|
30
|
-
*/
|
|
31
|
-
export function apiKey() {
|
|
32
|
-
const name = backend() === "openrouter" ? "OPENROUTER_API_KEY" : "TYPESAFE_API_KEY";
|
|
33
|
-
const direct = process.env[name];
|
|
34
|
-
if (typeof direct === "string" && direct.trim().length > 0) {
|
|
35
|
-
return direct.trim();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const legacy = backend() === "openrouter" ? process.env.TYPESAFE_API_KEY : undefined;
|
|
39
|
-
|
|
40
|
-
return typeof legacy === "string" && legacy.trim().length > 0 ? legacy.trim() : undefined;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
33
|
/** Overridable so tests never reach the network and the eval proxy can price the traffic. */
|
|
44
34
|
export function baseUrl() {
|
|
45
35
|
const configured = process.env.TYPESAFE_BASE_URL;
|
|
@@ -119,7 +109,7 @@ function normalizeAnswer(raw) {
|
|
|
119
109
|
* every question that state can answer rather than paying for the state again.
|
|
120
110
|
*/
|
|
121
111
|
export async function ask(state, questions, options = {}) {
|
|
122
|
-
const key =
|
|
112
|
+
const key = resolveKey(backend());
|
|
123
113
|
if (!key) {
|
|
124
114
|
return unavailable("no-key");
|
|
125
115
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Jev is the first thing in SpecPi that talks to a third party, so its switch is the first thing
|
|
2
|
-
// every other module in this directory consults. Master off means no key read, no consent
|
|
3
|
-
// network call and no prompt injection: the harness behaves exactly as it did before the
|
|
4
|
-
// existed.
|
|
2
|
+
// every other module in this directory consults. Master off means no key value read, no consent
|
|
3
|
+
// read, no network call and no prompt injection: the harness behaves exactly as it did before the
|
|
4
|
+
// extension existed. `/jev status` still reports whether a key exists while the layer is off, by
|
|
5
|
+
// name and never by value, because "how do I configure this" is a question asked before enabling
|
|
6
|
+
// anything -- see key-source.mjs.
|
|
5
7
|
//
|
|
6
8
|
// The file is SpecPi's own, hardened the same way as web-access and capability-policy: atomic
|
|
7
9
|
// write, mode 0600, symlinks refused, and a missing or unreadable file read as off.
|
|
@@ -20,6 +22,14 @@ export const SYSTEM_NAMES = Object.freeze([
|
|
|
20
22
|
"progress",
|
|
21
23
|
"untrusted",
|
|
22
24
|
"capability",
|
|
25
|
+
// The command guard, native since schema 3. It used to be a separate pinned package with its own
|
|
26
|
+
// global configuration file, which is why it used to carry its own switch here: a second switch,
|
|
27
|
+
// outside the master, with its own startup preference and its own persistence rules. Those rules
|
|
28
|
+
// disagreed with the layer's often enough to be their own source of defects -- a preference
|
|
29
|
+
// erased by a command that had decided nothing about the guard, a state written by one command
|
|
30
|
+
// and reverted by the next session. As a system it is gated, budgeted, reported and toggled by
|
|
31
|
+
// exactly the same code as the other seven.
|
|
32
|
+
"guard",
|
|
23
33
|
]);
|
|
24
34
|
|
|
25
35
|
/**
|
|
@@ -91,6 +101,12 @@ export const DEFAULT_BUDGETS = Object.freeze({
|
|
|
91
101
|
// Once per session by construction, and only when local signals already suggest it. Two rather
|
|
92
102
|
// than one so a retried first turn is not silently un-served.
|
|
93
103
|
capability: 2,
|
|
104
|
+
// Per gated tool call that local rules could not settle, so its frequency is retention's rather
|
|
105
|
+
// than compaction's -- and like retention, most calls never reach it: read-only commands and
|
|
106
|
+
// ordinary project writes are answered locally for nothing. Running out means the guard defers
|
|
107
|
+
// to the permission system for the rest of the session, which is what it does for every other
|
|
108
|
+
// kind of unavailability.
|
|
109
|
+
guard: 208,
|
|
94
110
|
});
|
|
95
111
|
|
|
96
112
|
/** 0 is a real budget meaning no calls. Switching a system off is what `systems[name] = false` is for. */
|
|
@@ -127,16 +143,12 @@ export function regularFile(file, label) {
|
|
|
127
143
|
/** Every unknown shape collapses to the same all-off default rather than a partial enable. */
|
|
128
144
|
export function defaultSettings() {
|
|
129
145
|
return {
|
|
130
|
-
schema:
|
|
146
|
+
schema: 3,
|
|
131
147
|
master: false,
|
|
132
148
|
startup: false,
|
|
133
149
|
systems: Object.fromEntries(SYSTEM_NAMES.map((name) => [name, false])),
|
|
134
150
|
budgets: { ...DEFAULT_BUDGETS },
|
|
135
151
|
progressNudge: "notify",
|
|
136
|
-
// The guard is a separate package with its own gate, so it carries its own switch rather
|
|
137
|
-
// than riding the advisor's master. Both ship off: nothing in the Jev layer is active on a
|
|
138
|
-
// fresh install, and `startup` is how a user chooses to default one on.
|
|
139
|
-
guard: { enabled: false, startup: false },
|
|
140
152
|
};
|
|
141
153
|
}
|
|
142
154
|
|
|
@@ -174,22 +186,42 @@ function migrate(raw) {
|
|
|
174
186
|
return { ...raw, schema: 2, budgets, callBudgetPerSession: undefined };
|
|
175
187
|
}
|
|
176
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Schema 2 carried the command guard as a separate `guard: { enabled, startup }` pair, because it
|
|
191
|
+
* was a separate package with its own global configuration file. Schema 3 makes it the eighth
|
|
192
|
+
* system, so the stored preference becomes `systems.guard`.
|
|
193
|
+
*
|
|
194
|
+
* `guard.startup` is what migrates, not `guard.enabled`: the former is what the user chose for new
|
|
195
|
+
* sessions, and the latter was a session flag that happened to be written to disk. A file where the
|
|
196
|
+
* guard was wanted at startup but the layer itself was not produces a system that is on inside a
|
|
197
|
+
* layer that is off, which is inactive -- the guard used to sit outside the master switch and now
|
|
198
|
+
* does not. That direction is deliberate: a gate quietly becoming inactive is recoverable in one
|
|
199
|
+
* command, and a gate quietly becoming active is how a session stops being able to run anything.
|
|
200
|
+
*/
|
|
201
|
+
function migrateToThree(raw) {
|
|
202
|
+
const systems = { ...(raw?.systems ?? {}), guard: raw?.guard?.startup === true };
|
|
203
|
+
|
|
204
|
+
const { guard: _guard, ...rest } = raw ?? {};
|
|
205
|
+
|
|
206
|
+
return { ...rest, schema: 3, systems };
|
|
207
|
+
}
|
|
208
|
+
|
|
177
209
|
function normalize(raw) {
|
|
178
|
-
const
|
|
179
|
-
|
|
210
|
+
const one = raw?.schema === 1 ? migrate(raw) : raw;
|
|
211
|
+
const source = one?.schema === 2 ? migrateToThree(one) : one;
|
|
212
|
+
if (source?.schema !== 3) {
|
|
180
213
|
return defaultSettings();
|
|
181
214
|
}
|
|
182
215
|
|
|
183
216
|
const systems = Object.fromEntries(SYSTEM_NAMES.map((name) => [name, source.systems?.[name] === true]));
|
|
184
217
|
|
|
185
218
|
return {
|
|
186
|
-
schema:
|
|
219
|
+
schema: 3,
|
|
187
220
|
master: source.master === true,
|
|
188
221
|
startup: source.startup === true,
|
|
189
222
|
systems,
|
|
190
223
|
budgets: normalizeBudgets(source.budgets),
|
|
191
224
|
progressNudge: NUDGE_MODES.includes(source.progressNudge) ? source.progressNudge : "notify",
|
|
192
|
-
guard: { enabled: source.guard?.enabled === true, startup: source.guard?.startup === true },
|
|
193
225
|
};
|
|
194
226
|
}
|
|
195
227
|
|
|
@@ -222,7 +254,7 @@ export function writeFileAtomic(file, contents) {
|
|
|
222
254
|
export function saveSettings(settings) {
|
|
223
255
|
// A caller handing back a schema-1 shape is migrated rather than reset, so a round trip through
|
|
224
256
|
// an old reader cannot quietly disable the layer.
|
|
225
|
-
const next = normalize(settings?.schema === 1 ? settings : { ...settings, schema:
|
|
257
|
+
const next = normalize(settings?.schema === 1 || settings?.schema === 2 ? settings : { ...settings, schema: 3 });
|
|
226
258
|
const file = settingsFile();
|
|
227
259
|
if (fs.existsSync(file)) {
|
|
228
260
|
regularFile(file, "Jev settings");
|
|
@@ -236,19 +268,3 @@ export function saveSettings(settings) {
|
|
|
236
268
|
export function settingsPath() {
|
|
237
269
|
return settingsFile();
|
|
238
270
|
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* The key is never read into any structure that gets logged or serialized. Callers only ever ask
|
|
242
|
-
* whether one is present; the client reads it directly at call time.
|
|
243
|
-
*/
|
|
244
|
-
export function keyPresent() {
|
|
245
|
-
// OPENROUTER_API_KEY on the default path, TYPESAFE_API_KEY on the direct one; see client.mjs.
|
|
246
|
-
for (const name of ["OPENROUTER_API_KEY", "TYPESAFE_API_KEY"]) {
|
|
247
|
-
const value = process.env[name];
|
|
248
|
-
if (typeof value === "string" && value.trim().length > 0) {
|
|
249
|
-
return true;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return false;
|
|
254
|
-
}
|
|
@@ -156,6 +156,20 @@ export const THRESHOLDS = Object.freeze({
|
|
|
156
156
|
high: 0.85,
|
|
157
157
|
low: 0.15,
|
|
158
158
|
}),
|
|
159
|
+
guard: Object.freeze({
|
|
160
|
+
// The same measured Score operating point as every other system, written out rather than
|
|
161
|
+
// inherited by falling through `thresholdsFor`'s default. The guard asked under the name
|
|
162
|
+
// "gap" for exactly as long as it took to notice that adding a `guard` entry would then have
|
|
163
|
+
// changed nothing -- a silent no-op on the one system whose action takes a tool call away.
|
|
164
|
+
scoreConfidence: 0.6,
|
|
165
|
+
boundary: 0.3,
|
|
166
|
+
choiceConfidence: 0.75,
|
|
167
|
+
margin: 0.2,
|
|
168
|
+
// Unused: this system reads only the Score side. Kept so every system has a full set, and
|
|
169
|
+
// so the calibration tests cover this entry like any other.
|
|
170
|
+
high: 0.85,
|
|
171
|
+
low: 0.15,
|
|
172
|
+
}),
|
|
159
173
|
sources: Object.freeze({
|
|
160
174
|
scoreConfidence: 0.6,
|
|
161
175
|
boundary: 0.3,
|