Add project-level skills lock file schema package#5892
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5892 +/- ##
==========================================
+ Coverage 71.77% 71.82% +0.04%
==========================================
Files 702 705 +3
Lines 71901 72164 +263
==========================================
+ Hits 51608 51829 +221
- Misses 16593 16619 +26
- Partials 3700 3716 +16 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Tracked in #5899 (RFC THV-0080 stack). |
4997552 to
f0f5060
Compare
JAORMX
left a comment
There was a problem hiding this comment.
🤖 Automated panel review — PR 1/6 (lockfile schema)
Multi-agent review (correctness / security / architecture lenses), each finding adversarially verified against the diff. Advisory — worth a human pass. The whole stack is gated behind TOOLHIVE_SKILLS_LOCK_ENABLED (off by default), so none of these are live regressions.
🟠 Major (forward-compat — flagged here because this is the schema PR)
- The v1 schema bakes in a hazard for the deferred Sigstore fields.
Load/Saveunmarshal into fixedEntry/Lockfilestructs with no,inlinecatch-all /KnownFields, andvalidateLockfilehard-fails anyversion != 1. When the Sigstore stack addsprovenance, it's lose-lose: keepversion: 1and addomitemptyfields → an older binary from this stack silently strips the signing metadata on the nextSave(a teammate commits an unsigned lock); or bump toversion: 2→ this stack's binary hard-fails reading the file for the whole mixed-version team. Reserve/round-trip unknown fields (inline map) or define the version-skew contract now, while the schema is new.
🟡 Minor
ResolvedReferenceis completely unvalidated (validation.go:57). It's the exact fieldsyncfetches from without re-resolvingsource. In an unauthenticated, hand-editable YAML (Sigstore deferred), a lock-diff edit that swaps onlyresolvedReference— leavingsource/digestplausible — steers the fetch origin (attacker registry → cred exfil; or an internalhttp://169.254.169.254/…SSRF target). Constrain scheme/host/control-chars/length in the schema package.- Exported
Saveuses a fixed temp filename and takes no lock (lockfile.go:~210). OnlyUpdate/UpsertEntry/RemoveEntryacquireWithFileLock; the package's own tests calllf.Savedirectly. Two concurrent directSaves both write.toolhive.lock.tmp→ interleave/truncate, promote a half-written temp, or the post-renameRemovedeletes the other's temp. The comment's "already serialized by the caller's file lock" is false for any direct caller — use a random suffix or lock insideSave. contentDigestignores file mode and skips symlinks/non-regular files (contentdigest.go). A purechmod(exec bit flip) or symlink swap is invisible tosync --check. Precedented bygo.sumand partly covered by the sourceDigestpin, so minor — but a real limit worth noting for the Sigstore/verify stack. AlsocontentDigestis optional in the schema, so an entry can be pinned with no on-disk integrity primitive at all.- No
requiredBycycle detection at load (validation.go:41). The install-timeVisitedguard prevents this via normal installs, but a hand-edited/merge-resolved lock with a mutual-requiredByring of non-explicit entries passes validation and can never be cascade-removed (each keeps the other'sRequiredBynon-empty). Load-time validation is the right place to reject it.
🟢 Verified praise
- The control-char/NUL/newline rejection in
ContentDigestpaths with an explicit forged-collision test (TestContentDigestRejectsForgedPathCollision) is a genuinely careful serialization-ambiguity defense, and the frozen golden vectors are the right call.
JAORMX
left a comment
There was a problem hiding this comment.
🤖 Automated panel review — follow-up on 0a45fce → approving
The "Harden lock file schema against skew, forgery, and races" commit addresses every finding from the earlier review. Verified each fix against the code, not just the commit message:
- ✅ Forward-compat (major):
Entry.Extra/Lockfile.Extraasyaml:",inline"maps round-trip unknown keys through Load→Save, so an older binary no longer strips the deferred Sigstore provenance fields — and the comment correctly notes that a freshly-rewritten entry drops its stale extras. This is the right resolution of the schema-skew hazard. - ✅
resolvedReferencevalidation (minor):validateResolvedReferencebounds length, rejects whitespace/non-graphic runes, and requires a strict git:// orname.ParseReference(…, StrictValidation)OCI ref — rejecting URL-shaped SSRF targets likehttp://169.254.169.254/…. Exactly the syntactic guard requested. - ✅
Saverace (minor): per-callcrypto/randtemp suffix; concurrent directSaves can no longer promote or delete each other's half-written file, and the doc comment is now honest. - ✅
requiredBycycle detection (minor):findRequiredByCycle(3-color DFS) rejects hand-edited mutual-requires rings at validation. - ✅
contentDigestmode/symlink (minor): documented as a known limitation — appropriate here; the real enforcement belongs to the verification (Sigstore) stack.
Approving. One trivial non-blocking nit for a future touch: there's a stale leftover comment line above tmpFileName() in lockfile.go (// tmpFileName is the temporary file Save writes before renaming it into) dangling above the new doc comment — safe to delete.
Note this approval is for PR 1/6 on its own merits; the rest of the stack still has open blocker/major items in #5894–#5897.
|
Thanks for the thorough panel review — every finding was verified against the code and addressed. Fixed in the latest push:
|
RFC THV-0080 pins every project-scoped skill install to a committed toolhive.lock.yaml so teams get reproducible installs across machines, the same guarantee package-lock.json/Cargo.lock provide elsewhere. This package defines the v1 schema (entries, content digest, requiredBy) with no callers yet; skillsvc wiring follows in a later PR of the stack. Access is confined to the validated project root via os.Root (OS-enforced containment), not string validation alone.
Path validation rejected traversal but not control characters, so a crafted Path containing an embedded NUL and newline could reproduce the byte stream of an unrelated multi-file tree and collide with its digest. Save also never validated before writing, so a caller bug could persist an invalid lock file with no recovery through the package API. Also normalize repeated "./" prefixes idempotently.
Address panel-review findings on the schema package: unknown fields written by a newer binary were silently stripped on the next Save (breaking the deferred Sigstore fields before they exist) — round-trip them via inline maps; resolvedReference was completely unvalidated despite being the exact value sync fetches from without re-resolving — constrain it syntactically (strict OCI or git:// parse, no control characters, bounded length); Save used a fixed temp filename with no lock, so two direct concurrent calls could promote or destroy each other's half-written file — use a per-call random suffix; and a hand-edited requiredBy ring of non-explicit entries validated cleanly yet could never be cascade-removed — reject cycles at validation. Also document the frozen digest algorithm's file-mode/symlink limitations.
0a45fce to
141d980
Compare
Summary
toolhive.lock.yamlthat pins the exact content of every project-scoped skill install, giving skills the reproducibility guaranteepackage-lock.json/Cargo.lock/go.sumprovide elsewhere. This is the first PR of a stack productionizing the validated POC in Add project-level skills lock file (POC) #5715 into reviewable, independently-tested changes.pkg/skills/lockfile, a new leaf package with no callers yet: the lock file schema (Entry,Lockfile), a deterministic content-hash algorithm (ContentDigest) used for on-disk integrity verification, and load/save/upsert/remove operations. All filesystem access is confined to the validated project root viaos.Root(OS-enforced containment), not string path validation alone — this avoids the CodeQL "uncontrolled data used in path expression" findings that were flagged on the POC.Part of the production stack for #5715 (RFC THV-0080). Stack: 1/6 — lockfile → lock-service → install-hooks → sync → upgrade → cli-exitcodes. This PR has no consuming callers; wiring into
skillsvc.Installlands in PR3 of the stack.Type of change
Test plan
task test)task lint-fix)Table-driven tests cover round-trip load/save, unknown-version hard error, requiredBy cross-reference validation, golden contentDigest vectors (freezing the algorithm), a goroutine-hammer concurrency test against
UpsertEntry, and traversal/escape rejection. 89.9% statement coverage.Does this introduce a user-facing change?
No — this package has no callers yet.
Special notes for reviewers
Entrydeliberately omitsprovenance/unsigned(Sigstore fields) — those land in PR7 of the Sigstore stack, once Stack 1 (this stack) merges.mainbehind an env-var gate introduced in PR3, per the incremental-rollout plan — see that PR's description.contentdigest_test.gofreeze the dirhash algorithm; treat any future change to it as a lock-file format break, not a routine test update.🤖 Generated with Claude Code