Repository navigation
fix(skills): read run values through vcn.sh get, check the agent-tty socket dir, run the static proofs in CI - #545
Conversation
agent-tty keeps every session socket under the fixed dir /tmp/agent-tty, whatever TMPDIR says, and never checks who owns it. Reproduced on main in a bwrap user namespace that this user owns, with /tmp/agent-tty a host-root-owned 1777 dir (it shows as owned by nobody and this user can still write in it): agent-tty create made its per-home dir and socket inside it without complaint. The agent-tty client connects to the path and accepts any answer that echoes the request id, so a party that can rename entries in that dir can move the per-home dir away, listen on a socket of the same name, and agent-tty snapshot prints what it answers (the repro printed FORGED-BY-SQUATTER). Launch now makes /tmp/agent-tty with mode 0700 when it is missing and refuses one that is a symlink, is owned by another user or is group- or world-writable, through the check that already covers VERIFY_OUT (unsafe_dir). The check runs before launch makes the state dir, so a refusal leaves nothing behind. Proof: five cases in proof-launch-refusals.sh in a bwrap sandbox with a /tmp of its own (att-root-foreign, att-root-loose, att-root-link refuse; att-root-made and att-root-own-755 accept). The tmp-link-foreign case shares its sandbox setup with them. SKILL.md says what launch does, and that root is fine above the state dir. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
…s none Round-5 verifiers of #506 (F1): deleting `[[ -d $VCN_STATE/tmp ]] || mkdir -m 700 ...` from att() left the refusal proof at 34 PASS and the resolution proof at CASES 45. The line decides whether `record` works on a run that an older vcn.sh started, because that run has no tmp dir and agent-tty needs one for its WebM export. tmp-removed launches, removes $VCN_STATE/tmp, and requires `record` to exit 0 with a non-empty r2.webm and tmp back at mode 0700. Without the mkdir in att() the case fails: record exits 1 (agent-tty EXPORT_ERROR). _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
… run the static proofs in CI Follow-up of #506 and #508 (their verifiers' index items N3, m1-m5, F1 and F2). get and dashboard. The five journey-*.sh scripts and the dashboard command still ran `source "$VCN_STATE/run.env"` in the caller's own shell, with no check of the state dir. `vcn.sh get [-z] <NAME>` answers one value of run.env after load's check, and only a name that run.env holds. A journey reads OUT and WORK through it (`-z` keeps a newline at the end of a path), so a stale exported OUT cannot redirect its evidence, and `vcn.sh dashboard` runs the agent-tty dashboard through att(). Nothing parses printed text, which the #508 round-5 verifier showed to be forgeable. NODE_DIR. att() puts NODE_DIR in front of PATH. load and launch now refuse an empty or relative NODE_DIR (the current dir would go on PATH) and one with a colon (a second dir). A dir that lost its node is not refused, so cleanup still runs. Static proofs. proof-static.sh holds no-bare-tools and doc-vars, moved out of the resolution proof, and `mise run check` runs it, so CI does. It closes the gaps of the #508 round-6 index: indented and ~~~ fences (m1), a comment that names `source run.env` (m2), a self-test that checked a copy of the set pattern (m3), and `"${JQ_BIN:-jq}"` (m4). A new case, no-source-run-env, fails when any script or recipe of the skill sources a run.env except load. Proof cases: get-values, get-state, journey-out (VERIFY_OUT with ` OUT=` in it, a newline in it and at its end, and a stale exported OUT), dashboard, node-dir in the resolution proof, node-relative in the refusal proof. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
… checks
An independent review of the previous commit found, by reading, that load refused a
run.env that has no NODE_DIR at all. The hand-made run.env files of cleanup-no-agent-tty
and jq-old-run have none, so a full run of the resolution proof stopped at the first
of them. load now checks NODE_DIR when it is set (an empty value is set, and refused).
A run.env that launch left half written stays cleanable, and nothing runs agent-tty
for it, because SID and ATT_BIN are written after NODE_DIR.
- SKILL.md no longer says that the dashboard command runs agent-tty with the caller's
TMPDIR: it goes through att(). It counts three proofs in proof-static.sh, not two.
- unsafe_dir takes the label for the dirs above, so a /tmp that others can write says
"above the agent-tty socket dir". The refusal names `chmod go-w /tmp/agent-tty` for a
mode that agent-tty made under umask 002.
- no-source-run-env finds a source after if, then, do, else, elif, while, until and !,
not only after ; & | ( {. A self-test holds lines that must and must not match.
- no-bare-tools runs the scan of a file (bare_hits) on a sample, so dropping the default
pattern from the scan fails the case, not only a pattern test.
- node-relative puts a failing mise first on PATH: a mise in /usr/bin would otherwise
answer with an absolute path and the case would not reach its refusal.
_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
Round-5 verifier of #506 (F2): SKILL.md said that proof-launch-refusals.sh takes about 60 s. It ran for 163 s (the last gate on #506), about 190 s (a verifier) and 287 s (this branch, with 40 PASS lines, on a host at a load of about 20), because most cases start a launch. The sentence now says 3 to 5 minutes and why. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
The final gate of this branch ran proof-launch-refusals.sh in 338 s (40 PASS lines) on a host at a load of 35. SKILL.md now says 3 to 6 minutes, from 163 s to 338 s. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
|
@codex review |
|
@codex security review |
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
…ks that guard them Round 1 of the verifiers of #545 (Opus F3 to F12, gpt A and B). features/README.md read OUT as `OUT=$($V get OUT)`. A command substitution cuts a newline at the end of the value, and VERIFY_OUT may end in one, so the next documented write went to the twin of the evidence dir. SKILL.md and the README now give one recipe, `getz() { IFS= read -r -d '' "$1" < <($V get -z "$1"); }; getz OUT; getz WORK`, and the full-run block of SKILL.md no longer exports OUT. proof-static.sh gets two cases: doc-get-exact (no recipe reads a path through `$(... get NAME)`) and doc-recipe (the documented line runs on a hand-made run.env with a trailing newline in OUT and WORK, with a control that shows a command substitution cuts it). proof-static.sh, the points of Opus F3 to F6: a comment sets and sources nothing, so `# PORT=$(x)` no longer satisfies doc-vars and `echo x # source run.env` no longer exempts a block. A source after `builtin`, `command` or `eval`, in backticks or in quotes, is found, and so is a span that a line break splits. `$((PORT + 1))` is a use. A missing SKILL.md or features/*.md fails. The header says what the checks do not see (an indented code block, <pre>, a command that a variable holds, a source in a .lua, .py or .mjs file). Each change has a sample in the case and a mutant that turns it red. proof-launch-refusals.sh, gpt B and Opus F7: a case that accepts /tmp/agent-tty requires the launch to go on to `agent-tty doctor`, so a guard that refuses a safe dir with any message fails it (the strict-0700 mutant with another message was green). New cases: att-root-own-700 and att-root-nosticky (a 0700 dir under a /tmp that is world-writable without the sticky bit: only the walk above the dir can refuse it). Opus F9: load names the manual cleanup for a run whose NODE_DIR it refuses: stop the nvim and agent-tty processes and `rm -rf` the state dir. cleanup refuses too, because it would run agent-tty with that PATH. node-dir tests the hint and that the dir stays. Opus F8: get keeps both name checks, and get-values holds a name (`O.T`) that only the pattern check refuses. SKILL.md says that a 0775 /tmp/agent-tty (umask 002) is refused and that the message names `chmod go-w`. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
…gate measured The gate of the previous commit ran proof-launch-refusals.sh (42 PASS lines) in 149 s on a host at a load of about 20. Earlier runs took 163 s, 287 s and 338 s. SKILL.md now says 2 to 6 minutes (149 s to 338 s). _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. Chef's kiss. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
The last gate ran proof-launch-refusals.sh in 626 s on a host at a load of 50 to 90. SKILL.md said 149 s to 338 s. Name the range that the gates measured: 149 s to 626 s. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking:
|
@codex review |
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
…pecs there (coder#564) ## Why The README says the `unified` diff layout needs Neovim 0.9. It does not. The layout uses `vim.diff`, and Neovim 0.8.0 has that function. The 0.9 claim made eight integration specs skip every `unified` case on Neovim 0.8.0, so the 0.8.0 CI job ran no accept, reject, `:q`, window-size or trailing-line-break check for that layout. A regression there would have passed CI. ## What changed 1. **Both READMEs drop the 0.9 claim.** `README.md` (the `diff_opts.layout` comment) no longer says "Requires Neovim >= 0.9.0". `lua/claudecode/README.md` (the `unified` bullet) now says that Neovim 0.8.0 has `vim.diff`, so the layout runs on every Neovim the plugin supports. 2. **Eight specs lose their 0.9 skip.** In each, the `unified` cases now run on every Neovim: `diff_quit_reject_spec.lua`, `diff_window_choice_spec.lua`, `diff_window_sizes_spec.lua`, `diff_close_save_spec.lua`, `diff_hand_over_spec.lua`, `diff_accept_trailing_newlines_spec.lua`, `diff_accept_missing_final_newline_spec.lua`, `diff_accept_emptied_buffer_spec.lua`. In `diff_quit_reject_spec.lua` and `diff_window_choice_spec.lua` the skip covered the whole file. 3. **The error message tells the truth.** `setup_inline_diff` in `lua/claudecode/diff_inline.lua` raised `-32000` with "Inline diff requires Neovim >= 0.9.0" when Neovim has no diff function. The guard checks for the function (`vim.text.diff` or `vim.diff`) and never for a version, so the message now says what is missing: "The unified layout needs vim.text.diff or vim.diff, and this Neovim has neither". A unit test drives the guard with both functions removed. It fails on the old message (50 successes, 1 failure) and passes on the new one (51 successes). 4. **One 0.9 gate stays.** `diff_hand_over_spec.lua` picks `:w ++p` only when Neovim has it (0.9 and later) and a `mkdir` plus `:w` on 0.8.0. That is a real difference between the versions, and it is not about the `unified` layout. 5. **One case asserts the error it expects.** In `diff_window_sizes_spec.lua`, the case "gives the sizes back when the unified diff's setup fails" checked only that some error came back. With the unified path broken on Neovim 0.8.0 (a diff function that ignores `vim.diff`), it stayed green, because the guard raised an error too. It now asserts the `Diff setup failed` message and that the error data carries the failure of the user's autocmd. With that broken path it fails on 0.8.0, and it passes on 0.8.0 and 0.12.3. The tree holds no other 0.9 claim about the `unified` layout. I checked every `0.9` and `nvim-0.9` line in code, docs, comments, error strings, specs, scripts and the CI workflow. The ones that remain are about something else: `nvim -l` (0.9 and later only), `statuscolumn` and `:w ++p` gates in `diff.lua` that are correct, the LuaJIT bug in Neovim 0.8 and 0.9, visual mode in 0.8 and 0.9, and the RPC process model. `CHANGELOG.md` and its fixture copy keep their old lines, because they describe what the release did when it shipped. `diff_inline.lua` is covered by the diff lifecycle model. The change touches only the `setup_inline` block, and only a comment and an error string in it, so no behavior changed. The model passes (89205 states, every fact TRUE) and `covered.lock` is recorded for the new file. ## How I checked it The specs ran on a real Neovim 0.8.0 (`v0.8.0-1210-gd367ed9b2`) before and after the change: | spec (Neovim 0.8.0) | successes before | successes after | | --- | --- | --- | | `diff_quit_reject_spec.lua` | 0 (skipped) | 26 | | `diff_window_choice_spec.lua` | 0 (skipped) | 14 | | `diff_window_sizes_spec.lua` | 19 | 24 | | `diff_close_save_spec.lua` | 40 | 77 | | `diff_hand_over_spec.lua` | 12 | 13 | | `diff_accept_trailing_newlines_spec.lua` | 14 | 28 | | `diff_accept_missing_final_newline_spec.lua` | 64 | 94 | | `diff_accept_emptied_buffer_spec.lua` | 34 | 40 | That is 133 more tests on Neovim 0.8.0, with 0 failures and 0 errors. 13 of the 133 are vertical-layout cases of `diff_quit_reject_spec.lua`, which the file's 0.9 skip also hid. Three other specs (`diff_new_tab_bufenter_spec.lua`, `diff_new_tab_mode_spec.lua`, `diff_terminal_float_spec.lua`) already ran their `unified` cases on 0.8.0 without a 0.9 skip, and they pass. I also ran a unified diff by hand in the real Neovim 0.8.0 terminal UI (tmux, 110 columns). The proposal changed `two` to `TWO` and added `four`. The screen showed a red `- ` sign and a red-tinted line for the deleted row, and a green `+ ` sign and a green-tinted line for each added row. `:w` answered `FILE_SAVED` with the exact proposal text `one\nTWO\nthree\nfour\n`. `:q` answered `DIFF_REJECTED`. Gates on the final commit `598b0acc`, which merges `origin/main` 523ee20 into the branch (private XDG folders). Since coder#545, `mise run all` also runs the static proofs of the `verify-claudecode-nvim` skill, and they pass: - `mise run all`: native exit 0. luacheck: 0 warnings / 0 errors in 185 files. busted: 1738 successes / 0 failures / 0 errors / 28 pending. treefmt: 0 files changed. - `mise run format-check`: native exit 0 (427 files, 0 changed). - `scripts/run_integration_tests_individually.sh` on Neovim 0.12.3: 527 successes, 0 failed, 0 errors, native exit 0. On Neovim 0.8.0: 523 successes, 0 failed, 0 errors, native exit 0 (390 on `origin/main`, 133 more now). - Full `verify-diff-lifecycle/check.sh`, then `--record`, then all four `.agents/skills/verify-*/check.sh --hash-only`: every one matches its `covered.lock`. ## Left as it is `CHANGELOG.md` keeps its old lines about 0.9 and about the 0.8.0 job skipping `unified`. They describe what the release did when it shipped. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
… other path characters (coder#561) ## Why `cwd.git_root` must find the repository of a folder whatever its name is. A repository in a folder with a space or a comma in its path was not found when git could not run: the fallback gave nil, or the repository of Neovim's current folder. A folder named `[x] {y}` or `$HOME-like` was lost or changed even when git answered. A verifier of F12 found the first case (finding F12-P1, a space or comma in the path makes the `.git` fallback return nil). ## What changed Four problems in `lua/claudecode/cwd.lua`. The first two took a path as a pattern. The third stepped one folder down at a trailing `..`. The fourth is a flaw in my first version of the second step. 1. **The `.git` fallback** called `vim.fn.finddir(".git", start_dir .. ";")`. `finddir` reads its path as a list in `'path'` option syntax. A space or a comma split the path, and a `;` ended it. The fallback now walks up from the full path of `start_dir` and tests each folder for a `.git` folder with `vim.fn.isdirectory`. A relative `start_dir` now reaches the folders above the current one too. Before, a relative start gave a wrong answer or nil in some cases (for example `git_root("sub/deeper")` in the repository root). 2. **`normalize_dir`** passed every path through `vim.fn.expand`. `expand` reads `[`, `{`, `*`, `?`, `$` and `\` in a name as patterns or variables. A folder named `[x] {y}` or `it's` came back with a trailing slash. A folder named `$HOME-like` or `back\slash` came back nil. This also happened when `git rev-parse` answered. `normalize_dir` now returns a directory that exists as written, and expands any other string (`~`, `$VAR`). See step 4 for text that starts with `~` or `$`. 3. **`..` before Neovim 0.11.** Before 0.11, `fnamemodify(path, ":p")` keeps a last `..`, and `:h` of `a/b/..` is `a/b`. So a walk from a start folder that ends in `..` stepped one folder down and returned the wrong root. The walk now calls `simplify()` on the full path first. A verifier found this. 4. **Text that starts with `~` or `$`.** The first version of step 2 took a path that names an existing folder as written, on the raw text. `vim.fn.isdirectory` does not expand, so with `cwd = "~"` or `"$HOME"` and a stray folder of that literal name in Neovim's current folder (for example one that an unexpanded `mkdir "~/x"` made), `git_root` and `resolve` returned the stray folder where main returned the home folder. `normalize_dir` now sends text that starts with `~` or `$` through `expand` first, as a shell and `:cd` do. A verifier of the terminal change found this. `lua/claudecode/README.md` gets one new bullet that states this, with the rule for text that starts with `~` or `$`. It sits after the "Terminal cwd precedence" bullet. It names `git_root` and `resolve` only, and it says that the terminal still passes a static `cwd` and a `cwd_provider` result through `vim.fn.expand` itself. ## Scope - Only `cwd.lua` changes in code. Steps 1 and 2 are one change of idea: a path is a literal, not a pattern. Step 3 is a quirk of `fnamemodify` before Neovim 0.11. Step 4 closes a hole that step 2 opened. - `normalize_dir` serves `cwd.git_root` and `cwd.resolve`. Nothing in `lua/` calls `cwd.resolve`. The terminal calls `cwd.git_root` when `git_repo_cwd` is set, so this PR fixes folder names with `$` and `\` on that path: when git does not answer, the terminal now gets `$HOME-like`, `back\slash`, `[x] {y}`, a space and a comma right, where main gave nil or the repository of Neovim's current folder. A static `cwd` and a `cwd_provider` result do not go through `normalize_dir`: `terminal.lua:build_config` passes them through `vim.fn.expand` itself, so this PR does not change what the terminal does with those. An absolute start path that ends in `..` is fixed here, through the `simplify()` call above. - Two gaps stay for separate follow-ups. Main already has both, and this PR does not reach them. First, the fallback finds a `.git` folder only, not a `.git` file in a worktree or submodule. Second, the walk is lexical, so a link into another repository answers with the repository of the link's own folder, where git answers with the link target's repository. The terminal's `expand` of a static `cwd` and of a provider result is a third follow-up, and it has its own change. - `~` and `$VAR` still expand in `cwd.resolve` (the spec checks both). The spec's cases for `cwd.resolve` are named as `cwd.resolve` cases, because they do not test the terminal. - `git rev-parse` still runs first. Its result for a plain path is the same string as before. ## Blast Radius - User-visible: a repository in a folder with a space, a comma, a quote or another path character is now found when git cannot run. With git, `git_root` returns a folder with `[`, `{`, `*`, `?`, `$` or `\` in its name exactly. It does not reach the terminal through a `cwd_provider`: `build_config` still expands the provider's result, so the root README's example still loses `$` and `\` there (follow-up). A plain path gives the same result as before. - The fallback result is an absolute path with no `..` part. Before it could be relative. The one exception is a relative start whose name begins with `~`: Vim reads `~` as the home folder and `~name` as a user's home folder, so `:p` leaves `~name` relative and the result can be the relative `~name`. That is a pathological case, and no caller in `lua/` passes a relative path (the terminal passes the file's folder from `expand("%:p")` or `getcwd()`). - A string that names an existing folder is taken as written, except text that starts with `~` or `$`. That text always goes through `expand` first, so a folder named `~` or `$HOME` in Neovim's current folder does not take the place of the home folder, as on main. The cost: a relative folder name that starts with `$` and reads as a variable (`$HOME-like`, `$odd/sub`) is expanded and not found, as on main. A lone relative `$odd` that names no variable is left unchanged by Vim and is still found. An absolute path never starts with `~` or `$`, so every folder name this PR fixes keeps working. The change applies to `git_root`'s `start_dir` and to `cwd.resolve`. The terminal's own `expand` of a static `cwd` and a provider result is a separate change. - Neovim 0.8: the code uses `vim.fn.isdirectory`, `vim.fn.fnamemodify` and `vim.fn.simplify`, which exist there. I ran the new spec on Neovim 0.8.0, 0.10.0 and 0.12.3. The `..` case fails on 0.8.0 and 0.10.0 without the `simplify()` call, and passes on 0.12.3 either way. - `covered.lock`: `cwd.lua` is the terminal's working-directory lookup. `check.sh` printed STALE for the file hash of `cwd.lua` only, with no changed block or call site. The change cannot reach diff state, an openDiff answer, a disconnect or a diff command. The model passed (`main`: 89205 distinct states, `live-DisconnectedCallsAnswered`: 5269). `check.sh --record` wrote the new lock and `check.sh --hash-only` reports that it matches. ### Merge with coder#539 (F12) coder#539 landed first. This PR's merge commit with `origin/main` resolves the interplay: it re-records `covered.lock` and changes the one stale test in coder#539's `cwd_spec.lua` (see Verification). F11 (coder#535) merges with this PR without a conflict. ## Verification Red before the fix, green after (same spec, only `lua/claudecode/cwd.lua` swapped): | Spec | origin/main | with the fix | | --- | --- | --- | | `tests/integration/cwd_path_characters_spec.lua` (37 cases, real Neovim, 0.8.0 and 0.12.3) | 20 successes, 17 failures | 37 successes, 0 failures | | the new case "goes up from a start folder that ends in .." at `ede2f733` (before the `simplify()` call) | fails on 0.8.0 and 0.10.0, passes on 0.12.3 | passes on 0.8.0, 0.10.0 and 0.12.3 (35 successes when added, 37 at the final head, 0 failures on each) | | the two cases "expands a leading ~ / $ even when Neovim's cwd holds a folder named ~ / $HOME", with `cwd.lua` from `ca9e61c0` (before the guard) | 35 successes, 2 failures on 0.8.0 and 0.12.3 | 37 successes, 0 failures on each | The spec makes real folders and runs `cwd.git_root` on them, in two modes. In the first mode git is off `PATH` (the shell keeps its absolute path), so `git` ends with "not found" and a list command raises E475, as on a host without git. In the second mode a real `git init` makes the repository. The folder names are: a space, a comma, a semicolon, single and double quotes, brackets and braces, parentheses, non-ASCII letters, a star and a question mark, a percent sign and a hash, a backslash, a dollar sign, a leading tilde, and a plain name. Each name runs from the repository folder and from a folder two levels below it, and the answer must equal the repository path with no trailing slash. A folder that is not a repository returns nil, whatever its name. Seven more cases cover a relative start folder, a start folder that ends in `..`, `cwd.resolve` with `git_repo_cwd`, a static `cwd` with brackets and a star in `cwd.resolve`, `~` and `$VAR` in a setting of `cwd.resolve`, and two cases where Neovim's current folder holds a folder named `~` or `$HOME` and the setting must still expand. The 17 red cases are 10 of the no-git names, 4 of the git names, the relative start, `resolve`, and the static `cwd`. Gates on the final head `f12368bd`, which contains a merge of `origin/main` (05b9785, with coder#551, coder#535, coder#494, coder#545, coder#553, coder#555 and coder#557) (private XDG folders). An independent verifier passed `c73ab044`. Since then the branch has three changes of its own and two merges with main. The changes are a docs commit (the README sentence about where the walk starts), the fix for text that starts with `~` or `$` (step 4, with two spec cases and the README clause), and the `covered.lock` record for it. For the lock I ran the full `check.sh`: the model passed (89205 distinct states, 5269 live states, every fact TRUE), and only the file hash of `cwd.lua` was stale, with no changed block or call site. - `mise run all`: native exit 0. luacheck: 0 warnings / 0 errors in 186 files. busted: 1756 successes / 0 failures / 0 errors / 29 pending. treefmt: 0 files changed. Since coder#545, `mise run all` also runs the static proofs of the `verify-claudecode-nvim` skill, and they pass. - `mise run format-check`: native exit 0 (428 files, 0 changed). - `scripts/run_integration_tests_individually.sh` on Neovim 0.12.3: 564 successes, 0 failed, 0 errors, native exit 0. On Neovim 0.8.0: 427 successes, 0 failed, 0 errors, native exit 0 (the 0.8.0 run skips the specs that need 0.9). The new spec gives 37 successes on both. It starts about 100 processes (git and a shell), so it takes about 6 s on a quiet host and can pass the runner's 40 s limit at a load average above 100. A TIMEOUT line there means the host was slow, not that the spec hung. - All four `.agents/skills/verify-*/check.sh --hash-only` match their `covered.lock`. The merge with `origin/main` had two parts to resolve in an earlier commit. `covered.lock` conflicted on the hash line of `cwd.lua`, so I ran the full `check.sh` and `check.sh --record`. coder#539's `tests/unit/cwd_spec.lua` had a test that stubbed `vim.fn.finddir`, which the fallback no longer calls, so that test now walks up real folders. It does not depend on where `TMPDIR` is: it passes with `TMPDIR` inside a git checkout too. `cwd.lua` itself merged without a text conflict. The two latest merges needed no `covered.lock` change: coder#551 conflicted only in `tests/mocks/vim.lua`, where the branch keeps both its `simplify` entry and coder#551's `wordcount`, and coder#535 and coder#494 merged without a conflict. An independent verifier found the `..` case, the README wording and the other points that this body now states. The `terminal.lua` expand and `.git` files in worktrees are gaps that main already has. They are follow-ups, not part of this PR. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…cipe fail loudly (coder#566) ## Why coder#545's readiness advisor left five small follow-ups in the `verify-claudecode-nvim` skill. Four are claims that said more than the check behind them, and one is a recipe that failed without a word. Codex and a second independent review found more gaps in the new checks, and this PR closes all but the scanner gaps listed under "Not covered". ## What changed All changes are in `.agents/skills/verify-claudecode-nvim/`. `scripts/vcn.sh` changes one comment line (the usage header, see 7) and no code. `bin()` and the `VCN_NVIM` branch are untouched, and so are both launch proofs. 1. **Claims of the static scanner.** The `proof-static.sh` header, its five `ok` lines and the `SKILL.md` bullet say only what the scanner sees, except the `doc-get-exact` gaps under "Not covered". They list what it does not see: an indented code block, `<pre>`, a command that a variable holds, a source in a `.lua`, `.py` or `.mjs` file, a `read` that takes `get` through a pipe or a file, and seven shapes that no code block and no code line of a script has (a command substitution or `read < <(...)` whose `get` is on the next line, a `read -d ''` with no `IFS=`, a backtick substitution of `get`, a `source` whose `run.env` is on the next line after a backslash, a quoted command word such as `"source" run.env`, a `run.env` named through `BASH_ENV`, and a use of `PORT`, `SID`, `NVIM_PID` or `NODE_DIR` without a `$`, as in `let` or `(( ))`). I searched the skill for each of the seven and found none. The scanner has no new rule for them: a stated limit is the fix for a shape that no file has. The `doc-vars` line also says that the inline code of a doc counts as one unit, and `SKILL.md` names the checks that skip a block that sources a `run.env`. 2. **`doc-get-exact`** also flags `$(... get -z NAME)`, and a `read ... < <(... get NAME)` that has no `-d ''` among its own words. Another `read` on the same line does not hide it. A name in quotes (`get -z "OUT"`) counts as the name. A new function `path_reads` holds both rules, and the case's samples call it too. 3. **`doc-recipe`** reads a value that holds a backslash (`back\slash \\ end\n`, besides the newline and quotes), and runs each documented line with no `V` in the environment, from the repo root. Before, it supplied `V`, so a line that lost its own `V=` still passed. It builds its state dirs inside a dir named `a:b`, so a path with a colon works. 4. **`getz` fails loudly.** `getz() { IFS= read -r -d '' "$1" < <("$V" get -z "$1") || { echo "vcn.sh get $1 failed" >&2; return 1; }; }; getz OUT && getz WORK || exit 1`. When `get` refuses, the call prints `vcn.sh get <NAME> failed` and stops with status 1, so no later command runs with an empty `OUT`. (The first head of this PR only returned 1 from the function, and the call went on.) The `SKILL.md` line sets `V=` itself, as the README line does. `doc-recipe` has three refused-get runs per doc: a group-writable state dir (`load` refuses it), a `run.env` without `OUT`, and one without `WORK`. Each must exit with status 1 exactly, name the failed `get`, and run no later command. 5. **`SKILL.md` line 55:** the sentence "Keep it short: nvim's socket ..." is back after "the default passes.". coder#545 had put the `agent-tty` sentences between them. 6. **`SKILL.md` line 134:** the refusal proof's time is "2 minutes or more, 149 s to 966 s measured, depending on the load of the host", with no maximum. My own runs measured 149 s to 626 s. The 966 s is the figure from the issue brief. 7. **`vcn.sh` usage comment (line 19):** it said that `read -r -d ""` keeps a newline at the end of a value. With the default `IFS`, `read` trims it (a probe: `a\n` reads as `a`, and as `a\n` with `IFS=`). It now says `IFS= read -r -d ""`. No line of code changes: the non-comment lines of the file are identical. ## Evidence Every row is a mutant of the proof or of a doc, run on a copy of the final skill dir. Each red row fails at the case that the row names. The last rows are the opposite: a break that the check cannot see stays green, which shows what the check is for. | Change | Mutant | Result | | --- | --- | --- | | Control | none | green, `CASES 5` | | `-z` in the substitution rule | name pattern without the `-z` group | red: `doc-get-exact: the patterns miss: x=$($V get -z OUT)` | | The `read` rule | `path_reads` without it | red: `doc-get-exact: the patterns miss: IFS= read -r OUT < <($V get -z OUT)` | | `-d ''` only | any `-d` flag counts | red: `... read -r -d x OUT ...` | | `-d ''` bound to its own read | test on the whole line again | red: `... read -r -d "" scratch <<< x; IFS= read -r OUT < <($V get -z OUT)` | | Quoted names | quote groups removed | red: `... OUT=$($V get -z "OUT")` | | A doc with a `read` of a path and no `-d` | README span added (also with two reads on a line) | red: `doc-get-exact: features/README.md reads a path ...` | | A doc with `$($V get -z OUT)` or a quoted name | SKILL.md span added | red: `doc-get-exact: SKILL.md reads a path ...` | | Backslash in the value | README `getz` without `-r` | red: `doc-recipe: ... read OUT as '...'` | | No `V` of its own | README or SKILL.md `getz` without `V=` | red: `doc-recipe: ... failed: ... command not found` | | The refused-get message | README or SKILL.md `getz` without it | red: `doc-recipe: ... did not say 'vcn.sh get OUT failed'` | | `&&` between the two `getz` | `;` in README or SKILL.md | red: `doc-recipe: ... exited 0, wanted 1, after a refused get (OUT ...)` | | `|| exit 1` | removed, or `exit 2` | red: `... exited 0, wanted 1 ...`, `... exited 2, wanted 1 ...` | | A swallowed `WORK` failure | `{ getz WORK \|\| :; }` in README or SKILL.md | red: `doc-recipe: ... exited 0, wanted 1, after a refused get (WORK ...)` | | Directory and name in one string | colon-packed loop again | red: `doc-recipe: did not say 'vcn.sh get b/vcn.XXXXXX:OUT failed': vcn: no run at /tmp/.../a` | | Gap: the exact status | status test `!= 0`, README `exit 2` | green: with the exact test, the `exit 2` mutant is red | | Gap: the no-`WORK` case | case removed, SKILL.md swallows `WORK` | green: with the case, the same break is red | | Gap: README `getz` without `-r`, or without `V=`, `$($V get -z OUT)` in SKILL.md, `read -r OUT` of a path in the README | main's proof (from coder#545) | green (four runs) | | Gap: two reads on one line, a quoted name `get -z "OUT"` | the proof of this PR's first head | green (two runs): Codex's findings 1 and 2 | Codex's colon case, reproduced first: with a colon in `TMPDIR`, the old proof fails `doc-recipe` with `did not say 'vcn.sh get tmp/tmp.XXXXXXXXXX/vcn.XXXXXX:OUT failed'` and a truncated `VCN_STATE`. The final proof passes the same run (`CASES 5`). ## Gate On `858a482a`, the last head that changed code. The final head `ca9c9a32` adds only comment lines and `SKILL.md` text; an independent rerun of `mise run all` on `ca9c9a32` gave exit 0, 1756 successes, 0 failures, 0 errors, 28 pending, and `proof-static.sh` `CASES 5`. | Check | Result | | --- | --- | | `mise run clean && mise run all` | exit 0 in 1584 s. 1756 successes, 0 failures, 0 errors, 28 pending. `luacheck`: 0 warnings, 0 errors in 185 files. Its `check` task runs `proof-static.sh` | | `check.sh` of `verify-diff-lifecycle` | `== RESULT: PASS` | | `proof-static.sh` | exit 0, `CASES 5` | | `VCN_NVIM` = Neovim v0.8.0 (the official release binary), `vcn.sh launch`, `doctor`, `journey-opendiff-accept.sh`, `cleanup` | all four exit 0, the journey prints `PASS opendiff-accept`, the state dir and the saved PIDs are gone. This ran because `vcn.sh` changes (one comment line) | | `proof-launch-refusals.sh`, `proof-agent-tty-resolution.sh` | on the first head (`44d57979`): refusals exit 0 with 42 `PASS` in 360 s (3 ACL `SKIP`), resolution exit 0 with `CASES 49` and no `SKIP` in 1765 s (a restart of the host killed its first run, so this is its second). Both proofs run `vcn.sh`, which differs from that head by one comment line, and neither reads another file that this PR changed, so they did not run again | ## Not covered - The scanner misses a one-line `$(...)` with a `)` before its `get` (for example `OUT=$(cd "$(dirname "$V")"; "$V" get -z OUT)`), a `read ... < <(...)` with `;`, `&` or `|` before its `get`, and `{ IFS= read -r OUT; } < <(...)` or `while IFS= read -r OUT; do ...; done < <(...)`. No doc or script has these shapes today. A follow-up PR fixes the scanner for them. - The scanner's stated limits stay limits. A new shape in a doc or script that one of them hides is not seen. - `getz OUT && getz WORK || exit 1` ends the shell of the call that runs it. `SKILL.md` says that every bash call is a fresh shell, and the README starts every bash call with this line, so no session is lost, but a reader who pastes the line into a long-lived shell closes it. - The 966 s of the refusal proof is not a measurement of mine. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…HTTP retries (coder#560) ## Why On coder#545's merge-group runs (43206505037 and 43206955049) the `unit-tests` job failed in `Set up mise`, before it ran a test. `www.lua.org` reset the connection for `lua-5.5.0.tar.gz`, and mise gave up after its 3 retries (4 attempts, 10.6 s). Other PRs passed because they hit the tool cache. coder#545 edited a task in `mise.toml`, so its runs missed the cache (`mise cache not found for mise-v1-linux-x64-2026.11.5-bd6141...`) and every tool downloaded again. A `mise.toml` edit that does not change the tool set must not download tools again. ## Cause From `src/index.ts` of `jdx/mise-action` at the SHA that `test.yml` pins (v4.0.1): the default key here is `mise-v1-<platform>-<mise version>-<file_hash>`. The template adds `MISE_ENV` and a hash of the tool names in `install_args` only when they are set. `--locked` names no tool, so this key has neither. `file_hash` hashes every `mise.toml`, `mise.lock`, `.mise.toml` and `.tool-versions` in the repo, so any `mise.toml` edit, a task edit included, changes the key. Of the 10 commits on main that touched `mise.toml`, 5 left `mise.lock` alone. coder#545 was one of them: its `mise.lock` blob is the same before and after the merge, and its `mise.toml` is not. ## What changed Only `.github/workflows/test.yml`, in the `Set up mise` step of the `unit-tests` job: 1. `cache_key: mise-v1-{{platform}}-{{version}}-${{ hashFiles('mise.lock') }}`. The key keeps the shape of the default key (`mise-v1-linux-x64-2026.11.5-<hash>`) and hashes only `mise.lock`, which pins the tool set. mise-action restores and saves from this exact key, as before. 2. `cache_save: ${{ github.event_name == 'push' }}`. Only a push to main saves the cache. A pull request, the merge queue and a manual run restore it and never write it. mise runs the `postinstall` hooks of a PR's own `mise.toml` during `mise install`, mise-action saves after that, and the key ignores `mise.toml`. Without this line, a PR could keep a tampered tool in its own cache after it removed the hook. 3. `MISE_HTTP_RETRIES: "6"`. mise retries a failed HTTP request 3 times by default, with a backoff of about 0.2, 1 and 4 s (2.6 to 5.2 s in total). 6 retries add three more backoffs of about 15 s each, so the total is 25 to 50 s. `release-notes.yaml` and `release-please.yaml` already set `cache: false` (they publish), and `integration-tests` and `covered-lock` do not install mise. They are unchanged. The pinned mise version and its `sha256` are unchanged. ## What I did not do, and why The first head of this PR used a separate `actions/cache` step with a `restore-keys` prefix, so that a lock change would restore the previous cache and download only the changed tools. Codex raised four valid findings on it, and I dropped the design: 1. The step's path `~/.local/share/mise` gives another cache version than the absolute path that mise-action uses (`e61abf9f359d` for `~/.local/share/mise` and `7a8f38effbb6` for the absolute path: each is the first 12 characters of the `sha256` of `<path>|zstd-without-long|1.0`), so it could not restore the caches that mise-action had saved. The first CI run of that head printed `Cache not found for input keys: mise-v1-linux-x64-2026.11.5-cb7d66..., mise-v1-linux-x64-2026.11.5-`. 2. A `restore-keys` fallback restores installed tools across lock changes. mise does not check an installed tool against `mise.lock` (its docs: "Restored caches and installed tools"), so a tool whose checksum changed at the same version would stay as it was. 3. The fallback also keeps every old tool version in the next cache. 4. The extra step saved the cache in its post step, after the PR's scripts had run. mise's own advice is to cache the download store (`downloads-cas`) instead of the installs. That would not have prevented this failure. With the pinned mise 2026.11.5, a `--locked` install of `lua@5.5.0` downloads the tarball through the vfox plugin, unchecked, and neither my run nor an independent rerun made a `downloads-cas` dir. So a lock change still downloads every tool, as it does today, and a PR that changes `mise.lock` downloads every tool on each of its runs, because a PR does not save. The 6 retries cover a short reset of a download host. They do not cover an outage of several minutes. ## Evidence | Claim | Evidence | | --- | --- | | The failure is the cache miss plus a `www.lua.org` reset | Log of run 43206505037, job `unit-tests`: `mise cache not found for mise-v1-linux-x64-2026.11.5-bd6141...`, three `HTTP https://www.lua.org/ftp/lua-5.5.0.tar.gz attempt N failed (transient)` warnings with retries in 184 ms, 780 ms and 3.56 s, then `lua@5.5.0 ... failed: ... Connection reset by peer`. Run 43206955049 fails the same way, 3 minutes later, with the same key | | The action hashes every mise file | `src/index.ts` at the pinned SHA: `processCacheKeyTemplate` hashes `MISE_CONFIG_FILE_PATTERNS` | | A task edit leaves the new key alone | `git diff --stat` of coder#545's merge shows `mise.toml` changed and `mise.lock` not. The `mise.lock` blob is `f3df30fd` on both sides, so `hashFiles('mise.lock')` is the same | | The template gives the key above | Handlebars 4 (the engine that mise-action bundles) renders `mise-v1-{{platform}}-{{version}}-<hash>` with `linux-x64` and `2026.11.5` as `mise-v1-linux-x64-2026.11.5-<hash>` | | The saved cache has the version of main's caches | The repo's cache list: the entry that this PR's second head saved (`mise-v1-linux-x64-2026.11.5-cb7d66...`) has version `7a8f38effbb6`, the same as the caches of main. The first head's entry has `e61abf9f359d` | | mise's retry setting works as the comment says | Source of mise v2026.11.5 (`crates/mise-util/src/http.rs`): the schedule is about 200 ms, 1 s, 4 s, then 15 s, each with a random factor of 0.5 to 1. A local probe against a refused URL with a scratch mise dir: default `http_retries` made 4 attempts in 4 s. `MISE_HTTP_RETRIES=5` made 6 attempts in 29 s | | The retries rescue a run | Run 43220955113 (second head), job `unit-tests`, step `Set up mise`: `www.lua.org` failed 4 times (resets and a 500). The 5th attempt, after a 10.5 s backoff, downloaded Lua and the job passed. With mise's default of 3 retries it would have stopped after the 4th failed attempt, as on coder#545's runs | | The cache key resolves and the cache saves | Same run and step: the input prints as `cache_key: mise-v1-{{platform}}-{{version}}-cb7d66...`, the action prints `mise cache not found for mise-v1-linux-x64-2026.11.5-cb7d66...` (the first run with this key), then `Cache saved from /home/runner/.local/share/mise with key: mise-v1-linux-x64-2026.11.5-cb7d66...` | | The workflow is valid | `actionlint` 1.7.12 on every workflow: exit 0. `zizmor` 1.25.2 (`--offline`) on every workflow: no findings (25 suppressed), the same as main. `mise run format-check`: exit 0. Each of these ran on every head | | Gate | `mise run clean && mise run all` exits 0 in 848 s on the second head: 1737 successes, 0 failures, 0 errors, 28 pending, `luacheck` 0 warnings in 185 files. No test reads `test.yml`, and the later heads change only comments and the `cache_save` line, so the lint checks above cover them | | CI | On the final head, all 6 checks are green: `conventional-title`, `unit-tests`, `covered-lock`, `integration-tests (stable)`, `integration-tests (v0.8.0)` and `Required` | | Review | Codex found 6 issues: 4 on the first head (all fixed: the design above was dropped), 1 security issue on the third head `d08f3953` (fixed in `61d7ea13`: the cache now saves only on push), and 1 on the final head (rotate the cache key, because an earlier head could have saved a cache under it), declined with evidence. That head ran with the same `mise.toml` and `mise.lock` as main, and `mise.toml` has no hook. 0 threads are unresolved. This PR used 8 Codex review requests, past the cap of 6. The maintainer's record is in the PR conversation | ## Not covered - No test or lint checks the three settings. A later edit that removes `cache_key`, `cache_save` or `MISE_HTTP_RETRIES` keeps CI green. A follow-up PR adds a workflow guard. - Main has no cache entry with this key yet. Run 43227198398 on the final head printed `mise cache restored from key: mise-v1-linux-x64-2026.11.5-cb7d661d...`, but from an entry that run 43220955113 saved under `refs/pull/560/merge`, which only this PR can read. The merge-queue run of this PR and every run until the first push to main whose `Set up mise` step succeeds miss and download all 15 tools, with the 6 retries. The first push to main saves, and the next run with the same `mise.lock` restores. The saved entry has the cache version of main's caches (see above). - A `mise.lock` edit that leaves the 15 tools of CI alone (an entry for macOS only, or for a tool that only a task installs) changes the hash and misses the cache. - The key ignores `mise.toml`, `install_args` and `MISE_ENV`. A change that alters an install without changing `mise.lock` (a tool option in `mise.toml`) keeps the key. The comment in `test.yml` says to bump `v1` then. - A `mise.lock` change downloads every tool again. A download host that is down for longer than the 25 to 50 s of backoff still fails that run. - The luarocks cache keeps its key on `hashFiles('mise.toml')` on purpose: the rock list is in the `setup` task of `mise.toml`. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
Why
#506 and #508 left six loose ends in the
verify-claudecode-nvimskill (their verifiers' findings N3, m1 to m5, F1 and F2, and an advisor lead):journey-*.shscripts and the dashboard command still ransource "$VCN_STATE/run.env"in the caller's shell. No check of the state dir covers that, so a stale or foreignVCN_STATEran an unchecked file. Earlier attempts cut values out of printed text, and the docs(skills): stop telling callers to source run.env, and make the bare-tool self-check strict #508 round-5 verifier showed that text is forgeable (aVERIFY_OUTwithOUT=in its name sent a journey's evidence to another folder).doc-varsandno-bare-toolsself-checks had test gaps (indented and~~~fences, a comment that namessource run.env, a self-test of the wrong pattern copy,"${JQ_BIN:-jq}").att()putsNODE_DIRin front ofPATH, and nothing checkedNODE_DIR. Neither static check ran in CI.mkdirinatt()left both proofs green, though it decides whetherrecordworks on a run that an oldervcn.shstarted.SKILL.mdsaid the refusal proof takes about 60 s (it takes 2 to 11 minutes), and one sentence did not say that root is fine above the state dir.agent-ttykeeps its sockets under a fixed/tmp/agent-ttyand never checks who owns it.What changed
All changes are in
.agents/skills/verify-claudecode-nvim/andmise.toml.vcn.sh get [-z] <NAME>prints one value ofrun.envafter the check thatloadmakes of the state dir. It answers only a name thatrun.envholds (notPATH, not a name that is absent).-zends the value with a NUL, soIFS= read -r -d "" OUT < <(vcn.sh get -z OUT)keeps a newline at the end of a path. The five journeys readOUTandWORKthis way: nothing is sourced, no printed text is parsed, and anOUTthat the caller exported cannot redirect them.vcn.sh dashboardreplaces the dashboard recipe: it runsagent-tty dashboardthroughatt(), so it gets the sameNODE_DIR,TMPDIRand state check.scripts/proof-static.shholdsno-bare-toolsanddoc-vars(moved out of the resolution proof, so its count drops by two),no-source-run-env, and (round 2)doc-get-exactanddoc-recipe. It reads files only.mise run checkruns it, so CI does. The gaps are closed: fences are three or more backticks or tildes with any indent and a closing fence of the same character (m1), a whole-line comment that names a source is not a source (m2),units_missingand the self-test call the same two pattern functions, and a sample block that sets a variable to a literal must still be flagged (m3), and${JQ_BIN:-jq},${SETSID_BIN=setsid}and${JQ_BIN-jq}count as bare tools (m4).NODE_DIRguard.loadrefuses arun.envwhoseNODE_DIRis empty, relative or has a colon, andlaunchrefuses anodewhose directory is one of those (no state dir is made). ANODE_DIRthat is absolute but has lost itsnodeis not refused, socleanupstill runs. Whenloadrefuses, its message now says how to stop the run by hand: stop its nvim and agent-tty processes, thenrm -rfthe state dir (round 2:cleanuprefuses too, and the verifier asked what a user does then).tmp-removed(F1): launch, remove$VCN_STATE/tmp, thenrecordmust exit 0 with a non-empty WebM andtmpback at mode 0700.SKILL.md: the refusal proof's time is the measured one, and "no dir above it may be owned by another user" now says that root is fine.launchmakes/tmp/agent-ttywith mode 0700 when it is missing, and refuses one that is a symlink, is owned by another user or is group- or world-writable, before it makes anything. It also refuses a/tmp(or another dir above/tmp/agent-tty) that others can write and that has no sticky bit.Round 2 (after the verifiers of
397414f2)features/README.mdreadOUT=$($V get OUT), which drops a newline at the end of the pathSKILL.mdgivegetz() { IFS= read -r -d '' "$1" < <($V get -z "$1"); }; getz OUT; getz WORK. The full-run block ofSKILL.mdno longer exportsOUT. New static casesdoc-get-exact(no recipe reads a path of the run through$(... get NAME)) anddoc-recipe(it runs the documented recipe on a hand-maderun.envwith a newline at the end ofOUTandWORK, and a control shows that a command substitution cuts it)att-root-own-755proved only that launch did not refuseatt_root_casenow needs the next step to show: noagent-ttypassesagent-tty doctor(the stand-in), the mode of/tmp/agent-ttyafter launch, and no refusal text. New casesatt-root-own-700andatt-root-nosticky(a 0700 dir under a world-writable/tmpwith no sticky bit: launch refuses)builtin source,command .,eval, backticks and quotes count as sourcing. A span split over two lines is joined.$((PORT+1))is a use ofPORT.need_docsfails whenSKILL.mdorfeatures/*.mdis missing. The header says what the scanner does not see/tmpatt-root-nosticky(above)get(the regex and therun.envgrep): without the regex, a name likeO.TmatchesOUT=in thegrepand then fails in${!name}with a bash error instead of a refusal, andget-valuesnow holdsO.T. F10:SKILL.mdline 154. F12: kept as intended, andSKILL.mdsays soSKILL.md(it was 2 to 6 minutes, 149 s to 338 s, and a gate run then took 626 s), and each gate number names the head it was measured onThreat model for item 6
agent-tty0.5.0 puts sockets at/tmp/agent-tty/<sha256(home)[:8]>/<sha256(session id)[:12]>whateverTMPDIRsays (dist/storage/sessionPaths.js).dist/host/hostMain.jsmakes the per-home dir withmkdirrecursive andchmod 0700, and nothing checks who owns/tmp/agent-tty. The client connects to the path and accepts any answer that echoes the request id (dist/host/rpcClient.js).Who: another local user who owns
/tmp/agent-tty(a squatter who made it first and left it writable) or who can rename entries in it. The owner of a directory can rename its entries even with the sticky bit. What: move the per-home dir away, make a new one with the same name, listen on a socket of the same file name (both names are readable withls). Every lateragent-ttycall of the run then talks to the squatter: forged screens and forgedokanswers, and the keys thatvcn.shsends. Not covered: the same uid, and root.agent-ttyitself is not changed.I reproduced this on main in a
bwrapuser namespace that I own, with/tmp/agent-ttya host-root-owned 1777 dir (it shows as owned by nobody, and I can write in it):agent-tty createmade its per-home dir and socket in it without complaint, and after the per-home dir was swappedagent-tty snapshotprintedFORGED-BY-SQUATTER. No second account exists here, so the swap was done by my own uid, standing in for the owner of the root. That the owner may rename is POSIX, not something I tested.Evidence
Before is main
a9f8d0e7(the merge of #506). After is this branch. Every guard and every case below has a mutant that turns it red (the-randV=parts of the documented recipe are the exceptions; see Not covered), and each mutant ran against the case that names it. The mutants and their uncut output are in the run's scratch dir.get, journeysrun.envof a state dir that others can write (journey-state-probe.txt:run.env-sourced=YESfive times)refusing state dir ...: it is group- or world-writable,run.env-sourced=no)get-state(alsogetitself)getplaced beforeload(sources it): red. A journey that sources again: red here and inno-source-run-envget, valuesget -z OUTreturns aVERIFY_OUTwith a space, an equals sign, a tab, quotes and a newline (also at its end) exactly.get PATH, a name thatrun.envlacks and a bad name are refusedget-valuesgrep): red onO.T.-zends with a newline: red. No-zbranch: redOUTOUTis replaced by the sourceget: a journey run withOUTexported to a stale dir writes all its files into theOUTof its run, for aVERIFY_OUTwithOUT=in it, a newline in it and a newline at its end (and none in the stale dir or in the dir named insideVERIFY_OUT)journey-out(real launches)OUT: red (3 of its files land in the stale and the other dir). A journey that reads it through$(...): red for the newline at its endfeatures/README.md:OUT=$($V get OUT)cuts a newline at the end of the path, so the next documented write failsSKILL.mdusegetz(get -zandread -d '')doc-get-exact,doc-recipeSKILL.md: red. No recipe in the README: redsource "$VCN_STATE/run.env"; PATH=... "$ATT_BIN" --home ... dashboardin the caller's shellvcn.sh dashboardrenders the running session (alpha,gammaofhello.txt) in ascript(1)terminal and leaves no processdashboardcmd_dashboardthat does nothing: reddoc-varspasses an indented or~~~fence that expands$SID, a block whose comment namessource run.env, a self-test of a copy of the pattern, and${JQ_BIN:-jq}doc-vars,no-bare-toolsinproof-static.shvcn.shthey read, all red (old fence rule, no comment strip, trailing comment, weak set patterns, a default invcn.sh, an indented doc, a tilde doc, the dropped default pattern, noifor!in the source pattern, a second source invcn.sh)builtin source,evaland backticks were not seen. A span split over two lines was missed.$((PORT+1))was not a use. A missing doc passedno-source-run-env,doc-vars)proof-static.sh(5 cases)s1tos9c,d2tod5), all red, and the control greenNODE_DIRNODE_DIRleaves an emptyPATHentry: anodein the current dir ran as the interpreter ofagent-tty(nodedir-probe.txt:planted-node-ran=YES)loadrefuses an empty, relative or colonNODE_DIR, andlaunchrefuses such anodebefore it makes a state dir. An absoluteNODE_DIRwithout a node and arun.envwithoutNODE_DIRare not refused, socleanupruns. The refusal says how to stop the run by hand and keeps the state dirnode-dir,node-relative, and thecleanup-no-agent-ttyandjq-old-runcases that hold the exceptionslaunchremoved: red (and a leaked state dir). No hint: red. Hint names another dir: redagent-ttyand Chromiummise run checkrunsproof-static.sh, so theunit-testsjob doesproof-static.sh(5 cases)mkdirinatt()keeps both proofs greenrecordon a run without$VCN_STATE/tmpmakes it (0700) and writes a WebMtmp-removedmkdirremoved: red (recordexits 1)agent-tty createmade its per-home dir in a foreign-owned 1777/tmp/agent-tty, and after a swapsnapshotprintedFORGED-BY-SQUATTER(att-root-repro-main.txt,att-root-swap-main.txt)/tmpabove it. It makes a missing one 0700 and accepts a 0700 or 0755 dir of the user, and then runsagent-tty doctor(round 2)att-root-foreign,-loose,-link,-made,-own-700,-own-755,-nosticky(bwrap, a/tmpof their own)-foreign,-loose,-link,-made,-nosticky).mkdirremoved:-madered. Refusing any mode but 0700 with another message:-own-755red (it must seeagent-tty doctorrun next). Refusing a 0700 dir:-madeand-own-700red. Guard aftermktemp: 4 red (-foreign,-loose,-link,-nosticky), and a state dir is left. Owner test removed:-foreignred. Walk above the dir removed:-nostickyredGate on the head of this branch
The head is
98653693: the docs commit that gives the slowest measured time of the refusal proof, on top of2ae7ec86(the merge of main, #549 and #532, over the docs commit76088711, the round-2 commit30e86876and the pushed round 1).98653693changes one line ofSKILL.md. The runs below are on2ae7ec86unless a line says otherwise. The host ran at a load of 50 to 90 (other workspaces), so the times are slow ones.mise run clean && mise run allluacheck: 0 warnings, 0 errors in 181 files. Itschecktask runsproof-static.shcheck.shofverify-diff-lifecycle== RESULT: PASSproof-launch-refusals.shPASS. The three ACL cases printSKIP(no default ACL can be set here)proof-agent-tty-resolution.shCASES 49, noSKIP, noFAIL(thebwrapcases ran)proof-static.sh,mise run format-check98653693(the docs edit is in the tree for both): 5 casesok, exit 0, and 359 files formatted with 0 changed, exit 0opendiff-accept,diff-close-savefor an existing and a new file,new-tab-diff-mode,snacks-float-diff,snacks-float-reload)8df19bb1(the first of the two merges of main in this round),TMPDIRa symlink of mine to a private dir: all six pass with launch 0, journey 0 and cleanup 0, and the state dir is under the resolved path. The snacks journeys ran on a snacks.nvim clone. The second merge changed only docs, the integration runner script and its spec, and no file the journeys read8df19bb1mise run allexit 0 (1702 successes),check.shPASS, refusals 42PASSin 242 s, resolutionCASES 49in 1069 s30e86876mise run allexit 0 (1702 successes),check.shPASS, refusals 42PASSin 149 s, resolutionCASES 49in 722 s2ae7ec86and on98653693conventional-title,unit-tests,covered-lock,integration-tests (stable),integration-tests (v0.8.0),Required)2ae7ec86and on9865369398653693c2"), 0 review threadsThe slowest time of the refusal proof was 626 s, so
SKILL.mdnow says "2 to 11 minutes (149 s to 626 s measured, depending on the load of the host)". That is the sentence inSKILL.mdline 134. A verifier's run on98653693took 966 s (exit 0, 42 PASS), so that range is already exceeded. The follow-up PR fixes the sentence.Not covered
mise run allneed a real Neovim, so the Busted gate skips them. Theintegration-testsjobs of CI run them (scripts/run_integration_tests_individually.sh). This PR changes no Lua underlua/.proof-static.shdoes not see indented code blocks,<pre>blocks, a command held in a variable, or a source in a.lua,.pyor.mjsfile.proof-static.shalso does not see a backslash-continuedsource \, a quoted"source",BASH_ENVorlet.doc-get-exactdoes not see$(... get -z NAME)or a line read.doc-recipeuses a value with no backslash, and the test suppliesV. The follow-up PR fixes or lists these.getinsidegetzprints the refusal but leavesOUTorWORKempty, and a shell withoutset -egoes on. The follow-up PR makesgetzfail loudly.launchchecks/tmp/agent-tty. A tmp cleaner that deletes it during a run is not seen by the later commands.agent-ttyitself is not changed, and nothing is filed upstream. That the owner of a directory can rename its entries is POSIX reasoning, not a live test with a second account.run.envin a shell of their own is not stopped.SKILL.mdsays not to, andno-source-run-envchecks the skill's own scripts and recipes.proof-static.shtakes about a minute on a host at a load of 35. It is about 17 s of CPU (1.8 s user, 15.0 s sys), measured by a verifier on98653693.One mutant is equivalent, not green by accident:
mkdir -m 700instead ofmkdirinensure_att_rootchanges nothing, becausevcn.shruns underumask 077.Refs #506 and #508.
Generated with
xum• Model:anthropic:claude-sonnet-5-5• Thinking:xhigh