Skip to content

feat(install): Codex opt-out and credential-free attestation verification - #1197

Merged
backnotprop merged 3 commits into
mainfrom
feat/1178-installer-optouts
Aug 4, 2026
Merged

feat(install): Codex opt-out and credential-free attestation verification#1197
backnotprop merged 3 commits into
mainfrom
feat/1178-installer-optouts

Conversation

@backnotprop

@backnotprop backnotprop commented Aug 4, 2026

Copy link
Copy Markdown
Owner

TLDR: Closes #1178. (Updated through review round three: the cmd fetcher now runs via -EncodedCommand with no temp file on disk, and the fail-closed test is mutation-proof.) The installers gain explicit opt-outs for the Codex, Gemini, Kiro, and OpenCode integrations (flag, env var, config key) with honest three-state reporting, and provenance verification now fetches the attestation bundle from GitHub's public attestations endpoint and verifies with gh attestation verify --bundle, so it works with zero credentials; gh's authenticated fetch stays as fallback. Users who do not opt in see zero change in files or actions. All findings from the adversarial review round are addressed in the second commit.

Both designs are @astradevkin's, straight from the issue: the skip-var naming and detection-as-consent critique for the Codex opt-out, and the discovery that the public attestations endpoint serves the exact bundle the authenticated path fetches, including the 60/hr per-IP rate-limit note (single fetch attempt, never retried) and the observation that the Sigstore TUF trust root is fetched on every run and deserves its own failure message. Thank you for the unusually rigorous report, including the five negative controls.

Feature 1: per-agent install opt-outs

New in all three scripts (install.sh, install.ps1, install.cmd):

  • --skip-codex / -SkipCodex, PLANNOTATOR_SKIP_CODEX_INSTALL=1, and { "skipInstall": { "codex": true } } in ~/.plannotator/config.json. Precedence: flag over env var over config. The env var also accepts 0/false/no to override a config-enabled skip for one run.
  • Three distinct states, never conflated: Codex: detected, skipped (--skip-codex) (the parenthetical names whichever source triggered the skip), Codex was not detected., and the existing installed output. When a previous install did wire Codex and this run skips, one line notes the existing integration is unaffected.
  • Skip means do-not-write, never remove. A skipped agent's home is not touched at all: integration writes are suppressed and so is plannotator's own stale-skill cleanup inside that home. The skipped-state output also notes that the cross-agent skills in ~/.agents/skills serve multiple agents and are still installed.
  • Config reads are structure-aware everywhere (review M2): install.sh extracts the skipInstall object itself (character-indexed, works for single-line JSON) before matching per-agent keys and honors an explicit false as a veto; install.cmd parses the real JSON via PowerShell exactly like install.ps1. A "codex": true under any other config key can never opt anyone out.
  • On Windows, where the installers never write the Codex home (hooks are experimental upstream), the skip wording says exactly that, and the existing-integration note only fires when hooks.json actually references plannotator, never on mere file existence (review M4).

Mirrored opt-outs: Gemini (--skip-gemini), Kiro (--skip-kiro), and, per the issue's original ask, OpenCode (--skip-opencode). OpenCode has no detection leg, so its opt-out is a plain do-not-write switch (command stubs, plugin cache clear, stale-stub sweep) with a skip note rather than detected/not-detected reporting. The Gemini summary section is now also honest when Gemini is not detected.

PlannotatorConfig gains the skipInstall field (installer-read only), and AGENTS.md, the README, and the marketing installation, environment-variables, and verifying-your-install pages document everything (review M6).

Feature 2: credential-free attestation verification

Active only under the existing opt-in (--verify-attestation / PLANNOTATOR_VERIFY_ATTESTATION / config verifyAttestation), which stays off by default:

  1. Fetch https://api.github.com/repos/backnotprop/plannotator/attestations/sha256:<digest> unauthenticated. Single fetch attempt, no retry loop (the anonymous API allows 60 requests/hour per IP).
  2. Extract .attestations[].bundle into a JSONL file, the format gh attestation download writes. Dependency note: on macOS/Linux the extraction needs one JSON tool on PATH: node, then python3, then jq, whichever is present (review M3); with none of the three the installer says so by name and falls back, it never blames a fetch that did not run. On Windows, PowerShell extracts each bundle as a byte-exact substring of the raw response via a string-literal-aware brace scanner, deliberately not a ConvertFrom-Json/ConvertTo-Json round trip, whose DateTime coercion re-serializes date-shaped strings differently across PowerShell 5.1 and 7 and could corrupt a bundle field (review M7 guard).
  3. gh attestation verify <file> --bundle <bundle.jsonl> with the unchanged --repo + --source-ref + --signer-workflow constraints. The sh bundle file lives inside a private mktemp -d directory, removed on every exit path; a mktemp failure degrades to the fallback instead of aborting (review M5).
  4. If the bundle-path verify fails for any reason, the installer retries once through the exact authenticated path before classifying anything (review H1). An older gh that rejects --bundle, or a corrupted bundle, therefore ends in a working install or an accurate environment error, never a false "no valid signed provenance". A real provenance failure fails again on the retry and aborts.
  5. Any bundle-path unavailability (no extractor, fetch failure, extraction failure) falls back to the current authenticated invocation, unchanged. Verification is never silently skipped.

Failure messages are distinct and aligned across all three scripts (TUF beats auth in classification precedence everywhere), all fail-closed: real provenance failure (loud, aborts), Sigstore trust root unreachable (worded as connectivity; the TUF root is fetched per-run), and bundle-path-incomplete-plus-no-login (environment error naming the recovery options). The gh CLI is still required for the verify itself; only the login requirement is dropped.

Verification transcripts

macOS end-to-end (GH_CONFIG_DIR at an empty dir, GH_TOKEN/GITHUB_TOKEN cleared; gh reports "not logged into any GitHub hosts"):

Step Result
Download plannotator-darwin-arm64 from v0.25.1 digest 32d28fac...
GET .../attestations/sha256:<digest> unauthenticated HTTP 200, 2 bundles
Extract to JSONL, gh attestation verify --bundle with full constraints, zero credentials exit 0
Wrong binary / wrong signer workflow / wrong source-ref exit 1 each
Full install.sh --verify-attestation run in a sandbox HOME, gh unauthenticated exit 0, prints verified build provenance (SLSA, credential-free via the public attestations API)

Windows status: no Windows environment was reachable during this round, stated plainly. As the substitute: (a) the exact scanner region of install.ps1, and the scanner decoded from install.cmd's -EncodedCommand payload, are exercised by new PowerShell-driven unit tests against a captured real attestations response committed at scripts/fixtures/attestations-response.json (bundle_url values sanitized, every other byte as served), asserting each emitted bundle is a byte-exact substring of the response and parses to the exact bundle object, plus synthetic DateTime-string and braces-inside-strings controls; and (b) the install.ps1 bundle path (its literal fetch shape plus its literal scanner region) was run end-to-end under PowerShell 7.5.4 on macOS with zero credentials against the real endpoint and real v0.25.1 binary: fetch 200, 2 bundles extracted, verify exit 0, wrong-signer-workflow and wrong-binary negative controls exit 1. Windows PowerShell 5.1 remains untested end-to-end; the scanner uses only .NET string APIs available on 5.1, and any 5.1-specific failure degrades through the H1 retry to the authenticated path rather than a false provenance alarm.

Zero-default-change guarantee

Users who pass no new flag and set no new env var or config key get the behavior of main, verified two ways: statically (skips default off; all attestation changes live inside the off-by-default verify branch) and dynamically (main's install.sh and this branch's run in fresh sandbox HOMEs with identical inputs produce identical file trees, identical Codex config.toml hashes, and identical output) with one deliberate, review-requested exception: on machines without ~/.gemini, the final Gemini section now honestly prints "Gemini was not detected" instead of generic usage instructions. Files written and actions taken are identical everywhere; machines with ~/.gemini see unchanged output. Sandbox runs also re-confirmed the skip matrix: flag/env/config each honored and named as the source, env 0 overriding config true, flag beating env, and a pre-seeded hooks.json surviving a skip run hash-identical.

Tests

  • bun test scripts/install.test.ts: 110 pass. New since the review: four functional tests running install.sh against a sandbox HOME with stubbed curl/gh/git (H1 fallback engages and the provenance message does not print; a real verify failure exits 1 and installs nothing, pinning the fail-closed abort the reviewer proved untested; the M2 foreign-key collision installs instead of skipping; skipInstall.codex: true skips and names the config), and six PowerShell-driven scanner tests (two scanner sources times three cases) that skip cleanly when no PowerShell is on the host.
  • Full bun test: 2868 pass, 0 fail.
  • bash -n passes; install.ps1 parses clean under the PowerShell 7.5.4 AST parser. shellcheck remains unavailable on the dev machine.

Review round three (at cf595e9)

  • install.cmd no longer writes any fetch helper to disk: the previous %TEMP%\plannotator-attfetch-%RANDOM%.ps1 (a predictable-path code-execution vector, the M5 class escalated) is replaced by powershell -NoProfile -EncodedCommand with the payload defined next to its full REM PS: plaintext. A test decodes the blob and asserts byte equality with those documented lines plus the security-relevant shape, so the blob cannot drift from what a reviewer reads. Inputs still travel via env vars. The decoded payload was run end to end under pwsh against the real endpoint: 2 bundles written, gh verified the real v0.25.1 binary with zero credentials (exit 0), wrong-binary control failed (exit 1).
  • The fail-closed test now asserts the output ends with "Refusing to install." and was mutation-verified: with the verify-failure exit 1 deleted, the mutant still exits nonzero through an incidental mv failure, but the trailing mv error breaks the assertion and the test fails; restored, it passes.
  • Lows taken: a CI guard test fails loudly when CI is set and no PowerShell is on PATH (the scanner coverage cannot silently skip away); the scanner IndexOf calls are ordinal in both variants; the awk skipInstall extraction requires optional-whitespace-then-colon-then-brace after the key so string values cannot anchor it (non-token occurrences are skipped; unit-checked against escaped-embedded payloads); install.cmd comments warn that the fallback-reason literals inside parenthesized blocks must stay parenthesis-free.
  • Battery: bun test scripts/install.test.ts 113 pass / 0 fail with PowerShell on PATH, 107 pass / 6 skip / 0 fail without; full bun test 2871 pass / 0 fail; bash -n and the PS 7.5.4 AST parse clean.

Release note

Merging re-opens the S3 install-script sync at release time so the hosted plannotator.ai/install.sh|ps1|cmd copies pick up these changes.

…tion

Implements both asks from #1178 (reported and designed by @astradevkin):

- Per-agent installer opt-outs: --skip-codex / --skip-gemini / --skip-kiro
  flags, PLANNOTATOR_SKIP_{CODEX,GEMINI,KIRO}_INSTALL env vars, and
  config.json skipInstall.{codex,gemini,kiro} keys, with flag > env >
  config precedence mirroring verifyAttestation. Detected-but-skipped is
  reported as its own honest state, never conflated with not-detected,
  and a skipped agent's home is neither written nor cleaned up.

- Credential-free provenance verification: when --verify-attestation is
  active, the Sigstore bundle is fetched from GitHub's public
  attestations endpoint (single unauthenticated attempt, no retry) and
  verified via gh attestation verify --bundle with the same
  --repo/--source-ref/--signer-workflow constraints; gh's authenticated
  fetch remains the fallback. TUF trust-root failures are reported as
  connectivity, distinct from real provenance failures; every path
  stays fail-closed.

Zero behavior change for users who do not opt in: the default install
path is unchanged (verified by sandbox-HOME parity runs against main).
@backnotprop

Copy link
Copy Markdown
Owner Author

Review (at e4166c97)

TLDR: needs changes. No security downgrade anywhere: the credential-free path enforces the identical constraint set (proven with ten end-to-end controls against a real release binary with zero credentials) and every reachable failure path fails closed. The byte-identical-default guarantee holds, verified statically and dynamically. But one high and several medium defects need fixing, led by a false-alarm conflation this PR exists to eliminate.

Detail (AI review findings, skim as needed):

H1: a non-provenance failure of the new bundle path reports as a provenance failure, with no fallback. If gh attestation verify --bundle errors for any non-verification reason (proven with an older gh that rejects the flag: "unknown flag: --bundle"), the script prints the loud "no valid signed provenance was found... Refusing to install." A user who verified fine yesterday gets told their binary is bad. Fail-closed, so safe, but the wrong diagnosis. Fix: on a bundle-verify failure, retry once through the authenticated path before declaring provenance failure.

M2: the sh/cmd config grep is line-oblivious and inverts an explicit false. {"skipInstall":{"codex":false},"somethingElse":{"codex":true}} skips Codex and reports the source as config skipInstall.codex, whose value is literally false. The ps1 script parses real JSON and disagrees with its siblings about the same file. Scope the match to the skipInstall block and honor explicit false.

M3: the credential-free path silently requires node, and without it the error blames a fetch that never ran. This lands exactly on the target user (headless box, gh present, no node, no login), and usage text plus AGENTS.md say "no gh login required" with no caveat. Name the real cause, and consider python3/jq secondary extractors since this is the installer's only hard node dependency.

M4: on Windows the "existing Codex integration left untouched" line fires on the mere existence of hooks.json, even one containing only the user's own hooks (the reporter's exact setup), and neither ps1 nor cmd ever writes Codex hooks so the claim can never be true there. Gate it on plannotator content like sh does.

M5: the mktemp-then-rename-to-.jsonl dance leaves the renamed path symlink-race unsafe (impact: local-attacker file clobber, not provenance bypass, since gh still enforces the full constraint set on any substituted bundle). Use mktemp -d with the bundle inside. Same lines: under set -e a mktemp failure aborts the install instead of falling back.

M6: user-facing docs are stale. README and the marketing "verifying your install" page still say gh must be authenticated; the environment-variables and installation docs pages do not mention the new flags. The hosted scripts will change at the release S3 sync while the hosted docs stay wrong.

M7: the Windows bundle path is unverified end to end (the transcript is macOS-only, and the ps1 extraction has PowerShell-specific JSON round-trip hazards whose corruption would land in H1's false-alarm bucket). One Windows transcript before merge.

Lows, take or note: the fail-closed abort has no test (deleting the exit leaves all 100 tests green while an unverified binary installs; the new gh_status restructure makes that regression easier); --skip-codex still writes the cross-agent ~/.agents/skills (defensible, but say so in the skipped-state output); sh and cmd disagree on failure-classification precedence; Gemini's summary block ignores the skip flag and Gemini lacks a not-detected state; a trivial do-not-write --skip-opencode would honor the issue's actual ask; the anonymous fetch could reuse the already-resolved token to spare CI rate limits, at the cost of the pure-anonymous property (deliberate per the issue, so maintainer's call).

Verified good, for the record: ten-control constraint-set equivalence including tampered-artifact and wrong-signer negatives (and one instructive non-hole: the release attestation is multi-subject, so any asset's digest returns the same bundle set); fail-closed on every reachable path; injection-safe digest handling including cmd passing it via env rather than string concatenation (the correct post-#1162 pattern); skip semantics honest across flag/env/config with precedence proven in all three directions and a pre-seeded stale skill preserved under skip but cleaned without it; the #1157 and #1162 script regions untouched with tests passing; skipInstall config key type-only with zero runtime reads and browser safety unaffected; full suite 2858 pass / 0 fail; shellcheck profile identical to base; zero em dashes; mergeable with zero script drift on main since base.

- H1: a failed gh --bundle invocation now retries once through the exact
  authenticated path before any classification, so an older gh (unknown
  flag) or a corrupt bundle never reports as a provenance failure. Pinned
  by a functional stub-gh test; a real failure still fails again on the
  retry and aborts.
- M2: the sh config layer extracts the skipInstall object (awk, character
  indexed) before matching per-agent keys and honors explicit false as a
  veto; cmd now parses the real JSON via PowerShell like ps1. Functional
  tests cover the foreign-key collision and explicit-true cases.
- M3: sh names the real cause when the bundle path cannot run (no JSON
  extractor vs fetch vs extraction failure) and gains python3 and jq
  fallback extractors; docs state the dependency.
- M4: ps1/cmd gate the existing-integration note on plannotator content in
  hooks.json and word the skip state as what those platforms actually do
  (manual instructions suppressed).
- M5: sh bundle lives inside a private mktemp -d, one rm -rf on every exit,
  and a mktemp failure degrades to the fallback instead of aborting.
- M6: README, verifying-your-install, environment-variables, and
  installation docs updated for the credential-free path and skip flags.
- M7: ps1/cmd extraction replaced with a byte-exact string scanner (no
  ConvertFrom/ConvertTo round trip, immune to DateTime coercion), with
  PowerShell-driven unit tests over the captured real attestations
  response plus synthetic DateTime and brace-in-string controls.
- Lows: fail-closed abort pinned by a functional test; TUF beats auth in
  cmd classification (matches sh/ps1); skipped-state output mentions the
  shared ~/.agents/skills; Gemini summary is skip-aware and gains an
  honest not-detected state; --skip-opencode do-not-write switch added
  (flag, env var, config key) across all three scripts.
@backnotprop

Copy link
Copy Markdown
Owner Author

Delta re-review (at cf595e93)

TLDR: all six prior findings genuinely fixed and independently verified, including a 20-case adversarial corpus against the new byte-exact scanner (it held, and it is a real improvement over JSON round-tripping). Two new items from this round, both small: the cmd script's M7 fix introduced a predictable-path temp .ps1 executed with ExecutionPolicy Bypass (the M5 bug class escalated from data-write to code execution; use -EncodedCommand so no file exists), and the new fail-closed test is vacuous (it passes under the exact mutation it cites, because the mutated script still exits nonzero for an incidental reason; assert the refusal is terminal instead). One short round and this merges.

Detail (AI review findings, skim as needed):

Verified fixed: the H1 retry (old-gh repro now falls back and verifies with the honest non-credential-free success line; double-failure still fail-closed); the M2 extractor (both collision payloads plus an 11-case probe, explicit-false veto works, cmd now parses real JSON with the config passed via env); M3 cause-naming with the node/python3/jq chain (python3 path exercised end to end with node absent); M4 truthful Windows wording gated on plannotator content; M5 structurally closed via private mktemp -d with clean degradation; the low set (TUF-beats-auth alignment, agents-skills note, skip-aware Gemini with honest not-detected, --skip-opencode across all three scripts). The default-output parity claim holds: the only delta vs base is the new Gemini not-detected line. Docs verified updated and accurate, including the one line that correctly still says "authenticated" because it describes the manual non-bundle command.

New, needs fixing: (1) install.cmd writes %TEMP%\plannotator-attfetch-%RANDOM%.ps1 and executes it with -ExecutionPolicy Bypass; %RANDOM% is ~15 bits and the base had no temp script at all. Replace with -EncodedCommand (base64 blob, no file) or a private directory. (2) The fail-closed test passes with the exit 1 deleted (110/0 under the mutation) because the mutated script coincidentally still exits nonzero on a later mv failure; assert stdout ends with "Refusing to install." to make it discriminate. Lows worth taking: gate the pwsh-dependent scanner tests to hard-fail under CI rather than skip silently (they run today because ubuntu-latest ships pwsh, but a container migration would silently drop the coverage); make the scanner's IndexOf ordinal (culture-sensitive matching provably mis-offsets on exotic input; fail-safe today since any misparse lands in the H1 retry); tighten the awk token match to require a following colon; avoid parentheses in future cmd reason strings (the echo sits in a parenthesized block).

Sweep: full suite 2868 pass / 0 fail, install tests 110 pass with pwsh (104/6 skip without), typecheck and bash -n clean, shellcheck profile identical to base, PR body accurate with zero em dashes in prose (the one in the fixture is signed Rekor data that must not be altered), 11 expected files, mergeable.

…oof fail-closed test, lows)

- R1: install.cmd's attestation fetcher no longer touches disk. The
  %RANDOM%-named %TEMP% .ps1 (predictable-path code execution, the M5
  class escalated) is replaced by powershell -NoProfile -EncodedCommand
  with a base64(UTF-16LE) payload defined next to its full REM PS:
  plaintext; a test decodes the blob and asserts byte equality with the
  documented lines plus the security-relevant shape (env-var inputs,
  ordinal scan, no JSON round trip, distinct exit codes). Inputs still
  travel via env vars. Verified end to end under pwsh: the decoded blob
  fetched the real attestations response, wrote 2 bundles, gh verified
  the real v0.25.1 binary credential-free (exit 0) and rejected a wrong
  binary (exit 1).
- R2: the fail-closed test now asserts the output ENDS with 'Refusing to
  install.' - mutation-verified: with the verify-failure exit 1 deleted
  the mutant still exits 1 via an incidental mv failure, but the trailing
  mv error breaks the endsWith and the test fails; restored, it passes.
- Lows: CI guard test fails loudly when process.env.CI is set and no
  pwsh/powershell is on PATH (scanner coverage cannot silently vanish);
  scanner IndexOf calls are ordinal in ps1 and the encoded cmd variant;
  the awk skipInstall extraction requires optional-whitespace-then-colon-
  then-brace after the key (string values can no longer anchor it, with
  non-token occurrences skipped, unit-checked against escaped-embedded
  payloads); install.cmd comments warn that the fallback-reason literals
  inside parenthesized blocks must stay parenthesis-free.
@backnotprop

Copy link
Copy Markdown
Owner Author

Final delta (at 938c7177)

TLDR: merge as-is. Both round-two findings fixed and confirmed by the exact mechanisms that exposed them; all four lows fixed; no new issues.

Detail: the encoded-command blob was decoded independently and is byte-identical to its in-file plaintext documentation, with no execution-policy bypass, no temp script, and no JSON round-tripping anywhere in it; the R1 code-execution risk is eliminated rather than mitigated (the surviving bundle data file is consumed by gh, not executed, and any substitution still faces full Sigstore verification). The fail-closed mutation now fails the suite exactly as intended. The ordinal fix does real work (the zero-width-joiner decoy no longer matches, proven against the raw .NET behavior), the full 20-case corpus passed against both scanner variants with zero violations, and the two variants remain character-identical so they cannot drift. Twelve config-collision payloads all behave, including the round-two residual. The CI guard fails loudly on a simulated pwsh-less CI rather than skipping. Live end to end: credential-free verify passes logged out, the old-gh retry path works, zero temp leaks.

113 install tests with pwsh / 107+6 skips without, full suite 2871 pass / 0 fail, shellcheck profile identical to base, zero em dashes, four files of pure script churn, mergeable.

This closes issue #1178's implementation review. Thanks again to @astradevkin for two designs that survived contact with adversarial review intact; the credential-free path ships enforcing the identical constraint set with zero credentials, and the opt-outs ship with honest three-state reporting and byte-identical default behavior.

@backnotprop
backnotprop merged commit 7682628 into main Aug 4, 2026
14 checks passed
@backnotprop
backnotprop deleted the feat/1178-installer-optouts branch August 4, 2026 20:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

install.sh: please add a Codex opt-out flag, and support credential-free attestation verification

1 participant