From 3a9d5ae3fcd85cd7b784394747cc97225fb2dc03 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Mon, 27 Jul 2026 13:41:26 -0400 Subject: [PATCH 1/8] =?UTF-8?q?feat(access-guard):=20T1=20=E2=80=94=20froz?= =?UTF-8?q?en=20design=20spec=20+=20core=20evaluator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/access-guard.md: the implementation contract — schema (deny/read_only, tighten-only, unknown-key hard error), deny-CREATE/deny-ENUMERATE semantics, canonicalization order, enforcement inventory, verbatim refusal strings S1-S5 (post-Cod critique: [c3-access:*] tags, {operation} wording, S4 emitted whenever rules are active — no presence oracle), coverage matrix, residual risks, test matrix. services/access_guard.py: stdlib-only evaluator — Rule/Denial/AccessDenied, POSIX glob compiler (** crosses separators, basename patterns any-depth), two-scope union loader failing closed per scope on corrupt/unknown-key config, builtins (.env*, vault sidecars deny; .c3/.claude-settings/.git write-deny; install dir write-deny on installed layouts only — dev checkout exempt), ONE canonicalize() (UNC/device pre-strip, nearest-existing-parent, Windows-gated trailing dot/space + ADS + 8.3 handling), check/enforce/ refusal/search_footer API for T2 wiring. 37 tests: schema fail-closed, scope tightening, builtins, glob semantics, platform-gated canonicalization, refusal pins, S4 footer, AccessDenied. No enforcement wired yet — behavior unchanged until T2. Claude-Session: https://claude.ai/code/session_01CZVZgYdy4m9z9J9mGT5jp6 --- docs/access-guard.md | 169 ++++++++++++++++ services/access_guard.py | 389 +++++++++++++++++++++++++++++++++++++ tests/test_access_guard.py | 293 ++++++++++++++++++++++++++++ 3 files changed, 851 insertions(+) create mode 100644 docs/access-guard.md create mode 100644 services/access_guard.py create mode 100644 tests/test_access_guard.py diff --git a/docs/access-guard.md b/docs/access-guard.md new file mode 100644 index 0000000..6974ab2 --- /dev/null +++ b/docs/access-guard.md @@ -0,0 +1,169 @@ +# Access Guard — v1 Design Spec (implementation contract) + +Status: FROZEN before implementation (board review wf_b5381a5d-56a, chair +verdict endorse_with_changes; refusal strings critiqued by Cod 2026-07-27). +Changes to this spec after implementation starts require a documented reason +in the PR description. + +Access Guard lets the user mark paths the agent must not read, or must not +write, and makes every C3 surface enforce it. It is a **cooperative +mistake/prompt-injection guard, not containment**: a non-Claude agent's raw +shell or direct file API is out of scope (see Coverage matrix). + +## 1. Schema + +`.c3/config.json` (project scope) and `~/.c3/config.json` (global scope), +section `access`: + +```json +{ + "access": { + "deny": ["**/payroll/**", "*.pem"], + "read_only": ["docs/legal/**", "migrations/**"] + } +} +``` + +- Exactly two keys. **Any unrecognized key — especially `allow` — is a hard + config error** (guarantees a future grant schema can never be silently + no-op'd by an old C3). No `mode` field: the guard always enforces. +- Globs are stored POSIX forward-slash canonical; backslashes normalized on + input. Case-insensitive matching. +- Scopes UNION; merging only ever tightens. `deny` beats `read_only`. +- v1 rules are LOCAL-ONLY: `.c3/` is gitignored, committed rules cannot + arrive via clone. (Shareable rules are v2 and require the + audit-until-human-ack model.) +- ALL rule mutations are human-only (UI / `c3 access` CLI), ledger-logged. + No agent-facing mutation surface exists. The agent's read view is + `c3_status`; its runtime interface is the refusal string. + +### Semantics + +- `deny` = no read, no write, **no create** (R1), **no enumerate** (R2). +- `read_only` = no write/create/delete/rename-target. Reads are evaluated + separately (another rule may deny them). +- R1 deny-CREATE is checked against the canonicalized, post-processed leaf: + trailing-dot/space/ADS spellings of a denied name are denied. Write + denial applies to create, delete, rename and link where a surface can see + them — both rename endpoints need write. (Shell `mv` is a documented v1 + residual.) +- R2 deny-ENUMERATE: listings, search results, maps, and prefetch never + leak existence or filenames of denied paths. Direct probes get the same + refusal whether or not the target exists. +- Rule text is NOT secret: refusals and `c3_status` show the matched glob. + The guard protects file contents and existence, not the rule registry — + a user who wants an unlabeled protection names the glob accordingly. + +### Builtins (hardcoded, always-on, fail-closed) + +- deny (read+write): `**/.env*`, `**/.c3/secrets.enc`, `**/.c3/cred_state.json` +- write-deny (agent surfaces): `**/.c3/**`, `~/.c3/**`, + `**/.claude/settings*.json`, the C3 install directory + (`Path(cli.__file__).parent`), `**/.git/**` (reads stay open) +- Removable DEFAULT rules seeded in global scope on first run (user may + delete them): `*.pem`, `id_rsa*`, `*.key` +- Corrupt/unparseable access section ⇒ that scope evaluates deny-all with a + loud warning. Builtins apply even with no config at all. +- Internal service writes (credential_store, the guard's own state, ledger, + activity log) use privileged paths that bypass tool-layer checks — the + audit writer never consults the evaluator. + +## 2. Canonicalization (ONE exported function) + +`access_guard.canonicalize(path, project_root)` — every consumer (evaluator, +hooks, unlock map, shell scanner) uses this and nothing else: + +1. Pre-strip `\\?\UNC\` → `\\`, then `\\?\` and `\\.\` (resolve() preserves + them; files open fine under them). +2. Hard-deny anything still UNC-form (config escape hatch, off by default). +3. `resolve()` with nearest-existing-parent for non-existent targets. +4. On residual non-existent components: strip trailing `.` and ` ` until + stable. +5. Reject `:` outside drive position (named ADS survives resolve()). +6. Casefold the full comparison string. +7. 8.3 predicate: any residual component matching `~digit` → expand via + GetLongPathNameW or deny. + +## 3. Enforcement inventory (service layer, never MCP wrappers) + +| Surface | Check | +|---|---| +| compressor.compress_file + read service | read verdict (delegate + indexer inherit) | +| handle_edit | write verdict (create/edit/batch; both endpoints on any move) | +| handle_shell | cwd deny + advisory token scan (cmd AND exec_cmd, post-credential-expansion, MSYS `/x/`→`X:\`) | +| each search action | per-action pre-filter before dedup/top_k/map-build; `[c3-access:limited]` footer whenever ANY rules are active for the scope (not only on actual filtering — no presence oracle); defense-in-depth in `_append_prefetch` | +| c3_validate, c3_filter(file_path=), c3_impact | read verdict | +| ArtifactStore.restore | write verdict per member path | +| scanner.iter_files | index-time exclusion (denied paths never enter TF-IDF/vector index, MAP.md, file_memory) | +| c3_project | rules = global ∪ caller ∪ containing-realm of RESOLVED path; registration required beyond list/scan/register | +| c3_delegate | inherits read guard; pins codex `--sandbox read-only` when rules exist; autonomous backends behind user opt-in | +| PreToolUse hooks | new `hook_access_guard.py` FIRST in pretool routes; `_FAIL_CLOSED` synthesized deny on exception/import failure; verdict before `_PREREQS` early-return and before `_check_c3_used`; Bash + run_shell_command matchers installed same release (advisory scan); native Grep/Glob hard-denied only on explicit path args inside denied subtrees | + +Typed `AccessDenied` exception carries (verdict, rule, scope, reason) and +must re-raise through except-Exception-continue loops (delegate). + +Denial logging: coalesced per (rule, tool, session) with a hit counter; the +enforcement hook's tail scan counts only `type=='tool_call'` lines so a +denial storm cannot evict its evidence window. + +## 4. Refusal strings (VERBATIM — implementation copies these exactly) + +Interpolations `{path}`, `{project}`, `{tool}` are length-capped (200 chars, +middle-ellipsized) before insertion; `{operation}` ∈ read|write|create|delete. +Machine tags are stable API: `[c3-access:denied]`, `[c3-access:read_only]`, +`[c3-access:limited]`. + +**S1 — MCP deny:** +> [c3-access:denied] {operation} denied for {path} by Access Guard rule +> '{glob}' ({scope} scope). This is a policy decision, not a transient error +> — do not retry or route around it. Mark the affected step blocked and +> continue with unaffected files; report the skip to the user. Rules: +> `c3 access list` or the Access tab. + +**S2 — MCP read-only:** +> [c3-access:read_only] write denied for {path} by Access Guard rule +> '{glob}' ({scope} scope). The effective policy is read-only; reads are +> evaluated separately. Do not retry the write. Mark the affected step +> blocked and continue with unaffected files; report the skip. Rules: +> `c3 access list` or the Access tab. + +**S3 — hook deny (native tool):** +> [c3-access:denied] native {tool} {operation} denied for {path} by Access +> Guard rule '{glob}' ({scope} scope). This is a policy decision, not a +> transient error — do not retry through another tool or the shell. Mark +> the affected step blocked and continue with unaffected files. Rules: +> `c3 access list`. + +**S4 — search annotation (whenever any rules are active for the scope):** +> [c3-access:limited] results are limited to paths permitted by Access +> Guard. Absence is not evidence a path does not exist — check +> `c3 access list` before concluding missing work, and report the +> limitation if required work appears to be missing. + +**S5 — c3_project proxy deny:** +> [c3-access:denied] {operation} denied for {path} through project +> '{project}' by that project's Access Guard rule '{glob}' ({scope} scope). +> The target project's effective policy governs proxied access — do not +> retry. Mark the affected step blocked and continue with unaffected work. + +## 5. Coverage matrix (ships in UI tab, c3_status, guide) + +> Enforced: C3 MCP tools (all agents using C3) · Claude Code native tools +> (hooks) · c3_shell (best-effort scan, advisory). +> NOT enforced: non-Claude agents' raw shell, direct file APIs, editors. + +## 6. Residual risks (named, documented) + +Rename/move via shell; TOCTOU between evaluation and operation; non-Claude +agents outside the MCP layer; pre-existing vector-index/auto-memory content +recorded before a rule existed (index-time exclusion is forward-only in v1). + +## 7. Test matrix (minimum) + +corrupt-config-must-deny · injected-evaluator-exception-must-deny · +sticky-unlocked-still-denied · c3_bridge + c3_project bypass · +denial-storm (30 denials don't flip `_check_c3_used`) · Windows matrix on +NOT-YET-EXISTING targets + `\\?\`, `\\?\UNC\`, plain UNC spellings · +deny-CREATE trailing dot/space/ADS · 3-file-plan zero-retry acceptance · +CI meta-test: file-touching call sites route through the guard · +lint guard banning direct `Path.resolve()` in enforcement-adjacent code. diff --git a/services/access_guard.py b/services/access_guard.py new file mode 100644 index 0000000..df7f2c2 --- /dev/null +++ b/services/access_guard.py @@ -0,0 +1,389 @@ +"""Access Guard v1 — the single path-policy evaluator for every C3 surface. + +Implements docs/access-guard.md (frozen spec). One evaluator, one +canonicalizer; consumers (tool handlers, hooks, shell scanner, unlock map) +call this module and never resolve or match paths themselves. + +Semantics: ``deny`` = no read/write/create/enumerate; ``read_only`` = no +write. Scopes (global ``~/.c3`` + project ``.c3``) UNION and only tighten. +Builtins are hardcoded and non-overridable. A corrupt or invalid access +section makes that scope evaluate deny-all (fail closed) with a loud reason. + +Stdlib-only by design: hooks import this in a subprocess on every native +tool call. +""" +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path + +# ── Rule model ────────────────────────────────────────────────────────────── + +_KIND_DENY = "deny" +_KIND_READ_ONLY = "read_only" +_VALID_KEYS = {_KIND_DENY, _KIND_READ_ONLY} + +# Builtins: product integrity only (docs/access-guard.md §1). Non-overridable. +BUILTIN_DENY = ("**/.env*", "**/.c3/secrets.enc", "**/.c3/cred_state.json") +BUILTIN_WRITE_DENY = ("**/.c3/**", "**/.claude/settings*.json", "**/.git/**") + +# Seeded into GLOBAL scope by install/CLI (user-removable); not enforced here. +DEFAULT_GLOBAL_RULES = ("*.pem", "id_rsa*", "*.key") + +# Stable machine tags — API, do not change (docs/access-guard.md §4). +TAG_DENIED = "[c3-access:denied]" +TAG_READ_ONLY = "[c3-access:read_only]" +TAG_LIMITED = "[c3-access:limited]" + +_PATH_CAP = 200 # interpolation length cap for refusal strings + + +@dataclass(frozen=True) +class Rule: + glob: str # POSIX-canonical, casefolded + kind: str # deny | read_only + scope: str # builtin | global | project + _re: re.Pattern + _basename: bool # no '/' in glob → match against the leaf name + + def matches(self, canon: str, rel: str, name: str) -> bool: + if self._basename: + return bool(self._re.match(name)) + return bool(self._re.match(rel)) or bool(self._re.match(canon)) + + +@dataclass(frozen=True) +class Denial: + rule: str # matched glob (or a synthetic reason marker) + kind: str # deny | read_only + scope: str # builtin | global | project + reason: str # short evaluator-internal reason + + +class AccessDenied(Exception): + """Raised by enforce(); carries the formatted refusal in ``message``. + + Except-Exception-continue loops (e.g. c3_delegate) must re-raise this. + """ + + def __init__(self, denial: Denial, message: str): + super().__init__(message) + self.denial = denial + self.message = message + + +# ── Glob compilation ──────────────────────────────────────────────────────── + +def _glob_to_re(pat: str) -> re.Pattern: + """POSIX glob → regex. ``**`` crosses separators; ``*``/``?`` do not.""" + i, out = 0, [] + while i < len(pat): + c = pat[i] + if c == "*": + if pat.startswith("**/", i): + out.append("(?:.*/)?") + i += 3 + continue + if pat.startswith("**", i): + out.append(".*") + i += 2 + continue + out.append("[^/]*") + i += 1 + continue + if c == "?": + out.append("[^/]") + i += 1 + continue + out.append(re.escape(c)) + i += 1 + return re.compile("^" + "".join(out) + "$") + + +def _compile(glob: str, kind: str, scope: str) -> Rule: + canon = glob.replace("\\", "/").casefold().strip() + if canon.endswith("/**"): + # `X/**` also protects X itself (gitignore-style prefix semantics). + canon_alt = canon[:-3] + rx = re.compile( + f"(?:{_glob_to_re(canon).pattern}|{_glob_to_re(canon_alt).pattern})") + else: + rx = _glob_to_re(canon) + return Rule(glob=canon, kind=kind, scope=scope, _re=rx, + _basename="/" not in canon) + + +def validate_globs(globs) -> str: + """'' when every pattern compiles; else the first offending pattern.""" + for g in globs: + if not isinstance(g, str) or not g.strip(): + return repr(g) + try: + _compile(g, _KIND_DENY, "check") + except re.error: + return g + return "" + + +_BUILTIN_RULES = tuple( + [_compile(g, _KIND_DENY, "builtin") for g in BUILTIN_DENY] + + [_compile(g, _KIND_READ_ONLY, "builtin") for g in BUILTIN_WRITE_DENY] +) + + +def _install_dir_rule() -> Rule | None: + """Write-deny on the installed C3 package (agent self-modification). + + Applies only to installed layouts (site-/dist-packages). A dev checkout + is exempt: there, editing the C3 tree is the work itself, and the repo's + own rules/builtins still apply. + """ + try: + import cli # noqa: PLC0415 — lazy: not all callers have it importable + pkg = Path(cli.__file__).resolve().parent + parts = {p.casefold() for p in pkg.parts} + if "site-packages" not in parts and "dist-packages" not in parts: + return None + canon = str(pkg.parent).replace("\\", "/").casefold().rstrip("/") + return _compile(canon + "/**", _KIND_READ_ONLY, "builtin") + except Exception: + return None + + +# ── Config loading (two scopes, fail closed per scope) ────────────────────── + +_CORRUPT = object() # sentinel: scope config invalid → deny-all for the scope + + +def _read_scope_rules(base: Path, scope: str): + """List of Rule for one scope, [] when absent, _CORRUPT when invalid.""" + cfg = base / ".c3" / "config.json" + if not cfg.is_file(): + return [] + try: + section = (json.loads(cfg.read_text(encoding="utf-8")) + or {}).get("access") + except Exception: + return _CORRUPT + if section is None: + return [] + if not isinstance(section, dict): + return _CORRUPT + unknown = set(section) - _VALID_KEYS + if unknown: + return _CORRUPT # hard error — 'allow' must never silently no-op + rules = [] + for kind in (_KIND_DENY, _KIND_READ_ONLY): + globs = section.get(kind, []) + if not isinstance(globs, list) or validate_globs(globs): + return _CORRUPT + rules.extend(_compile(g, kind, scope) for g in globs) + return rules + + +def _global_base() -> Path | None: + try: + home = Path.home() + return home if str(home) not in ("", "/") else None + except Exception: + return None + + +def load_rules(project_path: str = ".") -> tuple: + """(rules, corrupt_scopes) — union of builtin + global + project scopes.""" + rules = list(_BUILTIN_RULES) + inst = _install_dir_rule() + if inst: + rules.append(inst) + corrupt = [] + gbase = _global_base() + proj = Path(project_path).resolve() + for scope, base in (("global", gbase), ("project", proj)): + if base is None or (scope == "global" and gbase == proj): + continue + scoped = _read_scope_rules(base, scope) + if scoped is _CORRUPT: + corrupt.append(scope) + else: + rules.extend(scoped) + return rules, corrupt + + +def has_active_rules(project_path: str = ".") -> bool: + """True when any user rules (or corrupt scopes) exist — drives S4.""" + rules, corrupt = load_rules(project_path) + return bool(corrupt) or len(rules) > len(_BUILTIN_RULES) + 1 + + +# ── Canonicalization (docs/access-guard.md §2 — the ONE implementation) ───── + +_ADS_RE = re.compile(r"^[a-zA-Z]:$") +_SHORTNAME_RE = re.compile(r"~\d") + + +def canonicalize(path, project_root=".") -> tuple: + """(canon_posix_casefolded, rel_posix_casefolded, denial_or_None). + + Handles device/UNC prefixes, nearest-existing-parent resolution for + not-yet-existing targets, trailing dot/space stripping, ADS rejection, + casefolding, and the 8.3 short-name predicate. ``rel`` is '' when the + path is outside ``project_root``. + """ + s = str(path) + if s.startswith("\\\\?\\UNC\\") or s.startswith("//?/UNC/"): + s = "\\\\" + s[8:] + elif s[:4] in ("\\\\?\\", "//?/", "\\\\.\\", "//./"): + s = s[4:] + if s.startswith(("\\\\", "//")): + return "", "", Denial("", _KIND_DENY, "builtin", + "UNC paths are denied by default") + p = Path(s) + if not p.is_absolute(): + p = Path(project_root) / p + + # Nearest-existing-parent resolve: resolve the deepest existing ancestor, + # then re-append the residual components with trailing dot/space stripped. + residual = [] + probe = p + while not probe.exists() and probe.parent != probe: + residual.append(probe.name) + probe = probe.parent + try: + resolved = probe.resolve() + except OSError: + return "", "", Denial("", _KIND_DENY, "builtin", + "path could not be resolved") + windows = os.name == "nt" + for name in reversed(residual): + cleaned = name + if windows: + # open('x.') / open('x ') land on 'x' on NTFS — model it. + while cleaned != cleaned.rstrip(". "): + cleaned = cleaned.rstrip(". ") + if not cleaned: + return "", "", Denial("", _KIND_DENY, "builtin", + "path component empties after normalization") + if windows and _SHORTNAME_RE.search(cleaned): + return "", "", Denial("<8.3-alias>", _KIND_DENY, "builtin", + "8.3 short-name component on a " + "non-existing target") + resolved = resolved / cleaned + + canon_str = str(resolved).replace("\\", "/") + if windows: + body = canon_str[2:] if _ADS_RE.match(canon_str[:2]) else canon_str + if ":" in body: + return "", "", Denial("", _KIND_DENY, "builtin", + "NTFS alternate data stream syntax is denied") + canon = canon_str.casefold() + + try: + root = str(Path(project_root).resolve()).replace("\\", "/").casefold() + root = root.rstrip("/") # drive-root projects resolve to 'c:/' + rel = canon[len(root) + 1:] if canon.startswith(root + "/") else "" + except Exception: + rel = "" + return canon, rel, None + + +# ── Evaluation ────────────────────────────────────────────────────────────── + +def check(path, operation: str, project_path: str = ".") -> Denial | None: + """None when allowed; a Denial otherwise. operation: read|write|create|delete. + + create/delete evaluate as write. Builtin write-denies apply only to + write-class operations; ``deny`` rules apply to everything (R1+R2). + """ + op_write = operation in ("write", "create", "delete") + canon, rel, denial = canonicalize(path, project_path) + if denial: + return denial + rules, corrupt = load_rules(project_path) + if corrupt: + return Denial("", _KIND_DENY, ",".join(corrupt), + "access section invalid or unparseable — scope fails " + "closed (fix .c3/config.json 'access')") + name = canon.rsplit("/", 1)[-1] + hit_ro = None + for rule in rules: + if not rule.matches(canon, rel, name): + continue + if rule.kind == _KIND_DENY: + return Denial(rule.glob, _KIND_DENY, rule.scope, "deny rule") + if hit_ro is None: + hit_ro = rule + if hit_ro is not None and op_write: + return Denial(hit_ro.glob, _KIND_READ_ONLY, hit_ro.scope, + "read-only rule") + return None + + +# ── Refusal strings (docs/access-guard.md §4 — verbatim) ──────────────────── + +def _cap(s: str) -> str: + s = str(s) + if len(s) <= _PATH_CAP: + return s + return s[: _PATH_CAP // 2 - 2] + " … " + s[-(_PATH_CAP // 2 - 2):] + + +def refusal(denial: Denial, path, operation: str, *, surface: str = "mcp", + tool: str = "", project: str = "") -> str: + """The exact S1/S2/S3/S5 string for a denial (see frozen spec).""" + p, glob, scope = _cap(path), denial.rule, denial.scope + if denial.kind == _KIND_READ_ONLY: + return ( + f"{TAG_READ_ONLY} write denied for {p} by Access Guard rule " + f"'{glob}' ({scope} scope). The effective policy is read-only; " + "reads are evaluated separately. Do not retry the write. Mark " + "the affected step blocked and continue with unaffected files; " + "report the skip. Rules: `c3 access list` or the Access tab." + ) + if surface == "hook": + return ( + f"{TAG_DENIED} native {_cap(tool)} {operation} denied for {p} by " + f"Access Guard rule '{glob}' ({scope} scope). This is a policy " + "decision, not a transient error — do not retry through another " + "tool or the shell. Mark the affected step blocked and continue " + "with unaffected files. Rules: `c3 access list`." + ) + if surface == "proxy": + return ( + f"{TAG_DENIED} {operation} denied for {p} through project " + f"'{_cap(project)}' by that project's Access Guard rule " + f"'{glob}' ({scope} scope). The target project's effective " + "policy governs proxied access — do not retry. Mark the " + "affected step blocked and continue with unaffected work." + ) + return ( + f"{TAG_DENIED} {operation} denied for {p} by Access Guard rule " + f"'{glob}' ({scope} scope). This is a policy decision, not a " + "transient error — do not retry or route around it. Mark the " + "affected step blocked and continue with unaffected files; report " + "the skip to the user. Rules: `c3 access list` or the Access tab." + ) + + +def search_footer(project_path: str = ".") -> str: + """S4 — appended to search output whenever any rules are active.""" + if not has_active_rules(project_path): + return "" + return ( + f"{TAG_LIMITED} results are limited to paths permitted by Access " + "Guard. Absence is not evidence a path does not exist — check " + "`c3 access list` before concluding missing work, and report the " + "limitation if required work appears to be missing." + ) + + +def enforce(path, operation: str, project_path: str = ".", *, + surface: str = "mcp", tool: str = "", project: str = "") -> None: + """Raise AccessDenied when ``operation`` on ``path`` is not permitted.""" + denial = check(path, operation, project_path) + if denial: + raise AccessDenied(denial, refusal( + denial, path, operation, surface=surface, tool=tool, + project=project)) diff --git a/tests/test_access_guard.py b/tests/test_access_guard.py new file mode 100644 index 0000000..ae7e0b7 --- /dev/null +++ b/tests/test_access_guard.py @@ -0,0 +1,293 @@ +"""Access Guard v1 evaluator tests — docs/access-guard.md §7 (T1 slice). + +Covers: schema fail-closed, scope union/tightening, builtins, glob +semantics, canonicalization (platform-gated), deny-CREATE, refusal-string +pinning, S4 footer, AccessDenied. Enforcement-surface wiring tests land +with T2; this file tests the evaluator itself. +""" +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from services import access_guard as ag # noqa: E402 + +WIN = os.name == "nt" + + +class GuardBase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.proj = Path(self._tmp.name) + (self.proj / ".c3").mkdir() + + def tearDown(self): + self._tmp.cleanup() + + def _write_access(self, section, base=None): + cfg = (base or self.proj) / ".c3" / "config.json" + cfg.parent.mkdir(exist_ok=True) + cfg.write_text(json.dumps({"access": section}), encoding="utf-8") + + def _check(self, rel, op="read"): + return ag.check(str(self.proj / rel), op, str(self.proj)) + + +class TestSchema(GuardBase): + def test_no_config_allows_normal_files(self): + self.assertIsNone(self._check("src/main.py")) + + def test_unknown_key_fails_closed(self): + self._write_access({"deny": [], "allow": ["**"]}) + d = self._check("src/main.py") + self.assertIsNotNone(d) + self.assertEqual(d.rule, "") + + def test_corrupt_json_fails_closed(self): + (self.proj / ".c3" / "config.json").write_text("{nope", encoding="utf-8") + d = self._check("src/main.py") + self.assertIsNotNone(d) + self.assertEqual(d.rule, "") + + def test_non_list_globs_fail_closed(self): + self._write_access({"deny": "secrets/**"}) + self.assertIsNotNone(self._check("src/main.py")) + + def test_empty_section_is_valid(self): + self._write_access({"deny": [], "read_only": []}) + self.assertIsNone(self._check("src/main.py")) + + +class TestRules(GuardBase): + def test_deny_blocks_read_and_write(self): + self._write_access({"deny": ["secrets/**"]}) + for op in ("read", "write", "create", "delete"): + d = self._check("secrets/key.txt", op) + self.assertIsNotNone(d, op) + self.assertEqual(d.kind, "deny") + self.assertEqual(d.scope, "project") + + def test_deny_covers_directory_itself(self): + self._write_access({"deny": ["secrets/**"]}) + self.assertIsNotNone(self._check("secrets")) + + def test_read_only_blocks_writes_allows_reads(self): + self._write_access({"read_only": ["docs/**"]}) + self.assertIsNone(self._check("docs/a.md", "read")) + d = self._check("docs/a.md", "write") + self.assertIsNotNone(d) + self.assertEqual(d.kind, "read_only") + + def test_deny_beats_read_only(self): + self._write_access({"deny": ["docs/x.md"], "read_only": ["docs/**"]}) + d = self._check("docs/x.md", "read") + self.assertEqual(d.kind, "deny") + + def test_basename_pattern_matches_any_depth(self): + self._write_access({"deny": ["*.pem"]}) + self.assertIsNotNone(self._check("a/b/c/server.pem")) + self.assertIsNone(self._check("a/b/pem.txt")) + + def test_case_insensitive(self): + self._write_access({"deny": ["Secrets/**"]}) + self.assertIsNotNone(self._check("sEcReTs/k.txt")) + + def test_single_star_does_not_cross_separator(self): + self._write_access({"deny": ["logs/*.log"]}) + self.assertIsNotNone(self._check("logs/a.log")) + self.assertIsNone(self._check("logs/sub/a.log")) + + def test_scope_union_tightens(self): + home = Path(tempfile.mkdtemp()) + try: + self._write_access({"read_only": ["src/**"]}, base=home) + self._write_access({"deny": ["src/hot.py"]}) + import unittest.mock as mock + with mock.patch.object(ag, "_global_base", return_value=home): + d = ag.check(str(self.proj / "src/hot.py"), "read", + str(self.proj)) + self.assertEqual(d.kind, "deny") + d2 = ag.check(str(self.proj / "src/other.py"), "write", + str(self.proj)) + self.assertEqual(d2.kind, "read_only") + self.assertEqual(d2.scope, "global") + finally: + import shutil + shutil.rmtree(home, ignore_errors=True) + + +class TestBuiltins(GuardBase): + def test_env_files_denied_read_and_write(self): + for op in ("read", "write"): + d = self._check(".env", op) + self.assertIsNotNone(d, op) + self.assertEqual(d.scope, "builtin") + self.assertIsNotNone(self._check("sub/dir/.env.local")) + + def test_vault_sidecars_denied(self): + self.assertIsNotNone(self._check(".c3/secrets.enc")) + self.assertIsNotNone(self._check(".c3/cred_state.json")) + + def test_c3_dir_write_denied_read_allowed(self): + self.assertIsNone(self._check(".c3/config.json", "read")) + d = self._check(".c3/config.json", "write") + self.assertIsNotNone(d) + self.assertEqual(d.kind, "read_only") + self.assertEqual(d.scope, "builtin") + + def test_git_write_denied_read_allowed(self): + self.assertIsNone(self._check(".git/HEAD", "read")) + self.assertIsNotNone(self._check(".git/hooks/pre-commit", "write")) + + def test_claude_settings_write_denied(self): + self.assertIsNotNone(self._check(".claude/settings.local.json", "write")) + self.assertIsNone(self._check(".claude/settings.local.json", "read")) + + def test_builtins_not_overridable(self): + # A user config cannot loosen builtins (no allow key exists at all); + # writing MORE rules never unblocks .env. + self._write_access({"read_only": ["**"]}) + d = self._check(".env", "read") + self.assertEqual(d.scope, "builtin") + + +class TestCanonicalize(GuardBase): + def test_relative_resolves_under_root(self): + canon, rel, denial = ag.canonicalize("a/b.txt", str(self.proj)) + self.assertIsNone(denial) + self.assertTrue(canon.endswith("a/b.txt")) + self.assertEqual(rel, "a/b.txt") + + def test_dotdot_escape_leaves_rel_empty(self): + canon, rel, denial = ag.canonicalize("../outside.txt", str(self.proj)) + self.assertIsNone(denial) + self.assertEqual(rel, "") + + def test_nonexistent_target_still_canonicalizes(self): + canon, rel, denial = ag.canonicalize("no/such/dir/f.txt", str(self.proj)) + self.assertIsNone(denial) + self.assertEqual(rel, "no/such/dir/f.txt") + + def test_unc_denied(self): + for spelling in (r"\\server\share\x.txt", "//server/share/x.txt", + "\\\\?\\UNC\\server\\share\\x.txt"): + _, _, denial = ag.canonicalize(spelling, str(self.proj)) + self.assertIsNotNone(denial, spelling) + self.assertEqual(denial.rule, "") + + @unittest.skipUnless(WIN, "Windows device-prefix semantics") + def test_device_prefix_stripped(self): + target = self.proj / "x.txt" + canon, rel, denial = ag.canonicalize("\\\\?\\" + str(target), + str(self.proj)) + self.assertIsNone(denial) + self.assertEqual(rel, "x.txt") + + @unittest.skipUnless(WIN, "NTFS trailing-dot semantics") + def test_trailing_dot_lands_on_real_name(self): + canon, rel, denial = ag.canonicalize("secrets/key.txt.", + str(self.proj)) + self.assertIsNone(denial) + self.assertEqual(rel, "secrets/key.txt") + + @unittest.skipUnless(WIN, "NTFS ADS semantics") + def test_ads_denied(self): + _, _, denial = ag.canonicalize("file.txt:stream", str(self.proj)) + self.assertIsNotNone(denial) + self.assertEqual(denial.rule, "") + + @unittest.skipUnless(WIN, "8.3 short names") + def test_short_name_on_missing_target_denied(self): + _, _, denial = ag.canonicalize("MISSIN~1/f.txt", str(self.proj)) + self.assertIsNotNone(denial) + self.assertEqual(denial.rule, "<8.3-alias>") + + @unittest.skipUnless(WIN, "NTFS deny-CREATE spellings") + def test_deny_create_via_trailing_dot_spelling(self): + self._write_access({"deny": ["secrets/**"]}) + d = self._check("secrets/new-key.pem.", "create") + self.assertIsNotNone(d) + self.assertEqual(d.kind, "deny") + + +class TestRefusals(GuardBase): + def setUp(self): + super().setUp() + self._write_access({"deny": ["secrets/**"], + "read_only": ["docs/**"]}) + + def test_s1_mcp_deny(self): + d = self._check("secrets/k.txt") + msg = ag.refusal(d, "secrets/k.txt", "read") + self.assertTrue(msg.startswith("[c3-access:denied]")) + self.assertIn("'secrets/**'", msg) + self.assertIn("project scope", msg) + self.assertIn("not a transient error", msg) + self.assertIn("do not retry", msg) + self.assertIn("c3 access list", msg) + + def test_s2_read_only(self): + d = self._check("docs/a.md", "write") + msg = ag.refusal(d, "docs/a.md", "write") + self.assertTrue(msg.startswith("[c3-access:read_only]")) + self.assertIn("reads are evaluated separately", msg) + self.assertIn("Do not retry the write", msg) + + def test_s3_hook(self): + d = self._check("secrets/k.txt") + msg = ag.refusal(d, "secrets/k.txt", "write", surface="hook", + tool="Edit") + self.assertIn("native Edit write denied", msg) + self.assertIn("another tool or the shell", msg) + + def test_s5_proxy(self): + d = self._check("secrets/k.txt") + msg = ag.refusal(d, "secrets/k.txt", "read", surface="proxy", + project="OtherProj") + self.assertIn("through project 'OtherProj'", msg) + self.assertIn("proxied access", msg) + + def test_s4_footer_present_when_rules_active(self): + footer = ag.search_footer(str(self.proj)) + self.assertTrue(footer.startswith("[c3-access:limited]")) + self.assertIn("Absence is not evidence", footer) + + def test_s4_footer_absent_without_user_rules(self): + clean = tempfile.TemporaryDirectory() + try: + import unittest.mock as mock + with mock.patch.object(ag, "_global_base", return_value=None): + self.assertEqual(ag.search_footer(clean.name), "") + finally: + clean.cleanup() + + def test_path_interpolation_capped(self): + d = self._check("secrets/k.txt") + long_path = "x" * 1000 + msg = ag.refusal(d, long_path, "read") + self.assertLess(len(msg), 600) + self.assertIn(" … ", msg) + + +class TestEnforce(GuardBase): + def test_enforce_raises_with_denial_and_message(self): + self._write_access({"deny": ["secrets/**"]}) + with self.assertRaises(ag.AccessDenied) as ctx: + ag.enforce(str(self.proj / "secrets/k.txt"), "read", + str(self.proj)) + self.assertEqual(ctx.exception.denial.kind, "deny") + self.assertIn("[c3-access:denied]", ctx.exception.message) + + def test_enforce_passes_when_allowed(self): + ag.enforce(str(self.proj / "src/ok.py"), "read", str(self.proj)) + + +if __name__ == "__main__": + unittest.main() From b9a3ee4c492831ad8cd5c8601c4afd2249601aad Mon Sep 17 00:00:00 2001 From: drknowhow Date: Mon, 27 Jul 2026 13:51:16 -0400 Subject: [PATCH 2/8] =?UTF-8?q?feat(access-guard):=20T2b=20=E2=80=94=20fai?= =?UTF-8?q?l-closed=20hook=20layer,=20Bash=20matchers,=20canonical=20unloc?= =?UTF-8?q?k=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli/hook_access_guard.py: new pretool sub-hook routed FIRST — native Read/Edit/Write/MultiEdit/NotebookEdit verdicts via the shared evaluator before any unlock logic (deny-beats-allow makes sticky unlocks unable to readmit a policy denial); explicit-path Grep/Glob denial with rootless searches kept advisory (+[c3-access:limited] footer); best-effort existence-gated Bash token scan with MSYS translation. hook_dispatch.py: _FAIL_CLOSED — if the access sub-hook fails to import or raises, write-class tools + Bash get a synthesized deny (loud, actionable reason) instead of falling through; read-class stays fail-open with a warning to avoid bricking sessions. install-mcp now registers Bash + run_shell_command PreToolUse matchers — the hook layer never fired for shell before (proven bypass). _hook_utils.canonical_key(): resolved/forward-slash/casefolded unlock-map keys, parity-pinned to access_guard.canonicalize; writes, lookups, and load-time migration of old-form keys all converge on it (closes the case-spelling unlock bypass). hook_pretool_enforce: the activity-log evidence window now counts tool_call entries instead of raw lines — foreign event types can no longer evict it. Claude-Session: https://claude.ai/code/session_01CZVZgYdy4m9z9J9mGT5jp6 --- cli/_hook_utils.py | 23 +++- cli/c3.py | 5 + cli/hook_access_guard.py | 119 +++++++++++++++++++ cli/hook_dispatch.py | 38 +++++- cli/hook_pretool_enforce.py | 16 ++- tests/test_hook_access_guard.py | 184 +++++++++++++++++++++++++++++ tests/test_hook_dispatch.py | 10 +- tests/test_hook_pretool_enforce.py | 2 +- tests/test_hook_state.py | 7 +- 9 files changed, 390 insertions(+), 14 deletions(-) create mode 100644 cli/hook_access_guard.py create mode 100644 tests/test_hook_access_guard.py diff --git a/cli/_hook_utils.py b/cli/_hook_utils.py index 4ef5c5f..7caf983 100644 --- a/cli/_hook_utils.py +++ b/cli/_hook_utils.py @@ -175,6 +175,17 @@ def load_enforcement_state(project_path: Path | None = None, session_id: str = " # session must not grant unlocks (signal files used to survive /clear). if session_id and state.get("session_id") and state["session_id"] != session_id: return _empty_state(session_id) + # Migrate unlock keys written before canonical_key (raw resolve() form): + # lookups are canonical now, so re-key at load — old and new forms merge. + migrated: dict = {} + for k, cats in state.get("unlocked_files", {}).items(): + try: + key = canonical_key(k) + except OSError: + continue + merged = set(migrated.get(key, [])) | set(cats) + migrated[key] = sorted(merged) + state["unlocked_files"] = migrated return state @@ -205,6 +216,16 @@ def record_c3_signal( save_enforcement_state(state, project_path) +def canonical_key(path) -> str: + """Canonical unlock-map key: resolved, forward-slash, casefolded. + + Must stay form-identical to services.access_guard.canonicalize()'s canon + string for existing paths (parity-tested) — the unlock map and the + access evaluator must never disagree about path identity on Windows. + """ + return str(Path(path).resolve()).replace("\\", "/").casefold() + + def record_unlocked_files( paths, categories, @@ -223,7 +244,7 @@ def record_unlocked_files( if not fp: continue try: - normalized = str(Path(fp).resolve()) + normalized = canonical_key(fp) except OSError: continue cats = set(state["unlocked_files"].get(normalized, [])) diff --git a/cli/c3.py b/cli/c3.py index 2f3ade5..a1bfcc0 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -5203,6 +5203,9 @@ def _hook_arg(raw: str) -> str: ] # ── PreToolUse hooks (enforcement — blocks native tools without prior c3_*) ── + # Bash/run_shell_command joined in v2.62: the Access Guard sub-hook + # scans shell commands (best-effort); without these matchers PreToolUse + # simply never fires for shell — proven bypass, see docs/access-guard.md. _pre_matcher_names = [ read_matcher, grep_matcher, @@ -5210,6 +5213,8 @@ def _hook_arg(raw: str) -> str: edit_matcher, write_matcher, *extra_edit_matchers, + shell_matcher, + "run_shell_command", ] desired_pre_hooks = [ {"matcher": m, "hooks": [{"type": "command", "command": hook_pretool_cmd}]} diff --git a/cli/hook_access_guard.py b/cli/hook_access_guard.py new file mode 100644 index 0000000..65c9437 --- /dev/null +++ b/cli/hook_access_guard.py @@ -0,0 +1,119 @@ +"""PreToolUse access-guard sub-hook — yielded FIRST from the pretool route. + +Evaluates native tool targets against services.access_guard BEFORE the +c3-usage/sticky-unlock logic in hook_pretool_enforce ever runs; the +dispatcher's deny-beats-allow merge makes a deny here final. hook_dispatch +treats this module as _FAIL_CLOSED: if it fails to import or raises, write- +class native tools are denied instead of sailing through. + +Bash coverage is a best-effort, existence-gated token scan — advisory by +design (docs/access-guard.md §3/§6); it catches an agent naming a denied +path, not an adversary hiding one. +""" +import re +import sys +from pathlib import Path + +_CLI_DIR = Path(__file__).resolve().parent +for _p in (str(_CLI_DIR.parent), str(_CLI_DIR)): + if _p not in sys.path: + sys.path.insert(0, _p) + +from _hook_utils import normalize_tool_name # noqa: E402 + +from services import access_guard as ag # noqa: E402 + +_WRITE_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} +_READ_TOOLS = {"Read", "SearchText"} +_SEARCH_TOOLS = {"Grep", "Glob", "FindFiles"} +_SHELL_TOOLS = {"Bash"} # normalize_tool_name folds run_shell_command in + +_TOKEN_SPLIT = re.compile(r"\s+") +_MAX_TOKENS = 200 + + +def _deny(reason: str) -> dict: + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + } + + +def _target(tool_input: dict) -> str: + return str( + tool_input.get("file_path") + or tool_input.get("notebook_path") + or tool_input.get("path") + or "" + ) + + +def _scan_shell(cmd: str, base: str): + """(denial, token) for the first confident deny-rule hit, else (None, ''). + + 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. + """ + for raw in _TOKEN_SPLIT.split(cmd)[:_MAX_TOKENS]: + tok = raw.strip("\"'`;,()") + if not tok or tok.startswith("-"): + continue + if "/" not in tok and "\\" not in tok and not tok.startswith("."): + continue + if re.match(r"^/[a-z]/", tok): # MSYS /c/foo → C:/foo + tok = f"{tok[1]}:{tok[2:]}" + denial = ag.check(tok, "read", base) + if denial and denial.kind == "deny": + try: + p = Path(tok) + exists = (p if p.is_absolute() else Path(base) / p).exists() + except OSError: + exists = False + if exists or denial.rule.startswith("<"): + return denial, tok + return None, "" + + +def run(payload: dict, project_path: Path | None = None) -> dict | None: + tool = normalize_tool_name(payload.get("tool_name", "")) + tool_input = payload.get("tool_input", {}) or {} + base = str(project_path if project_path is not None else Path.cwd()) + + if tool in _WRITE_TOOLS or tool in _READ_TOOLS: + fp = _target(tool_input) + if not fp: + return None + op = "write" if tool in _WRITE_TOOLS else "read" + denial = ag.check(fp, op, base) + if denial: + return _deny(ag.refusal(denial, fp, op, surface="hook", tool=tool)) + return None + + if tool in _SEARCH_TOOLS: + # Hard-deny only an EXPLICIT path argument inside a denied subtree; + # rootless/broad searches stay advisory (docs/access-guard.md §3). + root = str(tool_input.get("path") or "") + if root: + denial = ag.check(root, "read", base) + if denial and denial.kind == "deny": + return _deny(ag.refusal(denial, root, "read", + surface="hook", tool=tool)) + footer = ag.search_footer(base) + return {"additionalContext": footer} if footer else None + + if tool in _SHELL_TOOLS: + cmd = str(tool_input.get("command") or "") + if not cmd: + return None + denial, tok = _scan_shell(cmd, base) + if denial: + return _deny(ag.refusal(denial, tok, "read", + surface="hook", tool=tool) + + " (best-effort shell scan)") + return None + + return None diff --git a/cli/hook_dispatch.py b/cli/hook_dispatch.py index 8720245..e89150c 100644 --- a/cli/hook_dispatch.py +++ b/cli/hook_dispatch.py @@ -81,6 +81,9 @@ def _routes(event: str, raw_tool: str, norm_tool: str): """Yield sub-hook module names applicable to this event + tool, in the same relative order the separate hook commands used to run.""" if event == "pretool": + # Access Guard runs FIRST: merge_outputs keeps the first deny, so a + # policy denial here cannot be readmitted by sticky-unlock allows. + yield "hook_access_guard" # hook_pretool_enforce self-filters via its _PREREQS table. yield "hook_pretool_enforce" elif event == "posttool": @@ -109,6 +112,28 @@ def _routes(event: str, raw_tool: str, norm_tool: str): _RUN_CACHE: dict = {} +# Modules whose failure must DENY rather than fall through (fail closed). +# Scoped to write-class tools + shell: a broken guard must not let mutations +# sail through, but read-class fail-open avoids bricking whole sessions. +_FAIL_CLOSED = {"hook_access_guard"} +_FAIL_CLOSED_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"} + + +def _fail_closed_deny(module_name: str, err: str) -> dict: + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": ( + f"[c3-access:error] {module_name} failed to run ({err}). " + "Native write tools stay blocked until it loads — this is " + "fail-closed by design. C3 MCP tools still work; see " + ".c3/hook_errors.log and rerun `c3 install-mcp` if it " + "persists." + ), + } + } + def _load_run(module_name: str): """Import cli. and return its run() (cached). @@ -217,16 +242,23 @@ def dispatch(event: str, payload: dict, project_path: Path | None = None) -> dic warnings.append( f"[c3:hook-error] {module_name}: {err}; see .c3/hook_errors.log" ) + if (module_name in _FAIL_CLOSED and event == "pretool" + and norm_tool in _FAIL_CLOSED_TOOLS): + outputs.append(_fail_closed_deny(module_name, err)) continue try: out = run_fn(payload, project_path) if out: outputs.append(out) except Exception as exc: - # Non-critical: parity with the old behavior where each hook - # swallowed and logged its own exceptions. Other sub-hooks - # continue to run. + # Non-critical for most hooks: parity with the old behavior where + # each hook swallowed and logged its own exceptions. Access Guard + # is the exception — its failure denies write-class tools. log_hook_error(module_name, exc) + if (module_name in _FAIL_CLOSED and event == "pretool" + and norm_tool in _FAIL_CLOSED_TOOLS): + outputs.append(_fail_closed_deny( + module_name, f"{type(exc).__name__}: {exc}")) # Critical state-layer warnings (corrupt enforcement_state.json) # become visible instead of silently disabling enforcement. warnings.extend(_hook_utils.drain_state_warnings()) diff --git a/cli/hook_pretool_enforce.py b/cli/hook_pretool_enforce.py index 8b64ffb..9aba44f 100644 --- a/cli/hook_pretool_enforce.py +++ b/cli/hook_pretool_enforce.py @@ -30,6 +30,7 @@ from pathlib import Path from _hook_utils import ( + canonical_key, load_enforcement_state, log_hook_error, normalize_tool_name, @@ -153,7 +154,10 @@ def _is_file_unlocked(state: dict, file_path: str, category: str) -> bool: """Check if a file is unlocked for the given operation category.""" if not file_path: return False - normalized = str(Path(file_path).resolve()) if file_path else "" + try: + normalized = canonical_key(file_path) + except OSError: + return False cats = state.get("unlocked_files", {}).get(normalized, []) return category in cats or "both" in cats @@ -242,12 +246,19 @@ def _check_c3_used( if not log_file.exists(): return False, "" + # The evidence window counts TOOL_CALL entries, not raw lines: foreign + # event types (denial audits, session events) appended after a c3 call + # must not evict it. Tail a larger raw window, examine at most LOOKBACK + # tool_call entries. try: - lines = _tail_lines(log_file, LOOKBACK) + lines = _tail_lines(log_file, LOOKBACK * 10) except Exception: return False, "" + examined = 0 for line in reversed(lines): + if examined >= LOOKBACK: + break try: entry = json.loads(line) except (json.JSONDecodeError, ValueError): @@ -255,6 +266,7 @@ def _check_c3_used( if entry.get("type") != "tool_call": continue + examined += 1 tool = entry.get("tool", "") if tool not in allowed: diff --git a/tests/test_hook_access_guard.py b/tests/test_hook_access_guard.py new file mode 100644 index 0000000..9cac249 --- /dev/null +++ b/tests/test_hook_access_guard.py @@ -0,0 +1,184 @@ +"""T2b tests — the access-guard pretool sub-hook + fail-closed dispatch. + +Proves: native tool verdicts fire BEFORE unlock logic (sticky-unlock +immunity), the dispatcher denies write-class tools when the guard itself +breaks, explicit-path search denial vs rootless advisory, the best-effort +shell scan, Bash matcher registration, and canonical-key parity between the +unlock map and the evaluator. +""" +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from cli import _hook_utils # noqa: E402 + +sys.modules.setdefault("_hook_utils", _hook_utils) + +import cli.hook_access_guard as hag # noqa: E402 +import cli.hook_dispatch as hd # noqa: E402 +from services import access_guard as ag # noqa: E402 + + +class HookGuardBase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.proj = Path(self._tmp.name) + (self.proj / ".c3").mkdir() + self._write_access({"deny": ["secrets/**"], "read_only": ["docs/**"]}) + (self.proj / "secrets").mkdir() + (self.proj / "secrets" / "key.txt").write_text("k", encoding="utf-8") + _hook_utils.drain_state_warnings() + + def tearDown(self): + _hook_utils.drain_state_warnings() + self._tmp.cleanup() + + def _write_access(self, section): + (self.proj / ".c3" / "config.json").write_text( + json.dumps({"access": section}), encoding="utf-8") + + def _run(self, tool, tool_input): + return hag.run({"tool_name": tool, "tool_input": tool_input}, + project_path=self.proj) + + def _reason(self, out): + return (out or {}).get("hookSpecificOutput", {}).get( + "permissionDecisionReason", "") + + +class TestNativeVerdicts(HookGuardBase): + def test_write_tools_denied_on_deny_rule(self): + for tool in ("Edit", "Write", "MultiEdit"): + out = self._run(tool, {"file_path": str(self.proj / "secrets/key.txt")}) + self.assertIn("[c3-access:denied]", self._reason(out), tool) + + def test_notebook_edit_uses_notebook_path(self): + out = self._run("NotebookEdit", + {"notebook_path": str(self.proj / "secrets/nb.ipynb")}) + self.assertIn("[c3-access:denied]", self._reason(out)) + + def test_read_denied_on_deny_rule(self): + out = self._run("Read", {"file_path": str(self.proj / "secrets/key.txt")}) + self.assertIn("[c3-access:denied]", self._reason(out)) + + def test_read_only_rule_allows_read_denies_write(self): + (self.proj / "docs").mkdir() + target = str(self.proj / "docs" / "a.md") + self.assertIsNone(self._run("Read", {"file_path": target})) + out = self._run("Edit", {"file_path": target}) + self.assertIn("[c3-access:read_only]", self._reason(out)) + + def test_clean_file_passes(self): + self.assertIsNone(self._run("Edit", + {"file_path": str(self.proj / "src.py")})) + + +class TestSearchTools(HookGuardBase): + def test_explicit_denied_root_hard_denied(self): + out = self._run("Grep", {"pattern": "x", + "path": str(self.proj / "secrets")}) + self.assertIn("[c3-access:denied]", self._reason(out)) + + def test_rootless_search_gets_advisory_footer(self): + out = self._run("Grep", {"pattern": "x"}) + self.assertIsNotNone(out) + self.assertIn("[c3-access:limited]", out.get("additionalContext", "")) + self.assertNotIn("hookSpecificOutput", out) + + +class TestShellScan(HookGuardBase): + def test_existing_denied_path_in_command_denied(self): + target = str(self.proj / "secrets" / "key.txt") + out = self._run("Bash", {"command": f"cat {target}"}) + self.assertIn("[c3-access:denied]", self._reason(out)) + self.assertIn("best-effort shell scan", self._reason(out)) + + def test_regex_lookalike_token_not_denied(self): + # '\.env' is a grep pattern, not a file — existence gate must pass it. + out = self._run("Bash", {"command": r'grep -r "\.env" docs/'}) + self.assertIsNone(out) + + def test_benign_command_passes(self): + self.assertIsNone(self._run("Bash", {"command": "git status"})) + + +class TestDispatchFailClosed(HookGuardBase): + def _dispatch(self, tool, tool_input): + return hd.dispatch("pretool", + {"tool_name": tool, "tool_input": tool_input}, + project_path=self.proj) + + def test_import_failure_denies_write_class(self): + with mock.patch.object(hd, "_load_run", + side_effect=lambda m: (None, "ImportError: x")): + out = self._dispatch("Edit", {"file_path": str(self.proj / "a.py")}) + reason = out["hookSpecificOutput"]["permissionDecisionReason"] + self.assertIn("[c3-access:error]", reason) + self.assertIn("fail-closed", reason) + + def test_runtime_exception_denies_write_class(self): + real_load = hd._load_run + + def loader(mod): + if mod == "hook_access_guard": + return (lambda p, pp=None: 1 / 0), "" + return real_load(mod) + + with mock.patch.object(hd, "_load_run", side_effect=loader): + out = self._dispatch("Write", {"file_path": str(self.proj / "a.py")}) + self.assertEqual( + out["hookSpecificOutput"]["permissionDecision"], "deny") + self.assertIn("[c3-access:error]", + out["hookSpecificOutput"]["permissionDecisionReason"]) + + def test_read_class_fails_open_with_warning(self): + with mock.patch.object(hd, "_load_run", + side_effect=lambda m: (None, "ImportError: x")): + out = self._dispatch("Read", {"file_path": str(self.proj / "a.py")}) + self.assertNotIn("hookSpecificOutput", out or {}) + self.assertIn("[c3:hook-error]", (out or {}).get("additionalContext", "")) + + +class TestStickyUnlockImmunity(HookGuardBase): + def test_unlocked_denied_file_still_denied(self): + target = str(self.proj / "secrets" / "key.txt") + _hook_utils.record_unlocked_files( + [target], {"edit", "read"}, session_id="s1", + project_path=self.proj) + out = hd.dispatch( + "pretool", + {"tool_name": "Edit", "session_id": "s1", + "tool_input": {"file_path": target}}, + project_path=self.proj) + reason = out["hookSpecificOutput"]["permissionDecisionReason"] + self.assertIn("[c3-access:denied]", reason) + self.assertNotIn("[c3:enforce]", reason) + + +class TestCanonicalParity(unittest.TestCase): + def test_unlock_key_matches_evaluator_canon(self): + with tempfile.TemporaryDirectory() as tmp: + f = Path(tmp) / "A File.TXT" + f.write_text("x", encoding="utf-8") + self.assertEqual(_hook_utils.canonical_key(f), + ag.canonicalize(str(f), tmp)[0]) + + +class TestInstallMatchers(unittest.TestCase): + def test_pre_matchers_include_shell(self): + source = (REPO_ROOT / "cli" / "c3.py").read_text(encoding="utf-8") + block = source.split("_pre_matcher_names = [", 1)[1].split("]", 1)[0] + self.assertIn("shell_matcher", block) + self.assertIn("run_shell_command", block) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hook_dispatch.py b/tests/test_hook_dispatch.py index 2c03190..c45e4e9 100644 --- a/tests/test_hook_dispatch.py +++ b/tests/test_hook_dispatch.py @@ -80,9 +80,11 @@ def _routes(self, event, tool): from cli._hook_utils import normalize_tool_name return list(hook_dispatch._routes(event, tool, normalize_tool_name(tool))) - def test_pretool_routes_to_enforce(self): - self.assertEqual(self._routes("pretool", "Read"), ["hook_pretool_enforce"]) - self.assertEqual(self._routes("pretool", "Edit"), ["hook_pretool_enforce"]) + def test_pretool_routes_access_guard_first(self): + self.assertEqual(self._routes("pretool", "Read"), + ["hook_access_guard", "hook_pretool_enforce"]) + self.assertEqual(self._routes("pretool", "Edit"), + ["hook_access_guard", "hook_pretool_enforce"]) def test_posttool_bash_routes(self): self.assertEqual(self._routes("posttool", "Bash"), @@ -216,7 +218,7 @@ def test_posttool_c3_compress_unlocks_and_signals(self): state = _hook_utils.load_enforcement_state(self.tmp) self.assertEqual(state["session_id"], "sess-1") self.assertEqual(state["last_c3_call"]["tool"], "c3_compress") - self.assertIn(str(fp.resolve()), state["unlocked_files"]) + self.assertIn(_hook_utils.canonical_key(fp), state["unlocked_files"]) # Full round-trip: the signal written by posttool unlocks pretool Read. out2 = dispatch("pretool", {"tool_name": "Read", "session_id": "sess-1", diff --git a/tests/test_hook_pretool_enforce.py b/tests/test_hook_pretool_enforce.py index d91adf5..d28e424 100644 --- a/tests/test_hook_pretool_enforce.py +++ b/tests/test_hook_pretool_enforce.py @@ -110,7 +110,7 @@ def test_fresh_signal_records_sticky_unlock(self): self._write_state(tool="c3_search", read_unlocked=True) self._run("Read", {"file_path": str(self.tmp / "foo.py")}) state = _hook_utils.load_enforcement_state(self.tmp) - normalized = str((self.tmp / "foo.py").resolve()) + normalized = _hook_utils.canonical_key(self.tmp / "foo.py") self.assertIn("read", state["unlocked_files"].get(normalized, [])) def test_read_signal_does_not_unlock_edit(self): diff --git a/tests/test_hook_state.py b/tests/test_hook_state.py index 464c754..8dcafd5 100644 --- a/tests/test_hook_state.py +++ b/tests/test_hook_state.py @@ -58,14 +58,14 @@ def test_record_unlocked_files_merges_categories(self): record_unlocked_files([fp], {"read"}, project_path=self.tmp) record_unlocked_files([fp], {"edit"}, project_path=self.tmp) state = load_enforcement_state(self.tmp) - normalized = str(Path(fp).resolve()) + normalized = _hook_utils.canonical_key(fp) self.assertEqual(state["unlocked_files"][normalized], ["edit", "read"]) def test_record_json_unlocks_compat_wrapper(self): fp = str(self.tmp / "foo.py") record_json_unlocks([fp], project_path=self.tmp) state = load_enforcement_state(self.tmp) - normalized = str(Path(fp).resolve()) + normalized = _hook_utils.canonical_key(fp) self.assertEqual(state["unlocked_files"][normalized], ["edit", "read"]) def test_signal_preserves_existing_unlocks(self): @@ -123,7 +123,8 @@ def test_legacy_unlock_map_loaded(self): (self.tmp / ".c3" / "unlocked_files.json").write_text( json.dumps({"X:/proj/foo.py": ["read", "edit"]}), encoding="utf-8") state = load_enforcement_state(self.tmp) - self.assertEqual(state["unlocked_files"]["X:/proj/foo.py"], ["read", "edit"]) + key = _hook_utils.canonical_key("X:/proj/foo.py") + self.assertEqual(state["unlocked_files"][key], ["edit", "read"]) def test_corrupt_legacy_files_are_not_critical(self): (self.tmp / ".c3" / "last_c3_call.json").write_text("junk", encoding="utf-8") From a15f372b05e88e6af34e87b1722cf113c9d42e11 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Mon, 27 Jul 2026 14:02:48 -0400 Subject: [PATCH 3/8] =?UTF-8?q?docs(access-guard):=20T4=20=E2=80=94=20guid?= =?UTF-8?q?e=20page,=20nav=20sweep,=20README=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli/guide/access.html: full feature guide — rules & semantics, builtins table, refusal-tag reference, honest coverage matrix (enforced vs not), CLI reference, UI probe, troubleshooting. Copy reviewed by Vi (gemyep): sharper sandbox distinction, de-jargoned deny-create bullet, explicit non-Claude native-tool caveat in the not-enforced callout. Access link added to every guide page's header nav + index sidebar and nav-card grid. README gains the Access Guard section beside the vault (values vs files framing), stating tighten-only, deny-create/enumerate, human-only mutations, builtins, and the cooperative-guard limits. CHANGELOG lands with the release commit (T6). Claude-Session: https://claude.ai/code/session_01CZVZgYdy4m9z9J9mGT5jp6 --- README.md | 39 +++++ cli/guide/access.html | 288 +++++++++++++++++++++++++++++++++ cli/guide/bitbucket.html | 1 + cli/guide/credentials.html | 1 + cli/guide/getting-started.html | 1 + cli/guide/index.html | 9 ++ cli/guide/oracle.html | 1 + cli/guide/tools.html | 1 + cli/guide/workflow.html | 1 + 9 files changed, 342 insertions(+) create mode 100644 cli/guide/access.html diff --git a/README.md b/README.md index f869927..5177784 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,45 @@ inbound-only, **no hub route ever returns a stored value** (there is no Full documentation: [`cli/guide/credentials.html`](cli/guide/credentials.html) — open it in the app at `/guide/credentials.html`. +### Access Guard (v2.62.0) + +The Credential Vault protects *values*; **Access Guard** protects *files and +folders*. Two glob lists in your config mark what an agent must never read +(`deny`) or never write (`read_only`), and one shared evaluator enforces them +at every C3 surface — the MCP tools (`c3_read`/`c3_edit`/`c3_search`/…, so +any agent using C3 is covered), Claude Code's native tools via PreToolUse +hooks (fail-closed: a broken guard denies writes instead of waving them +through), and `c3_shell` via a best-effort command scan. + +```bash +c3 access add "secrets/**" --kind deny # never read, never write +c3 access add "migrations/**" --kind read_only +c3 access check secrets/key.txt # probe: verdict + matched rule +``` + +- **Tighten-only.** No allow list exists; scopes (global `~/.c3` + project + `.c3`) merge as a union. A cloned repo's config can only add protection, + never grant itself access. +- **`deny` means deny-create and deny-enumerate** too: alternate spellings + are canonicalized before matching, and denied paths never appear in search + results, maps, or the vector index. +- **Refusals teach the agent to stop.** Every denial carries a stable + `[c3-access:*]` tag, the matched rule and scope, and explicit + do-not-retry guidance — no retry loops, no "file must not exist, let me + recreate it". +- **All rule changes are human-only** (Access Guard UI tab or `c3 access` + CLI), ledger-logged; agents have no mutation surface. +- **Built-ins always on:** `.env*` files, the credential vault's sidecars, + and write-denies on `.c3/`, `.claude/settings*.json`, `.git/`, and the + installed C3 package itself. +- **Honest coverage:** this guards *cooperative* agents against mistakes and + prompt-injection. It is not a sandbox — raw shell or direct file access + outside C3's tools and hooks is not stopped. The guide states exactly + where each layer holds. + +Full documentation: [`cli/guide/access.html`](cli/guide/access.html) +— open it in the app at `/guide/access.html`. + ### Jira — Cloud + Data Center (v2.56.0) `c3_jira` connects to Jira Cloud (REST v3, email + API token) or self-hosted diff --git a/cli/guide/access.html b/cli/guide/access.html new file mode 100644 index 0000000..94853de --- /dev/null +++ b/cli/guide/access.html @@ -0,0 +1,288 @@ + + + + + + Access Guard — C3 Guide + + + + + +
+ +
+ + + v2.62.0 +
+ + + + + +
+

Access Guard

+

+ Mark the paths an AI agent must never read, or never write — and have + every C3 surface enforce it. Two glob lists in your project (or global) + config, one evaluator, refusals that tell the agent exactly why and + what to do instead. +

+ +
+ deny — no read, no write + read_only — no write + tighten-only + v2.62.0+ +
+ +
+

What this is — and is not. Access Guard protects a + cooperative agent against mistakes and prompt-injection: an + agent that names a protected path gets a firm, reasoned refusal at + every C3 surface. It is not a sandbox — it governs + file actions invoked through C3's tools and hooks, but does not + isolate raw OS processes or external editors. See + Coverage for exactly where each layer + holds.

+
+ +

Quick start

+
+
+ 1

Add a rule

+

UI → project → Access Guard tab → Add rule, or:

+
c3 access add "secrets/**" --kind deny
+
+
+ 2

Test a path

+

Use the tab's Test path probe, or:

+
c3 access check secrets/key.txt
+
+
+ 3

Let it work

+

Agent attempts on protected paths return + [c3-access:denied] refusals that name the rule and tell + the agent not to retry.

+
+
+ +

Rules & semantics

+

Rules live in the access section of + .c3/config.json (project scope) and + ~/.c3/config.json (global scope):

+
{
+  "access": {
+    "deny":      ["secrets/**", "*.pem"],
+    "read_only": ["docs/legal/**", "migrations/**"]
+  }
+}
+ + + + +
Rule kindReadWrite / create / deleteAppears in search?
deny✗✗✗ (never listed or hinted)
read_only✓ (unless another rule denies)✗✓
+
    +
  • Tighten-only. There is no allow list. + Scopes merge as a union — a rule anywhere applies everywhere. A + project config can only add protection, never grant access; an + unknown key in the section is a hard config error.
  • +
  • Globs are POSIX-style, case-insensitive. + ** crosses directories, * does not; a + pattern without / matches the file name at any depth + (*.pem matches a/b/server.pem).
  • +
  • deny also means deny-create. Path manipulation + and OS aliases (short names, trailing dots/spaces, stream syntax) + are normalized before matching, so alternate spellings of a + protected name cannot slip past the rule.
  • +
  • deny also means deny-enumerate. Search, maps, and + listings never reveal a denied file's existence. When any rules are + active, search output carries a [c3-access:limited] + footer so the agent doesn't conclude "missing" means "doesn't + exist".
  • +
  • Rule text is not secret. Refusals and + c3_status show the matched glob — the guard protects + file contents and existence, not the rule list. Name globs + accordingly.
  • +
  • All rule changes are human-only — UI or CLI, and + every mutation is logged to the project's edit ledger. Agents have + no tool that can add, remove, or loosen rules.
  • +
  • Local-only in v1. .c3/ is gitignored: + rules do not travel with a cloned repo.
  • +
+ +

Built-in protections (always on)

+

These apply in every C3 project, with no configuration, and cannot be + overridden. If the access config is corrupt or unparseable, that scope + fails closed — everything in it is denied until fixed.

+ + + + + + + + + +
PathsProtectionWhy
**/.env*denySecrets never enter agent context
.c3/secrets.enc · .c3/cred_state.jsondenyCredential vault sidecars
.c3/** · ~/.c3/**write-denyConfig self-tampering (incl. access rules + credential registry)
.claude/settings*.jsonwrite-denyHook/permission self-modification
.git/**write-denygit hooks are a code-execution vector (reads stay open)
C3 install directorywrite-denyAgent self-modification (installed layouts only)
*.pem · id_rsa* · *.keydefault ruleSeeded in global scope — visible and removable, unlike builtins
+ +

Refusal messages

+

Every denial carries a stable machine tag, the matched rule and scope, + and explicit no-retry guidance — so the agent pivots instead of + retry-looping or silently recreating "missing" files.

+ + + + + + +
TagMeaning
[c3-access:denied]Operation refused by a deny rule (or a write refused where read_only applies via hooks). Policy, not an error — the agent is told not to retry or route around it.
[c3-access:read_only]Write refused; reads remain separately evaluated.
[c3-access:limited]Search/listing footer: results may omit protected paths — a file that doesn't appear may still exist.
[c3-access:error]The enforcement hook itself failed — native write tools are denied until it loads (fail-closed). See Troubleshooting.
+ +

Coverage — honest limits

+
+

Enforced: C3 MCP tools (any agent that uses C3 — + Claude Code, Codex, Gemini, Oracle-connected models) · Claude Code + native tools (PreToolUse hooks, fail-closed) · c3_shell + (best-effort command scan, advisory).

+
+
+

Not enforced: a non-Claude agent's raw shell or + direct file APIs — including native file tools in non-Claude CLI + environments unless they route through C3 tools — external editors, + and OS-level access. Renames/moves via shell commands are a known + v1 gap. Access Guard is a guardrail + for cooperative agents — pair it with OS permissions when you need a + real boundary.

+
+ +

CLI commands

+
c3 access list                        # rules by scope, builtins included
+c3 access add "secrets/**" --kind deny            # project scope
+c3 access add "*.pem" --kind deny --global        # global scope
+c3 access remove "secrets/**" --kind deny
+c3 access check secrets/key.txt                   # probe: verdict + matched rule
+c3 access check docs/x.md --op write
+ +

UI tab & probe

+

Each project's UI gains an Access Guard tab: rules + grouped by scope (builtins shown locked), an add-rule form, delete with + typed confirmation for deny rules, and a Test path + probe that shows the verdict, matched rule, and the exact refusal the + agent would see. The coverage matrix above is pinned at the bottom of + the tab.

+ +

Troubleshooting

+ + + + + + + + + +
[c3-access:error] on every writeThe access hook failed to load — this is fail-closed by design. + Check .c3/hook_errors.log, rerun + c3 install-mcp. C3 MCP tools keep working + meanwhile.
Everything in a scope deniedThat scope's access section is corrupt or has an + unknown key. Fix the JSON; c3 access list names the + scope.
Expected file "missing" in searchCheck c3 access check <path> — deny rules + remove files from search entirely (deny-enumerate).
Rule not matchingGlobs are POSIX-style: use forward slashes; remember + * does not cross directories — use **. + Probe with c3 access check.
+ +
+

Relationship to the Credential Vault. Credentials + protect values (never in agent context, injected at the + subprocess boundary); Access Guard protects files and folders. + The vault's registry and sidecars are covered by Access Guard builtins, + and both systems log every mutation to the same edit ledger.

+
+
+
+ + diff --git a/cli/guide/bitbucket.html b/cli/guide/bitbucket.html index a7dd95e..7e5de87 100644 --- a/cli/guide/bitbucket.html +++ b/cli/guide/bitbucket.html @@ -78,6 +78,7 @@ Workflow Tools Credentials + Access Bitbucket Oracle ♥ Sponsor diff --git a/cli/guide/credentials.html b/cli/guide/credentials.html index f7be75a..397f6cd 100644 --- a/cli/guide/credentials.html +++ b/cli/guide/credentials.html @@ -87,6 +87,7 @@ Workflow Tools Credentials + Access Bitbucket Oracle ♥ Sponsor diff --git a/cli/guide/getting-started.html b/cli/guide/getting-started.html index 3bce7b4..f8eb029 100644 --- a/cli/guide/getting-started.html +++ b/cli/guide/getting-started.html @@ -21,6 +21,7 @@ Workflow Tools Credentials + Access Bitbucket Oracle ♥ Sponsor diff --git a/cli/guide/index.html b/cli/guide/index.html index e55c75b..a8bad2c 100644 --- a/cli/guide/index.html +++ b/cli/guide/index.html @@ -223,6 +223,7 @@ Workflow Tools Credentials + Access Bitbucket Oracle ♥ Sponsor @@ -258,6 +259,9 @@ 🔐 Credential Vault + + 🛡️ Access Guard + 🪣 Bitbucket @@ -321,6 +325,11 @@

Claude Code Companion

+ + 🛡️ + + + 🪣 diff --git a/cli/guide/oracle.html b/cli/guide/oracle.html index bbc3558..eff4139 100644 --- a/cli/guide/oracle.html +++ b/cli/guide/oracle.html @@ -76,6 +76,7 @@ Workflow Tools Credentials + Access Bitbucket Oracle ♥ Sponsor diff --git a/cli/guide/tools.html b/cli/guide/tools.html index f30f1bf..ff3c63a 100644 --- a/cli/guide/tools.html +++ b/cli/guide/tools.html @@ -80,6 +80,7 @@ Workflow Tools Credentials + Access Bitbucket Oracle ♥ Sponsor diff --git a/cli/guide/workflow.html b/cli/guide/workflow.html index 9745d7d..c8a9f03 100644 --- a/cli/guide/workflow.html +++ b/cli/guide/workflow.html @@ -223,6 +223,7 @@ Workflow Tools Credentials + Access Bitbucket Oracle ♥ Sponsor From af7ab814d306f5ca9c5fbc78c59fd5e47fe71cda Mon Sep 17 00:00:00 2001 From: drknowhow Date: Mon, 27 Jul 2026 14:04:19 -0400 Subject: [PATCH 4/8] =?UTF-8?q?feat(access-guard):=20T2a+T2c=20=E2=80=94?= =?UTF-8?q?=20service-layer=20wiring=20+=20shell/project/delegate/scanner?= =?UTF-8?q?=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2a (service layer, Oracle bridge + delegate inherit): compress_file and ArtifactStore.restore raise AccessDenied at the service layer; read/edit/ compress/validate/filter/impact handlers convert to S1/S2 refusals via finalize; search pre-filters denied paths per action before dedup/top_k (deny-ENUMERATE) with the [c3-access:limited] footer appended once and a defense-in-depth check in prefetch; delegate's except-Exception loops re-raise AccessDenied at all three sites. Restore exempts builtin write-denies only (the audited, versioned surface for exactly those files) — user rules still enforce; pinned by test. has_active_rules off-by-one fixed (single user rule was invisible in dev checkouts). T2c: c3_shell hard-denies a denied work_cwd and runs an ADVISORY post-credential-expansion token scan (MSYS translation, relative tokens resolved against cwd, URL/flag skips, refusals redacted); denials always log cwd. c3_project checks proxied paths against global ∪ caller ∪ containing realm (S5 refusals) and requires registration beyond list/scan/register/info — kills the mint-a-project pivot. c3_delegate pins codex to --sandbox read-only when rules exist and gates write-capable backends behind allow_write_delegation=false (zero-rules behavior byte-identical). scanner.iter_files prunes denied dirs/files at index time — nothing denied enters the TF-IDF/vector index, MAP.md, or file_memory; corrupt config excludes everything. 45 new tests across the two slices; combined verification run 260 passed. Claude-Session: https://claude.ai/code/session_01CZVZgYdy4m9z9J9mGT5jp6 --- cli/mcp_server.py | 10 +- cli/tools/artifacts.py | 9 +- cli/tools/compress.py | 19 +- cli/tools/delegate.py | 61 +++- cli/tools/edit.py | 12 + cli/tools/filter.py | 8 + cli/tools/impact.py | 20 ++ cli/tools/project.py | 88 ++++++ cli/tools/read.py | 12 + cli/tools/search.py | 67 +++- cli/tools/shell.py | 131 ++++++++ cli/tools/validate.py | 13 + services/access_guard.py | 186 ++++++++++- services/artifact_store.py | 19 ++ services/compressor.py | 11 +- services/scanner.py | 45 +++ tests/test_access_guard_shell_project.py | 379 +++++++++++++++++++++++ tests/test_access_guard_wiring.py | 330 ++++++++++++++++++++ 18 files changed, 1396 insertions(+), 24 deletions(-) create mode 100644 tests/test_access_guard_shell_project.py create mode 100644 tests/test_access_guard_wiring.py diff --git a/cli/mcp_server.py b/cli/mcp_server.py index b529f12..7eafe72 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -610,7 +610,7 @@ def finalize(name, args, resp, summ, **kw): async def c3_status(view: str = "budget", detailed: bool = False, ctx: Context = None) -> str: """PROJECT health and budget — run at session start or when context feels stale (read-only, plan-mode safe). - views: budget (tokens/ratio), health (memory/index/notifications), notifications (actionable only), sessions, ghost_files.""" + views: budget (tokens/ratio), health (memory/index/notifications), notifications (actionable only), sessions, ghost_files, access (Access Guard rules — read-only).""" svc = _svc(ctx) def finalize(name, args, resp, summ, **kw): @@ -622,9 +622,11 @@ def finalize(name, args, resp, summ, **kw): @mcp.tool() async def c3_delegate(task: str, task_type: str = "ask", context: str = "", file_path: str = "", backend: str = "ollama", + allow_write_delegation: bool = False, ctx: Context = None) -> str: """OFFLOAD to another model — use when the subtask is local-model-sized or needs a different perspective. - backend: ollama|codex|gemini|claude|auto. task_type: auto, summarize, explain, review, ask, test, diagnose, available, codex_check, gemini_check, codex_resume.""" + backend: ollama|codex|gemini|claude|auto. task_type: auto, summarize, explain, review, ask, test, diagnose, available, codex_check, gemini_check, codex_resume. + allow_write_delegation: explicit user opt-in for write-capable backends (gemini/claude/codex_resume) while Access Guard rules are active; codex is pinned read-only instead.""" svc = _svc(ctx) def finalize(name, args, resp, summ, **kw): @@ -649,7 +651,9 @@ def _progress_cb(message: str): pass svc._agent_progress_cb = _progress_cb try: - return await asyncio.to_thread(handle_delegate, task, task_type, context, file_path, svc, finalize, backend) + return await asyncio.to_thread(handle_delegate, task, task_type, context, + file_path, svc, finalize, backend, + allow_write_delegation) finally: svc._agent_progress_cb = None diff --git a/cli/tools/artifacts.py b/cli/tools/artifacts.py index fab393f..04efcd5 100644 --- a/cli/tools/artifacts.py +++ b/cli/tools/artifacts.py @@ -1,5 +1,7 @@ """c3_artifacts — agent-config tracking: inventory, history, diff, restore.""" +from services import access_guard + READ_ACTIONS = {"scan", "list", "history", "show", "diff", "status"} _ACTIONS = "scan, list, history, show, diff, restore, status" @@ -137,7 +139,12 @@ def done_resp(resp, summ="ok"): if not version: return done_resp("[artifacts:error] restore requires version.", "error") session = getattr(getattr(svc, "session_mgr", None), "current_session", None) or {} - res = store.restore(artifact, version, session_id=session.get("id", "")) + try: + res = store.restore(artifact, version, session_id=session.get("id", "")) + except access_guard.AccessDenied as exc: + # Service layer raises; the tool boundary converts to the S1/S2 + # refusal string (docs/access-guard.md §3). + return done_resp(exc.message, "access-denied") if "error" in res: return done_resp(f"[artifacts:error] {res['error']}", "error") ledger = getattr(svc, "edit_ledger", None) diff --git a/cli/tools/compress.py b/cli/tools/compress.py index d56e347..7594ba8 100644 --- a/cli/tools/compress.py +++ b/cli/tools/compress.py @@ -10,6 +10,7 @@ from cli.tools._helpers import finalize_with_tokens, show_token_ratios from core import count_tokens +from services import access_guard def _run_memory_mcp_cli(args: list, cwd: str, timeout: int = 30) -> tuple: @@ -114,6 +115,13 @@ def handle_compress(file_path: str, mode: str, svc, def _compress_single(file_path: str, mode: str, svc, finalize, maybe_facts) -> str: """Compress a single file.""" + # Access Guard: read verdict up front — covers the map/dense_map paths + # that never reach compressor.compress_file (docs/access-guard.md §3). + denial = access_guard.check(file_path, "read", svc.project_path) + if denial: + return finalize("c3_compress", {"file_path": file_path, "mode": mode}, + access_guard.refusal(denial, file_path, "read"), + "access-denied") full = Path(svc.project_path) / file_path if not full.exists(): full = Path(file_path) @@ -157,7 +165,11 @@ def _compress_single(file_path: str, mode: str, svc, finalize, maybe_facts) -> s raw_tokens=raw_tokens, optimized_tokens=map_tokens or None, response_tokens=map_tokens) - res = svc.compressor.compress_file(str(full), mode) + try: + res = svc.compressor.compress_file(str(full), mode) + except access_guard.AccessDenied as exc: + return finalize("c3_compress", {"file_path": file_path, "mode": mode}, + exc.message, "access-denied") if "error" in res: return f"Error: {res['error']}" resp = res['compressed'] @@ -178,6 +190,11 @@ def _compress_batch(paths: list, mode: str, svc, finalize, maybe_facts) -> str: def _do_one(fp): """Returns (fp, compressed_text, raw_tokens, optimized_tokens, error).""" try: + # Access Guard: per-member read verdict — the S1 refusal becomes + # this member's batch line; other members are still served. + denial = access_guard.check(fp, "read", svc.project_path) + if denial: + return fp, None, None, None, access_guard.refusal(denial, fp, "read") full = Path(svc.project_path) / fp if not full.exists(): full = Path(fp) diff --git a/cli/tools/delegate.py b/cli/tools/delegate.py index 0f48986..f79afef 100644 --- a/cli/tools/delegate.py +++ b/cli/tools/delegate.py @@ -16,6 +16,7 @@ from pathlib import Path from core import count_tokens +from services import access_guard from services.circuit_breaker import CircuitBreaker from services.win_subprocess import harden_win_argv @@ -914,6 +915,17 @@ def _handle_codex_delegate(task: str, task_type: str, context: str, cdef = CODEX_MODELS.get(task_type, CODEX_MODELS.get("ask", {})) model = dcfg.get("codex_default_model") or cdef.get("model", "") sandbox = dcfg.get("codex_default_sandbox") or cdef.get("sandbox", "read-only") + try: + _pin = access_guard.has_active_rules(str(svc.project_path)) + except Exception: + _pin = True # evaluator failure → fail closed + if _pin: + # Access Guard rules are active → pin the sandbox to read-only no + # matter what config asked for. Codex is the one delegated backend + # whose sandbox C3 can pin; the guard's file rules cannot be pushed + # into a foreign CLI, so delegation runs read-only instead. With + # ZERO user rules this branch never fires (byte-identical behavior). + sandbox = "read-only" reasoning = dcfg.get("codex_reasoning_effort") or cdef.get("reasoning", "high") timeout = int(dcfg.get("codex_timeout", 120)) @@ -925,6 +937,8 @@ def _handle_codex_delegate(task: str, task_type: str, context: str, res = svc.compressor.compress_file(str(Path(svc.project_path) / p), "smart") if isinstance(res, dict) and res.get("compressed"): enriched += f"\n--- file: {p} ---\n{res['compressed']}" + except access_guard.AccessDenied: + raise # policy refusal must surface, never be swallowed (spec §3) except Exception: continue @@ -1048,6 +1062,8 @@ def _handle_gemini_delegate(task: str, task_type: str, context: str, res = svc.compressor.compress_file(str(Path(svc.project_path) / p), "smart") if isinstance(res, dict) and res.get("compressed"): enriched += f"\n--- file: {p} ---\n{res['compressed']}" + except access_guard.AccessDenied: + raise # policy refusal must surface, never be swallowed (spec §3) except Exception: continue @@ -1125,11 +1141,25 @@ def _gemini_memory_bridge(output: str, task_type: str, task: str, svc): def handle_delegate(task: str, task_type: str, context: str, file_path: str, - svc, finalize, backend: str = "ollama") -> str: + svc, finalize, backend: str = "ollama", + allow_write_delegation: bool = False) -> str: dcfg = svc.delegate_config or {} if not dcfg.get("enabled", True): return "[delegate:disabled]" + # ── Access Guard (T2c) delegation posture ────────────────────────────── + # With ZERO user rules `_guard_active` is False and every branch below is + # inert — behavior is byte-identical to the pre-guard tool. When rules + # exist: codex is pinned to --sandbox read-only; backends that run + # autonomously with potential write access (gemini --approval-mode yolo, + # claude -p inheriting the project's permission allowlist, codex_resume + # reusing an unpinnable prior-session sandbox) require the explicit + # allow_write_delegation=true user opt-in. Evaluator errors fail closed. + try: + _guard_active = access_guard.has_active_rules(str(svc.project_path)) + except Exception: + _guard_active = True + # --- Health checks ----------------------------------------------------- if task_type == "available": # Parallel health check across all backends @@ -1196,6 +1226,15 @@ def _check_claude(): if not dcfg.get("codex_enabled", False): return finalize("c3_delegate", {"task_type": "codex_resume"}, "[delegate:error] Codex not enabled in config", "disabled") + if _guard_active and not allow_write_delegation: + return finalize( + "c3_delegate", {"task_type": "codex_resume"}, + "[delegate:blocked] Access Guard rules are active and " + "codex_resume reuses the previous session's sandbox, which C3 " + "cannot pin to read-only. Re-run with " + "allow_write_delegation=true (explicit user opt-in) or run " + "codex directly.", + "blocked") timeout = int(dcfg.get("codex_timeout", 120)) output, ok = _run_codex_resume(task, timeout=timeout, cwd=str(svc.project_path)) @@ -1219,6 +1258,11 @@ def _check_claude(): skips: list[str] = [] chosen = "" for cand in _cascade_order(task_type, dcfg): + if (_guard_active and not allow_write_delegation + and cand in ("gemini", "claude")): + skips.append(f"{cand} blocked by Access Guard (write-capable; " + "allow_write_delegation=false)") + continue reason = _cascade_skip_reason(cand, dcfg, svc) if reason is None: chosen = cand @@ -1237,6 +1281,19 @@ def _check_claude(): _log_progress(svc, cascade_note) finalize = _with_cascade_note(finalize, cascade_note) + if backend in ("gemini", "claude") and _guard_active and not allow_write_delegation: + detail = ("--approval-mode yolo auto-approves writes" + if backend == "gemini" + else "claude -p inherits the project's tool permission allowlist") + return finalize( + "c3_delegate", {"task_type": task_type, "backend": backend}, + f"[delegate:blocked] Access Guard rules are active and the " + f"'{backend}' backend runs autonomously with potential write " + f"access ({detail}) that C3 cannot fence to the guard's rules. " + "Re-run with allow_write_delegation=true (explicit user opt-in) " + f"or run {backend} directly.", + "blocked") + if backend == "codex": _log_progress(svc, f"[delegate] Routing {task_type} → Codex...") return _handle_codex_delegate(task, task_type, context, file_path, svc, dcfg, finalize) @@ -1268,6 +1325,8 @@ def _check_claude(): res = svc.compressor.compress_file(str(Path(svc.project_path) / p), "smart") if isinstance(res, dict) and res.get("compressed"): enriched += f"\n--- file: {p} ---\n{res['compressed']}" + except access_guard.AccessDenied: + raise # policy refusal must surface, never be swallowed (spec §3) except Exception: continue diff --git a/cli/tools/edit.py b/cli/tools/edit.py index d94fc59..e21076a 100644 --- a/cli/tools/edit.py +++ b/cli/tools/edit.py @@ -13,6 +13,8 @@ import threading from pathlib import Path +from services import access_guard + # Per-file locks — keyed by resolved absolute path string. _file_locks: dict[str, threading.Lock] = {} _locks_lock = threading.Lock() @@ -243,6 +245,16 @@ def handle_edit(file_path: str, old_string: str, new_string: str, except ValueError: rel = file_path + # Access Guard: write verdict right after path resolution — covers the + # create/edit/batch modes alike (docs/access-guard.md §3). Sits alongside + # (never replaces) any dedicated vault-file guard. + op = "write" if path.exists() else "create" + denial = access_guard.check(str(path), op, svc.project_path) + if denial: + return finalize("c3_edit", {"file": file_path}, + access_guard.refusal(denial, file_path, op), + "access-denied") + # ── Create mode ─────────────────────────────────────────────────────────── # File doesn't exist + single-edit mode + empty old_string → create file. # Batch mode always requires an existing file. diff --git a/cli/tools/filter.py b/cli/tools/filter.py index d7913cf..1035e8a 100644 --- a/cli/tools/filter.py +++ b/cli/tools/filter.py @@ -12,6 +12,7 @@ from cli.tools._helpers import finalize_with_tokens, show_token_ratios from core import count_tokens +from services import access_guard def handle_filter(file_path: str, text: str, pattern: str, max_lines: int, @@ -27,6 +28,13 @@ def handle_filter(file_path: str, text: str, pattern: str, max_lines: int, return _filter_text(text, depth, svc, finalize) # File mode + # Access Guard: read verdict (docs/access-guard.md §3), checked before + # existence so probes can't distinguish missing from denied (R2). + denial = access_guard.check(file_path, "read", svc.project_path) + if denial: + return finalize("c3_filter", {"file": file_path}, + access_guard.refusal(denial, file_path, "read"), + "access-denied") full = Path(svc.project_path) / file_path if not full.exists(): full = Path(file_path) diff --git a/cli/tools/impact.py b/cli/tools/impact.py index 0d932c0..525bb4b 100644 --- a/cli/tools/impact.py +++ b/cli/tools/impact.py @@ -9,6 +9,8 @@ import sys from pathlib import Path +from services import access_guard + _SKIP_DIRS = frozenset({ ".git", ".c3", "__pycache__", "node_modules", ".venv", "venv", ".pytest_cache", ".mypy_cache", "dist", "build", ".eggs", @@ -110,6 +112,14 @@ def handle_impact(target: str, file_path: str, mode: str, svc, finalize) -> str: project = Path(svc.project_path) + # Access Guard: refuse outright when the named source file is read-denied. + if file_path: + denial = access_guard.check(file_path, "read", str(project)) + if denial: + return finalize("c3_impact", {"target": target, "mode": mode}, + access_guard.refusal(denial, file_path, "read"), + "access-denied") + # Gather references refs = _grep_git(target, project) if refs is None: @@ -125,6 +135,16 @@ def handle_impact(target: str, file_path: str, mode: str, svc, finalize) -> str: norm = file_path.replace("\\", "/").lstrip("./") by_file = {k: v for k, v in by_file.items() if not k.endswith(norm)} + # Access Guard: denied reference files never appear in the listing + # (R2 deny-ENUMERATE; fails closed on evaluator errors). + def _readable(rel: str) -> bool: + try: + return access_guard.check(rel, "read", str(project)) is None + except Exception: + return False + + by_file = {k: v for k, v in by_file.items() if _readable(k)} + # Unstaged overlay for mode='unstaged' unstaged: set = set() if mode == "unstaged": diff --git a/cli/tools/project.py b/cli/tools/project.py index 21b980f..56fabc8 100644 --- a/cli/tools/project.py +++ b/cli/tools/project.py @@ -51,6 +51,76 @@ def _runtime_for(path: str): return shared_cache().get(path) +def _is_registered(path: str) -> bool: + """True when *path* is in the cross-project registry. Fails closed.""" + try: + from services import project_runtime as _pr + resolved = _pr._resolved(path) + return any(_pr._resolved(p.get("path", "")) == resolved + for p in _pr._read_registry()) + except Exception: + return False + + +def _registration_error(action: str, resolved: dict) -> str: + return ( + f"[c3_project:error] '{resolved['name']}' ({resolved['path']}) is not " + f"a registered project — '{action}' requires registration; a bare " + "directory containing .c3/ is not enough. Fix: " + f"c3_project(action='register', project='{resolved['path']}')." + ) + + +def _proxy_guard(action: str, resolved: dict, caller_path: str, *, + file_path: str = "") -> str: + """Access Guard pre-checks for proxied ops (docs/access-guard.md §3, S5). + + Effective rules for a proxied path = global ∪ caller-project ∪ the + containing realm of the RESOLVED absolute path. Realm entry is checked + for every proxied op (a caller/global rule can fence off a whole foreign + tree); file-level verdicts run for read/compress/edit. Inner handlers + enforce again through the target runtime — this layer exists so a + foreign realm can never be MORE permissive than the caller's own policy. + Returns a refusal string, or '' when permitted. Fails closed. + """ + try: + from services import access_guard as ag + except Exception: + return ("[c3_project:error] Access Guard evaluator unavailable — " + "failing closed for proxied access.") + target_root = resolved["path"] + name = resolved["name"] + + def _deny(path: str, op: str) -> str: + try: + # caller realm (global ∪ caller rules; abs/basename globs) … + d = ag.check(path, op, caller_path or ".") + if d is None: + # … then the containing realm of the RESOLVED path. + d = ag.check(path, op, target_root) + except Exception as exc: # evaluator error → fail closed + d = ag.Denial("", "deny", "builtin", + f"evaluator error: {type(exc).__name__}") + if d is None: + return "" + return ag.refusal(d, path, op, surface="proxy", project=name) + + msg = _deny(target_root, "read") # realm entry — is the target reachable? + if msg: + return msg + if action in ("read", "compress", "edit") and (file_path or "").strip(): + op = "write" if action == "edit" else "read" + for fp in str(file_path).split(","): + fp = fp.strip() + if not fp: + continue + p = fp if Path(fp).is_absolute() else str(Path(target_root) / fp) + msg = _deny(p, op) + if msg: + return msg + return "" + + # ── Discovery renderers ──────────────────────────────────────────────────── @@ -129,6 +199,8 @@ def _do_subprojects(action: str, project: str, *, target: str = "", tag: str = " resolved = resolve_project(project) except ValueError as e: return f"[c3_project:error] {e}" + if not _is_registered(resolved["path"]): + return _registration_error(action, resolved) from services.subprojects import ( VALID_CASCADE_OPS, VALID_REMOVE_MODES, @@ -358,6 +430,22 @@ def done(resp, summ="ok"): resolved = resolve_project(project) except ValueError as e: return done(f"[c3_project:error] {e}", "error") + + # Registration gate (Access Guard T2c): a bare directory that merely + # contains .c3/ is NOT a valid proxy target — only list/scan/register/ + # info accept unregistered paths. This closes the mint-a-rule-free- + # project pivot (create a fresh .c3 dir, proxy through its empty realm). + if not _is_registered(resolved["path"]): + return done(_registration_error(action, resolved), "error") + + # Access Guard proxy verdicts (S5) — before the foreign runtime is even + # built, so a denied realm never spins up an indexer. + guard_msg = _proxy_guard( + action, resolved, str(getattr(svc, "project_path", "") or "."), + file_path=file_path) + if guard_msg: + return done(guard_msg, "denied") + try: fsvc = _runtime_for(resolved["path"]) except Exception as e: diff --git a/cli/tools/read.py b/cli/tools/read.py index bcc0a1a..b58afac 100644 --- a/cli/tools/read.py +++ b/cli/tools/read.py @@ -8,6 +8,7 @@ from cli.tools._helpers import finalize_with_tokens, maybe_related_facts from core import count_tokens +from services import access_guard def _coerce_list(val: Any) -> list[str] | None: @@ -118,6 +119,17 @@ def _worker_finalize(name, args, resp, summ, **kw): f"multi:{len(paths)}files->{combined_tokens}tok", response_tokens=combined_tokens) + # Access Guard: read verdict per file (docs/access-guard.md §3), checked + # before existence so probes get the same refusal whether or not the + # target exists (R2). Batch members hit this via the per-file dispatch, + # so allowed members are served and denied ones carry the S1 line inline. + denial = access_guard.check(file_path, "read", svc.project_path) + if denial: + resp = access_guard.refusal(denial, file_path, "read") + if finalize is None: + return resp + return finalize("c3_read", {"file": file_path}, resp, "access-denied") + full = Path(svc.project_path) / file_path if not full.exists(): full = Path(file_path) diff --git a/cli/tools/search.py b/cli/tools/search.py index a47b520..e4b2009 100644 --- a/cli/tools/search.py +++ b/cli/tools/search.py @@ -7,11 +7,36 @@ from cli.tools._helpers import finalize_with_tokens, show_token_ratios from core import count_tokens +from services import access_guard # Hard cap: responses above this are truncated to avoid filling context. _RESPONSE_TOKEN_CAP = 2400 +def _read_denied(path, svc) -> bool: + """True when Access Guard denies reading `path` (fails closed on errors). + + Used as the per-action pre-filter (docs/access-guard.md §3): denied paths + are dropped BEFORE dedup/top_k/map-building so they never appear in + search output (R2 deny-ENUMERATE) and never consume result slots. + """ + try: + return access_guard.check(path, "read", svc.project_path) is not None + except Exception: + return True + + +def _with_access_footer(resp: str, svc) -> str: + """Append the S4 limitation notice exactly once (self-gates on rules).""" + try: + footer = access_guard.search_footer(svc.project_path) + except Exception: + footer = "" + if footer and resp and footer not in resp: + return resp + "\n" + footer + return resp + + def _approx_tokens(text: str) -> int: """Fast token estimate (~4 chars/token). Use for budget checks where exact tiktoken counts are wasted work — the difference is <5% and noise.""" @@ -54,25 +79,24 @@ def handle_search(query: str, action: str, top_k: int, max_tokens: int, finalize, maybe_facts, scope) if action == "exact": - return _exact_search(query, top_k, max_tokens, svc, finalize) - - if action == "files": + resp = _exact_search(query, top_k, max_tokens, svc, finalize) + elif action == "files": resp = _files_search(query, top_k, svc, finalize) if prefetch: resp = _append_prefetch(resp, query, top_k, svc) - return _cap_response(resp) - - if action == "transcript": - return _transcript_search(query, top_k, max_tokens, svc, finalize) - - if action == "semantic": - return _semantic_search(query, top_k, max_tokens, svc, finalize, maybe_facts) - - # Default: Code Search - resp = _code_search(query, top_k, max_tokens, svc, finalize, maybe_facts) - if prefetch: - resp = _append_prefetch(resp, query, top_k, svc) - return _cap_response(resp) + resp = _cap_response(resp) + elif action == "transcript": + resp = _transcript_search(query, top_k, max_tokens, svc, finalize) + elif action == "semantic": + resp = _semantic_search(query, top_k, max_tokens, svc, finalize, maybe_facts) + else: + # Default: Code Search + resp = _code_search(query, top_k, max_tokens, svc, finalize, maybe_facts) + if prefetch: + resp = _append_prefetch(resp, query, top_k, svc) + resp = _cap_response(resp) + # S4: one Access Guard limitation footer per response (self-gated). + return _with_access_footer(resp, svc) def _exact_search(query, top_k, max_tokens, svc, finalize): @@ -116,6 +140,8 @@ def _scan_file(rel): if result is None: continue rel, file_matches = result + if _read_denied(rel, svc): + continue # R2: denied paths never appear in results chunk = f"--- {rel} ---\n" + "\n".join(file_matches) chunk_tokens = count_tokens(chunk) if total_tokens + chunk_tokens > max_tokens and matched_parts: @@ -137,6 +163,8 @@ def _scan_file(rel): def _files_search(query, top_k, svc, finalize): res = svc.indexer.search(query, top_k=top_k, include_content=False) + # Access Guard pre-filter before any map-building (R2 deny-ENUMERATE). + res = [r for r in res if not _read_denied(r.get("file", ""), svc)] if not res: return finalize("c3_search", {"action": "files"}, f"[search:files:{query}] 0 results", "0") @@ -232,6 +260,8 @@ def _semantic_search(query, top_k, max_tokens, svc, finalize, maybe_facts): return _code_search(query, top_k, max_tokens, svc, finalize, maybe_facts) results = ei.search(query, top_k=top_k, max_tokens=max_tokens) + # Access Guard pre-filter before result assembly (R2 deny-ENUMERATE). + results = [r for r in results if not _read_denied(r.get("file", ""), svc)] if not results: return finalize("c3_search", {"query": query, "action": "semantic"}, f"[semantic:{query}] 0 results (falling back to code search)", @@ -256,6 +286,8 @@ def _semantic_search(query, top_k, max_tokens, svc, finalize, maybe_facts): def _code_search(query, top_k, max_tokens, svc, finalize, maybe_facts): results = svc.indexer.search(query, top_k=max(top_k + 1, top_k * 2), max_tokens=max_tokens, include_content=True) + # Access Guard pre-filter BEFORE dedup/top_k (R2 deny-ENUMERATE). + results = [r for r in results if not _read_denied(r.get("file", ""), svc)] if not results: return finalize("c3_search", {"query": query}, f"[search:{query}] 0 results", "0") @@ -309,6 +341,9 @@ def _append_prefetch(resp: str, query: str, top_k: int, svc) -> str: if path and path not in files: files.append(path) + # Defense-in-depth: never prefetch a map for an access-denied path (R2). + files = [f for f in files if not _read_denied(f, svc)] + cfg = (svc.hybrid_config or {}).get("agent_workflows", {}) max_files = max(1, int(cfg.get("prefetch_max_files", 3))) files = files[:max_files] diff --git a/cli/tools/shell.py b/cli/tools/shell.py index bbae768..e043432 100644 --- a/cli/tools/shell.py +++ b/cli/tools/shell.py @@ -27,6 +27,7 @@ from cli.tools.filter import handle_filter from core import count_tokens +from services import access_guard # Commands that mutate repo state — trigger edit-ledger refresh after success. _GIT_MUTATING = re.compile( @@ -324,6 +325,98 @@ def _dependency_hint(cmd: str, result: dict) -> str: ) +# ── Access Guard advisory scanner (T2c) ──────────────────────────────────── +# ADVISORY ONLY — this is best-effort, NEVER enforcement. A shell command can +# reach paths through subshells, variables, globs, quoting, and indirection +# that no static token scan can see. A hit here is meaningful (the command is +# refused); a clean scan proves nothing and must never be described as +# enforcement. The enforced surfaces are the MCP read/edit tools and the cwd +# check in handle_shell. +_TOKEN_SEPARATORS = re.compile(r"[\s;&|<>()`]+") +_MSYS_DRIVE = re.compile(r"^/([a-zA-Z])(/|$)") # /c/foo → c:/foo (any drive) +_URL_SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://") +_DRIVE_PREFIX = re.compile(r"^[a-zA-Z]:[/\\]") +_MAX_SCAN_TOKENS = 64 + + +def _scan_candidates(command: str, work_cwd: str) -> list[str]: + """Path-shaped tokens from a command string (best-effort tokenizer). + + Advisory heuristics: strips quotes, splits `--flag=path` / `VAR=path`, + translates MSYS drive spellings, skips flags/URLs/`host:port`-shaped + tokens, and keeps tokens that look like paths (separator, extension, or + an existing entry under the effective cwd). Quoted paths containing + spaces are split and may be missed — documented best-effort behavior. + """ + out: list[str] = [] + for chunk in _TOKEN_SEPARATORS.split(command): + for raw in chunk.split(): + tok = raw.strip("'\"") + todo = [tok] + if "=" in tok: # --flag=path / VAR=path + todo.append(tok.split("=", 1)[1].strip("'\"")) + for t in todo: + if not t or t.startswith("-"): + continue + m = _MSYS_DRIVE.match(t) + if m: # MSYS spelling → drive path + t = m.group(1) + ":/" + t[len(m.group(1)) + 2:] + if _URL_SCHEME.match(t): + continue # URLs are not local paths + if ":" in t and not _DRIVE_PREFIX.match(t): + continue # host:port, {{cred:N}}, ADS… + pathish = "/" in t or "\\" in t or "." in t.strip(".") + if not pathish: + try: + pathish = os.path.exists(os.path.join(work_cwd, t)) + except (OSError, ValueError): + pathish = False + if not pathish: + continue + out.append(t) + if len(out) >= _MAX_SCAN_TOKENS: + return out + 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, ''). + + 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. + """ + for tok in _scan_candidates(command, work_cwd): + target = tok if os.path.isabs(tok) else os.path.join(work_cwd, 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": + continue + if denial.rule.startswith("<") and denial.rule != "": + continue # canonicalization artifact on a maybe-not-a-path token + return denial, tok + return None, "" + + +def _log_access_denied(svc, work_cwd: str, denial, surface: str) -> None: + """Record a guard refusal — with the effective cwd — in the activity log.""" + if not getattr(svc, "activity_log", None): + return + try: + svc.activity_log.log("access_denied", { + "tool": "c3_shell", "surface": surface, "cwd": work_cwd, + "rule": denial.rule, "scope": denial.scope, + }) + except Exception: + pass + + # git diagnostics whose output the caller almost always needs verbatim — never # auto-filter these, even past the line threshold. _GIT_DIAGNOSTIC = re.compile( @@ -378,6 +471,20 @@ async def handle_shell(cmd: str, cwd: str, timeout: int, filter_output: bool, work_cwd = cwd or svc.project_path work_cwd = str(Path(work_cwd).resolve()) + # ── Access Guard: HARD deny when the effective cwd is under a deny rule + # (docs/access-guard.md §3, handle_shell row). The cwd itself is + # enforced; what the command touches beyond it is only covered by the + # ADVISORY token scan below. Evaluator errors fail closed. + try: + cwd_denial = access_guard.check(work_cwd, "read", str(svc.project_path)) + except Exception as exc: + cwd_denial = access_guard.Denial( + "", "deny", "builtin", + f"evaluator error: {type(exc).__name__}") + if cwd_denial is not None and cwd_denial.kind == "deny": + _log_access_denied(svc, work_cwd, cwd_denial, "cwd") + return access_guard.refusal(cwd_denial, work_cwd, "read") + # ── credential injection (local runtime only) ───────── # `cmd` stays the RAW template form for every echo/log below; only # `exec_cmd` (never logged) carries decoded values. Cross-project proxies @@ -413,6 +520,30 @@ async def handle_shell(cmd: str, cwd: str, timeout: int, filter_output: bool, if exec_cmd != cmd and _BLOCKED.search(exec_cmd): return "[c3_shell:error] blocked pattern after credential expansion" + # ── Access Guard ADVISORY token scan — best-effort, not enforcement. + # Mirrors the _BLOCKED post-expansion re-check above: the raw template + # `cmd` is always scanned and, when credential expansion changed the + # string, the expanded `exec_cmd` is scanned too — a {{cred:NAME}} + # template can neither smuggle in nor mask a denied path. Subshells, + # variables, and globs are invisible to a token scan, so a clean pass + # is never treated (or described) as enforcement. + for scan_text in ([cmd] if exec_cmd == cmd else [cmd, exec_cmd]): + scan_denial, hit_tok = _advisory_guard_scan( + scan_text, work_cwd, str(svc.project_path)) + if scan_denial is not None: + _log_access_denied(svc, work_cwd, scan_denial, "token_scan") + msg = access_guard.refusal(scan_denial, hit_tok, "read") + ( + "\n[c3_shell:note] shell path scanning is best-effort " + "(advisory) — a denied hit refuses the whole command, but a " + "clean scan is not enforcement." + ) + if enable_creds: + try: # never leak a decoded credential via the refusal text + msg = _creds.redact_text(msg) + except Exception: + pass + return msg + ghost_root = Path(work_cwd) _ghosts_before = _list_root_files(ghost_root) git_before = ( diff --git a/cli/tools/validate.py b/cli/tools/validate.py index e3499be..838eb9e 100644 --- a/cli/tools/validate.py +++ b/cli/tools/validate.py @@ -8,6 +8,8 @@ import sys from pathlib import Path +from services import access_guard + async def _deep_type_check(full: Path, ext: str, timeout: int = 15) -> list[str]: """Run pyright (Python) or tsc (TypeScript) if available. Returns advisory type-warning strings.""" @@ -164,6 +166,11 @@ async def handle_validate(file_path: str, svc, finalize) -> str: async def _validate_one(file_path: str, svc) -> tuple: """Validate a single file and return (status_word, detail_line).""" + # Access Guard: read verdict (docs/access-guard.md §3) — the S1 refusal + # becomes the batch line for this member; other members are still served. + denial = access_guard.check(file_path, "read", svc.project_path) + if denial: + return ("SKIP", access_guard.refusal(denial, file_path, "read")) full = Path(svc.project_path) / file_path if not full.exists(): full = Path(file_path) @@ -241,6 +248,12 @@ async def _validate_one(file_path: str, svc) -> tuple: async def _validate_single(file_path: str, svc, finalize) -> str: """Original single-file validation path.""" + # Access Guard: read verdict, checked before existence (R2 probe parity). + denial = access_guard.check(file_path, "read", svc.project_path) + if denial: + return finalize("c3_validate", {"file_path": file_path}, + access_guard.refusal(denial, file_path, "read"), + "access-denied") full = Path(svc.project_path) / file_path if not full.exists(): full = Path(file_path) diff --git a/services/access_guard.py b/services/access_guard.py index df7f2c2..767896e 100644 --- a/services/access_guard.py +++ b/services/access_guard.py @@ -215,7 +215,11 @@ def load_rules(project_path: str = ".") -> tuple: def has_active_rules(project_path: str = ".") -> bool: """True when any user rules (or corrupt scopes) exist — drives S4.""" rules, corrupt = load_rules(project_path) - return bool(corrupt) or len(rules) > len(_BUILTIN_RULES) + 1 + # Count the install-dir rule only when it actually loaded: in a dev + # checkout it is absent, and a fixed "+1" made a single user rule + # invisible to S4 (footer suppressed while filtering was active). + n_baseline = len(_BUILTIN_RULES) + (1 if _install_dir_rule() else 0) + return bool(corrupt) or len(rules) > n_baseline # ── Canonicalization (docs/access-guard.md §2 — the ONE implementation) ───── @@ -387,3 +391,183 @@ def enforce(path, operation: str, project_path: str = ".", *, raise AccessDenied(denial, refusal( denial, path, operation, surface=surface, tool=tool, project=project)) + + +# ── Rule management (HUMAN surfaces only: UI / REST / `c3 access` CLI) ────── +# Privileged internal writes: this module maintains its own store (the +# `access` section of config.json) server-side on behalf of a human surface. +# The builtin .c3 write-deny governs agent tools, not the module writing its +# own state. No agent-facing mutation surface exists (docs/access-guard.md §1); +# callers are responsible for ledger/activity logging. + +# §5 coverage matrix — single source for the UI tab, c3_status, and the guide. +COVERAGE_MATRIX = ( + "Enforced: C3 MCP tools (all agents using C3) · Claude Code native tools " + "(hooks) · c3_shell (best-effort scan, advisory). NOT enforced: " + "non-Claude agents' raw shell, direct file APIs, editors." +) + +_VALID_SCOPES = ("global", "project") + + +def _scope_config_path(scope: str, project_path: str = ".") -> Path: + """Config file that owns *scope*'s rules (never the builtin pseudo-scope).""" + if scope == "project": + return Path(project_path).resolve() / ".c3" / "config.json" + if scope == "global": + base = _global_base() + if base is None: + raise ValueError("global scope unavailable: no home directory") + return base / ".c3" / "config.json" + raise ValueError(f"unknown scope '{scope}' — expected one of: " + f"{', '.join(_VALID_SCOPES)}") + + +def _norm_glob(glob) -> str: + """POSIX forward-slash canonical storage form (spec §1).""" + return str(glob or "").replace("\\", "/").strip() + + +def _str_list(value) -> list: + return [g for g in value if isinstance(g, str)] if isinstance(value, list) else [] + + +def _raw_scope_section(cfg: Path) -> tuple: + """(section_dict, corrupt) — best-effort raw view of one scope's rules.""" + if not cfg.is_file(): + return {}, False + try: + data = json.loads(cfg.read_text(encoding="utf-8")) or {} + except Exception: + return {}, True + section = data.get("access") if isinstance(data, dict) else None + if section is None: + return {}, False + if not isinstance(section, dict): + return {}, True + corrupt = bool(set(section) - _VALID_KEYS) + for kind in _VALID_KEYS: + globs = section.get(kind, []) + if not isinstance(globs, list) or validate_globs(_str_list(globs)) \ + or len(_str_list(globs)) != len(globs): + corrupt = True + return section, corrupt + + +def list_rules(project_path: str = ".") -> dict: + """Raw config view per scope + the always-on ``builtin`` pseudo-scope. + + {"builtin": {"deny": [...], "read_only": [...], "corrupt": False}, + "global": {"deny": [...], "read_only": [...], "corrupt": bool}, + "project": {...}} + + Builtin write-denies surface under ``read_only`` (they deny write-class + operations only; reads stay open). Corrupt scopes still show whatever + string globs are recoverable, flagged ``corrupt`` — that scope evaluates + deny-all until the human repairs config.json by hand. + """ + builtin_ro = list(BUILTIN_WRITE_DENY) + inst = _install_dir_rule() + if inst: + builtin_ro.append(inst.glob) + out = {"builtin": {"deny": list(BUILTIN_DENY), "read_only": builtin_ro, + "corrupt": False}} + for scope in _VALID_SCOPES: + try: + cfg = _scope_config_path(scope, project_path) + except ValueError: + out[scope] = {"deny": [], "read_only": [], "corrupt": False} + continue + section, corrupt = _raw_scope_section(cfg) + out[scope] = { + _KIND_DENY: _str_list(section.get(_KIND_DENY)), + _KIND_READ_ONLY: _str_list(section.get(_KIND_READ_ONLY)), + "corrupt": corrupt, + } + return out + + +def _load_config_for_write(cfg: Path) -> tuple: + """(config_dict, access_section) for read-modify-write of one scope. + + Refuses corrupt state: unparseable JSON, a non-dict access section, + unknown keys (especially ``allow``), or non-string-list globs are never + silently rewritten — the scope fails closed until the human repairs + config.json by hand (frozen spec §1). + """ + data = {} + if cfg.is_file(): + try: + data = json.loads(cfg.read_text(encoding="utf-8")) or {} + except Exception as exc: + raise ValueError( + f"{cfg} is not valid JSON — fix it by hand before editing " + f"access rules ({exc})") + if not isinstance(data, dict): + raise ValueError(f"{cfg} root is not a JSON object — fix it by hand") + section = data.setdefault("access", {}) + if not isinstance(section, dict) or set(section) - _VALID_KEYS: + raise ValueError( + "access section is invalid (unknown keys or wrong shape) — the " + "scope fails closed; fix config.json 'access' by hand") + for kind in _VALID_KEYS: + globs = section.get(kind, []) + if not isinstance(globs, list) or not all(isinstance(g, str) for g in globs): + raise ValueError( + f"access.{kind} must be a list of glob strings — the scope " + "fails closed; fix config.json 'access' by hand") + return data, section + + +def _write_scope_config(cfg: Path, data: dict) -> None: + """Atomic same-directory replace; privileged internal write.""" + cfg.parent.mkdir(parents=True, exist_ok=True) + tmp = cfg.with_name(cfg.name + ".tmp") + tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + os.replace(tmp, cfg) + + +def set_rule(glob, kind: str, scope: str, project_path: str = ".") -> dict: + """Add one rule to *scope*'s config. Human surfaces only; callers log. + + Returns {"glob", "kind", "scope", "added"} — ``added`` False when an + equivalent (casefolded) glob is already present. Raises ValueError on an + unknown kind/scope, an invalid glob, or a corrupt config. + """ + if kind not in _VALID_KEYS: + raise ValueError(f"unknown kind '{kind}' — expected one of: " + f"{_KIND_DENY}, {_KIND_READ_ONLY}") + canon = _norm_glob(glob) + bad = validate_globs([canon]) + if bad: + raise ValueError(f"invalid glob: {bad}") + cfg = _scope_config_path(scope, project_path) + data, section = _load_config_for_write(cfg) + target = section.setdefault(kind, []) + if any(_norm_glob(g).casefold() == canon.casefold() for g in target): + return {"glob": canon, "kind": kind, "scope": scope, "added": False} + target.append(canon) + _write_scope_config(cfg, data) + return {"glob": canon, "kind": kind, "scope": scope, "added": True} + + +def remove_rule(glob, kind: str, scope: str, project_path: str = ".") -> dict: + """Remove one rule from *scope*'s config. Human surfaces only; callers log. + + Returns {"glob", "kind", "scope", "removed"}. Raises ValueError on an + unknown kind/scope or a corrupt config (a corrupt section is repaired by + hand, never rewritten here). + """ + if kind not in _VALID_KEYS: + raise ValueError(f"unknown kind '{kind}' — expected one of: " + f"{_KIND_DENY}, {_KIND_READ_ONLY}") + canon = _norm_glob(glob) + cfg = _scope_config_path(scope, project_path) + data, section = _load_config_for_write(cfg) + target = section.get(kind, []) + keep = [g for g in target if _norm_glob(g).casefold() != canon.casefold()] + removed = len(keep) != len(target) + if removed: + section[kind] = keep + _write_scope_config(cfg, data) + return {"glob": canon, "kind": kind, "scope": scope, "removed": removed} diff --git a/services/artifact_store.py b/services/artifact_store.py index 7af661a..e75ed56 100644 --- a/services/artifact_store.py +++ b/services/artifact_store.py @@ -30,6 +30,7 @@ from pathlib import Path from typing import Optional +from services import access_guard from services.artifact_defs import ( ArtifactUnit, _norm, @@ -531,6 +532,24 @@ def restore(self, artifact: str, version: int, session_id: str = "") -> dict: warnings, written, removed = [], [], [] target_paths = {m["path"] for m in ver["members"]} + # Access Guard: write verdict per member path BEFORE any write — + # one denied member aborts the whole restore, naming that member + # (docs/access-guard.md §3). Builtin write-deny (scope=builtin, + # kind=read_only) is exempt here: restore is the audited, + # versioned surface for exactly those files (settings/instruction + # docs) and would otherwise be structurally impossible. Builtin + # hard denies and every user rule still enforce. + pending = [("write", m["path"]) for m in ver["members"]] + pending += [("delete", m["path"]) for m in entry.get("members", []) + if m["path"] not in target_paths] + for op, rel_p in pending: + denial = access_guard.check(str(self.project_path / rel_p), op, + str(self.project_path)) + if denial and not (denial.scope == "builtin" + and denial.kind == "read_only"): + raise access_guard.AccessDenied( + denial, access_guard.refusal(denial, rel_p, op)) + for m in ver["members"]: if not m.get("blob"): warnings.append(f"{m['path']}: not restorable " diff --git a/services/compressor.py b/services/compressor.py index d009adf..a4555a0 100644 --- a/services/compressor.py +++ b/services/compressor.py @@ -14,6 +14,7 @@ from typing import Any, Iterable, Optional from core import count_tokens, measure_savings +from services import access_guard from services.parser import HAS_TREE_SITTER, extract_sections_ast # Language-specific comment patterns @@ -174,6 +175,11 @@ def compress_file(self, filepath: str, mode: str = "structure") -> dict: - "summary": High-level LLM summary (requires router) - "bug_scan": Structure map + annotated exception-handling hotspots with line numbers """ + # Access Guard: read verdict before any cache or disk access — denied + # paths raise AccessDenied (docs/access-guard.md §3). Callers that + # need string returns catch it and surface exc.message. Checked + # before exists() so probes can't distinguish missing from denied. + access_guard.enforce(str(filepath), "read", str(self.project_root)) filepath = Path(filepath).resolve() if not filepath.exists(): return {"error": f"File not found: {filepath}", "compressed": ""} @@ -726,7 +732,10 @@ def compress_directory(self, dirpath: str, mode: str = "smart", skipped_protected.append(self._relative_to_project(fpath)) continue - result = self.compress_file(str(fpath), mode) + try: + result = self.compress_file(str(fpath), mode) + except access_guard.AccessDenied: + continue # R2 deny-ENUMERATE: denied paths never appear in listings if "error" not in result: results.append(result) total_original += result.get("original_tokens", 0) diff --git a/services/scanner.py b/services/scanner.py index 8d4e51d..6ba0fc3 100644 --- a/services/scanner.py +++ b/services/scanner.py @@ -86,6 +86,45 @@ def pruned(dirname: str) -> bool: return pruned +def make_access_excluder(root) -> Callable[[object], bool]: + """Index-time Access Guard exclusion (docs/access-guard.md §3, scanner row). + + Returns ``predicate(path) -> bool`` — True means the path must not enter + any index (TF-IDF/vector, .c3/MAP.md, file_memory). Rules are loaded + ONCE per walk (calling ``access_guard.check`` per file would re-read + both config scopes for every file); matching still uses only the + evaluator's own ``canonicalize`` and ``Rule.matches`` — no path or glob + logic lives here. Only deny-kind rules exclude: ``read_only`` permits + reads and therefore indexing. Fails closed: a corrupt scope or an + evaluator error excludes everything, the same deny-all posture as + ``access_guard.check``. + """ + try: + from services import access_guard as ag + except Exception: + return lambda _p: True + root_s = str(root) + try: + rules, corrupt = ag.load_rules(root_s) + except Exception: + return lambda _p: True + if corrupt: + return lambda _p: True + deny_rules = tuple(r for r in rules if r.kind == 'deny') + + def excluded(path) -> bool: + try: + canon, rel, denial = ag.canonicalize(path, root_s) + if denial is not None: + return True + name = canon.rsplit('/', 1)[-1] + return any(r.matches(canon, rel, name) for r in deny_rules) + except Exception: + return True + + return excluded + + def iter_files( root, exts: Optional[Set[str]] = None, @@ -116,6 +155,8 @@ def iter_files( if respect_gitignore: names, glob_skips = gitignore_dir_patterns(root) skip |= names + # Access Guard: denied paths never enter any index (no opt-out). + guard_excluded = make_access_excluder(root) yielded = 0 seen = 0 @@ -134,6 +175,8 @@ def iter_files( continue if exclude_parts is not None and exclude_parts(rel_parts + (d,)): continue + if guard_excluded(os.path.join(dirpath, d)): + continue # denied subtree — never descended, never listed kept.append(d) dirnames[:] = kept seen += len(filenames) + len(kept) @@ -144,6 +187,8 @@ def iter_files( continue if exclude_parts is not None and exclude_parts(rel_parts + (fname,)): continue + if guard_excluded(os.path.join(dirpath, fname)): + continue # access-denied file — never indexed yield Path(dirpath) / fname yielded += 1 if max_files is not None and yielded >= max_files: diff --git a/tests/test_access_guard_shell_project.py b/tests/test_access_guard_shell_project.py new file mode 100644 index 0000000..3977f07 --- /dev/null +++ b/tests/test_access_guard_shell_project.py @@ -0,0 +1,379 @@ +"""Access Guard T2c surface tests — c3_shell, c3_project, scanner, c3_delegate. + +Mirrors tests/test_access_guard.py GuardBase fixtures (tmp project + .c3 + +_write_access), plus global-scope isolation so a developer's real ~/.c3 +rules cannot leak in. The evaluator itself is covered by test_access_guard; +this file covers the T2c enforcement wiring: + +- handle_shell: hard cwd deny, ADVISORY token scan (relative, absolute, and + MSYS spellings), post-credential-expansion scan, denial activity logging. +- handle_project: S5 proxy refusal via target-realm rules; unregistered + directories refused beyond discovery actions. +- scanner.iter_files: index-time exclusion of denied paths (fail closed on + corrupt config). +- handle_delegate: byte-identical no-rules behavior; codex sandbox pin and + write-capable backend refusal when rules are active. +""" +from __future__ import annotations + +import asyncio +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from cli.tools import delegate as delegate_mod # noqa: E402 +from cli.tools import project as project_mod # noqa: E402 +from cli.tools import shell as shell_mod # noqa: E402 +from services import access_guard as ag # noqa: E402 +from services.scanner import iter_files # noqa: E402 + +WIN = os.name == "nt" + +_OK_RESULT = {"exit_code": 0, "stdout": "ok", "stderr": "", + "duration_ms": 1, "timed_out": False, "shell": "sh"} + + +def _finalize(name, args, resp, summ, **kw): + return resp + + +def _run(coro): + return asyncio.run(coro) + + +def _fake_svc(tmp: Path): + """Minimal svc stub with project_path + log sinks (mirrors test_c3_shell).""" + activity: list[tuple[str, dict]] = [] + + class _ActivityLog: + def log(self, kind, payload): + activity.append((kind, payload)) + + svc = SimpleNamespace( + project_path=str(tmp), + activity_log=_ActivityLog(), + edit_ledger=None, + _agent_progress_cb=None, + ) + return svc, activity + + +class GuardBase(unittest.TestCase): + """Mirror of tests/test_access_guard.py GuardBase + global isolation.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.proj = Path(self._tmp.name) + (self.proj / ".c3").mkdir() + self._gb = mock.patch.object(ag, "_global_base", return_value=None) + self._gb.start() + + def tearDown(self): + self._gb.stop() + self._tmp.cleanup() + + def _write_access(self, section, base=None): + cfg = (base or self.proj) / ".c3" / "config.json" + cfg.parent.mkdir(exist_ok=True) + cfg.write_text(json.dumps({"access": section}), encoding="utf-8") + + +# ── c3_shell: hard cwd deny ──────────────────────────────────────────────── + + +class TestShellCwdDeny(GuardBase): + def test_denied_cwd_refuses_without_spawn(self): + self._write_access({"deny": ["secrets/**"]}) + (self.proj / "secrets").mkdir() + svc, activity = _fake_svc(self.proj) + with mock.patch.object(shell_mod, "_run_sync", + side_effect=AssertionError("must not spawn")): + out = _run(shell_mod.handle_shell( + "echo hi", str(self.proj / "secrets"), 10, True, True, + svc, _finalize, enable_creds=False)) + self.assertIn("[c3-access:denied]", out) + self.assertIn("'secrets/**'", out) + denied = [p for k, p in activity if k == "access_denied"] + self.assertEqual(len(denied), 1) + self.assertTrue(denied[0]["cwd"].casefold().endswith("secrets")) + + def test_read_only_cwd_still_runs_and_logs_cwd(self): + self._write_access({"read_only": ["docs/**"]}) + (self.proj / "docs").mkdir() + svc, activity = _fake_svc(self.proj) + with mock.patch.object(shell_mod, "_run_sync", + return_value=dict(_OK_RESULT)): + out = _run(shell_mod.handle_shell( + "echo hi", str(self.proj / "docs"), 10, True, True, + svc, _finalize, enable_creds=False)) + self.assertIn("[c3_shell:OK]", out) + execs = [p for k, p in activity if k == "shell_exec"] + self.assertEqual(len(execs), 1) + self.assertTrue(execs[0]["cwd"]) # effective cwd always logged + + +# ── c3_shell: advisory token scan ────────────────────────────────────────── + + +class TestShellTokenScan(GuardBase): + def setUp(self): + super().setUp() + self._write_access({"deny": ["secrets/**"]}) + self.svc, self.activity = _fake_svc(self.proj) + + def _shell(self, cmd): + with mock.patch.object(shell_mod, "_run_sync", + return_value=dict(_OK_RESULT)): + return _run(shell_mod.handle_shell( + cmd, str(self.proj), 10, True, True, self.svc, _finalize, + enable_creds=False)) + + def test_relative_denied_token_refused(self): + out = self._shell("cat secrets/key.txt") + self.assertIn("[c3-access:denied]", out) + self.assertIn("best-effort", out) # never claims enforcement + + def test_absolute_denied_token_refused(self): + p = str(self.proj / "secrets" / "key.txt").replace("\\", "/") + out = self._shell(f"cat {p}") + self.assertIn("[c3-access:denied]", out) + + @unittest.skipUnless(WIN, "MSYS drive spellings are Windows-only") + def test_msys_spelling_refused(self): + p = str(self.proj / "secrets" / "key.txt").replace("\\", "/") + msys = "/" + p[0].lower() + p[2:] # C:/x → /c/x + out = self._shell(f"cat {msys}") + self.assertIn("[c3-access:denied]", out) + + def test_benign_command_runs(self): + out = self._shell("echo hello") + self.assertIn("[c3_shell:OK]", out) + + def test_scan_denial_logged_with_cwd(self): + self._shell("cat secrets/key.txt") + denied = [p for k, p in self.activity if k == "access_denied"] + self.assertEqual(len(denied), 1) + self.assertEqual(denied[0]["surface"], "token_scan") + self.assertTrue(denied[0]["cwd"]) + + +# ── c3_shell: post-credential-expansion scan ─────────────────────────────── + + +class TestShellPostExpansionScan(GuardBase): + """{{cred:NAME}} commands are scanned before AND after expansion.""" + + def setUp(self): + super().setUp() + self._write_access({"deny": ["secrets/**"]}) + self.svc, _ = _fake_svc(self.proj) + + def _shell_with_creds(self, cmd, expanded, used): + from services import credential_store as creds + patches = [ + mock.patch.object(creds, "expand_templates", + return_value=(expanded, used, [])), + mock.patch.object(creds, "list_entries", return_value={}), + mock.patch.object(creds, "resolve", return_value=({}, [])), + mock.patch.object(creds, "redact_text", side_effect=lambda t: t), + mock.patch.object(creds, "touch_last_used", return_value=None), + mock.patch.object(shell_mod, "_run_sync", + side_effect=AssertionError("must not spawn")), + ] + for p in patches: + p.start() + try: + return _run(shell_mod.handle_shell( + cmd, str(self.proj), 10, True, True, self.svc, _finalize)) + finally: + for p in patches: + p.stop() + + def test_template_command_still_scanned(self): + # A benign expansion must not exempt the command from scanning. + out = self._shell_with_creds( + "deploy --token={{cred:TOK}} secrets/key.txt", + "deploy --token=benignvalue secrets/key.txt", ["TOK"]) + self.assertIn("[c3-access:denied]", out) + + def test_expansion_introduced_denied_path_refused(self): + # The raw template is clean; only the POST-expansion string contains + # the denied path — proving the expanded command is scanned. + out = self._shell_with_creds( + "cat {{cred:PATHY}}", "cat secrets/key.txt", ["PATHY"]) + self.assertIn("[c3-access:denied]", out) + + +# ── c3_project: proxy guard + registration gate ──────────────────────────── + + +class TestProjectProxy(GuardBase): + def setUp(self): + super().setUp() + self._tmp2 = tempfile.TemporaryDirectory() + self.target = Path(self._tmp2.name) + (self.target / ".c3").mkdir() + (self.target / "secrets").mkdir() + (self.target / "secrets" / "k.txt").write_text("x", encoding="utf-8") + self._write_access({"deny": ["secrets/**"]}, base=self.target) + self.svc, _ = _fake_svc(self.proj) + + def tearDown(self): + self._tmp2.cleanup() + super().tearDown() + + def _registry(self, entries): + return mock.patch("services.project_runtime._read_registry", + return_value=entries) + + def test_proxied_read_of_denied_file_gets_s5(self): + reg = [{"name": "tgt", "path": str(self.target)}] + with self._registry(reg), \ + mock.patch.object(project_mod, "_runtime_for", + side_effect=AssertionError("no runtime")): + out = project_mod.handle_project( + "read", self.svc, _finalize, project=str(self.target), + file_path="secrets/k.txt") + self.assertIn("[c3-access:denied]", out) + self.assertIn("through project 'tgt'", out) + self.assertIn("'secrets/**'", out) + + def test_unregistered_directory_refused(self): + with self._registry([]), \ + mock.patch.object(project_mod, "_runtime_for", + side_effect=AssertionError("no runtime")): + out = project_mod.handle_project( + "read", self.svc, _finalize, project=str(self.target), + file_path="README.md") + self.assertIn("not a registered project", out) + self.assertIn("register", out) + + def test_registered_clean_file_reaches_runtime(self): + reg = [{"name": "tgt", "path": str(self.target)}] + with self._registry(reg), \ + mock.patch.object(project_mod, "_runtime_for", + side_effect=RuntimeError("sentinel")): + out = project_mod.handle_project( + "read", self.svc, _finalize, project=str(self.target), + file_path="ok.txt") + self.assertIn("sentinel", out) # guard passed; runtime was borrowed + + +# ── scanner.iter_files: index-time exclusion ─────────────────────────────── + + +class TestScannerExclusion(GuardBase): + def setUp(self): + super().setUp() + (self.proj / "src").mkdir() + (self.proj / "src" / "a.py").write_text("x=1", encoding="utf-8") + (self.proj / "secrets").mkdir() + (self.proj / "secrets" / "b.py").write_text("y=2", encoding="utf-8") + + def test_no_rules_yields_everything(self): + names = {p.name for p in iter_files(self.proj, exts={".py"})} + self.assertEqual(names, {"a.py", "b.py"}) + + def test_denied_paths_never_enter_walk(self): + self._write_access({"deny": ["secrets/**"]}) + names = {p.name for p in iter_files(self.proj, exts={".py"})} + self.assertEqual(names, {"a.py"}) + + def test_basename_deny_excluded(self): + self._write_access({"deny": ["*.pem"]}) + (self.proj / "src" / "k.pem").write_text("p", encoding="utf-8") + found = {p.name for p in iter_files(self.proj)} + self.assertNotIn("k.pem", found) + self.assertIn("a.py", found) + + def test_corrupt_config_excludes_everything(self): + (self.proj / ".c3" / "config.json").write_text("{nope", + encoding="utf-8") + self.assertEqual(list(iter_files(self.proj, exts={".py"})), []) + + +# ── c3_delegate: guard posture ───────────────────────────────────────────── + + +class TestDelegateGuard(GuardBase): + def _svc(self, dcfg): + svc, _ = _fake_svc(self.proj) + svc.delegate_config = dcfg + svc.ollama_client = None + svc.compressor = None + return svc + + def test_no_rules_gemini_path_unchanged(self): + svc = self._svc({"enabled": True}) + with mock.patch.object(delegate_mod, "_handle_gemini_delegate", + return_value="GEMINI-SENTINEL") as h: + out = delegate_mod.handle_delegate( + "t-norules-gem", "ask", "", "", svc, _finalize, + backend="gemini") + self.assertEqual(out, "GEMINI-SENTINEL") + h.assert_called_once() + + def test_rules_active_gemini_refused_without_opt_in(self): + self._write_access({"deny": ["secrets/**"]}) + svc = self._svc({"enabled": True}) + with mock.patch.object(delegate_mod, "_handle_gemini_delegate", + return_value="GEMINI-SENTINEL") as h: + out = delegate_mod.handle_delegate( + "t-rules-gem", "ask", "", "", svc, _finalize, + backend="gemini") + self.assertIn("allow_write_delegation", out) + h.assert_not_called() + + def test_rules_active_opt_in_passes_through(self): + self._write_access({"deny": ["secrets/**"]}) + svc = self._svc({"enabled": True}) + with mock.patch.object(delegate_mod, "_handle_gemini_delegate", + return_value="GEMINI-SENTINEL"): + out = delegate_mod.handle_delegate( + "t-optin-gem", "ask", "", "", svc, _finalize, + backend="gemini", allow_write_delegation=True) + self.assertEqual(out, "GEMINI-SENTINEL") + + def _run_codex_capture(self, svc): + seen = {} + + def fake_run(**kw): + seen.update(kw) + return "CODEX-OUT", True + + with mock.patch.object(delegate_mod, "_run_codex", + side_effect=fake_run), \ + mock.patch.object(delegate_mod, "_codex_available", True), \ + mock.patch.object(delegate_mod, "_codex_memory_bridge", + return_value=None): + out = delegate_mod.handle_delegate( + f"t-codex-{id(svc)}", "ask", "", "", svc, _finalize, + backend="codex") + return out, seen + + def test_rules_active_codex_sandbox_pinned(self): + self._write_access({"deny": ["secrets/**"]}) + svc = self._svc({"enabled": True, "codex_enabled": True, + "codex_default_sandbox": "workspace-write"}) + out, seen = self._run_codex_capture(svc) + self.assertEqual(out, "CODEX-OUT") + self.assertEqual(seen.get("sandbox"), "read-only") + + def test_no_rules_codex_sandbox_from_config(self): + svc = self._svc({"enabled": True, "codex_enabled": True, + "codex_default_sandbox": "workspace-write"}) + out, seen = self._run_codex_capture(svc) + self.assertEqual(out, "CODEX-OUT") + self.assertEqual(seen.get("sandbox"), "workspace-write") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_access_guard_wiring.py b/tests/test_access_guard_wiring.py new file mode 100644 index 0000000..f26fa11 --- /dev/null +++ b/tests/test_access_guard_wiring.py @@ -0,0 +1,330 @@ +"""Access Guard T2 wiring tests — enforcement surfaces (docs/access-guard.md §3). + +The evaluator itself is pinned by tests/test_access_guard.py (T1). This file +drives the wired surfaces: compressor/read/edit refusals, batch read +per-member behavior, search deny-ENUMERATE + the S4 footer, validate/filter/ +impact read verdicts, ArtifactStore.restore write verdicts (and their tool- +boundary conversion), and the Oracle c3_bridge bypass path. +""" +from __future__ import annotations + +import asyncio +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from cli.tools.artifacts import handle_artifacts # noqa: E402 +from cli.tools.compress import handle_compress # noqa: E402 +from cli.tools.edit import handle_edit # noqa: E402 +from cli.tools.filter import handle_filter # noqa: E402 +from cli.tools.impact import handle_impact # noqa: E402 +from cli.tools.read import handle_read # noqa: E402 +from cli.tools.search import handle_search # noqa: E402 +from cli.tools.validate import handle_validate # noqa: E402 +from services import access_guard as ag # noqa: E402 +from services.artifact_store import ArtifactStore # noqa: E402 +from services.compressor import CodeCompressor # noqa: E402 + +DENIED = "[c3-access:denied]" +READ_ONLY = "[c3-access:read_only]" +LIMITED = "[c3-access:limited]" + + +def _finalize(name, args, resp, summ="", **kw): + return resp + + +def _no_facts(*_a, **_kw): + return "" + + +class WiringBase(unittest.TestCase): + """Mirrors GuardBase (temp project + .c3) plus the parity-test svc stub.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.proj = Path(self._tmp.name) + (self.proj / ".c3").mkdir() + # Isolate from the developer's real ~/.c3 access rules. + self._home = patch.object(ag, "_global_base", return_value=None) + self._home.start() + self.svc = self._make_svc() + + def tearDown(self): + self._home.stop() + self._tmp.cleanup() + + def _make_svc(self): + svc = MagicMock() + svc.project_path = str(self.proj) + svc.hybrid_config = {} + svc.session_mgr = None + svc.edit_ledger = None + svc.activity_log = None + svc.validation_cache = None + svc.memory = None + return svc + + def _write_access(self, section): + cfg = self.proj / ".c3" / "config.json" + cfg.write_text(json.dumps({"access": section}), encoding="utf-8") + + def _write(self, rel, text="data\n"): + p = self.proj / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + return p + + +class TestReadWiring(WiringBase): + def test_denied_read_refused(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("secrets/k.txt", "TOPSECRET\n") + resp = handle_read("secrets/k.txt", svc=self.svc, finalize=_finalize) + self.assertIn(DENIED, resp) + self.assertNotIn("TOPSECRET", resp) + + def test_probe_gets_same_refusal_when_target_missing(self): + self._write_access({"deny": ["secrets/**"]}) + resp = handle_read("secrets/ghost.txt", svc=self.svc, finalize=_finalize) + self.assertIn(DENIED, resp) + self.assertNotIn("not found", resp.lower()) + + def test_batch_read_serves_allowed_refuses_denied(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("ok.py", "x = 1\n") + self._write("secrets/k.txt", "TOPSECRET\n") + resp = handle_read("ok.py,secrets/k.txt", lines=[1, 1], + svc=self.svc, finalize=_finalize) + self.assertIn("x = 1", resp) + self.assertIn(DENIED, resp) + self.assertNotIn("TOPSECRET", resp) + + +class TestEditWiring(WiringBase): + def test_denied_write_refused(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("secrets/k.txt", "data\n") + resp = handle_edit("secrets/k.txt", "data", "x", "", "", False, + self.svc, _finalize) + self.assertIn(DENIED, resp) + self.assertEqual((self.proj / "secrets/k.txt").read_text(encoding="utf-8"), + "data\n") + + def test_denied_create_refused(self): + self._write_access({"deny": ["secrets/**"]}) + resp = handle_edit("secrets/new.txt", "", "content", "", "", False, + self.svc, _finalize) + self.assertIn(DENIED, resp) + self.assertFalse((self.proj / "secrets/new.txt").exists()) + + def test_read_only_write_refused(self): + self._write_access({"read_only": ["docs/**"]}) + self._write("docs/a.md", "hello\n") + resp = handle_edit("docs/a.md", "hello", "world", "", "", False, + self.svc, _finalize) + self.assertIn(READ_ONLY, resp) + self.assertEqual((self.proj / "docs/a.md").read_text(encoding="utf-8"), + "hello\n") + + def test_allowed_edit_still_works(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("ok.txt", "hello\n") + resp = handle_edit("ok.txt", "hello", "world", "", "", False, + self.svc, _finalize) + self.assertNotIn(DENIED, resp) + self.assertEqual((self.proj / "ok.txt").read_text(encoding="utf-8"), + "world\n") + + +class TestCompressWiring(WiringBase): + def _compressor(self): + return CodeCompressor(cache_dir=str(self.proj / ".c3" / "cache"), + project_root=str(self.proj)) + + def test_compressor_raises_access_denied(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("secrets/k.py", "TOPSECRET = 1\n") + comp = self._compressor() + with self.assertRaises(ag.AccessDenied) as ctx: + comp.compress_file(str(self.proj / "secrets/k.py"), "smart") + self.assertIn(DENIED, ctx.exception.message) + + def test_handle_compress_refuses(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("secrets/k.py", "TOPSECRET = 1\n") + self.svc.compressor = self._compressor() + resp = handle_compress("secrets/k.py", "smart", self.svc, + _finalize, _no_facts) + self.assertIn(DENIED, resp) + self.assertNotIn("TOPSECRET", resp) + + def test_handle_compress_map_mode_refuses(self): + # map/dense_map never reach compressor.compress_file — the handler + # check must cover them too. + self._write_access({"deny": ["secrets/**"]}) + self._write("secrets/k.py", "TOPSECRET = 1\n") + resp = handle_compress("secrets/k.py", "map", self.svc, + _finalize, _no_facts) + self.assertIn(DENIED, resp) + + +class TestSearchWiring(WiringBase): + _RESULTS = [ + {"file": "secrets/k.py", "lines": "1-2", "name": "", "type": "code", + "content": "TOPSECRET", "tokens": 2, "score": 1.0, "file_tokens": 2}, + {"file": "ok.py", "lines": "1-2", "name": "", "type": "code", + "content": "x = 1", "tokens": 2, "score": 0.9, "file_tokens": 2}, + ] + + def test_code_search_hides_denied_and_carries_footer(self): + self._write_access({"deny": ["secrets/**"], "read_only": ["docs/**"]}) + self.svc.indexer.search = MagicMock( + return_value=[dict(r) for r in self._RESULTS]) + resp = handle_search("k", "code", 3, 1200, self.svc, + _finalize, _no_facts) + self.assertNotIn("secrets", resp) + self.assertNotIn("TOPSECRET", resp) + self.assertIn("ok.py", resp) + self.assertIn(LIMITED, resp) + + def test_single_user_rule_still_carries_footer(self): + # Regression: has_active_rules off-by-one hid S4 when exactly one + # user rule existed in a dev checkout. + self._write_access({"deny": ["secrets/**"]}) + self.svc.indexer.search = MagicMock( + return_value=[dict(r) for r in self._RESULTS]) + resp = handle_search("k", "code", 3, 1200, self.svc, + _finalize, _no_facts) + self.assertIn(LIMITED, resp) + + def test_files_search_hides_denied(self): + self._write_access({"deny": ["secrets/**"]}) + self.svc.indexer.search = MagicMock(return_value=[ + {"file": "secrets/k.py", "lines": "1", "name": "", "type": "file"}]) + resp = handle_search("k", "files", 3, 1200, self.svc, + _finalize, _no_facts) + self.assertNotIn("secrets/k.py", resp) + self.assertIn(LIMITED, resp) + + def test_exact_search_hides_denied(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("ok.py", "needle = 1\n") + self._write("secrets/k.py", "needle = 2\n") + self.svc.file_memory.list_tracked = MagicMock( + return_value=["ok.py", "secrets/k.py"]) + resp = handle_search("needle", "exact", 3, 1200, self.svc, + _finalize, _no_facts) + self.assertIn("ok.py", resp) + self.assertNotIn("secrets/k.py", resp) + self.assertIn(LIMITED, resp) + + def test_no_rules_no_footer(self): + self.svc.indexer.search = MagicMock(return_value=[ + dict(self._RESULTS[1])]) + resp = handle_search("k", "code", 3, 1200, self.svc, + _finalize, _no_facts) + self.assertNotIn(LIMITED, resp) + + +class TestValidateWiring(WiringBase): + def test_single_refused(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("secrets/k.py", "x = 1\n") + resp = asyncio.run(handle_validate("secrets/k.py", self.svc, _finalize)) + self.assertIn(DENIED, resp) + + def test_batch_serves_allowed_refuses_denied(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("ok.py", "x = 1\n") + self._write("secrets/k.py", "x = 1\n") + resp = asyncio.run( + handle_validate("ok.py,secrets/k.py", self.svc, _finalize)) + self.assertIn(DENIED, resp) + self.assertIn("PASS", resp) + + +class TestFilterWiring(WiringBase): + def test_file_mode_refused(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("secrets/k.txt", "TOPSECRET\n") + resp = handle_filter("secrets/k.txt", "", "", 500, "smart", True, + self.svc, _finalize) + self.assertIn(DENIED, resp) + self.assertNotIn("TOPSECRET", resp) + + +class TestImpactWiring(WiringBase): + def test_denied_reference_files_hidden(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("lib.py", "target_sym = 1\n") + self._write("secrets/hidden.py", "target_sym = 2\n") + resp = handle_impact("target_sym", "", "", self.svc, _finalize) + self.assertIn("lib.py", resp) + self.assertNotIn("secrets", resp) + + def test_denied_file_path_arg_refused(self): + self._write_access({"deny": ["secrets/**"]}) + self._write("secrets/hidden.py", "target_sym = 2\n") + resp = handle_impact("target_sym", "secrets/hidden.py", "", + self.svc, _finalize) + self.assertIn(DENIED, resp) + + +class TestArtifactRestoreWiring(WiringBase): + def _two_versions(self, rel, v1, v2): + store = ArtifactStore(str(self.proj)) + self._write(rel, v1) + store.scan() + self._write(rel, v2) + store.scan() + return store + + def test_restore_refused_by_user_rule(self): + store = self._two_versions("CLAUDE.md", "v1\n", "v2\n") + self._write_access({"read_only": ["CLAUDE.md"]}) + with self.assertRaises(ag.AccessDenied) as ctx: + store.restore("instructions:CLAUDE.md", 1) + self.assertIn("CLAUDE.md", ctx.exception.message) + # Abort before any write: live file untouched. + self.assertEqual((self.proj / "CLAUDE.md").read_text(encoding="utf-8"), + "v2\n") + + def test_restore_settings_exempt_from_builtin_write_deny(self): + # The builtin agent-surface write-deny on **/.claude/settings*.json + # must NOT break restore — restore is the audited surface for exactly + # those files. User rules (above) still enforce. + store = self._two_versions(".claude/settings.local.json", + '{"a": 1}', '{"a": 2}') + res = store.restore("settings:.claude/settings.local.json", 1) + self.assertTrue(res.get("restored")) + + def test_tool_boundary_converts_to_refusal_string(self): + store = self._two_versions("CLAUDE.md", "v1\n", "v2\n") + self._write_access({"read_only": ["CLAUDE.md"]}) + self.svc.artifact_store = store + resp = handle_artifacts("restore", self.svc, _finalize, + artifact="instructions:CLAUDE.md", version=1) + self.assertIn(READ_ONLY, resp) + + +class TestOracleBridgeWiring(WiringBase): + def test_bridge_read_path_refused(self): + from oracle.services.c3_bridge import C3Bridge + self._write_access({"deny": ["secrets/**"]}) + self._write("secrets/k.txt", "TOPSECRET\n") + bridge = C3Bridge.__new__(C3Bridge) # skip heavy runtime-cache init + bridge.get_runtime = lambda p: self.svc + res = bridge.c3_read(str(self.proj), "secrets/k.txt") + self.assertIn(DENIED, res["result"]) + self.assertNotIn("TOPSECRET", res["result"]) + + +if __name__ == "__main__": + unittest.main() From 1aee991eae298f07b4706702a77850263aabe149 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Mon, 27 Jul 2026 14:06:02 -0400 Subject: [PATCH 5/8] =?UTF-8?q?test(access-guard):=20T5=20=E2=80=94=20CI?= =?UTF-8?q?=20meta-tests=20against=20coverage=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three structural guards: (1) every cli/tools module doing raw file I/O must import access_guard or be allowlisted with a written reason — a new tool that opens files unguarded fails CI before it ships a hole; (2) direct Path.resolve() is banned in enforcement-adjacent files outside the two canonicalizer implementations — path identity stays single-sourced; (3) denial-storm regression: 30 foreign activity entries after a c3 signal must not evict the enforcement evidence window. The resolve() ban caught its first offender during development (a config -path helper in the in-flight surfaces slice) — reviewed and allowlisted as store plumbing, which is exactly the decision flow the test exists to force. Claude-Session: https://claude.ai/code/session_01CZVZgYdy4m9z9J9mGT5jp6 --- tests/test_access_guard_meta.py | 155 ++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/test_access_guard_meta.py diff --git a/tests/test_access_guard_meta.py b/tests/test_access_guard_meta.py new file mode 100644 index 0000000..3fa7e46 --- /dev/null +++ b/tests/test_access_guard_meta.py @@ -0,0 +1,155 @@ +"""T5 meta-tests — structural guards against Access Guard coverage drift. + +The board's top residual risk: correctness depends on every current AND +future file-touching call site routing through the guarded service layer. +These tests make drift a CI failure instead of a silent hole: + +1. Every tool-handler module under cli/tools/ that performs raw file I/O + must import services.access_guard (or be explicitly allowlisted WITH a + reason). A new tool that opens files without consulting the guard fails + here first. +2. Enforcement-adjacent modules must not call Path.resolve() directly — + path identity flows through ONE canonicalizer (access_guard.canonicalize + / _hook_utils.canonical_key). The first drive-by resolve() reintroduces + the case/prefix mismatch bypasses the shipped regressions can't see. +3. Denial-storm regression: a burst of non-tool_call activity entries after + a c3 signal must not evict hook_pretool_enforce's evidence window. +""" +from __future__ import annotations + +import json +import re +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from cli import _hook_utils # noqa: E402 + +sys.modules.setdefault("_hook_utils", _hook_utils) + + +_RAW_IO = re.compile( + r"(?" + for line in src.splitlines(): + m = re.match(r"\s*def\s+(\w+)", line) + if m: + current = m.group(1) + if ".resolve()" in line: + hits.append(current) + return hits + + def test_no_direct_resolve_outside_canonicalizers(self): + offenders = {} + for rel, allowed in _RESOLVE_BAN.items(): + src = (REPO_ROOT / rel).read_text(encoding="utf-8") + bad = [fn for fn in self._functions_using_resolve(src) + if fn not in allowed and fn != ""] + if bad: + offenders[rel] = sorted(set(bad)) + self.assertEqual( + offenders, {}, + f"direct Path.resolve() in enforcement-adjacent code: " + f"{offenders}. Route through canonical_key / " + f"access_guard.canonicalize instead.") + + +class TestDenialStormRegression(unittest.TestCase): + def test_30_foreign_entries_do_not_evict_evidence(self): + import cli.hook_pretool_enforce as enforce + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp) + (base / ".c3").mkdir() + log = base / ".c3" / "activity_log.jsonl" + lines = [json.dumps({"type": "tool_call", "tool": "c3_search"})] + lines += [json.dumps({"type": "access_denied", + "rule": "secrets/**", "n": i}) + for i in range(30)] + log.write_text("\n".join(lines) + "\n", encoding="utf-8") + state = {"session_id": "", "last_c3_call": None, + "unlocked_files": {}} + allowed, via = enforce._check_c3_used( + base, state, "Read", {"file_path": "x.py"}) + self.assertTrue( + allowed, + "30 foreign activity entries evicted the c3_search evidence " + "— the tail scan must count tool_call entries only") + self.assertEqual(via, "activity") + + +if __name__ == "__main__": + unittest.main() From 40f28a7291e417c23f4f87ca1a351b1c4a1c8116 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Mon, 27 Jul 2026 14:07:08 -0400 Subject: [PATCH 6/8] =?UTF-8?q?feat(access-guard):=20T3=20=E2=80=94=20huma?= =?UTF-8?q?n=20surfaces:=20UI=20tab,=20REST,=20CLI,=20status=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit services/access_guard.py gains the append-only write API (list_rules / set_rule / remove_rule; atomic config writes, glob validation, corrupt sections never silently rewritten — mutations refuse while the scope stays deny-all) + COVERAGE_MATRIX as the single §5 source. REST /api/access (GET/POST/DELETE + check probe returning the exact refusal string), audited to activity log + edit ledger like credentials. New Access Guard UI tab (scope-grouped rules, locked builtins, typed-glob confirm on deny deletes, test-path probe, corrupt-scope banner, coverage footer). `c3 access list|add|remove|check` CLI, ledger-logged via=cli. c3_status view='access' — read-only, plan-mode safe. All rule mutations remain human-only; no agent-facing mutation surface exists. 20 new surface tests; verified here with a 71-test combined run. Claude-Session: https://claude.ai/code/session_01CZVZgYdy4m9z9J9mGT5jp6 --- cli/c3.py | 115 ++++++++++ cli/commands/parser.py | 36 +++ cli/server.py | 102 +++++++++ cli/tools/status.py | 32 ++- cli/ui/app.js | 4 + cli/ui/components/access.js | 332 +++++++++++++++++++++++++++ tests/test_access_guard_surfaces.py | 335 ++++++++++++++++++++++++++++ 7 files changed, 955 insertions(+), 1 deletion(-) create mode 100644 cli/ui/components/access.js create mode 100644 tests/test_access_guard_surfaces.py diff --git a/cli/c3.py b/cli/c3.py index a1bfcc0..331d868 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -5904,6 +5904,120 @@ def _creds_cmd_import(args, project_path: str) -> None: "(use --overwrite to replace)") +def cmd_access(args): + """Access Guard rule management — human-only mutation surface (spec §1).""" + sub = getattr(args, "access_cmd", None) + if not sub: + print("Usage: c3 access {list,add,remove,check} [args]") + return + + project_path = getattr(args, "project_path", ".") or "." + + if sub == "list": + _access_cmd_list(args, project_path) + elif sub == "add": + _access_cmd_add(args, project_path) + elif sub == "remove": + _access_cmd_remove(args, project_path) + elif sub == "check": + _access_cmd_check(args, project_path) + else: + print(f"Unknown access subcommand: {sub}") + + +def _access_scope(args) -> str: + return "global" if getattr(args, "use_global", False) else "project" + + +def _access_audit(action: str, glob: str, kind: str, scope: str, + project_path: str) -> None: + """Ledger + activity log for CLI rule mutations. Identifiers only; failure-safe.""" + try: + from services.activity_log import ActivityLog + ActivityLog(project_path).log("access_action", { + "kind": "access", "action": action, "glob": glob, + "rule_kind": kind, "scope": scope, "via": "cli", + }) + except Exception: + pass + try: + from services.edit_ledger import EditLedger + EditLedger(project_path).log_edit( + file=f"access://{glob}", change_type=f"access_{action}", + summary=f"{action} {kind} rule '{glob}' ({scope}) via `c3 access`", + tags=["access", action], + detail={"kind": "access", "action": action, "glob": glob, + "rule_kind": kind, "scope": scope}, + ) + except Exception: + pass + + +def _access_cmd_list(args, project_path: str) -> None: + from services import access_guard + + scopes = access_guard.list_rules(project_path) + notes = {"builtin": "built-in, always on", + "global": "~/.c3/config.json", + "project": ".c3/config.json"} + for scope in ("builtin", "global", "project"): + sec = scopes.get(scope) or {} + rules = [(k, g) for k in ("deny", "read_only") for g in sec.get(k, [])] + print(f"[{scope}] ({notes[scope]}) — {len(rules)} rule(s)") + for kind, glob in rules: + print(f" {kind:<10} {glob}") + if sec.get("corrupt"): + print(" [warn] access section invalid — scope fails closed " + "(deny-all); fix config.json 'access' by hand") + print() + print(access_guard.COVERAGE_MATRIX) + + +def _access_cmd_add(args, project_path: str) -> None: + from services import access_guard + + scope = _access_scope(args) + try: + result = access_guard.set_rule(args.glob, args.kind, scope, project_path) + except ValueError as exc: + print(f"[error] {exc}") + return + if result["added"]: + _access_audit("add", result["glob"], args.kind, scope, project_path) + print(f"[OK] Added {args.kind} rule '{result['glob']}' (scope={scope})") + else: + print(f"[=] Rule already present: '{result['glob']}' " + f"({args.kind}, {scope})") + + +def _access_cmd_remove(args, project_path: str) -> None: + from services import access_guard + + scope = _access_scope(args) + try: + result = access_guard.remove_rule(args.glob, args.kind, scope, project_path) + except ValueError as exc: + print(f"[error] {exc}") + return + if result["removed"]: + _access_audit("remove", result["glob"], args.kind, scope, project_path) + print(f"[OK] Removed {args.kind} rule '{result['glob']}' (scope={scope})") + else: + print(f"[error] no {args.kind} rule matching '{result['glob']}' " + f"in {scope} scope") + + +def _access_cmd_check(args, project_path: str) -> None: + from services import access_guard + + op = getattr(args, "op", "read") or "read" + denial = access_guard.check(args.target, op, project_path) + if denial is None: + print(f"[OK] {op} allowed: {args.target}") + else: + print(access_guard.refusal(denial, args.target, op)) + + def cmd_jira(args): """Jira Cloud / Data Center credential + workspace management.""" sub = getattr(args, "jira_cmd", None) @@ -7204,6 +7318,7 @@ def main(): "bitbucket": cmd_bitbucket, "jira": cmd_jira, "creds": cmd_creds, + "access": cmd_access, "oracle": cmd_oracle, "upgrade": cmd_upgrade, } diff --git a/cli/commands/parser.py b/cli/commands/parser.py index f8fb0f6..32e8ab5 100644 --- a/cli/commands/parser.py +++ b/cli/commands/parser.py @@ -447,6 +447,42 @@ def build_parser(version: str, parse_cli_ide_arg): cr_import.add_argument("--overwrite", action="store_true", help="Replace entries already registered in the target scope") cr_import.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)") + # ── Access Guard (v2.62.0) ────────────────────────────────────────── + # Human-only mutation surface (frozen spec docs/access-guard.md §1): + # rule changes happen here or in the UI tab, never via an agent tool. + p_access = subparsers.add_parser( + "access", + help="Access Guard — path deny/read-only rules the agent must respect", + ) + access_subs = p_access.add_subparsers(dest="access_cmd") + + ac_list = access_subs.add_parser( + "list", help="Show rules per scope (builtin + global + project) and coverage" + ) + ac_list.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)") + + ac_add = access_subs.add_parser("add", help="Add a rule to the project (or --global) scope") + ac_add.add_argument("glob", help="POSIX glob (** crosses directories), e.g. 'secrets/**' or '*.pem'") + ac_add.add_argument("--kind", choices=["deny", "read_only"], required=True, + help="deny = no read/write/enumerate; read_only = no write") + ac_add.add_argument("--global", dest="use_global", action="store_true", + help="Store in the global scope (~/.c3) so every C3 project enforces it") + ac_add.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)") + + ac_remove = access_subs.add_parser("remove", help="Remove a rule from the project (or --global) scope") + ac_remove.add_argument("glob", help="The stored glob to remove (case-insensitive match)") + ac_remove.add_argument("--kind", choices=["deny", "read_only"], required=True, + help="Which rule list the glob lives in") + ac_remove.add_argument("--global", dest="use_global", action="store_true", + help="Remove from the global scope (~/.c3)") + ac_remove.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)") + + ac_check = access_subs.add_parser("check", help="Test a path: verdict + matched rule + refusal string") + ac_check.add_argument("target", help="Path to test (absolute or project-relative)") + ac_check.add_argument("--op", choices=["read", "write"], default="read", + help="Operation to evaluate (default: read)") + ac_check.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)") + # ── Oracle Discovery API (v2.32.0) ────────────────────────────────── p_oracle = subparsers.add_parser( "oracle", diff --git a/cli/server.py b/cli/server.py index ce6a65f..a709470 100644 --- a/cli/server.py +++ b/cli/server.py @@ -201,6 +201,7 @@ def _cleanup_runtime(): "ui/components/bitbucket.js", "ui/components/jira.js", "ui/components/credentials.js", + "ui/components/access.js", "ui/components/instructions.js", "ui/components/settings.js", "ui/components/chat.js", @@ -2785,6 +2786,107 @@ def api_credentials_check(name): }) +# ── Access Guard (v2.62.0) ────────────────────────────── +# Human-only rule management: ALL mutations arrive from this UI (or the +# `c3 access` CLI) and are ledger/activity-logged. No agent-facing mutation +# surface exists — the agent's read view is c3_status; its runtime interface +# is the refusal string (docs/access-guard.md §1). + + +def _access_route_audit(action: str, glob: str, kind: str, scope: str) -> None: + """Rule identifiers only. Failure-safe.""" + try: + from services.activity_log import ActivityLog + ActivityLog(str(PROJECT_PATH)).log("access_action", { + "kind": "access", "action": action, "glob": glob, + "rule_kind": kind, "scope": scope, "via": "ui", + }) + except Exception: + pass + try: + from services.edit_ledger import EditLedger + EditLedger(str(PROJECT_PATH)).log_edit( + file=f"access://{glob}", change_type=f"access_{action}", + summary=f"{action} {kind} rule '{glob}' ({scope}) via Access Guard UI", + tags=["access", action], + detail={"kind": "access", "action": action, "glob": glob, + "rule_kind": kind, "scope": scope}, + ) + except Exception: + pass + + +@app.route('/api/access', methods=['GET']) +def api_access_list(): + """Rule registry: builtin pseudo-scope + global + project, plus the §5 + coverage matrix string and the list of corrupt (deny-all) scopes.""" + from services import access_guard + scopes = access_guard.list_rules(str(PROJECT_PATH)) + corrupt = [s for s in ("global", "project") + if (scopes.get(s) or {}).get("corrupt")] + return jsonify({"scopes": scopes, "corrupt": corrupt, + "coverage": access_guard.COVERAGE_MATRIX}) + + +@app.route('/api/access', methods=['POST']) +def api_access_add(): + """Add a rule {glob, kind, scope}. Human surface — mutations audited.""" + from services import access_guard + data = request.get_json() or {} + glob = str(data.get("glob") or "") + kind = str(data.get("kind") or "").strip() + scope = str(data.get("scope") or "project").strip() + try: + result = access_guard.set_rule(glob, kind, scope, str(PROJECT_PATH)) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if result["added"]: + _access_route_audit("add", result["glob"], kind, scope) + return jsonify({"rule": result}) + + +@app.route('/api/access', methods=['DELETE']) +def api_access_remove(): + """Remove a rule (query args or JSON body: glob, kind, scope).""" + from services import access_guard + data = request.get_json(silent=True) or {} + glob = str(request.args.get("glob") or data.get("glob") or "") + kind = str(request.args.get("kind") or data.get("kind") or "").strip() + scope = str(request.args.get("scope") or data.get("scope") or "project").strip() + try: + result = access_guard.remove_rule(glob, kind, scope, str(PROJECT_PATH)) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if result["removed"]: + _access_route_audit("remove", result["glob"], kind, scope) + return jsonify(result) + + +@app.route('/api/access/check', methods=['GET', 'POST']) +def api_access_check(): + """Test-path probe: verdict + matched rule + scope + exact refusal string. + Read-only — never mutates rules; safe to call freely.""" + from services import access_guard + data = request.get_json(silent=True) or {} + path = str(request.args.get("path") or data.get("path") or "").strip() + op = str(request.args.get("op") or data.get("op") or "read").strip() + if not path: + return jsonify({"error": "path required"}), 400 + if op not in ("read", "write", "create", "delete"): + return jsonify({"error": f"unknown op '{op}' — expected " + "read|write|create|delete"}), 400 + denial = access_guard.check(path, op, str(PROJECT_PATH)) + if denial is None: + return jsonify({"path": path, "op": op, "verdict": "allowed", + "rule": "", "scope": "", "refusal": ""}) + return jsonify({ + "path": path, "op": op, + "verdict": "read_only" if denial.kind == "read_only" else "denied", + "rule": denial.rule, "scope": denial.scope, "reason": denial.reason, + "refusal": access_guard.refusal(denial, path, op), + }) + + @app.route('/api/delegate', methods=['POST']) def api_delegate(): """Delegate a task to local LLM with optional streaming.""" diff --git a/cli/tools/status.py b/cli/tools/status.py index 0319506..791abc4 100644 --- a/cli/tools/status.py +++ b/cli/tools/status.py @@ -30,6 +30,9 @@ def handle_status(view: str, detailed: bool, svc, finalize) -> str: if view == "ghost_files": return _ghost_files_view(svc, finalize) + if view == "access": + return _access_view(svc, finalize) + # Graceful migration for removed views removed = { "tokens": "Merged into 'budget'. Use c3_status(view='budget', detailed=True).", @@ -42,7 +45,7 @@ def handle_status(view: str, detailed: bool, svc, finalize) -> str: return finalize("c3_status", {"view": view}, f"[status:moved] '{view}' view removed from MCP. {removed[view]}", "moved") - return f"[status:error] Unknown view: {view}. Available: budget, health, notifications, sessions, ghost_files" + return f"[status:error] Unknown view: {view}. Available: budget, health, notifications, sessions, ghost_files, access" def _budget_view(svc, detailed, finalize): @@ -338,6 +341,33 @@ def _notifications_view(svc, finalize): "\n".join(lines), f"{len(pending)}p") +def _access_view(svc, finalize): + """Access Guard summary — the agent's READ-ONLY view of the rule registry. + + Rule counts per scope, corrupt-scope warnings, and the §5 coverage + matrix. Rule MUTATIONS are human-only (UI tab / `c3 access` CLI) — + no agent-facing mutation surface exists. Plan-mode safe. + """ + from services import access_guard + + scopes = access_guard.list_rules(str(svc.project_path)) + lines = ["# Access Guard"] + for scope in ("builtin", "global", "project"): + sec = scopes.get(scope) or {} + n_deny = len(sec.get("deny") or []) + n_ro = len(sec.get("read_only") or []) + line = f"[{scope}] {n_deny} deny, {n_ro} read_only" + if sec.get("corrupt"): + line += (" [warn] access section invalid — scope fails closed " + "(deny-all); fix config.json 'access' by hand") + lines.append(line) + lines.append("") + lines.append(access_guard.COVERAGE_MATRIX) + lines.append("Rules: `c3 access list` or the Access Guard tab " + "(mutations are human-only).") + return finalize("c3_status", {"view": "access"}, "\n".join(lines), "ok") + + def _ghost_files_view(svc, finalize): """Scan project root for ghost files, report details, and auto-clean them.""" project_root = Path(svc.project_path) diff --git a/cli/ui/app.js b/cli/ui/app.js index 7bd92ca..eb9ab3e 100644 --- a/cli/ui/app.js +++ b/cli/ui/app.js @@ -12,6 +12,7 @@ const tabs = [ { id: "bitbucket", label: "Bitbucket", icon: "gitBranch" }, { id: "jira", label: "Jira", icon: "check" }, { id: "credentials", label: "Credentials", icon: "lock" }, + { id: "access", label: "Access Guard", icon: "alertTriangle" }, { id: "instructions", label: "Instructions", icon: "fileText" }, { id: "settings", label: "Settings", icon: "settings" }, ]; @@ -131,6 +132,9 @@ function App() {
+
+ +
diff --git a/cli/ui/components/access.js b/cli/ui/components/access.js new file mode 100644 index 0000000..2e68520 --- /dev/null +++ b/cli/ui/components/access.js @@ -0,0 +1,332 @@ +// AccessPanel — Access Guard rule manager (v2.62.0) +// Globals: T, I, api, useState, useEffect, useCallback, Badge +// Human-only mutation surface: ALL rule changes happen here (or `c3 access`) +// and are ledger/activity-logged. Agents get refusal strings, never this tab. + +const ACCESS_EMPTY_FORM = { glob: "", kind: "deny", scope: "project" }; +const ACCESS_SCOPE_ORDER = ["builtin", "global", "project"]; +const ACCESS_SCOPE_NOTES = { + builtin: "hardcoded, always on, fail-closed — cannot be edited", + global: "~/.c3/config.json — applies to every C3 project", + project: ".c3/config.json — this project only", +}; + +const AccessPanel = () => { + const [scopes, setScopes] = useState(null); + const [coverage, setCoverage] = useState(""); + const [corrupt, setCorrupt] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [busy, setBusy] = useState(false); + const [form, setForm] = useState(null); // null = closed; {…} = add-rule form + const [probePath, setProbePath] = useState(""); + const [probeOp, setProbeOp] = useState("read"); + const [probe, setProbe] = useState(null); // last /check response + const [probeBusy, setProbeBusy] = useState(false); + + const load = useCallback(async () => { + try { + const data = await api.get("/api/access"); + setScopes((data && data.scopes) || {}); + setCoverage((data && data.coverage) || ""); + setCorrupt((data && data.corrupt) || []); + setError(""); + } catch (e) { setError(String(e)); } + setLoading(false); + }, []); + + useEffect(() => { load(); }, [load]); + + const flash = (msg) => { setNotice(msg); setTimeout(() => setNotice(""), 4000); }; + + const addRule = async () => { + const glob = (form.glob || "").trim(); + if (!glob) { setError("Glob required — e.g. secrets/** or *.pem"); return; } + setBusy(true); + try { + const resp = await api.post("/api/access", + { glob, kind: form.kind, scope: form.scope }); + if (resp && resp.error) { setError(resp.error); } + else if (resp && resp.rule && !resp.rule.added) { + setError(`Rule already present: '${resp.rule.glob}' (${form.kind}, ${form.scope})`); + } else { + setForm(null); + flash(`Added ${form.kind} rule '${glob}' (${form.scope})`); + load(); + } + } catch (e) { setError(String(e)); } + setBusy(false); + }; + + const removeRule = async (scope, kind, glob) => { + if (kind === "deny") { + // Typed-name confirm: removing a deny rule re-exposes the paths. + const typed = window.prompt( + `Removing DENY rule '${glob}' (${scope} scope) re-opens agent access to matching paths.\n\nType the glob exactly to confirm:`); + if (typed === null) return; + if (typed.trim() !== glob) { + setError("Confirmation text did not match — rule kept."); + return; + } + } else if (!window.confirm(`Remove read-only rule '${glob}' (${scope} scope)?`)) { + return; + } + setBusy(true); + try { + const resp = await api.del( + `/api/access?glob=${encodeURIComponent(glob)}&kind=${encodeURIComponent(kind)}&scope=${encodeURIComponent(scope)}`); + if (resp && resp.error) setError(resp.error); + else { flash(`Removed ${kind} rule '${glob}'`); } + load(); + } catch (e) { setError(String(e)); } + setBusy(false); + }; + + const runProbe = async () => { + const p = probePath.trim(); + if (!p) return; + setProbeBusy(true); + try { + const data = await api.get( + `/api/access/check?path=${encodeURIComponent(p)}&op=${encodeURIComponent(probeOp)}`); + setProbe(data); + } catch (e) { setError(String(e)); } + setProbeBusy(false); + }; + + const inputStyle = { + width: "100%", padding: "7px 10px", borderRadius: 6, fontSize: 12, + border: `1px solid ${T.border}`, background: T.surface, color: T.text, + outline: "none", boxSizing: "border-box", + }; + const labelStyle = { fontSize: 11, color: T.textMuted, marginBottom: 4, display: "block" }; + const kindColor = (kind) => (kind === "deny" ? T.error : T.warn); + + const scopeRules = (scope) => { + const sec = (scopes && scopes[scope]) || {}; + const rows = []; + (sec.deny || []).forEach(g => rows.push({ kind: "deny", glob: g })); + (sec.read_only || []).forEach(g => rows.push({ kind: "read_only", glob: g })); + return rows; + }; + + const verdictColor = (v) => + v === "allowed" ? T.accent : v === "read_only" ? T.warn : T.error; + + return ( +
+ {/* Header */} +
+ + Access Guard +
+ +
+
+ Paths the agent must not read (deny) or must not write + (read_only), enforced across every C3 surface. Rule changes are + human-only — this tab and c3 access — and + every mutation is ledger-logged. deny also hides matching paths + from listings and search (no existence oracle). +
+ + {error && ( +
{error}
+ )} + {notice && ( +
{notice}
+ )} + {corrupt.length > 0 && ( +
+ ⚠ Corrupt access section in scope{corrupt.length > 1 ? "s" : ""}:{" "} + {corrupt.join(", ")} — that scope evaluates deny-all until you + fix config.json "access" by hand. +
+ )} + + {/* Add-rule form */} + {form && ( +
+
+ New rule +
+
+
+ Glob (POSIX, ** crosses directories) + setForm({ ...form, glob: e.target.value })} + placeholder="e.g. secrets/** or *.pem" + style={{ ...inputStyle, fontFamily: "monospace" }} + autoComplete="off" spellCheck={false} /> +
+
+ Kind + +
+
+ Scope + +
+
+
+ + +
+
+ )} + + {/* Test-path probe */} +
+ Test a path against the active rules +
+ setProbePath(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") runProbe(); }} + placeholder="e.g. src/payroll/report.xlsx" + style={{ ...inputStyle, flex: 1, fontFamily: "monospace" }} + autoComplete="off" spellCheck={false} /> + + +
+ {probe && !probe.error && ( +
+ {probe.verdict}{" "} + {probe.verdict === "allowed" ? ( + + {probe.op} permitted for {probe.path} + + ) : ( + + matched rule '{probe.rule}'{" "} + ({probe.scope} scope) + + )} + {probe.refusal && ( +
{probe.refusal}
+ )} +
+ )} + {probe && probe.error && ( +
{probe.error}
+ )} +
+ + {/* Rules grouped by scope */} + {loading ? ( +
Loading…
+ ) : ACCESS_SCOPE_ORDER.map(scope => { + const rows = scopeRules(scope); + const isBuiltin = scope === "builtin"; + return ( +
+
+ + {scope} + + + {ACCESS_SCOPE_NOTES[scope]} + +
+ {rows.length === 0 ? ( +
+ No {scope} rules. {isBuiltin ? "" : "Add one above or run "} + {!isBuiltin && c3 access add <glob> --kind deny} +
+ ) : ( +
+ {rows.map((r, i) => ( +
+ {isBuiltin && } + + {r.glob} + + {r.kind} + {isBuiltin && ( + built-in — read-only + )} +
+ {!isBuiltin && ( + removeRule(scope, r.kind, r.glob)} + style={{ cursor: "pointer", color: T.error }}> + + + )} +
+ ))} +
+ )} +
+ ); + })} + + {/* §5 coverage matrix footer */} +
+ {coverage || ("Enforced: C3 MCP tools (all agents using C3) · Claude Code " + + "native tools (hooks) · c3_shell (best-effort scan, advisory). " + + "NOT enforced: non-Claude agents' raw shell, direct file APIs, editors.")} +
+
+ ); +}; \ No newline at end of file diff --git a/tests/test_access_guard_surfaces.py b/tests/test_access_guard_surfaces.py new file mode 100644 index 0000000..f81f709 --- /dev/null +++ b/tests/test_access_guard_surfaces.py @@ -0,0 +1,335 @@ +"""Access Guard T3 surface tests — REST /api/access, write API, CLI, UI bundle. + +Flask test client + temp PROJECT_PATH + patched global base — fully offline. +Mutations are human-only surfaces (UI/REST/CLI); every add/remove must land +in the activity log and edit ledger by identifier. The check probe must +return the exact S1/S2 refusal strings from the frozen spec (§4). +""" +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +import cli.server as srv +from services import access_guard as ag + + +class AccessBase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self._home = tempfile.TemporaryDirectory() + self.proj = Path(self._tmp.name) + (self.proj / ".c3").mkdir() + self._old_project_path = srv.PROJECT_PATH + srv.PROJECT_PATH = self.proj + self._gb = mock.patch.object( + ag, "_global_base", return_value=Path(self._home.name)) + self._gb.start() + self.client = srv.app.test_client() + + def tearDown(self): + self._gb.stop() + srv.PROJECT_PATH = self._old_project_path + self._tmp.cleanup() + self._home.cleanup() + + def _add(self, glob, kind="deny", scope="project"): + return self.client.post( + "/api/access", json={"glob": glob, "kind": kind, "scope": scope}) + + def _check(self, path, op="read"): + return self.client.get( + "/api/access/check", query_string={"path": str(path), "op": op}) + + +class TestAccessRoutes(AccessBase): + def test_list_includes_builtins_and_coverage(self): + resp = self.client.get("/api/access") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn("**/.env*", data["scopes"]["builtin"]["deny"]) + self.assertIn("**/.git/**", data["scopes"]["builtin"]["read_only"]) + self.assertIn("Enforced:", data["coverage"]) + self.assertIn("NOT enforced:", data["coverage"]) + self.assertEqual(data["corrupt"], []) + for scope in ("global", "project"): + self.assertEqual(data["scopes"][scope]["deny"], []) + self.assertEqual(data["scopes"][scope]["read_only"], []) + + def test_add_check_remove_round_trip(self): + resp = self._add("secrets/**") + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json()["rule"]["added"]) + # Persisted in the project config + cfg = json.loads( + (self.proj / ".c3" / "config.json").read_text(encoding="utf-8")) + self.assertEqual(cfg["access"]["deny"], ["secrets/**"]) + # Visible in the list + data = self.client.get("/api/access").get_json() + self.assertIn("secrets/**", data["scopes"]["project"]["deny"]) + # Denied probe returns verdict + matched rule + scope + exact S1 text + target = str(self.proj / "secrets" / "x.txt") + data = self._check(target).get_json() + self.assertEqual(data["verdict"], "denied") + self.assertEqual(data["rule"], "secrets/**") + self.assertEqual(data["scope"], "project") + denial = ag.check(target, "read", str(self.proj)) + self.assertEqual(data["refusal"], ag.refusal(denial, target, "read")) + self.assertIn("[c3-access:denied]", data["refusal"]) + self.assertIn("policy decision, not a transient error", data["refusal"]) + self.assertIn("do not retry or route around it", data["refusal"]) + self.assertIn("'secrets/**' (project scope)", data["refusal"]) + # Remove, then the probe allows + resp = self.client.delete("/api/access", query_string={ + "glob": "secrets/**", "kind": "deny", "scope": "project"}) + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json()["removed"]) + data = self._check(target).get_json() + self.assertEqual(data["verdict"], "allowed") + self.assertEqual(data["refusal"], "") + + def test_read_only_rule_write_probe_returns_s2(self): + self._add("docs/**", kind="read_only") + target = str(self.proj / "docs" / "a.md") + self.assertEqual(self._check(target, "read").get_json()["verdict"], + "allowed") + data = self._check(target, "write").get_json() + self.assertEqual(data["verdict"], "read_only") + self.assertEqual(data["rule"], "docs/**") + self.assertIn("[c3-access:read_only]", data["refusal"]) + self.assertIn("reads are evaluated separately", data["refusal"]) + + def test_add_rejects_bad_glob_unknown_kind_unknown_scope(self): + self.assertEqual(self._add(" ").status_code, 400) + self.assertEqual(self._add("x/**", kind="allow").status_code, 400) + self.assertEqual(self._add("x/**", kind="").status_code, 400) + self.assertEqual( + self._add("x/**", scope="planet").status_code, 400) + # Nothing persisted by the rejected calls + cfg_path = self.proj / ".c3" / "config.json" + if cfg_path.exists(): + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + self.assertEqual(cfg.get("access", {}).get("deny", []), []) + + def test_check_validates_params(self): + self.assertEqual( + self.client.get("/api/access/check").status_code, 400) + resp = self._check(str(self.proj / "a.txt"), op="chmod") + self.assertEqual(resp.status_code, 400) + + def test_mutations_audited_by_identifier(self): + self._add("secrets/**") + self.client.delete("/api/access", query_string={ + "glob": "secrets/**", "kind": "deny", "scope": "project"}) + log = self.proj / ".c3" / "activity_log.jsonl" + self.assertTrue(log.exists()) + text = log.read_text(encoding="utf-8") + self.assertIn("access_action", text) + self.assertIn("secrets/**", text) + ledger = self.proj / ".c3" / "edit_ledger.jsonl" + self.assertTrue(ledger.exists()) + ltext = ledger.read_text(encoding="utf-8") + self.assertIn("access://secrets/**", ltext) + self.assertIn("access_add", ltext) + self.assertIn("access_remove", ltext) + + def test_corrupt_scope_reported_and_mutation_refused(self): + (self.proj / ".c3" / "config.json").write_text( + json.dumps({"access": {"deny": [], "allow": ["**"]}}), + encoding="utf-8") + data = self.client.get("/api/access").get_json() + self.assertIn("project", data["corrupt"]) + self.assertTrue(data["scopes"]["project"]["corrupt"]) + # Mutations never silently rewrite a corrupt section + self.assertEqual(self._add("x/**").status_code, 400) + resp = self.client.delete("/api/access", query_string={ + "glob": "x/**", "kind": "deny", "scope": "project"}) + self.assertEqual(resp.status_code, 400) + # The scope fails closed for the probe + data = self._check(str(self.proj / "anything.txt")).get_json() + self.assertEqual(data["verdict"], "denied") + self.assertEqual(data["rule"], "") + + +class TestWriteApi(AccessBase): + def test_set_rule_normalizes_and_dedupes(self): + r1 = ag.set_rule("secrets\\inner\\**", "deny", "project", str(self.proj)) + self.assertEqual(r1["glob"], "secrets/inner/**") + self.assertTrue(r1["added"]) + r2 = ag.set_rule("SECRETS/INNER/**", "deny", "project", str(self.proj)) + self.assertFalse(r2["added"]) + cfg = json.loads( + (self.proj / ".c3" / "config.json").read_text(encoding="utf-8")) + self.assertEqual(cfg["access"]["deny"], ["secrets/inner/**"]) + + def test_set_rule_global_scope_writes_patched_home(self): + ag.set_rule("*.p12", "deny", "global", str(self.proj)) + gcfg = Path(self._home.name) / ".c3" / "config.json" + self.assertTrue(gcfg.exists()) + data = ag.list_rules(str(self.proj)) + self.assertIn("*.p12", data["global"]["deny"]) + # Evaluator picks it up through the same patched base + d = ag.check(str(self.proj / "cert.p12"), "read", str(self.proj)) + self.assertIsNotNone(d) + self.assertEqual(d.scope, "global") + + def test_unknown_kind_and_scope_raise(self): + with self.assertRaises(ValueError): + ag.set_rule("x/**", "allow", "project", str(self.proj)) + with self.assertRaises(ValueError): + ag.set_rule("x/**", "deny", "galaxy", str(self.proj)) + with self.assertRaises(ValueError): + ag.remove_rule("x/**", "allow", "project", str(self.proj)) + + def test_invalid_glob_raises(self): + with self.assertRaises(ValueError): + ag.set_rule(" ", "deny", "project", str(self.proj)) + with self.assertRaises(ValueError): + ag.set_rule("", "read_only", "project", str(self.proj)) + + def test_remove_missing_rule_reports_removed_false(self): + r = ag.remove_rule("ghost/**", "deny", "project", str(self.proj)) + self.assertFalse(r["removed"]) + self.assertFalse((self.proj / ".c3" / "config.json").exists()) + + def test_write_preserves_other_config_sections(self): + cfg_path = self.proj / ".c3" / "config.json" + cfg_path.write_text( + json.dumps({"memory_llm": {"model": "m"}, "access": {"deny": []}}), + encoding="utf-8") + ag.set_rule("secrets/**", "deny", "project", str(self.proj)) + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + self.assertEqual(cfg["memory_llm"], {"model": "m"}) + self.assertEqual(cfg["access"]["deny"], ["secrets/**"]) + + +class TestCliSurface(AccessBase): + def test_parser_wires_access_subcommands(self): + from cli.commands.parser import build_parser + parser = build_parser("0.0-test", lambda v: v) + args = parser.parse_args( + ["access", "add", "x/**", "--kind", "deny", "--path", str(self.proj)]) + self.assertEqual(args.command, "access") + self.assertEqual(args.access_cmd, "add") + self.assertEqual(args.kind, "deny") + self.assertFalse(args.use_global) + args = parser.parse_args( + ["access", "remove", "x/**", "--kind", "read_only", "--global"]) + self.assertTrue(args.use_global) + args = parser.parse_args(["access", "check", "foo.txt", "--op", "write"]) + self.assertEqual(args.target, "foo.txt") + self.assertEqual(args.op, "write") + args = parser.parse_args(["access", "list"]) + self.assertEqual(args.access_cmd, "list") + + def _run(self, **kw): + from cli.c3 import cmd_access + buf = StringIO() + with redirect_stdout(buf): + cmd_access(SimpleNamespace(**kw)) + return buf.getvalue() + + def test_cmd_access_add_list_check_remove(self): + pp = str(self.proj) + out = self._run(access_cmd="add", glob="secrets/**", kind="deny", + use_global=False, project_path=pp) + self.assertIn("[OK] Added deny rule 'secrets/**'", out) + cfg = json.loads( + (self.proj / ".c3" / "config.json").read_text(encoding="utf-8")) + self.assertEqual(cfg["access"]["deny"], ["secrets/**"]) + # Duplicate add is a no-op + out = self._run(access_cmd="add", glob="secrets/**", kind="deny", + use_global=False, project_path=pp) + self.assertIn("already present", out) + # List shows scope groups + rule + coverage matrix + out = self._run(access_cmd="list", project_path=pp) + self.assertIn("[builtin]", out) + self.assertIn("secrets/**", out) + self.assertIn("NOT enforced:", out) + # Check prints the S1 refusal for a denied path + out = self._run(access_cmd="check", + target=str(self.proj / "secrets" / "a.txt"), + op="read", project_path=pp) + self.assertIn("[c3-access:denied]", out) + self.assertIn("'secrets/**' (project scope)", out) + # Check allows an untouched path + out = self._run(access_cmd="check", + target=str(self.proj / "src" / "main.py"), + op="read", project_path=pp) + self.assertIn("[OK] read allowed", out) + # Remove + out = self._run(access_cmd="remove", glob="secrets/**", kind="deny", + use_global=False, project_path=pp) + self.assertIn("[OK] Removed deny rule", out) + # CLI mutations are audited too (identifiers only, house pattern) + text = (self.proj / ".c3" / "activity_log.jsonl").read_text( + encoding="utf-8") + self.assertIn("access_action", text) + self.assertIn("secrets/**", text) + ltext = (self.proj / ".c3" / "edit_ledger.jsonl").read_text( + encoding="utf-8") + self.assertIn("access://secrets/**", ltext) + + def test_cmd_access_bad_glob_prints_error(self): + out = self._run(access_cmd="add", glob=" ", kind="deny", + use_global=False, project_path=str(self.proj)) + self.assertIn("[error]", out) + + +class TestStatusView(AccessBase): + def _status(self): + from cli.tools.status import handle_status + svc = SimpleNamespace(project_path=str(self.proj)) + return handle_status("access", False, svc, + lambda name, args, resp, summ, **kw: resp) + + def test_access_view_counts_and_coverage(self): + ag.set_rule("secrets/**", "deny", "project", str(self.proj)) + ag.set_rule("docs/**", "read_only", "project", str(self.proj)) + out = self._status() + self.assertIn("[project] 1 deny, 1 read_only", out) + self.assertIn("[builtin]", out) + self.assertIn("[global] 0 deny, 0 read_only", out) + self.assertIn("NOT enforced:", out) + + def test_access_view_corrupt_warning(self): + (self.proj / ".c3" / "config.json").write_text( + json.dumps({"access": {"allow": ["**"]}}), encoding="utf-8") + out = self._status() + self.assertIn("fails closed", out) + + +class TestUiBundle(unittest.TestCase): + def test_access_bundle_entry_present_ordered_and_on_disk(self): + files = srv._UI_JS_FILES + self.assertIn("ui/components/access.js", files) + self.assertLess(files.index("ui/components/credentials.js"), + files.index("ui/components/access.js")) + self.assertLess(files.index("ui/components/access.js"), + files.index("ui/app.js")) + self.assertTrue((REPO_ROOT / "cli" / "ui" / "components" / "access.js").exists()) + + def test_app_registers_access_tab_and_panel(self): + src = (REPO_ROOT / "cli" / "ui" / "app.js").read_text(encoding="utf-8") + self.assertIn('id: "access"', src) + self.assertIn('label: "Access Guard"', src) + self.assertIn("", src) + comp = (REPO_ROOT / "cli" / "ui" / "components" / "access.js" + ).read_text(encoding="utf-8") + self.assertIn("const AccessPanel", comp) + self.assertIn("/api/access/check", comp) + self.assertIn("NOT enforced:", comp) # §5 coverage matrix footer + + +if __name__ == "__main__": + unittest.main() From 61906815703193431c900b44ac1259935635962d Mon Sep 17 00:00:00 2001 From: drknowhow Date: Mon, 27 Jul 2026 14:08:37 -0400 Subject: [PATCH 7/8] =?UTF-8?q?feat(access-guard):=20integration=20?= =?UTF-8?q?=E2=80=94=20AccessDenied=20handling=20at=20CLI=20and=20REST=20e?= =?UTF-8?q?dges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _compress_file_cli wrapper converts AccessDenied to an error dict at all four direct compress_file call sites in the human CLI (T2a handoff — the service layer raises; edges convert). UI and hub restore routes return 403 with the refusal message instead of a 500. Claude-Session: https://claude.ai/code/session_01CZVZgYdy4m9z9J9mGT5jp6 --- cli/c3.py | 18 ++++++++++++++---- cli/hub_server.py | 8 ++++++-- cli/server.py | 6 +++++- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/cli/c3.py b/cli/c3.py index 331d868..1a9489c 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -92,6 +92,16 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" + + +def _compress_file_cli(compressor, path, mode="smart", **kw): + """compress_file for human-CLI paths: AccessDenied → error dict, not a + traceback. The refusal text names the rule so the operator can act.""" + from services import access_guard as _ag + try: + return compressor.compress_file(path, mode, **kw) + except _ag.AccessDenied as exc: + return {"error": exc.message} __version__ = "2.61.1" @@ -1338,7 +1348,7 @@ def _benchmark_extract_preview(full_path: Path, compressor: CodeCompressor, patt extracted = "" if ext in code_exts and not pattern: - result = compressor.compress_file(str(full_path), "smart") + result = _compress_file_cli(compressor, str(full_path), "smart") extracted = result.get("compressed", "") if "error" not in result else f"Error: {result['error']}" elif ext == ".jsonl" and not pattern: @@ -1617,7 +1627,7 @@ def _benchmark_delegate_optional(project_path: Path, sample: list[tuple[Path, st "Focus on responsibilities, important functions/classes, and notable dependencies." ) - compressed_result = compressor.compress_file(str(fpath), "smart") + compressed_result = _compress_file_cli(compressor, str(fpath), "smart") compressed_context = compressed_result.get("compressed", "") if isinstance(compressed_result, dict) else "" if not compressed_context: compressed_context = raw_content @@ -3478,7 +3488,7 @@ def _rel_path(path: Path) -> str: raw_content = content comp_baseline_latencies.append((time.perf_counter() - t_read) * 1000) t0 = time.perf_counter() - result = compressor.compress_file(str(fpath), "smart") + result = _compress_file_cli(compressor, str(fpath), "smart") comp_c3_latencies.append((time.perf_counter() - t0) * 1000) raw_tokens = count_tokens(raw_content) comp_orig += raw_tokens @@ -3544,7 +3554,7 @@ def _rel_path(path: Path) -> str: tmp.write(raw_extracted) tmp_path = tmp.name try: - comp_res = compressor.compress_file(tmp_path, mode="smart") + comp_res = _compress_file_cli(compressor, tmp_path, mode="smart") extracted_text += comp_res.get("compressed", raw_extracted) finally: if os.path.exists(tmp_path): diff --git a/cli/hub_server.py b/cli/hub_server.py index cb38150..d3fae65 100644 --- a/cli/hub_server.py +++ b/cli/hub_server.py @@ -30,6 +30,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from core.ide import PROFILES, detect_ide, get_profile, load_ide_config, normalize_ide_name +from services import access_guard from services.activity_log import ActivityLog from services.project_manager import ProjectManager from services.tool_classifier import CATEGORIES @@ -2513,8 +2514,11 @@ def api_projects_artifacts_restore(): return err if not data.get("artifact") or not data.get("version"): return jsonify({"error": "artifact and version are required"}), 400 - res = _artifact_store_for(resolved).restore( - data["artifact"], int(data["version"]), session_id="hub") + try: + res = _artifact_store_for(resolved).restore( + data["artifact"], int(data["version"]), session_id="hub") + except access_guard.AccessDenied as exc: + return jsonify({"error": exc.message}), 403 if "error" in res: return jsonify(res), 400 _artifact_audit(resolved, "restore", f"{res['id']}@v{data['version']}") diff --git a/cli/server.py b/cli/server.py index a709470..31dce34 100644 --- a/cli/server.py +++ b/cli/server.py @@ -26,6 +26,7 @@ from core import count_tokens from core.config import load_delegate_config, load_mcp_config, load_proxy_config from core.ide import PROFILES, detect_ide, get_profile, load_ide_config, normalize_ide_name +from services import access_guard from services.protocol import CompressionProtocol from services.runtime import build_runtime, stop_runtime from services.session_manager import SessionManager @@ -1512,7 +1513,10 @@ def api_artifacts_restore(): if not data.get("artifact") or not data.get("version"): return jsonify({"error": "artifact and version are required"}), 400 store = _artifact_store() - res = store.restore(data["artifact"], int(data["version"]), session_id="ui") + try: + res = store.restore(data["artifact"], int(data["version"]), session_id="ui") + except access_guard.AccessDenied as exc: + return jsonify({"error": exc.message}), 403 if "error" in res: return jsonify(res), 400 ledger = getattr(runtime, "edit_ledger", None) From 6723d500881f4dc8054dc32a061a31de975e6b6f Mon Sep 17 00:00:00 2001 From: drknowhow Date: Mon, 27 Jul 2026 14:14:33 -0400 Subject: [PATCH 8/8] =?UTF-8?q?release:=20v2.62.0=20=E2=80=94=20Access=20G?= =?UTF-8?q?uard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version bump (both sites), CHANGELOG feature entry, post-merge test sweep (canonical unlock keys in hook smoke assertions, comment reword that tripped the encoding lint). Full suite 1546 passed / 1 skipped, ruff clean, 19-file UI bundle esbuild-parses clean. Claude-Session: https://claude.ai/code/session_01CZVZgYdy4m9z9J9mGT5jp6 --- CHANGELOG.md | 78 ++++++++++++++++++++++++++++++++++++++++ cli/c3.py | 2 +- pyproject.toml | 2 +- services/access_guard.py | 2 +- tests/test_hook_smoke.py | 4 +-- 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7887636..adcc8db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,84 @@ 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.62.0] - 2026-07-27 + +### Added — Access Guard: path-level read/write prevention for agents + +The Credential Vault protects *values*; Access Guard protects *files and +folders*. Two glob lists — `deny` (no read, no write, no create, no +enumerate) and `read_only` (no write) — in the `access` section of +`.c3/config.json` (project) and `~/.c3/config.json` (global), enforced by +one shared evaluator at every C3 surface. Designed by an adversarial board +review (five seats + chair) whose amendments shaped everything below. + +- **`services/access_guard.py`** — the single evaluator: two-scope + tighten-only union (no `allow` list exists; unknown keys are a hard + config error; a corrupt section makes that scope deny-all — fail + closed), POSIX case-insensitive globs (basename patterns match at any + depth), and ONE `canonicalize()` handling `\\?\`/UNC pre-stripping, + nearest-existing-parent resolution for not-yet-existing targets, and + Windows-gated trailing-dot/space, alternate-data-stream, and 8.3 + short-name defenses. Builtins (non-overridable): `**/.env*` and the + vault sidecars denied outright; write-denies on `.c3/**`, `~/.c3/**`, + `.claude/settings*.json`, `.git/**` (reads stay open), and the installed + C3 package (dev checkouts exempt). `*.pem`/`id_rsa*`/`*.key` ship as + visible, removable default rules. +- **Service-layer enforcement** (never the MCP wrappers, so the Oracle + Discovery bridge, `c3_project`, and `c3_delegate` inherit): + `compress_file` and `ArtifactStore.restore` raise a typed + `AccessDenied`; read/edit/compress/validate/filter/impact convert to + refusals; search pre-filters denied paths from results (deny-ENUMERATE) + and appends a presence-only `[c3-access:limited]` footer whenever rules + are active; `scanner.iter_files` excludes denied paths at index time so + they never enter the TF-IDF/vector index, MAP.md, or file_memory. +- **Hook layer, fail-closed** — new `hook_access_guard.py` runs FIRST in + the PreToolUse route: native Read/Edit/Write/MultiEdit/NotebookEdit + verdicts before any unlock logic (sticky unlocks cannot readmit a policy + denial); explicit-path Grep/Glob denial with rootless searches kept + advisory; best-effort existence-gated Bash token scan. If the guard + itself fails to import or crashes, write-class tools are DENIED with an + actionable reason instead of falling through. install-mcp now registers + `Bash` + `run_shell_command` PreToolUse matchers — previously the hook + layer never fired for shell at all. Unlock-map keys are now canonical + (resolved/casefolded, old stores migrate at load), closing a + case-spelling bypass; the enforcement evidence window counts tool_call + entries so denial storms can't evict it. +- **`c3_shell`** hard-denies a denied working directory and runs an + advisory post-credential-expansion token scan (MSYS path translation, + refusals redacted). **`c3_project`** evaluates proxied paths against + global ∪ caller ∪ the containing realm and requires target registration + (no more rule-free pivot projects). **`c3_delegate`** pins codex to + `--sandbox read-only` when rules exist and gates write-capable backends + behind an explicit `allow_write_delegation` opt-in. +- **Human surfaces — all rule mutations are human-only and ledger-logged; + agents have no mutation surface.** Per-project **Access Guard UI tab** + (scope-grouped rules, locked builtins, typed-glob delete confirmation, + test-path probe showing the exact refusal, corrupt-scope banner, + coverage-matrix footer), REST `/api/access` (+ `check` probe), + `c3 access list|add|remove|check` CLI, and a read-only + `c3_status(view='access')`. +- **Refusals as API** — stable machine tags (`[c3-access:denied]`, + `[c3-access:read_only]`, `[c3-access:limited]`, `[c3-access:error]`) + with the matched rule, scope, and explicit do-not-retry / plan-disposition + guidance, frozen verbatim in `docs/access-guard.md` before + implementation. +- **Honest coverage, stated everywhere it matters**: enforced for C3 MCP + tools (any agent using C3), Claude Code native tools (hooks), and + best-effort for `c3_shell` — NOT for a non-Claude agent's raw shell, + direct file APIs, or external editors. A cooperative-agent guard against + mistakes and prompt-injection, not a sandbox. Known v1 residuals are + documented: shell renames/moves, TOCTOU, pre-existing index content. +- **CI meta-tests against drift** — every `cli/tools` module doing raw + file I/O must import the guard or be allowlisted with a written reason; + direct `Path.resolve()` is banned in enforcement-adjacent code; a + denial-storm regression pins the evidence window. +- **Guide** — new `cli/guide/access.html` (linked from every guide page), + README section, and the coverage matrix in tab + status + docs. + +~130 new tests across evaluator, wiring, hooks, shell/project/delegate, +surfaces, and meta suites. Full suite: 1546 passed. + ## [2.61.2] - 2026-07-27 ### Security — vault write-guard: closes an `agent_readable` escalation diff --git a/cli/c3.py b/cli/c3.py index dfe4892..2fe257a 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -92,7 +92,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.61.2" +__version__ = "2.62.0" def _compress_file_cli(compressor, path, mode="smart", **kw): diff --git a/pyproject.toml b/pyproject.toml index 68c0fed..a2ff153 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.61.2" +version = "2.62.0" description = "Local code-intelligence layer for AI coding tools (Claude Code, Codex, Copilot, Cursor, Antigravity). Retrieve less, read less, edit safer — version the configs that shape your agent (CLAUDE.md, skills, hooks, MCP), and manage multi-project + sub-project hierarchies from a local hub." readme = "README.md" requires-python = ">=3.10" diff --git a/services/access_guard.py b/services/access_guard.py index 767896e..a07786a 100644 --- a/services/access_guard.py +++ b/services/access_guard.py @@ -264,7 +264,7 @@ def canonicalize(path, project_root=".") -> tuple: for name in reversed(residual): cleaned = name if windows: - # open('x.') / open('x ') land on 'x' on NTFS — model it. + # Creating 'x.' or 'x ' lands on 'x' on NTFS — model it. while cleaned != cleaned.rstrip(". "): cleaned = cleaned.rstrip(". ") if not cleaned: diff --git a/tests/test_hook_smoke.py b/tests/test_hook_smoke.py index c429dc2..10dc477 100644 --- a/tests/test_hook_smoke.py +++ b/tests/test_hook_smoke.py @@ -123,7 +123,7 @@ def test_unlocks_editable_file(self): project_path=self.tmp) self.assertIn("[c3:edit-ready]", out["additionalContext"]) state = _hook_utils.load_enforcement_state(self.tmp) - cats = state["unlocked_files"].get(str(fp.resolve()), []) + cats = state["unlocked_files"].get(_hook_utils.canonical_key(fp), []) self.assertEqual(cats, ["edit", "read"]) def test_other_tools_ignored(self): @@ -153,7 +153,7 @@ def test_compress_unlocks_and_nudges(self): out = hook_edit_unlock.run(self._payload(fp), project_path=self.tmp) self.assertIn("[c3:edit-ready]", out["additionalContext"]) state = _hook_utils.load_enforcement_state(self.tmp) - self.assertIn(str(fp.resolve()), state["unlocked_files"]) + self.assertIn(_hook_utils.canonical_key(fp), state["unlocked_files"]) def test_duplicate_nudge_suppressed(self): fp = self.tmp / "mod.py"