diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7b821..34a4378 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ All notable changes to Code Context Control (C3) are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.157.2] - 2026-10-16 + +### Fixed — the shell scans stop refusing commands that name no protected file + +Replaying 80 days of logged shell refusals through the current scanners, +1,074 of 1,200 commands were still refused, and almost all of them named no +protected file. The Bash hook treated the path-spelling checks (``, +``, ``, `<8.3-alias>`) as final, so `origin/main:$f`, +`node:assert/strict`, `taskkill //F`, a `//` comment in a heredoc and `...` +in an echo were hard denies. c3_shell refused any token matching a deny glob +whether or not a file existed, so `\.env` in a grep regex and `e.key` in JS +were refused. After this change 134 are refused, and those are real reads of +`.env` files and key files plus one docker `//tmp` path. + +- `cli/hook_access_guard.py`: a spelling refusal is re-judged as the file the + spelling could reach (the stream's file, the dot-collapsed path) and stands + only when that file exists and a real rule denies it. A token shaped like a + real share (`\\host\share`, `//host/share`) is still denied. +- `cli/tools/shell.py`: the read scan refuses only a token naming an existing + denied file, judged from the cwd and every directory the command `cd`s to, + with unquoted globs expanded the way bash expands them. +- Both scans also judge quoted strings whole, read the way the shell reads + quotes, plus string literals inside them. Before, a quoted path with + spaces (`"U:/1. Projects/x/.env"`) split into fragments: the hook let it + through, and c3_shell caught it only because a fragment matched the glob. +- `cd /u/repo` now resolves to `u:/repo` in both scans and the write-target + extractor; it used to resolve to `:/u/repo`, a directory that + does not exist, so reads after it were judged in the wrong place. +- `git show :` inside a loop body (`do git show …`) gets the + revspec rewrite. +- Known limit: `|`, `;` and `&` inside quotes still split tokens, so a + quoted regex fragment that names a protected directory is still refused. + +### Changed — `.env` templates are outside the `**/.env*` builtin + +`.env.example`, `.env.local.example`, `.env.sample`, `.env.template` and +`.env.dist` list variable names and are normally committed, but the builtin +denied reading and editing them on every surface (21 refusals in the same +80 days). A leaf name ending in `.example`, `.sample`, `.template` or `.dist` +is no longer covered, in every builtin mode; `.env.example.local`, +`.env-example` and `.envrc` stay denied. User rules are unchanged, so a +project that wants templates protected can add a deny rule for them. + ## [2.157.1] - 2026-10-14 ### Fixed — the phone's session search and overview no longer leak hidden transcript text diff --git a/cli/_shell_writes.py b/cli/_shell_writes.py index ba92798..bb084c7 100644 --- a/cli/_shell_writes.py +++ b/cli/_shell_writes.py @@ -33,7 +33,73 @@ _FILE_CMDS = {"cp", "mv", "touch", "rm", "install"} _STREAMS = {"/dev/null", "/dev/stdout", "/dev/stderr", "nul", "&1", "&2", "-"} _SYNTAX = set("$`(){}|<>*?") +_MSYS_DRIVE_RE = re.compile(r"^/([a-zA-Z])(?=/|$)") +_QUOTED_RES = (re.compile(r'"([^"\n]{1,400})"'), re.compile(r"'([^'\n]{1,400})'")) MAX_TARGETS = 20 +MAX_QUOTED = 64 + + +def msys_drive(path: str) -> str: + """`/c/x` -> `c:/x` on Windows, where Git Bash spells drives that way. + + Without it `cd /u/repo` resolves to `:/u/repo`, a directory + that does not exist, and every relative path after it is judged there. + """ + if os.name != "nt" or not _MSYS_DRIVE_RE.match(path): + return path + return f"{path[1]}:/{path[3:]}" + + +def _shell_quoted(cmd: str) -> list[str]: + """Quoted strings as the shell reads them: `'` is literal inside `"`.""" + out: list[str] = [] + buf: list[str] = [] + quote, esc = None, False + for ch in cmd: + if quote is None: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch in "'\"": + quote, buf = ch, [] + elif quote == '"' and esc: + buf.append(ch) + esc = False + elif quote == '"' and ch == "\\": + buf.append(ch) + esc = True + elif ch == quote: + out.append("".join(buf)) + quote = None + else: + buf.append(ch) + return out + + +def quoted_paths(cmd: str) -> list[str]: + """Path-shaped quoted substrings of a command, for the access scans. + + A quoted path with spaces (`"U:/1. Projects/x/.env"`) splits into + fragments under a whitespace tokenizer, and quoted code + (`node -e "fs.readFileSync('x/.env')"`, a heredoc body) hides its + path inside a larger token. So this returns the shell's own quoted + strings plus the string literals inside them and anywhere in the text. + Over-collecting is expected: callers judge each candidate against files + that exist. + """ + out: list[str] = [] + cmd = cmd or "" + tops = _shell_quoted(cmd) + for s in tops + [m.group(1) for t in tops + [cmd] + for rx in _QUOTED_RES for m in rx.finditer(t)]: + s = s.strip() + if (s and "\n" not in s and len(s) <= 400 and s not in out + and ("/" in s or "\\" in s or s.startswith("."))): + out.append(s) + if len(out) >= MAX_QUOTED: + break + return out def _clean(tok: str) -> str: @@ -79,7 +145,7 @@ def _cd_target(segment: str, cwd: str) -> str | None: m = _CD_RE.match(segment) if not m: return None - target = m.group("dir").strip("\"'") + target = msys_drive(os.path.expanduser(m.group("dir").strip("\"'"))) try: return target if os.path.isabs(target) else os.path.normpath(os.path.join(cwd, target)) except (OSError, ValueError): @@ -99,8 +165,7 @@ def shell_write_targets(cmd: str, cwd: str) -> list[str]: try: for segment in _SEGMENT_SPLIT.split(cmd or ""): for tok in _segment_targets(segment): - if re.match(r"^/[a-z]/", tok): # MSYS /c/foo -> C:/foo - tok = f"{tok[1]}:{tok[2:]}" + tok = msys_drive(os.path.expanduser(tok)) try: p = Path(tok) if os.path.isabs(tok) else Path(here) / tok key = os.path.normcase(os.path.normpath(str(p))) diff --git a/cli/c3.py b/cli/c3.py index 83163b0..e17088b 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -91,7 +91,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.157.1" +__version__ = "2.157.2" # The PreToolUse matcher for native subagent calls (installer and hub migration). AGENT_MATCHER = "Agent|Task" diff --git a/cli/hook_access_guard.py b/cli/hook_access_guard.py index 27bf330..39dd9d2 100644 --- a/cli/hook_access_guard.py +++ b/cli/hook_access_guard.py @@ -34,13 +34,19 @@ class native tools are denied instead of sailing through. # The same write-target extractor the edit ledger uses after the fact. Its # absence degrades the shell branch to the read-only scan, never to a crash. try: - from _shell_writes import shell_write_targets + from _shell_writes import msys_drive, quoted_paths, shell_write_targets except Exception: # pragma: no cover — package-style import (dispatcher, tests) try: - from cli._shell_writes import shell_write_targets + from cli._shell_writes import msys_drive, quoted_paths, shell_write_targets except Exception: shell_write_targets = None + def msys_drive(path: str) -> str: + return path + + def quoted_paths(cmd: str) -> list: + return [] + _WRITE_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} _READ_TOOLS = {"Read", "SearchText"} _SEARCH_TOOLS = {"Grep", "Glob", "FindFiles"} @@ -112,7 +118,20 @@ class native tools are denied instead of sailing through. # `:` — how git names a blob: `git show HEAD:setup.py`, # `git cat-file -p origin/main:src/a.py`. _GIT_REVSPEC_RE = re.compile(r"^(?P[^:\s]+):(?P[^:\s]+)$") -_GIT_CMD_RE = re.compile(r"^\s*(?:[\w.\-]*[/\\])?git(?:\.exe)?\b", re.IGNORECASE) +# Loop and conditional bodies (`do git show …`) and env prefixes +# (`GIT_PAGER= git show …`) are still git commands. +_GIT_CMD_RE = re.compile( + r"^\s*(?:(?:do|then|else|time|exec|command)\s+|[!({]\s*|[A-Za-z_]\w*=\S*\s+)*" + r"(?:[\w.\-]*[/\\])?git(?:\.exe)?\b", + re.IGNORECASE, +) + +# Refusals that judge how a path is SPELLED rather than which file it names. +# A shell token that trips one is usually not a path at all, so these are +# re-judged as the file they could reach (_spelling_verdict). +_SPELLING_RULES = frozenset({"", "", "", "<8.3-alias>"}) +# `\\host\share` or `//host/share`: one separator between host and share. +_UNC_SHARE_RE = re.compile(r"^(?:\\\\|//)[^\\/\s]+[\\/][^\\/\s]+") # Where one command ends and the next begins. Anchoring "is this git?" to the # start of the whole string was wrong in the most ordinary way possible: @@ -190,6 +209,56 @@ def _looks_like_a_path(tok: str) -> bool: return not (_SYNTAX_CHARS & set(tok)) +def _exists(path: str) -> bool: + try: + return Path(path).exists() + except (OSError, ValueError): + return False + + +def _stream_base(path: str) -> str: + """`C:/x/f.txt:name:$DATA` -> `C:/x/f.txt`: the file a stream belongs to.""" + head = path[:2] if re.match(r"^[A-Za-z]:", path) else "" + return head + path[len(head):].split(":", 1)[0] + + +def _dots_collapsed(path: str) -> str: + """The path Windows opens for a spelling with dot-only components. + + Win32 strips trailing dots and spaces, so `a/.../b` and `a/x/../b` both + land somewhere real; model that before asking which file it is. + """ + parts = re.split(r"[\\/]", path) + kept = [p if p in ("", ".", "..") or p.rstrip(". ") else "." for p in parts] + return os.path.normpath("/".join(kept)) + + +def _spelling_verdict(denial, tok: str, probe: str, base: str): + """The deny a path-spelling refusal stands for in a shell command, or None. + + The spelling rules keep a protected file from being reached under an odd + name. Tokens that trip them in shell text are mostly not paths + (`origin/main:x`, `// comment`, `taskkill //F`, `...`), so the token is + judged as the file it could name, with the usual existence gate, and + refused only when that file is denied. A real UNC share stays denied: a + network path cannot be judged locally. + """ + if denial.rule == "": + unc = any(_UNC_SHARE_RE.match(t) for t in (tok, tok.replace("\\\\", "\\"))) + return denial if unc else None + if denial.rule == "": + target = _stream_base(probe) + elif denial.rule == "": + target = _dots_collapsed(probe) + else: + return None # <8.3-alias>: a short name that does not exist names nothing + real = ag.check(target, "read", base) + if (real and real.kind == "deny" and not real.rule.startswith("<") + and _exists(target)): + return real + return None + + def _deny(reason: str) -> dict: return { "hookSpecificOutput": { @@ -290,7 +359,8 @@ def _cd_target(segment: str, cwd: str) -> str | None: if not target or target.startswith("-"): return None try: - return os.path.abspath(os.path.join(cwd, os.path.expanduser(target))) + return os.path.abspath( + os.path.join(cwd, msys_drive(os.path.expanduser(target)))) except (OSError, ValueError): return None @@ -300,12 +370,13 @@ def _scan_shell(cmd: str, base: str): Confident = the token resolves to an EXISTING path that a ``deny`` rule covers (existence-gating keeps regex/pattern arguments like '\\.env' - from false-denying), or the evaluator flags the spelling itself. + from false-denying). A spelling refusal (````, ````, …) counts + only when the file the spelling could reach is itself denied + (``_spelling_verdict``). Quoted substrings are judged as well, so a + quoted path with spaces is not lost to whitespace splitting. Tokens that are shell syntax rather than path arguments are skipped - outright, as are URLs and IPv6 literals: all of them trip the ADS spelling - check, which is exempt from existence-gating, so a token naming nothing on - disk would otherwise hard-deny (#50). + outright, as are URLs and IPv6 literals. Scanning is per **command segment**, not per whole string. A segment is what sits between `&&`, `||`, `;`, `|` or a newline, and each one answers "am I a @@ -315,6 +386,7 @@ def _scan_shell(cmd: str, base: str): """ budget = _MAX_TOKENS cwd = base + visited = [base] for segment in _SEGMENT_SPLIT.split(cmd or ""): if budget <= 0: break @@ -347,8 +419,7 @@ def _scan_shell(cmd: str, base: str): from_revspec = revspec_path is not None if from_revspec: tok = revspec_path - if re.match(r"^/[a-z]/", tok): # MSYS /c/foo → C:/foo - tok = f"{tok[1]}:{tok[2:]}" + tok = msys_drive(tok) # Resolve against the cwd the command will actually run in, not the # session root. `cd /elsewhere && cat .env` used to compute the # denial correctly and then throw it away, because the existence @@ -363,10 +434,12 @@ def _scan_shell(cmd: str, base: str): probe = tok denial = ag.check(probe, "read", base) if denial and denial.kind == "deny": - try: - exists = Path(probe).exists() - except OSError: - exists = False + if denial.rule in _SPELLING_RULES: + denial = _spelling_verdict(denial, tok, probe, base) + if denial is not None: + return denial, tok + continue + exists = _exists(probe) # A revspec names a path in HISTORY, so the working tree is the # wrong place to ask whether it is real. `git show HEAD~5:.env` # reads a denied file whether or not that file exists today, and @@ -378,6 +451,34 @@ def _scan_shell(cmd: str, base: str): moved = _cd_target(segment, cwd) if moved is not None: cwd = moved + visited.append(cwd) + return _scan_quoted(cmd, visited, base) + + +def _scan_quoted(cmd: str, dirs: list, base: str): + """(denial, token) for a quoted path naming an existing denied file. + + Quotes can span the segment separators, so this runs over the whole + command and resolves a relative path against every directory the scan + visited. A quoted `//host/path` is far more often a protocol-relative URL + in code than a share, so `` is not judged here; the token scan still + judges it. + """ + for raw in quoted_paths(cmd): + if _is_network_token(raw): + continue + tok = msys_drive(os.path.expanduser(raw)) + for here in ([base] if os.path.isabs(tok) else dirs): + probe = tok if os.path.isabs(tok) else os.path.join(here, tok) + denial = ag.check(probe, "read", base) + if not denial or denial.kind != "deny" or denial.rule == "": + continue + if denial.rule in _SPELLING_RULES: + denial = _spelling_verdict(denial, tok, probe, base) + elif not (_exists(probe) or denial.rule.startswith("<")): + denial = None + if denial is not None: + return denial, tok return None, "" diff --git a/cli/tools/shell.py b/cli/tools/shell.py index b07809b..47f3ce9 100644 --- a/cli/tools/shell.py +++ b/cli/tools/shell.py @@ -19,6 +19,7 @@ from __future__ import annotations import asyncio +import glob import os import re import shutil @@ -27,7 +28,7 @@ import time from pathlib import Path -from cli._shell_writes import shell_write_targets +from cli._shell_writes import msys_drive, quoted_paths, shell_write_targets from cli.tools import _grants from cli.tools._helpers import finalize_with_tokens from cli.tools.shell_nudge import bypass_hint @@ -737,6 +738,15 @@ def render_shell_response(cmd: str, result: dict, svc, *, _URL_SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://") _DRIVE_PREFIX = re.compile(r"^[a-zA-Z]:[/\\]") _MAX_SCAN_TOKENS = 64 +_CD_SEGMENT = re.compile( + r"""(?:^|&&|\|\||[;\n])\s*cd\s+(?:/d\s+)?("[^"]+"|'[^']+'|[^\s;&|]+)""", + re.IGNORECASE) +_MAX_SCAN_DIRS = 16 +_GLOB_CHARS = frozenset("*?[") +_MAX_GLOB_HITS = 50 +# Refusals that stand even for a token naming no existing file: the evaluator +# could not judge the command at all. +_FAIL_CLOSED_RULES = ("", "") def _scan_candidates(command: str, work_cwd: str) -> list[str]: @@ -779,28 +789,79 @@ def _scan_candidates(command: str, work_cwd: str) -> list[str]: return out -def _advisory_guard_scan(command: str, work_cwd: str, project_path: str): - """(denial, token) when a path-shaped token hits a deny rule, else (None, ''). +def _scan_dirs(command: str, work_cwd: str) -> list[str]: + """``work_cwd`` plus every directory a ``cd`` in the command may move to. + + A union rather than a replay: the scan only needs to know where a + relative token could land, and over-including a directory can only add + refusals. + """ + dirs = [work_cwd] + for m in _CD_SEGMENT.finditer(command or ""): + target = msys_drive(os.path.expanduser(m.group(1).strip("\"'"))) + for here in list(dirs): + if len(dirs) >= _MAX_SCAN_DIRS: + return dirs + try: + dirs.append(os.path.normpath(os.path.join(here, target))) + except (OSError, ValueError): + continue + return dirs + + +def _existing_targets(tok: str, dirs: list[str], command: str = "") -> list[str]: + """Files ``tok`` names from any of ``dirs``, globs expanded as bash would. - ADVISORY: only deny-kind rule hits (and the corrupt-config deny-all) - refuse; canonicalization artifacts on tokens that may not be paths at - all (UNC-ish, 8.3, unresolvable) are skipped so non-path tokens cannot - veto commands. Evaluator errors fail closed (synthetic denial), matching - the hook layer's posture. + Bash expands a pattern only outside quotes, and reads ``**`` as ``*`` + without globstar, so a pattern is expanded only when it also appears + unquoted in ``command``, and matched that way. """ - for tok in _scan_candidates(command, work_cwd): - target = tok if os.path.isabs(tok) else os.path.join(work_cwd, tok) + expand = bool(_GLOB_CHARS & set(tok)) and bool( + re.search(r"(?:^|[^'\"])" + re.escape(tok), command)) + tok = os.path.expanduser(tok) + found: list[str] = [] + for here in ([""] if os.path.isabs(tok) else dirs): + path = os.path.join(here, tok) if here else tok try: - denial = access_guard.check(target, "read", project_path) - except Exception as exc: # fail closed — mirror hook _FAIL_CLOSED - return access_guard.Denial( - "", "deny", "builtin", - f"evaluator error: {type(exc).__name__}"), tok - if denial is None or denial.kind != "deny": + if expand: + found.extend(glob.glob(path.replace("**", "*"))[:_MAX_GLOB_HITS]) + elif os.path.lexists(path): + found.append(path) + except (OSError, ValueError): continue - if denial.rule.startswith("<") and denial.rule != "": - continue # canonicalization artifact on a maybe-not-a-path token - return denial, tok + return found + + +def _advisory_guard_scan(command: str, work_cwd: str, project_path: str): + """(denial, token) when a token names an existing denied file, else (None, ''). + + ADVISORY: a deny rule refuses only for a file that exists from the + command's cwd or a directory it ``cd``s to, with globs expanded. Text that + merely matches a glob (``\\.env`` in a regex, ``event.key`` in code) names + nothing to read. Canonicalization artifacts on tokens that may not be + paths at all (UNC-ish, 8.3, unresolvable) are skipped. A corrupt config + and evaluator errors refuse regardless, matching the hook layer. + """ + dirs = _scan_dirs(command, work_cwd) + quoted = [msys_drive(q) for q in quoted_paths(command) + if not _URL_SCHEME.match(q)] + candidates = [(t, command) for t in _scan_candidates(command, work_cwd)] + for tok, context in candidates + [(q, "") for q in quoted]: + hits = _existing_targets(tok, dirs, context) + judged = hits or [tok if os.path.isabs(tok) else os.path.join(work_cwd, tok)] + for target in judged: + try: + denial = access_guard.check(target, "read", project_path) + except Exception as exc: # fail closed — mirror hook _FAIL_CLOSED + return access_guard.Denial( + "", "deny", "builtin", + f"evaluator error: {type(exc).__name__}"), tok + if denial is None or denial.kind != "deny": + continue + if denial.rule in _FAIL_CLOSED_RULES: + return denial, tok + if hits and not denial.rule.startswith("<"): + return denial, tok return None, "" diff --git a/docs/access-guard.md b/docs/access-guard.md index 1fbcd0f..ca22a71 100644 --- a/docs/access-guard.md +++ b/docs/access-guard.md @@ -64,7 +64,10 @@ section `access`: ### Builtins (hardcoded, always-on, fail-closed) - deny (read+write): `**/.env*`, `**/.c3/edit_blobs/**` (c3_edit's saved - pre/post file images), `**/.c3/secrets.enc`, `**/.c3/cred_state.json` + pre/post file images), `**/.c3/secrets.enc`, `**/.c3/cred_state.json`. + `**/.env*` leaves out templates whose name ends in `.example`, `.sample`, + `.template` or `.dist` (`.env.example`, `.env.local.example`): they list + variable names and are normally committed. - write-deny (agent surfaces): `**/.c3/**`, `~/.c3/**`, `**/.claude/settings*.json`, the C3 install directory (`Path(cli.__file__).parent`), `**/.git/**` (reads stay open) diff --git a/pyproject.toml b/pyproject.toml index 9834009..8df9fee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.157.1" +version = "2.157.2" description = "Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub." readme = "README.md" requires-python = ">=3.10" diff --git a/services/access_guard.py b/services/access_guard.py index 9792683..a3f3b75 100644 --- a/services/access_guard.py +++ b/services/access_guard.py @@ -17,7 +17,7 @@ import json import os import re -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from services.atomic_json import write_json_atomic @@ -92,6 +92,10 @@ # Tier 1 — a human may switch these off, but only through the two-key opt-out # below: a config entry AND a keyring attestation. Default is all enforced. BUILTIN_DENY = ("**/.env*", "**/.c3/edit_blobs/**") +# Committed templates that list variable names, not values. Outside +# `**/.env*` so an agent can read and update them. +_ENV_TEMPLATE_RE = re.compile( + r"^\.env(?:\.[^/.]+)*\.(?:example|sample|template|dist)$") # .grok/hooks/*.json is Grok Build's hook REGISTRATION (the command lines it # runs), the same role .claude/settings*.json plays for Claude Code, and Grok's # folder trust is granted per folder, not per hook file. @@ -179,8 +183,12 @@ class Rule: # builtins, where confirm must cover reads or it silently becomes # allow-read. Ignored for every other kind. confirm_ops: str = "write" + # Leaf names the rule does not cover even when the glob matches them. + _except: re.Pattern | None = None def matches(self, canon: str, rel: str, name: str) -> bool: + if self._except is not None and self._except.match(name): + return False if self._basename: return bool(self._re.match(name)) return bool(self._re.match(rel)) or bool(self._re.match(canon)) @@ -344,6 +352,10 @@ def _norm_builtin(glob) -> str: confirm_ops="write"), } del _g +_BUILTIN_VARIANTS["**/.env*"] = { + mode: replace(rule, _except=_ENV_TEMPLATE_RE) + for mode, rule in _BUILTIN_VARIANTS["**/.env*"].items() +} def _builtin_rules_effective(modes: dict | None = None, diff --git a/tests/test_access_guard.py b/tests/test_access_guard.py index 4ec5a1c..21e0cdd 100644 --- a/tests/test_access_guard.py +++ b/tests/test_access_guard.py @@ -131,6 +131,27 @@ def test_env_files_denied_read_and_write(self): self.assertEqual(d.scope, "builtin") self.assertIsNotNone(self._check("sub/dir/.env.local")) + def test_env_templates_are_readable_and_writable(self): + for rel in (".env.example", "web/.env.local.example", ".ENV.Sample", + ".env.template", ".env.dist"): + for op in ("read", "write"): + with self.subTest(rel=rel, op=op): + self.assertIsNone(self._check(rel, op)) + + def test_env_files_that_only_look_like_templates_stay_denied(self): + for rel in (".env.example.local", ".env.examples", ".env-example", + ".env.prod", ".envrc"): + with self.subTest(rel=rel): + self.assertIsNotNone(self._check(rel)) + + def test_env_template_exemption_holds_in_every_builtin_mode(self): + for mode in ("deny", "confirm"): + rule = ag._BUILTIN_VARIANTS["**/.env*"][mode] + with self.subTest(mode=mode): + self.assertFalse(rule.matches("/p/.env.example", ".env.example", + ".env.example")) + self.assertTrue(rule.matches("/p/.env", ".env", ".env")) + def test_vault_sidecars_denied(self): self.assertIsNotNone(self._check(".c3/secrets.enc")) self.assertIsNotNone(self._check(".c3/cred_state.json")) diff --git a/tests/test_access_guard_shell_project.py b/tests/test_access_guard_shell_project.py index 475a787..9156f10 100644 --- a/tests/test_access_guard_shell_project.py +++ b/tests/test_access_guard_shell_project.py @@ -134,7 +134,9 @@ def test_read_only_cwd_still_runs_and_logs_cwd(self): class TestShellTokenScan(GuardBase): def setUp(self): super().setUp() - self._write_access({"deny": ["secrets/**"]}) + self._write_access({"deny": ["secrets/**", "*.key"]}) + (self.proj / "secrets").mkdir() + (self.proj / "secrets" / "key.txt").write_text("k", encoding="utf-8") self.svc, self.activity = _fake_svc(self.proj) def _shell(self, cmd): @@ -172,6 +174,35 @@ def test_scan_denial_logged_with_cwd(self): self.assertEqual(denied[0]["surface"], "token_scan") self.assertTrue(denied[0]["cwd"]) + def test_text_matching_a_deny_glob_but_naming_no_file_runs(self): + for cmd in ( + "git ls-files | grep -iE 'token|\\.key$'", + "node -e \"document.onkeydown = (e) => e.key\"", + "grep -n this.key js/app.js", + "c3 access add 'secrets/**'", + ): + with self.subTest(cmd=cmd): + self.assertIn("[c3_shell:OK]", self._shell(cmd)) + + def test_an_unquoted_glob_reaching_a_denied_file_is_refused(self): + self.assertIn("[c3-access:denied]", self._shell("cat secrets/*.txt")) + + def test_a_quoted_glob_is_not_expanded(self): + self.assertIn("[c3_shell:OK]", self._shell("echo 'secrets/*.txt'")) + + def test_a_quoted_path_with_spaces_is_judged_whole(self): + spaced = self.proj / "secrets" / "a dir (1)" + spaced.mkdir() + (spaced / "f.txt").write_text("x", encoding="utf-8") + p = str(spaced / "f.txt").replace("\\", "/") + for cmd in (f'cat "{p}"', f"python -c \"Path(r'{p}').read_text()\""): + with self.subTest(cmd=cmd): + self.assertIn("[c3-access:denied]", self._shell(cmd)) + + def test_a_relative_read_after_cd_is_refused(self): + self.assertIn("[c3-access:denied]", + self._shell("cd secrets && cat key.txt")) + # ── c3_shell: post-credential-expansion scan ─────────────────────────────── @@ -182,6 +213,8 @@ class TestShellPostExpansionScan(GuardBase): def setUp(self): super().setUp() self._write_access({"deny": ["secrets/**"]}) + (self.proj / "secrets").mkdir() + (self.proj / "secrets" / "key.txt").write_text("k", encoding="utf-8") self.svc, _ = _fake_svc(self.proj) def _shell_with_creds(self, cmd, expanded, used): diff --git a/tests/test_hook_access_guard.py b/tests/test_hook_access_guard.py index e4d501d..ac830e2 100644 --- a/tests/test_hook_access_guard.py +++ b/tests/test_hook_access_guard.py @@ -197,12 +197,20 @@ def test_urls_and_ipv6_do_not_deny(self): self.assertIsNone(denial, f"{cmd!r} denied on token {tok!r}") @unittest.skipUnless(WIN, "NTFS ADS semantics") - def test_real_ads_spelling_still_denies(self): - for tok in ("./notes.txt:$DATA", "./a/b.txt:hidden"): + def test_a_stream_of_a_protected_file_is_denied_by_its_rule(self): + for tok in ("./secrets/key.txt:$DATA", "./secrets/key.txt:hidden"): with self.subTest(tok=tok): denial, _ = hag._scan_shell(f"type {tok}", str(self.proj)) self.assertIsNotNone(denial) - self.assertEqual(denial.rule, "") + self.assertEqual(denial.rule, "secrets/**") + + @unittest.skipUnless(WIN, "NTFS ADS semantics") + def test_a_stream_of_an_unprotected_or_missing_file_is_allowed(self): + (self.proj / "notes.txt").write_text("n", encoding="utf-8") + for tok in ("./notes.txt:$DATA", "./a/b.txt:hidden"): + with self.subTest(tok=tok): + denial, _ = hag._scan_shell(f"type {tok}", str(self.proj)) + self.assertIsNone(denial) def test_denied_path_still_caught_alongside_a_url(self): denial, tok = hag._scan_shell( @@ -281,7 +289,7 @@ def test_a_real_ads_spelling_is_still_caught(self): denial, _ = hag._scan_shell( "type ./secrets/key.txt:hidden", str(self.proj)) self.assertIsNotNone(denial) - self.assertEqual(denial.rule, "") + self.assertEqual(denial.rule, "secrets/**") def test_the_scheme_search_does_not_swallow_a_stream_spelling(self): """The cost of unanchoring, priced. @@ -318,12 +326,13 @@ def test_the_doubled_colon_default_stream_form_still_denies(self): This is the control that stops the node-id fix from being a hole. An earlier draft skipped every `::` token and would have opened it. """ - # Deliberately a path NO deny glob covers, so `` is the only rule - # that can catch it. Pointing this at `secrets/**` would have "passed" - # on the glob and proven nothing about the spelling check. - denial, _ = hag._scan_shell("type ./notes.txt::$DATA", str(self.proj)) + # The evaluator answers `` for the whole token before any glob + # runs, so `secrets/**` can only come back through the spelling + # verdict, which a token skipped as a node id never reaches. + denial, _ = hag._scan_shell( + "type ./secrets/key.txt::$DATA", str(self.proj)) self.assertIsNotNone(denial) - self.assertEqual(denial.rule, "") + self.assertEqual(denial.rule, "secrets/**") def test_a_git_revspec_is_judged_on_its_path_half(self): """`git show :` — the second idiom, and a tightening. @@ -468,6 +477,78 @@ def test_a_bare_filename_is_still_out_of_scope(self): self.assertIsNotNone(denial) self.assertNotIn("key.txt", tok) + +class TestShellScanSpellingVerdicts(HookGuardBase): + """A path-spelling refusal counts only when the file it could reach is denied.""" + + def _allowed(self, cmds): + for cmd in cmds: + with self.subTest(cmd=cmd): + denial, tok = hag._scan_shell(cmd, str(self.proj)) + self.assertIsNone(denial, f"{cmd!r} denied on {tok!r}") + + def test_double_slash_switches_and_comments_are_not_shares(self): + self._allowed(( + "taskkill //F //PID 1234", + "cmd //c dir", + "grep -n '// ----' static/app.js", + "printf 'a\\\\r\\\\nb'", + )) + + def test_a_real_share_is_still_denied(self): + cmds = ["cat //server/share/f.txt"] + if WIN: # backslashes name a share only on Windows + cmds.append(r"type \\server\share\f.txt") + for cmd in cmds: + with self.subTest(cmd=cmd): + denial, _ = hag._scan_shell(cmd, str(self.proj)) + self.assertIsNotNone(denial) + self.assertEqual(denial.rule, "") + + @unittest.skipUnless(WIN, "NTFS spelling rules are Windows-only") + def test_colons_dots_and_tildes_in_non_paths_are_allowed(self): + self._allowed(( + "for f in a.py b.py; do git cat-file -e origin/main:$f; done", + "node --import node:assert/strict t.mjs", + 'git commit -m "merge origin/main: keep ours"', + "echo waiting...; echo ....", + "git log --oneline origin/main~2", + )) + + def test_a_revspec_inside_a_loop_body_is_judged_on_its_path(self): + denial, tok = hag._scan_shell( + "for r in HEAD; do git show $r:secrets/key.txt; git show HEAD:secrets/key.txt; done", + str(self.proj)) + self.assertIsNotNone(denial) + self.assertEqual(tok, "secrets/key.txt") + + @unittest.skipUnless(WIN, "Win32 strips dot-only path components") + def test_a_dot_only_component_does_not_hide_a_denied_file(self): + denial, _ = hag._scan_shell( + "type ./missing/../secrets/key.txt ./.../secrets/key.txt", str(self.proj)) + self.assertIsNotNone(denial) + + @unittest.skipUnless(WIN, "MSYS drive spellings are Windows-only") + def test_an_msys_cd_target_is_the_real_directory(self): + p = str(self.proj.parent).replace("\\", "/") + msys = "/" + p[0].lower() + p[2:] + denial, _ = hag._scan_shell( + f'cd "{msys}" && cat {self.proj.name}/secrets/key.txt', str(self.proj)) + self.assertIsNotNone(denial) + self.assertEqual(denial.rule, "secrets/**") + + def test_a_quoted_path_with_spaces_is_judged_whole(self): + spaced = self.proj / "secrets" / "a dir (1)" + spaced.mkdir() + (spaced / "f.txt").write_text("x", encoding="utf-8") + p = str(spaced / "f.txt").replace("\\", "/") + for cmd in (f'cat "{p}"', f"python -c \"Path(r'{p}').read_text()\"", + f"grep -n \"a|b\" 'x'; cat '{p}'"): + with self.subTest(cmd=cmd): + denial, _ = hag._scan_shell(cmd, str(self.proj)) + self.assertIsNotNone(denial) + self.assertEqual(denial.rule, "secrets/**") + def test_a_git_segment_does_not_license_rewrites_in_other_segments(self): """Per-segment, not per-string: `cat a:b && git status` is two commands.