Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 80 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
name: ci

# Base CI for the catalog repo (story 055.W3.1) + D24's full invariant suite (story 055.W3.3):
# - D20(1) blocking secret scanning + D20(4) capability analysis: still story 055.W4.1 / 055.W4.2,
# not built here.
# - D20(4) capability analysis landed in story 055.W4.2 (see its step below).
# - D20(1) BLOCKING secret scanning landed in story 055.W4.1: the gate itself lives inside
# publisher/publish.mjs (lib/secret-scanner.mjs + the vendored gitleaks corpus in
# lib/secret-rules.mjs), with a per-CLASS negative fixture through the real CLI in
# test/publish-cli.test.mjs. The steps this workflow adds for it are the two things a unit test
# cannot show: that the scanner BINARY runs end-to-end over a real artifact and REFUSES one with a
# planted credential, and that the plugin channel stays decoupled from the binary channel.
# What the scanner cannot see is documented in docs/SECRET-SCANNING.md and printed on every run.
# - D24's three no-going-back invariants (immutable id via lineage_id, burned-name ledger,
# license-in-package-root) + the publish-time half of D21 (tier vocabulary from the plugin's own
# manifest) — ADDED this story. See docs/INVARIANTS.md for the full design reasoning.
Expand Down Expand Up @@ -86,6 +92,78 @@ jobs:
node scripts/analyze-capabilities.mjs --artifact "$tmp/demo.tar.gz" --require-allowed-tools
echo "OK — analyzer ran end-to-end over a real artifact"

# story 055.W4.1 (D20(1)) — the BLOCKING scanner, exercised end-to-end over real artifacts.
# THREE artifacts on purpose: a clean one that must PASS, a planted one that must be REFUSED,
# and (fix-cycle-1, F2) an UNSCANNABLE one that must also be REFUSED. A step that only ever
# runs the clean case is the exact failure this story names — "a scanner that passes verde
# against a clean package proves nothing". The planted credential is assembled here from
# fragments so this workflow file never contains a literal credential shape (which would also
# trip the base guard further down this job).
- name: Secret scanning refuses a planted credential, refuses an unscannable member, passes a clean package (D20(1) — BLOCKING)
run: |
set -e
tmp="$(mktemp -d)"

# 1. clean package — must exit 0
mkdir -p "$tmp/clean/skills/demo"
printf 'MIT\n' > "$tmp/clean/LICENSE"
printf -- '---\nname: demo\ndescription: demo\nallowed-tools: Read\n---\n\nNothing secret here.\n' \
> "$tmp/clean/skills/demo/SKILL.md"
tar -czf "$tmp/clean.tar.gz" -C "$tmp/clean" .
node scripts/scan-secrets.mjs --artifact "$tmp/clean.tar.gz"
echo "OK — clean package passes"

# 2. same package + one planted credential — must exit non-zero
cp -R "$tmp/clean" "$tmp/dirty"
mkdir -p "$tmp/dirty/config"
printf 'GH_TOKEN=%s%s\n' 'ghp_' 'aB3dEf7hIjKlM9oPqRsTuVwXyZ0123456789' > "$tmp/dirty/config/ci.env"
tar -czf "$tmp/dirty.tar.gz" -C "$tmp/dirty" .
if node scripts/scan-secrets.mjs --artifact "$tmp/dirty.tar.gz"; then
echo "REFUSED: the scanner accepted an artifact with a planted credential — the gate is decorative"
exit 1
fi
echo "OK — planted credential is REFUSED end-to-end"

# 3. fix-cycle-1 (F2): the SAME credential behind a one-byte NUL prefix. Before the
# fail-closed decision this exited 0 — the member was classified binary, skipped, and
# the artifact published. Unscannable is now treated as not publishable.
cp -R "$tmp/clean" "$tmp/unscannable"
mkdir -p "$tmp/unscannable/config"
printf '\000' > "$tmp/unscannable/config/creds.env"
printf 'AWS_ACCESS_KEY_ID=%s%s\n' 'AKIA' 'QRS7TUVWX234YZ56' >> "$tmp/unscannable/config/creds.env"
tar -czf "$tmp/unscannable.tar.gz" -C "$tmp/unscannable" .
if node scripts/scan-secrets.mjs --artifact "$tmp/unscannable.tar.gz"; then
echo "REFUSED: an unscannable member passed — one NUL byte defeats the gate again"
exit 1
fi
echo "OK — unscannable member is REFUSED end-to-end (fail-closed)"

# 4. fix-cycle-2 (F10): a SHADOWED duplicate member — the same path twice in one tar
# stream, credential first, clean second. Extraction keeps only the clean one, so a
# filesystem-based inventory saw nothing; the credential nevertheless shipped and was
# recoverable with `tar -xOzf`. The scan now enumerates the archive's MEMBER TABLE.
cp -R "$tmp/clean" "$tmp/shadow"
mkdir -p "$tmp/shadow/config"
printf 'AWS_ACCESS_KEY_ID=%s%s\n' 'AKIA' 'QRS7TUVWX234YZ56' > "$tmp/shadow/config/app.env"
( cd "$tmp/shadow" && tar -cf "$tmp/shadow.tar" . )
mkdir -p "$tmp/shadow2/config"
printf 'APP_ENV=production\n' > "$tmp/shadow2/config/app.env"
( cd "$tmp/shadow2" && tar -rf "$tmp/shadow.tar" ./config/app.env )
gzip -c "$tmp/shadow.tar" > "$tmp/shadow.tar.gz"
# the fixture is only meaningful if the credential really is in the published bytes
tar -xOzf "$tmp/shadow.tar.gz" ./config/app.env | grep -q 'AKIA' \
|| { echo "fixture broken: the shadowed member does not carry the credential"; exit 1; }
if node scripts/scan-secrets.mjs --artifact "$tmp/shadow.tar.gz"; then
echo "REFUSED: a shadowed duplicate member passed — the credential ships silently"
exit 1
fi
echo "OK — shadowed duplicate member is REFUSED end-to-end (F10)"

# story 055.W4.1 (AC5) — the plugin channel must never read binary-channel state.
- name: Plugin channel stays decoupled from the binary channel (D19 / AC5)
run: |
node scripts/check-channel-separation.mjs

- name: No obvious secret shapes committed (base guard — NOT the D20(1) blocking scanner)
run: |
set -e
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,51 @@ catalog side of those decisions; the Cockpit-side consumer lives in the product
| `docs/CATALOG-AND-MIRROR.md` | How the index, the R2 artifact mirror, and the publish pipeline fit together | — |
| `docs/SCHEMA.md` | Field-by-field explanation of the index entry schema | — |
| `docs/INVARIANTS.md` | The four no-going-back invariants (D24 a/b/c + D21's `AC8`), how each is verified, and the explicitly-named design boundaries | — |
| `lib/secret-rules.mjs` | The **vendored gitleaks rule corpus** (MIT, a dated snapshot of a named upstream ref) — 14 rules across 14 covered classes, plus what was deliberately left out and why | Enforced publish-time AND in CI (`055.W4.1`) |
| `lib/secret-scanner.mjs` | The engine that runs those rules over the manifest and the artifact's real bytes, with its blind spots attached to every report | Enforced publish-time AND in CI |
| `lib/pin.mjs` | Version pin resolution (`<plugin_id>@<version>` → digest) — a **pure function** of (index, pin), which is what makes it deterministic and channel-independent at once | — |
| `scripts/check-channel-separation.mjs` | CI proof that no executable file here reads binary-channel state (D19 / AC5) | Run on every push |
| `docs/SECRET-SCANNING.md` | What the blocking scanner catches, why the rules are vendored rather than depended on, and — the important part — **what it does not see** | — |
| `docs/PIN-AND-CHANNEL.md` | The pin, its determinism proof, **its cost**, and the plugin channel's independence from the binary channel | — |

## Blocking secret scanning (`055.W4.1`, D20(1))

A package containing a recognisable credential **does not publish** — failure, not warning, with no
flag or environment variable that disables it. The scan covers the **manifest** (which becomes a
public catalog entry) and the **artifact's real bytes** (what a client downloads and runs), using a
vendored subset of gitleaks' rule corpus so the detection patterns are reused rather than reinvented.

```bash
node scripts/scan-secrets.mjs --artifact <plugin.tar.gz>
node scripts/scan-secrets.mjs --manifest <manifest.json> --json
```

**Read `docs/SECRET-SCANNING.md` §5 before treating a clean scan as a safety verdict.** In
particular: the scan inspects the *published* manifest and artifact, **not** the target of an MCP
pointer resolved at runtime (`{command, args}`, typically `npx <package>`), and an obfuscated or
encoded secret escapes it entirely. Those limits are printed on **every** run, including successful
ones.

A member the scan **cannot read** — binary, oversized, a duplicate/shadowed path, or a non-regular
member — **blocks the publish** rather than being skipped: unscannable is treated as not publishable
(§5.1), and the inventory comes from the archive's own **member table**, not from what survives
extraction (§5.2).

## Version pin + the plugin channel (`055.W4.1`, D20(2) / D19)

`<plugin_id>@<version>` resolves to a **digest**; the mirror path is content-addressed, so the same
pin yields the same bytes. The plugin's update cycle is **independent** of the cockpit binary's
(`ADR-COCKPIT-UPDATE-CHANNELS`, epic 017 — reused as a concept, never reimplemented here), and CI
enforces that no file in this repo reads binary-channel state.

```bash
node scripts/resolve-pin.mjs --index index/index.json --pin sinkra-os@1.2.0 [--verify ./downloaded.tar.gz]
```

**The pin is not pure gain.** It freezes an install — which also means an already-installed artifact
**cannot be repaired** by a later corrected build. Index freshness (`055.W5.1`, D20(5)) is what gives
that capability back. The cost is carried on every resolution and printed in every output mode; the
reasoning is in `docs/PIN-AND-CHANNEL.md` §2.

## Capability analysis + mandatory `allowed-tools` (`055.W4.2`)

Expand Down
4 changes: 2 additions & 2 deletions docs/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Measured in the product repo's SOT (`.aiox-core/skills/`), 2026-08-10:
| Skills declaring `allowed-tools` before this story | **0 of 35** |
| Skills that ship their own `scripts/` (`OWNS_SCRIPTS`) | **6 of 35** |
| Skills whose body instructs executing a script (`INSTRUCTS_EXECUTION`) | **9 of 35** |
| Is an MCP server an inspectable artifact? | **No — a `{command, args}` pointer** (`mcp.rs:68`) |
| Is an MCP server an inspectable artifact? | **No — a `{command, args}` pointer** (`crates/aiox-core/src/mcp.rs:68`) |

D17's prohibition on third-party `scripts/` remains valid, but it is verifiable **over the folder,
not over the behaviour**. That is why the analyzer emits two signals and never one number.
Expand Down Expand Up @@ -126,7 +126,7 @@ These travel **with** the capabilities, always — onto the entry, and into the
coupling is deliberate: **a capability list displayed without its blind spots lies by omission.**

- **An MCP server is a runtime-resolved pointer, not an artifact.** The manifest supplies
`{command, args}` (product repo `mcp.rs:68`, typically `npx <package>`) against a registry AIOX
`{command, args}` (product repo `crates/aiox-core/src/mcp.rs:68`, typically `npx <package>`) against a registry AIOX
does not control. This analysis covers the **pointer**; it has never opened the **target**. The
`npx` target is never inspected, downloaded or executed.
- A signature over the index covers the pointer, not the pointed-at package. **Provenance is not
Expand Down
5 changes: 5 additions & 0 deletions docs/CATALOG-AND-MIRROR.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,8 @@ is not built here. If you are editing this repo and about to write the word "rev
"remove" as something the system does **on its own** (not as an explicit, manual, reasoned
operator action), stop — check whether `055.W1.3`'s `O5` reconciliation and `055.W5.1` have actually
landed first.

Story `055.W4.1` obeys this guardrail explicitly: the version pin's documented cost is that an
already-installed artifact **cannot be repaired**, and the thing that gives that capability back is
index freshness (`055.W5.1`). *Repair* is not *revocation*, and `docs/PIN-AND-CHANNEL.md` §2 says so
in the same words rather than leaving a reader to infer it.
Loading
Loading