From efefd6ca93de946cd29b8e7ff4df79d560712543 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 3 Aug 2026 11:04:57 -0700 Subject: [PATCH 1/3] Record the two write-guard limits a live sweep exposed Both were found while driving seventeen pull requests through the review loop, and neither was written down. A write inside a script file is unseen. The hook reads the command the agent runs, so for a script invocation that is one string and every mutation inside it reaches the server unparsed. This is the wider of the two, and it is one an agent opens by accident: batching fifteen repetitive pushes into a loop looks like tidiness rather than like switching a guard off. The boundary is the write, not the script, so a script that only reads or prepares local commits is unaffected. A push followed by a newline over-blocks. The argument list ends at && and not at a newline, so a later line's tokens are read as refspecs and the push denies as a direct push to a protected branch it never targeted. Safe in direction, but the denial names a bypass that was not attempted, and a guard that cries wolf is one an agent starts working around. The gh pr edit breakage found in the same session is already documented in the review runbook, workaround included, so nothing is added for it. Co-Authored-By: Claude Opus 5 (1M context) --- host-setup/agent-safety/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index 6a8fa41a..f9bb3385 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -71,6 +71,8 @@ The installer writes this. It is here so you can inspect or hand-place it: - **Precision over recall for the write footguns.** The hook denies the specific dangerous write shapes with high confidence rather than gating every write, so it never blocks legitimate work. A shape it does not catch still falls under the behavioral rules. - **The branch-bypass rule fails closed.** Unlike the write-footgun rules, a push to `main`/`master`/`develop` is denied even when its rules cannot be determined (the API is unreachable, or the checkout's origin cannot be resolved to query them), because the harm there is a silent success under the maintainer's admin bypass. The rule reads each branch's live rules, so it adapts to every repo (a code-style `develop` denies, a config-style `develop` allows) with no per-repo configuration, and hands the exact command to the maintainer to run when a bypass is genuinely intended. - **Opaque targets are unseen.** The hook cannot see the repository behind a GraphQL node id, which is exactly why rule 2 blocks a *literal* id at all, since a captured `$variable` is trusted. Likewise, the cross-origin check only runs when an `origin` can be resolved and the write names an explicit `-R`/`repos//` target. A write from a non-git directory, or one whose target is only a node id, is evaluated by rules 1 and 2 alone. +- **A write inside a script file is unseen, so never batch writes into one.** The hook reads the command the agent runs, which for `bash deploy.sh` is that one string, so a `git push` or a `gh` mutation inside the script reaches the server without the hook ever parsing it. This is the widest hole in the kit and it is one an agent opens by accident, since collecting fifteen repetitive pushes into a loop looks like tidiness rather than like disabling a guard. Issue each write as its own command. A script that only reads, computes, or prepares local commits is fine, because the boundary is the write and not the script. +- **A `git push` followed by a newline over-blocks.** The push argument list ends at `&&` and not at a newline, so every token on a later line of the same command is read as a refspec. Measured against the installed hook, `git push -u origin revendor/x` resolves to that one branch, while the same push followed by a newline and a `gh pr create` naming `develop` as its base resolves to five, meaning `revendor/x`, `gh`, `pr`, `create`, and `develop`. The push then denies as a direct push to a protected branch. The direction is safe, since it blocks rather than admits, but the denial names a bypass the agent never attempted, and a guard that cries wolf is one an agent starts working around. Until it is fixed, issue the push as its own command, which is the rule directly above in any case. Tracked in the hub `TODO.md`. - **Not a credential control.** A fine-grained PAT limited to owned repositories is a separate, stronger structural guard (a hard `403` on any non-owned repo) and is left to per-machine credential setup, out of this kit. From 998d70819973e65c06f4fc08e7a497b03905c39b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 3 Aug 2026 11:17:12 -0700 Subject: [PATCH 2/3] Attribute the over-block to the hook's parser, not to git The bullet read as though the shell carried newlines into the push argument list and as though git denied the push. Neither is true: the hook splits the command string itself, and its split ends an argument list at && but not at a newline, so it is the hook that mis-parses and the hook that denies. Naming the wrong actor in a limits section is worse than saying nothing, since a reader debugging it would go looking at git. Also drops the unexplained "hub" qualifier on TODO.md, which reads oddly in the hub's own tree where TODO.md is simply TODO.md. Co-Authored-By: Claude Opus 5 (1M context) --- host-setup/agent-safety/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index f9bb3385..df6855d0 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -72,7 +72,7 @@ The installer writes this. It is here so you can inspect or hand-place it: - **The branch-bypass rule fails closed.** Unlike the write-footgun rules, a push to `main`/`master`/`develop` is denied even when its rules cannot be determined (the API is unreachable, or the checkout's origin cannot be resolved to query them), because the harm there is a silent success under the maintainer's admin bypass. The rule reads each branch's live rules, so it adapts to every repo (a code-style `develop` denies, a config-style `develop` allows) with no per-repo configuration, and hands the exact command to the maintainer to run when a bypass is genuinely intended. - **Opaque targets are unseen.** The hook cannot see the repository behind a GraphQL node id, which is exactly why rule 2 blocks a *literal* id at all, since a captured `$variable` is trusted. Likewise, the cross-origin check only runs when an `origin` can be resolved and the write names an explicit `-R`/`repos//` target. A write from a non-git directory, or one whose target is only a node id, is evaluated by rules 1 and 2 alone. - **A write inside a script file is unseen, so never batch writes into one.** The hook reads the command the agent runs, which for `bash deploy.sh` is that one string, so a `git push` or a `gh` mutation inside the script reaches the server without the hook ever parsing it. This is the widest hole in the kit and it is one an agent opens by accident, since collecting fifteen repetitive pushes into a loop looks like tidiness rather than like disabling a guard. Issue each write as its own command. A script that only reads, computes, or prepares local commits is fine, because the boundary is the write and not the script. -- **A `git push` followed by a newline over-blocks.** The push argument list ends at `&&` and not at a newline, so every token on a later line of the same command is read as a refspec. Measured against the installed hook, `git push -u origin revendor/x` resolves to that one branch, while the same push followed by a newline and a `gh pr create` naming `develop` as its base resolves to five, meaning `revendor/x`, `gh`, `pr`, `create`, and `develop`. The push then denies as a direct push to a protected branch. The direction is safe, since it blocks rather than admits, but the denial names a bypass the agent never attempted, and a guard that cries wolf is one an agent starts working around. Until it is fixed, issue the push as its own command, which is the rule directly above in any case. Tracked in the hub `TODO.md`. +- **The hook's own parser over-blocks a `git push` followed by a newline.** Git and GitHub are not involved in this one: the hook splits the command string to find each `git push` and its arguments, and that split ends an argument list at `&&` but not at a newline, so it reads every token on a later line of the same command as a refspec of that push. Measured against the installed hook, `git push -u origin revendor/x` resolves to that one branch, while the same push followed by a newline and a `gh pr create` naming `develop` as its base resolves to five, meaning `revendor/x`, `gh`, `pr`, `create`, and `develop`. The hook then denies the push as a direct push to a protected branch that the push never named. The direction is safe, since it blocks rather than admits, but the denial names a bypass the agent never attempted, and a guard that cries wolf is one an agent starts working around. Until the parser is fixed, issue the push as its own command, which is the rule directly above in any case. Tracked in `TODO.md`. - **Not a credential control.** A fine-grained PAT limited to owned repositories is a separate, stronger structural guard (a hard `403` on any non-owned repo) and is left to per-machine credential setup, out of this kit. From a3fb37893780d688941743e353214587054ed6b3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 3 Aug 2026 11:45:04 -0700 Subject: [PATCH 3/3] Say which thing writes must not be batched into The bullet header read 'never batch writes into one', where one could be a script, a command, or a line. It is a script, which is the limitation the bullet then describes, so the header now says so. Co-Authored-By: Claude Opus 5 (1M context) --- host-setup/agent-safety/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index df6855d0..bd279ba7 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -71,7 +71,7 @@ The installer writes this. It is here so you can inspect or hand-place it: - **Precision over recall for the write footguns.** The hook denies the specific dangerous write shapes with high confidence rather than gating every write, so it never blocks legitimate work. A shape it does not catch still falls under the behavioral rules. - **The branch-bypass rule fails closed.** Unlike the write-footgun rules, a push to `main`/`master`/`develop` is denied even when its rules cannot be determined (the API is unreachable, or the checkout's origin cannot be resolved to query them), because the harm there is a silent success under the maintainer's admin bypass. The rule reads each branch's live rules, so it adapts to every repo (a code-style `develop` denies, a config-style `develop` allows) with no per-repo configuration, and hands the exact command to the maintainer to run when a bypass is genuinely intended. - **Opaque targets are unseen.** The hook cannot see the repository behind a GraphQL node id, which is exactly why rule 2 blocks a *literal* id at all, since a captured `$variable` is trusted. Likewise, the cross-origin check only runs when an `origin` can be resolved and the write names an explicit `-R`/`repos//` target. A write from a non-git directory, or one whose target is only a node id, is evaluated by rules 1 and 2 alone. -- **A write inside a script file is unseen, so never batch writes into one.** The hook reads the command the agent runs, which for `bash deploy.sh` is that one string, so a `git push` or a `gh` mutation inside the script reaches the server without the hook ever parsing it. This is the widest hole in the kit and it is one an agent opens by accident, since collecting fifteen repetitive pushes into a loop looks like tidiness rather than like disabling a guard. Issue each write as its own command. A script that only reads, computes, or prepares local commits is fine, because the boundary is the write and not the script. +- **A write inside a script file is unseen, so never batch writes into a script.** The hook reads the command the agent runs, which for `bash deploy.sh` is that one string, so a `git push` or a `gh` mutation inside the script reaches the server without the hook ever parsing it. This is the widest hole in the kit and it is one an agent opens by accident, since collecting fifteen repetitive pushes into a loop looks like tidiness rather than like disabling a guard. Issue each write as its own command. A script that only reads, computes, or prepares local commits is fine, because the boundary is the write and not the script. - **The hook's own parser over-blocks a `git push` followed by a newline.** Git and GitHub are not involved in this one: the hook splits the command string to find each `git push` and its arguments, and that split ends an argument list at `&&` but not at a newline, so it reads every token on a later line of the same command as a refspec of that push. Measured against the installed hook, `git push -u origin revendor/x` resolves to that one branch, while the same push followed by a newline and a `gh pr create` naming `develop` as its base resolves to five, meaning `revendor/x`, `gh`, `pr`, `create`, and `develop`. The hook then denies the push as a direct push to a protected branch that the push never named. The direction is safe, since it blocks rather than admits, but the denial names a bypass the agent never attempted, and a guard that cries wolf is one an agent starts working around. Until the parser is fixed, issue the push as its own command, which is the rule directly above in any case. Tracked in `TODO.md`. - **Not a credential control.** A fine-grained PAT limited to owned repositories is a separate, stronger structural guard (a hard `403` on any non-owned repo) and is left to per-machine credential setup, out of this kit.