Skip to content

Latest commit

 

History

History
463 lines (333 loc) · 175 KB

security.md

File metadata and controls

463 lines (333 loc) · 175 KB

Security architecture

Upstream Pi runs the agent. PiShip validates distribution inputs, locks declared resources and package identities, and verifies packaged file hashes before entering Pi. A distribution's extensions are executable code with the user's process privileges. For piship/v1alpha3 and later distributions PiShip also governs what loads and what tools may do, and can run tool subprocesses and MCP stdio servers inside an OS sandbox (see Governance). The Pi process itself is never sandboxed.

Payload and state

The installer owns only its receipt, installed payload, and command shim. Existing install, command, and state collisions fail by default. uninstall preserves ~/.piship/<id>; purge <id> --yes removes that one distribution's state after uninstall (uninstall <id> --purge --yes does both, deleting the secrets before it removes the install; both refuse while the distribution is signed in, so run <command> logout first, see logout and revocation), and deletes the platform secret-store entries its identity, runtime credential, and sandbox credential metadata reference, confirming each deletion; a secret it cannot delete fails the purge before any state is removed. The payload is separate from mutable config, sessions, cache, logs, credential metadata, and other runtime data. No installed distribution shares a cache with another. The one runtime cache of an installed distribution is a V8 compile cache of a bundled payload's JavaScript (the bytecode Node compiled it to, so the next start does not parse the runtime again), kept in that distribution's own state at <state>/<id>/cache/compile, created only once the state directory exists (from the second launch), and swept with the cache data class like any other cache file. Node checks an entry against the source file it was made from, not against the lock or the payload inventory, so the cache is off whenever the distribution declares runtime.verifyAtLaunch: true (launch then also verifies the whole payload, inventory included), when the lock cannot be read, for an unbundled payload, and when NODE_DISABLE_COMPILE_CACHE is set. A build machine has a separate cache, in PiShip's user cache directory and never in a payload, a project, or an install: piship release keeps its installed dependency tree and bundled runtime there, keyed by what decides their bytes, so a second release does not install and bundle them again. It holds only runtime bytes (every audit, the SBOM, the tests, and the archive are redone each time) and has the trust of the owner's home directory: whoever can write it can change what a release contains. A bundled-runtime entry is content-hashed at each lookup, and an installed tree is checked by its file set, file sizes, and an entry digest. Using it changes no byte of the archive, release.json, or checksums.txt; where a release's runtime came from is reported outside every file the release covers: one stderr line from piship release (runtime cache: hit (entry <digest>, linked N, copied M, bundle hit)), the PISHIP_DEBUG_TIMING output, and an unsigned <out>/releases/<name>.build-info.json beside the archive, which a rebuild replaces. --rebuild or PISHIP_RELEASE_NO_CACHE=1 forces a cold build, and CI release qualification starts cold (performance).

Temporary directories (a release's extraction, launch-check scratch, sandbox session temp that can hold tool output, build, release, install and update staging) are private (0700), carry an owner marker (process, instance, host, creation time), and are removed when the operation ends. A process killed by SIGKILL, a machine reset, or power loss cannot remove its own, so a later doctor, or the next operation that makes that kind of directory, removes a directory only when its marker is valid and its owner is gone, or has not refreshed the marker for 24 hours (a machine that sleeps, or a clock that jumps forward, for more than a day while the owner cannot refresh displaces a live owner's directory, and a reused process ID delays a removal by up to that day). It never removes a running process's directory, an unmarked one, a link, another user's directory, or anything across a mount (temporary directories). The marker is not authenticated: a sandboxed command can write one wherever the sandbox lets it write. So the sweeps that run without being asked run only in roots a contained command cannot write (the OS temp directory, the install home, apps/<id>), never in the output directory of piship build or piship release, which lies in the project, unless you pass --reclaim-staging; and each removal checks identity, not names: the directory is renamed to a fresh name and verified to be the same real directory, then removed by rm, which does not follow a link, so a directory swapped for a link while it is removed is never followed. Without a usable rm (Windows, Alpine's BusyBox) a portable walk does the same checks before each listing, and what no path-based call can close is the moment between its check and each removal; there the choice of roots carries the safety, so a sandboxed command that is still running could steer --reclaim-staging in a dist/ it can write, or a sweep of an OS temp directory the manifest lists as writable (Windows has no native sandbox backend, so this does not arise there). Directories made by earlier PiShip versions have no marker and are left for the user to delete.

Pi receives a dedicated agent directory (<state>/agent, which also holds Pi's crash log and the tools Pi downloads), user and acceptance session directories, in-memory settings, and a loader with ambient extension, skill, prompt, theme, and context discovery disabled. Only declared resources are packaged. PiShip checks the session file a launch would continue before Pi reads it: a damaged one, or one over 64 MiB, is not resumed (the launch starts a new session with a notice and leaves the file alone), and a session another live process owns is never appended to; an owner on another host or in another PID namespace (a container sharing the home directory) cannot be verified, so its session is neither resumed nor taken over until the user deletes its owner record (sessions). Packaging rejects symlinks in resource roots, nested files, and adapter paths. For v1alpha1 and v1alpha2 distributions, project files remain accessible to Pi tools and trusted extensions, and PiShip does not enforce project trust or tool policy.

The SHA-256 inventory detects accidental or unauthorized file changes only while the inventory itself is trusted. doctor, verify-release, and a launch of a distribution that sets runtime.verifyAtLaunch: true verify the whole inventory. Install and update hash the archive and each file in the same pass that extracts them and compare the results with the signed or bound inventory before they write the receipt, and every launch hashes piship.lock, which carries policy.enforced, against the digest the receipt recorded. The check is skipped, not failed, where there is nothing to compare with: a build directory, an install home other than the one in use, a receipt that cannot be read, and an installation recorded before the receipt carried the digest. A launch without runtime.verifyAtLaunch and a rollback do not scan the rest of the extracted payload. For piship/v1alpha4 and later releases, signed channel metadata and build provenance establish where an artifact came from (Releases and updates). Nothing here is a security boundary against a malicious local user who can rewrite both files and inventory.

Secrets

  • piship.yaml and piship.lock never hold secrets. The schema rejects secret fields, client secrets, and runtime variables whose names look like secrets. It cannot detect every secret embedded in an otherwise allowed string, so never commit one.
  • Identity tokens and runtime credentials are wrapped in SecretValue, which prints, serializes, and inspects as [REDACTED]. Only an explicit reveal() returns the value. Every value held as a SecretValue (six characters or longer) is scrubbed from later diagnostics, as is, base64 and base64url-encoded, percent-encoded, and JSON-escaped; a secret encoded together with other bytes (user:secret in a Basic credential) is caught only by the token shapes. PiShipError messages, actions, and details, launcher and CLI error output (including an error no code path catches, which the launcher prints redacted before it exits with status 1; while Pi's interactive UI runs, Pi's own crash handler prints it and records it in Pi's crash log, which /bug attaches to a report, and PiShip redacts the error object in place first: its message, stack, and nested strings such as its cause), config explain, and doctor pass through the same redaction, which also removes common token shapes (Bearer and Basic credentials, sk- keys, JWTs, GitHub, GitLab, Slack, AWS, and Google API tokens, PEM private keys, and secret-named key=value pairs). MCP results sent to the model, stderr, and audit events use this one pattern set. In structured values, keys named like a credential (token, apiKey, x-api-key, apiToken, authorization, cookie, password, and their spellings) are replaced whatever they hold, bytes are shown as redacted text, and an audit detail key that holds a secret is dropped.
  • State files hold only non-secret metadata and secret-store references (architecture).
  • Pi records a failed model request's error text, and the provider diagnostics some providers attach (error messages, stacks, and details), in the session file (sessions/user/ and sessions/acceptance/). PiShip runs both through the same redaction before Pi keeps the message (a message_end handler of the built-in piship-redaction extension, which Pi runs after the distribution's extensions), so a gateway that echoes the request's Authorization header into its error leaves neither the registered runtime credential nor a bearer shape in that error text, as the session file, the agent state, and the terminal hold it. The status and wording stay, so retries and the credential-rejection check read the redacted text as before. If the redaction itself fails, the error text is replaced whole by [REDACTED error text] and the diagnostics are dropped, since Pi would otherwise keep the original message. The distribution's own extensions see the error text before redaction, as they see everything else in the process. A secret the gateway echoes in a form that is neither one of the registered encodings nor a token shape is not recognized. The same handler is the only seam Pi offers, and it runs once per message at message_end: a tool result's details and nested call records are therefore scrubbed in what Pi persists and in the message_end line a JSON-mode launch writes, but Pi writes the events it raises while a tool runs verbatim as well, so an intermediate line (a tool execution's progress or end, carrying the result the tool returned) can hold what the tool itself put in its details. A tool that echoes a credential it read into its own result puts it on that line before redaction ever sees the message; that is a tool-authoring defect PiShip cannot fix at message_end, and it is why a distribution's own tools must not put a credential in a result.
  • A session file is not a secret-free record. What PiShip scrubs before Pi writes it is exactly two things: a failed request's error text and diagnostics (above), and an MCP tool's result, which is redacted and capped before the model, and so Pi, sees it. Everything else Pi records as it is: the user's prompts, the model's output (its text, its thinking, and the arguments of its tool calls), and the results of Pi's built-in tools (a command's output, a file's contents, an edit). PiShip keeps the credentials it holds out of what feeds those: the runtime credential and identity tokens live in memory and the secret store, never in Pi's environment (managed runtime isolation), and the sandbox credential never reaches a sandbox command. But a secret that a command prints, a file holds, the user pastes, or the model writes out is kept in the session file verbatim, and so is a credential an in-process extension or an unsandboxed command reads from wherever it is stored. Treat sessions/ as private project data (state).
  • Pi's crash log is kept in the distribution's state. Pi's interactive crash handler keeps the last few uncaught errors in crashes.json in Pi's agent directory. PiShip sets PI_CODING_AGENT_DIR to <state>/agent on every launch, after a managed launch has removed the inherited PI_ variables with the other ones, so the file is <state>/agent/crashes.json and not under ~/.pi/agent/. logout and uninstall keep it with the rest of the state, and purge removes it. The in-place redaction of an uncaught error described above has three limits. It is installed at Pi's session_start, so an error thrown in the short startup window between Pi registering its crash handler and that event is printed and recorded unredacted. It rewrites strings the error holds in its own properties, and those of the objects it holds, four levels deep; a value held inside a Headers or Map object on an error property (a failed response's headers, say) is not rewritten. And a thrown primitive, such as a bare string, cannot be changed in place and is printed and recorded as it is.
  • PiShip sets Pi's startup and network defaults on every launch. Environment: PI_SKIP_VERSION_CHECK=1 (no request to pi.dev for the latest Pi version and no "Update Available ... Run pi update" notice: a distribution's users have no pi command, and Pi changes only with a new distribution release), PI_TELEMETRY=0, and PI_CODING_AGENT_DIR=<state>/agent. In-memory Pi settings: enableInstallTelemetry: false; lastChangelogVersion set to the Pi version (the settings store is new on each launch, so without it Pi took every session for a fresh install and sent pi.dev an install report); quietStartup: true (no Pi logo, key hints, or resource listing at startup); and tuiMode: "regular" (Pi 1.0 starts fullscreen by default; a distribution keeps the terminal's scrollback). Pi's tools manager fixes its tool directory (<agent dir>/bin) from PI_CODING_AGENT_DIR when Pi is imported, so the branded command sets the variable before it imports Pi; set later, Pi would keep ~/.pi/agent/bin, the user's own directory (as it did in v0.9.0 and earlier). Because Pi's agent directory is in the state, the fd and rg binaries Pi downloads from github.com, when one is neither in <state>/agent/bin/ nor on PATH (at interactive start, and when the agent first uses Pi's find or grep tool), land in the distribution's state, where purge removes them; each distribution downloads its own copies. A managed launch also sets PI_OFFLINE=1, after it has removed the inherited PI_ variables (managed runtime isolation): Pi then makes no request of its own (no model catalog refresh, no fd or rg download). A managed distribution that needs @ completion and Pi's find and grep tools bundles fd and rg (bundled search tools) or relies on the users' PATH. A personal distribution does not go offline by itself: PI_OFFLINE=1 in the shell stops the download and Pi's other network requests there. Four things stay Pi's, and no public Pi switch changes them: the terminal title is still "π" (Pi's APP_TITLE), the exit hint still says "To resume this session: pi --session-dir ... --session ..." (the branded command continues the project's most recent session by itself, and --new-session starts a new one), Pi still adds its /bug hint to a PiShip identity or credential failure in the TUI (#139), and the built-in /share command, which is under Limits.
  • A remote sandbox backend's stored credential (sandbox.credential: stored) enters only through <command> sandbox login: a no-echo prompt on stderr, or the first line of a piped stdin. There is no command-line flag, environment variable, file, or in-session command for it, so PiShip never puts it in argv, shell history, the model context, or a Pi session file. It is kept in the same secret store as the runtime credential, bound to the principal and to the origins of the sandbox endpoint (and Kubernetes router) it was stored for, and it is never in the manifest, the lock, audit events, doctor output, rollback snapshots, or a sandbox command's environment (sandbox credentials).
  • Secrets go to the platform store first: macOS Keychain, Linux Secret Service, or Windows Credential Manager. Secrets reach these tools on standard input, never as command arguments. storage.provider: system never silently falls back to a file: an unavailable store fails with SECRET_STORE_UNAVAILABLE.
  • The file store is an explicit opt-in: owner-only (0700 directory, 0600 files) atomic writes under <state>/secrets/. Values are base64url-encoded, not encrypted. A managed manifest must set acknowledgePlaintext: true to use it, and login, doctor, and config explain report it as a plaintext fallback.

Unit tests exercise the platform stores through a command-runner double. The live test (PISHIP_LIVE_SECRET_STORE=1) runs against the real stores in the CI check job on every pull request: macOS Keychain, Windows Credential Manager, and the Linux Secret Service under GNOME Keyring on a private D-Bus session. managed-clean-machine, user-switching (its system variant) and lifecycle-secret-store use the live store on every Portable E2E target (managed and headless use the file store), and the Reference E2E uses the Linux Secret Service. What each tier has shown on which commit is on the status page.

Managed runtime isolation

In managed mode the branded command removes ambient credential variables from its own environment before Pi starts, so Pi, its tools, and child processes do not inherit them. This covers names containing API_KEY, ACCESS_KEY, SECRET, TOKEN, PASSWORD, CREDENTIAL, or AUTH, and common provider prefixes such as OPENAI_, ANTHROPIC_, AWS_, AZURE_, GOOGLE_CLOUD_, GITHUB_TOKEN, and PI_. Declared runtime variables, SSH_AUTH_SOCK, and GPG_AGENT_INFO are kept. Proxy variables are also removed when network.proxy.inheritEnvironment is false. PiShip then sets its own Pi switches (PI_SKIP_VERSION_CHECK, PI_TELEMETRY, PI_CODING_AGENT_DIR, and PI_OFFLINE; see Secrets), so no other inherited PI_ variable reaches Pi. --smoke reports the removed names, never values.

The managed Pi ModelRuntime uses an in-memory credential store and no models.json or auth.json, so personal Pi auth under ~/.pi or the distribution's agent/ is not read. PiShip registers one provider, named after the app ID, for the declared gateway. Only allowed models are listed, selectable, authorized, or callable; built-in, environment, file-based, and extension-registered providers receive no credentials; Pi's provider login and setRuntimeApiKey are refused. A resumed session on a model that is no longer allowed is switched to the resolved model with a visible notice.

Model governance is not containment. Extensions run in-process with the user's privileges: they can read files, spawn processes, and make their own network requests. PiShip does not isolate them from the credential while a request is in flight.

Network and TLS

PiShip-managed requests (OIDC, broker, gateway probes, Streamable HTTP MCP servers, and HTTP audit sinks) use a managed fetch:

  • TLS verification is always on. A launch with NODE_TLS_REJECT_UNAUTHORIZED=0 fails with TLS_POLICY_VIOLATION, and the manifest cannot disable verification.
  • Plain HTTP is accepted for loopback hosts, and for a private or internal host (the default, since intranets use plain HTTP a lot), never for a public one. Endpoint URLs may not embed credentials, query strings, or fragments. Each endpoint may be forced to HTTPS-only separately: the update channel with updates.transport: https (update transport), and, from piship/v1alpha6, a Streamable HTTP MCP server (MCP), the gateway, the broker, the OIDC issuer, an HTTP audit sink, and a remote sandbox with httpTransport: https (below). http-allowed is the explicit spelling of the default. Plain HTTP is admitted only to a private or internal host and only for the endpoint's own requests; an endpoint set to https keeps the loopback-only rule whatever the others say, and the runtime credential is never sent over plain HTTP.
  • HTTP_PROXY, HTTPS_PROXY, and NO_PROXY are honored when inherited (either case; a lowercase name wins, and a host in NO_PROXY is contacted directly). network.tls.additionalCA bundles are added to, never replace, the default roots. A bundle that lacks the server's certificate fails the request; nothing relaxes verification to make it pass, and an https endpoint is never retried or redirected over plain HTTP.
  • With network.privateOnly, only the declared issuer, broker, and gateway hosts, network.allowHosts, and, for update commands, the updates.source host may be contacted; anything else fails with NETWORK_DENIED. OIDC endpoints that discovery returns on other hosts, and Streamable HTTP MCP servers or HTTP audit sinks on other hosts, must be listed in allowHosts. Managed mode requires network.publicFallback: deny, which turns private-only on for every managed launch; doctor shows the effective outbound state and warns about HTTP MCP servers and audit sinks whose hosts are not declared. A sink or server host is never allowed just for being one. The comparison is on the hostname only: the port and scheme are ignored, so a host declared for one endpoint admits every port on it (an audit sink on another port of the gateway's host is allowed), and PiShip does not check that a host is a private address (a public address listed in allowHosts is honored). privateOnly is a hostname allowlist, not a network boundary.
  • Redirects are not followed.

The proxy, CA, and privateOnly policy is also applied to the Pi process's default HTTP dispatcher, so Pi's provider requests and extensions' in-process fetch calls to undeclared hosts fail too. It does not cover raw sockets or other HTTP clients; it is not an egress firewall. Child processes are not bound by it either: they get the approved network environment below, and only the OS sandbox's deny or allow network mode limits what they can reach.

Plain HTTP to internal endpoints

A company that runs its gateway, broker, identity provider, audit collector, or sandbox service on an internal host without TLS needs no setting: plain HTTP to a private or internal host is admitted by default, and piship/v1alpha6 httpTransport: https on an endpoint forbids it there (manifest). Admitting it is a real loss of protection, which validate warns about on one line and piship diff reports as high risk when an endpoint is loosened from https:

  • Gateway (inference.httpTransport). The gateway credential is a bearer sent on every model request. Anyone who can read the network path between the user's machine and the gateway (a device on the same LAN segment, a compromised switch or Wi-Fi access point, a proxy) can copy it and use it from anywhere that reaches the gateway until it expires or is revoked; the broker's scoping (models, user) still applies, but nothing binds it to this machine. Every prompt, file excerpt, tool result, and response is readable, and can be altered in transit, so an attacker on the path can inject instructions the model follows.
  • Broker (credential.broker.httpTransport). The identity access token PiShip presents and the gateway credential the broker returns travel in clear, so the same reader obtains both.
  • Identity provider (identity.oidc.httpTransport). The authorization code, the ID and access tokens, and the refresh token travel in clear. A copied refresh token yields new tokens until the provider revokes it, which outlasts a short gateway credential; the discovery document and its JWKS can be replaced, so a path attacker can also make the client accept tokens it signed. The authorization page the browser opens may be plain HTTP, and the password typed into it is then readable too. This is the most consequential endpoint; prefer an internal CA in network.tls.additionalCA.
  • Audit sink (audit.sinks[].httpTransport). Events, including any captured content, are readable, and can be dropped or altered in transit: the collector's record is only as reliable as the network path.
  • Sandbox (sandbox.httpTransport). Commands, their output, and files sent to the sandbox are readable and alterable, and a stored sandbox credential is readable. sandbox.credential: runtime keeps the endpoint https-only.

What stays: TLS verification is unchanged for every https endpoint, and plain HTTP is admitted only to the endpoint's own origin (scheme, host, and port) on the fetch its client uses; another private host, another port, and every endpoint set to httpTransport: https keep the loopback-only rule. For the gateway, the process dispatcher Pi's model requests use admits its origin too: under a private-only policy (every managed launch) that holds for every in-process request, including an extension's fetch, so an extension can reach the gateway over plain HTTP as Pi does. Without private-only (a personal distribution by default) the process dispatcher checks no destination at all. Only the host name is judged, never DNS. A .local or single-label name is answered by mDNS or the DNS search domains, which another device can spoof, so use an IP address or a name under .internal or .corp. The gateway credential is sent over plain HTTP only to the gateway itself: an MCP server with credential: runtime or a sandbox with sandbox.credential: runtime stays refused with plain HTTP, even on the gateway's origin. A configured HTTP_PROXY would receive plain-HTTP traffic in clear, so a plain-HTTP request through a proxy that is not itself a private or internal host is refused with NETWORK_DENIED (for every endpoint, MCP servers and the update channel included), and doctor reports it; list the host in NO_PROXY to reach it directly. A private proxy still sees the traffic in clear.

Prefer https, even with a private CA. Where plain HTTP is accepted, keep the gateway credential's lifetime short (minutes, and revoke on logout) so a copied credential expires soon, scope it to the fewest models, and keep the network segment between users and these services trusted.

Pi's interactive /bug command sends a bug report to the Pi developers' report service through that same default dispatcher. The report holds the environment, the model and provider names, the failed turns with their error text, Pi's crash records, and, when the user chooses to include it, the session transcript (session.jsonl), which can hold private project content. PiShip cannot turn the command off through Pi's public extension API. Under private-only the upload fails with NETWORK_DENIED unless the report service's host is in allowHosts, and every managed launch runs private-only. A distribution that runs without private-only (a personal one by default) lets the upload through. This is a question of data governance, not a leaked secret: the transcript and the crash records hold the redacted text described under Secrets. An organization whose transcripts must stay inside it turns network.privateOnly on and does not list that host, or blocks the host at its own egress proxy.

Child process network environment

In a managed distribution, a command the agent runs through the bash tool receives the network environment the policy approves and no other. A personal distribution is not restricted: its commands keep the proxy and CA variables of the shell that started it, as before. PiShip derives the approved environment from network and the launch environment:

Variable A child receives it when
HTTP_PROXY, HTTPS_PROXY, NO_PROXY, and their lowercase forms network.proxy.inheritEnvironment is true, with the value PiShip's own clients use. A proxy URL that embeds credentials, is not an http or https URL, or looks like a credential is withheld, so a child never receives a proxy credential; a child that needs the proxy needs one that takes no credential in its URL
NODE_EXTRA_CA_CERTS exactly one network.tls.additionalCA bundle is declared. It adds to the default roots; several bundles cannot be expressed as one file and are withheld
any other proxy, CA, or TLS-verification variable: ALL_PROXY, FTP_PROXY, SOCKS_PROXY, SSL_CERT_FILE, SSL_CERT_DIR, CURL_CA_BUNDLE, CURL_HOME, WGETRC, REQUESTS_CA_BUNDLE, PIP_CERT, PIP_PROXY, PIP_TRUSTED_HOST, PYTHONHTTPSVERIFY, GIT_SSL_*, GIT_PROXY_COMMAND, the GIT_CONFIG_* family (which can set http.sslVerify), the proxy, CA, and strict-SSL settings of npm and yarn (npm_config_https_proxy, npm_config_cafile, npm_config_strict_ssl, and similar), CARGO_HTTP_PROXY, CARGO_HTTP_CAINFO, DENO_CERT, JAVA_TOOL_OPTIONS, _JAVA_OPTIONS, NODE_TLS_REJECT_UNAUTHORIZED, and NODE_OPTIONS when it holds --use-openssl-ca, --use-system-ca, --use-bundled-ca, --openssl-*, --tls-*, or --insecure-http-parser never: it is dropped, so a child cannot be told to skip verification or to use a different set of trust roots than the policy declares. This is a list of names, not a scan of values: a credential embedded in the value of some other variable is not detected

How each kind of child gets it:

  • No enforced sandbox. The command keeps its environment as before (with credential-looking names already removed in managed mode); only the network variables above are replaced by the approved ones. A user's own ! command carries no environment from Pi and keeps the process environment. A same-user process can also read the environment the launcher was started with (/proc/<pid>/environ on Linux, ps -E on macOS), which removing a variable from the running process does not change: only a sandbox with its own process namespace keeps a command from reading it.
  • Enforced local sandbox that allows the network (sandbox.network.mode: allow). Any network variable named in sandbox.environment.allow is dropped, and the approved variables are added to the other allowlisted ones. With deny there is no network to configure: none are added, and a network variable in the allowlist is dropped too.
  • Remote sandbox backend (isolation: remote, such as E2B and Kubernetes). Never: the proxy address and the CA file name this host, and are meaningless in another one. A network variable in sandbox.environment.allow is dropped for a remote backend as well.
  • MCP stdio servers keep receiving only their declared environment, as before; a variable a server lists in env.allow is passed as it is.
  • A personal distribution, and a distribution with no network policy (piship/v1alpha1), is unchanged.

This is environment hygiene, not enforcement: a child can ignore or unset the variables. The launcher's own process keeps a proxy URL that embeds credentials, because PiShip's clients need it, so any process that inherits the whole environment, such as a user's ! command, can read it.

Governance

A piship/v1alpha3 or later launch opens a governance session before Pi starts (see architecture). Every mandatory control that cannot be established fails the launch; nothing silently falls back to an ungoverned run. Every decision names its enforcement plane:

Plane Meaning Actions
control-plane PiShip decides before the action runs, inside the Pi process Model selection (model.select) and dispatch (model.dispatch), resource and extension loading, MCP server start and tool calls, tool calls; file access through the governed read, write, and edit tools and shell command gating when no sandbox is enforced; the support resource of session.export where Pi runs offline
sandbox An enforced sandbox backend also contains the action: the OS sandbox proven by a live probe, or a remote or custom backend whose capabilities PiShip checked (the report says live-probe or backend-attested) shell.execute when the sandbox is enforced; filesystem.* only when the backend enforces the path policy (filesystem-* planes, as the native sandbox does; a remote backend's host-filesystem-isolation does not count); network.connect when network-deny is enforced
gateway The managed gateway or credential broker prevents the action Reserved: no current seam reports it
audit-only Observed and recorded, not prevented None against Pi 1.0.x

A plane is reported as a status: control-plane, sandbox, and gateway are enforced; audit-only is audit-only; an action no plane covers is unsupported, neither prevented nor recorded, and never emitted in audit. The runtime seam table (RUNTIME_SEAMS in @piship/policy) records which actions Pi exposes a seam for; Pi has no capability discovery API, so it is static data proven by the compatibility tests. policy explain prints the status and plane for any action and resource. There is no runtime seam for web.request, browser.execute, agent.invoke, or memory.*, nor for network.connect without an enforced deny sandbox; policy explain and doctor report them as unsupported.

A rule whose action names an unsupported action exactly, with effect deny or ask, is POLICY_UNENFORCEABLE: in a managed distribution validate fails and the release policy gate refuses it, unless policy.acknowledgeUnenforced lists it as "<action>:<resource>"; doctor shows each such rule, acknowledged or not. A personal distribution gets a warning. policy.default, *, and <prefix>.* rules never count. Containment is taken from the manifest: a required sandbox contains the filesystem and shell planes, and the network plane only when sandbox.network.mode is deny. A non-builtin capability provider whose capability is effective is also decided as provider.load (the provider ID) and extension.load (<class>:<path>) before its extension loads; a denial skips the provider, records provider.denied, and makes the capability not effective.

Policy precedence

For each request the policy engine takes the first matching rule of each layer and applies the strictest effect (deny over ask over allow):

  1. Distribution Enforced (policy.enforced).
  2. Team and project rules (policy.adapter and the project's .piship/policy.json): narrowing only; their allow rules are ignored and reported. Team rules and project rules are matched separately, each taking its own first match, so a team ask never hides a project deny (and the other way round).
  3. Distribution Defaults (policy.defaults), or
  4. in personal mode, a matching User rule from <state>/config/policy.json, which takes the default's place. In managed mode user rules are a narrowing-only layer like step 2: their allow rules are ignored and reported (doctor, policy explain, and a policy.violation audit event), and their ask and deny rules join the strictest-effect comparison.

When nothing matches, policy.default applies. A personal user may relax a default but never an enforced rule; a managed user, a team, or a project file can never widen anything. ask needs an approval channel: before Pi starts, a y/N prompt on an interactive terminal; during a session, a dialog with Allow once, Allow for this session, and Deny. Allow for this session is kept in memory and ends with the session: it covers the same action on the same file, command, or other target only, never a deny, and never an ask that policy.enforced, a team, a project, or a managed user's rule wrote to keep its prompt; it is audited as an approval (detail.remember: session on the answer, detail.approval: session on each later use). Launches without an interactive terminal or UI (--smoke, --smoke-model, or standard input that is not a TTY) have no channel, so ask resolves to deny. Denials name the policy, rule, and reason.

One exception to "a managed user can never widen anything" is opt-in per distribution: with policy.userAuto: allowed (v1alpha5, managed), a user may switch on auto mode, and while it is on an ask decided by policy.defaults or policy.default is approved without a prompt, interactive or headless. The strictest effect is still computed first, so auto mode only turns a final ask into an approval: a deny from any layer stays a denial, and an ask from policy.enforced, a team or project rule, or the user's own rules keeps its prompt. It does not relax project trust prompts, the built-in denials (the state directory, git control files), Plan mode, the sandbox, model and MCP allowlists, or audit. What it gives up is the person's per-action confirmation for actions the administrator left to ask: an agent following injected instructions can then do whatever those defaults leave at ask without anyone confirming it. That is why it is off unless the administrator allows it, why every approval is audited as policy.auto_approved and every switch as policy.auto_enabled or policy.auto_disabled, and why an administrator who wants a prompt kept for specific actions puts that ask in policy.enforced. The switch is stored in the distribution state, which tools never read or write, bound to the signed-in principal, and applies only while the running release allows it. Switching on is audited before it applies, both with <command> auto on and in a session, where /auto on delivers the event to the sinks before it switches; a required sink that does not take it leaves auto mode off. Switching off is never blocked by audit: it applies first and is recorded best effort. Someone who can write the state directory can still switch auto mode on by editing config/auto.json without either command; a session that starts with it on therefore records policy.auto_enabled with detail.source: state, so the first session that uses it is audited.

<command> --yolo (manifest) is the same relaxation for one session, started with a flag instead of a stored switch, and it stores nothing. It never changes a deny, and it does not answer project trust prompts, the built-in denials (the state directory, git control files), Plan mode, the sandbox, model and MCP allowlists, or audit. In a personal distribution the user owns the policy, so it approves every ask, including one from an enforced rule or the user's own rules. In a managed distribution it is refused at startup with POLICY_DENIED, before sign-in, the sandbox, or Pi starts, unless the manifest declares policy.userAuto: allowed: the administrator's decision to allow auto-approval is the only way to get it. Where it is allowed it approves exactly what auto mode approves, an ask from policy.defaults or policy.default, and an ask from policy.enforced, a team or project rule, or the user's own rules keeps its prompt. It carries the same risk as auto mode: an agent following injected instructions can do whatever the ask rules leave to approval without anyone confirming it. What bounds that is that the person types it for each session, that the status line and a startup notice show it, and that it is audited: policy.auto_enabled with detail.source: yolo when the session starts, and each approval as policy.auto_approved with detail.approval: auto and detail.autoSource: yolo. Headless (--smoke) it approves an ask that would otherwise resolve to deny, under the same rules, and a deny stays a denial.

Tools, shell, and Plan mode

Governed sessions start Pi without its built-in tools and supply governed read, write, edit, and bash tools built from Pi's public tool definitions, so Pi's rendering and diff behavior stay upstream. Every tool call is decided as tool.execute in Pi's tool_call hook. If that decision fails (for example the audit buffer is full), the call is blocked with a sanitized reason and recorded as tool.denied; it never runs.

  • read, write, and edit run in the Pi process. Each file access is decided on both the path as given and its symlink-resolved target, and the stricter decision wins. The file is then opened at the decided target without following a final symlink, and reads and writes go through that open file only. On Linux the path of the opened file is decided again when it differs; elsewhere the opened file must be the same file (device and inode) as the decided target, or the access fails (piship.path-changed), so a symlink or directory swapped in after the decision is not followed. A write that creates a file may leave it empty at the swapped location before this check refuses it. When the sandbox is enforced, its profile is also applied to these in-process tools: read-denied paths are refused and writes must land in a write-allowed path.
  • read and edit load at most 16 MiB of a file. Pi's read tool takes the whole file into memory before it truncates its output, so a larger file (an image included) is refused with an error that names its size and points to bash (sed -n, head -c, grep -n) to work on part of it, including an offset read into such a file. The size is taken from the opened file, and the read stops one byte past the limit, so a file that grows while it is read is refused too. A named pipe planted in a project is refused at once: files are opened without waiting for a writer, so it cannot hold up the process's file I/O.
  • Whatever the policy and sandbox level, these tools never read or write the distribution state directory (piship.state), and never write the project's git control files: .git, .git/config, the config, config.worktree, and commondir of the git directory (and the shared config a worktree points to, and the files those configs include), or anything under the hooks/, info/, modules/ (submodule git directories), and worktrees/ (linked-worktree metadata) directories of the git directory and its common directory, or under the directory core.hooksPath names in those configs and in the system, global, and environment config git reads (piship.project.git-config). These files decide the project's origin, and so its trust class, and what git runs outside any sandbox. An enforced sandbox keeps the same files and trees read-only for shell commands and MCP stdio servers even inside a writable workspace. Without an enforced sandbox, shell commands are not covered by this rule.
  • bash commands and user ! commands are decided as shell.execute, then run inside the OS sandbox when it is enforced. Without an enforced sandbox they run as Pi would run them, and the decision covers only the command text. A command-text rule is pattern matching, not containment: without a sandbox a permitted command can still read or write any file the user can. An allow or ask rule such as git * is a prefix hint: it does not match a command containing a shell metacharacter (;, &, |, $, `, <, >, (, ), a line break, or cmd.exe's ^ and %) unless the rule's pattern spells out that character, so git status; rm -rf ~ or git $(id) falls through to the next rule or policy.default. A bare ** pattern means every command. deny rules match regardless.
  • A bash or ! command may produce at most 64 MiB of output, inside or outside the sandbox. At the limit PiShip passes one notice (output exceeded the 64 MiB shell output limit), stops the command, and kills its process tree; the tool reports no exit code.
  • The agent's bash tool keeps Pi's definition, display tail, live updates, and result text, but PiShip runs its execute, so the complete output of a truncated command is owned by the session. Like Pi 1.0's, its result also carries structuredContent, which codemode scripts receive: output (up to 1 MiB, and when the output is longer its first and last 512 KiB), truncated, full_output_path (only when the output was truncated and saved, so valid only until the session closes), exit_code, and wall_time_seconds. A non-zero exit returns an isError: true result with that content instead of throwing, as Pi 1.0 does. It is written to a private directory, piship-out-* in the OS temp directory (mode 0700, files 0600), created on first use and removed with its contents when the session closes. Each file holds at most the 64 MiB limit plus the notice, and never uses the last 256 MiB of free temp space: past that it stops growing and the footer says Full output (first <size>). A write error on that file, a full temp filesystem (ENOSPC) included, stops only the copy: the command runs on, the result keeps its tail, the footer says Full output not saved: <code> instead of a path, and the session continues. A session that crashed before closing leaves its directory; the next governed session removes it when it is provably abandoned (owned by the same user, mode 0700, and recording a process on this host that no longer runs). That sweep runs on macOS and Linux, not Windows.
  • A user's ! command still runs through Pi, which keeps its streaming and Esc cancel, and Pi still copies its complete output to a pi-bash-<id>.log file in the OS temp directory. PiShip removes the files Pi recorded for the session's ! commands when the session closes (only a regular file with that name, directly in the OS temp directory, owned by the same user), and lowers the command's output budget to the free temp space minus 256 MiB (never below 32 KiB, under which Pi writes no file), stopping the command with a notice at that point. Two gaps remain until Pi exposes the file: a temp filesystem that fills anyway (another writer, or a race with the free-space check) while Pi writes it is not contained, because Pi's write stream has no error handler; and a file is not removed when the process crashes, or when a ! command ran while the agent was streaming and Pi had not yet recorded it at quit. Closing them needs an upstream Pi option, such as an output: { dir, maxPersistBytes } setting in BashToolOptions and the ! executor's options, and an error listener in the output accumulator's temp-file stream.
  • Plan mode (the builtin workflow capability) allows only the read and ask_user tools (ask_user only while the piship-ask-user builtin provides it). Every other tool (write, edit, bash, MCP tools, and extension-registered tools) is blocked, as are all file writes and user ! commands, until the user switches with /build. Plan mode is a control-plane restriction on tool calls; it does not restrict extension code that runs outside a tool call.
  • Pi 0.99 and later add the built-in extensions builtin:mcp, builtin:codemode, builtin:tool-search, and builtin:llama. The first three would run tools outside the paths above, but a PiShip session does not load any of them: only Pi's CLI adds them, and PiShip builds the session through the SDK with noExtensions: true and its own extension factories. A compatibility test asserts that no builtin:* extension loads. A tool that an extension runs through ctx.executeTool still emits tool_call, which PiShip's policy hook decides. The only MCP servers in a session are the declared ones, started by PiShip (MCP).
  • Exposure (piship/v1alpha6 runtime.tools.exposure and each MCP server's exposure and tools) decides what the model can see; policy still decides every call. A hidden tool, a tool the policy denies at session start, and Pi's base tools PiShip does not govern (grep, find, ls, powershell) are excluded from the session, so neither the model, a Codemode script, tool_search, nor an extension can reach them. An extension tool declared wider than the manifest allows fails the launch (CONFIG_INVALID); in managed mode a tool registered after launch, or re-registered with a wider exposure than launch resolved, is refused (rule piship.exposure.unresolved) and a widened one blocks the session's later model requests, and Codemode is refused (POLICY_DENIED) while a tool it can reach is decided only by the policy default. When enabled, PiShip loads Pi's Codemode (without the script models global) and tool search itself.
  • Every tool call is recorded as tool.request when it starts, with detail.source (top-level, codemode, or nested for another extension tool's ctx.executeTool), detail.parent (the calling tool's call ID), and detail.exposure. A call refused before PiShip's policy hook (an unknown or excluded tool, arguments that do not validate, or another extension's tool_call hook that ran first) is recorded as tool.denied with rule piship.pre-policy and detail.error not-found, invalid-arguments, or refused-before-policy, never the error text. Pi stops at the first tool_call hook that blocks, so PiShip cannot tell a validation failure from an earlier hook's refusal: it records invalid-arguments only when no other extension's tool_call hook runs before its own, and refused-before-policy otherwise, without reading the error text. Approval prompts of concurrent calls open one at a time. The arguments and error text of nested calls that Pi keeps with a tool result are redacted before the session file is written.
  • The selected model is decided as model.select before the session starts; models the policy denies, or that need approval not yet given, are not offered for switching.
  • The physical model that receives a request is decided as model.dispatch; without a model.dispatch rule for it, model.select decides, so a route cannot reach a model a v0.8 model.use rule denies. A virtual model (piship/v1alpha6 models.catalog.<id>.virtual) is registered only from its declared router while extensions load, at launch and on /reload; any other registration fails the launch, and one made later is refused (POLICY_DENIED). Every request it routes, from the agent loop, a direct extension call, or a compaction or summary, passes PiShip's model runtime after Pi routes it and before any request is sent: a target outside the declared routes, or one model.dispatch or the credential's entitlement does not allow, is refused (MODEL_DENIED), and each routed request is recorded as model.dispatch with detail.selected, detail.dispatched, and detail.router. Classifier and image requests are checked the same way and return an error result instead of failing, as Pi's API does.

Project trust

PiShip classifies the working project as company, external, or unknown from its git origin remote or path and decides each project item by its trust dimension (manifest). Pi's own project discovery stays disabled; only admitted items are handed to Pi's loader. Every candidate is symlink-resolved: a target outside the project root is evaluated as unknown origin, and executable items (skills, agents, hooks, extensions, MCP, providers) that leave the root are denied. A candidate directory is walked (following links that stay inside the root, at most 50,000 entries and 32 levels) and is treated the same way when any link inside it leaves the root, or when it is too large or deep to check; doctor names the offending entry or limit. The walk is a check at launch, not a lock: a link created inside the directory after the check is not detected. Instruction @path imports must resolve inside the root and are followed at most five levels. A dangling link is reported as denied. This release never loads project agents, hooks (.pi/settings.json), or capability providers. Project .mcp.json servers are used only in personal explicit MCP mode with mcp.project: allow, never receive the runtime credential, and cannot replace a declared server.

Project configuration that an extension loads itself

A repository that works with Claude Code ships CLAUDE.md, .claude/rules, commands, skills, agents, hooks and settings.json, .mcp.json, and more. PiShip loads none of it except the root CLAUDE.md (above). An extension in the distribution, such as the pi-code package, reads the rest itself. pi-code 1.4.2 decides whether it may in two steps, both through Pi's public API. First ctx.isProjectTrusted(), the flag of the session's settings: when it is false, trust-aware project configuration is withheld. Its MCP extension can still start .mcp.json servers admitted by user-level enableAllProjectMcpServers or enabledMcpjsonServers, without consulting that flag. Then, for configuration Pi itself never gates (.claude/* is not among what Pi's hasTrustRequiringProjectResources looks for), the decision in Pi's trust store, <agent dir>/trust.json, which it finds with getAgentDir(): it uses the stored decision, asks the person when there is none, and loads nothing when nobody can be asked (a headless run). Before this release PiShip's in-memory settings reported every project as trusted, so the first step never closed anything and the second was the person's answer, remembered in trust.json.

In a governed session (piship/v1alpha3 and later) PiShip now answers both steps from policy (manifest). What is enforced:

  • The flag. ctx.isProjectTrusted() reports PiShip's decision for the project the launch governs: true only when policy admits every item found (the five claude* dimensions, and .mcp.json, .pi/mcp.json, and .pi/agents by mcp and agents, each in every directory from the working directory up to the root), or when none was found and policy permits it. It is false for a managed distribution unless the company admits the configuration, and for a project that holds one denied item, since Pi gives an extension one answer per session. A session in another directory than the launch's (a resumed one recorded elsewhere) is untrusted. An ungoverned distribution (v1alpha1, v1alpha2) keeps Pi's default of true.
  • The store. Where an extension would otherwise ask the person, PiShip writes the decision for the project directory into trust.json (true when admitted, and only when the project holds something it decided on), withdraws an earlier true when the project is no longer admitted, and preserves a person's explicit stored false in personal mode. If an approval cannot be written, the project is reported untrusted rather than left for the extension to ask. A person's click, a pi-code prompt, or Pi's /trust therefore cannot open what policy closes: the flag is checked first, and the store is rewritten at the next launch. When an untrusted project holds .pi resources Pi gates, Pi also prints its own notice that they are ignored and suggests /trust; PiShip cannot change that text, and a /trust answer changes nothing the next launch decides again.
  • The audit. Each item is a resource.load or resource.denied event, and the decision a project:.claude event with detail.seam: project-trust (audit). A policy rule, policy.default, and a project's narrowing-only .piship/policy.json decide each item as resource.load project:<path>.
  • The Pi seam. The compatibility suite runs a real Pi session with an extension that follows pi-code's two steps and asserts that the flag, the trust store, and Pi's own check of .pi/mcp.json behave as above on the pinned Pi. It does not run pi-code.

What is not enforced:

  • An extension that does not ask. Only an extension that consults ctx.isProjectTrusted() and Pi's trust store is bound. A package that reads .claude/, .mcp.json, or any project file by itself bypasses project trust, as before (Limits).
  • The user's own scope. pi-code also reads ~/.claude (or CLAUDE_CONFIG_DIR), ~/.claude.json, the installed Claude plugins, a managed-settings file of its own, and mcp.json and agents in Pi's agent directory (the distribution's <state>/agent, not ~/.pi/agent, because PiShip relocates it). These are the person's files, not the project's: they load whatever the project's trust is, run with the person's privileges, and are not user-class resources PiShip decided, so resourceTrust.user: deny does not reach them. A managed distribution that must not run them leaves pi-code's hooks extension out with a package filter, and its MCP extension too (manifest).
  • MCP servers an extension starts. pi-code starts the servers of .mcp.json, .pi/mcp.json, and the user's files with its own client. They are not decided as mcp.server.start, not limited by exposure, not run in the sandbox, and not covered by mcp.tool.call rules. Admitting the mcp dimension for a project lets pi-code start its .mcp.json servers that way; company-approved does not admit them through PiShip, but user-level consent can still cause pi-code to connect them. Managed pi-code distributions must exclude !extensions/mcp/**; doctor warns when a package MCP extension is selected.
  • What a trusted file runs. PiShip decides by dimension and by what exists, never by content. An admitted .claude/settings.json can declare hooks, environment, and a status-line command that run with the person's privileges outside the sandbox, and an admitted command or skill can run shell commands the same way.
  • Managed provider configuration. A managed package permission provider never admits a preexisting project .pi/extensions/pi-permission-system/config.json: the session stays untrusted so its operator configuration cannot be replaced by project yoloMode or permission maps. Its project .pi directories are read-only to prevent planting a new override, including on Linux where absent individual files cannot be protected. Without an enforced sandbox reporting protected-path enforcement, such a provider session also remains untrusted.
  • Granularity. One answer per session means rules cannot be admitted and hooks refused for a project that holds both: the project stays closed.
  • Discovery. It is a launch-time scan from the working directory up to the project root. It does not see a directory below the working directory (pi-code also attaches a nested CLAUDE.md or CLAUDE.local.md as the agent works there), or a file created after the launch. A linked worktree main checkout's .claude/settings.local.json is discovered as outside-root executable configuration and denied. For a managed distribution that found nothing, the project is untrusted unless policy admits all five dimensions, which covers a file PiShip did not see; with something found and admitted, an extension could otherwise admit a file planted by the agent during the session. Managed sessions whose origin does not explicitly allow claudeHooks exclude pi-code's extensions/hooks/** and protect the entire project .claude directories against writes, including absent directories and replacement of their ancestors. Rules in those directories remain readable when admitted; editing them requires an operator change. This whole-directory restriction ensures missing settings files cannot be planted on Linux. A linked .claude directory is rejected in this managed case because another writable alias can bypass a read-only mount.
  • Prompt injection. An admitted rule, command, skill, or CLAUDE.md is model input; trust decides whether it loads, not whether it is safe.

The origin remote is read from the project's own git configuration, which anyone who can write the checkout controls. A remote matcher alone is a claim, not proof: a clone of any repository can declare a company remote. Managed distributions should combine remote with path in one matcher (both must match), so only checkouts under a managed location count as company projects. The governed file tools refuse to rewrite these git files; an enforced sandbox's write allowlist limits shell commands.

Certified resources and providers

A certified resource's tree digest is checked against its reviewed integrity at lock time and recomputed from the installed payload at launch; a mismatch fails the launch with INTEGRITY_FAILED. A certified resource reviewed for another Pi version or platform is not loaded. Certified and non-builtin provider trees with npm install-time scripts or a binding.gyp (an implicit node-gyp rebuild) are rejected at lock time. Non-builtin capability providers load only when their trust class is allowed and their files match the lock. user resources and providers are denied by default in managed mode. Certification is a digest match against reviewed evidence, not a signature.

Pi packages

PiShip, not Pi, resolves and vendors Pi packages (manifest), and no package code runs while it does. npm runs only with --ignore-scripts and a --git that refuses to run, given both as flags and as npm_config_ignore_scripts and npm_config_git in its environment, so an npm install that npm nests inherits them too, and the environment outranks a fetched project's own .npmrc. piship lock resolves only with npm 11 or later and refuses an older npm before any resolving call: npm 10 (bundled with Node 22) runs a git dependency's prepare in its own process despite --ignore-scripts. piship build installs a checked lockfile, which holds no git entry, so any npm can run it. Before npm is called, a git or local package's own dependencies and optionalDependencies, and an npm package's published ones, must be registry versions or semver ranges: npm fetches a git dependency by cloning it and running its prepare under that repository's own npm configuration despite --ignore-scripts, so a git dependency at that level is refused rather than resolved. A git or local package containing a symlink or a node_modules directory (in any spelling the filesystem folds to it, such as Node_Modules or node_moduleſ) is refused before its package.json is read, and an installed directory under node_modules that the package lockfile does not list fails the lock or build, so no module loads from outside the locked closure. Git content is archived as committed: the repository's .gitattributes and the build environment's core.autocrlf cannot convert line endings, run filters, or expand export-subst or ident. The install-script review (including a git or local package root's own scripts or binding.gyp) applies at lock and build whether or not the manifest has lifecycle.release. A certified package's evidence must match its locked tree digest (and, for npm, its version) at lock and at launch, and its files are not loaded on a Pi version or platform the evidence does not list.

Bundling and sharing change which files hold a package's dependencies, not what is trusted (performance). Both run after vendoring, from the vendored files and the lockfile, and neither executes package code: a scan parses files with esbuild in a process of its own, and a decision that cannot be made from the parse keeps the vendored files. The locked entry files are never rewritten, so the launch check above is the same. A dependency several packages hold identical copies of (same name, version, registry integrity, and bytes, with no platform-specific or run-time-locating code) lives once in pi-packages/.shared, and each place keeps its package.json, its license files, and plain forwarding modules; no symlink, junction, or hardlink is created, so the payload, its inventory, and its archive hold regular files as before and extraction needs no privilege. The shared files are in the payload inventory like every other file, so doctor, verify-release, and a verified install cover them. Two packages that used separate copies of a dependency now share one instance of it, and a certified package's dependencies may be forwarding modules: the certified evidence binds the package's own tree, as before, and not its dependencies.

Limits: a git spec deeper in the closure (a dependency's own dependency) becomes visible only when npm resolves it. The refusing git stops a clone, but npm can still fetch a hosted-git spec with a full commit, such as github:owner/repo#<sha>, as an https tarball from the host without git, and prepares it. With npm 11, which piship lock requires, the nested npm install inherits npm_config_ignore_scripts and npm itself skips the prepare, so none of the fetched project's scripts runs, and the lockfile check then refuses the entry; npm 10 would run that prepare, which is why it is refused. The fetch itself still reaches the host, so lock in an environment holding no credential the lock does not need. At launch PiShip checks the sha256 of the inventory's entry files only (the extension, skill, prompt, and theme files Pi loads); the modules they import, the package's other files and its vendored node_modules, are bound by the tree digest and lockfile at build and are not rechecked per package at launch.

What a package is given (manifest) is manifest content and never holds a secret: the manifest's secret check applies to every value and every string of a configuration file, credential-looking and PiShip-owned variable names are refused, and the lock records both. A declared variable replaces the shell's value in every mode, so a managed distribution's value (a package's feature switch, an install opt-out) is not one a user's environment overrides; it reaches only the Pi process and what that process passes on, never a child whose launcher passes a fixed list of its own (the MCP SDK's stdio transport does). A configuration file is written owner-only through an atomic replace and never through a link, and its content is checked against the lock's digest before it is written. seed keeps a file the user edited, so a seed is a default and not a control: put a rule that must hold in enforce mode, in policy.enforced, or in both. Whatever a package does with its configuration is the package's behavior: the permission provider the developer example ships reads a project's own .pi/extensions/pi-permission-system/config.json over the seeded one once the project is trusted, and the example's rules deny the agent a write to that path rather than rely on the provider to refuse it. A capability provider that is a package is decided as provider.load, then each extension file as extension.load; when either is refused, or the files do not match the lock, the capability is not effective and its extensions are not loaded, and PiShip's own policy stays in force without it.

Bundled search tools

runtime.searchTools ships fd and rg as release content (manifest) instead of letting Pi download them, which would need Pi online: in Pi 1.1.0 PI_OFFLINE also gates the model catalog refresh, Pi's package update and version checks, and the /bug upload, and session.export support counts as enforced in managed mode only because Pi runs offline. Nothing about PI_OFFLINE, the private-only launch policy, or the session export status changes.

  • Pinned at lock. Only the official upstream release archive for the exact tool version and release target is accepted, from an origin in release.sources (https://github.com must be added explicitly), and the lock records the sha256 of the archive and of the executable taken from it. Downloads follow redirects only over https to GitHub's release asset hosts. The first download is trust on first use of that upstream release: PiShip checks no upstream signature, so review the version before adding it, as for any new dependency; afterwards the digests pin it and piship diff reports any change. The download cache is not trusted: piship lock reuses a cached archive only when the existing lock already pins it (the same official URL and digest), and downloads any other cached file again, so a file planted in the cache under an official name is never pinned by a first lock; the build and the launch take nothing from the cache that the lock does not pin.
  • Verified at build and launch. The build refuses a lock whose archive URL is not the official upstream asset for its tool, version, and target (LOCK_INVALID) before it downloads or reads anything, and the stale-lock check treats such a lock as stale. The build takes the cached archive only when it matches the lock, reads it with the release extractor's rules (no links, special files, traversal, or duplicates), and writes the executable and its license files into the payload, where the inventory covers them; the release SBOM lists each tool. At launch the payload inventory is verified as for every file, and each executable is checked against the lock again before it is copied into <state>/agent/bin.
  • Precedence. Pi searches <agent dir>/bin before PATH, and the branded command fixes that directory to the distribution's state before Pi is imported, so neither a user's fd or rg on PATH nor one in ~/.pi/agent/bin replaces the pinned ones. launchPiDistribution refuses to start (CONFIG_INVALID) when PI_CODING_AGENT_DIR is not <state>/agent on entry, so an entry point that skips that step fails instead of letting Pi use ~/.pi/agent/bin or PATH. A compatibility test pins both Pi behaviors.

Limits: <state>/agent/bin is in the user's own state directory. A process running as the user can replace an executable there after the launch has checked it, as it can change any state file; the next launch restores the pinned copy, and doctor fails while it differs. Pi runs the tools in its own process, not inside the sandbox, as it did with tools from PATH.

MCP

MCP servers are declared in the manifest and started by PiShip, not by an extension.

  • Each server start is authorized as mcp.server.start before anything is spawned or contacted. A denied or failing server is reported; a required one fails the launch.
  • initialize must negotiate a supported protocol version (2025-06-18, 2025-03-26, or 2024-11-05), and expectedServerName must match when declared.
  • A tool is offered to the model only when the manifest admits it (up to v1alpha5 the server's tools.allow / tools.deny lists; in piship/v1alpha6 an exposure other than hidden, see Tools, shell, and Plan mode) and the policy does not always deny <server>:<tool>. A denied tool is never offered, and a call to it never reaches the server.
  • Every call is authorized again when it happens (manifest lists or exposure, then policy with approval for ask); an authorization error is a denial. Results are redacted and capped at 256 KiB.
  • stdio servers run in the project root with only their allowlisted and fixed environment variables; credential-looking names are stripped. When the sandbox is enforced they run inside it, with the same filesystem, network, and environment profile as tool subprocesses. A backend that cannot contain local processes (the e2b-compatible and Kubernetes backends, and remote custom adapters) refuses them with SANDBOX_UNAVAILABLE, so they do not start.
  • Streamable HTTP servers use the managed fetch, do not follow redirects, and receive the runtime credential as a bearer only with credential: runtime. The runtime credential is issued for the inference gateway, so it is sent only to the origin (scheme, host, and port) of inference.baseUrl; a credential: runtime server on any other origin fails to start (MCP_UNHEALTHY) before the credential is read.
  • Plain HTTP (piship/v1alpha6 httpTransport, http-allowed by default and https to forbid it) is admitted per server for an internal MCP server without a certificate. It permits plain HTTP only to a private or internal host, judged by name as for the update channel (a public host fails validate, and a ${NAME} URL that resolves to one fails the server's start); the proxy and private-only policy apply unchanged, and network.allowHosts must still list the host. A server with credential: runtime is https-only, so the bearer is never sent in clear. Everything sent to and from such a server, including tool arguments, results, and identity headers, is readable and alterable by anyone on the network path, and the server's identity is not verified. An altered tool result reaches the model, so the network path becomes a prompt-injection vector. A configured HTTP_PROXY would see the traffic in clear: a proxy that is not itself a private or internal host is refused (plain HTTP to internal endpoints), and a private one still sees it; list the host in NO_PROXY to reach it directly. A .local name is answered over mDNS and a single-label name through the DNS search domains, which another device on the network can spoof, so validate warns for both and an IP address or a fully qualified name under .internal or .corp is the better choice. validate warns, doctor shows the server as plain HTTP, unencrypted, and piship diff reports loosening a server from https as high risk. Prefer https, even with an internal CA in network.tls.additionalCA.
  • Identity headers (piship/v1alpha6 headers, which need identity.mode: oidc) send a claim of the signed-in identity (sub, preferred_username, or a verified email) as a request header. The value is the claim of the identity the launch activated, held in memory; a manifest cannot declare a literal or environment value, so a user cannot name someone else by editing the manifest. That is not proof of identity: the claims come from the ID token at login but are kept in the identity metadata file in the user's own state directory, which the user can edit before a launch, and the server receives no token it could verify. An identity header is therefore not authentication: the server trusts the client and the network path, not a proof. Use it only where that is acceptable, and preferably over https. sub is the stable key; preferred_username and email are only as trustworthy as the identity provider's policy on who may change them, and email is sent only with email_verified: true (Microsoft Entra ID usually omits email_verified, so Entra distributions use preferred_username, which in a work tenant is the administrator-managed UPN). A claim that is missing, not a string, empty, padded, longer than 256 characters, or not printable ASCII (including CR, LF, or other control characters) fails the server's start. Each request checks only the stored identity metadata, never the secret store or the identity provider: after a logout, or when another user signs in while a session runs, that session's requests to the server fail (the server's health is unchanged) rather than send a claim, and the next launch uses the new user's claims. Header names that carry authentication, cookies, framing, hop-by-hop semantics, proxy or client-address information, method overrides, or MCP transport state (Authorization, Cookie, Host, Content-Type, X-Forwarded-*, X-Real-IP, X-HTTP-Method-Override, Mcp-Session-Id, and more, in any case) are refused by validate and again by the transport. The header value is identity data: the lock, doctor, and config explain show only the header and claim names, and audit events and metrics record nothing about identity headers.

Audit

Audit is metadata first. Events (piship-audit/v1) record the event type, time, principal (issuer and subject, never a token; see user switching), session, distribution, and, where relevant, the resource, decision, policy, rule, and enforcement plane. File access is recorded as a path class (workspace, tmp, home, or other), shell commands by tool and byte length, and MCP calls by <server>:<tool>, outcome, and duration. Prompt, response, command, and source content are recorded only for classes the distribution opts in to under audit.capture, and are redacted. The file sink writes <state>/logs/audit.jsonl (0600 in a 0700 directory) and rotates it by size: at 10 MiB it becomes audit.jsonl.1, and five rotated files are kept, so the oldest local events are deleted after about 60 MiB. A piship/v1alpha6 data.audit.retention is a minimum that rotation honors: a rotated file younger than it is kept past the fifth (audit.jsonl.6 and on), so local audit is then bounded by what is written within the minimum, and the retention sweep at launch and logout deletes rotated files once they are older. The bound is approximate: while another process is rotating, a writer keeps appending past 10 MiB and rotates on a later write. Rotation only renames files, so a process still appending keeps writing into the renamed file and no retained line is lost; ship events to an HTTP sink when they must be kept longer. HTTP sinks POST piship-audit-batch/v1 batches through the managed fetch; the integration contract has the schema. Every event carries a random id that a resent batch keeps, so a collector can de-duplicate. Update and rollback activations and refusals are recorded as runtime.update and runtime.rollback events with versions, channel, key ID, and error code. Where the distribution allows auto mode, each ask it approves is recorded as policy.auto_approved with the action's own metadata, and each switch as policy.auto_enabled or policy.auto_disabled; a session started with --yolo records the same events with detail.source: yolo on the first and detail.autoSource: yolo on each approval. A retention sweep is recorded as data.swept before it deletes, and a runtime repair as runtime.mutation.reverted. Local counters for denials, startup failures, sandbox level, MCP health, update, check, and rollback outcomes, identity and credential durations, gateway reachability, live catalog fetches, resource and provider load failures, and the running versions are kept in <state>/logs/metrics.json; they hold only numbers, times, error codes, and identifiers, never a URL, token, subject, or content.

The retention sweep (a piship/v1alpha6 data section) runs after a session ends and at logout and deletes only Pi session files, rotated audit files, and cache files older than their class's declared retention. A session file is deleted only under the sweep's own owner record, so a launch resuming it at the same time keeps it, and rotated audit files only while the sweep holds the audit rotation lock. The declared retention is applied as it is: there is no user retention preference yet, so the bounds the manifest describes (audit a minimum a user may lengthen, the other classes maximums a user may shorten) have no user value to combine with, and a user cannot shorten how long sessions or cache are kept on their machine.

Sink Condition Result
Required Cannot be opened (file) or reached (empty-batch probe, HTTP) at launch Launch fails with AUDIT_UNAVAILABLE
Required Delivery fails Events stay buffered and are retried; governed actions continue
Required Buffer full while delivery keeps failing New events are dropped and counted; governed actions fail closed with AUDIT_UNAVAILABLE until the buffer drains. A model request is a governed action too: it is refused before anything is sent to the gateway, because a prompt can carry workspace content
Optional Cannot be opened, delivery fails, or buffer full Sink is degraded; affected batches or events are dropped and counted; governed actions continue
Optional Close deadline passes In-flight delivery is aborted; the batch counts as dropped
Required Events still undelivered when the session or command ends, or dropped earlier Delivery is retried until the close deadline (5 s). Whatever the sink has not taken is reported, never discarded silently, whenever the session ends through its normal shutdown (Ctrl+D, /quit, SIGTERM, SIGHUP, or the end of a command): the session ends with AUDIT_UNAVAILABLE (the command exits non-zero). A crash of Pi itself (an uncaught exception, or a lost terminal) skips that shutdown, and the events the sink had not taken are then only in the local file sink, if one is configured, and a launch that is already failing keeps its own error and counts AUDIT_UNAVAILABLE among the startup failures doctor shows

A required sink is the whole policy for mandatory audit: events are buffered up to audit.buffer.maxEvents while the sink is down, and governed actions fail closed once that buffer is full. There is no separate setting. doctor reports each sink's state and delivered, pending, and dropped counts, with an HTTP sink shown by host only. doctor gets those counts by opening a governed session as a launch does, so its session.start, policy.loaded, decision, and session.end events reach the sinks under the signed-in principal (doctor report). login, logout, update, and rollback record their events after the operation, so signing out still works while a company sink is down. With only optional sinks that is best effort (a failure is a warning). With a required sink that cannot be reached or does not take every event, the command fails with AUDIT_UNAVAILABLE after the operation, which is not undone; a refused update or rollback keeps its own error and prints the audit failure.

Sandbox

PiShip owns policy and governance. The sandbox backend owns execution isolation. The sandbox is activated only when sandbox.required: true, through the backend sandbox.provider names: the native OS sandbox (the default and the only one described in the rest of this section), a company custom adapter, an e2b-compatible service such as E2B or CubeSandbox, or kubernetes-agent-sandbox. Whatever the backend, PiShip decides each command first, sends only the approved environment (never credential-looking names, and for remote backends never host-bound variables such as PATH or HOME), uploads no files, sends a credential only as sandbox.credential or the adapter module declares it and only to the origins that credential may reach (the inference gateway's for runtime, the ones a stored credential was stored for), and owns every timeout and cancellation. A backend whose declared capabilities miss a plane the policy needs, that is unavailable, or that fails its check fails the launch with SANDBOX_UNAVAILABLE. A report never claims more than the backend enforces: the E2B and Kubernetes backends report host-filesystem-isolation (the sandbox cannot reach this host's files), never the filesystem-* planes, because they do not apply sandbox.filesystem path rules inside the remote sandbox; those rules keep governing PiShip's local file tools. credential: runtime reuses the inference runtime credential under its origin rule; credential: stored sends a sandbox credential bound to the user and the endpoint origins (sandbox credentials). For backends PiShip cannot live-probe, isolation is attested by the backend and checked from outside; see sandbox backends for the contract, what each backend receives, and its limits. Their network denial is verified only when the backend declares a network probe that PiShip then reaches from a temporary allow-mode sandbox of the same backend and cannot reach from the session's sandbox; a probe reachable from the denied sandbox fails closed, and in every other case (no probe, as for the E2B backend, a deny-only backend, or a probe the allow-mode sandbox cannot reach) network denial is attested by the backend and reported so in doctor (network denial).

A remote backend declares whether its sandbox sees the files PiShip's file tools edit: shared (a mount), synchronized (a backend-owned two-way sync), or snapshot (the default, and what the E2B and Kubernetes backends declare). Only shared and synchronized count as a complete coding-agent workspace, and only after PiShip verified them. A shared or synchronized backend reaches this host's files through the workspace, so it may not claim host-filesystem-isolation; it must claim workspace-confinement (attested) and git-control-protection. Just before the first command that reaches the sandbox, never at activation and never from doctor, PiShip writes a random token in a directory it owns (inside the git directory, or a backend-declared directory, which in the working tree is used only for company-origin projects) and has the sandbox read it and answer with another, which shows both directions or lowers the reported mode to what the evidence supports (a warning). The same command tries, without changing anything, to append to the protected git control files and to create a file in the protected git directories; if any attempt succeeds, or the check cannot run, the backend is retired and the command fails with SANDBOX_UNAVAILABLE, because a hook written from the sandbox would run outside it with the user's next git command. Renaming those paths is not tried remotely and stays attested. The check is repeated after 30 minutes or when the backend reports a new environment (workspace).

For the native backend, PiShip selects the platform adapter and runs a live probe inside it: a child must fail to write outside the write allowlist, fail to read denied paths, see only allowed environment variables, and, in deny mode, fail to reach an external address and a host loopback listener. The probe also checks git-control-protection: a child must fail to write a protected file, create a file in a protected directory, or rename the directory that holds a protected file. That check is reported, not yet required, until it has passed on Linux and macOS CI: a local backend that fails it activates without the plane and with a warning. Only planes the probe proved are reported as enforced. If the adapter is missing or the probe fails, the launch fails with SANDBOX_UNAVAILABLE; there is no unsandboxed fallback. The distribution state directory is always read-denied; the installed payload stays readable; tmp is a private per-session directory.

Platform Adapter Status
Linux bubblewrap (bwrap): read-only host root, private /tmp, bind-mounted write paths, denied paths replaced by empty mounts, all namespaces unshared (network shared only in allow mode), capabilities dropped, environment cleared to the allowlist. Git control files and hooks inside write paths are bound read-only, and the directories leading to them are pinned so they cannot be renamed. Session-bus and container-daemon sockets are hidden in every mode Verified with the live probe. Needs bubblewrap and unprivileged user namespaces
macOS Seatbelt through /usr/bin/sandbox-exec with a generated profile: writes denied outside the allowlist, denied paths unreadable and unwritable, (deny network*) in deny mode, writes to git control files, hooks, and the .git entry denied after the write allowlist, and launches that launchd would start outside the sandbox denied: lsopen, Apple events, the LaunchServices and Apple event services, and running launchctl, open, or osascript (matched by path and by code signature, so a copied binary is also covered) Verified in macOS CI (the v0.3 pull request run; current evidence per backend is on the status page) on macOS 26 arm64: the live probe, and a boundary test that first confirms an unsandboxed launchd job runs, then sees launchctl, open, and osascript refused, copied launchctl and open fail, and no escape marker created. Other launch paths not listed here are not covered by a test
Windows None No native adapter: a required native sandbox fails closed with SANDBOX_UNAVAILABLE; a remote backend can be used instead. Child processes that PiShip spawns itself, such as MCP stdio servers, run inside a kill-on-close Job Object, so their descendants end when their leader or PiShip exits; that bounds process lifetime and isolates nothing (its known start-up limits are under Limits). Commands the agent runs through Pi's bash tool without a sandbox are started by Pi and are not wrapped

Limits

  • The Pi process and in-process extensions are not contained. Company, certified, and user extension code runs with the user's privileges, can read files, spawn processes, and use the network directly, and can bypass Pi's tool_call hook and PiShip's governed tools. Trust classes and certification decide what loads; they do not sandbox it. A package that reads project files by itself (.claude/, .mcp.json) is bound by project trust only if it asks Pi's project-trust API; pi-code does, and PiShip answers it from policy (project configuration that an extension loads itself).
  • Model governance covers every path Pi itself dispatches through, all of which pass the model runtime PiShip builds. Extension code can still call a model provider without it, through Pi's model library or its own requests, with any credential it can read. In managed mode the provider variables of the environment are removed before Pi starts (managed runtime isolation), but PiShip does not clear the provider keys Pi's model library knows from the process environment in personal mode, nor any credential an extension reads from elsewhere; model.dispatch does not bound such a call. Only extension.load and trust decide whether that code runs, and in managed mode the gateway's credential scope still applies to the managed credential.
  • Pi's built-in /share command uploads the session file as a GitHub gist through the user's own gh CLI. gh is a child process, outside PiShip's in-process network policy, so network.privateOnly does not apply to it, and no public Pi switch turns the command off. A session file holds the user's prompts, the model's output, and tool results (Secrets), so /share is a path by which a session can leave the machine, in managed mode too. An organization whose sessions must stay inside it controls gh and github.com at its own endpoint and egress controls; the production validation protocol makes that a requirement before the first step.
  • Session export (session.export, manifest) is enforced only where Pi has a seam. public is unsupported: PiShip closes /share's Radius path (no radius credential is issued, and a gateway distribution with the ID radius is refused), but not its gist fallback above. local (/export) is unsupported by construction, since the user can read session files under <state>/sessions. support is enforced only where Pi runs offline (PI_OFFLINE=1, every managed launch): in personal mode Pi's /bug upload is open, so it is unsupported there. The lock's sessionExportStatus and doctor always report all three, whatever the manifest declares.
  • Runtime mutation governance keeps distribution-enforced instructions and sections in every request Pi builds and blocks the session when they cannot be restored, but it is not a boundary against a malicious extension, which is out of scope (see the last item of this list). An extension can still replace the whole provider payload in before_provider_request, after PiShip's last check, or reach the provider without Pi; prompt integrity holds against extensions that rearrange Pi's prompt, not against ones that bypass it.
  • Pi exposes no event when the active tool set changes and none when tool_search discovers or activates a tool. PiShip keeps hidden and denied tools out of the session (excludeTools) and still decides every call at tool_call, so a deferred tool is never executed ungoverned; what tool search can list is decided by what PiShip registers.
  • The sandbox network contract is deny or allow only. There are no hostname allowlists at the OS boundary. In allow mode, host loopback services and abstract Unix sockets are reachable.
  • A remote backend's verified network denial shows that one controlled target is reachable with the network allowed and blocked with it denied, not that every destination is blocked. Attested network denial is the backend's word.
  • Filesystem denies are path based. An existing hard link to a denied file in a readable location stays readable.
  • There is no seccomp or system-call filtering.
  • The workspace check refutes a shared or synchronized claim but cannot prove a mount, and it holds at one point in time: a command that runs in a replaced remote environment before the next check is not checked afterwards. A remote workspace's sandbox.filesystem read denies are not enforced inside the sandbox: a read-denied path inside a shared or synchronized workspace is readable by remote commands unless the backend also claims both filesystem-* planes. workspace-confinement is attested by the backend.
  • Git control-file protection covers the project's own git directory and common directory, the submodule git directories and linked-worktree metadata below them, the files those configs include, and the core.hooksPath directory named in those configs or in the config git reads besides the repository: the system config (GIT_CONFIG_SYSTEM, else /etc/gitconfig and the other places a git installation keeps it: Homebrew's, Xcode's, and the Command Line Tools'; a git built with another prefix needs GIT_CONFIG_SYSTEM to name its file; none with GIT_CONFIG_NOSYSTEM), the global config (GIT_CONFIG_GLOBAL, or ~/.gitconfig and the XDG file), and the GIT_CONFIG_COUNT and GIT_CONFIG_PARAMETERS (what git -c exports) settings of PiShip's own environment; no other file is read. A config that lists more than 64 includes, hooks paths, or settings, a file over 1 MiB, or a GIT_CONFIG_PARAMETERS that is not in the format git writes, is followed only in part, and doctor reports git control as not verified. The system, global, and environment config, and what they include, are kept read-only for sandboxed commands wherever they lie, which matters when the sandbox may write the directory that holds one (a distribution that allows ~); PiShip's file tools do not refuse them, so the edit tool can still change the user's ~/.gitconfig. On Linux (bubblewrap) a control file that does not exist yet (such as commondir, config.worktree, or an include nobody has created) cannot be guarded, since a mount over a missing file would leave an empty file in the repository and PiShip creates no placeholder files: doctor reports git control as not verified, and the sandbox warning names the files, for every regular repository. The sandbox still runs. On macOS (Seatbelt) a missing file is denied and stays verified when the directory above it cannot be swapped (the workspace root, another writable root, or a directory that holds an existing protected path such as .git); one in any other directory (an include under a directory of the working tree, a global config whose directory does not exist) is not verified there either, since a command can build the directory elsewhere and rename it into place. The scripts a core.hooksPath directory in the working tree calls (husky's .husky/pre-commit) are ordinary workspace files, so doctor reports git control as not verified there too. So does a protected path reached through a symbolic link in a directory the sandbox may write (an include that is a link, a linked .git/hooks, a dotfile-managed ~/.gitconfig when ~ is writable): protection covers what the link points to, and no backend can hold the link itself in place.
  • On Windows, the start of a child that PiShip spawns itself (such as an MCP stdio server) has three known limits, not fixed in v0.7. A child that writes the launcher's startup-failure marker (PISHIP_WINDOWS_JOB_ERROR) to its own stderr has that output dropped and its exit reported as a startup failure, because the marker is read from stderr. The Job Object helper is compiled from C# at every child start, which adds startup time to each one. And the request that carries the child's file, arguments, working directory, and approved environment travels base64-encoded in one environment variable of the launcher (PISHIP_JOB_REQUEST), so a request larger than Windows allows in one variable (documented as 32,767 characters; not measured here) cannot start the child.
  • Machine administrators, and anyone who can modify the installed payload, state, or inventory, can defeat these controls. Governance does not protect against them or against a malicious extension a distribution chose to load.

Releases and updates

The production lifecycle is described in the release guide: the owner workflow, artifact contract, and update lifecycle. Its trust model:

  • Consumer verification. verify-release checks archive digests, checksums.txt, every payload file against the inventory, manifest and lock agreement, release.json against the payload, SBOM completeness, notices coverage, and the recorded scan and test results. It proves a release is complete and consistent, not who built it.
  • Update trust. An installation keeps its current update root (keys with a root role and a channel role, each with a threshold) as installation state, started at a fresh install from the verified release's updates.trust.bootstrap. Before every update it fetches newer roots (root/<N+1>.json) one version at a time, accepts each only when signed to both the current and the new root-role threshold, and persists it; any fetch failure other than an authoritative 404 stops the update. Release activation, rollback, and release locks never change the installation's root, so a channel signer cannot widen its own authority, and a key a newer root removed stays refused after rollback. A damaged or missing trust state fails closed and is never rebuilt from a release lock.
  • Channel signing. Channel metadata must carry signatures from the newest root's channel role, at least its threshold of distinct keys (piship-signature/v1, with an optional signatures[] whose top-level entry keeps v0.7 clients working). It must name its distribution and channel, not have expired, and not have a lower sequence than the client has already accepted, which blocks replay. Each archive is then checked against the SHA-256 and size in the signed entry before extraction, and its version, command, Pi version, and lock digest must match that entry. There is no unsigned or trust-on-first-use mode. Key custody, bootstrap, rotation, emergency revocation, lost-key recovery, and channel hosting are in release trust root and the key runbook.
  • Build provenance. The release-candidate CI workflow runs only when a maintainer dispatches it, alone or through Release qualification, on the exact candidate commit. It builds each target twice on separate runners from fresh checkouts and fails unless piship reproducibility finds equal payloads. It creates GitHub artifact attestations (Sigstore keyless signatures bound to the GitHub Actions workflow identity and the ref the run was for) for the first build of each archive. A separate job verifies them with gh attestation verify --repo --signer-workflow --source-ref. The same job rejects a tampered archive, a wrong digest, and a modified payload file, release.json, or checksums.txt. A run dispatched on another branch is attested with that branch's ref (and runs from before v0.5, when the workflow also ran for pull requests, carry refs/pull/<n>/merge), so consumers verify with --source-ref refs/heads/main to accept only main builds (Build provenance). This is independent of the owner's channel key: provenance says which workflow built an archive, and the channel signature says which releases the owner offers.
  • Update transport. Update sources must be https, plain http on a loopback host, or a local directory. Plain http on a private or internal host is admitted by default, and a distribution forces HTTPS-only with updates.transport: https (manifest); a public host is refused, and only the host name is judged, so an internal-looking name that DNS maps to a public address is the owner's to prevent. A plain-HTTP source needs updates.trust.bootstrap, and it changes nothing about verification: update integrity never came from the transport, because roots, channel metadata, and archives are checked against the installation's root, its thresholds, the sequence floor, expiry, and signed digests. A network attacker on the plain-HTTP path can therefore withhold or delay updates, which blocking an https host also achieves, but cannot forge, alter, or replay them. Releases carry no secrets. Requests use the managed fetch with the distribution's network policy (proxy, CA bundles, and privateOnly with its allowed hosts, plus the declared updates.source host), follow a redirect only within the source's origin (so never from https to plain HTTP), and archive names must be plain .tar.gz file names. When a runtime variable is not set in the shell that runs update or rollback, the policy comes from the manifest alone: the proxy setting, privateOnly, and network.allowHosts apply as at launch and the hosts of the unresolved endpoints are not added, so the policy is never wider than a launch's, and a --from URL on a host it does not allow fails with NETWORK_DENIED, and an HTTP audit sink on such a host is refused as it is at launch. A declared CA bundle is never left out: an unset variable in network.tls.additionalCA fails with CONFIG_UNAVAILABLE naming it, as at launch, before any request. Release content is signature-checked either way. Extraction refuses links, special files, unsafe or reserved names, and oversize archives.
  • Supply-chain gates. piship release refuses locked packages and bundled search tools from origins outside release.sources, a search tool archive that is not the official upstream asset for its version and target, locked packages without a recorded source or sha512 integrity, unreviewed npm lifecycle scripts, an unsupported Pi pin, policy conflicts, missing certification evidence, and a required sandbox on a target without an adapter. It records an SPDX SBOM and third-party notices, and blocks on npm audit findings at or above release.vulnerabilities.failOn unless a reviewed exception has not yet expired. A registry signature gate runs npm audit signatures over the payload packages: an invalid signature or attestation fails the build, missing signatures are recorded, and a check that cannot run is recorded as unavailable rather than failing the build. These gates run only in piship release; piship build, dev, and test do not apply them.
  • Activation. Payloads are immutable per version; the active release is switched by one atomic receipt rename after the archive and every extracted file match the signed or bound digests and the migration check has passed. No candidate is launched first: release qualification runs the smoke tests, and the first launch of the new release checks its lock. Downgrades through update are refused; rollback only returns to the locally retained release, after checking its target and metadata.
  • Credentials across versions. Credentials are never part of a release, snapshot, or rollback. Update and rollback keep credential metadata only when the target can read its schema; otherwise they revoke it where supported, then delete every local secret reference (including orphaned and pending generations) and the metadata, so the target signs in or reacquires. A discarded credential marker is always cleared this way. Every deletion is confirmed by reading the reference back: a secret that cannot be deleted, or that no secret store is available to delete, stops the switch before activation with SECRET_STORE_UNAVAILABLE and keeps all metadata, so no release is ever left with a secret that nothing references. The secret store is selected from the lock alone, so a runtime variable that is not set cannot turn this into a silent skip. Rollback therefore cannot restore a credential that logout removed or revoked; this is tested for every credential class (identity tokens, the runtime credential, a discarded credential, and the non-secret pending revocation record) across update, rollback, a second update and rollback, and a reinstall over kept state.

Limits: release archives carry no macOS notarization or code signature and no Windows Authenticode signature. Provenance depends on GitHub Actions and the repository's workflow files. A channel host can withhold updates until metadata expires. A stolen channel key stops counting on a client once that client fetches a root that removes it; an attacker who both holds a still-trusted channel key and controls what the client receives from its update source can withhold that root, so the key stays usable against that client until it reaches the authentic source or its current root or the attacker's metadata expires (security boundary). Over plain HTTP (a private updates.source over http, unless updates.transport: https), anyone on the network path can answer root/<N+1>.json with a 404 as the source's operator can over HTTPS, so this root-withholding exposure extends from the endpoint operator to the network path; it still does not let them forge or replay metadata. A channel signature also authorizes code: a malicious release a client activated ships the CLI that manages the installation and can rewrite its trust state, so root rotation recovers only installations that have not yet activated a release signed with the compromised key; the others need a reinstall from an archive verified out of band. An installation whose trust originated from v1alpha4 legacy keys (both roles, threshold 1) gives a compromised legacy key root authority as well, so root rotation cannot recover it either; owners should move to a split root and channel bootstrap promptly. The vulnerability verdict is a build-time snapshot. Local workspace packages are linked rather than downloaded, so they are left out of the lock's package list and are covered by the payload inventory and archive digest, not by the source gate or SBOM checksums. Any other locked package without integrity fails the source gate.

Shared file store

An install or update may place the runtime, Pi package, and dependency files of a release (payload paths under node_modules/ and pi-packages/) from a user-local, content-addressed store instead of writing them itself (PISHIP_STORE; hardlink by default on Windows and off on macOS and Linux, PISHIP_STORE=off disables it everywhere, see performance). The store is a cache of bytes, not a trust root and not part of the installation. Its design, architecture, exists to keep five properties that a shared location could otherwise break:

  • An installation is whole without the store. Each installed file is its own file in apps/<id>/<version> (with hardlink, a second name for the object's file, which survives the object's removal). The receipt names no store object, a launch never opens or creates the store, and a release archive carries no store reference. A machine with an empty or missing store installs and launches exactly as one with a full store, and deleting the store removes only saved writes. packages/core/src/store-boundary.test.ts fails if launch, build, bundle, or archive code refers to the store.
  • A store hit proves nothing about trust. An object is named by the SHA-256 of its bytes, and it is placed only after its bytes are read back and hashed to the digest the archive's own bytes have. Install and update then check every file against the release inventory exactly as they do without a store. Trust is decided by the lock, the signed channel entry, and the inventory, never by what the store holds.
  • A distribution's own files never enter the store. Resources, the lock, policy, metadata, and anything outside node_modules/ and pi-packages/ are written by the installer. Files are shared by content, so a file two distributions both ship is one object; what a distribution is allowed to run is still governed by its own lock.
  • GC cannot delete a live runtime. Collection removes store objects and bookkeeping only; it never touches an installed release, so whatever it decides about an object, no installed file is removed or changed. It keeps every object that the active release and the rollback target of each installed distribution placed, keeps all it cannot judge (an unreadable receipt, a release recorded for another install home that still exists), and sweeps nothing while an install or update is filling the store or while a new object, reference, or marker is younger than 24 hours. It runs only from doctor.
  • The store cannot be used to install a different version. Objects are looked up only by the digest of bytes in hand, never by name, version, or "latest". The reference that pins a release is written under the release's own id and version after its files were checked, and what that reference keeps alive is decided by the installation's receipt, not by the reference.
Threat What prevents it Residual risk Tests
Mutable shared code. A program writes to a file of one installation and the change reaches another installation or the store. Objects are read-only and never modified after publication. With copy and clone every installed file is its own inode (a clone shares blocks copy-on-write), so a write reaches nothing else. With hardlink (the default on Windows), objects are 0444/0555 or, on Windows, carry the read-only attribute, so a write fails; a placement hashes the object first, so a changed object is replaced and never spread; a placement also restores the read-only mode of an object whose bytes are right but whose mode was changed; and doctor verifies the store and every installed payload and reports a changed installed file as damaged, which repair restores from a trusted archive. PiShip itself never writes to an installed payload file after extraction (the launcher, receipt, leases, state, and data are outside the linked trees, and update and rollback replace or remove whole version directories), which store-lifecycle.test.ts checks by running install, update, rollback, and uninstall over a hard-linked store. With hardlink, a program that runs as the user, can change a file's mode, and writes to it changes every installation that links the object until the next verification finds it; on Windows a program can clear the read-only attribute of a file it owns as easily. That is the same authority that can already rewrite the installed tree, the launcher, the receipt, and the user's state, so it adds the ability to reach another installation of the same user (and the store) without touching it, until a doctor or the next placement of the object. The program is responsible, not PiShip: PiShip makes the change visible and repairable, not impossible. Pi packages that write into their own installed directory (a cache under node_modules/) would write through the link on every installation that shares the file; the supported place for package data is the data directory, and PISHIP_STORE=off is the control for a deployment that cannot guarantee it. On Windows, a file's read-only attribute is shared by all of its names, so a delete path that cleared it on one name would clear it for the object; the placement restores it. Hard-linked files cannot be flushed with FlushFileBuffers (it needs write access), so syncTree skips them on Windows as it skips any file it cannot open for writing; a power loss after an update can leave such a file truncated, which doctor reports as a damaged installation and repair restores. store.test.ts "never lets a write to a file placed by copy reach the object or another file" (and clone), "detects, and does not spread, a mutation of a hard-linked object that a program forced", "shares the inode only when asked to hard-link, and then keeps the object read-only", "removes a tree of read-only hard-linked files, by every kind of removal, and leaves the object intact", "restores the read-only mode of an object..."; store-link-limit.test.ts; store-lifecycle.test.ts "never writes through a hard link..."
Cross-distribution trust bleed. A distribution gets bytes, policy, or approval that another distribution's installation left behind. Only runtime, Pi package, and dependency bytes are shared; resources, locks, policy, metadata, and state are not (state is never inside a store-linked tree, and the store must be disjoint from the state, install, and bin homes). Nothing records which distribution an object came from, and a hit changes no trust decision. Distributions of one OS user share a store file system and can read one another's objects, as they can read one another's installs. store.test.ts "leaves what a distribution owns, and empty files, to the caller"; store-lifecycle.test.ts "installs a release whole, with its shared files from the store and no reference to it"
GC deleting a live runtime. See above: GC removes only objects and references; it pins the active and rollback releases from the receipts, defers to an install in flight, and honours a 24-hour grace period. An interrupted install or update leaves objects the next collection may remove and never changes an object a live release placed. A release no longer pinned loses its store objects after 24 hours; an install of it fetches them again from the archive. collect.test.ts "keeps what the active and the rollback release pin, and collects the rest", "sweeps nothing while an install or update is filling the store", "recovers from an operation that died, without touching a live release"; store-lifecycle.test.ts "pins the active release and the rollback target...", "keeps a live release intact when an update is interrupted..."
Wrong-version resolution. An install or rollback uses an object of another version, or a reference of one version keeps another's objects. Objects are content-addressed; references are per (id, version); liveness is the receipt's active and previous, so a reinstall, update, or rollback pins exactly the releases it names. None beyond the SHA-256 collision bound. store-lifecycle.test.ts (update and rollback); collect.test.ts
Store poisoning and corruption. A damaged, truncated, replaced, or symlinked object, or a file planted under an object's name. A placement lstat-checks the object (a regular file of the right size) and hashes it before use; a mismatch is removed and rewritten from the bytes in hand, counted as repaired, and never installed. doctor verifies every object within a budget and removes damaged ones. A program with the user's write access can also replace the install; that is outside the threat model. store.test.ts "detects a changed, truncated, or replaced object, replaces it, and installs the right bytes"; collect.test.ts "finds a damaged object and removes it so the next install writes it again"; store-lifecycle.test.ts "repairs a damaged object from the next install's own bytes..."
Interleaved writers. Two installs or an install and an update fill the store at once. Objects are written to a temporary file in the store and published by one rename; a lost race verifies and uses the winner's object; an in-flight marker defers collection. None known. store.test.ts "materializes one object correctly from many writers at once"; store-processes.test.ts
Reference and path abuse. A distribution id or version used as a path component. A reference is written only for an id and version that match the install's own name rules; a reference whose recorded id, version, or objects do not match its path or the object name rules pins nothing. None known. store.test.ts "records the objects a release placed, whole, and refuses a name that is not an id"

v0.11 security review

The review that closes the v0.11 milestone (task D1). It was done on main at the shared runtime store (#229), and on the two other v0.11 pull requests as branches, read-only: dependency sharing and bundle-safe closures (v011/footprint) and the piship/v1 contracts and migration (v011/contracts). It went past the code to the evidence chain a release carries, and it fixed what it found on main: three defects, F1 to F3.

Scope. The review concentrated on what is new in v0.11 (the runtime store, dependency sharing, closure bundling, the v1alpha6 to v1 migration) and on the release evidence (SBOM, notices, scans, signatures, attestation). The areas that did not change (identity, credential, inference, policy, resource, provider and project trust, MCP, sandbox, audit and redaction, update, and rollback) were not audited again: their evidence is the thirteen-case test map and the lifecycle E2E, which run in this milestone's qualification, and their responsibilities are in the table below.

Findings

ID Finding Status
F1 The registry signature gate verified nothing. piship release ran npm audit signatures --omit=dev in a payload whose root package.json lists workspaces but whose packages/ directory is gone. npm reaches the runtime packages only through those workspaces, so every package was unreachable, --omit=dev dropped it, and npm answered "found no dependencies to audit that were installed from a supported registry". The release recorded signatures: unavailable and reported qualified. The v0.9.1 candidate archives of Release qualification run 37297400038 (acmecode and mypi on linux-x64 and win32-x64) and a local build on darwin-arm64 all carry that verdict. The vulnerability scan had the same flaw and was fixed earlier; its comment names it. Fixed. withWorkspaceManifests (packages/core/src/release/scans.ts) puts the workspace manifests in place while npm runs and removes them; a local release now records passed with no invalid and no missing signature. Tests: packages/core/src/release/scans.test.ts; Release candidate verify runs scripts/check-release-evidence.mjs, which fails on a verdict that audited no package (tests/release-evidence.test.ts). The first qualification run on this commit is the first CI evidence of a verified signature on all three targets
F2 The SBOM carried no source or integrity for a vendored Pi package's dependencies. generateSbom took the registry URL and SHA-512 only from the lock's runtime packages, so every dependency under pi-packages/<id>/node_modules had downloadLocation: NOASSERTION and no checksum, although each package's npm lockfile ships beside it and records both. The omission was independent of the file layout, but sharing and bundling make the files a poor place to read it from. Fixed. generateSbom reads each vendored package's package-lock.json (packages/core/src/supply-chain.ts), and ignores a lock entry whose version is not the installed one. Tests: supply-chain.test.ts "records the source and integrity of a Pi package's dependency from its lockfile..."
F3 Two installs opening a new store at once could turn the store off for one of them, and an opener that stopped halfway could turn it off for good. ContentStore.open wrote the layout marker (store.json) in place; a second process that read it between its creation and its content found an unknown layout and carried on without a store, and a crash left an empty marker that every later open refused. Installs are whole without the store, so nothing installed was wrong, but concurrent installs silently stopped sharing, and store-processes.test.ts failed in about one run in five under load. Fixed. The marker is written to a temporary file and published by one rename, and an empty marker is written again; a marker that is not ours is still left alone (packages/core/src/store/store.ts). Tests: store/store.test.ts "writes its marker whole, and writes again one an earlier opener left empty"; store/store-processes.test.ts passed 20 of 20 runs under load after the change, and 16 of 20 before
F4 The rest of the shared file store: no defect. Every case below holds, and the new tests state them as attacks (packages/core/src/store/security-cases.test.ts). One hardening note: ContentStore.place names a new object by the digest its caller passes and does not hash the bytes again; both callers compute that digest from the very bytes they pass, and an object that does not match its name is detected as damaged and replaced at its next use, so a wrong digest cannot outlive the operation. Documented
F5 Dependency sharing and closure bundling: one defect, fixed. The review found none. The tests written for the merged code did: two sets of one package name and version with the same bytes and different registry integrity (for example from a mirror) were each shared, and both got the same directory name, because it was derived from the bytes alone; the second rename landed on a non-empty directory and failed the build. Never a mixed tree, but a build that could not finish. The name is now derived from the integrity and the bytes, and a name already taken keeps the second set package-local and records why (dedupe.test.ts "keeps copies of one content apart when their registry integrity differs", "keeps a set package-local when its shared directory name is already taken"). Fixed, tested
F6 Registry signatures cover the runtime closure only. The trees vendored under pi-packages/<id> are checked for integrity and advisories, not for registry signatures (artifact contract). A per-package check would also meet npm's hidden lockfile, which dependency sharing leaves older than the directories it rewrote. Documented limit

The eight special checks

Each row says where the case could occur, what prevents it, and which test fails if it regresses. Tests are in packages/core/src unless a path says otherwise. A test marked (contracts) is on that branch and is part of the pull request that introduces the code; a test marked to add does not exist yet and is listed with its target in v0.11-followups.md.

Case Where What prevents it Tests
Cross-distribution trust bleed the runtime store; dependency sharing; the migration Only node_modules/ and pi-packages/ bytes enter the store, named by their digest; a distribution's resources, lock, policy, and state never do, and a hit changes no trust decision. Sharing exists inside one distribution's payload and has no reference outside it. A migration rewrites one manifest and records no resolved value, entitlement, or credential store/store.test.ts "leaves what a distribution owns, and empty files, to the caller"; store/security-cases.test.ts "an object two distributions share stays while either one retains it..."; store/collect.test.ts "collects everything of a distribution that is no longer installed, and nothing of another"; store-lifecycle.test.ts "refuses a store inside the install home..."; store-boundary.test.ts; tests/e2e/shared-store.test.ts (the installed tree and state name no store); (contracts) lock-v1.test.ts "never records resolved runtime values, entitlements, or credentials"
Mutable shared code store objects; .shared Objects are read-only; with copy and clone every installed file is its own inode; with hardlink a mutation is detected and never spread, and the mode is not the default. Shared dependency files live inside one installed version and are in its inventory, so doctor and verify-release see a change. Two packages of one installation share one instance of a dependency, by design (Pi packages) store/store.test.ts "never lets a write to a file placed by copy reach the object or another file" (and clone), "detects, and does not spread, a mutation of a hard-linked object that a program forced"; store/collect.test.ts "finds a damaged object and removes it so the next install writes it again"; pi-packages/dedupe.test.ts "shares identical copies and keeps Node's resolution exactly"; release.test.ts "verify-release and doctor name a changed file" (a shared file, a forwarding module, an added and a removed shared file, a bundled module: verify-release and verifyPayload, the check doctor runs, both fail with INTEGRITY_FAILED naming the path)
Dependency confusion the store; dependency sharing; vendored Pi packages A name and a version are never a key. An object is found by the digest of bytes in hand. A dependency is shared only when name, exact version, registry integrity, and the digest of every file (and its executable bit) are equal, and a copy without integrity, with other bytes, or of another version keeps its own. Packages come only from release.sources and the lock's integrity (Pi packages) store/security-cases.test.ts "two distributions that ship one package name and version with different bytes each install their own"; pi-packages/dedupe.test.ts "keeps package-local copies" ("when the versions differ", "when the content differs at the same version", "when only an executable bit differs", "when a copy has no registry integrity", "never shares the declared package, whatever it holds"); tests/e2e/pi-packages.test.ts
Wrong-version resolution store references; Node resolution after sharing; schema migration A reference is per (id, version) and liveness is the receipt's active and previous. A shared dependency resolves where it did, through the same exports map, to the same bytes. An update or rollback across piship/v1alpha6 and piship/v1 installs and rolls back the release it names store/security-cases.test.ts "a release of the same name and version with other bytes is a different object, and a rollback keeps its own"; store-lifecycle.test.ts "pins the active release and the rollback target..."; dedupe.test.ts "shares identical copies and keeps Node's resolution exactly", pi/src/compatibility-packages.test.ts "loads the same extensions from shared dependencies and a bundled closure"; (contracts) lifecycle-schema-compat.test.ts "updates a piship/v1alpha6 installation to a piship/v1 release and rolls back, leaving the old release untouched"
Rollback corruption update and rollback; the store; migration The target release stays whole in apps/<id>/<version> and is verified before it is activated; the receipt switches by one atomic rename; an interrupted update discards only its own debris; credentials are never in a snapshot tests/e2e/lifecycle-rollback.test.ts; tests/e2e/lifecycle-update.test.ts; tests/e2e/lifecycle-credentials.test.ts; store-lifecycle.test.ts "keeps a live release intact when an update is interrupted, and discards only the update's own debris"; tests/e2e/shared-store.test.ts (update and rollback with a store); (contracts) lifecycle-schema-compat.test.ts. Not covered: an update and rollback of the developer release (a gap)
GC deleting a live runtime doctor's store collection A collection removes store objects and bookkeeping only, never an installed release; it keeps what the active and the rollback release of every installed distribution placed and everything it cannot judge, sweeps nothing while an install or update is filling the store or within the 24-hour grace period, and runs only from doctor store/collect.test.ts "keeps what the active and the rollback release pin, and collects the rest", "sweeps nothing while an install or update is filling the store", "recovers from an operation that died, without touching a live release"; store/security-cases.test.ts "GC deleting a live runtime: an object a collection removes under a running install is never placed damaged...", "an installed hard link keeps its bytes when the collection removes the object it was linked from"; store-lifecycle.test.ts; store-boundary.test.ts (no launch, build, or archive code opens the store); tests/e2e/shared-store.test.ts (with every store file past the grace period, doctor removes an unpinned object and keeps both releases' objects)
Store mutation the store An object is created once, by one rename, read-only, and hashed before every placement; a damaged, truncated, replaced, or symlinked object is replaced from the bytes in hand and never installed; doctor verifies every object within a budget store/store.test.ts "detects a changed, truncated, or replaced object, replaces it, and installs the right bytes", "materializes one object correctly from many writers at once"; store/store-processes.test.ts; store/collect.test.ts "finds a damaged object and removes it..."; store-lifecycle.test.ts "repairs a damaged object from the next install's own bytes, never installing the damaged ones"
Bundle dependency omission closure bundling; the portable runtime bundle; dependency sharing A closure is bundled only when it is pure, statically imported JavaScript with no native code, WebAssembly, install script, dynamic import, or run-time discovery, and nothing left behind imports it; otherwise its files stay and the reason is recorded. What a bundled or shared package leaves on disk is its package.json and license files, so it is still listed. The runtime bundle records the packages it bundled (metadata/bundle.json). The SBOM and the notices list the graph, and Release candidate checks them on every target supply-chain.test.ts "the logical dependency graph under a smaller file layout" (a shared layout and a bundled runtime list the same packages, source, integrity, and license text); tests/release-evidence.test.ts; pi-packages/closure.test.ts ("bundles below the extension files and leaves them byte for byte", "falls back, saying why"), dedupe.test.ts "lists the same payload packages before and after sharing"; release.test.ts "the SBOM and notices do not depend on the file layout" (a release built vendored, shared, and shared with a bundled closure has the same SBOM packages and relationships, license index, and notices), dedupe.test.ts "lists the same payload packages before and after sharing" (packages, SBOM, and notices)

Responsibility boundaries

What each party owns, for the controls in this document. "PiShip guarantee" holds when the distribution is built and installed as documented and the machine is the user's; the last column is what the threat model does not cover.

Concern PiShip guarantee Distribution owner External IdP, gateway, broker OS, administrator, endpoint (outside the threat model)
Identity OIDC with PKCE and a validated ID token; the identity of a session never changes silently; no credential survives a change of user Issuer, client, and claims in the manifest; who may sign in Authentication, MFA, account lifecycle, token revocation A user or process with access to the user's session or keychain
Credentials Stored in the platform secret store, bound to the principal, revoked at logout, never in a snapshot, report, or rollback Broker endpoint, credential class and lifetime, acknowledgePlaintext for a file store Issuing, scoping, and revoking runtime credentials The secret store's own protection; a debugger or administrator reading process memory
Inference and models Only declared, entitled models; the declared gateway only; ambient provider keys never sent in managed mode The model list and routes, the gateway Model access, rate limits, upstream provider keys, logging The provider's handling of prompts
Policy Enforced decisions for every action with a runtime seam; an action without one is reported unsupported, never enforced The policy, policy.enforced, defaults Anyone who can edit the installed payload or state
Resource, provider, and project trust Only declared and admitted resources load; certified digests are checked; project configuration loads only as policy admits What is certified, reviewed, and admitted; the packages it chooses Code a distribution chose to load runs with the user's privileges
MCP Only declared servers; tool results redacted and capped; plain HTTP only to a private host, httpTransport: https forbids it The servers declared and their trust class The remote server's behavior A malicious local server the user's own account can start
Sandbox Activation fails closed when it is required and cannot be enforced; containment is what the backend attests Whether it is required, the backend, its image and credentials A remote sandbox service's isolation Windows has no native adapter; kernel and sandbox escapes
Audit and redaction Metadata-first events; a required sink that is down stops the launch; secrets are redacted The sink, retention, what leaves the machine The collector's storage and access Anyone who can read or alter local logs
Release artifact Deterministic archive, inventory, SBOM, notices, scans, and recorded results; provenance for the project's own CI builds; verify-release Reviewing the lock and exceptions; key custody; publishing The registry's package signatures and advisory data The machine that builds or installs; code signing and notarization are not provided
Update and rollback Signed channel, root and role thresholds, replay and downgrade refusal, atomic activation, local rollback The channel, its keys, rotation and revocation A channel host withholding updates; a compromised still-trusted key and a controlled network
Migration migrate --check changes nothing; ambiguous configuration is reported for review and never rewritten; no credential or resolved value enters a migrated manifest Reviewing every reported item
Dependency sharing and closure bundling Same name, version, integrity, and bytes only; Node resolution, the lock's logical graph, the SBOM, the notices, and the install-script review unchanged; every file in the inventory Choosing Pi packages; reviewing the footprint report
Runtime store A cache of bytes, never a trust root; an installation is whole and a launch never opens it; collection only from doctor Whether to enable it (PISHIP_STORE) A process with the user's write access can change the store as it can change the install

Logout and revocation

logout revokes the runtime credential at the broker's revoke endpoint when one is declared, revokes identity refresh and access tokens at the provider's revocation endpoint when discovery advertises one, then deletes local secrets, including any orphaned or pending generations, and metadata. Revocation failures are reported as warnings, and local clearing still happens. Local clearing does not depend on the runtime configuration: when a runtime variable is not set, or a provider cannot be loaded, logout warns, contacts nothing, and still deletes the local secrets and confirms it; the runtime credential is recorded as a pending revocation (when the provider could revoke it) and the identity tokens are audited as not revoked, since both may still be valid remotely. A secret that cannot be deleted makes logout fail with SECRET_STORE_UNAVAILABLE and a message naming what was and was not cleared; its metadata stays so the next login or logout deletes it. logout also deletes the stored sandbox credential, which has no remote revocation; sandbox logout deletes only that one. Preferences and the principal binding are kept, and so are sessions unless a piship/v1alpha6 data.purge.onLogout names sessions (sessions no live process owns are then deleted); the retention sweep also runs at logout (retention sweep). purge removes PiShip-owned state files and deletes the secret-store entries its metadata references, confirming each deletion, but it revokes nothing. So while an identity session, a runtime credential, or a stored sandbox credential is present (a discarded marker counts as signed out), purge and uninstall --purge refuse before deleting anything: run <command> logout, which revokes the credential, then uninstall <id> --purge --yes (after a plain uninstall, node <release-dir>/payload/bin/<command> logout runs the same sign-out from the downloaded release, then purge <id> --yes). Only with no release left to run logout, --without-logout purges anyway, deletes the local secrets, and warns that what it held stays live at the identity provider or broker until it expires. A secret it cannot delete fails the purge before any state is removed, so running it again retries. Credentials that PiShip does not manage, such as Pi-native provider auth, may need manual revocation. Update and rollback revoke, best effort, a credential they must clear because the target cannot read it.

User switching

The user is the principal (iss, sub) (identity). User A's runtime credential is never usable by user B:

  • Bound state. The runtime credential and its entitlement (the broker's model list) are bound to the principal they were issued to, and checked against the signed-in principal before every use. A credential bound to anyone else, or to nobody, is revoked where supported and deleted, never used. The model selection (model and models.allowed in config/preferences.json) is cleared when the principal changes; other preferences stay. A missing principal binding (state from a release before v0.7) counts as a change unless the stored session is the signing-in principal's, so a user who signs in after the previous user signed out under that release does not inherit the selection. The model catalog is fetched with the current credential at each launch and never cached on disk.
  • Sandbox credential. The stored sandbox credential is bound the same way: it is used only by the principal that stored it (or, without identity, only while no identity is configured), and only for the endpoint origins it was stored for. login as another principal deletes it, confirming the deletion, before the new identity is stored, and a failed deletion fails the login. A launch that finds one bound to anyone else, including after a change of workload principal, deletes it, confirming the deletion, and refuses with SANDBOX_UNAVAILABLE; a running session that finds it changed stops using it without deleting anything. logout, sandbox logout, and purge delete it, and an update or rollback to a release that cannot read it deletes it before the switch.
  • Order of a sign-in. login revokes and deletes the previous credential and confirms the deletion before it stores the new identity. On a change of principal it also clears the model selection and the previous identity session (its tokens are revoked at the provider where supported, then deleted and the deletion confirmed) before the new identity is stored. A crash at any point leaves either the previous identity without a credential or the new identity without one, never the new identity with the old credential.
  • Fail closed. A secret that cannot be deleted, or whose deletion cannot be confirmed (a locked keyring, a store error), fails the login before the new identity is stored, and fails every launch, until the secret store works again. The references stay tracked by a discarded marker that no release reads as a credential, so neither a later launch nor a rollback to an older release can use them.
  • Revocation failures do not block the next user. A failed broker revocation is shown, audited as credential.revoke with revocation: failed, and recorded without the secret as a pending revocation that every login checks and doctor reports. The local secret is deleted all the same. Because the broker authenticates revocation with the credential itself, PiShip cannot send the revocation again once the secret is deleted: a pending entry is resolved when the credential expires, or by an administrator revoking it at the broker.
  • Rollback. Snapshots never contain credentials, and rollback never restores one, so it cannot bring back the previous user's credential.
  • Running sessions. A running session serves the principal it started with. Its first activation (or its login) pins the principal, and every later credential use compares the stored credential's bound principal and generation with it: a session never serves its cached secret once another user signed in elsewhere, never renews into a credential issued to someone else, and fails with IDENTITY_REQUIRED ("The signed-in user changed; restart the session") instead. An identity refresh must keep the pinned principal, and never refreshes or returns a session another user stored meanwhile (IDENTITY_INVALID, "Another identity signed in").
  • Serialized changes. A sign-in runs from clearing the previous credential to acquiring the new one under the credential lock (credentials-metadata/inference.json.lock), clears another principal's sandbox credential under the sandbox credential lock (credentials-metadata/sandbox.json.lock), and writes the identity under the identity lock (identity/session.json.lock), always in that order; a sign-out takes the same locks in the same order, and an identity refresh takes only the identity lock, so no two of them wait for each other in a cycle. A launch or renewal re-reads the stored identity under the credential lock and stops, touching nothing, when it is no longer the principal it resolved (another sign-in or a sign-out happened meanwhile). The browser part of a sign-in runs before any lock is taken.
  • Sign-out. logout revokes and deletes the runtime credential and the identity tokens and confirms each deletion. Tokens that cannot be deleted leave an identity discarded marker (piship-identity-discarded/v1) in place of the session: it names only the references still to delete, no release restores it as a session, and every later command deletes them first and fails closed (SECRET_STORE_UNAVAILABLE) until it can, so the signed-out user cannot launch again on the kept tokens. The command reports the failure and exits with an error.
  • Attribution. Audit events name the principal as one string, the issuer, #, then the subject (principalId in @piship/contracts; % and # in the issuer are percent-encoded), so two users with the same subject at different issuers are never confused. Email and display name are attributes, never the attribution.

Limits:

  • Pi session history is project data owned by Pi. With an identity session it is kept per principal: sessions/user/<principal digest>/ and sessions/acceptance/<principal digest>/, where the digest is 32 hex characters of a SHA-256 over the issuer and subject, so another user signing in on the same OS account does not resume the previous user's sessions, and the same user resumes across logout, login, update, and rollback. A distribution with no identity provider (a personal one) keeps the shared sessions/user/. Sessions written directly under sessions/user/ by an earlier release are not resumed by a signed-in user; purge removes them.
  • The principal binding and pending revocations are ordinary files under the state directory: anyone who can modify the state directory can defeat them, as with the rest of the state.
  • A release before v0.7 cannot finish a deletion that failed. PiShip's own update and rollback delete the secrets a discarded marker (credential or identity) names before they switch, and stop when they cannot; but an older release that finds a marker anyway (installed over this state some other way) sees an incompatible schema and removes it without deleting those secrets, which are then left in the secret store untracked. They are never used, and purge does not find them either; remove them with the platform secret-store tool (entries named piship:<id>:inference#<n>, piship:<id>:sandbox#<n>, and piship:<id>:identity#<n>), or repair the secret store and sign out before rolling back.
  • A pending revocation without an expiry never resolves on its own. At most 20 entries are kept; older ones are dropped and only their number is kept and reported.

Headless and workload runs

A managed distribution with a workload identity runs with no person, no browser, and no stored login. The headless E2E (tests/e2e/headless.test.ts, local fixtures) runs such a distribution with no browser opener allowed, no authorization page and no identity provider request, personal credentials planted around it, key rotation, expiry, and a change of workload principal.

What a headless run proves:

  • No browser. The adapter's login receives an openUrl that fails the run; PiShip never prints an authorization URL or starts a browser opener for it, and no process approves an authorization page. The E2E's browser-opener tripwire runs on POSIX only.
  • No ambient personal credential. Provider keys and other credential-named variables are removed from the environment before any adapter loads (managed runtime isolation); Pi's auth.json under ~/.pi or another distribution's state is not used or sent (the headless E2E plants such values and asserts that none reaches a request or a state file); and a missing or failing workload identity fails the run closed, with no fallback to Pi-native or personal credentials.
  • A scoped credential. The gateway sees only the runtime credential the broker issued to the workload principal, bound to (iss, sub) like a person's (user switching); the broker sees only the workload token.
  • Expiry and rotation. A credential is renewed within refresh.beforeExpiry with the workload's current token, replaced generations are deleted, and a credential past its expiry that cannot be renewed fails the run (CREDENTIAL_EXPIRED, or IDENTITY_EXPIRED when the broker rejects the workload token and the adapter has no valid one).
  • Managed policy. The model allowlist, entitlement, network policy (including for the adapter's own requests through context.fetch), and every other managed control apply unchanged.
  • Principal changes. A run whose workload principal differs from the previous run's never uses the previous principal's credential, entitlement, or model selection, and leaves none of its secrets behind.

What it cannot prove:

  • Who the workload is. PiShip does not validate a workload token (it checks an ID token only in the built-in OIDC flow): the principal is what the adapter returns, and the adapter is distribution code running with the process's privileges. The broker is the authority that must validate the workload token and bind the credential it issues to that token's subject; a broker that trusts the principal without checking the token grants whatever the adapter claims.
  • That the token source is protected. A token file, platform endpoint, or metadata service is the workload platform's responsibility; anything that can read it can act as the workload.
  • That the agent cannot read the workload token. The agent's own tools run as the same user: a prompt-injected bash call can read a token file, or a well-known platform path, and use it outside PiShip's policy and audit, and a broad token (a default Kubernetes service account token) also works against other services. Use a token whose audience is the broker only, keep the file outside the workspace and the state directory, mode 0600, and run the agent under a required sandbox whose filesystem policy denies the token path and <state>/secrets/ (the built-in profiles are not verified to do so). PiShip removes credential-named variables such as AWS_WEB_IDENTITY_TOKEN_FILE from the environment before adapters load, so name the token path in a variable the adapter reads that is not one of them.
  • Isolation between workloads finer than the subject. Credentials, entitlement, and session history are keyed by (iss, sub); workloads with the same subject share them. Two workloads with different subjects must not share a state directory: each activation would revoke the other's credential.
  • That nothing is at rest. The workload session is held in memory only, but the runtime credential is stored in the configured secret store. A Linux runner without a Secret Service needs the plaintext file store, and there is no memory-only option yet (headless storage).
  • Revocation at the end of a job. A credential stays valid at the gateway until it expires unless the job runs logout (which revokes it where the broker supports it) or the broker issues short-lived credentials.
  • Confinement of the adapter. Like any adapter or extension, the workload adapter is in-process code; PiShip cannot stop it from starting a browser or using other credentials on its own. Loading it is a code-trust decision.

Subagent child launch

An extension that runs subagents as child processes of the branded command (pi-code's subagent tool runs <command> --mode json -p --no-session [options] <task>) needs that command to accept those arguments. It does so only when the environment variable PI_CODE_SUBAGENT=1, which the tool sets for its children, is present, the arguments start with --mode json -p, and the launch proves that a running session of this distribution started it (the first bullet below); otherwise the launch is refused as before ("Unknown branded command option"), except that for arguments of this shape the message shows only --mode json -p ..., because the last one is the task. The marker is not a security boundary, and neither is the proof: whoever can read the distribution's state directory or the session's environment, which is the user's own account, can start a child. Plainly: for anyone who holds a stored sign-in on the machine and runs as that user, a child adds a non-interactive way to send a prompt through that sign-in, which the plain command does not offer. Governance is unchanged: policy, tool exposure, project trust, the sandbox, model entitlement, and audit apply to that prompt exactly as to a session, there is no --yolo and no approval channel, and session.start in the audit log records subagent: true and the parentSession that started it. What it adds is the entry, not a permission; a person who can already run the command as that user could send the same requests through the signed-in session by other means.

The child is the same governed launch as a session: the distribution, the signed-in identity and its brokered credential, policy, tool exposure, project trust, the sandbox, and audit all apply, and it ends in Pi's print mode in JSON on the task. What is different:

  • Started by a session. Every session of the distribution writes a record of itself to <state>/subagent-owners/ (a directory and files private to the user, modes 0700 and 0600) holding a random 256-bit token, its process (with its start identity or start time), the host, its session id, and its workspace, and puts the token in its own environment as PISHIP_SUBAGENT_TOKEN, which pi-code's spawn passes to its children together with the marker. The name is one the credential filters know (TOKEN): the sandbox gives a contained command no such variable, and the managed launch removes it from a process, so the child reads and deletes it first, before that cleanup runs and so that nothing the child runs inherits it. A command the session runs without the OS sandbox containing it (see Tools, shell, and Plan mode) does not get it either: the uncontained shell tool drops it from the environment it passes, and passes an explicit environment even for a user's own ! command, which would otherwise inherit the whole process environment. A child refuses to start unless the token names a record whose process still runs on this host, is the one that wrote it (a reused process ID is told apart by its start identity on Linux and its start time elsewhere, as far as the system can say), and whose directory and file are private to the current user; the record is opened without following a link and checked and read through that descriptor (the link check does not exist on Windows). A launch with the marker alone, a made-up token, or a token whose session has ended is refused with CONFIG_INVALID before any state is created. The child's session.start records the parent's session id from that record. Limits: the token is in the environment of the session's own process and in the record, both the user's own; a model-run command cannot read either through the sandbox or the shell tool, but an extension or a tool that spawns a process with the session's full environment hands it on, and that process can start a child, which is still a governed session. A child's own children are refused, since the token is not passed on. The child's parent process is not compared with the record's process, so that a launcher between them does not break the tool. A crash leaves a record naming a dead process, which the next start removes; a record whose process ID was reused is told apart as described, and where the system cannot tell (no start identity, no ps) it is trusted as long as some process has that ID.
  • Workspace. The record holds the parent's working directory (real path). A child refuses to start when its own working directory is not that directory or one below it, and the refusal names both directories and says to omit cwd in the subagent call or start the session in that directory. The one other place accepted is the worktree pi-code creates for an isolation: worktree agent: a directory named pi-agent-worktree-<agent>-<8 hex digits> directly under the OS temp directory, owned by the user, whose .git file names a directory directly below the workspace repository's worktrees/ (one level, the repository the workspace is in, or the one a workspace that is itself a linked worktree belongs to) that names the worktree's .git file back, as git worktree add writes them (absolute paths, or relative ones where git is set to write those). The files git wrote are read bounded, as regular files of the current user, and anything unreadable is a refusal. A directory that only has the name and a .git file, a worktree of another repository, and any worktree when the workspace is not in a repository are refused, so an isolated agent whose cwd is in another repository fails. This follows pi-code 1.4.2's source (extensions/subagent/worktree.ts: git worktree add -b <branch> <tmpdir>/pi-agent-worktree-<agent>-<8 hex>) and was exercised against real git worktree add directories, not against a running pi-code isolated agent. A subagent call that passes a cwd outside the workspace now fails where it used to run.
  • Allowlist. Only --no-session, --no-context-files, --model, --thinking, --tools, --exclude-tools, --system-prompt, and a background run's --session-id with --session-dir are accepted; the task is the last argument. Any other option, such as --yolo, --api-key, --extension, or --session, is refused by name before any state exists, without repeating the task.
  • Options only narrow. --model goes through the same check as the launch option, so a model outside models.allowed or the credential's entitlement fails with MODEL_DENIED. --tools is an allowlist applied after exposure (plus the tools the distribution requires to stay active). A child given --tools does not get Codemode or tool search, which would reach the tools the list leaves out: they stay inactive and are not required there, and a --tools list that names codemode or tool_search is refused with CONFIG_INVALID (they are not made excluded instead, which would fail the launch's check for an excluded tool Pi still registers). --tools accepts explicit tool names only: an entry that is a * pattern, a +name/-name modifier, empty, or contains whitespace is refused with CONFIG_INVALID before anything reaches Pi, because Pi parses those forms as pattern matching or as default modifiers, either of which would drop the allowlist bound. --exclude-tools is the narrowing side and is not held to that rule: Pi feeds it only to createToolNameMatcher as a filter on the active set, never to the allowlist that decides registration, so an exclusion can only remove a tool, never add one. PiShip therefore parses it exactly as Pi's own CLI does (trim each entry, drop empties) and accepts the * patterns Pi documents there (--exclude-tools 'mcp__*'), since refusing them would break the documented way to drop MCP tools. --exclude-tools adds to the exclusions. A --exclude-tools entry naming a mandatory tool exactly is refused with CONFIG_INVALID; a * pattern that would also remove a mandatory tool cannot be caught at parse time (the mandatory set is computed later), but Pi's _isAllowedTool applies exclusions to registration as well as to the active set, so the tool is never registered, the integrity extension cannot re-activate it, and the launch is blocked fail-closed rather than run without a tool the distribution requires. Neither can enable a tool that exposure or policy hides. --system-prompt replaces Pi's base prompt only: the distribution's enforced instructions are still added and the integrity check still applies. --no-context-files changes nothing, because a governed session loads none of the project's context files.
  • Files. The system prompt is read only from a regular file named by pi-code under the OS temp directory (pi-subagent-*), and a background session only in its own pi-code-bg-session-* directory there. Both directories are made by pi-code with mkdtemp, which creates them with mode 0700 (read in pi-code 1.4.2's extensions/subagent/run.ts and background.ts, and observed with Node's mkdtempSync). The pi-subagent-* directory must be a real directory (not a link) that belongs to the current user and is closed to group and others, and the prompt file in it is opened without following a link and checked and read through that one descriptor (it must be a regular file of at most 1 MiB with one hard link, belonging to the current user), so a file swapped in after the path was checked is not read. On Windows O_NOFOLLOW does not exist: there the real-path and directory-link checks still apply, but a swap between the check and the open is not caught. The background session directory is checked where it is opened, on use: a real directory, owned by the current user, mode 0700 (the owner and mode checks are POSIX; on Windows only the link and junction check applies). The temp-directory comparison uses the native real path, and on Windows ignores case; none of the Windows behavior has been verified on Windows.
  • Excluded tools. --exclude-tools naming a tool the launch itself needs (such as ask_user where the distribution requires it) is refused with CONFIG_INVALID, since Pi's integrity check would re-activate it.
  • No terminal, no approvals. The child has no --yolo and no approval channel, so an ask is denied as in any headless run. It follows the user's stored auto mode like any session, and nothing else; auto-approval is not inherited from the parent's --yolo. A child that is not signed in fails with IDENTITY_REQUIRED and never starts a sign-in. A child does not take the permission provider's --yolo lease: it is an ordinary session to it, so ordinary sessions run beside it, and a live --yolo session on a distribution whose provider declares a session key refuses it, as it refuses any ordinary session.
  • Project trust. The child decides trust exactly as a session in that directory does, from the stored decision or policy and never by asking.
  • Output. Stdout carries only the JSON events; PiShip's own messages go to stderr. The task is not echoed to logs or audit beyond what a session already records.
  • Agent hooks from the environment. pi-code's hooks extension reads PI_CODE_AGENT_HOOKS (a JSON object of hook commands for the agent, passed by the parent) from the environment, but only when PI_CODE_SUBAGENT=1. In a personal distribution both variables reach the child unchanged, and the hook commands run as the user, outside governed bash and the sandbox, like any pi-code hook; a distribution that must not run hooks leaves that extension out with a package filter. In a managed distribution they do not reach it: the child's arguments are checked against the marker first, then the managed launch removes every PI_* variable from the process environment (above), PI_CODE_SUBAGENT and PI_CODE_AGENT_HOOKS included, unless the manifest declares them under variables. A managed child therefore runs no agent hooks, and pi-code inside it no longer sees the marker it uses to keep a child from starting subagents of its own (a grandchild is refused by the proof above instead). PiShip does not inspect or filter the variable. A managed session whose origin does not allow claudeHooks also excludes extensions/hooks/** (above).

Pasted redirect URL

When a machine has no browser (PISHIP_NO_BROWSER=1, or a remote shell, a display-less Linux session, or a container, as identity lists), login accepts the redirect address pasted into the terminal (identity). It carries a single-use authorization code that is bound to the sign-in by PKCE, and, for a pasted address, by state; a bare code has no state to check and relies on PKCE and the ID token's nonce alone. The code is useless without the verifier that stays in the login process, and a pasted address with another state or another redirect is refused and never exchanged. Do not share it or paste it anywhere else: until the exchange it is a credential in flight. PiShip never prints or logs what was pasted, and its messages never repeat the pasted query (the terminal itself shows what you type).

Security test map

Each of the thirteen security cases of the test plan has a test at the boundary it claims, not at a mock of the code under test. Tests under tests/security run in npm test; tests/e2e runs the real branded command against local fixtures (Portable E2E); tests/enterprise-reference and examples/enterprise-reference/tests run against a live Keycloak, credential broker, and LiteLLM (Reference E2E, needs Docker). A test is named by its file and its title.

Case Boundary Tests
1. Invalid OIDC issuer The OIDC client checking the ID token, the iss response parameter, and the discovery document packages/identity/src/oidc.test.ts "rejects a response with idTokenIssuer"; tests/security/oidc-invalid.test.ts "rejects a callback naming another issuer (mix-up)" and, for a rejected sign-in, "stores no identity, no credential and no secret when nobody was signed in" and "leaves the signed-in user, and their unrevoked credential, in place" under "an ID token from another issuer"; launcher: tests/e2e/security-controls.test.ts "refuses an ID token from another issuer, leaves nothing signed in, and asks the broker for nothing"; live Keycloak: tests/enterprise-reference/security-oidc.test.ts "refuses an issuer whose discovery document names another issuer"
2. Invalid audience The same, and the credential broker checking the access token oidc.test.ts "rejects a response with idTokenAudience" and the authorized-party tests; oidc-invalid.test.ts "an ID token for another audience" and "an ID token for a list of audiences that omits this client"; launcher: security-controls.test.ts "refuses an ID token for another audience, ..."; live: security-oidc.test.ts "refuses the ID token, which has the right issuer and signature and the wrong audience" (broker), and the broker's own contract tests in examples/enterprise-reference/broker/test/contract.test.mjs
3. Invalid state or nonce The OIDC client and the loopback listener oidc.test.ts "refuses a callback with another state and never exchanges its code", "keeps waiting through a stray callback and signs in with the genuine one", and "rejects a response with idTokenNonce"; loopback.test.ts "answers stray requests without ending the sign-in, then takes the matching callback"; oidc-invalid.test.ts "rejects a callback whose state is not the one PiShip sent", "... a callback with no state", "... an authorization request whose nonce is not the one PiShip sent", "... an authorization request with no nonce, so the ID token carries none", "does not take a second callback for the same sign-in (state replay)", "rejects an authorization code issued for another request's PKCE verifier"; launcher: security-controls.test.ts "refuses a callback with another state, keeps waiting, and stores nothing when cancelled" (POSIX; it ends the login with Ctrl-C) and "refuses an ID token with another nonce, ..."; live: the same attacks in security-oidc.test.ts
4. Cross-user credential reuse DistributionAccess and its stores against the fixture provider and broker; the launcher; a live Keycloak, broker, and gateway packages/core/src/user-switching.test.ts (the six no-residue cases below); tests/e2e/user-switching.ts "gives Bob none of Alice's credential, entitlement, or model selection, and leaves none of her secrets, across update and rollback"; live: examples/enterprise-reference/tests/user-switching.test.ts "signs Bob in over Alice, revokes Alice's key and tokens, and gives Bob none of hers"; sandbox credential: tests/e2e/security-sandbox.test.ts "never outlives its user, ..."
5. Cross-user entitlement reuse The same, and the three-way model intersection user-switching.test.ts "case 2: logging in as Bob over Alice reuses none of her credential, entitlement, metadata, cache, or model selection"; packages/core/src/entitlement.test.ts; live: user-switching.test.ts "refuses a model Bob is not entitled to, at PiShip and at the gateway"
6. Credential leakage Everything a real run writes or prints: piship.yaml, the lock, state, logs, audit events and sink requests, sessions, model requests and tool results, command output, update and rollback reports and snapshots, and the temp directory the run is given tests/e2e/security-sweep.test.ts "leaves no secret of the run in any file, output, audit event, sink request, model request or tool result", "does not repeat what a broker or a revoke endpoint echoes back of a token or a credential"; tests/e2e/security-lifecycle.test.ts "prints, snapshots and stores no secret of the run, across an update, a rollback, a user switch and a second update"; tests/e2e/managed.test.ts "initializes, locks, builds, logs in, enforces policy, resumes, and logs out"; the scan itself: tests/security/scan-helpers.test.ts. The sweep follows the runtime credential and the access, refresh, and ID tokens; the stored sandbox credential is followed in tests/e2e/security-sandbox.test.ts (case 7). A gateway that echoes its Authorization header leaves nothing in the Pi session file: security-sweep.test.ts "keeps what a gateway echoes of the credential out of the Pi session file"
7. Sandbox credential leakage The wire to the sandbox service, error text, state, snapshots, audit tests/e2e/sandbox-credential.test.ts "sandbox login over stdin, a launch whose bash runs remotely, and a fail-closed endpoint change"; tests/e2e/security-sandbox.test.ts "never outlives its user, never appears in what a rejecting service echoes, and never lets the command run on the host instead"; packages/core/src/sandbox-credential.test.ts
8. Policy widening The policy engine, and real tool calls at the launcher tests/security/policy-widening.test.ts (every combination of layers, rules that name everything, a directory, or a file in each layer, and path spellings; a team ask does not hide a project deny); tests/e2e/security-controls.test.ts "does not let a team, a project, or a user rule allow what the distribution denies or leaves to ask" and "keeps an enforced setting and an entitled model against a hand-edited preferences file"; packages/pi/src/user-auto.test.ts "never touches deny, an enforced rule, or an explicit ask" and "has no effect while the release does not allow it, and is not offered" (auto mode); tests/e2e/governance.test.ts "enforces Plan mode, MCP policy, project trust, and certified integrity" (a project rule that tries to allow ~/.ssh); packages/core/src/entitlement.test.ts (models)
9. Public fallback The managed fetch and the launcher tests/e2e/network.test.ts "publicFallback: deny refuses an undeclared public host before any proxy or connection"; tests/e2e/security-lifecycle.test.ts "contacts only the declared update host, over verified TLS, and follows no redirect"
10. TLS downgrade The managed fetch, its trust roots, and the launcher packages/contracts/src/network.test.ts "TLS is never downgraded or disabled"; tests/security/tls-policy.test.ts; tests/e2e/network.test.ts (custom CA, NODE_TLS_REJECT_UNAUTHORIZED=0); tests/e2e/managed.test.ts (launch and doctor refuse it)
11. Rollback credential resurrection The lifecycle commands, every credential class tests/e2e/lifecycle-credentials.test.ts "never restores a revoked or cleared secret of any credential class across update, rollback and reinstall"; tests/e2e/lifecycle-rollback.test.ts; user-switching.test.ts "rollback cannot resurrect Alice's credential, and Alice signing in again gets a new one"
12. Audit secret leakage The audit file, the HTTP sink's requests, the events of every flow packages/core/src/branded-audit.test.ts "records every identity, credential, and runtime event through real sinks without secrets"; packages/pi/src/audit-events.test.ts "emits every audit event name, metadata only"; tests/e2e/security-sweep.test.ts (the sink's requests, byte for byte, and the audit log of a run that hits every flow)
13. Mandatory-control downgrade One row per control below see below

The six no-residue cases of a change of user (each run in packages/core/src/user-switching.test.ts against an in-memory secret store and against the restricted file fallback; the platform stores have their own tests):

No-residue case Test
A to B through logout "case 1: Alice logs out, then Bob logs in; case 6: nothing of Alice remains"
A to B by signing in as B "case 2: logging in as Bob over Alice reuses none of her credential, entitlement, metadata, cache, or model selection"
A crash between storing B's identity and clearing A's credential "case 3: a crash after Bob's identity is stored leaves no Alice credential for Bob's launch", "case 3: a crash before Bob's identity is stored leaves Alice's session without her old credential", "case 3 and 5: Bob's identity stored beside Alice's credential (an interrupted older release) discards hers at Bob's launch", "stops a login at every crash point without leaving Alice's credential usable by Bob"
The secret store cannot delete during the switch "case 4: a failed deletion blocks Bob's login before his identity is stored, and a recovered store completes it", "case 4: Alice's identity tokens that cannot be deleted also block Bob's login"
A's credential metadata under B's identity "case 5: Alice's credential metadata restored under Bob's identity is discarded, not used"
A scan of the store, the state directory, and the rollback snapshots the residue scan at the end of cases 1 and 2, and the decoded scan of the file fallback in tests/e2e/user-switching.ts

Mandatory controls (case 13): each stops the launch or the action when its configuration or implementation is missing or broken.

Control Fails closed when Tests
Identity the ID token, state, or nonce is invalid; the issuer variable is unset; a workload identity is missing security-controls.test.ts "identity: an invalid sign-in is refused and stores nothing (cases 1-3)"; tests/e2e/headless.test.ts "runs with no browser and no stored login, ..."
Credential the broker is down or names an undeclared gateway; the secret store cannot delete or read; never a personal key security-controls.test.ts "credential: no credential means no launch, and never a personal one (case 13)"; tests/e2e/lifecycle-credentials.test.ts; tests/e2e/managed.test.ts (ambient keys never reach the gateway)
Sandbox no adapter can be enforced; a remote backend is down or rejects its credential (the command never runs on the host) tests/e2e/governance.test.ts (unsupported adapter); tests/e2e/security-sandbox.test.ts
Policy a user, project, or team policy is malformed or fails to load security-controls.test.ts "policy: a broken policy stops the launch; a widening one is ignored (cases 8, 13)"
Artifact integrity doctor, verify-release, or a launch with runtime.verifyAtLaunch: true finds a file that no longer matches the inventory or a missing inventory. Every launch refuses a piship.lock or enforced policy file that no longer matches the digest the receipt recorded; other local payload edits are not detected by a routine launch, and certified resource admission still checks its reviewed digest. Signed updates continue to reject altered archives and channel metadata. security-controls.test.ts "integrity: explicit diagnostics detect payload changes"; tests/e2e/cli.test.ts; tests/e2e/lifecycle-integrity.test.ts
Audit a required sink is down at the readiness probe: nothing runs and no model request is sent; a sink that stops taking events has a full buffer: tool calls and model requests stop with AUDIT_UNAVAILABLE; the events it did not take are reported when the session ends security-controls.test.ts "audit: a required sink that stops taking events (case 13)" (three tests: the readiness probe, the full buffer, and the report at the end); tests/e2e/governance.test.ts

Guarantees and their limits

Within the enforcement plane each control names, PiShip provides: deterministic loading of only declared and admitted resources; resource, provider, and project trust decisions; model allowlist enforcement in managed mode; MCP governance for declared servers; static version and integrity verification of the payload and releases; storage and process-scoped injection of the credentials PiShip manages; inspectable configuration and explainable policy decisions; metadata-first audit where enabled; an enforcement status for every action that never reports an action without a runtime seam as enforced; activation checks for a required sandbox; and reproducible static builds.

PiShip does not and cannot guarantee:

  • protection from a machine administrator or anyone who can modify the installed payload, state, or inventory;
  • isolation that the configured sandbox backend does not provide, or native OS isolation on Windows; for a remote or custom backend, the isolation its operator attests;
  • that approved third-party code (extensions, providers, MCP servers) is trustworthy; loading it is a code-trust decision;
  • the correctness or security of the organization's identity provider, credential broker, inference gateway, upstream model providers, or external MCP servers;
  • prevention of every prompt-injection attack; project instructions are model input, not an enforcement mechanism;
  • endpoint controls that MDM, EDR, or network infrastructure normally provide.

The two deployment modes have different credential trust models. In a managed gateway profile the organization keeps upstream provider and gateway administration credentials server-side, and the developer machine holds only the scoped runtime credential the broker issues. In personal mode the owner may intentionally keep personal provider credentials on the machine, either delegated to Pi-native auth in the distribution's isolated agent/ directory or stored through a local-secret secret store; PiShip then protects the machine-local copy only as described under Secrets.

Not covered

PiShip does not contain the Pi process or in-process extensions, enforce hostname-level egress for child processes, filter system calls, or code-sign release artifacts for macOS or Windows. v1alpha1 and v1alpha2 distributions have no tool policy, OS sandbox, MCP governance, or audit log; v1alpha1 to v1alpha3 distributions have no signed update channel. Report vulnerabilities through SECURITY.md.