fix(powershell-format): release the _ps_before snapshot and disclose a rewrite on the tool-break arm - #3401
Conversation
…a rewrite on the tool-break arm The hook copies the target file to an mktemp snapshot so a formatter rewrite can be disclosed on the user channel. Arms 3 and 5 release it and maybe_disclose_ps_rewrite releases it on the arms that call it, but the trust-gate arm (pwsh exit 6) and the tool-break catch-all did neither, leaking one temp file per gated run. The tool-break arm also exited 0 without calling maybe_disclose_ps_rewrite. Invoke-Formatter writes the reformatted file back before Invoke-ScriptAnalyzer runs, and both sit inside the same try/catch that raises exit 4, so a rewrite can already be on disk when pwsh throws; the hook silently violated its own rewrite-disclosure contract. That arm now discloses, which also releases the snapshot on both the changed and unchanged paths. The trust-gate arm owes no disclosure decision: every exit 6 in the pwsh block is raised by the gate, which runs before Invoke-Formatter, so no rewrite can have landed. It only releases the snapshot, matching arms 3 and 5. Closes #3366 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 49s —— View job Code review (
|
|
Claude finished @kyle-sexton's task in 1m 32s —— View job Security review —
|
|
Last security-reviewed head: |
|
Claude has reviewed this PR 3 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fc4980e3f3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
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".
… into one document Review follow-up on #3366. The tool-break arm printed hook::ctx_flush's additionalContext document and then a second systemMessage document from the disclosure. The hook output contract is ONE JSON document for the whole of stdout, so that is an invalid response in which either channel can be lost. Arm 1 (findings) already had the same shape, so a run that both reformatted and reported findings was already emitting two objects. Split maybe_disclose_ps_rewrite into take_ps_rewrite_disclosure (release the snapshot, record the text in PS_REWRITE_MESSAGE) and the emitting wrapper. The two arms that carry additionalContext now build it in a variable and emit both channels through hook::emit_channels. Arms 0, 3, 5, and 6 keep the wrapper and are unchanged. Move the stub-driven arm cases above the real-pwsh prerequisite gate: they need neither a real pwsh nor the PSScriptAnalyzer module, and below the gate this regression coverage was skipped on exactly the pwsh-less hosts CI runs on. Add Case 4b for arm 1: a lowercase alias plus a global var produces a rewrite AND a finding, and the case asserts stdout is one document (jq -s length), that both channels survive, and that the formatter actually rewrote, so the composition check cannot pass on a run with nothing to compose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 59s —— View job Code review (
|
|
Claude finished @kyle-sexton's task in 1m 4s —— View job Security review (
|
…ck SC2012) The hygiene lane's shellcheck runs at info severity, so `ls -A | wc -l` in the new arm helper failed CI. Use `find <dir> -mindepth 1 | wc -l` for both the post-run count and the stub pwsh's live probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 11s —— View job Code review (
|
|
Claude finished @kyle-sexton's task in 36s —— View job Security review (
|
Summary
plugins/powershell-format/hooks/powershell-format.shsnapshots the target file to anmktempcopy (
_ps_before) somaybe_disclose_ps_rewritecan name a formatter rewrite on the userchannel. Arms 3 and 5
rm -fit, and the disclosure helper releases it on the arms that call it.Two arms did neither:
6)reachedemit_skippedwithout releasing it, leaking one temp file pergated run;
*)exited 0 without releasing it AND without callingmaybe_disclose_ps_rewrite, so besides the leak a rewrite that had already landed wentundisclosed.
The second one is the more serious of the two.
Invoke-Formatterwrites the reformatted fileback at line 631,
Invoke-ScriptAnalyzerruns at line 640, and both sit inside the sametry/catchthat raisesexit 4. So on a tool break the file on disk can already differ fromwhat the user wrote, and the hook said nothing.
Reproduced against the pre-fix hook with a stub
pwshand an isolatedTMPDIR(see Verificationfor the harness):
Fix
maybe_disclose_ps_rewritesplits intotake_ps_rewrite_disclosure(release the snapshot,record the text in
PS_REWRITE_MESSAGE) and the emitting wrapper. An arm that also carriesadditionalContextcan then compose both channels throughhook::emit_channelsinstead ofprinting a second JSON object. Arms 0/3/5/6 keep the wrapper and are unchanged in behavior.
*): build the tool-break context in a variable, take the disclosure, and emit both asONE document. That releases the snapshot on the changed and unchanged paths alike, so it
satisfies the disclosure and the cleanup criteria together.
1)(findings) gets the same composition. It already printedhook::ctx_flush's documentfollowed by the disclosure's, so a run that both reformatted and reported findings emitted two
objects. That is a pre-existing violation of the same contract, in the same file, and closing
it is two lines given the split above; leaving it would have left the fix half-applied.
6):rm -f "$_ps_before", exactly parallel to arms 3 and 5.No disclosure call on arm 6, and that is deliberate rather than an omission. The issue raised
it as an open question ("a rewrite disclosure decision may also be warranted"). Every
exit 6inthe pwsh block is raised by the trust gate at lines 269-591;
Invoke-Formatteris at line 622. Norewrite can have landed on that path, so the snapshot is provably identical to the file and only
needs releasing. A comment on the arm records that reasoning so a future reader does not read the
asymmetry as a second bug.
No
EXITtrap. The issue floated one as a hardening idea; a trap spanning this hook's manyexit 0arms,emit_skipped, and its subshells is materially more blast radius than the two lines theacceptance criteria ask for. Left as a follow-up rather than smuggled in here.
Verification
Windows 11, Git Bash, pwsh 7.6.5, PSScriptAnalyzer present.
New cases in
powershell-format.test.sh, driven through the suite's existing stub-pwshmechanism (
make_stub_pwsh), each with its ownTMPDIRso "left nothing behind" is a strictemptiness check, and with
HOOK_TELEMETRY_SINKunwired so hook-utils' own envelopemktempcannot land in the scratch. They sit ABOVE the real-
pwshprerequisite gate, because they needneither a real
pwshnor PSScriptAnalyzer and would otherwise be skipped on exactly thepwsh-less hosts where this regression coverage matters:exit 6): scratch empty after the run.exit 4after the stub rewrites the file): scratch empty; stdout is exactlyONE JSON document (
jq -s length == 1); it carries both the disclosure and the tool-breakadditionalContext.pwsh: a fixture pairing a lowercase alias with a global varproduces a rewrite AND a finding; stdout is one document carrying both channels, and a third
assertion confirms the formatter actually rewrote, so the composition check cannot pass on a
run with nothing to compose.
The emptiness assertions are not vacuous, and the suite proves it rather than asserting it.
The stub
pwshcounts the scratch's entries while it is running, and each arm asserts that countis at least 1 before asserting the post-run count is 0. Without that live probe, a scratch the
hook's
mktempnever used would read as "empty" and the check would pass over a leak somewhereelse.
Pre-fix reproduction used a standalone replica of
run_armagainst agit stash-ed-out hook (thefull suite is ~12 minutes on this box, so the arms were iterated in isolation first): both arms
reported
left=1and the tool-break arm emitted no disclosure, as quoted above. The numbers inthis section come from the real suite run, not the replica.
Full suite:
bash plugins/powershell-format/hooks/powershell-format.test.sh->PASS=86 FAIL=0(74 on
main; 12 new assertions).Repo gates, all green:
scripts/affected-tests.sh --run(selects this suite),scripts/check-changelog-parity.sh --checkand--check-bump origin/main,scripts/validate-plugins.sh,scripts/check-shell-portability.sh --pathsover both touchedshell files.
One unrelated note: the
envinvocation in the new helper puts-u HOOK_TELEMETRY_SINKbeforeevery
NAME=VALUE, becauseenvstops parsing options at the first operand and a trailing-u FOOis taken as the command to run (exit 127). Caught while building these cases.Related
Closes #3366