Skip to content

fix(guardrails): refuse instead of asking when an attended session is marked unwatched - #12934

Merged
kyle-sexton merged 1 commit into
mainfrom
work-resolve-issue/issue-12910-41700062912-1
Nov 3, 2026
Merged

kyle-sexton merged 1 commit into
mainfrom
work-resolve-issue/issue-12910-41700062912-1

Conversation

@melodic-automation-lanes

Copy link
Copy Markdown
Contributor

Closes #12910

Summary

Opened by the work-resolve-issue lane. The run's own summary:

refuse.sh now treats a session with CLAUDE_CODE_SESSION_ATTENDED=1 and GUARDRAILS_SESSION_UNWATCHED=1 as unwatched. In that session, block-dangerous-git and block-root-delete-target deny (exit 2) instead of asking, and the reason ends with the command for a person to run later. Attended sessions without the variable still get the ask. How it is detected, and why: the Claude Code hooks reference documents no field or variable that says whether a person is watching (only permission_mode and non-interactive -p behavior), so the session has to declare it with an environment variable. A plain env var was chosen over a userConfig option because a launcher can set it per session without reconfiguring the plugin. The variable can only turn an ask into a deny, so a repository that sets it through its settings env loosens nothing.

Not verified

  • The guardrails shell test suites (block-dangerous-git.test.sh, block-root-delete-target-e.test.sh, run-guards.test.sh) were not executed: this lane's Bash runs only the named wrappers, so whether the new cases pass is checked by reading the code only. Left to the PR's CI.
  • Tidy: passes 1-3 (simplify, comment triage, generated-code residue) ran over the code lines and found nothing to change. Pass 4 (docs-hygiene:write-for-humans) was not invoked, to save turns. Pass 5 (ai-slop:audit fix) could not run its detector script, since the lane's Bash allows only the named…
  • Review nits left as they are: the guard headers (block-dangerous-git.sh:68-69, block-root-delete-target.sh:841-842) still say 'asks the user when the session is attended' and defer the definition to refuse.sh. Under run-guards, a command both guards deny prints the unwatched note twice. There is no…
  • Nothing sets GUARDRAILS_SESSION_UNWATCHED automatically. A lane that runs an interactive session nobody watches has to export it. The harness-ops lane launcher uses claude --bg, which already reads ATTENDED=0, so it needs no change.
  • Untracked files at the checkout root (.bashrc, .bash_profile, .gitconfig, .idea, .mcp.json, .profile, .ripgreprc, .vscode, .zprofile, .zshrc) were there before this run and are not part of this change.

Fix

  • plugins/guardrails/hooks/refuse.sh: new hook::refuse_unwatched (ATTENDED=1 and GUARDRAILS_SESSION_UNWATCHED=1). hook::refuse_asks excludes it. In an unwatched session the deny path appends a note and the command from HOOK_REFUSE_COMMAND. The header documents the signal and why it is declared rather…
  • plugins/guardrails/hooks/block-dangerous-git.sh: sets HOOK_REFUSE_COMMAND to the command read from the payload, before any PowerShell rewrite.
  • plugins/guardrails/hooks/block-root-delete-target.sh: sets HOOK_REFUSE_COMMAND after the PowerShell CR normalization.
  • plugins/guardrails/hooks/block-dangerous-git.test.sh: unsets GUARDRAILS_SESSION_UNWATCHED at setup. New cases: unwatched git reset --hard denies with no ask document and names the form, the run-later line and the command, alone and dispatched. Values other than exactly 1 still ask. Unwatched…
  • plugins/guardrails/hooks/block-root-delete-target-e.test.sh: new cases: unwatched outside-tree delete (rm -rf /etc/x, decided in the judgment subshell) and rm -rf / deny alone and dispatched, with the reason naming the target, the run-later line and the command. Attended with UNWATCHED=0 still asks.
  • plugins/guardrails/hooks/block-root-delete-target-fixtures.sh: unsets GUARDRAILS_SESSION_UNWATCHED at setup.
  • plugins/guardrails/hooks/run-guards.test.sh: unsets GUARDRAILS_SESSION_UNWATCHED at setup, so a host that sets it does not break the attended asks (review finding).
  • plugins/guardrails/README.md: the guard-table rows for both guards name the condition. New 'Unwatched sessions' section after the generated options block, with a Contents entry.
  • .changes/guardrails/work-resolve-issue-issue-12910-41700062912-1-e3fae7e4.md: minor fragment describing the change.

Verification

The runner ran each check from the base copy against 005e6ee:

  • scripts/check-changelog-fragments.sh --check: passed
  • scripts/check-changed-skills.sh --merge-base: passed

The run reports:

  • read https: //code.claude.com/docs/en/hooks: the common input fields carry permission_mode (default, plan, acceptEdits, auto, dontAsk, bypassPermissions). permissionDecision "ask" prompts the user, and in a -p run where no one can answer, Claude Code denies the call. The page documents no field or…
  • read https: //github.com/melodic-software/claude-code-plugins/issues /12848: an interactive lane session left overnight stalled for about 9 hours on a block-root-delete-target ask, because CLAUDE_CODE_SESSION_ATTENDED reads 1 whether or not anyone is at the keyboard. The broader defect is tracked…
  • check-changelog-fragments: all 24 fragments valid (run before and after the review fixes)
  • check-changed-skills: no changed skills, nothing to gate
  • check-affected: the first run failed plugin-options-docs because the new README section sat inside the generated options block. After moving it below the END GENERATED marker: 12 passed, 6 skipped, 0 failed (run again after the review fixes, same result)
  • fresh-context review subagent over the whole diff: no correctness bugs. It found a test-isolation risk in run-guards.test.sh (fixed) and a stale 'Attended, it records' line in the refuse.sh header (fixed).
  • The lane run: https://github.com/melodic-software/claude-code-plugins/actions/runs/41700062912

Left out: the items under Not verified.

Related

#12910

Lane-Activity: work-resolve-issue/resolve-issue
@melodic-automation-lanes

Copy link
Copy Markdown
Contributor Author

Unattended merge: native auto-merge leaves this pull request unarmed at head 005e6ee858a92d1ff563b884b599134d33186520. The lanes clear this reason on their own; the next scheduled run judges it again.

pr-merge's gate refuses its verifier verdict (verify:missing)

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

claude-review reviewed 005e6ee858a92d1ff563b884b599134d33186520: read 9 of 9 changed file(s); 0 finding(s), 0 as inline comments.

Code review: PR #12934, refuse instead of asking when an attended session is marked unwatched

No findings. I read every hunk of all 9 changed files. I also read the head copies of refuse.sh, the rdt_block and judgment-subshell paths in block-root-delete-target.sh, and the hook::refuse call site in block-dangerous-git.sh.

Correctness

  • hook::refuse_unwatched only returns true when CLAUDE_CODE_SESSION_ATTENDED=1 and GUARDRAILS_SESSION_UNWATCHED is exactly 1. hook::refuse_asks excludes that case, so an unwatched session falls through to the existing stderr + exit 2 path. The variable can only turn an ask into a deny. It never makes a deny looser.
  • In block-root-delete-target.sh, an outside-tree or root delete is decided inside the judgment subshell (L8287-L8294). When unwatched, hook::refuse exits 2 there. The parent turns rdt_judge_rc == 2 into exit 2, and the subshell inherits HOOK_REFUSE_COMMAND. So the run-later note and the command reach stderr on this path too.
  • HOOK_REFUSE_COMMAND is set after the PowerShell CR normalization in block-root-delete-target (L943). In block-dangerous-git it is set right after the payload read (L180), before any refusal can fire.
  • Test isolation: the suites that run attended ask cases now unset GUARDRAILS_SESSION_UNWATCHED. block-dangerous-git.test.sh and run-guards.test.sh do it directly, and the -b/-c/-e root-delete suites get it through block-root-delete-target-fixtures.sh. The other suites pin ATTENDED=0, and unwatched mode only applies when ATTENDED is 1, so they are unaffected.
  • The refuse.sh mode change (100644 → 100755) matches the issue lane's rule of making every changed .sh executable (scripts/check-affected.sh, CHECK_AFFECTED_SCRIPT_EXEC_BIT). It does not change how the file is sourced.

Closing issue #12910 acceptance criteria

  • refuse.sh tells attended from unwatched, and the refusal names the command to run later: met. Tests: block-dangerous-git.test.sh asserts report this command:\ngit reset --hard, and block-root-delete-target-e.test.sh asserts report this command:\nrm -rf /etc/x.
  • A test covers an unwatched ask that does not stall: met. The unwatched cases expect exit 2 and an empty stdout (no ask document), run alone and dispatched.
  • A test covers an attended session that still asks: met. The existing attended cases remain, and new cases cover GUARDRAILS_SESSION_UNWATCHED set to 0, empty, true and 1, all still asking.
  • A .changes/guardrails/*.md fragment: met (bump: minor, which fits a new opt-in signal).

Not verified

I had no shell, so I ran no tests. The PR body says the guardrails shell suites were not run either and leaves them to CI. That claim and the checks listed under Verification (changelog fragments, check-affected) are the author's and unverified.

Noted, not counted

  • The PR body names its own nits: under run-guards, a command that both guards deny prints the unwatched note twice, and the guard headers still describe the attended ask without mentioning the unwatched case. Both are cosmetic and leave the decision unchanged.
  • No plugin manifest changed, so there are no manifest lines.
  • No new spend: the change adds no API, plan or billing usage.

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

claude-security-review reviewed 005e6ee858a92d1ff563b884b599134d33186520: read 9 of 9 changed file(s); 0 finding(s), 0 as inline comments.

Security review: PR #12934, head 005e6ee

No security issues found. I read every hunk of all 9 changed files in .claude-lane/pr.diff. None of them matches a trusted-path glob.

What changed: plugins/guardrails/hooks/refuse.sh gains hook::refuse_unwatched. It is true only when both CLAUDE_CODE_SESSION_ATTENDED and GUARDRAILS_SESSION_UNWATCHED are exactly 1. hook::refuse_asks now also requires ! hook::refuse_unwatched.

Trust-boundary check:

  • The new predicate only narrows when the guard asks. An attended session that sets the variable gets a deny (exit 2) instead of an ask. An unattended session denies as it did before.
  • No input can turn a deny into an allow or an ask:
    • The comparison is exact == 1, quoted, with - defaults.
    • Values such as " 1" and true still ask, and the tests cover them.
  • So a repository that sets the variable in its settings env loosens nothing.

Data-flow check:

  • HOOK_REFUSE_COMMAND is printed to stderr with printf '%s\n', so it is never used as a format string or evaluated.
  • Its value is the tool-call command the model itself wrote. The hook reads it from the payload in block-dangerous-git.sh:180 and block-root-delete-target.sh:943.
  • Echoing that command back into the hook's stderr gives the model nothing it did not already have. It exposes no token or secret and adds no execution path.

Other changes:

  • refuse.sh changes mode from 100644 to 100755. The guards source this file and never execute it, so the bit grants nothing.
  • The test files only add unset GUARDRAILS_SESSION_UNWATCHED and new cases.
  • The README and the changelog fragment are prose. They describe the new behavior and remove no standing instruction.

Not applicable:

  • No plugin manifest changed, so there are no manifest <field> lines.
  • No workflow, token, trusted-actor list, merge hold or kill-switch file changed.

The PR context contains no embedded directives aimed at this lane.

Spend: none. The change affects no paid resource and no lane budget.

critical-findings: 0

@kyle-sexton
kyle-sexton added this pull request to the merge queue Nov 3, 2026
@melodic-automation-lanes

Copy link
Copy Markdown
Contributor Author

The unattended merge path armed native auto-merge at head 005e6ee858a92d1ff563b884b599134d33186520.

Merged via the queue into main with commit 1f52269 Nov 3, 2026
53 checks passed
@kyle-sexton
kyle-sexton deleted the work-resolve-issue/issue-12910-41700062912-1 branch November 3, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(guardrails): do not stall an attended-but-unwatched session on a guard ask decision

1 participant