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/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/_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 0fb94b6..2fe257a 100644
--- a/cli/c3.py
+++ b/cli/c3.py
@@ -92,7 +92,17 @@
# 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):
+ """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}
def _command_deps() -> CommandDeps:
@@ -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):
@@ -5203,6 +5213,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 +5223,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}]}
@@ -5899,6 +5914,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)
@@ -7199,6 +7328,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/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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 kind Read Write / create / delete Appears 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.
+
+ Paths Protection Why
+ **/.env* deny Secrets never enter agent context
+ .c3/secrets.enc · .c3/cred_state.json deny Credential vault sidecars
+ .c3/** · ~/.c3/** write-deny Config self-tampering (incl. access rules + credential registry)
+ .claude/settings*.json write-deny Hook/permission self-modification
+ .git/** write-deny git hooks are a code-execution vector (reads stay open)
+ C3 install directory write-deny Agent self-modification (installed layouts only)
+ *.pem · id_rsa* · *.key default rule Seeded 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.
+
+ Tag Meaning
+ [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 write
+ The 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 denied
+ That scope's access section is corrupt or has an
+ unknown key. Fix the JSON; c3 access list names the
+ scope.
+ Expected file "missing" in search
+ Check c3 access check <path> — deny rules
+ remove files from search entirely (deny-enumerate).
+ Rule not matching
+ Globs 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 @@
+
@@ -321,6 +325,11 @@ Claude Code Companion
Credential Vault
Secrets agents can use but never read — keyring storage, injection at the subprocess boundary, cross-project search (v2.58.0+)
+
+ 🛡️
+ Access Guard
+ Paths agents must never read or write — deny/read-only globs enforced at every C3 surface, with honest coverage limits (v2.62.0+)
+
🪣
Bitbucket Data Center
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
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 f881102..be9211d 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,
@@ -158,7 +159,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
@@ -247,12 +251,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):
@@ -260,6 +271,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/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/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/server.py b/cli/server.py
index ce6a65f..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
@@ -201,6 +202,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",
@@ -1511,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)
@@ -2785,6 +2790,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/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 bb6c101..e3d3b06 100644
--- a/cli/tools/edit.py
+++ b/cli/tools/edit.py
@@ -13,6 +13,7 @@
import threading
from pathlib import Path
+from services import access_guard
from services import credential_store as _cs
# Per-file locks — keyed by resolved absolute path string.
@@ -251,6 +252,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/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/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/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
+
+
setForm(form ? null : { ...ACCESS_EMPTY_FORM })} style={{
+ background: T.accent, color: "#fff", border: "none",
+ padding: "6px 12px", borderRadius: 6, fontSize: 12, cursor: "pointer",
+ }}>+ Add rule
+
+
+ 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
+ setForm({ ...form, kind: e.target.value })}
+ style={inputStyle}>
+ deny — no read, no write, hidden
+ read_only — no write
+
+
+
+ Scope
+ setForm({ ...form, scope: e.target.value })}
+ style={inputStyle}>
+ project (this project only)
+ global (all C3 projects)
+
+
+
+
+ Add
+ setForm(null)} style={{
+ background: T.surfaceAlt, color: T.text, border: `1px solid ${T.border}`,
+ padding: "6px 16px", borderRadius: 6, fontSize: 12, cursor: "pointer",
+ }}>Cancel
+
+
+ )}
+
+ {/* 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} />
+ setProbeOp(e.target.value)}
+ style={{ ...inputStyle, width: 110 }}>
+ read
+ write
+ create
+ delete
+
+ Check
+
+ {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/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/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
new file mode 100644
index 0000000..a07786a
--- /dev/null
+++ b/services/access_guard.py
@@ -0,0 +1,573 @@
+"""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)
+ # 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) ─────
+
+_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:
+ # Creating 'x.' or 'x ' lands 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))
+
+
+# ── 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.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()
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()
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_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()
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()
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_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"
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")