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.
- A subagent child's
--toolsis now a strict allowlist of explicit tool names: an entry that is a*pattern or a+name/-namemodifier is refused withCONFIG_INVALIDbefore 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--toolslist namingcodemodeortool_searchand let a*or modifier through. The narrowing side is unchanged:--exclude-toolsstill 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
detailsJSON is now redacted for every tool, not only Codemode:detailsis 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.
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:
resources.packages[].pretranspile: truewrites the TypeScript modules a package's extensions import as JavaScript beside them at build time, so an import written./x.jsfinds 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, andpiship diffreports it. For the pi-code package it tookresources_loadedfrom 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 nativeimportstill fails on a type re-exported as a value, a left-over__dirnameor arequireused in any way but a plain call (a plainrequire(...)call fails the build), or a package.json without"type": "module", and a module imported as both./xand./x.jscan load twice; the build prints a warning for each of those it can see. The written files are in the payload inventory (sodoctorand 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'supdatefrom a signed channel offering it fails with exit status 1 andINTEGRITY_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), andvalidate,lock, andverify-releasefail 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).
- 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.jsonand the copy in@piship/corelist 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 setPI_PROGRAM_STATUS, which a person can set to0in the shell for a personal distribution.
- pi-code's
subagenttool works in a distribution: its child processes run<command> --mode json -p --no-session [options] <task>underPI_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 outsidemodels.allowedfails withMODEL_DENIED, tools and prompts can only be narrowed (a child given--toolsno longer gets Codemode or tool search, which reached tools the list left out, and a--toolslist 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 itssession.startaudit event records the parent's session id. In a managed distribution the launch removesPI_CODE_SUBAGENTandPI_CODE_AGENT_HOOKSfrom the child's environment like everyPI_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
psis run as/bin/pswhere 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.
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:
loginaccepts the OAuth redirect URL (or the barecode) 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 withPISHIP_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 itsstate; the code exchange and the error mapping are the same.LoginContextgains the optionalreadRedirectUrl. 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 --replacereinstalls 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> loginat 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, runlogin, 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) andnever;<command> config trust listandconfig trust forgetshow 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 --idand 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> modelslists the models of a Pi-native distribution.
- 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 nohttpTransport: http-allowedper endpoint. A public host still needs HTTPS,httpTransport: https(orupdates.transport: https) forces an endpoint to HTTPS only,credential: runtimestays HTTPS only, andvalidate,config explainanddoctorlist what travels unencrypted. Schemas beforepiship/v1alpha6have no field to writehttps. piship init --personalloads a person's own project files by default (instructionsandskillsallow or ask for external and unknown projects, extensions and MCP ask, hooks deny) and bundlesfdandrgwhen GitHub is reachable from whereinitruns, sopiship lockneeds network access for it; when GitHub does not answer within 3 seconds,initleaves outruntime.searchToolsandhttps://github.cominrelease.sourcesand says so.- A project resource a policy answers
askfor is now asked about; before, it was dropped without a prompt. piship devandpiship testcreate a missing lock and relock a stale one when the manifest declares no Pi packages.runtime.pimay 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 unusedvariablesentry is a warning. Validation errors carry the line and column, collect the common structural mistakes in one pass, and suggest the nearest field.buildandinstallprint commands that can be typed.updateuses a retained release or a kept download whose digest equals the signed entry instead of downloading it again,update --checkover a URL no longer downloads the archive, andupdateandrollbackremove obsolete releases when they commit.doctorshows free space and the releases it keeps; a payload that fails verification still ends it before anything of the payload runs.- A bundled
fdorrgproblem is a warning instead of a failure, and--versionis 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 damagedpreferences.jsonis moved aside as.damaged-<time>and defaults are used. - Two
--yolosessions 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
fdnorrgonPATHor bundled still runs Pi withPI_OFFLINE=1rather than wait for their download, and now says so once at launch: it names the tools, notes that Pi's model catalog refresh and/bugare off too, and gives the three ways out (install them,PISHIP_ALLOW_TOOL_DOWNLOAD=1, orruntime.searchTools).
- A proxy variable without a scheme (
HTTPS_PROXY=proxy.corp:8080) crashed every launch with a raw URL error; it now getshttp://and a notice. - Every unset runtime variable is reported at once. A personal launch with
NODE_TLS_REJECT_UNAUTHORIZED=0goes 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
.cmdshim 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 withoutfdandrg. - 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.
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:
--yololaunch option (manifest):<command> --yoloapproves theaskdecisions 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--modeland--new-sessionand also with--smokeand--smoke-model(headless, where anaskis approved instead of resolving to deny); with--help,--version, or a subcommand it fails withCONFIG_INVALIDbefore anything starts, and a distribution without a policy refuses it too.denyis never changed and nothing is stored: noauto.json, no policy file. In a personal distribution everyaskis approved, including one from an enforced rule or the user's own rules. In a managed distribution it works only where the manifest declarespolicy.userAuto: allowed; otherwise it fails at startup withPOLICY_DENIEDbefore sign-in, the sandbox, or Pi starts, and where it is allowed it approves what auto mode approves (anaskfrompolicy.defaultsorpolicy.default) while anaskfrompolicy.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, aYOLOstatus line, and/auto status, and/auto offends it for the session. It is audited with the existing events:policy.auto_enabledwithdetail.source: yolowhen the session starts, and each approval aspolicy.auto_approvedwithdetail.approval: autoand a newdetail.autoSource: yolo; the schema ids are unchanged.--helplists 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.jsonand the.mcp.jsonand.pi/agentsfiles below the root, which the extension also reads. They are decided by five new optionalpolicy.projectTrustdimensions (claudeRules,claudeCommands,claudeSkills,claudeAgents,claudeHooks), then asresource.loadwith the resourceproject:<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 declaresallow(company-approvedadmits no project content here, andclaudeHooksdefaults todeny). 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
projectTrustedset from policy, soctx.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.loadorresource.deniedevent (detail.kindclaudeorextension-config), and the decision is one more forproject:.claudewithdetail.seam: project-trust.doctorreportsproject trust for extensionsin the Project group.piship diffreads 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 iddevcode): 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 beforesudo, 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 acode-review-targetsskill, a committed lock, a hardened managed variant (managed.piship.yaml, validated and never launched), a release candidate archive next toacmecodeandmypi, and its own E2E and unit checks. See its README for requirements and known limits. piship/v1alpha6package fieldsenvironmentandagentFiles(manifest).environmentsets 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.agentFileswrites JSON configuration under the agent directory'sextensions/, as aseedthat keeps a file the user edited or as anenforcefile 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 diffreports them (a new or changed file is high), anddoctorlists 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 afterprovider.loadand each file'sextension.loaddecision. Thepermissionscapability can nameautoApproveFileandautoApproveKey, the key of its provider's own file that--yolosets for one launch and takes back when the process ends. - The
--smokesummary lists the registeredtools(with exposure and source), theactiveTools, thecommands, and anyduplicateTools, 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|hardlinkmakes an install or update place the runtime, Pi package, and dependency files of a release (node_modules/andpi-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\storeon Windows;PISHIP_STORE_HOMEmoves it) instead of writing them again. An installation stays self-contained: every installed file is its own file (a second name for the object underhardlink), 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 inrefs/<id>/<version>.jsonwhile the receipt names it as the active or the rollback release. Onlydoctorcollects (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:offon 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.mjscompares the primitives (fresh, warm, update, remove; store on another volume) and the manual Windows benchmark workflow runs it.scripts/store-budgets.jsonandscripts/store-budgets.mjshold 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.mjsmeasures 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--windowsmode for the manual Windows workflow.scripts/performance-budgets.jsonandnpm run check:budgetsfail 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/v1andpiship-lock/v1are frozen. They arepiship/v1alpha6andpiship-lock/v1alpha6with the same fields, defaults (release.bundleandrelease.stripstay 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 initand every example usepiship/v1; the example locks were regenerated. An additive change keepspiship/v1, and a breaking change needspiship/v2(support policy). Behavior change: a runtime built before this change does not knowpiship/v1and refuses such a release when it updates, so publish one release onpiship/v1alpha6with this PiShip first (updating across a schema). piship migratetopiship/v1: a v1alpha6 manifest migrates by changing theschemaline only.migrate --checkstays read-only and now reports one ofmigratable(exit 0),requires review(exit 1), orcannot 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 --checkleaves files byte-identical. - The
personalanddeveloperexamples bundlefdandrg(runtime.searchTools, pinned forlinux-x64,darwin-arm64, andwin32-x64;https://github.comadded to theirrelease.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 onPATH, no longer lets Pi download them: it runs Pi offline for that start (no file-name autocomplete, andrgis missing from the shell until installed or bundled), notestool_downloads_deferredin thePISHIP_DEBUG_TIMINGstartup report, anddoctorwarns.PISHIP_ALLOW_TOOL_DOWNLOAD=1leaves the download to Pi. PISHIP_DEBUG_TIMING=1reports where time goes on stderr: each stage ofreleaseand each phase of a launch aspiship-timing/v1JSON (the launch phases includelauncher_start,node_entry,pi_initialized, andui_ready, with counters such asidentity_spawnsand the launcher builds), and the steps ofbuild, install, and update aslabel: N mslines.scripts/benchmark-install.mjsandscripts/benchmark-build.mjsrecord 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.tsstates 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.
- Managed project hook configuration stays read-only when hooks are not admitted, and managed permission-provider project configuration cannot override enforced defaults. Provider
--yolouses live-session ownership to prevent shared configuration from changing another terminal's approvals. - Every
@piship/*package andPISHIP_VERSIONare0.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.jsonand the copy in@piship/corelist 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 failsvalidateuntil itsruntime.piis updated. The rootpackage.jsonoverridesandPI_SIBLING_PINSin@piship/pipin the seven sibling packages to exactly 1.0.3. Regenerating the npm lock moved only the eight@earendil-worksentries, since every third-party dependency they declare is unchanged, and the reviewed install scripts are the same. Every example is relocked withruntime.pi: "1.0.3". The public API PiShip uses is unchanged, the session format stays version 3, and nobuiltin:*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-responsestoazure, inauth.json,models.json, andsettings.jsonas 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>/agentor signs in again, and a personal manifest whosemodels.allowednamesazure-openai-responses/<model>must nameazure/<model>. PiShip auto-resumes the latest session; an old provider id falls back to another model and loses its prompt cache. Setmodels.defaultto a renamed model also listed inmodels.allowed. - Other Pi 1.0.3 changes that reach PiShip distributions:
HomeandEndalways move the editor cursor, with the fullscreen transcript's top and bottom onCtrl+HomeandCtrl+End; Pi creates its output files (a!command'spi-bash-*.logincluded) readable by the user only; Codemode'simage()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 bundledfdandrg), 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.cmdshim 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-latestrunner (Defender real-time protection on, exclusions removed for the run; runner noise is large; an earlier commit,600dea7, against0752f02; 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--smokelaunch 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.stripremoves source maps and TypeScript declarations from the payload, andrelease.bundleassembles a compact Node runtime through upstream public exports, retaining WASM/native assets and extension loading. Thepersonalanddeveloperexamples write both out astrue; 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
updatewaits 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 apson 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 bydoctorandPISHIP_DEBUG_TIMING), and an installed launcher that an earlier PiShip wrote is replaced afterupdateorrollbackcommits, bydoctor, and at the end of a session. The Windows command shim no longer runswhere node, and the bundledfdandrgare checked at start by a file-system receipt instead of being read and hashed. piship releaseruns 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, andchecksums.txt; where a release's runtime came from is reported outside every file the release covers: one stderr line frompiship release(runtime cache: hit (entry <digest>, linked N, copied M, bundle hit)), thePISHIP_DEBUG_TIMINGoutput, and an unsigned<out>/releases/<name>.build-info.jsonbeside the archive, which a rebuild replaces. The key also includes npm's major.minor and, on Linux, the libc family.--rebuildorPISHIP_RELEASE_NO_CACHE=1forces a cold release build, andpiship build --rebuildignores the stamp beside its output, from whichpiship buildreuses 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 unchangedpiship buildabout 0.13 s on macOS (performance).piship releasereportsqualifiedand itsrelease.jsonrecordsqualification: qualified;piship build,dev, andtestproduce an unqualified local build and say so, with a marker<out>/<id>.piship-qualification.jsonbeside (not inside) the payload.piship installof one warns, andverify-releaserefuses 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
verifyjob, and reports the installed file count of every release in theinstalljobs.
- Required-control failures before a model dispatch now record
model.deniedwith 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-releaseanddoctor, 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, ortestanother distribution, which the portable artifact contract (docs/portable-artifact.md) promises. The payload now carries one deterministicauthoring.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-releaseaccepts 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 rannpm audit signatures --omit=devin a payload that keeps the rootworkspacesbut not the workspace packages, so npm reached no runtime package and answered "found no dependencies to audit", and every release recordedsignatures: unavailableandqualifiedhaving 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 recordspassed. A release whose signature verdict isunavailablefor 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 change: in a governed distribution (
piship/v1alpha3and later) Pi's project-trust flag is PiShip's decision. It was the in-memory default of true for every project, soctx.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;v1alpha1andv1alpha2keep Pi's default. Pi's own notice about an untrusted project with.piresources can now appear, and it still suggests/trust. - Behavior change on Windows: installs and updates use the shared file store by default.
PISHIP_STOREunset now meanshardlinkwith full content verification on Windows (defaultStoreModeinpackages/core/src/store/policy.ts); macOS and Linux stayoff. An explicitPISHIP_STORE=off|copy|clone|hardlinkalways wins, soPISHIP_STORE=offrestores the previous behavior on Windows, and an unknown value is still refused withCONFIG_INVALID. The decision is measured on a Defender-onwindows-latestrunner (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;copyandclone(which falls back tocopyon NTFS) are 1.05x to 1.10x on a first install and are not used. An installed file undernode_modules/orpi-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 bydoctor, restored byrepair). 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 inpiship/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 anexportsmap, and has no native code, WebAssembly, install script,os/cpufield,bin, unparsable file, or code that finds modules or files at run time; the real files then live once inpi-packages/.shared, and each place keeps itspackage.json, its license files, and plain one-line forwarding modules for the filesexportsreaches. No link of any kind is created, so the payload, its inventory, and its archive stay regular files, and Node resolves through the sameexportsmap 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 inmetadata/pi-package-footprint.json, inpiship buildoutput, and indoctor. 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. Setrelease.bundle: falseto keep every package's own vendored tree. - Default changed:
release.bundleandrelease.stripare nowtrueforpiship/v1alpha6when a manifest leaves them out, sopiship build,dev,test, andreleaseof 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, setrelease.bundle: falseandrelease.strip: falseand runpiship lockagain (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 migratefrompiship/v1alpha5reports the change.piship/v1alpha4andpiship/v1alpha5cannot set either key and build as before. The schema stayspiship/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, andupdateno longer runverify-release. They extract the archive once, with 8 parallel writers, straight intoapps/<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 explicitverify-releaseordoctorretain those. A directory Windows will not rename (a scanner holds a handle) is retried. Install, update, and launch never delete an old version:doctorreclaims obsolete versions and the.retained-*directories an update set aside, within a time budget, and keeps the active version and the rollback target.updateandrollbackno longer remove the leftovers of an interrupted operation (doctordoes). A power loss during an install or update may leave extracted files incomplete; restore them withpiship repair. - Behavior change: a launch no longer hashes the whole payload inventory. Every launch hashes
piship.lock, the one small file that carriespolicy.enforcedand the capabilities, against the digest the install receipt recorded and fails withINTEGRITY_FAILEDon 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 newpiship/v1alpha6fieldruntime.verifyAtLaunch: truemakes a launch run the full payload verification and keeps the V8 compile cache off; unset orfalse, 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 (doctordoes), 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 bydoctorandverify-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.
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).
piship/v1alpha6MCP server fieldhttpTransport: http-allowed(manifest): astreamable-httpserver may use plain HTTP to a private or internal host, judged by name as forupdates.transport: http-allowed. A public host failsvalidate, and a${NAME}URL that resolves to one fails the server's start. It cannot be combined withcredential: runtime, the private-only network policy applies unchanged, and only that server's requests may use plain HTTP.validatewarns that the traffic is unencrypted, and also for a.localor single-label host.doctorandconfig explainshow it, andpiship diffreports it as high risk, also on an added server. Without it nothing changes.piship/v1alpha6MCP server fieldheaders(manifest): astreamable-httpserver may receive a claim of the signed-in OIDC identity (sub,preferred_username, oremailwithemail_verified: true) as a request header, such asX-Company-User: { identityClaim: preferred_username }. It needsidentity.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, andconfig explain; audit and metrics record nothing about the headers. The header is not authentication (security).piship/v1alpha6fieldhttpTransport: http-allowedon 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, andsandbox.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 failsvalidate, 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: runtimecannot be combined with it, and an MCP server withcredential: runtimestays refused over plain HTTP even on the gateway's origin.validatewarns 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.localor single-label host;doctorandconfig explainshow each opt-in andpiship diffreports it as high risk (security). Without it nothing changes, and existing locks keep their digests.updates.transportkeeps its name.piship/v1alpha6fieldruntime.searchTools(manifest):mode: bundledshipsfdandrgwith 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 lockdownloads the official upstream release archives (sharkdp/fd and BurntSushi/ripgrep; defaults fd 10.5.0 and ripgrep 15.2.0, or the exact versionsfdandrgpin) for every target inrelease.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.commust be inrelease.sources; thesourcegate also refuses an archive that is not the official asset, andpiship buildrefuses such a lock before it downloads or reads anything.piship buildandpiship releasecheck 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'stools/(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 beforePATH, so a user's ownfdorrgnever replaces them.--smokeruns Pi's find and grep tools,doctorandconfig explainshow the tools and versions, andpiship diffreports 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 thesession.exportstatus 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 forfdandrgbeforePATHand puts the ones it downloads) when it is imported, before PiShip setPI_CODING_AGENT_DIR, so Pi used~/.pi/agent/bininstead of the distribution's state: a personal distribution downloadedfdandrgthere, and every distribution preferred a copy found there. The launcher now sets the variable before it imports Pi, through@piship/pi/environment. A user'sfdorrgin~/.pi/agent/binis no longer used; Pi uses one onPATHor, where Pi is online, downloads its own into<state>/agent/bin. - Behavior change: a plain-HTTP request admitted by an opt-in (
httpTransport: http-allowedon any endpoint, MCP servers included, andupdates.transport: http-allowed) is refused withNETWORK_DENIEDwhen an inheritedHTTP_PROXYwould carry it and the proxy is not itself a private or internal host, since the request would cross the proxy in clear;NO_PROXYis honored, and the error anddoctorsay to add the host to it (security).
- Every
@piship/*package andPISHIP_VERSIONare0.9.1, and every example is relocked with it.
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).
- Manifest schema
piship/v1alpha6(manifest):runtime.tools(codemode,toolSearch,exposure),runtime.cacheWarming, MCP serverclass,exposure, andtoolsas an exposure map,models.catalogentries withtype(chat,classifier,image),api,output, andvirtualroutes, adatasection (retention, purge, export),policy.acknowledgeUnenforced,resources.packagesandpackageTrust, andrelease.vulnerabilities.registry. v1alpha5 manifests keep loading with an unchanged meaning, andpiship releaseaccepts 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), andsessionExportStatus, with typed slots for Pi packages and the Pi sibling tree. Older locks are still read. - Policy actions
model.select(the new name ofmodel.use, which stays accepted as an alias and is normalized at parse time in manifests, rule files, and locks;config/policy.jsonis never rewritten),model.dispatch, andsession.export(resourcespublic,local,support). piship migrate --checkwrites 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 basegrep,find,ls, andpowershell, are excluded throughcreateAgentSession({ excludeTools }). Pi's Codemode (without the scriptmodelsglobal) and tool search are explicit, governed opt-ins loaded from the lock. Tool calls are recorded attool_execution_startwithdetail.source(top-level,codemode,nested),detail.parent, anddetail.exposure; calls Pi refuses before the policy hook are recorded under rulepiship.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 lockresolves eachresources.packagesentry 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-scriptsand stored inpiship.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, underpackageTrust.piship buildvendors each package intopi-packages/<id>(git throughgit 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 (countingbinding.gyp, with reviewed entries in the new v1alpha6release.installScripts), andnpm auditper package lockfile underrelease.vulnerabilities(usingrelease.vulnerabilities.registrywhen set). At launch each package file is checked against its locked sha256 and decided on its own before Pi's loader sees it, withdetail.packagein audit. Pi's own installer, which runs npm lifecycle scripts, never installs a package (manifest).piship lockneeds 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'spreparedespite--ignore-scripts);piship buildandpiship releasework 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 releasenpm auditrun npm asnodewith npm's CLI script, found through thenpm.cmdshim onPATH, with the arguments passed as an array and nocmd.exe, so a registry URL or version range from a manifest is never read as shell syntax.vulnerabilities.jsongains an optionalpackageslist, 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 underpi-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) orpiship lockfails; at launch a mismatch fails withINTEGRITY_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 asruntime.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
cacheWarmingsetting fromruntime.cacheWarming(offwhen none is declared, where Pi's default isstreaming), answerscache_warming_decisionwithstopwhen the enforced mode isoff, and refuses/settingschanges unlessuserOverrideallows them; an undeclared mode is enforced for a managed distribution only. The lock recordscacheWarming. - Audit event types
model.dispatch,session.export,runtime.mutation.reverted, anddata.swept, and the enforcement planegateway, extend the closed enums ofpiship-audit/v1andpiship-audit-batch/v1; the schema ids are unchanged (integration contract). doctorhas a Governance group: the manifest schema (a v1alpha5 manifest suggestspiship 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 showsoff); runtime mutation (enforced per turn; a blocked runtime failsdoctor); 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 diffcompares the v1alpha6 lock fields:runtimeTools, per-tool exposure, seam evidence, the data contract and declareddata,sessionExportStatus, virtual models, Pi packages (packages[]), andcacheWarming. 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 arehigh; a cache warming change to a mode other thanoff, allowing a user override of it, and an addedpolicy.acknowledgeUnenforcedentry aremedium(a change tooffor to no override islow). Themodel.usetomodel.selectrename 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
doctorreads after a rollback.
- Behavior change: prompt cache warming is
offunlessruntime.cacheWarmingsays otherwise, for every distribution built with v0.9, including an unmigratedpiship/v1alpha5one (which cannot declare the field); v0.8 sessions warmed the prompt cache through Pi's own default (streaming). A managed distribution enforcesoff; a personal one starts atoffand does not pin it.piship migrate --checkreports it, andruntime.cacheWarming.mode: streaminginpiship/v1alpha6keeps warming. - Behavior change: a tool whose
tool.executedecision is an unconditionaldenyis no longer offered to the model; v0.8 offered it and refused the call. This coversread,write,edit,bash,ask_user, and extension tools, forpiship/v1alpha5distributions too.ask, Plan mode, and user auto mode do not hide a tool. - Behavior change: a declared MCP server's
classis decided bypolicy.resourceTrustbefore the server starts (a denied start is audited asmcp.server.startwith the class, and a denied required server fails the launch withMCP_DENIED), and the releasepolicygate refuses a server whose class is denied. A v1alpha5 server has the default class (companymanaged,userpersonal), so an unmigrated manifest whoseresourceTrustdenies it stops starting that server;piship migrate --checkreports it. - Models are governed by two actions:
model.selectfor the model a session selects (possibly virtual) andmodel.dispatchfor the physical model a request is sent to; without amodel.dispatchrule for a model,model.selectdecides it, so a v0.8model.usedeny 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 withPOLICY_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 declaredroutes, or onemodel.dispatchor the entitlement denies, is refused withMODEL_DENIEDand audited; an allowed one is recorded as amodel.dispatchevent withdetail.selected,detail.dispatched, anddetail.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 releasemodelsgate refuses a managed route outside the allowed catalog and a router that is not a declared extension (apackage:<id>router is not resolved yet). - Enforcement is reported as a status derived from the runtime seam table (
RUNTIME_SEAMSin@piship/policy):enforced,audit-only(observed and recorded, not prevented), orunsupported(no runtime seam: neither prevented nor recorded).policy explainprintsunsupportedwhere it printed anaudit-onlyexplanation forweb.request,browser.execute,agent.invoke,memory.*, and an uncontainednetwork.connect, printsenforced (<plane>)for enforced actions, and addsenforcementStatusto its JSON. A manageddenyoraskrule naming an unsupported action exactly failsvalidateand the releasepolicygate withPOLICY_UNENFORCEABLE, and a personal one warns;doctorlists every such rule. This applies topiship/v1alpha5managed manifests as well, which cannot acknowledge a rule becausepolicy.acknowledgeUnenforcedis v1alpha6 only:validateandpiship releasefail until the rule is removed, or the manifest is migrated and the rule acknowledged. - A piship/v1alpha6
datasection is enforced. At every session launch and atlogout, files past their retention are deleted by mtime: Pi session files under<state>/sessions(a session another launch holds is kept), rotatedlogs/audit.jsonl.Nfiles (never the liveaudit.jsonl), and files under<state>/cache;logoutalso purges the classes indata.purge.onLogout. Adata.sweptaudit 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.onLogoutmay not nameaudit, 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.onUninstallis parsed, locked, compared bypiship diff, and shown bydoctor, butuninstalldoes not read it: uninstall keeps the state as before. data.export.<resource>is a distribution-enforcedsession.exportrule, checked against the seam table like any other rule. The lock recordssessionExportStatusfor every resource anddoctoralways shows it. A gateway distribution with the idradiusis refused withRADIUS_PROVIDER_RESERVED: its model provider takes that id, so Pi's/sharewould 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.jsonand the copy in@piship/corelist 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 nobuiltin:*extension loads (upgrade notes). - Pi 1.0.1 and later ship no
npm-shrinkwrap.jsonand declare their sibling packages with^. The rootpackage.jsonoverridespinchord,pi-agent-core,pi-ai,pi-codemode,pi-mcp,pi-telemetry, andpi-tuito exactly 1.0.2, from one list (PI_SIBLING_PINSin@piship/pi) that the compatibility suite checks against the overrides, the npm lock, and the installed packages. ThePi latest canarydrops the overrides before it installs the newest Pi and skips that check underPISHIP_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'sos/cpufiltering now applies to Pi's optional packages, so the personal example's payload drops from 147.6 MB to 145.3 MB ondarwin-arm64and from 149.2 MB to 146.9 MB onlinux-x64. - Every example is migrated to
piship/v1alpha6and relocked aspiship-lock/v1alpha6, with the same effective policy:model.userules readmodel.select, each MCP server states itsclassandexposure: direct, and the demo'stools.allowlist is the equivalent exposure map. None declaresruntime.cacheWarming, so their sessions no longer warm the prompt cache (see the behavior change above). examples/enterprise-referenceand its sandbox variant declare thepackages/pi-platformPi package (source: local, classcompany, one extension, one skill, one prompt, and themsdependency) withpackageTrust.local.paths: [./packages]and aresource.loadrule for its prompt; the package lockfile is committed inpiship.lock.d/.- Every
@piship/*package andPISHIP_VERSIONare0.9.0.
session.export: supportwas reported as enforced in personal mode, where Pi's/bugupload is open: only a managed launch runs Pi offline (PI_OFFLINE=1). Its status is nowenforcedonly where Pi runs offline andunsupportedotherwise, in the seam table's containment, the lock'ssessionExportStatus,doctor, andPOLICY_UNENFORCEABLE.- The commented MCP example in the managed
piship inittemplate used the v1alpha5tools: {allow: [search]}filter, whichvalidaterejects in thepiship/v1alpha6manifestinitwrites once the example is uncommented. It now shows an exposure map (search: direct,"*": hidden), and a test uncomments it and parses the manifest. piship diffdescribeddata.purge.onUninstall: allas "Uninstall purges all data", althoughuninstalldoes not read the field and keeps the state. It now reports the purge as recorded and not applied, and a change in either direction islow, 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, thenlockanddiffagainst 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 commitpiship.lock.d/and to write MCPtoolsas an exposure map.
- Pi 1.0.2 pins
brace-expansion5.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, andpiship releaseon the demo passes the vulnerability gate with noallowentries and no findings. The agent setup guide no longer says to copy the entries (#129).
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).
policy.userAuto(offorallowed, absent meansoff) in managedpiship/v1alpha5manifests lets the administrator allow a user-controlled auto mode. A user switches it with<command> auto on|off|statusor/autoin a session; while it is on, anaskfrompolicy.defaultsorpolicy.defaultis approved without a prompt, interactive and headless.denyand everyaskfrompolicy.enforced, a team or project rule, or the user's own rules are untouched. Each approval is audited aspolicy.auto_approvedand each switch aspolicy.auto_enabledorpolicy.auto_disabled(newpiship-audit/v1event names); switching on is audited first (/auto ondelivers 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 recordspolicy.auto_enabledwithdetail.source: state.policy explainreportsAUTO-APPROVEDonly 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, andpolicy 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) orhttp-allowed, which lets the update channel (channel metadata, root files, and release archives, fromupdates.sourceorupdate --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 ascom,net,org,dev, orapp) counts as public, and the docs say that a single label depends on the clients' DNS search domains. A public host is avalidateerror orNETWORK_DENIEDat run time,http-allowedrequiresupdates.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, anddiffshow it,doctorwarnssource http (integrity by signature only), and an update or check over plain HTTP is audited withtransport: http. A manifest without the field locks byte-identically (manifest, #199).
- An explicit shell
askfrompolicy.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 anallowprefix rule, sogit push origin main; truefell through to a weaker rule or a distribution default, which auto mode then approved without a prompt. Such anasknow applies to the chained command too, with or without auto mode; it only adds anask, and adenystill 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 reportedThe previous credential could not be deletedfor 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 installingsecret-tooland a keyring or switching tostorage: {provider: file}, a discarded marker is no longer called a previous credential, anddoctorsays 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).purgeanduninstall --purgeapply the same rule, so a stale v0.8.0 marker no longer makes them fail forever on a machine withoutsecret-tool, and report the dropped references; the brandedlogoutwithout its runtime variables andsandbox loginnow say which references they dropped, aslogindoes (#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 testlaunches with the distribution's credential, so it failed withCREDENTIAL_REQUIREDfor 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 wherepiship testran) and runpiship testagain, and how a missing Secret Service on Linux (WSL, SSH) shows up (#195, #202).
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).
- Manifest schema
piship/v1alpha5:updates.trust.bootstrap, a versioned update root (version,expires,keys, androotandchannelroles with thresholds), replacesupdates.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 validatewarns 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 canonicalsha256-<hex>of the parsed manifest (independent of YAML comments, key order, and line endings), andupdates.trust.bootstrapis the exact validated bootstrap root. Older locks are still read. piship migratetakes 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 releaserequires apiship/v1alpha5manifest. A newtrustgate 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,
updatefetchesroot/<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/v1accepts an optionalsignatures[]: 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 initprints a validatedupdates.trust.bootstrapblock 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-channelaccepts repeated--key/--key-idpairs and refuses signatures that do not satisfy the releases' declared trust.keygenprints the public key instead of aupdates.trust.keysblock (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 inspectshows the pinned update keys by ID andsha256:fingerprint, in the summary and as an additivetrustfield in--json(#178, #167).- The manual
Live providerworkflow takes an optionalmodelinput that replaces theLIVE_PROVIDER_MODELsecret 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
/shareas an endpoint and egress-control requirement for an organization that prohibits public session export.
- 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 providerruns: 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.jsonand the copy in@piship/corelist 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'snpm-shrinkwrap.jsonomitsintegrityfor its own seven packages, so the npm lock carries the registry's published sha512 for them, whichnpm civerifies (upgrade notes). AssistantMessage.thinkingLevel, new in Pi 1.0, is classifiedmetadatain 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 nobuiltin:*extension loads. A tool an extension runs throughctx.executeToolstill emitstool_call, which the policy hook decides (#176).- Behavior change: the governed
bashfollows Pi 1.0's result shape. It returns Pi'sstructuredContent(outputup to 1 MiB, with the first and last 512 KiB when longer,truncated,full_output_pathwhen the output was truncated and saved,exit_code, andwall_time_seconds), which codemode scripts receive, and a non-zero exit returns anisError: trueresult 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) andtuiMode: "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/bughint (#139), and Pi's built-in/sharecommand, which uploads the session file as a GitHub gist through the user'sghCLI, have no public Pi switch. They are recorded as known limits, and/shareas a path by which a session can leave the machine, in managed mode too (security). CodeQLcancels a superseded pull request run, asCIdoes: only an older run of the same pull request is cancelled, never amain, 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.
- Behavior change: every launch sets
PI_SKIP_VERSION_CHECK=1andPI_TELEMETRY=0and, in Pi's in-memory settings,enableInstallTelemetry: falseandlastChangelogVersionto the Pi version. Pi no longer asks pi.dev for the latest version or shows "Runpi 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_DIRto<state>/agent. Pi's crash log (crashes.json) and thefdandrgbinaries Pi downloads are now in the distribution's state, wherepurgeremoves them, and no longer under~/.pi/agent; each distribution downloads its ownfdandrg. A managed launch also setsPI_OFFLINE=1after it has removed the inheritedPI_variables, so Pi makes no request of its own (no model catalog refresh, nofdorrgdownload): putfdandrgonPATHfor@completion and Pi's find and grep tools (security).
- 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_FAILEDfor 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 withState already exists. Whenpiship testordevcreates a distribution's state, it marks it (piship-test-state/v1), and the first install of that distribution adopts it without--use-existing-stateand removes the marker. State that existed before, or that an earlier install owned, still requires the flag.buildnow points topiship testbeforeinstall. - A release whose manifest declares
updates.sourcebut noupdates.trust.bootstrapbuilds as an update-disabled release instead of being refused by thetrustgate. It cannot verify an update either way (updatefails closed andpiship validatewarns, 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'sPublic key:line, which replaced v0.7'spublicKey: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 forroot/2.json, whose 404 ends the refresh before the channel is read (#191, #192).
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.
- The reference sandbox container service in
examples/enterprise-referencepins 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.
- 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, andbuildwhen every URL involved is plain; with a variable involved, or for an optional server,validatewarns (#168). piship inspectprints a summary by default; its JSON now needs--json.piship testandpiship doctorprint 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-Clientheader 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-modelrequest reports the new, additive error codeREQUEST_CANCELLED; a managed endpoint's request that got no answer (refused or reset connection, DNS failure, deadline) isGATEWAY_UNREACHABLE, retryable, instead ofGATEWAY_PROTOCOL_ERROR(#85). rollbackno 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-channelverifies 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 typecheckalso 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: runtimethat can never be sent, now failvalidateandlockwhen 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_VIOLATIONwith anadditionalCAaction (#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_UNAVAILABLEand 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
409to 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 other409now means the request is still in progress: the key is kept,Retry-Afteris 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, itsretryAfterMs, and the action "try again later"; it is no longer reported asCREDENTIAL_REVOKEDwith "run login" (#157). - The lifecycle lock, the launch gate, and the
.launchingmarker 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.updateandrollbackwait 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 optionalAbortSignal. A timeout is retryable and names the adapter and the call; a hung credential adapter releases the credential lock (#145). /resumeis 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).
testCredentialBrokerandtestAuditCollectorin@piship/adapter-conformancesend a company's own credential broker or audit collector the requests PiShip sends (a repeated and a reusedIdempotency-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).
- An unset
${VAR}innetwork.tls.additionalCAstopsupdatewith the same named-variableCONFIG_UNAVAILABLElaunch 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 explainshows the manifest schema and governance rows;doctorno longer reports an unusable session, a dead issuer, or itself as healthy;update,rollback, andbuildshow progress on a terminal; an invalid user policy names its file;init,build, andinstallend with the next step and a PATH line (#161). @piship/adapter-conformanceaddstestCredentialBrokerandtestAuditCollector, 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.allowHostssemantics, oneAuditSinkdefinition, an onboarding path in the README, and the reference README's services and prerequisites (#162). - The agent's
bashtool 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). doctorlists 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
networkProbecases (#160). piship repair <id> <release archive>restores a damaged installed release from a verified copy without running it;INTEGRITY_FAILEDnames 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> loginin a terminal, or restart for a changed principal), and managed/loginnames the distribution's sign-in instead of upstream provider logins (#139). validatewarns about a relativeadditionalCA, an ignorednetwork: denyon an optional sandbox, and lists the variables needed at launch separately from those onlyupdateneeds (#142).network.tls.additionalCAis trusted for hosts reached through a proxy tunnel (#148).doctorchecks 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.mdstates exactly which session-file content PiShip scrubs (#159).- An empty or cut-short
state.jsonis rebuilt; a torn final audit line is skipped; a damagedpreferences.jsonis 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
retryableandretryAfterMs(#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-companyand the enterprise reference distribution (and its sandbox variant) declared asandbox.filesystem.read.denylist that replaced the defaults and silently left~/.config/gcloud,~/.azure,~/.kube,~/.docker, and~/.pi(and, in the demo,~/.netrcand~/.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.mdlisted 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.mddescribedAuditSinkboth 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 ownfileandhttpsinks 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
subalone, 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.mdnumbers 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 anotherapp.id(#162). - The manifest reference now states what
network.allowHostsaccepts 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 initpath 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.mdopens with a task table instead of only the former-anchor table (#162). docs/security.mdgave 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).
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 change: the governed
readandedittools 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 namesbash(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
bashor!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 api-bash-*.logtemp 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
bashtool 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
--smokeis 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), becausepiship test,dev --smoke, anddoctorcannot 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/resumeof such a session is refused. A/resumealso 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/coreexportsprocessIdentity(), 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 nosession.end, the sandbox was not disposed, and a piped secret hung while the pipe stayed open. Now the secret prompt fails withCREDENTIAL_REQUIREDand 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 ascancelledand 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-psays 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 modelwhen the model came from the preference, otherwise<command> --model <model>, and<command> modelsto list the choices;modelslists even while the preference is stale. Before, every launch andmodelsfailed withMODEL_UNAVAILABLEorMODEL_DENIEDand no hint. There is no automatic fallback (#137).
- 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>andpiship rollback <id>(andconfig explain,doctor,dev, andtest) forwarded their arguments to the branded command throughcmd.exeunquoted, so a--frompath 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 --yesuninstalls and purges in one command. A user who installed from a release and deleted the download had only the installed manager, whichuninstallremoves, so the separatepurgethat 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 plainuninstalldoes. Plainuninstallstill 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 thatuninstallcould 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 suggestinguninstall. - 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>/appsmade a plainuninstalldelete 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
TypeErrorwhen 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.logoutremoves 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,
updateandrollbackreport 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.updateorruntime.rollbackis audited as allowed, and the lifecycle metric countsok. Before, the marker write threw after the commit, so the command reportedUPDATE_FAILEDorROLLBACK_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 recordspiship-lifecycle-lock/v1with 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:
updateandrollbackwrite 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 concurrentuninstallorrollbackran, 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 retryableUPDATE_FAILEDorROLLBACK_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 withCould 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>.tmplike the others; the start-up sweep andlogoutrecognize both names. - Behavior change: a migration snapshot is built in a
.staging-p<pid>-*directory, itssnapshot.jsonwritten last and read back, flushed, and published by one rename, and is named<sequence>-<from>-to-<to>;snapshot.jsongains asequence. 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 validsnapshot.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 afinally,dispose(), orexithook, 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 (onlyapps/<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, orapps/<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.doctorreports a count when abandoned directories resist removal. - Behavior change: the staging of killed
piship build,dev,test, andpiship releaseruns in their output directory (dist/,dist/releases) is reported, not removed, unless you pass--reclaim-stagingtopiship buildorpiship 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 itstmpis moved first), and removes it withrm -rf --one-file-system(-xon macOS), which never follows a link. Wherermis 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
TMPDIRin atmpsubdirectory (<os temp>/piship-sandbox-*/tmp); the marker sits outsidetmp, which contained commands can write in. Before,TMPDIRwas thepiship-sandbox-*directory itself.piship buildassembles the payload in apayloadsubdirectory of its staging directory, beside the marker, andpiship releasestages in.piship-release-<id>-*(it was.<name>-*), assembles the payload straight into it, and keeps its fixed directories as.releaseand.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_INVALIDand name the file, the failed check, and thePISHIP_INSTALL_HOMEorPISHIP_BIN_HOMEvalue 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:
purgeanduninstall --purgerefuse, 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> logoutfirst (<command> sandbox logoutwhen 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-logoutpurges anyway for a user with no release left to run logout, and warns that the credential stays live until it expires (#151).
- 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.lockTimingandAuditRotation.lockStaleMsset the intervals (tests shorten them);withFileLockmoved 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 madeopenSyncfail withEEXISTwhile 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 retryableCREDENTIAL_ACQUIRE_FAILEDafter 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.jsonlgrew 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.
verifiedAtis 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 ashutdownTimeearlier 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 itsshutdownTime, 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
psin UTC and the C locale and recorded as UTC seconds. Before, it was theps -o lstart=text in the caller's time zone and locale, so anupdateorrollbackfrom a shell with anotherTZorLANGcould 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).
- Contract change:
@piship/contractsadds the normalized principal:PrincipalKey(issuerandsubject),principalKey,samePrincipal,principalId(one unambiguous string for attribution), andprincipalDigest(a directory-safe digest). Email, display name, and username stay attributes. Credential metadata (piship-credential-metadata/v1, additive) records theprincipalits credential and entitlement were issued to, and@piship/identityaddsassertSamePrincipal. - 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;loginalso 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.revokewith reasonprincipal-change) and deleted before anything uses it.logindeletes 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 (modelandmodels.allowedin preferences; other preferences stay). A newidentity/principal.jsonrecords the principal that owns the state;logoutkeeps it, so a change of user after a logout is recognized, andidentity.loginevents carryprincipalChange: truewhen 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 alocal-secretdistribution with identity asks for its key again atlogin. - Behavior change: an identity refresh that returns another issuer or subject fails with
IDENTITY_INVALIDfor 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>/(andsessions/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 undersessions/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_atwas never renewed, so themodelsthe broker returned at acquire stayed in force until the nextlogin: 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.loginstill 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 allowlistA, Bwith entitlementB, Cand gatewayB, C, Dyields exactlyB. - 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 declaresinteractive: falsesupplies the session: every launch,doctor, andloginobtains it from the adapter'slogin()without a stored login, gives the adapter anopenUrlthat fails the run, holds the session in memory only (noidentity/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 interactiveloginwith 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.loginandidentity.refreshevents carryworkload: true.@piship/identityaddsWorkloadIdentityProviderandisWorkloadIdentityProvider, and an identity adapter whoseinteractiveis neither a boolean nor absent is now refused withCONFIG_INVALID. Adapters that do not declareinteractive: falsekeep the interactive path unchanged. A new E2E runs such a distribution with no browser opener, no authorization page, planted personal credentials (provider variables and Piauth.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/identitypackages (internal to PiShip, exported for the CLI and the launcher) changed:LifecycleOptions.deleteSecretis replaced bysecretStore,PurgeResult.problemsis removed, andCredentialManager.exclusive(),holdsFileLock,isLockTimeout,CredentialPhase,REVOCATION_RETRY_LIMIT,PendingRevocations.dropped,DistributionAccess.usesWorkloadIdentity(),DistributionAccess.markCredentialRejected()taking the credential the request used,writeIdentityDiscardedMarker, the refusal wording parameter ofassertSamePrincipal, and the revoke reasonunboundare added.AccessOptions.onPhaseacceptsAccessPhase. Audit events gain the reasonscredential.refresh: entitlementandidentity.refresh: age. No Pi public API is used differently. - Behavior change: audit events name the principal as
principalIdgives it (issuer,#, subject) instead of the bare subject, in a governed session and atlogin,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:
loginruns from clearing the previous credential to acquiring the new one under the credential lock, and writes the identity under the identity lock;logouttakes 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.jsoncounts 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 retryableGATEWAY_UNREACHABLE. What the adapter throws becomesIDENTITY_INVALIDwithout its message, which could quote the token file; an adapter that caught the refusedopenUrland returned a session anyway fails the run; a session withoutexpiresAtis refused; a session is obtained again after five minutes even when valid longer; concurrent callers share one call; and a workload adapter'slogoutis never called (it used to receive a stored person's tokens).doctorreports a workload identity as "workload identity, obtained per run" instead of failing with "not signed in". - Contract change (additive):
@piship/contractsnow definesRETAINED_CLAIMS, the claim allowlist, andWorkloadIdentityProvider, which@piship/identitystill exports unchanged. The adapter SDK re-exports both, andPrincipalKey,principalKey, andsamePrincipal, so an adapter author can check a session's principal the way PiShip does. The identity kit'sIDENTITY_ALLOWED_CLAIMSis now that same list instead of a copy.
- 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 shareCREDENTIAL_ACQUIRE_FAILEDwith transport failures. Broker errors also carry a non-secretdetailwithoperation(acquireorrevoke),reason(unreachable,timeout,cancelled,authentication,denied,rate-limited,unavailable,rejected, orcontract), and the HTTPstatuswhen 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, anddetail. It used to be rewrapped asCREDENTIAL_EXPIREDorCREDENTIAL_REVOKEDthat were never retryable, so a broker outage or rate limit during renewal read as "sign in again". - Behavior change: a caller's
CredentialContext.signalnow 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_REVOKEDinstead of an uncoded error, a 403 isCREDENTIAL_DENIED, and a 429 or 5xx carriesRetry-After. A broker 5xx on acquire also carriesRetry-Afternow. A network or TLS policy refusal from the managed fetch keeps its own code (NETWORK_DENIED,TLS_POLICY_VIOLATION) instead of becoming a retryableCREDENTIAL_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) ornetwork 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 isCREDENTIAL_DENIEDand not a timeout, a success body larger than 64 KiB is acontractfailure, and an early renewal that the broker refuses with 403 fails withCREDENTIAL_DENIEDinstead 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_unavailableresponse now fails with a retryableGATEWAY_UNREACHABLE, and a 429 with a retryableGATEWAY_RATE_LIMITED, both with the server'sRetry-After. They used to fail with a non-retryableIDENTITY_INVALID("Contact your administrator").invalid_grantis stillIDENTITY_EXPIRED. - Contract change:
CredentialContextadds an optionalidempotencyKey, and thehttp-brokeracquire request sends it as anIdempotency-Keyheader. 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 indetail.idempotencyKey, and a failure without an answer (timeout,unreachable,cancelled) carriesdetail.outcome:not-sentwhen the request never left PiShip,unknownwhen the broker may already have issued a credential. A 409 or 422 to an acquire that sent a key is a non-retryableCREDENTIAL_ACQUIRE_FAILEDwithreason: idempotency-conflict; a 409 or 422 without a key is stillrejected. 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-brokerclient accepts only visible ASCII in the identity access token it sends and in the credential the broker returns, and only 1 to 256 characters fromA-Za-z0-9._:-incredential_id; anything else is refused before it is sent or stored (IDENTITY_INVALIDfor the access token, acontractfailure 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 aSecretValue, that holds a space, a control character, or a non-ASCII character is refused withCREDENTIAL_ACQUIRE_FAILEDbefore it is stored. - Contract change: a credential adapter's
credentialIdmust be 1 to 256 letters, digits,.,_,:, or-, as thehttp-brokerclient already required. The ID reaches notices, audit events, the revocation retry record, anddoctor, so any other value now fails the acquire withCREDENTIAL_ACQUIRE_FAILEDfor every provider; an adapter that returned an ID such askey/1or 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_FAILEDas a short or spaced value. - Behavior change: the idempotency key of an
http-brokeroradapteracquire or renewal is now durable. Before,CredentialManagergenerated 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 secondlogin, or a plain retry, issued a second credential and left the first unused. The key is now recorded incredentials-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. Aloginof 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.acquireandcredential.refreshaudit events carryidempotencyKey, andresumed: truewhen the key was repeated. - Contract change: the broker contract now requires that repeating an
Idempotency-Keyreturns 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 withCREDENTIAL_ACQUIRE_FAILED(componentcredential), asking the user to check the broker for the key and revoke a credential it issued, then run the brandedlogoutandlogincommands; onlylogoutremoves 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/credentialsexportsCREDENTIAL_ISSUANCE_SCHEMA,CREDENTIAL_ISSUANCE_FILE,ISSUANCE_RETENTION_MS,PendingIssuance, andIssuanceBase;CredentialManagerOptionsgainsissuancePath,issuanceRetentionMs, andissuanceTarget;IssuanceRequestis exported;CredentialManagergainspendingIssuance(), andlogout()takeskeepIssuanceFor(a principal whose pending request survives a replacing sign-in);AccessStatePathsgainscredentialIssuance. A credential adapter receives the durable key asCredentialContext.idempotencyKeyas before, and should report an ambiguous failure asretryableor withdetail.outcome, since any otherPiShipErrorends the request. The lock'sruntime.stateSchemasgains thecredentialIssuancekey, 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
unreachableerror names the system error behind the managed fetch's code: the message isThe credential broker is unreachable (ECONNRESET)(text that a script or test matching the old message must accept), andsanitizedDetail.transportholds the code. Contract change (additive):transportis a new field of the error'ssanitizedDetail, 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'sGATEWAY_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 asNETWORK_DENIED, is still shown, and the error codes are unchanged (CREDENTIAL_ACQUIRE_FAILED, andCREDENTIAL_REVOKEDfor 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 aslitellm.<ExceptionClass>) now leaves the credential and the entitlement alone, andclassifyGatewayStatus(which takes the answer body as a new optional third argument) maps it toGATEWAY_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 keepCREDENTIAL_REVOKEDandMODEL_DENIED. A 429 withoutRetry-Afternow takes its retry time from LiteLLM'sllm_provider-retry-after, at most one hour; before, the provider's retry time was lost.@piship/corere-exportsclassifyGatewayStatus,gatewayErrorBodyandisUpstreamProviderErrorfor the Pi seam. The gateway evidence test asserts the new behavior against LiteLLM v1.103.0. - Behavior change: a failed
--smoke-modelrequest is reported with the code of the gateway's answer. Before, every failure wasGATEWAY_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_UNREACHABLEfor 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 staysGATEWAY_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_FAILEDand 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 runlogoutthenlogin, anddoctorshows the pending request (#128). - Behavior change: an
http-brokeranswer withnullin 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 (andsubject: nullas a credential for another subject), the error did not name the field, and every retry minted another broker credential. A non-nullsubjectmust still match the identity, and a contract failure names the offending field, never its value, in the message and insanitizedDetail.field(#131).
- Behavior change: the Linux Secret Service store now splits a value whose stored form exceeds 8000 characters across part items named
<ref>+<n>, withchunks:<count>:<write>in the primary item (the macOS and Windows stores usechunks:<count>), and each part carries theparentandwriteattributes, and fails whensecret-toolwarnspassword is too long.secret-tool storekeeps 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. TheCIcheck 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, whensecret-toolcannot reach a session bus. Each part item now carries its primary's reference as aparentattribute, soputclears every earlier part before it writes, a failedputleaves 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),getfails closed on a missing or extra part, anddeleteclears parts by attribute and reports an error when the item or any part is still there afterwards. - Behavior change: on Linux with the
systemstore, a rollback to a release before v0.7 (a lock withoutruntime.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 thechunks:<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 withSECRET_STORE_UNAVAILABLE(decision 30). - Behavior change: the Linux Secret Service store treats a locked keyring as an error.
secret-tool lookupanswers 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 withsecret-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
failedrevocation with a problem entry (revocation: the stored credential could not be read (<code>)), sologoutshows it, and anupdateorrollbackthat clears a credential the target cannot read warns that it was not revoked remotely. It used to be a silentskipped, which hid a credential that could still be valid at the broker. A store that holds no secret for the credential is stillskipped. - 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 withSECRET_STORE_UNAVAILABLEwhile it cannot, andloginfails before storing the new identity. Before, clearing unusable metadata ignored deletion errors and removed the metadata that pointed at the secrets, andlogoutremoved identity and credential metadata even when their secrets were still stored;logoutnow 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, thecredential.revokeevent carriesretryPending: true, everyloginrepeats it as a notice until the credential expires, andDistributionAccess.pendingRevocations()returns the count and ages fordoctor. The revocation itself cannot be re-sent, because the broker authenticates it with the deleted credential. - Behavior change: the branded
logoutclears local secrets even when a runtime variable is not set or a provider cannot be loaded. Before, it failed withCONFIG_UNAVAILABLEbefore 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.revokewithrevocation: failedandretryPending: true), andidentity.logoutis audited withrevocation: 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 retryableCREDENTIAL_ACQUIRE_FAILEDinstead of signing out around the other process. An identity adapter that cannot be loaded is reported asidentity revocation: not attemptedand audited withrevocation: failed, notunsupported. - Behavior change: the branded
logoutexits non-zero withSECRET_STORE_UNAVAILABLEwhen 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:
updateandrollbackconfirm every deletion of a credential the target release cannot read, and stop before activation withSECRET_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.deleteSecretis replaced bysecretStore(aSecretStore), and without one, credential metadata that names secrets stops the switch instead of being dropped. - Behavior change:
piship purgeconfirms every secret-store deletion and fails withSECRET_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.problemsis removed from the public@piship/coretype: a failed deletion throws instead. - Behavior change: a
logoutwhose identity tokens cannot be deleted replacesidentity/session.jsonwith 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 withSECRET_STORE_UNAVAILABLEwhile 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.jsonkeeps at most 20 entries; older ones are dropped and counted (dropped, reported byreadPendingRevocationsand at login).doctorshows 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, andpurge) 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/credentialsexportssecretRefsFromTextandmetadataFileSecretRefs. - Behavior change: a change of
credential.storage.provider(filetosystem, orsystemtofile) between the active and the target release is a credential transition inupdateandrollback. 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 undersecrets/, or an entry in the Keychain, Credential Manager, or Secret Service. Now a present identity session and runtime credential areclear-and-reacquirewith the reasonThe secret store changes from <old> to <new>; …(also inupdate --checkandpiship 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.purgedeletes platform-store references by that record instead of inferring the store from whethersecrets/holds files, so a state left by a storage change no longer loses platform-store entries.MigrationItem.storageTransition,MigrationTarget.storage,AccessOptions.secretStoreFor, andCredentialManagerOptions.storeProvider/storeForare new. - Behavior change: the branded
logoutthat 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 ofcredential.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 withSECRET_STORE_UNAVAILABLE. - Behavior change:
updateandrollbackclear 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 withSECRET_STORE_UNAVAILABLEand keeps the metadata.LifecycleOptionsgainssecretStoreFor, the resolver for the other store; without one (a test with an injectedsecretStore) 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
logoutwhose wait for the credential or identity lock runs out fails with the retryableCREDENTIAL_ACQUIRE_FAILEDeven when a required audit sink is down. Before, the audit of what the command had already done ran first, and itsAUDIT_UNAVAILABLEreplaced 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 ofcredential.storage.provideralways does. The committed example locks changed there. - Behavior change: an unreadable file secret store is reported as unavailable. Before, a
<state>/secretsdirectory or entry that could not be read (permissions, a directory where the entry should be, unparsable content) read as no stored session, so launch reportedIDENTITY_REQUIREDand sent the user to a login that also failed. Now only a missing entry reads as absent; anything else fails withSECRET_STORE_UNAVAILABLEnaming the path, with the action to restore owner-only access or remove the invalid entry and sign in again (#150).
- Contract change: audit
userkeeps the v0.7 form, the principal asprincipalIdgives it (issuer,#, subject), underpiship-audit/v1; v0.6 wrote the bare subject. The form is now fixed: changing it again needs a new audit schema, and no separateprincipalfield 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 stayspiship-audit/v1and the batch schemapiship-audit-batch/v1, and the file sink writes it too.@piship/contractsaddsAuditEvent.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, andAuditSink, 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, andclose()returns the finalAuditStatusor, after all cleanup, throwsAUDIT_UNAVAILABLEwith the pending and dropped counts per sink. A launch or--smokerun whose required sink loses events therefore exits with that error (doctorprints its whole report first and then fails withCONFIG_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 countsAUDIT_UNAVAILABLEin the local startup failures instead.@piship/auditaddsrequiredAuditLoss(status); an event emitted afterAuditLog.close()now counts as dropped instead of being ignored. - Behavior change:
login,logout,update, androllbackhonor 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 withAUDIT_UNAVAILABLEafter the operation, which is not undone (signing out still signs out); a refusedupdateorrollbackkeeps its own error and prints the audit failure. Distributions with only optional sinks keep the warning. - Behavior change: a
loginthat 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, andlogoutprints 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 itsbefore_provider_requesthook, 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 nosession.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_UNAVAILABLEalso outranks a cleanup error in--smokeand--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.
- Behavior change: in a managed distribution, commands the agent runs through the
bashtool receive only the network environment the distribution's policy approves (child process network environment). Proxy variables are passed whennetwork.proxy.inheritEnvironmentis 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_CERTSis set from a single declarednetwork.tls.additionalCAbundle. 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, andNODE_OPTIONSwhen 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 thatsandbox.environment.allownames (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 innetwork.tls.additionalCAinstead. If Pi ever stops giving the agent'sbashtool 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:
doctorreports 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 (asscheme://host:port), whetherNO_PROXYis 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 labeledagent commands); for a personal distribution it says they are not restricted. Audit lists each sink with its target (local fileor the HTTP host, never the URL path or query), state, and delivered, pending, and dropped counts. Sandbox adds the isolation (local,remote, ornone) 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,doctorreportsAUDIT_UNAVAILABLEin the Audit group and exits non-zero after printing the whole report instead of losing it. - Behavior change: the branded
--helpof a distribution withcredential.provider: pi-nativeno longer listsloginandlogout, which always refuse there withPOLICY_DENIED; it listsdoctor | models | versionand says to start the command and use Pi's/login,/logout, and/modelinside the session. The help of every other access mode is unchanged. - Behavior change: the
policynote ofconfig explainfollows the deployment mode. A managed distribution now says that user rules inconfig/policy.jsonare narrowing only (they may tighten a default, never relax a default or an enforced rule, and theirallowrules 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()andclose()savedlogs/metrics.jsonwithout 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 exampleMCP_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.createDispatchertakes{ 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 undersessions/user/orsessions/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-inpiship-redactionextension, 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 amessage_endhandler 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 inlaunch/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:
updateandrollbackkeep 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, sonetwork.privateOnly(and managedpublicFallback: deny), the proxy setting, and the CA bundles were silently dropped:update --check --froman undeclared host was contacted, and so was an HTTP audit sink ofruntime.updateandruntime.rollback, where a launch refuses both withNETWORK_DENIED. The policy now comes from the manifest alone in that case: private-only andallowHostsapply, 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 teamaskthat matched hid a projectdenyfor the same request: never wider than the distribution, but the project's restriction was silently ignored. Decisions only get stricter;policy explainmarks the first match of each of the two sources. - Behavior change: redaction catches more of a known secret and more credential keys. A registered
SecretValueis 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_PATTERNin@piship/contractsnow 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 astokenorapi_keydid.redactValueno longer overflows the stack on a circular value (a back reference reads[Circular]) and shows aBufferorUint8Arrayas 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.jsonis now reported as a policy warning indoctorandpolicy explain(its rules still do not apply, which narrows nothing), and is not recorded as apolicy.violation. Contract change (additive):PolicyEngineInputin@piship/policygains the optionaldiagnostics. - 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
uncaughtExceptionhandler in front, which printed the whole error with its stack, wrote its message and stack tocrashes.jsonin Pi's agent directory (attached by/bugto 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. Atsession_start, after Pi registered its handler, the built-inpiship-redactionextension puts a listener in front of it that rewrites the error object in place (message, stack, and the strings it holds, such as itscause). A thrown primitive cannot be rewritten in place and is still printed as it is, an error thrown beforesession_startis not rewritten, and neither is a value held inside aHeadersorMapobject on the error (security). - Behavior change:
doctor's gateway line readsreachable (<n> listed; model providers not contacted)instead ofreachable (<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.
- 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:443from a denied remote sandbox, and a check that cannot run there no longer fails adenysession. With a declared network probe, a connection from the session's sandbox fails activation closed, and network denial is reportedverifiedonly 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 listsallow. Without a probe (thee2b-compatiblebackend 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 requireddenysandbox still activates. A declared probe that proved nothing adds a warning and one session notice. See network denial. - Behavior change: the
kubernetes-agent-sandboxbackend no longer claims network denial, because it cannot verify the warm pool's NetworkPolicy; a requireddenysandbox on it fails closed. It declaresnetwork: ["allow"]only. - Contract change:
SandboxCapabilitiesgains the optionalnetworkProbe: {host, port}(remote backends; a malformed one fails closed), exported from the adapter SDK asSandboxNetworkProbe.ContainmentReportgainsnetworkDenial(evidence: "verified" | "attested", whether a probe was declared, and a fixed reason) indenymode.doctor's network line readsdeny (verified)ordeny (attested by the backend, not verified: <reason>), and the local sandbox metric recordsnetworkDenial. The sandbox conformance kit'scapabilitiesrefuses a malformed probe, andnetwork claimsuses 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, andnative.test.tsrequiresdispose()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 onlySystemRoot,WINDIR,TEMP,TMP, the job request, and the child's approvedPATH(no credentials). The supervisor turns PowerShell's module auto-loading off and importsMicrosoft.PowerShell.Utilityfrom$PSHOME, so it starts in under a second withoutPSModulePath; with auto-loading, PowerShell searched every installed module and took 20 to 30 seconds. Before, it was started as barepowershell.exefrom the workspace with the whole launcher environment, so apowershell.exein the project root ran outside the Job Object with every launcher variable, a regression from #104. A missing or non-absoluteSystemRootandWINDIRmake the child fail to start, with no fallback to the bare name (#127).
- Contract change: the sandbox backend contract gains a workspace concept.
SandboxCapabilities.workspace({ mode, propagationMs?, sentinelDir? }, omitted meanssnapshot, ignored for local backends), the optionalSandboxInstance.epoch(), and two guarantees:workspace-confinement(a remote backend reaches this host's files only through the workspace) andgit-control-protection(profile.writeProtectcannot be changed from inside the sandbox, also through a sync engine), whichCONTAINMENT_PLANESandSANDBOX_GUARANTEESnow list.requiredPlanestakes the workspace mode: a remote backend with a shared or synchronized workspace needsworkspace-confinementandgit-control-protectioninstead ofhost-filesystem-isolation, and one that claimshost-filesystem-isolationas well fails closed, since a mounted workspace contradicts it. A malformedworkspacedeclaration fails closed beforeprepare.ContainmentReportaddsisolationandworkspace,ActiveSandboxaddsworkspace()andonWorkspaceReport(),describeContainmenttakes an optional live workspace report, a probe result can carrywarnings, and@piship/sandboxexportsworkspaceDeclaration,describeWorkspace, and the workspace types. The built-ine2b-compatibleandkubernetes-agent-sandboxbackends declaresnapshotexplicitly. - Behavior change: a remote backend that declares a shared or synchronized workspace fails closed with
SANDBOX_UNAVAILABLEwhen 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
sentinelDiris 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. Thesession.startaudit event'sdetailaddsworkspace, 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 readsremote (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/commondirand.git/config.worktreenormally 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, andinfopassed while a sandboxed command could write.git/commondirand point the next hostgit statusat 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-workspacefor 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 filesh2s,s2h, ands2h.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 bydoctor; the commands still run) when it finds one. Before, the report saidattested-renameswithcomplete: truewhatever the backend did about renames, so a backend that made.git/hooksread-only but let a commandmv .git/hooks .git/h; mkdir .git/hooks, or rename.gititself, 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.gita mount point to keep the stateattested-renames. Renames are still never tried, soattested-renamesis the mount and permission structure, not a proof; a check that cannot tell (nostat, no mount table) reportsnot-verified. - Behavior change: the remote workspace check writes every workspace-relative path as
./<path>and givesmkdira--. Before, a protected path that starts with-, such as acore.hooksPathof-h, reachedmkdir,stat, andcdas an option, so the probe failed for the wrong reason and a writable directory passed as protected, and asentinelDirthat starts with-never verified.
- 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 anddoctor's containment line now list the plane. It is not yet required: a custom local wrapping adapter that ignoreswriteProtectstill 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.), anddoctor's isolation line comes from the report'sisolationinstead of being inferred fromhost-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 directorycore.hooksPathnames in the project's git config, each as a whole tree and for the git directory a.gitfile points to and the shared directory of a linked worktree as well. Before, a sandboxed command could write a submodule'sconfigorhooks/pre-commit, whichgit statusreads 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 acore.hooksPathdirectory 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 reportednot-verifiedfor 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.hooksPathis read from the user's global config too (~/.gitconfigand$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 filesinclude.pathandincludeIf.*.pathname (every one, whatever its condition, from the including file's directory or~/, up to git's depth of ten and once each) are read forcore.hooksPathand protected, whether or not they exist yet: before, a sandboxed command could putcore.hooksPathorcore.fsmonitorinto 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 controlnot-verifiedwhile 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.hooksPathand the includes are read from the system config (GIT_CONFIG_SYSTEM, or/etc/gitconfig, unlessGIT_CONFIG_NOSYSTEMis true), fromGIT_CONFIG_GLOBAL(which replaces~/.gitconfigand the XDG file when set), and from theGIT_CONFIG_COUNT,GIT_CONFIG_KEY_<n>, andGIT_CONFIG_VALUE_<n>settings, whereinclude.pathtakes only an absolute or~/path, as in git. Before, a relativecore.hooksPathset 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.hooksPathvalues, 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 controlnot-verifiedfor 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):ProtectedPathsin@piship/sandboxgains the optionalunverified(the reason a list is incomplete), which@piship/pi'sgitProtectionsets, and@piship/policyexportsprojectGitControlUnverified. - 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 agit checkoutthat deletes a directory holding an included config file fails with EBUSY or EPERM in a native sandbox, and a package install whosepreparescript 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 reportednot-verifiedfor 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_GLOBALandGIT_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 acore.hooksPathto~/.gitconfig, which the user's nextgitcommand outside the sandbox runs. They are now in the sandbox profile'swriteProtectwherever 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~/.gitconfigwould stop the edit tool changing the user's own git configuration (piship.project.git-configdoes 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 controlnot-verifiedfor a local backend, as a missing include in the working tree already did. With the default manifest (writableworkspaceandtmponly) nothing changes. Contract change (additive):projectGitControlFilesin@piship/policytakes 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 indoctor) 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, acore.hooksPathdirectory or.git/hookslinked to a shared directory, or a linked~/.gitconfigwhen~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 reportedattested-renamesor verified. A link in a directory the sandbox cannot write is not reported, so a linked~/.gitconfigchanges nothing unless the manifest lets commands write~. Contract change (additive):@piship/policyexportsprojectGitControlLinks, andProtectedPathsin@piship/sandboxgains the optional inputlinks, which@piship/pi'sgitProtectionsets. - Behavior change: the git paths a sandbox must not change now also follow
GIT_CONFIG_PARAMETERS, whichgit -c key=valueexports to the commands it starts.core.hooksPathandinclude.path(an absolute or~/path, as forGIT_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 throughgit -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 reportednot-verifiedwith that reason. - Behavior change: on native Linux, git control is now reported
not-verifiedfor 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/commondirand.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 saidverifiedwhile 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 optionalSandboxCapabilities.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(orGIT_CONFIG_SYSTEM), but git fixes its path when it is built, so a Homebrew git reads/opt/homebrew/etc/gitconfigor/usr/local/etc/gitconfigand Apple's git theusr/etc/gitconfigof its toolchain: acore.hooksPathor include set there was not followed, and the file was not protected in a writable directory (/usr/local/etcbelongs 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_SYSTEMstill names the one file (empty: none) andGIT_CONFIG_NOSYSTEMstill turns them all off. A git built with another prefix (Nix, a source build) is not known and needsGIT_CONFIG_SYSTEMto name its file, which the docs say. - Behavior change: on macOS (Seatbelt), git control is no longer reported
not-verifiedfor a missing protected file whose directory cannot be swapped, such as a missing include in the project root or a missing~/.gitconfigin a writable home. Seatbelt denies the path of a missing file, but the check counted every missing file outside.gitwhatever 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.
- Contract change:
sandbox.credentialacceptsstoredfor the custom, e2b-compatible, and kubernetes-agent-sandbox providers, withinpiship/v1alpha4; it needssandbox.endpoint. A release whose manifest uses it can be installed byupdateonly from a release that already knows the value.nonestill parses to an omitted field, so the lock's sandbox digest changes only for a manifest that uses a credential. - Contract change:
@piship/contractsaddsSandboxCredentialAccess,SandboxCredentialSource, andSandboxCredentialKind.CustomBackendContextgainscredentialOriginsandcredentialRejected, andRemoteBackendOptionsgainscredentialRejected;CredentialManagerOptionsgainsslot(inference, the default, orsandbox) andorigins;LocalSecretCredentialProvidertakes an optional prompt label, kind, and maximum length;@piship/credentialsexportsCredentialSlotandSANDBOX_CREDENTIAL_METADATA_SCHEMA; andCredentialManager.markRejected(used?: RejectedCredential | string)also marks a non-renewable credential of thesandboxslot (a bare reference marks that generation only; aRejectedCredentialnames the issuance by reference, acquisition time, optionalcredentialId, and principal, compared under the lock). The lock'sruntime.stateSchemasgains thesandboxCredentialkey, so the committed example locks changed there. - Behavior change:
logoutalso deletes the stored sandbox credential, andloginas another principal deletes it, confirming the deletion, before the new identity is stored; a deletion that cannot be confirmed fails the login.purgedeletes 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_REQUIREDand no longer deletes the new user's credential;sandbox loginchecks 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 auditscredential.revokeafteridentity.logout;loginclears another user's sandbox credential a second time once the new identity is stored, so asandbox loginthat 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'scredentialRejected()means (credentials).
- Behavior change: the sandbox conformance kit's
secret leakagecheck finds the credential when a backend writes it toprocess.stdoutorprocess.stderrin 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 andBufferwrites 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 sameprocess.envnames 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.
- Two workspace packages for adapter authors,
@piship/adapter-sdkand@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/contractsnow definesAdapterContextandResolvedEndpoints, which@piship/corestill re-exports unchanged. The committed example locks change innpmLockSha256and, for the stored sandbox credential's state class, inruntime.stateSchemas. - The adapter SDK surface, a preview:
defineIdentityAdapter,defineCredentialAdapter, anddefineAuditSinkreturn their argument unchanged;defineSandboxAdapterreturns a factory whose backend has passedcustomBackend(), the check PiShip's loader applies;withTimeoutcomposes a timeout with the caller's cancellation. Everything else is a curated re-export of@piship/contractsand@piship/sandbox(context and provider types,SecretValueand redaction, theManagedFetchtype,PiShipErrorandparseRetryAfter, the audit batch contract, and sandbox capability declarations), which a unit test pins and checks for internal or Pi imports.defineAuditSinktypes the collector behind the built-inhttpaudit 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 andnode: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,
testAuditSinkin@piship/adapter-conformance. Before, an adapter author had no way to check anAuditSinkagainstpiship-audit-batch/v1other than reading the contract. The kit connects the sink to a fake collector it controls and reportspassed,failed, orskippedwith 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,
testCredentialAdapterin@piship/adapter-conformance: it runs a credential adapter against a fake broker it owns, through the adapter's managedfetch, 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 withoutrevoke(), 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,
testIdentityAdapterin@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 managedfetch, 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; arespondhook 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 whoseminthook the adapter author supplies; without one they are skipped withneeds 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,
testSandboxAdapterin@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 declaredsharedorsynchronizedworkspace PiShip's two-way sentinel, the git control probe, and the re-check after the validity window or anepoch()change, as passed, failed, or skipped with a reason that never holds a secret. A snapshot backend's workspace checks are skipped withdeclares snapshot, never passed;cleanupand 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. Thecancellationcheck 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, whichdocs/adapter-sdk.mdsays. - The adapter SDK guide documents the sandbox credential a custom backend receives. Before, it said
CustomBackendContext.credentialcame only fromsandbox.credential: runtimeand did not mentioncredentialOriginsorcredentialRejected. It now lists the three sources (runtime,stored, and the module'ssandboxCredentialexport), the origins each may be sent to, and whatcredentialRejected()does for each.SandboxCredentialAccessstays out of the SDK: a backend never sees it, and a module's own credential is a plainCredentialProvider, which the SDK already exports.
- Workspace consistency for sandbox backends (workspace). A remote backend declares whether its sandbox sees the files PiShip's file tools edit:
shared,synchronized, orsnapshot, the default. Before the first command that reaches the sandbox (never at activation, in Plan mode, or fromdoctor), 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 optionalSandboxInstance.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.jsonas a newworkspaceentry: 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 asUNKNOWN. The field is additive withinpiship-metrics/v1. Whiledoctor'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: storedsends 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 logoutdeletes it. It is kept in the configured secret store underpiship:<id>:sandbox#<n>, with non-secret metadata incredentials-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 exportsandboxCredential, aCredentialProviderthat 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 recordscredential.acquireandcredential.revokewithpurpose: "sandbox", never the secret, origin, or endpoint. doctorreports 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 declaressandbox.credential: stored, doctor showssandbox 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 namessandbox 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.
- 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 on127.0.0.1only. The Keycloak realm has a publicacmecodeclient that requires PKCES256and registers the port-less loopback redirecthttp://127.0.0.1/callback, and two users, alice and bob, in different groups. LiteLLM runs theexamples/enterprise-litellmproxy config unchanged. Secrets come from a git-ignored.envthatexamples/enterprise-reference/scripts/generate-env.mjsfills with random values;scripts/get-token.mjsin the same directory signs a user in through Authorization Code with PKCE. Readiness isdocker compose up --waiton 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-brokercontract had only the loopback fixture behind it. The broker (Node built-ins only, abrokercompose service on127.0.0.1) validates the access token (RS256 only, key bykidfrom the realm JWKS with a bounded refetch, issuer, audiencepiship-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/coderandacme/general;/support:acme/coder), and returns the documented response withcredential_idin PiShip's alphabet,base_url, and the authenticatedsubject. It honorsIdempotency-Keyas documented (replay, 422 conflict, 503 while in flight, answers kept in memory), answers 401, 403, 429 and 503 withRetry-Afteras 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'sHttpBrokerCredentialProvider, 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.yamlfollowsexamples/demo-companysection by section (policy, required sandbox, audit, signed channels, release) and points at the stack: the Keycloak realm as the OIDC issuer with the publicacmecodeclient and the port-less loopback redirect, the reference broker for the credential, LiteLLM as the gateway. It is locked withpiship lockand builds; a copy that pins a release key passes everypiship releasegate 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 (withPISHIP_LIVE_SECRET_STORE=1) or the restricted file store,models, a streamed--smoke-modelrequest, renewal of a key the gateway rejected, andlogout(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 bothacmecode-reference(builddist/acmecode-reference, modelsacmecode-reference/<model>, and the broker serves that ID throughBROKER_DISTRIBUTIONincompose.yaml), so it never shares a state directory, secret-store entries (piship:<id>:*), or a launcher with the demo company'sacmecode; theACMECODE_*variables, the Keycloak clientacmecode, and the broker'sazpdefault 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: customhad 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 thedockercommand line,127.0.0.1only) 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,.gitread-only), and a single-file adapter on@piship/adapter-sdkdeclares asharedworkspace withworkspace-confinementandgit-control-protection. The service answers 401 without a key it issued (only hashes are kept, andgenerate-key.mjsnever 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, whichgenerate-key.mjsfills with the running user or--uid), optionally narrowed to directories (roots,--root), orunbound: 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 ofdocker exec --interactive, oneNAME=valueline 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'sPISHIP_EXEC_ID(one that scrubbed its environment then survives until the sandbox is removed, as the sandbox README says). It names itselfreference-<uid>-<port>unlessSANDBOX_INSTANCEis 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}, andcredential: stored(each user runssandbox loginwith 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 ispiship.locknext 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 inpackages/core/src/release.test.tsnow lists it.
-
Docs: every documented CLI command runs as
node packages/cli/dist/bin.js. Going through npm's executor failed on a clean clone, becausenpm cilinks 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-stateafterpiship 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 downloadfdandrginto~/.pi/agent/bin, contacts pi.dev, and showspi updateandpi --sessionhints;PI_OFFLINE=1stops 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 readsACME_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/v1alpha4andpiship-lock/v1alpha4.sandbox.credential: storedand the lock keysruntime.stateSchemas.sandboxCredentialandcredentialIssuanceare 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
sharedorsynchronizedworkspace is verified by two-way propagation, and a protected path that might only be renamed isnot-verifiedgit 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 indocs/architecture.md) say that a crashed holder whose process ID is in use blocksupdate,rollback, anduninstallfor up to 24 hours, quote the error and the action that namesapps/<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-litellmexample 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 thatpurgeconfirms every secret deletion and fails before it removes state, where they said best effort. The manifest guide lists thesandboxCredentialandcredentialIssuancestate schemas,--new-session, anduninstall <id> --purge --yes; the demo company README lists thedoctorgroups 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 noInferenceProvideradapter 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 are0.7.0; they said0.1.0.
-
The SDK example adapters pass their conformance kits.
credential.mjssendsCredentialContext.idempotencyKeyasIdempotency-Keyand reports it asdetail.idempotencyKeyon every failure, refuses a credential that has already expired withCREDENTIAL_EXPIRED, and turns an answer that is not JSON or lacks a field into aPiShipErrorinstead of aSyntaxErrorwhose message can quote the body.identity.mjsmaps HTTP 401 and an OAuthinvalid_granttoIDENTITY_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.mjsreturns a fixed reason fromavailable()and never passes a transport error on, since either could quote the credential.tests/adapter-kits/examples.test.tsruns the kits against the identity, credential, and sandbox examples. -
The stored sandbox credential that
DistributionAccesshands out (sandboxCredential(), used bylogin,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, soSandboxCredential'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-memorysecretStorewithoutsecretStoreForsees 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 auditran without the workspace package manifests, so it took every locked package for a dev dependency and--omit=devaudited nothing: the gate always passed, and brace-expansion 5.0.9 with two high advisories passedfailOn: high. Now nested runtime packages are scanned and a blocking advisory failspiship 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 undeclaredbase_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 (issuedNotRevokedin 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:
updaterefuses 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()coversX-Api-Keyandapi-keyheaders, anyAuthorizationscheme (Token),?token=and&token=query parameters,CookieandSet-Cookieheaders, JSON keys with escaped quotes, and camelCase key names such assecretAccessKey(#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-commitwhile.git/hooksdid 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.sockordocker.raw.sock) also with the network allowed, so a contained command cannot start a container outside the sandbox (#159).
- 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_PROXYpath, 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 thatnetwork.privateOnlymatches hostnames only: it ignores ports and schemes and does not check that a host is a private address. - The personal
local-secretandnoneaccess-mode E2E scenarios (identity.mode: nonewith 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: noidentity/principal.jsonis written, a local secret stored by an earlier release stays usable, andlogin,logout, andloginagain 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 aslifecycle-secret-store-system.test.tson the platform store andlifecycle-secret-store-file.test.tson 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-switchingalso 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 declaressystemstorage. The system-storage scenarios run only withPISHIP_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 intests/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) intests/e2e/security-sweep.test.tsandsecurity-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 theuser-switching,lifecycle-credentials, andlifecycle-rollbackE2E 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 itsAuthorizationheader into an error body leaving the runtime credential in the Pi session file; with the session-file redaction below it searchessessions/like everything else. - A personal clean-machine E2E,
tests/e2e/personal-clean-machine.ts(run aspersonal-clean-machine-mypi.test.tsandpersonal-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-lifecyclemade no model request andpersonal-local-modelhad 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 withloginin 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, anddoctorreports 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,runin 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 withmodels, an authenticated model request with--smoke-model, session resume,doctor, commands run through the native sandbox, update, rollback, logout, and uninstall withpurge, 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 wherePISHIP_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, andlogout, 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/faultswith{"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.0by 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 inpackages/core/src/lifecycle.test.ts. A unit test keepsSTATE_DATA_CLASSESand the state table indocs/architecture.mdin 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 bynpm 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.
- Tests against the live enterprise reference stack, run by
npm run test:referenceundertests/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): thehttp-brokerprovider and thehttpaudit sink's delivery pass every behavior they can show, the native backend and a custom backend passed throughcustomBackend()pass, and thee2b-compatiblebackend, 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/auditexportsHttpSinkWriter, thehttpsink's delivery, for that test. The audit kit gains seeded defects that failevent id stabilityanddelivery failurealone, and a test that every audit behavior has one. - A
Reference E2Eworkflow (.github/workflows/reference-e2e.yml) runs the AcmeCode reference distribution against the live enterprise reference stack onubuntu-latest: it pulls the images, starts the stack withdocker compose up --waitand records the pull and startup times in the job summary, runs the broker's live check and contract tests, and thennpm run test:referencewith the Linux Secret Service (GNOME Keyring on a private D-Bus session,PISHIP_LIVE_SECRET_STORE=1, no file fallback).Release qualificationruns it next toCI, CodeQL, and Portable E2E, andRelease candidatenow waits for it; it also runs nightly (a failure opens a tracking issue) and byworkflow_dispatch, and never on pull requests. On failure it uploads the container logs of every stack, scrubbed byexamples/enterprise-reference/scripts/scrub-logs.mjs; the reference tests write those logs only whenPISHIP_REFERENCE_LOG_DIRis set. The scrubber replaces every.envvalue of 8 characters or more (ports excepted), anything shaped like a LiteLLM key, a JWT, or aBearerorBasicvalue, and an OAuthcode,state, orsession_statewhere it is a URL parameter (after?,&,#, or&, 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 providerworkflow (.github/workflows/live-provider.yml,workflow_dispatchonly, never in the merge gate or Release qualification) routes the reference LiteLLM to a real model provider and sends one--smoke-modelrequest through the installed AcmeCode distribution, with the result (status, stop reason, reply length, time) in the job summary and the provider name shown asredactedunless the dispatch input turns that off. The provider key, base URL, and model come from theLIVE_PROVIDER_API_KEY,LIVE_PROVIDER_BASE_URL, andLIVE_PROVIDER_MODELsecrets of thelive-providerenvironment 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 andscrub-logs.mjsdo 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 committedcompose.yamland LiteLLM config are unchanged.startStackin the reference distribution tests takes override files, AcmeCode's environment in those tests never holds aLIVE_PROVIDER_*variable, and kept container logs are also scrubbed of everyLIVE_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 asCREDENTIAL_REVOKED; the installed AcmeCode distribution renews a key the gateway blocked or expired at launch and reportsCREDENTIAL_REVOKEDwhen the broker is down for the renewal;/v1/modelslists only a key's models, and PiShip offers the intersection of allowlist, entitlement and live list; a streamed answer ends withstop, 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 429all_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}onPOST /__mock/faults), andtests/enterprise-reference/stack.tstakes 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 inpiship-reftest-<pid>-*directories, and stop it on exit, Ctrl-C, and termination, so a secondnpm run test:referencenever stops the first one's stack. What a killed run left (Compose projects, temporary directories, and sandbox containers labelledpiship.sandbox.instance=piship-reftest-<pid>-...whose process is gone, removed by ID) is removed by the suite's global setup, as for the files undertests/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. WithPISHIP_LIVE_SECRET_STORE=1on macOS the tests refuse to run unlessCIis set, since the platform store there is the developer's own login keychain. - Evidence for the reference container sandbox, run by
npm run test:referenceand the Reference E2E workflow (Docker required), inexamples/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 (includingnetwork claimsagainst a listener in a container of its own,workspace consistency, andgit control protection), and, seeded by hand into the service while it was written, a writable.gitmount, an ignored network denial, and a cancel that only killed thedocker execclient 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 workspaceshared(both directions immediate, protected git paths read-only, git controlattested-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 asdocker inspectshows 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/healthwith 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 fakedockerwithout Docker (node --test test/*.test.mjsinsandbox/), also in the Reference E2E workflow. - The
Reference E2Eworkflow runs its test files in four groups on four runners at once, named invitest.reference.config.ts(PISHIP_REFERENCE_SHARD=<group>/<count>) and balanced by their measured durations, beside astackjob 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 runsnpm ciand 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 asreference-e2e-logs-stackorreference-e2e-logs-<group>.
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.
- 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 thedoctorprobe; 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 thePI_COMPATIBILITYcopy in@piship/coreadd agovernancesurface,candidatefor Pi 0.87.1: CI and Portable E2E evidence on Linux, macOS, and Windows, no live company services.- A
Pi latest canaryworkflow (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 withPISHIP_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 qualificationworkflow (manual only) runsCI, CodeQL, and the full Portable E2E in parallel on one commit, thenRelease candidateonce 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--smokeanddoctor. Reproducibility reports are namedreproducibility-<distribution>-<target>. - Pluggable sandbox backends, a preview.
sandbox.providerselectsnative(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), orkubernetes-agent-sandbox, withadapter,endpoint,router,namespace,template,workdir,user(e2b-compatible only;rootfor CubeSandbox), andcredentialsettings that each provider accepts only where they apply. All backends implement oneSandboxBackendcontract 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 asPATHorHOME, or credential-looking variables, and receives the inference runtime credential only withcredential: runtimeon 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 withSANDBOX_UNAVAILABLE. Reports never claim more than a backend enforces: local backends must enforce thefilesystem-*path planes, while the E2B and Kubernetes backends report onlyhost-filesystem-isolation(host files unreachable),network-deny, andenvironment-filter, anddoctorsays that path rules are not enforced inside a remote sandbox. Kubernetes claims carry a boundedspec.lifecycle(shutdownTime,shutdownPolicy: Delete) that PiShip renews while the session uses them, so a claim whose cleanup fails still expires. The containment report addsprovider,verification(live-probeorbackend-attested), andlocalProcesses; 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.
- Behavior change: release archives are compressed at zlib level 6 instead of 9.
piship releaseof 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 buildremoves optional packages whose npm lockos/cpuexclude 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.jsonin the payload is unchanged. - Project consolidation; see docs/roadmap.md.
- Documentation: a single status page for what current
mainsupports 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.mdis 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
CIcheck 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-scopedPi compatibilityandSecret storeworkflows 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:
doctorshows the effective outbound state on oneoutboundline in place of thepublic fallbackandprivate-onlylines. 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/contractsno longer exports the error codesAPPROVAL_REQUIRED,PROVIDER_UNHEALTHY,PROVIDER_UNRESOLVED,RESOURCE_DENIED, andSANDBOX_REQUIRED(removed fromPISHIP_ERROR_CODESandPiShipErrorCode); no code path produced them. Headlessaskresolves to deny, denied tools and resources are refused and audited as denials (refused commands and settings usePOLICY_DENIED), provider problems are reported on theresolved,enabled, andhealthycapability axes, and a required sandbox that cannot be enforced fails withSANDBOX_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.jsonlby size: before it would pass 10 MB it becomesaudit.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 whenaudit.jsonlis empty. - Contract change:
modelRequirementGaps,incompatibleCapabilities, andModelIncompatibilitymoved from@piship/coreto@piship/policy, which also exportsModelEvidence;@piship/coreno longer exports them and addsconfiguredModel.@piship/policydropsCapabilityStateInput.modelToolSupportandTOOL_DEPENDENT_CAPABILITIES(no caller passed them) and addsCapabilityStateInput.modelandpolicyDenied. - Behavior change:
release.jsonrecords thegovernancesurface inpi.surfacesand counts it towardpi.compatibility, now the weakest of the deployment,governance, andlifecyclesurfaces, whenever the distribution declares governance (everypiship/v1alpha3or later manifest). Thepirelease gate refuses a release whose governance surface isunsupported. - Behavior change:
piship buildruns the releasesourceandinstall-scriptgates onpiship/v1alpha4locks before assembling the payload (Build gate source: ...,Build gate install-script: ...).piship devandpiship testdo not;piship releaseis unchanged. - Behavior change: the demo company's
policy.projectTrust.companymatcher requires bothremote: "git.acme.example/**"andpath: "/srv/src/**", as the manifest guide recommends; itspiship.lockis regenerated. - The README opens with the enterprise case for PiShip ("Why PiShip?") before the quickstart.
- Contract change: in
@piship/sandbox, the native adapters run throughNativeBackend;ActiveSandbox.dispose()returns a promise;ContainmentReport.adapteris a backend id string and the report addsprovider,verification, andlocalProcesses;probeSandboxalso 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 releaseapplies 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_requestconcurrency expressions and the fork-attestation condition from the dispatch- and schedule-only Portable E2E and Release candidate workflows.
- 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.jsonare narrowing only, like team and project rules. A userallowrule is ignored and reported indoctorandpolicy explainand recorded as apolicy.violationaudit event. Useraskanddenyrules join the strictest-effect comparison, so they can tighten a decision but never relax a distribution default orpolicy.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.doctornames the offending entry or limit. The walk runs at launch and does not detect links created afterward. - If the
tool_callpolicy 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 astool.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/contractsnow holds the only redaction pattern set.redactnow also removes GitHub, GitLab, Slack, AWS, and Google API tokens, standaloneBasiccredentials whose value looks like base64 (so text like "basic authentication" is kept), JWT shapes without theeyJprefix, 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, andconfig explainare also redacted more thoroughly.redactValueredactstoken,cookie,passwd,client_secret, andbearerkeys too (new exportSECRET_KEY_PATTERN).scrubTextin@piship/auditis now the same function asredact. - Behavior change: A
credential: runtimeStreamable HTTP MCP server receives the runtime credential only when its URL has the same origin (scheme, host, and port) asinference.baseUrl, the gateway the credential is issued for. A server on any other origin fails to start withMCP_UNHEALTHYbefore the credential is read. A required server therefore fails the launch. - Behavior change: In managed mode,
network.publicFallback: denyis now enforced. Managed mode requiresdeny, so every managed launch is private-only, whatevernetwork.privateOnlysays. The managed fetch and the Pi process's default dispatcher accept only the issuer, broker, and gateway hosts,network.allowHosts, and, for update commands, theupdates.sourcehost. Managed distributions with Streamable HTTP MCP servers or HTTP audit sinks on other hosts must list those hosts innetwork.allowHosts.config explainshows the effectivenetwork.privateOnly, anddoctorwarns about any HTTP MCP server or HTTP audit sink whose host is not declared. Undeclared hosts are never allowed implicitly.
- Two audit writers no longer rotate
logs/audit.jsonltwice 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-denyandfilesystem-write-allowlist. A custom backend that declares only one of them no longer makespolicy explainor file-tool denials saysandbox, anddoctorsays the path rules are only partly enforced (#25). - Behavior change:
updatevalidates the resolvedupdates.source(after${NAME}resolution) and--fromwith the same rules as the manifest:https, orhttpon127.0.0.1,localhost, or[::1], with no credentials, query string, or fragment. Other schemes (ftp:,file:, publichttp:) are refused withNETWORK_DENIEDinstead of being read as a local directory. A local directory resolved fromupdates.sourcemust be absolute;--fromstill accepts a relative directory. The manifest validation hint now lists the accepted forms. - The inference gateway now parses
Retry-AfterHTTP-dates as well as seconds, using one sharedparseRetryAfterfrom@piship/contracts(also used by the credential broker). Error output (formatError) showsRetry after: <n> swhen an error carries a positive wait. - Behavior change:
piship keygenrefuses to write the private key inside a git work tree unless the path is git-ignored;--force-in-worktreeoverrides this explicitly. When git is missing or exits with an error, a.gitentry in any parent directory counts as a work tree. - Behavior change:
piship migratefrompiship/v1alpha2writespassiveContext: denyfor every project origin. v1alpha2 never loaded project themes (its resource loader ran withnoThemesagainst the distribution directory), so the oldallowstarted loading.pi/themesafter migration. Manifests already migrated keep what they declare. - Behavior change:
piship purge <id> --yesalso 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_UNREACHABLEinstead ofIDENTITY_INVALID, and a gateway model-list probe that times out (while connecting or reading the body) fails with a retryableGATEWAY_UNREACHABLEinstead 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 toidentity/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 explainno longer claims that non-allow decisions on theaudit-onlyplane 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
PiShipErrorinstead of a plainError: a payload file that differs from the inventory fails withINTEGRITY_FAILED, and a lock that no longer matches the packaged manifest or npm lock fails withLOCK_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 genericUPDATE_FAILEDorROLLBACK_FAILEDwhen the active payload fails verification. - Behavior change: A capability whose provider the policy refuses is reported as
enabled: noinstead ofhealthy: noincapabilities,doctor, and the session's capability state. - Behavior change: The
compatiblecapability axis checks an enabled capability's modelrequirementsagainst the selected model with the same comparison as the launch-timeMODEL_INCOMPATIBLEcheck, so a running session's report and launch agree. Offline,capabilitiesanddoctoruse the model launch would select from the configuration and its manifest catalog metadata; they can differ from launch when--modelor a credential entitlement changes the selected model.
- 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 onmainthat ordinary pushes and pull requests never refresh. - Large modules are split with no behavior change:
@piship/coreaccess,diff,release, andlifecycle(nowrelease/,install/,update/), the core index,@piship/schemagovernance parsing, and@piship/pilaunch code and the governance session. No non-test source file exceeds 900 lines exceptcore/src/access/distribution-access.ts(about 1,000 lines), whoseDistributionAccessclass 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/pito@piship/core(branded/).@piship/coreadditionally exportsrunLogin,runLogout,runConfig,runUpdate,runRollback,lifecycleDoctor,undeclaredGovernanceHosts,openAccess,governedLock, and the typesBrandedContext,DoctorLine, andGovernedLock. 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/piand@piship/policydependencies, so the example locks record a new npm lock digest. - Tests keep
PISHIP_VERSIONequal to every workspace package version and the pinned Pi version equal across core, pi,package-lock.json, andcompatibility/pi.json.
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.
MODEL_INCOMPATIBLEwhen a model does not meet a capability's model requirements.- Accurate
credential.acquire,credential.refresh,credential.revoke, and logout audit events. validateruns the lock-time integrity checks without writing a lock;dev --smokewith diff and dev E2E coverage.- The personal reference distribution on
piship/v1alpha4with 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
lifecyclesurface is recorded inrelease.json; local metrics are metadata-only.
- Plan mode is an allowlist of
readandask_user. - Streamable HTTP MCP URLs resolve runtime references.
- Actions without a runtime hook report
audit-only. initwritespiship/v1alpha4.SecretValuenormalization, cross-process identity refresh, a Windows file-store ACL, andazptests.- 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.mdandCONTRIBUTING.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.
- 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
sourcegate 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.
Preview milestone; not published to npm. Commit 7077086 (#14), "add production release lifecycle". Introduces piship/v1alpha4 and piship-lock/v1alpha4.
- A required
updatessection (channel, allowed channels, source, rollback, pinned Ed25519 release keys) and an optionalreleasesection (targets, approved package sources, vulnerability threshold, and expiring exceptions), withpiship migratefrom v1alpha3. piship-lock/v1alpha4with 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 blockingnpm auditfindings; output is one deterministic.tar.gzper target wrapping the unchanged payload withrelease.json, an SPDX 2.3 SBOM, third-party notices, the scan result, checksums, andinstall.sh/install.ps1.piship verify-release,piship reproducibility(per-target payload equality report), andpiship diff(release-impact changes with risk and required tests).- Signed update channels:
piship keygenandpiship sign-channelwritestable,candidate, ordevmetadata signed with Ed25519, with expiry and a monotonic sequence that clients use to refuse replays. - Branded
update [--check] [--channel] [--from] [--accept-review]androllback, also reachable aspiship 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-checkruns the migration check alone. - Supply Chain and Update sections in
doctor,runtime.updateandruntime.rollbackaudit events, lifecycle outcome counters in local metrics, and theUPDATE_FAILEDandROLLBACK_FAILEDerror 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.
- The demo company example is a managed
piship/v1alpha4distribution with a governed handbook MCP server, Plan/Build workflow, a required sandbox, and stable and candidate update channels read fromACMECODE_UPDATE_SOURCE; it pins no release key. - Installation uses a versioned
piship-install/v1receipt, a per-distributionlaunch.mjsthat starts the active release, and one directory per retained release.piship installalso accepts a release directory or archive. Installs made by earlier versions still run and uninstall, but need a reinstall for update and rollback.
Preview milestone; not published to npm. Commit 07adb72 (#13), "add governance and security baseline". Introduces piship/v1alpha3 and piship-lock/v1alpha3.
- Resources declared by trust class (
company,certified,user, andbuiltinextensions); 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, andaudit-onlyenforcement planes, and a brandedpolicy explain <action> <resource> [--json]. Headlessaskresolves 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.jsonmay relax distribution defaults only. - Capability contracts with the builtin
permissionsand Plan/Buildworkflowproviders, a six-axiscapabilities [--json]report, and the builtinpiship-ask-userextension. - Governed
read,write,edit, andbashtools 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_UNAVAILABLEfor a required sink), and local metrics. - Governance sections in
doctor,config explain, and the--smokesummary;piship-lock/v1alpha3; andpiship migrateto v1alpha3. - New packages
@piship/policy,@piship/audit,@piship/sandbox, and@piship/mcp.
- The demo company example became a managed
piship/v1alpha3distribution 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.
- 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 isdenyorallowonly.
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.
- The
piship/v1alpha2schema for managed and personal distributions, with allowlisted runtime references and a static lockaccesssection;piship migrateupgrades 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 providershttp-broker,local-secret,pi-native,none, andadapter; 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 brandedlogin,logout,doctor,models, andconfigcommands. - The demo company example, which runs against deterministic local fixtures.
- The
yamldependency moved to 2.9.1 (#10).
- The Pi runtime is governed so managed distributions expose only allowed models and never inherit ambient provider credentials.
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.
- 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.
- 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 duringupdateandrollback, 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 everylogin,logout,sandbox loginand launch until the provider was changed back. - The
Live providerworkflow'slivejob runs in thelive-providerGitHub environment, which holds the provider secrets and limits which branches can use them;.github/workflows/live-provider.ymland the reference README describe the setup. Before, the secrets were repository secrets, available to a dispatch from any branch.