diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 225758368b..3e177dc470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -788,6 +788,7 @@ jobs: tests/test_openclaw_detection_real.py \ tests/test_repo_scan_daemon_wiring.py \ tests/test_guard_workspace_kinds.py \ + tests/test_tool_risk.py \ tests/test_no_shadowed_module_functions.py \ tests/test_hooks_claude_code.py \ tests/test_hook_ownership_quoted_launcher.py \ diff --git a/clawmetry/git_config_exec.py b/clawmetry/git_config_exec.py new file mode 100644 index 0000000000..2a363cbf7e --- /dev/null +++ b/clawmetry/git_config_exec.py @@ -0,0 +1,115 @@ +"""Which git config settings make git execute a program. + +One list, in a leaf, because two modules ask the same question and a copy in +each is a copy that drifts. Neither format overlaps the other: literal exec +keys (``core.hookspath``) and value-dependent ones (``alias.*``) are resolved +by the same predicate so both callers rate identically. + +* :mod:`clawmetry.repo_scan` asks it of a ``.git/config`` on disk -- that is + the whole basis of the ``repo_config_exec`` finding; +* :mod:`clawmetry.tool_risk` asks it of ``git -c =`` on a command + line, which is the same arbitrary code execution passed a different way. + +They disagreed: the scanner rated a poisoned ``core.hooksPath`` critical while +the classifier rated the identical key on the command line ``medium``, and a +``min_risk: high`` policy therefore held neither (clawmetry-pro#244). + +This module imports only ``re``. It exists so ``tool_risk`` can share the +knowledge without importing ``repo_scan``: ``tool_risk`` is the leaf that +``approvals`` and the routes import, and it stays one by depending on this +constant module rather than on a scanner. + +It is a PREDICATE, not a name set. Executability is partly value-dependent -- +``alias.x`` is a shell command only when its value starts with ``!`` -- so a +name-only export would either miss that or promote every benign alias. + +NOT here, deliberately: ``protocol.ext.allow``. It names no program; it enables +the ``ext::`` transport so the command arrives in the remote URL instead. A +caller that can see a whole command line handles it separately. +""" +from __future__ import annotations + +import re + +#: Git config keys whose VALUE is a program git runs. +_EXEC_KEYS = ( + "core.fsmonitor", + "core.hookspath", + "core.sshcommand", + "core.editor", + "core.pager", + "core.askpass", + "sequence.editor", + "credential.helper", + "uploadpack.packobjectshook", + "diff.external", + "gpg.program", + "init.templatedir", +) + +#: Key shapes whose value is a program. ``alias.*`` is value-dependent. +_EXEC_KEY_PATTERNS = ( + re.compile(r"^filter\..+\.(clean|smudge|process)$"), + re.compile(r"^diff\..+\.(command|textconv)$"), + re.compile(r"^merge\..+\.driver$"), + re.compile(r"^alias\..+$"), # only flagged when the value starts "!" +) + +#: Values that are ordinary tools rather than payloads. +_KNOWN_GOOD_PREFIXES = ( + ("git-lfs", "clean"), ("git-lfs", "smudge"), ("git-lfs", "filter-process"), + ("git", "lfs"), + ("cat",), ("true",), ("false",), + ("rustfmt",), ("gofmt",), ("black",), ("prettier",), + ("less",), ("more",), ("delta",), ("diff-so-fancy",), +) + +#: A value that chains, substitutes or redirects is never known-good, whatever +#: it starts with -- ``git-lfs clean -- %f; curl attacker`` starts with git-lfs. +#: Covers: sequence (;, &&, ||, &), pipe (|), substitution ($, `), redirect +#: (>, <), comment injection (#), history expansion (!), subshell/brace grouping +#: ( ) { }, and CR (\r) which some shells treat as a command separator. +_SHELL_METACHARS = re.compile(r"[;&|`$><\n\r!#(){}]") + + +def executes(full_key: str, value: str = "") -> bool: + """Does setting ``full_key`` to ``value`` make git run a program? + + ``value`` defaults to empty for callers that see the key but not the value + (``git --config-env=k=ENVVAR`` names an environment variable). That is + conservative on purpose: literal exec keys still match, value-dependent + ones do not fire on a value we cannot see. + """ + key = str(full_key or "").strip().lower() + val = str(value or "") + if key in _EXEC_KEYS: + return True + for rx in _EXEC_KEY_PATTERNS: + if rx.match(key): + if key.startswith("alias."): + return val.strip().startswith("!") + return True + return False + + +def value_known_good(value: str) -> bool: + """Is this value a recognised ordinary tool rather than a payload? + + ``core.pager=less`` executes by definition and is an entirely ordinary + thing to type, so a classifier that promotes on the key alone reports + noise on a common command. + """ + if _SHELL_METACHARS.search(value or ""): + return False + tokens = str(value or "").split() + # An empty value sets the key to nothing, which runs nothing. Matches + # repo_scan's long-standing behaviour exactly -- this module was extracted + # from it, and a semantic change here would silently move every + # repo_config_exec verdict. + if not tokens: + return True + lowered = [t.lower() for t in tokens] + for prefix in _KNOWN_GOOD_PREFIXES: + if lowered[:len(prefix)] == list(prefix): + return True + return False diff --git a/clawmetry/repo_scan.py b/clawmetry/repo_scan.py index be9a968883..eaf8114a5e 100644 --- a/clawmetry/repo_scan.py +++ b/clawmetry/repo_scan.py @@ -38,6 +38,8 @@ class of attack where the agent chooses nothing at all — GitSpawn being the import re from typing import Optional +from clawmetry import git_config_exec as _gce + #: Every file a scan READS, relative to the workspace. Declared here so the #: daemon's cache stamp cannot miss one: the stamp is what decides whether a #: repo is re-scanned, so a file the scanner reads but the stamp ignores means @@ -70,40 +72,16 @@ class of attack where the agent chooses nothing at all — GitSpawn being the # Git config keys whose VALUE is a program git will execute. Section+key, lowered. # Sourced from git-config(1); the wildcard forms cover per-name subsections. -_EXEC_KEYS = ( - "core.fsmonitor", - "core.hookspath", - "core.sshcommand", - "core.editor", - "core.pager", - "core.askpass", - "sequence.editor", - "credential.helper", - "uploadpack.packobjectshook", - "diff.external", - "gpg.program", - "init.templatedir", -) -_EXEC_KEY_PATTERNS = ( - re.compile(r"^filter\..+\.(clean|smudge|process)$"), - re.compile(r"^diff\..+\.(command|textconv)$"), - re.compile(r"^merge\..+\.driver$"), - re.compile(r"^alias\..+$"), # only flagged when the value starts "!" -) +_EXEC_KEYS = _gce._EXEC_KEYS +_EXEC_KEY_PATTERNS = _gce._EXEC_KEY_PATTERNS # Commands that legitimately appear in these keys in ordinary repositories. # Matched against the value's leading tokens, so `git-lfs clean -- %f` is # recognised while `git-lfs clean; curl evil` is not. -_KNOWN_GOOD_PREFIXES = ( - ("git-lfs", "clean"), ("git-lfs", "smudge"), ("git-lfs", "filter-process"), - ("git", "lfs"), - ("cat",), ("true",), ("false",), - ("rustfmt",), ("gofmt",), ("black",), ("prettier",), - ("less",), ("more",), ("delta",), ("diff-so-fancy",), -) +_KNOWN_GOOD_PREFIXES = _gce._KNOWN_GOOD_PREFIXES # A value that chains, substitutes or redirects is never "known good", whatever # it starts with — `git-lfs clean -- %f; curl attacker` starts with git-lfs. -_SHELL_METACHARS = re.compile(r"[;&|`$><\n]|\$\(|\|\|") +_SHELL_METACHARS = _gce._SHELL_METACHARS _TASKS_AUTORUN = re.compile(r'"runOn"\s*:\s*"folderOpen"', re.I) @@ -121,16 +99,7 @@ def _sketch(value: str, limit: int = 80) -> str: def _is_known_good(value: str) -> bool: - if _SHELL_METACHARS.search(value or ""): - return False - tokens = str(value).split() - if not tokens: - return True - lowered = [t.lower() for t in tokens] - for prefix in _KNOWN_GOOD_PREFIXES: - if lowered[:len(prefix)] == list(prefix): - return True - return False + return _gce.value_known_good(value) # Hook managers that legitimately point core.hooksPath at the working tree. @@ -289,16 +258,44 @@ def _parse_git_config(text: str) -> list: return out +def git_config_value_known_good(value: str) -> bool: + """Is this config VALUE a recognised, ordinary tool rather than a payload? + + Shared with :mod:`clawmetry.tool_risk` for the same reason as + :func:`git_config_executes`: ``core.pager=less`` executes by definition and + is an entirely ordinary thing to type, so a classifier that promotes it on + the key alone reports noise on a common command. A value that chains, + substitutes or redirects is never known-good whatever it starts with. + """ + return _is_known_good(str(value or "")) + + +def git_config_executes(full_key: str, value: str = "") -> bool: + """Does setting this git config key to this value make git run a program? + + The single source of truth for "this git config executes", shared with + :mod:`clawmetry.tool_risk` so the on-disk scanner and the tool-stream + classifier cannot disagree about the same fact. It is a PREDICATE, not a + name set, because executability is partly value-dependent: ``alias.x`` is + a shell command only when its value starts with ``!``, and a name-only + export would either miss that or promote every benign alias. + + ``value`` defaults to empty for callers that can see the key but not the + value (``git --config-env=k=ENVVAR`` names an environment variable, not + the program). That is deliberately conservative: literal exec keys still + match, and the value-dependent ones do not fire on an unknown value. + + NOTE: this does NOT cover ``protocol.ext.allow``. That key names no + program — it enables the ``ext::`` transport so the command comes from the + URL argument instead. Callers that see a command line must handle it + separately; treating it as an exec key here would be wrong. + """ + return _key_is_executable(str(full_key or "").strip().lower(), + str(value or "")) + + def _key_is_executable(full_key: str, value: str) -> bool: - if full_key in _EXEC_KEYS: - return True - for rx in _EXEC_KEY_PATTERNS: - if rx.match(full_key): - # A git alias is only a shell command when it starts with "!". - if full_key.startswith("alias."): - return value.strip().startswith("!") - return True - return False + return _gce.executes(full_key, value) def _finding(kind: str, severity: str, title: str, detail: str, diff --git a/clawmetry/tool_risk.py b/clawmetry/tool_risk.py index 7098bb5fb2..e118b1b2cd 100644 --- a/clawmetry/tool_risk.py +++ b/clawmetry/tool_risk.py @@ -22,10 +22,12 @@ * **Worst signal wins.** Every matching rule contributes a reason; the final level is the maximum. Reasons are plain copy (no em-dashes, no jargon) because they surface verbatim in approval prompts. - * **This module imports nothing from the rest of clawmetry.** It is the - leaf that ``approvals.py`` (and routes) import, so the canonical tool - map lives HERE now and ``approvals`` re-exports it (single source of - truth, no drift between watcher / replay / hook gate). + * **One clawmetry import is permitted: ``clawmetry.git_config_exec``.** + That module is a pure-constant leaf (only imports ``re``) shared by both + ``tool_risk`` and ``repo_scan``. The exception is documented in the + Governance blueprint (PR #5848). All other clawmetry imports remain + prohibited so the tool map stays the leaf that ``approvals.py`` and + routes import. Public API: classify_tool_call(tool_name, args) -> {level, rank, category, reasons} @@ -40,6 +42,9 @@ import re from typing import Any +from clawmetry.git_config_exec import executes as _git_cfg_executes +from clawmetry.git_config_exec import value_known_good as _git_cfg_value_known_good + # ── Canonical tool categories (moved verbatim from approvals.py) ────────── # Harness-agnostic tool categories. Approval policies are authored against # OpenClaw's tool names (``exec``, ``read``, …), but other harnesses emit the @@ -148,9 +153,23 @@ def _rx(p: str) -> "re.Pattern[str]": # targeting root, home, or a bare glob of either. _RM_RECURSIVE_FORCE = _rx( r"\brm\s+(?=[^|;&]*\s-\w*r)(?=[^|;&]*\s-\w*f)") +# Quantifiers are bounded because `[^|;&]*` and the `\s+` that follows it both +# match a space, so the engine backtracks between them: CodeQL py/polynomial- +# redos, "slow on strings with many repetitions of ' '". This regex predates +# the git-config work but is the one the alerts on this file actually blame, +# and it runs on every exec classification, which the Brain feed performs +# thousands of times per page-load. An `rm` invocation longer than these +# bounds is not something this rule can usefully judge anyway. +# Was compiled INLINE inside _classify_exec, so it was rebuilt on every exec +# classification -- thousands per Brain page-load -- and `\w*r\w*` let the +# engine backtrack between the two runs (CodeQL py/polynomial-redos). Hoisted +# and bounded. +_RM_DASH_R = _rx(r"\brm\s{1,8}-\w{0,8}r\w{0,8}\s") + _RM_ROOT_TARGET = _rx( - r"\brm\s+[^|;&]*\s+(?:--?\w+\s+)*(?:/|/\*|~|~/|\$home\b|\$\{home\}|" - r"%userprofile%|c:\\\\?\s*$|c:\\\\?\*)\s*(?:$|[|;&])") + r"\brm\s{1,8}[^|;&]{0,256}\s{1,8}(?:--?\w{1,32}\s{1,8}){0,8}" + r"(?:/|/\*|~|~/|\$home\b|\$\{home\}|" + r"%userprofile%|c:\\\\?\s*$|c:\\\\?\*)\s{0,8}(?:$|[|;&])") _CMD_RULES: list[tuple["re.Pattern[str]", str, str]] = [ # ── critical: irreversible machine or data destruction ── @@ -213,7 +232,11 @@ def _rx(p: str) -> "re.Pattern[str]": (_rx(r"\b(?:env|printenv|set)\b\s*(?:$|[|;&])[^|;&]*" r"\b(?:curl|wget|nc)\b"), "high", "dumps environment variables toward the network"), - (_rx(r"\b\w*(?:api[_-]?key|secret|token|passwd|password|credential)\w*\s*="), + # Bounded: `\w*` on BOTH sides of the alternation lets the engine + # backtrack between them (CodeQL py/polynomial-redos). An identifier + # longer than these bounds is not one this rule can usefully judge. + (_rx(r"\b\w{0,32}(?:api[_-]?key|secret|token|passwd|password|credential)" + r"\w{0,32}\s{0,8}="), "high", "references secret-looking values"), (_rx(r"\breg\s+add\s+hklm\b"), "high", "writes to the Windows machine registry"), @@ -265,13 +288,111 @@ def _rx(p: str) -> "re.Pattern[str]": _WRITE_HTTP = ("post", "put", "patch") +# EVERY quantifier below is bounded. Unbounded ones here are a real denial of +# service, not a theoretical one: this runs on the Brain feed's hot path, which +# classifies thousands of rows per page-load, over a command string an agent +# (or whatever prompted it) chose. CodeQL py/polynomial-redos flagged the +# unbounded first cut. A git config key is short and a value longer than the +# cap is not something we can usefully rate anyway. +_GIT_CONFIG_INLINE = _rx(r"(?:^|\s)-c[ \t]{0,4}([A-Za-z0-9._*-]{1,64})=") +_GIT_CONFIG_ENV_OPT = _rx( + r"(?:^|\s)--config-env[=\s]([A-Za-z0-9._*-]{1,64})=") +# Environment forms of the same keys, set inline on the command. +_GIT_EXEC_ENVVARS = _rx( + r"\b(GIT_SSH_COMMAND|GIT_EXTERNAL_DIFF|GIT_EDITOR|GIT_PAGER|GIT_ASKPASS" + r"|GIT_SEQUENCE_EDITOR)=") +# Not an exec KEY: it names no program. It enables the ext:: transport so the +# program comes from the URL argument, which is why repo_scan's key list does +# not (and should not) contain it. +_GIT_EXT_TRANSPORT = _rx(r"\bprotocol\.ext\.allow\s*=") + + +def _scan_config_value(cmd: str, start: int, limit: int = 512) -> str: + """The value after ``-c key=``, read by one left-to-right scan. + + Honours a single level of shell quoting and stops at the first unquoted + whitespace or command separator. Linear and allocation-bounded by + construction: no regex, so no backtracking to be polynomial about. + """ + out: list = [] + quote = "" + for ch in cmd[start:start + limit]: + if quote: + if ch == quote: + quote = "" + else: + out.append(ch) + continue + if ch in "\"'": + quote = ch + continue + if ch.isspace() or ch in ";|&": + break + out.append(ch) + return "".join(out) + + +def _classify_git_exec_config(cmd: str, hits: list[tuple[str, str]]) -> None: + """Flag `git -c =` where the key makes git run a program. + + Uses ``_git_cfg_executes`` / ``_git_cfg_value_known_good`` from + ``clawmetry.git_config_exec``. Reasons name the key so an operator working the Approvals + queue can identify the threat. + """ + if "git" not in cmd.lower(): + return + seen: set = set() + for m in _GIT_CONFIG_INLINE.finditer(cmd): + key = m.group(1) + k = key.lower() + # The VALUE is scanned, never matched. A regex alternation over + # quoted-or-unquoted is ambiguous -- the unquoted branch also matches a + # quote -- which is a polynomial backtrack on a hot path + # (CodeQL py/polynomial-redos). A single left-to-right scan cannot + # backtrack at all. Shell quoting is the caller's, not git's: + # `alias.x='!payload'` must read as `!payload` or the value-dependent + # alias rule never fires. + val = _scan_config_value(cmd, m.end()) + if k in seen: + continue + if not _git_cfg_executes(k, val): + continue + seen.add(k) + if _git_cfg_value_known_good(val): + # Recognition, not suppression: `core.pager=less` executes by + # definition and is ordinary. Say what it is and leave the level + # to the other rules rather than promoting a common command. + hits.append(("medium", + f"sets git {key} to a recognised tool ({val.split()[0]})")) + continue + hits.append(("high", f"sets git {key}, which git executes")) + # --config-env names an ENV VAR holding the value, so the value is not + # visible here; the predicate is called with an empty value, which matches + # literal exec keys and deliberately does not fire on value-dependent ones. + for key in _GIT_CONFIG_ENV_OPT.findall(cmd): + k = key.lower() + if k not in seen and _git_cfg_executes(k): + seen.add(k) + hits.append(("high", + f"sets git {key} from the environment, which git executes")) + for name in _GIT_EXEC_ENVVARS.findall(cmd): + hits.append(("high", f"sets {name}, which git executes")) + if _GIT_EXT_TRANSPORT.search(cmd): + hits.append(("high", + "enables the git ext:: transport, which runs a program " + "named in the remote URL")) + + def _classify_exec(cmd: str, hits: list[tuple[str, str]]) -> None: low = cmd.lower() for rx_, level, reason in _CMD_RULES: if rx_.search(low): hits.append((level, reason)) + # Read from the ORIGINAL command, not `low`: an alias's executability + # depends on its value, and lowercasing a path can change it. + _classify_git_exec_config(cmd, hits) # rm -rf aimed at root or home escalates to critical. - if _RM_RECURSIVE_FORCE.search(low) or _rx(r"\brm\s+-\w*r\w*\s").search(low): + if _RM_RECURSIVE_FORCE.search(low) or _RM_DASH_R.search(low): if _RM_ROOT_TARGET.search(low): hits.append(("critical", "recursive delete targets the filesystem root or home")) diff --git a/docs/MODULE_MAP.md b/docs/MODULE_MAP.md index 891856251d..90f5478746 100644 --- a/docs/MODULE_MAP.md +++ b/docs/MODULE_MAP.md @@ -4,7 +4,7 @@ > `python3 scripts/gen_module_map.py` (CI fails on drift via > `tests/test_module_map_drift.py`). -250 modules, 82 Flask blueprints. `CLAUDE.md` carries a short curated table of the ones you reach for most often; this is the whole list. +251 modules, 82 Flask blueprints. `CLAUDE.md` carries a short curated table of the ones you reach for most often; this is the whole list. Size bands are deliberately coarse so this file does not churn on every PR: **small** is under 200 lines, **medium** under 1k, **large** under 5k, **huge** is 5k and up. @@ -187,6 +187,7 @@ The pip-installable package: CLI, sync daemon, DuckDB store, detectors, enforcem | `clawmetry/flow_trace.py` | medium | Flow trace assembly for the Harness Engineering tab (REQ-HB-006). | | `clawmetry/gateway_protocol.py` | small | the single source of the OpenClaw gateway WebSocket protocol range every connect frame must advertise. | | `clawmetry/gateway_tap.py` | medium | live OpenClaw gateway WebSocket subscriber. | +| `clawmetry/git_config_exec.py` | small | Which git config settings make git execute a program. | | `clawmetry/git_outcomes.py` | medium | Read a repository and say whether the agent's work shipped (REQ-OBS-CEA-022). | | `clawmetry/guard_actuator.py` | medium | Guard actuator — the ONE path from a decision to a process. | | `clawmetry/harness_bench.py` | medium | Harness Engineering bench: pure scoring math, no I/O. | diff --git a/docs/ci_test_coverage_baseline.json b/docs/ci_test_coverage_baseline.json index d0f0382c0b..0a60757b4a 100644 --- a/docs/ci_test_coverage_baseline.json +++ b/docs/ci_test_coverage_baseline.json @@ -7,7 +7,7 @@ "Ratchet down by running --update-baseline after wiring new tests in.", "Related: issue #5813" ], - "total": 1149, + "total": 1148, "listed": 221, - "unlisted_max": 928 + "unlisted_max": 927 } diff --git a/tests/test_tool_risk.py b/tests/test_tool_risk.py index 0410a7874f..d4e809b739 100644 --- a/tests/test_tool_risk.py +++ b/tests/test_tool_risk.py @@ -367,3 +367,104 @@ def test_extract_tool_blocks_string_args_and_variant_keys(): def test_watcher_event_types_cover_v3_and_nemoclaw(): assert "model.completed" in approvals._TOOL_EVENT_TYPES assert "tool.call" in approvals._TOOL_EVENT_TYPES + + +# ── git config that executes a program (clawmetry-pro#244) ──────────────── +# +# repo_scan._EXEC_KEYS is the basis of `repo_config_exec`: it knows which git +# config keys make git run a program. tool_risk did not, so passing the same +# key on the command line scored `medium` — rank 1, which a policy with +# `min_risk: high` does not hold. Six forms of arbitrary code execution were +# under-rated. Mirror of the Google ADK CI/CD finding, where a command filter +# that trusted `git` was reached through core.hooksPath. + +import pytest as _pytest + +from clawmetry.tool_risk import classify_tool_call as _classify, risk_rank + + +def _level(cmd): + return _classify("Bash", {"command": cmd})["level"] + + +@_pytest.mark.parametrize("cmd", [ + "git -c core.hooksPath=/tmp/evil status", + "git -c core.fsmonitor=/tmp/evil.sh status", + "git -c core.sshCommand=/tmp/evil.sh fetch", + "git -c alias.x='!/tmp/evil.sh' x", + "git -c protocol.ext.allow=always fetch ext::sh -c id", + "git --config-env=core.sshCommand=EVIL fetch", + "GIT_SSH_COMMAND=/tmp/evil.sh git fetch", +]) +def test_git_config_exec_is_high(cmd): + """Each of these runs an attacker-chosen program. `medium` is rank 1, so + a `min_risk: high` policy held none of them.""" + lvl = _level(cmd) + assert risk_rank(lvl) >= risk_rank("high"), ( + f"{cmd!r} scored {lvl!r} (rank {risk_rank(lvl)}); a min_risk:high " + f"policy does not hold it." + ) + + +def test_reason_names_the_key_not_side_effects_unknown(): + """An operator working the Approvals queue could not tell this from a + build command.""" + r = _classify("Bash", {"command": "git -c core.hooksPath=/tmp/evil status"}) + joined = " ".join(r["reasons"]) + assert "core.hooksPath" in joined, joined + assert "side effects unknown" not in joined, joined + + +@_pytest.mark.parametrize("cmd", [ + "git status", + "git -c user.name=bob commit -m x", + "git -c alias.lg='log --oneline' lg", # alias without "!" is not a shell + "git -c core.pager=less log", # recognised tool +]) +def test_ordinary_git_is_not_promoted(cmd): + """False positives are the whole design problem. An alias is executable + only when its value starts with `!`, and a recognised pager is ordinary.""" + assert risk_rank(_level(cmd)) < risk_rank("high"), cmd + + +def test_known_good_value_cannot_hide_a_payload_behind_a_metachar(): + """`core.pager="less; curl evil"` starts with a recognised tool. The + known-good check rejects any value that chains or redirects, and the + parser must capture the quoted value WHOLE for that check to see it.""" + assert _level('git -c core.pager="less; curl evil" log') == "high" + + +def test_predicate_is_shared_with_repo_scan_not_copied(): + """One list, not two that drift: the classifier must consult repo_scan's + predicate, so a key added there is rated here without a second edit.""" + from clawmetry.repo_scan import git_config_executes + assert git_config_executes("core.hooksPath", "/tmp/x") is True + assert git_config_executes("alias.x", "!/tmp/e.sh") is True + assert git_config_executes("alias.x", "log --oneline") is False + # protocol.ext.allow names no program; it must NOT be in the key set. + assert git_config_executes("protocol.ext.allow", "always") is False + + +def test_git_config_regexes_are_bounded_against_redos(): + """Every quantifier in the git-config patterns is bounded. + + CodeQL py/polynomial-redos flagged the first cut. This is not theoretical: + classify_tool_call runs on the Brain feed's hot path over a command string + an agent chose, so a quadratic match is a denial of service on a page-load. + """ + import time + # Pathological shapes for an unbounded `-c\s*([\w.]+)=` / value pattern. + for payload in ( + "git -c " + " " * 40000 + "a=b", + "git -c " + "a" * 40000 + "=b", + "git -c core.pager=" + "x" * 40000, + "git -c core.pager=\"" + "x" * 40000, + "git --config-env=" + "a" * 40000 + "=B", + ): + t0 = time.monotonic() + _classify("Bash", {"command": payload}) + elapsed = time.monotonic() - t0 + assert elapsed < 1.0, ( + f"classify took {elapsed:.2f}s on a {len(payload)}-char command; " + f"a quantifier is unbounded again" + )