diff --git a/.gitignore b/.gitignore index 7df567302..71e6c4182 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,6 @@ COMMIT_MSG.tmp # blog drafts (local, not for commit yet) /blog/ + +# Generated by `bun run build:pack` — the builtins emitted as a policy pack. +policy-pack/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c205dc270..6efb030c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,36 @@ # Changelog -## 1.0.2-beta.0 — 2026-08-19 +## 1.0.2-beta.0 — 2026-08-21 + +### Features + +- Deny when a pack this machine was told to enforce is not running. Every content-load failure here fails OPEN, which was sound only while the builtins shipped compiled in and enforced underneath; once a pack can be the only guard, the same behaviour is zero enforcement at exit 0 on a machine reporting healthy. The trigger is a RECORDED EXPECTATION, never an empty manifest: an absent `installed.json` is a fresh machine and stays silent, while a pack that is declared and will not resolve, or that registers less than it declared, denies. Six carve-outs, each closing a way the deny would be wrong — an `observe` pack (it evaluates and discards, so denying on its behalf denies for something that would have allowed), policies the user never took via `--only`, policies explicitly disabled, a pack the loader never received (inferring failure from "no registrations" cannot tell an import error from a pause skip, and a heuristic that DENIES is worse than one that allows), an active session pause (a registration-derived check would fire on every paused event and turn a bounded deliberate pause into a machine-wide deny), and a load TIMEOUT, which is transient — a machine-wide deny from one slow disk moment persists until a human intervenes, and in the warm worker the denials themselves add load. The deny is narrow, unlike the daemon's: an unreachable daemon means no evaluation happened so nothing can be known safe, but an unloadable pack has an ENUMERABLE set of missing guards, so it matches only the events and tools those policies declared. `UserPromptSubmit` instructs instead of denying, whatever a missing policy declared — handler.ts records from a real machine that a blanket deny takes it along and locks the user out of the agent entirely, and it is the one event that can tell a human what happened. It registers as a policy rather than emitting an exit code, because a bare exit-2 is a silent ALLOW on eight CLIs; it is additive rather than clearing the registry, so a corrupt third-party download cannot switch off the alwaysOn self-protection guard; and it registers above builtins so the deny is attributed to the missing pack rather than to whatever fired next. (#PR) + +- Install the pack that ships inside the npm package, from disk. The tarball already carries `policy-pack/` — the builtins as a real, digest-verified pack — and `installBundledPack()` copies it into `~/.failproofai/policies/packs/` with no network at all. This is the piece that makes removing the builtins from the bundle survivable: a machine that has just installed failproofai already HAS them, so a fresh offline install is a guarded machine rather than one enforcing nothing while reporting healthy. It verifies the digest even though nothing crossed a network, because the recorded digest is what the hook path re-checks before every import and a tarball can be corrupt on disk like anything else; it validates the manifest with the loader's OWN rules, so a bundled pack that could never load fails the install rather than looking fine until the next tool call; and it COPIES the artifact rather than loading it where it lies — a `sudo npm i -g` package directory is root-owned and the loader writes its rewritten module tree beside the source, so loading in place would give a non-root hook EACCES and the pack would silently never load. Anchored at `FAILPROOFAI_PACKAGE_ROOT` rather than `import.meta.url`, which does not survive the CJS bundle. Recorded with a `bundled:` source rather than a github one, since `pack add` on that id would otherwise look like a re-fetch of something never fetched. Idempotent, and a re-run keeps an existing selection. (#PR) + +- Install a pack's OWN defaults rather than everything in it, and select by category. `pack add` enabled all of a pack's policies, so installing the builtins pack switched on all 38 when only 10 are `defaultEnabled` — turning on `block-kubectl`, `block-terraform`, `require-ci-green-before-stop` and 25 others that the npm package deliberately leaves off because they interrupt legitimate work. The pack carried that opinion in its manifest and the installer overrode it with one nobody held. No flag now means the pack's declared defaults; `--all` takes everything; `--category x,y` takes whole categories by slug (`sanitize`, `dangerous-commands`, `packages-system`, …); `--only a,b` still takes exact names, and the two compose as a union in the pack's declared order. An unknown category names the ones that exist instead of failing blankly. Re-adding at a newer version still carries the previous choice forward, and `--all` is recorded as "the whole pack" rather than as the list of names that happened to exist at install time — so a later version's new policies are included rather than silently frozen out. The install summary now says how many of how many, and WHY that set is on ("the pack's defaults" / "your selection"), because `10/38` on its own reads like something went wrong; the not-enabled list is capped at six names plus a count instead of a 37-name wall with a 400-character suggested command. `pack list` groups by category and prints each slug, since a flat list gives no clue `--category` exists. (#PR) + +- Attribute a pack decision to the pack that made it. `pack` was already in scope where the activity row's source is decided and simply was not consulted, so every pack deny was filed as `custom` — which is also what a user's own local `.mjs` gets, leaving the two separable only by re-parsing the `pack/` prefix off our own display name, the exact practice these fields exist to replace. Rows now carry `policySource: "pack"` plus `packId` and `packVersion`, mirroring the cloud pair, so "which pack, which version decided this" is answerable without string surgery. The `policySource` union, the `HookActivityFilters.source` type and all four dashboard sites move in lockstep — the state union, the URL-param validation (which silently drops an unknown value, so a shared `?source=pack` link would have quietly resolved to "all sources"), the onChange revalidation and the `