Skip to content

feat: align command surface with the latest exe.dev docs - #22

Open
lollipopkit wants to merge 18 commits into
mainfrom
feat/exedev-docs-sync
Open

feat: align command surface with the latest exe.dev docs#22
lollipopkit wants to merge 18 commits into
mainfrom
feat/exedev-docs-sync

Conversation

@lollipopkit

@lollipopkit lollipopkit commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Checked against https://exe.dev/llms-full.txt on 2026-08-10.

exedev-ctl: commands upstream documents that only exec could reach

  • pool new/list/delete and new --pool — team reserved capacity
  • share add/remove --root, share receive-email --reply-policy
  • integrations test, integrations catalog, list --usage, --readonly, and the time-boxed --for/--until grants on attach
  • team settings auto-join, tax-ID fields on team billing update
  • billing credits usage/transactions/buy, billing payment, billing update, billing statement

Two documented commands are deliberately left to exec: billing provider link --token=... and exe0-to-exe1 <token>. Both take a token as an argument, and a typed wrapper would only make it easier to leak one into shell history and the process list.

new --command is removed. It is no longer in the upstream option list for new, so forwarding it only produced a server-side error.

Confirmation prompts

The dangerous-command guard now also covers pool delete, billing credits buy, billing payment remove, share access allow, team settings auto-join on, and share add --root — which grants SSH, Terminal, and Shelley access rather than the web-only share it resembles.

Server-side --yes forwarding (team disable, billing credits buy) moves from two hardcoded call sites to one declaration; exec is excluded so raw passthrough stays verbatim. --json and --yes also gained help text, which was previously blank.

exedev-k8s: SSH destination

Bootstrap reached every node at a hardcoded <vm>.exe.xyz. Upstream now documents that ssh_dest may carry a username prefix such as vm+bloggy@exe.dev when a VM hostname cannot route SSH directly, so bootstrap dialled the wrong destination for those VMs. It now reads the destination from the ls response (falling back to the hostname) and re-reads the list after creating VMs so new ones contribute theirs.

Release tooling (separate commit)

clap's #[command(version)] reads CARGO_PKG_VERSION, baked in at compile time, so --version reported whatever the crates were last set to rather than the tag the binary shipped under. scripts/release/set-version.sh rewrites the workspace members, the exedev-core path-dependency requirement, and Cargo.lock, and the release workflow runs it before the build. scripts/release/sync-homebrew-tap.sh regenerates the tap formula from published assets.

Docs

docs/exe-dev-api-reference.md (CLI reference section 10 → 11, check date, ssh_host/ssh_user, the llms-full.txt entry point), docs/exedev-automation.md, both cli/README coverage lists, both k8s_cli/README files — the old text described bootstrap as using ssh exe.dev ssh <vm>, which the code has never done — and the exedev-ctl skill.

Test

cargo test: 61 passed, 0 failed. cargo fmt --check clean, no new clippy warnings.

Summary by CodeRabbit

  • New Features

    • Added pool management, billing, payment, statement, tax details, team auto-join, and enhanced sharing commands.
    • Added integration read-only mode, credential testing, catalogs, and attachment expiry options.
    • Added global --yes support for eligible confirmation prompts.
  • Bug Fixes

    • Improved Kubernetes bootstrap, SSH routing, command safety, timeouts, secret protection, and readiness checks.
    • Added HTTPS endpoint validation and duplicate VM-name detection.
  • Documentation

    • Expanded CLI, billing, pool, integration, SSH, automation, and safety guidance in English and Chinese.
  • Chores

    • Improved release validation, version synchronization, publication verification, and Homebrew package generation.

Summary

Changes

  • Release pipeline: tag resolution, build matrix, publish protocol: Reworked .github/workflows/release.yml into a resolve/build/verify/publish/confirm pipeline with a dedicated resolve job that validates the tag (check-version.sh), resolves it to an immutable commit (peeling annotated tags), and pins every matrix build to that SHA. A separate verify job re-checks the tag before publish (which alone holds contents:write), and a confirm job re-checks after. New scripts: check-version.sh (semver grammar), set-version.sh (rewrites workspace crate versions + Cargo.lock atomically with restore-on-failure), sync-homebrew-tap.sh (generates a Homebrew formula from downloaded release archives).
  • exedev-ctl command surface, quoting, and dangerous-command guard: Expanded cli.rs/cli_command.rs typed command coverage (pool, invite, integrations, billing, shelley, browser, domain wildcard, team billing/auth/settings/vm, share root/reply-policy, exec passthrough) and core/src/shell.rs's is_dangerous guard with the corresponding entries plus raw-exec passthrough rules.
  • exedev-ctl HTTPS client transport: core/src/client.rs: ExeDevClient built with redirects disabled and exec() refusing non-https endpoints because the API key is sent as a bearer token.
  • Fleet file validation and node planning: k8s_cli/src/fleet.rs: parses/validates fleet.yaml, expands pools into NodeSpecs (control plane, project/task workers, spare pools), resolves cpu/memory/tags against defaults, generates exedev.dev labels and NoSchedule taints, and rejects duplicate generated VM names across pools and roles.
  • exe.dev inventory and SSH-destination parsing: k8s_cli/src/manager/parsing.rs: parse_vm_names / parse_ssh_destinations interpret exe.dev ls JSON (and rendered-table/output-wrapped listings), prefer authoritative vm_name fields over generic name, validate ssh destinations, and refuse non-JSON or empty responses rather than inventing inventory.
  • Remote SSH execution, retry policy, and bootstrap scripts: k8s_cli/src/manager/process.rs and scripts.rs: remote commands run via ssh with a wrapped status-marker script, hostname-mismatch guard, whole-exchange timeout, kill-on-drop, and retry only when the remote side provably ran nothing; scripts cover Tailscale up with Tailnet-Lock detection, k3s server/agent install with systemd/openrc and no-supervisor fallback.
  • Bootstrap orchestration, existing-cluster safety, and metadata reconciliation: k8s_cli/src/manager/mod.rs (+ kubectl.rs): run_bootstrap orders VM creation -> Tailscale -> k3s server -> token/kubeconfig fetch -> agents -> API/node readiness -> node label/taint reconciliation -> optional manifest apply; --mode existing validates kubectl targets the K3S_URL cluster before any VM is created; apply_node_metadata removes stale tool-owned labels/taints.
  • Secret/token/kubeconfig state persistence: k8s_cli/src/manager/state.rs: state lives under .exedev-k8s/<sanitized-cluster-name>/ with a legacy-dir adoption migration; token generation uses hard-link winner semantics; writes are staged 0600 and atomically renamed; reads reject symlinks and tighten permissions; parent dirs are fsynced.
  • Fleet/bootstrap unit tests: k8s_cli/src/manager/tests.rs plus in-module fleet.rs tests exercise fleet expansion/validation, parsing wrappers, destination fallbacks, secret-file permissions/TOCTOU/races, remote stdout/status parsing, ssh arg construction, and endpoint comparison.
  • Documentation and skill reference accuracy: Updated README.md/README.zh-CN.md, cli/README(.zh-CN).md, k8s_cli/README(.zh-CN).md, docs/exe-dev-api-reference.md, docs/exedev-automation.md, skills/exedev-ctl/SKILL.md, and skills/exedev-ctl/references/exedev-ctl.md to describe transports, confirmation guards, release archive layout, k3s fleet flow, and token guidance.

clap's `#[command(version)]` reads CARGO_PKG_VERSION, which is baked in at
compile time from Cargo.toml, so `--version` reported whatever the crates were
last set to rather than the tag the binary shipped under.

`scripts/release/set-version.sh` rewrites every workspace member, the
`exedev-core` path-dependency requirement, and Cargo.lock (the release build
runs with --locked). The release workflow runs it before the build step.

`scripts/release/sync-homebrew-tap.sh` regenerates the tap formula from the
published release assets, checking the archive payload against the member names
the formula installs.
Checked against https://exe.dev/llms-full.txt on 2026-08-10.

exedev-ctl gains the commands and flags that upstream documents but this CLI
could only reach through `exec`:

- `pool new/list/delete` and `new --pool` for team reserved capacity
- `share add/remove --root` and `share receive-email --reply-policy`
- `integrations test`, `integrations catalog`, `list --usage`, `--readonly`,
  and the time-boxed `--for`/`--until` grants on `attach`
- `team settings auto-join` and tax-ID fields on `team billing update`
- `billing credits usage/transactions/buy`, `billing payment`,
  `billing update`, and `billing statement`

`billing provider link` and `exe0-to-exe1` are deliberately left to `exec`:
both take a token as an argument, and a typed wrapper would only make it easier
to leak one into shell history and the process list.

`new --command` is removed; it is no longer in the upstream option list for
`new`, so forwarding it only produced a server-side error.

The dangerous-command guard now also covers `pool delete`,
`billing credits buy`, `billing payment remove`, `share access allow`,
`team settings auto-join on`, and `share add --root`, which grants SSH,
Terminal, and Shelley access rather than the web-only share it resembles.
Server-side `--yes` forwarding moves from two hardcoded call sites to one
declaration, and `exec` is excluded so raw passthrough stays verbatim.

exedev-k8s reached nodes at a hardcoded `<vm>.exe.xyz`. Upstream now documents
that `ssh_dest` may carry a username prefix such as `vm+bloggy@exe.dev` when a
VM hostname cannot route SSH directly, so bootstrap read the wrong destination
for those VMs. It now takes the destination from the `ls` response, falling
back to the hostname, and re-reads the list after creating VMs so new ones
contribute theirs.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 46d2207f-ed2d-41f4-9305-9c6050ed1dfe

📥 Commits

Reviewing files that changed from the base of the PR and between 74073e5 and c4e1d13.

📒 Files selected for processing (13)
  • .github/workflows/release.yml
  • cli/README.md
  • cli/README.zh-CN.md
  • cli/src/cli.rs
  • core/src/shell.rs
  • k8s_cli/src/fleet.rs
  • k8s_cli/src/manager/mod.rs
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/process.rs
  • k8s_cli/src/manager/scripts.rs
  • k8s_cli/src/manager/state.rs
  • k8s_cli/src/manager/tests.rs
  • skills/exedev-ctl/references/exedev-ctl.md

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9abd1831-79fc-4ee3-80c6-51e37eff2f42

📥 Commits

Reviewing files that changed from the base of the PR and between 49b0959 and 74073e5.

📒 Files selected for processing (8)
  • cli/src/lib.rs
  • core/src/client.rs
  • k8s_cli/src/fleet.rs
  • k8s_cli/src/manager/mod.rs
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/state.rs
  • k8s_cli/src/manager/tests.rs
  • scripts/release/set-version.sh
🚧 Files skipped from review as they are similar to previous changes (4)
  • scripts/release/set-version.sh
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/fleet.rs
  • k8s_cli/src/manager/mod.rs
📜 Recent review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: winnowl/review
🔇 Additional comments (8)
k8s_cli/src/manager/tests.rs (2)

503-508: LGTM!


778-862: LGTM!

k8s_cli/src/manager/state.rs (2)

75-91: LGTM!


303-308: LGTM!

core/src/client.rs (3)

1-1: LGTM!


32-47: LGTM!


49-58: LGTM!

cli/src/lib.rs (1)

48-48: LGTM!


📝 Walkthrough

Walkthrough

The PR expands CLI commands and confirmation handling, adds SSH destination-aware Kubernetes bootstrap operations, improves secret-file protection, updates documentation, and introduces immutable release versioning and Homebrew formula automation.

Changes

CLI command and safety updates

Layer / File(s) Summary
CLI command contracts
cli/src/cli.rs
Adds pool, billing, integration, sharing, and team settings commands and options.
Command serialization and confirmation forwarding
cli/src/cli_command.rs
Serializes new commands, forwards confirmation flags, and preserves raw exec arguments.
Dangerous-command handling and documentation
core/src/shell.rs, README*, cli/README*, skills/exedev-ctl/*, docs/exe-dev-api-reference.md
Detects additional dangerous commands and documents confirmation behavior, command coverage, SSH destinations, and token-bearing raw commands.

Kubernetes SSH destination routing

Layer / File(s) Summary
Inventory and SSH destination parsing
k8s_cli/src/manager/parsing.rs, k8s_cli/src/manager/mod.rs, k8s_cli/src/manager/process.rs
Parses reported SSH destinations and builds shared VM inventory data.
Bootstrap and cluster provisioning
k8s_cli/src/manager/mod.rs, k8s_cli/src/manager/process.rs, k8s_cli/src/manager/scripts.rs
Passes shared SSH targets through provisioning, diagnostics, token retrieval, kubeconfig retrieval, and readiness checks.
State protection and validation
k8s_cli/src/manager/state.rs, k8s_cli/src/manager/tests.rs, k8s_cli/src/fleet.rs, core/src/client.rs
Protects secret files, restricts state paths and permissions, validates fleet names and endpoints, and covers failure cases.
SSH routing documentation
k8s_cli/README*, docs/exedev-automation.md, docs/exe-dev-api-reference.md
Documents direct SSH routing through ssh_dest and hostname fallbacks.

Release automation

Layer / File(s) Summary
Release version synchronization
.github/workflows/release.yml, scripts/release/check-version.sh, scripts/release/set-version.sh
Validates versions, updates workspace manifests, resolves immutable commits, and verifies the tag before and after publication.
Homebrew formula generation
scripts/release/sync-homebrew-tap.sh
Downloads release archives, validates contents, computes checksums, and generates the formula.

Possibly related PRs


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Line 84: Update all three Bash steps in the release workflow that assign or
use inputs.tag_name to receive the value through the step environment, then
reference that environment variable inside the scripts instead of interpolating
inputs.tag_name directly; preserve the existing tag behavior while preventing
shell command injection.

In `@cli/README.md`:
- Around line 167-177: Correct the token-exposure guidance: in cli/README.md
lines 167-177 and cli/README.zh-CN.md lines 165-173, remove any implication that
raw exec protects tokens passed as arguments; in skills/exedev-ctl/SKILL.md
lines 42-44, remove the claim that exec or SSH prevents persisted-history
exposure; and in skills/exedev-ctl/references/exedev-ctl.md lines 272-275, state
that environment expansion avoids literal shell-history exposure but not local
process-argument exposure, unless a supported secure secret-input mechanism is
documented.

In `@k8s_cli/src/manager/parsing.rs`:
- Around line 67-71: Update parse_ssh_destinations to detect a present output
field in the outer JSON object, parse its string contents as the inner JSON
payload, and pass that payload to collect_ssh_destinations while preserving
direct-response parsing. Add a regression test covering the wrapped
{"output":"<JSON>"} response and verifying the reported ssh_dest values are
retained.

In `@scripts/release/set-version.sh`:
- Around line 30-33: Update the VERSION validation in the release script to
enforce full SemVer rules: reject leading-zero numeric components and numeric
prerelease identifiers, while accepting valid prerelease and build metadata such
as 1.2.3-rc.1+build.5. Use a complete SemVer validator or equivalent regex, and
add coverage for these accepted and rejected cases.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d3c20e12-9559-4cfe-a822-8023fd9cdddb

📥 Commits

Reviewing files that changed from the base of the PR and between 64ce62f and e32ca45.

📒 Files selected for processing (20)
  • .github/workflows/release.yml
  • README.md
  • README.zh-CN.md
  • cli/README.md
  • cli/README.zh-CN.md
  • cli/src/cli.rs
  • cli/src/cli_command.rs
  • core/src/shell.rs
  • docs/exe-dev-api-reference.md
  • docs/exedev-automation.md
  • k8s_cli/README.md
  • k8s_cli/README.zh-CN.md
  • k8s_cli/src/manager/mod.rs
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/process.rs
  • k8s_cli/src/manager/tests.rs
  • scripts/release/set-version.sh
  • scripts/release/sync-homebrew-tap.sh
  • skills/exedev-ctl/SKILL.md
  • skills/exedev-ctl/references/exedev-ctl.md
📜 Review details
⏰ Context from checks skipped due to timeout. (3)
  • GitHub Check: fmt, check, test
  • GitHub Check: winnowl/review
  • GitHub Check: fmt, check, test
🧰 Additional context used
🪛 LanguageTool
skills/exedev-ctl/references/exedev-ctl.md

[style] ~272-~272: Try using a descriptive adverb here.
Context: ...nd exe0-to-exe1 have no typed wrapper on purpose: each takes a token as an argument. Run...

(ON_PURPOSE_DELIBERATELY)

skills/exedev-ctl/SKILL.md

[style] ~44-~44: Try using a descriptive adverb here.
Context: ...inkandexe0-to-exe1` have no wrapper on purpose: both take a token as an argument, so r...

(ON_PURPOSE_DELIBERATELY)

🪛 Shellcheck (0.11.0)
scripts/release/sync-homebrew-tap.sh

[warning] 5-5: REPO_ROOT appears unused. Verify use (or export if used externally).

(SC2034)


[info] 170-170: Double quote to prevent globbing and word splitting.

(SC2086)

🪛 zizmor (1.29.0)
.github/workflows/release.yml

[error] 84-84: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🔇 Additional comments (19)
scripts/release/sync-homebrew-tap.sh (1)

1-171: LGTM!

k8s_cli/src/manager/parsing.rs (1)

12-14: LGTM!

Also applies to: 45-45

k8s_cli/src/manager/mod.rs (1)

36-37: LGTM!

Also applies to: 80-110, 173-189, 317-333, 352-374, 390-392, 413-453, 463-465, 477-479, 488-488, 517-517, 543-569

k8s_cli/src/manager/process.rs (1)

5-5: LGTM!

Also applies to: 31-52, 95-96, 117-134, 325-335

k8s_cli/src/manager/tests.rs (1)

3-5: LGTM!

Also applies to: 142-142, 157-185

docs/exe-dev-api-reference.md (1)

4-4: LGTM!

Also applies to: 14-17, 49-61, 313-323

docs/exedev-automation.md (1)

142-146: LGTM!

k8s_cli/README.md (1)

224-229: LGTM!

k8s_cli/README.zh-CN.md (1)

204-208: LGTM!

cli/src/cli.rs (1)

16-23: LGTM!

Also applies to: 68-69, 139-141, 262-280, 292-296, 401-430, 476-553, 645-668, 709-780, 800-911

cli/src/cli_command.rs (1)

10-18: LGTM!

Also applies to: 57-57, 114-114, 147-169, 192-214, 257-350, 393-398, 439-444, 467-510, 522-570, 866-1061

core/src/shell.rs (1)

48-76: LGTM!

Also applies to: 107-120

README.md (1)

47-52: LGTM!

README.zh-CN.md (1)

46-51: LGTM!

cli/README.md (1)

162-164: LGTM!

skills/exedev-ctl/SKILL.md (1)

36-39: LGTM!

skills/exedev-ctl/references/exedev-ctl.md (1)

202-264: LGTM!

.github/workflows/release.yml (1)

75-83: LGTM!

Also applies to: 85-87

scripts/release/set-version.sh (1)

1-29: LGTM!

Also applies to: 35-96

Comment thread .github/workflows/release.yml Outdated
Comment thread cli/README.md
Comment thread k8s_cli/src/manager/parsing.rs
Comment thread scripts/release/set-version.sh Outdated
The three release-workflow steps that resolve the tag interpolated
`inputs.tag_name` straight into their bash scripts, so a crafted
workflow_dispatch input ran as shell. They now receive it through the step
environment and read the variable instead.

`set-version.sh` validated versions with a loose "digits, dots and dashes"
pattern. It rejected the valid tag 1.2.3-rc.1+build.5, because build metadata
can follow a prerelease, and accepted invalid ones like 01.2.3, which cargo
refuses later in the release with a much less obvious error. Replaced with
semver.org's reference grammar.

The docs presented `exec --` and `ssh exe.dev` as a way to keep tokens out of
persisted history, which they are not: the token is a command argument either
way. They now state the actual reason these two commands have no wrapper (they
are one-time onboarding steps a wrapper would not make safer) and the actual
exposure (variable expansion keeps the literal out of shell history, but the
expanded value is still readable in local process arguments).

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (4)
  • 🟡 Medium The Homebrew sync script accepts any non-empty RELEASE_TAG and writes a formula without validating that it is a release tag/semantic version. For example, RELEASE_TAG='not a version' (or v01.2.3) is used to construct the archive filenames/URLs, and if matching assets happen to exist the script reaches cat &gt; "$TAP_FORMULA_PATH"; the generated formula then contains that malformed release URL/version. This violates the requirement that malformed semantic versions fail before writing an unusable formula. The claim would be false only if an earlier guaranteed contract rejects every malformed tag before this script runs, but the script is explicitly callable with an argument/environment and performs no such check. (inline)
  • 🟡 Medium The script accepts an arbitrary RELEASE_TAG and interpolates it into archive paths, download URLs, and double-quoted Ruby strings without validating or escaping it. A release tag such as v1.2.3"-bad (or any tag that does not match the semver/tag contract) can make the generated formula syntactically invalid; slash-containing tags can also make the local curl output path fail before the intended diagnostic. The release workflow validates indirectly through set-version, but this standalone sync script also supports gh/env tags and does not enforce that invariant. (inline)
  • 🟡 Medium Formula metadata is inserted into Ruby string/class syntax without escaping or validating it. Supplying FORMULA_DESC containing " or a newline, or FORMULA_CLASS containing non-Ruby identifier text, produces a formula that Homebrew cannot parse (and FORMULA_DESC can inject additional formula code). The same applies to REPO_SLUG and FORMULA_LICENSE in quoted Ruby strings, so the script's configurable inputs can make formula generation succeed while producing an unusable tap file. (inline)
  • 🟡 Medium Raw dangerous commands do not honor the global --yes flag. build_command explicitly excludes Commands::Exec from needs_server_confirmation, so exedev-ctl --transport http --yes exec -- team disable sends team disable without --yes; /exec has no pty and the server-side confirmation cannot be answered, causing the command to hang or fail instead of honoring the documented global confirmation override. This would be disproven if raw exec is intentionally required to make users place --yes in the raw command and the global flag is documented as inapplicable to raw commands. (inline)
📋 Additional findings from this change (not shown inline) (8)
  • 🟠 High A hung remote command can block bootstrap without a bounded timeout. (k8s_cli/src/manager/process.rs) — anchor-outside-diff
  • 🟠 High Bootstrap can complete while a fleet node is registered but NotReady. (k8s_cli/src/manager/mod.rs) — anchor-outside-diff
  • 🟡 Medium Manual dispatch does not constrain tag_name to an existing tag before checkout or release publication. actions/checkout receives the raw input as ref, so an input such as main or an arbitrary branch/SHA is checked out; the later version step then rejects it as non-semver, while an input that happens to be a valid semver but is not an existing tag can build from that ref and ask the publish action to create a release for a tag that was never checked out. The claim would be false only if the workflow's dispatch UI or repository permissions guaranteed that the input can only name an existing tag, which GitHub's string input does not. (.github/workflows/release.yml) — anchor-outside-diff
  • 🟡 Medium Raw exec is not preserved on the SSH transport when global JSON output is requested. (cli/src/ssh.rs) — anchor-outside-diff
  • 🟡 Medium Integration creation and attachment can change access/integration state without the local dangerous-command prompt. (core/src/shell.rs) — anchor-outside-diff
  • 🟡 Medium Several team authority changes are not covered by the dangerous-command matcher. (core/src/shell.rs) — anchor-outside-diff
  • 🟡 Medium Remote bootstrap misclassifies generic exit status 126 as Tailnet Lock and can retry the wrong operation indefinitely until user confirmation. (k8s_cli/src/manager/process.rs) — anchor-outside-diff
  • 🔵 Low Danger matching is not anchored at subcommand boundaries, so neighboring command names can spuriously trigger confirmation. (core/src/shell.rs) — anchor-outside-diff
🤖 Prompt for AI agents — all findings (12)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Findings on this change (also posted as inline comments) (4)

In scripts/release/sync-homebrew-tap.sh around line 41, address this finding:
The Homebrew sync script accepts any non-empty RELEASE_TAG and writes a formula without validating that it is a release tag/semantic version. For example, `RELEASE_TAG='not a version'` (or `v01.2.3`) is used to construct the archive filenames/URLs, and if matching assets happen to exist the script reaches `cat > "$TAP_FORMULA_PATH"`; the generated formula then contains that malformed release URL/version. This violates the requirement that malformed semantic versions fail before writing an unusable formula. The claim would be false only if an earlier guaranteed contract rejects every malformed tag before this script runs, but the script is explicitly callable with an argument/environment and performs no such check.

In scripts/release/sync-homebrew-tap.sh around line 15, address this finding:
The script accepts an arbitrary RELEASE_TAG and interpolates it into archive paths, download URLs, and double-quoted Ruby strings without validating or escaping it. A release tag such as `v1.2.3"-bad` (or any tag that does not match the semver/tag contract) can make the generated formula syntactically invalid; slash-containing tags can also make the local curl output path fail before the intended diagnostic. The release workflow validates indirectly through set-version, but this standalone sync script also supports gh/env tags and does not enforce that invariant.

In scripts/release/sync-homebrew-tap.sh around line 120, address this finding:
Formula metadata is inserted into Ruby string/class syntax without escaping or validating it. Supplying FORMULA_DESC containing `"` or a newline, or FORMULA_CLASS containing non-Ruby identifier text, produces a formula that Homebrew cannot parse (and FORMULA_DESC can inject additional formula code). The same applies to REPO_SLUG and FORMULA_LICENSE in quoted Ruby strings, so the script's configurable inputs can make formula generation succeed while producing an unusable tap file.

In cli/src/cli_command.rs around line 151, address this finding:
Raw dangerous commands do not honor the global `--yes` flag. `build_command` explicitly excludes `Commands::Exec` from `needs_server_confirmation`, so `exedev-ctl --transport http --yes exec -- team disable` sends `team disable` without `--yes`; `/exec` has no pty and the server-side confirmation cannot be answered, causing the command to hang or fail instead of honoring the documented global confirmation override. This would be disproven if raw `exec` is intentionally required to make users place `--yes` in the raw command and the global flag is documented as inapplicable to raw commands.

## Additional findings on this change (not posted inline) (8)

In k8s_cli/src/manager/process.rs around line 233, address this finding:
A hung remote command can block bootstrap without a bounded timeout.

In k8s_cli/src/manager/mod.rs around line 602, address this finding:
Bootstrap can complete while a fleet node is registered but NotReady.

In .github/workflows/release.yml around line 46, address this finding:
Manual dispatch does not constrain `tag_name` to an existing tag before checkout or release publication. `actions/checkout` receives the raw input as `ref`, so an input such as `main` or an arbitrary branch/SHA is checked out; the later version step then rejects it as non-semver, while an input that happens to be a valid semver but is not an existing tag can build from that ref and ask the publish action to create a release for a tag that was never checked out. The claim would be false only if the workflow's dispatch UI or repository permissions guaranteed that the input can only name an existing tag, which GitHub's string input does not.

In cli/src/ssh.rs around line 17, address this finding:
Raw exec is not preserved on the SSH transport when global JSON output is requested.

In core/src/shell.rs around line 52, address this finding:
Integration creation and attachment can change access/integration state without the local dangerous-command prompt.

In core/src/shell.rs around line 55, address this finding:
Several team authority changes are not covered by the dangerous-command matcher.

In k8s_cli/src/manager/process.rs around line 68, address this finding:
Remote bootstrap misclassifies generic exit status 126 as Tailnet Lock and can retry the wrong operation indefinitely until user confirmation.

In core/src/shell.rs around line 68, address this finding:
Danger matching is not anchored at subcommand boundaries, so neighboring command names can spuriously trigger confirmation.
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 5 of 6 areas reviewed

Comment thread scripts/release/sync-homebrew-tap.sh Outdated
RELEASE_TAG="$(gh release view --repo "$REPO_SLUG" --json tagName -q .tagName)"
fi

VERSION="${RELEASE_TAG#v}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Error Handling | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
The Homebrew sync script accepts any non-empty RELEASE_TAG and writes a formula without validating that it is a release tag/semantic version. For example, `RELEASE_TAG='not a version'` (or `v01.2.3`) is used to construct the archive filenames/URLs, and if matching assets happen to exist the script reaches `cat > "$TAP_FORMULA_PATH"`; the generated formula then contains that malformed release URL/version. This violates the requirement that malformed semantic versions fail before writing an unusable formula. The claim would be false only if an earlier guaranteed contract rejects every malformed tag before this script runs, but the script is explicitly callable with an argument/environment and performs no such check.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
VERSION="${RELEASE_TAG#v}"
VERSION="${RELEASE_TAG#v}"
SEMVER_NUM='(0|[1-9][0-9]*)'
SEMVER_PRE_ID="(${SEMVER_NUM}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
SEMVER_RE="^${SEMVER_NUM}\\.${SEMVER_NUM}\\.${SEMVER_NUM}(-${SEMVER_PRE_ID}(\\.${SEMVER_PRE_ID})*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$"
if [[ ! "$VERSION" =~ $SEMVER_RE ]]; then
echo "not a semantic version: $VERSION" >&2
exit 1
fi

TAP_REPO_PATH="${TAP_REPO_PATH:-$HOME/proj/homebrew-tap}"
TAP_FORMULA_PATH="${TAP_FORMULA_PATH:-}"
EXPLICIT_TAP_FORMULA_PATH="${TAP_FORMULA_PATH:-}"
RELEASE_TAG="${1:-${RELEASE_TAG:-}}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact set of characters accepted by curl and GitHub release URLs varies, so the quoted-tag-to-invalid-Ruby example is not necessary to establish the defect.
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
The script accepts an arbitrary RELEASE_TAG and interpolates it into archive paths, download URLs, and double-quoted Ruby strings without validating or escaping it. A release tag such as `v1.2.3"-bad` (or any tag that does not match the semver/tag contract) can make the generated formula syntactically invalid; slash-containing tags can also make the local curl output path fail before the intended diagnostic. The release workflow validates indirectly through set-version, but this standalone sync script also supports gh/env tags and does not enforce that invariant.

Comment thread scripts/release/sync-homebrew-tap.sh Outdated
}

mkdir -p "$(dirname "$TAP_FORMULA_PATH")"
cat > "$TAP_FORMULA_PATH" <<FORMULA

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The script has no in-repository caller, so actual production exposure depends on how operators invoke it and which environment variables they set.
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
Formula metadata is inserted into Ruby string/class syntax without escaping or validating it. Supplying FORMULA_DESC containing `"` or a newline, or FORMULA_CLASS containing non-Ruby identifier text, produces a formula that Homebrew cannot parse (and FORMULA_DESC can inject additional formula code). The same applies to REPO_SLUG and FORMULA_LICENSE in quoted Ruby strings, so the script's configurable inputs can make formula generation succeed while producing an unusable tap file.

Comment thread cli/src/cli_command.rs
Commands::Exec(cmd) => words.extend(cmd.command.clone()),
}

if !matches!(command, Commands::Exec(_)) && needs_server_confirmation(&words) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: unknown
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The raw exec passthrough behavior is intentional in the implementation comments and tests, but the global --yes help text and README describe the flag without excluding exec; resolving that product-policy ambiguity would require an explicit documentation decision.
🤖 Prompt for AI agents
In cli/src/cli_command.rs, address this finding:
Raw dangerous commands do not honor the global `--yes` flag. `build_command` explicitly excludes `Commands::Exec` from `needs_server_confirmation`, so `exedev-ctl --transport http --yes exec -- team disable` sends `team disable` without `--yes`; `/exec` has no pty and the server-side confirmation cannot be answered, causing the command to hang or fail instead of honoring the documented global confirmation override. This would be disproven if raw `exec` is intentionally required to make users place `--yes` in the raw command and the global flag is documented as inapplicable to raw commands.

sync-homebrew-tap.sh took a release tag from an argument, the environment, or
`gh` and interpolated it into download URLs, local paths, and double-quoted Ruby
strings with no validation, so a tag like `v1.2.3"-bad` or one containing a
slash produced a formula that generation reported as a success and Homebrew
could not parse. The same held for the configurable formula metadata. Both are
now validated up front, against the same semver grammar set-version.sh uses.

The dangerous-command guard prompted for `integrations detach` and `edit` but
not for `add` (which accepts --attach specs) or `attach`, so handing a
credential to a VM was silent while taking it away was not. It also covered
`team remove`, `role`, and `transfer` but not `team add`, `team auth set`, or
`team settings vm-sharing`, which change who holds authority over the team.

The release workflow passed a dispatch input to actions/checkout as a bare ref,
so a branch name or SHA would build a ref that is not the tag the publish job
creates a release for. It now resolves the input under refs/tags/.

`exec` still sends its arguments verbatim, and the reasoning is now recorded
next to the code and in both READMEs: injecting `--yes` could carry out a
destructive action the user never typed, while the global `--json` selects an
output format and does not change what the command does.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Around line 43-49: Update the Checkout step in the release workflow to set
actions/checkout’s persist-credentials option to false, while preserving the
existing ref selection and all other checkout behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 90af0310-a4e2-4310-ab24-7b6fed327092

📥 Commits

Reviewing files that changed from the base of the PR and between 61304ef and c281a48.

📒 Files selected for processing (6)
  • .github/workflows/release.yml
  • cli/README.md
  • cli/README.zh-CN.md
  • cli/src/cli_command.rs
  • core/src/shell.rs
  • scripts/release/sync-homebrew-tap.sh
🚧 Files skipped from review as they are similar to previous changes (4)
  • core/src/shell.rs
  • cli/README.md
  • cli/README.zh-CN.md
  • cli/src/cli_command.rs
📜 Review details
⏰ Context from checks skipped due to timeout. (2)
  • GitHub Check: winnowl/review
  • GitHub Check: fmt, check, test
🧰 Additional context used
🪛 zizmor (1.29.0)
.github/workflows/release.yml

[warning] 43-49: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🔇 Additional comments (2)
.github/workflows/release.yml (1)

78-94: LGTM!

Also applies to: 101-108, 151-157

scripts/release/sync-homebrew-tap.sh (1)

43-78: LGTM!

Comment thread .github/workflows/release.yml Outdated
The build job runs no git operations after checkout and compiles third-party
crates, whose build scripts execute with the workspace present. Leaving the
token in .git/config only widened what a compromised dependency could reach.

Nothing depended on it: the workspace has no git dependencies, so cargo never
needs the credential, and the publish job does not check out at all —
softprops/action-gh-release authenticates with GITHUB_TOKEN directly.

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (1)
  • 🟡 Medium Authoritative SSH destinations are lost when exe.dev ls returns a JSON object with an output string containing the actual serialized/listed payload. parse_vm_names explicitly falls back to value.output, but parse_ssh_destinations only traverses the JSON object and never parses output; consequently the VM name may be discovered from text while its ssh_dest is omitted and SshTargets::dest uses &lt;vm&gt;.exe.xyz instead of the reported user@host destination. This would be disproven if the ls API never wraps its JSON/text output in an output field, despite the existing parser's explicit support for that shape for names. (inline)
⛔ Unresolved from previous review (1) — not approved until fixed
  • k8s_cli/src/manager/process.rs: A hung remote command can block bootstrap without a bounded timeout.
📋 Additional findings from this change (not shown inline) (4)
  • 🟠 High Remote SSH operations have no bounded process/request lifetime. ConnectTimeout=15 only limits connection establishment; capture_remote_ssh_output awaits child.wait_with_output() indefinitely, so a remote script that hangs (for example a package install, curl, or service operation) blocks bootstrap forever and can retain the child/pipes indefinitely. This would be disproven if an external process supervisor guaranteed a timeout for every invocation, but the shown code invokes ssh directly and has no such timeout. (k8s_cli/src/manager/process.rs) — anchor-outside-diff
  • 🟡 Medium The SSH retry policy can repeat unsafe bootstrap effects after an ambiguous transport failure. Any SSH exit status 255 is retried, but status 255 can occur after the remote script has already installed Tailscale/k3s or changed service state and the connection drops while returning output; the next attempt reruns the entire script. The wrapper makes scripts report status but does not make these operations transactional or idempotent (and some install/start steps have side effects), so a transient disconnect can duplicate/partially repeat bootstrap effects. This would be disproven if ssh status 255 were proven to occur only before remote execution for the deployed SSH path. (k8s_cli/src/manager/process.rs) — anchor-outside-diff
  • 🟡 Medium Node taint application is additive rather than reconciliatory: it applies a desired taint with --overwrite, but never removes taints that are no longer in the fleet plan. If a previously isolated worker is changed to taint: null, rerunning bootstrap leaves the old NoSchedule taint in place; metadata therefore does not represent the plan and can keep workloads unschedulable. This would be disproven if the product intentionally guarantees that taints are immutable and stale taints are managed only by a separate command, but bootstrap's overwrite semantics and metadata obligation imply reconciliation. (k8s_cli/src/manager/mod.rs) — anchor-outside-diff
  • 🟡 Medium Established SSH sessions have no bounded lifetime, so a bootstrap can hang indefinitely after the TCP connection succeeds. (k8s_cli/src/manager/process.rs) — anchor-unreliable
♻️ Previously reported (still present) (3)
  • 🟠 High ARCHIVE_PREFIX is accepted from the environment and interpolated into Ruby double-quoted url strings without validation, so a value containing a quote and Ruby interpolation/code can generate a formula with altered syntax or execute code when Homebrew evaluates it. (scripts/release/sync-homebrew-tap.sh) — previously-reported
  • 🟠 High Bootstrap proceeds to metadata and manifest deployment after nodes merely register, even if none is Ready. wait_for_kubernetes_nodes returns when every expected name exists but never checks KubernetesNode.ready; run_bootstrap then immediately calls apply_node_metadata and kubectl_apply. A cluster can report all nodes with Ready=False/Unknown and still receive labels, taints, and manifests, violating the readiness invariant. This would be disproven if Kubernetes registration were guaranteed to imply the intended Ready condition for every node, but k3s commonly registers nodes before readiness and the parser explicitly distinguishes ready. (k8s_cli/src/manager/mod.rs) — previously-reported
  • 🟡 Medium Secret files are written with their final 0600 mode only after contents have been written. write_secret_file uses fs::write first, which creates a new file under the process umask (potentially 0644), then calls set_permissions; an interruption/crash between those calls leaves the kubeconfig or k3s token readable. The function also follows an existing symlink, so a path supplied via --kubeconfig can redirect secret contents to another file. This would be disproven if the CLI always runs with a guaranteed restrictive umask and rejects symlinked paths externally, neither of which is enforced here. (k8s_cli/src/manager/state.rs) — previously-reported
❓ Low-evidence leads (not confirmed — verify before acting) (1)
  • Homebrew generation can succeed with invalid non-arm64 assets because archive-member validation is performed only against macos-arm64. (scripts/release/sync-homebrew-tap.sh)
🤖 Prompt for AI agents — all findings (9)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Unresolved from the previous review — these block approval, fix them first (1)

In k8s_cli/src/manager/process.rs, address this finding:
A hung remote command can block bootstrap without a bounded timeout.

## Findings on this change (also posted as inline comments) (1)

In k8s_cli/src/manager/parsing.rs around line 67, address this finding:
Authoritative SSH destinations are lost when `exe.dev ls` returns a JSON object with an `output` string containing the actual serialized/listed payload. `parse_vm_names` explicitly falls back to `value.output`, but `parse_ssh_destinations` only traverses the JSON object and never parses `output`; consequently the VM name may be discovered from text while its `ssh_dest` is omitted and `SshTargets::dest` uses `<vm>.exe.xyz` instead of the reported user@host destination. This would be disproven if the ls API never wraps its JSON/text output in an `output` field, despite the existing parser's explicit support for that shape for names.

## Additional findings on this change (not posted inline) (4)

In k8s_cli/src/manager/process.rs around line 233, address this finding:
Remote SSH operations have no bounded process/request lifetime. `ConnectTimeout=15` only limits connection establishment; `capture_remote_ssh_output` awaits `child.wait_with_output()` indefinitely, so a remote script that hangs (for example a package install, curl, or service operation) blocks bootstrap forever and can retain the child/pipes indefinitely. This would be disproven if an external process supervisor guaranteed a timeout for every invocation, but the shown code invokes ssh directly and has no such timeout.

In k8s_cli/src/manager/process.rs around line 251, address this finding:
The SSH retry policy can repeat unsafe bootstrap effects after an ambiguous transport failure. Any SSH exit status 255 is retried, but status 255 can occur after the remote script has already installed Tailscale/k3s or changed service state and the connection drops while returning output; the next attempt reruns the entire script. The wrapper makes scripts report status but does not make these operations transactional or idempotent (and some install/start steps have side effects), so a transient disconnect can duplicate/partially repeat bootstrap effects. This would be disproven if ssh status 255 were proven to occur only before remote execution for the deployed SSH path.

In k8s_cli/src/manager/mod.rs around line 635, address this finding:
Node taint application is additive rather than reconciliatory: it applies a desired taint with `--overwrite`, but never removes taints that are no longer in the fleet plan. If a previously isolated worker is changed to `taint: null`, rerunning bootstrap leaves the old NoSchedule taint in place; metadata therefore does not represent the plan and can keep workloads unschedulable. This would be disproven if the product intentionally guarantees that taints are immutable and stale taints are managed only by a separate command, but bootstrap's overwrite semantics and metadata obligation imply reconciliation.

In k8s_cli/src/manager/process.rs, address this finding:
Established SSH sessions have no bounded lifetime, so a bootstrap can hang indefinitely after the TCP connection succeeds.

## Previously reported and still present (3)

In scripts/release/sync-homebrew-tap.sh around line 20, address this finding:
ARCHIVE_PREFIX is accepted from the environment and interpolated into Ruby double-quoted url strings without validation, so a value containing a quote and Ruby interpolation/code can generate a formula with altered syntax or execute code when Homebrew evaluates it.

In k8s_cli/src/manager/mod.rs around line 602, address this finding:
Bootstrap proceeds to metadata and manifest deployment after nodes merely register, even if none is Ready. `wait_for_kubernetes_nodes` returns when every expected name exists but never checks `KubernetesNode.ready`; `run_bootstrap` then immediately calls `apply_node_metadata` and `kubectl_apply`. A cluster can report all nodes with Ready=False/Unknown and still receive labels, taints, and manifests, violating the readiness invariant. This would be disproven if Kubernetes registration were guaranteed to imply the intended Ready condition for every node, but k3s commonly registers nodes before readiness and the parser explicitly distinguishes `ready`.

In k8s_cli/src/manager/state.rs around line 50, address this finding:
Secret files are written with their final 0600 mode only after contents have been written. `write_secret_file` uses `fs::write` first, which creates a new file under the process umask (potentially 0644), then calls `set_permissions`; an interruption/crash between those calls leaves the kubeconfig or k3s token readable. The function also follows an existing symlink, so a path supplied via `--kubeconfig` can redirect secret contents to another file. This would be disproven if the CLI always runs with a guaranteed restrictive umask and rejects symlinked paths externally, neither of which is enforced here.
📜 Review details

Model

  • gpt-5.6-luna, deepseek-v4-flash

Coverage

  • 4 of 4 areas reviewed

/// username prefix (for example `vm+bloggy@exe.dev`) when they do not. VMs whose
/// destination cannot be read are left out, and the caller falls back to the
/// `<vm>.exe.xyz` hostname.
pub(super) fn parse_ssh_destinations(response: &str) -> BTreeMap<String, String> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The repository does not include a live API fixture proving the exact /exec wrapper shape; this conclusion relies on the existing parse_vm_names support for an output field and the candidate's stated response shape.
🤖 Prompt for AI agents
In k8s_cli/src/manager/parsing.rs, address this finding:
Authoritative SSH destinations are lost when `exe.dev ls` returns a JSON object with an `output` string containing the actual serialized/listed payload. `parse_vm_names` explicitly falls back to `value.output`, but `parse_ssh_destinations` only traverses the JSON object and never parses `output`; consequently the VM name may be discovered from text while its `ssh_dest` is omitted and `SshTargets::dest` uses `<vm>.exe.xyz` instead of the reported user@host destination. This would be disproven if the ls API never wraps its JSON/text output in an `output` field, despite the existing parser's explicit support for that shape for names.

`ConnectTimeout=15` bounded only the connect, so a remote step that stopped
responding (a package install, a curl, a service start) blocked bootstrap with
no upper limit and held the ssh process and its pipes open. Each attempt now
runs under a 15-minute bound covering both the script write and the wait, with
kill_on_drop so the elapsed attempt takes the process with it. A timeout is not
retried: unlike the transport failures the 255 retry exists for, a stuck step
would only be repeated.

write_secret_file created the kubeconfig and cluster token with fs::write and
tightened them afterwards, so the contents existed at the umask's permissions
in between and stayed there if the process died in that window. It also
followed a symlink, letting a --kubeconfig path redirect secret contents into
another file. It now unlinks any existing entry and creates with 0600 applied
at creation.

wait_for_kubernetes_nodes returned as soon as every node name existed, but k3s
registers a node before it can run anything, so labels, taints, and manifests
could be applied to a cluster with every node NotReady. It now waits for the
Ready condition the parser already reads, and names unregistered and unready
nodes separately.

parse_ssh_destinations now also reads the {"output":"<json>"} wrapper that
parse_vm_names falls back to, so a wrapped listing keeps its authoritative
ssh_dest rather than silently degrading to the hostname. A wrapped table stays
empty, as before.

ARCHIVE_PREFIX was the one tap-formula input still reaching the Ruby strings
unvalidated.

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (4)
  • 🟠 High Only the macOS arm64 archive is inspected for required members; the other three downloaded archives can be malformed while the script still writes a successful formula. (inline)
  • 🟠 High The build can compile a different commit from the commit that triggered the release while publishing under the requested tag. (inline)
  • 🟡 Medium Tap layout detection can select an unused formula path. When a sharded tap has not yet created Formula/&lt;first-letter&gt; for this formula, the script falls back to Formula/&lt;name&gt;.rb; conversely, a flat/custom tap containing a directory with that first-letter name is treated as sharded. In either case the new formula can be written successfully where Homebrew will not discover it. (inline)
  • 🔵 Low The generated formula omits LICENSE from doc.install, even though every release archive packages it and the repository's release-install documentation lists it as an archive document. Users installing through the formula therefore do not receive all published documentation/license files. (inline)
📋 Additional findings from this change (not shown inline) (4)
  • 🟠 High A wrapped serialized ls response is parsed inconsistently: inventory names are treated as table text instead of decoding the JSON string in output. For a response such as {"output":"[{\"vm_name\":\"vm-1\",\"ssh_dest\":\"vm+vm-1@exe.dev\"}]"}, parse_ssh_destinations finds the target but parse_vm_names falls back to parse_vm_names_from_text, producing a bogus name like [{"vm_name":"vm-1",...}] rather than vm-1; bootstrap then believes the real VM is missing and can issue duplicate creation (or status/destroy reports incorrect inventory). This is disproven if the exe.dev API never returns serialized JSON inside output for ls, but the changed parser explicitly supports that shape for SSH destinations and its comment says the wrapper is shared. (k8s_cli/src/manager/parsing.rs) — anchor-outside-diff
  • 🟡 Medium Bootstrap accepts empty required credentials/endpoints because require_env only calls env::var and does not reject an empty string. In new mode, TS_AUTHKEY= reaches tailscale up --auth-key ''; in existing mode, empty K3S_URL/K3S_TOKEN pass the initial checks and are embedded in the worker install script. This allows the command to pass confirmation and create VMs before failing remotely (or potentially invoke k3s with an empty endpoint/token), leaving a partially bootstrapped fleet instead of rejecting invalid configuration up front. The claim is false only if the environment-loading layer guarantees these variables can never be present-but-empty, which ordinary process environments do not. (k8s_cli/src/manager/mod.rs) — anchor-outside-diff
  • 🟡 Medium set-version.sh can leave the workspace partially rewritten when any later operation fails. It commits each manifest with mv as it goes, so a missing/invalid later member or path dependency (and any cargo update failure) leaves earlier manifests at the new version while the remaining manifests/lockfile remain old; mv failures also leave the per-file .tmp behind. This violates the migration/error-handling requirement that failures not leave partial temporary files or inconsistent workspace state. For example, if k8s_cli/Cargo.toml is absent or cargo update --workspace cannot reach the registry, core/Cargo.toml and cli/Cargo.toml have already been changed. This is introduced by the new sequential rewrite implementation; it would be disproven only if the script were always run in a disposable checkout and no caller relies on the checkout afterward, but the workflow invokes it in the build checkout and the script is independently callable. (scripts/release/set-version.sh) — anchor-unreliable
  • 🟡 Medium A mutable third-party action reference is executed with the release job's write-capable token, so a compromise or retagging of softprops/action-gh-release@v3 can modify repository releases (and potentially other contents allowed by that token). (.github/workflows/release.yml) — anchor-outside-diff
🤖 Prompt for AI agents — all findings (8)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Findings on this change (also posted as inline comments) (4)

In scripts/release/sync-homebrew-tap.sh around line 140, address this finding:
Only the macOS arm64 archive is inspected for required members; the other three downloaded archives can be malformed while the script still writes a successful formula.

In .github/workflows/release.yml around line 49, address this finding:
The build can compile a different commit from the commit that triggered the release while publishing under the requested tag.

In scripts/release/sync-homebrew-tap.sh around line 91, address this finding:
Tap layout detection can select an unused formula path. When a sharded tap has not yet created `Formula/<first-letter>` for this formula, the script falls back to `Formula/<name>.rb`; conversely, a flat/custom tap containing a directory with that first-letter name is treated as sharded. In either case the new formula can be written successfully where Homebrew will not discover it.

In scripts/release/sync-homebrew-tap.sh around line 22, address this finding:
The generated formula omits `LICENSE` from `doc.install`, even though every release archive packages it and the repository's release-install documentation lists it as an archive document. Users installing through the formula therefore do not receive all published documentation/license files.

## Additional findings on this change (not posted inline) (4)

In k8s_cli/src/manager/parsing.rs around line 26, address this finding:
A wrapped serialized `ls` response is parsed inconsistently: inventory names are treated as table text instead of decoding the JSON string in `output`. For a response such as `{"output":"[{\"vm_name\":\"vm-1\",\"ssh_dest\":\"vm+vm-1@exe.dev\"}]"}`, `parse_ssh_destinations` finds the target but `parse_vm_names` falls back to `parse_vm_names_from_text`, producing a bogus name like `[{"vm_name":"vm-1",...}]` rather than `vm-1`; bootstrap then believes the real VM is missing and can issue duplicate creation (or status/destroy reports incorrect inventory). This is disproven if the exe.dev API never returns serialized JSON inside `output` for `ls`, but the changed parser explicitly supports that shape for SSH destinations and its comment says the wrapper is shared.

In k8s_cli/src/manager/mod.rs around line 743, address this finding:
Bootstrap accepts empty required credentials/endpoints because `require_env` only calls `env::var` and does not reject an empty string. In new mode, `TS_AUTHKEY=` reaches `tailscale up --auth-key ''`; in existing mode, empty `K3S_URL`/`K3S_TOKEN` pass the initial checks and are embedded in the worker install script. This allows the command to pass confirmation and create VMs before failing remotely (or potentially invoke k3s with an empty endpoint/token), leaving a partially bootstrapped fleet instead of rejecting invalid configuration up front. The claim is false only if the environment-loading layer guarantees these variables can never be present-but-empty, which ordinary process environments do not.

In scripts/release/set-version.sh, address this finding:
set-version.sh can leave the workspace partially rewritten when any later operation fails. It commits each manifest with mv as it goes, so a missing/invalid later member or path dependency (and any cargo update failure) leaves earlier manifests at the new version while the remaining manifests/lockfile remain old; mv failures also leave the per-file .tmp behind. This violates the migration/error-handling requirement that failures not leave partial temporary files or inconsistent workspace state. For example, if k8s_cli/Cargo.toml is absent or `cargo update --workspace` cannot reach the registry, core/Cargo.toml and cli/Cargo.toml have already been changed. This is introduced by the new sequential rewrite implementation; it would be disproven only if the script were always run in a disposable checkout and no caller relies on the checkout afterward, but the workflow invokes it in the build checkout and the script is independently callable.

In .github/workflows/release.yml around line 166, address this finding:
A mutable third-party action reference is executed with the release job's write-capable token, so a compromise or retagging of softprops/action-gh-release@v3 can modify repository releases (and potentially other contents allowed by that token).
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 5 of 5 areas reviewed

Comment thread scripts/release/sync-homebrew-tap.sh Outdated
# The formula's `install` block names each file directly, so a renamed or dropped
# archive member fails at install time on the user's machine rather than here.
# Check the payload against the release we just downloaded instead.
tar -tzf "$WORK_DIR/${ARCHIVE_PREFIX}-${RELEASE_TAG}-macos-arm64.tar.gz" > "$WORK_DIR/members.txt"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Data Integrity | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
Only the macOS arm64 archive is inspected for required members; the other three downloaded archives can be malformed while the script still writes a successful formula.

Comment thread .github/workflows/release.yml Outdated
# `refs/tags/` rather than the bare input: a dispatch input is a free-form
# string, so a branch name or SHA would otherwise check out and build a ref
# that is not the tag the publish job creates the release for.
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag_name) || github.ref }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact timing window depends on a tag being force-moved or otherwise updated while the workflow is running, but Git tags are mutable and the workflow performs no SHA consistency check.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
The build can compile a different commit from the commit that triggered the release while publishing under the requested tag.

Comment thread scripts/release/sync-homebrew-tap.sh Outdated
# `Formula`. Writing to the layout the repo does not use produces a file nothing
# installs from, and the release then reports a tap update that never reached anyone.
FORMULA_SHARD_DIR="$TAP_REPO_PATH/Formula/${FORMULA_NAME:0:1}"
if [[ -d "$FORMULA_SHARD_DIR" ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Migration | 🟡 Medium

🧩 Analysis
  • Change relation: unknown
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
Tap layout detection can select an unused formula path. When a sharded tap has not yet created `Formula/<first-letter>` for this formula, the script falls back to `Formula/<name>.rb`; conversely, a flat/custom tap containing a directory with that first-letter name is treated as sharded. In either case the new formula can be written successfully where Homebrew will not discover it.

Comment thread scripts/release/sync-homebrew-tap.sh Outdated
# guesses either one installs nothing.
ARCHIVE_PREFIX="${ARCHIVE_PREFIX:-exedev-clis}"
BINARIES=(exedev-ctl exedev-k8s)
DOCS=(README.md README.zh-CN.md fleet.example.yaml .env.example)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🔵 Low

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
The generated formula omits `LICENSE` from `doc.install`, even though every release archive packages it and the repository's release-install documentation lists it as an archive document. Users installing through the formula therefore do not receive all published documentation/license files.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
DOCS=(README.md README.zh-CN.md fleet.example.yaml .env.example)
DOCS=(README.md README.zh-CN.md LICENSE fleet.example.yaml .env.example)

The build resolved `github.ref` at checkout time, so a tag moved between the
trigger and the checkout would compile a commit the release was never requested
for. The push path now checks out `github.sha`. The publish step, the only one
holding contents: write, is pinned to a commit rather than the mutable v3 tag.

set-version.sh rewrote manifests one at a time, so a missing later member or a
failing lockfile refresh left the workspace split across two versions, with the
per-file .tmp behind. Rewrites are now staged and moved only once all of them
have succeeded, a failed `cargo update` restores the manifests, and a trap
clears the staging files on any exit.

The tap script inspected only the macOS arm64 archive, so a malformed Linux
archive still produced a formula reported as good. It now checks every platform
it downloaded. LICENSE is packaged in every archive and named in the install
docs but was missing from `doc.install`, so formula users never received it;
adding it to DOCS also brings it under that member check.

Tap layout was chosen from the presence of the first-letter directory alone,
which picks a path Homebrew will not read when a sharded tap has no letter
directory yet, or when a flat tap happens to hold a directory with that name.
An existing formula file now decides it, and a tap holding both asks for
TAP_FORMULA_PATH instead of guessing.

require_env accepted a present-but-empty value, so `TS_AUTHKEY=` passed the
pre-flight check and reached the VM as `tailscale up --auth-key ''` after the
plan was confirmed and VMs were created.

parse_vm_names now decodes a serialized listing inside the `output` wrapper
instead of reading it as table text, which produced JSON fragments as VM names
and would have had bootstrap recreate VMs that already exist. The previous
commit taught parse_ssh_destinations that shape; this makes the pair agree.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Around line 46-52: Update the release workflow to resolve a workflow_dispatch
tag to one immutable commit SHA before the matrix runs, then pass that SHA to
every matrix checkout instead of resolving the mutable tag independently. Ensure
the publish job verifies the tag still points to the built SHA before invoking
the release action, while preserving the existing github.sha behavior for push
events.

In `@k8s_cli/src/manager/state.rs`:
- Around line 59-73: Update the file replacement flow around fs::remove_file and
OpenOptions to write contents to a unique 0600 temporary file in the same
directory, then atomically rename it over path only after write_all succeeds.
Preserve the existing file when temporary-file creation or writing fails, clean
up any failed temporary file, and add a failure-path test verifying the original
contents remain intact.

In `@scripts/release/set-version.sh`:
- Around line 112-117: Update the manifest replacement flow around the TARGETS
backup and mv loops to track successful backup creation and whether the lockfile
refresh completed. On EXIT before a successful refresh, restore every available
target backup before deleting temporary files or backups, while preserving
normal cleanup after a successful apply.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 82ca53b0-9a1a-4e13-b363-bc78bec3cda5

📥 Commits

Reviewing files that changed from the base of the PR and between 6b6a1b9 and a28655b.

📒 Files selected for processing (8)
  • .github/workflows/release.yml
  • k8s_cli/src/manager/mod.rs
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/process.rs
  • k8s_cli/src/manager/state.rs
  • k8s_cli/src/manager/tests.rs
  • scripts/release/set-version.sh
  • scripts/release/sync-homebrew-tap.sh
🚧 Files skipped from review as they are similar to previous changes (3)
  • k8s_cli/src/manager/process.rs
  • k8s_cli/src/manager/parsing.rs
  • scripts/release/sync-homebrew-tap.sh
📜 Review details
⏰ Context from checks skipped due to timeout. (3)
  • GitHub Check: fmt, check, test
  • GitHub Check: winnowl/review
  • GitHub Check: fmt, check, test
🧰 Additional context used
🪛 zizmor (1.29.0)
.github/workflows/release.yml

[info] 173-173: action functionality is already included by the runner (superfluous-actions): use gh release in a script step

(superfluous-actions)

🔇 Additional comments (8)
k8s_cli/src/manager/mod.rs (2)

602-632: LGTM!


744-751: LGTM!

k8s_cli/src/manager/tests.rs (2)

40-50: LGTM!

Also applies to: 187-201


327-357: LGTM!

scripts/release/set-version.sh (3)

43-66: LGTM!


68-110: LGTM!


119-128: LGTM!

.github/workflows/release.yml (1)

53-56: LGTM!

Also applies to: 85-101, 105-115, 169-173

Comment thread .github/workflows/release.yml Outdated
Comment thread k8s_cli/src/manager/state.rs Outdated
Comment thread scripts/release/set-version.sh

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (4)
  • 🟡 Medium Version synchronization rewrites the release lockfile by updating all workspace dependency selections, so a tag's build is not reproducible from its checked-in Cargo.lock and may incorporate unreviewed dependency changes. (inline)
  • 🟡 Medium An untrusted reported SSH destination can be interpreted as an ssh option because it is placed directly before the command without ending option parsing. (inline)
  • 🟡 Medium Secret-file replacement is not failure-safe: write_secret_file unlinks the old secret before creating the replacement and writes directly into the new path. If write_all is interrupted or returns an I/O error after a partial write (or the process crashes during the write), the path is left containing a truncated/partial secret; on the next run read_or_create_k3s_token accepts that content as the persisted token, and a kubeconfig can likewise be left incomplete. This violates the requirement to leave no unsafe intermediate or stale-secret state. The claim would be false only if the filesystem/environment guarantees write_all is all-or-nothing and the process cannot terminate between unlink/create/write, which normal local filesystems do not guarantee. (inline)
  • 🟡 Medium A failed write can leave a truncated secret that is later accepted as authoritative state. write_secret_file unlinks the old file before writing and never removes a partially written new file when write_all fails; on the next invocation read_or_create_k3s_token only checks path.exists() and returns the file contents without validating token completeness. For example, a disk-full/error-after-N-bytes while replacing k3s-token leaves a short token, which a subsequent bootstrap uses for the server/agents instead of regenerating or preserving the last good token. The same non-atomic behavior leaves a truncated kubeconfig that callers then use. This would be false only if the filesystem guaranteed write_all is all-or-nothing for these files or callers never retry/use the state after an I/O error. (inline)
⚠️ Unverified risks (1)
  • Archive validation checks only tar listing names, so a required binary/document can be a symlink or hardlink rather than the expected regular file. (scripts/release/sync-homebrew-tap.sh)
📋 Additional findings from this change (not shown inline) (9)
  • 🟠 High A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. capture_remote_ssh_output retries solely from output.status.code() == Some(255) and ignores whether stdout already contains the wrapper's __EXEDEV_K8S_EXIT__: marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent. (k8s_cli/src/manager/process.rs) — anchor-outside-diff
  • 🟠 High read_or_create_k3s_token follows a symlink when loading an existing generated token (path.exists() followed by fs::read_to_string(&amp;path)). An attacker or another local process that can alter .exedev-k8s/&lt;cluster&gt;/k3s-token can replace it with a symlink to an arbitrary readable file, causing bootstrap to use that file's contents as the k3s token (and potentially disclose/use unrelated secret material). The write path removes a symlink, but the read path has no symlink_metadata/no-follow validation, so the stated replacement-and-secret-file invariant is incomplete. This would be false only if the state directory and all entries were guaranteed immutable/trusted for the entire process lifetime. (k8s_cli/src/manager/state.rs) — anchor-unreliable
  • 🟡 Medium The release is not bound to the tag's commit for the entire run: after checkout pins a push run to github.sha (and a dispatch run initially resolves the tag), publish later passes only the tag name to the release action. If the tag is moved between checkout and publish, the archives are built from the originally triggered commit but are attached to the tag/release at its new commit, so the release's source tag and binaries disagree. (.github/workflows/release.yml) — anchor-outside-diff
  • 🟡 Medium Text fallback drops legitimate VM names beginning with name, causing inventory inconsistency and duplicate-create attempts. (k8s_cli/src/manager/parsing.rs) — anchor-outside-diff
  • 🟡 Medium A valid JSON response with no recognized VM container is incorrectly parsed as a VM name from its serialized representation. (k8s_cli/src/manager/parsing.rs) — anchor-outside-diff
  • 🟡 Medium A successful kubectl node probe with malformed JSON aborts bootstrap immediately instead of consuming the configured readiness retries and preserving the last probe error. (k8s_cli/src/manager/mod.rs) — anchor-unreliable
  • 🟡 Medium The readiness probe's --request-timeout=8s only bounds the Kubernetes HTTP request, not the local kubectl subprocess. capture_command_output awaits TokioCommand::output() without a wall-clock timeout or kill-on-drop, so an auth plugin, kubeconfig exec plugin, DNS/TLS path, or wedged kubectl can hang one polling attempt forever and prevent both the retry window and final diagnostics. This would be false only if kubectl is guaranteed to exit for every supported kubeconfig/plugin/network failure. (k8s_cli/src/manager/process.rs) — anchor-outside-diff
  • 🟡 Medium remote_run treats every remote status 126 as Tailnet Lock authorization, even when the 126 came from an unrelated bootstrap command. The wrapper only returns an integer marker, and the caller branches solely on output.status == TAILNET_LOCK_AUTH_REQUIRED_STATUS; k3s/tailscale shell commands or privilege/tool execution can independently return 126. In that case bootstrap pauses for a misleading Tailnet confirmation and, after confirmation, reruns the entire state-changing step rather than reporting the real command failure, potentially duplicating installation/service actions. This would be false only if every possible command in every generated step were guaranteed never to return 126 except CHECK_TAILNET_LOCK_SCRIPT. (k8s_cli/src/manager/process.rs) — per-file-budget
  • 🟡 Medium Several newly represented destructive/access/credential operations bypass the local confirmation guard: share remove, share remove-link, share set-private, domain add, ssh-key add, and ssh-key generate-api-key do not match any dangerous prefix (only domain rm, share set-public/add-link/access allow, and ssh-key remove are covered). For example, exedev-ctl ssh-key generate-api-key --exp 30d creates a credential without a prompt, and exedev-ctl domain add vm example.com changes domain ownership/certificate state without a prompt. This violates the stated coverage for credential-bearing, access-granting, and domain operations; it would be false only if the server treated all of these as read-only/non-authorizing operations. (core/src/shell.rs) — anchor-outside-diff
♻️ Previously reported (still present) (1)
  • 🟠 High A push-triggered release can publish archives built from one commit under a tag that resolves to a different commit. (.github/workflows/release.yml) — previously-reported
🤖 Prompt for AI agents — all findings (14)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Findings on this change (also posted as inline comments) (4)

In scripts/release/set-version.sh around line 122, address this finding:
Version synchronization rewrites the release lockfile by updating all workspace dependency selections, so a tag's build is not reproducible from its checked-in Cargo.lock and may incorporate unreviewed dependency changes.

In k8s_cli/src/manager/process.rs around line 361, address this finding:
An untrusted reported SSH destination can be interpreted as an ssh option because it is placed directly before the command without ending option parsing.

In k8s_cli/src/manager/state.rs around line 72, address this finding:
Secret-file replacement is not failure-safe: `write_secret_file` unlinks the old secret before creating the replacement and writes directly into the new path. If `write_all` is interrupted or returns an I/O error after a partial write (or the process crashes during the write), the path is left containing a truncated/partial secret; on the next run `read_or_create_k3s_token` accepts that content as the persisted token, and a kubeconfig can likewise be left incomplete. This violates the requirement to leave no unsafe intermediate or stale-secret state. The claim would be false only if the filesystem/environment guarantees `write_all` is all-or-nothing and the process cannot terminate between unlink/create/write, which normal local filesystems do not guarantee.

In k8s_cli/src/manager/state.rs around line 66, address this finding:
A failed write can leave a truncated secret that is later accepted as authoritative state. `write_secret_file` unlinks the old file before writing and never removes a partially written new file when `write_all` fails; on the next invocation `read_or_create_k3s_token` only checks `path.exists()` and returns the file contents without validating token completeness. For example, a disk-full/error-after-N-bytes while replacing `k3s-token` leaves a short token, which a subsequent bootstrap uses for the server/agents instead of regenerating or preserving the last good token. The same non-atomic behavior leaves a truncated kubeconfig that callers then use. This would be false only if the filesystem guaranteed `write_all` is all-or-nothing for these files or callers never retry/use the state after an I/O error.

## Additional findings on this change (not posted inline) (9)

In k8s_cli/src/manager/process.rs around line 277, address this finding:
A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. `capture_remote_ssh_output` retries solely from `output.status.code() == Some(255)` and ignores whether stdout already contains the wrapper's `__EXEDEV_K8S_EXIT__:` marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent.

In k8s_cli/src/manager/state.rs, address this finding:
`read_or_create_k3s_token` follows a symlink when loading an existing generated token (`path.exists()` followed by `fs::read_to_string(&path)`). An attacker or another local process that can alter `.exedev-k8s/<cluster>/k3s-token` can replace it with a symlink to an arbitrary readable file, causing bootstrap to use that file's contents as the k3s token (and potentially disclose/use unrelated secret material). The write path removes a symlink, but the read path has no `symlink_metadata`/no-follow validation, so the stated replacement-and-secret-file invariant is incomplete. This would be false only if the state directory and all entries were guaranteed immutable/trusted for the entire process lifetime.

In .github/workflows/release.yml around line 175, address this finding:
The release is not bound to the tag's commit for the entire run: after checkout pins a push run to `github.sha` (and a dispatch run initially resolves the tag), publish later passes only the tag name to the release action. If the tag is moved between checkout and publish, the archives are built from the originally triggered commit but are attached to the tag/release at its new commit, so the release's source tag and binaries disagree.

In k8s_cli/src/manager/parsing.rs around line 147, address this finding:
Text fallback drops legitimate VM names beginning with `name`, causing inventory inconsistency and duplicate-create attempts.

In k8s_cli/src/manager/parsing.rs around line 41, address this finding:
A valid JSON response with no recognized VM container is incorrectly parsed as a VM name from its serialized representation.

In k8s_cli/src/manager/mod.rs, address this finding:
A successful kubectl node probe with malformed JSON aborts bootstrap immediately instead of consuming the configured readiness retries and preserving the last probe error.

In k8s_cli/src/manager/process.rs around line 194, address this finding:
The readiness probe's `--request-timeout=8s` only bounds the Kubernetes HTTP request, not the local kubectl subprocess. `capture_command_output` awaits `TokioCommand::output()` without a wall-clock timeout or kill-on-drop, so an auth plugin, kubeconfig exec plugin, DNS/TLS path, or wedged kubectl can hang one polling attempt forever and prevent both the retry window and final diagnostics. This would be false only if kubectl is guaranteed to exit for every supported kubeconfig/plugin/network failure.

In k8s_cli/src/manager/process.rs around line 74, address this finding:
`remote_run` treats every remote status 126 as Tailnet Lock authorization, even when the 126 came from an unrelated bootstrap command. The wrapper only returns an integer marker, and the caller branches solely on `output.status == TAILNET_LOCK_AUTH_REQUIRED_STATUS`; k3s/tailscale shell commands or privilege/tool execution can independently return 126. In that case bootstrap pauses for a misleading Tailnet confirmation and, after confirmation, reruns the entire state-changing step rather than reporting the real command failure, potentially duplicating installation/service actions. This would be false only if every possible command in every generated step were guaranteed never to return 126 except `CHECK_TAILNET_LOCK_SCRIPT`.

In core/src/shell.rs around line 43, address this finding:
Several newly represented destructive/access/credential operations bypass the local confirmation guard: `share remove`, `share remove-link`, `share set-private`, `domain add`, `ssh-key add`, and `ssh-key generate-api-key` do not match any dangerous prefix (only domain rm, share set-public/add-link/access allow, and ssh-key remove are covered). For example, `exedev-ctl ssh-key generate-api-key --exp 30d` creates a credential without a prompt, and `exedev-ctl domain add vm example.com` changes domain ownership/certificate state without a prompt. This violates the stated coverage for credential-bearing, access-granting, and domain operations; it would be false only if the server treated all of these as read-only/non-authorizing operations.

## Previously reported and still present (1)

In .github/workflows/release.yml around line 52, address this finding:
A push-triggered release can publish archives built from one commit under a tag that resolves to a different commit.
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 7 of 7 areas reviewed

# The release build runs with --locked, which fails outright when Cargo.lock still
# carries the old member versions. Refresh it here rather than leaving the build to
# discover the mismatch.
if ! (cd "$REPO_ROOT" && cargo update --workspace --quiet); then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/set-version.sh, address this finding:
Version synchronization rewrites the release lockfile by updating all workspace dependency selections, so a tag's build is not reproducible from its checked-in Cargo.lock and may incorporate unreviewed dependency changes.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
if ! (cd "$REPO_ROOT" && cargo update --workspace --quiet); then
if ! (cd "$REPO_ROOT" && cargo update -p exedev-cli-core -p exedev-ctl -p exedev-k8s --quiet); then

"-o".into(),
"ConnectTimeout=15".into(),
format!("{vm}.exe.xyz"),
dest.to_string(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The finding assumes the authenticated exe.dev service/API response is not contractually guaranteed to restrict ssh_dest to non-option destination syntax; the local code performs no such validation.
🤖 Prompt for AI agents
In k8s_cli/src/manager/process.rs, address this finding:
An untrusted reported SSH destination can be interpreted as an ssh option because it is placed directly before the command without ending option parsing.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
dest.to_string(),
"--".into(),
dest.to_string(),

Comment thread k8s_cli/src/manager/state.rs Outdated
.mode(0o600)
.open(path)
.with_context(|| format!("failed to create {}", path.display()))?;
file.write_all(contents.as_bytes())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact filesystem error/crash frequency is environment-dependent, but POSIX write and process-crash semantics do not provide all-or-nothing replacement.
🤖 Prompt for AI agents
In k8s_cli/src/manager/state.rs, address this finding:
Secret-file replacement is not failure-safe: `write_secret_file` unlinks the old secret before creating the replacement and writes directly into the new path. If `write_all` is interrupted or returns an I/O error after a partial write (or the process crashes during the write), the path is left containing a truncated/partial secret; on the next run `read_or_create_k3s_token` accepts that content as the persisted token, and a kubeconfig can likewise be left incomplete. This violates the requirement to leave no unsafe intermediate or stale-secret state. The claim would be false only if the filesystem/environment guarantees `write_all` is all-or-nothing and the process cannot terminate between unlink/create/write, which normal local filesystems do not guarantee.

Comment thread k8s_cli/src/manager/state.rs Outdated
return Err(err).with_context(|| format!("failed to replace {}", path.display()));
}
}
let mut file = fs::OpenOptions::new()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Reliability | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In k8s_cli/src/manager/state.rs, address this finding:
A failed write can leave a truncated secret that is later accepted as authoritative state. `write_secret_file` unlinks the old file before writing and never removes a partially written new file when `write_all` fails; on the next invocation `read_or_create_k3s_token` only checks `path.exists()` and returns the file contents without validating token completeness. For example, a disk-full/error-after-N-bytes while replacing `k3s-token` leaves a short token, which a subsequent bootstrap uses for the server/agents instead of regenerating or preserving the last good token. The same non-atomic behavior leaves a truncated kubeconfig that callers then use. This would be false only if the filesystem guaranteed `write_all` is all-or-nothing for these files or callers never retry/use the state after an I/O error.

write_secret_file wrote into the destination after unlinking it, so a write
that failed partway left a truncated token or kubeconfig that the next run read
back as authoritative. Contents now go to a 0600 staging file in the same
directory and are renamed over the target only once the write and flush
succeed, so a failure leaves the previous secret untouched. The token read path
rejected nothing, so an entry swapped for a symlink had another file's contents
adopted as the cluster token; it now requires a regular file.

ssh parses options up to the first non-option word, so a reported ssh_dest
beginning with `-` would have been taken as a local ssh option rather than a
host. The destination is now passed after `--`.

The 255 retry resent the script even when stdout already carried the wrapper's
exit marker, which only appears once the remote script has finished; that
turned a connection lost while returning output into a second install. It now
retries only when the remote side did not report completion. Remote status 126
was likewise read as Tailnet Lock regardless of origin, so an unrelated 126
prompted for a signature and then reran a state-changing step; it now also
requires the message the lock check emits.

kubectl's --request-timeout bounds its API call, not the process, so a wedged
credential or exec plugin could consume the whole readiness window in one
attempt. Captured commands now run under a wall-clock bound. A probe returning
unparseable JSON also aborted bootstrap outright instead of spending a retry.

parse_vm_names handed a JSON response it had already searched to the text
parser, turning `{"vms":[]}` into a VM named after the JSON, and dropped any
real VM whose name begins with "name" along with the header row.

The guard now also covers the credential and access operations it had skipped:
ssh-key add and generate-api-key mint credentials that reach VMs, share
remove/remove-link/set-private revoke access, and domain add changes domain and
certificate state.

The release workflow resolved the tag independently in every job. One resolve
job now pins a single commit that all four matrix builds check out, and publish
refuses to attach archives to a tag that has moved since. set-version.sh
restores its backups when it exits between applying manifests and refreshing
the lockfile, including on a signal.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
k8s_cli/src/manager/parsing.rs (1)

32-38: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Return the empty set after valid wrapped JSON.

When output parses as JSON but contains no VM names, Line 38 parses JSON syntax as table text. For example, {"output":"[]"} returns a VM named []. Bootstrap can then attempt to create that false VM.

Return Ok(names) after successful inner JSON parsing, even when names is empty.

Proposed fix
             if let Ok(inner) = serde_json::from_str::<Value>(output.trim()) {
                 collect_vm_names_from_json(&inner, &mut names);
-                if !names.is_empty() {
-                    return Ok(names);
-                }
+                return Ok(names);
             }
             return Ok(parse_vm_names_from_text(output));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@k8s_cli/src/manager/parsing.rs` around lines 32 - 38, Update the JSON-parsing
branch in the VM-name parsing function to return Ok(names) immediately after
successful serde_json::from_str and collect_vm_names_from_json, regardless of
whether names is empty; only fall back to parse_vm_names_from_text when JSON
parsing fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@k8s_cli/src/manager/state.rs`:
- Around line 58-80: In k8s_cli/src/manager/state.rs:58-80, replace predictable
staging-path creation and pathname rename with directory-handle operations, an
unpredictable staging name, and protected no-follow semantics so an attacker
cannot substitute the staged entry; in k8s_cli/src/manager/state.rs:102-111,
open the target without following symlinks, validate metadata from that opened
descriptor, and read from the same descriptor rather than reopening the
replaceable path.

---

Outside diff comments:
In `@k8s_cli/src/manager/parsing.rs`:
- Around line 32-38: Update the JSON-parsing branch in the VM-name parsing
function to return Ok(names) immediately after successful serde_json::from_str
and collect_vm_names_from_json, regardless of whether names is empty; only fall
back to parse_vm_names_from_text when JSON parsing fails.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5efd256b-d507-4e66-b9af-d8034f28e4d4

📥 Commits

Reviewing files that changed from the base of the PR and between a28655b and 1eddfcd.

📒 Files selected for processing (8)
  • .github/workflows/release.yml
  • core/src/shell.rs
  • k8s_cli/src/manager/mod.rs
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/process.rs
  • k8s_cli/src/manager/state.rs
  • k8s_cli/src/manager/tests.rs
  • scripts/release/set-version.sh
🚧 Files skipped from review as they are similar to previous changes (3)
  • core/src/shell.rs
  • k8s_cli/src/manager/mod.rs
  • k8s_cli/src/manager/tests.rs
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: winnowl/review
🔇 Additional comments (9)
k8s_cli/src/manager/parsing.rs (1)

40-43: LGTM!

Also applies to: 152-157

k8s_cli/src/manager/process.rs (1)

22-30: LGTM!

Also applies to: 82-89, 206-222, 255-288, 303-308, 392-397

k8s_cli/src/manager/state.rs (1)

2-40: LGTM!

.github/workflows/release.yml (4)

21-53: LGTM!


55-57: LGTM!

Also applies to: 81-84


117-128: LGTM!

Also applies to: 137-141


165-165: LGTM!

Also applies to: 178-197, 206-206

scripts/release/set-version.sh (2)

74-91: LGTM!


130-141: LGTM!

Comment thread k8s_cli/src/manager/state.rs Outdated

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (5)
  • 🟠 High The tag recheck is check-then-use rather than an atomic publish target. A maintainer or attacker who moves the release tag after Verify the tag still points at the built commit completes but before action-gh-release resolves tag_name, can cause the release to be attached to the moved tag while the archives remain from BUILT_SHA; the workflow will not refuse that move. (inline)
  • 🟡 Medium Creating a team pool is not classified as dangerous even though pool new mutates the team's reserved-capacity resource (and the command is documented as reserved capacity). The dangerous list covers pool delete but has no pool new prefix, so exedev-ctl pool new ... bypasses the confirmation required for newly supported resource-mutating operations. This would be false only if pool creation is guaranteed not to reserve/bill capacity and is intentionally considered harmless. (inline)
  • 🟡 Medium Manual dispatch does not reject an invalid tag name at the resolve boundary. Any existing non-semver tag (or a tag object whose peeled object is not a commit) is accepted and emitted as needs.resolve.outputs.tag/sha; the workflow only discovers a semver failure later in each matrix build when set-version runs, wasting builds and violating the requirement that invalid dispatch tags be rejected before build. There is also no explicit validation that the fetched object type is commit after peeling. (inline)
  • 🟡 Medium An explicitly configured TAP_FORMULA_PATH is accepted without any validation or confinement. The script creates its parent and truncates the path with cat &gt;, so a caller-controlled value such as an absolute path, ../ path, or symlink can overwrite an arbitrary file rather than a formula under the tap; this is not prevented by the repository/path validation (which only applies to auto-discovery). (inline)
  • 🟡 Medium Selecting a saved billing payment method is not guarded as dangerous. The typed billing payment default &lt;reference&gt; command changes which card will be charged, but is_dangerous only covers billing payment remove; therefore an interactive invocation executes without the required local confirmation. This would be false only if the server guarantees that changing the default payment method is harmless/non-consequential and is intentionally excluded from dangerous operations. (inline)
⛔ Unresolved from previous review (2) — not approved until fixed
  • read_or_create_k3s_token follows a symlink when loading an existing generated token (path.exists() followed by fs::read_to_string(&amp;path)). An attacker or another local process that can alter .exedev-k8s/&lt;cluster&gt;/k3s-token can replace it with a symlink to an arbitrary readable file, causing bootstrap to use that file's contents as the k3s token (and potentially disclose/use unrelated secret material). The write path removes a symlink, but the read path has no symlink_metadata/no-follow validation, so the stated replacement-and-secret-file invariant is incomplete. This would be false only if the state directory and all entries were guaranteed immutable/trusted for the entire process lifetime. — The read path now calls read_regular_file, which checks symlink_metadata, but the check and the subsequent fs::read_to_string(path) are separate pathname operations. A local attacker can replace the validated regular file with a symlink in that interval; read_to_string will then follow the symlink and adopt the target contents. Thus the reported replacement-and-secret-file consequence remains possible via a TOCTOU race.
  • k8s_cli/src/manager/process.rs: A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. capture_remote_ssh_output retries solely from output.status.code() == Some(255) and ignores whether stdout already contains the wrapper's __EXEDEV_K8S_EXIT__: marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent. — The retry is now gated on the marker, but it still retries status 255 whenever the marker is absent. remote_status_script emits the marker only after the remote script finishes, so a connection loss after the bootstrap executed but before the marker was received leaves no marker in captured stdout and satisfies output.status.code() == Some(255) &amp;&amp; !remote_ran; the full script is sent again. Thus the reported duplicate-execution consequence remains possible.
📋 Additional findings from this change (not shown inline) (8)
  • 🟠 High Generated state paths can escape the intended .exedev-k8s directory because cluster.name is only checked for nonempty and is joined directly into the path; a fleet cluster name such as ../../outside causes token and generated kubeconfig writes outside the state root. (k8s_cli/src/manager/state.rs) — anchor-outside-diff
  • 🟠 High The release workflow does not satisfy immutable action pinning for the build and artifact steps: checkout, toolchain installation, cache, artifact upload, and artifact download all use mutable tags (@v7, @stable, @v2, @v8). A retagged or compromised upstream action can therefore execute code in the release workflow (and in build jobs that compile third-party crates), despite the publish action itself being pinned. (.github/workflows/release.yml) — anchor-outside-diff
  • 🟠 High A cancelled or otherwise dropped kubectl mutation can leave the kubectl child running because run_command does not set kill_on_drop and has no enclosing timeout. (k8s_cli/src/manager/process.rs) — anchor-outside-diff
  • 🟠 High Reusing a persisted token does not enforce restrictive permissions: the existing-file branch only reads the file and returns its contents, so a token file created or changed to mode 0644 remains readable by other users on every subsequent run. (k8s_cli/src/manager/state.rs) — anchor-outside-diff
  • 🟠 High The generated k3s server kubeconfig is made world-readable on the VM: both server installation paths pass --write-kubeconfig-mode 644, although /etc/rancher/k3s/k3s.yaml contains client credentials. Any local VM user can read those credentials before fetch_kubeconfig copies the file. (k8s_cli/src/manager/scripts.rs) — anchor-unreliable
  • 🟠 High Existing-mode readiness, metadata, and manifest operations are not bound to K3S_URL: when --kubeconfig and KUBECONFIG are absent, the code proceeds with kubectl's default context. This can report readiness for or mutate an unrelated cluster while workers were installed against the required K3S_URL. (k8s_cli/src/manager/mod.rs) — anchor-unreliable
  • 🟡 Medium A remote SSH stdin write failure is discarded whenever ssh also exits unsuccessfully, so the caller reports only the child status/detail and cannot distinguish a broken stdin pipe from a transport or remote failure. (k8s_cli/src/manager/process.rs) — anchor-outside-diff
  • 🟡 Medium The script's cargo update --workspace is broader than a local package-version refresh and can re-resolve compatible registry dependencies in Cargo.lock. Thus a release run can silently change the reviewed third-party lock selection even though the script says no third-party dependency selection changes; with --locked builds this produces artifacts from dependency versions different from the committed lockfile. (scripts/release/set-version.sh) — anchor-unreliable
♻️ Previously reported (still present) (3)
  • 🟡 Medium New-mode bootstrap accepts an explicitly empty K3S_TOKEN and uses it as the cluster credential. (k8s_cli/src/manager/state.rs) — previously-reported
  • 🟡 Medium On a failed or interrupted cargo update, the rollback restores only the manifest backups, not Cargo.lock. cargo update --workspace runs after manifests are moved into place, while Cargo.lock is not included in TARGETS and is never backed up; if Cargo writes a new/partial lockfile before returning nonzero or the process is interrupted, EXIT cleanup restores old manifests but leaves the lockfile at the new state, violating manifest/lock consistency on failure paths. (scripts/release/set-version.sh) — previously-reported
  • 🔵 Low A JSON wrapper whose output contains a valid but empty/error serialized listing is incorrectly fed to the text parser, inventing a VM name from the JSON text. (k8s_cli/src/manager/parsing.rs) — previously-reported
❓ Low-evidence leads (not confirmed — verify before acting) (2)
  • Archive validation checks only that each expected pathname appears in tar -tzf output. It does not verify entry types or reject extra/path-traversal entries: a symlink or hardlink named ./exedev-ctl (or duplicate expected names alongside unrelated members) passes grep -qx. Formula generation then records a checksum and publishes a formula for an archive whose install payload is not the expected set of regular binaries/docs, defeating the requirement to validate downloaded artifacts before generation and potentially causing unsafe extraction/install behavior. (scripts/release/sync-homebrew-tap.sh)
  • Remote stdout is not preserved exactly: parse_remote_stdout strips every trailing newline before the completion marker. (k8s_cli/src/manager/process.rs)
🤖 Prompt for AI agents — all findings (18)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Unresolved from the previous review — these block approval, fix them first (2)

Somewhere in the code under review, address this finding:
`read_or_create_k3s_token` follows a symlink when loading an existing generated token (`path.exists()` followed by `fs::read_to_string(&path)`). An attacker or another local process that can alter `.exedev-k8s/<cluster>/k3s-token` can replace it with a symlink to an arbitrary readable file, causing bootstrap to use that file's contents as the k3s token (and potentially disclose/use unrelated secret material). The write path removes a symlink, but the read path has no `symlink_metadata`/no-follow validation, so the stated replacement-and-secret-file invariant is incomplete. This would be false only if the state directory and all entries were guaranteed immutable/trusted for the entire process lifetime.

In k8s_cli/src/manager/process.rs, address this finding:
A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. `capture_remote_ssh_output` retries solely from `output.status.code() == Some(255)` and ignores whether stdout already contains the wrapper's `__EXEDEV_K8S_EXIT__:` marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent.

## Findings on this change (also posted as inline comments) (5)

In .github/workflows/release.yml around line 206, address this finding:
The tag recheck is check-then-use rather than an atomic publish target. A maintainer or attacker who moves the release tag after `Verify the tag still points at the built commit` completes but before `action-gh-release` resolves `tag_name`, can cause the release to be attached to the moved tag while the archives remain from BUILT_SHA; the workflow will not refuse that move.

In core/src/shell.rs around line 78, address this finding:
Creating a team pool is not classified as dangerous even though `pool new` mutates the team's reserved-capacity resource (and the command is documented as reserved capacity). The dangerous list covers `pool delete` but has no `pool new` prefix, so `exedev-ctl pool new ...` bypasses the confirmation required for newly supported resource-mutating operations. This would be false only if pool creation is guaranteed not to reserve/bill capacity and is intentionally considered harmless.

In .github/workflows/release.yml around line 41, address this finding:
Manual dispatch does not reject an invalid tag name at the resolve boundary. Any existing non-semver tag (or a tag object whose peeled object is not a commit) is accepted and emitted as `needs.resolve.outputs.tag/sha`; the workflow only discovers a semver failure later in each matrix build when set-version runs, wasting builds and violating the requirement that invalid dispatch tags be rejected before build. There is also no explicit validation that the fetched object type is commit after peeling.

In scripts/release/sync-homebrew-tap.sh around line 182, address this finding:
An explicitly configured `TAP_FORMULA_PATH` is accepted without any validation or confinement. The script creates its parent and truncates the path with `cat >`, so a caller-controlled value such as an absolute path, `../` path, or symlink can overwrite an arbitrary file rather than a formula under the tap; this is not prevented by the repository/path validation (which only applies to auto-discovery).

In core/src/shell.rs around line 81, address this finding:
Selecting a saved billing payment method is not guarded as dangerous. The typed `billing payment default <reference>` command changes which card will be charged, but `is_dangerous` only covers `billing payment remove`; therefore an interactive invocation executes without the required local confirmation. This would be false only if the server guarantees that changing the default payment method is harmless/non-consequential and is intentionally excluded from dangerous operations.

## Additional findings on this change (not posted inline) (8)

In k8s_cli/src/manager/state.rs around line 14, address this finding:
Generated state paths can escape the intended .exedev-k8s directory because cluster.name is only checked for nonempty and is joined directly into the path; a fleet cluster name such as ../../outside causes token and generated kubeconfig writes outside the state root.

In .github/workflows/release.yml around line 79, address this finding:
The release workflow does not satisfy immutable action pinning for the build and artifact steps: checkout, toolchain installation, cache, artifact upload, and artifact download all use mutable tags (`@v7`, `@stable`, `@v2`, `@v8`). A retagged or compromised upstream action can therefore execute code in the release workflow (and in build jobs that compile third-party crates), despite the publish action itself being pinned.

In k8s_cli/src/manager/process.rs around line 188, address this finding:
A cancelled or otherwise dropped kubectl mutation can leave the kubectl child running because run_command does not set kill_on_drop and has no enclosing timeout.

In k8s_cli/src/manager/state.rs around line 35, address this finding:
Reusing a persisted token does not enforce restrictive permissions: the existing-file branch only reads the file and returns its contents, so a token file created or changed to mode 0644 remains readable by other users on every subsequent run.

In k8s_cli/src/manager/scripts.rs, address this finding:
The generated k3s server kubeconfig is made world-readable on the VM: both server installation paths pass --write-kubeconfig-mode 644, although /etc/rancher/k3s/k3s.yaml contains client credentials. Any local VM user can read those credentials before fetch_kubeconfig copies the file.

In k8s_cli/src/manager/mod.rs, address this finding:
Existing-mode readiness, metadata, and manifest operations are not bound to K3S_URL: when --kubeconfig and KUBECONFIG are absent, the code proceeds with kubectl's default context. This can report readiness for or mutate an unrelated cluster while workers were installed against the required K3S_URL.

In k8s_cli/src/manager/process.rs around line 289, address this finding:
A remote SSH stdin write failure is discarded whenever ssh also exits unsuccessfully, so the caller reports only the child status/detail and cannot distinguish a broken stdin pipe from a transport or remote failure.

In scripts/release/set-version.sh, address this finding:
The script's `cargo update --workspace` is broader than a local package-version refresh and can re-resolve compatible registry dependencies in Cargo.lock. Thus a release run can silently change the reviewed third-party lock selection even though the script says no third-party dependency selection changes; with `--locked` builds this produces artifacts from dependency versions different from the committed lockfile.

## Previously reported and still present (3)

In k8s_cli/src/manager/state.rs around line 23, address this finding:
New-mode bootstrap accepts an explicitly empty K3S_TOKEN and uses it as the cluster credential.

In scripts/release/set-version.sh around line 137, address this finding:
On a failed or interrupted `cargo update`, the rollback restores only the manifest backups, not Cargo.lock. `cargo update --workspace` runs after manifests are moved into place, while Cargo.lock is not included in TARGETS and is never backed up; if Cargo writes a new/partial lockfile before returning nonzero or the process is interrupted, EXIT cleanup restores old manifests but leaves the lockfile at the new state, violating manifest/lock consistency on failure paths.

In k8s_cli/src/manager/parsing.rs around line 26, address this finding:
A JSON wrapper whose `output` contains a valid but empty/error serialized listing is incorrectly fed to the text parser, inventing a VM name from the JSON text.
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 6 of 6 areas reviewed

uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228
with:
tag_name: ${{ steps.meta.outputs.tag }}
tag_name: ${{ needs.resolve.outputs.tag }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Data Integrity | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact probability of the interleaving depends on repository tag protections and who has permission to move the tag, but those controls do not make the workflow operation atomic.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
The tag recheck is check-then-use rather than an atomic publish target. A maintainer or attacker who moves the release tag after `Verify the tag still points at the built commit` completes but before `action-gh-release` resolves `tag_name`, can cause the release to be attached to the moved tag while the archives remain from BUILT_SHA; the workflow will not refuse that move.

Comment thread core/src/shell.rs Outdated
"team disable",
"team settings auto-join on",
"domain rm ",
"pool delete ",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Authorization | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In core/src/shell.rs, address this finding:
Creating a team pool is not classified as dangerous even though `pool new` mutates the team's reserved-capacity resource (and the command is documented as reserved capacity). The dangerous list covers `pool delete` but has no `pool new` prefix, so `exedev-ctl pool new ...` bypasses the confirmation required for newly supported resource-mutating operations. This would be false only if pool creation is guaranteed not to reserve/bill capacity and is intentionally considered harmless.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
"pool delete ",
"pool new ",
"pool delete",

run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
tag="${INPUT_TAG_NAME}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Build Deployment | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
Manual dispatch does not reject an invalid tag name at the resolve boundary. Any existing non-semver tag (or a tag object whose peeled object is not a commit) is accepted and emitted as `needs.resolve.outputs.tag/sha`; the workflow only discovers a semver failure later in each matrix build when set-version runs, wasting builds and violating the requirement that invalid dispatch tags be rejected before build. There is also no explicit validation that the fetched object type is commit after peeling.

Comment thread scripts/release/sync-homebrew-tap.sh Outdated
}

mkdir -p "$(dirname "$TAP_FORMULA_PATH")"
cat > "$TAP_FORMULA_PATH" <<FORMULA

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
An explicitly configured `TAP_FORMULA_PATH` is accepted without any validation or confinement. The script creates its parent and truncates the path with `cat >`, so a caller-controlled value such as an absolute path, `../` path, or symlink can overwrite an arbitrary file rather than a formula under the tap; this is not prevented by the repository/path validation (which only applies to auto-discovery).

Comment thread core/src/shell.rs Outdated
"pool delete ",
"billing capacity",
"billing credits buy ",
"billing payment remove ",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Authorization | 🟡 Medium

🧩 Analysis
  • Change relation: unknown
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In core/src/shell.rs, address this finding:
Selecting a saved billing payment method is not guarded as dangerous. The typed `billing payment default <reference>` command changes which card will be charged, but `is_dangerous` only covers `billing payment remove`; therefore an interactive invocation executes without the required local confirmation. This would be false only if the server guarantees that changing the default payment method is harmless/non-consequential and is intentionally excluded from dangerous operations.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
"billing payment remove ",
"billing payment remove ",
"billing payment default ",

State paths took the fleet's cluster name unchecked, so a name like
`../../outside` wrote the token and kubeconfig outside `.exedev-k8s`; the name
is now reduced to a single path component. An exported but empty K3S_TOKEN was
accepted through `env::var` and became the cluster credential for the server and
every agent. A reused token file left readable by others stayed that way on
every later run, and is now tightened when it is read back.

Secret staging used a name derived from the pid and unlinked it first, which is
both guessable and a window for substitution. The name is now random and the
`create_new` open, which refuses any existing entry including a symlink, is what
guarantees the file is this process's own. The read path checked the path and
then reopened it, so the entry could be swapped in between; it now reads through
one handle and confirms that handle is the object the no-follow check accepted.

k3s wrote its server kubeconfig 0644 on the VM, exposing client credentials to
every local user there. fetch_kubeconfig reads it through sudo, so nothing
needed that; it is now 0600.

A JSON wrapper holding an empty or error listing was still handed to the text
parser, which returned a VM named after the JSON. A parsed listing is now the
answer whether or not it is empty.

The guard now also covers `pool new`, which reserves billable capacity like the
`billing capacity` change already listed, and `billing payment default`, which
changes the card that gets charged.

Release inputs are checked where they enter: the semver grammar moved to
check-version.sh so the resolve step rejects a bad dispatch tag before four
matrix builds start, the peeled tag object must be a commit, and every action is
pinned to a commit rather than a mutable tag. set-version.sh now backs up
Cargo.lock alongside the manifests, so an interrupted refresh no longer restores
manifests and leaves a rewritten lockfile. The tap script validates an explicit
TAP_FORMULA_PATH, which it creates and truncates, the same way it validates a
discovered one.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
scripts/release/check-version.sh (1)

21-25: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Make SemVer matching locale-independent.

[[ ... =~ ... ]] uses locale-sensitive POSIX ERE ranges. Export LC_ALL=C before the match so the release gate accepts only ASCII SemVer characters on every runner.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/release/check-version.sh` around lines 21 - 25, Set LC_ALL=C before
the SEMVER_RE match in the version-check flow so the [[ "$VERSION" =~ $SEMVER_RE
]] evaluation uses ASCII-only, locale-independent character ranges. Keep the
existing SemVer pattern and validation behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@k8s_cli/src/manager/state.rs`:
- Around line 30-45: Update state_dir_name so distinct cluster_name values
cannot map to the same directory, while retaining readable sanitization where
possible. Encode the original UTF-8 bytes injectively or append an unambiguous
encoded suffix derived from the original name, and preserve the existing
fallback for names containing only underscores or otherwise ensure it remains
collision-free.
- Around line 67-72: The existing-token path returns empty or whitespace-only
contents from read_regular_file. In the path.exists() branch, validate the
trimmed token before restrict_secret_permissions and return, producing a clear
error for an empty value; preserve normal reuse for non-empty tokens. Add a test
covering an existing empty k3s-token file.

---

Nitpick comments:
In `@scripts/release/check-version.sh`:
- Around line 21-25: Set LC_ALL=C before the SEMVER_RE match in the
version-check flow so the [[ "$VERSION" =~ $SEMVER_RE ]] evaluation uses
ASCII-only, locale-independent character ranges. Keep the existing SemVer
pattern and validation behavior unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3fdd087b-3f27-413a-8dbc-c596a27e2fb8

📥 Commits

Reviewing files that changed from the base of the PR and between 1eddfcd and a034aa5.

📒 Files selected for processing (9)
  • .github/workflows/release.yml
  • core/src/shell.rs
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/scripts.rs
  • k8s_cli/src/manager/state.rs
  • k8s_cli/src/manager/tests.rs
  • scripts/release/check-version.sh
  • scripts/release/set-version.sh
  • scripts/release/sync-homebrew-tap.sh
🚧 Files skipped from review as they are similar to previous changes (5)
  • core/src/shell.rs
  • scripts/release/set-version.sh
  • scripts/release/sync-homebrew-tap.sh
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/tests.rs
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: winnowl/review
🧰 Additional context used
🪛 zizmor (1.29.0)
.github/workflows/release.yml

[error] 109-109: runtime artifacts potentially vulnerable to a cache poisoning attack (cache-poisoning): enables caching by default

(cache-poisoning)

🔇 Additional comments (8)
k8s_cli/src/manager/scripts.rs (1)

218-224: LGTM!

.github/workflows/release.yml (6)

194-219: The tag verification is still not atomic with release creation.

A tag can move after line 210 and before softprops/action-gh-release resolves tag_name. The release can then attach archives built from BUILT_SHA to the moved tag. This is the same unresolved issue from the previous review.


29-32: LGTM!


47-60: LGTM!


92-109: LGTM!


130-154: LGTM!


170-170: LGTM!

Also applies to: 185-185

scripts/release/check-version.sh (1)

1-20: LGTM!

Also applies to: 26-31

Comment thread k8s_cli/src/manager/state.rs
Comment thread k8s_cli/src/manager/state.rs

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (5)
  • 🟡 Medium The publish-time moving-tag check is not an atomic safeguard: it reads the tag target, then separately invokes softprops/action-gh-release with only tag_name. If an authorized actor moves the tag after the gh api check but before or during the release action, the action can create/update the release for the new target while attaching archives built from BUILT_SHA. Thus the workflow can publish artifacts that do not match the tag despite the verification step. This would be false only if the repository prevents tag updates for the entire check-to-action interval or the release action/API pins the release target to BUILT_SHA (neither is expressed here). (inline)
  • 🟡 Medium Archive member validation checks only that expected names appear, not that the entries are regular root files, so a malicious or malformed release archive can pass validation while formula installation receives the wrong payload type. (inline)
  • 🟡 Medium The script allows FORMULA_NAME and FORMULA_CLASS to identify different formulae, producing a file Homebrew cannot discover under the requested formula name. (inline)
  • 🟡 Medium share receive-email is a mutating access/capability command but is not classified as dangerous. The CLI builds share receive-email &lt;vm&gt; [on|off] [--reply-policy ...], which changes whether a VM can send email and who it may email; guard_dangerous_command is called for both transports before execution, yet is_dangerous has no share receive-email rule. Thus a user can enable broad email capability without confirmation. (inline)
  • 🔵 Low The destination-precedence test does not actually verify the required invariant that ssh_dest is authoritative: in parses_ssh_destinations_from_ls_json, each record either has matching ssh_dest/host fields or only host/user fields. A regression that incorrectly prefers ssh_host + ssh_user over a conflicting ssh_dest would pass the suite, despite connecting to a different VM route. This is a concrete test-alignment gap; it would be disproven if another exercised test (outside the reviewed file) supplies conflicting fields and asserts ssh_dest wins. (inline)
⛔ Unresolved from previous review (3) — not approved until fixed
  • Existing-mode readiness, metadata, and manifest operations are not bound to K3S_URL: when --kubeconfig and KUBECONFIG are absent, the code proceeds with kubectl's default context. This can report readiness for or mutate an unrelated cluster while workers were installed against the required K3S_URL. — The existing-mode path still permits both kubeconfig sources to be absent: it only prints a warning in bootstrap_k3s, then passes None through kubeconfig_for_bootstrap to readiness, node, metadata, and manifest calls. kubeconfig_args(None, ...) omits --kubeconfig, so kubectl still uses its default context, with no validation that it targets K3S_URL; the reported unrelated-cluster readiness or mutation remains possible.
  • The generated k3s server kubeconfig is made world-readable on the VM: both server installation paths pass --write-kubeconfig-mode 644, although /etc/rancher/k3s/k3s.yaml contains client credentials. Any local VM user can read those credentials before fetch_kubeconfig copies the file.
  • k8s_cli/src/manager/process.rs: A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. capture_remote_ssh_output retries solely from output.status.code() == Some(255) and ignores whether stdout already contains the wrapper's __EXEDEV_K8S_EXIT__: marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent. — The retry is now suppressed when captured stdout contains REMOTE_EXIT_PREFIX, but the retry condition still treats absence of that marker as proof that the remote script did not run: if output.status.code() == Some(255) &amp;&amp; !remote_ran &amp;&amp; attempt &lt; REMOTE_SSH_ATTEMPTS. The wrapper emits the marker only after the script finishes, so a transport drop after the state-changing script has executed but before the marker is received leaves remote_ran false and still resends the script. Thus status 255 can still cause a duplicate bootstrap; the current code does not establish that the remote command did not execute.
📋 Additional findings from this change (not shown inline) (8)
  • 🟠 High The publish job grants contents: write to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable github.token; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures. (.github/workflows/release.yml) — anchor-unreliable
  • 🟡 Medium The integrations setup prefix blocks the explicitly read-only integrations setup &lt;type&gt; --list form. build_integrations_command emits that exact form for IntegrationSetupCmd.list, while the classifier treats every setup invocation as dangerous; consequently a listing operation prompts and can be cancelled, violating the non-mutating-command exemption. (core/src/shell.rs) — anchor-outside-diff
  • 🟡 Medium When a JSON response contains both outer listing records and an output field containing a serialized listing, the two parsers disagree with the full payload: parse_vm_names returns immediately once it finds any outer name, and parse_ssh_destinations only unwraps when it finds no outer destinations. Thus {"vms":[{"vm_name":"existing"}],"output":"[{\"vm_name\":\"new\",\"ssh_dest\":\"vm+new@exe.dev\"}]"} yields only existing and no target for new, causing inventory/name-target mappings to be incomplete and potentially causing bootstrap to recreate or use hostname fallback for a VM represented in the serialized listing. This is disproven if the API contract guarantees wrapper objects never contain any listing fields outside output; the stated obligation for partially populated/wrapped listings should still be covered by a test or explicit invariant. (k8s_cli/src/manager/parsing.rs) — anchor-outside-diff
  • 🟡 Medium The systemd agent bootstrap can report success while the k3s agent has not actually become usable: it starts the unit with systemctl start --no-block, and k3s_service_started treats activating as success. The readiness loop then breaks immediately and the final check also accepts the same transient state, so a subsequent startup failure or hang is not surfaced. (k8s_cli/src/manager/scripts.rs) — anchor-outside-diff
  • 🟡 Medium The push path never validates the release tag before resolving it. Because the trigger pattern is only v*, a pushed tag such as v1.2 or vrelease reaches the resolve job and is exported to all builds; each matrix job then performs checkout and toolchain setup before set-version.sh rejects it. This violates the stated resolve-time validation and allows workflow runs for tags outside the binary/Cargo SemVer grammar (with wasted build work and no early rejection). The claim would be false if an external repository policy guaranteed every pushed v* tag is valid SemVer, but the workflow itself provides no such enforcement. (.github/workflows/release.yml) — anchor-unreliable
  • 🟡 Medium Several dangerous prefixes are matched without a token boundary, so valid/raw commands whose next token merely starts with the dangerous spelling are classified as dangerous. For example, exec -- ssh-key generate-api-keyx (and similarly team disablex, billing capacity-report, or team settings auto-join oncall) produces that exact command string and triggers the confirmation even though it is not the dangerous subcommand. This violates the invariant that a prefix should match only at a word boundary; the starts_with(prefix) branch currently accepts arbitrary suffixes for entries lacking a trailing space. This is a false-positive safety/CLI correctness issue, and it would be disproven if the remote command grammar intentionally treats these suffix forms as aliases of the dangerous commands. (core/src/shell.rs) — anchor-unreliable
  • 🟡 Medium Tailnet Lock lockout handling is bypassed when tailscale up returns the lockout error, so the script exits with the raw status before running CHECK_TAILNET_LOCK_SCRIPT; consequently the documented interactive sign-and-retry flow cannot trigger for the intended failure. (k8s_cli/src/manager/scripts.rs) — anchor-unreliable
  • 🔵 Low The dangerous-command classifier uses unbounded starts_with for several prefixes that do not end in a space, so raw commands whose subcommand is only a prefix of a dangerous form are incorrectly treated as dangerous. For example, exec -- team disablex, ssh-key generate-api-keyx, or billing capacityfoo reaches is_dangerous and prompts even though these are distinct command forms and should remain outside the policy. (core/src/shell.rs) — anchor-unreliable
♻️ Previously reported (still present) (4)
  • 🟠 High Plain-text error/status responses are treated as VM inventory names, violating the requirement that status/error strings not become names. For example, parse_vm_names(r#"{"output":"Error: quota exceeded\n"}"#) falls through to parse_vm_names_from_text, which returns {"Error:"}; an unwrapped VM vm-1 is unavailable similarly returns {"VM"}. A response such as vm-1 does not exist can falsely mark the planned VM as present, suppress creation, and later make bootstrap SSH to vm-1.exe.xyz. The parser should distinguish a valid rendered table from error/status text (or otherwise reject such text). This is introduced by the text fallback behavior in this change; it would be disproven only if the exe.dev client contract guarantees every non-JSON output is always a valid table and never an error/status body. (k8s_cli/src/manager/parsing.rs) — previously-reported
  • 🟠 High Tag verification and release publication are separated by a TOCTOU window. The workflow verifies that the tag resolves to BUILT_SHA, then invokes the release action using the mutable tag name; if an attacker moves the tag after the API check but before or during the action, the release can be published under a tag that no longer points to the built commit. Thus the check does not guarantee publishing is blocked when the tag moves after builds. The claim would be false only if tag mutation were prevented for the entire job by an external immutable-tag policy, which is not enforced by this workflow. (.github/workflows/release.yml) — anchor-unreliable
  • 🟡 Medium An explicit formula path is not safely constrained to the tap and can overwrite an arbitrary file (or follow a symlink) despite the script's path check. (scripts/release/sync-homebrew-tap.sh) — previously-reported
  • 🟡 Medium Automatic layout discovery can select a sharded destination based solely on an unrelated one-character directory, even when the tap is flat and Homebrew will use the flat formula path. (scripts/release/sync-homebrew-tap.sh) — previously-reported
🤖 Prompt for AI agents — all findings (20)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Unresolved from the previous review — these block approval, fix them first (3)

Somewhere in the code under review, address this finding:
Existing-mode readiness, metadata, and manifest operations are not bound to K3S_URL: when --kubeconfig and KUBECONFIG are absent, the code proceeds with kubectl's default context. This can report readiness for or mutate an unrelated cluster while workers were installed against the required K3S_URL.

Somewhere in the code under review, address this finding:
The generated k3s server kubeconfig is made world-readable on the VM: both server installation paths pass --write-kubeconfig-mode 644, although /etc/rancher/k3s/k3s.yaml contains client credentials. Any local VM user can read those credentials before fetch_kubeconfig copies the file.

In k8s_cli/src/manager/process.rs, address this finding:
A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. `capture_remote_ssh_output` retries solely from `output.status.code() == Some(255)` and ignores whether stdout already contains the wrapper's `__EXEDEV_K8S_EXIT__:` marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent.

## Findings on this change (also posted as inline comments) (5)

In .github/workflows/release.yml around line 194, address this finding:
The publish-time moving-tag check is not an atomic safeguard: it reads the tag target, then separately invokes `softprops/action-gh-release` with only `tag_name`. If an authorized actor moves the tag after the `gh api` check but before or during the release action, the action can create/update the release for the new target while attaching archives built from `BUILT_SHA`. Thus the workflow can publish artifacts that do not match the tag despite the verification step. This would be false only if the repository prevents tag updates for the entire check-to-action interval or the release action/API pins the release target to `BUILT_SHA` (neither is expressed here).

In scripts/release/sync-homebrew-tap.sh around line 175, address this finding:
Archive member validation checks only that expected names appear, not that the entries are regular root files, so a malicious or malformed release archive can pass validation while formula installation receives the wrong payload type.

In scripts/release/sync-homebrew-tap.sh around line 72, address this finding:
The script allows FORMULA_NAME and FORMULA_CLASS to identify different formulae, producing a file Homebrew cannot discover under the requested formula name.

In core/src/shell.rs around line 52, address this finding:
`share receive-email` is a mutating access/capability command but is not classified as dangerous. The CLI builds `share receive-email <vm> [on|off] [--reply-policy ...]`, which changes whether a VM can send email and who it may email; `guard_dangerous_command` is called for both transports before execution, yet `is_dangerous` has no `share receive-email` rule. Thus a user can enable broad email capability without confirmation.

In k8s_cli/src/manager/tests.rs around line 188, address this finding:
The destination-precedence test does not actually verify the required invariant that `ssh_dest` is authoritative: in `parses_ssh_destinations_from_ls_json`, each record either has matching `ssh_dest`/host fields or only host/user fields. A regression that incorrectly prefers `ssh_host` + `ssh_user` over a conflicting `ssh_dest` would pass the suite, despite connecting to a different VM route. This is a concrete test-alignment gap; it would be disproven if another exercised test (outside the reviewed file) supplies conflicting fields and asserts `ssh_dest` wins.

## Additional findings on this change (not posted inline) (8)

In .github/workflows/release.yml, address this finding:
The publish job grants `contents: write` to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable `github.token`; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures.

In core/src/shell.rs around line 65, address this finding:
The `integrations setup ` prefix blocks the explicitly read-only `integrations setup <type> --list` form. `build_integrations_command` emits that exact form for `IntegrationSetupCmd.list`, while the classifier treats every setup invocation as dangerous; consequently a listing operation prompts and can be cancelled, violating the non-mutating-command exemption.

In k8s_cli/src/manager/parsing.rs around line 23, address this finding:
When a JSON response contains both outer listing records and an `output` field containing a serialized listing, the two parsers disagree with the full payload: `parse_vm_names` returns immediately once it finds any outer name, and `parse_ssh_destinations` only unwraps when it finds no outer destinations. Thus `{"vms":[{"vm_name":"existing"}],"output":"[{\"vm_name\":\"new\",\"ssh_dest\":\"vm+new@exe.dev\"}]"}` yields only `existing` and no target for `new`, causing inventory/name-target mappings to be incomplete and potentially causing bootstrap to recreate or use hostname fallback for a VM represented in the serialized listing. This is disproven if the API contract guarantees wrapper objects never contain any listing fields outside `output`; the stated obligation for partially populated/wrapped listings should still be covered by a test or explicit invariant.

In k8s_cli/src/manager/scripts.rs around line 87, address this finding:
The systemd agent bootstrap can report success while the k3s agent has not actually become usable: it starts the unit with `systemctl start --no-block`, and `k3s_service_started` treats `activating` as success. The readiness loop then breaks immediately and the final check also accepts the same transient state, so a subsequent startup failure or hang is not surfaced.

In .github/workflows/release.yml, address this finding:
The push path never validates the release tag before resolving it. Because the trigger pattern is only `v*`, a pushed tag such as `v1.2` or `vrelease` reaches the resolve job and is exported to all builds; each matrix job then performs checkout and toolchain setup before `set-version.sh` rejects it. This violates the stated resolve-time validation and allows workflow runs for tags outside the binary/Cargo SemVer grammar (with wasted build work and no early rejection). The claim would be false if an external repository policy guaranteed every pushed `v*` tag is valid SemVer, but the workflow itself provides no such enforcement.

In core/src/shell.rs, address this finding:
Several dangerous prefixes are matched without a token boundary, so valid/raw commands whose next token merely starts with the dangerous spelling are classified as dangerous. For example, `exec -- ssh-key generate-api-keyx` (and similarly `team disablex`, `billing capacity-report`, or `team settings auto-join oncall`) produces that exact command string and triggers the confirmation even though it is not the dangerous subcommand. This violates the invariant that a prefix should match only at a word boundary; the `starts_with(prefix)` branch currently accepts arbitrary suffixes for entries lacking a trailing space. This is a false-positive safety/CLI correctness issue, and it would be disproven if the remote command grammar intentionally treats these suffix forms as aliases of the dangerous commands.

In k8s_cli/src/manager/scripts.rs, address this finding:
Tailnet Lock lockout handling is bypassed when `tailscale up` returns the lockout error, so the script exits with the raw status before running `CHECK_TAILNET_LOCK_SCRIPT`; consequently the documented interactive sign-and-retry flow cannot trigger for the intended failure.

In core/src/shell.rs, address this finding:
The dangerous-command classifier uses unbounded `starts_with` for several prefixes that do not end in a space, so raw commands whose subcommand is only a prefix of a dangerous form are incorrectly treated as dangerous. For example, `exec -- team disablex`, `ssh-key generate-api-keyx`, or `billing capacityfoo` reaches `is_dangerous` and prompts even though these are distinct command forms and should remain outside the policy.

## Previously reported and still present (4)

In k8s_cli/src/manager/parsing.rs around line 39, address this finding:
Plain-text error/status responses are treated as VM inventory names, violating the requirement that status/error strings not become names. For example, `parse_vm_names(r#"{"output":"Error: quota exceeded\n"}"#)` falls through to `parse_vm_names_from_text`, which returns `{"Error:"}`; an unwrapped `VM vm-1 is unavailable` similarly returns `{"VM"}`. A response such as `vm-1 does not exist` can falsely mark the planned VM as present, suppress creation, and later make bootstrap SSH to `vm-1.exe.xyz`. The parser should distinguish a valid rendered table from error/status text (or otherwise reject such text). This is introduced by the text fallback behavior in this change; it would be disproven only if the exe.dev client contract guarantees every non-JSON output is always a valid table and never an error/status body.

In .github/workflows/release.yml, address this finding:
Tag verification and release publication are separated by a TOCTOU window. The workflow verifies that the tag resolves to `BUILT_SHA`, then invokes the release action using the mutable tag name; if an attacker moves the tag after the API check but before or during the action, the release can be published under a tag that no longer points to the built commit. Thus the check does not guarantee publishing is blocked when the tag moves after builds. The claim would be false only if tag mutation were prevented for the entire job by an external immutable-tag policy, which is not enforced by this workflow.

In scripts/release/sync-homebrew-tap.sh around line 121, address this finding:
An explicit formula path is not safely constrained to the tap and can overwrite an arbitrary file (or follow a symlink) despite the script's path check.

In scripts/release/sync-homebrew-tap.sh around line 106, address this finding:
Automatic layout discovery can select a sharded destination based solely on an unrelated one-character directory, even when the tap is flat and Homebrew will use the flat formula path.
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 7 of 7 areas reviewed

# The archives were built from one commit; the release is about to be
# attached to a tag name. If the tag moved in between, publishing would ship
# binaries that do not match the source the tag now points at.
- name: Verify the tag still points at the built commit

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
The publish-time moving-tag check is not an atomic safeguard: it reads the tag target, then separately invokes `softprops/action-gh-release` with only `tag_name`. If an authorized actor moves the tag after the `gh api` check but before or during the release action, the action can create/update the release for the new target while attaching archives built from `BUILT_SHA`. Thus the workflow can publish artifacts that do not match the tag despite the verification step. This would be false only if the repository prevents tag updates for the entire check-to-action interval or the release action/API pins the release target to `BUILT_SHA` (neither is expressed here).

Comment thread scripts/release/sync-homebrew-tap.sh Outdated
for platform in "${PLATFORMS[@]}"; do
tar -tzf "$WORK_DIR/${ARCHIVE_PREFIX}-${RELEASE_TAG}-${platform}.tar.gz" > "$WORK_DIR/members.txt"
for member in "${BINARIES[@]}" "${DOCS[@]}"; do
if ! grep -qx "\./$member" "$WORK_DIR/members.txt"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact handling of a symlink source by Homebrew's bin.install/doc.install implementation is external to this repository, but the validation bypass itself is deterministic: tar -t emits an exact ./name line for a symlink, without indicating that it is not a regular file.
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
Archive member validation checks only that expected names appear, not that the entries are regular root files, so a malicious or malformed release archive can pass validation while formula installation receives the wrong payload type.

exit 1
fi

if [[ ! "$FORMULA_CLASS" =~ ^[A-Z][0-9A-Za-z_]*$ ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact Homebrew error wording may vary by Homebrew version, but a formula file's class is expected to correspond to its formula name and the generated mismatch cannot be installed as the requested name.
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
The script allows FORMULA_NAME and FORMULA_CLASS to identify different formulae, producing a file Homebrew cannot discover under the requested formula name.

Comment thread core/src/shell.rs Outdated
"share remove-link ",
"share remove-share-link ",
"share remove ",
"share access allow ",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In core/src/shell.rs, address this finding:
`share receive-email` is a mutating access/capability command but is not classified as dangerous. The CLI builds `share receive-email <vm> [on|off] [--reply-policy ...]`, which changes whether a VM can send email and who it may email; `guard_dangerous_command` is called for both transports before execution, yet `is_dangerous` has no `share receive-email` rule. Thus a user can enable broad email capability without confirmation.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
"share access allow ",
"share access allow ",
"share receive-email",

fn parses_ssh_destinations_from_ls_json() {
let destinations = parse_ssh_destinations(
r#"{"vms":[
{"vm_name":"routable","ssh_dest":"routable.exe.xyz","ssh_host":"routable.exe.xyz"},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🔵 Low

🧩 Analysis
  • Change relation: unknown
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In k8s_cli/src/manager/tests.rs, address this finding:
The destination-precedence test does not actually verify the required invariant that `ssh_dest` is authoritative: in `parses_ssh_destinations_from_ls_json`, each record either has matching `ssh_dest`/host fields or only host/user fields. A regression that incorrectly prefers `ssh_host` + `ssh_user` over a conflicting `ssh_dest` would pass the suite, despite connecting to a different VM route. This is a concrete test-alignment gap; it would be disproven if another exercised test (outside the reviewed file) supplies conflicting fields and asserts `ssh_dest` wins.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
{"vm_name":"routable","ssh_dest":"routable.exe.xyz","ssh_host":"routable.exe.xyz"},
{"vm_name":"routable","ssh_dest":"routable.exe.xyz","ssh_host":"wrong.exe.dev"},

…fication

Existing mode joined workers to K3S_URL and then labelled, tainted, and deployed
wherever kubectl happened to point, warning about it rather than checking. The
kubectl context is now compared with K3S_URL by host and port before anything is
changed, so a default context for another cluster fails instead of receiving the
fleet's metadata and manifests.

The dangerous-command classifier matched prefixes with `starts_with`, so raw
commands that merely spell one as a prefix — `team disablex`,
`billing capacityfoo`, `ssh-key generate-api-keyx` — prompted as if they were the
dangerous form. Matching is now anchored at a word boundary, which also lets the
list drop its trailing-space convention. `share receive-email` joins the list,
since it turns a VM's mailbox on and sets who it may write to, while the
read-only `integrations setup --list` and `--verify` forms no longer prompt.

`tailscale up` exits non-zero when the node is locked out, and the script
returned that status before reaching the Tailnet Lock check, so the documented
sign-and-retry flow could never trigger for the case it exists for. The check now
runs before the status is acted on.

The text fallback took any first word as a VM name, so `Error: quota exceeded`
became a VM called `Error:` and `VM vm-1 is unavailable` became `VM`; a planned
VM reported that way would look like it already existed. Words that are not DNS
labels are no longer inventory.

State directory names sanitized `a/b` and `a_b` to the same directory, where two
clusters would overwrite each other's token; unsafe names now carry a digest of
the original. An existing but empty token file was returned as the cluster
credential.

Release: the tag is validated on the push path too, since the trigger only
filters `v*`; verification moved to its own job so the write-capable token is
scoped to publishing alone; and a post-publish check turns a tag moved during
the release action from a silent mismatch into a failed run. The tap script
requires archive members to be regular files rather than merely present, keeps
FORMULA_CLASS consistent with FORMULA_NAME, confines an explicit
TAP_FORMULA_PATH to the tap and refuses a symlink, and treats a directory as
sharded only when it actually holds formulae.

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (2)
  • 🟡 Medium Endpoint comparison fails to normalize a trailing dot when an explicit port is present. same_cluster_endpoint("https://k3s.example.:6443", "https://k3s.example:6443") parses the first host as k3s.example. (the trim_end_matches('.') is applied to the whole authority before splitting, so it cannot remove a dot before :6443) and therefore rejects two equivalent DNS authorities. This violates the compatibility requirement for trailing dots and blocks existing-cluster bootstrap for a valid kubeconfig/K3S_URL pair. (inline)
  • 🟡 Medium Permission tightening of a reused token has a symlink-swap window that can chmod an attacker-selected target. (inline)
⛔ Unresolved from previous review (3) — not approved until fixed
  • The tag recheck is check-then-use rather than an atomic publish target. A maintainer or attacker who moves the release tag after Verify the tag still points at the built commit completes but before action-gh-release resolves tag_name, can cause the release to be attached to the moved tag while the archives remain from BUILT_SHA; the workflow will not refuse that move. — The current workflow still performs a pre-publish check in verify, then later passes the mutable tag name (needs.resolve.outputs.tag) to softprops/action-gh-release. A tag can move after the check and before or during that publish step, so the release can still be attached to the moved tag while the archives were built from BUILT_SHA. The new post-publish check only detects the race after the release has already been created/updated and does not undo or prevent the mismatch; therefore it is not an atomic publish target.
  • The publish job grants contents: write to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable github.token; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures. — The current workflow still sets permissions: contents: write at the publish job level. GitHub Actions applies that job token scope to every step in the job, so Download release archives and the final Confirm the published tag is still the built commit shell step still execute with a write-capable github.token; moving verification to a read-only verify job does not remove the exposure from the publish job's preceding and following steps.
  • k8s_cli/src/manager/process.rs: A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. capture_remote_ssh_output retries solely from output.status.code() == Some(255) and ignores whether stdout already contains the wrapper's __EXEDEV_K8S_EXIT__: marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent. — The retry is now suppressed when captured stdout contains REMOTE_EXIT_PREFIX, but it still retries any status-255 result without that marker. A lost SSH connection can occur after the wrapped script has executed (including after the state-changing bootstrap) but before the wrapper's final marker is delivered, leaving no marker in the client buffer; that outcome still satisfies output.status.code() == Some(255) &amp;&amp; !remote_ran and resends the script. The marker is therefore only evidence of completion when received, not evidence that execution did not occur when absent.
⚠️ Unverified risks (1)
  • A push-triggered annotated tag is not dereferenced before its SHA is exported. For an annotated-tag push, GITHUB_SHA can be the tag-object SHA rather than the commit SHA; the build jobs then pass that object ID to actions/checkout and the verify job compares the tag's dereferenced commit against the tag-object ID. This causes annotated tags pushed through the normal release trigger to fail checkout or fail verification, while the dispatch path explicitly handles this case. (.github/workflows/release.yml)
📋 Additional findings from this change (not shown inline) (6)
  • 🟠 High Changing a node's desired taint to none (or to a different taint) leaves stale scheduling restrictions on the cluster, and status reports the node as healthy anyway. apply_node_metadata only issues kubectl taint when the plan has a taint, so it never removes an old taint; meanwhile print_kubernetes_status treats every taint as acceptable when expected.taint is None and only checks presence rather than exact taint state. For example, a node previously configured with exedev.dev/pool=blue:NoSchedule remains tainted after the fleet is edited to unisolated, while status prints taint=ok, violating metadata reconciliation and drift reporting. (k8s_cli/src/manager/mod.rs) — anchor-outside-diff
  • 🟠 High The supervisor path downloads and executes the k3s installer without checksum verification. (k8s_cli/src/manager/scripts.rs) — anchor-unreliable
  • 🟡 Medium The no-supervisor PID fallback can incorrectly skip starting k3s when its PID file is stale or the PID has been reused by an unrelated process. (k8s_cli/src/manager/scripts.rs) — anchor-outside-diff
  • 🟡 Medium An explicit bare filename supplied as --kubeconfig cannot be written in the current directory. (k8s_cli/src/manager/state.rs) — anchor-outside-diff
  • 🟡 Medium Cluster-derived state paths can escape the intended state directory when a parent component is a symlink. (k8s_cli/src/manager/state.rs) — anchor-outside-diff
  • 🔵 Low Failed or interrupted no-supervisor k3s downloads can leave credential-free but persistent temporary binary/hash files in /tmp because cleanup is performed only on checksum mismatch or successful completion. (k8s_cli/src/manager/scripts.rs) — anchor-outside-diff
♻️ Previously reported (still present) (2)
  • 🟡 Medium set-version.sh can leave a partially updated workspace and stale Cargo.lock when applying staged manifests is interrupted or a move fails. (scripts/release/set-version.sh) — previously-reported
  • 🟡 Medium Archive validation only proves that at least one regular entry with each required name appears in the verbose tar listing; an archive containing a required regular file followed by a duplicate symlink or non-regular entry with the same name can pass validation while extraction/install uses the later entry. (scripts/release/sync-homebrew-tap.sh) — previously-reported
❓ Low-evidence leads (not confirmed — verify before acting) (1)
  • The endpoint parser accepts malformed/ambiguous authorities instead of rejecting them. Any authority whose final colon component is non-numeric is treated as a host with implicit port 6443, so values such as https://k3s.example:6443:7443 can compare equal to the same malformed authority (and malformed user/host forms are not rejected). Existing-cluster authorization therefore does not enforce an unambiguous host-and-port authority before allowing worker joins and subsequent kubectl mutations. (k8s_cli/src/manager/mod.rs)
🤖 Prompt for AI agents — all findings (13)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Unresolved from the previous review — these block approval, fix them first (3)

Somewhere in the code under review, address this finding:
The tag recheck is check-then-use rather than an atomic publish target. A maintainer or attacker who moves the release tag after `Verify the tag still points at the built commit` completes but before `action-gh-release` resolves `tag_name`, can cause the release to be attached to the moved tag while the archives remain from BUILT_SHA; the workflow will not refuse that move.

Somewhere in the code under review, address this finding:
The publish job grants `contents: write` to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable `github.token`; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures.

In k8s_cli/src/manager/process.rs, address this finding:
A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. `capture_remote_ssh_output` retries solely from `output.status.code() == Some(255)` and ignores whether stdout already contains the wrapper's `__EXEDEV_K8S_EXIT__:` marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent.

## Findings on this change (also posted as inline comments) (2)

In k8s_cli/src/manager/mod.rs around line 454, address this finding:
Endpoint comparison fails to normalize a trailing dot when an explicit port is present. `same_cluster_endpoint("https://k3s.example.:6443", "https://k3s.example:6443")` parses the first host as `k3s.example.` (the `trim_end_matches('.')` is applied to the whole authority before splitting, so it cannot remove a dot before `:6443`) and therefore rejects two equivalent DNS authorities. This violates the compatibility requirement for trailing dots and blocks existing-cluster bootstrap for a valid kubeconfig/K3S_URL pair.

In k8s_cli/src/manager/state.rs around line 169, address this finding:
Permission tightening of a reused token has a symlink-swap window that can chmod an attacker-selected target.

## Additional findings on this change (not posted inline) (6)

In k8s_cli/src/manager/mod.rs around line 291, address this finding:
Changing a node's desired taint to none (or to a different taint) leaves stale scheduling restrictions on the cluster, and status reports the node as healthy anyway. `apply_node_metadata` only issues `kubectl taint` when the plan has a taint, so it never removes an old taint; meanwhile `print_kubernetes_status` treats every taint as acceptable when `expected.taint` is `None` and only checks presence rather than exact taint state. For example, a node previously configured with `exedev.dev/pool=blue:NoSchedule` remains tainted after the fleet is edited to unisolated, while `status` prints `taint=ok`, violating metadata reconciliation and drift reporting.

In k8s_cli/src/manager/scripts.rs, address this finding:
The supervisor path downloads and executes the k3s installer without checksum verification.

In k8s_cli/src/manager/scripts.rs around line 223, address this finding:
The no-supervisor PID fallback can incorrectly skip starting k3s when its PID file is stale or the PID has been reused by an unrelated process.

In k8s_cli/src/manager/state.rs around line 115, address this finding:
An explicit bare filename supplied as `--kubeconfig` cannot be written in the current directory.

In k8s_cli/src/manager/state.rs around line 116, address this finding:
Cluster-derived state paths can escape the intended state directory when a parent component is a symlink.

In k8s_cli/src/manager/scripts.rs around line 166, address this finding:
Failed or interrupted no-supervisor k3s downloads can leave credential-free but persistent temporary binary/hash files in /tmp because cleanup is performed only on checksum mismatch or successful completion.

## Previously reported and still present (2)

In scripts/release/set-version.sh around line 124, address this finding:
set-version.sh can leave a partially updated workspace and stale Cargo.lock when applying staged manifests is interrupted or a move fails.

In scripts/release/sync-homebrew-tap.sh around line 215, address this finding:
Archive validation only proves that at least one regular entry with each required name appears in the verbose tar listing; an archive containing a required regular file followed by a duplicate symlink or non-regular entry with the same name can pass validation while extraction/install uses the later entry.
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 5 of 7 areas reviewed

Comment thread k8s_cli/src/manager/mod.rs Outdated
fn same_cluster_endpoint(left: &str, right: &str) -> bool {
fn parts(url: &str) -> (String, String) {
let without_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let authority = without_scheme

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Compatibility | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In k8s_cli/src/manager/mod.rs, address this finding:
Endpoint comparison fails to normalize a trailing dot when an explicit port is present. `same_cluster_endpoint("https://k3s.example.:6443", "https://k3s.example:6443")` parses the first host as `k3s.example.` (the `trim_end_matches('.')` is applied to the whole authority before splitting, so it cannot remove a dot before `:6443`) and therefore rejects two equivalent DNS authorities. This violates the compatibility requirement for trailing dots and blocks existing-cluster bootstrap for a valid kubeconfig/K3S_URL pair.

Comment thread k8s_cli/src/manager/state.rs Outdated
if mode & 0o077 == 0 {
return Ok(());
}
fs::set_permissions(path, fs::Permissions::from_mode(0o600))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The practical impact depends on whether an attacker can write the state directory and whether the selected target is owned by the process user; chmod will fail for targets the process cannot change.
🤖 Prompt for AI agents
In k8s_cli/src/manager/state.rs, address this finding:
Permission tightening of a reused token has a symlink-swap window that can chmod an attacker-selected target.

apply_node_metadata only ever added the desired taint, so a pool changed to
unisolated kept its old NoSchedule and stayed unschedulable while the plan said
otherwise, and status printed taint=ok because it treated a desired `None` as
nothing to check. Taints under the exedev.dev prefix that the plan no longer
wants are now removed, taints set by anything else are left alone, and status
compares what is on the node with what the plan asks for.

same_cluster_endpoint trimmed the root label from the whole authority, which
cannot reach a dot before an explicit port, so `k3s.example.:6443` and
`k3s.example:6443` compared as different clusters and blocked existing-mode
bootstrap. The trim now happens after the port is split off.

Tightening a reused token's permissions went through the path, and
fs::set_permissions follows symlinks, so an entry swapped after the check could
have had its target chmodded instead. It now happens through the handle the
contents were read from. State directories are also confirmed to be real
directories rather than symlinks before a secret is written into them, and a
bare relative path no longer takes the create_dir_all branch at all.

A recorded k3s pid can outlive the process and be reused by an unrelated one,
which read as "already running" and skipped the start; the pid is now confirmed
to still belong to k3s. A failed k3s download left its partial file in /tmp
rather than cleaning up as the checksum-mismatch path does.

Release: the post-publish check moved into its own job, so the job holding
contents: write is down to downloading the artifacts and running the release
action. Archive validation now requires every entry with a required name to be
a regular file, since extraction applies entries in order and a later symlink
would be what gets installed. set-version.sh marks the workspace as applied
before the first move rather than after the last, so an interrupt partway
through the moves still restores.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/src/shell.rs`:
- Around line 104-109: Update matches_command so command names are recognized
when followed by any shell whitespace separator, including tabs, rather than
only U+0020. Preserve exact-name matching and the existing argument-boundary
behavior for other whitespace-separated tokens.

In `@k8s_cli/src/manager/mod.rs`:
- Around line 771-781: Update stale_owned_taints to compare tool-owned taints by
both key and effect rather than key alone, and format removals as key:effect-.
Ensure a changed effect removes the previous taint while retaining the desired
one, and add a regression test covering the same key with different effects.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fb3c36bc-274d-4410-bf79-de1e804a5a37

📥 Commits

Reviewing files that changed from the base of the PR and between a034aa5 and 97b0ed2.

📒 Files selected for processing (10)
  • .github/workflows/release.yml
  • core/src/shell.rs
  • k8s_cli/src/manager/mod.rs
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/scripts.rs
  • k8s_cli/src/manager/state.rs
  • k8s_cli/src/manager/tests.rs
  • scripts/release/check-version.sh
  • scripts/release/set-version.sh
  • scripts/release/sync-homebrew-tap.sh
🚧 Files skipped from review as they are similar to previous changes (5)
  • scripts/release/check-version.sh
  • k8s_cli/src/manager/parsing.rs
  • scripts/release/sync-homebrew-tap.sh
  • scripts/release/set-version.sh
  • k8s_cli/src/manager/state.rs
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: winnowl/review
🔇 Additional comments (7)
k8s_cli/src/manager/mod.rs (1)

16-16: LGTM!

Also applies to: 36-36, 53-54, 293-304, 363-417, 432-482

k8s_cli/src/manager/scripts.rs (1)

53-67: LGTM!

Also applies to: 182-188, 214-214, 239-245, 295-313

k8s_cli/src/manager/tests.rs (1)

185-207: LGTM!

Also applies to: 483-572, 604-623

.github/workflows/release.yml (4)

229-260: The post-publication check does not prevent the existing tag-move race.

confirm fails only after softprops/action-gh-release publishes the release. If the tag moves after verify completes, the release can still attach archives built from BUILT_SHA to the moved tag. This is the same issue already reported for Line 229.


62-66: LGTM!


94-176: LGTM!


178-206: LGTM!

Comment thread core/src/shell.rs
Comment on lines +104 to +109
fn matches_command(command: &str, name: &str) -> bool {
command == name
|| command
.strip_prefix(name)
.is_some_and(|rest| rest.starts_with(' '))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Match tab-separated command tokens.

matches_command only accepts U+0020 after the command name. A command such as team\tdisable --yes or rm\t-rf ... returns false, although tabs separate shell command tokens. This bypasses the confirmation policy for raw exec commands.

Proposed fix
 fn matches_command(command: &str, name: &str) -> bool {
     command == name
         || command
             .strip_prefix(name)
-            .is_some_and(|rest| rest.starts_with(' '))
+            .is_some_and(|rest| rest.starts_with(' ') || rest.starts_with('\t'))
 }
 fn danger_matching_stops_at_word_boundaries() {
+    assert!(is_dangerous("team\tdisable --yes"));
     assert!(!is_dangerous("team disablex"));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/src/shell.rs` around lines 104 - 109, Update matches_command so command
names are recognized when followed by any shell whitespace separator, including
tabs, rather than only U+0020. Preserve exact-name matching and the existing
argument-boundary behavior for other whitespace-separated tokens.

Comment thread k8s_cli/src/manager/mod.rs Outdated
Comment on lines +771 to +781
let desired_key = desired.and_then(taint_key);
nodes
.get(name)
.map(|node| {
node.taints
.iter()
.filter_map(|taint| taint_key(taint))
.filter(|key| key.starts_with(NODE_LABEL_PREFIX))
.filter(|key| Some(*key) != desired_key)
.map(|key| format!("{key}-"))
.collect()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 6 'stale_owned_taints|taint_key|taint_identity|NoSchedule|NoExecute' \
  k8s_cli/src/manager/mod.rs k8s_cli/src/manager/tests.rs

Repository: lollipopkit/exedev-cli

Length of output: 8200


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- taint reconciliation and command construction ---'
sed -n '260,315p;700,790p' k8s_cli/src/manager/mod.rs

printf '%s\n' '--- taint parsing, desired-value construction, and tests ---'
rg -n -C 8 'taint_key|taint|NoExecute|NoSchedule|overwrite|stale_owned_taints' \
  k8s_cli/src/manager/mod.rs k8s_cli/src/manager/tests.rs

Repository: lollipopkit/exedev-cli

Length of output: 24694


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

p = Path("k8s_cli/src/manager/mod.rs")
text = p.read_text()
start = text.index("fn stale_owned_taints(")
end = text.index("\nfn kubeconfig_for_bootstrap", start)
print(text[start:end])

tests = Path("k8s_cli/src/manager/tests.rs").read_text()
start = tests.index("fn stale_owned_taints_are_scheduled_for_removal")
end = tests.find("\n#[test]", start + 1)
print("\n--- test ---")
print(tests[start:end if end != -1 else None])
PY

Repository: lollipopkit/exedev-cli

Length of output: 1931


🌐 Web query:

Kubernetes kubectl taint official documentation remove taint key:effect- overwrite same key different effect

💡 Result:

To remove a taint from a Kubernetes node, you append a hyphen (-) to the end of the taint specification used in the kubectl taint command [1][2]. Removing a specific taint with a known key and effect: To remove a taint matching both the key and the effect (e.g., key dedicated and effect NoSchedule), use the following syntax [1][3]: kubectl taint nodes :- Removing all taints with a specific key: To remove all taints that share the same key, regardless of their effect or value, append a hyphen to the key only [1][3]: kubectl taint nodes - Overwriting an existing taint: If you want to update the value of an existing taint, you do not need to remove it first. If you apply a taint with a key and effect that already exists on the node, Kubernetes will overwrite the existing value with the new one specified in your command [1][4]. For example, running: kubectl taint nodes =: will update the existing taint (with matching key and effect) to have . If the taint does not exist, it will be added [1][4]. Note: If you attempt to add a taint that already exists without intending to overwrite the value, you can control this behavior using the --overwrite flag (which defaults to false) [3][5]. Setting --overwrite=false will reject updates that would overwrite an existing taint [3][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read-only behavioral check of the repository's string representation.
python3 - <<'PY'
def taint_key(taint):
    return taint.split("=", 1)[0].split(":", 1)[0] or None

def stale_owned_taints(taints, desired):
    desired_key = taint_key(desired) if desired else None
    return [
        f"{key}-"
        for taint in taints
        if (key := taint_key(taint))
        and key.startswith("exedev.dev/")
        and key != desired_key
    ]

actual = ["exedev.dev/pool=blue:NoSchedule"]
desired = "exedev.dev/pool=blue:NoExecute"
print("current implementation:", stale_owned_taints(actual, desired))
print("desired key/effect removal:", ["exedev.dev/pool:NoSchedule-"])
PY

Repository: lollipopkit/exedev-cli

Length of output: 247


Reconcile tool-owned taints by key and effect.

stale_owned_taints compares only taint keys. A change from exedev.dev/pool=blue:NoSchedule to exedev.dev/pool=blue:NoExecute adds the new taint but leaves NoSchedule active.

Compare (key, effect) identities and remove stale entries with key:effect-. Add a regression test for the same key with different effects.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@k8s_cli/src/manager/mod.rs` around lines 771 - 781, Update stale_owned_taints
to compare tool-owned taints by both key and effect rather than key alone, and
format removals as key:effect-. Ensure a changed effect removes the previous
taint while retaining the desired one, and add a regression test covering the
same key with different effects.

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (8)
  • 🟠 High If the repository starts without Cargo.lock, a failed or interrupted migration can leave a newly created lockfile beside restored old manifests, violating the all-old/all-new state invariant. (inline)
  • 🟠 High The symlink and confinement checks are not atomic with the final write, so a local attacker able to modify the tap can replace the validated formula or parent directory before cat &gt; and redirect the overwrite. (inline)
  • 🟠 High A duplicate required member can end in a symlink and still pass validation if the symlink target is not the required name, because the awk predicate does not identify the symlink's actual name. (inline)
  • 🟠 High An explicit formula path is unconstrained when TAP_REPO_PATH is missing (or points at a non-directory), so the script can write outside any tap root. For example, with the default tap absent, TAP_FORMULA_PATH=/tmp/attacker-owned/out.rb TAP_REPO_PATH=/does-not-exist, the -d guard skips confinement, mkdir -p succeeds, and the final redirection overwrites/creates /tmp/attacker-owned/out.rb; /etc/... or another sensitive writable location is likewise reachable if permissions allow. A relative TAP_FORMULA_PATH=out.rb in an attacker-chosen working directory has the same behavior. The later missing-tap check only rejects the non-explicit case (-z "$EXPLICIT_TAP_FORMULA_PATH"), leaving this bypass intact. This is exploitable whenever untrusted or attacker-controlled environment/working-directory values reach a privileged or automation invocation; it would be disproven only if callers guarantee both a trusted existing TAP_REPO_PATH and a trusted explicit path. (inline)
  • 🟡 Medium The build is not reproducible despite using cargo build --locked: the workflow installs the moving stable Rust channel and the unversioned musl-tools package from the current Ubuntu repositories. Re-running the same resolved commit after either toolchain or system package updates can produce different binaries (or fail), so --locked only fixes Cargo dependency resolution and does not satisfy the stated reproducible locked-build obligation. (inline)
  • 🟡 Medium Manual resolution is not a single consistent read of the tag ref: it performs separate gh api .../git/ref/tags/${tag} requests for .object.sha and .object.type (lines 39-40). If the tag is moved between those requests, the SHA can come from the old target while the type comes from the new target. In the old-tag/new-commit interleaving, the code skips peeling because type is commit and exports the old tag-object SHA as needs.resolve.outputs.sha; builds then checkout the wrong object (or fail), and the later verification only detects the mismatch after the build. The claim is false only if the tag is guaranteed immutable during this two-request window, which the workflow otherwise explicitly treats as a possible race at lines 156-159. (inline)
  • 🟡 Medium A pre-existing sibling backup file can be destroyed even when the migration fails before making any changes. (inline)
  • 🟡 Medium set-version.sh is not safe against symlinked staging paths in the checkout: its awk redirection writes to a predictable Cargo.toml.tmp path, and the later mv can replace the real manifest with that symlink. For example, a checked-out core/Cargo.toml.tmp -&gt; /tmp/target causes the awk rewrite to write the generated manifest through /tmp/target, after which mv core/Cargo.toml.tmp core/Cargo.toml installs the symlink as the manifest. The same issue applies to the root .tmp and .next paths (and backup paths). This is a concrete arbitrary-file overwrite / workspace-corruption path whenever the repository or surrounding workspace can contain an attacker-controlled symlink; it would be disproven if the invocation environment guarantees all these adjacent paths are trusted non-symlinks and the checkout rejects symlinks. (inline)
⛔ Unresolved from previous review (2) — not approved until fixed
  • The publish job grants contents: write to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable github.token; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures. — The publish job still declares permissions: contents: write, and its first step, Download release archives, runs in that same job. GitHub Actions applies job-level token permissions to every step; moving tag verification to the separate read-only verify job removes write access from that shell step, but does not prevent the download action from receiving the write-capable github.token. A compromised download action can therefore still perform the issue's described repository mutation.
  • k8s_cli/src/manager/process.rs: A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. capture_remote_ssh_output retries solely from output.status.code() == Some(255) and ignores whether stdout already contains the wrapper's __EXEDEV_K8S_EXIT__: marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent. — The retry is now suppressed when captured stdout contains REMOTE_EXIT_PREFIX, but it still retries any SSH 255 that lacks the marker. If the remote wrapper finishes the non-idempotent script and the connection drops before the marker reaches the client, stdout has no marker while the remote state change has already occurred, so the full script is sent again. Marker absence does not establish that remote execution did not complete.
⚠️ Unverified risks (1)
  • Plain-text fallback accepts arbitrary lowercase words as VM inventory entries, so non-table prose or malformed API responses can make an existing VM appear present and suppress creation/bootstrap. (k8s_cli/src/manager/parsing.rs)
📋 Additional findings from this change (not shown inline) (6)
  • 🟠 High The verify/confirm sequence does not prevent a tag race during publication: after verify checks the tag (lines 164-176), an actor can move it before softprops/action-gh-release attaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built from BUILT_SHA while the tag points elsewhere; confirm (lines 216-237) only fails afterward and performs no deletion, retagging, or release correction. Thus a successful publish followed by a red workflow still leaves a mismatched public release. This is false only if the tag cannot be moved by any actor/token during the verify-to-publish window or if post-publication detection is explicitly considered sufficient rather than preventing the mismatch. (.github/workflows/release.yml) — anchor-unreliable
  • 🟠 High The symlink checks and realpath confinement are TOCTOU checks, not protection for the actual write. After -L "$TAP_FORMULA_PATH" and FORMULA_PARENT_REAL pass, a local attacker who can modify the tap directory can replace the formula file with a symlink (or replace a checked parent directory with a symlink) while the four release archives are downloaded/validated. The final cat &gt; "$TAP_FORMULA_PATH" follows that replacement and truncates/writes the symlink target outside the tap, potentially overwriting an arbitrary attacker-selected writable file. This is disproven only if the tap tree is guaranteed immutable/unmodifiable by other users/processes for the whole invocation, not merely trusted at startup. (scripts/release/sync-homebrew-tap.sh) — per-file-budget
  • 🟠 High Direct JSON arrays of arbitrary strings are treated as VM names without validation, allowing status/error payloads such as ["error", "quota exceeded"] (or a serialized arbitrary string list) to become inventory entries and suppress VM creation. (k8s_cli/src/manager/parsing.rs) — anchor-unreliable
  • 🟡 Medium Tag verification is implemented as two independent ref API requests for SHA and type. If an annotated tag is moved from tag object A to tag object B between those requests, verification can read A's SHA and B's tag type, dereference A, and compare the old commit as if it were the current tag; it can therefore pass while the ref now names B. The same split-read exists in resolve for manual dispatch, so the pinned SHA itself can be selected from a torn snapshot. (.github/workflows/release.yml) — anchor-unreliable
  • 🟡 Medium The generated formula does not pin the release version, so Homebrew derives its version from a platform-suffixed archive URL instead of the validated tag version. (scripts/release/sync-homebrew-tap.sh) — per-file-budget
  • 🟡 Medium Release-tag validation is locale-dependent and can admit characters outside the intended ASCII SemVer grammar, which are then interpolated into URLs and local archive paths. (scripts/release/sync-homebrew-tap.sh) — per-file-budget
♻️ Previously reported (still present) (3)
  • 🟠 High Publication is not atomically bound to the commit that was verified: the workflow verifies the tag, then invokes the write-capable release action using only the mutable tag name. If an authorized actor force-moves the tag from BUILT_SHA to another commit after verify completes but before/during softprops/action-gh-release, the action can create/update the release for the moved tag and attach archives built from the old commit; confirm only turns this into a failed workflow after the mismatched release has already been published. (.github/workflows/release.yml) — previously-reported
  • 🟠 High Predictable staging filenames are followed as symlinks, so a manipulated checkout can make the migration overwrite files outside the repository. (scripts/release/set-version.sh) — anchor-unreliable
  • 🟡 Medium SSH destinations inside the wrapped serialized listing are skipped whenever the outer JSON also contains any destination. In a mixed response such as an outer VM record plus {"output":"[{\"vm_name\":\"inner\",\"ssh_dest\":\"vm+inner@exe.dev\"}]"}, parse_ssh_destinations returns only the outer route; bootstrap later uses &lt;inner&gt;.exe.xyz instead of the authoritative API route. (k8s_cli/src/manager/parsing.rs) — previously-reported
❓ Low-evidence leads (not confirmed — verify before acting) (1)
  • The semver validation is locale-dependent in this script: unlike check-version.sh, it never sets LC_ALL=C before Bash's [[ ... =~ ... ]] expressions. In a locale whose collation makes [0-9A-Za-z] match non-ASCII collating characters, a tag containing such a character can pass SEMVER_RE; that character is then interpolated into GitHub URLs and the generated Ruby formula, violating the script's stated guarantee that the validated tag is safe for those contexts. This is disproven only if the supported Bash/locale combinations are explicitly restricted to ASCII/C locale (or if the regex is independently shown not to widen there). (scripts/release/sync-homebrew-tap.sh)
🤖 Prompt for AI agents — all findings (19)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Unresolved from the previous review — these block approval, fix them first (2)

Somewhere in the code under review, address this finding:
The publish job grants `contents: write` to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable `github.token`; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures.

In k8s_cli/src/manager/process.rs, address this finding:
A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. `capture_remote_ssh_output` retries solely from `output.status.code() == Some(255)` and ignores whether stdout already contains the wrapper's `__EXEDEV_K8S_EXIT__:` marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent.

## Findings on this change (also posted as inline comments) (8)

In scripts/release/set-version.sh around line 117, address this finding:
If the repository starts without Cargo.lock, a failed or interrupted migration can leave a newly created lockfile beside restored old manifests, violating the all-old/all-new state invariant.

In scripts/release/sync-homebrew-tap.sh around line 146, address this finding:
The symlink and confinement checks are not atomic with the final write, so a local attacker able to modify the tap can replace the validated formula or parent directory before `cat >` and redirect the overwrite.

In scripts/release/sync-homebrew-tap.sh around line 219, address this finding:
A duplicate required member can end in a symlink and still pass validation if the symlink target is not the required name, because the awk predicate does not identify the symlink's actual name.

In scripts/release/sync-homebrew-tap.sh around line 153, address this finding:
An explicit formula path is unconstrained when TAP_REPO_PATH is missing (or points at a non-directory), so the script can write outside any tap root. For example, with the default tap absent, `TAP_FORMULA_PATH=/tmp/attacker-owned/out.rb TAP_REPO_PATH=/does-not-exist`, the `-d` guard skips confinement, `mkdir -p` succeeds, and the final redirection overwrites/creates `/tmp/attacker-owned/out.rb`; `/etc/...` or another sensitive writable location is likewise reachable if permissions allow. A relative `TAP_FORMULA_PATH=out.rb` in an attacker-chosen working directory has the same behavior. The later missing-tap check only rejects the non-explicit case (`-z "$EXPLICIT_TAP_FORMULA_PATH"`), leaving this bypass intact. This is exploitable whenever untrusted or attacker-controlled environment/working-directory values reach a privileged or automation invocation; it would be disproven only if callers guarantee both a trusted existing TAP_REPO_PATH and a trusted explicit path.

In .github/workflows/release.yml around line 106, address this finding:
The build is not reproducible despite using `cargo build --locked`: the workflow installs the moving `stable` Rust channel and the unversioned `musl-tools` package from the current Ubuntu repositories. Re-running the same resolved commit after either toolchain or system package updates can produce different binaries (or fail), so `--locked` only fixes Cargo dependency resolution and does not satisfy the stated reproducible locked-build obligation.

In .github/workflows/release.yml around line 47, address this finding:
Manual resolution is not a single consistent read of the tag ref: it performs separate `gh api .../git/ref/tags/${tag}` requests for `.object.sha` and `.object.type` (lines 39-40). If the tag is moved between those requests, the SHA can come from the old target while the type comes from the new target. In the old-tag/new-commit interleaving, the code skips peeling because `type` is `commit` and exports the old tag-object SHA as `needs.resolve.outputs.sha`; builds then checkout the wrong object (or fail), and the later verification only detects the mismatch after the build. The claim is false only if the tag is guaranteed immutable during this two-request window, which the workflow otherwise explicitly treats as a possible race at lines 156-159.

In scripts/release/set-version.sh around line 121, address this finding:
A pre-existing sibling backup file can be destroyed even when the migration fails before making any changes.

In scripts/release/set-version.sh around line 34, address this finding:
set-version.sh is not safe against symlinked staging paths in the checkout: its awk redirection writes to a predictable `Cargo.toml.tmp` path, and the later `mv` can replace the real manifest with that symlink. For example, a checked-out `core/Cargo.toml.tmp -> /tmp/target` causes the awk rewrite to write the generated manifest through `/tmp/target`, after which `mv core/Cargo.toml.tmp core/Cargo.toml` installs the symlink as the manifest. The same issue applies to the root `.tmp` and `.next` paths (and backup paths). This is a concrete arbitrary-file overwrite / workspace-corruption path whenever the repository or surrounding workspace can contain an attacker-controlled symlink; it would be disproven if the invocation environment guarantees all these adjacent paths are trusted non-symlinks and the checkout rejects symlinks.

## Additional findings on this change (not posted inline) (6)

In .github/workflows/release.yml, address this finding:
The verify/confirm sequence does not prevent a tag race during publication: after `verify` checks the tag (lines 164-176), an actor can move it before `softprops/action-gh-release` attaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built from `BUILT_SHA` while the tag points elsewhere; `confirm` (lines 216-237) only fails afterward and performs no deletion, retagging, or release correction. Thus a successful publish followed by a red workflow still leaves a mismatched public release. This is false only if the tag cannot be moved by any actor/token during the verify-to-publish window or if post-publication detection is explicitly considered sufficient rather than preventing the mismatch.

In scripts/release/sync-homebrew-tap.sh around line 242, address this finding:
The symlink checks and realpath confinement are TOCTOU checks, not protection for the actual write. After `-L "$TAP_FORMULA_PATH"` and `FORMULA_PARENT_REAL` pass, a local attacker who can modify the tap directory can replace the formula file with a symlink (or replace a checked parent directory with a symlink) while the four release archives are downloaded/validated. The final `cat > "$TAP_FORMULA_PATH"` follows that replacement and truncates/writes the symlink target outside the tap, potentially overwriting an arbitrary attacker-selected writable file. This is disproven only if the tap tree is guaranteed immutable/unmodifiable by other users/processes for the whole invocation, not merely trusted at startup.

In k8s_cli/src/manager/parsing.rs, address this finding:
Direct JSON arrays of arbitrary strings are treated as VM names without validation, allowing status/error payloads such as `["error", "quota exceeded"]` (or a serialized arbitrary string list) to become inventory entries and suppress VM creation.

In .github/workflows/release.yml, address this finding:
Tag verification is implemented as two independent ref API requests for SHA and type. If an annotated tag is moved from tag object A to tag object B between those requests, verification can read A's SHA and B's `tag` type, dereference A, and compare the old commit as if it were the current tag; it can therefore pass while the ref now names B. The same split-read exists in resolve for manual dispatch, so the pinned SHA itself can be selected from a torn snapshot.

In scripts/release/sync-homebrew-tap.sh around line 243, address this finding:
The generated formula does not pin the release version, so Homebrew derives its version from a platform-suffixed archive URL instead of the validated tag version.

In scripts/release/sync-homebrew-tap.sh around line 51, address this finding:
Release-tag validation is locale-dependent and can admit characters outside the intended ASCII SemVer grammar, which are then interpolated into URLs and local archive paths.

## Previously reported and still present (3)

In .github/workflows/release.yml around line 229, address this finding:
Publication is not atomically bound to the commit that was verified: the workflow verifies the tag, then invokes the write-capable release action using only the mutable tag name. If an authorized actor force-moves the tag from BUILT_SHA to another commit after `verify` completes but before/during `softprops/action-gh-release`, the action can create/update the release for the moved tag and attach archives built from the old commit; `confirm` only turns this into a failed workflow after the mismatched release has already been published.

In scripts/release/set-version.sh, address this finding:
Predictable staging filenames are followed as symlinks, so a manipulated checkout can make the migration overwrite files outside the repository.

In k8s_cli/src/manager/parsing.rs around line 89, address this finding:
SSH destinations inside the wrapped serialized listing are skipped whenever the outer JSON also contains any destination. In a mixed response such as an outer VM record plus `{"output":"[{\"vm_name\":\"inner\",\"ssh_dest\":\"vm+inner@exe.dev\"}]"}`, parse_ssh_destinations returns only the outer route; bootstrap later uses `<inner>.exe.xyz` instead of the authoritative API route.
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 5 of 7 areas reviewed

# failure or interrupt there would otherwise leave a refreshed lockfile beside
# restored manifests.
LOCKFILE="$REPO_ROOT/Cargo.lock"
if [[ -f "$LOCKFILE" ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The precise Cargo failure timing is implementation-dependent, but any failure or interrupt after Cargo creates Cargo.lock and before REFRESHED=1 leaves the demonstrated inconsistent state.
🤖 Prompt for AI agents
In scripts/release/set-version.sh, address this finding:
If the repository starts without Cargo.lock, a failed or interrupted migration can leave a newly created lockfile beside restored old manifests, violating the all-old/all-new state invariant.

;;
esac

if [[ -L "$TAP_FORMULA_PATH" ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact exploitability window depends on a concurrent local actor's ability to modify the tap filesystem during the downloads, but the later path-based mkdir and redirection are definitely not atomic with the earlier checks.
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
The symlink and confinement checks are not atomic with the final write, so a local attacker able to modify the tap can replace the validated formula or parent directory before `cat >` and redirect the overwrite.

Comment thread scripts/release/sync-homebrew-tap.sh Outdated
# extraction applies entries in order, so a later symlink or directory with
# the same name is what ends up installed.
if ! awk -v want="./$member" '
$NF == want { seen++; if ($1 !~ /^-/) bad++ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Data Integrity | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The correction should be checked against the exact GNU tar and BSD tar verbose-listing formats used by the release runners.
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
A duplicate required member can end in a symlink and still pass validation if the symlink target is not the required name, because the awk predicate does not identify the symlink's actual name.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
$NF == want { seen++; if ($1 !~ /^-/) bad++ }
($NF == want || ($(NF-1) == "->" && $(NF-2) == want)) { seen++; if ($1 !~ /^-/) bad++ }

Comment thread scripts/release/sync-homebrew-tap.sh Outdated

# An explicit path gets the same confinement as a discovered one when there is a
# tap to confine it to; the file is created and truncated below either way.
if [[ -n "$TAP_REPO_PATH" && -d "$TAP_REPO_PATH" ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ Impact depends on an attacker being able to influence the environment or working directory of a privileged/automation invocation; the repository does not establish whether such callers exist.
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
An explicit formula path is unconstrained when TAP_REPO_PATH is missing (or points at a non-directory), so the script can write outside any tap root. For example, with the default tap absent, `TAP_FORMULA_PATH=/tmp/attacker-owned/out.rb TAP_REPO_PATH=/does-not-exist`, the `-d` guard skips confinement, `mkdir -p` succeeds, and the final redirection overwrites/creates `/tmp/attacker-owned/out.rb`; `/etc/...` or another sensitive writable location is likewise reachable if permissions allow. A relative `TAP_FORMULA_PATH=out.rb` in an attacker-chosen working directory has the same behavior. The later missing-tap check only rejects the non-explicit case (`-z "$EXPLICIT_TAP_FORMULA_PATH"`), leaving this bypass intact. This is exploitable whenever untrusted or attacker-controlled environment/working-directory values reach a privileged or automation invocation; it would be disproven only if callers guarantee both a trusted existing TAP_REPO_PATH and a trusted explicit path.


- name: Install Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Build Deployment | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact Rust and musl-tools versions used on any particular run are not observable from the workflow alone, so the precise binary delta cannot be predicted; however, both inputs are demonstrably resolved from moving sources rather than pinned versions.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
The build is not reproducible despite using `cargo build --locked`: the workflow installs the moving `stable` Rust channel and the unversioned `musl-tools` package from the current Ubuntu repositories. Re-running the same resolved commit after either toolchain or system package updates can produce different binaries (or fail), so `--locked` only fixes Cargo dependency resolution and does not satisfy the stated reproducible locked-build obligation.

Comment thread .github/workflows/release.yml Outdated
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
tag="${INPUT_TAG_NAME}"
sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --jq '.object.sha')"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
Manual resolution is not a single consistent read of the tag ref: it performs separate `gh api .../git/ref/tags/${tag}` requests for `.object.sha` and `.object.type` (lines 39-40). If the tag is moved between those requests, the SHA can come from the old target while the type comes from the new target. In the old-tag/new-commit interleaving, the code skips peeling because `type` is `commit` and exports the old tag-object SHA as `needs.resolve.outputs.sha`; builds then checkout the wrong object (or fail), and the later verification only detects the mismatch after the build. The claim is false only if the tag is guaranteed immutable during this two-request window, which the workflow otherwise explicitly treats as a possible race at lines 156-159.

TARGETS+=("$LOCKFILE")
fi

for target in "${TARGETS[@]}"; do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Resource Lifetime | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/set-version.sh, address this finding:
A pre-existing sibling backup file can be destroyed even when the migration fails before making any changes.


set_package_version() {
local src="$1" dest="$2"
awk -v ver="$VERSION" '

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The practical security impact depends on whether an attacker can control the checked-out commit or adjacent workspace paths used to invoke the script; the filesystem behavior itself is unconditional when a staging or backup path is a symlink.
🤖 Prompt for AI agents
In scripts/release/set-version.sh, address this finding:
set-version.sh is not safe against symlinked staging paths in the checkout: its awk redirection writes to a predictable `Cargo.toml.tmp` path, and the later `mv` can replace the real manifest with that symlink. For example, a checked-out `core/Cargo.toml.tmp -> /tmp/target` causes the awk rewrite to write the generated manifest through `/tmp/target`, after which `mv core/Cargo.toml.tmp core/Cargo.toml` installs the symlink as the manifest. The same issue applies to the root `.tmp` and `.next` paths (and backup paths). This is a concrete arbitrary-file overwrite / workspace-corruption path whenever the repository or surrounding workspace can contain an attacker-controlled symlink; it would be disproven if the invocation environment guarantees all these adjacent paths are trusted non-symlinks and the checkout rejects symlinks.

The tap script truncated the formula in place with `cat >`, so a symlink put
there after the checks was written through. It now writes beside the target and
renames over it, which replaces the entry rather than following it. An explicit
TAP_FORMULA_PATH was only confined when TAP_REPO_PATH happened to be an existing
directory, leaving `TAP_FORMULA_PATH=/tmp/x.rb TAP_REPO_PATH=/does-not-exist`
free to write anywhere; a tap root is now required and the path must resolve
inside it. Tag validation goes through the shared grammar, which pins LC_ALL, and
the formula pins the version instead of letting Homebrew infer it from a
platform-suffixed URL.

Archive validation read the last field of the verbose listing, which for a
symlink is its target rather than its name, so an archive carrying the real file
followed by a symlink of the same name passed while extraction ended on the
symlink. The name is now taken from before the ` -> `.

set-version.sh derives staging and backup names from its targets, so anything
already at one of them was written through — a symlink redirecting the rewrite
out of the workspace — and then deleted by cleanup; those siblings must now be
free before it runs. A workspace that had no Cargo.lock kept the one cargo
writes when a run was interrupted, since there was no backup to restore over it.
The cleanup also tripped `set -u` on an early failure, before any target was
registered.

The workflow read `.object.sha` and `.object.type` in two separate API calls, so
a tag moved in between could pair a SHA from one side with a type from the other
and pin a torn snapshot. Each ref is now read once.

Both listing parsers now read the `output` wrapper on the same terms, so a
response carrying outer records and a serialized listing contributes both rather
than whichever one the parser happened to check first. Bare strings in a JSON
array are filtered by name shape, which keeps prose out of inventory.

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (8)
  • 🟠 High A symlinked workspace-member directory can redirect the rewrite outside REPO_ROOT. The script constructs $REPO_ROOT/$member/Cargo.toml and validates only -f on the resulting path; with core -&gt; /tmp/other-worktree (or another symlinked member directory), awk ... &gt; "$manifest.tmp" and the later mv operate in /tmp/other-worktree, despite the sibling-name checks. This violates confinement even when no target-file symlink exists. (inline)
  • 🟠 High The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After FORMULA_PARENT_REAL is checked, the script calls mktemp and later mv using the original path; if an attacker replaces a checked descendant (for example tap/Formula/e) with a symlink to an outside directory, mktemp creates the staged file outside the tap and mv writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run. (inline)
  • 🟡 Medium Annotated tags are not resolved all the way to a commit: the workflow dereferences at most one tag object and then rejects a valid tag-of-tag (or compares it incorrectly during verification). (inline)
  • 🟡 Medium The script does not preserve or confine symlinked workspace manifests. It checks member/root manifests only with [[ -f ]], which follows a symlink, then stages from the symlink target and finally executes mv "$manifest.tmp" "$manifest"; mv replaces the symlink itself rather than updating its target. Thus a repository such as core/Cargo.toml -&gt; /outside/core.toml loses the symlink and installs a regular file in the checkout, while the external manifest remains unchanged; if a parent member directory is symlinked, staging and backups can instead operate outside the repository. This violates preservation of unrelated workspace structure and safe migration paths; it would be disproven only if the script rejected symlinked target paths and symlinked parent directories before staging. (inline)
  • 🟡 Medium An explicitly supplied empty version is silently replaced by RELEASE_TAG because both scripts use ${1:-${RELEASE_TAG:-}}, where :- treats an empty positional argument as absent. In an environment with RELEASE_TAG=1.2.3, check-version.sh '' succeeds and prints 1.2.3 instead of rejecting the malformed empty argument (and set-version.sh '' performs that same substitution before validation). This makes argument/environment semantics ambiguous and violates rejection of empty versions; it would be disproven only if the intended contract explicitly defines an empty argument as equivalent to omitting the argument, contrary to the stated malformed/empty rejection obligation. (inline)
  • 🟡 Medium An explicit TAP_FORMULA_PATH outside the tap can create or modify directories before confinement is checked. The script runs mkdir -p "$FORMULA_PARENT" and only afterward resolves FORMULA_PARENT_REAL and rejects it if outside TAP_REPO_PATH; for TAP_FORMULA_PATH=/tmp/attacker/new/formula.rb, this creates /tmp/attacker/new despite the eventual error. Under an environment-controlled path this violates the requirement that writes cannot escape the checkout and can alter filesystem state outside it. The claim would be false only if the parent directory were guaranteed to preexist and no creation occurred before the confinement check. (inline)
  • 🟡 Medium The pre-publish verification does not actually prevent publishing a release for a tag that moves after the check. If an authorized actor moves RELEASE_TAG from BUILT_SHA to another commit after verify reads the ref but before softprops/action-gh-release creates/updates the release, the publish step attaches the release to the moved tag; confirm only fails afterward, leaving an already-published mismatched release. This is proven false only if the tag is guaranteed immutable for the entire verify-to-publish interval or publication is otherwise serialized with ref updates. (inline)
  • 🟡 Medium The environment-mutating state test leaks the caller's K3S_TOKEN state into later tests. (inline)
⛔ Unresolved from previous review (5) — not approved until fixed
  • Direct JSON arrays of arbitrary strings are treated as VM names without validation, allowing status/error payloads such as ["error", "quota exceeded"] (or a serialized arbitrary string list) to become inventory entries and suppress VM creation. — The current code rejects the exact "Error:"/"quota exceeded" example by applying is_vm_name, but direct JSON arrays are still treated as inventories: every bare string matching the VM-name shape is inserted. Thus an arbitrary status/error payload such as ["error", "quota"] (or ["foo"]) can still create false VM entries and suppress creation; the tests explicitly acknowledge that a single lowercase prose word such as error remains indistinguishable from a valid VM name.
  • scripts/release/sync-homebrew-tap.sh: The symlink and confinement checks are not atomic with the final write, so a local attacker able to modify the tap can replace the validated formula or parent directory before cat &gt; and redirect the overwrite. — The final target-file symlink race is mitigated by writing a sibling temporary file and using mv, which replaces the target entry rather than following a symlink. However, the confinement check is still separate from the final path operations: after FORMULA_PARENT_REAL is validated, the script runs mktemp and then mv using the original pathname. An attacker can replace a writable parent path component with a symlink after validation, causing the staged file to be created or renamed in an attacker-chosen directory, so the reported redirect-through-parent-directory consequence remains possible.
  • .github/workflows/release.yml: The tag recheck is check-then-use rather than an atomic publish target. A maintainer or attacker who moves the release tag after Verify the tag still points at the built commit completes but before action-gh-release resolves tag_name, can cause the release to be attached to the moved tag while the archives remain from BUILT_SHA; the workflow will not refuse that move. — The defect remains: verify performs a read-and-compare of the tag, but publish later passes the mutable tag name to action-gh-release. A tag can still move after verification and before that action resolves tag_name, so the archives can be attached to the moved tag. The new confirm job only detects the mismatch after publication and does not prevent it.
  • The publish job grants contents: write to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable github.token; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures. — The tag-verification shell step was moved to the separate read-only verify job, but Download release archives remains in publish, whose job-level permissions: contents: write still grants the download action the write-capable github.token. A compromised download action can therefore still mutate repository contents or releases before publication.
  • k8s_cli/src/manager/process.rs: A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. capture_remote_ssh_output retries solely from output.status.code() == Some(255) and ignores whether stdout already contains the wrapper's __EXEDEV_K8S_EXIT__: marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent. — The retry is now suppressed when the client received REMOTE_EXIT_PREFIX, but status 255 without that marker is still retried. The wrapper emits the marker only after the remote script finishes, so a connection can be lost after the non-idempotent script has executed but before the marker reaches the client; in that case remote_ran is false and the full script is sent again. Thus the described duplicate-execution consequence remains possible.
⚠️ Unverified risks (1)
  • The no-follow protection is racy for state-directory components: a parent can be replaced after inspection and before staged creation. (k8s_cli/src/manager/state.rs)
📋 Additional findings from this change (not shown inline) (9)
  • 🟠 High New-cluster bootstrap can reuse an already-installed k3s server without verifying that it belongs to the requested cluster. (k8s_cli/src/manager/scripts.rs) — anchor-outside-diff
  • 🟠 High Existing generated state can be read through a symlinked state directory, violating the no-follow boundary for persistent secrets. (k8s_cli/src/manager/state.rs) — anchor-unreliable
  • 🟠 High Concurrent first-time token creation can return different credentials to concurrent bootstrap processes. (k8s_cli/src/manager/state.rs) — anchor-unreliable
  • 🟡 Medium The generalized formula-name validation rejects valid Homebrew versioned formula names containing @, such as foo@1, and therefore cannot satisfy compatibility for Homebrew naming conventions. The gate permits only [0-9A-Za-z._-], so the helper exits before selecting a path or generating a formula. This would be false only if the helper is intentionally limited to unversioned names and that limitation is an explicit scope constraint. (scripts/release/sync-homebrew-tap.sh) — per-file-budget
  • 🟡 Medium The expected class-name derivation mishandles valid dotted Homebrew names. For FORMULA_NAME=foo.bar, the script computes EXPECTED_CLASS=Foo.bar because it splits only on - and _, then rejects any supplied Ruby constant (a Ruby class cannot use a lowercase dotted component as the generated formula class). Thus valid formula names containing dots cannot be generated, and the naming compatibility obligation is not met. This would be false only if dotted formula names were deliberately unsupported despite the name validator allowing them. (scripts/release/sync-homebrew-tap.sh) — per-file-budget
  • 🟡 Medium A valid dotted Homebrew formula name cannot pass the class-name check: for FORMULA_NAME=foo.bar, the script computes EXPECTED_CLASS=Foo.bar, which is not a Ruby constant and cannot match the required FORMULA_CLASS pattern (Homebrew's class conversion removes the dot, e.g. FooBar). Thus generation aborts for valid names containing .. (scripts/release/sync-homebrew-tap.sh) — per-file-budget
  • 🟡 Medium An interrupted or failed rewrite of the root workspace manifest can leave Cargo.toml.next behind, preventing a subsequent migration. set_path_dep_version writes the root staged output to $ROOT_MANIFEST.next; cleanup_staged only removes each target's .tmp and .bak, never .next. If the process is terminated while awk is producing that file (or exits due to an awk/write error before the explicit rm), the next invocation's require_free_sibling "$ROOT_MANIFEST" sees the leftover .next and exits with 'already exists'. This violates failure/interruption restoration and safe cleanup; it would be disproven only if no termination/error can occur during that output operation, which is not guaranteed for CI/filesystem failures. (scripts/release/set-version.sh) — anchor-unreliable
  • 🟡 Medium Node labels do not converge to the fleet plan because stale exedev-owned labels are never removed. (k8s_cli/src/manager/mod.rs) — anchor-outside-diff
  • 🟡 Medium The API reference tells users to pipe the two secret-bearing commands through ssh exe.dev, but that does not avoid exposing the token in process arguments and contradicts the safer warning in the CLI/skill docs. (docs/exe-dev-api-reference.md) — anchor-unreliable
♻️ Previously reported (still present) (5)
  • 🟠 High The pre-publication verification does not make publication atomic with the tag check: after verify succeeds, the tag can move before or during the separate publish job, so the release can be attached to a different revision and only be reported failed later by confirm. (.github/workflows/release.yml) — previously-reported
  • 🟡 Medium A failed or interrupted formula generation leaves the staged hidden file behind. FORMULA_STAGED is created in the tap directory, but the only EXIT trap removes WORK_DIR; if cat, chmod, or mv fails, or the process is interrupted after mktemp, .${FORMULA_NAME}.XXXXXX remains in the formula directory. Repeated failures accumulate untracked temporary formula files and violate clean temporary state. This would be false only if an external cleanup mechanism reliably removes those files, which this script does not install. (scripts/release/sync-homebrew-tap.sh) — previously-reported
  • 🟡 Medium A wrapped response with both outer VM objects and a rendered output table loses VMs from the table. (k8s_cli/src/manager/parsing.rs) — previously-reported
  • 🟡 Medium The write is vulnerable to a symlink/rename race on the formula parent after confinement validation. An attacker able to modify the tap checkout can replace the checked parent directory (or one of its components) with a symlink to another directory between FORMULA_PARENT_REAL validation and mktemp/mv; both operations then resolve the changed path and can create or atomically replace FORMULA_STAGED/the formula outside TAP_REPO_PATH. The claim would be false only if the tap directory hierarchy is guaranteed immutable and inaccessible to concurrent actors for the entire download-and-write interval. (scripts/release/sync-homebrew-tap.sh) — previously-reported
  • 🟡 Medium The JSON parser still treats arbitrary bare strings in arrays as VM inventory whenever they happen to match the DNS-like heuristic. (k8s_cli/src/manager/parsing.rs) — anchor-unreliable
🤖 Prompt for AI agents — all findings (27)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Unresolved from the previous review — these block approval, fix them first (5)

Somewhere in the code under review, address this finding:
Direct JSON arrays of arbitrary strings are treated as VM names without validation, allowing status/error payloads such as `["error", "quota exceeded"]` (or a serialized arbitrary string list) to become inventory entries and suppress VM creation.

In scripts/release/sync-homebrew-tap.sh, address this finding:
The symlink and confinement checks are not atomic with the final write, so a local attacker able to modify the tap can replace the validated formula or parent directory before `cat >` and redirect the overwrite.

In .github/workflows/release.yml, address this finding:
The tag recheck is check-then-use rather than an atomic publish target. A maintainer or attacker who moves the release tag after `Verify the tag still points at the built commit` completes but before `action-gh-release` resolves `tag_name`, can cause the release to be attached to the moved tag while the archives remain from BUILT_SHA; the workflow will not refuse that move.

Somewhere in the code under review, address this finding:
The publish job grants `contents: write` to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable `github.token`; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures.

In k8s_cli/src/manager/process.rs, address this finding:
A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. `capture_remote_ssh_output` retries solely from `output.status.code() == Some(255)` and ignores whether stdout already contains the wrapper's `__EXEDEV_K8S_EXIT__:` marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent.

## Findings on this change (also posted as inline comments) (8)

In scripts/release/set-version.sh around line 107, address this finding:
A symlinked workspace-member directory can redirect the rewrite outside `REPO_ROOT`. The script constructs `$REPO_ROOT/$member/Cargo.toml` and validates only `-f` on the resulting path; with `core -> /tmp/other-worktree` (or another symlinked member directory), `awk ... > "$manifest.tmp"` and the later `mv` operate in `/tmp/other-worktree`, despite the sibling-name checks. This violates confinement even when no target-file symlink exists.

In scripts/release/sync-homebrew-tap.sh around line 157, address this finding:
The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After `FORMULA_PARENT_REAL` is checked, the script calls `mktemp` and later `mv` using the original path; if an attacker replaces a checked descendant (for example `tap/Formula/e`) with a symlink to an outside directory, `mktemp` creates the staged file outside the tap and `mv` writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.

In .github/workflows/release.yml around line 53, address this finding:
Annotated tags are not resolved all the way to a commit: the workflow dereferences at most one tag object and then rejects a valid tag-of-tag (or compares it incorrectly during verification).

In scripts/release/set-version.sh around line 108, address this finding:
The script does not preserve or confine symlinked workspace manifests. It checks member/root manifests only with `[[ -f ]]`, which follows a symlink, then stages from the symlink target and finally executes `mv "$manifest.tmp" "$manifest"`; `mv` replaces the symlink itself rather than updating its target. Thus a repository such as `core/Cargo.toml -> /outside/core.toml` loses the symlink and installs a regular file in the checkout, while the external manifest remains unchanged; if a parent member directory is symlinked, staging and backups can instead operate outside the repository. This violates preservation of unrelated workspace structure and safe migration paths; it would be disproven only if the script rejected symlinked target paths and symlinked parent directories before staging.

In scripts/release/check-version.sh around line 11, address this finding:
An explicitly supplied empty version is silently replaced by `RELEASE_TAG` because both scripts use `${1:-${RELEASE_TAG:-}}`, where `:-` treats an empty positional argument as absent. In an environment with `RELEASE_TAG=1.2.3`, `check-version.sh ''` succeeds and prints `1.2.3` instead of rejecting the malformed empty argument (and `set-version.sh ''` performs that same substitution before validation). This makes argument/environment semantics ambiguous and violates rejection of empty versions; it would be disproven only if the intended contract explicitly defines an empty argument as equivalent to omitting the argument, contrary to the stated malformed/empty rejection obligation.

In scripts/release/sync-homebrew-tap.sh around line 156, address this finding:
An explicit `TAP_FORMULA_PATH` outside the tap can create or modify directories before confinement is checked. The script runs `mkdir -p "$FORMULA_PARENT"` and only afterward resolves `FORMULA_PARENT_REAL` and rejects it if outside `TAP_REPO_PATH`; for `TAP_FORMULA_PATH=/tmp/attacker/new/formula.rb`, this creates `/tmp/attacker/new` despite the eventual error. Under an environment-controlled path this violates the requirement that writes cannot escape the checkout and can alter filesystem state outside it. The claim would be false only if the parent directory were guaranteed to preexist and no creation occurred before the confinement check.

In .github/workflows/release.yml around line 184, address this finding:
The pre-publish verification does not actually prevent publishing a release for a tag that moves after the check. If an authorized actor moves `RELEASE_TAG` from `BUILT_SHA` to another commit after `verify` reads the ref but before `softprops/action-gh-release` creates/updates the release, the publish step attaches the release to the moved tag; `confirm` only fails afterward, leaving an already-published mismatched release. This is proven false only if the tag is guaranteed immutable for the entire verify-to-publish interval or publication is otherwise serialized with ref updates.

In k8s_cli/src/manager/tests.rs around line 548, address this finding:
The environment-mutating state test leaks the caller's K3S_TOKEN state into later tests.

## Additional findings on this change (not posted inline) (9)

In k8s_cli/src/manager/scripts.rs around line 237, address this finding:
New-cluster bootstrap can reuse an already-installed k3s server without verifying that it belongs to the requested cluster.

In k8s_cli/src/manager/state.rs, address this finding:
Existing generated state can be read through a symlinked state directory, violating the no-follow boundary for persistent secrets.

In k8s_cli/src/manager/state.rs, address this finding:
Concurrent first-time token creation can return different credentials to concurrent bootstrap processes.

In scripts/release/sync-homebrew-tap.sh around line 59, address this finding:
The generalized formula-name validation rejects valid Homebrew versioned formula names containing `@`, such as `foo@1`, and therefore cannot satisfy compatibility for Homebrew naming conventions. The gate permits only `[0-9A-Za-z._-]`, so the helper exits before selecting a path or generating a formula. This would be false only if the helper is intentionally limited to unversioned names and that limitation is an explicit scope constraint.

In scripts/release/sync-homebrew-tap.sh around line 76, address this finding:
The expected class-name derivation mishandles valid dotted Homebrew names. For `FORMULA_NAME=foo.bar`, the script computes `EXPECTED_CLASS=Foo.bar` because it splits only on `-` and `_`, then rejects any supplied Ruby constant (a Ruby class cannot use a lowercase dotted component as the generated formula class). Thus valid formula names containing dots cannot be generated, and the naming compatibility obligation is not met. This would be false only if dotted formula names were deliberately unsupported despite the name validator allowing them.

In scripts/release/sync-homebrew-tap.sh around line 78, address this finding:
A valid dotted Homebrew formula name cannot pass the class-name check: for `FORMULA_NAME=foo.bar`, the script computes `EXPECTED_CLASS=Foo.bar`, which is not a Ruby constant and cannot match the required `FORMULA_CLASS` pattern (Homebrew's class conversion removes the dot, e.g. `FooBar`). Thus generation aborts for valid names containing `.`.

In scripts/release/set-version.sh, address this finding:
An interrupted or failed rewrite of the root workspace manifest can leave `Cargo.toml.next` behind, preventing a subsequent migration. `set_path_dep_version` writes the root staged output to `$ROOT_MANIFEST.next`; `cleanup_staged` only removes each target's `.tmp` and `.bak`, never `.next`. If the process is terminated while awk is producing that file (or exits due to an awk/write error before the explicit `rm`), the next invocation's `require_free_sibling "$ROOT_MANIFEST"` sees the leftover `.next` and exits with 'already exists'. This violates failure/interruption restoration and safe cleanup; it would be disproven only if no termination/error can occur during that output operation, which is not guaranteed for CI/filesystem failures.

In k8s_cli/src/manager/mod.rs around line 724, address this finding:
Node labels do not converge to the fleet plan because stale exedev-owned labels are never removed.

In docs/exe-dev-api-reference.md, address this finding:
The API reference tells users to pipe the two secret-bearing commands through `ssh exe.dev`, but that does not avoid exposing the token in process arguments and contradicts the safer warning in the CLI/skill docs.

## Previously reported and still present (5)

In .github/workflows/release.yml around line 214, address this finding:
The pre-publication verification does not make publication atomic with the tag check: after `verify` succeeds, the tag can move before or during the separate `publish` job, so the release can be attached to a different revision and only be reported failed later by `confirm`.

In scripts/release/sync-homebrew-tap.sh around line 169, address this finding:
A failed or interrupted formula generation leaves the staged hidden file behind. `FORMULA_STAGED` is created in the tap directory, but the only EXIT trap removes `WORK_DIR`; if `cat`, `chmod`, or `mv` fails, or the process is interrupted after `mktemp`, `.${FORMULA_NAME}.XXXXXX` remains in the formula directory. Repeated failures accumulate untracked temporary formula files and violate clean temporary state. This would be false only if an external cleanup mechanism reliably removes those files, which this script does not install.

In k8s_cli/src/manager/parsing.rs around line 30, address this finding:
A wrapped response with both outer VM objects and a rendered `output` table loses VMs from the table.

In scripts/release/sync-homebrew-tap.sh around line 158, address this finding:
The write is vulnerable to a symlink/rename race on the formula parent after confinement validation. An attacker able to modify the tap checkout can replace the checked parent directory (or one of its components) with a symlink to another directory between `FORMULA_PARENT_REAL` validation and `mktemp`/`mv`; both operations then resolve the changed path and can create or atomically replace `FORMULA_STAGED`/the formula outside `TAP_REPO_PATH`. The claim would be false only if the tap directory hierarchy is guaranteed immutable and inaccessible to concurrent actors for the entire download-and-write interval.

In k8s_cli/src/manager/parsing.rs, address this finding:
The JSON parser still treats arbitrary bare strings in arrays as VM inventory whenever they happen to match the DNS-like heuristic.
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 7 of 7 areas reviewed

Comment thread scripts/release/set-version.sh Outdated
}

for member in "${MEMBERS[@]}"; do
manifest="$REPO_ROOT/$member/Cargo.toml"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The eventual behavior of cargo update with a symlinked workspace member may vary, but the out-of-root writes occur before cargo update is invoked and therefore do not depend on cargo accepting the workspace.
🤖 Prompt for AI agents
In scripts/release/set-version.sh, address this finding:
A symlinked workspace-member directory can redirect the rewrite outside `REPO_ROOT`. The script constructs `$REPO_ROOT/$member/Cargo.toml` and validates only `-f` on the resulting path; with `core -> /tmp/other-worktree` (or another symlinked member directory), `awk ... > "$manifest.tmp"` and the later `mv` operate in `/tmp/other-worktree`, despite the sibling-name checks. This violates confinement even when no target-file symlink exists.

TAP_REPO_REAL="$(cd "$TAP_REPO_PATH" && pwd -P)"
FORMULA_PARENT="$(dirname "$TAP_FORMULA_PATH")"
mkdir -p "$FORMULA_PARENT"
FORMULA_PARENT_REAL="$(cd "$FORMULA_PARENT" && pwd -P)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ Exploitation requires an attacker to have filesystem write/rename access to the tap checkout while the script is running; under that threat model, the race is concrete. The exact outside destination is controlled by the swapped parent symlink.
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After `FORMULA_PARENT_REAL` is checked, the script calls `mktemp` and later `mv` using the original path; if an attacker replaces a checked descendant (for example `tap/Formula/e`) with a symlink to an outside directory, `mktemp` creates the staged file outside the tap and `mv` writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.

Comment thread .github/workflows/release.yml Outdated
sha="$(printf '%s' "${ref_json}" | jq -r '.object.sha')"
type="$(printf '%s' "${ref_json}" | jq -r '.object.type')"
# An annotated tag points at a tag object, not the commit it names.
if [[ "${type}" == "tag" ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
Annotated tags are not resolved all the way to a commit: the workflow dereferences at most one tag object and then rejects a valid tag-of-tag (or compares it incorrectly during verification).

Comment thread scripts/release/set-version.sh Outdated

for member in "${MEMBERS[@]}"; do
manifest="$REPO_ROOT/$member/Cargo.toml"
if [[ ! -f "$manifest" ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Data Integrity | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/set-version.sh, address this finding:
The script does not preserve or confine symlinked workspace manifests. It checks member/root manifests only with `[[ -f ]]`, which follows a symlink, then stages from the symlink target and finally executes `mv "$manifest.tmp" "$manifest"`; `mv` replaces the symlink itself rather than updating its target. Thus a repository such as `core/Cargo.toml -> /outside/core.toml` loses the symlink and installs a regular file in the checkout, while the external manifest remains unchanged; if a parent member directory is symlinked, staging and backups can instead operate outside the repository. This violates preservation of unrelated workspace structure and safe migration paths; it would be disproven only if the script rejected symlinked target paths and symlinked parent directories before staging.

Comment thread scripts/release/check-version.sh Outdated
# the point it is resolved, before four matrix builds check out and install a
# toolchain only to fail on the same string.

VERSION="${1:-${RELEASE_TAG:-}}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/check-version.sh, address this finding:
An explicitly supplied empty version is silently replaced by `RELEASE_TAG` because both scripts use `${1:-${RELEASE_TAG:-}}`, where `:-` treats an empty positional argument as absent. In an environment with `RELEASE_TAG=1.2.3`, `check-version.sh ''` succeeds and prints `1.2.3` instead of rejecting the malformed empty argument (and `set-version.sh ''` performs that same substitution before validation). This makes argument/environment semantics ambiguous and violates rejection of empty versions; it would be disproven only if the intended contract explicitly defines an empty argument as equivalent to omitting the argument, contrary to the stated malformed/empty rejection obligation.

fi
TAP_REPO_REAL="$(cd "$TAP_REPO_PATH" && pwd -P)"
FORMULA_PARENT="$(dirname "$TAP_FORMULA_PATH")"
mkdir -p "$FORMULA_PARENT"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
An explicit `TAP_FORMULA_PATH` outside the tap can create or modify directories before confinement is checked. The script runs `mkdir -p "$FORMULA_PARENT"` and only afterward resolves `FORMULA_PARENT_REAL` and rejects it if outside `TAP_REPO_PATH`; for `TAP_FORMULA_PATH=/tmp/attacker/new/formula.rb`, this creates `/tmp/attacker/new` despite the eventual error. Under an environment-controlled path this violates the requirement that writes cannot escape the checkout and can alter filesystem state outside it. The claim would be false only if the parent directory were guaranteed to preexist and no creation occurred before the confinement check.


# Separate from publish so the write-capable token is not in scope while this
# runs: the job that holds contents: write grants it to every one of its steps.
verify:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: unknown
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ Repository-level tag protection or an external serialization mechanism is not visible in this workflow; either could prevent an authorized tag move during the interval.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
The pre-publish verification does not actually prevent publishing a release for a tag that moves after the check. If an authorized actor moves `RELEASE_TAG` from `BUILT_SHA` to another commit after `verify` reads the ref but before `softprops/action-gh-release` creates/updates the release, the publish step attaches the release to the moved tag; `confirm` only fails afterward, leaving an already-published mismatched release. This is proven false only if the tag is guaranteed immutable for the entire verify-to-publish interval or publication is otherwise serialized with ref updates.

Comment thread k8s_cli/src/manager/tests.rs Outdated
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::env::set_current_dir(&dir).unwrap();
unsafe { std::env::remove_var(K3S_TOKEN_ENV) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: unknown
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact downstream test affected depends on test ordering and whether it reads K3S_TOKEN, but the process-global mutation is observable by any concurrent or later test that does so.
🤖 Prompt for AI agents
In k8s_cli/src/manager/tests.rs, address this finding:
The environment-mutating state test leaks the caller's K3S_TOKEN state into later tests.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
unsafe { std::env::remove_var(K3S_TOKEN_ENV) };
let previous_token = std::env::var_os(K3S_TOKEN_ENV);

The guard grew from a handful of commands to 37 over this branch, but the skill
still named three of them, and its own examples ran commands that now prompt —
including the token-generation example it tells an agent to use. An agent has no
terminal, so those fail with `failed to read confirmation: IO error: not a
terminal` before anything reaches exe.dev, with nothing in the skill explaining
why or what to do.

The reference now lists every guarded command by category, notes the two alias
spellings and the read-only `integrations setup` forms that are exempt, and
points at `is_dangerous` in core/src/shell.rs as the list to re-derive from.
Triage covers the non-terminal failure, and the token examples pass `--yes` on
that one command rather than leaving an agent to discover the prompt.

SKILL.md states the rule instead of enumerating: confirm the action with the
user, then rerun that single command with `--yes`, rather than adding it
pre-emptively.

Also corrects the archive contents, which omitted README.zh-CN.md; the doc, the
release workflow, and the tap formula's install list now agree.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@skills/exedev-ctl/references/exedev-ctl.md`:
- Around line 337-343: Update the direct SSH troubleshooting guidance in the
numbered list to use the authoritative JSON ssh_dest value, including any
username it contains, instead of constructing <vm>.exe.xyz. Preserve the
instruction to use direct SSH checks for separating VM reachability from exe.dev
API permissions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b3816e09-bf77-439a-8eee-c1b27c25ac2e

📥 Commits

Reviewing files that changed from the base of the PR and between 19e9d71 and ce5aaac.

📒 Files selected for processing (2)
  • skills/exedev-ctl/SKILL.md
  • skills/exedev-ctl/references/exedev-ctl.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • skills/exedev-ctl/SKILL.md
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: winnowl/review
🧰 Additional context used
🪛 LanguageTool
skills/exedev-ctl/references/exedev-ctl.md

[style] ~342-~342: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...ad the exe.dev command failure body. 6. If interactive SSH or stdin is involved, u...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)


[style] ~343-~343: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...stdin is involved, use the SSH path. 7. If SSH or script transport fails, prefer d...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

Comment thread skills/exedev-ctl/references/exedev-ctl.md Outdated
Triage step 7 told the reader to check a VM at `<vm>.exe.xyz`, which the same
document already warns against three sections earlier: exe.dev reports the
destination as `ssh_dest`, and it carries a `vm+<name>@exe.dev` username on VMs
whose hostname cannot route SSH. Following the step on such a VM fails, and the
step exists precisely to tell reachability apart from API permissions, so the
failure reads as the VM being down. SKILL.md gave the same advice for streamed
and interactive work.

Both now take the destination from `ssh_dest`, username included, and say what
assembling the hostname costs. The purpose of the step is unchanged.

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (4)
  • 🟠 High SSH transport retrying is based only on the absence of the wrapper exit marker; a connection can drop after the remote shell has executed side effects but before the marker is delivered, causing the same bootstrap script to be resent and potentially repeating installs or service starts. (inline)
  • 🟠 High Existing-cluster validation ignores the URL scheme, so a malformed or different-scheme K3S_URL is accepted as targeting the same cluster. For example, K3S_URL=http://cluster.example:6443 compares equal to a kubeconfig server of https://cluster.example:6443 (and ssh://... or any other scheme://... does too). The subsequent worker install and kubectl labeling/deployment are then allowed despite the two endpoints not being the same HTTPS API endpoint; validation should parse and require the expected scheme rather than discard it. This is false acceptance, not merely a formatting issue, because the check is the guard immediately before mutating workers and the selected Kubernetes cluster. (inline)
  • 🟡 Medium apply_node_metadata silently treats failure to fetch or parse the current node list as an empty map, then proceeds to label/taint nodes successfully; stale owned taints are therefore not removed and the command reports success despite an incomplete reconciliation. (inline)
  • 🟡 Medium Workflow-dispatched and later verification/confirmation of annotated tags dereference only one tag object, so a recursively annotated tag is rejected or misclassified instead of resolving to its commit. (inline)
⛔ Unresolved from previous review (7) — not approved until fixed
  • Concurrent first-time token creation can return different credentials to concurrent bootstrap processes. — The race remains in read_or_create_k3s_token: concurrent callers can both observe !path.exists(), generate different random tokens, and each call write_secret_file. That helper stages independently and then unconditionally renames over the destination; on the usual Unix semantics, both renames can succeed, while each caller returns its own locally generated token. The final file contains only the token from the last rename, so one bootstrap process can use credentials different from the persisted credential.
  • scripts/release/sync-homebrew-tap.sh: The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After FORMULA_PARENT_REAL is checked, the script calls mktemp and later mv using the original path; if an attacker replaces a checked descendant (for example tap/Formula/e) with a symlink to an outside directory, mktemp creates the staged file outside the tap and mv writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.
  • scripts/release/set-version.sh: A symlinked workspace-member directory can redirect the rewrite outside REPO_ROOT. The script constructs $REPO_ROOT/$member/Cargo.toml and validates only -f on the resulting path; with core -&gt; /tmp/other-worktree (or another symlinked member directory), awk ... &gt; "$manifest.tmp" and the later mv operate in /tmp/other-worktree, despite the sibling-name checks. This violates confinement even when no target-file symlink exists.
  • Direct JSON arrays of arbitrary strings are treated as VM names without validation, allowing status/error payloads such as ["error", "quota exceeded"] (or a serialized arbitrary string list) to become inventory entries and suppress VM creation. — The array branch now applies is_vm_name, but that only checks DNS-label syntax. A direct status payload such as ["error", "quota exceeded"] still inserts error because it is a valid lowercase label, so arbitrary string-array content can still become inventory and suppress creation of a VM named error. The original defect therefore remains for arbitrary strings that happen to match the accepted name pattern.
  • .github/workflows/release.yml: The tag recheck is check-then-use rather than an atomic publish target. A maintainer or attacker who moves the release tag after Verify the tag still points at the built commit completes but before action-gh-release resolves tag_name, can cause the release to be attached to the moved tag while the archives remain from BUILT_SHA; the workflow will not refuse that move. — The defect remains: verify performs a separate ref read and then completes, while publish still passes the mutable name ${{ needs.resolve.outputs.tag }} to softprops/action-gh-release. A tag can be moved after verification and before that action resolves tag_name, so the release can still be attached to the moved tag with archives built from BUILT_SHA. The later confirm job only detects the mismatch after publication; it does not prevent it.
  • The publish job grants contents: write to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable github.token; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures. — The publish job still declares permissions: contents: write, and Download release archives remains a preceding step in that same job. GitHub Actions applies the job token permissions to every step, so the download action still runs with a write-capable github.token; moving tag verification to the separate read-only verify job fixes only that portion of the original issue, not the artifact-download consequence.
  • k8s_cli/src/manager/process.rs: A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. capture_remote_ssh_output retries solely from output.status.code() == Some(255) and ignores whether stdout already contains the wrapper's __EXEDEV_K8S_EXIT__: marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent. — The retry is now suppressed when stdout contains __EXEDEV_K8S_EXIT__:, but a transport failure after the wrapped script has executed and before that marker reaches the client still produces status 255 with remote_ran == false. In that case the current condition if output.status.code() == Some(255) &amp;&amp; !remote_ran &amp;&amp; attempt &lt; REMOTE_SSH_ATTEMPTS retries the full script, so the reported duplicate-state-changing execution remains possible.
📋 Additional findings from this change (not shown inline) (4)
  • 🟠 High Nested listings are skipped whenever a JSON object also has a VM-name field, so supported mixed payloads can lose inventory entries and their SSH targets. (k8s_cli/src/manager/parsing.rs) — anchor-outside-diff
  • 🟡 Medium share receive-email accepts and forwards arbitrary state values instead of enforcing the documented on/off contract locally. For example, exedev-ctl share receive-email vm maybe parses successfully, builds share receive-email vm maybe, prompts as dangerous, and then sends the invalid command over SSH/HTTP rather than failing before transport. This violates the command-model argument-validation obligation and the documented state contract; it would be disproven if the exe.dev command contract explicitly accepted values beyond on and off. (cli/src/cli.rs) — anchor-outside-diff
  • 🟡 Medium The HTTP transport accepts an arbitrary --endpoint string and passes it directly to reqwest while attaching the exe.dev bearer token. A caller can run exedev-ctl --transport http --endpoint http://attacker.example/collect whoami; ExeDevClient::exec posts the command with .bearer_auth(&amp;self.token) to that cleartext/caller-controlled URL, potentially exposing the API key and command. This violates the documented HTTPS endpoint scoping/security expectation; it would be disproven if an upstream invariant or reqwest configuration validates the endpoint as HTTPS before the request (none is present in the reviewed client). (core/src/client.rs) — anchor-outside-diff
  • 🔵 Low Malformed hostnames with multiple trailing dots are accepted as the same cluster. same_cluster_endpoint("https://k3s.example...:6443", "https://k3s.example:6443") returns true because trim_end_matches('.') removes every trailing dot, although only a single final dot is the DNS root notation and the multi-dot authority is malformed. In Existing mode this lets a malformed K3S_URL pass the only pre-mutation target check and be used for worker joins while kubectl targets the canonical endpoint. (k8s_cli/src/manager/mod.rs) — per-file-budget
♻️ Previously reported (still present) (6)
  • 🟠 High New-mode bootstrap reuses any pre-existing VM whose name matches the control-plane plan and immediately installs/uses its existing k3s state without verifying that it belongs to the intended cluster. A stale or unrelated VM with the planned name can therefore be adopted as the control plane, and its kubeconfig/token can lead subsequent workers and manifests to that unrelated cluster. (k8s_cli/src/manager/mod.rs) — previously-reported
  • 🟠 High The verify/confirm sequence does not prevent a tag move during publication, so it can publish a release whose assets do not match the tag. For example, verify reads v1.2.3 as BUILT_SHA A and succeeds; before softprops/action-gh-release creates/updates the release, someone moves the tag to B; the action attaches the already-built archives under that tag name, and only the later confirm job notices the mismatch after the release is live. A failed run does not undo the release or assets. This would be false only if tag mutation is independently prevented for the entire workflow or publication is guaranteed atomic with the verification. (.github/workflows/release.yml) — previously-reported
  • 🟡 Medium Metadata reconciliation never removes stale tool-owned labels. When a fleet label is removed or its key is no longer desired, apply_node_metadata only sends the current key=value labels and leaves old exedev.dev/* labels on the node, while status reports only whether desired labels match and therefore can report labels=ok despite owned label drift. (k8s_cli/src/manager/mod.rs) — previously-reported
  • 🟡 Medium An interrupt or failure while generating the root manifest's second-stage file can leave Cargo.toml.next behind, and that stale file blocks future release/version-sync runs. (scripts/release/set-version.sh) — previously-reported
  • 🟡 Medium The push-trigger path does not resolve the tag ref or annotated tag object itself; it takes GITHUB_SHA directly, while only workflow-dispatch performs API dereferencing. For an annotated tag push (or an event SHA representing the tag object), checkout can receive a non-commit/object SHA and the build path is not guaranteed to compile the commit named by the tag. (.github/workflows/release.yml) — previously-reported
  • 🟡 Medium Existing-mode target validation happens after VM creation, so a mismatched or invalid existing-cluster target can still cause fleet VMs to be created before bootstrap aborts. (k8s_cli/src/manager/mod.rs) — previously-reported
🤖 Prompt for AI agents — all findings (21)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Unresolved from the previous review — these block approval, fix them first (7)

Somewhere in the code under review, address this finding:
Concurrent first-time token creation can return different credentials to concurrent bootstrap processes.

In scripts/release/sync-homebrew-tap.sh, address this finding:
The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After `FORMULA_PARENT_REAL` is checked, the script calls `mktemp` and later `mv` using the original path; if an attacker replaces a checked descendant (for example `tap/Formula/e`) with a symlink to an outside directory, `mktemp` creates the staged file outside the tap and `mv` writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.

In scripts/release/set-version.sh, address this finding:
A symlinked workspace-member directory can redirect the rewrite outside `REPO_ROOT`. The script constructs `$REPO_ROOT/$member/Cargo.toml` and validates only `-f` on the resulting path; with `core -> /tmp/other-worktree` (or another symlinked member directory), `awk ... > "$manifest.tmp"` and the later `mv` operate in `/tmp/other-worktree`, despite the sibling-name checks. This violates confinement even when no target-file symlink exists.

Somewhere in the code under review, address this finding:
Direct JSON arrays of arbitrary strings are treated as VM names without validation, allowing status/error payloads such as `["error", "quota exceeded"]` (or a serialized arbitrary string list) to become inventory entries and suppress VM creation.

In .github/workflows/release.yml, address this finding:
The tag recheck is check-then-use rather than an atomic publish target. A maintainer or attacker who moves the release tag after `Verify the tag still points at the built commit` completes but before `action-gh-release` resolves `tag_name`, can cause the release to be attached to the moved tag while the archives remain from BUILT_SHA; the workflow will not refuse that move.

Somewhere in the code under review, address this finding:
The publish job grants `contents: write` to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable `github.token`; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures.

In k8s_cli/src/manager/process.rs, address this finding:
A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. `capture_remote_ssh_output` retries solely from `output.status.code() == Some(255)` and ignores whether stdout already contains the wrapper's `__EXEDEV_K8S_EXIT__:` marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent.

## Findings on this change (also posted as inline comments) (4)

In k8s_cli/src/manager/process.rs around line 307, address this finding:
SSH transport retrying is based only on the absence of the wrapper exit marker; a connection can drop after the remote shell has executed side effects but before the marker is delivered, causing the same bootstrap script to be resent and potentially repeating installs or service starts.

In k8s_cli/src/manager/mod.rs around line 464, address this finding:
Existing-cluster validation ignores the URL scheme, so a malformed or different-scheme K3S_URL is accepted as targeting the same cluster. For example, `K3S_URL=http://cluster.example:6443` compares equal to a kubeconfig server of `https://cluster.example:6443` (and `ssh://...` or any other `scheme://...` does too). The subsequent worker install and kubectl labeling/deployment are then allowed despite the two endpoints not being the same HTTPS API endpoint; validation should parse and require the expected scheme rather than discard it. This is false acceptance, not merely a formatting issue, because the check is the guard immediately before mutating workers and the selected Kubernetes cluster.

In k8s_cli/src/manager/mod.rs around line 719, address this finding:
apply_node_metadata silently treats failure to fetch or parse the current node list as an empty map, then proceeds to label/taint nodes successfully; stale owned taints are therefore not removed and the command reports success despite an incomplete reconciliation.

In .github/workflows/release.yml around line 53, address this finding:
Workflow-dispatched and later verification/confirmation of annotated tags dereference only one tag object, so a recursively annotated tag is rejected or misclassified instead of resolving to its commit.

## Additional findings on this change (not posted inline) (4)

In k8s_cli/src/manager/parsing.rs around line 60, address this finding:
Nested listings are skipped whenever a JSON object also has a VM-name field, so supported mixed payloads can lose inventory entries and their SSH targets.

In cli/src/cli.rs around line 293, address this finding:
`share receive-email` accepts and forwards arbitrary state values instead of enforcing the documented `on`/`off` contract locally. For example, `exedev-ctl share receive-email vm maybe` parses successfully, builds `share receive-email vm maybe`, prompts as dangerous, and then sends the invalid command over SSH/HTTP rather than failing before transport. This violates the command-model argument-validation obligation and the documented state contract; it would be disproven if the exe.dev command contract explicitly accepted values beyond `on` and `off`.

In core/src/client.rs around line 43, address this finding:
The HTTP transport accepts an arbitrary `--endpoint` string and passes it directly to reqwest while attaching the exe.dev bearer token. A caller can run `exedev-ctl --transport http --endpoint http://attacker.example/collect whoami`; `ExeDevClient::exec` posts the command with `.bearer_auth(&self.token)` to that cleartext/caller-controlled URL, potentially exposing the API key and command. This violates the documented HTTPS endpoint scoping/security expectation; it would be disproven if an upstream invariant or reqwest configuration validates the endpoint as HTTPS before the request (none is present in the reviewed client).

In k8s_cli/src/manager/mod.rs around line 477, address this finding:
Malformed hostnames with multiple trailing dots are accepted as the same cluster. `same_cluster_endpoint("https://k3s.example...:6443", "https://k3s.example:6443")` returns true because `trim_end_matches('.')` removes every trailing dot, although only a single final dot is the DNS root notation and the multi-dot authority is malformed. In Existing mode this lets a malformed K3S_URL pass the only pre-mutation target check and be used for worker joins while kubectl targets the canonical endpoint.

## Previously reported and still present (6)

In k8s_cli/src/manager/mod.rs around line 332, address this finding:
New-mode bootstrap reuses any pre-existing VM whose name matches the control-plane plan and immediately installs/uses its existing k3s state without verifying that it belongs to the intended cluster. A stale or unrelated VM with the planned name can therefore be adopted as the control plane, and its kubeconfig/token can lead subsequent workers and manifests to that unrelated cluster.

In .github/workflows/release.yml around line 234, address this finding:
The verify/confirm sequence does not prevent a tag move during publication, so it can publish a release whose assets do not match the tag. For example, verify reads `v1.2.3` as BUILT_SHA A and succeeds; before `softprops/action-gh-release` creates/updates the release, someone moves the tag to B; the action attaches the already-built archives under that tag name, and only the later confirm job notices the mismatch after the release is live. A failed run does not undo the release or assets. This would be false only if tag mutation is independently prevented for the entire workflow or publication is guaranteed atomic with the verification.

In k8s_cli/src/manager/mod.rs around line 724, address this finding:
Metadata reconciliation never removes stale tool-owned labels. When a fleet label is removed or its key is no longer desired, `apply_node_metadata` only sends the current `key=value` labels and leaves old `exedev.dev/*` labels on the node, while status reports only whether desired labels match and therefore can report `labels=ok` despite owned label drift.

In scripts/release/set-version.sh around line 130, address this finding:
An interrupt or failure while generating the root manifest's second-stage file can leave `Cargo.toml.next` behind, and that stale file blocks future release/version-sync runs.

In .github/workflows/release.yml around line 62, address this finding:
The push-trigger path does not resolve the tag ref or annotated tag object itself; it takes `GITHUB_SHA` directly, while only workflow-dispatch performs API dereferencing. For an annotated tag push (or an event SHA representing the tag object), checkout can receive a non-commit/object SHA and the build path is not guaranteed to compile the commit named by the tag.

In k8s_cli/src/manager/mod.rs around line 87, address this finding:
Existing-mode target validation happens after VM creation, so a mismatched or invalid existing-cluster target can still cause fleet VMs to be created before bootstrap aborts.
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 7 of 7 areas reviewed

Comment thread k8s_cli/src/manager/process.rs Outdated
// Seeing it means ssh failed while returning output, not before running
// anything, so resending the script would repeat an install or a service
// change that already happened.
let remote_ran = String::from_utf8_lossy(&output.stdout).contains(REMOTE_EXIT_PREFIX);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Resource Lifetime | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In k8s_cli/src/manager/process.rs, address this finding:
SSH transport retrying is based only on the absence of the wrapper exit marker; a connection can drop after the remote shell has executed side effects but before the marker is delivered, causing the same bootstrap script to be resent and potentially repeating installs or service starts.

Comment thread k8s_cli/src/manager/mod.rs Outdated
/// URL without it are still the same cluster.
fn same_cluster_endpoint(left: &str, right: &str) -> bool {
fn parts(url: &str) -> (String, String) {
let without_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact downstream behavior of the k3s agent when given a non-HTTPS K3S_URL is dependency/runtime-specific; regardless, the validation guard itself returns true for a concrete HTTPS-versus-HTTP mismatch and permits the mutation path.
🤖 Prompt for AI agents
In k8s_cli/src/manager/mod.rs, address this finding:
Existing-cluster validation ignores the URL scheme, so a malformed or different-scheme K3S_URL is accepted as targeting the same cluster. For example, `K3S_URL=http://cluster.example:6443` compares equal to a kubeconfig server of `https://cluster.example:6443` (and `ssh://...` or any other `scheme://...` does too). The subsequent worker install and kubectl labeling/deployment are then allowed despite the two endpoints not being the same HTTPS API endpoint; validation should parse and require the expected scheme rather than discard it. This is false acceptance, not merely a formatting issue, because the check is the guard immediately before mutating workers and the selected Kubernetes cluster.

include_control_plane: bool,
kubeconfig: Option<&Path>,
) -> Result<()> {
let actual = kubectl_capture(kubeconfig, &["get", "nodes", "-o", "json"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Error Handling | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In k8s_cli/src/manager/mod.rs, address this finding:
apply_node_metadata silently treats failure to fetch or parse the current node list as an empty map, then proceeds to label/taint nodes successfully; stale owned taints are therefore not removed and the command reports success despite an incomplete reconciliation.

Comment thread .github/workflows/release.yml Outdated
sha="$(printf '%s' "${ref_json}" | jq -r '.object.sha')"
type="$(printf '%s' "${ref_json}" | jq -r '.object.type')"
# An annotated tag points at a tag object, not the commit it names.
if [[ "${type}" == "tag" ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Build Deployment | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The push-trigger path may receive a commit SHA directly from GitHub, but the workflow_dispatch path is sufficient to reproduce the defect for recursively annotated tags.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
Workflow-dispatched and later verification/confirmation of annotated tags dereference only one tag object, so a recursively annotated tag is rejected or misclassified instead of resolving to its commit.

…state

Two bootstraps of the same new cluster each generated a token and overwrote the
other, so the server and the agents could end up with different credentials. The
token is now linked into place, which fails rather than replaces when the name is
taken, and the loser adopts the winner's.

Existing-mode target validation ran inside bootstrap_k3s, after VMs had been
created, so a mismatched K3S_URL left the fleet's VMs behind when it aborted. It
now runs before anything is created. That check also discarded the URL scheme,
accepting `http://host:6443` as the HTTPS endpoint a kubeconfig names, and
accepted any number of trailing dots on the host.

apply_node_metadata treated an unreadable node list as an empty one and carried
on, reporting a reconciliation it had not done. It now fails. Stale labels get
the same treatment taints already had: `exedev.dev/*` labels the plan dropped are
removed rather than left beside the desired ones, and status stops reporting
`labels=ok` while they are still there.

An object that named a VM stopped both parsers from descending into listings
nested under it, dropping those entries and their SSH destinations.

The SSH retry keyed on the wrapper's exit marker, but its absence does not mean
the script never ran: output arriving without the marker means it did. Retries
are now limited to exchanges that produced no output at all.

`--endpoint` was passed to reqwest with the API key attached regardless of
scheme, so `--endpoint http://elsewhere/collect` sent the key and the command in
the clear; it must now be https. `share receive-email` forwarded any state
string rather than the documented on/off.

set-version.sh validated only `-f` on the assembled manifest path, so a symlinked
member directory redirected the rewrite, its staging file and its backup outside
the workspace. Member directories and manifests must now be real. `.next` is also
cleaned up, having been staged but not removed. The workflow peels a tag object
naming another tag object rather than giving up after one level.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/src/client.rs`:
- Around line 41-49: Configure the shared reqwest client builder to enforce
HTTPS with https_only(true), preventing redirects from downgrading requests. Add
regression tests covering both 307 and 308 redirects from HTTPS to HTTP,
verifying they are rejected while preserving the existing endpoint validation in
the client setup.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cec853e7-d8c7-4ed1-b1ef-a17d4cdae22b

📥 Commits

Reviewing files that changed from the base of the PR and between caff018 and f1bdea7.

📒 Files selected for processing (9)
  • .github/workflows/release.yml
  • cli/src/cli.rs
  • core/src/client.rs
  • k8s_cli/src/manager/mod.rs
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/process.rs
  • k8s_cli/src/manager/state.rs
  • k8s_cli/src/manager/tests.rs
  • scripts/release/set-version.sh
🚧 Files skipped from review as they are similar to previous changes (8)
  • scripts/release/set-version.sh
  • .github/workflows/release.yml
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/state.rs
  • cli/src/cli.rs
  • k8s_cli/src/manager/mod.rs
  • k8s_cli/src/manager/tests.rs
  • k8s_cli/src/manager/process.rs
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: winnowl/review
🔇 Additional comments (1)
core/src/client.rs (1)

1-1: LGTM!

Comment thread core/src/client.rs

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (8)
  • 🟠 High A failed SSH exchange can retry after the remote script has already started, because retry eligibility checks only whether stdout is non-empty. If the remote side executes a state-changing install that emits only stderr (or the connection drops before the wrapper's stdout marker is delivered), ssh can exit 255 with empty stdout and the same script is resent up to four times. A failed stdin write after partial delivery has the same outcome when ssh exits 255. This violates the no-potential-execution retry invariant and can repeat Tailscale/k3s installation or service changes. (inline)
  • 🟠 High An existing persisted token is not tightened to mode 0600 when an explicit K3S_TOKEN matches it. The environment branch uses read_regular_file, which only validates and reads the file; it does not call the handle-based permission restriction used by read_secret_file. Thus a prior token file at 0644 remains group/world-readable after a successful override/synchronization call. (inline)
  • 🟠 High The hard-link adoption path can adopt an unrelated regular file after the AlreadyExists result. On losing hard_link, the code removes its staging file and then independently opens path; another process can replace the destination entry (or win a separate write) in between, and read_secret_file validates only that the replacement is a regular file, not that it is the file created by the competing token writer. A concurrent bootstrap can therefore consume a different token and proceed with credentials not matching the server. This is false only if no other process can modify the state directory/destination during this window (for example, an enforced lock or exclusive directory ownership exists), but this function establishes neither. (inline)
  • 🟡 Medium An explicitly supplied empty argument is silently replaced by RELEASE_TAG, so the script can report success for a different version than the caller supplied. For example, with RELEASE_TAG=v1.2.3, check-version.sh '' exits successfully and prints 1.2.3 instead of rejecting the malformed empty tag. This violates the stated 'RELEASE_TAG is used when no argument is given' behavior and can make a wrapper validate an ambient value after accidentally passing an empty tag. The claim would be false if callers are guaranteed never to pass an empty positional argument or if empty is intentionally defined as equivalent to omission. (inline)
  • 🟡 Medium A concurrent invocation can leave a mixed-version workspace and overwrite rollback data because the script has no repository-wide lock. Two processes can both pass require_free_sibling before either creates its staging files, then interleave their cp/mv operations; for example one can install version A's core/Cargo.toml while the other installs version B's cli/Cargo.toml and root manifest, and their .bak files can overwrite each other so EXIT cleanup cannot restore the original state. (inline)
  • 🟡 Medium Malformed nonempty ssh_dest values are treated as authoritative instead of falling back to &lt;vm&gt;.exe.xyz. For example, an API object { "vm_name":"vm-1", "ssh_dest":"vm-1.exe.xyz other-arg" } is stored unchanged and passed as one SSH destination; OpenSSH cannot parse it as a valid host target, so bootstrap fails even though the documented hostname fallback is usable. The same applies to values containing shell/control whitespace or other invalid destination syntax. (inline)
  • 🟡 Medium Inventory parser tests do not cover objects containing both a generic name and exe.dev's authoritative vm_name, so the current key precedence regression is undetectable. VM_NAME_KEYS checks name before vm_name; for { "name":"display-name", "vm_name":"authoritative-name", "ssh_dest":"..." }, both parse_vm_names and parse_ssh_destinations index the VM under display-name, causing the authoritative destination to be associated with the wrong fleet node. The existing fixtures use only one name spelling per object and therefore pass. This candidate is disproved if the exe.dev contract guarantees these fields can never coexist (or if generic name is authoritative when they do). (inline)
  • 🟡 Medium The failure-path test secret_write_failure_leaves_the_previous_secret_intact is platform/user dependent: it assumes chmod 0500 prevents the test process from creating the staging file. Under root (and on platforms/filesystems that ignore Unix mode enforcement), write_secret_file succeeds, so unwrap_err() panics; because cleanup is after the assertion, the temporary directory can also be left behind. This is disproved if the test suite explicitly excludes root/non-Unix environments or runs with a guaranteed permission-denied filesystem fixture. (inline)
⛔ Unresolved from previous review (7) — not approved until fixed
  • .github/workflows/release.yml: The verify/confirm sequence does not prevent a tag race during publication: after verify checks the tag (lines 164-176), an actor can move it before softprops/action-gh-release attaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built from BUILT_SHA while the tag points elsewhere; confirm (lines 216-237) only fails afterward and performs no deletion, retagging, or release correction. Thus a successful publish followed by a red workflow still leaves a mismatched public release. This is false only if the tag cannot be moved by any actor/token during the verify-to-publish window or if post-publication detection is explicitly considered sufficient rather than preventing the mismatch. — The defect remains: the current verify job performs a read-only tag check, then publish still invokes softprops/action-gh-release with only tag_name, so the tag can be moved after verification and before or during attachment. The confirm job still only reads the tag afterward and exits 1; it does not prevent, remove, or correct a release that was attached to the moved tag. The workflow comments explicitly acknowledge that publication cannot be refused at that instant.
  • k8s_cli/src/manager/mod.rs: New-cluster bootstrap can reuse an already-installed k3s server without verifying that it belongs to the requested cluster. — The defect can still occur. In create_missing_vms, an existing VM is still skipped solely on inventory.names.contains(&amp;node.name), and new-cluster bootstrap then calls install_k3s_server on that VM. The current server installer only rejects existing agent state; when a k3s service/binary is already present it starts or reuses it without checking its cluster identity, token, or server configuration. Thus an already-installed server from another cluster can still be reused for the requested new cluster.
  • k8s_cli/src/manager/process.rs: SSH transport retrying is based only on the absence of the wrapper exit marker; a connection can drop after the remote shell has executed side effects but before the marker is delivered, causing the same bootstrap script to be resent and potentially repeating installs or service starts. — The retry guard now treats any captured stdout as evidence that the remote shell ran, but it still retries status-255 exchanges when stdout is empty. A bootstrap script can perform installs or service changes without writing stdout; if the connection drops after those side effects and before the wrapper's exit marker, both the script output and marker can be absent, so remote_ran remains false and the script is resent.
  • scripts/release/sync-homebrew-tap.sh: The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After FORMULA_PARENT_REAL is checked, the script calls mktemp and later mv using the original path; if an attacker replaces a checked descendant (for example tap/Formula/e) with a symlink to an outside directory, mktemp creates the staged file outside the tap and mv writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.
  • Direct JSON arrays of arbitrary strings are treated as VM names without validation, allowing status/error payloads such as ["error", "quota exceeded"] (or a serialized arbitrary string list) to become inventory entries and suppress VM creation. — The array-string path now applies is_vm_name, but that predicate only checks DNS-label shape. In the reported payload ["error", "quota exceeded"], error passes unchanged and is still inserted into inventory, so an error response can still create a false VM entry and suppress creation. The current test explicitly confirms this limitation: a single lowercase prose word is accepted because it can also be a valid VM name.
  • The publish job grants contents: write to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable github.token; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures. — The tag-verification shell step was moved into the separate read-only verify job, but publish still declares permissions: contents: write and its first step, Download release archives, remains in that same job. GitHub Actions applies the job token permissions to every step, so the download action still runs with a write-capable github.token before the release action; the least-privilege defect therefore remains.
  • k8s_cli/src/manager/process.rs: A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. capture_remote_ssh_output retries solely from output.status.code() == Some(255) and ignores whether stdout already contains the wrapper's __EXEDEV_K8S_EXIT__: marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent. — The retry is now suppressed when any stdout is captured, including the exit marker, but !output.stdout.is_empty() is not evidence that the remote script did not run: a non-idempotent script can execute and emit only stderr (or its marker/stdout can be lost when the SSH connection drops), leaving stdout empty while SSH returns 255. That path still resends the full script.
⚠️ Unverified risks (1)
  • The SSH policy accepts an unverified host key on first use, so a reported destination can be redirected to an attacker or wrong host by DNS/routing and still pass SSH authentication. StrictHostKeyChecking=accept-new only rejects changed keys after they are cached; it does not pin or otherwise authenticate the initial key. The remote hostname check is not a cryptographic identity check and can be satisfied by an attacker-controlled machine that reports the planned short hostname. This permits bootstrap scripts containing Tailscale auth keys and k3s tokens to be sent to the wrong host. (k8s_cli/src/manager/process.rs)
📋 Additional findings from this change (not shown inline) (11)
  • 🟠 High The HTTPS-only check is bypassable via redirects: reqwest::Client::new() follows redirects by default, so a server at the initially validated HTTPS endpoint can return a 307/308 redirect and cause the POST command body to be resent to another endpoint, including an http:// URL. In the HTTP case this sends the command in cleartext; even for an HTTPS cross-host redirect it discloses the command to an endpoint that was never validated. Bearer handling does not protect the command body, and same-host redirects can also retain the Authorization header. (core/src/client.rs) — anchor-outside-diff
  • 🟠 High Fleet validation permits duplicate generated VM/node names, so bootstrap can silently skip one planned node or target the wrong role/pool. (k8s_cli/src/fleet.rs) — anchor-outside-diff
  • 🟠 High Reading persisted generated tokens does not validate the state-directory components before opening the file. With .exedev-k8s or the per-cluster directory as a symlink to another real directory, path.exists() is true and open_regular_file follows the symlink, so an unrelated file outside the state directory is adopted as the cluster token. The real-directory check is only reached from prepare_secret_parent, which is not called on this read path. (k8s_cli/src/manager/state.rs) — anchor-unreliable
  • 🟠 High The state-directory symlink defense is check-then-use and can be bypassed by swapping a checked directory component after ensure_real_directories returns but before staging/rename. An attacker able to modify the working directory can replace .exedev-k8s or the cluster directory with a symlink in that window; subsequent OpenOptions::open follows it and writes the secret outside the state directory. The same pathname race affects reads because symlink_metadata checks the final entry while parent components are followed during File::open. (k8s_cli/src/manager/state.rs) — anchor-unreliable
  • 🟡 Medium Existing-mode status reports control-plane metadata drift even though bootstrap intentionally never reconciles that node. (k8s_cli/src/manager/mod.rs) — anchor-outside-diff
  • 🟡 Medium Several SSH failure paths lose the actual VM name. capture_remote_ssh_output accepts only argv and reports spawn failures as failed to run ssh, timeouts as remote command on this VM..., and wait/write errors without identifying the VM; remote_command_output does not add VM context around that call. During bootstrap, callers such as install_tailscale and install_k3s_agent therefore return errors that cannot tell which fleet VM failed, contrary to the required actionable VM-context diagnostics. (k8s_cli/src/manager/process.rs) — anchor-outside-diff
  • 🟡 Medium If the process is interrupted after the hard link succeeds but before remove_file(&amp;staged), the random staging pathname remains as a second hard link to the live token. On a later run, the normal destination read succeeds but the credential-bearing .tmp artifact is never discovered or cleaned, violating the requirement that interrupted operations not leave stale temporary files. (k8s_cli/src/manager/state.rs) — per-file-budget
  • 🟡 Medium The state-directory symlink defense is vulnerable to a TOCTOU race: ensure_real_directories checks the parent path and then stage_secret opens a pathname later, so a local attacker can replace .exedev-k8s (or a cluster subdirectory) with a symlink after the check and redirect the staged token/kubeconfig into an attacker-chosen directory. The final rename then publishes the secret at that redirected location. The existing symlink test only covers a static symlink and does not exercise this interleaving. (k8s_cli/src/manager/state.rs) — per-file-budget
  • 🟡 Medium Successful secret writes are not durable across a crash because only the staged file is sync_all'd; neither the directory entry created by hard_link nor the rename is followed by an fsync/sync_all on the parent directory. On filesystems where a crash can persist the file data but lose the link/rename, the command can return success and then restart with no token (minting a different token) or with the previous token/kubeconfig, while the already-bootstrapped cluster uses the lost/new credential. This would be disproven if the supported filesystem/platform guaranteed metadata persistence for these operations without a directory sync, but Unix filesystem semantics do not provide that guarantee. (k8s_cli/src/manager/state.rs) — per-file-budget
  • 🟡 Medium The process-global state tests restore cwd and remove the environment variable only after assertions/results, rather than using panic-safe cleanup. In an_empty_token_file_is_refused, secret_writes_reject_a_symlinked_state_directory, and a_losing_concurrent_token_creation_adopts_the_winner, any panic from setup, the operation, or an assertion leaves the process in the temporary directory (and may leave K3S_TOKEN_ENV modified), poisoning unrelated tests; the mutex only serializes these three tests and cannot protect other cwd/environment users. This is disproved if the test harness guarantees these tests cannot panic before cleanup and no other test/process uses cwd or the environment concurrently. (k8s_cli/src/manager/tests.rs) — anchor-unreliable
  • 🔵 Low a_losing_concurrent_token_creation_adopts_the_winner does not exercise concurrency at all: it calls read_or_create_k3s_token twice sequentially while holding a process-wide mutex. The first call necessarily creates the file and the second only reads it, so the hard_link(... AlreadyExists) losing-writer branch in create_k3s_token is never reached. A regression that generates two tokens or mishandles the atomic winner path can therefore pass this test. This is disproved only if another test invokes concurrent creation and deterministically asserts the losing path. (k8s_cli/src/manager/tests.rs) — inline-budget
♻️ Previously reported (still present) (7)
  • 🟠 High read_or_create_k3s_token does not validate that the generated state directory is real before reading an existing token. If .exedev-k8s (or the cluster subdirectory) is a symlink to another directory, path.exists() succeeds and read_regular_file follows the directory symlink; bootstrap then adopts the external file's token instead of rejecting the redirected state path. This can make one cluster reuse another cluster's credential or let a planted token control the new cluster's server/agent credential. (k8s_cli/src/manager/state.rs) — previously-reported
  • 🟠 High Rendered/prose fallback can still invent inventory VMs from lowercase error text, causing create decisions to treat a missing VM as present (and potentially bootstrap the fallback host). (k8s_cli/src/manager/parsing.rs) — anchor-unreliable
  • 🟡 Medium The resolver rejects valid recursively annotated tags once the chain is deeper than five tag objects. A ref whose object is a tag, whose object is another tag, repeated six times before reaching a commit is a valid Git tag structure, but after five iterations type remains tag and the job exits with “does not resolve to a commit”; the same bounded logic is duplicated in verify and confirm. Thus the workflow does not support recursive annotated tags generally, despite the obligation, and a legitimate release cannot be built or published. (.github/workflows/release.yml) — anchor-unreliable
  • 🟡 Medium The multi-file rewrite is not recoverable from an untrappable interruption: after APPLIED=1, the script moves each manifest into place one at a time, but restoration exists only in the EXIT trap. If the process is SIGKILLed or the runner/power fails after one or more moves and before cargo update completes, some workspace manifests are new while the remaining manifests and lockfile are old; no journal or durable commit marker exists to restore them on the next invocation. The stated no-mixed-version invariant is therefore not met for hard interruptions, even though ordinary INT/TERM paths are trapped. This is introduced by the staged-but-sequential apply design. The claim would be false only if the execution environment guaranteed that interruption can never occur between those moves (or discarded the workspace before it can be observed). (scripts/release/set-version.sh) — previously-reported
  • 🟡 Medium Kubectl subprocesses launched through run_command have no lifetime bound and are not configured with kill_on_drop. kubectl_apply and every metadata label/taint operation use this path, so a hung kubectl (including a stuck exec credential plugin, resolver, or API interaction) can remain indefinitely and the bootstrap cannot reach its timeout/error path. This violates the requirement that local kubectl command lifetimes be bounded and cancellable. (k8s_cli/src/manager/process.rs) — previously-reported
  • 🟡 Medium Successful staged replacement fsyncs only the temporary file, not the containing directory after rename. A crash after the rename can therefore lose the directory-entry update (or leave the old entry) despite write_secret_file having returned success, so the persisted token/kubeconfig is not crash-durable and may revert or disappear; this also undermines the requirement that failure/interruption preserve valid state. (k8s_cli/src/manager/state.rs) — anchor-unreliable
  • 🔵 Low The wrapper/parser does not preserve remote stdout boundaries: parse_remote_stdout removes every trailing newline before the exit marker with trim_end_matches('\n'). A successful remote command that intentionally emits value\n\n is returned as value, and remote_run may add at most one newline when displaying it. Thus wrapped scripts change captured/output semantics rather than preserving the command's exact stdout. (k8s_cli/src/manager/process.rs) — previously-reported
❓ Low-evidence leads (not confirmed — verify before acting) (1)
  • Cargo.lock symlinks are not rejected, so a failed run changes the lockfile's object type and can break the workspace's lockfile location. With Cargo.lock symlinked to build/locked/Cargo.lock, the script accepts it ([[ -f ]]), backs up the target contents, then mv .../Cargo.lock.tmp Cargo.lock replaces the symlink with a regular file; if cargo update fails, cleanup restores the backup as another regular file, permanently deleting the symlink (and a later successful run likewise leaves the symlink replaced). A broken Cargo.lock symlink is treated as a missing lockfile and rm -f Cargo.lock on failure deletes that symlink outright. (scripts/release/set-version.sh)
🤖 Prompt for AI agents — all findings (33)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Unresolved from the previous review — these block approval, fix them first (7)

In .github/workflows/release.yml, address this finding:
The verify/confirm sequence does not prevent a tag race during publication: after `verify` checks the tag (lines 164-176), an actor can move it before `softprops/action-gh-release` attaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built from `BUILT_SHA` while the tag points elsewhere; `confirm` (lines 216-237) only fails afterward and performs no deletion, retagging, or release correction. Thus a successful publish followed by a red workflow still leaves a mismatched public release. This is false only if the tag cannot be moved by any actor/token during the verify-to-publish window or if post-publication detection is explicitly considered sufficient rather than preventing the mismatch.

In k8s_cli/src/manager/mod.rs, address this finding:
New-cluster bootstrap can reuse an already-installed k3s server without verifying that it belongs to the requested cluster.

In k8s_cli/src/manager/process.rs, address this finding:
SSH transport retrying is based only on the absence of the wrapper exit marker; a connection can drop after the remote shell has executed side effects but before the marker is delivered, causing the same bootstrap script to be resent and potentially repeating installs or service starts.

In scripts/release/sync-homebrew-tap.sh, address this finding:
The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After `FORMULA_PARENT_REAL` is checked, the script calls `mktemp` and later `mv` using the original path; if an attacker replaces a checked descendant (for example `tap/Formula/e`) with a symlink to an outside directory, `mktemp` creates the staged file outside the tap and `mv` writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.

Somewhere in the code under review, address this finding:
Direct JSON arrays of arbitrary strings are treated as VM names without validation, allowing status/error payloads such as `["error", "quota exceeded"]` (or a serialized arbitrary string list) to become inventory entries and suppress VM creation.

Somewhere in the code under review, address this finding:
The publish job grants `contents: write` to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable `github.token`; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures.

In k8s_cli/src/manager/process.rs, address this finding:
A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. `capture_remote_ssh_output` retries solely from `output.status.code() == Some(255)` and ignores whether stdout already contains the wrapper's `__EXEDEV_K8S_EXIT__:` marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent.

## Findings on this change (also posted as inline comments) (8)

In k8s_cli/src/manager/process.rs around line 310, address this finding:
A failed SSH exchange can retry after the remote script has already started, because retry eligibility checks only whether stdout is non-empty. If the remote side executes a state-changing install that emits only stderr (or the connection drops before the wrapper's stdout marker is delivered), ssh can exit 255 with empty stdout and the same script is resent up to four times. A failed stdin write after partial delivery has the same outcome when ssh exits 255. This violates the no-potential-execution retry invariant and can repeat Tailscale/k3s installation or service changes.

In k8s_cli/src/manager/state.rs around line 74, address this finding:
An existing persisted token is not tightened to mode 0600 when an explicit `K3S_TOKEN` matches it. The environment branch uses `read_regular_file`, which only validates and reads the file; it does not call the handle-based permission restriction used by `read_secret_file`. Thus a prior token file at 0644 remains group/world-readable after a successful override/synchronization call.

In k8s_cli/src/manager/state.rs around line 115, address this finding:
The hard-link adoption path can adopt an unrelated regular file after the `AlreadyExists` result. On losing `hard_link`, the code removes its staging file and then independently opens `path`; another process can replace the destination entry (or win a separate write) in between, and `read_secret_file` validates only that the replacement is a regular file, not that it is the file created by the competing token writer. A concurrent bootstrap can therefore consume a different token and proceed with credentials not matching the server. This is false only if no other process can modify the state directory/destination during this window (for example, an enforced lock or exclusive directory ownership exists), but this function establishes neither.

In scripts/release/check-version.sh around line 11, address this finding:
An explicitly supplied empty argument is silently replaced by `RELEASE_TAG`, so the script can report success for a different version than the caller supplied. For example, with `RELEASE_TAG=v1.2.3`, `check-version.sh ''` exits successfully and prints `1.2.3` instead of rejecting the malformed empty tag. This violates the stated 'RELEASE_TAG is used when no argument is given' behavior and can make a wrapper validate an ambient value after accidentally passing an empty tag. The claim would be false if callers are guaranteed never to pass an empty positional argument or if empty is intentionally defined as equivalent to omission.

In scripts/release/set-version.sh around line 96, address this finding:
A concurrent invocation can leave a mixed-version workspace and overwrite rollback data because the script has no repository-wide lock. Two processes can both pass `require_free_sibling` before either creates its staging files, then interleave their `cp`/`mv` operations; for example one can install version A's `core/Cargo.toml` while the other installs version B's `cli/Cargo.toml` and root manifest, and their `.bak` files can overwrite each other so EXIT cleanup cannot restore the original state.

In k8s_cli/src/manager/parsing.rs around line 135, address this finding:
Malformed nonempty `ssh_dest` values are treated as authoritative instead of falling back to `<vm>.exe.xyz`. For example, an API object `{ "vm_name":"vm-1", "ssh_dest":"vm-1.exe.xyz other-arg" }` is stored unchanged and passed as one SSH destination; OpenSSH cannot parse it as a valid host target, so bootstrap fails even though the documented hostname fallback is usable. The same applies to values containing shell/control whitespace or other invalid destination syntax.

In k8s_cli/src/manager/tests.rs around line 188, address this finding:
Inventory parser tests do not cover objects containing both a generic `name` and exe.dev's authoritative `vm_name`, so the current key precedence regression is undetectable. `VM_NAME_KEYS` checks `name` before `vm_name`; for `{ "name":"display-name", "vm_name":"authoritative-name", "ssh_dest":"..." }`, both `parse_vm_names` and `parse_ssh_destinations` index the VM under `display-name`, causing the authoritative destination to be associated with the wrong fleet node. The existing fixtures use only one name spelling per object and therefore pass. This candidate is disproved if the exe.dev contract guarantees these fields can never coexist (or if generic `name` is authoritative when they do).

In k8s_cli/src/manager/tests.rs around line 416, address this finding:
The failure-path test `secret_write_failure_leaves_the_previous_secret_intact` is platform/user dependent: it assumes chmod 0500 prevents the test process from creating the staging file. Under root (and on platforms/filesystems that ignore Unix mode enforcement), `write_secret_file` succeeds, so `unwrap_err()` panics; because cleanup is after the assertion, the temporary directory can also be left behind. This is disproved if the test suite explicitly excludes root/non-Unix environments or runs with a guaranteed permission-denied filesystem fixture.

## Additional findings on this change (not posted inline) (11)

In core/src/client.rs around line 36, address this finding:
The HTTPS-only check is bypassable via redirects: reqwest::Client::new() follows redirects by default, so a server at the initially validated HTTPS endpoint can return a 307/308 redirect and cause the POST command body to be resent to another endpoint, including an http:// URL. In the HTTP case this sends the command in cleartext; even for an HTTPS cross-host redirect it discloses the command to an endpoint that was never validated. Bearer handling does not protect the command body, and same-host redirects can also retain the Authorization header.

In k8s_cli/src/fleet.rs around line 131, address this finding:
Fleet validation permits duplicate generated VM/node names, so bootstrap can silently skip one planned node or target the wrong role/pool.

In k8s_cli/src/manager/state.rs, address this finding:
Reading persisted generated tokens does not validate the state-directory components before opening the file. With `.exedev-k8s` or the per-cluster directory as a symlink to another real directory, `path.exists()` is true and `open_regular_file` follows the symlink, so an unrelated file outside the state directory is adopted as the cluster token. The real-directory check is only reached from `prepare_secret_parent`, which is not called on this read path.

In k8s_cli/src/manager/state.rs, address this finding:
The state-directory symlink defense is check-then-use and can be bypassed by swapping a checked directory component after `ensure_real_directories` returns but before staging/rename. An attacker able to modify the working directory can replace `.exedev-k8s` or the cluster directory with a symlink in that window; subsequent `OpenOptions::open` follows it and writes the secret outside the state directory. The same pathname race affects reads because `symlink_metadata` checks the final entry while parent components are followed during `File::open`.

In k8s_cli/src/manager/mod.rs around line 290, address this finding:
Existing-mode status reports control-plane metadata drift even though bootstrap intentionally never reconciles that node.

In k8s_cli/src/manager/process.rs around line 236, address this finding:
Several SSH failure paths lose the actual VM name. `capture_remote_ssh_output` accepts only argv and reports spawn failures as `failed to run ssh`, timeouts as `remote command on this VM...`, and wait/write errors without identifying the VM; `remote_command_output` does not add VM context around that call. During bootstrap, callers such as `install_tailscale` and `install_k3s_agent` therefore return errors that cannot tell which fleet VM failed, contrary to the required actionable VM-context diagnostics.

In k8s_cli/src/manager/state.rs around line 111, address this finding:
If the process is interrupted after the hard link succeeds but before `remove_file(&staged)`, the random staging pathname remains as a second hard link to the live token. On a later run, the normal destination read succeeds but the credential-bearing `.tmp` artifact is never discovered or cleaned, violating the requirement that interrupted operations not leave stale temporary files.

In k8s_cli/src/manager/state.rs around line 159, address this finding:
The state-directory symlink defense is vulnerable to a TOCTOU race: `ensure_real_directories` checks the parent path and then `stage_secret` opens a pathname later, so a local attacker can replace `.exedev-k8s` (or a cluster subdirectory) with a symlink after the check and redirect the staged token/kubeconfig into an attacker-chosen directory. The final rename then publishes the secret at that redirected location. The existing symlink test only covers a static symlink and does not exercise this interleaving.

In k8s_cli/src/manager/state.rs around line 176, address this finding:
Successful secret writes are not durable across a crash because only the staged file is `sync_all`'d; neither the directory entry created by `hard_link` nor the rename is followed by an `fsync`/`sync_all` on the parent directory. On filesystems where a crash can persist the file data but lose the link/rename, the command can return success and then restart with no token (minting a different token) or with the previous token/kubeconfig, while the already-bootstrapped cluster uses the lost/new credential. This would be disproven if the supported filesystem/platform guaranteed metadata persistence for these operations without a directory sync, but Unix filesystem semantics do not provide that guarantee.

In k8s_cli/src/manager/tests.rs, address this finding:
The process-global state tests restore cwd and remove the environment variable only after assertions/results, rather than using panic-safe cleanup. In `an_empty_token_file_is_refused`, `secret_writes_reject_a_symlinked_state_directory`, and `a_losing_concurrent_token_creation_adopts_the_winner`, any panic from setup, the operation, or an assertion leaves the process in the temporary directory (and may leave `K3S_TOKEN_ENV` modified), poisoning unrelated tests; the mutex only serializes these three tests and cannot protect other cwd/environment users. This is disproved if the test harness guarantees these tests cannot panic before cleanup and no other test/process uses cwd or the environment concurrently.

In k8s_cli/src/manager/tests.rs around line 705, address this finding:
`a_losing_concurrent_token_creation_adopts_the_winner` does not exercise concurrency at all: it calls `read_or_create_k3s_token` twice sequentially while holding a process-wide mutex. The first call necessarily creates the file and the second only reads it, so the `hard_link(... AlreadyExists)` losing-writer branch in `create_k3s_token` is never reached. A regression that generates two tokens or mishandles the atomic winner path can therefore pass this test. This is disproved only if another test invokes concurrent creation and deterministically asserts the losing path.

## Previously reported and still present (7)

In k8s_cli/src/manager/state.rs around line 84, address this finding:
`read_or_create_k3s_token` does not validate that the generated state directory is real before reading an existing token. If `.exedev-k8s` (or the cluster subdirectory) is a symlink to another directory, `path.exists()` succeeds and `read_regular_file` follows the directory symlink; bootstrap then adopts the external file's token instead of rejecting the redirected state path. This can make one cluster reuse another cluster's credential or let a planted token control the new cluster's server/agent credential.

In k8s_cli/src/manager/parsing.rs, address this finding:
Rendered/prose fallback can still invent inventory VMs from lowercase error text, causing create decisions to treat a missing VM as present (and potentially bootstrap the fallback host).

In .github/workflows/release.yml, address this finding:
The resolver rejects valid recursively annotated tags once the chain is deeper than five tag objects. A ref whose object is a tag, whose object is another tag, repeated six times before reaching a commit is a valid Git tag structure, but after five iterations `type` remains `tag` and the job exits with “does not resolve to a commit”; the same bounded logic is duplicated in verify and confirm. Thus the workflow does not support recursive annotated tags generally, despite the obligation, and a legitimate release cannot be built or published.

In scripts/release/set-version.sh around line 167, address this finding:
The multi-file rewrite is not recoverable from an untrappable interruption: after `APPLIED=1`, the script moves each manifest into place one at a time, but restoration exists only in the EXIT trap. If the process is SIGKILLed or the runner/power fails after one or more moves and before `cargo update` completes, some workspace manifests are new while the remaining manifests and lockfile are old; no journal or durable commit marker exists to restore them on the next invocation. The stated no-mixed-version invariant is therefore not met for hard interruptions, even though ordinary INT/TERM paths are trapped. This is introduced by the staged-but-sequential apply design. The claim would be false only if the execution environment guaranteed that interruption can never occur between those moves (or discarded the workspace before it can be observed).

In k8s_cli/src/manager/process.rs around line 178, address this finding:
Kubectl subprocesses launched through `run_command` have no lifetime bound and are not configured with `kill_on_drop`. `kubectl_apply` and every metadata label/taint operation use this path, so a hung kubectl (including a stuck exec credential plugin, resolver, or API interaction) can remain indefinitely and the bootstrap cannot reach its timeout/error path. This violates the requirement that local kubectl command lifetimes be bounded and cancellable.

In k8s_cli/src/manager/state.rs, address this finding:
Successful staged replacement fsyncs only the temporary file, not the containing directory after `rename`. A crash after the rename can therefore lose the directory-entry update (or leave the old entry) despite `write_secret_file` having returned success, so the persisted token/kubeconfig is not crash-durable and may revert or disappear; this also undermines the requirement that failure/interruption preserve valid state.

In k8s_cli/src/manager/process.rs around line 359, address this finding:
The wrapper/parser does not preserve remote stdout boundaries: `parse_remote_stdout` removes every trailing newline before the exit marker with `trim_end_matches('\n')`. A successful remote command that intentionally emits `value\n\n` is returned as `value`, and `remote_run` may add at most one newline when displaying it. Thus wrapped scripts change captured/output semantics rather than preserving the command's exact stdout.
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 7 of 7 areas reviewed

// Any stdout at all means the remote shell reached the script, whether or
// not the exit marker made it back, so resending would repeat whatever it
// had already done. Only an exchange that produced nothing is retried.
let remote_ran = !output.stdout.is_empty();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔀 Concurrency | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In k8s_cli/src/manager/process.rs, address this finding:
A failed SSH exchange can retry after the remote script has already started, because retry eligibility checks only whether stdout is non-empty. If the remote side executes a state-changing install that emits only stderr (or the connection drops before the wrapper's stdout marker is delivered), ssh can exit 255 with empty stdout and the same script is resent up to four times. A failed stdin write after partial delivery has the same outcome when ssh exits 255. This violates the no-potential-execution retry invariant and can repeat Tailscale/k3s installation or service changes.

Comment thread k8s_cli/src/manager/state.rs Outdated
if path.exists() {
let file_token = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let file_token = read_regular_file(&path)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In k8s_cli/src/manager/state.rs, address this finding:
An existing persisted token is not tightened to mode 0600 when an explicit `K3S_TOKEN` matches it. The environment branch uses `read_regular_file`, which only validates and reads the file; it does not call the handle-based permission restriction used by `read_secret_file`. Thus a prior token file at 0644 remains group/world-readable after a successful override/synchronization call.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
let file_token = read_regular_file(&path)?;
let file_token = read_secret_file(&path)?;

let _ = fs::remove_file(&staged);
Ok(token)
}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: unknown
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In k8s_cli/src/manager/state.rs, address this finding:
The hard-link adoption path can adopt an unrelated regular file after the `AlreadyExists` result. On losing `hard_link`, the code removes its staging file and then independently opens `path`; another process can replace the destination entry (or win a separate write) in between, and `read_secret_file` validates only that the replacement is a regular file, not that it is the file created by the competing token writer. A concurrent bootstrap can therefore consume a different token and proceed with credentials not matching the server. This is false only if no other process can modify the state directory/destination during this window (for example, an enforced lock or exclusive directory ownership exists), but this function establishes neither.

Comment thread scripts/release/check-version.sh Outdated
# the point it is resolved, before four matrix builds check out and install a
# toolchain only to fail on the same string.

VERSION="${1:-${RELEASE_TAG:-}}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/check-version.sh, address this finding:
An explicitly supplied empty argument is silently replaced by `RELEASE_TAG`, so the script can report success for a different version than the caller supplied. For example, with `RELEASE_TAG=v1.2.3`, `check-version.sh ''` exits successfully and prints `1.2.3` instead of rejecting the malformed empty tag. This violates the stated 'RELEASE_TAG is used when no argument is given' behavior and can make a wrapper validate an ambient value after accidentally passing an empty tag. The claim would be false if callers are guaranteed never to pass an empty positional argument or if empty is intentionally defined as equivalent to omission.

📝 Committable suggestion — review it before committing; it is generated, not proven.

Suggested change
VERSION="${1:-${RELEASE_TAG:-}}"
VERSION="${1-${RELEASE_TAG:-}}"

# The staging and backup names are derived from the target, so anything already
# sitting at one of them would be written through (a symlink there redirects the
# rewrite outside the workspace) and then deleted by the cleanup below.
require_free_sibling() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/set-version.sh, address this finding:
A concurrent invocation can leave a mixed-version workspace and overwrite rollback data because the script has no repository-wide lock. Two processes can both pass `require_free_sibling` before either creates its staging files, then interleave their `cp`/`mv` operations; for example one can install version A's `core/Cargo.toml` while the other installs version B's `cli/Cargo.toml` and root manifest, and their `.bak` files can overwrite each other so EXIT cleanup cannot restore the original state.

Comment thread k8s_cli/src/manager/parsing.rs Outdated
.map(str::trim)
.filter(|value| !value.is_empty())
};
if let Some(dest) = text("ssh_dest").or_else(|| text("sshDest")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Compatibility | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The normal exe.dev API contract may guarantee that ssh_dest is valid, but this code has no validation or fallback when the field violates that contract.
🤖 Prompt for AI agents
In k8s_cli/src/manager/parsing.rs, address this finding:
Malformed nonempty `ssh_dest` values are treated as authoritative instead of falling back to `<vm>.exe.xyz`. For example, an API object `{ "vm_name":"vm-1", "ssh_dest":"vm-1.exe.xyz other-arg" }` is stored unchanged and passed as one SSH destination; OpenSSH cannot parse it as a valid host target, so bootstrap fails even though the documented hostname fallback is usable. The same applies to values containing shell/control whitespace or other invalid destination syntax.

}

#[test]
fn parses_ssh_destinations_from_ls_json() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The repository does not include the exe.dev server implementation, so it cannot establish whether the live service ever emits both name and vm_name; however, the parser explicitly supports both keys and the documented authoritative identity field is vm_name.
🤖 Prompt for AI agents
In k8s_cli/src/manager/tests.rs, address this finding:
Inventory parser tests do not cover objects containing both a generic `name` and exe.dev's authoritative `vm_name`, so the current key precedence regression is undetectable. `VM_NAME_KEYS` checks `name` before `vm_name`; for `{ "name":"display-name", "vm_name":"authoritative-name", "ssh_dest":"..." }`, both `parse_vm_names` and `parse_ssh_destinations` index the VM under `display-name`, causing the authoritative destination to be associated with the wrong fleet node. The existing fixtures use only one name spelling per object and therefore pass. This candidate is disproved if the exe.dev contract guarantees these fields can never coexist (or if generic `name` is authoritative when they do).


// A read-only directory fails the staged create, standing in for any I/O
// error partway through replacing the file.
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The repository's CI execution user and filesystem type are not shown, so the failure may not occur in the project's usual environments.
🤖 Prompt for AI agents
In k8s_cli/src/manager/tests.rs, address this finding:
The failure-path test `secret_write_failure_leaves_the_previous_secret_intact` is platform/user dependent: it assumes chmod 0500 prevents the test process from creating the staging file. Under root (and on platforms/filesystems that ignore Unix mode enforcement), `write_secret_file` succeeds, so `unwrap_err()` panics; because cleanup is after the assertion, the temporary directory can also be left behind. This is disproved if the test suite explicitly excludes root/non-Unix environments or runs with a guaranteed permission-denied filesystem fixture.

exe.dev names its field `vm_name`, but the key list checked the generic `name`
first, so a record carrying both was indexed under a display name and its
destination attached to a node that does not exist. A nonempty but malformed
`ssh_dest` — one carrying whitespace or control characters — was also passed to
ssh verbatim, failing in a way that reads as the VM being unreachable; such a
value is now left out so the `<vm>.exe.xyz` fallback still applies.

Secret handling: the K3S_TOKEN branch read through a path that never tightened
permissions, so a token file left at 0644 stayed that way; reads did not check
that the state directory was real, so a symlinked `.exedev-k8s` had an outside
file adopted as the cluster credential; and only the staged file was flushed, not
the directory entry the rename or link created, so a crash could lose the name a
returned-successful write had published.

`--endpoint` was validated once for https but reqwest followed redirects, so the
endpoint could bounce the command, and the token on a same-host hop, somewhere
never checked. Redirects are no longer followed.

Fleet validation accepted two pools expanding to the same VM name, which
collapses two planned nodes into one with whichever role the plan visits last.

`run_command` now dies with a cancelled future, ssh failures carry the VM they
belong to, and the wrapper strips exactly its own trailing newline rather than
every one, which was rewriting the stdout of commands ending in a blank line.

check-version.sh treated an explicitly empty argument as no argument and
validated the ambient RELEASE_TAG instead. set-version.sh takes a lock, since two
runs could otherwise interleave their moves and overwrite each other's backups.

Tests: the process-global ones restore the working directory and environment
through a guard rather than after their assertions, the permission-based failure
test confirms the denial it depends on instead of assuming it, and the
losing-writer branch of token creation is now exercised deliberately rather than
by two sequential calls that never reach it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
k8s_cli/src/manager/state.rs (1)

74-90: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Normalize the environment token before you store and return it.

The environment branch returns token unchanged, but the stored-file branch at Line 92 returns read_secret_file(...).trim(). If K3S_TOKEN contains surrounding whitespace, the two paths produce different credentials for the same cluster. The comparison at Line 82 also compares a trimmed file value against an untrimmed environment value, so the file is rewritten on every run and never matches.

Trim once, then use the trimmed value for the comparison, the write, and the return value.

🐛 Proposed fix to normalize the environment token
     if let Ok(token) = env::var(K3S_TOKEN_ENV) {
         // An exported but empty value would otherwise become the cluster
         // credential for the server and every agent.
-        if token.trim().is_empty() {
+        let token = token.trim().to_string();
+        if token.is_empty() {
             bail!("{K3S_TOKEN_ENV} is set but empty");
         }
         if path.exists() {
             let file_token = read_secret_file(&path)?;
             if file_token.trim() != token {
                 write_secret_file(&path, &token)
                     .with_context(|| format!("failed to update {}", path.display()))?;
             }
         } else {
             write_secret_file(&path, &token)?;
         }
         return Ok(token);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@k8s_cli/src/manager/state.rs` around lines 74 - 90, Normalize the
K3S_TOKEN_ENV value once in the environment branch before any comparison or
persistence. Use the trimmed token for comparing with read_secret_file(&path),
writing via write_secret_file, and returning from the branch, while preserving
the existing empty-token validation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/src/client.rs`:
- Around line 36-42: Change the client constructor in core/src/client.rs to
return Result<Self> and propagate the error from
reqwest::Client::builder().redirect(...).build() instead of falling back to
Client::new(). Update all callers in cli/src/lib.rs and
k8s_cli/src/manager/mod.rs to handle the constructor’s Result and propagate or
otherwise appropriately return the error.

In `@scripts/release/set-version.sh`:
- Around line 77-79: Update cleanup_staged so release_lock occurs only after
restoring backups and removing sibling files, keeping the lock held throughout
cleanup. Preserve lock release when the target is empty and when cleanup fails
by ensuring all return and failure paths release the lock exactly once.

---

Outside diff comments:
In `@k8s_cli/src/manager/state.rs`:
- Around line 74-90: Normalize the K3S_TOKEN_ENV value once in the environment
branch before any comparison or persistence. Use the trimmed token for comparing
with read_secret_file(&path), writing via write_secret_file, and returning from
the branch, while preserving the existing empty-token validation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5046357f-b1d4-484a-8949-41a7535f15b6

📥 Commits

Reviewing files that changed from the base of the PR and between f1bdea7 and 49b0959.

📒 Files selected for processing (8)
  • core/src/client.rs
  • k8s_cli/src/fleet.rs
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/process.rs
  • k8s_cli/src/manager/state.rs
  • k8s_cli/src/manager/tests.rs
  • scripts/release/check-version.sh
  • scripts/release/set-version.sh
🚧 Files skipped from review as they are similar to previous changes (3)
  • scripts/release/check-version.sh
  • k8s_cli/src/manager/parsing.rs
  • k8s_cli/src/manager/process.rs
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: winnowl/review
🔇 Additional comments (6)
k8s_cli/src/manager/tests.rs (2)

561-584: StateSandbox still clears K3S_TOKEN instead of restoring it.

enter removes K3S_TOKEN, and Drop removes it again. A value that the caller exported before the test run is lost for every later test in the same process. Capture std::env::var_os(K3S_TOKEN_ENV) in enter and restore it in Drop.


407-439: LGTM!

Also applies to: 586-615, 693-770

k8s_cli/src/manager/state.rs (1)

113-133: LGTM!

Also applies to: 145-172

k8s_cli/src/fleet.rs (1)

3-7: LGTM!

Also applies to: 129-137, 192-204

core/src/client.rs (1)

46-55: LGTM!

scripts/release/set-version.sh (1)

62-70: LGTM!

Also applies to: 72-76, 102-104

Comment thread core/src/client.rs Outdated
Comment thread scripts/release/set-version.sh Outdated

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (1)
  • 🟡 Medium Metadata reconciliation fails to remove an old owned taint when its key is reused with a different effect. stale_owned_taints excludes every actual taint whose key equals the desired taint key, rather than excluding only the exact desired taint. Thus a node carrying exedev.dev/pool=pool:PreferNoSchedule and a plan desiring exedev.dev/pool=pool:NoSchedule gets the desired taint applied, but the old effect remains; status sees two owned taints and reports drift, and the node remains more restrictive than the plan. This would be false only if Kubernetes guaranteed no same-key/different-effect taints can coexist or kubectl taint --overwrite removed all effects, neither of which is assumed by the code's set-based reconciliation. (inline)
⛔ Unresolved from previous review (9) — not approved until fixed
  • The state-directory symlink defense is check-then-use and can be bypassed by swapping a checked directory component after ensure_real_directories returns but before staging/rename. An attacker able to modify the working directory can replace .exedev-k8s or the cluster directory with a symlink in that window; subsequent OpenOptions::open follows it and writes the secret outside the state directory. The same pathname race affects reads because symlink_metadata checks the final entry while parent components are followed during File::open. — The race remains: read_or_create_k3s_token and prepare_secret_parent call ensure_real_directories(parent) before later pathname operations, but stage_secret subsequently opens staged by path and write_secret_file subsequently calls fs::rename(&amp;staged, path). An attacker can replace .exedev-k8s or the cluster directory after the check, causing those operations to follow the swapped parent and write outside the state directory. Reads likewise still perform symlink_metadata/ensure_real_directories checks before fs::File::open(path) in open_regular_file, so swapping a parent component in between remains possible.
  • Plain-text fallback accepts arbitrary lowercase words as VM inventory entries, so non-table prose or malformed API responses can make an existing VM appear present and suppress creation/bootstrap. — The plain-text path still treats any token matching the DNS-label syntax as a VM name. is_vm_name only checks lowercase letters/digits/hyphens and length, so prose such as error, quota, vm, or unavailable can still be extracted as inventory entries and suppress creation; no table/row structure or authoritative VM-name validation is required.
  • k8s_cli/src/manager/process.rs: A failed SSH exchange can retry after the remote script has already started, because retry eligibility checks only whether stdout is non-empty. If the remote side executes a state-changing install that emits only stderr (or the connection drops before the wrapper's stdout marker is delivered), ssh can exit 255 with empty stdout and the same script is resent up to four times. A failed stdin write after partial delivery has the same outcome when ssh exits 255. This violates the no-potential-execution retry invariant and can repeat Tailscale/k3s installation or service changes. — The retry decision still depends solely on let remote_ran = !output.stdout.is_empty(); and retries status-255 attempts when stdout is empty. A remote script that changes state but emits only stderr, loses the wrapper's stdout, or receives only a partially written stdin can therefore still produce empty stdout and be resent.
  • .github/workflows/release.yml: The verify/confirm sequence does not prevent a tag race during publication: after verify checks the tag (lines 164-176), an actor can move it before softprops/action-gh-release attaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built from BUILT_SHA while the tag points elsewhere; confirm (lines 216-237) only fails afterward and performs no deletion, retagging, or release correction. Thus a successful publish followed by a red workflow still leaves a mismatched public release. This is false only if the tag cannot be moved by any actor/token during the verify-to-publish window or if post-publication detection is explicitly considered sufficient rather than preventing the mismatch. — The current workflow still performs a read-only tag check in verify, then publishes by tag name with softprops/action-gh-release, and only afterward checks the tag in confirm. The comments explicitly acknowledge that a tag moved during publication cannot be refused at that instant, and confirm only fails after the release has been attached; it does not prevent or repair the mismatched release. Therefore the reported verify-to-publish race remains possible.
  • k8s_cli/src/manager/mod.rs: New-cluster bootstrap can reuse an already-installed k3s server without verifying that it belongs to the requested cluster. — The defect remains. create_missing_vms still skips any planned node whose name is already in the inventory, and new-cluster bootstrap then calls install_k3s_server on that VM. The current server script only rejects pre-existing agent state; when a k3s service/binary already exists it skips installation and starts the existing k3s service, without checking its cluster identity, token, or API endpoint. Thus an existing server from another cluster can still be reused.
  • scripts/release/sync-homebrew-tap.sh: The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After FORMULA_PARENT_REAL is checked, the script calls mktemp and later mv using the original path; if an attacker replaces a checked descendant (for example tap/Formula/e) with a symlink to an outside directory, mktemp creates the staged file outside the tap and mv writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.
  • Direct JSON arrays of arbitrary strings are treated as VM names without validation, allowing status/error payloads such as ["error", "quota exceeded"] (or a serialized arbitrary string list) to become inventory entries and suppress VM creation. — The array branch now filters bare strings through is_vm_name, but that predicate accepts arbitrary DNS-label-like strings. Thus ["error", "quota exceeded"] still adds error to inventory (while only the second string is rejected), and any serialized list such as ["error"] or ["quota"] is still treated as a VM listing. The original consequence—an error/status payload creating a false inventory entry and potentially suppressing VM creation—can therefore still occur.
  • The publish job grants contents: write to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable github.token; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures. — The tag-verification shell step was moved to the separate read-only verify job, but publish still declares permissions: contents: write and runs actions/download-artifact before the release action in that same job. GitHub Actions job permissions apply to all steps, so the download action still executes with a write-capable github.token; the reported least-privilege consequence therefore remains possible.
  • k8s_cli/src/manager/process.rs: A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. capture_remote_ssh_output retries solely from output.status.code() == Some(255) and ignores whether stdout already contains the wrapper's __EXEDEV_K8S_EXIT__: marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent. — The retry is now suppressed when any stdout was received, which covers a received __EXEDEV_K8S_EXIT__: marker, but it still retries status 255 whenever stdout is empty. A remote bootstrap can execute state-changing commands without producing stdout (and the connection can fail before the wrapper’s final marker is received), leaving !output.stdout.is_empty() true and causing the full script to be resent. Thus the reported duplicate-execution consequence remains possible.
📋 Additional findings from this change (not shown inline) (2)
  • 🟠 High User-supplied worker/spare labels can forge the tool's role metadata because only pool/project/task keys are overwritten; exedev.dev/role is accepted unchanged. For example, a task with labels: {exedev.dev/role: control-plane} produces a worker NodeSpec carrying that value, and apply_node_metadata applies it with kubectl label --overwrite, so a worker is represented as a control-plane node and status/any downstream selector using that owned key can cross the role boundary. The fix is to reserve/reject all exedev.dev/* labels (or at least role) rather than treating only the currently generated keys as protected. This is introduced by the new user-label expansion; it would be disproven if validation or reconciliation filtered/rejected reserved role labels, but neither does (FleetFile::validate has no label-key checks and apply_node_metadata applies every NodeSpec label). (k8s_cli/src/fleet.rs) — anchor-outside-diff
  • 🟡 Medium Fleet validation accepts syntactically invalid Kubernetes label keys/values and only discovers the error after VM creation. A valid YAML such as labels: {"bad key": value} is copied directly into NodeSpec, then apply_node_metadata invokes kubectl with bad key=value; run_bootstrap creates missing VMs and performs all remote bootstrap before this step, so Kubernetes rejects the label and the command fails while leaving provisioned/bootstrapped resources. This would be disproven if another validation layer validated label syntax before create_missing_vms, but FleetFile::validate has no label validation and the manager passes labels directly to kubectl. (k8s_cli/src/fleet.rs) — anchor-unreliable
♻️ Previously reported (still present) (1)
  • 🟡 Medium A rendered-table output is not merged when the outer JSON object already contains any VM name, so inventory loses wrapped table rows. (k8s_cli/src/manager/parsing.rs) — previously-reported
🤖 Prompt for AI agents — all findings (13)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Unresolved from the previous review — these block approval, fix them first (9)

Somewhere in the code under review, address this finding:
The state-directory symlink defense is check-then-use and can be bypassed by swapping a checked directory component after `ensure_real_directories` returns but before staging/rename. An attacker able to modify the working directory can replace `.exedev-k8s` or the cluster directory with a symlink in that window; subsequent `OpenOptions::open` follows it and writes the secret outside the state directory. The same pathname race affects reads because `symlink_metadata` checks the final entry while parent components are followed during `File::open`.

Somewhere in the code under review, address this finding:
Plain-text fallback accepts arbitrary lowercase words as VM inventory entries, so non-table prose or malformed API responses can make an existing VM appear present and suppress creation/bootstrap.

In k8s_cli/src/manager/process.rs, address this finding:
A failed SSH exchange can retry after the remote script has already started, because retry eligibility checks only whether stdout is non-empty. If the remote side executes a state-changing install that emits only stderr (or the connection drops before the wrapper's stdout marker is delivered), ssh can exit 255 with empty stdout and the same script is resent up to four times. A failed stdin write after partial delivery has the same outcome when ssh exits 255. This violates the no-potential-execution retry invariant and can repeat Tailscale/k3s installation or service changes.

In .github/workflows/release.yml, address this finding:
The verify/confirm sequence does not prevent a tag race during publication: after `verify` checks the tag (lines 164-176), an actor can move it before `softprops/action-gh-release` attaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built from `BUILT_SHA` while the tag points elsewhere; `confirm` (lines 216-237) only fails afterward and performs no deletion, retagging, or release correction. Thus a successful publish followed by a red workflow still leaves a mismatched public release. This is false only if the tag cannot be moved by any actor/token during the verify-to-publish window or if post-publication detection is explicitly considered sufficient rather than preventing the mismatch.

In k8s_cli/src/manager/mod.rs, address this finding:
New-cluster bootstrap can reuse an already-installed k3s server without verifying that it belongs to the requested cluster.

In scripts/release/sync-homebrew-tap.sh, address this finding:
The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After `FORMULA_PARENT_REAL` is checked, the script calls `mktemp` and later `mv` using the original path; if an attacker replaces a checked descendant (for example `tap/Formula/e`) with a symlink to an outside directory, `mktemp` creates the staged file outside the tap and `mv` writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.

Somewhere in the code under review, address this finding:
Direct JSON arrays of arbitrary strings are treated as VM names without validation, allowing status/error payloads such as `["error", "quota exceeded"]` (or a serialized arbitrary string list) to become inventory entries and suppress VM creation.

Somewhere in the code under review, address this finding:
The publish job grants `contents: write` to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable `github.token`; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures.

In k8s_cli/src/manager/process.rs, address this finding:
A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. `capture_remote_ssh_output` retries solely from `output.status.code() == Some(255)` and ignores whether stdout already contains the wrapper's `__EXEDEV_K8S_EXIT__:` marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent.

## Findings on this change (also posted as inline comments) (1)

In k8s_cli/src/manager/mod.rs around line 821, address this finding:
Metadata reconciliation fails to remove an old owned taint when its key is reused with a different effect. `stale_owned_taints` excludes every actual taint whose key equals the desired taint key, rather than excluding only the exact desired taint. Thus a node carrying `exedev.dev/pool=pool:PreferNoSchedule` and a plan desiring `exedev.dev/pool=pool:NoSchedule` gets the desired taint applied, but the old effect remains; status sees two owned taints and reports drift, and the node remains more restrictive than the plan. This would be false only if Kubernetes guaranteed no same-key/different-effect taints can coexist or `kubectl taint --overwrite` removed all effects, neither of which is assumed by the code's set-based reconciliation.

## Additional findings on this change (not posted inline) (2)

In k8s_cli/src/fleet.rs around line 235, address this finding:
User-supplied worker/spare labels can forge the tool's role metadata because only pool/project/task keys are overwritten; `exedev.dev/role` is accepted unchanged. For example, a task with `labels: {exedev.dev/role: control-plane}` produces a worker NodeSpec carrying that value, and `apply_node_metadata` applies it with `kubectl label --overwrite`, so a worker is represented as a control-plane node and status/any downstream selector using that owned key can cross the role boundary. The fix is to reserve/reject all `exedev.dev/*` labels (or at least `role`) rather than treating only the currently generated keys as protected. This is introduced by the new user-label expansion; it would be disproven if validation or reconciliation filtered/rejected reserved role labels, but neither does (`FleetFile::validate` has no label-key checks and `apply_node_metadata` applies every NodeSpec label).

In k8s_cli/src/fleet.rs, address this finding:
Fleet validation accepts syntactically invalid Kubernetes label keys/values and only discovers the error after VM creation. A valid YAML such as `labels: {"bad key": value}` is copied directly into `NodeSpec`, then `apply_node_metadata` invokes kubectl with `bad key=value`; `run_bootstrap` creates missing VMs and performs all remote bootstrap before this step, so Kubernetes rejects the label and the command fails while leaving provisioned/bootstrapped resources. This would be disproven if another validation layer validated label syntax before `create_missing_vms`, but `FleetFile::validate` has no label validation and the manager passes labels directly to kubectl.

## Previously reported and still present (1)

In k8s_cli/src/manager/parsing.rs around line 34, address this finding:
A rendered-table `output` is not merged when the outer JSON object already contains any VM name, so inventory loses wrapped table rows.
📜 Review details

Model

  • gpt-5.6-luna, deepseek-v4-flash

Coverage

  • 2 of 9 areas reviewed

Comment thread k8s_cli/src/manager/mod.rs Outdated
.iter()
.filter_map(|taint| taint_key(taint))
.filter(|key| key.starts_with(NODE_LABEL_PREFIX))
.filter(|key| Some(*key) != desired_key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ Could not diff against the parent revision, so I could not definitively confirm the stale-taint removal logic is new in this PR; it appears to be part of this change (the label-removal sibling has a test at tests.rs:675, the taint-removal sibling has none), but this is inferred rather than git-verified.
  • ⚠️ The triggering state — an owned (exedev.dev/) taint sharing its key with the desired taint but with a different effect — is not producible by the tool alone (it only ever writes ...:NoSchedule, see fleet.rs); it requires a same-key/different-effect taint created externally via the documented manual kubectl repair workflow.
  • ⚠️ Confirmed from Kubernetes docs that same-key/different-effect taints can coexist on a node and that kubectl taint ... --overwrite replaces only the exact key+effect taint, leaving other effects of the same key in place; this is why the leftover taint survives.
🤖 Prompt for AI agents
In k8s_cli/src/manager/mod.rs, address this finding:
Metadata reconciliation fails to remove an old owned taint when its key is reused with a different effect. `stale_owned_taints` excludes every actual taint whose key equals the desired taint key, rather than excluding only the exact desired taint. Thus a node carrying `exedev.dev/pool=pool:PreferNoSchedule` and a plan desiring `exedev.dev/pool=pool:NoSchedule` gets the desired taint applied, but the old effect remains; status sees two owned taints and reports drift, and the node remains more restrictive than the plan. This would be false only if Kubernetes guaranteed no same-key/different-effect taints can coexist or `kubectl taint --overwrite` removed all effects, neither of which is assumed by the code's set-based reconciliation.

`ExeDevClient::new` swallowed a builder failure and fell back to `Client::new()`,
which follows redirects — the exact behaviour the builder was configured to
prevent. It returns a Result now and both callers propagate it.

set-version.sh released its lock at the top of cleanup, so a second run could
start while the first was still restoring backups. The lock is now released last,
on every path.

The K3S_TOKEN branch compared a trimmed file against an untrimmed variable,
rewriting the file on every run when the value carried a newline, and passed the
untrimmed value to the server and the agents. It is normalized once.

A response that is not JSON is no longer read as a table. exe.dev answers /exec
with JSON, so a non-JSON body is an error page, and turning its words into VM
names made a planned VM look like it already existed. A rendered table inside the
`output` wrapper is still read, and now merges with outer records instead of
being dropped whenever the outer object named anything.

Taint reconciliation excluded stale taints by key, so a key kept with a different
effect ended up carrying both: `--overwrite` does not clear the other effect.
Stale taints are compared whole and removed before the desired one is applied.

Fleet files can no longer supply the four labels the tool generates, which let a
worker present itself as a control-plane node, and their label keys and values
are checked against the Kubernetes grammar during validation rather than by
kubectl after every VM has been created and bootstrapped. Only the generated keys
are reserved, not the whole prefix: the repo's own fixtures use other
`exedev.dev/*` keys for their own bookkeeping.

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (7)
  • 🟠 High A valid JSON error response whose first output line starts with a syntactically valid VM name is treated as inventory when wrapped in the JSON output field. For example {"output":"vm-1 is unavailable\n"} makes parse_vm_names return {"vm-1"} because the text parser accepts the first token of every line and only rejects prose with non-name first tokens; bootstrap then suppresses creation of the planned VM. This is disproved only if wrapped non-JSON output is guaranteed always to be a table with a header, or the API guarantees errors cannot begin with a VM-shaped token. (inline)
  • 🟠 High Conflicting records for one VM silently overwrite the SSH destination instead of being rejected or treated as a conflict. For example, an outer record can report {"vm_name":"worker-1","ssh_dest":"vm+worker-1@exe.dev"} while a later record in output/items reports the same vm_name with ssh_dest":"vm+other@exe.dev"; traversal order makes the latter destination authoritative, so bootstrap can run the worker's installation script against the wrong VM while still believing it is operating on worker-1. This is disproven only if the exe.dev API contract guarantees that duplicate names can never occur across the merged wrapper/record sources (including intermediary or stale records). (inline)
  • 🟠 High The AlreadyExists branch treats any pre-existing regular file as the concurrent winner, so an attacker-planted destination (or a replacement after the link failure) is adopted as the cluster token without proving it was created by the competing bootstrap. (inline)
  • 🟠 High The workflow only detects a tag move after publication; it does not prevent publishing archives to a tag that changed after verification. verify reads and compares the tag, then publish separately invokes the release action by mutable tag_name; a concurrent force-move between those jobs causes the action to attach the already-built archives to the new commit, and confirm can only fail after the mismatched release is live. (inline)
  • 🟠 High A tag can move after verify passes but before (or during) softprops/action-gh-release, and publication is still performed against the mutable tag name rather than an immutable commit. In that race the release assets can be attached to a release whose tag points at a different commit; confirm only makes the workflow red after the bad release already exists and does not delete/repoint the release or assets. (inline)
  • 🟠 High The release lock is not recoverable after an untrappable termination: a SIGKILL (runner cancellation/host loss) after staging or applying leaves .set-version.lock, .tmp, and possibly .bak files, and the next invocation first refuses the stale lock and, after manually removing it, refuses the stale siblings. If the kill occurred after some moves, the workspace can remain split between versions with no supported automatic restoration path. (inline)
  • 🟠 High The HTTPS client accepts any string beginning with https:// as a valid API endpoint and sends the bearer token and command to it, so a typo or attacker-controlled --endpoint such as https://collector.example/exec exfiltrates EXE_DEV_API_KEY (and commands). The documented API contract is specifically https://exe.dev/exec; scheme-only validation does not establish that destination. This is introduced/exposed by the configurable endpoint plus the new validation, and would be disproven only if callers are guaranteed out-of-band to constrain the endpoint host before this client is reached. (inline)
⛔ Unresolved from previous review (8) — not approved until fixed
  • The state-directory symlink defense is check-then-use and can be bypassed by swapping a checked directory component after ensure_real_directories returns but before staging/rename. An attacker able to modify the working directory can replace .exedev-k8s or the cluster directory with a symlink in that window; subsequent OpenOptions::open follows it and writes the secret outside the state directory. The same pathname race affects reads because symlink_metadata checks the final entry while parent components are followed during File::open. — The defense remains check-then-use: prepare_secret_parent calls ensure_real_directories(parent) and returns, after which stage_secret opens the pathname-derived staging file with OpenOptions::open; a concurrent replacement of .exedev-k8s or the cluster directory can therefore redirect that open and subsequent rename outside the state directory. Reads likewise still perform symlink_metadata before File::open while parent components are pathname-resolved.
  • Plain-text fallback accepts arbitrary lowercase words as VM inventory entries, so non-table prose or malformed API responses can make an existing VM appear present and suppress creation/bootstrap. — The top-level non-JSON response path now errors instead of parsing prose, but the defect remains for the rendered-text fallback inside a valid JSON output wrapper: parse_vm_names still calls parse_vm_names_from_text(output), and is_vm_name still accepts any lowercase/digit/hyphen DNS-like first word without requiring table structure. For example, {"output":"planned-vm is unavailable\n"} still inventories planned-vm and can suppress its creation.
  • k8s_cli/src/manager/process.rs: A failed SSH exchange can retry after the remote script has already started, because retry eligibility checks only whether stdout is non-empty. If the remote side executes a state-changing install that emits only stderr (or the connection drops before the wrapper's stdout marker is delivered), ssh can exit 255 with empty stdout and the same script is resent up to four times. A failed stdin write after partial delivery has the same outcome when ssh exits 255. This violates the no-potential-execution retry invariant and can repeat Tailscale/k3s installation or service changes. — The retry gate still derives execution solely from stdout: let remote_ran = !output.stdout.is_empty(); and retries any status-255 attempt when stdout is empty. Thus a remote script that changes state but emits only stderr, or whose stdout marker is lost, can still be resent; a partial stdin write followed by status 255 is likewise still eligible for retry. The current write_result handling only suppresses an error when ssh exits successfully and does not make a failed/partial write non-retryable on status 255.
  • .github/workflows/release.yml: The verify/confirm sequence does not prevent a tag race during publication: after verify checks the tag (lines 164-176), an actor can move it before softprops/action-gh-release attaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built from BUILT_SHA while the tag points elsewhere; confirm (lines 216-237) only fails afterward and performs no deletion, retagging, or release correction. Thus a successful publish followed by a red workflow still leaves a mismatched public release. This is false only if the tag cannot be moved by any actor/token during the verify-to-publish window or if post-publication detection is explicitly considered sufficient rather than preventing the mismatch. — The workflow still performs a read-only tag check in verify, then later invokes softprops/action-gh-release by tag_name with a write-capable token; an actor can move the tag after the check and before publication, causing the release assets to be built from BUILT_SHA while the tag names another commit. The later confirm check only detects this after publication and exits without deleting or correcting the release.
  • k8s_cli/src/manager/mod.rs: New-cluster bootstrap can reuse an already-installed k3s server without verifying that it belongs to the requested cluster. — The defect can still occur. create_missing_vms still skips any planned VM solely when inventory.names.contains(&amp;node.name), without inspecting its k3s cluster identity. The new-cluster server path only calls require_no_k3s_agent_state_for_server; when an existing k3s server is present, the supervisor branch skips installation (if ! command -v k3s ...) and starts the existing k3s service, so an already-installed server can still be reused without verification that it belongs to the requested cluster.
  • scripts/release/sync-homebrew-tap.sh: The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After FORMULA_PARENT_REAL is checked, the script calls mktemp and later mv using the original path; if an attacker replaces a checked descendant (for example tap/Formula/e) with a symlink to an outside directory, mktemp creates the staged file outside the tap and mv writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.
  • The publish job grants contents: write to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable github.token; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures. — The tag-verification shell step was moved to the read-only verify job, but publish still declares contents: write at job scope and the artifact download remains a step in that job. GitHub Actions applies the job token permissions to every step in the job, so a compromised download action still receives a write-capable github.token; the specific least-privilege defect therefore remains.
  • k8s_cli/src/manager/process.rs: A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. capture_remote_ssh_output retries solely from output.status.code() == Some(255) and ignores whether stdout already contains the wrapper's __EXEDEV_K8S_EXIT__: marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent. — The retry is now suppressed when any stdout was received, which covers the reported case where the wrapper's exit marker arrives. However, the current decision is still let remote_ran = !output.stdout.is_empty(); followed by retrying status 255 when !remote_ran. A remote bootstrap can execute state-changing commands without producing stdout, or its output can be lost before any bytes reach the client, leaving status 255 and empty stdout; the code then resends the non-idempotent script without establishing that execution did not occur.
⚠️ Unverified risks (1)
  • The repository's recommended fleet.example.yaml is rejected by the documented CLI schema, so the advertised starting point cannot be used. Its sparePools.ingress.labels sets exedev.dev/role: ingress, but FleetFile::validate reserves exedev.dev/role (and other generated keys) and bails before planning or bootstrap. (fleet.example.yaml)
📋 Additional findings from this change (not shown inline) (25)
  • 🟠 High SSH fallback does not preserve argument boundaries for command values containing whitespace or shell metacharacters. run_ssh_fallback passes each logical word with command.args(words), but OpenSSH concatenates those arguments into a remote command that the remote shell reparses; e.g. exedev-ctl comment vm 'staging copy' is sent as comment vm staging copy and changes the comment arguments (and values containing ; can become shell syntax). The displayed shell_join quoting is not used for the actual SSH invocation. This is introduced by the new transport path; it would be disproven if the target SSH server were known to receive argv boundaries rather than a reparsed command string, which OpenSSH remote command execution does not provide. (cli/src/ssh.rs) — anchor-outside-diff
  • 🟠 High Fleet validation does not validate the generated label/taint values derived from project and task map keys, so a syntactically parseable fleet can pass planning and create VMs before Kubernetes metadata application fails. For example, a task key a/b yields exedev.dev/task=a/b and a pool project-a/b yields a malformed taint exedev.dev/pool=project-a/b:NoSchedule; Kubernetes label values cannot contain /, and the taint value is not safely representable in the kubectl argument. The code validates only user-supplied labels, then inserts project/task/pool names directly into NodeSpec. This is introduced/exposed by the schema expansion accepting arbitrary YAML map keys without corresponding generated-value validation. This would be disproven if an upstream schema/parser constrained project and task names to the Kubernetes-safe grammar, but serde map keys are currently unrestricted. (k8s_cli/src/fleet.rs) — anchor-outside-diff
  • 🟡 Medium The HTTP client buffers the entire response body without a size limit (response.text().await), even though the documented /exec contract limits request bodies but does not make response size bounded. With a user-controlled --endpoint pointing at an HTTPS server that streams a very large 2xx body, the CLI can consume unbounded memory before printing or failing. This is a concrete denial-of-service risk for the newly exposed custom HTTPS endpoint; it would be false only if an external invariant guarantees a strict small response limit for every endpoint accepted by the CLI, which endpoint validation does not establish. (core/src/client.rs) — anchor-outside-diff
  • 🟡 Medium Workers and spare nodes never receive the generated exedev.dev/role label, even though exedev.dev/role is declared a generated identity key and user attempts to set it are rejected. A consumer selecting exedev.dev/role=worker or =spare therefore cannot distinguish the generated roles, and the metadata/status contract is incomplete for those roles. (k8s_cli/src/fleet.rs) — anchor-outside-diff
  • 🟡 Medium The ownership reconciler deletes user-supplied labels under exedev.dev/ when they are removed from the fleet file, despite schema validation explicitly allowing non-generated keys under that prefix. For example, a node initially planned with exedev.dev/test-case=shared is later planned without that label; stale_owned_labels sees the key's prefix and emits exedev.dev/test-case-, deleting metadata that the contract says to preserve. (k8s_cli/src/manager/mod.rs) — anchor-unreliable
  • 🟡 Medium The documented replicas override is accepted and presented as the default Kubernetes Pod replica count, but fleet expansion drops it entirely: TaskPool.replicas is explicitly dead code and NodeSpec/FleetPlan carry no replica information. Thus changing projects.project1.tasks.a.replicas from 1 to 100 produces an identical plan and cannot affect any deployment; the example and fixtures describe this field as an allocation/workload input. This is a violated stated schema obligation, unless replicas is intentionally only future metadata and the documentation/comments are changed to say it has no effect. (k8s_cli/src/fleet.rs) — anchor-outside-diff
  • 🟡 Medium parse_vm_names can treat non-VM JSON objects as inventory because it unconditionally accepts a generic name/vm field without validating that the object is a VM record. For example, a successful wrapper such as {"name":"planned","output":"[{\"vm_name\":\"other\"}]"} (or any data metadata object with that shape) makes planned appear present; fetch_inventory then passes that set to create_missing_vms, which skips provisioning the planned VM even though the actual listing does not contain it. The same record can also seed an SSH destination under the wrong node name, because parse_ssh_destinations uses the same generic key set. This is an inventory/provisioning correctness bug: generic compatibility keys should only be used on recognized VM-list records, or the parser should require VM-specific fields/structure before indexing them. It would be disproven if the /exec ls contract guarantees that every object reachable at the parser's root or vms/items/data paths is a VM record and never includes wrapper/metadata objects with generic name fields. (k8s_cli/src/manager/parsing.rs) — inline-budget
  • 🟡 Medium Sanitized cluster names can still collide with an ordinary cluster name, causing credential overwrite/adoption across distinct clusters. (k8s_cli/src/manager/state.rs) — inline-budget
  • 🟡 Medium A SIGKILL, runner loss, or other untrappable termination after lock creation leaves .set-version.lock and possibly .tmp/.bak files behind. Every later invocation refuses the stale lock and cannot recover or unlock the workspace automatically, so a release checkout can remain permanently unusable without manual deletion and may retain a partially applied version change. (scripts/release/set-version.sh) — inline-budget
  • 🟡 Medium The Homebrew script creates the explicit formula parent before checking that its resolved path is inside the tap. With TAP_FORMULA_PATH=/outside/new/formula.rb, mkdir -p "$FORMULA_PARENT" creates directories outside TAP_REPO_PATH and only then rejects the path, allowing an attacker-controlled or mistaken environment value to mutate arbitrary filesystem locations. (scripts/release/sync-homebrew-tap.sh) — inline-budget
  • 🟡 Medium An explicitly supplied TAP_FORMULA_PATH is not checked to correspond to FORMULA_NAME. Setting TAP_FORMULA_PATH=$TAP_REPO_PATH/Formula/other.rb passes the .rb, .., and symlink checks and causes the script to overwrite other.rb with an exedev-cli formula, even though the script's contract says it is generating the selected formula. This permits an incorrect or malicious environment configuration to mutate an unrelated tap formula. (scripts/release/sync-homebrew-tap.sh) — inline-budget
  • 🟡 Medium The Fleet File section presents cluster.controlPlane.nodes: 1 as a valid Version 1 configuration, but the shown configuration omits required cluster.name and cluster.controlPlane.vmPrefix. Deserialization fails before validation because both fields are non-optional. (k8s_cli/README.md) — inline-budget
  • 🟡 Medium Manual dispatch can validate and build a different source revision than the requested tag, or fail before reaching the tag at all. (.github/workflows/release.yml) — inline-budget
  • 🟡 Medium The version lock has no recoverable owner or stale-lock protocol: an untrappable termination leaves the directory lock and the script refuses every subsequent invocation solely because it exists. This is a concrete workspace availability failure after SIGKILL/runner loss, independent of ordinary EXIT/INT/TERM cleanup. (scripts/release/set-version.sh) — inline-budget
  • 🟡 Medium Generated metadata is not validated even though it is sent directly to kubectl, so valid YAML can make bootstrap fail after VM creation. Project/task names are unconstrained and are inserted into label values and the pool name is used in the taint value; for example a project key longer than 63 characters or containing _// produces exedev.dev/project=&lt;invalid&gt; (and exedev.dev/pool=...), causing kubectl label/taint to reject the command. The failure occurs in apply_node_metadata, after the VMs and k3s have already been provisioned, leaving a partially bootstrapped cluster. (k8s_cli/src/fleet.rs) — anchor-unreliable
  • 🟡 Medium Generic/error JSON objects can be mistaken for VM inventory because any object-level name/vm string is accepted without requiring a listing-record shape or a successful status. For example, {"error":"quota exceeded","name":"vm-1"} (or an envelope metadata object with name":"vm-1") inserts vm-1, so create_missing_vms skips creation even though the response is not a VM listing; if it also carries destination fields, the same false record can seed an SSH target. This is disproved only if the API contract guarantees error/envelope objects can never contain these keys or if the caller rejects such responses before parsing. (k8s_cli/src/manager/parsing.rs) — inline-budget
  • 🟡 Medium Duplicate Kubernetes node records are silently last-write-wins. A kubectl get nodes -o json response containing two items with the same metadata.name—for example, the first says Ready=True and has the expected labels/taints while the second has Ready=False or conflicting metadata—gets reduced to whichever item appears last. Callers then make readiness and reconciliation decisions from an arbitrary conflicting record instead of failing closed, potentially applying/removing metadata based on forged or stale node state. This is disproven only if the kubectl/API response is guaranteed to reject or never emit duplicate node names before this parser receives it. (k8s_cli/src/manager/parsing.rs) — inline-budget
  • 🟡 Medium The CLI README's control-plane fleet snippet is presented as a supported Version 1 configuration, but it omits required fields cluster.name and cluster.controlPlane.vmPrefix. FleetFile deserialization requires both fields and validation explicitly rejects an empty control-plane prefix, so copying the documented snippet produces a parse error rather than a plan. (k8s_cli/README.md) — inline-budget
  • 🟡 Medium The verify-then-publish protocol does not prevent a tag move from producing a published release whose assets do not match the tag. verify reads the tag and compares it with BUILT_SHA, but publish later invokes the release API using only the mutable tag_name; a maintainer or attacker can move the tag in that interval, after which the assets are attached to the moved tag. confirm only notices the mismatch after publication and does not remove or quarantine the already-published release, so the documented invariant that publication cannot silently attach old binaries to a moved tag is not achieved. This would be disproven only if the GitHub release action/API atomically binds the uploaded assets to the exact previously verified commit (rather than resolving tag_name at publication time). (.github/workflows/release.yml) — inline-budget
  • 🟡 Medium The documented destroy workflow cannot reliably delete all VMs that the fleet owns when the VM is absent from exe.dev ls, despite the README presenting destroy --fleet ... as deleting fleet-managed VMs. The implementation only selects names present in the inventory by default; the recovery/complete behavior is behind the undocumented --all-planned flag. (k8s_cli/README.md) — inline-budget
  • 🟡 Medium A user/external label under exedev.dev/ is accepted by fleet validation but is later treated as tool-owned and deleted when it is absent from the current plan. For example, bootstrap with exedev.dev/test-case=shared, then remove that label from the fleet and bootstrap again: stale_owned_labels emits exedev.dev/test-case-, and status reports drift before deletion. This violates external metadata preservation and is inconsistent with the validation comment/test that explicitly permits exedev.dev/test-case. (k8s_cli/src/manager/mod.rs) — anchor-unreliable
  • 🟡 Medium The release workflow's resolve job checks out the workflow ref before running scripts/release/check-version.sh, even for workflow_dispatch where it resolves an arbitrary requested tag via the API. Dispatching a release for a tag whose target commit predates the script (or whose workflow ref lacks it) fails before the requested tag is built, contrary to the documented/manual tag-release contract. (.github/workflows/release.yml) — anchor-unreliable
  • 🟡 Medium Both READMEs instruct users to start from ../fleet.example.yaml, but that checked-in example is rejected by the current fleet schema: sparePools.ingress.labels sets exedev.dev/role: ingress, while FleetFile::validate() reserves every key in GENERATED_LABEL_KEYS, including exedev.dev/role, for tool-generated metadata. Running any command that loads the recommended example fails with the reserved-label validation error instead of provisioning the documented fleet. This would be false only if the validator allowed user-supplied exedev.dev/role, which it explicitly does not. (k8s_cli/README.md) — anchor-unreliable
  • 🔵 Low The release documentation describes release compatibility but provides no operational step for updating the Homebrew tap, and the release workflow only uploads GitHub archives. scripts/release/sync-homebrew-tap.sh is never invoked by the workflow, so completing the documented CI release leaves Homebrew users on the old formula unless an undocumented manual action is performed. (README.md) — inline-budget
  • 🔵 Low Both CLI READMEs inaccurately describe --json as printing raw JSON. print_response parses JSON and emits serde_json::to_string_pretty, while human mode unwraps API output/error; thus the flag changes formatting/interpretation and does not preserve the raw HTTPS response bytes. Automation or fixtures that rely on the documented 'raw JSON' behavior can receive reformatted JSON and different wrapper handling. (cli/README.md) — inline-budget
♻️ Previously reported (still present) (7)
  • 🟠 High A transport retry can replay a mutating remote script after it has already begun executing. capture_remote_ssh_output retries status 255 whenever output.stdout is empty, but the remote wrapper emits its exit marker only after the script finishes; if SSH loses the connection before that marker and the script produced no stdout (for example, an install command that is quiet until completion), the client sees empty stdout and resends the script even though the first attempt may already have changed the VM. This violates the no-replay invariant and can rerun package/service/bootstrap mutations. This would be disproven only if the SSH transport guaranteed status 255 with empty stdout cannot occur after remote command execution, which SSH does not guarantee. (k8s_cli/src/manager/process.rs) — previously-reported
  • 🟠 High The target-parent confinement and symlink checks are TOCTOU-vulnerable: an attacker able to modify the tap checkout can replace a checked path component with a symlink after validation, causing the staged formula to be created or renamed outside the tap. (scripts/release/sync-homebrew-tap.sh) — previously-reported
  • 🟡 Medium --json is appended after the entire fallback command, including the typed ssh command's VM command. Thus exedev-ctl --json ssh target cmd constructs ssh exe.dev ssh target cmd --json; --json is no longer a CLI/API output option and is instead passed to the VM command, potentially altering or failing the requested remote command. This violates consistent JSON behavior for a command explicitly documented as SSH-only. The claim would be false only if exe.dev's ssh command universally strips/ignores trailing --json while still applying JSON output, which conflicts with the command construction and documented SSH semantics. (cli/src/ssh.rs) — previously-reported
  • 🟡 Medium The resolve job executes scripts/release/check-version.sh from the checkout's default event ref, not from the commit being released: its checkout has no ref, while only build jobs checkout needs.resolve.outputs.sha. Thus a default-branch change can make resolve accept/reject or normalize a tag differently from the release commit's set-version.sh, and arbitrary release-branch scripts are not the code validated before the build. This violates same-commit release-path integrity and allows unreviewed default-branch script code to run in resolve. (.github/workflows/release.yml) — anchor-unreliable
  • 🟡 Medium An explicitly empty version argument is silently replaced by RELEASE_TAG instead of being rejected. For example, with RELEASE_TAG=v1.2.3, set-version.sh '' assigns VERSION from the environment and updates the workspace to 1.2.3, whereas the script's documented argument is the requested version and check-version deliberately distinguishes an empty argument. A wrapper or CI input that expands to empty can therefore release the wrong version. (scripts/release/set-version.sh) — previously-reported
  • 🟡 Medium Rendered tables using the repository's UTF-8 bordered format are not parsed as inventory. A table such as ┌──────┬─────────┐\n│ NAME │ STATUS │\n├──────┼─────────┤\n│ vm-1 │ running │\n└──────┴─────────┘ yields no VM names because the text parser takes the first whitespace-delimited token () rather than stripping borders and selecting the NAME column. If /exec returns this supported rendered-table shape in output, bootstrap sees an empty inventory and attempts duplicate creation. This is disproved only if the service contract guarantees rendered listings are always unbordered whitespace tables and never uses the documented/CLI UTF-8 rendering. (k8s_cli/src/manager/parsing.rs) — anchor-unreliable
  • 🔵 Low A failed formula write leaves the temporary staged formula behind in the tap directory, and repeated failures accumulate files that are not removed by the trap. (scripts/release/sync-homebrew-tap.sh) — previously-reported
❓ Low-evidence leads (not confirmed — verify before acting) (1)
  • The README documents exedev-k8s destroy --fleet fleet.yaml as the deletion procedure but omits the recovery condition and required flag for planned VMs absent from the inventory. By default run_destroy filters to names currently returned by ls, so a VM that exists but is temporarily missing from inventory is not deleted; the implementation's recovery message directs operators to destroy --all-planned. The documented command can report no managed VMs and leave the VM behind. (k8s_cli/README.md)
🤖 Prompt for AI agents — all findings (47)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Unresolved from the previous review — these block approval, fix them first (8)

Somewhere in the code under review, address this finding:
The state-directory symlink defense is check-then-use and can be bypassed by swapping a checked directory component after `ensure_real_directories` returns but before staging/rename. An attacker able to modify the working directory can replace `.exedev-k8s` or the cluster directory with a symlink in that window; subsequent `OpenOptions::open` follows it and writes the secret outside the state directory. The same pathname race affects reads because `symlink_metadata` checks the final entry while parent components are followed during `File::open`.

Somewhere in the code under review, address this finding:
Plain-text fallback accepts arbitrary lowercase words as VM inventory entries, so non-table prose or malformed API responses can make an existing VM appear present and suppress creation/bootstrap.

In k8s_cli/src/manager/process.rs, address this finding:
A failed SSH exchange can retry after the remote script has already started, because retry eligibility checks only whether stdout is non-empty. If the remote side executes a state-changing install that emits only stderr (or the connection drops before the wrapper's stdout marker is delivered), ssh can exit 255 with empty stdout and the same script is resent up to four times. A failed stdin write after partial delivery has the same outcome when ssh exits 255. This violates the no-potential-execution retry invariant and can repeat Tailscale/k3s installation or service changes.

In .github/workflows/release.yml, address this finding:
The verify/confirm sequence does not prevent a tag race during publication: after `verify` checks the tag (lines 164-176), an actor can move it before `softprops/action-gh-release` attaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built from `BUILT_SHA` while the tag points elsewhere; `confirm` (lines 216-237) only fails afterward and performs no deletion, retagging, or release correction. Thus a successful publish followed by a red workflow still leaves a mismatched public release. This is false only if the tag cannot be moved by any actor/token during the verify-to-publish window or if post-publication detection is explicitly considered sufficient rather than preventing the mismatch.

In k8s_cli/src/manager/mod.rs, address this finding:
New-cluster bootstrap can reuse an already-installed k3s server without verifying that it belongs to the requested cluster.

In scripts/release/sync-homebrew-tap.sh, address this finding:
The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After `FORMULA_PARENT_REAL` is checked, the script calls `mktemp` and later `mv` using the original path; if an attacker replaces a checked descendant (for example `tap/Formula/e`) with a symlink to an outside directory, `mktemp` creates the staged file outside the tap and `mv` writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.

Somewhere in the code under review, address this finding:
The publish job grants `contents: write` to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable `github.token`; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures.

In k8s_cli/src/manager/process.rs, address this finding:
A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. `capture_remote_ssh_output` retries solely from `output.status.code() == Some(255)` and ignores whether stdout already contains the wrapper's `__EXEDEV_K8S_EXIT__:` marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent.

## Findings on this change (also posted as inline comments) (7)

In k8s_cli/src/manager/parsing.rs around line 37, address this finding:
A valid JSON error response whose first output line starts with a syntactically valid VM name is treated as inventory when wrapped in the JSON `output` field. For example `{"output":"vm-1 is unavailable\n"}` makes `parse_vm_names` return `{"vm-1"}` because the text parser accepts the first token of every line and only rejects prose with non-name first tokens; bootstrap then suppresses creation of the planned VM. This is disproved only if wrapped non-JSON output is guaranteed always to be a table with a header, or the API guarantees errors cannot begin with a VM-shaped token.

In k8s_cli/src/manager/parsing.rs around line 124, address this finding:
Conflicting records for one VM silently overwrite the SSH destination instead of being rejected or treated as a conflict. For example, an outer record can report `{"vm_name":"worker-1","ssh_dest":"vm+worker-1@exe.dev"}` while a later record in `output`/`items` reports the same `vm_name` with `ssh_dest":"vm+other@exe.dev"`; traversal order makes the latter destination authoritative, so bootstrap can run the worker's installation script against the wrong VM while still believing it is operating on `worker-1`. This is disproven only if the exe.dev API contract guarantees that duplicate names can never occur across the merged wrapper/record sources (including intermediary or stale records).

In k8s_cli/src/manager/state.rs around line 128, address this finding:
The AlreadyExists branch treats any pre-existing regular file as the concurrent winner, so an attacker-planted destination (or a replacement after the link failure) is adopted as the cluster token without proving it was created by the competing bootstrap.

In .github/workflows/release.yml around line 243, address this finding:
The workflow only detects a tag move after publication; it does not prevent publishing archives to a tag that changed after verification. `verify` reads and compares the tag, then `publish` separately invokes the release action by mutable `tag_name`; a concurrent force-move between those jobs causes the action to attach the already-built archives to the new commit, and `confirm` can only fail after the mismatched release is live.

In .github/workflows/release.yml around line 241, address this finding:
A tag can move after `verify` passes but before (or during) `softprops/action-gh-release`, and publication is still performed against the mutable tag name rather than an immutable commit. In that race the release assets can be attached to a release whose tag points at a different commit; `confirm` only makes the workflow red after the bad release already exists and does not delete/repoint the release or assets.

In scripts/release/set-version.sh around line 66, address this finding:
The release lock is not recoverable after an untrappable termination: a SIGKILL (runner cancellation/host loss) after staging or applying leaves `.set-version.lock`, `.tmp`, and possibly `.bak` files, and the next invocation first refuses the stale lock and, after manually removing it, refuses the stale siblings. If the kill occurred after some moves, the workspace can remain split between versions with no supported automatic restoration path.

In core/src/client.rs around line 53, address this finding:
The HTTPS client accepts any string beginning with `https://` as a valid API endpoint and sends the bearer token and command to it, so a typo or attacker-controlled `--endpoint` such as `https://collector.example/exec` exfiltrates `EXE_DEV_API_KEY` (and commands). The documented API contract is specifically `https://exe.dev/exec`; scheme-only validation does not establish that destination. This is introduced/exposed by the configurable endpoint plus the new validation, and would be disproven only if callers are guaranteed out-of-band to constrain the endpoint host before this client is reached.

## Additional findings on this change (not posted inline) (25)

In cli/src/ssh.rs around line 16, address this finding:
SSH fallback does not preserve argument boundaries for command values containing whitespace or shell metacharacters. `run_ssh_fallback` passes each logical word with `command.args(words)`, but OpenSSH concatenates those arguments into a remote command that the remote shell reparses; e.g. `exedev-ctl comment vm 'staging copy'` is sent as `comment vm staging copy` and changes the comment arguments (and values containing `;` can become shell syntax). The displayed `shell_join` quoting is not used for the actual SSH invocation. This is introduced by the new transport path; it would be disproven if the target SSH server were known to receive argv boundaries rather than a reparsed command string, which OpenSSH remote command execution does not provide.

In k8s_cli/src/fleet.rs around line 323, address this finding:
Fleet validation does not validate the generated label/taint values derived from project and task map keys, so a syntactically parseable fleet can pass planning and create VMs before Kubernetes metadata application fails. For example, a task key `a/b` yields `exedev.dev/task=a/b` and a pool `project-a/b` yields a malformed taint `exedev.dev/pool=project-a/b:NoSchedule`; Kubernetes label values cannot contain `/`, and the taint value is not safely representable in the kubectl argument. The code validates only user-supplied labels, then inserts project/task/pool names directly into NodeSpec. This is introduced/exposed by the schema expansion accepting arbitrary YAML map keys without corresponding generated-value validation. This would be disproven if an upstream schema/parser constrained project and task names to the Kubernetes-safe grammar, but serde map keys are currently unrestricted.

In core/src/client.rs around line 68, address this finding:
The HTTP client buffers the entire response body without a size limit (`response.text().await`), even though the documented `/exec` contract limits request bodies but does not make response size bounded. With a user-controlled `--endpoint` pointing at an HTTPS server that streams a very large 2xx body, the CLI can consume unbounded memory before printing or failing. This is a concrete denial-of-service risk for the newly exposed custom HTTPS endpoint; it would be false only if an external invariant guarantees a strict small response limit for every endpoint accepted by the CLI, which endpoint validation does not establish.

In k8s_cli/src/fleet.rs around line 322, address this finding:
Workers and spare nodes never receive the generated `exedev.dev/role` label, even though `exedev.dev/role` is declared a generated identity key and user attempts to set it are rejected. A consumer selecting `exedev.dev/role=worker` or `=spare` therefore cannot distinguish the generated roles, and the metadata/status contract is incomplete for those roles.

In k8s_cli/src/manager/mod.rs, address this finding:
The ownership reconciler deletes user-supplied labels under `exedev.dev/` when they are removed from the fleet file, despite schema validation explicitly allowing non-generated keys under that prefix. For example, a node initially planned with `exedev.dev/test-case=shared` is later planned without that label; `stale_owned_labels` sees the key's prefix and emits `exedev.dev/test-case-`, deleting metadata that the contract says to preserve.

In k8s_cli/src/fleet.rs around line 69, address this finding:
The documented `replicas` override is accepted and presented as the default Kubernetes Pod replica count, but fleet expansion drops it entirely: `TaskPool.replicas` is explicitly dead code and `NodeSpec`/`FleetPlan` carry no replica information. Thus changing `projects.project1.tasks.a.replicas` from 1 to 100 produces an identical plan and cannot affect any deployment; the example and fixtures describe this field as an allocation/workload input. This is a violated stated schema obligation, unless replicas is intentionally only future metadata and the documentation/comments are changed to say it has no effect.

In k8s_cli/src/manager/parsing.rs around line 17, address this finding:
`parse_vm_names` can treat non-VM JSON objects as inventory because it unconditionally accepts a generic `name`/`vm` field without validating that the object is a VM record. For example, a successful wrapper such as `{"name":"planned","output":"[{\"vm_name\":\"other\"}]"}` (or any `data` metadata object with that shape) makes `planned` appear present; `fetch_inventory` then passes that set to `create_missing_vms`, which skips provisioning the planned VM even though the actual listing does not contain it. The same record can also seed an SSH destination under the wrong node name, because `parse_ssh_destinations` uses the same generic key set. This is an inventory/provisioning correctness bug: generic compatibility keys should only be used on recognized VM-list records, or the parser should require VM-specific fields/structure before indexing them. It would be disproven if the `/exec ls` contract guarantees that every object reachable at the parser's root or `vms`/`items`/`data` paths is a VM record and never includes wrapper/metadata objects with generic name fields.

In k8s_cli/src/manager/state.rs around line 51, address this finding:
Sanitized cluster names can still collide with an ordinary cluster name, causing credential overwrite/adoption across distinct clusters.

In scripts/release/set-version.sh around line 67, address this finding:
A SIGKILL, runner loss, or other untrappable termination after lock creation leaves `.set-version.lock` and possibly `.tmp`/`.bak` files behind. Every later invocation refuses the stale lock and cannot recover or unlock the workspace automatically, so a release checkout can remain permanently unusable without manual deletion and may retain a partially applied version change.

In scripts/release/sync-homebrew-tap.sh around line 156, address this finding:
The Homebrew script creates the explicit formula parent before checking that its resolved path is inside the tap. With `TAP_FORMULA_PATH=/outside/new/formula.rb`, `mkdir -p "$FORMULA_PARENT"` creates directories outside `TAP_REPO_PATH` and only then rejects the path, allowing an attacker-controlled or mistaken environment value to mutate arbitrary filesystem locations.

In scripts/release/sync-homebrew-tap.sh around line 132, address this finding:
An explicitly supplied TAP_FORMULA_PATH is not checked to correspond to FORMULA_NAME. Setting `TAP_FORMULA_PATH=$TAP_REPO_PATH/Formula/other.rb` passes the `.rb`, `..`, and symlink checks and causes the script to overwrite `other.rb` with an `exedev-cli` formula, even though the script's contract says it is generating the selected formula. This permits an incorrect or malicious environment configuration to mutate an unrelated tap formula.

In k8s_cli/README.md around line 73, address this finding:
The Fleet File section presents `cluster.controlPlane.nodes: 1` as a valid Version 1 configuration, but the shown configuration omits required `cluster.name` and `cluster.controlPlane.vmPrefix`. Deserialization fails before validation because both fields are non-optional.

In .github/workflows/release.yml around line 29, address this finding:
Manual dispatch can validate and build a different source revision than the requested tag, or fail before reaching the tag at all.

In scripts/release/set-version.sh around line 68, address this finding:
The version lock has no recoverable owner or stale-lock protocol: an untrappable termination leaves the directory lock and the script refuses every subsequent invocation solely because it exists. This is a concrete workspace availability failure after SIGKILL/runner loss, independent of ordinary EXIT/INT/TERM cleanup.

In k8s_cli/src/fleet.rs, address this finding:
Generated metadata is not validated even though it is sent directly to kubectl, so valid YAML can make bootstrap fail after VM creation. Project/task names are unconstrained and are inserted into label values and the pool name is used in the taint value; for example a project key longer than 63 characters or containing `_`/`/` produces `exedev.dev/project=<invalid>` (and `exedev.dev/pool=...`), causing `kubectl label`/`taint` to reject the command. The failure occurs in `apply_node_metadata`, after the VMs and k3s have already been provisioned, leaving a partially bootstrapped cluster.

In k8s_cli/src/manager/parsing.rs around line 68, address this finding:
Generic/error JSON objects can be mistaken for VM inventory because any object-level `name`/`vm` string is accepted without requiring a listing-record shape or a successful status. For example, `{"error":"quota exceeded","name":"vm-1"}` (or an envelope metadata object with `name":"vm-1"`) inserts `vm-1`, so `create_missing_vms` skips creation even though the response is not a VM listing; if it also carries destination fields, the same false record can seed an SSH target. This is disproved only if the API contract guarantees error/envelope objects can never contain these keys or if the caller rejects such responses before parsing.

In k8s_cli/src/manager/parsing.rs around line 252, address this finding:
Duplicate Kubernetes node records are silently last-write-wins. A `kubectl get nodes -o json` response containing two items with the same `metadata.name`—for example, the first says `Ready=True` and has the expected labels/taints while the second has `Ready=False` or conflicting metadata—gets reduced to whichever item appears last. Callers then make readiness and reconciliation decisions from an arbitrary conflicting record instead of failing closed, potentially applying/removing metadata based on forged or stale node state. This is disproven only if the kubectl/API response is guaranteed to reject or never emit duplicate node names before this parser receives it.

In k8s_cli/README.md around line 76, address this finding:
The CLI README's control-plane fleet snippet is presented as a supported Version 1 configuration, but it omits required fields `cluster.name` and `cluster.controlPlane.vmPrefix`. `FleetFile` deserialization requires both fields and validation explicitly rejects an empty control-plane prefix, so copying the documented snippet produces a parse error rather than a plan.

In .github/workflows/release.yml around line 242, address this finding:
The verify-then-publish protocol does not prevent a tag move from producing a published release whose assets do not match the tag. `verify` reads the tag and compares it with `BUILT_SHA`, but `publish` later invokes the release API using only the mutable `tag_name`; a maintainer or attacker can move the tag in that interval, after which the assets are attached to the moved tag. `confirm` only notices the mismatch after publication and does not remove or quarantine the already-published release, so the documented invariant that publication cannot silently attach old binaries to a moved tag is not achieved. This would be disproven only if the GitHub release action/API atomically binds the uploaded assets to the exact previously verified commit (rather than resolving `tag_name` at publication time).

In k8s_cli/README.md around line 156, address this finding:
The documented `destroy` workflow cannot reliably delete all VMs that the fleet owns when the VM is absent from `exe.dev ls`, despite the README presenting `destroy --fleet ...` as deleting fleet-managed VMs. The implementation only selects names present in the inventory by default; the recovery/complete behavior is behind the undocumented `--all-planned` flag.

In k8s_cli/src/manager/mod.rs, address this finding:
A user/external label under `exedev.dev/` is accepted by fleet validation but is later treated as tool-owned and deleted when it is absent from the current plan. For example, bootstrap with `exedev.dev/test-case=shared`, then remove that label from the fleet and bootstrap again: `stale_owned_labels` emits `exedev.dev/test-case-`, and status reports drift before deletion. This violates external metadata preservation and is inconsistent with the validation comment/test that explicitly permits `exedev.dev/test-case`.

In .github/workflows/release.yml, address this finding:
The release workflow's `resolve` job checks out the workflow ref before running `scripts/release/check-version.sh`, even for `workflow_dispatch` where it resolves an arbitrary requested tag via the API. Dispatching a release for a tag whose target commit predates the script (or whose workflow ref lacks it) fails before the requested tag is built, contrary to the documented/manual tag-release contract.

In k8s_cli/README.md, address this finding:
Both READMEs instruct users to start from `../fleet.example.yaml`, but that checked-in example is rejected by the current fleet schema: `sparePools.ingress.labels` sets `exedev.dev/role: ingress`, while `FleetFile::validate()` reserves every key in `GENERATED_LABEL_KEYS`, including `exedev.dev/role`, for tool-generated metadata. Running any command that loads the recommended example fails with the reserved-label validation error instead of provisioning the documented fleet. This would be false only if the validator allowed user-supplied `exedev.dev/role`, which it explicitly does not.

In README.md around line 70, address this finding:
The release documentation describes release compatibility but provides no operational step for updating the Homebrew tap, and the release workflow only uploads GitHub archives. `scripts/release/sync-homebrew-tap.sh` is never invoked by the workflow, so completing the documented CI release leaves Homebrew users on the old formula unless an undocumented manual action is performed.

In cli/README.md around line 134, address this finding:
Both CLI READMEs inaccurately describe `--json` as printing raw JSON. `print_response` parses JSON and emits `serde_json::to_string_pretty`, while human mode unwraps API `output`/`error`; thus the flag changes formatting/interpretation and does not preserve the raw HTTPS response bytes. Automation or fixtures that rely on the documented 'raw JSON' behavior can receive reformatted JSON and different wrapper handling.

## Previously reported and still present (7)

In k8s_cli/src/manager/process.rs around line 314, address this finding:
A transport retry can replay a mutating remote script after it has already begun executing. `capture_remote_ssh_output` retries status 255 whenever `output.stdout` is empty, but the remote wrapper emits its exit marker only after the script finishes; if SSH loses the connection before that marker and the script produced no stdout (for example, an install command that is quiet until completion), the client sees empty stdout and resends the script even though the first attempt may already have changed the VM. This violates the no-replay invariant and can rerun package/service/bootstrap mutations. This would be disproven only if the SSH transport guaranteed status 255 with empty stdout cannot occur after remote command execution, which SSH does not guarantee.

In scripts/release/sync-homebrew-tap.sh around line 157, address this finding:
The target-parent confinement and symlink checks are TOCTOU-vulnerable: an attacker able to modify the tap checkout can replace a checked path component with a symlink after validation, causing the staged formula to be created or renamed outside the tap.

In cli/src/ssh.rs around line 17, address this finding:
`--json` is appended after the entire fallback command, including the typed `ssh` command's VM command. Thus `exedev-ctl --json ssh target cmd` constructs `ssh exe.dev ssh target cmd --json`; `--json` is no longer a CLI/API output option and is instead passed to the VM command, potentially altering or failing the requested remote command. This violates consistent JSON behavior for a command explicitly documented as SSH-only. The claim would be false only if exe.dev's `ssh` command universally strips/ignores trailing `--json` while still applying JSON output, which conflicts with the command construction and documented SSH semantics.

In .github/workflows/release.yml, address this finding:
The resolve job executes `scripts/release/check-version.sh` from the checkout's default event ref, not from the commit being released: its checkout has no `ref`, while only build jobs checkout `needs.resolve.outputs.sha`. Thus a default-branch change can make resolve accept/reject or normalize a tag differently from the release commit's `set-version.sh`, and arbitrary release-branch scripts are not the code validated before the build. This violates same-commit release-path integrity and allows unreviewed default-branch script code to run in resolve.

In scripts/release/set-version.sh around line 14, address this finding:
An explicitly empty version argument is silently replaced by RELEASE_TAG instead of being rejected. For example, with `RELEASE_TAG=v1.2.3`, `set-version.sh ''` assigns `VERSION` from the environment and updates the workspace to 1.2.3, whereas the script's documented argument is the requested version and check-version deliberately distinguishes an empty argument. A wrapper or CI input that expands to empty can therefore release the wrong version.

In k8s_cli/src/manager/parsing.rs, address this finding:
Rendered tables using the repository's UTF-8 bordered format are not parsed as inventory. A table such as `┌──────┬─────────┐\n│ NAME │ STATUS  │\n├──────┼─────────┤\n│ vm-1 │ running │\n└──────┴─────────┘` yields no VM names because the text parser takes the first whitespace-delimited token (`│`) rather than stripping borders and selecting the NAME column. If `/exec` returns this supported rendered-table shape in `output`, bootstrap sees an empty inventory and attempts duplicate creation. This is disproved only if the service contract guarantees rendered listings are always unbordered whitespace tables and never uses the documented/CLI UTF-8 rendering.

In scripts/release/sync-homebrew-tap.sh around line 246, address this finding:
A failed formula write leaves the temporary staged formula behind in the tap directory, and repeated failures accumulate files that are not removed by the trap.
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 7 of 7 areas reviewed

} else {
// A rendered table is merged like a serialized one; skipping it
// when the outer object already named something dropped every row.
names.extend(parse_vm_names_from_text(output));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact exe.dev error payload formats are not established in the repository, but any valid JSON response with an output string beginning with a VM-shaped token follows this reachable path.
🤖 Prompt for AI agents
In k8s_cli/src/manager/parsing.rs, address this finding:
A valid JSON error response whose first output line starts with a syntactically valid VM name is treated as inventory when wrapped in the JSON `output` field. For example `{"output":"vm-1 is unavailable\n"}` makes `parse_vm_names` return `{"vm-1"}` because the text parser accepts the first token of every line and only rejects prose with non-name first tokens; bootstrap then suppresses creation of the planned VM. This is disproved only if wrapped non-JSON output is guaranteed always to be a table with a header, or the API guarantees errors cannot begin with a VM-shaped token.

if let Some(name) = name
&& let Some(destination) = ssh_destination_from_object(object)
{
destinations.insert(name.to_string(), destination);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Data Integrity | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The repository documentation does not establish whether the exe.dev API guarantees that duplicate VM names cannot appear across the outer wrapper and nested output; if such a guarantee exists, the triggering input would be outside the supported contract.
🤖 Prompt for AI agents
In k8s_cli/src/manager/parsing.rs, address this finding:
Conflicting records for one VM silently overwrite the SSH destination instead of being rejected or treated as a conflict. For example, an outer record can report `{"vm_name":"worker-1","ssh_dest":"vm+worker-1@exe.dev"}` while a later record in `output`/`items` reports the same `vm_name` with `ssh_dest":"vm+other@exe.dev"`; traversal order makes the latter destination authoritative, so bootstrap can run the worker's installation script against the wrong VM while still believing it is operating on `worker-1`. This is disproven only if the exe.dev API contract guarantees that duplicate names can never occur across the merged wrapper/record sources (including intermediary or stale records).

sync?;
Ok(token)
}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔀 Concurrency | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In k8s_cli/src/manager/state.rs, address this finding:
The AlreadyExists branch treats any pre-existing regular file as the concurrent winner, so an attacker-planted destination (or a replacement after the link failure) is adopted as the cluster token without proving it was created by the competing bootstrap.

uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228
with:
tag_name: ${{ steps.meta.outputs.tag }}
tag_name: ${{ needs.resolve.outputs.tag }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact GitHub API behavior under a simultaneous force-move is not executable here, but the workflow clearly performs an unconditional tag-name-based release after a separate point-in-time verification, so the race is inherent in the sequence.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
The workflow only detects a tag move after publication; it does not prevent publishing archives to a tag that changed after verification. `verify` reads and compares the tag, then `publish` separately invokes the release action by mutable `tag_name`; a concurrent force-move between those jobs causes the action to attach the already-built archives to the new commit, and `confirm` can only fail after the mismatched release is live.

# runs with contents: write, so retagging upstream would hand a new
# revision the ability to rewrite this repository's releases.
# softprops/action-gh-release v3.0.2
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact prior contents of the workflow are not available in the review tools, so whether the tag-based release publication was newly added versus retained cannot be established from the head revision alone; the changed workflow nevertheless implements the vulnerable verify-then-publish protocol.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
A tag can move after `verify` passes but before (or during) `softprops/action-gh-release`, and publication is still performed against the mutable tag name rather than an immutable commit. In that race the release assets can be attached to a release whose tag points at a different commit; `confirm` only makes the workflow red after the bad release already exists and does not delete/repoint the release or assets.

# Two concurrent invocations would otherwise both pass the sibling checks below,
# interleave their moves, and overwrite each other's backups so neither could be
# rolled back.
LOCK_DIR="$REPO_ROOT/.set-version.lock"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/set-version.sh, address this finding:
The release lock is not recoverable after an untrappable termination: a SIGKILL (runner cancellation/host loss) after staging or applying leaves `.set-version.lock`, `.tmp`, and possibly `.bak` files, and the next invocation first refuses the stale lock and, after manually removing it, refuses the stale siblings. If the kill occurred after some moves, the workspace can remain split between versions with no supported automatic restoration path.

Comment thread core/src/client.rs
// Every request carries the API key as a bearer token, so the endpoint has
// to be HTTPS: `--endpoint http://elsewhere/collect` would otherwise send
// the key and the command in the clear to whatever the caller named.
if !self.endpoint.to_ascii_lowercase().starts_with("https://") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟠 High

🧩 Analysis
  • Change relation: unknown
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The repository does not establish whether arbitrary HTTPS endpoints are an intentional supported deployment feature; if they are, the finding would instead require an explicit user-trust boundary and prominent warning.
🤖 Prompt for AI agents
In core/src/client.rs, address this finding:
The HTTPS client accepts any string beginning with `https://` as a valid API endpoint and sends the bearer token and command to it, so a typo or attacker-controlled `--endpoint` such as `https://collector.example/exec` exfiltrates `EXE_DEV_API_KEY` (and commands). The documented API contract is specifically `https://exe.dev/exec`; scheme-only validation does not establish that destination. This is introduced/exposed by the configurable endpoint plus the new validation, and would be disproven only if callers are guaranteed out-of-band to constrain the endpoint host before this client is reached.

`k3s_pidfile_alive` compared the recorded pid's `comm` against `k3s`, but the pid
belongs to the backgrounded wrapper — `sudo` when the SSH user is not root,
`nohup` otherwise — so it never matched. The agent wait loop would have burned
all 30 iterations and reported `k3s agent did not stay running` against a healthy
agent, and the server path would have started a second k3s on the next run. The
full argument vector names k3s through those wrappers and still catches a pid
reused by something else.

The FNV-1a multiplier was 0x1000000001b3, one digit too long and 16x the actual
prime, so the digest keeping two sanitized cluster names apart mixed far less
than the comment claimed. Reference vectors now pin it.

Sanitizing the state directory name relocated existing state: a cluster named
`prod.example` would have started from an empty directory, minted a new token and
installed a server its agents could not join. Its previous directory is moved
across on first use, marked TODO for removal.

`require_env` validated the trimmed value and returned the untrimmed one, so the
same K3S_TOKEN yielded one credential in New mode and another in Existing mode,
and a TS_AUTHKEY with a trailing newline reached `tailscale up` inside quotes.

Bootstrap re-read `ls` immediately after creating VMs, when provisioning has not
necessarily surfaced their destinations yet, so the flow that creates VMs was the
one least likely to get them. The creation responses now supply them.

`team settings auto-join` took any string while the guard matches only the
literal `on`, so `ON` widened team membership without a prompt. `resize` and `cp`
change the bill as directly as the commands already in the guard's spending
category, which the skill publishes as a table.

An empty response body is no longer read as an empty inventory, and a generic
`name` field must look like a VM name before it becomes one; `vm_name` is still
taken as given.

Also: the dispatch tag is validated before it is interpolated into an API path,
`same_cluster_endpoint` returns the bool its callers use, the readiness loop no
longer carries two copies of its retry tail, and the removal of `new --command`
is documented where the coverage lists are.

@winnowl winnowl Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🛠️ To have the bot fix these findings, comment @winnowl fix.

🔎 Confirmed findings (8)
  • 🟠 High The lockfile is not protected against symlink redirection. When Cargo.lock is a symlink, the script's [[ -f "$LOCKFILE" ]] check passes, require_free_sibling checks only .tmp/.next/.bak, and cp "$target" "$target.bak" follows the lockfile symlink; subsequent Cargo updates and rollback operations can therefore read or write a file outside the repository. This violates the required safe persistent rewrite behavior and can modify an attacker-chosen target in a checkout. The claim would be false if the execution environment independently guarantees Cargo.lock can never be symlinked, but the script itself does not enforce that. (inline)
  • 🟠 High The staged/backup sibling checks are vulnerable to pathname TOCTOU races: after require_free_sibling observes the names absent, another process can create a symlink at target.tmp, target.next, or target.bak before cp, mv, or cleanup. Those operations follow or remove the attacker-controlled pathname, allowing writes outside the workspace or deletion of an unrelated target, despite the script's concurrency protocol. The claim would be false only if the lock directory were enforced against every other process that can manipulate these paths; the lock is private to this script and does not make pathname operations atomic against an uncooperative process. (inline)
  • 🟠 High The staged protocol has a pathname TOCTOU window: require_free_sibling checks that each .tmp/.bak path is absent, but later the shell redirection creates .tmp and cp creates .bak by pathname. A concurrent local process can replace one checked name with a symlink after the check; the awk output or backup then follows it, allowing writes outside the repository (and cleanup can remove the linked target). The .set-version.lock only coordinates other cooperating invocations of this script and cannot enforce this filesystem invariant. (inline)
  • 🟠 High The script can mix assets from different release states because it downloads each platform through a mutable tag URL in a separate curl invocation and never pins or verifies the release identity. If the tag or its assets change between iterations, the generated formula contains per-platform SHA256 values from different releases; later Homebrew downloads can then fail checksum verification or install a split release. (inline)
  • 🟠 High SSH retries can re-execute a remote bootstrap step after the remote side has already started running it. The retry gate treats an empty stdout stream as proof that the remote command did not run (let remote_ran = !output.stdout.is_empty()), but a connection can be lost before the wrapper's final status marker is delivered even after the script has performed side effects (for example, the script installs/starts a service and then sshd/network disappears before any output reaches the client). In that case ssh exits 255 with no captured stdout, and the same stdin script is sent again, potentially repeating installation/service changes. This violates the no-retry-after-possible-execution invariant; it would be disproven only if the SSH transport guaranteed that status 255 with zero stdout necessarily means the remote shell never received or executed any command, which SSH does not guarantee. The existing tests cover destination construction and parsing but do not exercise a transport drop after remote execution with empty output. (inline)
  • 🟠 High SSH transport retry can rerun a remote bootstrap script after the remote side has already executed it. (inline)
  • 🟠 High New-cluster bootstrap can durably replace a valid generated token with an empty token if the remote token read succeeds but returns empty/whitespace output. fetch_k3s_node_token trims the response but does not reject empty, and write_secret_file then persists it; subsequent agents receive an empty token and the persisted state is corrupted for later retries. (inline)
  • 🟠 High Legacy-state adoption can escape .exedev-k8s and rename an arbitrary directory because it joins the raw cluster name before validating/sanitizing it. (inline)
⛔ Unresolved from previous review (18) — not approved until fixed
  • scripts/release/set-version.sh: Cargo.lock symlinks are not rejected, so a failed run changes the lockfile's object type and can break the workspace's lockfile location. With Cargo.lock symlinked to build/locked/Cargo.lock, the script accepts it ([[ -f ]]), backs up the target contents, then mv .../Cargo.lock.tmp Cargo.lock replaces the symlink with a regular file; if cargo update fails, cleanup restores the backup as another regular file, permanently deleting the symlink (and a later successful run likewise leaves the symlink replaced). A broken Cargo.lock symlink is treated as a missing lockfile and rm -f Cargo.lock on failure deletes that symlink outright.
  • scripts/release/set-version.sh: The release lock is not recoverable after an untrappable termination: a SIGKILL (runner cancellation/host loss) after staging or applying leaves .set-version.lock, .tmp, and possibly .bak files, and the next invocation first refuses the stale lock and, after manually removing it, refuses the stale siblings. If the kill occurred after some moves, the workspace can remain split between versions with no supported automatic restoration path.
  • scripts/release/sync-homebrew-tap.sh: The symlink checks and realpath confinement are TOCTOU checks, not protection for the actual write. After -L "$TAP_FORMULA_PATH" and FORMULA_PARENT_REAL pass, a local attacker who can modify the tap directory can replace the formula file with a symlink (or replace a checked parent directory with a symlink) while the four release archives are downloaded/validated. The final cat &gt; "$TAP_FORMULA_PATH" follows that replacement and truncates/writes the symlink target outside the tap, potentially overwriting an arbitrary attacker-selected writable file. This is disproven only if the tap tree is guaranteed immutable/unmodifiable by other users/processes for the whole invocation, not merely trusted at startup.
  • .github/workflows/release.yml: A tag can move after verify passes but before (or during) softprops/action-gh-release, and publication is still performed against the mutable tag name rather than an immutable commit. In that race the release assets can be attached to a release whose tag points at a different commit; confirm only makes the workflow red after the bad release already exists and does not delete/repoint the release or assets. — The publish step still passes the mutable tag name to the release action, and verify is a separate pre-publication check with no atomic reservation or immutable target. A tag can move after verify succeeds and before or during softprops/action-gh-release; the action can then create/update the release for the moved tag, while confirm only detects the mismatch afterward and does not undo the release or assets.
  • .github/workflows/release.yml: The workflow only detects a tag move after publication; it does not prevent publishing archives to a tag that changed after verification. verify reads and compares the tag, then publish separately invokes the release action by mutable tag_name; a concurrent force-move between those jobs causes the action to attach the already-built archives to the new commit, and confirm can only fail after the mismatched release is live. — The workflow still performs a separate read-only verification and then publishes by mutable tag name: verify checks the tag against BUILT_SHA, while publish invokes the release action with tag_name: ${{ needs.resolve.outputs.tag }}. A force-move after verification and before the publish API call can therefore still attach the archives to the new commit; confirm only detects that mismatch afterward.
  • k8s_cli/src/manager/state.rs: The AlreadyExists branch treats any pre-existing regular file as the concurrent winner, so an attacker-planted destination (or a replacement after the link failure) is adopted as the cluster token without proving it was created by the competing bootstrap. — The AlreadyExists handler still removes the staging file and directly returns read_secret_file(path). Although read_secret_file now validates that the destination is a regular file and remains the same inode during the read, it does not establish that the file was created by the competing bootstrap. An attacker-planted regular destination (or a replacement before the read) can therefore still be adopted as the cluster token.
  • k8s_cli/src/manager/parsing.rs: Conflicting records for one VM silently overwrite the SSH destination instead of being rejected or treated as a conflict. For example, an outer record can report {"vm_name":"worker-1","ssh_dest":"vm+worker-1@exe.dev"} while a later record in output/items reports the same vm_name with ssh_dest":"vm+other@exe.dev"; traversal order makes the latter destination authoritative, so bootstrap can run the worker's installation script against the wrong VM while still believing it is operating on worker-1. This is disproven only if the exe.dev API contract guarantees that duplicate names can never occur across the merged wrapper/record sources (including intermediary or stale records). — The duplicate-name conflict is still silently resolved by traversal order: collect_ssh_destinations continues to call destinations.insert(name.to_string(), destination) for every matching record, so a later outer/nested/wrapped record with the same VM name replaces the earlier destination. parse_ssh_destinations merges the outer JSON and serialized output, and neither path detects or rejects differing destinations; bootstrap therefore can still receive the wrong SSH target.
  • k8s_cli/src/manager/parsing.rs: A valid JSON error response whose first output line starts with a syntactically valid VM name is treated as inventory when wrapped in the JSON output field. For example {"output":"vm-1 is unavailable\n"} makes parse_vm_names return {"vm-1"} because the text parser accepts the first token of every line and only rejects prose with non-name first tokens; bootstrap then suppresses creation of the planned VM. This is disproved only if wrapped non-JSON output is guaranteed always to be a table with a header, or the API guarantees errors cannot begin with a VM-shaped token. — The original consequence remains possible: in parse_vm_names, a JSON wrapper whose output is not itself JSON still executes names.extend(parse_vm_names_from_text(output)). parse_vm_names_from_text accepts the first token of the first line when it is VM-shaped, so {"output":"vm-1 is unavailable\n"} still produces {"vm-1"} and can suppress creation.
  • k8s_cli/src/fleet.rs: Fleet validation does not validate the generated label/taint values derived from project and task map keys, so a syntactically parseable fleet can pass planning and create VMs before Kubernetes metadata application fails. For example, a task key a/b yields exedev.dev/task=a/b and a pool project-a/b yields a malformed taint exedev.dev/pool=project-a/b:NoSchedule; Kubernetes label values cannot contain /, and the taint value is not safely representable in the kubectl argument. The code validates only user-supplied labels, then inserts project/task/pool names directly into NodeSpec. This is introduced/exposed by the schema expansion accepting arbitrary YAML map keys without corresponding generated-value validation. This would be disproven if an upstream schema/parser constrained project and task names to the Kubernetes-safe grammar, but serde map keys are currently unrestricted. — FleetFile::validate still validates only user-supplied label keys and values; it never validates project/task or spare-pool map keys before to_plan() uses them. The current planner still inserts project_name and task_name directly into generated labels and constructs the pool value/taint directly, so an unrestricted key such as a/b can still produce invalid Kubernetes metadata after the fleet passes validation.
  • cli/src/ssh.rs: SSH fallback does not preserve argument boundaries for command values containing whitespace or shell metacharacters. run_ssh_fallback passes each logical word with command.args(words), but OpenSSH concatenates those arguments into a remote command that the remote shell reparses; e.g. exedev-ctl comment vm 'staging copy' is sent as comment vm staging copy and changes the comment arguments (and values containing ; can become shell syntax). The displayed shell_join quoting is not used for the actual SSH invocation. This is introduced by the new transport path; it would be disproven if the target SSH server were known to receive argv boundaries rather than a reparsed command string, which OpenSSH remote command execution does not provide.
  • The state-directory symlink defense is check-then-use and can be bypassed by swapping a checked directory component after ensure_real_directories returns but before staging/rename. An attacker able to modify the working directory can replace .exedev-k8s or the cluster directory with a symlink in that window; subsequent OpenOptions::open follows it and writes the secret outside the state directory. The same pathname race affects reads because symlink_metadata checks the final entry while parent components are followed during File::open. — The defect remains a check-then-use race. read_or_create_k3s_token calls ensure_real_directories(parent) before later reads/creates, and prepare_secret_parent does the same before stage_secret; stage_secret then opens the staging pathname by name, while open_regular_file checks metadata and subsequently calls fs::File::open(path). A concurrent replacement of .exedev-k8s or the cluster directory after the check can therefore still redirect those path-based operations through a symlink.
  • Plain-text fallback accepts arbitrary lowercase words as VM inventory entries, so non-table prose or malformed API responses can make an existing VM appear present and suppress creation/bootstrap. — The direct non-JSON path now errors, but the original inventory-injection defect remains for rendered text in a valid JSON output wrapper: parse_vm_names_from_text still takes the first token of every non-header line and accepts any DNS-shaped lowercase word. For example, {"output":"bloggy unavailable\n"} yields bloggy, so prose or a malformed table can still make an existing VM appear present and suppress creation.
  • k8s_cli/src/manager/process.rs: A failed SSH exchange can retry after the remote script has already started, because retry eligibility checks only whether stdout is non-empty. If the remote side executes a state-changing install that emits only stderr (or the connection drops before the wrapper's stdout marker is delivered), ssh can exit 255 with empty stdout and the same script is resent up to four times. A failed stdin write after partial delivery has the same outcome when ssh exits 255. This violates the no-potential-execution retry invariant and can repeat Tailscale/k3s installation or service changes. — The retry gate still derives remote_ran solely from stdout: let remote_ran = !output.stdout.is_empty();, and retries status 255 when stdout is empty. Thus a remote script that changes state but emits only stderr, or whose stdout/marker is lost before delivery, can still be resent; a failed partial stdin write followed by status 255 is likewise still eligible for retry. The surrounding comments do not alter that behavior.
  • .github/workflows/release.yml: The verify/confirm sequence does not prevent a tag race during publication: after verify checks the tag (lines 164-176), an actor can move it before softprops/action-gh-release attaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built from BUILT_SHA while the tag points elsewhere; confirm (lines 216-237) only fails afterward and performs no deletion, retagging, or release correction. Thus a successful publish followed by a red workflow still leaves a mismatched public release. This is false only if the tag cannot be moved by any actor/token during the verify-to-publish window or if post-publication detection is explicitly considered sufficient rather than preventing the mismatch. — The race remains. The current verify job performs a read of the tag before publish, but publish still invokes softprops/action-gh-release by tag_name; an actor can move the tag after that read and before or during attachment. The current confirm job only reads the tag after publication and exits nonzero on a mismatch, without deleting or correcting the already-created release/assets, so the described mismatched public release can still remain.
  • k8s_cli/src/manager/mod.rs: New-cluster bootstrap can reuse an already-installed k3s server without verifying that it belongs to the requested cluster. — The defect remains: create_missing_vms still treats a VM name alone as sufficient and skips creation, while New-mode bootstrap proceeds to install_k3s_server. The current server install command only checks for conflicting agent state and, when a supervisor-managed k3s binary/service already exists, starts the existing k3s service without checking its cluster identity, token, or kubeconfig. Thus an existing k3s server from another cluster can still be reused.
  • scripts/release/sync-homebrew-tap.sh: The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After FORMULA_PARENT_REAL is checked, the script calls mktemp and later mv using the original path; if an attacker replaces a checked descendant (for example tap/Formula/e) with a symlink to an outside directory, mktemp creates the staged file outside the tap and mv writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.
  • The publish job grants contents: write to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable github.token; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures. — The tag-verification shell step was moved into the read-only verify job, but publish still declares permissions: contents: write and its Download release archives step remains in that job. Since GitHub Actions applies the job token permissions to every step in the job, the download action still executes with a write-capable github.token; the least-privilege defect therefore remains for that preceding step.
  • k8s_cli/src/manager/process.rs: A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. capture_remote_ssh_output retries solely from output.status.code() == Some(255) and ignores whether stdout already contains the wrapper's __EXEDEV_K8S_EXIT__: marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent. — The retry is narrower but the defect remains: capture_remote_ssh_output retries status 255 whenever output.stdout is empty (let remote_ran = !output.stdout.is_empty(); ... &amp;&amp; !remote_ran). A remote script can execute and complete without producing ordinary stdout, while the connection is lost before the wrapper's final marker reaches the client; the resulting captured stdout is empty and the full non-idempotent script is sent again. The current code does not establish that remote execution did not occur, and it does not require or otherwise use receipt of the exit marker to make the retry safe.
⚠️ Unverified risks (1)
  • Raw destructive commands using boolean equals-form flags can bypass the local dangerous-command prompt. For example, exedev-ctl exec -- share add vm user@example.com --root=true is classified as non-dangerous because grants_shell_access only checks for a token exactly equal to --root; likewise exec -- integrations setup github --list --delete=true is treated as read-only because is_read_only_integrations_setup only recognizes the exact token --delete. If the exe.dev command parser accepts standard clap-style --flag=true boolean syntax, these commands grant shell access or mutate/disconnect an integration without the required confirmation. This is introduced by the new token-based guard and can be disproved only if the server definitively rejects all equals-form boolean flags before performing the action. (core/src/shell.rs)
📋 Additional findings from this change (not shown inline) (11)
  • 🟠 High The server-role guard does not detect an agent installed by the no-supervisor fallback. require_no_k3s_agent_state_for_server checks only the k3s-agent service and then permits the server when no supervisor exists; it never checks the agent data directory or /var/run/exedev-k8s-k3s-agent.pid. On an image without systemd/OpenRC, after an agent bootstrap leaves /var/lib/rancher/k3s/agent and its process running, a later control-plane bootstrap passes this guard and starts k3s server on the same VM, producing a mixed-role node and conflicting k3s state. This is disproven only if fallback agents are guaranteed never to remain on a VM before role changes (for example, VMs are always destroyed). (k8s_cli/src/manager/scripts.rs) — anchor-outside-diff
  • 🟠 High The supervisor install pipeline can report success when the k3s installer download fails. Because the script does not enable pipefail, curl -sfL https://get.k3s.io | ... sh -s ... returns the status of sh; a curl/TLS/network failure can leave sh with EOF and status 0, after which the script starts the service (or relies on a stale unit) instead of failing the install. This is especially harmful in the supervisor branch because it bypasses the checksum-verified binary fallback. (k8s_cli/src/manager/scripts.rs) — anchor-unreliable
  • 🟡 Medium An explicitly empty version argument is silently replaced by RELEASE_TAG, so the script can rewrite the workspace to a version different from the argument supplied by its caller. For example, with RELEASE_TAG=v0.1.11, set-version.sh '' evaluates VERSION as v0.1.11 and succeeds instead of rejecting the empty argument. This violates the script's input contract and can produce a mismatched build when a wrapper intentionally passes an empty value; it would be false only if callers are guaranteed never to pass an empty positional argument and the script is not required to distinguish that from an omitted argument. (scripts/release/set-version.sh) — per-file-budget
  • 🟡 Medium An explicit target path can generate a formula whose filename and Homebrew class do not match the configured formula name. (scripts/release/sync-homebrew-tap.sh) — per-file-budget
  • 🟡 Medium The tap-root confinement check is TOCTOU-unsafe: after FORMULA_PARENT_REAL is checked, an attacker can replace the parent directory with a symlink and redirect the staged formula write outside the tap. (scripts/release/sync-homebrew-tap.sh) — per-file-budget
  • 🟡 Medium Project/task and spare-pool map names are interpolated into generated label values and taint values without validation. For example, a fleet with project key team one (or task key a/b) passes validate, but planning produces exedev.dev/project=team one / exedev.dev/task=a/b and an isolation taint containing the same invalid value; kubectl label/taint then fails only after VM creation/bootstrap has begun. (k8s_cli/src/fleet.rs) — anchor-outside-diff
  • 🟡 Medium The documented fleet.example.yaml is rejected by the new validation: its sparePools.ingress.labels contains exedev.dev/role: ingress, but validate unconditionally rejects every user-supplied exedev.dev/role. Thus the repository's stated starter configuration cannot be loaded, violating compatibility with the example and making the intended ingress role impossible to configure. (fleet.example.yaml) — anchor-outside-diff
  • 🟡 Medium The server path treats any existing kubeconfig as proof that the newly requested server started, and it ignores the failure from the nonblocking start. On a VM with a stale /etc/rancher/k3s/k3s.yaml but a stopped/broken k3s service, start_k3s_service_no_block k3s can fail, the loop immediately sees the old non-empty file, and the bootstrap exits 0 even though this VM is not serving the cluster. (k8s_cli/src/manager/scripts.rs) — anchor-outside-diff
  • 🟡 Medium Checksum verification does not make the temporary download safe against a local symlink attack. The binary and checksum paths are predictable /tmp names based on $$; curl -o follows an attacker-created symlink at that path, and after the checksum matches the script runs chmod and privileged chown on the same pathname before moving it. A local user able to race/create that symlink can cause the downloaded bytes to overwrite an arbitrary writable file and cause sudo chown root:root (and chmod) to follow it, despite successful checksum verification. (k8s_cli/src/manager/scripts.rs) — inline-budget
  • 🟡 Medium Publishing a secret does not durably sync newly created ancestor directory entries, so a crash can lose the newly created cluster-state directory (and therefore the token/kubeconfig) even though the file and its immediate parent were fsynced. (k8s_cli/src/manager/state.rs) — inline-budget
  • 🟡 Medium Whitespace-bearing authoritative SSH destinations are accepted after trimming, contrary to the destination-integrity requirement. (k8s_cli/src/manager/parsing.rs) — inline-budget
♻️ Previously reported (still present) (3)
  • 🟠 High The tap write confinement check has a TOCTOU gap: it resolves and validates FORMULA_PARENT_REAL with cd -P, then later creates a staged file and performs mv -f "$FORMULA_STAGED" "$TAP_FORMULA_PATH". Another process can replace a validated parent directory with a symlink after the check, causing the final destination pathname to resolve outside the tap checkout. The script therefore does not fully satisfy refusal of symlink/path redirection at write time. This would be false only if the tap directory and its parents are trusted and immutable for the whole run, which is not enforced by the script. (scripts/release/sync-homebrew-tap.sh) — previously-reported
  • 🟠 High The verify/publish/confirm sequence does not actually prevent publishing a release for a tag that moves after verification: publish attaches assets by tag name after verify has completed, so a concurrent force-push (or delete/recreate) between those jobs can make the release point at a different commit. confirm only detects the mismatch after publication and fails the workflow; it does not undo the already-created/updated release or its assets. (.github/workflows/release.yml) — previously-reported
  • 🟡 Medium The Homebrew script can combine assets from different revisions when the release tag moves during its download loop. Each platform archive is fetched independently from a mutable /releases/download/$RELEASE_TAG/... URL, with no immutable release ID or commit/tag consistency check; if the tag is retargeted between iterations, the generated formula records four SHA256s belonging to a mixture of releases. Users then receive platform-dependent binaries from different source revisions under one formula version. This would be false only if the tag/release asset URLs were externally guaranteed immutable for the entire run, which GitHub tag-based download URLs do not provide. (scripts/release/sync-homebrew-tap.sh) — previously-reported
❓ Low-evidence leads (not confirmed — verify before acting) (2)
  • The archive member validation does not reliably prove that every expected member is a regular file because it parses human-oriented tar -tvzf output with whitespace splitting and checks only the first character of the mode field. Filenames containing spaces are split into multiple fields and may be reported missing or misidentified, while tar listing formats/options can place metadata differently; additionally, duplicate entries are accepted as long as no non-regular duplicate is detected, even though extraction order determines the final installed object. This violates the stated invariant that install-referenced archive members are verified regular files. The claim would be false if the release packaging permanently restricted all member names to the current space-free fixed list and the runner's tar output format is guaranteed, but the script does not encode that guarantee robustly. (scripts/release/sync-homebrew-tap.sh)
  • Tailnet Lock detection invokes tailscale lock status without the privilege wrapper, so a non-root bootstrap user that relies on SUDO can fail to read lock status and skip the required lockout pause/signature path. (k8s_cli/src/manager/scripts.rs)
🤖 Prompt for AI agents — all findings (40)
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

## Unresolved from the previous review — these block approval, fix them first (18)

In scripts/release/set-version.sh, address this finding:
`Cargo.lock` symlinks are not rejected, so a failed run changes the lockfile's object type and can break the workspace's lockfile location. With `Cargo.lock` symlinked to `build/locked/Cargo.lock`, the script accepts it (`[[ -f ]]`), backs up the target contents, then `mv .../Cargo.lock.tmp Cargo.lock` replaces the symlink with a regular file; if `cargo update` fails, cleanup restores the backup as another regular file, permanently deleting the symlink (and a later successful run likewise leaves the symlink replaced). A broken `Cargo.lock` symlink is treated as a missing lockfile and `rm -f Cargo.lock` on failure deletes that symlink outright.

In scripts/release/set-version.sh, address this finding:
The release lock is not recoverable after an untrappable termination: a SIGKILL (runner cancellation/host loss) after staging or applying leaves `.set-version.lock`, `.tmp`, and possibly `.bak` files, and the next invocation first refuses the stale lock and, after manually removing it, refuses the stale siblings. If the kill occurred after some moves, the workspace can remain split between versions with no supported automatic restoration path.

In scripts/release/sync-homebrew-tap.sh, address this finding:
The symlink checks and realpath confinement are TOCTOU checks, not protection for the actual write. After `-L "$TAP_FORMULA_PATH"` and `FORMULA_PARENT_REAL` pass, a local attacker who can modify the tap directory can replace the formula file with a symlink (or replace a checked parent directory with a symlink) while the four release archives are downloaded/validated. The final `cat > "$TAP_FORMULA_PATH"` follows that replacement and truncates/writes the symlink target outside the tap, potentially overwriting an arbitrary attacker-selected writable file. This is disproven only if the tap tree is guaranteed immutable/unmodifiable by other users/processes for the whole invocation, not merely trusted at startup.

In .github/workflows/release.yml, address this finding:
A tag can move after `verify` passes but before (or during) `softprops/action-gh-release`, and publication is still performed against the mutable tag name rather than an immutable commit. In that race the release assets can be attached to a release whose tag points at a different commit; `confirm` only makes the workflow red after the bad release already exists and does not delete/repoint the release or assets.

In .github/workflows/release.yml, address this finding:
The workflow only detects a tag move after publication; it does not prevent publishing archives to a tag that changed after verification. `verify` reads and compares the tag, then `publish` separately invokes the release action by mutable `tag_name`; a concurrent force-move between those jobs causes the action to attach the already-built archives to the new commit, and `confirm` can only fail after the mismatched release is live.

In k8s_cli/src/manager/state.rs, address this finding:
The AlreadyExists branch treats any pre-existing regular file as the concurrent winner, so an attacker-planted destination (or a replacement after the link failure) is adopted as the cluster token without proving it was created by the competing bootstrap.

In k8s_cli/src/manager/parsing.rs, address this finding:
Conflicting records for one VM silently overwrite the SSH destination instead of being rejected or treated as a conflict. For example, an outer record can report `{"vm_name":"worker-1","ssh_dest":"vm+worker-1@exe.dev"}` while a later record in `output`/`items` reports the same `vm_name` with `ssh_dest":"vm+other@exe.dev"`; traversal order makes the latter destination authoritative, so bootstrap can run the worker's installation script against the wrong VM while still believing it is operating on `worker-1`. This is disproven only if the exe.dev API contract guarantees that duplicate names can never occur across the merged wrapper/record sources (including intermediary or stale records).

In k8s_cli/src/manager/parsing.rs, address this finding:
A valid JSON error response whose first output line starts with a syntactically valid VM name is treated as inventory when wrapped in the JSON `output` field. For example `{"output":"vm-1 is unavailable\n"}` makes `parse_vm_names` return `{"vm-1"}` because the text parser accepts the first token of every line and only rejects prose with non-name first tokens; bootstrap then suppresses creation of the planned VM. This is disproved only if wrapped non-JSON output is guaranteed always to be a table with a header, or the API guarantees errors cannot begin with a VM-shaped token.

In k8s_cli/src/fleet.rs, address this finding:
Fleet validation does not validate the generated label/taint values derived from project and task map keys, so a syntactically parseable fleet can pass planning and create VMs before Kubernetes metadata application fails. For example, a task key `a/b` yields `exedev.dev/task=a/b` and a pool `project-a/b` yields a malformed taint `exedev.dev/pool=project-a/b:NoSchedule`; Kubernetes label values cannot contain `/`, and the taint value is not safely representable in the kubectl argument. The code validates only user-supplied labels, then inserts project/task/pool names directly into NodeSpec. This is introduced/exposed by the schema expansion accepting arbitrary YAML map keys without corresponding generated-value validation. This would be disproven if an upstream schema/parser constrained project and task names to the Kubernetes-safe grammar, but serde map keys are currently unrestricted.

In cli/src/ssh.rs, address this finding:
SSH fallback does not preserve argument boundaries for command values containing whitespace or shell metacharacters. `run_ssh_fallback` passes each logical word with `command.args(words)`, but OpenSSH concatenates those arguments into a remote command that the remote shell reparses; e.g. `exedev-ctl comment vm 'staging copy'` is sent as `comment vm staging copy` and changes the comment arguments (and values containing `;` can become shell syntax). The displayed `shell_join` quoting is not used for the actual SSH invocation. This is introduced by the new transport path; it would be disproven if the target SSH server were known to receive argv boundaries rather than a reparsed command string, which OpenSSH remote command execution does not provide.

Somewhere in the code under review, address this finding:
The state-directory symlink defense is check-then-use and can be bypassed by swapping a checked directory component after `ensure_real_directories` returns but before staging/rename. An attacker able to modify the working directory can replace `.exedev-k8s` or the cluster directory with a symlink in that window; subsequent `OpenOptions::open` follows it and writes the secret outside the state directory. The same pathname race affects reads because `symlink_metadata` checks the final entry while parent components are followed during `File::open`.

Somewhere in the code under review, address this finding:
Plain-text fallback accepts arbitrary lowercase words as VM inventory entries, so non-table prose or malformed API responses can make an existing VM appear present and suppress creation/bootstrap.

In k8s_cli/src/manager/process.rs, address this finding:
A failed SSH exchange can retry after the remote script has already started, because retry eligibility checks only whether stdout is non-empty. If the remote side executes a state-changing install that emits only stderr (or the connection drops before the wrapper's stdout marker is delivered), ssh can exit 255 with empty stdout and the same script is resent up to four times. A failed stdin write after partial delivery has the same outcome when ssh exits 255. This violates the no-potential-execution retry invariant and can repeat Tailscale/k3s installation or service changes.

In .github/workflows/release.yml, address this finding:
The verify/confirm sequence does not prevent a tag race during publication: after `verify` checks the tag (lines 164-176), an actor can move it before `softprops/action-gh-release` attaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built from `BUILT_SHA` while the tag points elsewhere; `confirm` (lines 216-237) only fails afterward and performs no deletion, retagging, or release correction. Thus a successful publish followed by a red workflow still leaves a mismatched public release. This is false only if the tag cannot be moved by any actor/token during the verify-to-publish window or if post-publication detection is explicitly considered sufficient rather than preventing the mismatch.

In k8s_cli/src/manager/mod.rs, address this finding:
New-cluster bootstrap can reuse an already-installed k3s server without verifying that it belongs to the requested cluster.

In scripts/release/sync-homebrew-tap.sh, address this finding:
The confinement check is pathname-based and can be bypassed by a concurrent symlink swap of a formula parent directory. After `FORMULA_PARENT_REAL` is checked, the script calls `mktemp` and later `mv` using the original path; if an attacker replaces a checked descendant (for example `tap/Formula/e`) with a symlink to an outside directory, `mktemp` creates the staged file outside the tap and `mv` writes there. The target-file symlink check does not protect parent components. This would be false only if the tap directory and all descendants were trusted against concurrent filesystem mutation for the entire run.

Somewhere in the code under review, address this finding:
The publish job grants `contents: write` to every step in the job, not only to the GitHub Release action. Consequently the artifact download and tag-verification shell step execute with a write-capable `github.token`; compromise of the download action or a command/tool invoked before the publish step could mutate repository contents/releases, contrary to the scope's least-privilege requirement. This would be disproven only if GitHub Actions supported per-step reduction of the job token here or the preceding steps demonstrably received a separate read-only token, neither of which this workflow configures.

In k8s_cli/src/manager/process.rs, address this finding:
A transport-status-255 retry can rerun a non-idempotent bootstrap after the remote side has already executed it. `capture_remote_ssh_output` retries solely from `output.status.code() == Some(255)` and ignores whether stdout already contains the wrapper's `__EXEDEV_K8S_EXIT__:` marker. For example, sshd runs the k3s install through the wrapper, the VM completes and starts k3s, then the client loses the connection before/while receiving the final output; ssh exits 255 and the loop sends the full install script again. This violates the no-duplicate-state-changing-step invariant; the retry is safe only when the exchange establishes that the remote command did not complete (or the operation is explicitly idempotent). This would be disproven if the SSH/remote wrapper contract guaranteed status 255 is emitted only before remote script execution, or if all retried scripts were proven idempotent.

## Findings on this change (also posted as inline comments) (8)

In scripts/release/set-version.sh around line 168, address this finding:
The lockfile is not protected against symlink redirection. When `Cargo.lock` is a symlink, the script's `[[ -f "$LOCKFILE" ]]` check passes, `require_free_sibling` checks only `.tmp/.next/.bak`, and `cp "$target" "$target.bak"` follows the lockfile symlink; subsequent Cargo updates and rollback operations can therefore read or write a file outside the repository. This violates the required safe persistent rewrite behavior and can modify an attacker-chosen target in a checkout. The claim would be false if the execution environment independently guarantees Cargo.lock can never be symlinked, but the script itself does not enforce that.

In scripts/release/set-version.sh around line 113, address this finding:
The staged/backup sibling checks are vulnerable to pathname TOCTOU races: after `require_free_sibling` observes the names absent, another process can create a symlink at `target.tmp`, `target.next`, or `target.bak` before `cp`, `mv`, or cleanup. Those operations follow or remove the attacker-controlled pathname, allowing writes outside the workspace or deletion of an unrelated target, despite the script's concurrency protocol. The claim would be false only if the lock directory were enforced against every other process that can manipulate these paths; the lock is private to this script and does not make pathname operations atomic against an uncooperative process.

In scripts/release/set-version.sh around line 115, address this finding:
The staged protocol has a pathname TOCTOU window: `require_free_sibling` checks that each `.tmp`/`.bak` path is absent, but later the shell redirection creates `.tmp` and `cp` creates `.bak` by pathname. A concurrent local process can replace one checked name with a symlink after the check; the awk output or backup then follows it, allowing writes outside the repository (and cleanup can remove the linked target). The `.set-version.lock` only coordinates other cooperating invocations of this script and cannot enforce this filesystem invariant.

In scripts/release/sync-homebrew-tap.sh around line 175, address this finding:
The script can mix assets from different release states because it downloads each platform through a mutable tag URL in a separate curl invocation and never pins or verifies the release identity. If the tag or its assets change between iterations, the generated formula contains per-platform SHA256 values from different releases; later Homebrew downloads can then fail checksum verification or install a split release.

In k8s_cli/src/manager/process.rs around line 319, address this finding:
SSH retries can re-execute a remote bootstrap step after the remote side has already started running it. The retry gate treats an empty stdout stream as proof that the remote command did not run (`let remote_ran = !output.stdout.is_empty()`), but a connection can be lost before the wrapper's final status marker is delivered even after the script has performed side effects (for example, the script installs/starts a service and then sshd/network disappears before any output reaches the client). In that case ssh exits 255 with no captured stdout, and the same stdin script is sent again, potentially repeating installation/service changes. This violates the no-retry-after-possible-execution invariant; it would be disproven only if the SSH transport guaranteed that status 255 with zero stdout necessarily means the remote shell never received or executed any command, which SSH does not guarantee. The existing tests cover destination construction and parsing but do not exercise a transport drop after remote execution with empty output.

In k8s_cli/src/manager/process.rs around line 320, address this finding:
SSH transport retry can rerun a remote bootstrap script after the remote side has already executed it.

In k8s_cli/src/manager/mod.rs around line 571, address this finding:
New-cluster bootstrap can durably replace a valid generated token with an empty token if the remote token read succeeds but returns empty/whitespace output. `fetch_k3s_node_token` trims the response but does not reject empty, and `write_secret_file` then persists it; subsequent agents receive an empty token and the persisted state is corrupted for later retries.

In k8s_cli/src/manager/state.rs around line 35, address this finding:
Legacy-state adoption can escape `.exedev-k8s` and rename an arbitrary directory because it joins the raw cluster name before validating/sanitizing it.

## Additional findings on this change (not posted inline) (11)

In k8s_cli/src/manager/scripts.rs around line 138, address this finding:
The server-role guard does not detect an agent installed by the no-supervisor fallback. `require_no_k3s_agent_state_for_server` checks only the `k3s-agent` service and then permits the server when no supervisor exists; it never checks the agent data directory or `/var/run/exedev-k8s-k3s-agent.pid`. On an image without systemd/OpenRC, after an agent bootstrap leaves `/var/lib/rancher/k3s/agent` and its process running, a later control-plane bootstrap passes this guard and starts `k3s server` on the same VM, producing a mixed-role node and conflicting k3s state. This is disproven only if fallback agents are guaranteed never to remain on a VM before role changes (for example, VMs are always destroyed).

In k8s_cli/src/manager/scripts.rs, address this finding:
The supervisor install pipeline can report success when the k3s installer download fails. Because the script does not enable `pipefail`, `curl -sfL https://get.k3s.io | ... sh -s ...` returns the status of `sh`; a curl/TLS/network failure can leave `sh` with EOF and status 0, after which the script starts the service (or relies on a stale unit) instead of failing the install. This is especially harmful in the supervisor branch because it bypasses the checksum-verified binary fallback.

In scripts/release/set-version.sh around line 14, address this finding:
An explicitly empty version argument is silently replaced by `RELEASE_TAG`, so the script can rewrite the workspace to a version different from the argument supplied by its caller. For example, with `RELEASE_TAG=v0.1.11`, `set-version.sh ''` evaluates `VERSION` as `v0.1.11` and succeeds instead of rejecting the empty argument. This violates the script's input contract and can produce a mismatched build when a wrapper intentionally passes an empty value; it would be false only if callers are guaranteed never to pass an empty positional argument and the script is not required to distinguish that from an omitted argument.

In scripts/release/sync-homebrew-tap.sh around line 132, address this finding:
An explicit target path can generate a formula whose filename and Homebrew class do not match the configured formula name.

In scripts/release/sync-homebrew-tap.sh around line 157, address this finding:
The tap-root confinement check is TOCTOU-unsafe: after FORMULA_PARENT_REAL is checked, an attacker can replace the parent directory with a symlink and redirect the staged formula write outside the tap.

In k8s_cli/src/fleet.rs around line 326, address this finding:
Project/task and spare-pool map names are interpolated into generated label values and taint values without validation. For example, a fleet with project key `team one` (or task key `a/b`) passes `validate`, but planning produces `exedev.dev/project=team one` / `exedev.dev/task=a/b` and an isolation taint containing the same invalid value; `kubectl label`/`taint` then fails only after VM creation/bootstrap has begun.

In fleet.example.yaml around line 83, address this finding:
The documented `fleet.example.yaml` is rejected by the new validation: its `sparePools.ingress.labels` contains `exedev.dev/role: ingress`, but `validate` unconditionally rejects every user-supplied `exedev.dev/role`. Thus the repository's stated starter configuration cannot be loaded, violating compatibility with the example and making the intended ingress role impossible to configure.

In k8s_cli/src/manager/scripts.rs around line 245, address this finding:
The server path treats any existing kubeconfig as proof that the newly requested server started, and it ignores the failure from the nonblocking start. On a VM with a stale `/etc/rancher/k3s/k3s.yaml` but a stopped/broken `k3s` service, `start_k3s_service_no_block k3s` can fail, the loop immediately sees the old non-empty file, and the bootstrap exits 0 even though this VM is not serving the cluster.

In k8s_cli/src/manager/scripts.rs around line 183, address this finding:
Checksum verification does not make the temporary download safe against a local symlink attack. The binary and checksum paths are predictable `/tmp` names based on `$$`; `curl -o` follows an attacker-created symlink at that path, and after the checksum matches the script runs `chmod` and privileged `chown` on the same pathname before moving it. A local user able to race/create that symlink can cause the downloaded bytes to overwrite an arbitrary writable file and cause `sudo chown root:root` (and chmod) to follow it, despite successful checksum verification.

In k8s_cli/src/manager/state.rs around line 203, address this finding:
Publishing a secret does not durably sync newly created ancestor directory entries, so a crash can lose the newly created cluster-state directory (and therefore the token/kubeconfig) even though the file and its immediate parent were fsynced.

In k8s_cli/src/manager/parsing.rs around line 152, address this finding:
Whitespace-bearing authoritative SSH destinations are accepted after trimming, contrary to the destination-integrity requirement.

## Previously reported and still present (3)

In scripts/release/sync-homebrew-tap.sh around line 158, address this finding:
The tap write confinement check has a TOCTOU gap: it resolves and validates `FORMULA_PARENT_REAL` with `cd -P`, then later creates a staged file and performs `mv -f "$FORMULA_STAGED" "$TAP_FORMULA_PATH"`. Another process can replace a validated parent directory with a symlink after the check, causing the final destination pathname to resolve outside the tap checkout. The script therefore does not fully satisfy refusal of symlink/path redirection at write time. This would be false only if the tap directory and its parents are trusted and immutable for the whole run, which is not enforced by the script.

In .github/workflows/release.yml around line 223, address this finding:
The verify/publish/confirm sequence does not actually prevent publishing a release for a tag that moves after verification: `publish` attaches assets by tag name after `verify` has completed, so a concurrent force-push (or delete/recreate) between those jobs can make the release point at a different commit. `confirm` only detects the mismatch after publication and fails the workflow; it does not undo the already-created/updated release or its assets.

In scripts/release/sync-homebrew-tap.sh around line 173, address this finding:
The Homebrew script can combine assets from different revisions when the release tag moves during its download loop. Each platform archive is fetched independently from a mutable `/releases/download/$RELEASE_TAG/...` URL, with no immutable release ID or commit/tag consistency check; if the tag is retargeted between iterations, the generated formula records four SHA256s belonging to a mixture of releases. Users then receive platform-dependent binaries from different source revisions under one formula version. This would be false only if the tag/release asset URLs were externally guaranteed immutable for the entire run, which GitHub tag-based download URLs do not provide.
📜 Review details

Model

  • gpt-5.6-luna

Coverage

  • 9 of 10 areas reviewed

# restored manifests.
LOCKFILE="$REPO_ROOT/Cargo.lock"
LOCKFILE_CREATED=0
if [[ -f "$LOCKFILE" ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In scripts/release/set-version.sh, address this finding:
The lockfile is not protected against symlink redirection. When `Cargo.lock` is a symlink, the script's `[[ -f "$LOCKFILE" ]]` check passes, `require_free_sibling` checks only `.tmp/.next/.bak`, and `cp "$target" "$target.bak"` follows the lockfile symlink; subsequent Cargo updates and rollback operations can therefore read or write a file outside the repository. This violates the required safe persistent rewrite behavior and can modify an attacker-chosen target in a checkout. The claim would be false if the execution environment independently guarantees Cargo.lock can never be symlinked, but the script itself does not enforce that.

# The staging and backup names are derived from the target, so anything already
# sitting at one of them would be written through (a symlink there redirects the
# rewrite outside the workspace) and then deleted by the cleanup below.
require_free_sibling() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ Exploitation requires a concurrent local process with write access to the repository paths; rename and unlink operations themselves generally replace/remove symlink entries, but the staged and backup writes (awk/cp redirection) follow a raced symlink and can write outside the workspace.
🤖 Prompt for AI agents
In scripts/release/set-version.sh, address this finding:
The staged/backup sibling checks are vulnerable to pathname TOCTOU races: after `require_free_sibling` observes the names absent, another process can create a symlink at `target.tmp`, `target.next`, or `target.bak` before `cp`, `mv`, or cleanup. Those operations follow or remove the attacker-controlled pathname, allowing writes outside the workspace or deletion of an unrelated target, despite the script's concurrency protocol. The claim would be false only if the lock directory were enforced against every other process that can manipulate these paths; the lock is private to this script and does not make pathname operations atomic against an uncooperative process.

# rewrite outside the workspace) and then deleted by the cleanup below.
require_free_sibling() {
local sibling
for sibling in "$1.tmp" "$1.next" "$1.bak"; do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The practical impact depends on the script running with write access to a sensitive path and an attacker being able to schedule a competing local process during the check-to-open interval; the .set-version.lock does not constrain non-cooperating processes.
🤖 Prompt for AI agents
In scripts/release/set-version.sh, address this finding:
The staged protocol has a pathname TOCTOU window: `require_free_sibling` checks that each `.tmp`/`.bak` path is absent, but later the shell redirection creates `.tmp` and `cp` creates `.bak` by pathname. A concurrent local process can replace one checked name with a symlink after the check; the awk output or backup then follows it, allowing writes outside the repository (and cleanup can remove the linked target). The `.set-version.lock` only coordinates other cooperating invocations of this script and cannot enforce this filesystem invariant.

SHAS=()
for platform in "${PLATFORMS[@]}"; do
archive="${ARCHIVE_PREFIX}-${RELEASE_TAG}-${platform}.tar.gz"
url="https://github.com/$REPO_SLUG/releases/download/$RELEASE_TAG/$archive"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact GitHub release/tag mutation window is operationally rare, but the script makes independent mutable-tag requests and does not enforce a single release identity.
🤖 Prompt for AI agents
In scripts/release/sync-homebrew-tap.sh, address this finding:
The script can mix assets from different release states because it downloads each platform through a mutable tag URL in a separate curl invocation and never pins or verifies the release identity. If the tag or its assets change between iterations, the generated formula contains per-platform SHA256 values from different releases; later Homebrew downloads can then fail checksum verification or install a split release.

// Any stdout at all means the remote shell reached the script, whether or
// not the exit marker made it back, so resending would repeat whatever it
// had already done. Only an exchange that produced nothing is retried.
let remote_ran = !output.stdout.is_empty();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Reliability | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact SSH/network failure timing is environment-dependent, but SSH provides no guarantee that status 255 with empty stdout means the remote shell did not execute the stdin script.
🤖 Prompt for AI agents
In k8s_cli/src/manager/process.rs, address this finding:
SSH retries can re-execute a remote bootstrap step after the remote side has already started running it. The retry gate treats an empty stdout stream as proof that the remote command did not run (`let remote_ran = !output.stdout.is_empty()`), but a connection can be lost before the wrapper's final status marker is delivered even after the script has performed side effects (for example, the script installs/starts a service and then sshd/network disappears before any output reaches the client). In that case ssh exits 255 with no captured stdout, and the same stdin script is sent again, potentially repeating installation/service changes. This violates the no-retry-after-possible-execution invariant; it would be disproven only if the SSH transport guaranteed that status 255 with zero stdout necessarily means the remote shell never received or executed any command, which SSH does not guarantee. The existing tests cover destination construction and parsing but do not exercise a transport drop after remote execution with empty output.

// not the exit marker made it back, so resending would repeat whatever it
// had already done. Only an exchange that produced nothing is retried.
let remote_ran = !output.stdout.is_empty();
if output.status.code() == Some(255) && !remote_ran && attempt < REMOTE_SSH_ATTEMPTS {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Error Handling | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact SSH failure timing is environment-dependent, but SSH status 255 does not guarantee that the remote command was never started.
🤖 Prompt for AI agents
In k8s_cli/src/manager/process.rs, address this finding:
SSH transport retry can rerun a remote bootstrap script after the remote side has already executed it.

async fn fetch_k3s_node_token(targets: &SshTargets, vm: &str) -> Result<String> {
let script = remote_privileged_script("${SUDO} cat /var/lib/rancher/k3s/server/node-token");
remote_capture(vm, &script)
remote_capture(targets, vm, &script)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In k8s_cli/src/manager/mod.rs, address this finding:
New-cluster bootstrap can durably replace a valid generated token with an empty token if the remote token read succeeds but returns empty/whitespace output. `fetch_k3s_node_token` trims the response but does not reject empty, and `write_secret_file` then persists it; subsequent agents receive an empty token and the persisted state is corrupted for later retries.

/// cannot join. Only an exact rename is attempted; anything else is left alone
/// for the operator to resolve.
fn adopt_legacy_state_dir(cluster_name: &str, current: &Path) {
let legacy = Path::new(STATE_DIR).join(cluster_name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The practical impact depends on the process working directory and whether a pre-existing directory at the traversal-resolved legacy path exists, but both are ordinary conditions and the code does not constrain them.
🤖 Prompt for AI agents
In k8s_cli/src/manager/state.rs, address this finding:
Legacy-state adoption can escape `.exedev-k8s` and rename an arbitrary directory because it joins the raw cluster name before validating/sanitizing it.

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.

1 participant