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
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<ads>`,
`<unc>`, `<empty-component>`, `<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 `<cwd drive>:/u/repo`, a directory that
does not exist, so reads after it were judged in the wrong place.
- `git show <rev>:<path>` 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
Expand Down
71 changes: 68 additions & 3 deletions cli/_shell_writes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cwd drive>:/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:
Expand Down Expand Up @@ -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):
Expand All @@ -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)))
Expand Down
2 changes: 1 addition & 1 deletion cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
129 changes: 115 additions & 14 deletions cli/hook_access_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -112,7 +118,20 @@ class native tools are denied instead of sailing through.
# `<rev>:<path>` — 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<rev>[^:\s]+):(?P<path>[^:\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({"<ads>", "<unc>", "<empty-component>", "<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:
Expand Down Expand Up @@ -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>":
unc = any(_UNC_SHARE_RE.match(t) for t in (tok, tok.replace("\\\\", "\\")))
return denial if unc else None
if denial.rule == "<ads>":
target = _stream_base(probe)
elif denial.rule == "<empty-component>":
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": {
Expand Down Expand Up @@ -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

Expand All @@ -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 (``<ads>``, ``<unc>``, …) 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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 `<unc>` 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 == "<unc>":
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, ""


Expand Down
Loading
Loading