From 55e0ace299a9c0c4c10a7c35a5a54c6362d34292 Mon Sep 17 00:00:00 2001 From: indexzero Date: Thu, 19 Mar 2026 15:51:31 -0400 Subject: [PATCH] docs: rewrite README and add doc/ feature guides The old README described a v1 toy. vlurp is not that anymore. New README: terminal-first, no emojis, shows the tool working. Install in one line, first fetch in three. Commands table, flags table, presets table. Links out to doc/ for depth. New doc/ guides organized by workflow: - doc/fetch.md fetching, filtering, presets, --ref, --as - doc/vlurpfile.md batch processing, manifest format - doc/supply-chain.md lineage, verify, pin, scan, threat model - doc/upgrade.md outdated, diff, upgrade, catalog, catalog-diff Also adds the three attestation design docs (sigstore-js plan, nono-direct plan, head-to-head comparison) for the upcoming Sigstore integration work. Co-Authored-By: Claude Opus 4.6 (1M context) --- ATTEST.H2H.md | 94 ++++++ NONO.ATTEST.PLAN.md | 681 +++++++++++++++++++++++++++++++++++++++++++ README.md | 224 +++++++++----- VLURP.ATTEST.PLAN.md | 523 +++++++++++++++++++++++++++++++++ doc/fetch.md | 127 ++++++++ doc/supply-chain.md | 162 ++++++++++ doc/upgrade.md | 279 ++++++++++++++++++ doc/vlurpfile.md | 123 ++++++++ 8 files changed, 2146 insertions(+), 67 deletions(-) create mode 100644 ATTEST.H2H.md create mode 100644 NONO.ATTEST.PLAN.md create mode 100644 VLURP.ATTEST.PLAN.md create mode 100644 doc/fetch.md create mode 100644 doc/supply-chain.md create mode 100644 doc/upgrade.md create mode 100644 doc/vlurpfile.md diff --git a/ATTEST.H2H.md b/ATTEST.H2H.md new file mode 100644 index 0000000..f16e6c8 --- /dev/null +++ b/ATTEST.H2H.md @@ -0,0 +1,94 @@ +# Attestation Plans: Head-to-Head + +Side-by-side comparison of VLURP.ATTEST.PLAN.md (sigstore-js) vs NONO.ATTEST.PLAN.md (nono-direct) to decide how vlurp should implement Sigstore attestation for skill packs. + +## Comparison Table + +| Dimension | sigstore-js (VLURP.ATTEST.PLAN) | nono-direct (NONO.ATTEST.PLAN) | +|-----------|--------------------------------|-------------------------------| +| **New npm deps** | `sigstore@4.1.0` (~2.5MB, 6 transitive pkgs) | None | +| **External binary** | None | `nono` (~15MB, user installs separately) | +| **New vlurp code** | ~400-500 LOC (attest.js, trust-policy.js, 2 commands) | ~200-250 LOC (nono.js bridge, trust-policy.js, 2 commands) | +| **Signing capability** | Yes -- `vlurp attest --keyless` | No -- user runs `nono trust sign` directly | +| **Verification** | In-process `sigstore.verify()` | Shell out to `nono trust verify` | +| **Runtime enforcement** | None. Advisory only. | Kernel-enforced via `nono run` (seccomp/Seatbelt) | +| **Trust policy format** | Custom `.vlurp-trust.json` | nono-native `trust-policy.json` | +| **Bundle format** | Custom `.vlurp.sigstore` | nono-native `.nono-trust.bundle` | +| **Predicate type** | `https://vlurp.dev/attestation/skill-pack/v1` (island) | `https://nono.sh/attestation/instruction-file/v1` (ecosystem) | +| **Upstream signing action** | Must build `vlurp-attest` action (doesn't exist) | `nono-attest` (already on GitHub Marketplace) | +| **Platform support** | Everywhere Node runs (incl. Windows) | macOS + Linux only (no Windows, no musl) | +| **Error handling** | Structured JS exceptions | Exit codes + stderr parsing | +| **Maturity of crypto dep** | `sigstore` is stable, used by npm itself | nono is alpha ("not recommended for production") | +| **Consumer-side signing** | Yes (Model B: sign with your own CI identity) | Possible but vlurp doesn't orchestrate it | +| **Works without attestation tool** | Graceful -- sigstore bundled in vlurp, always available | Graceful -- falls back to SHA-256 only when nono absent | + +## The Three Questions That Matter + +### 1. Does vlurp need to sign, or just verify? + +**sigstore-js** gives vlurp both. `vlurp attest --keyless` lets upstream authors AND consumers sign. This is important for Model B (consumer re-signs for their own audit trail) and means skill pack authors only need vlurp, not two tools. + +**nono** makes vlurp verification-only. Signing requires the author to set up nono-attest. This is fine for the "upstream signs, consumer verifies" flow, but means vlurp can't offer consumer-side signing without wrapping `nono trust sign` (adding the binary dependency anyway). + +**Verdict**: If consumer-side signing (Model B) matters, sigstore-js is better. If upstream-signs-consumer-verifies is sufficient, nono works. + +### 2. Does runtime enforcement change the security model? + +**sigstore-js** verification is a checkpoint. After `vlurp verify --sigstore` passes, nothing prevents file modification before the agent reads it. An attacker with write access to the skill directory can swap files between verification and agent launch. + +**nono** closes this gap with kernel enforcement. `nono run -- claude` re-verifies files at every `open()` syscall (Linux) or blocks unverified reads entirely (macOS). This is a fundamentally different security property -- not just "was this file signed?" but "is this file STILL the signed version at the moment the agent reads it?" + +**Verdict**: If the threat model includes post-verification tampering (shared machines, untrusted environments), only nono helps. If the threat model is "verify the supply chain at fetch time," both are equivalent. + +### 3. How much ecosystem coupling is acceptable? + +**sigstore-js** makes vlurp self-contained but creates an island. `.vlurp-trust.json`, `.vlurp.sigstore`, and `https://vlurp.dev/attestation/skill-pack/v1` are vlurp-specific. No other tool understands them. If the broader agent security ecosystem converges on nono's formats, vlurp would need to bridge or migrate. + +**nono** makes vlurp a citizen of nono's ecosystem. The same `trust-policy.json` works for `vlurp verify --attest` and `nono run`. The same `.nono-trust.bundle` is understood by nono-py, nono-ts, and any future tool that reads nono's predicate type. But vlurp is coupled to nono's schema evolution -- if nono changes `trust-policy.json` v2, vlurp must follow. + +**Verdict**: nono's ecosystem is early but has momentum (from the creator of Sigstore, already has Rust/Python/TypeScript SDKs). Betting on it is reasonable. Being coupled to alpha software is a real risk. + +## Risks + +### sigstore-js risks + +- **You build the wrong thing.** vlurp invents a predicate type, trust policy format, and bundle naming convention. If the ecosystem standardizes on nono's conventions, you've built an island that needs a bridge. +- **Signing is advisory.** You invest in attestation infrastructure that sophisticated attackers bypass by modifying files after verification. The security story has an asterisk. +- **Upstream adoption friction.** Skill pack authors must install vlurp (or use a vlurp-specific action) to sign. nono-attest is already shipping. + +### nono risks + +- **Alpha instability.** nono is v0.18.0 with an explicit "not for production" warning. CLI flags, trust-policy schema, or bundle format could change. vlurp's nono bridge could break on nono upgrades. +- **Adoption barrier.** Most vlurp users won't install nono. Attestation becomes a power-user feature that the majority never enables. The feature investment has a small audience. +- **Shell-out fragility.** `nono trust verify` output format is not contractual. Parsing stderr for status details is brittle. No `--json` flag exists yet. +- **No Windows.** vlurp runs on Windows (Node). nono doesn't. Attestation is platform-gated. + +## Recommendation + +**Start with nono. Keep the door open for sigstore-js.** + +### Why nono first + +1. **Less code, faster to ship.** The nono bridge is ~200 LOC vs ~500 LOC for the sigstore-js plan. Phase 1-2 are minimal investment. + +2. **Signing is already solved.** `nono-attest` exists on GitHub Marketplace. No competing action to build. Upstream authors can start signing today. + +3. **One trust policy, two enforcement points.** The same `trust-policy.json` that `vlurp verify --attest` uses also works with `nono run -- claude`. Users who want the full security story get it without maintaining two policy files. + +4. **Runtime enforcement is the differentiator.** The pitch: "vlurp manages your skill packs, nono secures them. Together they give you an unbroken chain from upstream CI to kernel-enforced agent execution." sigstore-js can't make this pitch. + +5. **The escape hatch exists.** Sigstore v0.3 bundles are a standard format. If nono dies or changes incompatibly, sigstore-js verification can read the same `.nono-trust.bundle` files. The lineage schema and trust-policy concepts translate directly. No lock-in. + +### The hedge + +Write `src/trust-policy.js` to be generic. Don't leak nono-specific details (binary paths, CLI flags) beyond `src/nono.js`. If a `src/sigstore.js` backend is added later implementing the same `{ verify(bundle, policy) }` interface, the rest of vlurp doesn't change. + +### Build order + +1. `src/nono.js` -- detect + shell-out bridge (Phase 1, ~80 LOC) +2. `src/trust-policy.js` -- read/generate `trust-policy.json` (Phase 2, ~100 LOC) +3. `vlurp trust init` + `vlurp trust show` (Phase 3a, ~100 LOC) +4. `vlurp verify --attest` (Phase 3b, ~80 LOC) +5. Bundle extraction in fetch pipeline (Phase 3c, ~30 LOC) + +Total: ~390 LOC of new vlurp code, zero new npm dependencies, attestation verification that composes with nono's runtime enforcement. diff --git a/NONO.ATTEST.PLAN.md b/NONO.ATTEST.PLAN.md new file mode 100644 index 0000000..00951ea --- /dev/null +++ b/NONO.ATTEST.PLAN.md @@ -0,0 +1,681 @@ +# NONO.ATTEST.PLAN: Use nono-attest Directly + +Use [nono](https://github.com/always-further/nono) as the attestation engine instead of reimplementing with sigstore-js. vlurp orchestrates which files to sign/verify and records results in lineage. nono owns the crypto, the bundle format, the trust policy schema, and -- uniquely -- kernel-enforced runtime verification. + +## Why nono Instead of sigstore-js + +The sigstore-js plan (VLURP.ATTEST.PLAN.md) rebuilds the attestation stack in JavaScript: in-toto statement construction, DSSE signing, Fulcio/Rekor interaction, trust policy matching. This works, but it reimplements what nono already does and misses the feature that makes nono interesting: **runtime enforcement**. + +| Concern | sigstore-js | nono | +|---------|-------------|------| +| Signing | `sigstore.attest()` -- JS library call | `nono trust sign --keyless` -- CLI | +| Verification | `sigstore.verify()` -- JS library call | `nono trust verify --policy` -- CLI | +| In-toto statement | vlurp builds it | nono builds it | +| Trust policy | vlurp-specific `.vlurp-trust.json` | nono-native `trust-policy.json` | +| Bundle format | vlurp-specific `.vlurp.sigstore` | nono-native `.nono-trust.bundle` / `.bundle` | +| Runtime enforcement | None. Signing is advisory. | Kernel-enforced. seccomp (Linux), Seatbelt (macOS). Agent cannot read unverified files. | +| Predicate type | `https://vlurp.dev/attestation/skill-pack/v1` (custom) | `https://nono.sh/attestation/instruction-file/v1` (nono standard) | +| Dependency | `sigstore@4.1.0` (~2.5MB npm) | `nono` binary (~15MB, platform-specific) | +| Upstream adoption | Skill pack author must use vlurp to sign | Skill pack author uses nono-attest action (already exists, already on GitHub Marketplace) | + +The decisive advantage: with nono, `vlurp fetch` + `nono run -- claude` gives you an unbroken chain from CI signing to kernel-enforced runtime verification. With sigstore-js, signing is advisory -- nothing stops a modified file from being read after verification. + +## Architecture + +### The Two-Tool Model + +vlurp and nono have complementary roles: + +``` +vlurp nono +============================ ============================ +Package manager for skill packs Security infrastructure + +- Fetch files from GitHub - Sign files (Sigstore keyless) +- Filter, extract, rename - Verify bundles (trust policy) +- Track lineage (.vlurp.jsonl) - Runtime sandbox (kernel-enforced) +- Catalog skills (catalog.json) - Pre-exec scan (verify before read) +- Upgrade with structured diffs - TOCTOU protection (re-verify fd) +- Content scanning (prompt injection) - Blocklist enforcement +``` + +vlurp tells nono WHAT to sign/verify. nono does the crypto and enforcement. Neither tool replaces the other. + +### Data Flow + +``` +UPSTREAM REPO CONSUMER MACHINE +================================= ================================= + +Skill pack author's CI: User runs: + vlurp fetch garrytan/gstack + nono-attest action | + | +--> download tarball + +--> nono trust sign --keyless +--> extract + filter + | --all +--> hash files (lineage) + | +--> write .vlurp.jsonl + +--> commits: | + SKILL.md +--> also fetches *.bundle files + bin/helper.sh | from tarball if present + .nono-trust.bundle | + +--> vlurp verify --attest + | | + | +--> nono trust verify + | --policy trust-policy.json + | --all + | | + | +--> record attestation in + | .vlurp.jsonl + | + +--> User launches agent: + nono run --profile claude-code + -- claude + | + +--> pre-exec scan verifies + | all SKILL.md bundles + +--> kernel sandbox blocks + reads of unverified files +``` + +### What vlurp Does vs What nono Does + +**vlurp's job (orchestration):** + +1. Know which files belong to which source (lineage) +2. Download bundles alongside content during fetch +3. Invoke `nono trust verify` with the right files and policy +4. Record attestation results in `.vlurp.jsonl` +5. Present attestation status in `vlurp verify` output +6. Generate/manage `trust-policy.json` from vlurpfile sources + +**nono's job (crypto + enforcement):** + +1. Construct in-toto v1 statements with file subjects + SHA-256 digests +2. Sign via Sigstore (Fulcio certificate, Rekor log, DSSE envelope) +3. Produce `.nono-trust.bundle` / `.bundle` artifacts +4. Verify bundles: certificate chain, Rekor proof, signature, OID matching +5. Runtime: block reads of unverified instruction files via kernel mechanisms + +## nono Binary Management + +### Installation + +vlurp does NOT install nono automatically. nono is an independent tool the user installs themselves: + +```bash +# macOS / Linux +brew install nono + +# Or from GitHub releases +curl -fsSL https://github.com/always-further/nono/releases/latest/download/nono-v0.18.0-aarch64-apple-darwin.tar.gz | tar xz +sudo mv nono /usr/local/bin/ + +# Verify +nono --version +``` + +vlurp checks for nono's presence and degrades gracefully: + +``` +$ vlurp verify --attest .claude/skills + + nono not found. Install it for Sigstore attestation verification: + brew install nono + https://github.com/always-further/nono + + Falling back to SHA-256 integrity verification only. +``` + +### Why Not Auto-Install + +1. nono is a security tool. Users should make a conscious decision to install it. +2. Platform-specific binaries are fragile to auto-install (glibc versions, code signing). +3. nono has its own update mechanism and release cadence. +4. `brew install nono` is one command. No need to over-engineer this. + +### Detection + +```js +// src/nono.js +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); + +export async function isNonoInstalled() { + try { + const { stdout } = await exec('nono', ['--version']); + return { installed: true, version: stdout.trim() }; + } catch { + return { installed: false, version: null }; + } +} +``` + +## nono CLI Interface + +### Signing + +```bash +# Sign all instruction files matching trust policy patterns +nono trust sign --keyless --all + +# Sign specific files as multi-subject bundle +nono trust sign --keyless SKILL.md bin/helper.sh config/settings.json +# -> produces .nono-trust.bundle + +# Sign specific files as per-file bundles +nono trust sign --keyless SKILL.md +# -> produces SKILL.md.bundle +``` + +### Verification + +```bash +# Verify all instruction files against trust policy +nono trust verify --policy trust-policy.json --all + +# Verify specific bundle +nono trust verify --policy trust-policy.json SKILL.md +``` + +### Relevant Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Signing/verification succeeded | +| 1 | Verification failed (invalid signature, policy mismatch, etc.) | +| non-zero | Various error conditions | + +### Bundle Output + +nono produces standard Sigstore v0.3 bundles: + +| Mode | File | Content | +|------|------|---------| +| Multi-subject (default) | `.nono-trust.bundle` | Single DSSE envelope covering all specified files | +| Per-file | `.bundle` | One DSSE envelope per file | + +Both contain: DSSE envelope with in-toto v1 statement, Fulcio certificate chain, Rekor transparency log inclusion proof, ECDSA P-256 signature. + +## Trust Policy + +vlurp uses nono's native `trust-policy.json` format. No vlurp-specific schema. + +### `trust-policy.json` + +```json +{ + "version": 1, + "instruction_patterns": ["SKILLS*", "SKILL*", "CLAUDE*", "AGENT*", ".claude/**/*.md"], + "publishers": [ + { + "name": "gstack CI", + "issuer": "https://token.actions.githubusercontent.com", + "repository": "garrytan/gstack", + "workflow": ".github/workflows/sign-instruction-files.yml", + "ref_pattern": "refs/heads/main" + }, + { + "name": "obra skills", + "issuer": "https://token.actions.githubusercontent.com", + "repository": "obra/claude-skills" + } + ], + "blocklist": { "digests": [] }, + "enforcement": "deny" +} +``` + +### nono Fields vs vlurp Source Mapping + +| trust-policy.json field | Maps to | Notes | +|-------------------------|---------|-------| +| `publishers[].repository` | vlurpfile source (e.g. `garrytan/gstack`) | nono uses GitHub `owner/repo`, matches vlurp's source format | +| `publishers[].issuer` | Always `https://token.actions.githubusercontent.com` for GitHub Actions | | +| `publishers[].workflow` | The upstream repo's signing workflow path | User must know this | +| `publishers[].ref_pattern` | Allowed branch/tag refs | Glob pattern matching | +| `instruction_patterns` | Glob patterns for auto-discovery with `--all` | Separate from vlurp's lineage-based file tracking | +| `enforcement` | `"warn"` or `"deny"` | Controls whether verification failure blocks | + +### `vlurp trust init` + +vlurp generates a starter `trust-policy.json` from the `.vlurpfile`: + +``` +$ vlurp trust init + + Generated trust-policy.json with 2 publisher entries: + + garrytan/gstack (fill in workflow path) + obra/claude-skills (fill in workflow path) + + Edit trust-policy.json to add workflow paths and ref_patterns. +``` + +Generated file: + +```json +{ + "version": 1, + "instruction_patterns": ["SKILL*", "CLAUDE*", "AGENT*", ".claude/**/*.md"], + "publishers": [ + { + "name": "garrytan/gstack", + "issuer": "https://token.actions.githubusercontent.com", + "repository": "garrytan/gstack", + "workflow": "", + "ref_pattern": "refs/heads/main" + }, + { + "name": "obra/claude-skills", + "issuer": "https://token.actions.githubusercontent.com", + "repository": "obra/claude-skills", + "workflow": "", + "ref_pattern": "refs/heads/main" + } + ], + "blocklist": { "digests": [] }, + "enforcement": "warn" +} +``` + +The user fills in `workflow` paths. Enforcement defaults to `"warn"` so that first-time setup doesn't break fetches. + +## New Commands + +### `vlurp verify --attest [--trust-policy ] ` + +Enhanced verify that runs SHA-256 integrity check AND nono bundle verification. + +```sh +# Full verification: integrity + Sigstore attestation +vlurp verify --attest .claude/skills + +# With explicit trust policy +vlurp verify --attest --trust-policy trust-policy.json .claude/skills + +# Existing behavior (unchanged, integrity only) +vlurp verify .claude/skills +``` + +**Behavior:** + +1. Run existing `verifyFiles()` (SHA-256 integrity against lineage) -- always +2. If `--attest`: + a. Check if nono is installed. If not, warn and skip attestation check. + b. Find `.nono-trust.bundle` or `*.bundle` files alongside fetched content + c. For each bundle found: + - Shell out to `nono trust verify --policy ` + - Parse exit code (0 = valid, non-zero = invalid) + - Capture stdout/stderr for status details + d. Record per-source attestation status + e. Report in verify output + +**Output (nono installed, bundles present):** + +``` +Verify: .claude/skills + + garrytan/gstack + SKILL.md ok (sha256: e3b0c442...) + browse/SKILL.md ok (sha256: 7f83b165...) + browse/bin/find-browse ok (sha256: a1b2c3d4...) + + attestation: verified + signer: garrytan/gstack (.github/workflows/attest.yml) + signed: 2026-03-16T10:00:00Z (refs/heads/main) + bundle: .nono-trust.bundle + + obra/claude-skills + patterns/SKILL.md ok (sha256: 9f86d081...) + + attestation: no bundle found + +All 4 tracked files verified (1 source attested, 1 source unattested) +``` + +**Output (nono not installed):** + +``` +Verify: .claude/skills + + garrytan/gstack + SKILL.md ok (sha256: e3b0c442...) + browse/SKILL.md ok (sha256: 7f83b165...) + + attestation: skipped (nono not installed) + +All 3 tracked files verified (attestation checks skipped -- install nono for Sigstore verification) +``` + +### `vlurp trust init` + +Generate `trust-policy.json` from `.vlurpfile` sources (described above). + +```sh +vlurp trust init # generates trust-policy.json +vlurp trust init --enforcement deny # set enforcement to deny +``` + +### `vlurp trust show` + +Display current trust policy and match against fetched sources. + +```sh +$ vlurp trust show + + trust-policy.json (enforcement: warn) + + Publishers: + garrytan/gstack + issuer: https://token.actions.githubusercontent.com + workflow: .github/workflows/attest.yml + ref: refs/heads/main + status: matched (source in .vlurpfile) + + obra/claude-skills + issuer: https://token.actions.githubusercontent.com + workflow: (not configured) + ref: refs/heads/main + status: matched (source in .vlurpfile) + + Blocklist: 0 digests + + Unmatched vlurpfile sources (no publisher entry): + anthropics/prompt-eng +``` + +## New Modules + +### `src/nono.js` -- nono CLI bridge + +Thin wrapper for shelling out to the nono binary. + +``` +Exports: + isNonoInstalled() + --> { installed: boolean, version: string | null } + + nonoSign({ files, keyless, workingDir }) + --> { ok: boolean, bundles: string[], output: string } + Calls: nono trust sign [--keyless] [--all | ] + + nonoVerify({ policy, files, workingDir }) + --> { ok: boolean, output: string } + Calls: nono trust verify [--policy ] [--all | ] +``` + +### `src/trust-policy.js` -- Trust policy generation + reading + +``` +Exports: + loadTrustPolicy(policyPath) + --> parsed trust-policy.json object (nono's schema) + + initTrustPolicy(sources, options) + --> trust-policy.json content string + + findPublisherForSource(policy, source) + --> publisher entry or null + + unmatchedSources(policy, sources) + --> source strings with no publisher entry +``` + +### `src/commands/trust.js` -- Trust subcommands (Ink component) + +Handles `vlurp trust init` and `vlurp trust show`. + +## Modified Modules + +### `src/commands/verify.js` + +Add `--attest` flag. When set, after the existing SHA-256 check, call `nonoVerify()` and display attestation results. + +### `src/commands/fetch.js` + +When fetching a tarball, check if `*.bundle` or `.nono-trust.bundle` files are present. If so, include them in the extracted files (currently vlurp may filter these out depending on glob patterns). Record their presence in the lineage record. + +### `src/lineage.js` + +Add `attestation` field to lineage records: + +```json +{ + "source": "github:garrytan/gstack", + "ref": "abc1234", + "fetched_at": "2026-03-17T10:00:00Z", + "files": { ... }, + "attestation": { + "status": "verified", + "bundle": ".nono-trust.bundle", + "verified_at": "2026-03-17T10:01:00Z", + "nono_version": "0.18.0" + } +} +``` + +| status | Meaning | +|--------|---------| +| `"verified"` | nono verified the bundle successfully | +| `"failed"` | nono verification failed (invalid sig, policy mismatch) | +| `"no-bundle"` | No `.bundle` file found in upstream repo | +| `"skipped"` | nono not installed, verification not attempted | +| `null` | Attestation not checked (legacy fetch, `--attest` not used) | + +### `src/cli.js` + +Add `--attest` and `--trust-policy` flags. Add `trust` command routing. + +### `src/index.js` + +Export nono bridge and trust policy functions. + +## Bundle Discovery During Fetch + +When `vlurp fetch garrytan/gstack` downloads a tarball, vlurp already extracts files matching glob filters. The question is how to find nono bundles in the upstream repo. + +### Strategy: Include bundles in tarball extraction + +GitHub tarballs include all committed files. If the upstream repo committed `.nono-trust.bundle` or `SKILL.md.bundle`, they're in the tarball. vlurp's extraction pipeline already sees them. + +**Change needed**: Ensure `.nono-trust.bundle` and `*.bundle` files are not excluded by filter globs. When the `--preset skills` filter is active (which matches `**/SKILL.md` and supporting files), extend it to also match `**/*.bundle` and `**/.nono-trust.bundle`. + +```js +// In preset definition or filter logic +const BUNDLE_GLOBS = ['**/*.bundle', '**/.nono-trust.bundle']; +``` + +After extraction, vlurp records which bundles were found in the lineage record. If `--attest` is used (or a trust policy exists), vlurp runs `nono trust verify` immediately. + +## Upstream Signing (Skill Pack Authors) + +Skill pack authors use the nono-attest GitHub Action directly. No vlurp involvement in signing. + +### Minimal Setup + +```yaml +name: Sign instruction files +on: + push: + branches: [main] + paths: ['**/SKILL.md', 'scripts/**'] + +permissions: + id-token: write + contents: write + +jobs: + sign: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: always-further/nono-attest@v0.0.2 +``` + +That's it. nono-attest installs the nono binary, signs all instruction files matching its default patterns, verifies as a smoke test, and commits the `.bundle` files. + +### With Explicit File List + +```yaml +- uses: always-further/nono-attest@v0.0.2 + with: + files: "SKILL.md browse/SKILL.md review/SKILL.md bin/find-browse" +``` + +### Per-File Bundles + +```yaml +- uses: always-further/nono-attest@v0.0.2 + with: + per-file: "true" +``` + +### vlurp's Role + +vlurp does NOT provide a competing GitHub Action for signing. The signing action IS nono-attest. vlurp's documentation tells skill pack authors: "Use nono-attest to sign your instruction files. Consumers who use vlurp will automatically verify the bundles." + +## Runtime Enforcement (The Bonus) + +This is the feature sigstore-js cannot provide. When the user runs their agent through nono: + +```bash +nono run --profile claude-code -- claude +``` + +nono's pre-exec scan: + +1. Locates all files matching `instruction_patterns` in the trust policy +2. Verifies `trust-policy.json`'s own bundle signature +3. For each instruction file: + - Finds `.bundle` sidecar + - Validates Fulcio certificate chain + - Confirms Rekor timestamp within certificate lifespan + - Verifies ECDSA signature over DSSE PAE + - Matches OIDC claims against trust policy publishers + - Checks SHA-256 digest against blocklist + - Computes file digest and compares to in-toto subject +4. If any file fails with `enforcement: "deny"` -- sandbox refuses to start +5. On Linux: seccomp-notify intercepts `openat2` syscalls, re-verifies on every file open (TOCTOU protection) +6. On macOS: Seatbelt rules deny reads of instruction files unless pre-verified + +vlurp cannot do any of this. It doesn't need to. vlurp's job is to get the files there with integrity tracking. nono's job is to make sure the agent can only read verified files. + +### The Combined Workflow + +```bash +# 1. Fetch skill packs (vlurp) +vlurp batch + +# 2. Verify attestation (vlurp shells out to nono) +vlurp verify --attest .claude/skills + +# 3. Run agent with kernel-enforced file verification (nono) +nono run --profile claude-code -- claude +``` + +Steps 1-2 are vlurp. Step 3 is nono. The trust-policy.json is shared between them. + +## Implementation Phases + +### Phase 1: nono Bridge (`src/nono.js`) + +- `isNonoInstalled()` -- detect nono binary, get version +- `nonoSign()` -- shell out to `nono trust sign` +- `nonoVerify()` -- shell out to `nono trust verify`, parse exit code +- Test suite: mock `execFile` for unit tests, real nono binary for integration tests (CI only) + +No new npm dependencies. Just `node:child_process`. + +### Phase 2: Trust Policy (`src/trust-policy.js`) + +- `loadTrustPolicy()` -- read and validate `trust-policy.json` (nono's schema) +- `initTrustPolicy()` -- generate from `.vlurpfile` sources +- `findPublisherForSource()` -- match vlurp source to trust policy publisher +- `unmatchedSources()` -- find vlurpfile sources with no publisher entry +- Test suite: policy generation, source matching + +No new npm dependencies. + +### Phase 3: Commands + +- `vlurp verify --attest` -- enhanced verify with nono attestation checking +- `vlurp trust init` -- generates `trust-policy.json` +- `vlurp trust show` -- displays trust policy status +- Bundle extraction during fetch (ensure `*.bundle` files aren't filtered out) +- Lineage `attestation` field recording +- CLI flag additions (`--attest`, `--trust-policy`) + +### Phase 4: Documentation + +- README section on attestation with nono +- Skill pack author guide (how to add nono-attest to your CI) +- Consumer guide (how to verify with vlurp + nono) +- trust-policy.json authoring guide + +## Dependency Budget + +No new npm dependencies. vlurp shells out to `nono` which the user installs independently. + +| Component | How it's acquired | +|-----------|-------------------| +| nono binary | `brew install nono` or manual download from GitHub releases | +| trust-policy.json | `vlurp trust init` generates it, user edits | +| `.nono-trust.bundle` | Committed by upstream repo's nono-attest CI action | + +## Tradeoffs vs sigstore-js Plan + +### Advantages of nono-direct + +1. **Runtime enforcement.** The only way to get kernel-enforced file verification. sigstore-js gives advisory verification that nothing enforces after the check. + +2. **No new npm dependencies.** Zero added to vlurp's dependency tree. The nono binary is installed and managed independently. + +3. **Upstream ecosystem compatibility.** Skill pack authors using nono-attest (already on GitHub Marketplace) produce bundles that vlurp automatically consumes. No need for a vlurp-specific signing action. + +4. **Trust policy is shared.** The same `trust-policy.json` works for `vlurp verify --attest` and `nono run --profile claude-code`. One policy, two enforcement points. + +5. **No crypto in vlurp.** vlurp never touches private keys, certificates, or signing operations. The security-critical code stays in nono's audited Rust implementation. + +6. **nono's predicateType is the ecosystem standard.** `https://nono.sh/attestation/instruction-file/v1` is what other tools (nono run, nono-py, nono-ts) understand. A vlurp-specific predicate type would be an island. + +### Disadvantages of nono-direct + +1. **External binary dependency.** Users must install nono separately. Not everyone will. The attestation features are opt-in, not default. + +2. **Platform-specific binary.** nono ships tarballs for linux-x86_64, darwin-x86_64, darwin-aarch64. No Windows. No musl. No 32-bit. + +3. **Shell-out architecture.** Error handling is exit codes + stderr parsing. Less structured than a library call. nono's output format could change between versions. + +4. **Alpha software.** nono is at v0.18.0 with an explicit "not recommended for production" warning. API surface may change. Bundle format may evolve. + +5. **Two tools to learn.** Users need to understand both vlurp and nono. The sigstore-js plan keeps everything in one tool. + +6. **No signing from vlurp.** The sigstore-js plan lets vlurp itself sign (for consumer-side attestation). The nono plan requires users to run `nono trust sign` directly. vlurp is verification-only. + +### When to Choose Which + +| Scenario | Recommendation | +|----------|---------------| +| Skill pack author wants to sign their files in CI | nono-attest (either plan) | +| Consumer wants verification at fetch time | Either plan works | +| Consumer wants kernel-enforced runtime protection | nono-direct (only option) | +| Consumer wants zero external dependencies | sigstore-js plan | +| Consumer is on Windows | sigstore-js plan (nono has no Windows support) | +| Ecosystem standardization matters | nono-direct (shared predicate type + trust policy) | +| vlurp wants to be self-contained | sigstore-js plan | + +## Open Questions + +1. **nono output parsing.** Does `nono trust verify` produce structured (JSON) output, or just human-readable text? If human-readable only, we rely on exit codes and hope the format is stable. Filing an issue for `--json` output would help. + +2. **Bundle discovery conventions.** When vlurp fetches a tarball, should it look for `.nono-trust.bundle` at the repo root, or in each skill directory, or both? Need to match what nono-attest produces. + +3. **Minimum nono version.** What's the minimum nono version vlurp should require? The `trust sign` / `trust verify` subcommands need to be stable. Pin to a minimum version and check on detection. + +4. **Graceful degradation scope.** When nono is not installed, vlurp falls back to SHA-256 integrity only. Should vlurp warn on every fetch, or only when `--attest` is explicitly passed? Constant warnings would annoy users who don't care about attestation. + +5. **Consumer-side signing.** The sigstore-js plan supports "consumer signs locally" (Model B). The nono plan could support this too (`nono trust sign --keyless` in the consumer's CI), but vlurp wouldn't orchestrate it. Is that OK, or does vlurp need a `vlurp attest` command that wraps `nono trust sign`? + +6. **Can both plans coexist?** It's possible to implement nono-direct for verification (Phase 1-3) now, and add sigstore-js signing later for environments without nono. The trust policy and bundle format would need to be nono-compatible to avoid divergence. diff --git a/README.md b/README.md index 8dd2497..0f6586f 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,196 @@ -# `vlurp === (vibes && slurp) // true` +``` + | ._._ +\/||_|| |_) + | +``` -A fun CLI tool to quickly slurp vibe(ish) files from GitHub repositories and gists. +Fetch text from GitHub. Pin to commits. Hash every file. Scan for threats. Diff before you accept. -## Installation +vlurp is not a package manager. It does not resolve dependencies, run install scripts, or manage a registry. It fetches files from GitHub repos, tracks where they came from, and tells you what they do -- so you can make informed trust decisions about text that becomes AI agent instructions. + +## Install ```sh -# Install globally npm install -g vlurp +``` -# Or with pnpm -pnpm add -g vlurp +or run directly: -# Or run directly with npx +```sh npx vlurp / ``` -## Usage +## Quick start -```sh -# vlurp a repository using user/repo format -vlurp cool-repo/has-agents -# → Creates ./cool-repo/has-agents +Fetch a repo's Claude config: + +``` +$ vlurp eyaltoledano/claude-task-master -d ./vlurp + + eyaltoledano/claude-task-master@HEAD + 2 files + ./vlurp/eyaltoledano/claude-task-master/ +``` + +Fetch skill files pinned to a commit, flattened into a named directory: + +``` +$ vlurp obra/superpowers -d .claude/skills --preset skills --ref e4f5a6b -# vlurp to a specific directory -vlurp cool-repo/has-agents -d ~/projects -# → Creates ~/projects/cool-repo/has-agents + obra/superpowers@e4f5a6b + 22 files (preset: skills) + .claude/skills/obra/superpowers/ +``` -# vlurp using a GitHub URL -vlurp https://github.com/whoever/cool-configs -# → Creates ./whoever/cool-configs +Fetch a specific directory from deep inside a repo: -# vlurp a GitHub Gist -vlurp https://gist.github.com/user/abc123def456 -# → Creates ./user/abc123def456 +``` +$ vlurp whilp/dotfiles -d .claude/skills \ + --filter ".claude/skills/duckdb-json/**" --as duckdb -# Show help -vlurp --help + whilp/dotfiles@HEAD + 3 files + .claude/skills/duckdb/ +``` -# Filter files (default: .claude/** and CLAUDE.md) -vlurp cool-repo/has-agents --filter "*.ts" --filter "*.tsx" +Check that nothing has been modified since you fetched: -# vlurp only specific directories -vlurp whoever/cool-configs --filter "lib/**" --filter "doc/**" +``` +$ vlurp verify .claude/skills -# vlurp only markdown files -vlurp user/repo --filter "*.md" + obra/superpowers/skills/tdd/SKILL.md ok sha256:e3b0c442 + obra/superpowers/skills/verify/SKILL.md ok sha256:7f83b165 + duckdb/SKILL.md ok sha256:13681909 -# Use a preset instead of manual filters -vlurp user/repo --preset claude -vlurp user/repo --preset skills + 25 files verified, 0 modified +``` + +See what a skill tells your agent to do before you inject it: -# Auto-detect repo structure and apply appropriate filters -vlurp user/repo --auto +``` +$ vlurp scan .claude/skills -# Preview what would be fetched -vlurp user/repo --dry-run + obra/superpowers/skills/tdd/SKILL.md + ok No injection patterns + warn Bash (8 refs), Edit (4 refs) -# Process a .vlurpfile -vlurp batch .vlurpfile -vlurp batch .vlurpfile --dry-run + duckdb/SKILL.md + ok No injection patterns + warn Bash (11 refs) + warn References external commands: duckdb + + 25 files, 0 issues, 12 warnings + tool surface: Bash (31), Read (8), Edit (6) + command surface: duckdb, git, npm ``` -## Presets +Upgrade when upstream changes, with a structured diff of what's new: + +``` +$ vlurp upgrade --dry-run + + obra/superpowers e4f5a6b -> 9c8b7a6 + + tdd 1.0.0 -> 1.0.1 + tools: (unchanged) + files: (unchanged) -| Preset | Description | Filters | -|--------|-------------|---------| -| `claude` | Claude Code config | `.claude/**`, `CLAUDE.md` | -| `skills` | Agent skills | `skills/**`, `SKILL.md`, `**/*.md` | -| `agents` | Agent definitions | `agents/**`, `commands/**`, `**/*.md` | -| `docs` | Documentation | `**/*.md` (excluding boilerplate) | -| `all-md` | All markdown | `**/*.md` | -| `minimal` | Minimal claude | `.claude/**`, `CLAUDE.md` only | + debug (new skill) + tools: Bash, Read, Grep -## Batch Processing + 1 source, 8 skills (1 new, 1 changed, 6 unchanged) +``` + +## Batch Process multiple repos from a `.vlurpfile`: -```bash -vlurp batch .vlurpfile +```sh +# .vlurpfile + +# Official Anthropic skills +vlurp anthropics/skills -d ./vlurp --filter "skills/**" --filter "template/**" + +# obra/superpowers -- Core agent patterns +vlurp obra/superpowers -d ./vlurp --filter "skills/**" --filter ".claude/**" + +# DuckDB skills from assorted dotfiles +vlurp whilp/dotfiles -d ./vlurp --filter ".claude/skills/duckdb-json/**" +vlurp PovertyAction/ipa-research-data-science-hub -d ./vlurp --filter ".claude/skills/duckdb/**" + +# Microsoft Amplifier -- multi-agent framework +vlurp microsoft/amplifier -d ./vlurp --filter "**/*.md" +``` + +``` +$ vlurp batch .vlurpfile +``` + +## Commands + ``` +vlurp Fetch from a GitHub repo or gist +vlurp batch Process a .vlurpfile (batch fetch) +vlurp verify Check file integrity against lineage +vlurp pin [source] Pin sources to current upstream HEAD +vlurp outdated [vlurpfile] Check for upstream changes +vlurp diff Content diff against upstream +vlurp scan Analyze for injection/escalation patterns +vlurp catalog Generate skill index (catalog.json) +vlurp upgrade [source] Upgrade outdated sources +vlurp catalog-diff [old] [new] Compare catalog snapshots +``` + +## Flags -Example `.vlurpfile`: -```bash -# Comments start with # -vlurp anthropics/skills -d ./vlurp --preset skills -vlurp obra/superpowers -d ./vlurp --preset claude -vlurp user/repo -d ./vlurp --filter "docs/**" +``` +-d Root output directory +--ref Pin to a git ref (commit, tag, branch) +--as Flatten output into named directory +--preset Use a preset filter set +--filter Glob pattern for file matching (repeatable) +--auto Auto-detect repo structure +--dry-run, -n Preview without writing +--force, -f Overwrite without prompting +--json Machine-readable output (catalog-diff) +--vlurpfile Explicit .vlurpfile path (upgrade) ``` -## Features +## Presets + +``` +claude .claude/**, CLAUDE.md +skills skills/**, SKILL.md, **/*.md +agents agents/**, commands/**, **/*.md +docs **/*.md (excluding boilerplate) +all-md **/*.md +minimal .claude/**, CLAUDE.md only +``` + +## Feature guides + +| Guide | Covers | +|-------|--------| +| **[Fetching & Filtering](doc/fetch.md)** | Sources, globs, presets, `--ref`, `--as`, `--auto` | +| **[The .vlurpfile](doc/vlurpfile.md)** | Batch processing, manifest format, intent vs reality | +| **[Supply Chain Security](doc/supply-chain.md)** | Lineage, verify, pin, scan, threat model | +| **[Upgrades & Change Detection](doc/upgrade.md)** | outdated, diff, upgrade, catalog, catalog-diff | + +## Data files + +``` +.vlurpfile Fetch intent. One vlurp command per line. Human-authored. +.vlurp.jsonl Lineage. SHA-256 hashes, provenance, scan results. Machine-generated. +.vlurp.sigstore Sigstore attestation bundle (optional). Cryptographic proof of fetch. +catalog.json Derived skill index. Names, tools, commands, supporting files. +``` -- 🚀 Fast - Downloads tarballs instead of cloning entire git history -- 📦 Lightweight - Minimal dependencies -- 🎨 Clean output with progress indicators -- 🔒 Only works with github.com and gist.github.com -- 🌈 Simple - Just pass a repo or URL and go! -- 🎯 Selective - Filter files with glob patterns (defaults to .claude/** and CLAUDE.md) +The `.vlurpfile` is intent -- what you want. The `.vlurp.jsonl` is reality -- what you got. Both are committed to git. Both are reviewed in PRs. Separately. ## Contributing -Please see our [Contributing Guide](CONTRIBUTING.md) for details on how to contribute to this project. +See [CONTRIBUTING.md](CONTRIBUTING.md). -## Code of Conduct +## License -This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. +[Apache-2.0](LICENSE) diff --git a/VLURP.ATTEST.PLAN.md b/VLURP.ATTEST.PLAN.md new file mode 100644 index 0000000..756c0da --- /dev/null +++ b/VLURP.ATTEST.PLAN.md @@ -0,0 +1,523 @@ +# VLURP.ATTEST.PLAN: Sigstore Attestation for Skill Packs + +Port of [nono-attest](https://github.com/always-further/nono-attest) concepts to JavaScript using `sigstore-js`. No Rust CLI dependency. No runtime sandbox. Pure npm package attestation for the vlurp skill pack pipeline. + +## Reference: What nono-attest Does + +nono-attest is a GitHub Action that signs AI agent instruction files (SKILL.md, CLAUDE.md, etc.) using Sigstore keyless attestation. It produces **Sigstore v0.3 bundles** containing: + +| Layer | Content | +|-------|---------| +| **DSSE Envelope** | `payloadType: "application/vnd.in-toto+json"`, payload is an in-toto v1 statement | +| **In-toto Statement** | `subject`: filename + SHA-256 digest; `predicateType`: attestation type URI; `predicate`: signer identity (OIDC issuer, repo, workflow ref) | +| **Verification Material** | Fulcio certificate chain (keyless) OR public key hint (keyed); Rekor transparency log inclusion proof | +| **Signature** | ECDSA P-256 over the DSSE Pre-Authentication Encoding (PAE) | + +Verification confirms four things: Fulcio certificate chain validity, Rekor inclusion proof (timestamp within certificate window), ECDSA signature validity, and OIDC claim matching against a trust policy. + +Two signing modes: **multi-subject** (single `.nono-trust.bundle` covering all files atomically) and **per-file** (`.bundle` sidecars for independent verification). + +## What vlurp Ports (and What It Doesn't) + +### Ported + +| nono-attest concept | vlurp equivalent | +|---------------------|------------------| +| `nono trust sign --keyless` | `vlurp attest` using `sigstore.attest()` | +| `nono trust verify --policy` | `vlurp verify --sigstore` using `sigstore.verify()` | +| `.nono-trust.bundle` (multi-subject) | `.vlurp.sigstore` (per-source bundles) | +| `.bundle` (per-file) | Not needed -- vlurp's lineage model groups files by source | +| `trust-policy.json` (publisher definitions) | `.vlurp-trust.json` (same schema, adapted for vlurp sources) | +| GitHub Actions OIDC identity | `@sigstore/sign` `CIContextProvider` | +| Fulcio certificate OID matching | `sigstore.verify()` `certificateOIDs` option | + +### Not ported + +| nono-attest concept | Why not | +|---------------------|---------| +| Runtime sandbox (seccomp, Seatbelt) | vlurp is a package manager, not a runtime. File interception is out of scope. | +| TOCTOU protection | No runtime file access mediation. vlurp verifies at fetch time. | +| Pre-exec scan | vlurp's `verify` command is the equivalent -- user runs it explicitly. | +| nono CLI binary installation | Replaced entirely by `sigstore` npm package (pure JS). | +| Per-file bundles | vlurp's source-level lineage makes per-source bundles the natural unit. | + +## sigstore-js API Mapping + +``` +npm: sigstore@4.1.0 + @sigstore/sign@4.1.0 + @sigstore/verify@3.1.0 + @sigstore/bundle@4.0.0 + @sigstore/core@3.1.0 + @sigstore/tuf@4.0.1 + @sigstore/protobuf-specs@0.5.0 +``` + +### Signing (attest) + +```js +import { attest } from 'sigstore'; + +// Build in-toto statement payload +const statement = { + _type: 'https://in-toto.io/Statement/v1', + subject: [ + { name: 'gstack/browse/SKILL.md', digest: { sha256: 'abc123...' } }, + { name: 'gstack/review/SKILL.md', digest: { sha256: 'def456...' } }, + // ... all files from lineage record + ], + predicateType: 'https://vlurp.dev/attestation/skill-pack/v1', + predicate: { + source: 'github:garrytan/gstack', + ref: 'abc1234def5678...', + fetched_at: '2026-03-17T10:00:00Z', + vlurp_version: '2.0.0' + } +}; + +const payload = Buffer.from(JSON.stringify(statement)); + +const bundle = await attest(payload, 'application/vnd.in-toto+json', { + // Keyless (CI): uses Fulcio + OIDC token from environment + // identityToken is auto-detected in GitHub Actions via CIContextProvider + fulcioURL: 'https://fulcio.sigstore.dev', + rekorURL: 'https://rekor.sigstore.dev', + tlogUpload: true +}); + +// bundle is a SerializedBundle (JSON-serializable Sigstore v0.3 bundle) +await writeFile('.vlurp.sigstore', JSON.stringify(bundle, null, 2)); +``` + +### Verification + +```js +import { verify } from 'sigstore'; + +const bundle = JSON.parse(await readFile('.vlurp.sigstore', 'utf8')); + +const signer = await verify(bundle, { + // Fulcio certificate OID matching (maps to trust policy publishers) + certificateIssuer: 'https://token.actions.githubusercontent.com', + certificateOIDs: { + '1.3.6.1.4.1.57264.1.8': 'https://github.com/garrytan/gstack', // source repo + '1.3.6.1.4.1.57264.1.9': 'refs/heads/main', // ref + '1.3.6.1.4.1.57264.1.11': '.github/workflows/attest.yml' // workflow + } +}); + +// signer contains the verified identity claims +// Then: extract in-toto statement from DSSE envelope, compare subject digests +// against files on disk (reuse vlurp's existing hashFile / verifyFiles) +``` + +### CI Identity Detection + +```js +import { CIContextProvider } from '@sigstore/sign'; + +// In GitHub Actions, this reads ACTIONS_ID_TOKEN_REQUEST_URL + +// ACTIONS_ID_TOKEN_REQUEST_TOKEN to get an OIDC token from GitHub's +// token endpoint. The token contains claims: repository, workflow, +// ref, sha, etc. Fulcio extracts these into X.509 certificate extensions. +const provider = new CIContextProvider('sigstore'); +const token = await provider.getToken(); +``` + +## Architecture + +### Data Flow + +``` +UPSTREAM REPO (signing) CONSUMER (verification) +========================== ========================== + +CI workflow runs: vlurp fetch garrytan/gstack + vlurp attest --keyless | + | +--> download tarball + +--> hash SKILL.md files +--> extract + filter + +--> build in-toto statement +--> hash files (lineage) + +--> sigstore.attest() +--> write .vlurp.jsonl + | | | + | +--> Fulcio (get cert) | + | +--> sign DSSE envelope | + | +--> Rekor (log signature) | + | | + +--> commit .vlurp.sigstore | + | + If .vlurp.sigstore exists in repo: + +--> download bundle + +--> sigstore.verify(bundle) + +--> match OIDs against trust policy + +--> compare statement subjects + against fetched file hashes + +--> record attestation status + in .vlurp.jsonl +``` + +### Two Attestation Models + +**Model A: Upstream signs, consumer verifies** (recommended) + +The skill pack author runs `vlurp attest` in their CI. The `.vlurp.sigstore` bundle is committed to the repo. When a consumer runs `vlurp fetch`, vlurp downloads the bundle alongside the content, verifies it against the consumer's trust policy, and records the attestation status in lineage. + +This is the direct port of nono-attest. The identity in the Fulcio certificate is the upstream CI workflow. The consumer trusts specific publishers defined in `.vlurp-trust.json`. + +**Model B: Consumer signs locally** (optional, for private repos / air-gapped) + +The consumer runs `vlurp attest` in their own CI after fetching. The identity is the consumer's CI workflow. This creates a local chain of trust: "I fetched this content from this source at this ref and signed it with my CI identity." Useful when the upstream repo doesn't use Sigstore, but the consumer wants attestation for their own audit trail. + +Model A and Model B can be composed: verify upstream attestation, then re-sign with local identity. + +## Trust Policy + +### `.vlurp-trust.json` + +```json +{ + "version": 1, + "publishers": [ + { + "name": "gstack CI", + "source": "github:garrytan/gstack", + "issuer": "https://token.actions.githubusercontent.com", + "repository": "garrytan/gstack", + "workflow": ".github/workflows/attest.yml", + "ref_pattern": "refs/heads/main" + }, + { + "name": "obra skills", + "source": "github:obra/claude-skills", + "issuer": "https://token.actions.githubusercontent.com", + "repository": "obra/claude-skills" + } + ], + "blocklist": { + "digests": [] + }, + "enforcement": "warn" +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `version` | yes | Schema version (always `1`) | +| `publishers[].name` | yes | Human-readable publisher label | +| `publishers[].source` | yes | vlurp source string to match (e.g. `github:garrytan/gstack`) | +| `publishers[].issuer` | yes | Expected OIDC issuer in Fulcio certificate | +| `publishers[].repository` | no | Expected source repository OID (`1.3.6.1.4.1.57264.1.8`) | +| `publishers[].workflow` | no | Expected workflow URI OID (`1.3.6.1.4.1.57264.1.11`) | +| `publishers[].ref_pattern` | no | Glob pattern for allowed refs OID (`1.3.6.1.4.1.57264.1.9`) | +| `blocklist.digests` | no | SHA-256 digests to reject regardless of valid signature | +| `enforcement` | yes | `"warn"` (log and continue) or `"deny"` (fail fetch) | + +### Enforcement Semantics + +| enforcement | No bundle | Bundle present, invalid | Bundle present, valid | Blocklisted digest | +|-------------|-----------|------------------------|-----------------------|-------------------| +| `warn` | fetch proceeds, log warning | fetch proceeds, log warning | fetch proceeds, log success | fetch **fails** | +| `deny` | fetch **fails** | fetch **fails** | fetch proceeds | fetch **fails** | + +Blocklist always wins. A valid signature on a blocklisted file is still rejected. + +## New Commands + +### `vlurp attest [--keyless] [--key ] [--source ]` + +Sign fetched content using Sigstore. + +```sh +# Keyless (CI): uses OIDC token from environment +vlurp attest --keyless + +# Sign specific source +vlurp attest --keyless --source garrytan/gstack + +# Sign all sources in .vlurpfile +vlurp attest --keyless +``` + +**Behavior:** + +1. Read `.vlurp.jsonl` to get lineage records with file hashes +2. For each source (or specified source): + a. Build in-toto v1 statement with all files as subjects + b. Set `predicateType` to `https://vlurp.dev/attestation/skill-pack/v1` + c. Set predicate to `{ source, ref, fetched_at, vlurp_version }` + d. Call `sigstore.attest(payload, 'application/vnd.in-toto+json', options)` + e. Write bundle to `.vlurp.sigstore` (or `/.vlurp.sigstore`) +3. Commit bundle files if `--commit` is set + +### `vlurp verify --sigstore [--trust-policy ]` + +Enhanced verify that checks both file integrity AND Sigstore attestation. + +```sh +# Verify files + sigstore bundles against default trust policy +vlurp verify --sigstore .claude/skills + +# Verify with explicit trust policy +vlurp verify --sigstore --trust-policy .vlurp-trust.json .claude/skills + +# Existing behavior (file integrity only, unchanged) +vlurp verify .claude/skills +``` + +**Behavior:** + +1. Run existing `verifyFiles()` (SHA-256 integrity check against lineage) +2. If `--sigstore` flag: + a. Load trust policy from `.vlurp-trust.json` (or `--trust-policy` path) + b. For each source with a `.vlurp.sigstore` bundle: + - Load and parse the Sigstore bundle + - Find matching publisher in trust policy by `source` field + - Call `sigstore.verify(bundle, { certificateIssuer, certificateOIDs })` with publisher's OID expectations + - Extract in-toto statement from verified DSSE envelope + - Compare statement subjects against files on disk (SHA-256) + - Check file digests against blocklist + c. Report per-source attestation status + +### `vlurp trust init` + +Generate a starter `.vlurp-trust.json` from existing `.vlurpfile` sources. + +```sh +vlurp trust init +# Creates .vlurp-trust.json with one publisher entry per source, +# pre-filled with GitHub Actions OIDC issuer and source repository. +# User fills in workflow and ref_pattern. +``` + +## New Modules + +### `src/attest.js` -- Core attestation logic + +``` +Exports: + buildInTotoStatement({ source, ref, fetchedAt, files }) + --> { _type, subject, predicateType, predicate } + + signStatement(statement, options) + --> SerializedBundle (calls sigstore.attest) + + verifyBundle(bundle, publisher) + --> { valid, signer, subjects } (calls sigstore.verify, extracts statement) + + matchSubjects(subjects, diskFiles) + --> [{ file, status: 'ok' | 'mismatch' | 'missing' | 'extra' }] +``` + +### `src/trust-policy.js` -- Trust policy operations + +``` +Exports: + loadTrustPolicy(policyPath) + --> parsed and validated policy object + + findPublisher(policy, source) + --> publisher entry or null + + publisherToVerifyOptions(publisher) + --> { certificateIssuer, certificateOIDs } (sigstore VerifyOptions) + + checkBlocklist(policy, digests) + --> [{ digest, blocked: true/false }] + + initTrustPolicy(sources) + --> starter policy JSON string +``` + +### `src/commands/attest.js` -- CLI command (Ink component) + +### `src/commands/trust.js` -- Trust policy management CLI + +## Modified Modules + +### `src/lineage.js` + +Add `attestation` field to lineage records: + +```json +{ + "source": "github:garrytan/gstack", + "ref": "abc1234", + "fetched_at": "2026-03-17T10:00:00Z", + "files": { ... }, + "attestation": { + "status": "verified", + "bundle": ".vlurp.sigstore", + "signer": { + "issuer": "https://token.actions.githubusercontent.com", + "repository": "garrytan/gstack", + "workflow": ".github/workflows/attest.yml", + "ref": "refs/heads/main" + }, + "verified_at": "2026-03-17T10:01:00Z" + } +} +``` + +When no Sigstore bundle is present, `attestation` is `null`. + +### `src/commands/verify.js` + +Add `--sigstore` flag path that calls `verifyBundle()` and `matchSubjects()` after the existing `verifyFiles()` check. + +### `src/commands/fetch.js` + +After fetching, check if a `.vlurp.sigstore` exists in the upstream repo. If it does and a trust policy is configured, verify it inline during fetch. Record attestation status in lineage. + +### `src/index.js` + +Export new public API surface: + +```js +export { buildInTotoStatement, signStatement, verifyBundle, matchSubjects } from './attest.js'; +export { loadTrustPolicy, findPublisher, checkBlocklist } from './trust-policy.js'; +``` + +## In-toto Statement Schema + +```json +{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "gstack/browse/SKILL.md", + "digest": { "sha256": "e3b0c44298fc1c14..." } + }, + { + "name": "gstack/browse/bin/find-browse", + "digest": { "sha256": "7f83b1657ff1fc53..." } + } + ], + "predicateType": "https://vlurp.dev/attestation/skill-pack/v1", + "predicate": { + "source": "github:garrytan/gstack", + "ref": "abc1234def5678901234567890abcdef12345678", + "ref_type": "commit", + "fetched_at": "2026-03-17T10:00:00Z", + "vlurp_version": "2.0.0", + "filters": ["**/*.md", "bin/**"], + "preset": "skills" + } +} +``` + +This follows the [in-toto v1 statement spec](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md). The `subject` array lists every file in the lineage record with its SHA-256 digest. The predicate carries vlurp-specific provenance metadata. + +## Fulcio Certificate OID Reference + +These X.509 extension OIDs are set by Fulcio from the OIDC token claims. Used for publisher matching in trust policy verification. + +| OID | Field | Example | +|-----|-------|---------| +| `1.3.6.1.4.1.57264.1.1` | OIDC issuer | `https://token.actions.githubusercontent.com` | +| `1.3.6.1.4.1.57264.1.8` | Source repository | `https://github.com/garrytan/gstack` | +| `1.3.6.1.4.1.57264.1.9` | Repository ref | `refs/heads/main` | +| `1.3.6.1.4.1.57264.1.11` | Build config (workflow URI) | `https://github.com/garrytan/gstack/.github/workflows/attest.yml@refs/heads/main` | + +Note: `sigstore.verify()` accepts `certificateIssuer` as a top-level option (maps to OID `1.3.6.1.4.1.57264.1.1`) and `certificateOIDs` as a `Record` for the rest. + +## GitHub Action for Upstream Signing + +Skill pack authors add this to their CI (replaces the nono-attest action with pure vlurp + sigstore-js): + +```yaml +name: Attest skill packs +on: + push: + branches: [main] + paths: ['**/SKILL.md', 'skills/**'] + +permissions: + id-token: write # Required for Sigstore keyless signing + contents: write # Required to commit .vlurp.sigstore bundles + +jobs: + attest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npx vlurp attest --keyless --commit +``` + +That's it. `npx vlurp attest --keyless --commit` hashes all SKILL.md files and their supporting files, builds in-toto statements, signs them via Sigstore, and commits the `.vlurp.sigstore` bundles. + +## Implementation Phases + +### Phase 1: Core Attestation (`src/attest.js`) + +- `buildInTotoStatement()` -- construct in-toto v1 statement from lineage record +- `signStatement()` -- call `sigstore.attest()` with the statement payload +- `verifyBundle()` -- call `sigstore.verify()` and extract the in-toto statement +- `matchSubjects()` -- compare in-toto subjects against disk files +- Test suite: unit tests with mock bundles, integration test with real Sigstore staging instance + +Dependencies: `sigstore@^4.1.0` + +### Phase 2: Trust Policy (`src/trust-policy.js`) + +- `loadTrustPolicy()` -- read and validate `.vlurp-trust.json` +- `findPublisher()` -- match a vlurp source to a trust policy publisher +- `publisherToVerifyOptions()` -- convert publisher to sigstore VerifyOptions +- `checkBlocklist()` -- reject blocklisted digests +- `initTrustPolicy()` -- generate starter policy from `.vlurpfile` sources +- Test suite: policy matching, enforcement semantics, blocklist precedence + +No new dependencies. + +### Phase 3: Commands + +- `vlurp attest` -- Ink component, calls `signStatement()` per source +- `vlurp verify --sigstore` -- enhanced verify with Sigstore bundle checking +- `vlurp trust init` -- generates `.vlurp-trust.json` +- Integration with `vlurp fetch`: auto-verify upstream `.vlurp.sigstore` if trust policy exists +- Integration with `vlurp upgrade`: carry attestation status through upgrade flow + +### Phase 4: GitHub Action + +- `.github/actions/vlurp-attest/action.yml` -- composite action wrapping `npx vlurp attest` +- Documentation for skill pack authors +- Example workflows + +## Dependency Budget + +| Package | Size | Why | +|---------|------|-----| +| `sigstore@^4.1.0` | ~2.5MB installed | Signing, verification, TUF root, bundle types | + +Single dependency. The `sigstore` package bundles `@sigstore/sign`, `@sigstore/verify`, `@sigstore/bundle`, `@sigstore/core`, `@sigstore/tuf`, and `@sigstore/protobuf-specs`. This is the official npm Sigstore client maintained by the Sigstore project. + +## Testing Strategy + +### Unit Tests + +- `test/attest.test.js` -- Statement building, subject matching (no network) +- `test/trust-policy.test.js` -- Policy parsing, publisher matching, enforcement logic + +### Integration Tests + +- Sign with Sigstore staging (`fulcio.sigstore.dev` / `rekor.sigstore.dev`) +- Requires `ACTIONS_ID_TOKEN_REQUEST_URL` in CI or manual OIDC token +- Skip in local dev, run in CI with `id-token: write` permission + +### Mock Tests + +- Mock `sigstore.attest()` / `sigstore.verify()` for command-level tests +- Pre-built test bundles for verify path testing + +## Open Questions + +1. **Bundle storage granularity**: One `.vlurp.sigstore` per source directory, or a single `.vlurp.sigstore` at the vlurpfile level containing multiple bundles? Per-source is simpler and matches lineage semantics. Single file means one verification for the entire skill set. + +2. **Upstream bundle discovery**: How does `vlurp fetch` know where to look for the upstream `.vlurp.sigstore`? Convention: look for `.vlurp.sigstore` at the repo root. Or: the upstream repo declares it in a `vlurp.json` manifest. Simplest: just check if the file exists in the fetched tarball. + +3. **Key-based signing**: The plan focuses on keyless (CI OIDC). Should `vlurp attest --key ` support local key-based signing for air-gapped environments? `sigstore-js` supports this but the verification path is different (no Fulcio certificate, just a public key). + +4. **Predicate type URI**: `https://vlurp.dev/attestation/skill-pack/v1` is a placeholder. Should this be a real, resolvable URL with a JSON schema? The in-toto spec recommends it but doesn't require it. + +5. **Rekor log permanence**: Signing uploads to the public Rekor transparency log. This means all vlurp attestations are publicly discoverable. Is this acceptable for private skill packs? If not, `tlogUpload: false` skips Rekor but loses the timestamp proof. diff --git a/doc/fetch.md b/doc/fetch.md new file mode 100644 index 0000000..dfa3cec --- /dev/null +++ b/doc/fetch.md @@ -0,0 +1,127 @@ +# Fetching & Filtering + +vlurp fetches files from GitHub repositories and gists. It downloads tarballs -- not git clones -- so you get files without history, without `.git` directories, and without executing anything on your machine. + +You do not need a forty-nine step installer to get a text file onto your disk. + +## Sources + +vlurp accepts three source formats: + +```sh +# user/repo shorthand +vlurp obra/superpowers + +# Full GitHub URL +vlurp https://github.com/microsoft/amplifier + +# Gist URL +vlurp https://gist.github.com/user/abc123def456 +``` + +All three produce the same result: files on disk. + +## Output directory + +By default, vlurp writes to `.//`. Use `-d` to set a root directory: + +```sh +vlurp obra/superpowers -d .claude/skills +# writes to .claude/skills/obra/superpowers/ +``` + +Use `--as` to flatten the path: + +```sh +vlurp obra/superpowers -d .claude/skills --as superpowers +# writes to .claude/skills/superpowers/ +``` + +`--as` strips the `owner/repo` prefix and all internal path nesting. When you're fetching skill files from deep inside a repo's directory tree, `--as` puts them where you can find them. + +```sh +vlurp whilp/dotfiles -d ./skills --filter ".claude/skills/duckdb-json/**" --as duckdb +# instead of: ./skills/whilp/dotfiles/.claude/skills/duckdb-json/SKILL.md +# you get: ./skills/duckdb/SKILL.md +``` + +## Filtering + +By default, vlurp matches a broad set of agent-relevant files: `.claude/**`, `CLAUDE.md`, `**/*.md`, `agents/**`, `commands/**`. Use `--filter` to override: + +```sh +# Only TypeScript files +vlurp user/repo --filter "*.ts" --filter "*.tsx" + +# Only a specific directory +vlurp user/repo --filter "lib/**" + +# Combine inclusions and exclusions +vlurp user/repo --filter "**/*.md" --filter "!README.md" +``` + +`--filter` accepts glob patterns. Prefix with `!` to exclude. Multiple `--filter` flags are combined. + +## Presets + +Presets are named filter sets for common repo structures: + +``` +claude .claude/**, CLAUDE.md +skills skills/**, SKILL.md, **/*.md +agents agents/**, commands/**, **/*.md +docs **/*.md (excluding boilerplate) +all-md **/*.md +minimal .claude/**, CLAUDE.md only +``` + +```sh +vlurp obra/superpowers --preset skills +vlurp eyaltoledano/claude-task-master --preset claude +``` + +Presets exclude boilerplate by default (README.md, LICENSE, CONTRIBUTING.md, etc.). If you want everything, use `all-md` or explicit `--filter` globs. + +## Auto-detect + +`--auto` inspects the tarball contents and picks the best preset: + +```sh +vlurp microsoft/amplifier --auto +``` + +If the repo has a `.claude/` directory, `--auto` uses `claude`. If it has `skills/` or `SKILL.md` files, it uses `skills`. Otherwise it falls back to the default filter set. + +## Pinning with `--ref` + +`--ref` pins a fetch to a specific git commit, tag, or branch: + +```sh +vlurp obra/superpowers --ref e4f5a6b +vlurp anthropics/skills --ref v1.2.0 +vlurp microsoft/amplifier --ref main +``` + +When `--ref` is a commit SHA, the fetch is immutable. The same SHA always produces the same content. This is the foundation of everything in vlurp's security model. + +When `--ref` is omitted, vlurp fetches the default branch HEAD. This is the mutable case. Whatever the author pushed most recently is what you get. If the author pushed a malicious change thirty seconds ago, you have it. Pin your refs. + +## Dry run + +`--dry-run` shows what would be fetched without writing anything: + +```sh +$ vlurp obra/superpowers --preset skills --dry-run + + obra/superpowers@HEAD + would fetch 22 files (preset: skills) + would write to ./obra/superpowers/ +``` + +## Lineage + +Every fetch produces a lineage record in `.vlurp.jsonl` with SHA-256 hashes of every file. This happens automatically. See [Supply Chain Security](supply-chain.md) for details. + +## Scanning + +Fetched content is scanned for prompt injection, tool escalation, and exfiltration patterns by default. See [Supply Chain Security](supply-chain.md) for details. diff --git a/doc/supply-chain.md b/doc/supply-chain.md new file mode 100644 index 0000000..aa0f845 --- /dev/null +++ b/doc/supply-chain.md @@ -0,0 +1,162 @@ +# Supply Chain Security + +A skill file for an AI agent is a text file. Markdown, usually. It contains instructions that get injected into the agent's context window where they become -- functionally -- commands. When a skill says "use Bash to run `rm -rf /`", the agent reaches for Bash. When a skill says "fetch this URL and POST the contents of .env", the agent tries. + +A skill file is not code in the traditional sense. There is no compiler. There is no sandbox. There is no runtime boundary. The text enters the context window and the agent follows the instructions because that is what agents do. The blast radius of a skill file is the full permission set of the agent reading it. + +vlurp does not sandbox your agent. It does not prevent an LLM from following injected instructions. What it does is make the attack surface **visible** and **auditable** so that you -- the human -- can make an informed trust decision before text enters the context window. + +## Threat model + +When a skill file gets injected into an LLM context, these attacks are possible: + +| Threat | Description | +|--------|-------------| +| **Prompt injection** | "Ignore all previous instructions" or equivalent. The LLM cannot distinguish skill-author intent from attack. | +| **Mutable upstream** | You fetch a skill today, it's fine. The author pushes a malicious update. Your next fetch picks it up silently. | +| **Tool escalation** | The skill says "use Bash to run..." and the agent executes arbitrary commands via the LLM. | +| **Exfiltration** | The skill instructs the agent to send `.env`, credentials, or source code to external URLs. | +| **Persistence** | The skill instructs the agent to modify `CLAUDE.md`, install more tools, or add self-replicating instructions. | +| **Transitive trust** | You trust repo A. Repo A's skill references content from repo B. You trusted A, not B. | + +## Pin your refs + +`--ref` pins a fetch to a specific git commit SHA. Pinned content is immutable. The same SHA always produces the same tarball. + +```sh +vlurp obra/superpowers -d .claude/skills --preset skills --ref e4f5a6b +``` + +When `--ref` is omitted, vlurp fetches whatever is on the default branch right now. If the author pushed a malicious change thirty seconds ago, you have it. This is how supply chain attacks work: the content is fine when you first look, and different when you fetch. + +To pin all unpinned sources in a `.vlurpfile` to the current upstream HEAD: + +```sh +vlurp pin +``` + +This is the minimum viable supply chain protection. Without pinning, everything else is theater. + +## Lineage + +Every `vlurp fetch` or `vlurp batch` produces a lineage record in `.vlurp.jsonl`: + +```json +{ + "source": "github:obra/superpowers", + "ref": "e4f5a6b7c8d9", + "ref_type": "commit", + "fetched_at": "2026-03-15T06:00:00Z", + "filters": [], + "preset": "skills", + "as": null, + "files": { + "skills/tdd/SKILL.md": { "sha256": "e3b0c442...", "size": 3421 }, + "skills/verify/SKILL.md": { "sha256": "7f83b165...", "size": 2891 } + } +} +``` + +Every field matters: + +- `source` and `ref`: where this content came from, exactly +- `fetched_at`: when. So you can correlate with upstream commit history. +- `files`: SHA-256 hash of every file's content, computed after extraction. Not a hash from a remote API. A hash of what is on your disk. + +Lineage records are JSONL -- one JSON object per line. Append-friendly, git-merge-friendly, and grep-friendly. + +The `.vlurp.jsonl` file should be committed alongside the content it describes. When a teammate opens a PR that adds new skill files, the lineage is in the same diff. Review the content AND its provenance. + +## Verify + +`vlurp verify` hashes every file on disk and compares against lineage records: + +``` +$ vlurp verify .claude/skills + + obra/superpowers/skills/tdd/SKILL.md ok sha256:e3b0c442 + obra/superpowers/skills/verify/SKILL.md ok sha256:7f83b165 + duckdb/SKILL.md MODIFIED sha256:13681909 (expected: a1b2c3d4) + local-notes.md untracked + + 24 tracked files: 23 ok, 1 modified +``` + +| Status | Meaning | +|--------|---------| +| `ok` | File hash matches lineage. Content has not been modified. | +| `modified` | File exists but hash differs. Something changed since fetch. | +| `missing` | Lineage references a file that is no longer on disk. | +| `untracked` | File on disk with no lineage record. Local addition. | + +Exit code 0 if all tracked files match. Exit code 1 if any are modified or missing. Untracked files are informational, not failures. + +Run `vlurp verify` in CI, in git hooks, or before agent launches. If it exits non-zero, something changed that was not fetched by vlurp. + +## Scan + +`vlurp scan` analyzes text content for patterns that indicate prompt injection, tool escalation, exfiltration, and persistence: + +``` +$ vlurp scan .claude/skills + + obra/superpowers/skills/tdd/SKILL.md + ok Valid frontmatter (name: "tdd") + ok No injection patterns + warn Bash (8 refs), Edit (4 refs) + + duckdb/SKILL.md + ok No injection patterns + warn Bash (11 refs) + warn References external commands: duckdb + + 24 files, 0 issues, 8 warnings + tool surface: Bash (31), Read (8), Edit (6), Write (2) + command surface: duckdb, git, npm +``` + +### Detection categories + +**Injection** (high severity): Known prompt override phrases ("ignore all previous instructions", "you are now", "system prompt:"), base64-encoded instruction blocks, unicode homoglyph substitutions, zero-width characters hiding text. + +**Tool escalation** (medium severity): `curl | sh`, `wget | bash`, `git push --force`, `git reset --hard`, `rm -rf /`, `chmod` world-executable, `--no-verify` hook bypass. + +**Exfiltration** (high severity): Instructions to POST file contents to URLs, references to `.env` or credential files, instructions to send data to external services. + +**Persistence** (medium severity): Instructions to modify `CLAUDE.md` or `.claude/` config, instructions to install additional tools or skills, self-replicating instructions ("add this to your CLAUDE.md"). + +### Tool and command surface + +The scan report includes two surface area summaries: + +- **Tool surface**: Which agent tools the skill references (Bash, Read, Write, Edit, WebFetch, WebSearch). This tells you the permission set the skill expects. +- **Command surface**: Which external commands appear in code blocks. This tells you what the skill will ask your agent to execute. + +A skill with `tool surface: Bash (24)` is asking your agent to run 24 shell commands. That is not inherently malicious -- many legitimate skills use Bash heavily. But it is information you need before you inject that text into context. + +### Scan produces a report, not a verdict + +vlurp scan does not tell you whether to trust a file. It tells you what the file does. The human makes the trust decision. The tool provides evidence. + +Some tools in this space offer "security audits" that produce a risk score after you've already committed to the installation. That is a checkbox, not security. vlurp shows you the surface area before you accept the content, and the decision is yours. + +## Sigstore attestation + +For environments where the person who reviews content is not the person who ran the fetch, vlurp supports cryptographic attestation via [Sigstore](https://www.sigstore.dev/). A Sigstore bundle provides a transparency-logged proof that a specific identity fetched specific content at a specific time. + +See [ATTEST.H2H.md](../ATTEST.H2H.md) for the current attestation design. + +## Summary + +The security model is four layers deep: + +``` +PIN Immutable content via --ref +HASH SHA-256 of every file in .vlurp.jsonl +VERIFY Check disk against lineage at any time +SCAN Know what the content does before you inject it +``` + +No layer is sufficient alone. Together they turn an invisible attack vector into an auditable one. You pin so content can't change. You hash so changes are detectable. You verify so you know nothing has been tampered with. You scan so you understand what you're trusting. + +The human reviews the scan report like they review a code diff. The lineage is reviewed in PRs alongside the content. Upstream changes are diffed before acceptance. At every step, a person is looking at evidence and making a decision. That is the correct division of labor between a tool and a person. diff --git a/doc/upgrade.md b/doc/upgrade.md new file mode 100644 index 0000000..6588c32 --- /dev/null +++ b/doc/upgrade.md @@ -0,0 +1,279 @@ +# Upgrades & Change Detection + +vlurp fetches, pins, and verifies. But upstream repos change. Authors push new skills, update existing ones, remove deprecated files. You need to know what changed, decide whether to accept it, and apply the update without losing your audit trail. + +Some tools upgrade by running `git reset --hard origin/main` followed by a shell script that compiles binaries and creates symlinks. That is an `npm postinstall` script by another name. The upstream repo author decides what code runs on your machine after every upgrade. vlurp does not execute anything. Upgrading is fetching with better UX. No lifecycle hooks. No setup scripts. No trust escalation. Files are files. + +## Check what's outdated + +`vlurp outdated` compares your pinned refs against the current upstream HEAD: + +``` +$ vlurp outdated .vlurpfile + + obra/superpowers (pinned: e4f5a6b, upstream: 9c8b7a6) + 3 files changed, 1 file added + + whilp/dotfiles (pinned: 6fc9349) + up to date + + microsoft/amplifier (pinned: 4a5b6c7, upstream: d3e2f1a) + 1 file changed + + 2 of 3 sources outdated +``` + +This is a read-only check. It resolves upstream HEAD via the GitHub API and compares against your `.vlurpfile` pins. Nothing is fetched, nothing is modified. + +Exit code 0 if everything is current. Exit code 1 if any source is outdated. + +## Content diff + +`vlurp diff` shows what actually changed in the upstream content: + +``` +$ vlurp diff obra/superpowers -d .claude/skills + + --- obra/superpowers/skills/tdd/SKILL.md (local, e4f5a6b) + +++ obra/superpowers/skills/tdd/SKILL.md (upstream, 9c8b7a6) + @@ -42,6 +42,12 @@ + ## Running tests + + + +### Parallel execution + + + +Run tests with maximum parallelism: + +```bash + +npm test -- --parallel + +``` +``` + +This fetches the upstream tarball to a temp directory, applies your filters, and diffs against your local files. Temp content is discarded after diffing. Nothing is modified. + +This is `terraform plan` for your agent context. You see what would change before you accept it. + +## Upgrade + +`vlurp upgrade` is the atomic operation that combines outdated checking, fetching, cataloging, and vlurpfile rewriting: + +```sh +# Upgrade all outdated sources +vlurp upgrade + +# Upgrade a specific source +vlurp upgrade obra/superpowers + +# Preview without modifying anything +vlurp upgrade --dry-run + +# Explicit vlurpfile path +vlurp upgrade --vlurpfile .vlurpfile.skills +``` + +### What upgrade does + +1. **Reads** `.vlurpfile` (intent) and `.vlurp.jsonl` (current state) +2. **Resolves** upstream HEAD for each source via GitHub API +3. **Compares** against pinned refs to find outdated sources +4. **Fetches** new content through the standard pipeline (download, extract, filter, hash, scan) +5. **Catalogs** new content and diffs against the previous catalog +6. **Rewrites** `.vlurpfile` with updated `--ref` values +7. **Outputs** upgrade summary with catalog diff + +The fetch pipeline is the same one used by `vlurp fetch` and `vlurp batch`. Upgraded content gets hashed, lineage gets written, scans run. No shortcuts. + +### Dry run + +`vlurp upgrade --dry-run` performs steps 1-3, fetches upstream to a temp directory, catalogs and diffs, then discards everything. No files are modified. No `.vlurpfile` changes. No `.vlurp.jsonl` updates. You see exactly what would change. + +``` +$ vlurp upgrade --dry-run + + obra/superpowers e4f5a6b -> 9c8b7a6 + + tdd 1.0.0 -> 1.0.1 + tools: (unchanged) + files: (unchanged) + + debug (new skill) + tools: Bash, Read, Grep + commands: node, npm + + microsoft/amplifier 4a5b6c7 -> d3e2f1a + + CLAUDE.md (changed) + tools: +WebFetch + files: +docs/agent-patterns.md + + 2 sources, 8 skills (1 new, 2 changed, 5 unchanged) +``` + +### Vlurpfile rewriting + +`vlurp upgrade` rewrites the `.vlurpfile` to update `--ref` values. If a source had no `--ref`, one is added. Comments, blank lines, and argument ordering are preserved. + +Before: + +```sh +# Core agent patterns +vlurp obra/superpowers -d .claude/skills --preset skills --ref e4f5a6b + +# Multi-agent framework +vlurp microsoft/amplifier -d .claude/skills --filter "**/*.md" +``` + +After `vlurp upgrade`: + +```sh +# Core agent patterns +vlurp obra/superpowers -d .claude/skills --preset skills --ref 9c8b7a6 + +# Multi-agent framework +vlurp microsoft/amplifier -d .claude/skills --filter "**/*.md" --ref d3e2f1a +``` + +The superpowers entry's ref was updated. The amplifier entry gained a ref -- it was previously unpinned, now pinned to the version that was fetched. + +## Skill catalog + +`vlurp catalog` reads fetched content and produces `catalog.json` -- a derived index of every skill with its metadata: + +```sh +vlurp catalog .claude/skills +``` + +```json +{ + "generated_at": "2026-03-15T06:00:00Z", + "vlurp_version": "2.0.0", + "skills": { + "tdd": { + "source": "github:obra/superpowers", + "ref": "9c8b7a6", + "path": "obra/superpowers/skills/tdd/SKILL.md", + "name": "tdd", + "version": "1.0.1", + "description": "Test-driven development workflow", + "tool_surface": ["Bash", "Read", "Edit"], + "command_surface": ["npm", "node"], + "supporting_files": ["examples.md"], + "fetched_at": "2026-03-15T06:00:00Z" + } + } +} +``` + +The catalog is **derived**, not authored. It is regenerated from content on disk by vlurp's own code. No upstream-authored scripts execute. No template engines process files. The catalog reflects what is actually on disk, not what a remote API claims. + +Fields are extracted from YAML frontmatter and content analysis: + +- `name`, `description`, `version`: from frontmatter +- `tool_surface`: agent tools referenced in the content (Bash, Read, Edit, etc.) +- `command_surface`: external commands found in code blocks +- `supporting_files`: non-SKILL.md files in the same directory + +## Catalog diff + +`vlurp catalog-diff` compares two catalog snapshots and reports what changed at the skill level: + +```sh +# Compare catalog.prev.json vs catalog.json (default) +vlurp catalog-diff + +# Explicit paths +vlurp catalog-diff old-catalog.json new-catalog.json + +# Machine-readable output +vlurp catalog-diff --json +``` + +``` +obra/superpowers e4f5a6b -> 9c8b7a6 + + tdd 1.0.0 -> 1.0.1 + tools: (unchanged) + commands: (unchanged) + files: (unchanged) + + debug (new skill) + tools: Bash, Read, Grep + commands: node, npm + + verify 1.0.0 -> 1.0.0 + tools: (unchanged) + commands: (unchanged) + files: +checklist.md + + old-skill (removed) + +Summary: 1 source, 6 skills (1 new, 1 removed, 1 changed, 3 unchanged) +``` + +### Diff categories + +| Category | Meaning | +|----------|---------| +| **new** | Present in new catalog, absent in old | +| **removed** | Present in old catalog, absent in new | +| **changed** | Present in both, any field differs | +| **unchanged** | Present in both, all fields identical | + +For changed skills, the diff reports which fields changed: + +- `version`: old -> new +- `tool_surface`: +added, -removed +- `command_surface`: +added, -removed +- `supporting_files`: +added, -removed + +### Machine-readable output + +`vlurp catalog-diff --json` produces output consumable by CI pipelines, Claude Code hooks, or any automation: + +```json +{ + "skills": { + "tdd": { + "status": "changed", + "version": { "old": "1.0.0", "new": "1.0.1" }, + "tool_surface": { "added": [], "removed": [] }, + "supporting_files": { "added": [], "removed": [] } + }, + "debug": { + "status": "new", + "tool_surface": { "added": ["Bash", "Read", "Grep"], "removed": [] } + } + }, + "summary": { "total": 6, "new": 1, "removed": 1, "changed": 1, "unchanged": 3 } +} +``` + +### Why catalogs, not changelogs + +Some tools read `CHANGELOG.md` and ask an LLM to summarize it. This has three problems: changelogs are optional, LLM summaries are lossy, and prose is not machine-readable. + +Catalog diffs are none of these things. If the SKILL.md files exist, the catalog exists -- no author discipline required. A new tool in `tool_surface` shows up as `+Bash`, not as a paragraph that might bury the lead. And `--json` output pipes directly into any CI gate. + +The catalog is what a skill pack IS -- its capabilities, surfaces, and structure. The catalog diff is what CHANGED. That is a more useful changelog than any human-written prose or any LLM summary of human-written prose. + +## Workflow + +A typical upgrade workflow: + +```sh +# 1. See what's outdated +vlurp outdated .vlurpfile + +# 2. Preview the changes +vlurp upgrade --dry-run + +# 3. Apply the upgrade +vlurp upgrade + +# 4. Verify the result +vlurp verify .claude/skills + +# 5. Review and commit +git diff .vlurpfile .vlurp.jsonl catalog.json +git add -A && git commit -m "chore: upgrade skill packs" +``` + +Each step produces evidence that the next step can verify. The dry run shows what would change. The upgrade applies it. The verify confirms integrity. The git diff shows the reviewer exactly what happened. At every step, a human is looking at output and making a decision. diff --git a/doc/vlurpfile.md b/doc/vlurpfile.md new file mode 100644 index 0000000..c5ad9bb --- /dev/null +++ b/doc/vlurpfile.md @@ -0,0 +1,123 @@ +# The .vlurpfile + +A `.vlurpfile` is a manifest of fetch commands. One vlurp invocation per line, comments with `#`. It is a shell script you can also run manually. It is human-readable, human-editable, and greppable. + +It is not a `package.json`. There is no dependency resolution. There are no lifecycle hooks. There is no semver. Each line is an independent fetch operation. + +## Format + +```sh +# .vlurpfile -- Agent skill sources, reviewed and pinned + +# obra/superpowers -- Core agent patterns +vlurp obra/superpowers -d .claude/skills --preset skills --ref e4f5a6b + +# whilp/dotfiles -- DuckDB skills +vlurp whilp/dotfiles -d .claude/skills --filter ".claude/skills/duckdb-json/**" --as duckdb --ref 6fc9349 + +# eyaltoledano/claude-task-master -- Task management +vlurp eyaltoledano/claude-task-master -d .claude/skills --preset claude --ref 29e67fa +``` + +Rules: + +- Lines starting with `#` are comments +- Blank lines are ignored +- Each command line is a complete `vlurp` invocation +- Arguments are parsed the same way as the CLI +- You can run any line by itself: copy it, paste it into your terminal + +## Batch processing + +`vlurp batch` processes an entire `.vlurpfile`: + +```sh +vlurp batch .vlurpfile +``` + +Each line executes in sequence. Progress is reported per-source. If a source fails, vlurp continues with the remaining sources and reports failures at the end. + +Preview what would happen without writing anything: + +```sh +vlurp batch .vlurpfile --dry-run +``` + +Force overwrite existing content: + +```sh +vlurp batch .vlurpfile --force +``` + +## File naming conventions + +You can name your vlurpfile anything. Common conventions: + +``` +.vlurpfile Default +.vlurpfile.skills Skills only +.vlurpfile.claude Claude Code configuration +.vlurpfile.team Shared team configuration +``` + +vlurp looks for `.vlurpfile` by default when running `vlurp upgrade` or `vlurp pin` without an explicit path. + +## Intent vs reality + +The `.vlurpfile` records **intent**: what you want to fetch, from where, with what filters and pins. + +The `.vlurp.jsonl` records **reality**: what was actually fetched, when, with what SHA-256 hashes. + +``` +.vlurpfile "fetch obra/superpowers at ref e4f5a6b with preset skills" +.vlurp.jsonl "fetched obra/superpowers at e4f5a6b on 2026-03-15, got 22 files, here are the hashes" +``` + +Both files are committed to git. Both are reviewed in PRs. The `.vlurpfile` is reviewed for intent ("should we add this source?"). The `.vlurp.jsonl` is reviewed for integrity ("did the fetch produce what we expected?"). They answer different questions. + +Some tools use a lock file that stores a hash from the GitHub Trees API -- not a hash of the content that was actually written to disk. That is a receipt for what the server said, not proof of what you have. vlurp hashes the files after extraction. The `.vlurp.jsonl` describes reality. + +## Editing + +You can edit a `.vlurpfile` with any text editor. It's a text file. + +To pin all unpinned sources to the current upstream HEAD: + +```sh +vlurp pin +``` + +To upgrade all sources to the latest upstream and update their `--ref` values: + +```sh +vlurp upgrade +``` + +Both commands rewrite the `.vlurpfile` in place, preserving comments, blank lines, and argument ordering. See [Upgrades & Change Detection](upgrade.md) for details. + +## Example: real-world vlurpfile + +```sh +# .vlurpfile -- Production agent skills +# Last reviewed: 2026-03-15 by @indexzero +# +# Review checklist: +# 1. vlurp batch .vlurpfile --dry-run +# 2. vlurp scan .claude/skills +# 3. git diff .vlurp.jsonl + +# Official Anthropic skills +vlurp anthropics/skills -d .claude/skills --filter "skills/**" --filter "template/**" --ref b7c8d9e + +# Core agent patterns (obra) +vlurp obra/superpowers -d .claude/skills --filter "skills/**" --filter ".claude/**" --ref e4f5a6b + +# DuckDB skills from assorted dotfiles +vlurp whilp/dotfiles -d .claude/skills --filter ".claude/skills/duckdb-json/**" --as duckdb --ref 6fc9349 +vlurp PovertyAction/ipa-research-data-science-hub -d .claude/skills --filter ".claude/skills/duckdb/**" --as duckdb-ipa + +# Microsoft Amplifier -- multi-agent framework +vlurp microsoft/amplifier -d .claude/skills --filter "**/*.md" --ref 4a5b6c7 +``` + +The comment block at the top is a review checklist. When this file changes in a PR, the reviewer runs those three commands. The `.vlurpfile` is the table of contents. The `.vlurp.jsonl` diff in the same PR is the proof that the content matches.