Operator tasks move from the Inbox into Tasks with the Waits on Operator filter. Review and commit asks carry the staged diff; a file whose old or new blob holds an armour line or a detection-pattern match, or every file when no pattern loads, ships only its path, status, mode and counts. The approval binds to the staged tree id from git write-tree, the encoded request body respects the hub limit, and the hub image ships every client module signed.py loads. Committed below the bar on the operator's word 2026-11-12 (no further per-bundle tests, one gate after the merge): the full hub suite and the W1 e2e never ran, two high MEDUSA rows (sandbox_paths.py FLOOR, permission_hook.py ask POST) wait for their acceptance. 🎯 Fix: 85 % 💥 Break: 12 % 💸 Cost: 30 % ⏱️ Time: 30 % 🛡️ Security: 50 % 🏆 Score: 100 🚦 Gate: ⛔ fix 85 % < 90, break 12 % > 5; security 50 % < 100: ask the operator before the commit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
6.3 KiB
Python
168 lines
6.3 KiB
Python
"""Args:
|
|
top: the working tree; its .claude folder and that of $CLAUDE_PROJECT_DIR join the merge.
|
|
|
|
Returns:
|
|
denied_roots: FLOOR with every sandbox.filesystem.denyRead path and every
|
|
permissions.deny Read(...) rule of the settings Claude Code merges
|
|
(managed, user, project), as absolute paths or globs (a **/ also
|
|
matching no folder), also resolved
|
|
through symlinks; a relative one lies under its scope (managed: its
|
|
folder, user: the config dir, project: the project root).
|
|
denied: whether a path is, matches or lies under one of those.
|
|
|
|
Raises:
|
|
Unreadable: when a settings file or managed-settings.d exists but cannot be read or parsed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import itertools
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
from pathlib import Path
|
|
|
|
MANAGED = Path("/etc/claude-code")
|
|
SETTINGS = ("settings.json", "settings.local.json")
|
|
SETTINGS_LIMIT = 1 << 20
|
|
GLOB = re.compile(r"[*?\[]")
|
|
FLOOR =("~/.ssh", "~/.gnupg", "~/.aws", "~/.kube", "~/.netrc", "~/.git-credentials", "~/.config/gcloud", "~/.docker/config.json", "~/.claude")
|
|
READ_RULE = re.compile(r"\s*Read(?:\((.*)\))?\s*")
|
|
|
|
|
|
class Unreadable(Exception):
|
|
pass
|
|
|
|
|
|
def project_roots(top: Path) -> list[Path]:
|
|
session = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
return [top, *([Path(session)] if session else [])]
|
|
|
|
|
|
def settings_files(top: Path) -> list[tuple[Path, Path]]:
|
|
"""Returns:
|
|
Every settings file Claude Code merges, with the folder its relative
|
|
paths start from.
|
|
"""
|
|
user = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
|
|
scoped = [(MANAGED / "managed-settings.json", MANAGED), *((file, MANAGED) for file in managed_parts())]
|
|
scoped += [(user / name, user) for name in SETTINGS]
|
|
return scoped + [(root / ".claude" / name, root) for root in project_roots(top) for name in SETTINGS]
|
|
|
|
|
|
def managed_parts() -> list[Path]:
|
|
folder = MANAGED / "managed-settings.d"
|
|
try:
|
|
names = sorted(os.listdir(folder))
|
|
except FileNotFoundError:
|
|
return []
|
|
except OSError as error:
|
|
raise Unreadable(f"settings folder {folder} unreadable: {type(error).__name__}") from error
|
|
return [folder / name for name in names if name.endswith(".json")]
|
|
|
|
|
|
def settings_text(file: Path) -> str | None:
|
|
"""Returns:
|
|
The text of a settings file, None when it does not exist.
|
|
|
|
Raises:
|
|
Unreadable: when it is no regular file of at most SETTINGS_LIMIT bytes.
|
|
OSError, ValueError: when it cannot be opened or is no UTF-8.
|
|
"""
|
|
try:
|
|
fd = os.open(file, os.O_RDONLY | os.O_NONBLOCK)
|
|
except FileNotFoundError:
|
|
return None
|
|
try:
|
|
info = os.fstat(fd)
|
|
if not stat.S_ISREG(info.st_mode) or info.st_size > SETTINGS_LIMIT:
|
|
raise Unreadable(f"settings {file} is no regular file of at most {SETTINGS_LIMIT} bytes")
|
|
with open(fd, "rb", closefd=False) as handle:
|
|
return handle.read(SETTINGS_LIMIT + 1).decode("utf-8")
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def settings_data(file: Path) -> object:
|
|
try:
|
|
text = settings_text(file)
|
|
return json.loads(text) if text and text.strip() else {}
|
|
except (OSError, ValueError) as error:
|
|
raise Unreadable(f"settings {file} unreadable: {type(error).__name__}") from error
|
|
|
|
|
|
def strings_at(data: object, keys: tuple[str, ...]) -> list[str]:
|
|
for key in keys:
|
|
data = data.get(key) if isinstance(data, dict) else None
|
|
if data is None:
|
|
return []
|
|
if not (isinstance(data, list) and all(isinstance(entry, str) for entry in data)):
|
|
raise Unreadable(f"{'.'.join(keys)} is no list of strings")
|
|
return data
|
|
|
|
|
|
def rule_paths(rules: list[str], scope: Path, folders: list[Path]) -> list[str]:
|
|
"""Returns:
|
|
The paths the permissions.deny Read rules name, read as Claude Code
|
|
reads them: //x absolute, ~/x under home, /x under scope, any other
|
|
under scope and every one of folders, a name without / at any depth;
|
|
a bare Read denies /.
|
|
"""
|
|
found = []
|
|
for entry in rules:
|
|
match = READ_RULE.fullmatch(entry)
|
|
if match:
|
|
found += rule_path(match.group(1) or "//", scope, folders)
|
|
return found
|
|
|
|
|
|
def rule_path(spec: str, scope: Path, folders: list[Path]) -> list[str]:
|
|
if spec.startswith("//"):
|
|
return ["/" + spec.lstrip("/")]
|
|
if spec == "~" or spec.startswith("~/"):
|
|
return [os.path.expanduser(spec)]
|
|
if spec.startswith("/"):
|
|
return [f"{scope}{spec}"]
|
|
name = spec.removeprefix("./")
|
|
bases = [scope, *folders]
|
|
if "/" in name.rstrip("/"):
|
|
return [f"{base}/{name}" for base in bases]
|
|
return [entry for base in bases for entry in (f"{base}/{name}", f"{base}/*/{name}")]
|
|
|
|
|
|
def denied_roots(top: Path) -> list[str]:
|
|
folders = project_roots(top)
|
|
entries = [(entry, Path.home()) for entry in FLOOR]
|
|
for file, scope in settings_files(top):
|
|
data = settings_data(file)
|
|
entries += [(entry, scope) for entry in strings_at(data, ("sandbox", "filesystem", "denyRead"))]
|
|
entries += [(entry, scope) for entry in rule_paths(strings_at(data, ("permissions", "deny")), scope, folders)]
|
|
return [root for entry, scope in entries for root in resolved(entry, scope)]
|
|
|
|
|
|
def resolved(entry: str, base: Path) -> set[str]:
|
|
found = set()
|
|
for path in zero_folders("/" + os.path.normpath(os.path.join(base, os.path.expanduser(entry))).lstrip("/")):
|
|
cut = GLOB.search(path)
|
|
head = os.path.dirname(path[: cut.start()]) if cut else path
|
|
found |= {path, os.path.realpath(head) + path[len(head) :]}
|
|
return found
|
|
|
|
|
|
def zero_folders(path: str) -> set[str]:
|
|
"""Returns:
|
|
path and every spelling of it with some of its **/ dropped, as a
|
|
gitignore ** also matches no folder at all.
|
|
"""
|
|
parts = path.split("**/")
|
|
return {"".join(part + cut for part, cut in zip(parts, cuts, strict=False)) + parts[-1] for cuts in itertools.product(("**/", ""), repeat=len(parts) - 1)}
|
|
|
|
|
|
def denied(path: str, roots: list[str]) -> bool:
|
|
if any(path == root or path.startswith(f"{root.rstrip('/')}/") for root in roots if not GLOB.search(root)):
|
|
return True
|
|
chain = [path, *map(str, Path(path).parents)]
|
|
return any(fnmatch.fnmatchcase(link, root) for root in roots if GLOB.search(root) for link in chain)
|