Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
562 changes: 533 additions & 29 deletions mureo/credential_guard.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ markers = [
"unit: Unit tests",
"integration: Integration tests",
"real_save: Opt out of the credentials-write stub and save for real (tmp_path)",
"slow: Exhaustive runs kept out of the default suite (pytest -m slow)",
]

[tool.mypy]
Expand Down
180 changes: 180 additions & 0 deletions tests/credential_guard_product.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""The differential product the Bash credential guard's numbers come from.

``mureo/credential_guard.py`` quotes counts — "all N members read the
credentials file, and all N deny". This module is where those come from, so
the claim is a property that can be re-run rather than a number someone
once measured. ``tests/test_credential_guard_product.py`` runs it.

The shape is a cartesian product of the axes that have actually produced
bypasses, because every one of them was a *combination* rather than a
feature:

* how the parent directory is supplied — literal, ``$HOME``, ``"$HOME"``,
``$VAR``, ``"$VAR"``, ``${VAR}``, ``$VAR$EMPTY``, ``$1``, ``$(cmd)``,
backtick;
* how the name is broken up — continuation, quote splits, escapes,
classes, wildcards, brace forms, sequences;
* what the breaking form *contains* — an alternative carrying its own dot
is what defeated the fold that replaced a group with one placeholder;
* how deeply the form nests — the expansion budget's cliff was invisible
while every member sat at depth two or less;
* where in the name it happens.

Each member is checked two ways: run in a real bash against a throwaway
HOME holding a marker credentials file, and put through the real generated
hook command. A member that reads the marker and is not denied is a
bypass.
"""

from __future__ import annotations

import json
import os
import shutil
import subprocess
from pathlib import Path

# These tests execute the attack command in a real shell and check whether it
# reaches the file. That is a statement about POSIX path, glob and quoting
# semantics; under an emulated shell on Windows a "no" would mean "not
# reachable through this translation layer", which is not the property under
# test and is indistinguishable from the guard working.
POSIX_SHELL = os.name == "posix"

Q1, Q2, BS, TICK, NL = chr(39), chr(34), chr(92), chr(96), chr(10)
CONT = BS + NL
NAME = ".mureo"
MARKER = "MUREO-PRODUCT-MARKER-8F31A7"

# (label, prelude, text placed in front of the name)
PARENTS: list[tuple[str, str, str]] = [
("literal", "", "~/"),
("$HOME", "", "$HOME/"),
('"$HOME"', "", Q2 + "$HOME" + Q2 + "/"),
("$VAR", "D2=~/; ", "$D2"),
('"$VAR"', "D2=~/; ", Q2 + "$D2" + Q2),
("${VAR}", "D2=~/; ", "${D2}"),
("$VAR$EMPTY", "D2=~/; E2=; ", "$D2$E2"),
("$1", "set -- ~/; ", "$1"),
("$(cmd)", "", "$(printf " + Q1 + "%s" + Q1 + " ~/)"),
("backtick", "", TICK + "printf " + Q1 + "%s" + Q1 + " ~/" + TICK),
]

# (label, the other alternative of a brace group, extra members of a class)
FILLERS: list[tuple[str, str, str]] = [
("plain", "z", "z"),
("dotted", "x.y", "x"),
("dotted twice", "a.b.c", "a"),
("backup-looking", "bashrc.bak", "b"),
("nested group", "{a,b}.c", "c"),
("metachar", "x*", "x"),
("leading dot", ".hidden", "h"),
]

NESTINGS = [0, 1, 2, 3, 5, 8, 9, 11, 14, 20]


def breaks(alt: str, cls: str) -> list[tuple[str, object]]:
"""Ways to spell ``NAME`` so the six characters are not consecutive."""
return [
("none", lambda n, i: n),
("continuation", lambda n, i: n[:i] + CONT + n[i:]),
("two continuations", lambda n, i: n[:i] + CONT + n[i:] + CONT),
("single-quote split", lambda n, i: n[:i] + Q1 + Q1 + n[i:]),
("single-quoted char", lambda n, i: n[:i] + Q1 + n[i] + Q1 + n[i + 1 :]),
("double-quote split", lambda n, i: n[:i] + Q2 + Q2 + n[i:]),
("double-quoted char", lambda n, i: n[:i] + Q2 + n[i] + Q2 + n[i + 1 :]),
("escaped char", lambda n, i: n[:i] + BS + n[i] + n[i + 1 :]),
("class", lambda n, i: n[:i] + "[" + n[i] + cls + "]" + n[i + 1 :]),
("wildcard", lambda n, i: n[:i] + "?" + n[i + 1 :]),
("brace here", lambda n, i: n[:i] + "{" + n[i] + "," + alt + "}" + n[i + 1 :]),
("brace tail", lambda n, i: n[:i] + "{" + n[i:] + "," + alt + "}"),
("brace whole", lambda n, i: "{" + n + "," + alt + "}"),
("sequence", lambda n, i: n[:i] + "{" + n[i] + ".." + n[i] + "}" + n[i + 1 :]),
("star", lambda n, i: n[:i] + "*" + n[i + 1 :]),
]


def nest(spelled: str, depth: int) -> str:
"""Bury a spelling inside ``depth`` further brace levels."""
for level in range(depth):
spelled = "{z" + str(level) + "," + spelled + "}"
return spelled


def members() -> list[tuple[str, str]]:
"""Every member of the product, as ``(label, command)``."""
out: list[tuple[str, str]] = []
seen: set[str] = set()
for plabel, prelude, prefix in PARENTS:
for flabel, alt, cls in FILLERS:
for blabel, spell in breaks(alt, cls):
# A filling only varies the forms that have one.
if flabel != "plain" and not (
blabel.startswith("brace") or blabel == "class"
):
continue
positions = [0] if blabel in ("none", "brace whole") else range(1, 6)
for i in positions:
try:
spelled = spell(NAME, i) # type: ignore[operator]
except IndexError:
continue
depths = (
NESTINGS
if (flabel == "plain" and plabel in ("literal", "$VAR"))
else [0]
)
for depth in depths:
body = nest(spelled, depth)
cmd = prelude + "cat " + prefix + body + "/credentials.json"
if cmd in seen:
continue
seen.add(cmd)
out.append(
(f"{plabel} | {blabel} | {flabel} | @{i} | d{depth}", cmd)
)
return out


def build_home(root: str | os.PathLike[str]) -> Path:
"""A throwaway HOME with a marker credentials file."""
home = Path(root) / "home"
shutil.rmtree(home, ignore_errors=True)
(home / ".mureo").mkdir(parents=True)
(home / ".mureo" / "credentials.json").write_text(
json.dumps({"access_token": MARKER}), encoding="utf-8"
)
(home / "project").mkdir(exist_ok=True)
return home


def reads_marker(command: str, home: str | os.PathLike[str], bash: str) -> bool:
"""Does a real shell actually print the credentials file for this?

``home`` is coerced here rather than at the call sites. It arrives as a
``Path`` from every caller, and ``subprocess`` on POSIX accepts one in
``env`` while Windows raises ``TypeError: environment can only contain
strings`` — so an annotation that disagreed with the argument was not
cosmetic, it was a failure waiting for the first platform that enforces
the contract. Coercing at the boundary means the signature and the
reality agree without every caller having to remember.

The environment is ``os.environ`` with ``HOME`` overridden, not a
hand-built pair: a minimal env drops variables a shell needs to start
at all on some platforms, which fails as "the command did not read the
file" — indistinguishable from the guard working.
"""
env = dict(os.environ, HOME=str(home), USERPROFILE=str(home))
try:
proc = subprocess.run(
[bash, "-c", command],
capture_output=True,
text=True,
env=env,
cwd=str(home),
timeout=15,
)
except subprocess.TimeoutExpired:
return False
return MARKER in proc.stdout
52 changes: 49 additions & 3 deletions tests/hook_guard_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,33 @@
portable (no bash dependency on the Windows CI job) while exercising the
exact code a shell would hand to ``python3 -c``. ``extract_python_code``
also asserts the payload is shell-safe: because the code sits inside double
quotes on the command line, any ``"``, ``$``, backtick, or backslash would
change meaning under a POSIX shell.
quotes on the command line, any ``"``, ``$``, backtick, backslash, or ``!``
(history expansion) would change meaning under a POSIX shell.

That is not the whole story, though: lifting the payload out skips the
wrapper, so a quoting mistake in the wrapper itself would go unnoticed.
``run_guard_in_shell`` closes that by handing the whole command to a real
bash. Anything whose answer depends on quoting — the guard's own, or the
command's — belongs there.
"""

from __future__ import annotations

import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any

_COMMAND_RE = re.compile(r'^python3 -c "(?P<code>[^"]*)" # \[mureo-credential-guard\]$')

_SHELL_HAZARDS = ("$", "`", "\\", "\n")
_SHELL_HAZARDS = ("$", "`", "\\", "\n", "!")

BASH = shutil.which("bash")
PYTHON3 = shutil.which("python3")


def extract_python_code(command: str) -> str:
Expand Down Expand Up @@ -66,6 +76,42 @@ def run_guard(
)


def run_guard_in_shell(
command: str,
tool_input: dict[str, Any] | None,
home: Path,
tool_name: str = "Bash",
raw_stdin: str | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run the guard command *as a shell runs it*: ``bash -c <command>``.

``run_guard`` above lifts the python payload out of the command and runs
it directly, which never exercises the ``python3 -c "..."`` wrapper. The
quoting of that wrapper is a layer of its own: it is where a stray ``"``,
``$``, backtick or ``!`` in the payload would change the program the
shell actually runs. Tests that care about a quoting question must go
through here.

``raw_stdin`` sends bytes verbatim instead of a tool-call JSON, for the
malformed-input cases.
"""
assert BASH is not None and PYTHON3 is not None, "needs bash and python3"
payload = (
raw_stdin
if raw_stdin is not None
else json.dumps({"tool_name": tool_name, "tool_input": tool_input or {}})
)
env = dict(os.environ, HOME=str(home), USERPROFILE=str(home))
return subprocess.run(
[BASH, "-c", command],
input=payload,
capture_output=True,
text=True,
env=env,
timeout=30,
)


def deny_decision(proc: subprocess.CompletedProcess[str]) -> str | None:
"""Return the ``permissionDecision`` emitted by a guard run, if any."""
if not proc.stdout.strip():
Expand Down
Loading