# Security model

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.

## Scope monitoring

The human declares project-relative paths. Interactive `write` and `edit` calls outside those paths require a decision; headless calls are recorded as pending. Git snapshots detect changes from other tools after execution. Paths, snapshots, and displayed findings are bounded, and uncertainty remains visible until an explicit recheck. Ignored files, subprocess activity, timing races, and changes outside the observed project can escape snapshot coverage. A clean report does not prove that no other mutation occurred.

Scope records use Pi's current session branch. Restoring a branch does not create new authority or accept pending drift. An improvement contract can supply paths only through the human's `/scope task` command.

## Capability requests

Optional tool groups ship withdrawn. The `request_capability` tool lets the model name a withdrawn group instead of silently working around it; it never grants one. Activation requires a human decision, either accepted in the moment or recorded earlier as a standing grant, so the tool refuses in headless sessions and a declined prompt leaves the session unchanged. Activation is additive, offers only the named group's tools, and lasts for the session: it writes no startup preference, and the next session starts from the saved preference as before. Delegation is not requestable, because its own package requires a human command to bind a model. Acceptance offers the tools; it is not a per-call permission, and the Permission System continues to govern what those tools may do.

`/capability allow <name>` records a standing grant so that group is offered without a prompt, and `/capability ask <name>` returns it to prompting. Recording either requires an interactive human command, and a grant that fires still announces itself in the session. Grants live in `<agent-dir>/specpi/capabilities/settings.json`, written atomically with owner-only permissions; links and irregular files are refused, and an unreadable file, a foreign schema or an unknown capability name reads as no grant. A standing grant removes the prompt, not the requirement for a human: headless sessions are still refused, so a granted capability cannot be activated by an unattended run. It also does not offer the group at startup — the model must still ask, and the group stays withdrawn until it does.

## Jev advisor

The advisor is the only part of SpecPi that sends anything off this machine, and it ships off. With the master switch off there is no network call, no key read, no consent read and no prompt injection: the harness behaves exactly as it did before the extension existed.

What is sent: a summary object of at most 1 KB per call to `openrouter.ai`, which is where Jev is published and the default route, or to `api.typesafe.ai` when `JEV_BACKEND=typesafe` selects the direct API. Either way, over HTTPS. It carries tool names, byte counts, relative paths, entry counts, short descriptions and a bounded sample of lines from the material being judged. `sanitize.mjs` is the only module permitted to build one, so the rule is enforced in one place rather than promised in several: credentials, tokens, JWTs, emails and URLs are redacted, absolute paths are relativized, and anything still over budget is truncated rather than sent. Redaction reduces exposure; it does not guarantee that a model-authored string carries nothing sensitive.

Earlier versions of this document said file contents and command output were "refused outright". That was never accurate and is corrected here. Deciding whether a tool result is spent, or whether a fetched page is addressing the agent, cannot be done from byte counts alone, so `outline()` has always sent a sample of the result's own lines: up to six from the head and two from the tail, each collapsed to at most 80 characters and passed through the same redaction as everything else. As of 0.27.0 it also samples up to four lines evenly spaced through the middle, because an instruction planted in a fetched page is rarely in its first six lines and a digest that could never contain one would ask system 7 a question its own state made unanswerable. So the accurate statement is: **a bounded, redacted sample of at most twelve short lines per result, inside a 1 KB total budget** &mdash; not the whole content, and not none of it. What the sample cannot include is anything beyond that budget, which is enforced by truncation rather than by intention.

Consent is separate from the switch. The first time any system would transmit, an interactive dialog names the endpoint, the shape of the data and the byte budget. Without an interactive human nothing is sent, so an unattended run cannot be the thing that starts transmitting. The grant lives in `<agent-dir>/specpi/jev/consent.json` and is bound to the host it was given for, so switching backends asks again rather than carrying an old answer to a new destination. `/jev forget` revokes it.

Every call appends one line to `<agent-dir>/specpi/jev/transmissions.jsonl`: timestamp, system, question keys, state byte count, a SHA-256 of the exact payload, latency and the gated outcome. The payload itself is never written. `/jev ledger` reads it back, so "only digests are sent" is checkable rather than asserted. The ledger is user data and survives uninstall.

The advisor also keeps `<agent-dir>/specpi/jev/usage.json`, the running call count for the current session, written atomically with owner-only permissions. It exists because the ledger has no session boundary in it, so nothing outside the advisor's own process could say what *this* session had spent, and the count is what SpecPi Chat shows beside each budget. It is the one file in this layer meant to be read by another process, and it is deliberately the least interesting one: counts per system, the budgets they are counted against, a session identifier and two timestamps. No state, no questions, no answers, not even the ledger's digests. It is written only while the master switch is on, so a layer nobody has enabled leaves no trace of having been installed, and the last session's counts are kept rather than deleted at shutdown, because "this has never run" and "the session that just ended spent its whole budget" are different facts and a reader should be able to tell them apart. One file serves the directory, as the settings file does, so where several Pi sessions share an agent directory it describes whichever wrote to it last; it carries a session identifier, two timestamps and an active flag so a reader can say which rather than having to assume.

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.

Three of the eight systems do something the other five do not, and all three are off by default like everything else here.

**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.

**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.

**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.

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.

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 &mdash; 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.

## Command policy and the Jev guard

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.

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 &mdash; 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 &mdash; 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.

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.

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.

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.

## Improvement authority and evidence

Collection is off by default. Enabling it permits sanitized gap observations, not implementation. Only an exact human `/harness-improvement` selection authorizes a wishlist-sourced change. The selected contract is bound to the gap, source checkout, session, and selection generation. Web access tools (`web_search`, `source_check`, `fetch_content`, `get_search_content`) are hidden until a human offers them, through `/webaccess on` or by accepting a `request_capability` prompt.

Retirement requires source registry integration, unchanged verification policy, a matching contract, bounded source snapshots, `npm run check`, and closed registered validators. Receipts distinguish machine-observed gates from model-reported acceptance evidence. Stale selections, changed source, missing evidence, and failed checks reject retirement. A validator proves only the behavior it exercises; the human remains responsible for accepting the result. The loop never commits, publishes, or installs a resulting change automatically.

Wishlist records remain local under `<agent-dir>/specpi/`. Sanitization and salted identifiers reduce exposure but do not guarantee anonymity. Evidence supplied by a model can still be sensitive. Reports, archives, outcomes, and issue drafts are local; external sharing requires explicit human action. Pi's own model requests and retention are governed by Pi and the chosen provider.

## Installer and migration

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.

`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.

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.

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.

## Browser QA package

`packages/browser-qa` is an independently released Node-native Pi package extracted from the retired browser tools. The installer acquires the immutable `specpi-browser-qa@0.3.0` release through Pi; its source and dependencies are not bundled in SpecPi's npm artifact. Its fourteen tools ship withdrawn: until a human runs `/browser on`, saves `/browser startup on`, or accepts a `request_capability` prompt, Pi is offered no browser tool and the model cannot launch a browser at all. Delegation ships off on the same basis, so neither package's tools reach a default session. Its explicit setup downloads Playwright Chromium, and its doctor performs offline rendering, image-comparison and accessibility smoke checks. It uses package-local dependency resolution and the standard Playwright browser cache rather than SpecPi's retired managed runtime. No Pi settings, credentials or personal profiles are migrated. The ephemeral context is not OS/network isolation; pages can reach localhost and private networks. See the package's [security documentation](packages/browser-qa/SECURITY.md) for artifact retention, best-effort redaction, permission and cleanup limits. This is QA tooling, not general-browser feature parity. BetterWright remains optional/manual. Normal updates restore recorded pre-existing entries and remove only unchanged SpecPi-added BetterWright entries; modified entries, personal browsers, profiles, cookies, and user-owned tools are not migrated or deleted.

## VS Code frontend

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 &mdash; whether an `openrouter` api_key entry is present &mdash; 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.

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.

The optional **Destructive guard** preset replaces the complete global configuration draft; it never merges with the old global rules or options. The user can preview/edit the replacement or undo it without a write. Saving uses the same native confirmation, bound global destination, backup, and atomic replacement as ordinary settings edits. The preset asks by default, adds explicit destructive-command denials, disables YOLO and logging, and clears the authorizer chain. It performs no project-policy or agent-definition inspection and no additional directory enumeration. This is a configuration template, not independent enforcement or a claim that global settings override every runtime scope. Project/per-agent policies and session approvals remain upstream-controlled, including 32.0.2's ordered-map merging behavior. Case-sensitive patterns can block benign uses and miss scripts, alternative executable spellings, or unconfigured shell tools. Restart after saving and inspect effective policy; the template is not a sandbox.

## Upstream package boundary

The seven packages add their own extensions, tools, prompts, skills, network connections, filesystem operations, and subprocesses under their upstream defaults. They are not confined by the improvement loop's selection requirement. Permission System owns tool policies; SpecPi does not inject a duplicate guard or claim its coverage. Delegation starts real Pi child sessions in the same process tree, restricted to a frozen source snapshot and three read-only tools; experiments run the user's own `git` and create worktrees on disk. Neither is an OS sandbox. See [delegation](packages/delegation/SECURITY.md) and [experiments](packages/experiments/SECURITY.md). `pi-lens` and `pi-background-tasks` (including its Anthropic provider wrapper) are no longer part of the default base. Normal updates remove only unchanged entries originally added by SpecPi; pre-existing or modified entries and downloaded bytes remain. `--skip-package-install` preserves the old base. Restart Pi and Chat connections to unload retired extensions. Independently retained installations remain trusted upstream code; retained Pi Lens can still apply configured formatting/autofixes. Web access and usage reporting can contact services and use credentials through their upstream implementations. SpecPi's local-only wishlist collection policy does not describe all activity of those packages.

Top-level versions are pinned; upstream transitive dependency ranges are not frozen by SpecPi. `doctor` reads configured pins and installed package metadata, then invokes the installed Browser QA bin for real offline rendering, pixel-comparison, and accessibility checks when the managed base includes it. Doctor never downloads a browser, and missing Chromium or OS libraries fail with recovery guidance. It does not validate provider access or browser readiness for independently retained BetterWright. Core-only installations do not run browser checks. `check:base` acquires the packages, checks combined resource loading and Chat RPC startup, and exercises upstream approval, denial, and cancellation with synthetic context. It uses temporary home/configuration directories and sends no model prompt. The base check also exercises Node-only Chromium setup and offline Browser QA readiness; authenticated services, OS isolation, and every upstream tool's behavior remain outside that check. See [THIRD_PARTY.md](THIRD_PARTY.md) for sources and compatibility limits.

Manifests, source checkouts, Pi, dependencies, and the operating system are trusted inputs. Validation rejects unexpected managed resource paths and symlinked managed paths; it does not claim protection against another process changing files during an operation. Repository and release checks use isolated temporary Pi directories.
