diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md
index 2cf77678..c9b5fb61 100644
--- a/host-setup/agent-safety/README.md
+++ b/host-setup/agent-safety/README.md
@@ -55,7 +55,10 @@ text says, because the harm it covers was never in the text.
4. **A git operation that would only succeed by bypassing an active branch rule is denied**: a
direct push to a branch whose rules require a pull request, a force-push where history is
protected, a delete where deletion is blocked, or an explicit-bypass flag (`--admin` on a merge,
- `--no-verify` or a `-c core.hooksPath=` override on a commit/push). Judge branch-rule cases against that branch's *live* rules, so a
+ `--no-verify` or a `-c core.hooksPath=` override on a commit/push). An `include.path`/`includeIf`
+ override on a commit or push is denied as one too. The file it pulls in may set
+ `core.hooksPath` where the hook cannot read it. These flags are read in a command the hook
+ parses, a wrapper shell's body among them, and not inside a quoted `eval` argument. Judge branch-rule cases against that branch's *live* rules, so a
code-style `develop` denies and a config-style `develop` allows with no per-repo configuration.
Each refspec's operation is judged on its own, so a `+src` or a `:dst` refspec forces or
deletes only its own branch. A force-push and a delete each write the branch too, so a branch
@@ -68,8 +71,16 @@ text says, because the harm it covers was never in the text.
that case, a push whose target the hook cannot read at all is denied whatever branch it names.
That covers a push in text the hook does not parse and a push carrying a `-c`/`--config-env`
override. It covers a git command under an `include.path`/`includeIf` override too, where git
- lists no such subcommand, since the included file may define it as a push alias. It also covers
- a bare push or a `HEAD`/`@` refspec whose directory or branch may change before it runs. The
+ lists no such subcommand, since the included file may define it as a push alias. The same holds
+ where git does not build the subcommand in. An `--exec-path`, a `GIT_EXEC_PATH`, or a PATH
+ change can leave such a name to an alias. The override is also unreadable on a git command that
+ runs other git commands (`rebase`, `submodule`, `bisect`, `filter-branch`, `difftool`,
+ `mergetool`), whatever its arguments say. It reaches every git command that one runs. An `eval`
+ argument holding an include key is read as the command it runs. It also covers a bare push or a
+ `HEAD`/`@` refspec whose directory or branch may change before it runs. Such a push's local
+ branch is checked under its whole name, such as `release/1.0`. It is also checked with its first
+ segment dropped. A remote-tracking push target such as `origin/feature/x` is checked with its
+ remote dropped. The
workaround is an explicit refspec run as its own command. Every other
requirement here favors precision over recall throughout, denying only a positively-identified
dangerous shape, since a hook that fails closed on an unrelated resolution failure blocks
@@ -464,9 +475,10 @@ opt-in per clone, visible to anyone reading the repo, and bypassable on purpose,
rule whose harm is a quality miss rather than a destruction, which the host layer's bar excludes.
The hub's own `.husky/pre-push` is the worked example there, refusing a branch push that no
recorded local review pass covers, per [`GOVERNANCE.md`][governance] "Verification Discipline".
-The two layers meet at requirement 4, which denies `--no-verify` and a `-c core.hooksPath=`
-override unconditionally, so a Claude Code session meets a committed hook it
-cannot wave through with either while a human keeps the escape hatch. They do not compose into a
+The two layers meet at requirement 4, which denies `--no-verify`, a `-c core.hooksPath=`
+override, and an include override on a commit or push unconditionally, wherever it reads them. So
+a Claude Code session meets a committed hook it
+cannot wave through with any of them while a human keeps the escape hatch. They do not compose into a
seal, and saying so would be the more comfortable claim rather
than the true one. A committed hook is bypassable by construction, since it cannot police its own
invocation, and `--no-verify` is the documented route rather than the only one. This requirement
diff --git a/host-setup/agent-safety/claude/README.md b/host-setup/agent-safety/claude/README.md
index a8069da8..bd8464d6 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. 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 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 whose target the hook cannot read, such as one in text it does not parse, one carrying a `-c` config override, or a git command an `include.path`/`includeIf` override may define as a push alias, is denied whatever branch it names. 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. 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. 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 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 whose target the hook cannot read, such as one in text it does not parse, one carrying a `-c` config override, a git command an `include.path`/`includeIf` override may define as a push alias, or a git command that runs other git commands under one, is denied whatever branch it names. 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 among them, and not inside a quoted `eval` argument. 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/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.
- **The whole of `CLAUDE.md`, rendered on every run**, loaded into every session on the machine (including ad-hoc work outside any project). It holds a header, two marker-delimited blocks of host-wide content, and the host-local file. Nothing else survives an install. Content outside the blocks was never written or checked, and it restated fleet rules in wording the fleet had since changed.
diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py
index ca9c783d..75b499b0 100755
--- a/host-setup/agent-safety/claude/gh-write-guard.py
+++ b/host-setup/agent-safety/claude/gh-write-guard.py
@@ -314,24 +314,35 @@ def _live_branch_rules(owner, repo, branch):
def _current_push_branch(cwd):
- """Resolve the destination branch of a bare `git push` from the branch's configured push target."""
+ """Resolve the destination branches of a bare `git push` from the branch's configured push target."""
return _read_branch(
cwd,
(
- ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{push}"],
- ["rev-parse", "--abbrev-ref", "HEAD"],
+ (["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{push}"], True),
+ (["rev-parse", "--abbrev-ref", "HEAD"], False),
),
)
def _head_branch(cwd):
"""Resolve the branch `HEAD` names, which a `git push HEAD` updates under the same name."""
- return _read_branch(cwd, (["rev-parse", "--abbrev-ref", "HEAD"],))
+ return _read_branch(cwd, ((["rev-parse", "--abbrev-ref", "HEAD"], False),))
+
+
+def _branch_names(branch):
+ """The branch names a bare push's resolved branch stands for, a seam's single string included."""
+ return [branch] if isinstance(branch, str) else list(branch)
def _read_branch(cwd, reads):
- """The branch the first of `reads` that succeeds in `cwd` names, or None."""
- for args in reads:
+ """The branch names the first of `reads` that succeeds in `cwd` gives, or None.
+
+ Each read is `(args, remote)`. A remote read gives a remote-tracking name such as
+ `origin/feature/x`, and its first segment is stripped as the remote name. A local read gives a
+ branch name such as `release/1.0`, which is kept whole. That name is also read with its first
+ segment stripped, as this function read every name before, so both names are checked.
+ """
+ for args, remote in reads:
try:
r = subprocess.run(
["git", "-C", cwd or ".", *args],
@@ -348,7 +359,8 @@ def _read_branch(cwd, reads):
return None
ref = r.stdout.strip()
if r.returncode == 0 and ref and ref != "HEAD":
- return ref.split("/", 1)[1] if "/" in ref else ref
+ stripped = ref.split("/", 1)[1] if "/" in ref else ref
+ return [stripped] if remote else list(dict.fromkeys((ref, stripped)))
return None
@@ -983,6 +995,15 @@ def _overrides_hooks_path(global_opts):
return any(_config_key(v) == "core.hookspath" for v in _git_config_overrides(global_opts))
+def _overrides_include(global_opts):
+ """True when the global options pull in a config file by `include.path` or `includeIf`.
+
+ That file may set `core.hooksPath` where the guard cannot read it, so a commit or push under
+ one is read as a hooks-path override.
+ """
+ return any(_includes_config_file(v) for v in _git_config_overrides(global_opts))
+
+
# --- Rule 6: a mutating git op against a primary checkout ---------------------------------------------
# `-C `, `--work-tree `/`--work-tree=`, and a `GIT_WORK_TREE=` command-text prefix are the options this rule resolves the mutation target directory from, following real git's own priority rather than a last-one-wins scan across all three.
# `--work-tree`/`GIT_WORK_TREE` name the working tree a mutating command like `reset --hard`/`clean -f` actually writes into, and win regardless of where `-C` points or how many `-C` options preceded it.
@@ -1634,6 +1655,7 @@ def _cd_dot_dot(cmd):
_GIT_COMMAND_NAMES: list[set[str]] = []
+_GIT_BUILTIN_NAMES: list[set[str]] = []
def _git_command_names():
@@ -1643,10 +1665,23 @@ def _git_command_names():
possible alias, and the caller fails closed. The self-test pins the set, so its cases do not
depend on the git that runs them.
"""
- if not _GIT_COMMAND_NAMES:
+ return _listed_git_commands("builtins,main,others", _GIT_COMMAND_NAMES)
+
+
+def _git_builtin_names():
+ """The commands built into git, which no `--exec-path` or PATH change can hand to an alias.
+
+ It is read and pinned the way `_git_command_names` is.
+ """
+ return _listed_git_commands("builtins", _GIT_BUILTIN_NAMES)
+
+
+def _listed_git_commands(kinds, cache):
+ """The names `git --list-cmds=` prints, read once into `cache`, or an empty set on failure."""
+ if not cache:
try:
r = subprocess.run(
- ["git", "--list-cmds=builtins,main,others"],
+ ["git", f"--list-cmds={kinds}"],
capture_output=True,
text=True,
encoding="utf-8",
@@ -1657,8 +1692,8 @@ def _git_command_names():
names = set(r.stdout.split()) if r.returncode == 0 else set()
except Exception: # noqa: BLE001
names = set()
- _GIT_COMMAND_NAMES.append(names)
- return _GIT_COMMAND_NAMES[0]
+ cache.append(names)
+ return cache[0]
def _check_primary_checkout_mutation(
@@ -2180,6 +2215,7 @@ def _is_short_u(arg):
_INLINE_PUSH_ALIAS = re.compile(r"alias\..*\bpush\b", re.DOTALL | re.IGNORECASE)
_GIT_INCLUDE = re.compile(r"git\b.*?include", re.DOTALL | re.IGNORECASE)
+_GIT_INCLUDE_KEY = re.compile(r"git\b.*?include(?:\.path|if\.)", re.DOTALL | re.IGNORECASE)
def _includes_config_file(value):
@@ -2211,6 +2247,17 @@ def _unreadable_push(cmd):
- a `-c` config value, an inline alias expanding to a push among them
- a config file an `include.path`/`includeIf` override pulls in, read when the git command it
overrides names no command git lists, so it may be an alias that file defines
+ - the same file on a command git does not build in, which an `--exec-path`, a
+ `GIT_EXEC_PATH`, or a PATH change can leave to an alias that file defines
+ - the same file on one of `_COMMAND_RUNNING_GIT_SUBS`, whose child git commands it reaches, so
+ an alias it defines may push whatever the arguments name
+ - an include key in a wrapper shell argument other than the `-c` body, or in a wrapper body
+ nested past the reading cap
+
+ An `eval` argument holding an include key is read as the command it runs, for this check
+ alone. It is bounded by `_EVAL_READ_BUDGET` and `_EVAL_READ_COST`, as the wait rule's is.
+ Once the budget is spent, the text from the next `eval` on is searched once for an include
+ key, which fails closed, and no later `eval` in that text is read.
Any `-c`/`--config-env` on a push, or on a git command that runs one, counts too, an attached
`-c` among them, which git rejects (see `_git_config_overrides`). Config such
@@ -2220,12 +2267,28 @@ def _unreadable_push(cmd):
"""
overflow = []
texts = [cmd, *_embedded_wrapper_commands(cmd, overflow=overflow)]
- if any(_GIT_PUSH.search(t) for t in overflow):
+ if any(_GIT_PUSH.search(t) or _GIT_INCLUDE_KEY.search(t) for t in overflow):
return "wrapper shells nested deeper than the guard reads"
+ budget = _EVAL_READ_BUDGET
for text in texts:
toks = _shell_tokens(text)
n = len(toks)
+ quoted = _quoted_mask(text, toks) if "eval" in toks else None
+ eval_end = 0
for k, t in enumerate(toks):
+ if t == "eval" and k >= eval_end:
+ if budget <= 0:
+ if _GIT_INCLUDE_KEY.search(" ".join(toks[k:])):
+ return "an include override inside more `eval` text than the guard reads"
+ eval_end = n
+ else:
+ payload, eval_end = _eval_payload(toks, k, quoted)
+ budget -= len(payload) + _EVAL_READ_COST
+ if _GIT_INCLUDE.search(payload):
+ texts.append(payload)
+ texts.extend(_embedded_wrapper_commands(payload, overflow=overflow))
+ if any(_GIT_PUSH.search(o) or _GIT_INCLUDE_KEY.search(o) for o in overflow):
+ return "wrapper shells nested deeper than the guard reads"
if t in ("eval", "source", ".") or _is_shell_wrapper_exe(t):
args, _ = _collect_arglist(toks, k + 1)
if _is_shell_wrapper_exe(t):
@@ -2234,6 +2297,11 @@ def _unreadable_push(cmd):
return (
f"a push inside an argument of `{t}` the guard does not read as a command"
)
+ if _is_shell_wrapper_exe(t) and any(_GIT_INCLUDE_KEY.search(a) for a in args):
+ return (
+ f"an include override inside an argument of `{t}` the guard does not read "
+ "as a command"
+ )
if not _is_git_exe(t):
continue
j = _git_subcommand_index(toks, k)
@@ -2246,15 +2314,23 @@ def _unreadable_push(cmd):
continue
if toks[j] == "push" and config:
return "a push carrying a `-c`/`--config-env` override, which can change what it updates"
- if (
- any(_includes_config_file(v) for v in values)
- and not _is_shell_op(toks[j])
- and toks[j] not in _git_command_names()
- ):
+ includes = _overrides_include(opts)
+ if includes and not _is_shell_op(toks[j]) and toks[j] not in _git_command_names():
return (
f"`git {toks[j]}` under an `include.path`/`includeIf` override, whose file "
"may define it as a push alias"
)
+ if includes and not _is_shell_op(toks[j]) and toks[j] not in _git_builtin_names():
+ return (
+ f"`git {toks[j]}` under an `include.path`/`includeIf` override, a command git "
+ "does not build in, which an `--exec-path` or a PATH change may leave to an "
+ "alias that file defines"
+ )
+ if includes and toks[j] in _COMMAND_RUNNING_GIT_SUBS:
+ return (
+ f"`git {toks[j]}` under an `include.path`/`includeIf` override, whose file "
+ "may define a push alias the git commands it runs expand"
+ )
if toks[j] in _COMMAND_RUNNING_GIT_SUBS:
args, _ = _collect_arglist(toks, j + 1)
if config and any(a == "push" or _GIT_PUSH.search(a) for a in args):
@@ -2326,7 +2402,7 @@ def _push_invocations(cmd):
def _bare_push_branch(cwd, c_dirs, work_tree, leading_cd, cd_unreliable, head=False):
- """The branch a bare push in the directory it runs in targets, or `_UNRESOLVED`.
+ """The branch names a bare push in the directory it runs in targets, or `_UNRESOLVED`.
A directory the command moves to is read, never the session's own, and one that does not
resolve to a directory, or that a later `cd` may have changed, is unresolved. The shell runs the
@@ -2428,7 +2504,7 @@ def _push_targets(cmd, cwd=None, current_branch=None):
if b is _UNRESOLVED:
results.append(("unresolved", leading_cd or " ".join(c_dirs)))
elif b:
- targets.append((ops, b))
+ targets.extend((ops, name) for name in _branch_names(b))
continue
if dst.startswith("refs/heads/"):
dst = dst[len("refs/heads/") :]
@@ -2453,7 +2529,7 @@ def _push_targets(cmd, cwd=None, current_branch=None):
results.append(("unresolved", leading_cd or " ".join(c_dirs)))
continue
if b:
- targets = [(push_ops or {"update"}, b)]
+ targets = [(push_ops or {"update"}, name) for name in _branch_names(b)]
results.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)]
@@ -2519,6 +2595,17 @@ def _check_bypass_flags(cmd):
"holds instead of the repository's, and none where it holds none. That is a hook bypass; run "
"the command without the override." + _handoff(cmd)
)
+ if any(
+ _overrides_include(opts)
+ for sub in ("commit", "push")
+ for t in texts
+ for opts, _ in _git_subcommand_invocations(t, sub)
+ ):
+ return "deny", (
+ "This pulls a config file into a commit or push by an include.path or includeIf override. "
+ "The guard does not read that file, which may set core.hooksPath and so bypass the "
+ "repository's hooks. Run the command without the override." + _handoff(cmd)
+ )
return "allow", ""
@@ -5441,6 +5528,174 @@ def classify(
"allow",
"control: an alias cannot shadow a command git lists, so an include on status defines no push alias",
),
+ (
+ "git -c include.path=/example/config commit -m x",
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "an included file may set core.hooksPath for a commit, read as the hooks-path override it may be",
+ ),
+ (
+ "git -c includeIf.gitdir:/example/.path=/example/config commit -m x",
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "an includeIf file on a commit is read the same way",
+ ),
+ (
+ "git --config-env=include.path=EXAMPLE_CONFIG commit -m x",
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "an include set through --config-env on a commit is read the same way",
+ ),
+ (
+ "bash -c 'git -c include.path=/example/config commit -m x'",
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "an include on a commit inside a wrapper body is read too",
+ ),
+ (
+ "git -c user.name=Example commit -m x",
+ "feature/x",
+ {"feature/x": set()},
+ "allow",
+ "control: an override that pulls in no file leaves a commit's hooks readable",
+ ),
+ (
+ "git -c include.path=/example/config rebase --exec 'git p origin develop' HEAD~1",
+ "feature/x",
+ {"feature/x": set(), "develop": _CODE_RULES},
+ "deny",
+ "an include reaches the git commands rebase runs, so an alias it defines may push",
+ ),
+ (
+ "git -c include.path=/example/config submodule foreach 'git p origin develop'",
+ "feature/x",
+ {"feature/x": set(), "develop": _CODE_RULES},
+ "deny",
+ "an include reaches the git commands submodule foreach runs too",
+ ),
+ (
+ "git -c include.path=/example/config rebase --continue",
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "an include on a command-running subcommand is unreadable whatever its arguments say",
+ ),
+ (
+ "git -c user.name=Example rebase --exec 'make test' HEAD~1",
+ "feature/x",
+ {"feature/x": set()},
+ "allow",
+ "control: a plain override on rebase with no push word stays allowed",
+ ),
+ (
+ "eval 'git -c include.path=/example/config p'",
+ None,
+ {"develop": _CODE_RULES},
+ "deny",
+ "an eval argument holding an include key is read as the command it runs",
+ ),
+ (
+ "eval 'git -c include.path=/example/config rebase --exec true HEAD~1'",
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "an include on rebase inside an eval argument is read too",
+ ),
+ (
+ "eval \"bash -c 'git -c include.path=/example/config p'\"",
+ None,
+ {"develop": _CODE_RULES},
+ "deny",
+ "a wrapper body inside an eval argument is read too",
+ ),
+ (
+ "eval 'git -c include.path=/example/config status'",
+ "feature/x",
+ {"feature/x": set()},
+ "allow",
+ "control: an eval argument read as a command holds no push alias where the command is listed",
+ ),
+ (
+ "eval " + "eval " * 9 + "'git -c include.path=/example/config status'",
+ "feature/x",
+ {"feature/x": set()},
+ "allow",
+ "control: a chain of evals is read once per payload, so a listed command at its end passes",
+ ),
+ (
+ "; ".join(["eval 'git -c include.path=/example/config status'"] * 300),
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "an include inside more eval text than the reading budget covers fails closed",
+ ),
+ (
+ "; ".join(["eval 'git -c include.path=/example/config status'"] * 6),
+ "feature/x",
+ {"feature/x": set()},
+ "allow",
+ "control: evals side by side are each read once",
+ ),
+ (
+ "bash -c '$1' _ 'git -c include.path=/example/config p'",
+ None,
+ {"develop": _CODE_RULES},
+ "deny",
+ "an include key in a wrapper argument the guard does not read fails closed",
+ ),
+ (
+ "git --exec-path=/example/libexec -c include.path=/example/config instaweb",
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "an --exec-path does not make a command git does not build in readable",
+ ),
+ (
+ "git -c include.path=/example/config --exec-path=/example/libexec status",
+ "feature/x",
+ {"feature/x": set()},
+ "allow",
+ "control: a builtin runs before any alias, so an --exec-path cannot hand it to one",
+ ),
+ (
+ "PATH=/example/bin git -c include.path=/example/config lfs",
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "a PATH prefix does not make a command git does not build in readable",
+ ),
+ (
+ "GIT_EXEC_PATH=/example/libexec git -c include.path=/example/config instaweb",
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "a GIT_EXEC_PATH prefix does not make a command git does not build in readable",
+ ),
+ (
+ "export GIT_EXEC_PATH=/example/libexec; bash -c 'git -c include.path=/example/config instaweb'",
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "nor does an exported GIT_EXEC_PATH before a wrapper body",
+ ),
+ (
+ "git -c include.path=/example/config instaweb",
+ "feature/x",
+ {"feature/x": set()},
+ "deny",
+ "a command git does not build in is read as one an exec-path or PATH change may hand to an alias",
+ ),
+ (
+ "git --exec-path=/example/libexec status",
+ "feature/x",
+ {"feature/x": set()},
+ "allow",
+ "control: an --exec-path with no include pulls in no alias",
+ ),
]
# (command, cwd, {dir: is_primary_or_None}, {(dir, ref): resolves_as_ref} or None, expected_decision, label) -- is_primary is True (a primary checkout), False (a linked worktree), or None (unresolved, for example when no repository exists or the git query itself fails, such as on a pre-2.31 git lacking `rev-parse --path-format`).
@@ -8231,6 +8486,36 @@ def _nested_wrapper(body, depth):
_PUSH_DIR_CASES = [
+ (
+ "@wt " + _nested_wrapper("git -c include.path=/example/config p", 6),
+ "deny",
+ "an include key in a wrapper body past the nesting cap fails closed",
+ ),
+ (
+ "@wt " + _nested_wrapper("git -c user.name=Example status", 6),
+ "allow",
+ "control: a body past the nesting cap holding no push and no include key passes",
+ ),
+ (
+ "@rel git push",
+ "deny",
+ "a local branch with no push target is read whole, so release/1.0 is checked as itself",
+ ),
+ (
+ "@rel git push origin HEAD",
+ "deny",
+ "the branch HEAD names is read whole too",
+ ),
+ (
+ "cd {rel} && git push",
+ "deny",
+ "a leading cd into the release worktree reads its branch whole",
+ ),
+ (
+ "@wt git push origin HEAD",
+ "allow",
+ "control: a feature branch read whole and stripped matches no protected rule",
+ ),
(
"@wt cd -P {base} && git push",
"deny",
@@ -8689,6 +8974,7 @@ def _push_dir_selftest(origin):
try:
base = os.path.join(root, "base")
wt = os.path.join(root, "wt")
+ rel = os.path.join(root, "rel")
missing = os.path.join(root, "missing")
git = [
"git",
@@ -8703,6 +8989,7 @@ def _push_dir_selftest(origin):
[*git, "init", "-q", "-b", "develop", base],
[*git, "-C", base, "commit", "-q", "--allow-empty", "--no-verify", "-m", "init"],
[*git, "-C", base, "worktree", "add", "-q", "-b", "feature/example", wt],
+ [*git, "-C", base, "worktree", "add", "-q", "-b", "release/1.0", rel],
)
for step in steps:
try:
@@ -8717,13 +9004,22 @@ def _push_dir_selftest(origin):
os.makedirs(os.path.join(base, "sub2"))
os.symlink(os.path.join(base, "sub2"), os.path.join(wt, "t"))
rules = lambda br: (
- {"pull_request", "non_fast_forward"} if br in ("develop", "main") else set()
+ {"pull_request", "non_fast_forward"}
+ if br in ("develop", "main", "release/1.0")
+ else set()
)
for template, want, label in _PUSH_DIR_CASES:
session = base
if template.startswith("@wt "):
template, session = template[4:], wt
- cmd = template.replace("{wt}", wt).replace("{base}", base).replace("{missing}", missing)
+ elif template.startswith("@rel "):
+ template, session = template[5:], rel
+ cmd = (
+ template.replace("{wt}", wt)
+ .replace("{rel}", rel)
+ .replace("{base}", base)
+ .replace("{missing}", missing)
+ )
got, _ = classify(
cmd,
cwd=session,
@@ -8801,7 +9097,23 @@ def _selftest():
ok = False
print(f" {mark} [{got:5}] want={want:5} {label}")
saved_names = list(_GIT_COMMAND_NAMES)
- _GIT_COMMAND_NAMES[:] = [{"checkout", "commit", "reset", "status", "worktree"}]
+ saved_builtins = list(_GIT_BUILTIN_NAMES)
+ _GIT_COMMAND_NAMES[:] = [
+ {
+ "checkout",
+ "commit",
+ "instaweb",
+ "lfs",
+ "rebase",
+ "reset",
+ "status",
+ "submodule",
+ "worktree",
+ }
+ ]
+ _GIT_BUILTIN_NAMES[:] = [
+ {"checkout", "commit", "rebase", "reset", "status", "submodule", "worktree"}
+ ]
for cmd, cur, rmap, want, label in _GIT_CASES:
got, _ = classify(
cmd,
@@ -8850,6 +9162,7 @@ def _selftest():
primary_checkout_lookup=lambda _d: False,
)
_GIT_COMMAND_NAMES[:] = saved_names
+ _GIT_BUILTIN_NAMES[:] = saved_builtins
mark = "ok " if got == "deny" else "FAIL"
if got != "deny":
ok = False
]