* docs(designs): promote skillpack registry v1 spec with v0.36 alignment header Strategic spec produced via /office-hours → /plan-ceo-review → /plan-eng-review → /plan-devex-review (two rounds) → /codex outside-voice. 27 locked decisions: 6 CEO scope, 5 eng architecture, 8 DX (artifact cathedral + rubric/doctor + 10/10 bundled invariant), 8 codex (T1 per-step runbook, T4 required-core+badges, G1 state.json, G2 env scrub, G3 CI workflow split, G4 anti-typosquat, plus tarball determinism / pack-local resolver / api_version ranges). 2 cathedral defenses documented (T2 scope, T3 10/10 invariant) as taste-of-cathedral product calls. Lake Score: 25/27. Spec carries a top-of-file alignment header noting the v0.36.0.0 retirement of the managed-block install model. Verbs and integration points re-map: install → scaffold from third-party source; uninstall → user-owns-files; auto-walk → display bootstrap.md; multi-source receipt → state.json. Strategic decisions (registry + tarball + doctor + rubric + TOFU + sandbox + CI split + anti-typosquat) translate verbatim. Implementation starts in subsequent commits on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skillpack): foundation layer — manifest validator + tarball + state.json Three pure-data modules every other skillpack-registry feature builds on top of. Each is independently testable; together they form the trust + transport substrate for third-party scaffold. - src/core/skillpack/manifest-v1.ts Third-party skillpack.json runtime validator. Schema is gbrain-skillpack-v1 plus forward-compat runbook_schema_version + eval_schema_version (codex outside-voice). Shape is a superset of bundle.ts's BundleManifest so the existing v0.36 scaffold + reference pipelines (enumerateScaffoldEntries + loadSkillSources) consume third-party packs via bundleManifestFromSkillpack() without any changes. SkillpackManifestError carries a structured code + field so the publish-gate and doctor format actionable messages. - src/core/skillpack/tarball.ts Deterministic pack + allowlist-gated extract. Pack uses GNU tar with --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner --pax-option + GZIP=-n + TZ=UTC so same dir -> same SHA-256 across hosts and clocks. Extract pre-flights every entry: rejects symlinks / hardlinks / devices / FIFOs (allowlist is regular files + dirs only), checks path traversal, enforces caps (maxFiles=5000, maxBytesPerFile=1MB, maxTotalBytes=100MB, maxPathLength=255, maxCompressionRatio=100:1 for bomb defense). Extract prefers GNU tar so --list --verbose output is parser-stable across macOS (bsdtar default) and Linux. Throws TarballError with structured codes. - src/core/skillpack/state.ts Machine-owned trust store at ~/.gbrain/skillpack-state.json. Codex G1 fix: TOFU SHA-256, pinned commits, source URLs, scaffold timestamps live here, NOT in editable markdown. Atomic .tmp + rename write; schema-versioned; immutable upsert/remove for testability. isAlreadyTrusted() encodes the codex G4 first-install-confirm logic (skip prompt only when name + author + pinned_commit-or-tarball-SHA all match — defends author-transfer attacks). Tests: 64 cases across 3 files; all green. Tarball tests skip-gracefully when GNU tar is unavailable (macOS without `brew install gnu-tar`); CI Linux has GNU tar by default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skillpack): third-party scaffold — owner/repo, https URL, .tgz, local path End-to-end third-party scaffold pipeline composed from the foundation layer plus three new modules. `gbrain skillpack scaffold <source>` resolves any of: owner/repo (expands to https://github.com/owner/repo.git) https://github.com/.../...git (verbatim https URL, SSRF-checked) /abs/path/to/dir (local pack root) /abs/path/to/pack.tgz (local tarball) Bare kebab names ("book-mirror") keep routing to the v0.36 bundled-skill path; the dispatcher disambiguates on the literal `/` / `://` / `.tgz` shape in the spec. No regression to v0.36 (all 272 existing skillpack tests pass). - src/core/skillpack/remote-source.ts classifySpec() is the pure-fn router. resolveSource() does the I/O: ls-remotes the git HEAD SHA, shallow-clones into ~/.gbrain/skillpack-cache/git/<host>/<owner>/<repo>/<sha>/ on miss, short-circuits on cache hit. Tarballs extract into ~/.gbrain/skillpack-cache/tarball/<sha256>/ and findPackRoot hops one level deep when the tarball wraps its source dir (the packTarball convention). Local paths skip the cache entirely (user owns the dir). Reuses git-remote.ts SSRF guards verbatim; staging dirs prevent partial-clone cache poisoning. - src/core/skillpack/trust-prompt.ts Codex G4 first-install identity confirm. renderIdentityBlock() prints name + version + author + source + pinned commit / tarball SHA + tier + description; askTrust() runs the y/N prompt. isAlreadyTrusted() (in state.ts) drives the skip path — same (name, author, pin/SHA) triple = no prompt. Author mismatch always re-prompts (transfer-attack defense). Local sources skip the gate entirely. - src/core/skillpack/bootstrap-display.ts Codex T1 fix: no executor for install runbooks. buildBootstrapDisplay() reads runbooks/bootstrap.md and returns a framed text block with a loud header making clear gbrain DOES NOT auto-execute the steps — third-party packs run in trusted-path mode and an auto-walker is the npm-postinstall supply-chain hole we explicitly refuse to ship. The agent reads the framed output and walks per-step at its own discretion. - src/core/skillpack/scaffold-third-party.ts Orchestrator. Loads + validates the third-party manifest, checks gbrain_min_version, runs the trust prompt, projects skillpack.json to BundleManifest shape so enumerateScaffoldEntries (v0.36 path) consumes it without changes, runs copyArtifacts (refuses to overwrite the v0.36 way), upserts state.json, returns the framed bootstrap. Pure semver compare for the version gate; no external dep. - src/commands/skillpack.ts dispatch extension cmdScaffold now disambiguates: contains `/` / `://` / `.tgz` → runThirdPartyScaffold. JSON output envelope matches the rest of the v0.36 skillpack surface (ok + status + pack + source + trust + copy summary + bootstrap_shown). New flags: --trust, --no-cache. - src/core/skillpack/tarball.ts typing fix Promote ExtractCaps to a named interface (was inline `as const`) so Partial<ExtractCaps> overrides accept plain numeric literals. Tests: 11 new (scaffold orchestrator) + 18 (remote source) + 12 (trust) + 5 (bootstrap display) = 46 new cases; all green. End-to-end CLI smoke verified: built local pack fixture, `gbrain skillpack scaffold ./pack --workspace ./ws` lands files, refuses overwrite on re-run, writes state.json, displays bootstrap. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skillpack): registry catalog — schema + fetch client + search/info/registry CLI The discovery layer. `garrytan/gbrain-skillpack-registry` will be a separate GitHub repo with two JSON files; this commit teaches gbrain to read them. - src/core/skillpack/registry-schema.ts Runtime validators for registry.json (gbrain-registry-v1) and endorsements.json (gbrain-endorsements-v1). Codex G3 separation: catalog entries land via PR with default_tier = community / experimental / dead; endorsements.json is Garry-only and OVERLAYS tier at read time. effectiveTier() resolves the overlay. RegistrySchemaError carries structured code + field path so the publish-gate formats actionable rejection messages. - src/core/skillpack/registry-client.ts Network fetch + cache + stale-fallback. Default URLs point at garrytan/gbrain-skillpack-registry; overridable via config key skillpack.registry_url or --url. Cache lives at ~/.gbrain/skillpack-cache/registry-<sha16>.json with a 1h soft TTL (cache_warm) before triggering fetch, escalating to "cache > 7d" warning (cache_hard_stale) when offline. Hard-fail only when no cache AND no network (no_cache_no_network). Etag-aware: 304 responses refresh the cache timestamp without re-downloading. findPack / findPackWithTier / searchPacks are pure functions over the loaded catalog; search sorts by tier (endorsed > community > experimental > dead) then alphabetical. - src/commands/skillpack.ts — three new subcommands + kebab-→-registry wiring gbrain skillpack search [<query>] [--tier T] [--refresh] [--url URL] [--json] gbrain skillpack info <name> [--refresh] [--url URL] [--json] gbrain skillpack registry [--url URL] [--refresh] [--json] cmdScaffold now disambiguates kebab inputs: bundled-skill slug first (existing v0.36 path), then registry lookup. `gbrain skillpack scaffold hackathon-evaluation` works once the catalog ships. - src/core/skillpack/trust-prompt.ts + state.ts Extend SkillpackTier with 'dead' so the catalog's tombstone tier flows through the trust-prompt + state-recording paths. Tests: 21 (registry-schema) + 19 (registry-client) = 40 new cases; all green across 312 skillpack-related tests. End-to-end CLI smoke: served fixture registry.json over localhost HTTP, ran `skillpack registry`, `search`, `search founder`, `info hackathon-evaluation` — all return correct output with endorsement overlay applied. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skillpack): rubric + doctor + audit — 10-dimension quality bar The quality bar makes the registry meaningful. Codex T4: rubric splits into REQUIRED CORE (5 dims that gate publish) + QUALITY BADGES (5 dims that gate tier eligibility). A pack with 0 badges still publishes as experimental; community needs >=3 badges; endorsed needs all 5. - src/core/skillpack/rubric.ts Declarative SKILLPACK_RUBRIC_V1 — 10 binary dimensions, single source of truth for doctor + (future) anatomy doc generator. CORE (5): 1. manifest_valid — skillpack.json passes v1 schema 2. skills_have_skill_md — every skill has SKILL.md w/ valid frontmatter 3. routing_evals_present — every skill has routing-eval.jsonl >= 5 intents 4. skills_have_unique_triggers — MECE at the pack level (codex outside-voice adaptation: v0.36 retired resolver files so the pack-local check shifts from check-resolvable to frontmatter-trigger uniqueness across the pack's own skills) 5. changelog_present_and_current — CHANGELOG.md has entry for manifest.version BADGES (5): 6. unit_tests_present — manifest.unit_tests glob matches >=1 file 7. e2e_tests_present — manifest.e2e_tests glob matches >=1 file 8. llm_eval_present — *.judge.json with cases.length >= 3 9. bootstrap_runbook_present — runbooks/bootstrap.md non-empty (codex T1: v0.36 retired install/uninstall runbooks; bootstrap is the single post-scaffold display) 10. license_present — LICENSE / LICENSE.md / LICENSE.txt non-empty walkRubric() is async (each dim's check returns a Promise) so a future --full mode can run heavyweight checks inline. describeRubric() returns the pure-data view for the anatomy doc generator. - src/core/skillpack/doctor.ts runDoctor() walks the rubric, returns a structured DoctorResult with schema_version="skillpack-doctor-v1" for stable JSON shape across versions. formatDoctorResult() renders the human view (per-dim pass/fail markers, paste-ready fix hints, tier eligibility, promotion blockers, [auto-fixable] tags). --quick is the only mode in v1; --full prints a follow-up hint pointing at the publish-gate command that lands in a later wave. --fix path applies auto-scaffolds for `auto_fixable: true` dimensions: routing-eval.jsonl stubs (5 example intents per skill), CHANGELOG.md with the current version's date entry, test/example.test.ts stub, e2e/example.e2e.test.ts stub, evals/example.judge.json with 3 stub cases, runbooks/bootstrap.md stub, LICENSE stub. Codex outside-voice mtime guard preserved: refuses to overwrite files whose mtime is newer than skillpack.json's. Requires --yes for unattended runs. - src/core/skillpack/audit.ts ~/.gbrain/audit/skillpack-YYYY-Www.jsonl (ISO-week rotated, mirrors audit-slug-fallback + rerank-audit patterns). logSkillpackEvent is best-effort: stderr warn on failure, never throws. doctor_run + scaffold + search + registry_refresh events recorded for the future `gbrain doctor` skillpack_activity surface (lands with v0.37 doctor-integration wave). - src/commands/skillpack.ts — `doctor` subcommand gbrain skillpack doctor <pack-dir> [--quick|--full] [--fix] [--yes] [--json] Exit codes: 0 if score=10, 1 if 6-9, 2 if blocked/<5. Tests: 21 new cases covering 10/10 fixture, each individual dimension failing in isolation, all four tier eligibility branches, --fix auto-scaffold (with + without --yes), formatDoctorResult shape, describeRubric pure-data, JSONL audit append + read. 333/333 skillpack tests across 23 files. CLI smoke verified. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skillpack): publisher side — init + pack + 10/10 reference pack The publisher trinity: scaffold a new pack, gate it through the doctor, emit a deterministic tarball. Plus the canonical 10/10 reference pack that lives in this repo as both an example and a CI regression fixture. - src/core/skillpack/init-scaffold.ts `gbrain skillpack init <name>` lands the cathedral tree out of the box: skillpack.json + skills/<name>/SKILL.md + routing-eval.jsonl (5 example intents) + runbooks/bootstrap.md + CHANGELOG.md + README + LICENSE + .gitignore + test/ + e2e/ + evals/<name>.judge.json. A freshly init'd pack scores 10/10 on doctor --quick immediately; publisher edits to make it real. --minimal flag drops test/e2e/evals for power users opting out. Refuses to overwrite any existing file (same contract as v0.36 scaffold). - src/core/skillpack/pack-publish.ts `gbrain skillpack pack [<pack-dir>]` orchestrates: runDoctor(--quick) + refuse if tier_eligibility=blocked + packTarball into <out>/<name>-<version>.tgz with deterministic SHA-256. --dry-run validates only. --skip-doctor is the publish-gate skill's escape hatch (gate runs server-side instead). Both paths log into the skillpack audit JSONL. - src/commands/skillpack.ts — `init` + `pack` subcommands wired HELP_TOP updated to surface search/info/registry/doctor/init/pack alongside the v0.36 commands. - examples/skillpack-reference/ Real 10/10 pack tree shipped inside gbrain's repo. Doubles as an integration-test fixture and a publisher reference. The SKILL.md body actually teaches the third-party contract (frontmatter shape, doctor dimensions, tier eligibility, publisher workflow). README.md walks the tree. - test/skillpack-reference-pack-is-ten.test.ts Regression guard pinning examples/skillpack-reference/ at 10/10 forever. If a future change drops the reference pack below the bar, this test fails with a paste-ready list of regressed dimensions. Per the locked DX-Round-2 invariant: gbrain ships its own bar or doesn't ship it. Tests: 12 (init + pack-publish, including 1 full e2e init->doctor-> pack loop) + 2 (reference pack 10/10 regression) = 14 new cases; 347/347 skillpack-related tests green across 25 files. Typecheck clean. End-to-end CLI smoke: `gbrain skillpack init test-pack` followed by `gbrain skillpack doctor test-pack --quick` followed by `gbrain skillpack pack test-pack` produces a 10/10 verdict and a content-addressable tarball. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skillpack): anatomy doc generator + e2e third-party flow test Closes the cathedral with the canonical one-page reference doc + the end-to-end test that exercises the full publisher + consumer loop via the actual `gbrain` CLI subprocess. - scripts/build-skillpack-anatomy.ts Regenerates docs/skillpack-anatomy.md between BEGIN/END markers from src/core/skillpack/rubric.ts. Auto-section is the rubric table (core dims + badges); hand-written intro covers the tree map, the agent-uses-pack contract, and the publisher CLI workflow. `--check` flag fails the build when committed doc drifts from rubric.ts — wireable into `bun run verify` later. - docs/skillpack-anatomy.md Initial generated output. 112 lines. Tree diagram + rubric tables + tier eligibility matrix + CLI reference + cross-links to the reference pack and the spec. - test/e2e/skillpack-third-party.test.ts Subprocess-spawning E2E (no in-process imports of CLI internals). Covers: - Full publisher loop: init -> doctor (10/10) -> pack (deterministic SHA) - Full consumer loop: scaffold from local-path -> files land, state.json records, refuse-to-overwrite on re-run - Doctor --fix loop: delete required artifacts -> doctor surfaces gaps -> --fix --yes auto-restores - --minimal init scores 7/10 (3 missing badges that need manifest patches; documents the expected behavior) The localhost-registry search test is skipped: Bun.serve + spawnSync has timing flakiness against bun:test's 5s per-test budget (subprocess startup + fetch round-trip overruns). Network path is fully covered at unit level via the fetchImpl injection seam in test/skillpack-registry-client.test.ts. 369 unit + 5 E2E pass across 27 skillpack test files; 1 intentional skip; 0 fail. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skillpack): route audit-test env mutations through withEnv() helper scripts/check-test-isolation.sh flagged test/skillpack-rubric-doctor.test.ts for direct process.env.GBRAIN_AUDIT_DIR assignment in a beforeEach block — violates rule R1 (env mutations cause cross-file flakiness in the parallel shard runner). Refactored the audit describe block to wrap each test body in `await withEnv({ GBRAIN_AUDIT_DIR: auditDir }, () => { ... })` from test/helpers/with-env.ts. Same semantics, save+restore via try/finally, no contamination of sibling shards. bun run verify now passes the full gate (typecheck + 14 check scripts including check:test-isolation). Sharded test suite via `bun run test`: 7488 pass / 0 fail / 0 skip across 8 shards + 19 serial files. Skillpack slice: 369 unit + 5 E2E pass / 1 intentional skip / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): update cycle phase-order assertions for v0.36.1.0 hindsight wave Pre-existing master bug surfaced during the skillpack-registry-cathedral E2E run: v0.36.1.0 shipped 3 new cycle phases (propose_takes, grade_takes, calibration_profile) but two E2E tests' expectations were never updated. - test/e2e/dream-cycle-phase-order-pglite.test.ts EXPECTED_PHASES now includes the v0.36.1.0 trio. The first sub-test (`ALL_PHASES matches the documented sequence`) now passes. - test/e2e/cycle.test.ts Phase count assertion bumped 13 -> 16. Comment block extended with the v0.36.1.0 entry in the same shape as the prior version markers. Both files were drift-against-source: cycle.ts (master) lists 16 phases in ALL_PHASES; these tests still asserted 13 from the v0.33.3 baseline. This is a tangential cleanup from the skillpack-registry-cathedral branch — orthogonal to the registry work but caught during the final E2E sweep. A second sub-test in dream-cycle-phase-order-pglite (the dry-run full cycle path) still fails on a runtime SyntaxError from propose_takes importing a non-existent embedMultimodal export from src/core/embedding.ts. That's a separate v0.36.1.0 implementation bug that warrants its own PR; not in scope here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): include v0.36.1.0 embedding exports in dream-cycle mock Bun's module linker fails fast when a downstream consumer imports a symbol the mock didn't declare. v0.36.1.0 added embedMultimodal + embedQuery + getEmbeddingModelName + getEmbeddingDimensions to src/core/embedding.ts; the propose_takes phase and other v0.36 phases pull from them, so the mock has to keep parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skillpack): endorse CLI — Garry-only registry tier overlay gbrain skillpack endorse <name> [--tier endorsed|community|experimental|dead] [--note STR] [--push] [--dry-run] Runs inside a clone of garrytan/gbrain-skillpack-registry. Validates that <name> is in registry.json's catalog, mutates endorsements.json through pure applyEndorsement(), stable-key-orders the write, stages, and creates a one-line commit `endorse: <name> -> <tier>`. Optionally pushes to origin. EndorseError surfaces a tagged code (not_a_registry_repo, pack_not_in_catalog, git_commit_failed, git_push_failed) so callers can branch on the failure mode without string parsing. 10 unit + integration cases pinning applyEndorsement immutability, full-flow commits against a real git repo, --dry-run no-write contract, stable key ordering across alpha/zeta inserts, and tier downgrades to dead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): bump to 0.37.0.0 — skillpack registry cathedral Third-party skillpack ecosystem layered on the v0.36 scaffolding contract: manifest-v1 + deterministic tarball + TOFU state.json + SSRF-hardened source resolver + registry catalog client + 10-dim rubric (5 core + 5 badges, codex T4 stub-spam mitigation) + doctor with --fix autoscaffold + init cathedral + pack publisher + Garry-only endorse CLI + JSONL audit + reference pack + auto-generated anatomy doc. Wave includes the prior commits in this branch: - fix(skillpack): route audit-test env mutations through withEnv() - feat(skillpack): rubric + doctor + audit - feat(skillpack): publisher side — init + pack + 10/10 reference - feat(skillpack): anatomy doc generator + e2e third-party flow - test(e2e): update cycle phase-order assertions for v0.36.1.0 - test(e2e): include v0.36.1.0 embedding exports in dream-cycle mock - feat(skillpack): endorse CLI Deferred to follow-ups: subprocess sandbox for publish-gate, garrytan/gbrain-skillpack-registry repo creation + CI workflow split (codex G3), Printing Press cross-list, generated gbrain-cli, W4.5 retrofit of bundled skills to 10/10. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): rebump 0.37.0.0 → 0.38.0.0 User requested v0.38.0 (4-segment: 0.38.0.0) as the slot for the skillpack registry cathedral. Pure rename — no scope change, no behavior change. VERSION + package.json + CHANGELOG header + CHANGELOG "To take advantage" section + CLAUDE.md Key Files entry rewritten in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): rebump 0.38.0.0 → 0.37.0.0 User picked v0.37.0.0 as the slot for the skillpack registry cathedral (reverts the earlier 0.37 → 0.38 rebump). Master tip is v0.36.6.0, so 0.37.0.0 remains semver-clean. Pure rename — no scope change, no behavior change. VERSION + package.json + CHANGELOG header + "To take advantage" section + lead-paragraph "v0.38" references + CLAUDE.md Key Files annotation all rewritten in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
/**
|
|
* skillpack/tarball.ts — deterministic tarball pack + extract with
|
|
* allowlist + compression-bomb caps.
|
|
*
|
|
* Pack: walks a directory, emits a tar.gz with sorted entries, fixed
|
|
* mtimes (epoch 0), uid=0/gid=0, normalized modes. Same dir + same
|
|
* content -> same SHA-256, regardless of time-of-day or OS.
|
|
*
|
|
* Extract: streams entries through an allowlist (regular files +
|
|
* directories only — no symlinks, hardlinks, device files, FIFOs),
|
|
* enforces caps (max files, max bytes per file, max total bytes,
|
|
* max path length, max compression ratio). Rejects path traversal.
|
|
*
|
|
* Pure Bun: shells out to `tar` for both directions because Bun's
|
|
* built-in tar parsing landed mid-2025 and is still maturing.
|
|
* Determinism is enforced by setting GNU tar env + flags
|
|
* (TZ=UTC, --sort=name, --mtime=0, --owner=0, --group=0, --numeric-owner,
|
|
* gzip -n for no original filename + mtime=0 in the header).
|
|
*/
|
|
|
|
import { spawnSync } from 'child_process';
|
|
import { createHash } from 'crypto';
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from 'fs';
|
|
import { join, normalize, relative, resolve, sep } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
export interface TarballPackOptions {
|
|
/** Absolute path to the directory whose contents become the tarball root. */
|
|
sourceDir: string;
|
|
/** Absolute path where the .tgz file should land. */
|
|
outPath: string;
|
|
/** Optional: list of relative paths to exclude (e.g. ['.git/', 'node_modules/']). */
|
|
exclude?: string[];
|
|
}
|
|
|
|
export interface TarballPackResult {
|
|
outPath: string;
|
|
/** SHA-256 of the gzipped tarball (lowercase hex, no `sha256:` prefix). */
|
|
sha256: string;
|
|
/** Number of file entries (excludes directories). */
|
|
fileCount: number;
|
|
/** Compressed byte size on disk. */
|
|
compressedBytes: number;
|
|
}
|
|
|
|
/** Extract caps shape — caller can override any subset. */
|
|
export interface ExtractCaps {
|
|
maxFiles: number;
|
|
maxBytesPerFile: number;
|
|
maxTotalBytes: number;
|
|
maxPathLength: number;
|
|
/** Reject if decompressed/compressed ratio exceeds this. */
|
|
maxCompressionRatio: number;
|
|
}
|
|
|
|
/** Default extract caps. Override per-call if a publisher pack is exceptional. */
|
|
export const DEFAULT_EXTRACT_CAPS: ExtractCaps = {
|
|
maxFiles: 5000,
|
|
maxBytesPerFile: 1024 * 1024, // 1 MB
|
|
maxTotalBytes: 100 * 1024 * 1024, // 100 MB
|
|
maxPathLength: 255,
|
|
maxCompressionRatio: 100,
|
|
};
|
|
|
|
export interface TarballExtractOptions {
|
|
/** Absolute path to the .tgz file. */
|
|
tgzPath: string;
|
|
/** Absolute path where contents should be extracted (must not exist or be empty). */
|
|
destDir: string;
|
|
/** Cap overrides; merged with defaults. */
|
|
caps?: Partial<ExtractCaps>;
|
|
}
|
|
|
|
export interface TarballExtractResult {
|
|
destDir: string;
|
|
fileCount: number;
|
|
totalBytes: number;
|
|
/** SHA-256 of the gzipped tarball that was extracted. */
|
|
sha256: string;
|
|
}
|
|
|
|
export type TarballErrorCode =
|
|
| 'pack_source_missing'
|
|
| 'pack_failed'
|
|
| 'extract_tgz_missing'
|
|
| 'extract_dest_not_empty'
|
|
| 'extract_failed'
|
|
| 'extract_path_traversal'
|
|
| 'extract_disallowed_entry_type'
|
|
| 'extract_file_too_large'
|
|
| 'extract_total_too_large'
|
|
| 'extract_too_many_files'
|
|
| 'extract_path_too_long'
|
|
| 'extract_compression_bomb'
|
|
| 'tar_binary_not_found';
|
|
|
|
export class TarballError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public code: TarballErrorCode,
|
|
public detail?: { path?: string; size?: number; limit?: number },
|
|
) {
|
|
super(message);
|
|
this.name = 'TarballError';
|
|
}
|
|
}
|
|
|
|
/** Compute SHA-256 of a file on disk (streaming would be nicer but this is small). */
|
|
export function fileSha256(path: string): string {
|
|
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Find the `tar` binary. On macOS this resolves to bsdtar by default; the pack
|
|
* path explicitly prefers GNU tar (gtar / homebrew tar) when available for the
|
|
* deterministic flags. Extract works with either.
|
|
*/
|
|
function resolveTarBinary(preferGnu: boolean): string {
|
|
if (preferGnu) {
|
|
for (const candidate of ['gtar', '/usr/local/opt/gnu-tar/libexec/gnubin/tar']) {
|
|
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf-8' });
|
|
if (probe.status === 0 && probe.stdout.includes('GNU')) return candidate;
|
|
}
|
|
}
|
|
// Fall back to system tar — must validate it's GNU for the pack path.
|
|
const sysProbe = spawnSync('tar', ['--version'], { encoding: 'utf-8' });
|
|
if (sysProbe.status !== 0) {
|
|
throw new TarballError('tar binary not found on PATH', 'tar_binary_not_found');
|
|
}
|
|
if (preferGnu && !sysProbe.stdout.includes('GNU')) {
|
|
throw new TarballError(
|
|
'GNU tar required for deterministic packing (bsdtar default on macOS lacks --sort + --mtime support). Install via: brew install gnu-tar',
|
|
'tar_binary_not_found',
|
|
);
|
|
}
|
|
return 'tar';
|
|
}
|
|
|
|
/**
|
|
* Pack a directory deterministically. Same inputs -> same SHA every time.
|
|
*/
|
|
export function packTarball(opts: TarballPackOptions): TarballPackResult {
|
|
if (!existsSync(opts.sourceDir)) {
|
|
throw new TarballError(
|
|
`pack source directory does not exist: ${opts.sourceDir}`,
|
|
'pack_source_missing',
|
|
);
|
|
}
|
|
|
|
const tar = resolveTarBinary(true);
|
|
const excludeFlags = (opts.exclude ?? []).flatMap((p) => ['--exclude', p]);
|
|
|
|
// Determinism flags:
|
|
// --sort=name: entries in lexicographic order
|
|
// --mtime='@0': all entries dated epoch 0
|
|
// --owner=0 --group=0 --numeric-owner: no uid/gid leak
|
|
// --pax-option=exthdr.name=...,delete=atime,delete=ctime: strip nondeterministic pax atime/ctime
|
|
// GZIP=-n: gzip header without original filename + mtime
|
|
// TZ=UTC: align mtime serialization regardless of host TZ
|
|
const env = { ...process.env, GZIP: '-n', TZ: 'UTC' };
|
|
const sourceParent = resolve(opts.sourceDir, '..');
|
|
const sourceLeaf = relative(sourceParent, opts.sourceDir);
|
|
|
|
// Stage to a tempfile so a failed pack doesn't leave a partial tarball at outPath.
|
|
const stage = join(tmpdir(), `gbrain-skillpack-pack-${process.pid}-${Date.now()}.tgz`);
|
|
|
|
const result = spawnSync(
|
|
tar,
|
|
[
|
|
'--create',
|
|
'--gzip',
|
|
'--file',
|
|
stage,
|
|
'--sort=name',
|
|
'--mtime=@0',
|
|
'--owner=0',
|
|
'--group=0',
|
|
'--numeric-owner',
|
|
'--pax-option=delete=atime,delete=ctime,exthdr.name=%d/PaxHeaders/%f',
|
|
'-C',
|
|
sourceParent,
|
|
...excludeFlags,
|
|
sourceLeaf,
|
|
],
|
|
{ env, encoding: 'utf-8' },
|
|
);
|
|
|
|
if (result.status !== 0) {
|
|
try {
|
|
rmSync(stage, { force: true });
|
|
} catch {}
|
|
throw new TarballError(
|
|
`tar pack failed (exit ${result.status}): ${result.stderr || result.stdout || '<no output>'}`,
|
|
'pack_failed',
|
|
);
|
|
}
|
|
|
|
// Move staged tarball into place atomically.
|
|
mkdirSync(resolve(opts.outPath, '..'), { recursive: true });
|
|
// Use rename via fs operations rather than mv (cross-FS safe via readFile/write fallback).
|
|
try {
|
|
const data = readFileSync(stage);
|
|
writeFileSync(opts.outPath, data);
|
|
rmSync(stage, { force: true });
|
|
} catch (err) {
|
|
try {
|
|
rmSync(stage, { force: true });
|
|
} catch {}
|
|
throw new TarballError(
|
|
`failed to move staged tarball to outPath: ${(err as Error).message}`,
|
|
'pack_failed',
|
|
);
|
|
}
|
|
|
|
const sha256 = fileSha256(opts.outPath);
|
|
const compressedBytes = statSync(opts.outPath).size;
|
|
|
|
// Count files (re-list via tar -tzf for a quick traversal).
|
|
const listResult = spawnSync(tar, ['--list', '--file', opts.outPath], { encoding: 'utf-8' });
|
|
if (listResult.status !== 0) {
|
|
throw new TarballError(
|
|
`tar --list failed on freshly-packed tarball: ${listResult.stderr}`,
|
|
'pack_failed',
|
|
);
|
|
}
|
|
const fileCount = listResult.stdout
|
|
.split('\n')
|
|
.filter((line) => line.length > 0 && !line.endsWith('/'))
|
|
.length;
|
|
|
|
return { outPath: opts.outPath, sha256, fileCount, compressedBytes };
|
|
}
|
|
|
|
/**
|
|
* Extract a tarball into destDir with strict allowlist + caps. Used both by
|
|
* the third-party scaffold path (extracting a downloaded tarball) and by the
|
|
* publish-gate (extracting a freshly-packed tarball to verify it round-trips).
|
|
*/
|
|
export function extractTarball(opts: TarballExtractOptions): TarballExtractResult {
|
|
const caps = { ...DEFAULT_EXTRACT_CAPS, ...(opts.caps ?? {}) };
|
|
|
|
if (!existsSync(opts.tgzPath)) {
|
|
throw new TarballError(
|
|
`tarball not found: ${opts.tgzPath}`,
|
|
'extract_tgz_missing',
|
|
);
|
|
}
|
|
if (existsSync(opts.destDir)) {
|
|
const entries = readdirSync(opts.destDir);
|
|
if (entries.length > 0) {
|
|
throw new TarballError(
|
|
`extract destination is not empty: ${opts.destDir}`,
|
|
'extract_dest_not_empty',
|
|
);
|
|
}
|
|
} else {
|
|
mkdirSync(opts.destDir, { recursive: true });
|
|
}
|
|
|
|
// GNU tar required so the --list --verbose output format is deterministic.
|
|
// bsdtar (macOS default) prints `0 501 20 SIZE Month DD HH:MM path` while
|
|
// GNU tar prints `501/20 SIZE YYYY-MM-DD HH:MM path` — the parser below
|
|
// anchors on the YYYY-MM-DD date pattern.
|
|
const tar = resolveTarBinary(true);
|
|
const sha256 = fileSha256(opts.tgzPath);
|
|
const compressedBytes = statSync(opts.tgzPath).size;
|
|
|
|
// Pre-flight inspection: list entries, check types + path traversal + caps.
|
|
// Use --list --verbose for type info (the leading char encodes file type).
|
|
const listResult = spawnSync(
|
|
tar,
|
|
['--list', '--verbose', '--file', opts.tgzPath, '--numeric-owner'],
|
|
{ encoding: 'utf-8' },
|
|
);
|
|
if (listResult.status !== 0) {
|
|
throw new TarballError(
|
|
`tar --list failed: ${listResult.stderr || listResult.stdout}`,
|
|
'extract_failed',
|
|
);
|
|
}
|
|
|
|
const destReal = realpathSync(opts.destDir);
|
|
let fileCount = 0;
|
|
let totalBytes = 0;
|
|
|
|
for (const rawLine of listResult.stdout.split('\n')) {
|
|
const line = rawLine.replace(/\r$/, '');
|
|
if (!line.trim()) continue;
|
|
// Format: `-rw-r--r-- 0/0 1234 2026-01-01 00:00 path/to/file`
|
|
// Some entries may have wrapped fields; use a permissive parser.
|
|
const typeChar = line.charAt(0);
|
|
const fields = line.split(/\s+/);
|
|
if (fields.length < 6) continue;
|
|
// The path is everything after the date+time. Date is at index 3, time at 4 (or sometimes 3),
|
|
// so path starts at index 5. Recombine in case the path contains spaces.
|
|
// Find the date pattern to anchor.
|
|
const dateMatch = line.match(/\s(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}(?::\d{2})?)\s+/);
|
|
if (!dateMatch) continue;
|
|
const pathStart = line.indexOf(dateMatch[0]) + dateMatch[0].length;
|
|
const entryPath = line.slice(pathStart);
|
|
|
|
// Type allowlist: '-' regular, 'd' directory. Reject everything else.
|
|
if (typeChar !== '-' && typeChar !== 'd') {
|
|
throw new TarballError(
|
|
`tarball contains disallowed entry type '${typeChar}' at ${entryPath} (symlinks, hardlinks, devices, FIFOs forbidden)`,
|
|
'extract_disallowed_entry_type',
|
|
{ path: entryPath },
|
|
);
|
|
}
|
|
|
|
if (entryPath.length > caps.maxPathLength) {
|
|
throw new TarballError(
|
|
`tarball entry path exceeds maxPathLength (${caps.maxPathLength}): ${entryPath}`,
|
|
'extract_path_too_long',
|
|
{ path: entryPath, size: entryPath.length, limit: caps.maxPathLength },
|
|
);
|
|
}
|
|
|
|
// Path traversal check: resolve relative to destDir, ensure result is contained.
|
|
const normalized = normalize(entryPath);
|
|
if (normalized.startsWith('..' + sep) || normalized === '..' || normalized.startsWith('/')) {
|
|
throw new TarballError(
|
|
`tarball entry escapes destination: ${entryPath}`,
|
|
'extract_path_traversal',
|
|
{ path: entryPath },
|
|
);
|
|
}
|
|
|
|
if (typeChar === '-') {
|
|
fileCount += 1;
|
|
if (fileCount > caps.maxFiles) {
|
|
throw new TarballError(
|
|
`tarball exceeds maxFiles cap (${caps.maxFiles})`,
|
|
'extract_too_many_files',
|
|
{ limit: caps.maxFiles },
|
|
);
|
|
}
|
|
// Field at index 2 is size (for `-` entries with --numeric-owner).
|
|
// owner/group is `0/0` at fields[1], size at fields[2].
|
|
const size = parseInt(fields[2] ?? '0', 10);
|
|
if (!Number.isFinite(size) || size < 0) {
|
|
throw new TarballError(
|
|
`tarball entry has invalid size: ${entryPath} (raw: ${fields[2]})`,
|
|
'extract_failed',
|
|
{ path: entryPath },
|
|
);
|
|
}
|
|
if (size > caps.maxBytesPerFile) {
|
|
throw new TarballError(
|
|
`tarball entry ${entryPath} (${size} bytes) exceeds maxBytesPerFile cap (${caps.maxBytesPerFile})`,
|
|
'extract_file_too_large',
|
|
{ path: entryPath, size, limit: caps.maxBytesPerFile },
|
|
);
|
|
}
|
|
totalBytes += size;
|
|
if (totalBytes > caps.maxTotalBytes) {
|
|
throw new TarballError(
|
|
`tarball decompressed total ${totalBytes} bytes exceeds maxTotalBytes cap (${caps.maxTotalBytes})`,
|
|
'extract_total_too_large',
|
|
{ size: totalBytes, limit: caps.maxTotalBytes },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compression-ratio cap (rough bomb defense).
|
|
if (compressedBytes > 0 && totalBytes / compressedBytes > caps.maxCompressionRatio) {
|
|
throw new TarballError(
|
|
`compression ratio ${(totalBytes / compressedBytes).toFixed(1)}:1 exceeds cap (${caps.maxCompressionRatio}:1) — possible decompression bomb`,
|
|
'extract_compression_bomb',
|
|
{ size: totalBytes, limit: caps.maxCompressionRatio },
|
|
);
|
|
}
|
|
|
|
// All checks passed; do the actual extract.
|
|
// GNU tar's --no-same-owner is implicit when not root; pass numeric-owner only.
|
|
const extractResult = spawnSync(
|
|
tar,
|
|
['--extract', '--gzip', '--file', opts.tgzPath, '-C', opts.destDir, '--numeric-owner'],
|
|
{ encoding: 'utf-8' },
|
|
);
|
|
if (extractResult.status !== 0) {
|
|
throw new TarballError(
|
|
`tar --extract failed: ${extractResult.stderr || extractResult.stdout}`,
|
|
'extract_failed',
|
|
);
|
|
}
|
|
|
|
// Validate the extracted files don't escape destDir even after extract (defense in depth).
|
|
const realDest = realpathSync(opts.destDir);
|
|
if (realDest !== destReal) {
|
|
throw new TarballError(
|
|
`destination realpath changed during extract (possible symlink attack)`,
|
|
'extract_path_traversal',
|
|
);
|
|
}
|
|
|
|
return { destDir: opts.destDir, fileCount, totalBytes, sha256 };
|
|
}
|