Add typed exit codes and confirmation gate to skill sync/upgrade#5897
Add typed exit codes and confirmation gate to skill sync/upgrade#5897samuv wants to merge 3 commits into
Conversation
dd81407 to
a7e9269
Compare
726ce33 to
9efdc60
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## skills-lock/05-upgrade #5897 +/- ##
==========================================================
+ Coverage 71.79% 71.84% +0.04%
==========================================================
Files 711 711
Lines 72746 72746
==========================================================
+ Hits 52228 52261 +33
+ Misses 16775 16720 -55
- Partials 3743 3765 +22 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
a7e9269 to
9deff64
Compare
9efdc60 to
0e58495
Compare
9deff64 to
3e750f4
Compare
0e58495 to
58e1107
Compare
3e750f4 to
cf3c890
Compare
58e1107 to
80ae30f
Compare
JAORMX
left a comment
There was a problem hiding this comment.
🤖 Automated panel review — PR 6/6 (typed exit codes + confirmation gate)
Multi-agent review, each finding adversarially verified against the diff. Advisory. Whole stack gated behind TOOLHIVE_SKILLS_LOCK_ENABLED (off by default).
🟠 Major — the exit-code contract this PR exists to implement is violated on one path
- Partial failure (exit 3) is silently downgraded to check failure (exit 2) under
--fail-on-changes(skill_upgrade.go:~93). The error path maps any 409 unconditionally:if httperr.Code(err) == http.StatusConflict { return withExitCode(wrapped, ExitCodeCheckFailure) }. But in #5896 a resolve failure also becomes a 409 (theFailOnChangesloop returns 409 for any status that isn'tUpToDate/NotUpgradable, which includesFailed), and the partial result is discarded. So when--fail-on-changesdetects a change and a skill genuinely failed (e.g. transient registry outage), CI sees exit 2 ("lock is stale, auto-open upgrade PR") instead of the RFC-mandated exit 3 ("something broke"). YourupgradeExitErrorprecedence (failed > refBlocked) is correct — it's just bypassed by the 409 shortcut. Root cause spans #5896 (server conflatingFailedwith "would change" + discarding the result), but the observable inversion lives in this mapping.
🟡 Minor
- The confirmation prompt is blind (
skill_confirm.go:~35).requireConfirmationprints only"Sync skills for <root>? [y/N]"— no skill names,source,resolvedReference, ordigest, and it runs before the client call so nothing is shown first. The comment sells it as a security control against AI-executed instructions, and the docs equate the lock togo.sum— but with Sigstore deferred,syncinstalls whatever digest is committed with no verification, and the lock is attacker-editable. This is strictly less informative than the precedent it's modeled on (thv upgradecallsprintUpgradeDetail()beforeconfirmUpgrade()). A lock-diff that swaps adigest/resolvedReference(leavingsourceuntouched for an easy line-diff wave-through) sails past the prompt. Print the change set before prompting. projectRootis echoed verbatim into the TTY prompt viafmt.Printf(skill_confirm.go:~35). CWE-22 validation targets traversal, not printable-char safety; a checkout dir name containing ANSI/OSC escapes can repaint/spoof the very prompt that's meant to be the human gate. Strip/escape non-graphic runes before printing.- No test covers exit 3 (
test/e2e/cli_skills_lock_test.go). E2E asserts 0/2/4 but not partial failure — the branch with the most precedence logic and the PR's core reason to exist (and the branch broken by the major above).
🟢 Verified praise
exitCodeErrorviaerrors.Ascorrectly survivesfmt.Errorf("…: %w")wrapping upstream — good choice.- Refusing a non-interactive run without
--yes(exit 4) instead of silently proceeding is the correct fail-closed divergence from theconfirmUpgradeprecedent, and it's well-tested.
🧭 Stack-level notes (posting here as the top of the stack)
- Gating consistency:
sync/upgradeare registered ininit()and ship in--help+ generated docs with no "experimental / off by default" marker, while the service hard-gates them (403). BecauserequireConfirmationruns before the API call, a disabled-feature run exits 4 without--yesbut hits the 403 path with--yes— the failure code depends on--yes. The prompt also goes to stdout, polluting--format json. - Sigstore deferral (the strategic call): RFC THV-0080's "v1" is lock-file and signing together — precisely because "a committed digest alone is an unauthenticated trust root" was the finding that motivated adding signing. Shipping the digest half first is a reasonable delivery split, but across the stack the code/docs currently present it as delivering integrity/trust when it delivers drift detection over an attacker-editable file. Recommend landing the stack (it's gated off) but making the trust language honest until Sigstore lands, and fixing the schema forward-compat hazard (see #5892) now so the signing fields slot in without a version-skew break.
- Sequencing praise: the
skillSyncer→SkillLockServiceinterface widening, themanaged-flag introduce/consume split, and the default-off posture are a genuinely clean staged rollout.
2e916c5 to
61ff016
Compare
16a5271 to
7c01f04
Compare
|
All findings addressed, including the stack-level notes. In the latest push:
|
JAORMX
left a comment
There was a problem hiding this comment.
🤖 Automated panel re-review on 61ff016 → approving
The major exit-code inversion is fixed and adversarially confirmed against the real code:
- ✅ Exit-3 no longer masked as exit-2: with #5896 now returning outcomes instead of a 409,
upgradeExitErrorderives the exit code fromresult.Outcomeswithfailed (3) > wouldChange (2) > refBlockedprecedence — checked strictly in that order, andUpgradeStatusFailedonly incrementsfailed. A genuine resolve failure during--fail-on-changesnow exits 3, not 2. Covered by the explicit inversion unit test ({Upgraded, Failed}+failOnChanges→ exit 3) and a wired exit-3 E2E.
Minors fixed:
- ✅ Confirmation prompt now prints per-entry name + source + short-digest (
printLockEntriesSummary) before prompting. - ✅
sanitizeTerminalstrips non-graphic runes from the action string,projectRoot, and every echoed entry field — the ANSI/OSC prompt-spoofing vector is closed. - ✅ Prompt + summary moved to stderr, so
--format jsonstdout stays parseable; both commands' help now marks them experimental (docs regenerated). - ✅ Exit-3 (partial failure) and fresh-clone
--checkexit-2 now have E2E coverage.
Nits acknowledged as by-design/inherent: the confirmation gate is CLI-only (the API server has no TTY); the disabled-feature exit code differs by --yes (a consequence of server-side gating, gone once the gate is removed). One new cosmetic nit: upgrade [name...] shows all lock entries in the confirmation summary even when args restrict the target set — consider scoping it to args. Non-blocking. LGTM.
That closes out the whole Stack 1 review: with 611f480/1c30244/a954095/7c01f04/61ff016 (and #5892 0a45fce), every blocker and major from the original panel is fixed and independently verified. 🎉
7c01f04 to
9817f7e
Compare
61ff016 to
ec82025
Compare
RFC THV-0080 gives CI a scriptable contract for sync/upgrade: exit 2 for check/freshness failures, 3 for partial failures, 4 for policy rejections, distinct from the generic 1 cobra already uses. It also requires a pre-install confirmation gate — skill content is a set of AI-followed instructions, so a non-interactive run must refuse outright without --yes rather than silently proceeding. - cmd/thv/app/exitcode.go: exitCodeError carries a specific exit code through a RunE return; main.go maps it via ExitCodeFromError instead of a hardcoded os.Exit(1) - cmd/thv/app/skill_confirm.go: requireConfirmation prompts on a TTY, refuses with ExitCodePolicyRejection off one - sync/upgrade gain --yes, skipped automatically by --check/ --preview (which never install anything); their exit-code mapping prioritizes partial failure over drift/ref-change-blocked - docs/arch/12-skills-system.md: new Project Lock File section covering rollout gating, schema, install/uninstall hooks, sync, upgrade, and the exit-code contract - CLI E2E coverage for all four exit codes (0/2/4, plus --yes) This is the last PR of Stack 1 (lock file + sync + upgrade) for RFC THV-0080. Stack 2 (Sigstore signing/verification) builds on top once this stack is fully merged; TOOLHIVE_SKILLS_LOCK_ENABLED stays off by default until that full v1 lands. Part of the production stack for #5715 (RFC THV-0080). Stack: 6/6.
upgradeExitError mapped a ref-change-blocked outcome to exit 4 unconditionally, even during --preview — where nothing was actually blocked, only reported. Thread preview through, mirroring how syncExitError already gates Drifted behind --check. Also reconcile a docs contradiction about --preview's side effects, drop a stale signer-change reference in the exit-code doc comment (that guard doesn't exist until Stack 2), and add upgrade exit-code E2E coverage.
The panel review's major: the CLI mapped any 409 unconditionally to exit 2, silently downgrading a genuine resolution failure during the CI gate from "something broke" (exit 3) to "lock is stale" (exit 2) and discarding the partial result. With the server now returning outcomes instead of a 409, exit codes derive entirely from outcomes with the correct precedence (failed > would-change > ref-blocked), sync --check counts missing installs toward exit 2, and plan-only runs print "would upgrade" instead of claiming an install happened. The confirmation prompt also gets teeth and armor: it now prints the lock entries being acted on (a bare "proceed?" gave the human gate nothing to judge), goes to stderr so --format json stays parseable, and strips non-graphic runes so a hostile directory or entry name cannot repaint the prompt with terminal escapes. Both commands are marked experimental in their help text, the architecture doc states the pre-Sigstore trust model plainly (drift detection over a repository-editable file, not verified integrity), and E2E coverage adds the missing exit-3 path and the fresh-clone exit-2 gate.
ec82025 to
4c4eff9
Compare
9817f7e to
f764fed
Compare
Summary
sync/upgrade: exit2for check/freshness failures,3for partial failures,4for policy rejections — distinct from the generic1cobra already uses everywhere else. It also requires a pre-install confirmation gate: skill content is a set of AI-followed instructions, so a non-interactive run must refuse outright without--yesrather than silently proceeding. This is the last PR of Stack 1.cmd/thv/app/exitcode.go:exitCodeErrorlets aRunEcarry a specific exit code back through cobra;main.gomaps it viaExitCodeFromErrorinstead of a hardcodedos.Exit(1).cmd/thv/app/skill_confirm.go:requireConfirmationprompts on a TTY, refuses withExitCodePolicyRejectionoff one (matching the RFC's stated rationale, not the workload-upgrade command's precedent of silently skipping the prompt when non-interactive — skill content is higher-stakes).sync/upgradegain--yes, skipped automatically by--check/--preview(which never install anything). Exit-code mapping prioritizes partial failure over drift/ref-change-blocked (a real operational failure is a stronger signal than a guard doing its job).docs/arch/12-skills-system.md: new "Project Lock File" section covering the rollout gate, schema, install/uninstall hooks, sync, upgrade, and the exit-code contract — plus updates to the existing storage/API/CLI tables.--checkclean/drifted, non-interactive refusal,--yesbypass).Part of the production stack for #5715 (RFC THV-0080). Stack: 6/6 (final PR of Stack 1) — lockfile → lock-service → install-hooks → sync → upgrade → cli-exitcodes. Based on #5896 (PR5).
Type of change
Test plan
task test)task test-e2e, scoped:ginkgo --label-filter=skills-lock)task lint-fix)Unit tests for
ExitCodeFromError/withExitCode(including error wrapping),syncExitError/upgradeExitErrorprecedence rules,requireConfirmation's--yesand non-interactive-refusal paths (the interactive TTY-prompt branch is untestable in a normalgo testprocess and is left uncovered, matching the existingconfirmUpgradehelper for workload upgrades — this codebase's established precedent for interactive-only code). New CLI E2E spec file drives the realthvbinary against a real server process and asserts actual process exit codes via*exec.ExitError.Also verified against a clean
origin/mainworktree that a batch of 24 unrelated skills E2E failures on this dev machine (extracting skill: target path validation: symlink found at "/var") are a pre-existing macOS-local environment quirk (/var→/private/var), not something this stack introduced.Does this introduce a user-facing change?
Not yet — same
TOOLHIVE_SKILLS_LOCK_ENABLEDgate as the rest of the stack. Once the full RFC v1 (this stack + the later Sigstore stack) lands:thv skill sync/upgradewill prompt for confirmation before installing and use the documented exit codes for scripting.Special notes for reviewers
TOOLHIVE_SKILLS_LOCK_ENABLEDdefault — per the plan agreed for this stack, the feature stays gated until the Sigstore stack (built on top of this one) also lands, since the RFC frames "v1" as lock-file and signing together.🤖 Generated with Claude Code