diff --git a/.githooks/commit-msg b/.githooks/commit-msg deleted file mode 100755 index e41f004..0000000 --- a/.githooks/commit-msg +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -# Block a commit whose MESSAGE carries an embargoed token. Commit messages on a -# public branch are as public as the tree, and the pre-commit hook cannot see the -# message (it does not exist yet at that point), so this needs its own hook. -# -# Enable (one-time, per clone): git config core.hooksPath .githooks - -set -e - -if ! command -v node >/dev/null 2>&1; then - echo "embargo-guard: node not found; cannot verify the commit message." >&2 - echo "embargo-guard: refusing the commit (fail closed)." >&2 - exit 1 -fi - -# Resolved by this hook's own location — see the note in pre-commit. -GUARD_ROOT="$(cd "$(dirname "$0")/.." && pwd)" - -exec node "$GUARD_ROOT/scripts/embargo-guard.mjs" --message "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index d059eb2..0000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -# Block a commit whose staged content carries an embargoed token. -# See scripts/embargo-guard.mjs for why the check is digest-based. -# -# Enable (one-time, per clone): git config core.hooksPath .githooks -# This hook is bypassable with --no-verify, which is why the same check also runs -# as the `embargo` job in .github/workflows/ci.yml on every push and PR. - -set -e - -if ! command -v node >/dev/null 2>&1; then - echo "embargo-guard: node not found; cannot verify staged content." >&2 - echo "embargo-guard: refusing the commit (fail closed)." >&2 - exit 1 -fi - -# Resolve the guard by THIS HOOK's location, not `git rev-parse --show-toplevel`. -# In a worktree whose branch predates the guard, the toplevel has no scripts/ and -# no .githooks/ — and with a relative core.hooksPath git finds no hook there at -# all and silently commits. Set core.hooksPath to an ABSOLUTE path (see README) -# and this resolves back to the checkout that actually holds the guard. -GUARD_ROOT="$(cd "$(dirname "$0")/.." && pwd)" - -exec node "$GUARD_ROOT/scripts/embargo-guard.mjs" --staged diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32dba44..0837e78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI # First CI for command-center (Lane C / roadmap C8). -# On every push and PR: run the embargo guard, the lint/type gates (rustfmt, +# On every push and PR: run the lint/type gates (rustfmt, # clippy, svelte-check + tsc), the Rust tests for both cargo workspaces and the # frontend's vitest suite, then build the frontend + fleetd sidecar and produce # Tauri bundles on all three target OSes. Bundles are uploaded as workflow @@ -32,69 +32,6 @@ env: CARGO_TERM_COLOR: always jobs: - # --------------------------------------------------------------------------- - # Embargo guard. The same check the .githooks/pre-commit hook runs locally — - # duplicated here because a local hook is bypassable with `--no-verify`, and - # this class of mistake is exactly the kind someone waves through in a hurry. - # - # The denylist is NOT in the repo: the tokens are low-entropy, so a committed - # digest is a crackable copy of the token (see scripts/embargo-guard.mjs). It - # arrives here as the EMBARGO_GUARD_CONFIG repo secret. The guard fails closed - # when that secret is absent — which includes pull requests from forks, since - # GitHub withholds secrets from them. A fork PR therefore needs a maintainer to - # run the check locally; that is the intended trade, not an oversight. - # --------------------------------------------------------------------------- - embargo: - name: embargo guard - runs-on: ubuntu-latest - env: - EMBARGO_GUARD_CONFIG: ${{ secrets.EMBARGO_GUARD_CONFIG }} - steps: - # Full history: this job scans the branch's commit messages, not just its - # tree. A message on a public branch is as public as a file in it. - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - # Hermetic — generates throwaway tokens and their denylists at runtime, so - # it needs no secret and still runs on fork PRs. - - name: Test the guard - run: node --test scripts/embargo-guard.test.mjs - - - name: Scan tracked files - run: node scripts/embargo-guard.mjs --all - - - name: Scan this branch's commit messages - run: | - base="origin/${{ github.event.pull_request.base.ref || github.event.repository.default_branch }}" - if git rev-parse --verify "$base" >/dev/null 2>&1; then - git log --format=%B "$base..HEAD" > "$RUNNER_TEMP/msgs.txt" - else - git log --format=%B -50 > "$RUNNER_TEMP/msgs.txt" - fi - node scripts/embargo-guard.mjs --message "$RUNNER_TEMP/msgs.txt" - - # --------------------------------------------------------------------------- - # rustfmt + clippy, over BOTH cargo workspaces. - # - # cockpit/ui/src-tauri declares its own empty `[workspace]` table, so it is a - # standalone workspace that the root manifest does not list as a member. That - # makes it invisible to `--workspace` / `--all` run from the repo root, which - # is why every gate here runs twice — once per manifest. Dropping either half - # silently leaves that crate ungated. - # - # rustfmt only needs the sources, so both fmt checks run first and fail fast. - # Clippy has to actually compile, and compiling the tauri crate needs two - # things the root workspace does not: the WebKitGTK system deps (same list the - # build job installs) and the fleetd sidecar binary, because tauri-build - # resolves the `externalBin` resource at compile time and hard-errors when it - # is absent. - # --------------------------------------------------------------------------- lint: name: fmt + clippy runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 628b4dd..9e21eff 100644 --- a/.gitignore +++ b/.gitignore @@ -30,9 +30,5 @@ # ContextCurator local store (user's product data, not ours to track) /.context-curator/ -# embargo guard denylist — digests of low-entropy tokens are crackable, so the -# denylist is never committed (see scripts/embargo-guard.mjs) -/.embargo-guard.local.json - # Root-level tooling artifacts (cockpit/ui has its own ignore for its tree). node_modules/ diff --git a/README.md b/README.md index b92b580..ea9c543 100644 --- a/README.md +++ b/README.md @@ -162,34 +162,6 @@ cargo test --workspace # 116 tests; Docker/network ITs are #[ignore]d | [`scripts/`](scripts) | Runnable demos (e.g. restart recovery) | | [`docs/`](docs) | Quickstart, architecture vision, roadmap | -### Repo hooks - -One-time, per clone: - -```bash -git config core.hooksPath "$(pwd)/.githooks" # run from the repo root -EG_TOKEN='' node scripts/embargo-guard.mjs --add-entry # repeat per token -``` - -**Use an absolute path.** A relative `core.hooksPath` is resolved against each -worktree's own root, so in a worktree whose branch predates `.githooks/` git finds no hook and -commits without checking — a silent fail-open. An absolute path points every worktree back at this -checkout, and the hooks resolve the guard and its denylist by their own location. - -This enables the embargo guard ([`scripts/embargo-guard.mjs`](scripts/embargo-guard.mjs)), which -blocks commits whose staged content or commit message contains a forbidden token, matching a -normalized sliding window so case, punctuation and line wrapping don't evade it. - -The denylist is **not committed**. It holds salted digests rather than plaintext, but the tokens are -low-entropy, so a digest published next to its salt is just a slow-release copy of the token — a -10-digit one fell to a targeted search in 22.6s on one CPU core. So it lives in -`.embargo-guard.local.json` (gitignored) locally and in the `EMBARGO_GUARD_CONFIG` repo secret for -CI. Nothing about the forbidden tokens is in the repository: not the plaintext, not a regex, not a -digest, not a length. - -The same check runs as the `embargo` job in CI, so skipping the hook — or committing with -`--no-verify` — does not skip the check. It fails closed when no denylist is available. - ## Status **Feature-complete and tested:** diff --git a/docs/STATUS.md b/docs/STATUS.md index a3327d7..e9d3029 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ --- stage: Build readiness: "control plane publication-ready; product shell on roadmap" -updated: "2026-08-16" +updated: "2026-08-30" name: "Command Center" base_branch: "main" test_cmd: "cargo test --workspace" @@ -17,14 +17,18 @@ its own `STATUS.md`, so the Command Center appears on its own board as a `local: ## State summary **TL;DR.** The **control plane and workflow layer are feature-complete and tested**, the repo is -**public**, and `main` is **branch-protected**. **CI is now a real gate**: #60 (merged 2026-08-15) +**public**, and `main` is **branch-protected**, with `cargo test (workspace)` as the required check. +**The embargo guard was removed 2026-08-30** (operator decision; the embargo was lifted 2026-08-29) — +hooks, script, CI job and denylist are all gone, and `embargo guard` has been **dropped from the +branch-protection required checks**, without which every PR would have hung forever on a check that +no longer reports. **CI is now a real gate**: #60 (merged 2026-08-15) added rustfmt, clippy, `svelte-check + tsc`, vitest and — critically — `cargo test (cockpit)`, which had **never run in CI at all** because `cockpit/ui/src-tauri` is a standalone cargo workspace the root `--workspace` never reached. Five of seven test tiers were advisory until that landed. The **superseded guard digests are out of public history** (targeted 9-commit `filter-repo` rewrite). **The interactive smoke is FINISHED.** Run 2 (dev) and **Run 3 (packaged, 2026-08-16)** are both -complete, and **#49 is READY FOR REVIEW with all 18 CI checks green** — it is waiting on a human -merge decision and nothing else. Run 3 scored **9 PASS / 2 BLOCKED / 2 NOT RUN / 0 FAIL** and, with +complete, and **#49 is MERGED** (`e2fc3ce`) — the product shell is no longer roadmap. Run 3 scored +**9 PASS / 2 BLOCKED / 2 NOT RUN / 0 FAIL** and, with the fixes that followed, closed **five** defects: **D-7** (view-plugins received no state at all), **D-8** (**the packaged bundle shipped no plugin root — no shipped build could load a view-plugin**), **D-2** (every plugin was granted every capability; now fails closed), **D-4** (re-verified packaged: @@ -32,6 +36,39 @@ the fixes that followed, closed **five** defects: **D-7** (view-plugins received Across both runs, **`db74a47` is CONFIRMED twice** — 1,127 samples in dev and 632 in packaged, zero unresponsive in either. +**✅ Telltale has moved OUT of this repo (2026-08-30) — the pivot is complete.** A feedback pipeline +— authenticated bug reports deduplicated into GitHub issues — was built as a `telltale/` +subdirectory here (PR #64, 82 tests, fully reviewed). That was the wrong repository. It now lives at +**[`adbarc92/telltale`](https://github.com/adbarc92/telltale)** (private), extracted with +`git subtree split` so all **17 commits** kept their TDD and review history with `telltale/` as the +repo root. Verified on the extracted tree before pushing: `npm ci` clean, `npx vitest run` → +**82 passed, 1 skipped**, `npm run check` → exit 0. The spec and plan moved with it +(`docs/design.md`, `docs/plan.md`); the plan gained a **Defects found during execution** section +recording six defects in its own reference code, all fixed in `src/`. + +**Nothing had to be stripped from this repo.** `telltale/` never existed on `main` or on +`chore/remove-embargo-guard` — only on PR #64's branch — so closing that PR was sufficient, and the +`vitest (telltale)` CI job never reached `main` either. **PR #64 and PR #63 are both closed, not +merged.** Extraction detail and the six defects are in +[`docs/handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md`](handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md). + +**What remains here is the integration only, and it is NOT started:** a `feedback` source adapter +for the Project Dashboard reading Telltale's `GET /v1/issues` (design spec §6 in the Telltale repo). +Its change surface is eight files across `cockpit/ui`. Three things that spec settles and are easy +to get wrong: cards are **`Idle`, never `Build`** (`sortedCards` ranks `Build` above `Live`, so one +old bug would outrank a live production project); **`Blocked` only for an open `telltale:crash` +issue with no assignee** (`blockedCount` is the board's "NEEDS YOU" headline — a condition that +never clears poisons it); and **`family` is inert** — written by three adapters, read by nothing. +A command-center-side adapter spec gets written when P3 begins, rather than maintained in two +places while the work is unstarted. + +**⚠ Intermittent race in the fleetd spend cap, unrelated to the above.** +`server::tests::concurrent_missions_cannot_both_breach_the_cap` failed on PR #64 with **both** +concurrent missions admitted past the $20 global cap (`left: 2, right: 1`) — the condition its own +comment calls "an open race" — then passed on a re-run of the identical tree. `create_mission` holds +the store lock across check and insert, so the obvious explanation does not apply; the `.ok()` that +swallows `upsert_unit`'s error is the first thing to look at. Not investigated further. + **Vision (unchanged):** the Command Center is the operator's **one-stop shop for agentic engineering** — dispatch work, see every project's stage, act without alt-tabbing, host the other tools inside it, and (future) **remote-control** it from away-from-desk. **Feature-complete before @@ -171,6 +208,51 @@ across from #47; close it._ ## Session log +### 2026-08-30 — Built the Telltale Worker here, then pivoted it out; removed the embargo guard + +**Three PRs opened.** [#62](https://github.com/adbarc92/command-center/pull/62) removes the **embargo +guard** in full — hooks, script, CI job, denylist, README section — the embargo having been lifted +2026-08-29. It was five interlocking parts, and `embargo guard` was a *required status check* on +`main`, so deleting the CI job alone would have hung every PR forever on a check that no longer +reports; branch protection was updated in the same breath. Conflicts with `main` (PR #49 landed +mid-session) resolved in `af11995`. + +[#63](https://github.com/adbarc92/command-center/pull/63) carries the **Telltale spec and plan**. The +spec lost roughly half its mass across three rounds of adversarial critique: the entire crash-side +pipeline was deleted once it became clear it was **reimplementing Sentry** (whose native GitHub +integration already opens one issue per group), and fleet dispatch was cut because it would have +widened a credentialed agent's push target from one sandbox to every repo in the registry, for a +pipeline whose input is internet-authored text. + +[#64](https://github.com/adbarc92/command-center/pull/64) built the **ingest Worker** — eleven TDD +tasks, each independently reviewed, plus a whole-branch review and a final fix wave. 82 tests, zero +runtime dependencies. That process found **six defects in the plan's own reference code**, including +a registry entry pointing at `adbarc92/tenzy`, which does not exist — `gh api` silently follows a +transfer redirect to `OpenBarclay/tenzy`, and the primary PAT cannot write to an org repo. + +**Then the pivot.** Telltale was never meant to live in this repo — the operator's earlier "put it in +Command Center" was about the *spec*, and it was extended to the implementation without being put to +them. Telltale becomes its own app and repo; this repo keeps only the `feedback` source adapter. +**#64 is to be closed, not merged.** Full extraction plan: +[`docs/handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md`](handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md). + +**Pivot executed, same session.** [`adbarc92/telltale`](https://github.com/adbarc92/telltale) created +(private) and seeded by `git subtree split --prefix=telltale`, carrying all **17 commits** with +`telltale/` as the repo root. The extracted tree was verified *before* pushing — `npm ci` clean, +`npx vitest run` → **82 passed, 1 skipped**, `npm run check` → exit 0 — confirming every in-code path +was already relative to `telltale/`. Spec and plan followed as `docs/design.md` and `docs/plan.md` +(telltale PR #1), with cross-links repaired and a **Defects found during execution** section added to +the plan so its six known-defective reference snippets cannot be rebuilt by anyone following it. + +**The handoff's step 3 turned out to be a no-op.** It called for `git rm -r telltale/` on a branch off +`chore/remove-embargo-guard` and a revert of the `vitest (telltale)` CI job. Neither existed there: +`telltale/` was only ever on #64's branch, and the CI job with it. Because #64 was closed rather than +merged, nothing entered this repo's history and there was nothing to strip. What *was* worth +salvaging were the two docs that existed only on that branch — the pivot handoff and this STATUS +entry — cherry-picked onto #62 before #64 closed. **#64 and #63 are both closed.** + +Also surfaced, unrelated: an **intermittent race in fleetd's global spend cap** (see State summary). + ### 2026-08-16 — Built the smoke skill, then it found the defect that would have shipped Two halves. First, built and merged the **`driving-interactive-smoke-tests`** skill into `claude-kit` diff --git a/docs/handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md b/docs/handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md new file mode 100644 index 0000000..1c30bc2 --- /dev/null +++ b/docs/handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md @@ -0,0 +1,187 @@ +# Handoff — pivot: extract Telltale into its own repo, leave only the Command Center integration behind + +**Written:** 2026-08-30 · **Branch:** `feat/telltale-worker` · **Session:** `31f0a85d-8bcc-4d27-a849-e9e950749558` + +## ⏳ Background operation in flight + +**None.** No build, test run, or agent is running. This is a deliberate pivot handoff, not an +idle handoff. Everything below is committed and pushed; nothing is mid-flight. + +CI on PR #64 is green except one **pre-existing, intermittent** `fleetd` failure — see +[Known issues](#known-issues-carried-forward). It is not caused by this work. + +## Goal + +**Telltale is its own app in its own repo.** Command Center keeps only the integration: a +`feedback` source adapter for the Project Dashboard that reads Telltale's `GET /v1/issues`. + +The previous session built the Telltale ingest Worker as a `telltale/` **subdirectory inside the +command-center repo**. That was wrong. The operator's "put it in Command Center" answered a question +about where the *spec document* should live; it was extended to the implementation without ever being +put to them. The work itself is sound and fully reviewed — it is in the wrong repository. + +## State + +- **Active spec:** [`docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md`](../superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md) — on branch `docs/telltale-feedback-pipeline-spec` (PR #63), **not on `main` or this branch**. +- **Active plan:** [`docs/superpowers/plans/2026-08-30-telltale-worker.md`](../superpowers/plans/2026-08-30-telltale-worker.md) — same branch. +- **SDD ledger** (28 rulings, every review verdict, all deferred findings): `.claude/worktrees/telltale-worker/.superpowers/sdd/2026-08-30-telltale-worker/progress.md` — **gitignored, lives only in that worktree. Read it before discarding the worktree.** + +### Three open PRs on `adbarc92/command-center` + +| PR | Branch | Disposition under the pivot | +|---|---|---| +| **#62** chore: remove the embargo guard | `chore/remove-embargo-guard` | **Unaffected — merge as-is, first.** Conflicts with `main` already resolved (`af11995`). | +| **#63** docs(spec): Telltale pipeline | `docs/telltale-feedback-pipeline-spec` | **Needs splitting** — see step 4. | +| **#64** feat(telltale): the ingest Worker (P1) | `feat/telltale-worker` | **Close after extraction.** Its content moves to the new repo. Do not merge. | + +### What is in `feat/telltale-worker` + +- **17 commits touching `telltale/`** — the whole TDD + review history, worth preserving. +- `telltale/` — 29 tracked files: `src/` (9 modules), `test/` (11 files), `package.json`, `tsconfig.json`, `vitest.config.ts`, `wrangler.toml`, `README.md`, `.gitignore`. +- **The only non-Telltale change this branch makes** is the `vitest (telltale)` job added to `.github/workflows/ci.yml`. Everything else in a `cb11214..HEAD` diff came from merging `origin/main` (PR #49, plugin-runtime). +- 82 tests + 1 gated integration test, `tsc --noEmit` clean, zero runtime dependencies. + +## Successor's next action + +Work in this order. Steps 1–3 are mechanical; step 4 has a decision in it. + +### 1. Create the repo + +```bash +gh repo create adbarc92/telltale --private \ + --description "Authenticated bug-report intake that deduplicates into GitHub issues" +``` + +Default taken: **`adbarc92`, private**. Rationale in [Decisions](#live-decisions--defaults-taken). + +### 2. Extract `telltale/` with its history + +`git subtree split` rewrites the 17 commits with `telltale/` as the repo root, preserving each +task's TDD and review history. Run from the **main checkout**, not the worktree: + +```bash +cd D:/MajorProjects/CURRENT/command-center +git subtree split --prefix=telltale feat/telltale-worker -b telltale-extracted +git clone . /tmp/telltale-new --branch telltale-extracted --single-branch +cd /tmp/telltale-new +git remote set-url origin https://github.com/adbarc92/telltale.git +git branch -m main +git push -u origin main +``` + +**Verify before continuing:** `npm ci && npx vitest run` in the new clone must give **82 passed, +1 skipped**, and `npm run check` must exit 0. The paths inside the code are all relative to +`telltale/`, so nothing should need editing — but confirm rather than assume. The one thing that +*will* be wrong is `README.md`'s spec link, which points at a command-center path (see step 4). + +### 3. Strip Telltale out of command-center + +On a fresh branch off `chore/remove-embargo-guard`: + +```bash +git rm -r telltale/ +``` + +…and revert the `vitest (telltale)` job from `.github/workflows/ci.yml`. That job is the only +command-center file this work added; with `telltale/` gone it would fail on a missing lock file. + +Then **close PR #64** with a comment pointing at the new repo. Do not merge it. + +### 4. Decide where the spec lives, then rework PR #63 + +The spec covers both halves: §1–§5 and §8–§9 are the Worker; **§6 is the Command Center adapter**; +§7 is the Halyard boundary. + +**Recommended split:** +- The **spec moves to the new repo** (`docs/design.md` or equivalent) — it is Telltale's design doc. +- **command-center keeps a short spec for the `feedback` source adapter only**, reproducing §6 and + linking out to the Telltale repo for the contract it consumes. +- The **plan** ([`2026-08-30-telltale-worker.md`](../superpowers/plans/2026-08-30-telltale-worker.md)) moves with the spec — it describes P1, which is now entirely the other repo's work. + +Whatever you choose, **the plan file still contains six defects execution found and fixed in code.** +Anyone following it would rebuild them. Add a "Defects found during execution" section rather than +rewriting each code block: + +| # | Defect | Consequence | +|---|---|---| +| 1 | Registry placeholders, and `adbarc92/tenzy` in the corrected table | That repo does not exist — `gh api` silently follows a transfer redirect to `OpenBarclay/tenzy`. The primary PAT cannot write to an org repo, and a followed 301 turns a POST into a GET. | +| 2 | `decide()` did not aggregate `not_planned` across closed duplicates | An operator's explicit "won't fix" was silently discarded and the pipeline commented anyway. | +| 3 | GitHub POSTs had no `Content-Type: application/json` | Every test stayed green because the fake never does HTTP; it would have failed only in production. | +| 4 | `restClient` had no tests at all | The entire production HTTP path was verified only through the fake's parallel logic. | +| 5 | Unguarded `JSON.parse(env.TELLTALE_SENDER_SECRETS)` | A malformed secret crashed every request *before* any stat was recorded — the exact silent failure `/v1/stats` exists to eliminate. | +| 6 | `if (!secret) return fail(401)` ran before the registry lookup | Made the spec's required `404` on a typo'd slug unreachable. | + +### 5. Then, and only then, build the integration (spec §6 / P3) + +This is the part that stays in command-center, and it is **not started**. Spec §6.1 has the verified +change surface: + +| File | Change | +|---|---| +| `cockpit/ui/src/lib/dashboard/model.ts` | `Source` union `+ 'feedback'` | +| `cockpit/ui/src/lib/dashboard/adapters/feedback.ts` | New adapter + `FeedbackReader` seam | +| `cockpit/ui/src/lib/dashboard/api.ts` | `tauriFeedbackReader` | +| `cockpit/ui/src/lib/dashboard/store.ts` | `pollFeedback` | +| `cockpit/ui/src/views/Dashboard.svelte` | `SOURCE_LABEL` entry + a `pollFeedback` call | +| `cockpit/ui/src/App.svelte` | Wire the reader | +| `cockpit/ui/src-tauri/src/dashboard.rs` | `feedback_issues` command + `TELLTALE_BASE_URL`/`TELLTALE_TOKEN` env | +| `cockpit/ui/src-tauri/src/lib.rs` | Register the command in `generate_handler!` | + +Three things the spec settles that are easy to get wrong: +- **Cards are `Idle`, never `Build`.** `sortedCards` ranks `Build` above `Live`, so "one old bug exists" would sort above "this project is live in production." +- **`Blocked` only for an open `telltale:crash` issue with no assignee.** `blockedCount` is the board's headline "NEEDS YOU" number; a condition that never clears poisons it. +- **`family` is inert.** It is written by three adapters and read by nothing. Do not claim it clusters cards. + +## Live decisions / defaults taken + +Taken during this sweep, without asking — override freely: + +- **Repo `adbarc92/telltale`, private.** `adbarc92` because Telltale is infrastructure, not a product, + and that account already owns the infrastructure repos. Private because every covered project is + private today; publishing is a separate call that belongs with the launch decisions in + `D:\MajorProjects\LAUNCH-SCHEDULE.md`. +- **`git subtree split`, not a fresh-start copy.** The 17 commits carry an unusually good record — + each task's RED/GREEN evidence and its review round. Worth preserving. +- **PR #64 closed, not merged.** Merging then reverting would put the Worker in command-center's + history permanently. +- **Spec moves; command-center keeps an adapter-only spec.** Recommended, not settled — step 4. + +Settled earlier in the session and already reflected in the code: + +- Issues land in **each project's own repo**, including public ones; the PII exposure was raised and + knowingly accepted, and the scrub covers both `title` and `body`. +- The **ingest-abuse risk** was put to the operator and accepted: HMAC bounds and attributes abuse + rather than preventing it, since a secret in a shipped binary is extractable. +- **Crashes never traverse the Worker.** Sentry's native GitHub integration owns that path. +- **Fleet dispatch is out of scope** — it would widen a credentialed agent's push target from one + sandbox to every repo in the registry, for a pipeline whose input is internet-authored text. + +## Known issues carried forward + +1. **`fleetd` has an intermittent race in its spend cap — unrelated to Telltale, and worth a look.** + `server::tests::concurrent_missions_cannot_both_breach_the_cap` failed on PR #64 with + `left: 2, right: 1` — **both concurrent missions admitted past the $20 global cap**, the exact + condition the test's own comment calls "an open race." It passed on a re-run of the identical + tree, so it is intermittent. `create_mission` *looks* correct (it holds the store lock across + check and insert), which makes it more interesting, not less. First thing to look at: + `s.upsert_unit(&row, now_ms()).ok()` swallows the insert error, so a failed insert would leave + committed spend stale and admit the next caller. This branch adds no Rust; `main` passed the same + test at `e2fc3ce`. +2. **Two registry repos do not exist yet:** `adbarc92/telltale-intake` (pawsport's redirect target) + and `adbarc92/telltale-probe` (the live grader's target). Until they are created, + `GET /v1/issues` carries a permanent `errors` entry for `pawsport` and the grader cannot pass. + The README's Deploy section leads with creating them. +3. **`src/registry.ts`'s `pawsport` comment says "archived on GitHub"** while its target repo is one + the README now tells the operator to create. Parked as a one-line fix; contradictory as written. +4. **Cloudflare free-tier limits for Workers + KV were never verified** (no web access in that + session). Blocks deploy, not implementation. +5. **24 further Minor findings** are triaged in the SDD ledger. All were judged ship-as-is by the + whole-branch review. + +## Do not redo + +- The Worker is **complete and reviewed**: eleven scoped task reviews, a whole-branch review on the + most capable model, and a final fix wave. Do not re-litigate its design; read the spec's §11 + ("Cut from earlier drafts") before proposing anything that looks missing. +- The plan's reference code is **known-defective** (table in step 4). Trust the committed code over + the plan wherever they disagree. diff --git a/scripts/embargo-guard.mjs b/scripts/embargo-guard.mjs deleted file mode 100755 index adaaefd..0000000 --- a/scripts/embargo-guard.mjs +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env node -// Embargo guard — blocks forbidden tokens from entering the repo. -// -// WHY THIS EXISTS: docs/STATUS.md once carried an attestation that an embargoed -// name was absent, and named that name inline to say so. The attestation was -// itself the leak, and it sat on the public default branch (and in code search) -// until someone happened to read it. A guard is the only thing that makes that -// class of mistake non-repeatable. -// -// THE AWKWARD PART: a guard that greps for a forbidden string has to contain the -// forbidden string, which recreates the exact problem it is solving. -// -// WHY THE DIGESTS ARE NOT COMMITTED: the first cut of this guard shipped salted -// SHA-256 digests in a tracked file, reasoning that a digest is not plaintext. It -// is not, but that is the wrong bar. The inputs here are LOW ENTROPY — a name, an -// email, a phone number — and the salt has to ship next to the digest for the -// guard to work, so it stops rainbow tables and nothing else. Measured: the -// 10-digit phone digest fell to a targeted search in 22.6 SECONDS on one CPU core -// (~9.1M candidates, single-threaded). A committed digest of a low-entropy secret -// is a slow-release copy of that secret. -// -// So the denylist lives OUTSIDE the repo, and the guard resolves it from, in order: -// 1. $EMBARGO_GUARD_CONFIG — inline JSON (CI injects a repo secret) -// 2. $EMBARGO_GUARD_CONFIG_FILE — path to a JSON file -// 3. .embargo-guard.local.json — untracked, gitignored (local default) -// Nothing about the forbidden tokens is committed: not the plaintext, not a -// regex, not a digest, not a length. -// -// Matching is digest-based against a window of normalized text. Normalization -// (lowercase, then delete everything outside [a-z0-9]) is what makes it robust: -// case, spacing, punctuation, markdown emphasis and a hard line wrap all collapse -// to the same form, so "Foo Bar", "foo-bar", "**FooBar**" and a "Foo\nBar" line -// break are all caught by one digest. -// -// Usage: -// node scripts/embargo-guard.mjs --staged # pre-commit: staged blobs -// node scripts/embargo-guard.mjs --message # commit-msg: the message -// node scripts/embargo-guard.mjs --all # CI: every tracked file -// EG_TOKEN='...' node scripts/embargo-guard.mjs --add-entry -// -// Exit 0 = clean, exit 1 = violation or the guard could not run. It fails CLOSED: -// a missing or malformed denylist blocks the commit rather than waving it through, -// because a guard that silently disables itself is worse than no guard at all. - -import { createHash, randomBytes } from 'node:crypto'; -import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const LOCAL_CONFIG_NAME = '.embargo-guard.local.json'; - -// Files above this size are almost certainly build output or vendored blobs. We -// report every skip rather than passing silently — a guard that quietly declines -// to look at something reads as "clean" when it never checked. -const MAX_BYTES = 8 * 1024 * 1024; - -function fail(msg) { - console.error(`embargo-guard: ${msg}`); - process.exit(1); -} - -function git(args, opts = {}) { - return execFileSync('git', args, { maxBuffer: 512 * 1024 * 1024, ...opts }); -} - -const repoRoot = git(['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); - -// Where this script lives, which is NOT always the repo root: in a git worktree, -// --show-toplevel is that worktree, and if its branch predates the guard it has -// no scripts/ or denylist of its own. The hooks resolve this script by their own -// path, so fall back to a denylist sitting beside it — otherwise every commit in -// an older worktree fails closed for want of a config that is one directory away. -const scriptRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); -const localConfigPath = join(repoRoot, LOCAL_CONFIG_NAME); -const scriptConfigPath = join(scriptRoot, LOCAL_CONFIG_NAME); - -const SETUP_HINT = [ - 'No denylist found. It is deliberately not committed — see the header of this', - 'script. Provide one of:', - ' - $EMBARGO_GUARD_CONFIG (inline JSON; how CI injects its secret)', - ' - $EMBARGO_GUARD_CONFIG_FILE (path to a JSON file)', - ` - ${LOCAL_CONFIG_NAME} (untracked, gitignored)`, - 'Seed the local one with: EG_TOKEN=\'...\' node scripts/embargo-guard.mjs --add-entry ', -].join('\n '); - -/** Resolve the denylist from env or the untracked local file. Never from the tree. */ -function readRawConfig() { - if (process.env.EMBARGO_GUARD_CONFIG) { - return { text: process.env.EMBARGO_GUARD_CONFIG, source: '$EMBARGO_GUARD_CONFIG' }; - } - const fromFile = process.env.EMBARGO_GUARD_CONFIG_FILE; - if (fromFile) { - if (!existsSync(fromFile)) fail(`$EMBARGO_GUARD_CONFIG_FILE points at ${fromFile}, which does not exist. Fail closed.`); - return { text: readFileSync(fromFile, 'utf8'), source: fromFile }; - } - for (const candidate of [localConfigPath, scriptConfigPath]) { - if (existsSync(candidate)) { - return { text: readFileSync(candidate, 'utf8'), source: candidate }; - } - } - fail(SETUP_HINT); -} - -function loadConfig() { - const { text, source } = readRawConfig(); - let cfg; - try { - cfg = JSON.parse(text); - } catch (err) { - fail(`denylist from ${source} is not valid JSON (${err.message}). Fail closed.`); - } - const entries = cfg.entries; - if (!Array.isArray(entries) || entries.length === 0) { - fail(`denylist from ${source} declares no entries. Fail closed.`); - } - for (const e of entries) { - if (!e.id || !Number.isInteger(e.length) || e.length < 1 || !/^[0-9a-f]+$/i.test(e.salt || '') || !/^[0-9a-f]{64}$/i.test(e.digest || '')) { - fail(`denylist entry ${JSON.stringify(e.id ?? '?')} is malformed. Fail closed.`); - } - } - return cfg; -} - -/** Lowercase, then strip every character outside [a-z0-9]. */ -function normalize(text) { - return text.toLowerCase().replace(/[^a-z0-9]/g, ''); -} - -/** - * Slide a window of entry.length over the normalized text, comparing the salted - * digest of each window. Returns { entry, normIndex } on the first hit, else null. - */ -function scan(text, entries) { - const norm = normalize(text); - if (!norm) return null; - // latin1 because `norm` is pure ASCII by construction; subarray then gives a - // zero-copy view per window instead of allocating a string 2M times. - const buf = Buffer.from(norm, 'latin1'); - - for (const entry of entries) { - if (buf.length < entry.length) continue; - const salt = Buffer.from(entry.salt, 'hex'); - const last = buf.length - entry.length; - for (let i = 0; i <= last; i++) { - const h = createHash('sha256'); - h.update(salt); - h.update(buf.subarray(i, i + entry.length)); - if (h.digest('hex') === entry.digest) return { entry, normIndex: i }; - } - } - return null; -} - -/** - * Map a normalized-string index back to a 1-based line number in the original - * text. Only called on a hit, so the slow character walk costs nothing normally. - */ -function locateLine(text, normIndex) { - let seen = 0; - let line = 1; - for (let i = 0; i < text.length; i++) { - const ch = text[i]; - if (ch === '\n') line++; - const c = ch.toLowerCase(); - if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { - if (seen === normIndex) return line; - seen++; - } - } - return line; -} - -function isBinary(buf) { - return buf.subarray(0, 8000).includes(0); -} - -/** Report a violation. Deliberately prints location only, never the matched text - * — CI logs on a public repo are public, so echoing the hit would re-leak it. */ -function report(hits, skipped) { - console.error(''); - console.error(' EMBARGO GUARD: blocked — forbidden token found.'); - console.error(''); - for (const h of hits) { - console.error(` ${h.where}:${h.line} (matches entry "${h.id}")`); - } - console.error(''); - console.error(' The matched text is not printed here on purpose: this output'); - console.error(' reaches public CI logs, and echoing it would re-leak it.'); - console.error(''); - console.error(' Remove the token. Do not describe it by name in an attestation,'); - console.error(' a comment, or a commit message — use a placeholder. There is no'); - console.error(' allowlist: an exemption would just be another committed copy.'); - console.error(''); - if (skipped.length) console.error(` (skipped ${skipped.length} file(s): ${skipped.join(', ')})`); - process.exit(1); -} - -function finish(hits, skipped, scannedCount, label) { - if (hits.length) report(hits, skipped); - for (const s of skipped) console.warn(`embargo-guard: skipped ${s}`); - console.log(`embargo-guard: clean (${scannedCount} ${label} scanned).`); - process.exit(0); -} - -// --- modes ----------------------------------------------------------------- - -function runOverFiles(files, readContent, label) { - const { entries } = loadConfig(); - const hits = []; - const skipped = []; - let scanned = 0; - - for (const file of files) { - let buf; - try { - buf = readContent(file); - } catch (err) { - skipped.push(`${file} (unreadable: ${err.message})`); - continue; - } - if (buf.length > MAX_BYTES) { - skipped.push(`${file} (over ${MAX_BYTES} bytes)`); - continue; - } - if (isBinary(buf)) continue; // binary: not a plausible carrier, not counted - scanned++; - const text = buf.toString('utf8'); - const hit = scan(text, entries); - if (hit) hits.push({ where: file, line: locateLine(text, hit.normIndex), id: hit.entry.id }); - } - finish(hits, skipped, scanned, label); -} - -function modeStaged() { - const out = git(['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z'], { encoding: 'utf8' }); - const files = out.split('\0').filter(Boolean); - if (files.length === 0) { - console.log('embargo-guard: no staged files.'); - process.exit(0); - } - // Read the STAGED blob, not the working tree — otherwise a bad version could be - // staged and then cleaned up on disk before committing, and the guard would miss it. - runOverFiles(files, (f) => git(['show', `:${f}`]), 'staged file(s)'); -} - -function modeAll() { - const out = git(['ls-files', '-z'], { encoding: 'utf8' }); - const files = out.split('\0').filter(Boolean); - runOverFiles(files, (f) => readFileSync(join(repoRoot, f)), 'tracked file(s)'); -} - -function modeMessage(path) { - if (!path) fail('--message needs a path to the commit message file.'); - runOverFiles([path], (f) => readFileSync(f), 'commit message'); -} - -// Writes to the untracked local denylist, creating it if absent. The plaintext is -// read from the environment and never written anywhere. -function modeAddEntry(id) { - if (!id) fail('--add-entry needs an id.'); - const token = process.env.EG_TOKEN; - if (!token) fail('set EG_TOKEN to the token to forbid (it is never written to disk).'); - const norm = normalize(token); - if (!norm) fail('EG_TOKEN normalizes to nothing.'); - - const cfg = existsSync(localConfigPath) - ? JSON.parse(readFileSync(localConfigPath, 'utf8')) - : { _comment: `Untracked denylist for scripts/embargo-guard.mjs. Digests only, and NOT committed: these tokens are low-entropy, so a committed digest is a crackable copy of the token. Keep ${LOCAL_CONFIG_NAME} gitignored.`, algorithm: 'sha256', entries: [] }; - - const salt = randomBytes(16); - const digest = createHash('sha256').update(salt).update(Buffer.from(norm, 'latin1')).digest('hex'); - if (cfg.entries.some((e) => e.length === norm.length && e.digest === digest)) { - fail('that token is already covered.'); - } - cfg.entries.push({ id, length: norm.length, salt: salt.toString('hex'), digest }); - writeFileSync(localConfigPath, `${JSON.stringify(cfg, null, 2)}\n`); - console.log(`embargo-guard: added entry "${id}" to ${LOCAL_CONFIG_NAME} (untracked).`); -} - -const [mode, arg] = process.argv.slice(2); -switch (mode) { - case '--staged': modeStaged(); break; - case '--all': modeAll(); break; - case '--message': modeMessage(arg); break; - case '--add-entry': modeAddEntry(arg); break; - default: - fail('usage: --staged | --all | --message | --add-entry '); -} diff --git a/scripts/embargo-guard.test.mjs b/scripts/embargo-guard.test.mjs deleted file mode 100644 index 39e98e3..0000000 --- a/scripts/embargo-guard.test.mjs +++ /dev/null @@ -1,199 +0,0 @@ -// Tests for the embargo guard. Run: node --test scripts/embargo-guard.test.mjs -// -// Every case builds its own throwaway token at runtime and derives a denylist -// from it, so the fixtures never contain a real embargoed value — the same -// property the guard itself is built around. A test that needed the real token -// inline would reintroduce exactly the bug under test. -// -// Each case runs a COPY of the guard inside a scratch git repo, never the one in -// this checkout. That is what keeps it hermetic: the guard falls back to a local -// denylist beside the repo root and then beside its own script, so running the -// real script in place would silently pick up the developer's real denylist and -// mask every fail-closed assertion. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createHash, randomBytes } from 'node:crypto'; -import { execFileSync } from 'node:child_process'; -import { writeFileSync, mkdtempSync, rmSync, mkdirSync, copyFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const GUARD = join(dirname(fileURLToPath(import.meta.url)), 'embargo-guard.mjs'); - -/** A random lowercase token, so no real value is ever committed in a fixture. */ -function throwawayToken(len = 14) { - const alphabet = 'abcdefghijklmnopqrstuvwxyz'; - return Array.from(randomBytes(len), (b) => alphabet[b % 26]).join(''); -} - -function denylistFor(token, id = 'test-token') { - const norm = token.toLowerCase().replace(/[^a-z0-9]/g, ''); - const salt = randomBytes(16); - const digest = createHash('sha256').update(salt).update(Buffer.from(norm, 'latin1')).digest('hex'); - return JSON.stringify({ algorithm: 'sha256', entries: [{ id, length: norm.length, salt: salt.toString('hex'), digest }] }); -} - -/** - * Run the guard against `content` inside a throwaway git repo. - * mode 'staged' stages the file first (the pre-commit path); mode 'message' - * points the guard straight at it (the commit-msg path). - */ -function runGuard(content, { denylist, mode = 'staged', filename = 'subject.md' } = {}) { - const dir = mkdtempSync(join(tmpdir(), 'embargo-test-')); - try { - execFileSync('git', ['init', '-q'], { cwd: dir }); - // Run a copy, so both resolution roots (repo root and script dir) land inside - // the scratch repo and no real denylist is reachable. - mkdirSync(join(dir, 'scripts')); - const guard = join(dir, 'scripts', 'embargo-guard.mjs'); - copyFileSync(GUARD, guard); - const file = join(dir, filename); - writeFileSync(file, content); - - const env = { ...process.env, EMBARGO_GUARD_CONFIG_FILE: '' }; - if (denylist === undefined) delete env.EMBARGO_GUARD_CONFIG; - else env.EMBARGO_GUARD_CONFIG = denylist; - - const args = mode === 'staged' ? ['--staged'] : ['--message', file]; - if (mode === 'staged') execFileSync('git', ['add', filename], { cwd: dir }); - - try { - const stdout = execFileSync(process.execPath, [guard, ...args], { - cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env, - }); - return { code: 0, stdout, stderr: '' }; - } catch (err) { - return { code: err.status, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }; - } - } finally { - rmSync(dir, { recursive: true, force: true }); - } -} - -test('blocks the token written contiguously', () => { - const token = throwawayToken(); - const r = runGuard(`prose before ${token} prose after`, { denylist: denylistFor(token) }); - assert.equal(r.code, 1); - assert.match(r.stderr, /EMBARGO GUARD: blocked/); -}); - -test('blocks the token split across a line break', () => { - // The original leak wrapped mid-token in a markdown file; this is the case a - // naive substring grep misses. - const token = throwawayToken(); - const split = `${token.slice(0, 4)}\n${token.slice(4)}`; - const r = runGuard(`a sentence that wraps at ${split} and continues`, { denylist: denylistFor(token) }); - assert.equal(r.code, 1); -}); - -test('blocks the token mangled with case and punctuation', () => { - const token = throwawayToken(); - const mangled = token.split('').map((c, i) => (i % 2 ? c.toUpperCase() : c)).join('-'); - const r = runGuard(`sneaky: ${mangled}`, { denylist: denylistFor(token) }); - assert.equal(r.code, 1); -}); - -test('blocks the token broken up by markdown emphasis', () => { - const token = throwawayToken(); - const r = runGuard(`**${token.slice(0, 3)}**${token.slice(3)}`, { denylist: denylistFor(token) }); - assert.equal(r.code, 1); -}); - -test('blocks the token in a commit message', () => { - const token = throwawayToken(); - const r = runGuard(`chore: mentions ${token}`, { denylist: denylistFor(token), mode: 'message' }); - assert.equal(r.code, 1); -}); - -test('passes clean content, and does not flag a near-miss', () => { - const token = throwawayToken(); - const nearMiss = `${token.slice(0, -1)}${token.at(-1) === 'a' ? 'b' : 'a'}`; - const r = runGuard(`entirely unrelated prose, plus ${nearMiss}`, { denylist: denylistFor(token) }); - assert.equal(r.code, 0); - assert.match(r.stdout, /clean/); -}); - -test('reports the location but never echoes the matched text', () => { - // Load-bearing: this output reaches public CI logs. Printing the hit would - // re-leak the exact thing the guard exists to keep out. - const token = throwawayToken(); - const r = runGuard(`line one\nline two has ${token}\nline three`, { denylist: denylistFor(token) }); - assert.equal(r.code, 1); - assert.match(r.stderr, /subject\.md:2/, 'should report file and line'); - assert.ok(!r.stderr.includes(token), 'must not print the matched token'); -}); - -test('names which entry matched, so a multi-entry denylist is actionable', () => { - const token = throwawayToken(); - const r = runGuard(`x ${token} y`, { denylist: denylistFor(token, 'some-id') }); - assert.match(r.stderr, /matches entry "some-id"/); -}); - -test('fails closed when no denylist is available', () => { - const r = runGuard('harmless content', { denylist: undefined }); - assert.equal(r.code, 1, 'must not pass when it cannot check'); - assert.match(r.stderr, /No denylist found/); -}); - -test('finds a denylist beside the script when the repo root has none', () => { - // Regression: in a git worktree whose branch predates the guard, the toplevel - // has no denylist and no scripts/ — the hooks resolve the guard by their own - // path, so it must fall back to the denylist beside itself. Before this, the - // guard fell through to "no denylist" and (with a relative core.hooksPath, no - // hook at all) a leaking commit sailed through in every worktree. - const token = throwawayToken(); - const home = mkdtempSync(join(tmpdir(), 'embargo-guardhome-')); - const repo = mkdtempSync(join(tmpdir(), 'embargo-worktree-')); - try { - mkdirSync(join(home, 'scripts')); - const guard = join(home, 'scripts', 'embargo-guard.mjs'); - copyFileSync(GUARD, guard); - // Denylist lives beside the guard, NOT in the repo being scanned. - writeFileSync(join(home, '.embargo-guard.local.json'), denylistFor(token)); - - execFileSync('git', ['init', '-q'], { cwd: repo }); - writeFileSync(join(repo, 'subject.md'), `leaking ${token} here`); - execFileSync('git', ['add', 'subject.md'], { cwd: repo }); - - const env = { ...process.env, EMBARGO_GUARD_CONFIG_FILE: '' }; - delete env.EMBARGO_GUARD_CONFIG; - - let code = 0; - let stderr = ''; - try { - execFileSync(process.execPath, [guard, '--staged'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env }); - } catch (err) { - code = err.status; - stderr = err.stderr ?? ''; - } - assert.equal(code, 1, 'must block'); - // Exit 1 alone proves nothing here: failing closed for want of a denylist - // also exits 1. It has to block because it MATCHED. - assert.match(stderr, /EMBARGO GUARD: blocked/, 'must block on a match, not by failing closed'); - assert.doesNotMatch(stderr, /No denylist found/, 'must have located the denylist beside the script'); - } finally { - rmSync(home, { recursive: true, force: true }); - rmSync(repo, { recursive: true, force: true }); - } -}); - -test('fails closed on a malformed denylist', () => { - const r = runGuard('harmless content', { denylist: 'not json at all' }); - assert.equal(r.code, 1); - assert.match(r.stderr, /not valid JSON/); -}); - -test('fails closed on a denylist with no entries', () => { - const r = runGuard('harmless content', { denylist: JSON.stringify({ entries: [] }) }); - assert.equal(r.code, 1); - assert.match(r.stderr, /no entries/); -}); - -test('fails closed on a structurally invalid entry', () => { - const bad = JSON.stringify({ entries: [{ id: 'x', length: 5, salt: 'zz', digest: 'short' }] }); - const r = runGuard('harmless content', { denylist: bad }); - assert.equal(r.code, 1); - assert.match(r.stderr, /malformed/); -});