diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index 19921faf..fd765efd 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -99,7 +99,12 @@ text says, because the harm it covers was never in the text. git reads them. A short-option cluster such as `-uf` or `-ud` forces or deletes. A long option cut short, such as `--del`, `--al`, or `--mirr`, is the option it starts, and `--branches` is `--all`. Of `--force`, `--delete`, `--all`, `--mirror`, `--tags`, or `--prune` and its `--no-` form, the later one wins. `--no-force` leaves a `--force-with-lease` in place, as git does. A refspec pattern whose destination can match under `refs/heads/`, such as `refs/*:refs/*`, is read as pushing - every branch protected by default. Beside such a pattern, a lone `:` + every branch protected by default. It is also read as each branch it maps a local ref to in the + push's checkout. So a branch under a ruleset such as `release/*` is checked. A lone `:` is read + the same way, as each local branch with a same-named remote-tracking ref of its remote. `--all` + is read as every local branch too, and `--mirror` as every local and remote-tracking branch. + With `--prune`, a pattern or a lone `:` also deletes each remote-tracking branch it matches. + Beside such a pattern, a lone `:` refspec, `--all`, or a bare push, a `--prune` is read as deleting every branch protected by default. A `--mirror` push with no refspec is read as forcing and deleting every branch protected by default. So is a push with no refspec whose remote has a persisted @@ -116,7 +121,11 @@ text says, because the harm it covers was never in the text. maintainer's own admin bypass. A push to any other branch whose rules cannot be determined passes this requirement instead, since there is nothing yet on record to bypass. A push that `-C` or a leading `cd` runs in another checkout is judged against that checkout's origin as well - as the session's. The hook may not name the checkout a push runs in or that checkout's origin. + as the session's. A push is also judged against the repository its remote writes. That is read + from the remote's push URLs, `pushurl` and `pushInsteadOf` included, or from a URL the push + names, rewritten as git rewrites it. A push naming no remote takes it from `--repo`, the + branch's `pushRemote`, `remote.pushDefault`, or the branch's `remote`, in git's order. A URL + naming no repository, such as a local path, leaves that push's rules undetermined. The hook may not name the checkout a push runs in or that checkout's origin. That holds under `sudo -D` or `env -C`, in a wrapper shell, and in a directory that does not exist. That push's rules then count as undetermined. Apart from that case, a push whose target the hook cannot read at all is denied whatever branch it names. @@ -147,7 +156,16 @@ text says, because the harm it covers was never in the text. That is its own name under a `push.default` of `current` or `simple`, and each `branch..merge` branch under `upstream`. An unset `push.default` is read as `simple`. Both are read where `push.default` holds a value git does not define, or where its read exits with any other error. A bare push is denied - where any read of its branch, its push target, or `push.default` times out or cannot run. A word the shell rewrites or drops before git reads it is denied where it can + where any read of its branch, its push target, or `push.default` times out or cannot run. A + bare push also reads its remote's `remote..push` refspecs as if the command named them, + and a `push.default` of `matching` as a lone `:`. A source-only refspec is also read as the + destination a `remote..push` refspec maps it to, and under `upstream` as its + `branch..merge`. A push is denied, with an explicit refspec as the fix, where such a + config or branch list cannot be read. That covers a matching push to a URL, which has no + remote-tracking refs to list. It also covers a pattern, a lone `:`, `--all`, or `--mirror` + after a `gh` command, a wrapper shell, or most git subcommands. The git subcommands that do + not count leave `HEAD` and the push config alone, such as `add`, `commit`, `fetch`, or + `status`. The hook lists the branches before that command runs. A word the shell rewrites or drops before git reads it is denied where it can change what git runs. Such a word is a `$`, backtick, or brace-expansion word. It is denied where any git command's subcommand goes, and among a push's words or a commit's. On any git command it is denied among the global options where word splitting may split it. A @@ -319,7 +337,10 @@ text says, because the harm it covers was never in the text. join folds it logically, so the two can name different checkouts. A mutating invocation naming one is denied wherever it points. So is a bare `checkout`/`switch` argument, which cannot then be told from a pathspec. So is a subcommand git neither ships nor finds on `PATH`, which may be an - alias defined there. An absolute path with no `..` is the workaround. On Windows, a Git Bash drive spelling such as + alias defined there. An absolute path with no `..` is the workaround. A directory the shell + reads from its own directory stack is denied the same way, leading or not. That is `cd -`, a + `~-` or `~+` tilde word, a `pushd`/`popd` index such as `+1`, or a bare `pushd`/`popd`. The hook cannot see that + stack. On Windows, a Git Bash drive spelling such as `/c/repos/x`, in any of those values or in the hook's own reported cwd, reads as `C:/repos/x`, the path MSYS hands `git.exe`. A rooted path with no drive segment stays unconverted, since MSYS maps it under the Git install root, which the hook cannot see without executing something. Fail open (allow) when no git repository diff --git a/host-setup/agent-safety/claude/README.md b/host-setup/agent-safety/claude/README.md index 6e1646f7..0f2c3643 100644 --- a/host-setup/agent-safety/claude/README.md +++ b/host-setup/agent-safety/claude/README.md @@ -12,7 +12,7 @@ installation and operational detail. Codex and opencode have no equivalent hook Into `~/.claude/` (or `%USERPROFILE%\.claude\` on Windows): -- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies four classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is under an owner other than the checkout origin's. The write and its target are read where gh reads them. That includes a repo flag before the subcommand, an `R` ending a short-option cluster, and a `GH_REPO` value. It also includes an `--org` or `--owner` value and a linked issue's URL. A write whose repository owner is a run-time word fails closed. So does one whose target the hook does not read, such as a gh write an `eval` runs. Sibling repositories under the same owner are allowed, since the harm this guards is reaching a stranger's repository rather than working across one maintainer's own fleet, and a different owner is allowed only when the maintainer names it in `GH_WRITE_GUARD_ALLOW` (an `owner/repo` list, where `owner/*` grants a whole owner). That variable is read from the environment the session was launched with, which is the one channel an agent cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` inside a Bash call never reaches it. Second, a **git operation that bypasses a repository protection**. The branch-rule cases (a direct push, a force-push, or a branch delete to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged per refspec against the branch's **live** rules, so a code-style `develop` is denied while a config-style `develop` is allowed with no per-repo configuration. A push's force, delete, all, mirror, and prune options are read as git reads them. A short-option cluster such as `-uf` or `-ud` forces or deletes. A long option cut short, such as `--del`, `--al`, or `--mirr`, is the option it starts, and `--branches` is `--all`. Of `--force`, `--delete`, `--all`, `--mirror`, `--tags`, or `--prune` and its `--no-` form, the later one wins. `--no-force` leaves a `--force-with-lease` in place, as git does. A refspec pattern whose destination can match under `refs/heads/`, such as `refs/*:refs/*`, is read as pushing every protected-default branch. Beside such a pattern, a lone `:` refspec, `--all`, or a bare push, a `--prune` is read as deleting every protected-default branch. A `--mirror` push with no refspec is read as forcing and deleting every protected-default branch. So is a push with no refspec whose remote has a persisted `remote..mirror` set true, or whose setting the hook cannot read. A push naming no remote reads every remote's setting. Git reads every word after a push's first `--` or `--end-of-options` as the remote or a refspec, and so does the hook. A bare push's destination is also read from the branch's own configuration. That is its own name under a `push.default` of `current` or `simple`, and each `branch..merge` branch under `upstream`. An unset `push.default` is read as `simple`. Both are read where `push.default` holds a value git does not define, or where its read exits with any other error. Git may complete a destination outside `refs/` that starts `heads/` under `refs/`, as in `HEAD:heads/develop` or `:heads/develop`. So such a destination is read as the branch after `heads/` as well as by its literal name. A push to a protected-default branch fails closed when its rules cannot be determined, either because the API is unreachable or because the checkout's origin cannot be resolved to query them. A push that `-C` or a leading `cd` runs in another checkout is judged against that checkout's origin as well as the session's. One whose checkout or origin the hook cannot name has undetermined rules, as under `sudo -D` or in a wrapper shell. A push whose target the hook cannot read is denied whatever branch it names. That covers a push in text it does not parse, or carrying a `-c` config override. Config set through `GIT_CONFIG_COUNT` counts as one. It covers a push in interpreter code that can start a process, in text piped to a shell, or run by a persisted alias. An alias set in the environment is followed into the persisted config. A git subcommand whose aliases reach a `!` shell alias is denied in any checkout, whatever that alias runs. The hook does not read its shell text. A persisted alias is read from the directory git runs in. It is also read from a `--git-dir` or `GIT_DIR` repository, through a linked worktree's `.git` file as git reads it. A git command nested past the wrapper reading cap is denied where its subcommand may be an alias. Any git command under an `include.path`/`includeIf` override is denied too. So is a config value or environment variable git runs as a command, where it names its program by a path or an expansion. So is one from `--config-env`. The hook reads none of those. A shell whose command string runs git is denied where its prefix or an earlier `export` sets config for it. That is a `GIT_CONFIG_GLOBAL` or `GIT_CONFIG_SYSTEM` file other than `/dev/null`, or config through `GIT_CONFIG_COUNT`. A variable git runs as a command, set there, counts too, unless it names a plain program that runs no git. The hook reads that string without those settings. So is a bare push whose push target resolves while `git remote` cannot list the remotes. So is one where any read of its branch, its push target, or `push.default` times out or cannot run. So is a word the shell rewrites or drops before git reads it, where it can change what git runs. Such a word is a `$`, backtick, or brace-expansion refspec, subcommand, or commit argument, or a comment among a push's words. So is a `-C`, `--git-dir`, or `--work-tree` value that word splitting may split, that holds a brace expansion, or holds an expansion whose quoting the guard cannot read, on every git subcommand, and a command word holding a `$` or backtick where the subcommand is `push` or `commit`. So is a directory value holding a double-quoted list expansion such as `"$@"`, or an indirect `"${!name}"`, on every git subcommand. The explicit-bypass flags are denied **unconditionally**, since the flag is itself the bypass and needs no branch query: `gh pr merge --admin` overrides the server-side merge gate, and `git commit`/`git push --no-verify` skips the local git hooks, as does a `git -c core.hooksPath=` override on either, which points git at another hooks directory, and an `include.path`/`includeIf` override on either, whose file may do the same. These are read in a command the hook parses, a wrapper shell's body and a command substitution's body among them. A substitution inside a double-quoted `${...}` or an unquoted-delimiter heredoc counts. So does a command string `pwsh`, `fish`, `script`, `cmd`, or another shell runs. So does a config or environment value git runs as a command, such as a `core.pager` or `GIT_EDITOR` value naming its program plainly. Those bodies are read up to a few levels of nesting, and a git commit or push nested deeper is denied. `--admin` is also read inside a quoted `eval` argument, as the command it runs. A quoted `"--admin"` is read too where the shell passes it to gh as a flag. The other flags are not read inside a quoted `eval` argument. A run-time word among a `gh pr merge`'s words, such as `$A` or `"$(...)"`, may expand to `--admin`, so it fails closed. An option's value that word splitting cannot split is the exception, as in `--body "$BODY"`. A run-time `--repo` value fails closed too, since the target check cannot read it. A run-time word where gh's command or `pr`'s subcommand goes may expand to `pr merge`. It fails closed beside a literal `--admin`, or where word splitting may split it. So does a run-time command word before `pr merge`, as in `G=gh; $G pr merge`. Any other run-time word among such a command's words fails closed as it does on a merge, so `gh pr "$S" "$PR"` is denied. `--no-verify` is also read cut short, as in `--no-veri`. A commit's `-n` is also read inside a short-option cluster such as `-an`, while the `n` in `-mn` is the message. Third, a **mutating git operation run directly against a primary checkout** (`checkout`/`switch`/`pull`/`reset`/`rebase`/`merge`/`cherry-pick`/`revert`/`restore`/`add`/`commit`/`rm`/`mv`/`apply`/`am`/`push`/`stash` (anything but `list`/`show`)/`clean -f`/`worktree remove -f`), the harm behind a second incident where an agent reused the maintainer's own primary checkout instead of a worktree despite having read the prose rule against it. "Primary" is decided by comparing `git rev-parse --git-dir` against `--git-common-dir`, not a `.git`-is-a-directory guess. `worktree add`/`list`/`prune`, an unforced `worktree remove`, `merge --ff-only`/`pull --ff-only`, and a `checkout `/`switch ` carrying no force flag (bundled or attached forms like `-qf`/`-Bname` recognized, not only an exact token) stay exempt even in a primary checkout, matching the documented base-clone cleanup step the `repo-worktree` skill already carries. A subcommand this rule does not recognize is resolved through a chain of git aliases, inline and persisted, before falling through, so a custom alias cannot hide a denied builtin behind an unrecognized name. A `!`-prefixed shell alias is denied outright rather than interpreted. A directory named with a `..` component is not resolved, so a mutating command naming one is denied. That holds for `-C`, any `cd`, `pushd`, `popd`, or `chdir` word in the command, `--work-tree`, `--git-dir`, and a `GIT_DIR=`/`GIT_WORK_TREE=` prefix. The target directory follows real git's own `-C`/`--work-tree`/`--git-dir` priority, not a last-option-wins scan. A different owner-style grant, `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT`, follows the same channel as `GH_WRITE_GUARD_ALLOW`. Reads and everything else pass through. Fourth, an **unbounded shell wait**, which the root spec's requirement 7 defines in full rather than this bullet redefining it. In outline: a loop that sleeps, carrying no bound the rule recognizes. It is the shape an agent writes when a brief tells it to poll. It is also the harm behind a third incident, where seven such loops outlived the subagents that wrote them and the run that dispatched those subagents. A shell started by a tool call runs in a session of its own, so the agent could not have reaped it even had it tried. A nested loop is judged on its own terms, and a command that forks anything out of a `timeout`'s reach, by backgrounding it or by `setsid`, is bounded by nothing. A heredoc body is data rather than a command line unless a shell is what it is fed to, so a document quoting the forbidden shape is written rather than denied. It fires even in autonomous / bypass-permissions sessions, which is how the first incident happened. See the root spec's requirements 6 and 7 for the exact rules. +- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies four classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is under an owner other than the checkout origin's. The write and its target are read where gh reads them. That includes a repo flag before the subcommand, an `R` ending a short-option cluster, and a `GH_REPO` value. It also includes an `--org` or `--owner` value and a linked issue's URL. A write whose repository owner is a run-time word fails closed. So does one whose target the hook does not read, such as a gh write an `eval` runs. Sibling repositories under the same owner are allowed, since the harm this guards is reaching a stranger's repository rather than working across one maintainer's own fleet, and a different owner is allowed only when the maintainer names it in `GH_WRITE_GUARD_ALLOW` (an `owner/repo` list, where `owner/*` grants a whole owner). That variable is read from the environment the session was launched with, which is the one channel an agent cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` inside a Bash call never reaches it. Second, a **git operation that bypasses a repository protection**. The branch-rule cases (a direct push, a force-push, or a branch delete to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged per refspec against the branch's **live** rules, so a code-style `develop` is denied while a config-style `develop` is allowed with no per-repo configuration. A push's force, delete, all, mirror, and prune options are read as git reads them. A short-option cluster such as `-uf` or `-ud` forces or deletes. A long option cut short, such as `--del`, `--al`, or `--mirr`, is the option it starts, and `--branches` is `--all`. Of `--force`, `--delete`, `--all`, `--mirror`, `--tags`, or `--prune` and its `--no-` form, the later one wins. `--no-force` leaves a `--force-with-lease` in place, as git does. A refspec pattern whose destination can match under `refs/heads/`, such as `refs/*:refs/*`, is read as pushing every protected-default branch. It is also read as each branch it maps a local ref to. A lone `:` is also read as each local branch with a same-named remote-tracking ref. So a branch under a ruleset such as `release/*` is checked. Beside such a pattern, a lone `:` refspec, `--all`, or a bare push, a `--prune` is read as deleting every protected-default branch. A `--mirror` push with no refspec is read as forcing and deleting every protected-default branch. So is a push with no refspec whose remote has a persisted `remote..mirror` set true, or whose setting the hook cannot read. A push naming no remote reads every remote's setting. Git reads every word after a push's first `--` or `--end-of-options` as the remote or a refspec, and so does the hook. A bare push's destination is also read from the branch's own configuration. That is its own name under a `push.default` of `current` or `simple`, and each `branch..merge` branch under `upstream`. An unset `push.default` is read as `simple`. Both are read where `push.default` holds a value git does not define, or where its read exits with any other error. Git may complete a destination outside `refs/` that starts `heads/` under `refs/`, as in `HEAD:heads/develop` or `:heads/develop`. So such a destination is read as the branch after `heads/` as well as by its literal name. A push to a protected-default branch fails closed when its rules cannot be determined, either because the API is unreachable or because the checkout's origin cannot be resolved to query them. A push that `-C` or a leading `cd` runs in another checkout is judged against that checkout's origin as well as the session's. A push is also judged against the repository its remote writes. That is read from the remote's push URLs or the URL the push names. A bare push takes its remote from `--repo`, `pushRemote`, `remote.pushDefault`, or the branch's `remote`. A local path leaves the push's rules undetermined. Persisted `remote..push` refspecs are read too, and so is a `push.default` of `matching` or `upstream`. A push whose branch list or push config cannot be read is denied. So is a pattern, a lone `:`, `--all`, or `--mirror` after a `gh` command, a wrapper shell, or most git subcommands. A subcommand such as `commit` that leaves `HEAD` and the push config alone does not count. One whose checkout or origin the hook cannot name has undetermined rules, as under `sudo -D` or in a wrapper shell. A push whose target the hook cannot read is denied whatever branch it names. That covers a push in text it does not parse, or carrying a `-c` config override. Config set through `GIT_CONFIG_COUNT` counts as one. It covers a push in interpreter code that can start a process, in text piped to a shell, or run by a persisted alias. An alias set in the environment is followed into the persisted config. A git subcommand whose aliases reach a `!` shell alias is denied in any checkout, whatever that alias runs. The hook does not read its shell text. A persisted alias is read from the directory git runs in. It is also read from a `--git-dir` or `GIT_DIR` repository, through a linked worktree's `.git` file as git reads it. A git command nested past the wrapper reading cap is denied where its subcommand may be an alias. Any git command under an `include.path`/`includeIf` override is denied too. So is a config value or environment variable git runs as a command, where it names its program by a path or an expansion. So is one from `--config-env`. The hook reads none of those. A shell whose command string runs git is denied where its prefix or an earlier `export` sets config for it. That is a `GIT_CONFIG_GLOBAL` or `GIT_CONFIG_SYSTEM` file other than `/dev/null`, or config through `GIT_CONFIG_COUNT`. A variable git runs as a command, set there, counts too, unless it names a plain program that runs no git. The hook reads that string without those settings. So is a bare push whose push target resolves while `git remote` cannot list the remotes. So is one where any read of its branch, its push target, or `push.default` times out or cannot run. So is a word the shell rewrites or drops before git reads it, where it can change what git runs. Such a word is a `$`, backtick, or brace-expansion refspec, subcommand, or commit argument, or a comment among a push's words. So is a `-C`, `--git-dir`, or `--work-tree` value that word splitting may split, that holds a brace expansion, or holds an expansion whose quoting the guard cannot read, on every git subcommand, and a command word holding a `$` or backtick where the subcommand is `push` or `commit`. So is a directory value holding a double-quoted list expansion such as `"$@"`, or an indirect `"${!name}"`, on every git subcommand. The explicit-bypass flags are denied **unconditionally**, since the flag is itself the bypass and needs no branch query: `gh pr merge --admin` overrides the server-side merge gate, and `git commit`/`git push --no-verify` skips the local git hooks, as does a `git -c core.hooksPath=` override on either, which points git at another hooks directory, and an `include.path`/`includeIf` override on either, whose file may do the same. These are read in a command the hook parses, a wrapper shell's body and a command substitution's body among them. A substitution inside a double-quoted `${...}` or an unquoted-delimiter heredoc counts. So does a command string `pwsh`, `fish`, `script`, `cmd`, or another shell runs. So does a config or environment value git runs as a command, such as a `core.pager` or `GIT_EDITOR` value naming its program plainly. Those bodies are read up to a few levels of nesting, and a git commit or push nested deeper is denied. `--admin` is also read inside a quoted `eval` argument, as the command it runs. A quoted `"--admin"` is read too where the shell passes it to gh as a flag. The other flags are not read inside a quoted `eval` argument. A run-time word among a `gh pr merge`'s words, such as `$A` or `"$(...)"`, may expand to `--admin`, so it fails closed. An option's value that word splitting cannot split is the exception, as in `--body "$BODY"`. A run-time `--repo` value fails closed too, since the target check cannot read it. A run-time word where gh's command or `pr`'s subcommand goes may expand to `pr merge`. It fails closed beside a literal `--admin`, or where word splitting may split it. So does a run-time command word before `pr merge`, as in `G=gh; $G pr merge`. Any other run-time word among such a command's words fails closed as it does on a merge, so `gh pr "$S" "$PR"` is denied. `--no-verify` is also read cut short, as in `--no-veri`. A commit's `-n` is also read inside a short-option cluster such as `-an`, while the `n` in `-mn` is the message. Third, a **mutating git operation run directly against a primary checkout** (`checkout`/`switch`/`pull`/`reset`/`rebase`/`merge`/`cherry-pick`/`revert`/`restore`/`add`/`commit`/`rm`/`mv`/`apply`/`am`/`push`/`stash` (anything but `list`/`show`)/`clean -f`/`worktree remove -f`), the harm behind a second incident where an agent reused the maintainer's own primary checkout instead of a worktree despite having read the prose rule against it. "Primary" is decided by comparing `git rev-parse --git-dir` against `--git-common-dir`, not a `.git`-is-a-directory guess. `worktree add`/`list`/`prune`, an unforced `worktree remove`, `merge --ff-only`/`pull --ff-only`, and a `checkout `/`switch ` carrying no force flag (bundled or attached forms like `-qf`/`-Bname` recognized, not only an exact token) stay exempt even in a primary checkout, matching the documented base-clone cleanup step the `repo-worktree` skill already carries. A subcommand this rule does not recognize is resolved through a chain of git aliases, inline and persisted, before falling through, so a custom alias cannot hide a denied builtin behind an unrecognized name. A `!`-prefixed shell alias is denied outright rather than interpreted. A directory named with a `..` component is not resolved, so a mutating command naming one is denied. So is one after `cd -`, a `~-` or `~+` tilde word, or a bare or indexed `pushd`/`popd`. The hook cannot see the shell's directory stack. That holds for `-C`, any `cd`, `pushd`, `popd`, or `chdir` word in the command, `--work-tree`, `--git-dir`, and a `GIT_DIR=`/`GIT_WORK_TREE=` prefix. The target directory follows real git's own `-C`/`--work-tree`/`--git-dir` priority, not a last-option-wins scan. A different owner-style grant, `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT`, follows the same channel as `GH_WRITE_GUARD_ALLOW`. Reads and everything else pass through. Fourth, an **unbounded shell wait**, which the root spec's requirement 7 defines in full rather than this bullet redefining it. In outline: a loop that sleeps, carrying no bound the rule recognizes. It is the shape an agent writes when a brief tells it to poll. It is also the harm behind a third incident, where seven such loops outlived the subagents that wrote them and the run that dispatched those subagents. A shell started by a tool call runs in a session of its own, so the agent could not have reaped it even had it tried. A nested loop is judged on its own terms, and a command that forks anything out of a `timeout`'s reach, by backgrounding it or by `setsid`, is bounded by nothing. A heredoc body is data rather than a command line unless a shell is what it is fed to, so a document quoting the forbidden shape is written rather than denied. It fires even in autonomous / bypass-permissions sessions, which is how the first incident happened. See the root spec's requirements 6 and 7 for the exact rules. The guard also reads git config as git does. A `core.hooksPath` override is denied on any git command, since a checkout, a branch, and any command that updates a ref run hooks. Only a `/dev/null` value is allowed there, and not on a commit, a push, or a name that may be an alias. A `HOME` or `XDG_CONFIG_HOME` other than `/dev/null` on a git command names a global config file the hook does not read, so it is denied. A persisted alias read that times out or cannot run is denied, and an alias expansion is read past its leading `-p` or `-c` options. Some commands before a push may rewrite its remote config, such as a `git config` or `git remote` that does not only read. So may a `gh` command whose group can touch git config, or a shell running a script file the hook does not read. After one, the push reads its remote's mirror setting and origin as undetermined. - **`hooks/tool-containment.py`**: a shell prefix, registered as `CLAUDE_CODE_SHELL_PREFIX` in the `env` block of `settings.json`, that runs every Bash tool call inside a transient systemd user scope carrying `TasksMax`, `MemoryMax`, and `MemorySwapMax=0`, the last so a runaway at the memory ceiling is killed rather than spilled into the host's swap. A runaway fan-out then fails at the ceiling with `fork: Resource temporarily unavailable` instead of growing until the host has to be reset, the harm behind the probe-shim incident in the root spec's requirement 9. The guard above reads the command text, and that incident's recursion lived in a script file the command only named. The ceilings default to 8192 tasks and 25% of RAM. A `claude` session started from a tool call is nested in the session that ran it. Every scope of such a chain also joins one slice, `claude-tree-.slice`. That slice carries a total ceiling for the whole tree, 32768 tasks and 50% of RAM. The root's id reaches nested sessions as `AGENT_CONTAINMENT_ROOT`, which every contained command inherits. The tree's ceiling is a runtime drop-in put on when its slice is first created, and the sweep below reverts it. The maintainer raises any ceiling in the same `env` block. An inline `VAR=x` in a command cannot change one, since the prefix reads it before the command runs. The variables are `AGENT_CONTAINMENT_TASKS_MAX` and `AGENT_CONTAINMENT_MEMORY_MAX` for a scope, and `AGENT_CONTAINMENT_TREE_TASKS_MAX` and `AGENT_CONTAINMENT_TREE_MEMORY_MAX` for the tree. A tree ceiling set below a scope's binds first. Each scope is named `claude-tool---`, so the sweep below can stop one session's tool calls and nothing else. Its description records the pid of the agent that ran it. That record is how the sweep tells a scope whose session has ended from one whose session still runs. The installer sets the prefix only where it can contain, per the Linux bullet under "Scope and Limits", and never over a `CLAUDE_CODE_SHELL_PREFIX` value that names anything else. - **`hooks/stray-process-sweep.py`**: a SessionEnd hook in two phases. First, it stops every `claude-tool--*` scope this session left loaded and reports what each held, since the scope's name proves which session started it. It then stops every scope in this session's tree whose recorded agent is no longer running, and names each one's session. That is how a nested session's scopes end, since stopping the parent's scopes kills the nested `claude` before its own sweep runs. Each stop can end an agent nested deeper, so it judges again, up to four rounds. The root session then stops its tree's slice and reverts the ceiling put on it, once nothing is left in it. Second, it reports, and never ends, the process trees outside those scopes that outlive the session. The guard above stops a leak from being written. This finds the ones already running: the ones started before the guard was installed, and the shapes the guard deliberately does not reach. It reports every descendant of the agent process running outside the agent's own session. It names each subtree root's command and age, and hands over the `kill` line. This phase kills nothing itself. A long build backgrounded on purpose reads identically to a leaked wait from there. What is still wanted on the machine is the maintainer's call. A SessionEnd hook cannot block, and its JSON output is discarded, so stderr on exit 2 is the one channel that reaches the maintainer. The sweep uses that for a report rather than for a refusal. It registers under a group carrying no matcher, which fires on every exit reason. It also carries an explicit timeout, since the event's own budget is 1.5 seconds and only a declared per-hook timeout raises it. See the root spec's requirement 8. diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index d31131c2..faa0fb38 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -400,6 +400,9 @@ def _gh_path_may_be(words, table): return sub is not None and bool(subs) and (sub in subs or _is_run_time_word(sub)) +_URL_OWNER_REPO = re.compile(r"[:/]([A-Za-z0-9_.\-]+)/([A-Za-z0-9_.\-]+?)(?:\.git)?/?$") + + def _origin_owner_repo(cwd): try: url = subprocess.run( @@ -413,7 +416,7 @@ def _origin_owner_repo(cwd): # A checkout with no usable origin is answered as unknown rather than crashing the hook. except Exception: # noqa: BLE001 return None - m = re.search(r"[:/]([A-Za-z0-9_.\-]+)/([A-Za-z0-9_.\-]+?)(?:\.git)?/?$", url) + m = _URL_OWNER_REPO.search(url) return (m.group(1).lower(), m.group(2).lower()) if m else None @@ -2373,17 +2376,50 @@ def _cd_dot_dot(cmd): Any such `cd` counts, leading or not, with options (`cd -P`) or without. Rule 6 reads only a plain leading `cd`, and resolves every other invocation from the session's directory. """ + return _cd_argument(cmd, lambda _name, a: _has_dot_dot(a)) + + +def _cd_argument(cmd, test): + """The first argument of any `cd`/`pushd`/`popd`/`chdir` in the command or its wrapper bodies that `test(name, arg)` accepts. + + One given no argument is tested once with an empty one. + """ for text in [cmd, *_embedded_wrapper_commands(cmd)]: toks = _shell_tokens(text) for k, t in enumerate(toks): - if t.rsplit("/", 1)[-1] in _CD_BUILTINS: + name = t.rsplit("/", 1)[-1] + if name in _CD_BUILTINS: args, _ = _collect_arglist(toks, k + 1) - found = next((a for a in args if _has_dot_dot(a)), None) + found = next((a for a in args or [""] if test(name, a)), None) if found is not None: return found return None +_DIR_STACK_TILDE = re.compile(r"^~(?:[+-]\d*|\d+)(?:/|$)") + + +def _names_dir_stack(value): + """True when a directory value is a tilde word the shell reads from its directory stack.""" + return bool(value) and bool(_DIR_STACK_TILDE.match(value)) + + +def _cd_dir_stack(cmd): + """The first `cd`/`pushd`/`popd` argument in the command or its wrapper bodies naming a stack directory. + + That is `cd -`, a `~-`/`~+` tilde word, a `pushd` or `popd` stack index such as `+1`, or a + bare `pushd` or `popd`. The shell reads each from its own state, which the guard cannot see. + """ + + def stacked(name, a): + if name in ("pushd", "popd") and (a == "" or re.fullmatch(r"[+-]\d+", a)): + return True + return (name in ("cd", "chdir") and a == "-") or _names_dir_stack(a) + + found = _cd_argument(cmd, stacked) + return "a bare pushd or popd" if found == "" else found + + _GIT_COMMAND_NAMES: list[set[str]] = [] _GIT_BUILTIN_NAMES: list[set[str]] = [] _PINNED_ALIASES: list[dict[str, str]] = [] @@ -2456,24 +2492,34 @@ def _check_primary_checkout_mutation( identity_cache = {} file_cache = {} cd_dotted = _cd_dot_dot(cmd) + cd_stacked = _cd_dir_stack(cmd) for c_dirs, work_tree, git_dir, inline_aliases, sub, args, leading_cd in _all_git_invocations( cmd ): - dotted = cd_dotted or next( - (v for v in (leading_cd, *c_dirs, work_tree, git_dir) if v and _has_dot_dot(v)), None - ) - if dotted is not None: + dir_values = (leading_cd, *c_dirs, work_tree, git_dir) + stacked = cd_stacked or next((v for v in dir_values if _names_dir_stack(v)), None) + dotted = cd_dotted or next((v for v in dir_values if v and _has_dot_dot(v)), None) + if dotted is not None or stacked is not None: verdict = _primary_checkout_verdict(sub, args, None, lambda _d, _r: False) if verdict is None and sub not in _git_command_names(): verdict = True - if not verdict: + if verdict and dotted is not None: + return "deny", ( + f"This `git {sub}` names its directory with a `..` component ({dotted}), which " + "git and the shell resolve physically through any symlink, so the guard cannot " + "tell whether it runs against a primary checkout. Failing closed. Spell the " + "directory without a `..` component, as an absolute path." + ) + if verdict: + return "deny", ( + f"This `git {sub}` runs after a directory change the shell reads from its own " + f"directory stack ({stacked}), such as the previous directory `cd -` returns " + "to. The guard cannot see that stack, so it cannot tell whether the command " + "runs against a primary checkout. Failing closed. Name the directory as an " + "absolute path." + ) + if dotted is not None: continue - return "deny", ( - f"This `git {sub}` names its directory with a `..` component ({dotted}), which git " - "and the shell resolve physically through any symlink, so the guard cannot tell " - "whether it runs against a primary checkout. Failing closed. Spell the directory " - "without a `..` component, as an absolute path." - ) resolved = _resolve_target_dir(c_dirs, work_tree, leading_cd, cwd) repo_git_dir = _resolve_repo_dir(git_dir, c_dirs, leading_cd, cwd) # This rule tests two independent dimensions of "does this touch a primary checkout", since git's own --work-tree/--git-dir split lets a single invocation mutate one repository's index/refs/HEAD while writing working-tree files into an entirely different directory. @@ -5349,10 +5395,11 @@ def _rewritten_git_words(cmd): def _push_invocations(cmd): - """Every `git push` as (c_dirs, work_tree, leading_cd, cd_unreliable, repo_unreliable, args). + """Every `git push` as (c_dirs, work_tree, leading_cd, cd_unreliable, repo_unreliable, args, refs_unreliable). Wrappers are included. `repo_unreliable` says the push may run in another repository than the one its `-C` and leading `cd` name (see `_repo_may_move`). A wrapper shell always sets it. + `refs_unreliable` says a command before the push may also move a ref (see `_head_may_move`). A leading `cd` is trusted only while it is the one directory change in the text its push runs in, so a later `cd` marks the push unreliable rather than letting the first one vouch for it. @@ -5419,19 +5466,30 @@ def _push_invocations(cmd): ) ) unreliable = extra > 0 or head_moves or repo_unreliable - out.append((c_dirs, None, leading_cd, unreliable, repo_unreliable, args)) + refs_moves = head_moves or repo_unreliable + out.append( + (c_dirs, None, leading_cd, unreliable, repo_unreliable, args, refs_moves) + ) j = _git_subcommand_index(body_toks, word) split, _ = _collect_arglist(body_toks, j + 1, split_fused=True) if split != args: splits.append( - (c_dirs, None, leading_cd, unreliable, repo_unreliable, split) + ( + c_dirs, + None, + leading_cd, + unreliable, + repo_unreliable, + split, + refs_moves, + ) ) - unmatched = [a for _c, _w, _l, _u, _r, a in out[first:]] + unmatched = [a for _c, _w, _l, _u, _r, a, _m in out[first:]] for _opts, args in _git_subcommand_invocations(body, "push"): if args in unmatched: unmatched.remove(args) else: - out.append(([], None, leading_cd, True, True, args)) + out.append(([], None, leading_cd, True, True, args, True)) out.extend(splits) return out @@ -5533,6 +5591,334 @@ def _remote_mirrors(cwd, remote): return "true" in values +_REMOTE_UNRESOLVED = object() + +_SCP_LIKE_URL = re.compile(r"^[^/]+:") +_DOS_DRIVE_PATH = re.compile(r"^[A-Za-z]:") + + +def _url_owner_repo(url): + """The (owner, repo) a push URL names, or None for a local path, a `file://` URL, or no match. + + Git reads a URL with `://` as a transport URL and a word whose first colon comes before any + slash as an scp-like one, `host:path`. Any other word is a local path. So is a word starting + with a drive letter and a colon, as Git for Windows reads `C:x/y`, even where it is not absolute. + """ + if "://" in url: + if url.lower().startswith("file://"): + return None + elif not _SCP_LIKE_URL.match(url) or _DOS_DRIVE_PATH.match(url): + return None + m = _URL_OWNER_REPO.search(url) + return (m.group(1).lower(), m.group(2).lower()) if m else None + + +def _push_url_alias(cwd, url): + """`url` rewritten by the longest `url..pushInsteadOf` prefix, None where none applies, or `_UNRESOLVED`.""" + code, out = _git_read(cwd, ["config", "--get-regexp", r"^url\..*\.pushinsteadof$"]) + if code == 1: + return None + if code != 0: + return _UNRESOLVED + best = None + for line in out.splitlines(): + key, _, prefix = line.partition(" ") + base = key[len("url.") : -len(".pushinsteadof")] + if prefix and url.startswith(prefix) and (best is None or len(prefix) > len(best[1])): + best = (base, prefix) + return None if best is None else best[0] + url[len(best[1]) :] + + +def _remote_repos(cwd, remote): + """The (owner, repo) pairs a push to `remote` writes, read in `cwd`. + + A configured remote's push URLs are read the way git reads them, `pushurl` and `pushInsteadOf` + included. Any other word is a URL or a path, rewritten by `pushInsteadOf` or else `insteadOf`. + A URL that names no repository, or a read that fails, gives `_REMOTE_UNRESOLVED` among them. + """ + if not remote or remote.startswith("-"): + return [_REMOTE_UNRESOLVED] + code, out = _git_read(cwd, ["remote", "get-url", "--push", "--all", remote]) + if code == 2: + alias = _push_url_alias(cwd, remote) + if alias is _UNRESOLVED: + return [_REMOTE_UNRESOLVED] + if alias is None: + code, out = _git_read(cwd, ["ls-remote", "--get-url", remote]) + else: + code, out = 0, alias + if code != 0 or not out: + return [_REMOTE_UNRESOLVED] + repos = [_url_owner_repo(u.strip()) for u in out.splitlines() if u.strip()] + return list(dict.fromkeys(_REMOTE_UNRESOLVED if r is None else r for r in repos)) + + +def _bare_push_remotes(cwd): + """The remotes a push naming none may write, or `_UNRESOLVED`. + + Git takes the current branch's `pushRemote`, then `remote.pushDefault`, then the branch's + `remote`, then `origin`. Where no key is set, a sole configured remote is read beside `origin`. + """ + code, ref = _git_read(cwd, ["symbolic-ref", "-q", "HEAD"]) + if code is None: + return _UNRESOLVED + name = ref[len("refs/heads/") :] if code == 0 and ref.startswith("refs/heads/") else None + keys = ["remote.pushDefault"] + if name is not None: + keys = [f"branch.{name}.pushRemote", *keys, f"branch.{name}.remote"] + for key in keys: + code, value = _git_read(cwd, ["config", "--get", key]) + if code == 0 and value: + return [value] + if code != 1: + return _UNRESOLVED + listed = _git_stdout(cwd, ["remote"]) + if listed is None: + return _UNRESOLVED + names = listed.split() + return list(dict.fromkeys(["origin", *(names if len(names) == 1 else [])])) + + +def _ref_names(cwd, prefix): + """The full ref names under `prefix` in `cwd`, or `_UNRESOLVED`.""" + code, out = _git_read(cwd, ["for-each-ref", "--format=%(refname)", prefix]) + if code != 0: + return _UNRESOLVED + return [r for r in out.splitlines() if r] + + +def _tracking_branches(cwd, remote): + """The branches `remote`'s remote-tracking refs name, or `_UNRESOLVED` where it is no configured remote.""" + listed = _git_stdout(cwd, ["remote"]) + if listed is None or remote not in listed.split(): + return _UNRESOLVED + refs = _ref_names(cwd, f"refs/remotes/{remote}/") + if refs is _UNRESOLVED: + return _UNRESOLVED + return [r[len(f"refs/remotes/{remote}/") :] for r in refs] + + +def _local_branches(cwd): + """The local branch names in `cwd`, or `_UNRESOLVED`.""" + refs = _ref_names(cwd, "refs/heads/") + return refs if refs is _UNRESOLVED else [r[len("refs/heads/") :] for r in refs] + + +def _matching_branches(cwd, remote): + """The branches a matching push to `remote` writes, each local branch with a same-named remote-tracking ref. + + It is `_UNRESOLVED` where either list cannot be read or `remote` is no configured remote. + """ + local, tracking = _local_branches(cwd), _tracking_branches(cwd, remote) + if local is _UNRESOLVED or tracking is _UNRESOLVED: + return _UNRESOLVED + return [b for b in local if b in set(tracking)] + + +def _pattern_match(pattern, name): + """The text a refspec pattern's `*` stands for in `name`, or None where it does not match.""" + head, _, tail = pattern.partition("*") + if len(name) < len(head) + len(tail) or not name.startswith(head) or not name.endswith(tail): + return None + return name[len(head) : len(name) - len(tail)] + + +def _pattern_branches(cwd, src, dst, remotes, pruned): + """The branches a `src:dst` pattern refspec writes, or `_UNRESOLVED`. + + Each local ref `src` matches is written at `dst` with the matched text put in its `*`. With + `pruned`, each branch a remote-tracking ref of `remotes` names that `dst` matches is too. + """ + refs = _ref_names(cwd, "refs/") + if refs is _UNRESOLVED: + return _UNRESOLVED + out = [] + for ref in refs: + stem = _pattern_match(src, ref) + if stem is not None: + out.extend(_dst_branches(dst.replace("*", stem, 1))) + for remote in remotes if pruned else (): + tracking = _tracking_branches(cwd, remote) + if tracking is _UNRESOLVED: + return _UNRESOLVED + out.extend(b for b in tracking if _pattern_match(dst, f"refs/heads/{b}") is not None) + return out + + +def _src_names(src): + """The full ref names git may read a refspec source as, by the rules `git rev-parse` uses.""" + if src.startswith("refs/"): + return [src] + return [src, f"refs/{src}", f"refs/tags/{src}", f"refs/heads/{src}", f"refs/remotes/{src}"] + + +def _remote_push_refspecs(cwd, remote): + """The `remote..push` refspecs in `cwd`, or `_UNRESOLVED`.""" + code, out = _git_read(cwd, ["config", "--get-all", f"remote.{remote}.push"]) + if code == 1: + return [] + if code != 0: + return _UNRESOLVED + return [line.strip() for line in out.splitlines() if line.strip()] + + +_UNMAPPED_PUSH_DEFAULTS = frozenset({"nothing", "current", "simple", "matching"}) + + +def _refspec_parts(spec): + """A refspec as (forced, source, destination), the destination its source where it names none. + + A negative refspec, which writes nothing, is None. + """ + forced = spec.startswith("+") + spec = spec.removeprefix("+") + if spec.startswith("^"): + return None + src, colon, dst = spec.partition(":") + return forced, src, dst if colon else src + + +def _mapped_src_branches(cwd, remote, src): + """The (ops, branch) pairs a source-only refspec `src` may write beside its own name, or `_UNRESOLVED`. + + A `remote..push` refspec whose source matches `src` maps it to that refspec's + destination, and a `+` on it forces. Under `push.default=upstream`, or `tracking`, a branch + maps to its `branch..merge`. A `push.default` git cannot read is read as `upstream` too. + """ + names = _src_names(src) + specs = _remote_push_refspecs(cwd, remote) + if specs is _UNRESOLVED: + return _UNRESOLVED + out = [] + for spec in specs: + parts = _refspec_parts(spec) + if parts is None or spec.removeprefix("+") == ":": + continue + forced, s, d = parts + ops = {"force"} if forced else set() + for name in names: + if "*" in s: + stem = _pattern_match(s, name) + if stem is not None: + out.extend((ops, b) for b in _dst_branches(d.replace("*", stem, 1))) + elif name in _src_names(s): + out.extend((ops, b) for b in _dst_branches(d)) + code, mode = _git_read(cwd, ["config", "--get", "push.default"]) + if code is None: + return _UNRESOLVED + if code == 1 or (code == 0 and mode.lower() in _UNMAPPED_PUSH_DEFAULTS): + return out + for name in names: + if not name.startswith("refs/heads/"): + continue + code, merges = _git_read(cwd, ["config", "--get-all", f"branch.{name[11:]}.merge"]) + if code not in (0, 1): + return _UNRESOLVED + merged = [m.strip() for m in merges.splitlines()] if code == 0 else [] + out.extend((set(), b) for m in merged for b in _dst_branches(m)) + return out + + +_PUSH_CONFIG_UNREAD = ( + "the push configuration that picks its remote or maps its refspec (branch..pushRemote, " + "remote.pushDefault, remote..push, push.default, branch..merge) could not be read" +) + +_UNREAD_PUSH_OPS = frozenset({"unreadable", "unresolved", "undetermined"}) + + +def _add_read_targets(found, targets, named, what): + """Add the (ops, branch) pairs a read named to `targets`, or an `undetermined` op where it failed.""" + if named is _UNRESOLVED: + found.append( + ( + "undetermined", + ( + f"{what} writes branches the guard reads from the checkout's refs and push " + "configuration. That read failed, a command before the push may change those " + "refs, or the remote is a URL or path with no remote-tracking refs to list" + ), + ) + ) + else: + targets.extend(named) + + +def _config_refspec_targets(cwd, remote, spec, push_ops, pruned): + """The (ops, branch) pairs one refspec writes, read against the refs in `cwd`, or `_UNRESOLVED`. + + A lone `:` writes each branch `_matching_branches` lists. A pattern writes each branch + `_pattern_branches` lists. Either, with `pruned`, also deletes the branches the remote's + remote-tracking refs name. Any other refspec writes the branch its destination names. A + negative refspec writes none, and so does a `HEAD` destination, which the caller reads itself. + """ + parts = _refspec_parts(spec) + if parts is None: + return [] + forced, src, dst = parts + ops = set(push_ops) | ({"force"} if forced else set()) + if (src, dst) == ("", ""): + names = _matching_branches(cwd, remote) + if names is not _UNRESOLVED and pruned: + tracking = _tracking_branches(cwd, remote) + names = _UNRESOLVED if tracking is _UNRESOLVED else [*names, *tracking] + if names is _UNRESOLVED: + return _UNRESOLVED + return [((ops or {"update"}) | pruned, name) for name in names] + if not src: + ops.add("delete") + ops = ops or {"update"} + if "*" in dst: + if not _pattern_may_name_branch(dst): + return [] + names = _pattern_branches(cwd, src, dst, [remote], pruned) + if names is _UNRESOLVED: + return _UNRESOLVED + return [(ops | pruned, name) for name in names if name] + if dst.upper() in ("HEAD", "@"): + return [] + return [(ops, name) for name in _dst_branches(dst) if name] + + +def _every_branch_targets(cwd, remotes, ops, with_tracking): + """(ops, branch) for every local branch, and with `with_tracking` every remote-tracking one, or `_UNRESOLVED`.""" + names = _local_branches(cwd) + if names is _UNRESOLVED: + return _UNRESOLVED + for remote in remotes if with_tracking else (): + tracking = _tracking_branches(cwd, remote) + if tracking is _UNRESOLVED: + return _UNRESOLVED + names = [*names, *tracking] + return [(set(ops), name) for name in names] + + +def _bare_push_config_targets(cwd, remote, push_ops, pruned): + """The (ops, branch) pairs a push to `remote` naming no refspec writes by persisted config, or `_UNRESOLVED`. + + A `remote..push` refspec is read as if the command named it. With none set, a + `push.default` of `matching` writes what a lone `:` refspec does. The current branch's own + target is read apart from this (see `_current_push_branch`). + """ + specs = _remote_push_refspecs(cwd, remote) + if specs is _UNRESOLVED: + return _UNRESOLVED + if not specs: + code, mode = _git_read(cwd, ["config", "--get", "push.default"]) + if code == 1 or (code == 0 and mode.lower() != "matching"): + return [] + if code != 0: + return _UNRESOLVED + specs = [":"] + out = [] + for spec in specs: + named = _config_refspec_targets(cwd, remote, spec, push_ops, pruned) + if named is _UNRESOLVED: + return _UNRESOLVED + out.extend(named) + return out + + def _push_targets(cmd, cwd=None, current_branch=None): """Parse every push in the command into a list of (op, branch, repo) entries. @@ -5572,23 +5958,42 @@ def _push_targets(cmd, cwd=None, current_branch=None): `remote..mirror` in the directory it runs in (see `_remote_mirrors`). A remote set to mirror, or a read that cannot answer, adds a `mirror` op, a force and a delete, on every protected default. A `current_branch` seam stands in for that read too, as no mirror. + + A pattern, a lone `:`, `--all`, and `--mirror` are also read as each branch they write in the + push's checkout (see `_config_refspec_targets`). A source-only refspec is also read as the + branch its push config maps it to (see `_mapped_src_branches`). A bare push is also read as + the branches its `remote..push` or a `matching` default writes. Each entry is also given + once more for each repository the push's remote writes (see `_remote_repos`). Where that + repository is not named, it is `_REMOTE_UNRESOLVED`. A command before the push that may move + refs makes a branch-list read fail. Entries naming a protected default come first. A read + that fails adds an `undetermined` op. A `current_branch` seam, or no `cwd`, skips these reads. """ results = [] unreadable = _unreadable_push(cmd) if unreadable: results.append(("unreadable", unreadable, None)) readings = [ - (c_dirs, work_tree, leading_cd, cd_unreliable, repo_unreliable, reading, after_end) - for c_dirs, work_tree, leading_cd, cd_unreliable, repo_unreliable, args in _push_invocations( - cmd + (c_dirs, work_tree, leading_cd, cd_unreliable, repo_unreliable, reading, after_end, moves) + for c_dirs, work_tree, leading_cd, cd_unreliable, repo_unreliable, args, moves in ( + _push_invocations(cmd) ) for reading in _push_readings(args) for after_end in ((False, True) if _dash_word_after_end(reading) else (False,)) ] - for c_dirs, work_tree, leading_cd, cd_unreliable, repo_unreliable, args, after_end in readings: + for ( + c_dirs, + work_tree, + leading_cd, + cd_unreliable, + repo_unreliable, + args, + after_end, + refs_unreliable, + ) in readings: found = [] force = lease = delete = push_all = mirror = tags_only = prune = options_ended = False positionals = [] + repo_opt = None i = 0 while i < len(args): t = args[i] @@ -5630,7 +6035,11 @@ def _push_targets(cmd, cwd=None, current_branch=None): True # --follow-tags is NOT tags-only: it also pushes the current branch ) elif t in _PUSH_VALUE_FLAGS: + if t == "--repo" and i + 1 < len(args): + repo_opt = args[i + 1] i += 1 # skip this flag's value + elif "=" in t and _spells_long_option(t, ("repo",)): + repo_opt = t.partition("=")[2] elif t.startswith("-"): pass # some other flag (e.g. -u, --no-verify) else: @@ -5644,6 +6053,17 @@ def _push_targets(cmd, cwd=None, current_branch=None): pruned = {"delete"} if prune else set() targets = [] matched_ops = set() + remote = positionals[0] if positionals else repo_opt + read_dir = ( + _push_dir(cwd, c_dirs, leading_cd, repo_unreliable) + if current_branch is None and cwd is not None + else _UNRESOLVED + ) + readable = read_dir is not _UNRESOLVED + + def refs_read(read, *read_args, _moves=refs_unreliable): + return _UNRESOLVED if _moves else read(*read_args) + for rs in refspecs: ops = set(push_ops) if rs.startswith("+"): @@ -5651,6 +6071,9 @@ def _push_targets(cmd, cwd=None, current_branch=None): rs = rs[1:] if rs == ":": matched_ops |= (ops or {"update"}) | pruned + if readable: + named = refs_read(_config_refspec_targets, read_dir, remote, ":", ops, pruned) + _add_read_targets(found, targets, named, "a lone `:` refspec") continue if rs.startswith(":"): ops.add("delete") # `:dst` empty-source refspec deletes dst @@ -5658,6 +6081,15 @@ def _push_targets(cmd, cwd=None, current_branch=None): dst = rs.split(":", 1)[1] if ":" in rs else rs if "*" in dst and _pattern_may_name_branch(dst): matched_ops |= ops | pruned + if readable: + named = refs_read(_config_refspec_targets, read_dir, remote, rs, ops, pruned) + _add_read_targets(found, targets, named, "a pattern refspec") + elif readable and ":" not in rs and dst.upper() not in ("HEAD", "@"): + mapped = _mapped_src_branches(read_dir, remote, rs) + if mapped is _UNRESOLVED: + found.append(("undetermined", _PUSH_CONFIG_UNREAD)) + else: + targets.extend((ops | extra, name) for extra, name in mapped if name) if dst.upper() in ("HEAD", "@"): if dst not in ("HEAD", "@"): targets.append((ops, dst)) @@ -5673,23 +6105,39 @@ def _push_targets(cmd, cwd=None, current_branch=None): continue targets.extend((ops, name) for name in _dst_branches(dst) if name) if not refspecs and not mirror: - remote = positionals[0] if positionals else None + mirror_remote = positionals[0] if positionals else None target = _push_dir(cwd, c_dirs, leading_cd, repo_unreliable) if current_branch is not None: mirrored = False elif target is _UNRESOLVED: mirrored = _UNRESOLVED else: - mirrored = _remote_mirrors(target, remote) + mirrored = _remote_mirrors(target, mirror_remote) if mirrored: found.extend(("mirror", br) for br in _PROTECTED_DEFAULT_ORDER) + remotes = [remote] if remote else None + if readable and remotes is None: + remotes = _bare_push_remotes(read_dir) + if remotes is _UNRESOLVED: + found.append(("undetermined", _PUSH_CONFIG_UNREAD)) + remotes = [] if not refspecs: if mirror: targets = [({"force", "delete"}, br) for br in _PROTECTED_DEFAULT_ORDER] + if readable: + mirror_ops = {"force", "delete"} + named = refs_read(_every_branch_targets, read_dir, remotes, mirror_ops, True) + _add_read_targets(found, targets, named, "a mirror push") elif push_all: targets = [ ((push_ops or {"update"}) | pruned, br) for br in _PROTECTED_DEFAULT_ORDER ] + if readable: + all_ops = (push_ops or {"update"}) | pruned + named = refs_read( + _every_branch_targets, read_dir, remotes, all_ops, bool(pruned) + ) + _add_read_targets(found, targets, named, "an --all push") elif tags_only: targets = [] # tags only, no branch is updated else: @@ -5703,6 +6151,10 @@ def _push_targets(cmd, cwd=None, current_branch=None): continue if b: targets = [(push_ops or {"update"}, name) for name in _branch_names(b)] + if readable: + for name in remotes: + named = _bare_push_config_targets(read_dir, name, push_ops, pruned) + _add_read_targets(found, targets, named, "a push naming no refspec") found.extend((op, br) for ops, br in targets for op in sorted(ops)) if matched_ops: matched = [*_PROTECTED_DEFAULT_ORDER, *(br for _ops, br in targets)] @@ -5712,7 +6164,15 @@ def _push_targets(cmd, cwd=None, current_branch=None): else: repo = None results.extend((op, br, repo) for op, br in found) - return results + if readable and any(op not in _UNREAD_PUSH_OPS for op, _br in found): + written = [r for name in remotes for r in _remote_repos(read_dir, name)] + for written_repo in dict.fromkeys(written): + if written_repo != repo: + results.extend((op, br, written_repo) for op, br in found) + return sorted( + dict.fromkeys(results), + key=lambda t: (t[0] not in _UNREAD_PUSH_OPS, t[1] not in _PROTECTED_DEFAULT), + ) def _handoff(cmd): @@ -6108,8 +6568,9 @@ def _check_push_bypass(cmd, cwd, origin, current_branch=None, rules_lookup=None) A force and a delete each write the branch too, so a pull_request rule denies them as it denies an update. A push run in another checkout is also checked against that checkout's - origin (see `_push_repo`). The `rules_lookup` seam stands in for every query, so with it only - a checkout the guard cannot name adds a reading. + origin (see `_push_repo`). A push to another remote is checked against the repository it + writes. The `rules_lookup` seam stands in for every query. With it, only a checkout or remote + the guard cannot name adds a reading. """ targets = _push_targets(cmd, cwd, current_branch) if not targets: @@ -6119,6 +6580,7 @@ def _check_push_bypass(cmd, cwd, origin, current_branch=None, rules_lookup=None) ) # only a quoted mention or a non-push git command: no git/API work needed if rules_lookup is None and origin is None: origin = _origin_owner_repo(cwd) + cache = {} for op, br, repo in targets: if op == "unreadable": return "deny", ( @@ -6137,15 +6599,26 @@ def _check_push_bypass(cmd, cwd, origin, current_branch=None, rules_lookup=None) "with an explicit refspec (git push origin :refs/heads/) as its own " "command, or cd into a directory that exists." + _handoff(cmd) ) + if op == "undetermined": + return "deny", ( + f"Could not tell which branches this push writes, because {br}. Failing closed " + "rather than miss a branch a ruleset protects. Push each branch with an explicit " + "refspec to a named remote as its own command (git push origin " + ":refs/heads/)." + _handoff(cmd) + ) readings = [None] - if repo is not None and repo != origin and (repo is _UNRESOLVED or rules_lookup is None): + unnamed = repo is _UNRESOLVED or repo is _REMOTE_UNRESOLVED + if repo is not None and repo != origin and (unnamed or rules_lookup is None): readings.append(repo) for checkout in readings: if checkout is None: if rules_lookup is not None: rules = rules_lookup(br) elif origin is not None: - rules = _live_branch_rules(origin[0], origin[1], br) + key = (origin, br) + if key not in cache: + cache[key] = _live_branch_rules(origin[0], origin[1], br) + rules = cache[key] else: rules = None # no origin to query the rules against where = "" @@ -6161,8 +6634,18 @@ def _check_push_bypass(cmd, cwd, origin, current_branch=None, rules_lookup=None) "rules could not be read. Run the push from that checkout's own directory, or " "with -C naming a directory that exists" ) + elif checkout is _REMOTE_UNRESOLVED: + rules, where = None, "" + reason = ( + "the repository this push writes to could not be determined from its remote, " + "URL, or path, so its rules could not be read. Push to a remote whose URL " + "names the repository" + ) else: - rules = _live_branch_rules(checkout[0], checkout[1], br) + key = (checkout, br) + if key not in cache: + cache[key] = _live_branch_rules(checkout[0], checkout[1], br) + rules = cache[key] where = f" in {checkout[0]}/{checkout[1]}" reason = f"its branch rules{where} could not be read (the API may be unreachable)" denial = _rule_denial(op, br, rules, reason, where) @@ -7677,7 +8160,8 @@ def classify( ref rather than a pathspec, and config_lookup(dir, name) stands in for the live git-config read that resolves a persisted (non-inline) alias. - The current_branch seam also stands in for the persisted mirror read a push with no refspec makes. + The current_branch seam also stands in for the persisted mirror read a push with no refspec + makes. It stands in for the remote, push config, and branch-list reads `_push_targets` makes too. """ # Fold shell line-continuations so a multi-line Bash invocation, such as `gh pr merge 5 \ --admin`, parses as one command. # Only backslash-newline is joined, so a real newline between commands still separates them. @@ -12339,6 +12823,110 @@ def classify( "allow", "a read-only builtin after a dot-dot cd is allowed", ), + ( + "cd - && git reset --hard", + "/repos/task", + {"/repos/task": False}, + None, + "deny", + "cd - moves to the previous directory, which the guard cannot see, so it fails closed", + ), + ( + "cd ~- && git reset --hard", + "/repos/task", + {"/repos/task": False}, + None, + "deny", + "a ~- tilde word is the previous directory too, not a literal directory name", + ), + ( + "cd ~-/sub && git commit -m x", + "/repos/task", + {"/repos/task": False}, + None, + "deny", + "a ~- tilde word with a path after it is read from the previous directory too", + ), + ( + "git -C ~- reset --hard", + "/repos/task", + {"/repos/task": False}, + None, + "deny", + "a ~- tilde word as a -C value is the previous directory too", + ), + ( + "git status; cd -; git commit -m x", + "/repos/task", + {"/repos/task": False}, + None, + "deny", + "a cd - that is not leading is unresolved too", + ), + ( + "pushd +1 && git reset --hard", + "/repos/task", + {"/repos/task": False}, + None, + "deny", + "a pushd stack index moves to a directory the guard cannot see", + ), + ( + "popd && git reset --hard", + "/repos/task", + {"/repos/task": False}, + None, + "deny", + "a bare popd moves to a directory the guard cannot see", + ), + ( + "pushd && git commit -m x", + "/repos/task", + {"/repos/task": False}, + None, + "deny", + "a bare pushd swaps to a directory the guard cannot see", + ), + ( + "popd && git status", + "/repos/task", + {"/repos/task": False}, + None, + "allow", + "control: a read-only builtin after a bare popd is allowed", + ), + ( + "bash -c 'cd - && git reset --hard'", + "/repos/task", + {"/repos/task": False}, + None, + "deny", + "a cd - inside a wrapper body is unresolved too", + ), + ( + "cd - && git status", + "/repos/task", + {"/repos/task": False}, + None, + "allow", + "control: a read-only builtin after cd - is allowed", + ), + ( + "cd ~/repos/task && git reset --hard", + "/repos/task", + {"/repos/task": False}, + None, + "allow", + "control: a plain ~ is the home directory, not a stack directory", + ), + ( + "cd -P /repos/task && git commit -m x", + "/repos/task", + {"/repos/task": False}, + None, + "allow", + "control: a cd option is not cd -", + ), ( "git -C /repos/task worktree remove ../y", "/repos/task", @@ -15762,6 +16350,279 @@ def _push_repo_selftest(): return ok +_UPSTREAM_DEVELOP = [ + ["config", "push.default", "upstream"], + ["config", "branch.feature/example.merge", "refs/heads/develop"], +] + +_PUSH_TARGET_CASES = [ + ([], "git push upstream develop", "deny", "a push to another remote reads that remote's rules"), + ( + [], + "git push upstream feature/example", + "allow", + "control: another remote's unruled branch is allowed", + ), + ([], "git push origin develop", "allow", "control: the origin allows its own develop"), + ( + [], + "git push https://example.invalid/example/beta.git develop", + "deny", + "a push to a URL reads the rules of the repository it names", + ), + ( + [], + "git push git@example.invalid:example/beta.git develop", + "deny", + "a push to an scp-like URL reads the rules of the repository it names", + ), + ( + [], + "git push ../elsewhere develop", + "deny", + "a push to a path names no repository, so it fails closed on a protected default", + ), + ( + [], + "git push ../elsewhere feature/example", + "allow", + "control: a path leaves an unprotected branch alone", + ), + ( + [], + "git push C:example/alpha develop", + "deny", + "a drive-relative Windows path is a local path, not a repository it seems to name", + ), + ( + [["config", "branch.feature/example.pushRemote", "upstream"], *_UPSTREAM_DEVELOP], + "git push", + "deny", + "a bare push reads the repository of the branch's pushRemote", + ), + ( + [["config", "remote.pushDefault", "upstream"], *_UPSTREAM_DEVELOP], + "git push", + "deny", + "a bare push reads the repository of remote.pushDefault", + ), + ( + _UPSTREAM_DEVELOP, + "git push --repo=upstream", + "deny", + "a bare push reads the repository its --repo option names", + ), + ( + _UPSTREAM_DEVELOP, + "git push --rep=upstream", + "deny", + "a --repo option cut short still names the push's remote", + ), + ( + [ + ["config", "branch.feature/example.pushRemote", "upstream"], + ["config", "push.default", "current"], + ], + "git push", + "allow", + "control: a pushRemote push of an unruled branch is allowed", + ), + ( + [["config", "remote.origin.pushurl", "https://example.invalid/example/beta.git"]], + "git push origin develop", + "deny", + "a remote's pushurl names the repository a push writes", + ), + ( + [["config", "url.https://example.invalid/example/beta.git.pushInsteadOf", "ex:"]], + "git push ex: develop", + "deny", + "a pushInsteadOf rewrite names the repository a push to a URL writes", + ), + ( + [["config", "url.https://example.invalid/example/beta.git.insteadOf", "ex:"]], + "git push ex: develop", + "deny", + "an insteadOf rewrite names the repository a push to a URL writes", + ), + ( + [["config", "push.default", "matching"]], + "git push", + "deny", + "push.default=matching writes every local branch with a same-named remote-tracking ref", + ), + ( + [["config", "push.default", "matching"]], + "git push origin", + "deny", + "push.default=matching holds for a push naming only its remote", + ), + ( + [["config", "push.default", "matching"], ["branch", "-q", "-D", "release/1"]], + "git push", + "allow", + "control: matching branches that carry no rule are allowed", + ), + ( + [["config", "push.default", "matching"]], + "git push https://example.invalid/example/alpha.git", + "deny", + "a matching push to a URL, which has no remote-tracking refs, fails closed", + ), + ( + [["config", "remote.origin.push", "refs/heads/feature/example:refs/heads/release/1"]], + "git push origin feature/example", + "deny", + "remote..push maps a source-only refspec to its destination", + ), + ( + [["config", "remote.origin.push", "refs/heads/feature/*:refs/heads/release/*"]], + "git push origin feature/example", + "deny", + "a remote..push pattern maps a source-only refspec too", + ), + ( + [["config", "remote.origin.push", "refs/heads/feature/example:refs/heads/release/1"]], + "git push origin feature/example:feature/example", + "allow", + "control: an explicit destination is not mapped", + ), + ( + [ + ["config", "push.default", "upstream"], + ["config", "branch.feature/example.merge", "refs/heads/release/1"], + ], + "git push origin feature/example", + "deny", + "push.default=upstream maps a source-only branch to its branch..merge", + ), + ( + [["config", "remote.origin.push", "refs/heads/*:refs/heads/*"]], + "git push", + "deny", + "a remote..push pattern is read for a bare push", + ), + ( + [["config", "remote.origin.push", ":"]], + "git push", + "deny", + "a remote..push lone colon is read for a bare push", + ), + ( + [], + 'git push origin "refs/heads/*:refs/heads/*"', + "deny", + "a pattern is read as each branch it matches, one under a non-default ruleset included", + ), + ( + [], + 'git push origin "refs/heads/feature/*:refs/heads/release/*"', + "deny", + "a pattern is read as each destination it maps a local branch to", + ), + ( + [], + 'git push origin "refs/heads/feature/*:refs/heads/feature/*"', + "allow", + "control: a pattern matching only unruled branches is allowed", + ), + ([], "git push origin :", "deny", "a lone colon is read as each matching branch"), + ( + [["branch", "-q", "-D", "release/1"]], + 'git push --prune origin "refs/heads/*:refs/heads/*"', + "deny", + "a pruning pattern deletes each remote-tracking branch it matches", + ), + ([], "git push --all origin", "deny", "--all is read as every local branch"), + ( + [["branch", "-q", "-D", "release/1"]], + "git push --all origin", + "allow", + "control: --all over unruled local branches is allowed", + ), + ([], "git push --mirror origin", "deny", "--mirror is read as every local branch"), + ( + [["config", "remote.origin.push", "+refs/heads/feature/example:refs/heads/hotfix/1"]], + "git push origin feature/example", + "deny", + "a forced remote..push mapping forces the branch it maps to", + ), + ( + [["config", "remote.origin.push", "refs/heads/feature/example:refs/heads/hotfix/1"]], + "git push origin feature/example", + "allow", + "control: an unforced mapping updates a branch whose only rule forbids a force", + ), + ( + [["branch", "-q", "-D", "release/1"]], + "git branch -q release/9 && git push --all origin", + "deny", + "a command before the push that may add a branch leaves the branch list unread", + ), + ( + [["branch", "-q", "-D", "release/1"]], + "git status && git push --all origin", + "allow", + "control: a command before the push that moves no ref leaves the branch list readable", + ), +] + + +def _push_target_selftest(): + """Run `_PUSH_TARGET_CASES`, each in a fresh repository built from constructed names only. + + The session's checkout has `origin` at `example/alpha`, where a `release/` branch requires a + pull request, and `upstream` at `example/beta`, where `develop` does. It sits on + `feature/example` beside a local `release/1`, with remote-tracking refs for `develop`, + `release/1`, and `feature/example` under `origin`. + """ + import shutil + import tempfile + + if os.name == "nt" or os.path.sep != "/": + print(" skip [push ] push target cases need POSIX paths") + return True + saved = globals()["_live_branch_rules"] + globals()["_live_branch_rules"] = lambda owner, repo, br: ( + {"pull_request"} + if (repo, br) == ("beta", "develop") or (repo == "alpha" and br.startswith("release/")) + else {"non_fast_forward"} + if (repo, br) == ("alpha", "hotfix/1") + else set() + ) + ok = True + try: + for steps, cmd, want, label in _PUSH_TARGET_CASES: + root = tempfile.mkdtemp(prefix="guard-push-target-") + try: + repo = os.path.join(root, "repo") + setup = [ + ["remote", "add", "origin", "https://example.invalid/example/alpha.git"], + ["remote", "add", "upstream", "https://example.invalid/example/beta.git"], + *( + ["update-ref", f"refs/remotes/origin/{b}", "HEAD"] + for b in ("develop", "release/1", "feature/example") + ), + ["branch", "-q", "release/1"], + ["switch", "-q", "-c", "feature/example"], + ["branch", "-q", "-u", "refs/remotes/origin/feature/example"], + *steps, + ] + if not _build_selftest_repo(repo, setup): + print(f" skip [push ] could not build the repository for: {label}") + continue + got, _ = classify( + cmd, cwd=repo, environ={}, primary_checkout_lookup=lambda d: False + ) + ok = ok and got == want + print(f" {'ok ' if got == want else 'FAIL'} [{got:5}] want={want:5} {label}") + finally: + shutil.rmtree(root, ignore_errors=True) + finally: + globals()["_live_branch_rules"] = saved + return ok + + def _no_origin_selftest(): """Run `_NO_ORIGIN_CASES` with the origin read pinned to resolve nothing. @@ -16355,6 +17216,8 @@ def counting_lex(text, posix=True): ok = False if not _push_repo_selftest(): ok = False + if not _push_target_selftest(): + ok = False if not _no_origin_selftest(): ok = False if not _alias_push_selftest(origin):