Skip to content

Latest commit

 

History

History
790 lines (609 loc) · 304 KB

CHANGELOG.md

File metadata and controls

790 lines (609 loc) · 304 KB

Changelog

All notable changes to this project are documented in this file. Each section is a project milestone; the manifest and lock schema each milestone uses is listed in the version map. No milestone has been published to npm. v0.7.0, v0.7.1, v0.8.0, v0.8.1, v0.9.0, v0.9.1, v0.11.0, v0.12.0, and v0.13.0 are published as GitHub pre-releases (v0.7.0, v0.7.1, v0.8.0, v0.8.1, v0.9.0, v0.9.1); v0.11.0, v0.12.0, and v0.13.0 are published the same way (v0.11.0, v0.12.0, v0.13.0); v0.10.0 was never published and its changes ship in v0.11.0. Every package is versioned 0.13.0.

Unreleased

Fixed

  • A subagent child's --tools is now a strict allowlist of explicit tool names: an entry that is a * pattern or a +name/-name modifier is refused with CONFIG_INVALID before it reaches Pi, because Pi reads those forms as pattern matching or as default modifiers and either drops the allowlist bound, activating Codemode, tool search, and every extension tool activated-on-registration. v0.13.0 refused only a --tools list naming codemode or tool_search and let a * or modifier through. The narrowing side is unchanged: --exclude-tools still accepts the * patterns Pi documents there (--exclude-tools 'mcp__*'), since an exclusion can only remove a tool, never widen the bound (security).
  • A tool result's per-tool details JSON is now redacted for every tool, not only Codemode: details is arbitrary tool-supplied data, so any tool that echoes a credential or argument material into it had that text persisted to the session file and shown in the HTML export. The nested calls a tool made to other tools are redacted whole, and a provider's deferred handle (deferred.data) is scrubbed as the arbitrary-JSON carrier it is. The path that runs when redaction itself fails no longer spreads the original message either: it keeps only the fields the classification tables know, so a field a future Pi adds is dropped rather than carried unredacted (security).
  • On an agent directory shared across hosts, one host no longer takes down another host's live package-configuration lock, override, or provider lease. An owner record now carries the writer's host, start identity, and start time; a record from another host, or one that cannot be read for a reason other than absence, is kept rather than judged dead by a local process lookup. A legacy pid-only record whose ID no process holds is still reclaimable, so an upgrade does not wedge a directory (security).
  • A reused process ID is told from the process that wrote a record directionally: a process the system dates clearly after the record can only be a different one holding the ID, and a creation stamp in the future (a wall clock stepped backwards) is left unjudged instead of taking a live holder's lock down mid-transaction.

v0.13.0

Released 2026-10-09. Minor milestone on Pi 1.1.0 and the stable piship/v1 contracts (piship/v1alpha6 stays accepted for the migration window). It makes pi-code's subagent tool work in a distribution through the governed launcher, adds build-time transpilation of TypeScript Pi packages, and moves the pinned Pi from 1.0.3 to 1.1.0. Every credential, integrity, sandbox and deny control is as in v0.12.0. Tag v0.13.0 is commit 7f9e167d7e78d54783f47db5455f8196e98dc79c (the squash of #255, which is also the version change), published as a GitHub pre-release with nine attested archives (acmecode, mypi, and devcode for each target). Release qualification run 37887824632 passed on that exact commit on its first attempt. v0.13.0 is the production-validation baseline and replaces v0.12.0 (status). Changes since v0.12.0:

Added

  • resources.packages[].pretranspile: true writes the TypeScript modules a package's extensions import as JavaScript beside them at build time, so an import written ./x.js finds a file at once instead of Pi's loader trying about eleven names for it in every extension. Off by default; a manifest without it builds as before. The extension files keep their locked bytes, the written files are in the payload inventory, the lock records the switch, and piship diff reports it. For the pi-code package it took resources_loaded from about 800 ms to 240 ms warm and 2.5 s to 1.4 s cold on Linux, with 40,000 fewer failed file lookups per start. The written files work under Pi's loader only: a plain native import still fails on a type re-exported as a value, a left-over __dirname or a require used in any way but a plain call (a plain require(...) call fails the build), or a package.json without "type": "module", and a module imported as both ./x and ./x.js can load twice; the build prints a warning for each of those it can see. The written files are in the payload inventory (so doctor and the install check them) but not in the lock's tree digest, which pins the vendored package as npm installed it. A runtime built before the field cannot take such a release: v0.12.0's update from a signed channel offering it fails with exit status 1 and INTEGRITY_FAILED: Release verification: invalid field at resources.packages[0].pretranspile ...: Unknown field pretranspile (its action text, "obtain it again from the trusted source", does not name the cause), and validate, lock, and verify-release fail the same way; the installed release is left untouched and healthy, so an installation on an older runtime needs a fresh install, as support-policy describes for other additions. (manifest, performance).

Changed

  • Pi is pinned to 1.1.0 (@earendil-works/pi-coding-agent, exact), up from 1.0.3, with its seven sibling packages; compatibility/pi.json and the copy in @piship/core list it with the statuses of 1.0.3 and keep the earlier versions, and Release qualification run 37887824632, recorded above, is the first on 1.1.0 (upgrade notes). Pi 1.1.0's interactive mode sends status reports to the terminal (the session name and the first line of an error message) and a support query at startup; PiShip leaves Pi's default and does not set PI_PROGRAM_STATUS, which a person can set to 0 in the shell for a personal distribution.

Fixed

  • pi-code's subagent tool works in a distribution: its child processes run <command> --mode json -p --no-session [options] <task> under PI_CODE_SUBAGENT=1, which the branded launcher refused ("Unknown branded command option"), so every subagent ended "(no output)". The child now takes the same governed launch (identity, credential, policy, exposure, project trust, sandbox, audit) and runs Pi's print mode in JSON. Only the options pi-code passes are accepted, by allowlist, and only under the marker; anything else, such as --yolo, is refused by name, a model outside models.allowed fails with MODEL_DENIED, tools and prompts can only be narrowed (a child given --tools no longer gets Codemode or tool search, which reached tools the list left out, and a --tools list that names either is refused), and a launch without the marker is refused as before, except that the refusal no longer repeats the task. The marker alone is not enough: a session publishes a record with a random token in the state directory and hands the token to its children in a variable the sandbox and the shell tool keep from commands, a child that cannot name a live session's record is refused before any state is created, a child works only inside its parent's workspace (or the git worktree of an isolated pi-code agent, checked against the workspace's repository), and its session.start audit event records the parent's session id. In a managed distribution the launch removes PI_CODE_SUBAGENT and PI_CODE_AGENT_HOOKS from the child's environment like every PI_ variable, so pi-code's agent hooks do not run in a managed child (security).
  • Launches that start a session at the same time (a subagent's parallel children) wait up to about three seconds for the package-configuration lock instead of failing at once with "Package configuration is being changed by another launch"; after that the same error is reported. The retries back off with jitter and the pauses never run past that budget (one attempt can still take longer than the budget by the time its check of the holder's process takes, which is a process start on macOS and Windows; a launch that is not a session still does not wait), and a lock directory a launch left without an owner record, because it died between creating it and recording itself, is taken over once it is ten seconds old instead of blocking every launch for good. The lock is now claimed with its owner record already inside (one rename) and released by renaming it away, so no launch ever sees or takes a lock its holder is still filling; an owner record that cannot be read for a reason other than absence means the owner is unknown and the lock is left alone; and a recovery directory a dead launch left is cleared after ten seconds as well. Each lock carries a random token, so a recovery that stalled cannot delete a lock another launch took meanwhile and a holder never removes a lock that is no longer its own; a lock whose holder died is told from one whose process ID another process now has by the holder's start identity or time; a lock is only moved after its owner record was read in place and found to be the expected one (a recovery that stalls past its time limit can no longer move a lock another launch took), and a recovery marks its directory in use again before it takes a lock down; a release that fails, or that cannot read the owner record for a moment (a scanner holding the directory on Windows), is retried and then done in place, and reported only if both fail; and ps is run as /bin/ps where it exists.
  • On Windows, looking up a lock owner that had just exited printed PowerShell's error text on the person's terminal; the lookup now discards it and reads as no identity.

v0.12.0

Released 2026-10-07. Minor milestone on Pi 1.0.3 and the stable piship/v1 contracts (piship/v1alpha6 stays accepted for the migration window). It removes the crashes, dead ends and repeated prompts a user meets from install to daily use, makes plain HTTP to private hosts work by default, and keeps every credential, integrity, sandbox and deny control as it was. A manifest that leaves out runtime.pi is accepted from this version, so a distribution that omits it needs one bridge release that still states it. Tag v0.12.0 is commit 8d40a68f825b9f527fcf8c52a7869f571f1ae50b (the squash of #246; the version change is #245, commit 0404e4842eff2c4477670ac0d8db9212850a6db4), published as a GitHub pre-release with nine attested archives (acmecode, mypi, and devcode for each target). Release qualification run 37678031776 passed on that exact commit on its first attempt. v0.12.0 was the production-validation baseline until v0.13.0 replaced it, and replaced v0.11.0 (status). Changes since v0.11.0:

Added

  • login accepts the OAuth redirect URL (or the bare code) pasted into the terminal, so a machine with no browser, such as a remote shell, signs in without an SSH port forward (identity, security). It is offered with PISHIP_NO_BROWSER=1, in a remote shell, on a display-less Linux session, or in a container, and races the loopback listener there; the pasted address must be this sign-in's redirect with its state; the code exchange and the error mapping are the same. LoginContext gains the optional readRedirectUrl. No manifest, lock, or schema change. In those cases the waiting hint says to paste the address and no longer suggests an SSH port forward; the hint is now printed in English and Traditional Chinese, and with a local browser it adds nothing about pasting.
  • <command> uninstall [--purge --yes] removes the installation from the branded command, says what stays (sign-in, sessions, their sizes) and prints the exact purge command. install --replace reinstalls over an existing installation, and a reinstall adopts state that names the same distribution without --use-existing-state.
  • A managed launch with no sign-in signs in on the spot at a terminal and goes on; a plain <command> login at a terminal goes on into the session after it succeeds. Without a terminal, in CI, and for personal distributions nothing changes. A sign-in that ends during a session says to exit, run login, and start again.
  • A person's answer about a project's configuration is kept (personal distributions): the prompt asks once per project, lists every item, and offers y, a (always until those files change) and never; <command> config trust list and config trust forget show and revoke it. What a project's policy leaves out is now one line per project, shown once, with the way to trust it. Managed distributions always ask and keep nothing.
  • The approval prompt offers "Allow for this session", for the same action and target only, never over a deny, and audited as an approval.
  • piship init --id and a suggested id when the directory name is not a valid one; docs/first-distribution.md, the first row of the README docs table.
  • <command> models lists the models of a Pi-native distribution.

Changed

  • Plain HTTP to a private or internal host works by default for the update channel, MCP servers, inference, credential.broker, identity.oidc, audit sinks and remote sandboxes, with no httpTransport: http-allowed per endpoint. A public host still needs HTTPS, httpTransport: https (or updates.transport: https) forces an endpoint to HTTPS only, credential: runtime stays HTTPS only, and validate, config explain and doctor list what travels unencrypted. Schemas before piship/v1alpha6 have no field to write https.
  • piship init --personal loads a person's own project files by default (instructions and skills allow or ask for external and unknown projects, extensions and MCP ask, hooks deny) and bundles fd and rg when GitHub is reachable from where init runs, so piship lock needs network access for it; when GitHub does not answer within 3 seconds, init leaves out runtime.searchTools and https://github.com in release.sources and says so.
  • A project resource a policy answers ask for is now asked about; before, it was dropped without a prompt.
  • piship dev and piship test create a missing lock and relock a stale one when the manifest declares no Pi packages. runtime.pi may be left out of a manifest (it then means the build's pinned Pi); a release that leaves it out cannot be updated to from a runtime built before this change, so an installed distribution needs one bridge release first. An unused variables entry is a warning. Validation errors carry the line and column, collect the common structural mistakes in one pass, and suggest the nearest field. build and install print commands that can be typed.
  • update uses a retained release or a kept download whose digest equals the signed entry instead of downloading it again, update --check over a URL no longer downloads the archive, and update and rollback remove obsolete releases when they commit. doctor shows free space and the releases it keeps; a payload that fails verification still ends it before anything of the payload runs.
  • A bundled fd or rg problem is a warning instead of a failure, and --version is no longer blocked by it; a payload copy that does not match the lock is still never run. A damaged or oversized most recent session starts a new one, and a damaged preferences.json is moved aside as .damaged-<time> and defaults are used.
  • Two --yolo sessions share the permission provider. A resumed session whose model is no longer allowed switches to the first allowed one. A failed optional MCP server, a refused file-tool path, and a model refused with 403 each say what happened and what to do. Shell commands in the sandbox get their package caches in the session's temporary directory.
  • A personal launch that has neither fd nor rg on PATH or bundled still runs Pi with PI_OFFLINE=1 rather than wait for their download, and now says so once at launch: it names the tools, notes that Pi's model catalog refresh and /bug are off too, and gives the three ways out (install them, PISHIP_ALLOW_TOOL_DOWNLOAD=1, or runtime.searchTools).

Fixed

  • A proxy variable without a scheme (HTTPS_PROXY=proxy.corp:8080) crashed every launch with a raw URL error; it now gets http:// and a notice.
  • Every unset runtime variable is reported at once. A personal launch with NODE_TLS_REJECT_UNAUTHORIZED=0 goes on with verification on instead of stopping. A full, read-only or busy disk, and a download cut short, are explained and retryable.
  • On Windows the .cmd shim works for a non-ASCII user name, and a constrained PowerShell fails at once and names the file store. A build for a target the lock does not pin builds without fd and rg.
  • The Windows credential helper no longer echoes a stored value back through stderr when the text after chunks: is not a number: the count is parsed as digits only and bounded, a bad one gets a fixed message, and a target with a space, line break, or other control character is refused before it is written.

v0.11.0

Released 2026-10-07. Minor milestone on Pi 1.0.3 with the stable piship/v1 and piship-lock/v1 contracts (piship/v1alpha6 and piship-lock/v1alpha6 stay accepted for the migration window). Tag v0.11.0 is commit d4f740f0ce7c1b7bebc244e0d4cfa471ede81813, published as a GitHub pre-release with nine attested archives (acmecode, mypi, and devcode for each target). Release qualification run 37538011406 passed on that exact commit, every job on its first attempt. v0.11.0 was the production-validation baseline until v0.12.0 replaced it, and replaced v0.9.1 (status).

v0.10.0 was never published. It was a candidate only, with no tag, release, or archives. Its changes ship in v0.11.0, as one GitHub pre-release, and are listed here with the rest (the developer distribution, --yolo, package environment and agent files, package capability providers, managed Claude project trust controls, and Pi 1.0.3).

Migration bridge. A runtime built before piship/v1 rejects a piship/v1 release with INTEGRITY_FAILED when it updates. An installed distribution therefore needs one bridge release built with PiShip 0.11.0 and still on piship/v1alpha6 before it moves to piship/v1 (support policy).

Windows. The shared file store defaults to hardlink with full content verification on Windows (macOS and Linux stay off; PISHIP_STORE=off restores the previous behavior). The .cmd shim measured +6 to +24 ms over a direct start, so there is no native launcher.

Changes since v0.9.1:

Added

  • --yolo launch option (manifest): <command> --yolo approves the ask decisions of that one session without a prompt, so a developer is not interrupted for routine work, and keeps the administrator's control. It is a leading option, accepted in any order with --model and --new-session and also with --smoke and --smoke-model (headless, where an ask is approved instead of resolving to deny); with --help, --version, or a subcommand it fails with CONFIG_INVALID before anything starts, and a distribution without a policy refuses it too. deny is never changed and nothing is stored: no auto.json, no policy file. In a personal distribution every ask is approved, including one from an enforced rule or the user's own rules. In a managed distribution it works only where the manifest declares policy.userAuto: allowed; otherwise it fails at startup with POLICY_DENIED before sign-in, the sandbox, or Pi starts, and where it is allowed it approves what auto mode approves (an ask from policy.defaults or policy.default) while an ask from policy.enforced, a team or project rule, or the user's own rules keeps its prompt. Project trust prompts are not answered. It is shown by a startup notice, a YOLO status line, and /auto status, and /auto off ends it for the session. It is audited with the existing events: policy.auto_enabled with detail.source: yolo when the session starts, and each approval as policy.auto_approved with detail.approval: auto and a new detail.autoSource: yolo; the schema ids are unchanged. --help lists it where it can work.
  • Project trust for Claude Code configuration (manifest, security). A repository that works with Claude Code can be used in a distribution that includes an extension for it, such as the pi-code package, and PiShip policy, not the project and not a click, decides whether that configuration loads. .claude/rules, .claude/commands, .claude/skills, .claude/agents, and .claude/hooks, with .claude/settings.json, .claude/settings.local.json, .claude/CLAUDE.md, CLAUDE.local.md, and .claude/output-styles, are discovered in every directory from the working directory up to the project root, together with Pi's .pi/mcp.json and the .mcp.json and .pi/agents files below the root, which the extension also reads. They are decided by five new optional policy.projectTrust dimensions (claudeRules, claudeCommands, claudeSkills, claudeAgents, claudeHooks), then as resource.load with the resource project:<path> so policy rules apply per item. Personal mode loads a company or external project without a prompt and asks once per launch for an unknown one; managed mode loads nothing until the company declares allow (company-approved admits no project content here, and claudeHooks defaults to deny). The dimensions are absent from the parsed policy and the lock unless declared, so no existing lock changes and no schema id does.
  • The decision is handed to Pi's public project-trust seam. In a governed session the in-memory settings are created with projectTrusted set from policy, so ctx.isProjectTrusted() reports PiShip's answer, and PiShip writes the decision for the project directory into Pi's trust store under the distribution's agent directory (and withdraws a stale approval), which is where pi-code looks before it asks the person. A person's click, /trust, or a file the project ships cannot open what policy closes. The project's configuration is admitted as a unit, since Pi gives an extension one answer per session: one denied item leaves all of it unloaded, and the item is named in the audit, --smoke, doctor, and a notice. The compatibility suite runs a real Pi session with an extension that follows pi-code's two steps.
  • Audit: each item is a resource.load or resource.denied event (detail.kind claude or extension-config), and the decision is one more for project:.claude with detail.seam: project-trust. doctor reports project trust for extensions in the Project group. piship diff reads an undeclared Claude dimension as its mode's default, so declaring the default is no change and admitting hooks is reported as high risk.
  • The developer example (examples/developer, app id devcode): a personal distribution of six exact-pinned Pi packages (pi-code, pi-lens, pi-background-tasks, pi-review, pi-browser-use, and @gotgenes/pi-permission-system) with a profile that runs normal development without asking, asks before sudo, recursive deletes, destructive git, writes outside the workspace, unknown programs, and browser scripts, and denies clear secrets (~/.ssh, private keys, cloud and registry credentials, keychains). It ships a code-review-targets skill, a committed lock, a hardened managed variant (managed.piship.yaml, validated and never launched), a release candidate archive next to acmecode and mypi, and its own E2E and unit checks. See its README for requirements and known limits.
  • piship/v1alpha6 package fields environment and agentFiles (manifest). environment sets non-secret variables for a package before Pi loads it, with { statePath } for a path in the distribution's state; a declared value replaces the shell's, in managed mode too. agentFiles writes JSON configuration under the agent directory's extensions/, as a seed that keeps a file the user edited or as an enforce file rewritten at every launch. Both are optional and additive: no schema version changes, and an existing lock keeps its digests. The lock records them (the files by the digest of the exact bytes), piship diff reports them (a new or changed file is high), and doctor lists them.
  • A capability provider can be a declared Pi package (provider.package), taking the package's class and review evidence; its extension files load through the provider after provider.load and each file's extension.load decision. The permissions capability can name autoApproveFile and autoApproveKey, the key of its provider's own file that --yolo sets for one launch and takes back when the process ends.
  • The --smoke summary lists the registered tools (with exposure and source), the activeTools, the commands, and any duplicateTools, so a name two extensions both register, which Pi resolves silently, is visible.
  • A shared, immutable, content-addressed file store for install and update, off by default except on Windows (see the entry above). PISHIP_STORE=copy|clone|hardlink makes an install or update place the runtime, Pi package, and dependency files of a release (node_modules/ and pi-packages/, files up to 1 MiB) from a store of file objects named by their SHA-256 in PiShip's user cache directory (<cache>/piship/store, %LOCALAPPDATA%\piship\store on Windows; PISHIP_STORE_HOME moves it) instead of writing them again. An installation stays self-contained: every installed file is its own file (a second name for the object under hardlink), the receipt names no store object, a launch never opens the store, a release archive carries no store reference, and an offline machine with an empty store installs and launches exactly as before. Objects are written once, read-only, and hashed against their digest before every placement, so a damaged object is replaced from the archive's own bytes and never installed; the release inventory is checked afterwards as without a store. A release's objects are pinned in refs/<id>/<version>.json while the receipt names it as the active or the rollback release. Only doctor collects (objects no retained release pins and that are older than 24 hours, leftovers, dead references) and verifies the store, within a time budget; install commit, update activation, rollback, and launch never do, and a collection sweeps nothing while an install or update is filling the store. The default follows the measurement, per platform: off on macOS, where every primitive costs more than writing the files (a fresh install 4x to 6x, a repeat install 2.3x to 4.6x), and on Linux, which has no measurement. scripts/benchmark-store.mjs compares the primitives (fresh, warm, update, remove; store on another volume) and the manual Windows benchmark workflow runs it. scripts/store-budgets.json and scripts/store-budgets.mjs hold the regression rules (explain above 5%, block above 10%, on the median) for the store and for the install and startup metrics of the personal, managed, and developer distributions. The Windows launcher decision is a benchmark-driven template in performance, pending a Windows measurement. See architecture, security, and performance.
  • scripts/benchmark-footprint.mjs measures the same workload (payload and installed file counts, payload and release archive bytes, cold and warm build, install, first and warm start) over the personal example, the managed reference, and the developer example, with repeated runs, a reversed order, and a --windows mode for the manual Windows workflow. scripts/performance-budgets.json and npm run check:budgets fail a pull request when the deterministic figures (payload files, installed files, archive bytes) of the managed example are more than 10% over their recorded budget (it builds offline in seconds); the personal example (it downloads fd and rg) and the developer example (it needs the registry) are checked nightly and in Release qualification. Timings are not gated by a pull request (performance).
  • Stable contracts: piship/v1 and piship-lock/v1 are frozen. They are piship/v1alpha6 and piship-lock/v1alpha6 with the same fields, defaults (release.bundle and release.strip stay on), validation, and lock content; only the schema ids differ, so every command accepts both, the digest of a v1alpha6 manifest and the lock of a v1alpha6 release are unchanged, and a PiShip with this change installs, verifies, updates, and rolls back a release built before it. piship init and every example use piship/v1; the example locks were regenerated. An additive change keeps piship/v1, and a breaking change needs piship/v2 (support policy). Behavior change: a runtime built before this change does not know piship/v1 and refuses such a release when it updates, so publish one release on piship/v1alpha6 with this PiShip first (updating across a schema).
  • piship migrate to piship/v1: a v1alpha6 manifest migrates by changing the schema line only. migrate --check stays read-only and now reports one of migratable (exit 0), requires review (exit 1), or cannot migrate (exit 3, which was exit 1 for a manifest that did not parse). It lists as review items what only the owner can decide: a Pi pin this PiShip does not carry, the fixed bootstrap expiry an earlier migration wrote, and a managed bootstrap whose roles share a key. Nothing is rewritten for these. The v1alpha5 message about bundling no longer says a bundled payload cannot build or release another distribution, which stopped being true with the authoring snapshot.
  • Documentation: the support policy (contract versions, migration window, lock reader and relock rules, state compatibility, platforms and end of life), the Pi compatibility contract (exact pin, supported matrix, upgrade procedure, seams, unsupported semantics), and the UX contract (the four answers for every restriction that exists today, and what fails closed or degrades in a managed distribution). "Best effort" is no longer used for the sandbox setting in the agent setup guide.
  • Tests: every example parses to the same manifest under v1alpha6 and v1; the v1alpha6 manifest digest is frozen; a v1 lock equals its v1alpha6 lock except for the schema ids; a v1alpha6 release installs, and an installation on it updates to a v1 release and rolls back; the migration fixtures cover migratable, requires review, and cannot migrate; migrate --check leaves files byte-identical.
  • The personal and developer examples bundle fd and rg (runtime.searchTools, pinned for linux-x64, darwin-arm64, and win32-x64; https://github.com added to their release.sources; both relocked). Pi downloads the tools on the first prompt when they are missing and waits for it (24 to 30 s measured), so the personal example's archive grows by 3.1 MB (124 files, from 117). Behavior change: a personal launch of a distribution that bundles neither tool, with neither on PATH, no longer lets Pi download them: it runs Pi offline for that start (no file-name autocomplete, and rg is missing from the shell until installed or bundled), notes tool_downloads_deferred in the PISHIP_DEBUG_TIMING startup report, and doctor warns. PISHIP_ALLOW_TOOL_DOWNLOAD=1 leaves the download to Pi.
  • PISHIP_DEBUG_TIMING=1 reports where time goes on stderr: each stage of release and each phase of a launch as piship-timing/v1 JSON (the launch phases include launcher_start, node_entry, pi_initialized, and ui_ready, with counters such as identity_spawns and the launcher builds), and the steps of build, install, and update as label: N ms lines. scripts/benchmark-install.mjs and scripts/benchmark-build.mjs record them.
  • A shared file store end-to-end test (tests/e2e/shared-store.test.ts) installs, updates, rolls back, and diagnoses a real release with a store and installs with an unusable one, and the developer example's payload is installed through an empty store and compared with an install without one; packages/core/src/store/security-cases.test.ts states the store's attack cases.
  • Docs: the v0.11 security review with its responsibility boundaries, the qualification matrix and follow-ups, and a roadmap with v0.10, v0.11, and a 1.0.0 GA decision, and no separate v1.0 engineering milestone.

Changed

  • Managed project hook configuration stays read-only when hooks are not admitted, and managed permission-provider project configuration cannot override enforced defaults. Provider --yolo uses live-session ownership to prevent shared configuration from changing another terminal's approvals.
  • Every @piship/* package and PISHIP_VERSION are 0.11.0; the shipped examples are relocked with that version.
  • Pi is pinned to 1.0.3 (@earendil-works/pi-coding-agent, exact), up from 1.0.2. compatibility/pi.json and the copy in @piship/core list 1.0.3 with the statuses of 1.0.2, and keep 1.0.2, 1.0.0, and 0.87.1 so that releases built on them are still recognized; the launcher runs only 1.0.3, so a manifest that pins 1.0.2 fails validate until its runtime.pi is updated. The root package.json overrides and PI_SIBLING_PINS in @piship/pi pin the seven sibling packages to exactly 1.0.3. Regenerating the npm lock moved only the eight @earendil-works entries, since every third-party dependency they declare is unchanged, and the reviewed install scripts are the same. Every example is relocked with runtime.pi: "1.0.3". The public API PiShip uses is unchanged, the session format stays version 3, and no builtin:* extension loads; Release qualification run 37538011406, recorded above, is the first on 1.0.3 (upgrade notes).
  • Pi 1.0.3 renames its Azure provider from azure-openai-responses to azure, in auth.json, models.json, and settings.json as well. PiShip references neither id and a managed launch is unaffected, but a personal distribution's user who signed in to Azure renames the key under <state>/agent or signs in again, and a personal manifest whose models.allowed names azure-openai-responses/<model> must name azure/<model>. PiShip auto-resumes the latest session; an old provider id falls back to another model and loses its prompt cache. Set models.default to a renamed model also listed in models.allowed.
  • Other Pi 1.0.3 changes that reach PiShip distributions: Home and End always move the editor cursor, with the fullscreen transcript's top and bottom on Ctrl+Home and Ctrl+End; Pi creates its output files (a ! command's pi-bash-*.log included) readable by the user only; Codemode's image() also saves each image to a temp file in the OS temp directory and names the path in the result; and an error from a dead terminal exits without a crash record. The compatibility suite passes unchanged on 1.0.3.
  • Windows measurements for v0.11.0 (GitHub-hosted windows-latest, Defender on, exclusions removed; two runs on different CPUs, so compare ratios, not runs): the bundled personal example is 125 files and 10.6 MB (including the 2.3 MB authoring snapshot and the bundled fd and rg), installs in 1.0 to 1.2 s, upgrades in 0.7 to 1.0 s, starts in 2.5 to 3.6 s the first time and 1.1 to 1.5 s warm, and builds a portable distribution in 9.3 to 13.4 s the first time (authoring cache cold) and 0.6 to 0.8 s warm. The .cmd shim added a median of 6.1 and 23.5 ms to a start, so no native launcher is built. Writer concurrency 8 and the 1 MiB buffer stay (no tested setting beat them by more than the run's noise). Table and limits: performance.
  • Earlier measurement of the file-operation reduction (before the authoring snapshot, shared store, and dedupe), on a GitHub-hosted windows-latest runner (Defender real-time protection on, exclusions removed for the run; runner noise is large; an earlier commit, 600dea7, against 0752f02; seconds, forward / reversed run order): archive install 110 / 124 (baseline) against 1.2 / 1.3 bundled and 44 / 47 stripped unbundled; upgrade 127 / 130 against 3.3 / 2.4 and 48 / 46; first --smoke launch 6.7 / 8.1 against 5.2 / 4.8 and 8.9 / 9.1; warm build 332 / 247 against 0.7 / 0.5; warm release 318 / 255 against 35 / 36; installed files 17,170 against 117 bundled and 7,455 stripped; archive 31.8 MB against 4.7 MB and 26.4 MB. Not a claim about any other machine; the full table is in performance.
  • Fewer files to install on Windows: release.strip removes source maps and TypeScript declarations from the payload, and release.bundle assembles a compact Node runtime through upstream public exports, retaining WASM/native assets and extension loading. The personal and developer examples write both out as true; the others take the default. Measurements, the operation estimates, and Windows test instructions are in performance.
  • The installed launcher registers a launch, then lets go: it writes its runtime lease itself, before the runtime loads, and releases the launch gate at once, so a second launch or an update waits for milliseconds instead of a cold load. Lease, gate, lifecycle-lock, and session-owner records name a process by ID, host, start time, and a random instance, and carry a start identity only on Linux: asking the system for one is a PowerShell start on Windows and a ps on macOS, so a process asks only when it must judge a contested record, with a start-time tolerance of 30 s (it was 5 s). Leases are no longer flushed. The launcher carries a build stamp (// launcher-build: <12 hex>, shown by doctor and PISHIP_DEBUG_TIMING), and an installed launcher that an earlier PiShip wrote is replaced after update or rollback commits, by doctor, and at the end of a session. The Windows command shim no longer runs where node, and the bundled fd and rg are checked at start by a file-system receipt instead of being read and hashed.
  • piship release runs its registry scans, SBOM, notices, and smoke tests side by side, hashes the archive while it compresses it, and keeps an immutable runtime cache of the installed dependency tree and the bundled runtime in PiShip's user cache directory (<cache>/piship/runtime, keyed by the build input, Pi and npm lock, platform, Node, strip setting, and esbuild version; only runtime bytes, never an audit, SBOM, test, or archive). A cache hit, a miss, and a cold build give a byte-identical archive, release.json, and 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. The key also includes npm's major.minor and, on Linux, the libc family. --rebuild or PISHIP_RELEASE_NO_CACHE=1 forces a cold release build, and piship build --rebuild ignores the stamp beside its output, from which piship build reuses an unchanged payload. The cache has the trust of the owner's home directory: a bundled-runtime entry is content-hashed at each lookup, and an installed tree is checked by its file set, sizes, and an entry digest; a cache on another volume is filled by copying. CI release qualification starts cold. A warm bundled release took about 3.1 s, from 5.6 s, and an unchanged piship build about 0.13 s on macOS (performance).
  • piship release reports qualified and its release.json records qualification: qualified; piship build, dev, and test produce an unqualified local build and say so, with a marker <out>/<id>.piship-qualification.json beside (not inside) the payload. piship install of one warns, and verify-release refuses it.
  • The SBOM records the registry source and the integrity of the dependencies of each vendored Pi package, read from that package's package-lock.json, and the SBOM and notices are tested to list the same packages, sources, checksums, and license text under a bundled runtime and under shared dependencies.
  • Release candidate checks the evidence each release records about itself (qualified, tests and vulnerability verdict passed, every SBOM package in the notices, one audit record per vendored Pi package, the registry signature verdict) in every verify job, and reports the installed file count of every release in the install jobs.

Fixed

  • Required-control failures before a model dispatch now record model.denied with the error code. An unavailable audit sink still refuses the request when recording that denial fails. The full-buffer E2E verifies the actual tool or model refusal boundary and that no later tool execution or model request occurs.
  • Fixed: a build that shared dependencies failed when two sets of one package name and version held the same bytes under different registry integrity (a mirror, for example): both sets got one shared directory name and the second rename landed on a non-empty directory. The directory name now derives from the integrity as well as the bytes, and a name already taken keeps its set package-local. New tests cover a changed shared file, forwarding module, and bundled module under verify-release and doctor, the SBOM and notices of a shared and a bundled payload, and the path length of shared files.
  • Fix a regression from the Windows file-operation reduction: a bundled payload could no longer build, release, dev, or test another distribution, which the portable artifact contract (docs/portable-artifact.md) promises. The payload now carries one deterministic authoring.json.gz (inventoried, never read by launch, install, update, or rollback) that authoring commands expand into a digest-keyed user cache and reuse warm.
  • Fix the live team-member budget reference test to follow actual spend: an early 429 counts outstanding reservations, so it waits for settled spend and continues until the member budget is truly consumed, without loosening the final 429 expectations.
  • Fix release notices to include bundled fd and ripgrep and their shipped licenses, matching the SBOM so verify-release accepts distributions with bundled search tools.
  • Update the development-only transitive dependency source-map-js to 1.2.2 to fix CVE-2026-93749 (indexed source-map denial of service).
  • Fix the registry signature check of piship release: it ran npm audit signatures --omit=dev in a payload that keeps the root workspaces but not the workspace packages, so npm reached no runtime package and answered "found no dependencies to audit", and every release recorded signatures: unavailable and qualified having verified nothing (the v0.9.1 candidate archives of two targets and local builds all did). The check now puts the workspace manifests in place while npm runs and removes them, and a local release records passed. A release whose signature verdict is unavailable for that reason fails Release candidate (scripts/check-release-evidence.mjs).
  • Fix the shared file store's layout marker: it was written in place, so a second install opening a new store at once could read it half-written, take it for a layout it does not know, and run without the store, and an opener that stopped between creating and writing it left an empty marker that turned the store off for good. The marker is now published by one rename, and an empty one is written again.
  • Sandbox conformance kit: the wait for a command that has printed nothing before the timeout and dispose checks abort it now counts only time the event loop was responsive. A stall at start (a busy host, a backend that works synchronously in the kit's process) no longer aborts a command the backend was not yet given a turn to start, which misreported a backend that ignores cancellation before start as failing the timeout check too. The timing constants are unchanged.

Behavior changes

  • Behavior change: in a governed distribution (piship/v1alpha3 and later) Pi's project-trust flag is PiShip's decision. It was the in-memory default of true for every project, so ctx.isProjectTrusted() was always true and the only gate on .claude/* was the extension's own question. It is now false for a managed distribution unless the company admits the configuration (or the policy admits all five Claude dimensions where the project has none), and false in a personal one only when policy denies an item or the person declines. A distribution without pi-code or another extension that asks Pi sees no change; v1alpha1 and v1alpha2 keep Pi's default. Pi's own notice about an untrusted project with .pi resources can now appear, and it still suggests /trust.
  • Behavior change on Windows: installs and updates use the shared file store by default. PISHIP_STORE unset now means hardlink with full content verification on Windows (defaultStoreMode in packages/core/src/store/policy.ts); macOS and Linux stay off. An explicit PISHIP_STORE=off|copy|clone|hardlink always wins, so PISHIP_STORE=off restores the previous behavior on Windows, and an unknown value is still refused with CONFIG_INVALID. The decision is measured on a Defender-on windows-latest runner (NTFS, Node 24.21.0, 3,000 files, 7 repetitions), relative to direct extraction: first install 0.97x, repeat install 0.10x, update 0.17x, removing the installed tree 0.70x; copy and clone (which falls back to copy on NTFS) are 1.05x to 1.10x on a first install and are not used. An installed file under node_modules/ or pi-packages/ is now a second name for a read-only object in %LOCALAPPDATA%\piship\store; every placement hashes the object first, a file that cannot take another link (NTFS allows 1,023) or a store on another volume falls back to a copy, and a program that clears the read-only attribute and writes to an installed file changes every installation that shares it (reported by doctor, restored by repair). A placement now also restores the read-only mode of an object whose bytes are right. The cost is documented in performance and the risk in security.
  • Behavior change: with release.bundle (on by default in piship/v1alpha6), a build now shares dependencies that several vendored Pi packages hold identical copies of, and bundles the closure of a Pi package when that is safe. A dependency is shared only when every copy has the same name, version, registry integrity (from the package's npm lockfile), and bytes, has no dependencies of its own, has an exports map, and has no native code, WebAssembly, install script, os/cpu field, bin, unparsable file, or code that finds modules or files at run time; the real files then live once in pi-packages/.shared, and each place keeps its package.json, its license files, and plain one-line forwarding modules for the files exports reaches. No link of any kind is created, so the payload, its inventory, and its archive stay regular files, and Node resolves through the same exports map as before. A closure is bundled only when it is pure JavaScript with static imports, no native or WebAssembly asset, no install script, no dynamic import or require, nothing that locates its own files, no JSON or TypeScript module in it, and no CommonJS module an extension imports directly; the locked extension files stay byte for byte and what they import is bundled below them. Anything else keeps its vendored files. The lock, SBOM, notices, and resource digests are unchanged. Two packages that used separate copies of a shared dependency now share one instance of it. What each closure and dependency became, and why, is in metadata/pi-package-footprint.json, in piship build output, and in doctor. The developer example goes from 9,007 to 8,292 payload files and its release archive from 43.8 MB to 42.0 MB on macOS; none of its six packages is bundle-safe, so all keep their vendored files. See performance. Set release.bundle: false to keep every package's own vendored tree.
  • Default changed: release.bundle and release.strip are now true for piship/v1alpha6 when a manifest leaves them out, so piship build, dev, test, and release of a manifest that never mentioned them now produce a bundled, stripped payload (the personal example is 117 files, from 17,170) that carries its authoring inputs as one compressed snapshot, expanded only when it builds another distribution. To keep the unbundled layout, set release.bundle: false and release.strip: false and run piship lock again (the lock records the keys only when the manifest writes them, so omitting them changes no lock; upgrading PiShip already makes every lock stale, so relock then in any case). piship migrate from piship/v1alpha5 reports the change. piship/v1alpha4 and piship/v1alpha5 cannot set either key and build as before. The schema stays piship/v1alpha6: no manifest written before this parses differently or has another digest, so a newer PiShip still verifies the payload of an older release.
  • Behavior change: piship install, install.sh, install.ps1, and update no longer run verify-release. They extract the archive once, with 8 parallel writers, straight into apps/<id>/<version>, and compute the archive's SHA-256 and the SHA-256 of every file in that same pass: the results are compared with the signed or bound inventory before the receipt is written, and a directory install hashes while it copies, so an install is verified without extra file operations. They do not run the checksums, SBOM, notice, and scan verification, flush every file, or boot the candidate as a launch check; release qualification and an explicit verify-release or doctor retain those. A directory Windows will not rename (a scanner holds a handle) is retried. Install, update, and launch never delete an old version: doctor reclaims obsolete versions and the .retained-* directories an update set aside, within a time budget, and keeps the active version and the rollback target. update and rollback no longer remove the leftovers of an interrupted operation (doctor does). A power loss during an install or update may leave extracted files incomplete; restore them with piship repair.
  • Behavior change: a launch no longer hashes the whole payload inventory. Every launch hashes piship.lock, the one small file that carries policy.enforced and the capabilities, against the digest the install receipt recorded and fails with INTEGRITY_FAILED on a mismatch; it is skipped where there is nothing to compare with (a build directory, another install home, an unreadable receipt, an installation recorded before the receipt carried the digest). The new piship/v1alpha6 field runtime.verifyAtLaunch: true makes a launch run the full payload verification and keeps the V8 compile cache off; unset or false, a launch also checks that each resource the lock lists is still a regular file, and a bundled payload may keep a V8 compile cache in the distribution's state. The digest checks that admit a Pi package's entry files and a certified resource always run. Startup no longer reclaims abandoned temporary directories (doctor does), and the audited data retention sweep and the replacement of an outdated installed launcher run after a session ends and at logout. Other local payload edits are found by doctor and verify-release.
  • Behavior change: exclude live Chrome startup from Release qualification while retaining it in nightly and standalone manual Portable E2E. Qualification continues to run all other developer and release checks and does not certify browser availability.

v0.9.1

Preview milestone, published as a GitHub pre-release from tag v0.9.1 (commit a97ba43, the squash of #215); not published to npm. Release qualification passed on that exact commit (run 37297400038). v0.9.1 is a patch milestone that collects the changes made on main after v0.9.0: plain HTTP to a private or internal host as an explicit opt-in for MCP servers and for the gateway, credential broker, OIDC issuer, audit sinks, and sandbox, identity-derived MCP request headers, fd and rg bundled with the release (runtime.searchTools), and the fix for Pi's tool directory. It stays on Pi 1.0.2 and piship/v1alpha6 / piship-lock/v1alpha6: the new fields are additive and optional, and the schema ids are unchanged. v0.9.1 is the production-validation baseline, replacing v0.9.0 (baseline).

Added

  • piship/v1alpha6 MCP server field httpTransport: http-allowed (manifest): a streamable-http server may use plain HTTP to a private or internal host, judged by name as for updates.transport: http-allowed. A public host fails validate, and a ${NAME} URL that resolves to one fails the server's start. It cannot be combined with credential: runtime, the private-only network policy applies unchanged, and only that server's requests may use plain HTTP. validate warns that the traffic is unencrypted, and also for a .local or single-label host. doctor and config explain show it, and piship diff reports it as high risk, also on an added server. Without it nothing changes.
  • piship/v1alpha6 MCP server field headers (manifest): a streamable-http server may receive a claim of the signed-in OIDC identity (sub, preferred_username, or email with email_verified: true) as a request header, such as X-Company-User: { identityClaim: preferred_username }. It needs identity.mode: oidc, accepts no literal or environment value, and refuses authentication, cookie, framing, hop-by-hop, proxy, method-override, and MCP transport header names. The claim is the launch's activated identity's, held in memory; each request only checks the stored identity metadata, so after a logout or a user switch the session's requests fail instead of sending a claim. A missing or unusable claim fails the server's start. Only header and claim names reach the lock, doctor, and config explain; audit and metrics record nothing about the headers. The header is not authentication (security).
  • piship/v1alpha6 field httpTransport: http-allowed on every other network endpoint (manifest): inference.httpTransport (the gateway), credential.broker.httpTransport (endpoint and revoke endpoint), identity.oidc.httpTransport (the issuer and every endpoint its discovery names, including the browser's authorization page), audit.sinks[].httpTransport, and sandbox.httpTransport (endpoint and router), for a company that runs these services on an internal host without TLS. Each opts in on its own, and only a private or internal host is accepted, as for MCP servers; a public host fails validate, and a ${NAME} that resolves to one fails at launch. Plain HTTP is admitted only to that endpoint's own origin on the fetch its client uses, and for the gateway also on the process dispatcher Pi's model requests use; every other host and endpoint keeps the loopback-only rule. sandbox.credential: runtime cannot be combined with it, and an MCP server with credential: runtime stays refused over plain HTTP even on the gateway's origin. validate warns what travels unencrypted (the gateway credential, which can be replayed until it expires, prompts, identity tokens including the refresh token, audit events, sandbox commands), and about a .local or single-label host; doctor and config explain show each opt-in and piship diff reports it as high risk (security). Without it nothing changes, and existing locks keep their digests. updates.transport keeps its name.
  • piship/v1alpha6 field runtime.searchTools (manifest): mode: bundled ships fd and rg with the release, so Pi's find and grep tools and @ completion work on a managed machine without anything installed and without Pi going online. piship lock downloads the official upstream release archives (sharkdp/fd and BurntSushi/ripgrep; defaults fd 10.5.0 and ripgrep 15.2.0, or the exact versions fd and rg pin) for every target in release.targets, through the managed fetch with the lock environment's proxy and CA, into PiShip's download cache, and records per target the archive URL and sha256, the executable's path and sha256, and its size (searchTools, digests.searchTools). https://github.com must be in release.sources; the source gate also refuses an archive that is not the official asset, and piship build refuses such a lock before it downloads or reads anything. piship build and piship release check the cached archive and executable against the lock, read the archive with the release extractor's rules (no links, special files, or traversal), and place the executable in the payload's tools/ (with its license files), where the inventory, verify-release, and the SBOM cover it. At launch the executables are checked again and copied into Pi's tool directory, <state>/agent/bin, which Pi searches before PATH, so a user's own fd or rg never replaces them. --smoke runs Pi's find and grep tools, doctor and config explain show the tools and versions, and piship diff reports an added tool or changed bytes as high risk and a removed tool or a version change as medium. PI_OFFLINE, the private-only launch policy, and the session.export status are unchanged. Without the field nothing changes, and existing locks keep their digests.
  • Fixed: Pi fixes its tool directory (<agent dir>/bin, where it looks for fd and rg before PATH and puts the ones it downloads) when it is imported, before PiShip set PI_CODING_AGENT_DIR, so Pi used ~/.pi/agent/bin instead of the distribution's state: a personal distribution downloaded fd and rg there, and every distribution preferred a copy found there. The launcher now sets the variable before it imports Pi, through @piship/pi/environment. A user's fd or rg in ~/.pi/agent/bin is no longer used; Pi uses one on PATH or, where Pi is online, downloads its own into <state>/agent/bin.
  • Behavior change: a plain-HTTP request admitted by an opt-in (httpTransport: http-allowed on any endpoint, MCP servers included, and updates.transport: http-allowed) is refused with NETWORK_DENIED when an inherited HTTP_PROXY would carry it and the proxy is not itself a private or internal host, since the request would cross the proxy in clear; NO_PROXY is honored, and the error and doctor say to add the host to it (security).

Changed

  • Every @piship/* package and PISHIP_VERSION are 0.9.1, and every example is relocked with it.

v0.9.0

Preview milestone, published as a GitHub pre-release from tag v0.9.0 (commit ff3c382, the squash of #209); not published to npm. Release qualification passed on that exact commit (run 37247881318). v0.9.0 adopts Pi 1.0.2 and makes PiShip's governance Pi 1.x-native: tool exposure with Codemode and tool search, model selection and dispatch with virtual routes, Pi packages locked and vendored without Pi's installer, runtime mutation and cache warming, a data lifecycle with session export governance, and enforcement reported from a runtime seam table. It adds the last alpha schemas, piship/v1alpha6 and piship-lock/v1alpha6; v1alpha5 manifests keep loading, and piship migrate --check reports what migrating would change. v0.9.0 was the production-validation baseline until v0.9.1, replacing v0.8.1 (previous baseline).

Added

  • Manifest schema piship/v1alpha6 (manifest): runtime.tools (codemode, toolSearch, exposure), runtime.cacheWarming, MCP server class, exposure, and tools as an exposure map, models.catalog entries with type (chat, classifier, image), api, output, and virtual routes, a data section (retention, purge, export), policy.acknowledgeUnenforced, resources.packages and packageTrust, and release.vulnerabilities.registry. v1alpha5 manifests keep loading with an unchanged meaning, and piship release accepts both.
  • Lock schema piship-lock/v1alpha6: runtimeTools, the resolved exposure of PiShip's tools and declared MCP rules (tools), virtual model routes, the runtime seam evidence (enforcement: Pi version, seam table, digest), the data contract (data), and sessionExportStatus, with typed slots for Pi packages and the Pi sibling tree. Older locks are still read.
  • Policy actions model.select (the new name of model.use, which stays accepted as an alias and is normalized at parse time in manifests, rule files, and locks; config/policy.json is never rewritten), model.dispatch, and session.export (resources public, local, support).
  • piship migrate --check writes nothing and exits non-zero when migrating would change an effective decision. Migrations run from a registry keyed by (from, to) with a fixture per step; v1alpha5 to v1alpha6 never broadens (migration).
  • Tool exposure: every tool's exposure (direct, model-only, codemode, deferred, hidden) is resolved before Pi creates the session, and hidden or denied tools, plus Pi's ungoverned base grep, find, ls, and powershell, are excluded through createAgentSession({ excludeTools }). Pi's Codemode (without the script models global) and tool search are explicit, governed opt-ins loaded from the lock. Tool calls are recorded at tool_execution_start with detail.source (top-level, codemode, nested), detail.parent, and detail.exposure; calls Pi refuses before the policy hook are recorded under rule piship.pre-policy. Approval prompts of concurrent calls open one at a time, and nested calls kept with a tool result are redacted before the session file is written.
  • Pi packages: piship lock resolves each resources.packages entry to an immutable identity (an npm exact version and integrity, a full git commit SHA, or a local content digest) and records it with its tree digest, file count, and per-file resource inventory; one npm lockfile per package is generated with --ignore-scripts and stored in piship.lock.d/packages/<id>/. Ranges, dist-tags, aliases, link, file, and workspace references, git dependencies, URLs with credentials or a query, and host-provided Pi packages are refused across the whole closure, under packageTrust. piship build vendors each package into pi-packages/<id> (git through git archive, never a checkout) and checks the locked identity, digest, and inventory. The closures pass the existing release gates: release.sources, the install-script review (counting binding.gyp, with reviewed entries in the new v1alpha6 release.installScripts), and npm audit per package lockfile under release.vulnerabilities (using release.vulnerabilities.registry when set). At launch each package file is checked against its locked sha256 and decided on its own before Pi's loader sees it, with detail.package in audit. Pi's own installer, which runs npm lifecycle scripts, never installs a package (manifest). piship lock needs npm 11 or later when the manifest declares Pi packages and refuses an older npm (Node 22 bundles npm 10, which runs a git dependency's prepare despite --ignore-scripts); piship build and piship release work with any npm. CI, Portable E2E, Reference E2E, and Live provider install npm 11.21.0, and contributors need npm 11 (CONTRIBUTING). On Windows, package resolution and the release npm audit run npm as node with npm's CLI script, found through the npm.cmd shim on PATH, with the arguments passed as an array and no cmd.exe, so a registry URL or version range from a manifest is never read as shell syntax. vulnerabilities.json gains an optional packages list, the scan of each vendored Pi package lockfile with its scan time (a personal release records a warning when no audit endpoint answers; a managed release fails), and the SBOM and notices include the dependencies installed under pi-packages/<id>/node_modules. A certified package's evidence must match the lock (its integrity equals the locked tree digest or the registry integrity, and an npm package's version equals the locked version) or piship lock fails; at launch a mismatch fails with INTEGRITY_FAILED, and a Pi version or platform the evidence does not list means its files are not loaded.
  • Runtime mutation governance: Pi has no veto and no event for a change to the prompt or the active tool set, so PiShip re-asserts what it enforces from the last mutation hook of each request: distribution-owned instructions (certified and company, and every instruction in managed mode), the workflow section, ask_user, and the Codemode and tool search tools the exposure table activates. Each repair is recorded as runtime.mutation.reverted, and a failed repair blocks every later model request. Everything else stays mutable by an extension.
  • Cache warming: PiShip always writes Pi's cacheWarming setting from runtime.cacheWarming (off when none is declared, where Pi's default is streaming), answers cache_warming_decision with stop when the enforced mode is off, and refuses /settings changes unless userOverride allows them; an undeclared mode is enforced for a managed distribution only. The lock records cacheWarming.
  • Audit event types model.dispatch, session.export, runtime.mutation.reverted, and data.swept, and the enforcement plane gateway, extend the closed enums of piship-audit/v1 and piship-audit-batch/v1; the schema ids are unchanged (integration contract).
  • doctor has a Governance group: the manifest schema (a v1alpha5 manifest suggests piship migrate <manifest> --check, then --write); the seam table's Pi version, with a warning when it differs from the running Pi; enforced actions as N of M, with a line per unsupported action; Codemode, deferred tools, tool exposure, and the extension tools resolved at launch; virtual models; Pi packages (how many are vendored, integrity verified at launch, a line per package); the cache warming mode (a v1alpha6 lock without it shows off); runtime mutation (enforced per turn; a blocked runtime fails doctor); data retention and purge, and a warning when an installed older release predates the data contract and would stop sweeping after a rollback. The session export line stays in the Policy group, always shown.
  • piship diff compares the v1alpha6 lock fields: runtimeTools, per-tool exposure, seam evidence, the data contract and declared data, sessionExportStatus, virtual models, Pi packages (packages[]), and cacheWarming. A new package, a change of a package's source, URL, version, commit, integrity, or tree, a widened package class, widened tool exposure, an enforcement downgrade, and an export that becomes allowed are high; a cache warming change to a mode other than off, allowing a user override of it, and an added policy.acknowledgeUnenforced entry are medium (a change to off or to no override is low). The model.use to model.select rename is not a change.
  • Install-path tests: v0.8.1-shaped state with a v1alpha5 manifest launches under v0.9 without any state file being rewritten, and the audit schema ids stay those a v0.8.1 doctor reads after a rollback.

Changed

  • Behavior change: prompt cache warming is off unless runtime.cacheWarming says otherwise, for every distribution built with v0.9, including an unmigrated piship/v1alpha5 one (which cannot declare the field); v0.8 sessions warmed the prompt cache through Pi's own default (streaming). A managed distribution enforces off; a personal one starts at off and does not pin it. piship migrate --check reports it, and runtime.cacheWarming.mode: streaming in piship/v1alpha6 keeps warming.
  • Behavior change: a tool whose tool.execute decision is an unconditional deny is no longer offered to the model; v0.8 offered it and refused the call. This covers read, write, edit, bash, ask_user, and extension tools, for piship/v1alpha5 distributions too. ask, Plan mode, and user auto mode do not hide a tool.
  • Behavior change: a declared MCP server's class is decided by policy.resourceTrust before the server starts (a denied start is audited as mcp.server.start with the class, and a denied required server fails the launch with MCP_DENIED), and the release policy gate refuses a server whose class is denied. A v1alpha5 server has the default class (company managed, user personal), so an unmigrated manifest whose resourceTrust denies it stops starting that server; piship migrate --check reports it.
  • Models are governed by two actions: model.select for the model a session selects (possibly virtual) and model.dispatch for the physical model a request is sent to; without a model.dispatch rule for a model, model.select decides it, so a v0.8 model.use deny is not bypassed through a route. A piship/v1alpha6 virtual model (models.catalog.<id>.virtual) is registered only from its declared router while extensions load (at launch and on /reload): an undeclared id or another extension fails the launch, and a registration after that is refused with POLICY_DENIED. Every routed request, from the agent loop, an extension's direct call, or a compaction or summary, is checked after Pi routes it and before it is sent: a target outside the declared routes, or one model.dispatch or the entitlement denies, is refused with MODEL_DENIED and audited; an allowed one is recorded as a model.dispatch event with detail.selected, detail.dispatched, and detail.router. A virtual default model and a resumed virtual selection work. Classifier, image, and deferred requests, and Pi's type-aware model listings, are governed the same way; a refused classifier or image request returns Pi's error result. Validation refuses a route that is not an allowed physical chat model, and the release models gate refuses a managed route outside the allowed catalog and a router that is not a declared extension (a package:<id> router is not resolved yet).
  • Enforcement is reported as a status derived from the runtime seam table (RUNTIME_SEAMS in @piship/policy): enforced, audit-only (observed and recorded, not prevented), or unsupported (no runtime seam: neither prevented nor recorded). policy explain prints unsupported where it printed an audit-only explanation for web.request, browser.execute, agent.invoke, memory.*, and an uncontained network.connect, prints enforced (<plane>) for enforced actions, and adds enforcementStatus to its JSON. A managed deny or ask rule naming an unsupported action exactly fails validate and the release policy gate with POLICY_UNENFORCEABLE, and a personal one warns; doctor lists every such rule. This applies to piship/v1alpha5 managed manifests as well, which cannot acknowledge a rule because policy.acknowledgeUnenforced is v1alpha6 only: validate and piship release fail until the rule is removed, or the manifest is migrated and the rule acknowledged.
  • A piship/v1alpha6 data section is enforced. At every session launch and at logout, files past their retention are deleted by mtime: Pi session files under <state>/sessions (a session another launch holds is kept), rotated logs/audit.jsonl.N files (never the live audit.jsonl), and files under <state>/cache; logout also purges the classes in data.purge.onLogout. A data.swept audit event is recorded before each class is deleted; when a required sink does not take it, that class is kept. Credential metadata is never swept. Audit retention is a minimum: purge.onLogout may not name audit, and size rotation no longer deletes a rotated audit file younger than the declared minimum, so local audit then grows with what is written within the minimum instead of staying near 60 MiB. data.purge.onUninstall is parsed, locked, compared by piship diff, and shown by doctor, but uninstall does not read it: uninstall keeps the state as before.
  • data.export.<resource> is a distribution-enforced session.export rule, checked against the seam table like any other rule. The lock records sessionExportStatus for every resource and doctor always shows it. A gateway distribution with the id radius is refused with RADIUS_PROVIDER_RESERVED: its model provider takes that id, so Pi's /share would upload the session to Radius with the distribution's credential.
  • Pi is pinned to 1.0.2 (@earendil-works/pi-coding-agent, exact), up from 1.0.0. compatibility/pi.json and the copy in @piship/core list 1.0.2 with the statuses of 1.0.0, and keep 1.0.0 and 0.87.1 so that releases built on them are still recognized; the launcher runs only 1.0.2. The public API change is additive, the session format stays version 3, and no builtin:* extension loads (upgrade notes).
  • Pi 1.0.1 and later ship no npm-shrinkwrap.json and declare their sibling packages with ^. The root package.json overrides pin chord, pi-agent-core, pi-ai, pi-codemode, pi-mcp, pi-telemetry, and pi-tui to exactly 1.0.2, from one list (PI_SIBLING_PINS in @piship/pi) that the compatibility suite checks against the overrides, the npm lock, and the installed packages. The Pi latest canary drops the overrides before it installs the newest Pi and skips that check under PISHIP_PI_CANARY=1.
  • The reviewed install scripts are re-keyed to their hoisted paths (node_modules/@google/genai, node_modules/esbuild, node_modules/protobufjs, same versions and scripts). npm's os/cpu filtering now applies to Pi's optional packages, so the personal example's payload drops from 147.6 MB to 145.3 MB on darwin-arm64 and from 149.2 MB to 146.9 MB on linux-x64.
  • Every example is migrated to piship/v1alpha6 and relocked as piship-lock/v1alpha6, with the same effective policy: model.use rules read model.select, each MCP server states its class and exposure: direct, and the demo's tools.allow list is the equivalent exposure map. None declares runtime.cacheWarming, so their sessions no longer warm the prompt cache (see the behavior change above).
  • examples/enterprise-reference and its sandbox variant declare the packages/pi-platform Pi package (source: local, class company, one extension, one skill, one prompt, and the ms dependency) with packageTrust.local.paths: [./packages] and a resource.load rule for its prompt; the package lockfile is committed in piship.lock.d/.
  • Every @piship/* package and PISHIP_VERSION are 0.9.0.

Fixed

  • session.export: support was reported as enforced in personal mode, where Pi's /bug upload is open: only a managed launch runs Pi offline (PI_OFFLINE=1). Its status is now enforced only where Pi runs offline and unsupported otherwise, in the seam table's containment, the lock's sessionExportStatus, doctor, and POLICY_UNENFORCEABLE.
  • The commented MCP example in the managed piship init template used the v1alpha5 tools: {allow: [search]} filter, which validate rejects in the piship/v1alpha6 manifest init writes once the example is uncommented. It now shows an exposure map (search: direct, "*": hidden), and a test uncomments it and parses the manifest.
  • piship diff described data.purge.onUninstall: all as "Uninstall purges all data", although uninstall does not read the field and keeps the state. It now reports the purge as recorded and not applied, and a change in either direction is low, because nothing changes at run time.
  • Docs: the agent setup guide gains step 9, upgrading an existing distribution (the Pi pin and certified evidence, migrate --check, validate, then lock and diff against the previous lock, and rollback). Its questions and answer tables now cover cache warming, Codemode and tool exposure, Pi packages, and data retention, and it says to commit piship.lock.d/ and to write MCP tools as an exposure map.

Security

  • Pi 1.0.2 pins brace-expansion 5.0.12, which fixes GHSA-qhr7-859c-m2p7 and GHSA-6j4f-fj2g-mc7p. The reviewed exception for them is removed from the five example manifests, and piship release on the demo passes the vulnerability gate with no allow entries and no findings. The agent setup guide no longer says to copy the entries (#129).

v0.8.1

Preview milestone, published as a GitHub pre-release from tag v0.8.1 (commit d069104, the squash of #203); not published to npm. Release qualification passed on that exact commit (run 37127974973). v0.8.1 collects the changes made on main after v0.8.0: an administrator-allowed user auto mode, an administrator-allowed plain-HTTP update channel, handling of a secret store that cannot be reached, and the fixes from the review of those changes. The manifest and lock schemas stay piship/v1alpha5 and piship-lock/v1alpha5, and Pi stays at 1.0.0. v0.8.1 was the production-validation baseline until v0.9.0, replacing v0.8.0 (previous baseline).

Added

  • policy.userAuto (off or allowed, absent means off) in managed piship/v1alpha5 manifests lets the administrator allow a user-controlled auto mode. A user switches it with <command> auto on|off|status or /auto in a session; while it is on, an ask from policy.defaults or policy.default is approved without a prompt, interactive and headless. deny and every ask from policy.enforced, a team or project rule, or the user's own rules are untouched. Each approval is audited as policy.auto_approved and each switch as policy.auto_enabled or policy.auto_disabled (new piship-audit/v1 event names); switching on is audited first (/auto on delivers the event before it switches) and stays off when a required sink does not take it, switching off is never blocked by audit, and a session that starts with auto mode on records policy.auto_enabled with detail.source: state. policy explain reports AUTO-APPROVED only for actions a session decides itself. The switch is stored in <state>/config/auto.json, reset when another principal signs in, kept by uninstall, deleted by purge, and inert under a release that does not allow it. validate, inspect, config explain, diff, doctor, and policy explain (ask (auto-approved by user)) report it. A personal manifest that declares the field is rejected. Manifests without it lock byte-identically (user auto mode, #197).
  • updates.transport (piship/v1alpha5, optional): https (the default, unchanged behavior) or http-allowed, which lets the update channel (channel metadata, root files, and release archives, from updates.source or update --from) use plain HTTP to a private or internal host, for an intranet without HTTPS. A single-label host that is a public top-level domain (any two-letter label, or a common one such as com, net, org, dev, or app) counts as public, and the docs say that a single label depends on the clients' DNS search domains. A public host is a validate error or NETWORK_DENIED at run time, http-allowed requires updates.trust.bootstrap, and signature, digest, sequence, expiry, and root-refresh checks are unchanged. Every other endpoint (OIDC, broker, gateway, MCP, audit, sandbox) keeps the HTTPS-only rule. validate, config explain, and diff show it, doctor warns source http (integrity by signature only), and an update or check over plain HTTP is audited with transport: http. A manifest without the field locks byte-identically (manifest, #199).

Fixed

  • An explicit shell ask from policy.enforced, a team or project rule, or a managed user's own rule no longer stops applying when the command chains past its pattern: first-match skipped it as it skips an allow prefix rule, so git push origin main; true fell through to a weaker rule or a distribution default, which auto mode then approved without a prompt. Such an ask now applies to the chained command too, with or without auto mode; it only adds an ask, and a deny still wins (#202).
  • A login through a system secret store that cannot be reached (Linux without secret-tool, as on WSL or a headless server) no longer leaves a secret reference behind: the first login wrote a discarded marker naming the new reference before the write, the write failed, and every later login reported The previous credential could not be deleted for a secret that never existed. Such a write now restores the metadata file, and a marker whose store command is not installed is dropped at the next login or logout, also after switching to the file store; a write that fails any other way keeps its reference tracked. The error leads with the store's problem (secret-tool was not found), its action names installing secret-tool and a keyring or switching to storage: {provider: file}, a discarded marker is no longer called a previous credential, and doctor says what stale references remain and which command clears them (#196).
  • A discarded marker is no longer dropped whole when its store command is not installed: it could name a secret that exists (a logout or replacement whose deletion failed while the store answered, or a first sign-in interrupted after its write), with the command missing only now. A marker now lists the references never confirmed written in unconfirmed; only those are dropped, the rest stay tracked and fail closed. A v0.8.0 marker has no such list and cannot tell them apart, so all of its references are still dropped (troubleshooting). purge and uninstall --purge apply the same rule, so a stale v0.8.0 marker no longer makes them fail forever on a machine without secret-tool, and report the dropped references; the branded logout without its runtime variables and sandbox login now say which references they dropped, as login does (#198, #202).
  • Redaction no longer hides the first word of a problem that follows a label ending in a credential word (new credential: delete <ref> showed [REDACTED] <ref>); the same word as a whole value is still redacted (#196).
  • Docs: piship test launches with the distribution's credential, so it failed with CREDENTIAL_REQUIRED for a local-secret or managed distribution that had no stored key. The agent setup guide now says to sign in with the built command (dist/<id>/bin/<command> login, in the directory where piship test ran) and run piship test again, and how a missing Secret Service on Linux (WSL, SSH) shows up (#195, #202).

v0.8.0

Preview milestone, published as a GitHub pre-release from tag v0.8.0 (commit 8550282, the squash of #193); not published to npm. Release qualification passed on that exact commit (run 37108519783). v0.8.0 is pre-production trust and validation hardening: it collects the changes made on main after v0.7.1 and freezes the update-trust contracts before real installations exist (its scope is in the roadmap). The manifest and lock schemas move to piship/v1alpha5 and piship-lock/v1alpha5, and Pi stays at 1.0.0. v0.8.0 was the production-validation baseline until v0.8.1, replacing v0.7.1 (previous baseline).

Added

  • Manifest schema piship/v1alpha5: updates.trust.bootstrap, a versioned update root (version, expires, keys, and root and channel roles with thresholds), replaces updates.trust.keys. Role key IDs must be listed, thresholds run from 1 to the role's key count, and a key ID or public key is listed once. piship validate warns about an update source without bootstrap trust and about a managed distribution whose roles share a key (manifest).
  • Lock schema piship-lock/v1alpha5: the manifest digest is the canonical sha256-<hex> of the parsed manifest (independent of YAML comments, key order, and line endings), and updates.trust.bootstrap is the exact validated bootstrap root. Older locks are still read.
  • piship migrate takes a v1alpha4 manifest to v1alpha5: its keys become a compatibility bootstrap with every key in both roles at threshold 1 (no key is invented, trust is no stronger), with a warning that managed rollout needs an explicit root / channel split. A manifest without keys stays update-disabled.
  • Behavior change: piship release requires a piship/v1alpha5 manifest. A new trust gate refuses a managed distribution whose root and channel roles share a key; an update source without bootstrap trust builds as an update-disabled release (#193). A fresh install takes its update root from the release's bootstrap.
  • Behavior change: installation update trust state. An installation keeps its current update root in <install home>/trust/<id>.json (piship-update-trust/v1, owner-only, replaced atomically), started at a fresh install from the verified release's bootstrap (v1alpha4 keys become root 1 with both roles at threshold 1). Release activation, rollback, and a release lock never change it: a release that pins other keys no longer changes what an installed client trusts, and the v0.7 retired-key list is no longer extended. A damaged or missing trust state fails closed, with a verified reinstall as the recovery; a receipt from before v0.8 is migrated once from its active lock (installation trust state).
  • Sequential root refresh: before reading a channel, update fetches root/<N+1>.json (piship-update-root/v1) and its signature from the update source, requires version N+1 and signatures meeting both the current and the new root-role thresholds, persists each, and verifies the channel with the newest root's channel role. Only HTTP 404 (or no such file) ends the refresh; any other failure stops the update. Root files and signatures are limited to 64 KiB and one attempt to 64 transitions, and an expired newest root authorizes nothing (root refresh).
  • piship-signature/v1 accepts an optional signatures[]: the top-level signature must be one of its entries, duplicate key IDs are refused, and only distinct valid signatures from the role's keys count toward its threshold. v0.7 clients keep checking the top-level signature (v0.7 to v0.8 bridge).
  • piship trust-root init prints a validated updates.trust.bootstrap block from public keys; piship trust-root next <dir> --manifest <piship.yaml> --sign <id>=<key>... publishes the next root (key and role changes, expiry), verified against the current and new root-role thresholds before anything is written. sign-channel accepts repeated --key / --key-id pairs and refuses signatures that do not satisfy the releases' declared trust. keygen prints the public key instead of a updates.trust.keys block (key runbook).
  • piship install --sha256 <hex> refuses a release archive whose SHA-256 differs from the published digest; with a release or payload directory it is refused, because a directory has no archive digest. --expect-key sha256:<fingerprint>, repeatable, refuses a release whose lock does not pin each given update key; the lock may pin further keys. Either refusal installs nothing (distribution bootstrap, #178, #167).
  • piship inspect shows the pinned update keys by ID and sha256: fingerprint, in the summary and as an additive trust field in --json (#178, #167).
  • The manual Live provider workflow takes an optional model input that replaces the LIVE_PROVIDER_MODEL secret for one run, and a failed run's job summary shows the gateway's HTTP status and PiShip's error code, never provider text (#182).
  • The production validation protocol: the managed sequence from install to purge, the command that runs each step and what a pass proves, and the only fields a report may carry. The production evidence issue form asks for exactly those fields and requires a confirmation that a report holds no raw credentials, tokens, claims, internal URLs or hosts, usernames or email addresses, model names or IDs, raw logs, or company-identifying fields. The protocol calls out /share as an endpoint and egress-control requirement for an organization that prohibits public session export.

Changed

  • The trust root records the decisions that the PiShip project operates no official update channel for the example distributions and that release key custody belongs to each distribution owner; the remaining signed-channel gaps are deferred to #181 (#180).
  • The status page records the first passing Live provider runs: manual, real-provider evidence for the reference stack only, with no validation yet against a company identity provider or gateway in production.
  • Pi is pinned to 1.0.0 (@earendil-works/pi-coding-agent, exact), up from 0.87.1. compatibility/pi.json and the copy in @piship/core list both versions with the same surface statuses, so a release built on 0.87.1 is still recognized; the launcher runs only 1.0.0. v0.7.1 stays the production-validation baseline on 0.87.1. Pi 1.0's npm-shrinkwrap.json omits integrity for its own seven packages, so the npm lock carries the registry's published sha512 for them, which npm ci verifies (upgrade notes).
  • AssistantMessage.thinkingLevel, new in Pi 1.0, is classified metadata in the redaction map. The governance review of the built-in extensions of Pi 0.99 and 1.0 (builtin:mcp, builtin:codemode, builtin:tool-search, builtin:llama) found that a PiShip session loads none of them, because only Pi's CLI adds them, and a compatibility test asserts that no builtin:* extension loads. A tool an extension runs through ctx.executeTool still emits tool_call, which the policy hook decides (#176).
  • Behavior change: the governed bash follows Pi 1.0's result shape. It returns Pi's structuredContent (output up to 1 MiB, with the first and last 512 KiB when longer, truncated, full_output_path when the output was truncated and saved, exit_code, and wall_time_seconds), which codemode scripts receive, and a non-zero exit returns an isError: true result instead of throwing. The full output file still lives in the session-owned output store.
  • Behavior change: every launch sets Pi's startup defaults in its in-memory settings: quietStartup: true (no Pi logo, key hints, or resource listing) and tuiMode: "regular" (Pi 1.0 defaults to fullscreen; a distribution keeps the terminal's scrollback).
  • Docs: the terminal title ("π", Pi's APP_TITLE), the exit hint (pi --session-dir), the /bug hint (#139), and Pi's built-in /share command, which uploads the session file as a GitHub gist through the user's gh CLI, have no public Pi switch. They are recorded as known limits, and /share as a path by which a session can leave the machine, in managed mode too (security).
  • CodeQL cancels a superseded pull request run, as CI does: only an older run of the same pull request is cancelled, never a main, scheduled, or Release qualification run.
  • Docs: the status page and the roadmap record v0.8.0 and its qualification, the first production consumer row links to the production validation protocol, and the roadmap's later schema candidates now follow piship/v1alpha5.

Security

  • Behavior change: every launch sets PI_SKIP_VERSION_CHECK=1 and PI_TELEMETRY=0 and, in Pi's in-memory settings, enableInstallTelemetry: false and lastChangelogVersion to the Pi version. Pi no longer asks pi.dev for the latest version or shows "Run pi update", and no longer takes every session for a fresh install: the settings store is new on each launch, so Pi sent pi.dev an install report each time.
  • Behavior change: every launch sets PI_CODING_AGENT_DIR to <state>/agent. Pi's crash log (crashes.json) and the fd and rg binaries Pi downloads are now in the distribution's state, where purge removes them, and no longer under ~/.pi/agent; each distribution downloads its own fd and rg. A managed launch also sets PI_OFFLINE=1 after it has removed the inherited PI_ variables, so Pi makes no request of its own (no model catalog refresh, no fd or rg download): put fd and rg on PATH for @ completion and Pi's find and grep tools (security).

Fixed

  • Installing a distribution that is already installed names the installed version and the exact uninstall command (the installed release's own piship.mjs; the branded command has none), says that state is kept, and says to install again with --use-existing-state. A damaged receipt keeps the previous message (#184).
  • INTEGRITY_FAILED for an installed release also names the recoveries a user without a PiShip CLI can run: the copy inside the downloaded release (node <extracted release>/payload/piship.mjs repair <id> <extracted release>) and, when the message lists only unexpected files such as a Finder .DS_Store, removing them (repairing an installed release, #186).
  • The documented first run (init, validate, lock, build, test, install) no longer fails with State already exists. When piship test or dev creates a distribution's state, it marks it (piship-test-state/v1), and the first install of that distribution adopts it without --use-existing-state and removes the marker. State that existed before, or that an earlier install owned, still requires the flag. build now points to piship test before install.
  • A release whose manifest declares updates.source but no updates.trust.bootstrap builds as an update-disabled release instead of being refused by the trust gate. It cannot verify an update either way (update fails closed and piship validate warns, as with no source), and the refusal stopped the example distributions, which declare a source and pin no keys, from building in Release candidate. A managed distribution whose root and channel roles share a key is still refused (manifest, #193).
  • Test-only: the reference distribution helper parses keygen's Public key: line, which replaced v0.7's publicKey: and left Reference E2E unable to create its release key, and the managed clean-machine and reference update flows expect the root refresh's request for root/2.json, whose 404 ends the refresh before the channel is read (#191, #192).

v0.7.1

Preview milestone, published as a GitHub pre-release from tag v0.7.1 (commit bd4bc09, the squash of #174); not published to npm. Release qualification passed on that exact commit (run 36857734987). v0.7.1 collects the v0.7.x fixes made on main after v0.7.0 and is the production-validation baseline: the one PiShip version a production consumer pins while it validates PiShip in production, instead of tracking main (baseline, #173). The manifest and lock schemas stay piship/v1alpha4 and piship-lock/v1alpha4, and Pi stays at 0.87.1. The signed update channel's trust root, rotation, and hosting are described in trust root; the project does not operate a signed channel yet.

Security

  • The reference sandbox container service in examples/enterprise-reference pins every directory above a nested protected path, so a sandboxed command can no longer move a protected path aside and bypass the protected git path enforcement (GHSA-w3j6-fv7v-wwr4, #170). Affected: that reference service in the v0.7.0 source. Not affected: the v0.7.0 release archives, the PiShip packages, and the bubblewrap and Seatbelt sandboxes.

Behavior and contract changes

  • A required Streamable HTTP MCP server that can never start (a runtime credential it cannot receive, or a host the private-only network refuses) now fails validate, lock, and build when every URL involved is plain; with a variable involved, or for an optional server, validate warns (#168).
  • piship inspect prints a summary by default; its JSON now needs --json. piship test and piship doctor print human summaries and take --json (#161).
  • Update sources: redirects are followed only within the same origin (at most 5), HTTP 429 is retryable with Retry-After, and a local clock that is ahead is named (#161).
  • Broker requests (acquire, renewal, revoke) and gateway requests carry an additive PiShip-Client header with the distribution, its version, the PiShip version, and the protocol version, so a broker or gateway can refuse releases it no longer supports; PiShip itself never decides anything from it (decision 31, #162).
  • An explicit sandbox filesystem or environment list replaces the defaults, as before; the example manifests now restate every default they need (#162).
  • A cancelled --smoke-model request reports the new, additive error code REQUEST_CANCELLED; a managed endpoint's request that got no answer (refused or reset connection, DNS failure, deadline) is GATEWAY_UNREACHABLE, retryable, instead of GATEWAY_PROTOCOL_ERROR (#85).
  • rollback no longer re-trusts a release key that a later update removed: the install receipt records retired keys (retiredKeys, optional) and channel metadata signed by one is refused (#167).
  • sign-channel verifies existing channel metadata before extending it and replaces it atomically; signing on top of metadata from a rotated-out key takes --previous-key <id>=<public-key> (#167).
  • npm run typecheck also type-checks the test files (tsconfig.tests.json) (#160).
  • A login callback without this sign-in's state (a stray request, a stale tab) is answered 400 and ignored; the login keeps waiting instead of failing (#149).
  • A required HTTP audit sink whose host the private-only network refuses, and a sandbox.credential: runtime that can never be sent, now fail validate and lock when every URL involved is plain; with a variable involved they warn (#142).
  • Managed request failures name the hop: a dead, blackholed, or 407/403 proxy is reported as the proxy's failure with a proxy action, and an untrusted, expired, or mismatched TLS chain is a non-retryable TLS_POLICY_VIOLATION with an additionalCA action (#148).
  • A Kubernetes sandbox command is no longer cut off by undici's 300-second timeouts. A command whose outcome is unknown (the router connection drops or a 5xx after it was sent) fails as non-retryable SANDBOX_UNAVAILABLE and is never sent again (#146).
  • The reference credential broker never replays a revoked or out-of-entitlement credential; the reference Keycloak realm has brute-force detection and a password policy (#159).
  • A broker 409 to an acquire or renewal that sent an idempotency key is a final key conflict only when its JSON body is {"error":"idempotency_key_reused"}. Any other 409 now means the request is still in progress: the key is kept, Retry-After is honored, and the failure is retryable, so PiShip never issues a second credential for it (#157).
  • A renewal that fails with a retryable broker or network error keeps CREDENTIAL_ACQUIRE_FAILED, its retryAfterMs, and the action "try again later"; it is no longer reported as CREDENTIAL_REVOKED with "run login" (#157).
  • The lifecycle lock, the launch gate, and the .launching marker record the holder's process ID, start identity, host, and instance. A holder from this host whose process is gone or was replaced is reclaimed at once; a live matching holder is never reclaimed; a holder from another host is kept until its 24-hour lease ends. update and rollback wait up to 3 seconds for the launch gate, then fail as retryable (#144).
  • Every call into distribution-owned adapter code (identity, credential, custom sandbox available, prepare, dispose, and adapter loading) has a deadline and receives an optional AbortSignal. A timeout is retryable and names the adapter and the call; a hung credential adapter releases the credential lock (#145).
  • /resume is refused when the session inspection itself fails; session owner records from another host or PID namespace are reported as unverifiable instead of live or dead (#158).

Added

  • testCredentialBroker and testAuditCollector in @piship/adapter-conformance send a company's own credential broker or audit collector the requests PiShip sends (a repeated and a reused Idempotency-Key, a missing and an invalid bearer, the readiness probe, a resent batch, a conflicting event ID, an unknown property) and report where the answers break the contract. Enterprise integration describes how to test a broker and a collector against PiShip's real client, a build of the company's own distribution, and these kits (#162).
  • A release key and rollback runbook for distribution owners: preparation (a backup key, key and release records), planned rotation over three releases, a compromised or lost key, recovering clients that can no longer verify the channel, and taking a bad release back from every machine. It is the operational side of the release trust root design (#162).

Fixed

  • An unset ${VAR} in network.tls.additionalCA stops update with the same named-variable CONFIG_UNAVAILABLE launch gives, before any request; the declared CA is never silently dropped (#169).
  • Every command takes --help; raw EISDIR, ENOENT, and EEXIST failures become PiShip errors with actions; config explain shows the manifest schema and governance rows; doctor no longer reports an unusable session, a dead issuer, or itself as healthy; update, rollback, and build show progress on a terminal; an invalid user policy names its file; init, build, and install end with the next step and a PATH line (#161).
  • @piship/adapter-conformance adds testCredentialBroker and testAuditCollector, to check a company's own broker and audit collector against what PiShip sends (#162).
  • Documentation: a key rotation and compromised-key runbook, a separate maintainer release checklist, one migration table, exact network.allowHosts semantics, one AuditSink definition, an onboarding path in the README, and the reference README's services and prerequisites (#162).
  • The agent's bash tool keeps its full output in a session-owned directory that is removed at close, and a full temp filesystem no longer crashes the session; a user's ! command output files are removed at close and kept within free temp space (#64).
  • Two Reference E2E runs can share a host: Docker chooses the host ports, and the reference sandbox service accepts port 0 (#79).
  • doctor lists each trusted release key's ID and fingerprint and any key this installation retired (#167).
  • Test coverage: sandbox probe mutants, #49 refusal at launch, update and rollback, killed Compose project recovery, CI guards on self-skipping tests, ID tokens in the secret ledger, stronger boundary controls, and shared malformed networkProbe cases (#160).
  • piship repair <id> <release archive> restores a damaged installed release from a verified copy without running it; INTEGRITY_FAILED names each unexpected, modified, or missing path. Integrity checking is unchanged: no file is exempt (#136).
  • A PiShip identity or credential failure inside the TUI shows a PiShip action (<command> login in a terminal, or restart for a changed principal), and managed /login names the distribution's sign-in instead of upstream provider logins (#139).
  • validate warns about a relative additionalCA, an ignored network: deny on an optional sandbox, and lists the variables needed at launch separately from those only update needs (#142).
  • network.tls.additionalCA is trusted for hosts reached through a proxy tunnel (#148).
  • doctor checks that each proxy accepts connections, that each CA bundle loads, and, when activation fails, which endpoint path fails (#148).
  • Login names a busy callback port and how to free it, fails at once on a provider client error, says what it waits for, cancels on Ctrl-C, and prints SSH port-forward guidance (#149).
  • The reference sandbox service compares each mount with the file it checked before running a command (#159).
  • docs/security.md states exactly which session-file content PiShip scrubs (#159).
  • An empty or cut-short state.json is rebuilt; a torn final audit line is skipped; a damaged preferences.json is refused with its path and how to recover (#143).
  • The audit sink starts a new event on its own line after a torn final line, so the event is not lost (#143).
  • A failed credential lock write no longer leaves an empty lock file behind (#157).
  • A broker credential that arrives already expired names the local and broker clocks (#157).
  • A sandbox adapter failure keeps its retryable and retryAfterMs (#157).
  • Cancelling one Kubernetes command no longer fails another command on the same claim (#158).
  • A workspace check timeout no longer retires the sandbox (#158).
  • The abandoned-temporary sweep at launch is bounded to a short budget and continues on the next start (#158).
  • An edited command shim can be removed with piship uninstall <id> --remove-edited-shim; a foreign file is never removed (#158).
  • The adapter conformance kits no longer hang on an adapter factory or available() that never settles (#145).
  • examples/demo-company and the enterprise reference distribution (and its sandbox variant) declared a sandbox.filesystem.read.deny list that replaced the defaults and silently left ~/.config/gcloud, ~/.azure, ~/.kube, ~/.docker, and ~/.pi (and, in the demo, ~/.netrc and ~/.npmrc) readable in the sandbox. They now restate every default, a test keeps them doing so, and the manifest reference says that a declared list replaces its default (#162).
  • docs/release/update-lifecycle.md listed the state marker, identity session, and runtime credential metadata twice with different text, and left out four state classes. It now has one row per class, which a test checks against the migration check's class list (#162).
  • docs/adapter-sdk.md described AuditSink both as the collector's receiving side and as a sink that delivers onward. It now has one definition (anything a batch is written to, as PiShip's own file and http sinks are) and names the two places a company writes one (#162).
  • The LiteLLM recipe in the enterprise integration contract keyed a broker's LiteLLM user, and with it the budget, on sub alone, so two principals from different issuers with the same subject would share one budget. It now derives the user from the issuer and the subject, as the reference broker already does (#162).
  • The reference READMEs, tests, and the identity conformance kit cited decisions D-01, D-07, and D-11 and sections of a maintainer-local specification, none of which a reader can find; docs/decisions.md numbers its decisions 1 to 30. Each reference now names what it meant, or the public document or test that holds it (#162).
  • The enterprise reference README described four services (there are five, with the broker), did not list the Linux sandbox prerequisites the reference distribution needs (bubblewrap with unprivileged user namespaces, and the Secret Service), and did not say which broker variables (BROKER_DISTRIBUTION, BROKER_AUTHORIZED_PARTY) to change for another app.id (#162).
  • The manifest reference now states what network.allowHosts accepts and matches: exact hostnames or IP literals only, with no wildcard, suffix, port, path, or range, and tests hold both the parser and the destination check to it (#162).
  • The owner workflow's release checklist was the PiShip project's own (its CI workflows, the example distributions, its maintainer's approval). It moved to the maintainer release checklist, and the owner workflow now has a checklist for a company releasing its own distribution (#162).
  • The README now gives the clone URL, a piship init path to start your own distribution, and a complete managed manifest that a test validates on its own (the excerpt before was not a valid manifest); docs/release.md opens with a task table instead of only the former-anchor table (#162).
  • docs/security.md gave the audit rotation size as 10 MB and 60 MB; it is 10 MiB per file (about 60 MiB in all). The roadmap claimed the README quickstart is checked against the current CLI; it now says which documentation checks exist (#162).

v0.7.0

Preview milestone, published as a GitHub pre-release from tag v0.7.0 (commit 4994eee, the squash of #165); not published to npm. v0.7 connects a managed distribution to the identity, credential, gateway, sandbox, and audit infrastructure a company already runs, and adds the adapter SDK, the conformance kits, and the reference stack that test those connections. The manifest and lock schemas stay piship/v1alpha4 and piship-lock/v1alpha4. Release qualification passed on that exact commit (status).

Behavior and contract changes come first, by topic, because they change what an existing command, type, or wire contract does. Added, changed, fixed, and test infrastructure entries follow.

Behavior and contract changes

Sessions, tools, and limits

  • Behavior change: the governed read and edit tools refuse a file over 16 MiB instead of loading it whole. Before, Pi's output truncation applied only after the complete file was in memory, so an authorized read of a huge log or sparse file could exhaust it. The size is checked on the opened file and the read stops one byte past the limit, so a file that grows while it is read cannot pass; the error names bash (sed -n, head -c, grep -n) for reading part of the file. The tools also open a file without waiting for a peer, and refuse a named pipe: before, a FIFO planted in a project blocked a file I/O thread that an abort could not release, and a few of them stopped all asynchronous file I/O of the process (tools).
  • Behavior change: a governed bash or ! command is stopped once it has produced 64 MiB of output, inside or outside the sandbox. Before, Pi copied the complete output of a command to a pi-bash-*.log temp file with no limit, so a runaway command could fill the disk. At the limit the output ends with [output exceeded the 64 MiB shell output limit; PiShip stopped the command. …], the process tree is killed, and the tool reports no exit code; output below the limit and its truncation are unchanged. The temp file is still Pi's and outlives the session, and a full temp filesystem while Pi writes it is not contained (tools).
  • Behavior change: on Windows, the child processes PiShip spawns itself through its governed spawner (MCP stdio servers, for one) run inside a kill-on-close Job Object, so descendants terminate when their leader or PiShip exits. That bounds process lifetime and is not a sandbox: commands the agent runs through Pi's bash tool without a sandbox are started by Pi and are not wrapped (security). Three limits of the Windows start-up path are known and not fixed in v0.7: a child that writes the launcher's startup-failure marker to stderr has that output dropped and its exit reported as a startup failure, the C# helper is compiled at every child start, and the request that carries the child's command and environment travels in one environment variable, so it is bound by Windows' per-variable limit (security).
  • Behavior change: a damaged Pi session is no longer resumed. Before, Pi skipped a line it could not parse, and completed a truncated last record, and the launch continued with part of the conversation silently missing. A launch now checks the project's most recent session before Pi reads it (each line parses, one header, unique entry IDs, parents that exist and form no cycle, compaction, context edit, branch summary, and label references that exist, a complete last record) and stops with CONFIG_UNAVAILABLE, the problem, the line, and the file's path, leaving the file unchanged. See sessions.
  • Behavior change: a most recent Pi session over 64 MiB is no longer resumed automatically. Before, every launch read it to the end, however large, before the TUI appeared. The launch now stops without reading it, with the size and path; the file is kept. The acceptance session of --smoke is the exception to this and the damaged-session refusal above: it holds no user work, so a damaged or oversized one is replaced by a new acceptance session with a notice (the old file is kept), because piship test, dev --smoke, and doctor cannot pass --new-session.
  • Behavior change: <command> --new-session (and --new-session --smoke) starts a new session without reading the most recent one, the way to continue after either refusal above.
  • Behavior change: two launches in the same project no longer append to the same Pi session file. Before, a second terminal resumed the session the first was writing, and the two wrote diverging branches into one file. A launch now holds an owner record for its session file (process ID, start identity, host, and instance, in .piship-owners/ beside the sessions); a launch whose most recent session is owned by another live process starts a new session and says so, and /resume of such a session is refused. A /resume also runs the damaged and over-64 MiB checks above on its target, which before applied only to the session a launch opened. The record of a crashed owner, or of a process ID since reused, is taken over; a record whose process this host cannot verify (another host, or no start identity) counts as live for 24 hours after its last refresh. @piship/core exports processIdentity(), the process start identity the runtime leases already use.
  • Behavior change: Ctrl-C, Ctrl-D, or end of input at the secret prompt (login, sandbox login) or the startup approval prompt settles the prompt. Before, Node exited 13 through an unsettled wait without cleanup: the credential lock stayed behind, the audit log had no session.end, the sandbox was not disposed, and a piped secret hung while the pipe stayed open. Now the secret prompt fails with CREDENTIAL_REQUIRED and releases the lock, a piped secret shows its prompt on standard error and is read up to the first line, and a cancelled approval is recorded as cancelled and treated as a denial (#134).
  • Behavior change: the interactive branded command fails at once with CONFIG_INVALID (" needs a terminal") when standard input or output is not a terminal, before any state, identity session, credential, or sandbox is created, and an unknown option such as -p says there is no non-interactive prompt mode. Before, it started Pi's interactive screen without a terminal and hung until killed, after it had obtained a credential, and the integration docs promised CI, automation, and a headless RPC service. A workload identity runs headless through --smoke, --smoke-model, and the subcommands; a prompt or print mode is not part of v0.7 (#135).
  • Behavior change: a model preference or distribution default that is not allowed or entitled fails with an action that names the recovery: <command> config unset model when the model came from the preference, otherwise <command> --model <model>, and <command> models to list the choices; models lists even while the preference is stale. Before, every launch and models failed with MODEL_UNAVAILABLE or MODEL_DENIED and no hint. There is no automatic fallback (#137).

Install, update, rollback, and uninstall

  • Behavior change: installed launchers now hold version-specific runtime leases. Updates retain payloads still used by live sessions, recovery reclaims them after the session exits, and uninstall refuses while a runtime remains active. Doctor reports live and stale leases.
  • Behavior change: on Windows, piship update <id> and piship rollback <id> (and config explain, doctor, dev, and test) forwarded their arguments to the branded command through cmd.exe unquoted, so a --from path with spaces was split and & | ^ % in a value were interpreted by the shell. Node now runs the release's command script directly and every argument arrives unchanged.
  • Behavior change: uninstall <id> --purge --yes uninstalls and purges in one command. A user who installed from a release and deleted the download had only the installed manager, which uninstall removes, so the separate purge that must follow had nothing to run it. The referenced secret-store entries are deleted first, under the uninstall's locks and while the release is still installed; a secret that cannot be deleted fails the command before anything is removed, so it can be run again. Every other refusal (a live runtime session, a changed install) is checked before the first secret is deleted, and the command refuses while a runtime is active as plain uninstall does. Plain uninstall still keeps state.
  • Behavior change: a first install killed between creating apps/<id> and writing its ownership marker left an unmarked directory that the next install reported as a collision and that uninstall could not remove, since no receipt existed. The directory now appears with its marker in one rename, so the next install always recognizes and finishes an interrupted one; one killed after its shim but before removing the marker is completed instead of reported as a collision. A collision with a directory or shim PiShip has no record of now names that path instead of suggesting uninstall.
  • Behavior change: PiShip refuses state, install, and bin homes that are equal or nested in one another (compared as real paths, so symlink aliases and, on macOS and Windows, case differences count) with CONFIG_INVALID, at install, uninstall, purge, update, rollback, and launch. Before, PISHIP_STATE_HOME=<install-home>/apps made a plain uninstall delete the state it promises to keep, and a bin home inside one distribution's state let its purge delete other distributions' commands.
  • Behavior change: the rollback and update migration check crashed with a TypeError when the target release's lock did not list a state schema key the current release has; a missing key now means the target reads none of that class, which is then cleared (credential classes) before the switch.
  • Behavior change: the atomic writer of the install receipt, the state marker, identity, principal, and discarded-marker records, and preferences writes every byte before it flushes and renames: a write that takes only part of the bytes is continued, and one that makes no progress fails. Before, one low-level write was assumed complete, so a short write under disk or quota pressure could publish a truncated receipt, which the launcher then cannot read. Any failure before the rename (a write, flush, or rename error) now keeps the previous file byte for byte and removes the temporary; it used to be left behind. Temporaries are named <file>.p<pid>-<random>.tmp. One a killed writer left is never read as state and is removed when the distribution next starts (every atomically written state file, including the credential metadata, the pending-revocation record, the discarded markers, local metrics, and the file secret store), once its process no longer exists or it is ten minutes old, and a live writer's is kept; the next update or rollback removes the receipt's. logout removes every identity, credential, and file-store temporary under the credential and identity locks before it reports the credentials cleared, so a crash can no longer leave a copy of signed-out identity metadata (subject, issuer, email, claims) or a file-store secret behind; the principal binding and the pending-revocation record, which logout keeps, lose only abandoned temporaries.
  • Behavior change: once the install receipt has switched, update and rollback report the activation even when the state marker cannot be written afterwards (a full, read-only, or failing state filesystem): the command succeeds with a notice (" is active, but its state marker could not be written (...); the next update or rollback repairs it"), runtime.update or runtime.rollback is audited as allowed, and the lifecycle metric counts ok. Before, the marker write threw after the commit, so the command reported UPDATE_FAILED or ROLLBACK_FAILED, audit recorded a denial, and the next launch ran the release the command said was not activated. Removing the staging directory after the commit no longer fails the command either. The marker keeps its previous content and the next update or rollback repairs it before comparing state.
  • Behavior change: the lifecycle lock (apps/<id>/.lifecycle.lock) is a lease: it records piship-lifecycle-lock/v1 with the holder's process ID and a random instance ID, the holder refreshes its modification time every 15 seconds, and a lock not refreshed for 24 hours by the wall clock is stale even when a process with its ID exists. Before, the lock held only a process ID and counted as held while any process had that ID, so after a crashed update the ID's reuse by an unrelated process blocked update, rollback, and uninstall for as long as that process lived. A lock whose process is gone is still taken over at once; a stale lock is moved aside and checked before it is deleted, so two waiters cannot remove each other's new lock; and a holder removes only its own lock. A lock written by an earlier PiShip (a bare process ID) is handled the same way. What the lease depends on: for a live process ID it depends on the wall clock, because each command is one attempt in a fresh process with no earlier observation of the lock to compare with (the credential lock's monotonic rule needs a waiter that watches it). The 24 hour bound keeps a forward clock correction, a resumed VM, or a sleeping machine from letting a second operation take a running holder's lock; only a jump or sleep of more than a day while the holder cannot refresh (a launch check blocks its refresh for up to five minutes) still does, and that holder then aborts at its commit. A crashed holder's lock whose process ID is in use therefore blocks the next operation for up to a day; the error names the lock file, which can be removed by hand.
  • Behavior change: update and rollback write the install receipt only while the lifecycle lock still names them. Before, a holder never checked that it still held the lock, so one whose lock another process had taken over (after a clock jump of more than a day while it could not refresh, or a lock removed by hand) went on while a concurrent uninstall or rollback ran, and two lifecycle operations ran at once. Now the holder re-reads the lock right before it writes the receipt, and when another process took it over (or it was removed) nothing is written and the operation fails with a retryable UPDATE_FAILED or ROLLBACK_FAILED; it also leaves the release directories alone, because the operation that holds the lock now owns them. The check and the write are two steps, so a takeover between them is not caught. A lifecycle lock that exists but cannot be read is judged by its modification time alone (24 hours), and a directory or symlink at its path is never removed; before, such a lock made every operation fail with Could not lock. The busy error names the lock file.
  • Behavior change: the credential metadata, its discarded marker, the pending-revocation record, and the file secret store's entries use the same atomic writer as the records above, which now lives in @piship/credentials (core imports it). Before, they were written with one low-level write and renamed without a flush, and a failed write left its temporary behind, so a short write or a power loss under disk or quota pressure could publish a truncated credential metadata or secret file, which then reads as damaged. They now get the same guarantees: every byte is written and flushed before the rename, and any failure before it keeps the previous file and removes the temporary. Their temporaries are named <file>.p<pid>-<random>.tmp like the others; the start-up sweep and logout recognize both names.
  • Behavior change: a migration snapshot is built in a .staging-p<pid>-* directory, its snapshot.json written last and read back, flushed, and published by one rename, and is named <sequence>-<from>-to-<to>; snapshot.json gains a sequence. Retention keeps the last three completed snapshots by that sequence. Before, the snapshot directory was created in place and retention sorted directory names, which began with the wall-clock time: a snapshot interrupted by a crash, a full disk, or an I/O error stayed behind and counted as one of the three, displacing valid ones, and after the clock was set back the newest snapshot sorted first and was deleted. A failure now removes the staging directory, and each snapshot removes staging directories of a process that no longer exists (or older than ten minutes) and snapshot directories without a valid snapshot.json (a snapshot whose manifest names another schema, which a newer release wrote, is kept and does not count toward the three). Snapshots written before sequences count as older than every new one.
  • Behavior change: the temporary directories PiShip makes are reclaimed after a hard termination. Before, every workflow that made one (piship-sandbox-*, piship-verify-*, piship-launch-check-*, piship-release-test-* under the OS temp directory; .piship-<id>-* and the release staging beside a build's output; the install and update .staging-*; the sandbox probe's .piship-probe-*) removed it only in a finally, dispose(), or exit hook, which SIGKILL, a machine reset, or power loss never runs, so extracted payloads, throwaway runtime state, and a session's tool output accumulated for good (only apps/<id> was cleaned, by the next update or rollback). Now each is created private (0700) with an owner marker (.piship-owner, piship-temporary-owner/v1: kind, name, process ID, instance ID, host token, creation time) that the owner refreshes every 15 minutes, and the branded launcher, archive verification, piship release (for its tests), install, update, rollback, and every sandbox activation remove a directory of the OS temp directory, the install home, or apps/<id> whose owner process no longer exists. A directory whose owner ID exists is removed only when its marker was not refreshed for 24 hours (a reused process ID; a sleeping machine or a forward clock jump of over a day displaces a live owner's directory), and one from another host only then. Never removed: a running process's directory, one without a valid marker (a directory that merely shares a name prefix, and every directory an earlier PiShip made, however old, which have no marker), a link, another user's directory, a directory on another filesystem, and nothing under a mount point. doctor reports a count when abandoned directories resist removal.
  • Behavior change: the staging of killed piship build, dev, test, and piship release runs in their output directory (dist/, dist/releases) is reported, not removed, unless you pass --reclaim-staging to piship build or piship release. The output directory usually lies in the project, which a sandboxed command may write, and the owner marker is plain JSON any process can forge, so a start-up sweep there would let such a command plant a directory for PiShip to remove; the next build or release instead prints how many abandoned staging directories it found (never their paths) and how to remove them. The removal itself does not trust names: it records each abandoned directory's device and inode, moves the directory to a fresh name and requires it to be the same real directory (for a sandbox session its tmp is moved first), and removes it with rm -rf --one-file-system (-x on macOS), which never follows a link. Where rm is not usable (Windows, BusyBox) a portable walk re-examines each directory before it acts on what it listed. A removal killed after it moved the directory leaves a .piship-reclaim-* directory, which a later sweep removes once it has not changed for 10 minutes. An owner's own removal of a scratch directory retries for a moment and, if it still fails, leaves the directory for a later sweep instead of failing the build, release, verification, or launch check it belonged to. The sandbox probe does not sweep the session temp directory, which contained commands write.
  • Behavior change: the sandbox session's temp directory holds an owner marker and the sandbox's TMPDIR in a tmp subdirectory (<os temp>/piship-sandbox-*/tmp); the marker sits outside tmp, which contained commands can write in. Before, TMPDIR was the piship-sandbox-* directory itself. piship build assembles the payload in a payload subdirectory of its staging directory, beside the marker, and piship release stages in .piship-release-<id>-* (it was .<name>-*), assembles the payload straight into it, and keeps its fixed directories as .release and .audit. The names are short on purpose: Windows cannot run an npm install script in a directory longer than 260 characters, and the deepest such directory is inside the build's staging directory, so a release output path of about 100 characters or less is the limit there. A change of paths only; the payload, the release, and their inventories are unchanged.
  • Behavior change: a damaged or path-mismatched install receipt no longer traps a distribution. Before, launch said reinstall, reinstall said uninstall first, uninstall failed with a raw JSON parse error, purge said uninstall first, and another app's damaged receipt blocked every install. Now receipt errors are CONFIG_INVALID and name the file, the failed check, and the PISHIP_INSTALL_HOME or PISHIP_BIN_HOME value to restore; uninstall removes such a distribution using only paths derived from its id under the current homes; and another app's damaged receipt blocks an install only when its remaining text names the command (#130).
  • Behavior change: purge and uninstall --purge refuse, before deleting anything, while a distribution is signed in (an identity session, a runtime credential, or a stored sandbox credential), and say to run <command> logout first (<command> sandbox logout when only a sandbox credential is stored). Before, they deleted the local secrets and exited 0 without revoking anything, so the credential stayed live at the broker with no warning. --without-logout purges anyway for a user with no release left to run logout, and warns that the credential stays live until it expires (#151).

Locks and clocks

  • Behavior change: the credential and identity lock and the audit rotation lock no longer decide ownership from how old the lock's mtime looks against the wall clock, apart from the audit rotation lock's one hour bound described below. A waiter takes a lock over only after it has seen the same holder token with the same mtime for the stale interval (75 s for the credential and identity lock, 30 s for the audit rotation lock) on the monotonic clock, and the caller's 90 s wait is monotonic. The audit rotation lock alone has two more ways out that need no waiter that watches, because its holder keeps it for milliseconds: the process in its token no longer exists on this host, or its mtime is over an hour old by the wall clock (a reused process ID). Before, a forward clock jump (NTP correction, a manual change, a resumed VM) let a second process break a live holder's lock and run a credential refresh or a rotation concurrently, and a backward jump kept an abandoned lock and stretched the wait indefinitely. A crashed holder's lock is now recovered after the stale interval whatever its mtime says; one left behind an hour "in the future" was never recovered. CredentialManagerOptions.lockTiming and AuditRotation.lockStaleMs set the intervals (tests shorten them); withFileLock moved to its own module and is still exported from @piship/credentials.
  • Behavior change: a credential or identity lock that exists but cannot be read no longer makes a waiter spin. Before, a lock file the waiter could not read (a root-owned one left by a crashed sudo <brand> login) or a directory or dangling symlink at the lock's path made openSync fail with EEXIST while the lock could not be observed, and the wait loop went round again before its deadline check and its pause: 100% CPU with no timeout. Now every pass checks the deadline and pauses. An unreadable lock file is judged by its modification time alone (taken over after 75 s without a change on the monotonic clock, never while its holder keeps touching it), and a directory or symlink at the lock's path is never moved or deleted: the waiter fails with retryable CREDENTIAL_ACQUIRE_FAILED after its 90 s wait.
  • Behavior change: the audit rotation lock of a crashed process is recovered by the next writer, however short-lived. Before, what a writer had seen of a lock lived in process memory, so only a process that tried to rotate repeatedly for 30 s took it over; short-lived commands never did, and audit.jsonl grew past its 10 MB limit until a long session broke the lock. Now a lock is also taken over at once when the process ID in its token (<pid>-<host id>-<random>) no longer exists on this host, and when its mtime is over an hour old by the wall clock (a reused process ID: a rotation takes milliseconds, so no live rotator holds a lock that long). A token from an earlier PiShip has no host id and is read as this host's. A process ID from another host (a state directory shared between hosts) is never read as dead. A lock that cannot be read is judged by its mtime alone, and a directory or symlink at its path is never touched; before, an unreadable lock was never recovered. A lock whose process ID is in use and that is younger than an hour still waits for the 30 s watch.
  • Behavior change: the 30-minute validity of a workspace check counts the time elapsed on whichever of the monotonic clock and the wall clock advanced more, and the sandbox-to-host propagation wait uses the monotonic clock alone. Before, both used the wall clock: a system clock set back could keep a verified shared or synchronized workspace trusted past 30 minutes or stretch the propagation wait, and one set forward cut the wait short. The monotonic clock stops while the machine sleeps, so the wall clock still counts for validity: a check made before a suspend does not outlive it. A clock set forward cannot be told apart from a suspend and makes the next command check again early. verifiedAt is still the wall-clock time.
  • Behavior change: a Kubernetes SandboxClaim's lifetime no longer shrinks when this machine's clock is set back. PiShip takes the wall clock and the monotonic clock when it creates a claim and counts the time since on whichever advanced more; that count decides when renewal is due, when the claim counts as expired, and the renewed shutdownTime (creation time plus the count plus the lifetime), which is never earlier than the one the cluster last accepted. Before, a clock set back made a renewal send a shutdownTime earlier than the one already committed while PiShip kept counting on the later one, and a clock set forward extended the claim by a whole jump at every renewal. The monotonic clock stops while the machine sleeps, so the wall clock still counts: after a suspend the claim is renewed or, past its shutdownTime, replaced, and a keepalive that fires late never revives it. A clock set forward counts as elapsed time: renewal comes earlier and a renewal lengthens the claim by at most the jump. The readiness wait uses the monotonic clock alone. Client and cluster clocks must still roughly agree (kubernetes-agent-sandbox).
  • Behavior change: on macOS the process start identity in runtime leases and session owner records is read with ps in UTC and the C locale and recorded as UTC seconds. Before, it was the ps -o lstart= text in the caller's time zone and locale, so an update or rollback from a shell with another TZ or LANG could judge a running session's lease dead, delete the payload it ran from, and take over its session owner record. A record still holding the earlier text counts as unverified, live for 24 hours after its last refresh, never as dead (#126).
  • Behavior change: a credential lock left by a process that died on this host is broken at once, and a waiting command says what it waits for. Before, a lock left by a killed sign-in or refresh made the next launch wait about 76 seconds in silence, and a hung holder made other terminals fail after 90 seconds with a message naming no process. Now the lock records the holder's PID and host name; a same-host holder that no longer exists is taken over through the same move-aside as before, and locks from other hosts or older versions keep the 75-second rule. After about 2 seconds the waiter prints once which lock is held and by which process, and the timeout error names it too (#133).

Identity and users

  • Contract change: @piship/contracts adds the normalized principal: PrincipalKey (issuer and subject), principalKey, samePrincipal, principalId (one unambiguous string for attribution), and principalDigest (a directory-safe digest). Email, display name, and username stay attributes. Credential metadata (piship-credential-metadata/v1, additive) records the principal its credential and entitlement were issued to, and @piship/identity adds assertSamePrincipal.
  • Behavior change: a runtime credential is used only by the principal (iss, sub) it was issued to. Before, credential metadata named no principal, so user B signing in after user A, or over A without a logout, could launch on A's credential and entitlement; login also stored B's identity before it cleared A's credential and only printed a notice when that failed, so a crash or a store failure in between left B's identity with A's credential. Now a credential bound to another principal, or to none while an identity is signed in, is revoked where supported (credential.revoke with reason principal-change) and deleted before anything uses it. login deletes the previous credential and confirms the deletion before it stores the new identity; on a change of principal it also revokes and deletes the previous identity session and clears the model selection (model and models.allowed in preferences; other preferences stay). A new identity/principal.json records the principal that owns the state; logout keeps it, so a change of user after a logout is recognized, and identity.login events carry principalChange: true when the user changed. In a distribution with an identity provider, credential metadata written before this release has no principal and is replaced once at the first launch or login (without an identity provider it is kept as it is): a broker or adapter credential is reacquired automatically, and a local-secret distribution with identity asks for its key again at login.
  • Behavior change: an identity refresh that returns another issuer or subject fails with IDENTITY_INVALID for identity adapters as well as the built-in OIDC provider, and the stored session is kept; before, an adapter's refresh could replace the signed-in user. With identity configured, a request-time credential now also requires a signed-in identity. Provider messages copied into renewal, revocation, and logout problems are redacted.
  • Behavior change: Pi session history is kept per signed-in principal in a distribution with an identity provider: sessions live in sessions/user/<principal digest>/ (and sessions/acceptance/<principal digest>/), so a different user signing in on the same OS account no longer resumes the previous user's conversations, which can hold private project content, while the same user still resumes across logout, login, update, and rollback. Sessions an earlier release wrote directly under sessions/user/ are not resumed by a signed-in user (purge removes them); a personal distribution with no identity keeps the shared directory.
  • Behavior change: the entitlement of a runtime credential is re-read after the gateway denies a model. Before, a credential without expires_at was never renewed, so the models the broker returned at acquire stayed in force until the next login: a model the organization added was not offered and a withdrawn one was still offered. DistributionAccess.refreshEntitlement() now renews a broker or adapter credential through the existing refresh path after a gateway 403 (MODEL_DENIED), at most once per credential generation, so a denial of the renewed credential re-issues nothing; a failed renewal keeps the current credential. The new entitlement applies from the next launch and, like every entitlement, only narrows the distribution allowlist. login still re-reads it, since it always replaces the credential. New tests pin the model catalog rules: the allowlist is the ceiling, the entitlement, the live gateway listing, and a user preference only remove models, an enforced model does not override a narrower entitlement, and allowlist A, B with entitlement B, C and gateway B, C, D yields exactly B.
  • Contract change: a managed distribution can run headless, with a workload identity instead of a person. An identity adapter (identity.mode: adapter, no schema change) that declares interactive: false supplies the session: every launch, doctor, and login obtains it from the adapter's login() without a stored login, gives the adapter an openUrl that fails the run, holds the session in memory only (no identity/session.json, no identity secret), obtains it again when it expires within 60 s or the broker answers 401, and refuses a new session for another principal inside one process (IDENTITY_INVALID) or an already expired one (IDENTITY_EXPIRED). Before, activation always needed an interactive login with a stored session, so a managed distribution could not run in CI or a worker. The workload principal goes through the same credential binding and no-residue rules as a person: when a run's workload principal differs from the previous run's, the previous credential is revoked where supported and deleted, its model selection cleared, and a stored identity session of another principal deleted (confirmed, or the run fails closed). identity.login and identity.refresh events carry workload: true. @piship/identity adds WorkloadIdentityProvider and isWorkloadIdentityProvider, and an identity adapter whose interactive is neither a boolean nor absent is now refused with CONFIG_INVALID. Adapters that do not declare interactive: false keep the interactive path unchanged. A new E2E runs such a distribution with no browser opener, no authorization page, planted personal credentials (provider variables and Pi auth.json) that are never used, credential rotation and expiry, managed model and network policy, and a change of workload principal. The identity, credentials, enterprise integration, and security docs describe what a workload adapter must implement, which credential store works on a headless runner (a Linux runner without a Secret Service needs the acknowledged plaintext file store; there is no memory-only option), and what a headless run can and cannot prove.
  • Contract change: the @piship/credentials, @piship/core, and @piship/identity packages (internal to PiShip, exported for the CLI and the launcher) changed: LifecycleOptions.deleteSecret is replaced by secretStore, PurgeResult.problems is removed, and CredentialManager.exclusive(), holdsFileLock, isLockTimeout, CredentialPhase, REVOCATION_RETRY_LIMIT, PendingRevocations.dropped, DistributionAccess.usesWorkloadIdentity(), DistributionAccess.markCredentialRejected() taking the credential the request used, writeIdentityDiscardedMarker, the refusal wording parameter of assertSamePrincipal, and the revoke reason unbound are added. AccessOptions.onPhase accepts AccessPhase. Audit events gain the reasons credential.refresh: entitlement and identity.refresh: age. No Pi public API is used differently.
  • Behavior change: audit events name the principal as principalId gives it (issuer, #, subject) instead of the bare subject, in a governed session and at login, logout, and the sandbox credential commands, so users with the same subject at different issuers are no longer attributed alike.
  • Behavior change: a running session is pinned to the principal it activated (or signed in) as. Before, a session kept serving its cached runtime credential after another user signed in elsewhere (still usable at the gateway when its revocation had failed or was unsupported), and a renewal in it, including the one after a gateway rejection during activation, returned the credential just issued to the other user while audit still named the first. Now the cached secret is used only while the stored credential is the same generation and bound to the pinned principal, and a different signed-in user fails the call with IDENTITY_REQUIRED ("The signed-in user changed; restart the session").
  • Behavior change: login runs from clearing the previous credential to acquiring the new one under the credential lock, and writes the identity under the identity lock; logout takes the same locks in the same order. A launch or renewal re-checks the stored identity under the credential lock before it touches a credential. Before, a launch that had read user A's identity while user B signed in could discard B's new credential and store one issued to A, and a refresh racing a login could leave B's session file naming A's tokens.
  • Behavior change: an identity refresh never refreshes or returns a stored session of another principal (IDENTITY_INVALID, "Another identity signed in; run login again") or a session signed out meanwhile; before, it could spend the other user's refresh token inside the first user's process.
  • Behavior change: a missing identity/principal.json counts as a change of principal unless the stored session is the signing-in principal's, so a user signing in after another user signed out under v0.6 no longer inherits that user's model selection; a signed-in user upgrading keeps theirs.
  • Behavior change: a workload identity adapter's login() has 30 s (combined with the caller's signal); a slower one fails the run with a retryable GATEWAY_UNREACHABLE. What the adapter throws becomes IDENTITY_INVALID without its message, which could quote the token file; an adapter that caught the refused openUrl and returned a session anyway fails the run; a session without expiresAt is refused; a session is obtained again after five minutes even when valid longer; concurrent callers share one call; and a workload adapter's logout is never called (it used to receive a stored person's tokens). doctor reports a workload identity as "workload identity, obtained per run" instead of failing with "not signed in".
  • Contract change (additive): @piship/contracts now defines RETAINED_CLAIMS, the claim allowlist, and WorkloadIdentityProvider, which @piship/identity still exports unchanged. The adapter SDK re-exports both, and PrincipalKey, principalKey, and samePrincipal, so an adapter author can check a session's principal the way PiShip does. The identity kit's IDENTITY_ALLOWED_CLAIMS is now that same list instead of a copy.

Credential providers, the broker, and gateway errors

  • Contract change: a new error code, CREDENTIAL_DENIED, reports an authorization denial (HTTP 403) from the credential broker, for acquire, refresh, and revoke. A 403 used to share CREDENTIAL_ACQUIRE_FAILED with transport failures. Broker errors also carry a non-secret detail with operation (acquire or revoke), reason (unreachable, timeout, cancelled, authentication, denied, rate-limited, unavailable, rejected, or contract), and the HTTP status when the broker answered, so callers can tell the retry classes apart where they share a code.
  • Behavior change: a failed credential renewal keeps the failure's retryable, retryAfterMs, and detail. It used to be rewrapped as CREDENTIAL_EXPIRED or CREDENTIAL_REVOKED that were never retryable, so a broker outage or rate limit during renewal read as "sign in again".
  • Behavior change: a caller's CredentialContext.signal now composes with the broker's 30 s timeout instead of replacing it, so a signal that never fires can no longer leave a broker request waiting forever. A cancelled request is reported as a non-retryable cancellation instead of a retryable timeout, and the timeout now also covers reading the response body.
  • Behavior change: broker revocation goes through the same transport as acquisition: it has the same timeout and cancellation, a transport failure is a retryable CREDENTIAL_REVOKED instead of an uncoded error, a 403 is CREDENTIAL_DENIED, and a 429 or 5xx carries Retry-After. A broker 5xx on acquire also carries Retry-After now. A network or TLS policy refusal from the managed fetch keeps its own code (NETWORK_DENIED, TLS_POLICY_VIOLATION) instead of becoming a retryable CREDENTIAL_ACQUIRE_FAILED.
  • Behavior change: the credential broker client no longer builds an error from a transport error's own message, which for an invalid header value can contain the token that was being sent; it reports the system error code (ECONNREFUSED) or network error, and the managed fetch does the same. The answer status now decides the classification before any body is read, so a broker that answers 403 and never finishes the body is CREDENTIAL_DENIED and not a timeout, a success body larger than 64 KiB is a contract failure, and an early renewal that the broker refuses with 403 fails with CREDENTIAL_DENIED instead of continuing on the credential that is still valid.
  • Behavior change: an OIDC token endpoint 5xx (including a proxy's HTML error page) or a server_error / temporarily_unavailable response now fails with a retryable GATEWAY_UNREACHABLE, and a 429 with a retryable GATEWAY_RATE_LIMITED, both with the server's Retry-After. They used to fail with a non-retryable IDENTITY_INVALID ("Contact your administrator"). invalid_grant is still IDENTITY_EXPIRED.
  • Contract change: CredentialContext adds an optional idempotencyKey, and the http-broker acquire request sends it as an Idempotency-Key header. The key is a random UUID per logical acquire or renewal, never derived from the identity or a token, which PiShip keeps until the request is resolved (see the durable key below). Before, a credential request carried nothing a broker could use to recognize a retry, so retrying after a lost answer could issue a second credential. Broker acquire errors carry the key in detail.idempotencyKey, and a failure without an answer (timeout, unreachable, cancelled) carries detail.outcome: not-sent when the request never left PiShip, unknown when the broker may already have issued a credential. A 409 or 422 to an acquire that sent a key is a non-retryable CREDENTIAL_ACQUIRE_FAILED with reason: idempotency-conflict; a 409 or 422 without a key is still rejected. A caller key that is not 1 to 255 visible ASCII characters is refused before anything is sent. Apart from one repeat with a refreshed identity token after a broker 401, PiShip retries no credential request automatically; the enterprise integration contract now tells broker authors how to honor the key and which failures are safe to retry.
  • Behavior change: the http-broker client accepts only visible ASCII in the identity access token it sends and in the credential the broker returns, and only 1 to 256 characters from A-Za-z0-9._:- in credential_id; anything else is refused before it is sent or stored (IDENTITY_INVALID for the access token, a contract failure for the answer). Before, a credential with a control character or a space was stored and then failed every request as a retryable network error, and any string was accepted as a credential ID, which is shown, audited, and sent back on revoke. The same rule now applies to every credential provider: a runtime credential an adapter or the local-secret provider returns, as a string or a SecretValue, that holds a space, a control character, or a non-ASCII character is refused with CREDENTIAL_ACQUIRE_FAILED before it is stored.
  • Contract change: a credential adapter's credentialId must be 1 to 256 letters, digits, ., _, :, or -, as the http-broker client already required. The ID reaches notices, audit events, the revocation retry record, and doctor, so any other value now fails the acquire with CREDENTIAL_ACQUIRE_FAILED for every provider; an adapter that returned an ID such as key/1 or an email address must return a plain identifier or none.
  • Behavior change: the local-secret provider rejects any entered character a request header cannot carry (a non-ASCII character, for example) at entry, with the same CREDENTIAL_ACQUIRE_FAILED as a short or spaced value.
  • Behavior change: the idempotency key of an http-broker or adapter acquire or renewal is now durable. Before, CredentialManager generated a new key for every attempt, and a caller had to pass the failed attempt's key back itself, so a broker that issued a credential whose answer was lost, followed by a PiShip crash, a restart, a second login, or a plain retry, issued a second credential and left the first unused. The key is now recorded in credentials-metadata/pending-issuance.json (piship-credential-issuance/v1: key, mode, kind of request, a hash of the destination, principal, the credential it renews, creation time; never a secret; 0600, atomic, under the credential lock) before the request is sent, nothing is sent when it cannot be recorded, and every later attempt of the same logical request sends it, in any process, until the credential is committed, the broker gives a final answer (403, 409 or 422, another 4xx except 401, a contract violation), the principal or the destination (broker endpoint, adapter configuration) changes, or the user logs out (also without the runtime variables) or purges. A login of the same principal keeps it; a renewal after a gateway rejection and an entitlement re-read never repeat another kind of request's key; a caller-supplied key never replaces a pending one. Logout or a change of principal with an unresolved request leaves a credential the broker may have issued valid until it expires. A record whose credential was committed by a process that stopped before removing it is recognized (the stored credential is no longer the one it renews) and dropped before anything else, so its key is never sent for a later request. A later renewal after a commit still gets a new key. credential.acquire and credential.refresh audit events carry idempotencyKey, and resumed: true when the key was repeated.
  • Contract change: the broker contract now requires that repeating an Idempotency-Key returns the same issuance or an equivalent safely recoverable result, never a second credential, that the broker scopes keys to the principal, and that it keeps a key at least as long as its credential stays valid or 24 hours, whichever is shorter (the reference broker falls short on restart, past its per-principal record cap, and on a 502, as its README now says). A pending key 24 hours old or more, or dated more than a minute ahead of the clock, is never replaced by a new key: the acquire or renewal sends nothing and fails closed with CREDENTIAL_ACQUIRE_FAILED (component credential), asking the user to check the broker for the key and revoke a credential it issued, then run the branded logout and login commands; only logout removes the record. There is no capability negotiation: a broker that ignores the key still gets a duplicate after an ambiguous failure, and the docs say so. @piship/credentials exports CREDENTIAL_ISSUANCE_SCHEMA, CREDENTIAL_ISSUANCE_FILE, ISSUANCE_RETENTION_MS, PendingIssuance, and IssuanceBase; CredentialManagerOptions gains issuancePath, issuanceRetentionMs, and issuanceTarget; IssuanceRequest is exported; CredentialManager gains pendingIssuance(), and logout() takes keepIssuanceFor (a principal whose pending request survives a replacing sign-in); AccessStatePaths gains credentialIssuance. A credential adapter receives the durable key as CredentialContext.idempotencyKey as before, and should report an ambiguous failure as retryable or with detail.outcome, since any other PiShipError ends the request. The lock's runtime.stateSchemas gains the credentialIssuance key, a credential class: an update or rollback to a release without it clears the record, as does any switch that clears the runtime credential, and no snapshot holds it. Clearing it never deletes a secret: update and rollback take no secret reference from a metadata-only class.
  • Behavior change: the credential broker's unreachable error names the system error behind the managed fetch's code: the message is The credential broker is unreachable (ECONNRESET) (text that a script or test matching the old message must accept), and sanitizedDetail.transport holds the code. Contract change (additive): transport is a new field of the error's sanitizedDetail, present only when the connection failed with a system error. Before, the message ended at (GATEWAY_UNREACHABLE), so the cause the managed fetch keeps at the end of its own message (ECONNRESET, ECONNREFUSED) was dropped, which hid the reason of a Windows failure in CI. Only a bare uppercase system code is taken, never the host, a URL, a header, or a body; a failure without a code reads (network error). Nothing is retried. The managed fetch's GATEWAY_UNREACHABLE, the code it gives every transport failure, is no longer part of the message: it named the inference gateway although the broker was down. Another PiShip code, such as NETWORK_DENIED, is still shown, and the error codes are unchanged (CREDENTIAL_ACQUIRE_FAILED, and CREDENTIAL_REVOKED for a renewal after a rejection).
  • Behavior change: PiShip tells a model provider's refusal that LiteLLM relays from the gateway's own. Before, an upstream 401 (a provider key the gateway holds is wrong) was read in a session as the user's credential being rejected, so the credential was marked and a new key was minted at every start although the user's key was fine; and an upstream 403 was read as a model denial, so it re-read the entitlement and minted a new key. A 401 or 403 whose error message starts with litellm. (LiteLLM wraps a provider's exception as litellm.<ExceptionClass>) now leaves the credential and the entitlement alone, and classifyGatewayStatus (which takes the answer body as a new optional third argument) maps it to GATEWAY_UNREACHABLE, not retryable, naming the provider; the gateway's own refusals (auth_error, token_not_found_in_db, expired_key, key_model_access_denied) and any other gateway's 401 and 403 keep CREDENTIAL_REVOKED and MODEL_DENIED. A 429 without Retry-After now takes its retry time from LiteLLM's llm_provider-retry-after, at most one hour; before, the provider's retry time was lost. @piship/core re-exports classifyGatewayStatus, gatewayErrorBody and isUpstreamProviderError for the Pi seam. The gateway evidence test asserts the new behavior against LiteLLM v1.103.0.
  • Behavior change: a failed --smoke-model request is reported with the code of the gateway's answer. Before, every failure was GATEWAY_PROTOCOL_ERROR, so a gateway outage (503), a rate limit, or a rejected credential read as a protocol problem. When Pi's message carries the status and error body, the failure is classified as the model list check classifies that status (GATEWAY_UNREACHABLE for a 5xx or a provider refusal relayed by LiteLLM, GATEWAY_RATE_LIMITED, CREDENTIAL_REVOKED, MODEL_DENIED), with its retry flag (Pi's message carries no headers, so no retry time); a stream that failed after it started, an aborted request, or a message without a status stays GATEWAY_PROTOCOL_ERROR.
  • Behavior change: an unresolved credential request older than the broker's 24-hour idempotency retention, or dated ahead of the clock, no longer blocks launches that can use the stored credential. Before, every launch failed with CREDENTIAL_ACQUIRE_FAILED and asked the employee to reconcile with the broker. Now only a request that would send a new credential fails; the error names the pending key (detail.reason: issuance-retention) and says to run logout then login, and doctor shows the pending request (#128).
  • Behavior change: an http-broker answer with null in an optional field (credential_id, expires_at, models, base_url, subject) is read as if the field were absent. Before, the acquire failed as a contract violation (and subject: null as a credential for another subject), the error did not name the field, and every retry minted another broker credential. A non-null subject must still match the identity, and a contract failure names the offending field, never its value, in the message and in sanitizedDetail.field (#131).

Secret stores, sign-out, and stored state

  • Behavior change: the Linux Secret Service store now splits a value whose stored form exceeds 8000 characters across part items named <ref>+<n>, with chunks:<count>:<write> in the primary item (the macOS and Windows stores use chunks:<count>), and each part carries the parent and write attributes, and fails when secret-tool warns password is too long. secret-tool store keeps only the first 8192 bytes of a secret and still exits 0, so a value of about 6 KB or more, such as an OIDC token bundle with large tokens, was stored truncated without an error. Values of 8000 characters or fewer are stored and read exactly as before. The CI check job on Ubuntu now installs GNOME Keyring and runs the live secret-store test against it on every pull request, next to Keychain on macOS and Credential Manager on Windows; the Linux store had only a command-runner test before, which is why the truncation went unnoticed. New tests also pin that the Linux store fails closed, with no plaintext fallback, when secret-tool cannot reach a session bus. Each part item now carries its primary's reference as a parent attribute, so put clears every earlier part before it writes, a failed put leaves no part behind (an earlier chunked value it could not replace is removed rather than served half old and half new; an earlier plain value stays), get fails closed on a missing or extra part, and delete clears parts by attribute and reports an error when the item or any part is still there afterwards.
  • Behavior change: on Linux with the system store, a rollback to a release before v0.7 (a lock without runtime.stateSchemas.credentialIssuance) clears the identity session, the runtime credential, and the stored sandbox credential before the switch, deleting every part and confirming it; the target signs in again. Before, the migration check kept them, because their metadata schemas did not change: the older release read the chunks:<count>:<write> primary as the secret, so the identity was unusable, and its sign-out deleted only the primary and left the parts in the keyring. A deletion that fails stops the rollback before activation with SECRET_STORE_UNAVAILABLE (decision 30).
  • Behavior change: the Linux Secret Service store treats a locked keyring as an error. secret-tool lookup answers a locked keyring with exit 1 and no message, exactly as it answers a missing item, so a locked keyring read as an absent secret, a delete reported success while the items stayed, and the caller then cleared the metadata that pointed at them. A miss is now confirmed with secret-tool search. Each stored part also carries the id of the write that made it, so two writers of one reference can no longer leave a value made of both, and a reference may not start with a dash.
  • Behavior change: when revoking a credential, a secret store that cannot be read is a failed revocation with a problem entry (revocation: the stored credential could not be read (<code>)), so logout shows it, and an update or rollback that clears a credential the target cannot read warns that it was not revoked remotely. It used to be a silent skipped, which hid a credential that could still be valid at the broker. A store that holds no secret for the credential is still skipped.
  • Behavior change: a secret-store deletion is confirmed by reading the reference back, and a deletion that fails or cannot be confirmed (a locked keyring) is an error, never taken as done. Metadata whose secrets could not all be deleted is replaced by a discarded marker (piship-credential-discarded/v1) that lists them and that no release uses as a credential; the next launch or login deletes them first and fails closed with SECRET_STORE_UNAVAILABLE while it cannot, and login fails before storing the new identity. Before, clearing unusable metadata ignored deletion errors and removed the metadata that pointed at the secrets, and logout removed identity and credential metadata even when their secrets were still stored; logout now reports the failure and keeps the metadata so the next login or logout retries.
  • Behavior change: a failed remote revocation no longer blocks the next sign-in or keeps the local secret, and it is no longer only a warning: it is recorded, without the secret, in credentials-metadata/revocation-retry.json, the credential.revoke event carries retryPending: true, every login repeats it as a notice until the credential expires, and DistributionAccess.pendingRevocations() returns the count and ages for doctor. The revocation itself cannot be re-sent, because the broker authenticates it with the deleted credential.
  • Behavior change: the branded logout clears local secrets even when a runtime variable is not set or a provider cannot be loaded. Before, it failed with CONFIG_UNAVAILABLE before deleting anything. It now warns, contacts no identity provider or broker, deletes the local identity tokens and runtime credential, and confirms the deletion; a credential the provider could have revoked is recorded as a pending revocation (credential.revoke with revocation: failed and retryPending: true), and identity.logout is audited with revocation: failed. That local sign-out takes the credential lock and then the identity lock, like every other sign-out, so a refresh that is still running finishes first and cannot store the session again; when a lock cannot be had within 90 s the command fails with a retryable CREDENTIAL_ACQUIRE_FAILED instead of signing out around the other process. An identity adapter that cannot be loaded is reported as identity revocation: not attempted and audited with revocation: failed, not unsupported.
  • Behavior change: the branded logout exits non-zero with SECRET_STORE_UNAVAILABLE when a local secret could not be deleted, and says which of the identity session and the runtime credential were cleared and which were not. It used to print "Local runtime and identity credentials were cleared" and exit 0 after reporting the failure as a warning.
  • Behavior change: update and rollback confirm every deletion of a credential the target release cannot read, and stop before activation with SECRET_STORE_UNAVAILABLE, keeping all credential and identity metadata, when a secret cannot be deleted. Before, the branded commands found the secret store through the full access configuration, so a runtime variable that was not set left them without a store and every secret was skipped silently; a delete that failed was only a notice; and in both cases the metadata that named the secret was removed, leaving secret-store entries nothing referenced. The store is now selected from the lock alone. A discarded credential marker is always cleared this way. LifecycleOptions.deleteSecret is replaced by secretStore (a SecretStore), and without one, credential metadata that names secrets stops the switch instead of being dropped.
  • Behavior change: piship purge confirms every secret-store deletion and fails with SECRET_STORE_UNAVAILABLE, removing no state, when a secret cannot be deleted, so running it again retries. It used to report the failure as a warning, exit 0, and remove the state directory, including the metadata that named the secret. PurgeResult.problems is removed from the public @piship/core type: a failed deletion throws instead.
  • Behavior change: a logout whose identity tokens cannot be deleted replaces identity/session.json with an identity discarded marker (piship-identity-discarded/v1: the references still to delete, no claim) and returns the problem. Before, the session metadata was kept as it was, so once the store worked again the signed-out user could launch on it. Every command that reads the identity deletes the marker's tokens first and fails closed with SECRET_STORE_UNAVAILABLE while it cannot; an unreadable session file is cleared the same way instead of dropping its tokens silently, and so is a stored session a workload run replaces.
  • Behavior change: replaced credential generations and replaced identity token bundles are deleted with confirmation; one that fails stays listed in orphans, is reported as a notice, and is retried. Before, the failure was dropped silently.
  • Behavior change: credentials-metadata/revocation-retry.json keeps at most 20 entries; older ones are dropped and counted (dropped, reported by readPendingRevocations and at login). doctor shows the pending revocation retries in the Secret Store group, by count and oldest age with a warning, never by credential ID.
  • Behavior change: damaged credential or identity metadata no longer loses ownership of the secrets it names. Clearing unusable metadata (at launch, login, logout, update, rollback, and purge) used to parse the file and, when it was not valid JSON, treat it as naming no secret and delete it, stranding the Keychain, Credential Manager, or Secret Service entries it referenced. The references (and the neighboring generations a readable file would give) are now also read from the file's text, deleted with confirmation, and kept in a discarded marker while they cannot be deleted; a file too damaged to name any reference is cleared with a notice saying a secret may remain. A first credential or identity generation is named by a discarded marker before its secret is written, so a process that stops before the metadata commit no longer leaves an untracked secret. @piship/credentials exports secretRefsFromText and metadataFileSecretRefs.
  • Behavior change: a change of credential.storage.provider (file to system, or system to file) between the active and the target release is a credential transition in update and rollback. Before, the migration check kept identity and credential metadata whose schema the target reads, so the target looked the old store's references up in its own store, found nothing, and asked for a new login while the old secret stayed behind: plaintext under secrets/, or an entry in the Keychain, Credential Manager, or Secret Service. Now a present identity session and runtime credential are clear-and-reacquire with the reason The secret store changes from <old> to <new>; … (also in update --check and piship migrate-check), the switching release revokes the runtime credential where supported and deletes every reference from the old store before activation, printing <class> was cleared because the secret store changes from <old> to <new>: its secrets were deleted from the <old> store; sign in again, and the target signs in again. Contract change: credential and identity metadata and their discarded markers record the store that holds their references (secret_store); metadata recorded for another store than the configured one is never read as a session or credential, and its references are deleted from the recorded store (revoking the credential first where supported), failing closed while that store is unavailable. purge deletes platform-store references by that record instead of inferring the store from whether secrets/ holds files, so a state left by a storage change no longer loses platform-store entries. MigrationItem.storageTransition, MigrationTarget.storage, AccessOptions.secretStoreFor, and CredentialManagerOptions.storeProvider/storeFor are new.
  • Behavior change: the branded logout that signs out locally (a runtime variable is not set, or a provider cannot be loaded) deletes the identity tokens from the secret store the session records (secret_store), not the configured one. Before, after a change of credential.storage.provider (a Pi session of the old release refreshed the identity after the switch, or a release without access configuration made the switch), the tokens were looked up in the configured store, nothing was found, the deletion counted as confirmed, and the session file that was the only record of them was removed, which left the refresh token in the Keychain, untracked. When the recorded store is not available here, or a deletion fails, the session is replaced by an identity discarded marker that names the references and that store, and the command fails with SECRET_STORE_UNAVAILABLE.
  • Behavior change: update and rollback clear the credential classes the target cannot read under the credential lock and then the identity lock, and delete each file's secrets from the store that file records. Before, they held neither lock, so a live session of the active release could commit a new credential generation between the deletion and the removal of the metadata and leave its secret untracked (in the platform store, for good); and they always used the switching release's configured store, so secrets a file recorded for the other store were not found, counted as deleted, and lost the metadata that named them. A recorded store that is not available here stops the switch with SECRET_STORE_UNAVAILABLE and keeps the metadata. LifecycleOptions gains secretStoreFor, the resolver for the other store; without one (a test with an injected secretStore) such references stay tracked.
  • Behavior change: the store a metadata file records (secret_store) is read from the top-level field of the parsed file; only a file that no longer parses is searched in its text. Before, the first match anywhere in the text counted, so a nested key of that name (in a claim, say) could change the store secrets are deleted from.
  • Behavior change: a logout whose wait for the credential or identity lock runs out fails with the retryable CREDENTIAL_ACQUIRE_FAILED even when a required audit sink is down. Before, the audit of what the command had already done ran first, and its AUDIT_UNAVAILABLE replaced the lock timeout, and the revocation problems found before the wait ran out were not shown. The audit failure is now printed beside those problems, and the lock timeout is the command's error.
  • Behavior change: an update or rollback that clears some credential classes no longer removes the whole file-store secrets/ directory. Before, clearing any class (the sandbox credential alone, say) also deleted the secrets of the runtime credential and identity it kept, so the next launch failed closed and had to sign in again, and the kept runtime credential was never revoked. Each cleared class's secrets are still deleted one by one; the directory goes only when every present class that holds secrets is cleared, which a change of credential.storage.provider always does. The committed example locks changed there.
  • Behavior change: an unreadable file secret store is reported as unavailable. Before, a <state>/secrets directory or entry that could not be read (permissions, a directory where the entry should be, unparsable content) read as no stored session, so launch reported IDENTITY_REQUIRED and sent the user to a login that also failed. Now only a missing entry reads as absent; anything else fails with SECRET_STORE_UNAVAILABLE naming the path, with the action to restore owner-only access or remove the invalid entry and sign in again (#150).

Audit

  • Contract change: audit user keeps the v0.7 form, the principal as principalId gives it (issuer, #, subject), under piship-audit/v1; v0.6 wrote the bare subject. The form is now fixed: changing it again needs a new audit schema, and no separate principal field is added. Records written before v0.7 are not rewritten, so a collector holding both should tell them apart by the release that sent them (decision 29).
  • Contract change: every audit event now carries an id, a random UUID assigned once when it is emitted and kept when a failed batch is resent, so a collector can drop an event it has already stored. Before, events had no ID or sequence and a retried batch could be stored twice without a way to tell. The field is additive: the event schema stays piship-audit/v1 and the batch schema piship-audit-batch/v1, and the file sink writes it too. @piship/contracts adds AuditEvent.id (optional in the type, since events written before it have none), AUDIT_BATCH_SCHEMA (moved from @piship/audit, which no longer exports it), AuditBatch, and AuditSink, the interface the file and HTTP sinks implement.
  • Behavior change: a required audit sink no longer loses events silently when a governed session ends. Before, GovernanceSession.close() ignored the result of the final flush, so events a required sink had not taken by the 5-second deadline, or had dropped earlier from a full buffer, disappeared without a report; a failing MCP or sandbox cleanup could also skip the flush entirely. Now the final flush always runs, a required sink is retried until the deadline, and close() returns the final AuditStatus or, after all cleanup, throws AUDIT_UNAVAILABLE with the pending and dropped counts per sink. A launch or --smoke run whose required sink loses events therefore exits with that error (doctor prints its whole report first and then fails with CONFIG_UNAVAILABLE), and closing takes up to 5 seconds longer while a required sink is down. A launch that fails for another reason keeps its error and counts AUDIT_UNAVAILABLE in the local startup failures instead. @piship/audit adds requiredAuditLoss(status); an event emitted after AuditLog.close() now counts as dropped instead of being ignored.
  • Behavior change: login, logout, update, and rollback honor a required audit sink. Before, their events were best effort even for a required sink: an unreachable sink or undelivered events printed a warning and the command succeeded. Now the command fails with AUDIT_UNAVAILABLE after the operation, which is not undone (signing out still signs out); a refused update or rollback keeps its own error and prints the audit failure. Distributions with only optional sinks keep the warning.
  • Behavior change: a login that fails after it changed state, such as a broker refusal after the new identity was stored, now records what happened; the audit ran only after a success before. The login error stays the command's error even when the audit also fails, and logout prints its revocation warnings before the audit that can fail the command. The session commands (--smoke, --smoke-model, the interactive launch) also end every teardown step even when an earlier one failed: a failing runtime dispose used to skip closing the governance session, and a close that lost required audit events used to replace the session's own error and skip saving metrics and clearing the published context. The session's error is kept, and a teardown failure is raised only when the session itself succeeded.
  • Behavior change: a model request is refused while a required audit sink has lost events and its buffer is full, before anything is sent to the gateway, with AUDIT_UNAVAILABLE. Tool calls were already refused in that state, but a prompt, which can carry workspace content, still left; Pi swallows an error thrown from its before_provider_request hook, so the check sits in the model runtime that PiShip wraps, on every request path.
  • Behavior change: an interactive session now ends its governance session when Pi shuts down. Pi ends a session by awaiting its runtime disposal and then calling process.exit() (Ctrl+D, /quit, SIGTERM, SIGHUP), so the code after the interactive loop never ran: the session recorded no session.end, skipped the final audit flush, and lost any event a required sink had not yet taken without a report. Teardown now runs inside that disposal; if it fails the error is printed and the exit code is 1. AUDIT_UNAVAILABLE also outranks a cleanup error in --smoke and --smoke-model, and a failed login records the identity that was stored, or none when the distribution has no identity provider, and a failed session start is counted in the local startup failures.

Network, policy, redaction, and diagnostics

  • Behavior change: in a managed distribution, commands the agent runs through the bash tool receive only the network environment the distribution's policy approves (child process network environment). Proxy variables are passed when network.proxy.inheritEnvironment is true, as a host and port only: never a proxy URL that embeds credentials or has a path, query, fragment, or backslash. NODE_EXTRA_CA_CERTS is set from a single declared network.tls.additionalCA bundle. Every other proxy, CA, or TLS-verification variable is dropped: ALL_PROXY, SSL_CERT_FILE, SSL_CERT_DIR, CURL_CA_BUNDLE, REQUESTS_CA_BUNDLE, GIT_SSL_*, GIT_CONFIG_*, NODE_TLS_REJECT_UNAUTHORIZED, the proxy, trust, and verification settings of npm and yarn, pip, Cargo, Deno, curl and wget, and Java, and NODE_OPTIONS when it changes trust roots. An uncontained command used to inherit every network variable. A command in an enforced sandbox that allows the network received none of these and now receives the approved ones; a sandbox that denies the network and a remote backend receive none, even one that sandbox.environment.allow names (an allowlist entry used to pass a proxy URL with credentials through). A distribution that relied on an ambient CA variable in agent commands declares the bundle in network.tls.additionalCA instead. If Pi ever stops giving the agent's bash tool an environment, the process environment is its base, so it is never left unrestricted. Only managed distributions are affected: a personal distribution keeps the proxy and CA variables of the user's shell in its commands, exactly as before, and so does a distribution with no network policy (piship/v1alpha1).
  • Behavior change: doctor reports enterprise state in its own groups, printed in one fixed order (doctor report). Gateway, Secret Store, Network, Audit, Release, and Workspace are new groups; the old Access, Security, and "MCP and audit" sections are gone (their lines moved to Identity, Credential, Network, MCP, and Audit), and the release line moved from Supply Chain to Release. Network now says whether a proxy is active (as scheme://host:port), whether NO_PROXY is set, how many CA bundles are declared, and which network variables a managed distribution's agent commands receive or have withheld, by name and reason (the line is labeled agent commands); for a personal distribution it says they are not restricted. Audit lists each sink with its target (local file or the HTTP host, never the URL path or query), state, and delivered, pending, and dropped counts. Sandbox adds the isolation (local, remote, or none) and the containment summary. Identity no longer shows the signed-in user's display name or subject, only whether a session is signed in and the configured issuer. Every line, including passed checks, is now sanitized: URL credentials, queries, and fragments are removed and token shapes redacted. doctor's governed session now uses the distribution's network policy, child network environment, and signed-in principal, as a launch does, and records credential lifecycle events from its activation; it used to run with the default network policy and no principal. When that session's close finds events a required audit sink did not take, doctor reports AUDIT_UNAVAILABLE in the Audit group and exits non-zero after printing the whole report instead of losing it.
  • Behavior change: the branded --help of a distribution with credential.provider: pi-native no longer lists login and logout, which always refuse there with POLICY_DENIED; it lists doctor | models | version and says to start the command and use Pi's /login, /logout, and /model inside the session. The help of every other access mode is unchanged.
  • Behavior change: the policy note of config explain follows the deployment mode. A managed distribution now says that user rules in config/policy.json are narrowing only (they may tighten a default, never relax a default or an enforced rule, and their allow rules are ignored); before, every distribution said user rules "may only relax defaults", the opposite of what managed policy accepts. A personal distribution says a user rule takes a matching default's place and may relax it, but never overrides an enforced rule.
  • Behavior change: local metrics never block a governed session. Before, GovernanceSession.open() and close() saved logs/metrics.json without containing a failure, so a full, read-only, or unreachable state directory failed an otherwise valid launch after policy, sandbox, MCP, and audit were up, turned a clean close into an error, and on a failing launch could replace the original error (for example MCP_DENIED) with the metrics I/O error. Every metrics record and save on the governed path is now best effort; required audit still fails closed exactly as before, and update and rollback, which already treated metrics as best effort, are unchanged.
  • Behavior change: managed requests (identity provider, credential broker, gateway probes, the audit HTTP sinks and update downloads) no longer reuse connections: each opens its own and closes it after the response, which costs one TCP (and TLS) handshake per request (per flush for an audit sink); HTTP/2, whose one session per origin would be reused the same way, is not negotiated for them. A platform secret store call blocks the process until its tool exits, which takes seconds on Windows, and a pooled connection the server closed meanwhile still looked usable afterwards; a broker request sent on it failed with CREDENTIAL_ACQUIRE_FAILED ("the credential broker is unreachable", an outcome that is never retried), as a Windows sign-in on the live Credential Manager did in Portable E2E. A managed request can no longer fail on a connection the server closed while the process was blocked. createDispatcher takes { keepAlive: false } for this; the process-wide dispatcher for Pi's own traffic keeps pooling connections.
  • Behavior change: PiShip redacts a failed model request's error text before Pi records it in a session file. Pi keeps a provider error (errorMessage) in the session file under sessions/user/ or sessions/acceptance/, and PiShip redacted only what it printed, so a gateway that echoed its Authorization header into an error body left the runtime credential in plain text there. A built-in piship-redaction extension, loaded last in every launch (managed or personal, interactive or --smoke-model), replaces the finished assistant message with one whose error text (errorMessage) and provider diagnostics (diagnostics, each an error message, stack, and details) went through the shared redaction (registered secret values and the token shapes); normal streaming, abort, retries, and usage are unchanged. The two APIs PiShip registers for a managed gateway attach no diagnostics in Pi 0.87.1, but pi-native launches (Anthropic, Bedrock, Codex) do. The redaction fails closed: Pi persists the original message when a message_end handler throws, so if the redaction itself fails, the error text is replaced whole by [REDACTED error text] and the diagnostics are dropped. Every field of Pi's assistant message is classified in launch/redaction.ts, so a Pi upgrade that adds a field fails to compile until it is classified, and the compatibility suite checks the messages Pi builds against the same list.
  • Behavior change: update and rollback keep the manifest's network policy when a runtime variable is not set. Before, a variable left unset in the shell (such as the gateway URL) made them fall back to the default policy, so network.privateOnly (and managed publicFallback: deny), the proxy setting, and the CA bundles were silently dropped: update --check --from an undeclared host was contacted, and so was an HTTP audit sink of runtime.update and runtime.rollback, where a launch refuses both with NETWORK_DENIED. The policy now comes from the manifest alone in that case: private-only and allowHosts apply, the unresolved endpoint hosts are not added, the declared update host still is, and a CA bundle whose path does not resolve is left out.
  • Behavior change: team rules (policy.adapter) and project rules (.piship/policy.json) are matched separately, and the stricter of the team's and the project's first matching rule joins the strictest-effect comparison. They used to form one first-match list with the team rules first, so a team ask that matched hid a project deny for the same request: never wider than the distribution, but the project's restriction was silently ignored. Decisions only get stricter; policy explain marks the first match of each of the two sources.
  • Behavior change: redaction catches more of a known secret and more credential keys. A registered SecretValue is also scrubbed in its base64, base64url, percent-encoded, and JSON-escaped forms (before, only the exact value was, so an echo of it in a Basic header, a URL, or a JSON string passed). SECRET_KEY_PATTERN in @piship/contracts now also matches the camelCase, snake_case, and kebab-case spellings of token and key names and credential headers (apiToken, x-api-key, auth_token, proxy-authorization, set-cookie, private_key); before, only exact names such as token or api_key did. redactValue no longer overflows the stack on a circular value (a back reference reads [Circular]) and shows a Buffer or Uint8Array as its redacted UTF-8 text instead of one unredacted number per byte. The audit sanitizer drops a detail key that holds a secret instead of keeping it verbatim. The generated launcher also prints an error no code path catches (an unhandled rejection or a throw from a callback) through the redaction and exits with status 1, where Node printed it with its stack. An unreadable project .piship/policy.json is now reported as a policy warning in doctor and policy explain (its rules still do not apply, which narrows nothing), and is not recorded as a policy.violation. Contract change (additive): PolicyEngineInput in @piship/policy gains the optional diagnostics.
  • Behavior change: an uncaught error in an interactive session is redacted before Pi prints it and records it in its crash log. Pi's interactive mode puts its own uncaughtException handler in front, which printed the whole error with its stack, wrote its message and stack to crashes.json in Pi's agent directory (attached by /bug to a report sent to Pi's developers), and exited, so the launcher's redacting handler never ran there: an undici "invalid header value" error or a rejection carrying an echoed Authorization header left the credential on the terminal and on disk. At session_start, after Pi registered its handler, the built-in piship-redaction extension puts a listener in front of it that rewrites the error object in place (message, stack, and the strings it holds, such as its cause). A thrown primitive cannot be rewritten in place and is still printed as it is, an error thrown before session_start is not rewritten, and neither is a value held inside a Headers or Map object on the error (security).
  • Behavior change: doctor's gateway line reads reachable (<n> listed; model providers not contacted) instead of reachable (<n> listed), and inference says what the model list check proves: the gateway is reachable, accepts the runtime credential, and lists these models. It never reaches a model provider behind the gateway, which a gateway such as LiteLLM answers from its own configuration, so a provider that is down, refuses the gateway's provider key, or rate limits it shows only when a request is sent. The check is unchanged; no probing request is added. The gateway evidence test shows the model list answering in full while every request to the upstream fails.

Sandbox backends

  • Behavior change: a remote sandbox backend's network denial is checked against a target the backend declares, never an address PiShip picks. PiShip no longer tries 1.1.1.1:443 from a denied remote sandbox, and a check that cannot run there no longer fails a deny session. With a declared network probe, a connection from the session's sandbox fails activation closed, and network denial is reported verified only when the same probe is also reachable from a temporary allow-mode sandbox of the same backend, created only then and only for a backend that lists allow. Without a probe (the e2b-compatible backend and the SDK example), for a deny-only backend, or when the allow-mode sandbox cannot be created or cannot reach the probe, network denial is attested by the backend: the plane is reported and a required deny sandbox still activates. A declared probe that proved nothing adds a warning and one session notice. See network denial.
  • Behavior change: the kubernetes-agent-sandbox backend no longer claims network denial, because it cannot verify the warm pool's NetworkPolicy; a required deny sandbox on it fails closed. It declares network: ["allow"] only.
  • Contract change: SandboxCapabilities gains the optional networkProbe: {host, port} (remote backends; a malformed one fails closed), exported from the adapter SDK as SandboxNetworkProbe. ContainmentReport gains networkDenial (evidence: "verified" | "attested", whether a probe was declared, and a fixed reason) in deny mode. doctor's network line reads deny (verified) or deny (attested by the backend, not verified: <reason>), and the local sandbox metric records networkDenial. The sandbox conformance kit's capabilities refuses a malformed probe, and network claims uses a declared probe as its default target and fails when it is not reachable with the network allowed.
  • Behavior change: the native sandbox backend's dispose() stops a command that is still running (SIGTERM, then SIGKILL of its process group, as a cancellation does), waits for at most two seconds until it is gone, and refuses a command after it; a command started in the same tick as the dispose is stopped too. Before, dispose() did nothing, so a command running when a session ended ran on until the process exited, and one that ignored SIGTERM could write into the session's temp directory, or recreate it, after the session removed it. The sandbox conformance kit found it, and native.test.ts requires dispose() to wait.
  • Behavior change: the Kubernetes backend no longer reuses a claim the cluster removed before its renewal threshold. Before every command it checks that the claim still exists, and a router 404 or a renewal that finds the claim gone while a command runs retires it: the next command gets a fresh claim, and a command whose outcome is ambiguous is never replayed. Before, a claim deleted early kept receiving commands until PiShip's own renewal threshold. New tests cover a router 404 on a claim the API still lists and a claim removed mid-command (kubernetes-agent-sandbox).
  • Behavior change: on Windows, the Job Object supervisor that starts the child processes PiShip spawns itself (governed commands, probes, MCP stdio servers) is started by absolute path from %SystemRoot%\System32\WindowsPowerShell\v1.0\ with only SystemRoot, WINDIR, TEMP, TMP, the job request, and the child's approved PATH (no credentials). The supervisor turns PowerShell's module auto-loading off and imports Microsoft.PowerShell.Utility from $PSHOME, so it starts in under a second without PSModulePath; with auto-loading, PowerShell searched every installed module and took 20 to 30 seconds. Before, it was started as bare powershell.exe from the workspace with the whole launcher environment, so a powershell.exe in the project root ran outside the Job Object with every launcher variable, a regression from #104. A missing or non-absolute SystemRoot and WINDIR make the child fail to start, with no fallback to the bare name (#127).

Sandbox workspace check

  • Contract change: the sandbox backend contract gains a workspace concept. SandboxCapabilities.workspace ({ mode, propagationMs?, sentinelDir? }, omitted means snapshot, ignored for local backends), the optional SandboxInstance.epoch(), and two guarantees: workspace-confinement (a remote backend reaches this host's files only through the workspace) and git-control-protection (profile.writeProtect cannot be changed from inside the sandbox, also through a sync engine), which CONTAINMENT_PLANES and SANDBOX_GUARANTEES now list. requiredPlanes takes the workspace mode: a remote backend with a shared or synchronized workspace needs workspace-confinement and git-control-protection instead of host-filesystem-isolation, and one that claims host-filesystem-isolation as well fails closed, since a mounted workspace contradicts it. A malformed workspace declaration fails closed before prepare. ContainmentReport adds isolation and workspace, ActiveSandbox adds workspace() and onWorkspaceReport(), describeContainment takes an optional live workspace report, a probe result can carry warnings, and @piship/sandbox exports workspaceDeclaration, describeWorkspace, and the workspace types. The built-in e2b-compatible and kubernetes-agent-sandbox backends declare snapshot explicitly.
  • Behavior change: a remote backend that declares a shared or synchronized workspace fails closed with SANDBOX_UNAVAILABLE when a sandboxed command could append to a protected git control file or create a file in a protected git directory (.git/hooks, .git/info), or when that check cannot run: the instance is retired before the agent's command is sent, and no later command runs in the session. The check changes nothing and removes what it created; renames stay attested by the backend.
  • Behavior change: the governed session passes the project's origin to the sandbox, so a backend's working-tree sentinelDir is now used for company-origin projects; before, the origin never reached the sandbox and such a directory was never used. Projects of any other origin still keep the check inside the git directory. The session.start audit event's detail adds workspace, the declared workspace mode, when a sandbox is enforced. doctor's isolation line for a remote backend that declares a shared or synchronized workspace now reads remote (commands run on another machine; host files reachable only through the workspace) instead of claiming host files are unreachable.
  • Behavior change: the workspace check also tries to create each protected git control file that does not exist yet (.git/commondir and .git/config.worktree normally do not), without clobbering, and a remote shared or synchronized backend that lets the sandbox create one fails closed like one that lets it append. Before, only protected files that already existed were probed, so a backend that guarded the existing .git/config, hooks, and info passed while a sandboxed command could write .git/commondir and point the next host git status at a config of its choosing. The host removes what the probe created only if it is still an empty regular file, reached through plain directories.
  • Behavior change: the workspace check no longer deletes anything recursively in the directory it shares with the sandbox. Before, the cleanup of its nonce directory, the sweep of stale ones, and the exit hook used a recursive delete, so a process the sandbox left running could swap .git/piship-workspace for a link to a host directory between checks and have <nonce> removed there, for example an editor's per-workspace storage. Now each step checks the path's components again, unlinks only the regular files h2s, s2h, and s2h.tmp, and removes the directory only if that leaves it empty; the sweep skips a stale directory that holds any other name, which then stays until someone removes it.
  • Behavior change: the remote workspace check now looks for a way to rename the protected git paths, and reports git control as not-verified (shown as a warning by doctor; the commands still run) when it finds one. Before, the report said attested-renames with complete: true whatever the backend did about renames, so a backend that made .git/hooks read-only but let a command mv .git/hooks .git/h; mkdir .git/hooks, or rename .git itself, went unnoticed. A protected path, or a directory above it up to the workspace root, counts as movable when it is not a mount point (a different device than its parent, or listed in /proc/self/mountinfo) and its parent is writable. A backend that protects the paths by mounting them read-only now also has to make .git a mount point to keep the state attested-renames. Renames are still never tried, so attested-renames is the mount and permission structure, not a proof; a check that cannot tell (no stat, no mount table) reports not-verified.
  • Behavior change: the remote workspace check writes every workspace-relative path as ./<path> and gives mkdir a --. Before, a protected path that starts with -, such as a core.hooksPath of -h, reached mkdir, stat, and cd as an option, so the probe failed for the wrong reason and a writable directory passed as protected, and a sentinelDir that starts with - never verified.

Protected git paths and git control

  • Behavior change: the live probe of a local backend also checks git-control-protection (writing a protected file, creating a file in a protected directory, and renaming the directory that holds a protected file must fail), so native reports and doctor's containment line now list the plane. It is not yet required: a custom local wrapping adapter that ignores writeProtect still activates, without the plane and with a warning, until the check has passed on Linux and macOS CI. The containment line of a remote backend now ends with a workspace sentence (for E2B and Kubernetes: Workspace: snapshot. Remote commands see a copy, not the files the agent edits; this is not a complete coding-agent workspace.), and doctor's isolation line comes from the report's isolation instead of being inferred from host-filesystem-isolation.
  • Behavior change: the git paths a sandbox must not change now also cover the git directories of submodules (.git/modules), the metadata of linked worktrees (.git/worktrees), and the directory core.hooksPath names in the project's git config, each as a whole tree and for the git directory a .git file points to and the shared directory of a linked worktree as well. Before, a sandboxed command could write a submodule's config or hooks/pre-commit, which git status reads and runs, or create .git/worktrees/<name>/commondir, and the user's next git command ran it. The native sandboxes (bubblewrap, Seatbelt), PiShip's file tools (piship.project.git-config), and the remote workspace check all use the larger set, so a native shell command can no longer commit or fetch inside a submodule, add a submodule or a linked worktree, or regenerate a core.hooksPath directory such as husky's .husky/_; those run outside the sandbox. A hooks directory in the working tree is protected but the scripts it calls are project files, so git control is reported not-verified for it, for native and remote backends alike.
  • Behavior change: the git paths a sandbox must not change now also follow the git config beyond the repository's own files. core.hooksPath is read from the user's global config too (~/.gitconfig and $XDG_CONFIG_HOME/git/config, or ~/.config/git/config), and a relative value, such as a team's global .githooks, is taken from each project's root and protected; before, only the repository's own configs were read, so that directory was writable from the sandbox while git control was reported protected. The files include.path and includeIf.*.path name (every one, whatever its condition, from the including file's directory or ~/, up to git's depth of ten and once each) are read for core.hooksPath and protected, whether or not they exist yet: before, a sandboxed command could put core.hooksPath or core.fsmonitor into an included file in the working tree. No other file is read for this. PiShip's file tools now refuse to write an included file, and a global config file only when it lies inside the project. A local backend reports git control not-verified while an included config file in the working tree does not exist yet, since nothing can guard a missing file on Linux; a remote backend's check tries to create it and fails closed if it can.
  • Behavior change: the git paths a sandbox must not change now also come from the rest of the git config git reads, from the process environment PiShip has. core.hooksPath and the includes are read from the system config (GIT_CONFIG_SYSTEM, or /etc/gitconfig, unless GIT_CONFIG_NOSYSTEM is true), from GIT_CONFIG_GLOBAL (which replaces ~/.gitconfig and the XDG file when set), and from the GIT_CONFIG_COUNT, GIT_CONFIG_KEY_<n>, and GIT_CONFIG_VALUE_<n> settings, where include.path takes only an absolute or ~/ path, as in git. Before, a relative core.hooksPath set for the whole machine (a system-wide .githooks, a common team setup), or through one of those variables, was not protected while git control was reported protected. A relative value is taken from the project root. No other file is read.
  • Behavior change: the scan of the git config is bounded and cached. It follows at most 64 include entries, 64 core.hooksPath values, and 64 environment settings (each counted over every config) and does not read a config file over 1 MiB; before, one config under 1 MiB could list about fifty thousand includes, each a protected path, which made a bubblewrap command line or a remote check command too large to run, so the check failed closed and no sandboxed command ran. Past a limit PiShip protects what it followed, reports git control not-verified for local and remote backends alike, and shows the reason as a sandbox warning. The scan used to run again at every file-tool write; it is now kept, under a key made of the repository's config paths and the environment inputs, while every file it tried has the same modification time, change time (ctime, which only the kernel sets, so a writer that puts the modification time back is still seen), size, and inode, and for ten seconds at most (for a file system whose clock is too coarse to tell two writes apart), so a config change shows at the next access. Contract change (additive): ProtectedPaths in @piship/sandbox gains the optional unverified (the reason a list is incomplete), which @piship/pi's gitProtection sets, and @piship/policy exports projectGitControlUnverified.
  • Behavior change: the native sandboxes no longer pin the directories above a protected path that does not exist. A protected path that exists still pins every directory between the workspace root and it (mount points on Linux, rename and removal denied on macOS), so mv, rm -rf, or a git checkout that deletes a directory holding an included config file fails with EBUSY or EPERM in a native sandbox, and a package install whose prepare script rewrites a protected hooks directory such as husky's .husky/_ fails too; docs/sandbox.md says so. Before, the ancestors of a missing path were pinned as well, which stopped commands from moving a directory that only might hold a config file later; git control is reported not-verified for such a path in the working tree anyway.
  • Behavior change: a sandboxed command can no longer change the git config git reads besides the repository when it lies in a directory the sandbox may write. The system, global, and environment config (/etc/gitconfig, ~/.gitconfig, $XDG_CONFIG_HOME/git/config, GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM) and the files only they include were protected only when they lay inside the project, so under a manifest that lets commands write ~ a sandboxed command could add a core.hooksPath to ~/.gitconfig, which the user's next git command outside the sandbox runs. They are now in the sandbox profile's writeProtect wherever they lie (bubblewrap binds them read-only, Seatbelt denies writes to them, and the directories above one that exists are pinned as for any protected path). PiShip's own file tools are unchanged: they still refuse only the git files inside the project, since refusing ~/.gitconfig would stop the edit tool changing the user's own git configuration (piship.project.git-config does not cover it). A config file git reads that does not exist yet, in a directory the sandbox may write, cannot be guarded by bubblewrap and now reports git control not-verified for a local backend, as a missing include in the working tree already did. With the default manifest (writable workspace and tmp only) nothing changes. Contract change (additive): projectGitControlFiles in @piship/policy takes an optional { scope: "project" | "sandbox" } (the default, project, is the list it returned before).
  • Behavior change: git control is reported not-verified (for local and remote backends alike, with the reason in the sandbox warnings and in doctor) when a protected git path is reached through a symbolic link in a directory the sandbox may write: an include target that is a link or lies under a linked directory, a core.hooksPath directory or .git/hooks linked to a shared directory, or a linked ~/.gitconfig when ~ is writable. Protection followed the link to its real target only, and nothing a backend offers holds the link itself in place (a bubblewrap bind mount or a Seatbelt rule on a link acts on its target, and the remote check can only write through it), so a sandboxed command could write a new target, retarget the link, and have git follow it after the next scan; the scan then protected the new target only after its content was in place. Chosen over protecting the link's own path and pinning its directories, which holds on macOS and cannot on Linux. Before, such a setup was reported attested-renames or verified. A link in a directory the sandbox cannot write is not reported, so a linked ~/.gitconfig changes nothing unless the manifest lets commands write ~. Contract change (additive): @piship/policy exports projectGitControlLinks, and ProtectedPaths in @piship/sandbox gains the optional input links, which @piship/pi's gitProtection sets.
  • Behavior change: the git paths a sandbox must not change now also follow GIT_CONFIG_PARAMETERS, which git -c key=value exports to the commands it starts. core.hooksPath and include.path (an absolute or ~/ path, as for GIT_CONFIG_COUNT) are read from it in both formats git writes ('key=value' and 'key'='value'), counted with the 64 environment settings, and what it includes is protected like any include. Before it was not read, so a hooks directory or include set only through git -c (or a tool that exports it) was left writable. A value that is not in the format git writes is not guessed at: git control is reported not-verified with that reason.
  • Behavior change: on native Linux, git control is now reported not-verified for every regular repository, and the sandbox warning says why: git control is not verified: linux-bubblewrap cannot guard a protected file that does not exist yet (.git/config.worktree, .git/commondir). Bubblewrap protects a path by mounting over it and cannot guard a file that is not there (a mount point would be an empty file left in the repository, and PiShip creates no placeholder files), yet .git/commondir and .git/config.worktree, which git follows if they appear and a regular repository does not have, were left out of the check, so the report said verified while a sandboxed command could create one and redirect the next git command outside the sandbox. It is a downgrade of the report only: the sandbox is not failed, nothing is created, and the plane is still claimed for the paths that exist. Any protected file that does not exist where the sandbox may write now counts (inside or outside .git) unless the isolator says it can guard it. Seatbelt (macOS) denies the path itself and is unchanged. Contract change (additive): SandboxAdapter.guardsMissingFiles (bubblewrap false, Seatbelt true) and the optional SandboxCapabilities.guardsMissingFiles (false when omitted; the native backend reports its adapter's), so a custom local backend can declare it.
  • Behavior change: the git paths a sandbox must not change now also come from the system config of a Homebrew, Linuxbrew, Xcode, or Command Line Tools git. The system config was read only from /etc/gitconfig (or GIT_CONFIG_SYSTEM), but git fixes its path when it is built, so a Homebrew git reads /opt/homebrew/etc/gitconfig or /usr/local/etc/gitconfig and Apple's git the usr/etc/gitconfig of its toolchain: a core.hooksPath or include set there was not followed, and the file was not protected in a writable directory (/usr/local/etc belongs to the user on Intel macOS). PiShip cannot tell which git runs, so it reads and lists all of them (/etc/gitconfig, /opt/homebrew/etc/gitconfig, /usr/local/etc/gitconfig, /home/linuxbrew/.linuxbrew/etc/gitconfig, /Library/Developer/CommandLineTools/usr/etc/gitconfig, /Applications/Xcode.app/Contents/Developer/usr/etc/gitconfig); a file the running git does not read only adds a protected path. GIT_CONFIG_SYSTEM still names the one file (empty: none) and GIT_CONFIG_NOSYSTEM still turns them all off. A git built with another prefix (Nix, a source build) is not known and needs GIT_CONFIG_SYSTEM to name its file, which the docs say.
  • Behavior change: on macOS (Seatbelt), git control is no longer reported not-verified for a missing protected file whose directory cannot be swapped, such as a missing include in the project root or a missing ~/.gitconfig in a writable home. Seatbelt denies the path of a missing file, but the check counted every missing file outside .git whatever the isolator could guard. It now counts one only when the directory above it is neither a writable root, nor holds an existing protected path (which is pinned), nor lies below another protected path that is denied whole: there a command can build the directory elsewhere and rename it into place, which the path rule does not stop (the boundary test shows the swap working). The warning for that case says so (denies the path ... but not the directory above it, which can be swapped). Bubblewrap is unchanged: every missing file counts. Before, the exclusion covered only files under .git.

Sandbox credential

  • Contract change: sandbox.credential accepts stored for the custom, e2b-compatible, and kubernetes-agent-sandbox providers, within piship/v1alpha4; it needs sandbox.endpoint. A release whose manifest uses it can be installed by update only from a release that already knows the value. none still parses to an omitted field, so the lock's sandbox digest changes only for a manifest that uses a credential.
  • Contract change: @piship/contracts adds SandboxCredentialAccess, SandboxCredentialSource, and SandboxCredentialKind. CustomBackendContext gains credentialOrigins and credentialRejected, and RemoteBackendOptions gains credentialRejected; CredentialManagerOptions gains slot (inference, the default, or sandbox) and origins; LocalSecretCredentialProvider takes an optional prompt label, kind, and maximum length; @piship/credentials exports CredentialSlot and SANDBOX_CREDENTIAL_METADATA_SCHEMA; and CredentialManager.markRejected(used?: RejectedCredential | string) also marks a non-renewable credential of the sandbox slot (a bare reference marks that generation only; a RejectedCredential names the issuance by reference, acquisition time, optional credentialId, and principal, compared under the lock). The lock's runtime.stateSchemas gains the sandboxCredential key, so the committed example locks changed there.
  • Behavior change: logout also deletes the stored sandbox credential, and login as another principal deletes it, confirming the deletion, before the new identity is stored; a deletion that cannot be confirmed fails the login. purge deletes its secrets too.
  • Behavior change: a remote backend that gets HTTP 401 from an origin it sent a credential to reports it: a stored sandbox credential is marked rejected and the next launch asks for sandbox login; an adapter's own credential is renewed once and the one request that created nothing (never a sandbox, claim, or command creation) is repeated. Every credential a remote backend sends is registered for redaction, so a failure body that echoes it never reaches an error. A 403 is an authorization denial, not a rejection, and leaves the credential alone (the last entry under Sandbox credential).
  • Behavior change: the stored sandbox credential is deleted, stored, or marked rejected only for the user who is still signed in. A launch of a user who has since signed out, or been replaced, fails with IDENTITY_REQUIRED and no longer deletes the new user's credential; sandbox login checks the user again under the locks and stores nothing for one who signed out while it waited for the secret; a rejection names the issuance it used, so a later one under the same reference is never marked. logout (also the local one) now deletes the sandbox credential after the identity, and audits credential.revoke after identity.logout; login clears another user's sandbox credential a second time once the new identity is stored, so a sandbox login that finished between the first clear and the identity write does not stay behind. A workload identity has no stored user to check, so this guard does not apply to it. A metadata file that names neither the runtime nor the sandbox credential no longer yields generation references, so a damaged file cannot make purge or a migration delete the live runtime credential.
  • Behavior change: only an HTTP 401 from a remote sandbox service rejects the sandbox credential. A 403, such as Kubernetes RBAC refusing claim creation or router execution for a valid bearer, used to mark a stored credential rejected (requiring another sandbox login) or renew an adapter-issued one; it now leaves the credential in place and fails the activation or command with an error that names HTTP 403 and carries the redacted answer. This applies to the e2b-compatible and Kubernetes backends and to what a custom adapter's credentialRejected() means (credentials).

Conformance kits

  • Behavior change: the sandbox conformance kit's secret leakage check finds the credential when a backend writes it to process.stdout or process.stderr in several writes. It used to look at each write on its own, so a credential split across writes passed the check and its pieces reached the terminal. Each stream now keeps back a tail shorter than the credential until the next write or the end of the check, and looks for the credential in the bytes of string and Buffer writes alike; output without the credential is passed on byte for byte.
  • Behavior change: concurrent sandbox conformance kit runs in one process take turns at environment filtering. They used to plant their launcher variables into the same process.env names at once, so a run could check for its own values while the environment held another run's, and an adapter that leaked launcher values could pass. Each run now plants, checks, and restores the original environment before the next one starts.

Added

Adapter platform

  • Two workspace packages for adapter authors, @piship/adapter-sdk and @piship/adapter-conformance, built to a publishable shape but private and unpublished (decision 21). The SDK ships in every payload, which grows by the SDK's own files only (its dependencies were already there); the conformance package never does. @piship/contracts now defines AdapterContext and ResolvedEndpoints, which @piship/core still re-exports unchanged. The committed example locks change in npmLockSha256 and, for the stored sandbox credential's state class, in runtime.stateSchemas.
  • The adapter SDK surface, a preview: defineIdentityAdapter, defineCredentialAdapter, and defineAuditSink return their argument unchanged; defineSandboxAdapter returns a factory whose backend has passed customBackend(), the check PiShip's loader applies; withTimeout composes a timeout with the caller's cancellation. Everything else is a curated re-export of @piship/contracts and @piship/sandbox (context and provider types, SecretValue and redaction, the ManagedFetch type, PiShipError and parseRetryAfter, the audit batch contract, and sandbox capability declarations), which a unit test pins and checks for internal or Pi imports. defineAuditSink types the collector behind the built-in http audit sink; a distribution still cannot declare its own sink type. Adapters are single files: packages/adapter-sdk/examples/ has one of each kind, each importing only the SDK and node: built-ins.
  • The HTTP audit sink wire contract, piship-audit-batch/v1, is published with a JSON Schema in the enterprise integration contract: request, readiness probe, batching, retries, de-duplication, and privacy. A unit test validates every batch the sink sends, with every field at its limit, against the schema in the doc, and fails if either names a property the other lacks.
  • An audit sink conformance kit, testAuditSink in @piship/adapter-conformance. Before, an adapter author had no way to check an AuditSink against piship-audit-batch/v1 other than reading the contract. The kit connects the sink to a fake collector it controls and reports passed, failed, or skipped with a reason for ten behaviors: metadata-only defaults, secret redaction, identity attribution, session correlation, event id stability, delivery failure, buffer behavior, the fail-closed policy, shutdown flush, and duplicate handling. The buffer and shutdown checks apply to a sink that holds events itself (flush, status, close) and are skipped for a plain one. The kit imports only @piship/adapter-sdk. Its tests pass a reference plain sink and a reference buffering sink, and fail sinks seeded with one defect each, such as a leaked credential, a swallowed failure, a new ID on every write, an unbounded buffer, or a skipped flush on close.
  • The credential conformance kit, testCredentialAdapter in @piship/adapter-conformance: it runs a credential adapter against a fake broker it owns, through the adapter's managed fetch, and reports acquire, refresh, expiry, revoke, 401, 403, 429, 5xx, timeout, abort, redaction, concurrent refresh, retry behavior, and idempotency as passed, failed, or skipped, with a reason that never holds a secret. Each behavior is a contract statement an adapter author can read in the adapter SDK docs; a behavior the adapter cannot show, such as revoke without revoke(), is skipped, never passed. The kit's own tests pass a reference adapter and fail a seeded-bad variant of it for every behavior.
  • The identity conformance kit, testIdentityAdapter in @piship/adapter-conformance. Before, an identity adapter author could check the contract only by reading it. The kit runs an interactive or workload (interactive: false) identity adapter against a fake identity service it owns, through the adapter's managed fetch, and reports login, refresh, expiry, logout, claim normalization, revoked session, secret redaction, and service outage as passed, failed, or skipped, with a reason that never holds a token; a respond hook encodes the service's answers in the adapter's own protocol. PiShip does no JWT cryptography, so invalid issuer, invalid audience, invalid signature, the token validity window, and revoked tokens are checked only with a harness whose mint hook the adapter author supplies; without one they are skipped with needs harness, never passed (adapter SDK). The kit's tests pass a reference device-style adapter and a reference workload adapter, with and without a harness, and fail a seeded-bad variant for every behavior.
  • A sandbox conformance kit, testSandboxAdapter in @piship/adapter-conformance. Before, a company writing a custom sandbox backend could check its claims only by reading the contract, and PiShip's own outside check proves the environment and network claims only. The kit runs the backend as PiShip does and checks each claim by running commands inside it: a file planted on this host must stay unreadable, a loopback listener unreachable with the network denied, a planted launcher variable absent, the credential out of every result, error, and logged line, and a timed-out or cancelled command must not write the marker it would write later. It reports availability, capabilities, prepare, execute, environment filtering, secret leakage, filesystem claims, network claims, timeout, cancellation, cleanup, dispose, and fail-closed behavior, and for a declared shared or synchronized workspace PiShip's two-way sentinel, the git control probe, and the re-check after the validity window or an epoch() change, as passed, failed, or skipped with a reason that never holds a secret. A snapshot backend's workspace checks are skipped with declares snapshot, never passed; cleanup and the epoch check take hooks the adapter author provides (sandboxes, replaceEnvironment) and are skipped without them. The kit imports only @piship/adapter-sdk. Its tests pass reference backends that run real commands under Seatbelt or bubblewrap (local, snapshot, shared, synchronized, and one that returns output only at the end), and fail a reference seeded with one defect for every behavior. The cancellation check watches a command it aborted before it started for 2.8 s from the abort while nothing has printed (a first line restarts the count); a backend that starts the command later than that is not caught, which docs/adapter-sdk.md says.
  • The adapter SDK guide documents the sandbox credential a custom backend receives. Before, it said CustomBackendContext.credential came only from sandbox.credential: runtime and did not mention credentialOrigins or credentialRejected. It now lists the three sources (runtime, stored, and the module's sandboxCredential export), the origins each may be sent to, and what credentialRejected() does for each. SandboxCredentialAccess stays out of the SDK: a backend never sees it, and a module's own credential is a plain CredentialProvider, which the SDK already exports.

Sandbox contracts

  • Workspace consistency for sandbox backends (workspace). A remote backend declares whether its sandbox sees the files PiShip's file tools edit: shared, synchronized, or snapshot, the default. Before the first command that reaches the sandbox (never at activation, in Plan mode, or from doctor), PiShip verifies a shared or synchronized claim with a two-way sentinel in a directory it owns inside the project's git directory, and reports the weaker mode the evidence supports when a direction is delayed or missing. Only a verified shared or synchronized workspace, or a local backend, counts as a complete coding-agent workspace; a snapshot never does. The result is checked again after 30 minutes or when the backend reports a new environment through the optional SandboxInstance.epoch(), which the Kubernetes backend implements with its claim name. doctor's Workspace group now shows the consistency, the declared mode, the verification state, whether the workspace is complete, and how the git control files are protected.
  • A governed session records each workspace verification in the local metrics.json as a new workspace entry: the declared and effective modes, the verification state, and its time, never a path, sentinel token, or reason; a value PiShip does not define is stored as UNKNOWN. The field is additive within piship-metrics/v1. While doctor's own verification is pending, its Workspace group shows the last result a session recorded under the same declaration. When the effective mode is lower than the declared one, the session raises one notice per session, a warning that does not fail the launch; GovernanceSession.attachNotices() routes it to the UI. LocalMetrics.recordWorkspace() is the new recorder.
  • A sandbox credential for remote sandbox backends (sandbox credentials). Before, an e2b-compatible endpoint that requires X-API-Key, or a Kubernetes API that requires a bearer token, could be authenticated only when it sat on the inference gateway's origin (credential: runtime). sandbox.credential: stored sends the API key or token a person stores with the new <command> sandbox login, which reads it only from a no-echo prompt or the first line of a piped stdin (no flag, no environment variable, no file, no in-session command); <command> sandbox logout deletes it. It is kept in the configured secret store under piship:<id>:sandbox#<n>, with non-secret metadata in credentials-metadata/sandbox.json (piship-sandbox-credential-metadata/v1, a new state class), bound to the principal that stored it and to the origins of the endpoint (and Kubernetes router) it was stored for. Before its secret is read, one of another principal is deleted and refused, a rejected one is refused, and every URL it would reach must be one of those origins, so pointing the endpoint variable elsewhere fails closed and sends nothing. A custom adapter module may instead export sandboxCredential, a CredentialProvider that PiShip acquires per launch with the signed-in identity, holds in memory only, renews before expiry and once after a rejection, and revokes when the session ends. Audit records credential.acquire and credential.revoke with purpose: "sandbox", never the secret, origin, or endpoint.
  • doctor reports the stored sandbox credential in its Sandbox group. Before, nothing showed whether one was stored, rejected, or bound to someone else, until a launch failed. For a manifest that declares sandbox.credential: stored, doctor shows sandbox credential <state> (<source>, <kind>) in <store> and whether it is bound to the current principal and whether its recorded origin matches the configured endpoint. A credential the sandbox service rejected is a warning that names sandbox login; an absent credential, another user's, or one stored for another endpoint fails a required sandbox. Doctor reads only the non-secret metadata: never the secret, its reference or ID, or the recorded origins, and it deletes nothing.

Enterprise reference stack

  • An enterprise reference stack under examples/enterprise-reference/: a Docker Compose file with Keycloak 26.7.4, PostgreSQL 17, LiteLLM v1.103.0 (OSS features only), and a deterministic OpenAI-compatible mock upstream (streaming and non-streaming chat completions, /v1/models, and 401, 403, 429, and 5xx answers on request), every image pinned by digest and every port published on 127.0.0.1 only. The Keycloak realm has a public acmecode client that requires PKCE S256 and registers the port-less loopback redirect http://127.0.0.1/callback, and two users, alice and bob, in different groups. LiteLLM runs the examples/enterprise-litellm proxy config unchanged. Secrets come from a git-ignored .env that examples/enterprise-reference/scripts/generate-env.mjs fills with random values; scripts/get-token.mjs in the same directory signs a user in through Authorization Code with PKCE. Readiness is docker compose up --wait on healthchecks. The Reference E2E workflow (see Test infrastructure) runs it on Ubuntu.
  • A reference credential broker in the enterprise reference stack. Before, the stack had no broker, so nothing turned a Keycloak sign-in into a gateway credential and the http-broker contract had only the loopback fixture behind it. The broker (Node built-ins only, a broker compose service on 127.0.0.1) validates the access token (RS256 only, key by kid from the realm JWKS with a bounded refetch, issuer, audience piship-reference-broker, expiry and not-before with 30 s tolerance, azp), maps (iss, sub) to one LiteLLM user with a per-user budget and no team, mints a LiteLLM virtual key limited to the models of the user's groups (the group paths /engineering: acme/coder and acme/general; /support: acme/coder), and returns the documented response with credential_id in PiShip's alphabet, base_url, and the authenticated subject. It honors Idempotency-Key as documented (replay, 422 conflict, 503 while in flight, answers kept in memory), answers 401, 403, 429 and 503 with Retry-After as the contract reads them, rotates by generating a new key and deleting old ones (no Enterprise LiteLLM feature), and revokes by deleting the key that authenticates the call. It holds only the LiteLLM master key and logs no token, key, or upstream body. A contract test with a fake Keycloak and a fake LiteLLM, also driving PiShip's HttpBrokerCredentialProvider, runs without Docker (node --test test/*.test.mjs); its live check against a running stack (broker/live-check.mjs) and the contract tests run in Reference E2E. A security review shaped its limits: https is required for the JWKS and LiteLLM URLs outside loopback and the compose network, a revoke is rate limited per client address, records for an idempotency key are scoped to the principal and capped per principal, a cached JWKS is dropped an hour after the last good fetch, and issue and rotation are serialized per user.
  • An AcmeCode reference distribution for the enterprise reference stack, with tests that run it against the live stack (npm run test:reference, Docker required). Before, nothing tied the stack to a distribution, so the managed identity, credential, and inference paths had only loopback fixtures behind them. examples/enterprise-reference/piship.yaml follows examples/demo-company section by section (policy, required sandbox, audit, signed channels, release) and points at the stack: the Keycloak realm as the OIDC issuer with the public acmecode client and the port-less loopback redirect, the reference broker for the credential, LiteLLM as the gateway. It is locked with piship lock and builds; a copy that pins a release key passes every piship release gate on macOS arm64. The tests build and install it the way the managed E2E does, sign Alice in on the real Keycloak authorization page, and check the credential exchange against LiteLLM's own key record, storage in the platform secret store (with PISHIP_LIVE_SECRET_STORE=1) or the restricted file store, models, a streamed --smoke-model request, renewal of a key the gateway rejected, and logout (key and identity tokens revoked at LiteLLM and Keycloak, store empty). They also run Alice to Bob without a logout (Alice's key and session revoked, none of her credential, entitlement, model selection, or history reaches Bob, no trace of her secrets in the state directory or install home), refuse a model Bob is not entitled to at PiShip and at the gateway, and show a narrower distribution model list staying narrower than the entitlement. The tests use ports 38080, 34000, 38090, 35432, and 38070. The Reference E2E workflow runs these tests on Ubuntu with the Linux Secret Service; on macOS only a run with the restricted file store is recorded, since the Keychain refuses writes from a session without user interaction. The distribution's ID and launcher command are both acmecode-reference (build dist/acmecode-reference, models acmecode-reference/<model>, and the broker serves that ID through BROKER_DISTRIBUTION in compose.yaml), so it never shares a state directory, secret-store entries (piship:<id>:*), or a launcher with the demo company's acmecode; the ACMECODE_* variables, the Keycloak client acmecode, and the broker's azp default keep that name. Its sandbox denies reads of ~/.ssh, ~/.aws, ~/.gnupg, ~/.netrc, ~/.git-credentials, ~/.docker/config.json, ~/.kube, ~/.npmrc, ~/.config/gh, ~/.local/share/keyrings, and ~/Library/Keychains, since it is a manifest people copy.
  • A reference container sandbox for the AcmeCode reference distribution, examples/enterprise-reference/sandbox. Before, sandbox.provider: custom had SDK example adapters that ran against fakes and no working service behind one, so the shared workspace, the stored sandbox credential, and the sandbox conformance kit had never met a real sandbox. An organization-style service (Node built-ins and the docker command line, 127.0.0.1 only) starts an unprivileged container per session for the user whose key asked (that user's uid, no capabilities, no new privileges, a read-only root filesystem, limits, no network when the profile denies it, only the project mounted at /workspace, .git read-only), and a single-file adapter on @piship/adapter-sdk declares a shared workspace with workspace-confinement and git-control-protection. The service answers 401 without a key it issued (only hashes are kept, and generate-key.mjs never prints a key), gives a user only that user's sandboxes, and answers only for its own loopback name. Each registry key is bound to what it may mount: the host user whose projects it may mount (uid, which generate-key.mjs fills with the running user or --uid), optionally narrowed to directories (roots, --root), or unbound: true (--unbound) for a single-user service; an entry that says none of these is refused at start. A project a key may not mount, or one owned by root or in root's group, gets 422 and the service makes nothing in it. A command's environment reaches the container on the standard input of docker exec --interactive, one NAME=value line each and an empty line, where a reader exports it and then starts the command with its standard input closed (an environment cut short starts nothing), so it is in no file and on no process list. The service stops a command and everything it started when the caller's connection ends; cancelling or timing out a command ends every process of the sandbox but its init and main process when no other command runs in it, and while commands overlap only the processes marked with the command's PISHIP_EXEC_ID (one that scrubbed its environment then survives until the sandbox is removed, as the sandbox README says). It names itself reference-<uid>-<port> unless SANDBOX_INSTANCE is set, labels its containers with that instance and the user (piship.sandbox.owner), and removes its containers when it stops, when a sandbox is idle for an hour or lives 12 hours, and, once it has its port, those of an earlier run with both labels. No PiShip behavior or contract changed.
  • A second manifest and lock, examples/enterprise-reference/sandbox/piship.yaml, build the AcmeCode reference distribution with that sandbox: sandbox.provider: custom, the adapter, endpoint: ${ACMECODE_SANDBOX_URL}, and credential: stored (each user runs sandbox login with the key the organization issued), and one more runtime variable. The reference manifest and its lock are unchanged, so the reference tests are too. The variant sits in its own directory because a lock is piship.lock next to its manifest and a manifest cannot reach outside its directory; a unit test (tests/reference-sandbox-variant.test.ts) keeps it equal to the reference manifest outside its sandbox and its resources identical, and checks that the adapter is one file importing only the SDK, and the example-lock test in packages/core/src/release.test.ts now lists it.

Changed

  • Docs: every documented CLI command runs as node packages/cli/dist/bin.js. Going through npm's executor failed on a clean clone, because npm ci links no bin before the build, and npm then looked the unclaimed package name up on the public registry; a test rejects that form in tracked Markdown (#132).

  • Docs: the personal walkthrough installs with --use-existing-state after piship test, which writes the distribution's real state, so the documented order works (#140). The personal README and security page describe what Pi's interactive mode does at start: it may download fd and rg into ~/.pi/agent/bin, contacts pi.dev, and shows pi update and pi --session hints; PI_OFFLINE=1 stops the network part in personal distributions only (#138).

  • Docs: the Kubernetes sandbox example sets network.mode: allow, since that backend cannot enforce deny, and the workload identity example reads ACME_WORKLOAD_IDENTITY_PATH, which managed mode does not remove; a test checks both (#141). A new troubleshooting page lists the prerequisites and every error code with its action, and a test keeps it complete (#147).

  • Docs: v0.7 keeps piship/v1alpha4 and piship-lock/v1alpha4. sandbox.credential: stored and the lock keys runtime.stateSchemas.sandboxCredential and credentialIssuance are backward-compatible additions, which keep a preview schema's version; removing or reinterpreting a field, or making one required, needs a new version (decision 27, manifest).

  • Docs: the dispose hook and the session file check are registered as Pi 0.87.1 compatibility shims in compatibility, with an owner and an expiry at the next Pi upgrade. The compatibility page said there were none. A session file check test now pins that an entry type the check does not know is accepted.

  • Docs: workspace consistency and git-control protection are recorded as separate results (decision 28): a shared or synchronized workspace is verified by two-way propagation, and a protected path that might only be renamed is not-verified git control, a warning rather than a downgrade. Under bubblewrap that is the report in every regular repository, a stated non-claim.

  • Docs: the lifecycle lock's 24-hour bound is recorded as a decision. docs/release/update-lifecycle.md (and the install layout in docs/architecture.md) say that a crashed holder whose process ID is in use blocks update, rollback, and uninstall for up to 24 hours, quote the error and the action that names apps/<id>/.lifecycle.lock, and say the file may be removed by hand once no lifecycle command is running. No code changed.

  • Docs: statements that v0.7 made untrue are corrected. The enterprise integration contract and the enterprise-litellm example no longer say the broker and gateway paths are tested only with local fixtures, and name the enterprise reference stack that runs them against Keycloak, a reference broker, and LiteLLM. The contract's token table and the demo company README say that purge confirms every secret deletion and fails before it removes state, where they said best effort. The manifest guide lists the sandboxCredential and credentialIssuance state schemas, --new-session, and uninstall <id> --purge --yes; the demo company README lists the doctor groups that exist (the "MCP and audit" section is gone). The sandbox guide and the README state sandbox maturity per backend as the status page does, the inference guide says no InferenceProvider adapter loads, and the security guide says what the Windows Job Object covers and summarizes the session checks.

  • The packages, the CLI (--version), and the PiShip version recorded in locks and release metadata are 0.7.0; they said 0.1.0.

Fixed

  • The SDK example adapters pass their conformance kits. credential.mjs sends CredentialContext.idempotencyKey as Idempotency-Key and reports it as detail.idempotencyKey on every failure, refuses a credential that has already expired with CREDENTIAL_EXPIRED, and turns an answer that is not JSON or lacks a field into a PiShipError instead of a SyntaxError whose message can quote the body. identity.mjs maps HTTP 401 and an OAuth invalid_grant to IDENTITY_EXPIRED, reads no body from a 204, treats the logout of a session the service had already revoked as done, reports the issuer the service asserts instead of its own URL, and refuses an answer without a subject, issuer, or access token. sandbox.mjs returns a fixed reason from available() and never passes a transport error on, since either could quote the credential. tests/adapter-kits/examples.test.ts runs the kits against the identity, credential, and sandbox examples.

  • The stored sandbox credential that DistributionAccess hands out (sandboxCredential(), used by login, logout, and purge) now opens the store of another provider by the same rule as the runtime credential and the identity: none for an injected store. Before, it passed no resolver unless one was injected, so SandboxCredential's own default opened the other store beside the state for any injected store that was not in memory, and the slot deleted from a store the credential and the identity treated as unavailable. Only an embedder or a test that injects a non-memory secretStore without secretStoreFor sees a difference; an access that creates its own store, which is every shipped path, behaves as before.

  • The release vulnerability gate audits the payload's runtime dependencies. npm audit ran without the workspace package manifests, so it took every locked package for a dev dependency and --omit=dev audited nothing: the gate always passed, and brace-expansion 5.0.9 with two high advisories passed failOn: high. Now nested runtime packages are scanned and a blocking advisory fails piship release. The example manifests carry a reviewed exception for those two advisories until 2026-12-31, because upstream Pi pins the version in its shrinkwrap (#129).

  • A forced credential renewal after a gateway rejection renews only the generation the session sent. Two processes that both got a 401 for the same credential each renewed: the second renewed the credential the first had just stored, issuing one more. The second now adopts the first one's renewal (#157).

  • A 2xx broker answer that PiShip refuses after the broker issued the credential (a different subject, an undeclared base_url, or an already expired credential) now revokes that credential through the broker's revoke endpoint before the refusal is raised. Before, it stayed live at the broker with nothing recording it. If the revocation fails, the refusal says the credential stays live until it expires (issuedNotRevoked in its detail) (#157).

  • Revoking a custom sandbox adapter's own credential while its renewal was in flight left the renewed credential live. Revoke now waits for the renewal and revokes both credentials (#157).

  • Behavior change: update refuses to replace a retained release (the one rolled back from) while a running session still holds a runtime lease on it. Before, it removed that directory under the running session (#158).

  • Behavior change: on a remote shared or synchronized workspace whose backend reports epoch(), PiShip runs a no-op (true) before each command once the workspace is verified, so an environment the backend replaces (an expired Kubernetes claim) is verified before a command runs in it. Before, a command ran in the replaced environment first. A command during which the environment is still replaced fails instead of reporting its result (#158).

  • redact() covers X-Api-Key and api-key headers, any Authorization scheme (Token), ?token= and &token= query parameters, Cookie and Set-Cookie headers, JSON keys with escaped quotes, and camelCase key names such as secretAccessKey (#159).

  • The audit sanitizer replaces control characters before scrubbing. A control character inside a token's label hid the token from the scrubber, and replacing it afterwards wrote a recognizable token to the audit log (#159).

  • On macOS and Windows, the built-in protection of the project's git config, hooks, and info trees ignores case. A write to .git/HOOKS/pre-commit while .git/hooks did not exist matched no protected path and created a hook directory git runs (#159).

  • The macOS Seatbelt profile denies connections to the Docker daemon's socket (any unix socket named docker.sock or docker.raw.sock) also with the network allowed, so a contained command cannot start a container outside the sandbox (#159).

Test infrastructure

Clean-machine, lifecycle, and security E2E

  • Runtime leases are exercised through the installed launcher by a real long-running session process. A session started on 1.0.0 keeps its release through updates to 1.1.0 and 1.2.0 (and through an update that disables rollback), blocks uninstall, still reads a packaged resource of 1.0.0 on demand, and the next recovery reclaims 1.0.0 once it has exited (packages/core/src/lifecycle.test.ts, installed launcher).
  • Enterprise networking is qualified through the real launcher on loopback, with no compose stack: the proxy path (every managed request goes through the inherited proxy, and a proxy that refuses the traffic stops the launch), the NO_PROXY path, the custom CA path (a bundle without the server certificate fails the request), private endpoints under private-only, publicFallback: deny (an undeclared public host is refused before any proxy or connection), and TLS that is never disabled or downgraded. The docs now state that network.privateOnly matches hostnames only: it ignores ports and schemes and does not check that a host is a private address.
  • The personal local-secret and none access-mode E2E scenarios (identity.mode: none with an OpenAI-compatible endpoint) now run on every Portable E2E target instead of only on the Ubuntu qualification path, in their own file, tests/e2e/personal-access-modes.test.ts. They took 13 s and 9 s on Ubuntu in the last Release qualification, and a comparable build takes 19 s on macOS and 47 s on Windows. They, personal-local-model, and new unit tests also check that binding credentials to a principal changes nothing for a personal distribution: no identity/principal.json is written, a local secret stored by an earlier release stays usable, and login, logout, and login again keep the same model selection (the E2E scenarios also assert that the same session resumes).
  • The managed E2E scenarios run on the live platform secret store on every Portable E2E target (macOS Keychain in a temporary keychain the job creates and deletes, Windows Credential Manager, and the Linux Secret Service under an unlocked GNOME Keyring on a private D-Bus session), not only on the file fallback that the E2E helpers forced before. A new scenario, tests/e2e/lifecycle-secret-store.ts (run as lifecycle-secret-store-system.test.ts on the platform store and lifecycle-secret-store-file.test.ts on the file fallback), covers store, load, replace, delete, credential and identity refresh, update, rollback, logout, and purge with an identity token bundle large enough to be split across platform items on every OS; user-switching also runs on both storages. After each step the platform store holds exactly the references the metadata names, and the file fallback directory never exists for a distribution that declares system storage. The system-storage scenarios run only with PISHIP_LIVE_SECRET_STORE=1, which the Portable E2E workflow sets for its E2E step alone; elsewhere they report as skipped with the reason.
  • Each of the thirteen security cases of the v0.7 test plan has a test at the boundary it claims, and the security test map names the file and title of every one. New: a hostile browser against the OIDC client (a callback with another or no state, a request with another or no nonce, a callback naming another issuer, a replayed callback, an authorization code issued for another PKCE verifier), run against the fixture provider in tests/security/oidc-invalid.test.ts, which also proves a rejected sign-in stores nothing, asks the broker for nothing, and leaves the signed-in user and their credential in place, and against a live Keycloak in tests/enterprise-reference/security-oidc.test.ts; a sweep for the runtime credential and the access, refresh, and ID tokens, in plain and decoded form, through everything a real managed run writes or prints (files, state, audit file and sink requests, sessions, model requests and tool results, command output, update and rollback reports, snapshots) in tests/e2e/security-sweep.test.ts and security-lifecycle.test.ts; the stored sandbox credential across a change of user, a service that rejects it and echoes it, and an outage (security-sandbox.test.ts); launcher-level cases for the six mandatory controls and for team, project, and user rules that try to widen an enforced rule (security-controls.test.ts), with the engine checked over every combination of layers (tests/security/policy-widening.test.ts). The no-residue checks of the user-switching, lifecycle-credentials, and lifecycle-rollback E2E now search the file fallback in decoded form, since a plain search cannot see a value that store keeps base64url encoded. The sweep found a gateway that echoes its Authorization header into an error body leaving the runtime credential in the Pi session file; with the session-file redaction below it searches sessions/ like everything else.
  • A personal clean-machine E2E, tests/e2e/personal-clean-machine.ts (run as personal-clean-machine-mypi.test.ts and personal-clean-machine-local.test.ts), takes MyPi and MyPi Local each through the whole personal flow in one test on every Portable E2E target: install from a verified release with its own script, launch, credential, a real model request, session resume, doctor, update from a signed loopback channel, rollback, and uninstall. Before, personal-lifecycle made no model request and personal-local-model had no install, update or rollback. MyPi delegates its key to Pi's own auth file in its isolated agent directory: the test checks that the request carries that key and neither an environment provider key nor the user's ~/.pi, and that the key is in state only in Pi's auth file, not in the update and rollback records. MyPi Local stores its key with login in the file secret store (its test uses a copy of the manifest with the file store in place of the declared system store), the test checks that the key is in state only in that store's file, in plain form or as the store encodes it, and doctor reports the model list its endpoint returns. The lifecycle fixtures build a third release pair, MyPi Local with a channel source, a pinned key and the file secret store added to a copy of the example manifest, run in the lifecycle scenarios takes standard input, and the personal scenarios kill an installed command that has not finished after 300 s and fail with its arguments and the output so far, instead of waiting for the test's own timeout.
  • The managed clean-machine flow is one continuous E2E scenario, tests/e2e/managed-clean-machine.test.ts: install from the verified release with its shipped script, launch (refused before sign-in, without contacting a service), login, credential acquire, model discovery with models, an authenticated model request with --smoke-model, session resume, doctor, commands run through the native sandbox, update, rollback, logout, and uninstall with purge, on one installed distribution with one session, one secret store, and one set of fixture services. It runs on every Portable E2E target with the live platform secret store where PISHIP_LIVE_SECRET_STORE=1 (CI) and with the file fallback elsewhere, so a developer's keychain is never touched. Each step asserts an outcome (exit status and output, state on disk, secret store, audit log, or what the fixtures recorded); the file header states what "same flow" means per step and what the fixtures do not prove. The sandbox step runs on Linux (bubblewrap) and macOS (Seatbelt) with a copy of the demo whose owner allows shell commands in Build mode (lifecycleScenario(name, { commands: true })), because the demo as shipped asks before every command and a headless run denies that; the commands read the user's SSH key, write outside the workspace, and open a socket, and each is refused. Windows has no native sandbox backend, so there the flow asserts that the launch reports the sandbox as not required, runs no command, and says so.
  • The enterprise reference stack's distribution flow now covers the whole managed clean-machine flow against the live Keycloak, credential broker, and LiteLLM: beside install, sign-in, credential, models, a model request, session resume, doctor, and logout, it runs commands the model asks for inside the required sandbox (each escape refused), updates through a signed channel, rolls back, and uninstalls and purges, and it checks the audit trail and the platform store. The stack's copy of AcmeCode for this flow allows shell commands in Build mode and pins a release key of its own; the update channel is a loopback directory the test signs. To make the model ask for a tool, the mock upstream takes queued tool calls (POST /__mock/faults with {"toolCall": {"name", "arguments"}}), one per streamed request, and records the tool name in /__mock/requests; it can listen on a chosen host (MOCK_UPSTREAM_LISTEN_HOST, 0.0.0.0 by default), and its tool-call behavior has Docker-free tests that the Reference E2E workflow runs.
  • The rollback sentinel tests cover 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 (tests/e2e/lifecycle-credentials.test.ts), with fault injection for a locked secret store during logout there and during update, rollback, and purge in packages/core/src/lifecycle.test.ts. A unit test keeps STATE_DATA_CLASSES and the state table in docs/architecture.md in step.
  • Portable E2E runs each target's E2E files in shards on runners of their own (two on Ubuntu, three on macOS, four on Windows) instead of one job per target. The files are split by their measured duration and the release fixtures they use (tests/helpers/e2e-shards.ts, used by npm run test:e2e -- --shard=<i>/<n>), each shard prebuilds only its own fixtures, and files that wait for the live platform store go to different shards. The secret store lifecycle and user switching have a test file per storage, and the personal clean-machine flow one per example, so their scenarios can run on different runners. Every scenario still runs on every target.

Reference stack and CI

  • Tests against the live enterprise reference stack, run by npm run test:reference under tests/enterprise-reference/, show from LiteLLM's own spend records that one employee's credentials A, B and C, minted by the reference broker from a real Keycloak sign-in, accrue one spend total against one budget, that rotating and revoking keys keeps it, that a second employee has a separate one, and that the gateway itself refuses a request over the budget, over the requests-per-minute or tokens-per-minute limit, over the key's parallel-request limit, or for a model outside the key's list. Each file starts its own Compose project on the test ports (28080, 24000, 28090, 25432, 28070; one file at a time) and removes it afterwards. They also check that LiteLLM v1.103.0 enforces team-member budgets (max_budget_in_team) while ignoring the user's personal budget on team keys; the broker keeps issuing keys without a team.
  • The conformance kits run against PiShip's own implementations in tests/adapter-kits/ (kit self-tests): the http-broker provider and the http audit sink's delivery pass every behavior they can show, the native backend and a custom backend passed through customBackend() pass, and the e2b-compatible backend, against a fake E2B service that runs each command under Seatbelt or bubblewrap, fails two behaviors where the kit is stricter than PiShip, which the docs list with the others. @piship/audit exports HttpSinkWriter, the http sink's delivery, for that test. The audit kit gains seeded defects that fail event id stability and delivery failure alone, and a test that every audit behavior has one.
  • A Reference E2E workflow (.github/workflows/reference-e2e.yml) runs the AcmeCode reference distribution against the live enterprise reference stack on ubuntu-latest: it pulls the images, starts the stack with docker compose up --wait and records the pull and startup times in the job summary, runs the broker's live check and contract tests, and then npm run test:reference with the Linux Secret Service (GNOME Keyring on a private D-Bus session, PISHIP_LIVE_SECRET_STORE=1, no file fallback). Release qualification runs it next to CI, CodeQL, and Portable E2E, and Release candidate now waits for it; it also runs nightly (a failure opens a tracking issue) and by workflow_dispatch, and never on pull requests. On failure it uploads the container logs of every stack, scrubbed by examples/enterprise-reference/scripts/scrub-logs.mjs; the reference tests write those logs only when PISHIP_REFERENCE_LOG_DIR is set. The scrubber replaces every .env value of 8 characters or more (ports excepted), anything shaped like a LiteLLM key, a JWT, or a Bearer or Basic value, and an OAuth code, state, or session_state where it is a URL parameter (after ?, &, #, or &amp;, also percent-encoded once or twice inside another URL, and the whole value up to white space, a quote, <, >, or &) or a token-shaped value of 16 characters or more in a JSON object, also when that JSON is a quoted string. Words in ordinary log text (exit code=137, state=running) and short JSON values ("code":"401") are kept; a form-encoded body logged without a URL, a URL encoded three times, and other secret shapes are not recognized, so it is a filter for the reference stack's own logs, not a general one. Its tests (node --test scripts/test/*.test.mjs) run in the workflow and bound its scan to linear time.
  • A manual Live provider workflow (.github/workflows/live-provider.yml, workflow_dispatch only, never in the merge gate or Release qualification) routes the reference LiteLLM to a real model provider and sends one --smoke-model request through the installed AcmeCode distribution, with the result (status, stop reason, reply length, time) in the job summary and the provider name shown as redacted unless the dispatch input turns that off. The provider key, base URL, and model come from the LIVE_PROVIDER_API_KEY, LIVE_PROVIDER_BASE_URL, and LIVE_PROVIDER_MODEL secrets of the live-provider environment into the request step only; the environment must exist and be limited to protected branches before a run, since naming one that does not exist makes GitHub create it without protection (the workflow header and the README give the steps). The job log is public, so the workflow prints no LiteLLM or broker logs and the test puts no AcmeCode output in a failure message: on an upstream error those name the provider, its host, and its model in forms that secret masking and scrub-logs.mjs do not replace. A failed run shows only the test's messages, the result JSON, and the container states. The routing is a Compose override, examples/enterprise-reference/compose.live-provider.yaml, with a variant of the enterprise-litellm config, litellm-live-provider.yaml; the committed compose.yaml and LiteLLM config are unchanged. startStack in the reference distribution tests takes override files, AcmeCode's environment in those tests never holds a LIVE_PROVIDER_* variable, and kept container logs are also scrubbed of every LIVE_PROVIDER_* value.
  • Gateway evidence against the live enterprise reference stack (tests/enterprise-reference/gateway-evidence.test.ts, npm run test:reference). LiteLLM does not document its error bodies, so the test records what v1.103.0 answers: a missing, malformed, unknown, deleted, expired or blocked virtual key is 401 with its own error type (auth_error, token_not_found_in_db, expired_key), which PiShip's inference client reads as CREDENTIAL_REVOKED; the installed AcmeCode distribution renews a key the gateway blocked or expired at launch and reports CREDENTIAL_REVOKED when the broker is down for the renewal; /v1/models lists only a key's models, and PiShip offers the intersection of allowlist, entitlement and live list; a streamed answer ends with stop, a usage chunk and [DONE]; upstream 403, 429 and 5xx come back with their status after three attempts, and an upstream 401 at once, followed by a 5 s cooldown in which every key gets 429 all_deployments_in_cooldown; a stream the upstream cuts ends with an error event and no [DONE], and Pi reports it as a failed request with the partial text, never as an answer. How PiShip reads a provider refusal that LiteLLM relays is described under Behavior and contract changes (Credential providers, the broker, and gateway errors). The mock upstream can now cut a stream mid-answer ([mock:cut=N] in a streamed request, or {"cut": N} on POST /__mock/faults), and tests/enterprise-reference/stack.ts takes per-file ports and can stop and restart one service.
  • The AcmeCode reference tests (examples/enterprise-reference/tests) start each file's stack as a Compose project of its own, piship-reftest-<pid>-distribution-<random>, with its env file and installed distribution in piship-reftest-<pid>-* directories, and stop it on exit, Ctrl-C, and termination, so a second npm run test:reference never stops the first one's stack. What a killed run left (Compose projects, temporary directories, and sandbox containers labelled piship.sandbox.instance=piship-reftest-<pid>-... whose process is gone, removed by ID) is removed by the suite's global setup, as for the files under tests/enterprise-reference. A command the tests run is killed after 100 s, below the 120 s test timeout, so a hang fails with the command's output. With PISHIP_LIVE_SECRET_STORE=1 on macOS the tests refuse to run unless CI is set, since the platform store there is the developer's own login keychain.
  • Evidence for the reference container sandbox, run by npm run test:reference and the Reference E2E workflow (Docker required), in examples/enterprise-reference/tests/sandbox.test.ts. The sandbox conformance kit passes all 16 behaviors against the service with real containers, none failed and none skipped (including network claims against a listener in a container of its own, workspace consistency, and git control protection), and, seeded by hand into the service while it was written, a writable .git mount, an ignored network denial, and a cancel that only killed the docker exec client each failed the behaviors they should; no test seeds them again, and the sandbox README names the test that pins each. A governed session opened on the payload built from the committed manifest and lock, with the sandbox credential stored for a principal, shows PiShip's workspace check preceding the first sandboxed command and finding the workspace shared (both directions immediate, protected git paths read-only, git control attested-renames), nothing left in .git/piship-workspace, a hooks directory in the working tree read-only, a hooks directory that does not exist refused before any container starts, a rejected key marked rejected, and a runtime variable that names another host sent nothing. The file also checks the container as docker inspect shows it, that the service answers no other address of the machine, and that no command's environment is on the process list. The test starts its own service, which answers /health with its instance and exits with its sandboxes removed when its standard input closes (SANDBOX_EXIT_ON_STDIN_END=1), waits for a free port, polls the process list, and scans the kit's report and the run's output for the user's real key. 44 contract tests of the service and the adapter run against a fake docker without Docker (node --test test/*.test.mjs in sandbox/), also in the Reference E2E workflow.
  • The Reference E2E workflow runs its test files in four groups on four runners at once, named in vitest.reference.config.ts (PISHIP_REFERENCE_SHARD=<group>/<count>) and balanced by their measured durations, beside a stack job that runs the stack as a user starts it, the broker live check, and the contract tests that need no stack. Each group's job pulls the images and installs its system packages in the background while it runs npm ci and the build. Every scenario and assertion still runs, each file still against its own stack, and the run fails when any job fails. The run lasts as long as its slowest group, about 205 s of tests by the durations measured before the split, plus setup, instead of 12 to 14 minutes for every file in turn. The container logs of a failed job are uploaded as reference-e2e-logs-stack or reference-e2e-logs-<group>.

v0.6

Preview milestone; not published to npm. Frozen at commit ab3e7f2 on main, the last commit before a v0.7 pull request merges; the qualification runs recorded for it are on docs/status.md. Schema unchanged: piship/v1alpha4 and piship-lock/v1alpha4.

Added

  • Local metrics (<state>/logs/metrics.json) are now recorded at their real call sites: identity sign-in and session-check durations, broker and adapter credential acquire and refresh durations, gateway reachability (from each live catalog fetch and the doctor probe; the probe is not recorded again when activation already fetched the catalog), live model catalog fetches (time and model count), resource and provider load failures by error code, and the running distribution, PiShip, Pi, and Node versions. Access and the governed session share one metrics instance per launch, so neither save overwrites the other. Metrics remain metadata-only: numbers, times, error codes, and version strings, never a URL, token, subject, model ID, or content.
  • compatibility/pi.json (schema 1) and the PI_COMPATIBILITY copy in @piship/core add a governance surface, candidate for Pi 0.87.1: CI and Portable E2E evidence on Linux, macOS, and Windows, no live company services.
  • A Pi latest canary workflow (nightly and manual, never on pull requests) installs the newest published Pi over the pin without saving it, runs the compatibility suite on Ubuntu, macOS, and Windows with PISHIP_PI_CANARY=1 (which relaxes only the exact-version assertions), and opens or updates a tracking issue when a scheduled run fails. It does not exercise a branded launch.
  • Nightly Portable E2E opens or updates a tracking issue when a scheduled run fails.
  • A Release qualification workflow (manual only) runs CI, CodeQL, and the full Portable E2E in parallel on one commit, then Release candidate once all three pass, so a release needs one green run instead of three separately dispatched and cross-checked ones.
  • The Release candidate workflow also builds, reproduces, attests, verifies, and installs the personal example (mypi) on all three targets and runs its installed offline --smoke and doctor. Reproducibility reports are named reproducibility-<distribution>-<target>.
  • Pluggable sandbox backends, a preview. sandbox.provider selects native (the default: bubblewrap or Seatbelt), custom (a company adapter module, locked and packaged like other adapters), e2b-compatible (E2B, CubeSandbox, or any service implementing the E2B API), or kubernetes-agent-sandbox, with adapter, endpoint, router, namespace, template, workdir, user (e2b-compatible only; root for CubeSandbox), and credential settings that each provider accepts only where they apply. All backends implement one SandboxBackend contract in @piship/sandbox (availability, capabilities, prepare, exec, optional local wrap, dispose). PiShip keeps policy, approvals, audit, the approved environment, credentials, and the timeout and cancellation of every command; a remote backend never receives files, host-bound variables such as PATH or HOME, or credential-looking variables, and receives the inference runtime credential only with credential: runtime on the inference gateway origin (not a general sandbox authentication model). A required sandbox whose backend is unavailable, lacks a guarantee the policy needs, or fails its check fails closed with SANDBOX_UNAVAILABLE. Reports never claim more than a backend enforces: local backends must enforce the filesystem-* path planes, while the E2B and Kubernetes backends report only host-filesystem-isolation (host files unreachable), network-deny, and environment-filter, and doctor says that path rules are not enforced inside a remote sandbox. Kubernetes claims carry a bounded spec.lifecycle (shutdownTime, shutdownPolicy: Delete) that PiShip renews while the session uses them, so a claim whose cleanup fails still expires. The containment report adds provider, verification (live-probe or backend-attested), and localProcesses; backends that cannot contain local processes refuse MCP stdio servers. Tested with fake adapters and mock E2B and Kubernetes servers only; no live service evidence.

Changed

  • Behavior change: release archives are compressed at zlib level 6 instead of 9. piship release of the personal example dropped from about 24.7 s to 13.6 s on an M4 Max, for an archive 0.44% larger (145.6 MB instead of 145.0 MB). Archives stay byte-reproducible for the same inputs, but their SHA-256 digests differ from archives built at level 9; the extracted contents are unchanged.
  • Behavior change: piship build removes optional packages whose npm lock os/cpu exclude the build target. npm installs every platform build inside a shrinkwrapped dependency, so each payload carried all 26 of Pi's @esbuild/* binaries, 25 of them for other targets. On macOS arm64 the personal example's payload shrinks from about 427 MB to 140 MB and its release archive from about 145 MB to 34 MB; the SBOM and notices list only the packages that remain. package-lock.json in the payload is unchanged.
  • Project consolidation; see docs/roadmap.md.
  • Documentation: a single status page for what current main supports and the evidence behind it; stale CI tier, schema, and revocation claims corrected; the manifest guide lists its differences from the product specification.
  • Documentation: the release guide is split into owner workflow, artifact contract, and update lifecycle pages; docs/release.md is an index that maps every former section to its new location.
  • Documentation: an enterprise integration contract lists the IdP, credential broker, and OpenAI-compatible gateway endpoints PiShip calls, with a LiteLLM example under examples/enterprise-litellm/.
  • This changelog is organized by milestone.
  • The CI check jobs now run Pi compatibility on every target and the live Keychain and Credential Manager test on macOS and Windows for every pull request. The path-scoped Pi compatibility and Secret store workflows are removed: a pull request that did not touch their paths never reported their checks, which blocked the merge once they were required.
  • Behavior change: doctor shows the effective outbound state on one outbound line in place of the public fallback and private-only lines. When private-only is in effect, the line is a ✓ that lists the declared hosts. Personal mode without private-only shows a neutral informational line, and the old ✓ "allowed (personal owner policy)" is gone.
  • Contract change: @piship/contracts no longer exports the error codes APPROVAL_REQUIRED, PROVIDER_UNHEALTHY, PROVIDER_UNRESOLVED, RESOURCE_DENIED, and SANDBOX_REQUIRED (removed from PISHIP_ERROR_CODES and PiShipErrorCode); no code path produced them. Headless ask resolves to deny, denied tools and resources are refused and audited as denials (refused commands and settings use POLICY_DENIED), provider problems are reported on the resolved, enabled, and healthy capability axes, and a required sandbox that cannot be enforced fails with SANDBOX_UNAVAILABLE. A unit test now requires every remaining code to have a producing path. The manifest guide records the difference from the product specification (removed error codes).
  • Behavior change: the audit file sink rotates logs/audit.jsonl by size: before it would pass 10 MB it becomes audit.jsonl.1, and five rotated files are kept (about 60 MB in total; approximate while several processes write at once). The limits are fixed and not a manifest setting. Rotation only renames files under a lock, so concurrent appenders never lose a retained line. The update and rollback migration check reads the newest audit schema from the rotated file when audit.jsonl is empty.
  • Contract change: modelRequirementGaps, incompatibleCapabilities, and ModelIncompatibility moved from @piship/core to @piship/policy, which also exports ModelEvidence; @piship/core no longer exports them and adds configuredModel. @piship/policy drops CapabilityStateInput.modelToolSupport and TOOL_DEPENDENT_CAPABILITIES (no caller passed them) and adds CapabilityStateInput.model and policyDenied.
  • Behavior change: release.json records the governance surface in pi.surfaces and counts it toward pi.compatibility, now the weakest of the deployment, governance, and lifecycle surfaces, whenever the distribution declares governance (every piship/v1alpha3 or later manifest). The pi release gate refuses a release whose governance surface is unsupported.
  • Behavior change: piship build runs the release source and install-script gates on piship/v1alpha4 locks before assembling the payload (Build gate source: ..., Build gate install-script: ...). piship dev and piship test do not; piship release is unchanged.
  • Behavior change: the demo company's policy.projectTrust.company matcher requires both remote: "git.acme.example/**" and path: "/srv/src/**", as the manifest guide recommends; its piship.lock is regenerated.
  • The README opens with the enterprise case for PiShip ("Why PiShip?") before the quickstart.
  • Contract change: in @piship/sandbox, the native adapters run through NativeBackend; ActiveSandbox.dispose() returns a promise; ContainmentReport.adapter is a backend id string and the report adds provider, verification, and localProcesses; probeSandbox also accepts a target that prepares a wrapping instance. A timed-out or cancelled sandboxed command stops forwarding output at the moment PiShip aborts it. piship release applies the no-sandbox-on-Windows gate only to the native provider.
  • The README quickstart starts the local fixtures first and lists the sandbox, secret-store, and Windows (SANDBOX_UNAVAILABLE) requirements; the local-model walkthrough runs the stand-in server in its own terminal and documents the secret-store requirement.
  • Removed dead pull_request concurrency expressions and the fork-attestation condition from the dispatch- and schedule-only Portable E2E and Release candidate workflows.

Security

  • Base URLs are trimmed of trailing slashes in linear time; the previous regular expression was polynomial on long runs of / in a configured gateway or broker URL.
  • Behavior change: In managed mode, user policy rules in <state>/config/policy.json are narrowing only, like team and project rules. A user allow rule is ignored and reported in doctor and policy explain and recorded as a policy.violation audit event. User ask and deny rules join the strictest-effect comparison, so they can tighten a decision but never relax a distribution default or policy.default. Personal mode is unchanged: there, a matching user rule still takes the place of the distribution default.
  • Behavior change: Project resource directories (.pi/skills, .agents/skills, .pi/prompts, .pi/extensions, .pi/themes, .pi/agents, .piship/providers) are now walked, following links that stay inside the project root, up to 50,000 entries and 32 levels deep. A directory is treated like a top-level link that leaves the root when any link inside it leaves the root, or when it is too large or too deep to check: executable items are denied, and other items are evaluated as unknown origin. doctor names the offending entry or limit. The walk runs at launch and does not detect links created afterward.
  • If the tool_call policy decision throws (for example, because the audit buffer is full), the tool call is blocked. The model sees a sanitized reason that names only the error code, and the block is recorded as tool.denied. Before this fix, Pi 0.87.1 already refused the call but passed the unredacted error message to the model. A compatibility test now pins Pi's refusal.
  • Behavior change: @piship/contracts now holds the only redaction pattern set. redact now also removes GitHub, GitLab, Slack, AWS, and Google API tokens, standalone Basic credentials whose value looks like base64 (so text like "basic authentication" is kept), JWT shapes without the eyJ prefix, and PEM private keys. Before, only audit events removed these shapes. JWT matching starts only at the beginning of a token run, so adversarial output can no longer make redaction quadratic. As a result, MCP tool results sent to the model, stderr, error messages, doctor, and config explain are also redacted more thoroughly. redactValue redacts token, cookie, passwd, client_secret, and bearer keys too (new export SECRET_KEY_PATTERN). scrubText in @piship/audit is now the same function as redact.
  • Behavior change: A credential: runtime Streamable HTTP MCP server receives the runtime credential only when its URL has the same origin (scheme, host, and port) as inference.baseUrl, the gateway the credential is issued for. A server on any other origin fails to start with MCP_UNHEALTHY before the credential is read. A required server therefore fails the launch.
  • Behavior change: In managed mode, network.publicFallback: deny is now enforced. Managed mode requires deny, so every managed launch is private-only, whatever network.privateOnly says. The managed fetch and the Pi process's default dispatcher accept only the issuer, broker, and gateway hosts, network.allowHosts, and, for update commands, the updates.source host. Managed distributions with Streamable HTTP MCP servers or HTTP audit sinks on other hosts must list those hosts in network.allowHosts. config explain shows the effective network.privateOnly, and doctor warns about any HTTP MCP server or HTTP audit sink whose host is not declared. Undeclared hosts are never allowed implicitly.

Fixed

  • Two audit writers no longer rotate logs/audit.jsonl twice for one full file. A writer could read the generation a concurrent rotation had just advanced, yet measure the old file before it moved, and then shift the new file out as well, dropping retained events early. Under the rotation lock, the writer now re-measures the current file and rotates only if it is still full (#27).
  • Filesystem enforcement is reported as sandbox-enforced only when a sandbox backend reports both filesystem-read-deny and filesystem-write-allowlist. A custom backend that declares only one of them no longer makes policy explain or file-tool denials say sandbox, and doctor says the path rules are only partly enforced (#25).
  • Behavior change: update validates the resolved updates.source (after ${NAME} resolution) and --from with the same rules as the manifest: https, or http on 127.0.0.1, localhost, or [::1], with no credentials, query string, or fragment. Other schemes (ftp:, file:, public http:) are refused with NETWORK_DENIED instead of being read as a local directory. A local directory resolved from updates.source must be absolute; --from still accepts a relative directory. The manifest validation hint now lists the accepted forms.
  • The inference gateway now parses Retry-After HTTP-dates as well as seconds, using one shared parseRetryAfter from @piship/contracts (also used by the credential broker). Error output (formatError) shows Retry after: <n> s when an error carries a positive wait.
  • Behavior change: piship keygen refuses to write the private key inside a git work tree unless the path is git-ignored; --force-in-worktree overrides this explicitly. When git is missing or exits with an error, a .git entry in any parent directory counts as a work tree.
  • Behavior change: piship migrate from piship/v1alpha2 writes passiveContext: deny for every project origin. v1alpha2 never loaded project themes (its resource loader ran with noThemes against the distribution directory), so the old allow started loading .pi/themes after migration. Manifests already migrated keep what they declare.
  • Behavior change: piship purge <id> --yes also deletes the platform secret-store entries (identity token bundles, runtime credentials, including orphaned and pending generations) that the distribution's identity and credential metadata reference. Deletion is best effort: failures are printed as warnings and the state directory is still removed.
  • An OIDC request that times out now fails with a retryable GATEWAY_UNREACHABLE instead of IDENTITY_INVALID, and a gateway model-list probe that times out (while connecting or reading the body) fails with a retryable GATEWAY_UNREACHABLE instead of an uncoded error.
  • Behavior change: identity claims returned by an identity adapter are filtered to the same allowlist as OIDC (sub, iss, aud, azp, exp, iat, auth_time, name, preferred_username, email, email_verified, groups; scalar or string-array values only) before they are written to identity/session.json.
  • Behavior change: the cross-process credential and identity lock is no longer force-broken after 90 s while its holder is alive. The holder refreshes the lock as a heartbeat and before each blocking secret-store command; only a lock left unrefreshed for 75 s is broken (moved aside atomically and re-checked first), a holder releases only its own lock, and a waiter that runs out of time fails with a retryable CREDENTIAL_ACQUIRE_FAILED.
  • policy explain no longer claims that non-allow decisions on the audit-only plane are "observed and recorded"; it states that no runtime hook evaluates the action, so it is not enforced.
  • Behavior change: Launch-time payload verification throws PiShipError instead of a plain Error: a payload file that differs from the inventory fails with INTEGRITY_FAILED, and a lock that no longer matches the packaged manifest or npm lock fails with LOCK_INVALID, as at the release gate. Messages are unchanged apart from the code prefix; update and rollback metrics and audit record these codes instead of the generic UPDATE_FAILED or ROLLBACK_FAILED when the active payload fails verification.
  • Behavior change: A capability whose provider the policy refuses is reported as enabled: no instead of healthy: no in capabilities, doctor, and the session's capability state.
  • Behavior change: The compatible capability axis checks an enabled capability's model requirements against the selected model with the same comparison as the launch-time MODEL_INCOMPATIBLE check, so a running session's report and launch agree. Offline, capabilities and doctor use the model launch would select from the configuration and its manifest catalog metadata; they can differ from launch when --model or a credential entitlement changes the selected model.

Internal

  • CodeQL uploads under one fixed category, .github/workflows/codeql.yml:analyze, whether it runs on its own or inside Release qualification. The default category named the calling workflow, so the first Release qualification run opened a second code scanning configuration on main that ordinary pushes and pull requests never refresh.
  • Large modules are split with no behavior change: @piship/core access, diff, release, and lifecycle (now release/, install/, update/), the core index, @piship/schema governance parsing, and @piship/pi launch code and the governance session. No non-test source file exceeds 900 lines except core/src/access/distribution-access.ts (about 1,000 lines), whose DistributionAccess class shares private state across its identity and credential methods.
  • API addition: the Pi-free branded commands (login, logout, config, update, rollback, and the lifecycle doctor sections) move from @piship/pi to @piship/core (branded/). @piship/core additionally exports runLogin, runLogout, runConfig, runUpdate, runRollback, lifecycleDoctor, undeclaredGovernanceHosts, openAccess, governedLock, and the types BrandedContext, DoctorLine, and GovernedLock. No export was removed or renamed.
  • The boundary check discovers packages, rejects cross-package relative imports, flags unused workspace dependencies, reads the Pi version from compatibility/pi.json, and allowlists core's generated launcher imports. The CLI no longer declares the unused @piship/pi and @piship/policy dependencies, so the example locks record a new npm lock digest.
  • Tests keep PISHIP_VERSION equal to every workspace package version and the pinned Pi version equal across core, pi, package-lock.json, and compatibility/pi.json.

v0.5

Preview milestone; not published to npm. Commit 03c33cf (#16), "close governance, access, supply-chain, and personal-profile gaps". Schema unchanged: piship/v1alpha4 and piship-lock/v1alpha4.

Added

  • MODEL_INCOMPATIBLE when a model does not meet a capability's model requirements.
  • Accurate credential.acquire, credential.refresh, credential.revoke, and logout audit events.
  • validate runs the lock-time integrity checks without writing a lock; dev --smoke with diff and dev E2E coverage.
  • The personal reference distribution on piship/v1alpha4 with a user-managed MCP server and signed updates, with personal lifecycle and local-model E2E scenarios.
  • Per-seam Pi compatibility tests for each public API PiShip depends on.
  • The weaker of the distribution's surface and the lifecycle surface is recorded in release.json; local metrics are metadata-only.

Changed

  • Plan mode is an allowlist of read and ask_user.
  • Streamable HTTP MCP URLs resolve runtime references.
  • Actions without a runtime hook report audit-only.
  • init writes piship/v1alpha4.
  • SecretValue normalization, cross-process identity refresh, a Windows file-store ACL, and azp tests.
  • CI evidence tiers: pull requests run the fast merge gate; the full Portable E2E runs nightly and on demand; Release candidate qualification runs manually before a release. Recorded in AGENTS.md and CONTRIBUTING.md.
  • CI execution time (#15): the lifecycle E2E is split into independent parallel scenarios over shared immutable release fixtures, E2E files run in parallel, release-candidate consumer jobs reuse one trusted CLI artifact, and tamper tests use a single extraction. Portable E2E went from about 8m40s to about 4m17s and the release candidate from 4m18s to 3m07s, with no coverage removed.

Security

  • Git control files and hooks are protected from governed tools and inside the OS sandbox.
  • Best-effort remote revocation and full local secret cleanup when update, rollback, or migration clears credentials.
  • Whole-manifest secret scanning.
  • Supply chain: registry integrity for nested Pi packages, a source gate for packages without integrity, and a registry signature gate (npm audit signatures).

No three-target Portable E2E or Release Candidate run is recorded on 03c33cf; see docs/status.md.

v0.4

Preview milestone; not published to npm. Commit 7077086 (#14), "add production release lifecycle". Introduces piship/v1alpha4 and piship-lock/v1alpha4.

Added

  • A required updates section (channel, allowed channels, source, rollback, pinned Ed25519 release keys) and an optional release section (targets, approved package sources, vulnerability threshold, and expiring exceptions), with piship migrate from v1alpha3.
  • piship-lock/v1alpha4 with package sources and install-script flags, static digests of resources, policy, capabilities, MCP, sandbox, audit, and access, the update and release inputs, and the state schemas the release reads.
  • piship release: failing gates for stale locks, unapproved sources, missing integrity, unreviewed install scripts, unsupported Pi, policy conflicts, missing certification, a required sandbox without an adapter, unevidenced targets, failed release tests, and blocking npm audit findings; output is one deterministic .tar.gz per target wrapping the unchanged payload with release.json, an SPDX 2.3 SBOM, third-party notices, the scan result, checksums, and install.sh/install.ps1.
  • piship verify-release, piship reproducibility (per-target payload equality report), and piship diff (release-impact changes with risk and required tests).
  • Signed update channels: piship keygen and piship sign-channel write stable, candidate, or dev metadata signed with Ed25519, with expiry and a monotonic sequence that clients use to refuse replays.
  • Branded update [--check] [--channel] [--from] [--accept-review] and rollback, also reachable as piship update|rollback <id>: signature, digest, and release verification, a launch check, and a non-mutating migration check before one atomic activation; a retained known-good release; non-secret state snapshots; credentials cleared and reacquired, never snapshotted or restored. piship migrate-check runs the migration check alone.
  • Supply Chain and Update sections in doctor, runtime.update and runtime.rollback audit events, lifecycle outcome counters in local metrics, and the UPDATE_FAILED and ROLLBACK_FAILED error codes.
  • A release-candidate CI workflow that builds each evidenced target twice, verifies on a fresh job, and creates and verifies GitHub artifact attestations; artifacts stay as workflow artifacts and nothing is published.

Changed

  • The demo company example is a managed piship/v1alpha4 distribution with a governed handbook MCP server, Plan/Build workflow, a required sandbox, and stable and candidate update channels read from ACMECODE_UPDATE_SOURCE; it pins no release key.
  • Installation uses a versioned piship-install/v1 receipt, a per-distribution launch.mjs that starts the active release, and one directory per retained release. piship install also accepts a release directory or archive. Installs made by earlier versions still run and uninstall, but need a reinstall for update and rollback.

v0.3

Preview milestone; not published to npm. Commit 07adb72 (#13), "add governance and security baseline". Introduces piship/v1alpha3 and piship-lock/v1alpha3.

Added

  • Resources declared by trust class (company, certified, user, and builtin extensions); certified entries carry review evidence and a tree digest checked at lock and launch, and install-time npm scripts are rejected.
  • A layered policy engine (Enforced, then narrowing-only team and project rules, then Defaults or User rules) with control-plane, sandbox, and audit-only enforcement planes, and a branded policy explain <action> <resource> [--json]. Headless ask resolves to deny.
  • Project trust by git origin or path, with per-dimension effects, symlink and instruction-import confinement to the project root, and narrowing-only .piship/policy.json. User rules in <state>/config/policy.json may relax distribution defaults only.
  • Capability contracts with the builtin permissions and Plan/Build workflow providers, a six-axis capabilities [--json] report, and the builtin piship-ask-user extension.
  • Governed read, write, edit, and bash tools and ! commands built on Pi's public tool definitions.
  • Governed MCP over stdio and Streamable HTTP: server starts and tool calls are authorized, denied tools are never offered, and expectedServerName, timeouts, retries, and required servers are supported.
  • Metadata-first audit with file and HTTP sinks, opt-in content capture, a documented failure matrix (AUDIT_UNAVAILABLE for a required sink), and local metrics.
  • Governance sections in doctor, config explain, and the --smoke summary; piship-lock/v1alpha3; and piship migrate to v1alpha3.
  • New packages @piship/policy, @piship/audit, @piship/sandbox, and @piship/mcp.

Changed

  • The demo company example became a managed piship/v1alpha3 distribution with a governed handbook MCP server, Plan/Build workflow, and a required sandbox.
  • CI (#12): Windows jobs keep temp output and the npm cache on the runner's work drive and disable Defender real-time scanning; formatting, lint, typecheck, and boundary checks run once on Ubuntu; the live secret-store tests move to their own path-scoped workflow; superseded pull request runs are cancelled.

Security

  • An OS sandbox for tool subprocesses and MCP stdio servers, proven by a live probe: bubblewrap on Linux and Seatbelt on macOS. Windows has no adapter; a required sandbox fails closed with SANDBOX_UNAVAILABLE. Network mode is deny or allow only.

v0.2

Preview milestone; not published to npm. Commit c023aa4 (#11), "add managed access and configuration preview". Introduces piship/v1alpha2 and piship-lock/v1alpha2. The managed surface is verified with local fixtures only; live identity and gateway checks remain for the maintainer.

Added

  • The piship/v1alpha2 schema for managed and personal distributions, with allowlisted runtime references and a static lock access section; piship migrate upgrades v1alpha1 personal manifests.
  • Separate identity, credential, secret-store, and inference packages (@piship/contracts, @piship/identity, @piship/credentials, @piship/inference): OIDC Authorization Code + PKCE login; credential providers http-broker, local-secret, pi-native, none, and adapter; platform secret stores with a crash-safe refresh lifecycle; and an OpenAI-compatible gateway binding with an intersected model catalog.
  • Layered configuration with config explain, and branded login, logout, doctor, models, and config commands.
  • The demo company example, which runs against deterministic local fixtures.

Changed

  • The yaml dependency moved to 2.9.1 (#10).

Security

  • The Pi runtime is governed so managed distributions expose only allowed models and never inherit ambient provider credentials.

v0.1

Preview milestone; not published to npm. Commits 0251289 through 6b2be68 (#9), "deliver v0.1.0 portable personal distributions". Introduces piship/v1alpha1 and piship-lock/v1alpha1.

Added

  • The repository foundation, package metadata, and repository links; Pi compatibility and release boundaries (#4); evidence-based pull request conventions (#5); and the project positioning (#6).
  • The first runnable PiShip distribution (#7): a pinned Pi runtime with declared-only resources and personal mode.
  • The portable personal distribution core (#9): a portable pinned Pi payload bound to its OS and CPU, install and uninstall ownership, isolated state, a deterministic lock and file inventory, a branded launcher, declarative personal themes, and a real Pi compatibility smoke. The personal example is supported on Ubuntu x64, macOS arm64, and Windows x64 with Node 22.19.0.

Changed

  • Major npm upgrades are kept in explicit review (#2).
  • Behavior change: a change of credential.storage.provider (file to system or back) now also clears the stored sandbox credential during update and rollback, deleting it from the store its metadata records, and the sandbox credential's own operations (sandbox login, sandbox logout, launch, logout, login) reach that recorded store. Before, the metadata was kept, so after such a switch its deletion failed closed and blocked every login, logout, sandbox login and launch until the provider was changed back.
  • The Live provider workflow's live job runs in the live-provider GitHub environment, which holds the provider secrets and limits which branches can use them; .github/workflows/live-provider.yml and the reference README describe the setup. Before, the secrets were repository secrets, available to a dispatch from any branch.