Carry the Permission Rules the Review Loop Needs in the Installer - #624
Conversation
The kit configured settings.json for the hook alone, so a permission rule was hand-edited on one machine and reached none of the others. It now carries the rules it owns, so re-running the installer is what updates them everywhere. Each rule is declared as a prefix and a rule, and a re-run drops every rule under the prefix before adding the current one. That is the same strip-then-register the hook registration already uses, and it makes a changed rule update in place rather than accumulate beside the version it replaced. The prefix bounds what the installer owns, so a rule written by hand outside it is untouched. The list holds one rule today, for `scripts/pr_review.py`. Driving the review loop's reply and resolve by hand needs a raw GraphQL mutation, which is the shape that reached a stranger's repository. The script queries the thread id itself and takes no argument an id fits in, so allowing it is narrower than allowing the mutation it replaces. These widen rather than restrict, so they are their own component for the reason the fleet-bootstrap CLAUDE.md block is separate from the agent-safety one: the safety half's own text says nothing in it widens a permission. Verified against throwaway CLAUDE_HOME trees: added on a fresh install, reported already current on a re-run with no duplicate, superseded one and two older spellings in place, left unrelated rules and other keys alone, and still aborted without writing when settings.json is not valid JSON. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the per-machine agent safety installer to also manage the Claude Code settings.json permission grants required by the automated PR review loop, ensuring re-running the installer consistently propagates those permissions across hosts.
Changes:
- Add a managed permissions list to
host-setup/agent-safety/install.pyand mergepermissions.allowentries during install/update. - Document the new managed permission behavior and add verification steps in
host-setup/agent-safety/README.md.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| host-setup/agent-safety/README.md | Documents the new managed permissions.allow behavior and adds verification commands/snippet updates. |
| host-setup/agent-safety/install.py | Implements prefix-managed permissions.allow merging alongside existing hook registration and single-pass settings writes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Two findings, both correct and both reproduced before fixing. A settings.json key holding an unexpected type crashed the installer with a traceback partway through its edits. Reproduced on four shapes: `permissions` as a string, `permissions.allow` as a string, `hooks` as a string, and `hooks.PreToolUse` as an object. The first two are this change's, and the second two predate it, so the check covers every container the installer descends into rather than only the new ones. Each now names the key, the type it holds, the type required, and that nothing was written, which is the shape the invalid-JSON refusal beside it already used. The registration line also printed before the write, which this change caused by moving the write later so the hook and the permissions share one. A failure in between would have printed a claim that nothing on disk supported. Both lines are now collected and printed after the write lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (3)
host-setup/agent-safety/install.py:90
- The settings.json type validation checks nested containers, but still assumes the JSON root is a dict. If settings.json contains a valid non-object JSON value (e.g., a list), the later setdefault calls will raise an AttributeError and produce a traceback instead of the intended "nothing was written" error path.
# Every container this installer descends into is checked before it is used.
# A key holding an unexpected type would otherwise raise a traceback mid-edit.
# That reads as a crash rather than as the settings problem it is.
# The invalid-JSON refusal above is the shape this file already answers a malformed file with.
for path, want in (("hooks", dict), ("hooks/PreToolUse", list),
host-setup/agent-safety/install.py:168
- The final verification hint hardcodes
grep -c 'pr_review.py'but useslen(MANAGED_PERMISSIONS)for the expected count. If MANAGED_PERMISSIONS ever adds a rule that doesn't includepr_review.py, the printed command and expected count will diverge.
print(f" grep -c 'agent-safety v' \"{claude_md}\" # expect 2")
print(f" grep -c 'fleet-bootstrap v' \"{claude_md}\" # expect 2")
print(f" grep -c 'pr_review.py' \"{settings}\" # expect {len(MANAGED_PERMISSIONS)}")
host-setup/agent-safety/install.py:133
- The "updated, superseding N" status counts every matched entry, including the current canonical rule when it is already present. That can over-report how many older spellings were superseded (e.g., when the rule is present plus one older spelling, it prints "superseding 2").
superseded = [a for a in allow if isinstance(a, str) and a.startswith(prefix)]
allow[:] = [a for a in allow if a not in superseded] + [rule]
if superseded == [rule]:
action = "already current"
elif superseded:
Three suppressed findings, all correct and all reproduced first. A settings.json holding a valid non-object JSON value crashed with a traceback, since the key checks descend from a root nobody had checked. A list, a string, a number and a null each now name the type they hold and write nothing. The superseded count reported every rule the prefix matched, and the current rule matches its own prefix, so a file holding the current rule beside one older spelling reported superseding 2 where it replaced 1. Counted over the rules actually replaced now: superseding 1 for that case, already current where only the current rule matched, superseding 2 for two older spellings, added for none. The verification hint grepped a fixed word while counting the managed list, two things that agree only while every rule happens to carry that word. It prints one exact-match line per rule, derived from the rule itself, and the printed command was run against a real install to confirm it returns the count it claims. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2, three suppressed findings accepted and fixed in
|
| Root | Result |
|---|---|
[1,2,3] |
holds list at its root where an object is required |
"a string" |
holds str |
42 |
holds int |
null |
holds NoneType |
install.py:133, the superseded count, accepted. The current rule matches its own prefix, so counting the match set reported it as one of the rules it replaced. Counted over the rules actually replaced now:
permissions.allow before |
Was | Now |
|---|---|---|
| current rule plus one older spelling | superseding 2 |
superseding 1 |
| current rule alone | already current |
already current |
| two older spellings, no current | superseding 2 |
superseding 2 |
| empty | added |
added |
This is the same class of defect as the two earlier rounds on the other pull request, a count or a claim broader than what was measured, which is worth naming rather than quietly correcting.
install.py:168, the verification hint, accepted. It grepped a fixed word while counting the managed list, and those agree only while every rule happens to carry that word. It now prints one exact-match line per rule, derived from the rule itself:
grep -cF 'Bash(python3 scripts/pr_review.py:*)' "<home>/.claude/settings.json" # expect 1That printed command was run against a real install rather than assumed, and returns 1. The README's POSIX and PowerShell verify blocks were updated to the same exact-match form.
Verification on 645c99f
| Check | Result |
|---|---|
| Prose gate, whole tree | 0 violations across 0 files |
gh-write-guard.py --selftest |
PASS |
repo_gate.py |
eol 0, sha-pin 0 |
editorconfig-checker |
exit 0 |
markdownlint-cli2 on the kit README |
0 issues |
| Fresh install, re-run, supersede, unrelated rules preserved | all as before |
One aside worth recording. The write guard this kit installs blocked a command of mine during this round, because a >/dev/null on an unrelated install test sat in the same command string as a git push. The guard reads the command rather than the pipeline, so that is the rule working as written, and the remedy was to issue the push as its own command, which the kit's README already asks for.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
host-setup/agent-safety/install.py:105
- The type-check loop treats JSON
nullthe same as a missing key (node.get(...)returnsNonein both cases). If a user has e.g."hooks": nullor"permissions": null, this validation will pass butsetdefault(...)will later raise an AttributeError. Track key presence separately so an explicitnullis rejected with the same clear error path.
node = data
for p in parts[:-1]:
node = node.get(p, {})
held = node.get(parts[-1]) if isinstance(node, dict) else None
if held is not None and not isinstance(held, want):
host-setup/agent-safety/install.py:112
hooks.PreToolUseis only validated as a list, but later code assumes each element is an object and callsg.get(...). A user settings file like{ "hooks": { "PreToolUse": ["oops"] } }will still crash with a traceback. Add a preflight check to fail with a clear error (and no write) when any PreToolUse entry is not an object.
This issue also appears on line 101 of the same file.
# Every container this installer descends into is checked before it is used.
# A key holding an unexpected type would otherwise raise a traceback mid-edit.
# That reads as a crash rather than as the settings problem it is.
# The invalid-JSON refusal above is the shape this file already answers a malformed file with.
for path, want in (("hooks", dict), ("hooks/PreToolUse", list),
("permissions", dict), ("permissions/allow", list)):
Two suppressed findings, both correct, and a sweep of the same shape found a third the review did not name. The type check read a key through `dict.get`, which reports an absent key and one holding an explicit null alike, so a null passed the check and then crashed `setdefault`, which hands back the null it found rather than the default. A MISSING sentinel now separates the two, since a gap is filled and a null is a settings error. A list of the right type can still hold the wrong elements. The registration reads each PreToolUse group as an object and each group's `hooks` as a list it appends to, and neither was checked. The finding named the group case, and the same sweep found the group's own `hooks` key crashing on `.append` one line later, so both are covered along with an explicit null in that position. Eight malformed shapes now refuse with the key named, the type it holds, the type required, and the file byte-identical afterwards. Verified on this branch rather than on develop, which carries no permissions step and answered the first run misleadingly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/agent-safety/install.py:137
- The new settings.json type validation checks that
hooks.PreToolUseis a list and that each group is a dict, but it doesn't validate the elements of each group'shookslist. If a user has e.g.{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":["oops"]}]}}, the later strip step callsh.get(...)and will still crash with an AttributeError/traceback. Consider validating that everyhooks.PreToolUse[i].hooks[j]is a dict (and failing with the same clear "Nothing was written" message) before proceeding.
if "hooks" in g and not isinstance(g["hooks"], list):
reject(f"hooks.PreToolUse[{i}].hooks", g["hooks"], list)
return 1
One suppressed finding, correct. The strip step reads each entry of a group's `hooks` list as an object, and a list holding a string crashed it on `h.get`. That is the fourth round of this one class, so the paths were enumerated rather than patched again. The registration and the merge touch the settings root, the `hooks` object, its `PreToolUse` list, each group in it, each group's `hooks` list, each entry in that list, that entry's `command`, the `permissions` object, its `allow` list, and each entry in it. Every one is now either type-checked or read through a guard that cannot raise: `command` is wrapped in `str` before it is searched, and a non-string `allow` entry is skipped by an isinstance test and preserved. There is no deeper level to find. Twelve malformed shapes refuse with the key named and the file byte-identical. A file carrying legal but unusual values still installs, with a numeric `command` and non-string `allow` entries left exactly as they were. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 4, the suppressed finding accepted and fixed in
|
| Path | How it is safe |
|---|---|
| the settings root | checked as an object |
hooks |
checked |
hooks.PreToolUse |
checked |
hooks.PreToolUse[i] |
checked |
hooks.PreToolUse[i].hooks |
checked |
hooks.PreToolUse[i].hooks[j] |
checked, this round |
hooks.PreToolUse[i].hooks[j].command |
read through str(...), so any type is searchable |
permissions |
checked |
permissions.allow |
checked |
permissions.allow[k] |
skipped by an isinstance test where it is not a string, and preserved |
There is no deeper level to reach, so this class is closed rather than one round further along.
Twelve malformed shapes, each refused with the file byte-identical
settings.json |
Message |
|---|---|
[1,2,3] |
holds list at its root where an object is required |
{"hooks": null} |
hooks as NoneType where dict is required |
{"hooks": "off"} |
hooks as str |
{"hooks": {"PreToolUse": {}}} |
hooks.PreToolUse as dict where list is required |
{"hooks": {"PreToolUse": ["oops"]}} |
hooks.PreToolUse[0] as str where dict is required |
{"hooks": {"PreToolUse": [{"hooks": "x"}]}} |
hooks.PreToolUse[0].hooks as str |
{"hooks": {"PreToolUse": [{"hooks": null}]}} |
hooks.PreToolUse[0].hooks as NoneType |
{"hooks": {"PreToolUse": [{"hooks": ["oops"]}]}} |
hooks.PreToolUse[0].hooks[0] as str where dict is required |
{"permissions": null} |
permissions as NoneType |
{"permissions": "none"} |
permissions as str |
{"permissions": {"allow": null}} |
permissions.allow as NoneType |
{"permissions": {"allow": "all"}} |
permissions.allow as str |
Legal but unusual values still install
A settings file carrying a numeric command and non-string entries in allow installs normally, and those values come out exactly as they went in:
allow before: ["Bash(x)", 42, null]
allow after: ["Bash(x)", 42, null, "Bash(python3 scripts/pr_review.py:*)"]
That matters because a validator that rejects everything unfamiliar would refuse a settings file this kit has no business judging. The rule is that a key the installer writes through is checked, and a key it merely carries is not.
Verification on bdf73b4
| Check | Result |
|---|---|
| Prose gate, whole tree | 0 violations across 0 files |
gh-write-guard.py --selftest |
PASS |
repo_gate.py |
eol 0, sha-pin 0 |
editorconfig-checker |
exit 0 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
host-setup/agent-safety/README.md:13
- This paragraph claims allowing
scripts/pr_review.pyis narrower than allowing the raw GraphQL mutation. As written, the installed allow rule is a relative-path pattern (Bash(python3 scripts/pr_review.py:*)), which would also allow running an arbitraryscripts/pr_review.pyin any other checkout; that is not necessarily narrower than allowing the specificgh api graphqlmutation shape.
- **The permission rules this kit owns, merged into `settings.json`** beside the hook registration. Each is declared as a prefix and a rule, and a re-run drops every rule under the prefix before adding the current one, so a rule whose spelling changes updates in place rather than accumulating beside the version it replaced. The prefix bounds what the installer owns, so a rule written by hand outside it is never touched. These widen rather than restrict, which is why they are their own component for the same reason the `fleet-bootstrap` block is separate from the `agent-safety` one. Today the list holds one rule, for `scripts/pr_review.py`, the review loop's reply and resolve. Driving that loop by hand needs a raw GraphQL mutation, which is the shape that reached a stranger's repository, and the script queries the thread id itself and takes no argument an id fits in, so allowing the script is narrower than allowing the mutation it replaces.
The claim that allowing the script is narrower than allowing the mutation was wrong, and the review was right to reject it. A permission rule matches the command text rather than the directory the command runs in, so the rule reaches a `scripts/pr_review.py` in any checkout that carries one. An absolute path is not the fix. The hub is reached as a checkout of the caller's own and its location differs per task, so pinning one path would name a checkout the next task does not use, and the rule would stop matching the invocation it exists for. What actually bounds this is the committed rule that an agent reaches the hub as a checkout of its own, fetched immediately before it is read, rather than a copy it happens to find on disk. That rule is carried in the `fleet-bootstrap` block installed beside this one and stated in full in `GOVERNANCE.md` "Hub-Hosted Tooling", so the README names it rather than implying the permission rule carries a bound it does not. The rule itself is unchanged, since the defect was in the description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (4)
host-setup/agent-safety/install.py:24
- This comment wraps a single sentence across multiple
#lines.GOVERNANCE.mdrequires one sentence per line and forbids wrapping sentences across lines in comments.
# Distinguishes an absent key from one holding an explicit null, which `dict.get` reports alike.
# The two need different answers, since a gap is filled and a null is a settings error.
MISSING = object()
host-setup/agent-safety/install.py:45
- This comment block includes historical incident narrative ("reached a stranger's repository") and is longer than needed. The repo's comment conventions require comments to be short, state only the non-obvious why, and avoid historic/design narrative.
# The review loop's reply and resolve, the one write in that loop an agent performs.
# Driving it by hand needs a raw GraphQL mutation, which is the shape that reached a stranger's repository.
# `pr_review.py` queries the thread id itself and takes no argument an id fits in.
# Allowing the script is therefore narrower than allowing the mutation it replaces.
("Bash(python3 scripts/pr_review.py", "Bash(python3 scripts/pr_review.py:*)"),
host-setup/agent-safety/README.md:13
- This introduces an inline Markdown link (
[text](url)) in a non-exempt doc.GOVERNANCE.mdrequires reference-style links in Markdown docs other than the four instruction files, sohost-setup/agent-safety/README.mdshould avoid inline link targets.
- **The permission rules this kit owns, merged into `settings.json`** beside the hook registration. Each is declared as a prefix and a rule, and a re-run drops every rule under the prefix before adding the current one, so a rule whose spelling changes updates in place rather than accumulating beside the version it replaced. The prefix bounds what the installer owns, so a rule written by hand outside it is never touched. These widen rather than restrict, which is why they are their own component for the same reason the `fleet-bootstrap` block is separate from the `agent-safety` one. Today the list holds one rule, for `scripts/pr_review.py`, the review loop's reply and resolve. Driving that loop by hand needs a raw GraphQL mutation carrying a node id, which is the shape that reached a stranger's repository, where the script queries the id itself and takes no argument an id fits in. What the rule decides is which command runs without a prompt, and it matches the command text rather than the directory the command runs in, so it reaches a `scripts/pr_review.py` in any checkout that carries one. An absolute path would not narrow that, since the hub is reached as a checkout of the caller's own and its location differs per task, so pinning one path would name a checkout the next task does not use. What bounds it is the rule that an agent reaches the hub as a checkout of its own, fetched immediately before it is read, rather than a copy it happens to find on disk, which the `fleet-bootstrap` block beside this carries and [`GOVERNANCE.md`](../../GOVERNANCE.md) "Hub-Hosted Tooling" states in full.
host-setup/agent-safety/install.py:175
- The action message can be incorrect when
permissions.allowalready contains duplicate copies of the current rule. In that case the installer will still modify the list (deduplicate) but will report "already current" becauseolderonly counts non-identical matches.
matched = [a for a in allow if isinstance(a, str) and a.startswith(prefix)]
allow[:] = [a for a in allow if a not in matched] + [rule]
# Counted over the rules actually replaced rather than over everything the prefix matched.
# The current rule matches its own prefix, so counting the match set reports it as superseded.
older = [a for a in matched if a != rule]
Three of round six's four findings hold. The false claim round five removed from the README was still in the code comment beside the rule, since that sweep fixed the instance the finding named rather than the claim. The comment now says what the rule decides and that it bounds no checkout, which is what the README says. An inline link reached a file that is not one of the four instruction files, and GOVERNANCE.md allows inline targets in those alone. It is a reference definition in the existing group now, sorted by reference name. The action line read a list holding the current rule twice as unchanged while collapsing it, so the file changed and the output denied it. That case reports deduplicated with its count, verified alongside added, already current, and both superseding forms. The fourth finding is declined. It reads the MISSING comment as one sentence wrapped across two lines, where each line is a complete sentence with its own terminator, and prose_lint.py, which implements that rule and gates CI on it, exits 0 over the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 6, three of four accepted and fixed in
|
permissions.allow before |
Reported | After |
|---|---|---|
| empty | added |
one rule |
| the rule once | already current |
unchanged |
| the rule twice | deduplicated, collapsing 2 copies |
one rule |
| the rule plus one older spelling | updated, superseding 1 |
one rule |
| two older spellings | updated, superseding 2 |
one rule |
install.py:24, declined. It reads the MISSING comment as one sentence wrapped across two lines. Each line is a complete sentence carrying its own terminator:
# Distinguishes an absent key from one holding an explicit null, which `dict.get` reports alike.
# The two need different answers, since a gap is filled and a null is a settings error.
scripts/prose_lint.py implements that rule, gates CI on it, and exits 0 over this file:
python3 scripts/prose_lint.py host-setup/agent-safety/install.py --check comment-wrap --check comment-case
0 violation(s) across 0 file(s)The gate is the rule's definition here, so a finding it contradicts is declined rather than applied. Nothing is changed for it.
Verification on ee9a8e0
| Check | Result |
|---|---|
| Prose gate, whole tree | 0 violations across 0 files |
gh-write-guard.py --selftest |
PASS |
repo_gate.py |
eol 0, sha-pin 0 |
markdownlint-cli2 on this README |
0 issues |
editorconfig-checker |
exit 0 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
host-setup/agent-safety/README.md:29
- This sentence says the settings file is read once, but
install.pyreads it twice when it exists and is non-empty (settings.read_text(...)is called for the.strip()check and again forjson.loads(...)). Either cache the file contents or adjust the wording so it only promises the single write / single update pass.
Both are thin wrappers around `install.py`, so every OS runs one tested code path. The installer self-tests the hook before registering it, merges the settings.json hook entry and the permission rules without clobbering other keys, and updates each CLAUDE.md block in place by its own markers rather than duplicating it, so the two blocks move independently. The settings file is read once and written once, so the hook and the permission rules land together or not at all.
host-setup/agent-safety/install.py:161
- This comment claims settings.json is "read once and written once", but the file is currently read twice on the non-empty path (see the two
settings.read_text(...)calls before parsing). To avoid misleading future maintainers, either cache the read contents or update this comment to only describe the single-write behavior.
# 3. Permission rules, merged under the prefixes this installer owns.
# Dropping the prefix before appending is the same strip-then-register the hook above uses.
# That is what makes a re-run update a changed rule rather than leave both spellings in the list.
# Written in the same pass as the hook, so the file is read once and written once.
allow = data.setdefault("permissions", {}).setdefault("allow", [])
Both suppressed findings are the same defect and both are correct. The file was read twice on the non-empty path, once to test it for content and once to parse it, while a comment and the kit README both said it is read once and written once. The claim is mine, from the commit that moved the write. Fixed by making the code true rather than by weakening the sentence, since the property is worth having. One read into a variable serves both the emptiness test and the parse, which also removes a real hazard rather than only a redundant read: two reads can disagree when another process writes between them, and the emptiness the first read measured would then not be the content the second parsed. Verified across every path into that read: absent file, empty file, whitespace-only file, an existing valid file whose other keys survive, and an invalid one that still refuses without writing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 7, both suppressed findings accepted and fixed in
|
settings.json |
Result |
|---|---|
| absent | installs, rule added |
| present and empty | installs, rule added |
| present and whitespace only | installs, rule added |
present and valid, carrying effortLevel |
installs, effortLevel preserved |
| present and not valid JSON | exits 1, names the file, writes nothing |
Verification on 857f351
| Check | Result |
|---|---|
| Prose gate, whole tree | 0 violations across 0 files |
gh-write-guard.py --selftest |
PASS |
repo_gate.py |
eol 0, sha-pin 0 |
editorconfig-checker |
exit 0 |
Worth stating plainly, since this is the seventh round on a two-file change: every finding after the first has been in something the change itself asserted rather than in what it does. A count, a claim about scope, a claim about narrowness, and now a claim about reads. The code has been correct for several rounds and the prose around it has not, which is the failure mode this repository already names, and it is being corrected here rather than argued with.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/agent-safety/install.py:45
MANAGED_PERMISSIONSuses a prefix that does not include the:delimiter, so it can match and overwrite unrelated allow-rules that merely start withBash(python3 scripts/pr_review.py(e.g.,...pr_review.py-custom:*)). Since the prefix is the boundary for what the installer “owns”, it should be as specific as possible to the intended rule namespace.
# The review loop's reply and resolve, the one write in that loop an agent performs.
# Driving it by hand needs a raw GraphQL mutation carrying a node id, which is the shape to avoid.
# The rule decides which command skips a prompt, and it bounds no checkout, since it matches the text.
("Bash(python3 scripts/pr_review.py", "Bash(python3 scripts/pr_review.py:*)"),
]
The finding is right and the defect contradicts the property the design claims. The prefix ends at the script name, so a bare startswith also claimed `pr_review.py-custom`, `pr_review.py_old` and `pr_review.py2`, and a re-run would have deleted a hand-written rule for each while the README promised such a rule is never touched. Appending the colon to the prefix is not the fix, since it would stop matching `pr_review.py reply *`, which is exactly the older spelling the supersede exists to replace. What separates the two is the character after the name: a rule that invokes this script continues with a rule-syntax delimiter, where a different script continues with more of its own path. Verified on a list holding all four shapes at once. The one owned rule is superseded and the three unrelated ones survive untouched, and every action still reports correctly, meaning added, already current, deduplicated, and both superseding counts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 8, the suppressed finding accepted and fixed in
|
| Allow rule | Claimed by the old test | Actually this script |
|---|---|---|
Bash(python3 scripts/pr_review.py:*) |
yes | yes |
Bash(python3 scripts/pr_review.py reply *) |
yes | yes |
Bash(python3 scripts/pr_review.py-custom:*) |
yes | no |
Bash(python3 scripts/pr_review.py_old:*) |
yes | no |
Bash(python3 scripts/pr_review.py2:*) |
yes | no |
A re-run would have deleted the bottom three.
Appending the colon to the prefix is not the fix. It would stop matching pr_review.py reply *, which is exactly the older spelling the supersede exists to replace, so the rule would accumulate beside its predecessor instead. What separates the two cases is the character after the name: a rule invoking this script continues with a rule-syntax delimiter (:, a space, or the closing paren), where a different script continues with more of its own path.
Verified on a list holding all four shapes at once
before: [pr_review.py reply *, pr_review.py-custom, pr_review.py_old, pr_review.py2, git -C * log *]
action: updated, superseding 1
after: [pr_review.py-custom, pr_review.py_old, pr_review.py2, git -C * log *, pr_review.py:*]
One owned rule superseded, three unrelated ones untouched, and the unrelated git rule preserved as before.
Every action still reports correctly:
permissions.allow before |
Reported |
|---|---|
| empty | added |
| the rule once | already current |
| the rule twice | deduplicated, collapsing 2 copies |
| two older spellings | updated, superseding 2 |
Bash(python3 scripts/pr_review.py), the bare-paren form |
updated, superseding 1 |
Verification on e356409
| Check | Result |
|---|---|
| Prose gate, whole tree | 0 violations across 0 files |
gh-write-guard.py --selftest |
PASS |
repo_gate.py |
eol 0, sha-pin 0 |
markdownlint-cli2 on this README |
0 issues |
editorconfig-checker |
exit 0 |
The README sentence that made the promise now states the delimiter requirement that keeps it.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/agent-safety/install.py:192
- The "deduplicated" status message is computed from
copies, which currently represents the number of occurrences of the current rule. This can read as if that many entries were removed (e.g., "collapsing 2 copies" even though one copy remains). It also doesn't report deduplication when an update + dedup happen together. Consider computing the number of duplicates removed and reflecting that in the message, optionally alongside the update message.
action = f"deduplicated, collapsing {copies} copies"
Both halves of the finding hold. The message counted occurrences of the current rule rather than entries removed, so a list holding it twice read as collapsing two where one was removed and one remains. And an update and a deduplication can happen together, where the message named only the update: a list holding the rule twice beside one older spelling collapsed three entries to one and reported superseding 1, saying nothing about the duplicate. The action is composed from what the write actually removes now, and both causes are named when both apply. Every message is checkable against the entry count on each side of the run, which is how each was verified rather than by reading the code: 0 -> 1 added 1 -> 1 already current 2 -> 1 updated, removing 1 duplicate 3 -> 1 updated, removing 2 duplicates 2 -> 1 updated, superseding 1 2 -> 1 updated, superseding 2 3 -> 1 updated, superseding 1 and removing 1 duplicate 5 -> 2 updated, superseding 2 and removing 1 duplicate The last case also carries an unrelated rule for a different script, which survives, so the delimiter ownership from the previous commit is exercised here as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 9, the suppressed finding accepted and fixed in
|
| Before | After | Reported |
|---|---|---|
| 0 | 1 | added |
| 1 | 1 | already current |
| 2 | 1 | updated, removing 1 duplicate |
| 3 | 1 | updated, removing 2 duplicates |
| 2 | 1 | updated, superseding 1 |
| 2 | 1 | updated, superseding 2 |
| 3 | 1 | updated, superseding 1 and removing 1 duplicate |
| 5 | 2 | updated, superseding 2 and removing 1 duplicate |
The last row also carries a rule for a different script, pr_review.py-custom, which survives, so the delimiter ownership from the previous round is exercised here too. Every row's arithmetic is checkable: removed equals before minus after, plus one for the rule that is always written.
Verification on aa65b14
| Check | Result |
|---|---|
| Prose gate, whole tree | 0 violations across 0 files |
gh-write-guard.py --selftest |
PASS |
repo_gate.py |
eol 0, sha-pin 0 |
editorconfig-checker |
exit 0 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/agent-safety/install.py:185
- The inline comments explaining how
older/duplicatesare computed are currently repetitive (they restate the same rationale twice) which makes this section harder to scan. Consider collapsing them into a single concise comment so the intent is clear without duplication.
# Counted over the rules actually replaced rather than over everything the prefix matched.
# The current rule matches its own prefix, so counting the match set reports it as superseded.
older = [a for a in matched if a != rule]
# Counted over what the write removes rather than over what the prefix matched.
# The current rule matches its own prefix, so a match-set count reports it as superseded.
The finding is correct and the duplicate is mine. The previous commit replaced the block starting at the `older` assignment and left the comment above it in place, so two near-identical pairs sat around one statement. The stale pair is gone. Sweeping the file's comments rather than the two lines quoted found the same class one step apart: the module constant and step 3 both explained why dropping the prefix makes a re-run update in place. The constant documents the data, so step 3 keeps only what is its own, that it borrows the hook registration's shape and shares its single write. Comments only. The action matrix was re-run against entry counts to confirm it, and every message is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 10, the suppressed finding accepted and fixed in
|
| Location | What it said |
|---|---|
| the module constant | a re-run drops every rule the prefix owns before adding the current one, so a changed rule updates in place |
| step 3 | dropping the prefix before appending is what makes a re-run update a changed rule rather than leave both spellings |
The constant documents the data, so step 3 now keeps only what is its own: that it borrows the hook registration's shape and shares its single write.
Comments only, and confirmed as such
The action matrix was re-run against entry counts rather than assumed unchanged:
0 -> 1 added
1 -> 1 already current
2 -> 1 updated, removing 1 duplicate
3 -> 1 updated, superseding 1 and removing 1 duplicate
5 -> 2 updated, superseding 2 and removing 1 duplicate
Verification on ab6140e
| Check | Result |
|---|---|
| Prose gate, whole tree | 0 violations across 0 files |
gh-write-guard.py --selftest |
PASS |
repo_gate.py |
eol 0, sha-pin 0 |
editorconfig-checker |
exit 0 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
host-setup/agent-safety/install.py:119
- The error text says "Nothing was written", but this installer has already copied/self-tested the hook before reaching this validation. Consider narrowing the claim to the settings file so the message stays accurate.
sys.stderr.write(
f"{settings} is valid JSON but holds {type(data).__name__} at its root where an object "
"is required. Fix or remove it, then re-run. Nothing was written.\n"
)
host-setup/agent-safety/install.py:129
- This validation error also says "Nothing was written", but the hook file may already have been (re)deployed before this check. Rephrase to specify that settings.json was not updated/written to avoid a misleading message.
def reject(where, held, want):
sys.stderr.write(
f"{settings} has `{where}` as {type(held).__name__} where {want.__name__} is required. "
"Fix or remove that key, then re-run. Nothing was written.\n"
)
Both suppressed findings are the same defect and both are correct. The refusals claimed nothing was written, where step 1 copies the hook and self-tests it before any of these checks run, so the hook file is on disk when the message prints. The claim is mine, added with the checks. Each refusal now says that the settings file is unchanged and names the state the run leaves behind, that the hook is deployed but not registered, which is what a reader needs in order to know whether re-running is enough. Verified by listing the tree after each refusal rather than by reading the code: the hook is present, the settings file is byte-identical, and the exit code is 1. Swept the phrase rather than the two quoted lines. The four other occurrences are in `scripts/pr_review.py`, where nothing was written names a GitHub write on paths that return before any mutation, so that claim is accurate and unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 11, both suppressed findings accepted and fixed in
|
| Check | Result |
|---|---|
| Three refusal shapes, message and disk state | hook present, settings byte-identical, exit 1 |
| Prose gate, whole tree | 0 violations across 0 files |
gh-write-guard.py --selftest |
PASS |
editorconfig-checker |
exit 0 |
One correction to my earlier answers on this pull request, which I cannot edit and would not quietly rewrite: the round two and round three comments quoted the old Nothing was written message as evidence, and that phrasing was inaccurate for the same reason. What those rounds actually verified, and what still holds, is that the settings file is byte-identical after each refusal.
…ule (#621) Promotes three changes, 7 files. The second and third exist because this promotion's own review found defects in the first, and a promotion's head is `develop`, so each took its own branch and its own review before this could carry it. | Commit | Pull request | What it does | | --- | --- | --- | | `26af061` | [#620](#620) | Gates every default prose rule whole-tree, closing [#519](#519) | | `039be28` | [#622](#622) | Names the one default rule that does not gate, found by this promotion's review | | `674a27a` | [#624](#624) | Carries the permission rules the review loop needs in the host installer | ## What lands on `main` **The prose gate now blocks.** The backlog reached zero at `20916ad`, so the warn-only tier that `semicolon` and `dash` were given while several hundred findings sat in the tree has expired. Both block now, and `home-path` joins them. `home-path` is the substantive half. It had been in `DEFAULT_RULES` since it was written, so every bare local run included it, and it was named by neither CI step. The rule that catches an absolute home path naming a real account therefore ran on a developer's machine and gated nothing in CI, which is the pattern-detectable sliver of [`GOVERNANCE.md`](https://github.com/ptr727/ProjectTemplate/blob/develop/GOVERNANCE.md) "Representative Data in Agent-Authored Text". [`OPERATIONS.md`](https://github.com/ptr727/ProjectTemplate/blob/develop/OPERATIONS.md) had recorded the hole, and this closes it and drops the record. `charset-unknown` stays warn-only, deliberately. A finding there names a character no tier covers, and classifying one is a fleet-law edit rather than something the change that typed it can fix. **The agent-safety installer carries permission rules.** It configured `settings.json` for the hook alone, so a rule added on one machine reached none of the others. Each rule is declared as a prefix and a rule, ownership requires a delimiter after the prefix so a hand-written rule for a different script is never claimed, and a re-run updates a changed rule in place. The list holds one rule today, for `scripts/pr_review.py`. The installer also refuses a malformed `settings.json` at every level it descends rather than raising a traceback partway through its edits. ## What is unchanged The [`prose-gate`](https://github.com/ptr727/ProjectTemplate/blob/develop/.github/actions/prose-gate/action.yml) composite action runs `--diff` with no `--check` list, so it already gated the full default set over the lines a change touches. A downstream caller sees no behavior change from this promotion. Only the hub's own whole-tree steps move. Nothing is installed on any machine by this. Running the installer per host stays the maintainer's action, tracked by [#365](#365). ## One property of the gate worth stating on `main` `validate-task.yml` is `workflow_call`, invoked by `test-pull-request.yml` on `pull_request` to `main` and `develop`. There is no push trigger, so a merge commit never runs it. The promoted invocation was exercised by this pull request's own CI run on `26af061`, where `Check prose step` and `Report unclassified characters step` both appear and both pass. ## Review record Twenty-one Copilot rounds across the three pull requests, 5 on #620, 2 on #622, and 12 on #624, plus 2 here. Several rounds reported no comments while carrying findings in a `<details>` block, which is the shape a thread-polling loop reads as a pass. Worth recording rather than hidden in the count: nearly every finding was in a claim the change made about itself rather than in what the code does. A count that included the rule it was counting against, a scope claim an absolute path would not have fixed, a "read once" that read twice, and a "nothing was written" printed after the hook was already on disk. ## Verification Run on `develop` at `674a27a`: | Check | Result | | --- | --- | | Gating invocation, whole tree | 0 violations across 0 files | | `charset-unknown` and `sentence-split`, whole tree | 0 violations across 0 files | | `python3 scripts/test_prose_lint.py` | 198 tests, OK | | `python3 scripts/repo_gate.py` | `eol` 0, `sha-pin` 0 | | `python3 host-setup/agent-safety/gh-write-guard.py --selftest` | PASS | | `python3 spec/validate.py` | 22 cataloged, 0 backlog repos classify cleanly | | `editorconfig-checker` | exit 0 | | Installer, fresh `CLAUDE_HOME` | hook registered, permission rule added | Merge with a merge commit rather than a squash, and without `--delete-branch`, since this pull request's head is `develop`.
The kit configured
settings.jsonfor the hook alone, so a permission rule added on one machine reached none of the others. The installer now carries the rules it owns, which makes re-running it the way those rules reach every host.What changed
install.pygains aMANAGED_PERMISSIONSlist, each entry a prefix and a rule, merged in the same pass that registers the hook. The settings file is still read once and written once, so the hook and the rules land together or not at all.PreToolUsehookCLAUDE.mdblockspermissions.allowCLAUDE.mdblocksWhy a prefix rather than a plain append
A re-run drops every rule under the prefix before adding the current one, which is the same strip-then-register the hook registration already uses. A rule whose spelling changes therefore updates in place rather than accumulating beside the version it replaced, and the prefix bounds what the installer owns so a rule written by hand outside it is never touched.
The one rule it carries today
Bash(python3 scripts/pr_review.py:*), for the review loop's reply and resolve. Driving that loop by hand needs a raw GraphQL mutation, which is the shape that reached a stranger's repository.scripts/pr_review.pyqueries the thread id itself, takes no argument an id fits in, and refuses to resolve a thread whose reply did not land, so allowing the script is narrower than allowing the mutation it replaces.These widen rather than restrict, so they are their own component for the same reason the
fleet-bootstrapblock is separate from theagent-safetyone: the safety half's own text says nothing in it widens a permission. Keeping a grant in its own step preserves that, where folding it into the hook registration would not.Verification, run against throwaway
CLAUDE_HOMEtreessettings.jsonat alladded, list holds the one rulealready current, no duplicate rule, hook count still 1updated, superseding 1updated, superseding 2, collapsed to one ruleBash(git -C * log *),additionalDirectoriesandeffortLevelall preservedsettings.jsonis not valid JSONGates on this branch: prose gate 0 whole-tree,
gh-write-guard.py --selftestPASS,repo_gate.pyclean,editorconfig-checkerexit 0,markdownlint-cli20 issues.The prose gate caught two wrapped comment sentences in the new code before the first push, which is the gate promoted to blocking earlier today doing the job it was promoted for.
What this does not do
It installs nothing anywhere. Running it on each host stays the maintainer's action, tracked by the per-machine rollout in #365.