diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2985ae1..9912003 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,191 @@ 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.118.0] - 2026-09-04
+
+### Added — one approval can cover a whole rule for the session, not one file
+
+A confirm hold asked again for every file. Approving a `.mcp.json` write did
+nothing for `CLAUDE.md` next to it, and the `session` mode that already
+existed lifted the use count without touching the path binding — so a
+decision the user made once cost one tap per file. No surface even sent
+`mode`: the hub hardcoded "Approve once" and `c3 override approve` had no
+flag for it.
+
+- **Grant scope** (`docs/override-requests.md` §4.1, new). Every grant now
+ carries `scope`; the historical shape is `call` and stays the default (a
+ missing field reads as `call`, so nothing already minted changes meaning).
+ `scope="rule"` relaxes exactly two of §4's nine conditions: **tool**, from
+ the exact name to a declared op-class (`override_policy.TOOL_CLASSES`;
+ an unclassed tool is never widened, and `c3_artifacts`/`c3_project` are
+ deliberately unclassed), and **path**, from the exact canon key to the path
+ set the rule glob describes — evaluated by the same compiler, against the
+ same `(canon, rel)` pair, that produced the denial. A rule grant therefore
+ covers exactly the files that rule would have blocked.
+- Replacing what it drops: unlimited uses, its **own** TTL ceiling
+ (`rule_grant_ttl_s`, default 4 h, hard ceiling 8 h — a `call` grant keeps
+ its 15 minutes), and an **idle window** (`rule_grant_idle_s`, default
+ 30 min). The idle window is the load-bearing part: there is no session-END
+ signal to hang a long grant on — the MCP surface's `host_session_id` is
+ snapshotted at boot and goes stale across `/clear` — so a rule grant dies
+ on its own once the conversation stops using it.
+- **What it still cannot reach.** `find`/`consume` refuse
+ `forbidden_target()` on every call, not just at mint, so a rule grant over
+ `**/.c3/**` covers `.c3/notes.txt` and never `override_grants.json`,
+ `overrides.jsonl`, `config.json`, `secrets.enc` or `cred_state.json`.
+ Tier-0 never reaches the store. Minting also requires
+ `override.allow_rule_grants` (default off, AND-merged, counted as a
+ widening policy edit), a globbable rule — the synthetic discipline/shell
+ tokens are refused — a rule that actually covers the refusal, and the rule
+ glob **retyped by hand on every layer**, including `access_confirm`, which
+ stays one-tap for `once`.
+- **Visible and killable**, because a standing capability that only shows up
+ in the agent's own transcript is policed from the wrong place:
+ `GET /api/hub/grants` + `DELETE /api/hub/grants/`, an **Active grants**
+ strip in the hub Access tab, `c3 override list` printing a rule grant's
+ reach instead of the path it was minted from, and a `granted_context()`
+ line that says "covers every path <rule> matches, this session"
+ rather than a use count.
+- Surfaces: `Approve for this rule…` in `cli/hub_ui/components/hub_access.js`
+ (with a dialog that names what it reaches *before* the glob is retyped),
+ `--mode {once,session,rule}` on `c3 override approve`, `--mute` on
+ `c3 override deny` (both existed in the service layer and in the HTTP
+ routes, and neither had a CLI flag), and the rule branch in the mobile
+ decide route.
+
+### Added — a project can define and adjust its own approved paths
+
+Builtin guards (`**/.env*`, `**/.c3/**`, the agent-config confirm tier) were
+adjustable at global scope only: a project-scope `builtin_mode` or
+`disable_builtin` was a loud corrupt-scope error. The stated reason was that
+a project scope may only ever tighten, because a cloned repo could otherwise
+ship a config that loosens the guard. But that threat is answered by the
+**second** key, not by the scope — the config half has never been able to
+change a builtin on its own. So one repo needing `.env` readable meant
+turning it off for every project on the machine, which is strictly worse.
+
+- **Both keys are now realm-bound.** The keyring account for a project mode
+ is `builtin_mode|proj||` (global keeps its existing
+ spelling, so nothing already attested changes). Realm-atomic, the same
+ construction as `credential_store`: a global attestation never satisfies a
+ project entry and a project's never leaks to a sibling. A clone of a repo
+ carries `access.builtin_mode` and changes nothing, because the
+ attestation lives in this machine's keyring, not in the checkout.
+- A project mode **replaces** the global one for that glob, loosening
+ included — that is the feature. `effective_builtin_modes(project_path)`
+ resolves global then project, and `list_rules()` now reports
+ `mode_realms` so no surface can present a project-set opt-out as if it
+ were machine-wide. `c3 access list` prints `[project]` / `[global]` beside
+ each; the Access tab shows a badge and a scope picker.
+- **Rule scopes still only tighten** — that sentence in
+ docs/access-guard.md §1 is about `deny`/`read_only`/`confirm`/`mask` and
+ is unchanged. It is the separate builtin-MODE dimension that became
+ project-settable.
+- Unchanged at every scope: Tier-0 vault globs take no mode
+ (`set_builtin_mode` raises), and `override_policy.FORBIDDEN_TARGET_NAMES`
+ keeps `config.json`, `secrets.enc`, `cred_state.json`,
+ `override_grants.json` and `overrides.jsonl` unreachable even under
+ `builtin_mode: {"**/.c3/**": "allow"}` — so this can never become
+ self-granting.
+- Surfaces: `--project` on `c3 access builtin {disable,enable,mode}` (the
+ typed-glob confirmation now names *which* projects it affects), a `scope`
+ field on `POST /api/access/builtin_mode`, and a scope picker in the
+ project Access tab.
+- Loosening the `.env` guard now prints one advisory line pointing at the
+ vault: `c3_credentials import_env` reads the file server-side, so the
+ agent gets names, lengths and fingerprints and never a value. Advisory
+ only — never a refusal.
+- Hot path unaffected: the keyring is consulted only for globs a config
+ actually lists, which is normally none. Pinned by a test that asserts zero
+ keyring reads when no mode is set.
+
+**Compatibility:** a pre-2.118.0 C3 reading a project config that carries
+`builtin_mode` or `disable_builtin` treats that scope as corrupt ⇒ deny-all.
+Fail-closed, and the loud direction.
+
+### Added — `login` credentials for servers, databases and other non-web targets
+
+The vault could hold a website login and nothing else. `canonical_origin` was
+hard-refused for any scheme but `https`, so an SSH host, a database or an RDP
+box had no home — despite being exactly the kind of credential a project
+needs beside its `.env`.
+
+- `login`'s required fields are now `site_id`, `canonical_target`,
+ `username`. `canonical_target` is `scheme://host[:port]` from an
+ allowlist — https, ssh, sftp, ftps, rdp, smb, vnc, winrm, ldaps, imaps,
+ smtps, amqps, postgres, postgresql, mysql, mariadb, mssql, mongodb, redis.
+ Cleartext schemes (`http`, `ftp`, `telnet`, `smtp`, `imap`, `ldap`, …) are
+ refused **by name** and told which TLS variant to use, for the same reason
+ `http://` always was. Every other rule the origin parser had is unchanged:
+ no path, query, fragment or userinfo, host lowercased, port range-checked.
+ Ambiguity in that comparison is the whole attack, and `ssh://` earns no
+ exemption from it.
+- `password` moved to optional and `private_key` / `passphrase` were added,
+ with a cross-field rule: **one of password/private_key is required**, since
+ a login with neither is not a credential. A private key is checked for a
+ PEM/OpenSSH header (a pasted *public* key would otherwise fail much later,
+ in a runner, as an authentication error nobody traces back here) and gets
+ its own 8192-char allowance rather than raising the cap on every field.
+ Oversize payloads take the Fernet sidecar path that already existed.
+- **The origin-pinning property is preserved, not weakened.**
+ `canonical_origin` remains a known field, is accepted on input as an alias,
+ and still reads back — but **only when the target is https**. A browser
+ broker that pins a credential by asking for it gets `None` for an `ssh://`
+ entry and fails closed, instead of a string it cannot compare to a
+ top-level frame. Entries stored before this release need no migration: the
+ old spelling resolves on read in both directions.
+- Display projection is now `{site_id, scheme, target, has_totp, has_key}` —
+ username still deliberately withheld, secrets still booleans.
+- Everything inject-only is untouched and inherited for free: reveal stays
+ permanently refused, structured entries never auto-inject, the
+ plain↔structured boundary stays immutable, and no HTTP route returns a
+ field value.
+
+### Fixed — the session benchmark measured symbol narrowing against a file with no symbols
+
+`_select_sample` orders candidates by SIZE alone, so the largest file in a
+repo can be a changelog or a bundled HTML page. `bug_investigation` took
+`sample[0]` regardless, and its third step — "narrow to the relevant symbol",
+the whole point of the scenario — degenerated into a full-file read while
+still paying for the map, reporting *negative* savings that were a correct
+measurement of a workload nobody runs. It fired here the moment this
+release's own changelog entries made `CHANGELOG.md` the biggest file in the
+repo. The scenario now picks the first sampled file that actually exposes
+symbols (and reports an error rather than a number if none does):
+this repo went from -15.2% to +77.9%, with the symbol read dropping from
+29,438 tokens to 34.
+
+### Fixed — the request store could lose a row
+
+`~/.c3/oracle/override_requests.json` is one file for every project and
+session on the box, and every mutator did an unsynchronised
+load→mutate→save; two concurrent decisions, or a decide racing an auto-file,
+could each write back a list missing the other's row. The tmp file was a
+fixed `.tmp` name besides, so two writers collided on that too. Mutators now
+commit a single row under the same cross-process lock the grants store uses,
+and the tmp name is per-process. Rule grants raise the write rate, which is
+what made this worth fixing rather than noting.
+
+### Fixed — a rule-scoped grant would have matched nothing for project globs
+
+Caught by its own test before shipping: `rule_covers` was evaluating only the
+canonical path, so a project-relative rule like `secrets/**` — which the
+evaluator matches against the project-relative form — never matched. Both
+forms are now threaded from `path_key_pair` into matching, which is also the
+only way a grant and the rule that filed it can agree about the same file.
+
+### Fixed — `c3 creds set --type login` echoed the password to the terminal
+
+`_CREDS_HIDDEN_FIELDS` listed `number`, `cvc` and `ssn` but not `password`
+or `totp_secret`, so the CLI prompted for a login password with plain
+`input()` from v2.90.0. Both browser UIs masked it correctly and nothing
+asserted the CLI — the third UI had no parity test. Fixed, extended to
+`private_key` and `passphrase`, and `test_credential_ui_parity` now derives
+the must-mask set from `_SCHEMAS` instead of hardcoding it, so a future kind
+cannot reopen the hole. `private_key` is also read until a blank line rather
+than through single-line `getpass`, which would have stored the first line of
+a PEM.
+
## [2.115.0] - 2026-09-04
### Added — an advisory hint when the shell is used as a reader or a searcher (shell remediation S4)
diff --git a/cli/c3.py b/cli/c3.py
index ca96579..f7c0fea 100644
--- a/cli/c3.py
+++ b/cli/c3.py
@@ -92,7 +92,7 @@
# Config
CONFIG_DIR = ".c3"
CONFIG_FILE = ".c3/config.json"
-__version__ = "2.115.0"
+__version__ = "2.118.0"
def _compress_file_cli(compressor, path, mode="smart", **kw):
@@ -6338,7 +6338,16 @@ def _creds_entry_line(name: str, entry: dict) -> str:
return " ".join(parts)
-_CREDS_HIDDEN_FIELDS = {"number", "cvc", "ssn"} # prompted via getpass
+#: Fields prompted with getpass instead of input(). This list was missing
+#: `password` and `totp_secret` from v2.90.0 to v2.118.0, so
+#: `c3 creds set X --type login` echoed the password to the terminal — both
+#: JS UIs masked it and nothing asserted the CLI. tests/test_creds_cli.py now
+#: derives the expected set from _SCHEMAS, so a future kind cannot reopen it.
+_CREDS_HIDDEN_FIELDS = {"number", "cvc", "ssn", "password", "private_key",
+ "passphrase", "totp_secret"}
+#: A private key is pasted as many lines; getpass reads one. Read these until
+#: a blank line instead of silently storing the first line of a PEM.
+_CREDS_MULTILINE_FIELDS = {"private_key"}
def _creds_prompt_structured(name: str, ctype: str) -> str:
@@ -6355,7 +6364,16 @@ def _creds_prompt_structured(name: str, ctype: str) -> str:
fields: dict = {}
for fname in list(required) + list(optional):
tag = "" if fname in required else " (optional)"
- if fname in _CREDS_HIDDEN_FIELDS:
+ if fname in _CREDS_MULTILINE_FIELDS:
+ print(f" {fname}{tag} — paste it, then a blank line:")
+ lines = []
+ while True:
+ line = input()
+ if not line.strip():
+ break
+ lines.append(line)
+ raw = "\n".join(lines)
+ elif fname in _CREDS_HIDDEN_FIELDS:
raw = getpass.getpass(f" {fname}{tag}: ")
else:
raw = input(f" {fname}{tag}: ")
@@ -7031,12 +7049,14 @@ def _override_requests(args, project_path: str) -> None:
def _override_decide(args, decision: str, project_path: str) -> None:
from services import override_requests as orq
+ mode = getattr(args, "mode", None) or orq.MODE_ONCE
try:
row = orq.decide(getattr(args, "request_id", ""), decision,
uses=getattr(args, "uses", None),
ttl_s=getattr(args, "ttl_s", None),
note=getattr(args, "note", "") or "",
- decided_by="cli",
+ decided_by="cli", mode=mode,
+ mute=bool(getattr(args, "mute", False)),
confirm=getattr(args, "confirm", None))
except orq.OverrideError as exc:
print(f" {exc}")
@@ -7046,15 +7066,29 @@ def _override_decide(args, decision: str, project_path: str) -> None:
return
if decision == "deny":
- print(f" Denied {row['id']}. The block stands.")
+ muted = " This session will not be asked again." \
+ if row.get("muted") else ""
+ print(f" Denied {row['id']}. The block stands.{muted}")
return
print_header("Override approved")
print(f" Request : {row['id']} Grant: {row.get('grant_id', '?')}")
print(f" Allows : {row['tool']} {row['op']} on {row['path']}")
print(f" Session : {row['session_id']}")
- print(f"\n The rule {row['rule']} is still in force. One retry of this "
- "exact call, in")
- print(" that session, on that exact path — nothing else.")
+ # The reach line is the whole point of printing anything here: a session
+ # or rule grant is a standing capability, and saying "one retry" for one
+ # would be a lie the user acts on.
+ print(f"\n The rule {row['rule']} is still in force.")
+ if mode == orq.MODE_RULE:
+ print(" This grant covers EVERY path that rule matches, for any tool")
+ print(" in the same op class, in that session — until it expires or")
+ print(" the session stops using it. `c3 override list` shows it;")
+ print(" `c3 override revoke ` ends it.")
+ elif mode == orq.MODE_SESSION:
+ print(" Unlimited retries of this exact call, in that session, on")
+ print(" that exact path, until the grant expires.")
+ else:
+ print(" One retry of this exact call, in that session, on that exact")
+ print(" path — nothing else.")
def _override_show(policy, project_path: str, opol) -> None:
@@ -7074,6 +7108,14 @@ def _override_show(policy, project_path: str, opol) -> None:
f"{opol.HARD_MAX_TTL_S}s)")
print(f" uses : {policy.default_uses} "
f"(session grants: {'allowed' if policy.allow_session_grants else 'off'})")
+ if policy.allow_rule_grants:
+ print(f" rule grants : allowed — <= {policy.rule_grant_ttl_s}s "
+ f"(hard ceiling {opol.HARD_MAX_RULE_TTL_S}s), idle cap "
+ f"{policy.rule_grant_idle_s}s")
+ print(" one approval covers every path the rule "
+ "matches")
+ else:
+ print(" rule grants : off")
print(f" request TTL : {policy.request_ttl_s}s")
print(f" rate : {policy.max_pending_per_session} pending/session, "
f"{policy.max_requests_per_hour}/hour")
@@ -7185,11 +7227,22 @@ def _override_list(args, project_path: str, og) -> None:
if not grants:
print(" (none)")
for g in grants:
- print(f" {g.get('id', '?'):18s} {g.get('tool', '?')} "
- f"{g.get('op', '?')} {g.get('path_key', '?')}")
+ wide = g.get("scope") == og.SCOPE_RULE
+ left = ("unlimited use" if g.get("uses_remaining") is None
+ else f"{g.get('uses_remaining', 0)} use(s) left")
+ # A rule grant's path_key is where it was MINTED, not what it covers.
+ # Printing it in the same column as a call grant's target would read
+ # as a much smaller capability than it is.
+ reach = (f"any path matching {g.get('rule', '?')}" if wide
+ else g.get("path_key", "?"))
+ print(f" {g.get('id', '?'):18s} [{'rule' if wide else 'call'}] "
+ f"{g.get('tool', '?')} {g.get('op', '?')} {reach}")
print(f" {'':18s} rule {g.get('rule', '?')} · session "
f"{g.get('session_id', '?')} · expires {g.get('expires_at', '?')}"
- f" · {g.get('uses_remaining', 0)} use(s) left")
+ f" · {left}"
+ + (f" · idle cap {g.get('idle_s')}s" if g.get("idle_s") else ""))
+ if wide:
+ print(f" {'':18s} minted from {g.get('path_key', '?')}")
tail = int(getattr(args, "audit", 0) or 0)
if tail:
print("\n Audit (.c3/overrides.jsonl):")
@@ -7212,8 +7265,10 @@ def _override_check(args, policy, project_path: str, og, opol) -> None:
layer=gate, rule=rule, tool=tool, op=op,
path=getattr(args, "target", ""))
if hit:
- print(f" Allowed by {hit['id']} — {hit['uses_remaining']} use(s) "
- f"left, expires {hit['expires_at']}.")
+ left = ("unlimited use" if hit.get("uses_remaining") is None
+ else f"{hit['uses_remaining']} use(s) left")
+ print(f" Allowed by {hit['id']} ({hit.get('scope', 'call')} scope) "
+ f"— {left}, expires {hit['expires_at']}.")
print(" (`check` never consumes a use.)")
else:
print(" Denied — no live grant matches this exact "
@@ -7392,19 +7447,30 @@ def _access_cmd_list(args, project_path: str) -> None:
print(" [warn] access section invalid — scope fails closed "
"(deny-all); fix config.json 'access' by hand")
if scope == "builtin":
+ # Which SCOPE set each live mode. Printing a project-set opt-out
+ # as if it were machine-wide (or the reverse) is the exact
+ # confusion the realm split exists to prevent.
+ realms = sec.get("mode_realms") or {}
+
+ def _where(glob):
+ r = realms.get(access_guard._norm_builtin(glob), "")
+ return f" [{r}]" if r else ""
+
off = sec.get("disabled") or []
if off:
print(" DISABLED by you (not enforced):")
for glob in off:
- print(f" {'off':<10} {glob}")
- print(" re-enable: c3 access builtin enable ")
+ print(f" {'off':<10} {glob}{_where(glob)}")
+ print(" re-enable: c3 access builtin enable "
+ "[--project]")
moded = {g: m for g, m in (sec.get("modes") or {}).items()
if m not in ("default", "allow")}
if moded:
print(" MODE set by you (departs from the shipped default):")
for glob, m in sorted(moded.items()):
- print(f" {m:<10} {glob}")
- print(" reset: c3 access builtin mode default")
+ print(f" {m:<10} {glob}{_where(glob)}")
+ print(" reset: c3 access builtin mode default "
+ "[--project]")
can = [g for g in (sec.get("disableable") or [])
if access_guard._norm_builtin(g) not in set(off)
and access_guard._norm_builtin(g) not in moded]
@@ -7414,6 +7480,8 @@ def _access_cmd_list(args, project_path: str) -> None:
print(" disable: c3 access builtin disable · "
"granular: c3 access builtin mode "
"{deny,confirm,allow}")
+ print(" add --project to scope it to this project only "
+ "(attested under its own keyring realm)")
if sec.get("absolute"):
print(" absolute (credential vault, cannot be disabled): "
+ ", ".join(sec["absolute"]))
@@ -7430,7 +7498,8 @@ def _access_cmd_list(args, project_path: str) -> None:
def _access_cmd_builtin(args, project_path: str) -> None:
- """Switch a Tier-1 builtin off or back on. Human-only; always global scope.
+ """Switch a Tier-1 builtin off or back on. Human-only; global scope by
+ default, this project only with --project (v2.118.0).
Disabling asks for typed confirmation unless --yes: these guards are what
stop an agent rewriting its own hooks, settings or git history, and the
@@ -7452,8 +7521,11 @@ def _access_cmd_builtin(args, project_path: str) -> None:
return
disabling = action == "disable"
+ scope = "project" if getattr(args, "project_scope", False) else "global"
if disabling and not getattr(args, "yes", False):
- print(f"About to STOP enforcing the builtin guard on: {glob}")
+ where = (f"in THIS project only ({project_path})" if scope == "project"
+ else "on EVERY project on this machine")
+ print(f"About to STOP enforcing the builtin guard on: {glob} {where}")
print(" While off, any agent with C3 tools can write those paths.")
if glob.endswith("settings*.json") or ".claude" in glob:
print(" This is the file that configures the agent itself — hooks,")
@@ -7464,23 +7536,29 @@ def _access_cmd_builtin(args, project_path: str) -> None:
return
try:
- result = access_guard.set_builtin_disabled(glob, disabling)
+ result = access_guard.set_builtin_disabled(glob, disabling, scope,
+ project_path)
except ValueError as exc:
print(f"[error] {exc}")
return
- _access_audit(f"builtin_{action}", result["glob"], "builtin", "global",
- project_path)
+ _access_audit(f"builtin_{action}", result["glob"], "builtin",
+ result["scope"], project_path)
if not result["changed"]:
state = "already disabled" if disabling else "already enforced"
- print(f"[=] {result['glob']} — {state}")
+ print(f"[=] {result['glob']} — {state} ({result['scope']} scope)")
return
+ flag = " --project" if result["scope"] == "project" else ""
if disabling:
- print(f"[OK] Builtin '{result['glob']}' DISABLED (global scope).")
+ print(f"[OK] Builtin '{result['glob']}' DISABLED "
+ f"({result['scope']} scope).")
print(" Re-enable with: c3 access builtin enable "
- f"{result['glob']}")
+ f"{result['glob']}{flag}")
+ if result["glob"].endswith(".env*"):
+ print(_ENV_VAULT_HINT)
else:
- print(f"[OK] Builtin '{result['glob']}' re-enforced.")
+ print(f"[OK] Builtin '{result['glob']}' re-enforced "
+ f"({result['scope']} scope).")
if not result["attested"]:
print(" [warn] keyring attestation could not be cleared; the "
"builtin is enforced anyway (config entry removed).")
@@ -7496,8 +7574,18 @@ def _access_cmd_builtin(args, project_path: str) -> None:
}
+#: Shown whenever someone loosens the `.env` guard. `.env` is the one Tier-1
+#: builtin whose whole content is secrets, and C3 already has a place to put
+#: them that the agent cannot read. Advisory — never a refusal.
+_ENV_VAULT_HINT = (
+ " [note] .env values are better held in the vault: "
+ "`c3_credentials import_env` reads the file server-side, so the agent\n"
+ " gets names, lengths and fingerprints and never a value."
+)
+
+
def _access_cmd_builtin_mode(args, glob: str, project_path: str) -> None:
- """`c3 access builtin mode ` — granular builtin control.
+ """`c3 access builtin mode [--project]` — granular control.
`deny` and `default` never prompt (tightening / reset). `confirm` and
`allow` weaken the guard, so they get the same typed-glob beat of
@@ -7506,10 +7594,13 @@ def _access_cmd_builtin_mode(args, glob: str, project_path: str) -> None:
from services import access_guard
mode = getattr(args, "mode", None)
+ scope = "project" if getattr(args, "project_scope", False) else "global"
+ where = (f"in THIS project only ({project_path})" if scope == "project"
+ else "on EVERY project on this machine")
if mode in ("confirm", "allow") and not getattr(args, "yes", False):
widening = ("STOP enforcing" if mode == "allow"
else "PAUSE (not block) and ask you to approve")
- print(f"About to make the builtin guard on {glob} {widening}.")
+ print(f"About to make the builtin guard on {glob} {widening} {where}.")
if mode == "allow":
print(" While off, any agent with C3 tools can touch those paths.")
else:
@@ -7524,20 +7615,30 @@ def _access_cmd_builtin_mode(args, glob: str, project_path: str) -> None:
return
try:
- result = access_guard.set_builtin_mode(glob, mode)
+ result = access_guard.set_builtin_mode(glob, mode, scope, project_path)
except ValueError as exc:
print(f"[error] {exc}")
return
_access_audit(f"builtin_mode_{result['mode']}", result["glob"],
- "builtin", "global", project_path)
+ "builtin", result["scope"], project_path)
if not result["changed"]:
- print(f"[=] {result['glob']} — already {result['mode']}")
+ print(f"[=] {result['glob']} — already {result['mode']} "
+ f"({result['scope']} scope)")
return
print(f"[OK] Builtin '{result['glob']}' mode = {result['mode']} "
- f"(global scope): {_BUILTIN_MODE_HELP[result['mode']]}.")
+ f"({result['scope']} scope): {_BUILTIN_MODE_HELP[result['mode']]}.")
+ if result["scope"] == "project":
+ print(" Bound to this project's keyring realm — a clone of this "
+ "repo carries the config\n but not the attestation, so the "
+ "builtin stays enforced there.")
if result["mode"] != "default":
- print(f" Reset with: c3 access builtin mode {result['glob']} default")
+ flag = " --project" if result["scope"] == "project" else ""
+ print(f" Reset with: c3 access builtin mode {result['glob']} "
+ f"default{flag}")
+ if result["glob"].endswith(".env*") and result["mode"] in ("allow",
+ "confirm"):
+ print(_ENV_VAULT_HINT)
if not result["attested"]:
print(" [warn] keyring attestation could not be cleared; the "
"default is enforced anyway (config entry removed).")
diff --git a/cli/commands/parser.py b/cli/commands/parser.py
index 15440f7..005fd29 100644
--- a/cli/commands/parser.py
+++ b/cli/commands/parser.py
@@ -735,10 +735,18 @@ def _ci_common(sub):
help="Seconds until the grant expires (clamped to override.max_ttl_s)")
ov_approve.add_argument("--uses", type=int, default=None,
help="Uses allowed (default 1)")
+ ov_approve.add_argument(
+ "--mode", choices=("once", "session", "rule"), default="once",
+ help="once (default) | session: unlimited uses on THIS call until the "
+ "grant expires (needs override.allow_session_grants and "
+ "--confirm session) | rule: every path the rule matches, any "
+ "tool in the same op class (needs override.allow_rule_grants "
+ "and the rule glob in --confirm)")
ov_approve.add_argument("--note", default="",
help="Note recorded with the decision")
ov_approve.add_argument("--confirm", default=None,
- help="Required for deny/builtin rules: retype the rule glob by hand")
+ help="Required for deny/builtin rules and for --mode rule: "
+ "retype the rule glob by hand ('session' for --mode session)")
ov_approve.add_argument("--path", dest="project_path", default=".",
help="Project directory (default: current)")
@@ -746,6 +754,8 @@ def _ci_common(sub):
ov_deny.add_argument("request_id", help="Request id (ovr_…)")
ov_deny.add_argument("--note", default="",
help="Note the agent will see with the refusal")
+ ov_deny.add_argument("--mute", action="store_true",
+ help="Also stop this session asking the same thing again")
ov_deny.add_argument("--path", dest="project_path", default=".",
help="Project directory (default: current)")
@@ -830,10 +840,12 @@ def _ci_common(sub):
# ── Builtin opt-out (two-key) ───────────────────────────────────────
# Builtins are on by default. Switching one off needs a config entry AND
# a keyring attestation, so an agent that writes config.json alone still
- # cannot loosen the guard. Global scope only — project scopes may only
- # ever tighten. The credential-vault builtins are never disableable.
+ # cannot loosen the guard. Since v2.118.0 the pair may be written at
+ # PROJECT scope too, because the attestation is realm-bound: a cloned
+ # repo carries the config half and changes nothing. The credential-vault
+ # builtins are never disableable at any scope.
ac_builtin = access_subs.add_parser(
- "builtin", help="Disable or re-enable a built-in guard (global, needs confirmation)")
+ "builtin", help="Disable or re-enable a built-in guard (needs confirmation)")
builtin_subs = ac_builtin.add_subparsers(dest="builtin_cmd")
for _name, _help in (("disable", "Stop enforcing a built-in guard"),
("enable", "Re-enforce a built-in guard")):
@@ -841,6 +853,8 @@ def _ci_common(sub):
_p.add_argument("glob", help="One of: **/.env*, **/.c3/**, **/.claude/settings*.json, **/.git/**")
_p.add_argument("--path", dest="project_path", default=".",
help="Project directory used for the audit log (default: current)")
+ _p.add_argument("--project", dest="project_scope", action="store_true",
+ help="This project only (default: every project on this machine)")
if _name == "disable":
_p.add_argument("--yes", action="store_true",
help="Skip the typed confirmation (scripts/CI)")
@@ -857,6 +871,9 @@ def _ci_common(sub):
"approval; allow = off (two-key); default = shipped behaviour")
bm.add_argument("--path", dest="project_path", default=".",
help="Project directory used for the audit log (default: current)")
+ bm.add_argument("--project", dest="project_scope", action="store_true",
+ help="Set the mode for THIS project only, attested under "
+ "its own keyring realm (default: every project)")
bm.add_argument("--yes", action="store_true",
help="Skip the typed confirmation (scripts/CI)")
diff --git a/cli/guide/credentials.html b/cli/guide/credentials.html
index be06400..e350685 100644
--- a/cli/guide/credentials.html
+++ b/cli/guide/credentials.html
@@ -308,8 +308,9 @@
card (cardholder, number, expiry, optional cvc/billing_zip — Luhn-checked),
address (street1, city, state, zip, optional recipient/street2/country/phone),
identity (full_name, optional dob/ssn/phone/email) and
- login (site_id, canonical_origin, username, password, optional totp_secret —
- v2.90.0). The payload is stored as one
+ login (site_id, canonical_target, username, optional
+ password/private_key/passphrase/totp_secret — v2.90.0, generalized v2.118.0).
+ The payload is stored as one
canonical JSON object through the same keyring / encrypted-sidecar path as any other value.
They are inject-only by construction, which is stricter than a normal secret:
The registry shows a projection, not the data. Lists and the UI display
visa ••••4242 for a card (brand + last4, deliberately not expiry),
city/state for an address, the name for an identity, and
- github · https://github.com · 2FA for a login — site, origin and a 2FA
- boolean, with the username withheld on purpose, because username + origin is
- half the credential. Field names are public;
+ github · https://github.com · 2FA or
+ build01 · ssh://build01.lan:22 · key for a login — site, target and
+ 2FA/key booleans, with the username withheld on purpose, because username +
+ target is half the credential. Field names are public;
field values never transit any HTTP response — a sweep test enforces this.
Echoes redact per field to [cred:NAME.field]. Caveat: the
redactor only tracks values of 4+ characters, so a 3-digit CVC or 2-letter state that a
@@ -346,15 +348,34 @@
login is storage only, and that is a design constraint, not a
gap. C3 has no browser surface and must not grow one: a login runner in which the
- agent picks the destination URL turns page-content prompt injection into credential
+ agent picks the destination turns page-content prompt injection into credential
exfiltration, and a check written into a script that already holds the plaintext in its own
environment is not a boundary — the script can simply not call it. That is what
- canonical_origin is for: https-only, stored normalized
+ canonical_target is for: stored normalized
(scheme://host[:port], no path/query/fragment/userinfo, host lowercased), so a
separate out-of-process runner that the agent does not author can pin a credential to
- exactly one origin and check the live top-level frame before typing. That runner is
- deliberately not shipped in this package. totp_secret must be base32 — a
- malformed seed produces wrong codes that look exactly like a wrong password.
+ exactly one destination and check where it is actually about to authenticate before typing.
+ That runner is deliberately not shipped in this package. totp_secret must be
+ base32 — a malformed seed produces wrong codes that look exactly like a wrong password.
+
+
Not just websites (v2.118.0). A login holds any target with
+ one unambiguous destination: ssh://build01.lan:22,
+ postgres://db.internal:5432, rdp://jump.corp,
+ smb://files.lan. The scheme comes from an allowlist (https, ssh, sftp, ftps,
+ rdp, smb, vnc, winrm, ldaps, imaps, smtps, amqps, postgres, mysql, mariadb, mssql, mongodb,
+ redis); cleartext schemes are refused by name and told which TLS variant to
+ use instead, for the same reason http:// always was. A server login may carry a
+ private_key (PEM/OpenSSH, checked, with its own size allowance and an optional
+ passphrase) instead of a password — one of the two is required, because a login
+ with neither is not a credential.
+
+
The origin-pinning property is preserved, not weakened:
+ canonical_origin is still a valid field name and still reads back — but
+ only when the target is https. A browser broker that pins a credential by
+ asking for canonical_origin gets nothing for an ssh:// entry and
+ fails closed, rather than being handed a string it cannot compare to a top-level frame.
+ Entries stored before v2.118.0 need no migration: the old field name is accepted on input
+ and resolves on read.
diff --git a/cli/hub_server.py b/cli/hub_server.py
index 228443d..69087c3 100644
--- a/cli/hub_server.py
+++ b/cli/hub_server.py
@@ -3111,7 +3111,7 @@ def _hub_override_row(row: dict) -> dict:
quoted under the untrusted-input label, never as markup.
"""
out = {k: row.get(k) for k in _OVERRIDE_ROW_FIELDS}
- for extra in ("grant_id", "grant_mode", "muted"):
+ for extra in ("grant_id", "grant_mode", "grant_scope", "muted"):
if row.get(extra) is not None:
out[extra] = row.get(extra)
return out
@@ -3160,6 +3160,7 @@ def api_hub_overrides():
whether the layer is still escalatable and which typed challenge (if any)
an approval demands.
"""
+ from services import override_grants as orq_grants
from services import override_policy as opol
from services import override_requests as orq
project_path = ""
@@ -3199,6 +3200,12 @@ def api_hub_overrides():
if policy is not None else False
entry["allow_session_grants"] = bool(policy.allow_session_grants) \
if policy is not None else False
+ # A rule grant is only offerable when policy allows it AND the rule is
+ # a real glob — the synthetic discipline/shell tokens have no path set
+ # to widen along, so the card must not show a button that would 400.
+ entry["allow_rule_grants"] = bool(
+ policy is not None and policy.allow_rule_grants
+ and orq_grants.rule_is_globbable(row.get("rule", "")))
entry["project_name"] = Path(proj).name
out.append(entry)
return jsonify({"requests": out, "count": len(out),
@@ -3234,12 +3241,18 @@ def api_hub_override_decide(request_id):
rule_class = str(row.get("rule_class") or "")
confirm = data.get("confirm")
- if (decision == orq.DECISION_APPROVE
- and rule_class in opol.TYPED_CONFIRM_LAYERS
+ # A rule-scoped approval demands the glob retyped on EVERY layer, not just
+ # the two that already do — it is a standing capability, so the layers
+ # that are one-tap for `once` are deliberately not one-tap for `rule`.
+ needs_glob = (rule_class in opol.TYPED_CONFIRM_LAYERS
+ or mode == orq.MODE_RULE)
+ if (decision == orq.DECISION_APPROVE and needs_glob
and confirm != row.get("rule")):
return jsonify({
- "error": f"approving an {rule_class} request needs the rule "
- "glob retyped by hand",
+ "error": ("a rule-scoped approval needs the rule glob retyped by "
+ "hand" if mode == orq.MODE_RULE else
+ f"approving an {rule_class} request needs the rule "
+ "glob retyped by hand"),
"needs_confirmation": True,
"confirm_with": row.get("rule"),
}), 400
@@ -3261,14 +3274,73 @@ def api_hub_override_decide(request_id):
except Exception as exc:
return jsonify({"error": str(exc)}), 500
- _hub_override_audit(decision, result,
- confirmed=rule_class in opol.TYPED_CONFIRM_LAYERS,
+ _hub_override_audit(decision, result, confirmed=needs_glob,
detail_extra={"mode": mode,
"grant_id": result.get("grant_id", "")})
return jsonify({"request": _hub_override_row(result),
"decision": decision})
+#: Grant fields the hub may render. `path_key` is a canon identity, not a
+#: display string, but a rule grant's whole point is that it is NOT bound to
+#: that one path — so the card leads with the rule and shows the path only as
+#: "minted from", which is what the user was looking at when they approved.
+_GRANT_ROW_FIELDS = (
+ "id", "request_id", "session_id", "scope", "layer", "rule", "tool", "op",
+ "path_key", "expires_at", "uses_remaining", "idle_s", "granted_at",
+ "granted_by", "last_used_at",
+)
+
+
+@app.route("/api/hub/grants", methods=["GET"])
+def api_hub_grants():
+ """Live grants for one project — the standing capabilities, visible.
+
+ A rule-scoped grant can authorise many calls over hours, so it must be
+ inspectable and killable from the same screen that minted it. Without
+ this the only evidence a grant exists is a line in the agent's own
+ transcript, which is the wrong place to police it from.
+ """
+ from services import override_grants as og
+ raw = (request.args.get("path") or "").strip()
+ if not raw:
+ return jsonify({"error": "path is required"}), 400
+ try:
+ resolved = _resolve_project_path(raw)
+ except ValueError as e:
+ return jsonify({"error": str(e)}), 404
+ session_id = (request.args.get("session") or "").strip()
+ try:
+ grants = og.active(str(resolved), session_id=session_id)
+ except Exception as exc:
+ return jsonify({"error": str(exc)}), 500
+ out = [{k: g.get(k) for k in _GRANT_ROW_FIELDS} for g in grants]
+ return jsonify({"path": str(resolved), "grants": out, "count": len(out)})
+
+
+@app.route("/api/hub/grants/", methods=["DELETE"])
+def api_hub_grant_revoke(grant_id):
+ """Revoke one live grant. Human surface; ledger-logged like a decision."""
+ from services import override_grants as og
+ raw = (request.args.get("path") or "").strip()
+ if not raw:
+ return jsonify({"error": "path is required"}), 400
+ try:
+ resolved = _resolve_project_path(raw)
+ except ValueError as e:
+ return jsonify({"error": str(e)}), 404
+ try:
+ gone = og.revoke(str(resolved), grant_id=str(grant_id))
+ except Exception as exc:
+ return jsonify({"error": str(exc)}), 500
+ if not gone:
+ return jsonify({"error": "unknown or already-expired grant"}), 404
+ _hub_override_audit("grant_revoke",
+ {"id": "", "project_path": str(resolved), "rule": ""},
+ detail_extra={"grant_id": str(grant_id)})
+ return jsonify({"revoked": str(grant_id)})
+
+
@app.route("/api/hub/access", methods=["GET"])
def api_hub_access():
"""Effective Access Guard rules + override policy for one project.
diff --git a/cli/hub_ui/components/hub_access.js b/cli/hub_ui/components/hub_access.js
index eef193f..42a2b5d 100644
--- a/cli/hub_ui/components/hub_access.js
+++ b/cli/hub_ui/components/hub_access.js
@@ -60,6 +60,31 @@ function accApproveConfirm(row, onConfirm) {
};
}
+// The rule-scoped challenge. Deliberately harsher copy than the one above:
+// this is the only approval in the product that authorises files the user is
+// not looking at, so the dialog has to say which files those are BEFORE the
+// glob is retyped. requireText is always the rule — on every layer, not just
+// the two that already demand it — and decide() re-checks it server-side.
+function accRuleGrantConfirm(row, onConfirm) {
+ return {
+ title: 'Stop asking about this rule, this session?', tone: 'error',
+ confirmLabel: 'Approve for this rule', requireText: row.rule,
+ intro: This is bigger than the file on screen. Approving lets{' '}
+ this session do {row.op} on{' '}
+ every path that {row.rule} matches
+ — not just {row.path} — without asking
+ again.,
+ bullets: [
+ `The rule ${row.rule} stays in force for everything else; this grant is a standing exception inside it.`,
+ 'Any tool in the same op class can use it — an Edit approval also covers Write.',
+ 'It expires on its own, and sooner if the session stops using it. Revoke it any time under Active grants.',
+ 'The vault, .c3 policy files and Tier-0 denies stay unreachable — no grant can cover them.',
+ `Project: ${row.project_path}`,
+ ],
+ onConfirm,
+ };
+}
+
function AccJustification({ text }) {
if (!text) return null;
return (
@@ -75,7 +100,7 @@ function AccJustification({ text }) {
);
}
-function AccRequestCard({ row, busy, onDecide, onTypedApprove }) {
+function AccRequestCard({ row, busy, onDecide, onTypedApprove, onRuleApprove }) {
const pending = row.status === 'pending';
const leaf = String(row.path || '').split(/[\\/]/).filter(Boolean).pop() || row.path;
const expires = pending ? accExpiresIn(row.expires_at) : '';
@@ -130,6 +155,13 @@ function AccRequestCard({ row, busy, onDecide, onTypedApprove }) {
? onTypedApprove(row) : onDecide(row, 'approve', {}))}>
{row.needs_typed_confirm ? 'Approve…' : 'Approve once'}
+ {row.allow_rule_grants && row.escalatable && (
+ onRuleApprove(row)}
+ title={`Stop asking about ${row.rule} for the rest of this session`}>
+ Approve for this rule…
+
+ )}
onDecide(row, 'deny', {})}>Deny {
+ if (!path && known.length) setPath(known[0].path);
+ }, [known, path]);
+
+ const load = useCallback(async () => {
+ if (!path) { setGrants([]); return; }
+ try {
+ const data = await api.get(`/api/hub/grants?path=${encodeURIComponent(path)}`);
+ setGrants(data.grants || []);
+ } catch { /* keep last good list */ }
+ }, [path]);
+
+ useEffect(() => { load(); }, [load]);
+ usePoll(load, 5000);
+
+ const revoke = async (g) => {
+ setBusyId(g.id);
+ try {
+ await api.del(`/api/hub/grants/${g.id}?path=${encodeURIComponent(path)}`);
+ notify(`Revoked ${g.id} — the rule ${g.rule} blocks again`, 'ok');
+ } catch (e) { notify(apiErr(e), 'err'); }
+ setBusyId('');
+ load();
+ };
+
+ return (
+
+
+ Active grants
+
+ live exceptions the agent can still use — revoking one takes effect
+ on its next call
+
+
+
+
@@ -340,10 +473,12 @@ function HubAccess({ projects }) {
) : (
rows.map(row => (
+ onDecide={decide} onTypedApprove={typedApprove}
+ onRuleApprove={ruleApprove} />
))
)}
+
{confirmSpec && (
setConfirmSpec(null)} />
diff --git a/cli/hub_ui/components/hub_credentials.js b/cli/hub_ui/components/hub_credentials.js
index e157203..1e9f7ea 100644
--- a/cli/hub_ui/components/hub_credentials.js
+++ b/cli/hub_ui/components/hub_credentials.js
@@ -30,18 +30,26 @@ const CREDS_STRUCTURED = {
optional: ['recipient', 'street2', 'country', 'phone'], hidden: [] },
identity: { required: ['full_name'],
optional: ['dob', 'ssn', 'phone', 'email'], hidden: ['ssn', 'dob'] },
- login: { required: ['site_id', 'canonical_origin', 'username', 'password'],
- optional: ['totp_secret'], hidden: ['password', 'totp_secret'] },
+ // canonical_origin stays a known OPTIONAL field: it is the pre-2.118.0
+ // spelling, accepted on input as an alias for canonical_target. Password is
+ // optional too — a key-based server login has none — but the store still
+ // requires one of password/private_key.
+ login: { required: ['site_id', 'canonical_target', 'username'],
+ optional: ['password', 'private_key', 'passphrase', 'totp_secret',
+ 'canonical_origin'],
+ hidden: ['password', 'private_key', 'passphrase', 'totp_secret'] },
};
const credsDisplayText = (entry) => {
const d = entry.display || {};
if (entry.type === 'card') return `${d.brand || 'card'} ••••${d.last4 || '????'}`;
- // login's projection carries has_totp as a BOOLEAN — the generic join below
- // would render it as the word "true". Username is absent by design.
+ // login's projection carries has_totp / has_key as BOOLEANS — the generic
+ // join below would render them as the word "true". Username is absent by
+ // design.
if (entry.type === 'login') {
- const bits = [d.site_id, d.origin].filter(Boolean);
+ const bits = [d.site_id, d.target || d.origin].filter(Boolean);
if (d.has_totp) bits.push('2FA');
+ if (d.has_key) bits.push('key');
return bits.join(' · ');
}
const vals = Object.values(d).filter(Boolean);
diff --git a/cli/mcp_server.py b/cli/mcp_server.py
index 2ad11bf..cd0a064 100644
--- a/cli/mcp_server.py
+++ b/cli/mcp_server.py
@@ -1145,11 +1145,17 @@ async def c3_credentials(
STRUCTURED kinds (address/identity/card/login) hold named fields: set takes value
as a JSON object (card: cardholder/number/expiry[/cvc/billing_zip]; address:
street1/city/state/zip[/recipient/street2/country/phone]; identity:
- full_name[/dob/ssn/phone/email]; login: site_id/canonical_origin/username/password
- [/totp_secret]). `login` is STORAGE ONLY — C3 has no browser and never types a
- password anywhere. canonical_origin is https-only and stored normalized so an
- external runner can bind the credential to one origin; do NOT build a browser
- login runner in this package. Address a FIELD at the boundary —
+ full_name[/dob/ssn/phone/email]; login: site_id/canonical_target/username
+ [/password/private_key/passphrase/totp_secret], one of password/private_key
+ required). `login` covers websites AND servers, databases and other non-web
+ targets: canonical_target is scheme://host[:port] from an allowlist (https, ssh,
+ sftp, rdp, smb, winrm, ldaps, imaps, smtps, postgres, mysql, mssql, mongodb,
+ redis, …); cleartext schemes are refused by name. It is STORAGE ONLY — C3 has no
+ browser, opens no SSH session, and never types a password anywhere. The target is
+ stored normalized so an external runner can bind the credential to one
+ destination; `canonical_origin` still reads back, but ONLY when the target is
+ https, so a browser broker fails closed on a server entry. Do NOT build a login
+ runner in this package. Address a FIELD at the boundary —
env_creds='CARD.number' (env $CARD_NUMBER) or {{cred:CARD.number}} in cmd. Reveal
is permanently disabled for them and they never auto-inject; have the user enter
the values via the Credentials UI or `c3 creds set` so they never enter the chat.
diff --git a/cli/server.py b/cli/server.py
index c19999a..ea9d39c 100644
--- a/cli/server.py
+++ b/cli/server.py
@@ -3019,25 +3019,29 @@ def api_access_remove():
@app.route('/api/access/builtin_mode', methods=['POST'])
def api_access_builtin_mode():
- """Set a Tier-1 builtin's mode {glob, mode: deny|confirm|allow|default}.
-
- Human surface, GLOBAL scope by construction (set_builtin_mode writes
- ~/.c3 and attests in the keyring — the two-key rule). The typed-glob
- confirmation for widening modes lives in the UI; the irreversible parts
- (Tier-0 refusal, attestation-first ordering) are enforced in the service
- regardless of what the client believes.
+ """Set a Tier-1 builtin's mode {glob, mode, scope?}.
+
+ mode: deny|confirm|allow|default. scope: global (default) or project
+ (v2.118.0) — a project mode is attested under that project's own keyring
+ realm, so it governs this project and nothing else, and the config half
+ alone still changes nothing. The typed-glob confirmation for widening
+ modes lives in the UI; the irreversible parts (Tier-0 refusal,
+ attestation-first ordering) are enforced in the service regardless of
+ what the client believes.
"""
from services import access_guard
data = request.get_json() or {}
glob = str(data.get("glob") or "")
mode = str(data.get("mode") or "").strip()
+ scope = str(data.get("scope") or "global").strip()
try:
- result = access_guard.set_builtin_mode(glob, mode)
+ result = access_guard.set_builtin_mode(glob, mode, scope,
+ str(PROJECT_PATH))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if result["changed"]:
_access_route_audit(f"builtin_mode_{result['mode']}",
- result["glob"], "builtin", "global")
+ result["glob"], "builtin", result["scope"])
return jsonify(result)
diff --git a/cli/ui/components/access.js b/cli/ui/components/access.js
index adeaed1..9a108d4 100644
--- a/cli/ui/components/access.js
+++ b/cli/ui/components/access.js
@@ -54,6 +54,9 @@ const AccessPanel = () => {
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
+ // Where a builtin MODE change lands: this machine, or this project only
+ // (v2.118.0). Global stays the default — the narrower scope is a choice.
+ const [builtinScope, setBuiltinScope] = useState("global");
const [form, setForm] = useState(null); // null = closed; {…} = add-rule form
const [probePath, setProbePath] = useState("");
const [probeOp, setProbeOp] = useState("read");
@@ -233,29 +236,49 @@ const AccessPanel = () => {
const builtinModes = (scopes && scopes.builtin && scopes.builtin.modes) || {};
const builtinModeFor = (glob) => builtinModes[(glob || "").toLowerCase()] || "";
-
- const setBuiltinMode = async (glob, mode) => {
+ // Which scope set the live mode. A project-set opt-out that reads as
+ // machine-wide (or the reverse) is the exact confusion the realm split
+ // exists to prevent, so the row says it.
+ const builtinRealms = (scopes && scopes.builtin && scopes.builtin.mode_realms) || {};
+ const builtinRealmFor = (glob) => builtinRealms[(glob || "").toLowerCase()] || "";
+
+ const setBuiltinMode = async (glob, mode, scope) => {
+ const where = scope === "project"
+ ? "in THIS project only"
+ : "on EVERY project on this machine";
// Widening (confirm / allow) gets the same typed-glob beat of friction
// as the CLI; deny / default are tightening or reset and just confirm.
if (mode === "confirm" || mode === "allow") {
const what = mode === "allow"
? "STOP enforcing entirely"
: "PAUSE and file an approval request instead of blocking";
+ const note = scope === "project"
+ ? "\n\nThis is bound to this project's keyring realm — a clone of this repo carries the config but not the attestation, so the guard stays on there."
+ : "";
const typed = window.prompt(
- `The builtin guard on '${glob}' will ${what}.\n\nType the glob exactly to confirm:`);
+ `The builtin guard on '${glob}' will ${what} ${where}.${note}\n\nType the glob exactly to confirm:`);
if (typed === null) return;
if (typed.trim() !== glob) {
setError("Confirmation text did not match — builtin unchanged.");
return;
}
- } else if (!window.confirm(`Set builtin '${glob}' to ${mode}?`)) {
+ } else if (!window.confirm(`Set builtin '${glob}' to ${mode} ${where}?`)) {
return;
}
setBusy(true);
try {
- const resp = await api.post("/api/access/builtin_mode", { glob, mode });
+ const resp = await api.post("/api/access/builtin_mode", { glob, mode, scope });
if (resp && resp.error) setError(resp.error);
- else flash(`Builtin '${glob}' mode = ${mode}`);
+ else {
+ flash(`Builtin '${glob}' mode = ${mode} (${scope} scope)`);
+ // .env is the one builtin whose whole content is secrets, and C3
+ // already has somewhere better to put them. Advisory, never a block.
+ if (/\.env/.test(glob) && (mode === "allow" || mode === "confirm")) {
+ flash("Tip: .env values are better held in the vault — " +
+ "c3_credentials import_env reads the file server-side, so the " +
+ "agent gets names and fingerprints and never a value.");
+ }
+ }
load();
} catch (e) { setError(String(e)); }
setBusy(false);
@@ -644,6 +667,22 @@ const AccessPanel = () => {
{ACCESS_SCOPE_NOTES[scope]}
+ {isBuiltin && (
+
+
+ changes apply
+
+
+ )}
{rows.length === 0 ? (
{
built-in — mode:
+ {builtinRealmFor(r.glob) && (
+
+ set {builtinRealmFor(r.glob)}
+
+ )}
)}
diff --git a/cli/ui/components/credentials.js b/cli/ui/components/credentials.js
index ba4f2f0..7d07c99 100644
--- a/cli/ui/components/credentials.js
+++ b/cli/ui/components/credentials.js
@@ -23,18 +23,26 @@ const CREDS_STRUCTURED = {
optional: ["recipient", "street2", "country", "phone"], hidden: [] },
identity: { required: ["full_name"],
optional: ["dob", "ssn", "phone", "email"], hidden: ["ssn", "dob"] },
- login: { required: ["site_id", "canonical_origin", "username", "password"],
- optional: ["totp_secret"], hidden: ["password", "totp_secret"] },
+ // canonical_origin stays a known OPTIONAL field: it is the pre-2.118.0
+ // spelling, accepted on input as an alias for canonical_target. Password is
+ // optional too — a key-based server login has none — but the store still
+ // requires one of password/private_key.
+ login: { required: ["site_id", "canonical_target", "username"],
+ optional: ["password", "private_key", "passphrase", "totp_secret",
+ "canonical_origin"],
+ hidden: ["password", "private_key", "passphrase", "totp_secret"] },
};
const credsDisplayText = (entry) => {
const d = entry.display || {};
if (entry.type === "card") return `${d.brand || "card"} ••••${d.last4 || "????"}`;
- // login's projection carries has_totp as a BOOLEAN — the generic join below
- // would render it as the word "true". Username is absent by design.
+ // login's projection carries has_totp / has_key as BOOLEANS — the generic
+ // join below would render them as the word "true". Username is absent by
+ // design.
if (entry.type === "login") {
- const bits = [d.site_id, d.origin].filter(Boolean);
+ const bits = [d.site_id, d.target || d.origin].filter(Boolean);
if (d.has_totp) bits.push("2FA");
+ if (d.has_key) bits.push("key");
return bits.join(" · ");
}
const vals = Object.values(d).filter(Boolean);
diff --git a/docs/access-guard.md b/docs/access-guard.md
index 21449d4..c3a157d 100644
--- a/docs/access-guard.md
+++ b/docs/access-guard.md
@@ -27,9 +27,16 @@ section `access`:
- 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.
+ (Since v2.97.0/v2.99.0 the recognized set is `deny`, `read_only`,
+ `confirm`, `mask`, `disable_builtin`, `builtin_mode` — the unknown-key
+ rule itself is unchanged.)
- Globs are stored POSIX forward-slash canonical; backslashes normalized on
input. Case-insensitive matching.
- Scopes UNION; merging only ever tightens. `deny` beats `read_only`.
+ **This is a statement about RULES, and it still holds without exception.**
+ Since v2.118.0 the separate `builtin_mode` dimension is settable at
+ project scope and a project mode may loosen — see the builtin note below
+ for why that is not a hole in this sentence.
- 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.)
@@ -74,9 +81,34 @@ section `access`:
- Corrupt/unparseable access section ⇒ that scope evaluates deny-all with a
loud warning. Builtins apply even with no config at all.
- > Implementation note (v2.99.0): Tier-1 builtins gained granular modes —
- > `access.builtin_mode: {glob: deny|confirm|allow}`, global-only, two-key
- > attested, Tier-0 excluded; `disable_builtin` survives as the legacy
- > spelling of `allow`. Spec: docs/confirm-guard.md §7.
+ > `access.builtin_mode: {glob: deny|confirm|allow}`, two-key attested,
+ > Tier-0 excluded; `disable_builtin` survives as the legacy spelling of
+ > `allow`. Spec: docs/confirm-guard.md §7.
+- > **Amended v2.118.0 — builtin modes are settable per project.**
+ > `builtin_mode` and `disable_builtin` were global-only, on the reasoning
+ > that a project scope may only ever tighten, because a cloned repo could
+ > otherwise ship a config that loosens the guard. That threat is answered
+ > by the *second* key, not by the scope: **the config half has never been
+ > able to change a builtin.** The attestation is now realm-bound —
+ > `builtin_mode|proj||` in the `c3-access` keyring
+ > service, the same realm-atomic construction as `credential_store` — so a
+ > project's claim is inert unless a human wrote the attestation for *that
+ > project* on *this* machine. A clone carries config and nothing else.
+ >
+ > A project mode REPLACES the global one for that glob, loosening
+ > included; that is the point. `effective_builtin_modes(project_path)`
+ > resolves global then project, and `list_rules()` reports
+ > `mode_realms` so no surface can show a project-set opt-out as if it were
+ > machine-wide. Unchanged: Tier-0 takes no mode at any scope, and
+ > `override_policy.FORBIDDEN_TARGET_NAMES` keeps `config.json`,
+ > `secrets.enc`, `cred_state.json`, `override_grants.json` and
+ > `overrides.jsonl` unreachable even under
+ > `builtin_mode: {"**/.c3/**": "allow"}`, so this can never become
+ > self-granting.
+ >
+ > Compatibility: a pre-2.118.0 C3 reading a project config that carries
+ > either key treats that scope as corrupt ⇒ deny-all. Fail-closed, and the
+ > loud direction.
- 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.
diff --git a/docs/confirm-guard.md b/docs/confirm-guard.md
index 0760c0d..3c060f4 100644
--- a/docs/confirm-guard.md
+++ b/docs/confirm-guard.md
@@ -171,6 +171,14 @@ override-requests §3.1 property 1 ("default off, everywhere"):
typed-glob challenge stays where it was, on `access_deny` and
`access_builtin`. Rate limits, request TTL, grant TTL/use ceilings, mutes,
and the nine-condition grant matcher apply to confirm identically.
+- **Amended v2.118.0:** one-tap covers `mode=once` only. A **rule-scoped**
+ approval (override-requests §4.1) demands the rule glob retyped on every
+ layer, `access_confirm` included, because what it accepts is not "ask me
+ less about this file" but "stop asking me about this rule" — a standing
+ capability over paths the approver is not looking at. This is the layer
+ that benefits most from rule grants (the agent-config tier in §7 is
+ confirm-by-default and spans many files), which is exactly why its
+ challenge does not stay cheap when the reach grows.
## 6. Coverage
@@ -224,12 +232,11 @@ the Hub (§7.1). Residual risks are access-guard §6's, unchanged.
the mobile route: identifiers only, never the justification.
2. **`builtin_mode`** — SHIPPED v2.99.0. Per-builtin mode
- `deny | confirm | allow` (plus `default`, the reset verb), GLOBAL scope
- only, in `access.builtin_mode: {glob: mode}`. Two-key attested exactly
- like the opt-out it generalises: keyring account `builtin_mode|`
- must hold the SAME mode string, written attestation-first; every failure
- path enforces the shipped default. A mode never widens the op class the
- builtin governs:
+ `deny | confirm | allow` (plus `default`, the reset verb), in
+ `access.builtin_mode: {glob: mode}`. Two-key attested exactly like the
+ opt-out it generalises: the keyring account must hold the SAME mode
+ string, written attestation-first; every failure path enforces the
+ shipped default. A mode never widens the op class the builtin governs:
| Tier | default | deny | confirm | allow |
|---|---|---|---|---|
@@ -240,14 +247,27 @@ the Hub (§7.1). Residual risks are access-guard §6's, unchanged.
raises), and even under a confirm-mode `.c3/**` the `forbidden_target()`
files can never become a request. `disable_builtin` survives as the
legacy spelling of `allow` (`set_builtin_disabled` is a shim); a glob
- named in BOTH spellings makes the global scope corrupt (deny-all), and
+ named in BOTH spellings makes that scope corrupt (deny-all), and
`set_builtin_mode` lazily retires the legacy entry so the state cannot
- arise through the API. Project-scope `builtin_mode` ⇒ corrupt. Reads on
- a confirm-held builtin file a request from the hook and `c3_read`;
- enumeration surfaces exclude and never auto-file (no existence leak).
- Surfaces: `c3 access builtin mode ` (typed-glob confirm for
- the widening modes), `POST /api/access/builtin_mode` + a mode selector in
- the Access tab, `list_rules()["builtin"]["modes"]`.
+ arise through the API. Reads on a confirm-held builtin file a request
+ from the hook and `c3_read`; enumeration surfaces exclude and never
+ auto-file (no existence leak).
+ Surfaces: `c3 access builtin mode [--project]` (typed-glob
+ confirm for the widening modes), `POST /api/access/builtin_mode` (with a
+ `scope`) + a mode selector and scope picker in the Access tab,
+ `list_rules()["builtin"]["modes"]` and `["mode_realms"]`.
+
+ **Scope, amended v2.118.0.** Modes were global-only; they are now
+ settable at `project` scope too, and a project mode REPLACES the global
+ one for that glob — loosening included. The keyring account is therefore
+ realm-bound: `builtin_mode|` for global (spelling unchanged),
+ `builtin_mode|proj||` for a project. Realm-atomic
+ like `credential_store`: a global attestation never satisfies a project
+ entry and vice versa. What makes this safe is the property the two-key
+ design always had — **config alone changes nothing** — so a cloned repo
+ ships the config half and the builtin stays enforced. See
+ docs/access-guard.md §1 for the full amendment; Tier-0 and
+ `forbidden_target()` are unchanged at every scope.
3. **Agent-config confirm tier** — SHIPPED v2.100.0, completed v2.102.0.
`BUILTIN_CONFIRM_WRITE` is the previously UNGUARDED agent-config surface
diff --git a/docs/override-requests.md b/docs/override-requests.md
index 4cbf202..10c9419 100644
--- a/docs/override-requests.md
+++ b/docs/override-requests.md
@@ -170,11 +170,21 @@ credential vault.
"max_requests_per_hour": 20,
"notify_severity": "critical",
"allow_session_grants": false,
+ "allow_rule_grants": false,
+ "rule_grant_ttl_s": 14400,
+ "rule_grant_idle_s": 1800,
"wake": null
}
}
```
+`allow_rule_grants`, `rule_grant_ttl_s` and `rule_grant_idle_s` are §4.1.
+All three are widenings: the boolean AND-merges like `allow_session_grants`,
+and both integers min-merge and clamp to `HARD_MAX_RULE_TTL_S` /
+`HARD_MAX_RULE_IDLE_S`. A longer TTL or a longer idle window means a rule
+grant outlives more of the conversation, so the mobile policy route counts
+either as a loosening and demands the same challenge.
+
`wake` is §7.1. `null` (the default) means nobody is told when a request is
decided — see there for why that was the bug and not the design.
@@ -312,6 +322,86 @@ grant behind.
---
+## 4.1 Grant scope — `call` and `rule` (v2.118.0)
+
+§4 above describes a `scope="call"` grant, which is still the default and
+still what every grant minted before v2.118.0 is read as (a missing `scope`
+field means `call`).
+
+The problem it left: an approval covers **one file**. A confirm rule over
+`**/.mcp.json`-class paths, or a `deny` over a whole directory, produces one
+card per file, and a user working through five of them taps five times for a
+decision they made once. The `session` *mode* did not solve this — it lifts
+the use count, not the path binding.
+
+`scope="rule"` is the shape that does. It relaxes **exactly two** of the nine
+conditions:
+
+- **5 (tool)** — from the exact tool to a declared **op-class**
+ (`override_policy.TOOL_CLASSES` / `same_tool_class`). Exact match always
+ qualifies; beyond that both tools must resolve to the same declared class.
+ A tool in no class is never widened, so an unrecognised or future tool
+ cannot inherit an existing grant. `c3_artifacts` (restore writes a whole
+ prior version) and `c3_project` (writes into a *different* project) are
+ deliberately unclassed.
+- **7 (path)** — from the exact canon key to *"inside the path set the rule
+ glob describes"*, evaluated by `override_grants.rule_covers` against the
+ **same** `(canon, rel)` pair and the **same** compiler
+ (`access_guard._compile`) that produced the denial. A rule grant therefore
+ covers exactly the files that rule would have blocked — never one more.
+
+Everything else is unchanged, and three things replace what it drops:
+
+- **`uses_remaining` is `None`** (unlimited). `HARD_MAX_USES` does not apply.
+- **Its own TTL ceiling.** `rule_grant_ttl_s` (default 4 h, hard ceiling
+ `HARD_MAX_RULE_TTL_S` = 8 h) instead of `max_ttl_s` / `HARD_MAX_TTL_S`. A
+ `call` grant keeps its 15-minute ceiling untouched.
+- **An idle window.** `idle_s` (default 30 min, ceiling
+ `HARD_MAX_RULE_IDLE_S` = 1 h) measured from `last_used_at`, else
+ `granted_at`. An unreadable or absent-but-required window reads as expired,
+ never as eternal.
+
+The idle window exists because **there is no session-END signal to hang a
+long grant on.** The MCP surface's `host_session_id` is snapshotted once at
+`start_session()` and goes stale across `/clear`, while the hook surface
+reads the live payload field — so after `/clear` a long grant would keep
+matching MCP-surface calls in what is, to the user, a new conversation. A
+15-minute grant made that harmless; a 4-hour one would not. Rather than
+rewrite session identity (a shared `.c3/session.host` file is
+last-writer-wins across two windows in the same project — worse than the
+problem), a rule grant dies on its own the moment the conversation stops
+exercising it.
+
+**What still cannot be reached.** The widened path condition is the only
+route by which a grant could grow toward the vault or the policy files, and
+it does not: `find()` and `consume()` refuse `override_policy.forbidden_target()`
+on **every call**, not only at mint. So a rule grant over `**/.c3/**` covers
+`.c3/notes.txt` and never `override_grants.json`, `overrides.jsonl`,
+`config.json`, `secrets.enc` or `cred_state.json` (§11 threat 3). Tier-0
+absolute denies never reach the store at all.
+
+**Minting one requires all of:** `override.allow_rule_grants` (default
+`false`, AND-merged across scopes and counted as a *widening* policy edit),
+a **globbable** rule — the synthetic `` and
+`` tokens are refused, they name a behaviour and have no
+path set — a rule that actually covers the refusal being approved, and the
+**rule glob retyped by hand** on *every* layer, including `access_confirm`,
+which stays one-tap for `once`.
+
+**It must be visible and killable.** `GET /api/hub/grants` and the Access
+tab's *Active grants* strip list live grants with their real reach;
+`DELETE /api/hub/grants/` and `c3 override revoke ` end one
+immediately. `granted_context()` says *"covers every path <rule> matches,
+this session"* rather than a use count, so a standing capability never reads
+in the transcript like a single approval.
+
+**Near-misses are scoped too:** a rule grant is *supposed* to span tools and
+paths, so those differences are not reported for it — only a rule or op
+mismatch is, which keeps the "you approved X, the agent tried Y" signal out
+of the noise the user deliberately allowed.
+
+---
+
## 5. Gate placement
Order matters: `hook_dispatch._routes()` runs `hook_access_guard` **before**
diff --git a/oracle/services/mobile_api.py b/oracle/services/mobile_api.py
index cad694c..28d3df4 100644
--- a/oracle/services/mobile_api.py
+++ b/oracle/services/mobile_api.py
@@ -1828,7 +1828,7 @@ def _override_row(row: dict) -> dict:
out = {k: row.get(k) for k in _OVERRIDE_FIELDS}
# Decision extras — present only once decided, so a pending card is exactly
# the §3.3 shape and nothing more.
- for extra in ("grant_id", "grant_mode", "muted"):
+ for extra in ("grant_id", "grant_mode", "grant_scope", "muted"):
if row.get(extra) is not None:
out[extra] = row.get(extra)
return out
@@ -2046,7 +2046,11 @@ def _override_widenings(current, section: dict) -> list:
if want and not current.layers.get(key, False):
out.append(f"layers.{key}")
for key in ("max_ttl_s", "default_uses", "request_ttl_s",
- "max_pending_per_session", "max_requests_per_hour"):
+ "max_pending_per_session", "max_requests_per_hour",
+ # A longer TTL or a longer idle window both mean a rule grant
+ # outlives more of the conversation, which is a loosening even
+ # though nothing about it looks like a permission.
+ "rule_grant_ttl_s", "rule_grant_idle_s"):
if key in section:
try:
if int(section[key]) > int(getattr(current, key)):
@@ -2055,6 +2059,8 @@ def _override_widenings(current, section: dict) -> list:
pass
if section.get("allow_session_grants") and not current.allow_session_grants:
out.append("allow_session_grants")
+ if section.get("allow_rule_grants") and not current.allow_rule_grants:
+ out.append("allow_rule_grants")
return out
@@ -2105,6 +2111,7 @@ def mobile_override_get(request_id):
# Same refusal as an unknown id: whether a request exists for a project
# this gateway does not serve is not this token's business.
return jsonify({"error": "unknown request"}), 404
+ from services import override_grants as og
from services import override_policy as opol
policy = opol.resolve(str(row.get("project_path") or "."))
return jsonify({
@@ -2115,6 +2122,14 @@ def mobile_override_get(request_id):
"max_ttl_s": policy.max_ttl_s,
"default_uses": policy.default_uses,
"allow_session_grants": policy.allow_session_grants,
+ # Offerable only when policy allows it AND the rule is a real
+ # glob — a synthetic rule has no path set to widen along, and a
+ # button that always 400s is worse than no button.
+ "allow_rule_grants": bool(
+ policy.allow_rule_grants
+ and og.rule_is_globbable(row.get("rule", ""))),
+ "rule_grant_ttl_s": policy.rule_grant_ttl_s,
+ "rule_grant_idle_s": policy.rule_grant_idle_s,
"escalatable": policy.escalatable(str(row.get("rule_class") or "")),
},
"needs_typed_confirm": (
@@ -2137,6 +2152,7 @@ def mobile_override_decide(request_id):
gate = _override_write_gate()
if gate:
return gate
+ from services import override_grants as og
from services import override_policy as opol
from services import override_requests as orq
data = request.get_json(silent=True) or {}
@@ -2187,6 +2203,28 @@ def mobile_override_decide(request_id):
"needs_confirmation": True,
"confirm_with": orq.CONFIRM_SESSION,
}), 400
+ # (c) a rule grant — the largest shape, and the only one that reaches
+ # files the approver is not looking at. Its challenge is the rule
+ # glob on EVERY layer, including the ones that are one-tap for once.
+ if mode == orq.MODE_RULE:
+ if not policy.allow_rule_grants:
+ return jsonify({
+ "error": "rule-scoped grants are disabled for this project",
+ "detail": "`override.allow_rule_grants` is false — "
+ "approve once or for this session instead.",
+ }), 403
+ if not og.rule_is_globbable(row.get("rule", "")):
+ return jsonify({
+ "error": f"'{row.get('rule')}' names a behaviour, not a "
+ "path set — there is nothing to widen along",
+ }), 400
+ if confirm != row.get("rule"):
+ return jsonify({
+ "error": "a rule grant covers every path the rule "
+ "matches, for any tool in the same op class",
+ "needs_confirmation": True,
+ "confirm_with": row.get("rule"),
+ }), 400
requested_ttl = data.get("ttl_s")
try:
@@ -2212,7 +2250,8 @@ def mobile_override_decide(request_id):
return _svc_error(exc)
_override_audit(decision, result,
- confirmed=rule_class in opol.TYPED_CONFIRM_LAYERS,
+ confirmed=(rule_class in opol.TYPED_CONFIRM_LAYERS
+ or mode == orq.MODE_RULE),
detail_extra={"mode": mode,
"grant_id": result.get("grant_id", "")})
_gw_audit(f"mobile.override.{decision}",
@@ -2221,22 +2260,33 @@ def mobile_override_decide(request_id):
payload = {"request": _override_row(result), "decision": decision}
if decision == orq.DECISION_APPROVE:
- granted_ttl = policy.clamp_ttl(requested_ttl)
+ wide = mode == orq.MODE_RULE
+ # A rule grant has its OWN ceiling; reporting max_ttl_s here would
+ # tell the phone 15 minutes for a grant that actually lives hours.
+ granted_ttl = (policy.clamp_rule_ttl(requested_ttl) if wide
+ else policy.clamp_ttl(requested_ttl))
payload["grant"] = {
"id": result.get("grant_id", ""),
"mode": result.get("grant_mode", mode),
+ "scope": result.get("grant_scope", "call"),
"ttl_s": granted_ttl,
- "uses": policy.clamp_uses(
+ "uses": None if wide else policy.clamp_uses(
opol.HARD_MAX_USES if mode == orq.MODE_SESSION
else requested_uses),
}
+ if wide:
+ payload["grant"]["idle_s"] = policy.rule_idle_s()
+ payload["grant"]["covers"] = row.get("rule", "")
# §8: a client asking for a week gets 15 minutes and is TOLD so.
if requested_ttl is not None and granted_ttl < requested_ttl:
+ ceiling_key = "override.rule_grant_ttl_s" if wide \
+ else "override.max_ttl_s"
+ ceiling = policy.rule_grant_ttl_s if wide else policy.max_ttl_s
payload["clamped"] = True
payload["clamped_note"] = (
f"requested ttl_s={requested_ttl} was clamped to "
- f"{granted_ttl}s by this project's override.max_ttl_s "
- f"({policy.max_ttl_s}s).")
+ f"{granted_ttl}s by this project's {ceiling_key} "
+ f"({ceiling}s).")
return jsonify(payload)
diff --git a/pyproject.toml b/pyproject.toml
index c881759..d7641f2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "code-context-control"
-version = "2.115.0"
+version = "2.118.0"
description = "Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub."
readme = "README.md"
requires-python = ">=3.10"
diff --git a/services/access_guard.py b/services/access_guard.py
index d8d59cc..fb3e24b 100644
--- a/services/access_guard.py
+++ b/services/access_guard.py
@@ -327,10 +327,11 @@ def _norm_builtin(glob) -> str:
del _g
-def _builtin_rules_effective(modes: dict | None = None) -> list:
+def _builtin_rules_effective(modes: dict | None = None,
+ project_path: str = ".") -> list:
"""The Tier-1 rules as currently moded: default/deny/confirm variants,
'allow' omitted. Declaration order preserved."""
- modes = effective_builtin_modes() if modes is None else modes
+ modes = effective_builtin_modes(project_path) if modes is None else modes
out = []
for glob in DISABLEABLE_BUILTINS:
canon = _norm_builtin(glob)
@@ -362,39 +363,87 @@ def _builtin_rules_effective(modes: dict | None = None) -> list:
# Every failure path here leaves the builtin ENFORCED.
-def _builtin_attest_account(glob: str) -> str:
- return f"builtin_disabled|{_norm_builtin(glob)}"
+def _scope_base(scope: str, project_path: str = "."):
+ """The directory that owns *scope*'s `.c3` — the ONE resolver for scope
+ bases in this module (tests/test_access_guard_meta.py pins that).
+
+ Scope-dir plumbing, not user-path evaluation: a config location, never a
+ target being judged. Everything that needs a project's identity for a
+ realm, a mode lookup or a comparison goes through here, so two spellings
+ of the same project can never produce two realms.
+ """
+ if scope == "global":
+ return _global_base()
+ try:
+ return Path(project_path).resolve()
+ except Exception:
+ return None
-def _attest_builtin_disabled(glob: str, disabled: bool) -> bool:
+def realm(scope: str, project_path: str = ".") -> str:
+ """Keyring realm for a builtin attestation: ``global`` or ``proj|``.
+
+ Same construction as ``credential_store.realm()`` — deliberately, because
+ it buys the same property: an attestation is bound to ONE realm and never
+ falls through to another. A project's config claiming a mode is inert
+ unless the attestation for *that project's* realm exists on *this*
+ machine, so a cloned repo can ship whatever `access` section it likes and
+ the builtin stays enforced.
+ """
+ if scope == "global":
+ return "global"
+ base = _scope_base("project", project_path)
+ if base is None:
+ return ""
+ return "proj|" + os.path.normcase(str(base))
+
+
+def _builtin_attest_account(glob: str, scope: str = "global",
+ project_path: str = ".") -> str:
+ who = realm(scope, project_path)
+ if not who:
+ return ""
+ if scope == "global":
+ return f"builtin_disabled|{_norm_builtin(glob)}" # unchanged spelling
+ return f"builtin_disabled|{who}|{_norm_builtin(glob)}"
+
+
+def _attest_builtin_disabled(glob: str, disabled: bool, scope: str = "global",
+ project_path: str = ".") -> bool:
"""Write the keyring half of the opt-out. False when it did not stick.
A caller that ignores False would leave the config saying "disabled" while
evaluation keeps enforcing — so set_builtin_disabled() surfaces it.
"""
+ account = _builtin_attest_account(glob, scope, project_path)
+ if not account:
+ return False
try:
import keyring # noqa: PLC0415 — lazy: access_guard is stdlib-only on
keyring.set_password( # the hot path (hooks import it per tool call)
- _ACCESS_KEYRING_SERVICE, _builtin_attest_account(glob),
- "1" if disabled else "0")
+ _ACCESS_KEYRING_SERVICE, account, "1" if disabled else "0")
return True
except Exception:
return False
-def _verify_builtin_disabled(glob: str) -> bool:
+def _verify_builtin_disabled(glob: str, scope: str = "global",
+ project_path: str = ".") -> bool:
"""True only when the keyring agrees. Fails closed on any error."""
+ account = _builtin_attest_account(glob, scope, project_path)
+ if not account:
+ return False
try:
import keyring # noqa: PLC0415 — see above
return keyring.get_password(
- _ACCESS_KEYRING_SERVICE, _builtin_attest_account(glob)) == "1"
+ _ACCESS_KEYRING_SERVICE, account) == "1"
except Exception:
return False
-def _configured_disable_list() -> list:
- """Raw `access.disable_builtin` from the global config. [] on any problem."""
- base = _global_base()
+def _configured_disable_list(base=None) -> list:
+ """Raw `access.disable_builtin` from one scope's config. [] on a problem."""
+ base = _global_base() if base is None else base
if base is None:
return []
cfg = base / ".c3" / "config.json"
@@ -410,18 +459,19 @@ def _configured_disable_list() -> list:
return [g for g in raw if isinstance(g, str)] if isinstance(raw, list) else []
-def _legacy_disabled() -> frozenset:
+def _legacy_disabled(scope: str = "global", project_path: str = ".",
+ base=None) -> frozenset:
"""The original two-key opt-out read: `disable_builtin` config entries
that carry a matching keyring attestation. Kept as the legacy spelling of
mode="allow" — existing installs keep working without migration.
"""
- listed = _configured_disable_list()
+ listed = _configured_disable_list(base)
if not listed:
return frozenset()
allowed = {_norm_builtin(g) for g in DISABLEABLE_BUILTINS}
return frozenset(
c for c in (_norm_builtin(g) for g in listed)
- if c in allowed and _verify_builtin_disabled(c)
+ if c in allowed and _verify_builtin_disabled(c, scope, project_path)
)
@@ -432,34 +482,47 @@ def _legacy_disabled() -> frozenset:
# builtin at its shipped default. Every failure path here ENFORCES.
-def _mode_attest_account(glob: str) -> str:
- return f"builtin_mode|{_norm_builtin(glob)}"
+def _mode_attest_account(glob: str, scope: str = "global",
+ project_path: str = ".") -> str:
+ who = realm(scope, project_path)
+ if not who:
+ return ""
+ if scope == "global":
+ return f"builtin_mode|{_norm_builtin(glob)}" # unchanged spelling
+ return f"builtin_mode|{who}|{_norm_builtin(glob)}"
-def _attest_builtin_mode(glob: str, mode: str) -> bool:
+def _attest_builtin_mode(glob: str, mode: str, scope: str = "global",
+ project_path: str = ".") -> bool:
"""Write (mode) or clear ('') the keyring half. False when it won't stick."""
+ account = _mode_attest_account(glob, scope, project_path)
+ if not account:
+ return False
try:
import keyring # noqa: PLC0415 — lazy, see _attest_builtin_disabled
- keyring.set_password(
- _ACCESS_KEYRING_SERVICE, _mode_attest_account(glob), mode or "")
+ keyring.set_password(_ACCESS_KEYRING_SERVICE, account, mode or "")
return True
except Exception:
return False
-def _verify_builtin_mode(glob: str) -> str:
+def _verify_builtin_mode(glob: str, scope: str = "global",
+ project_path: str = ".") -> str:
"""The attested mode string, or ''. Fails closed on any error."""
+ account = _mode_attest_account(glob, scope, project_path)
+ if not account:
+ return ""
try:
import keyring # noqa: PLC0415 — see above
return str(keyring.get_password(
- _ACCESS_KEYRING_SERVICE, _mode_attest_account(glob)) or "")
+ _ACCESS_KEYRING_SERVICE, account) or "")
except Exception:
return ""
-def _configured_modes() -> dict:
- """Raw `access.builtin_mode` from the global config. {} on any problem."""
- base = _global_base()
+def _configured_modes(base=None) -> dict:
+ """Raw `access.builtin_mode` from one scope's config. {} on any problem."""
+ base = _global_base() if base is None else base
if base is None:
return {}
cfg = base / ".c3" / "config.json"
@@ -478,12 +541,15 @@ def _configured_modes() -> dict:
if isinstance(g, str) and isinstance(m, str)}
-def builtin_modes() -> dict:
- """{canonical_glob: mode} for genuinely-set modes (config AND attestation
- agree on the same string; Tier-1 globs and known modes only). Anything
- else keeps the shipped default — the safe direction.
+def builtin_modes(scope: str = "global", project_path: str = ".") -> dict:
+ """{canonical_glob: mode} for genuinely-set modes in ONE scope (config AND
+ that scope's attestation agree on the same string; Tier-1 globs and known
+ modes only). Anything else keeps the shipped default — the safe direction.
"""
- configured = _configured_modes()
+ base = _scope_base(scope, project_path)
+ if base is None:
+ return {}
+ configured = _configured_modes(base)
if not configured:
return {}
allowed = {_norm_builtin(g) for g in DISABLEABLE_BUILTINS}
@@ -491,27 +557,64 @@ def builtin_modes() -> dict:
for glob, mode in configured.items():
canon = _norm_builtin(glob)
if canon in allowed and mode in BUILTIN_MODES \
- and _verify_builtin_mode(canon) == mode:
+ and _verify_builtin_mode(canon, scope, project_path) == mode:
out[canon] = mode
return out
-def effective_builtin_modes() -> dict:
- """{canonical_glob: mode} with the legacy opt-out folded in as 'allow'.
+def _scope_modes(scope: str, project_path: str = ".") -> dict:
+ """One scope's effective modes, legacy opt-out folded in as 'allow'."""
+ base = _scope_base(scope, project_path)
+ if base is None:
+ return {}
+ out = {g: "allow"
+ for g in _legacy_disabled(scope, project_path, base)}
+ out.update(builtin_modes(scope, project_path))
+ return out
+
+
+def effective_builtin_modes(project_path: str = ".") -> dict:
+ """{canonical_glob: mode}, global first and then the project's own.
+
+ **A project mode REPLACES the global one for that glob, loosening
+ included** — that is the point (v2.118.0). It is safe for exactly one
+ reason: the config half alone has never been able to change anything.
+ Both halves are realm-bound, so a project's `access.builtin_mode` is
+ inert unless a human wrote the attestation for *that project's realm* on
+ *this* machine. A cloned repo ships config, not keyring entries.
+
+ ``project_path`` defaults to the CWD so every legacy caller keeps
+ working; the hot path pays nothing extra, because the keyring is only
+ consulted for globs a config actually lists, and normally none do.
+ """
+ out = _scope_modes("global")
+ proj_base = _scope_base("project", project_path)
+ if proj_base is None or proj_base == _global_base():
+ return out # the home directory IS the project; one scope, not two
+ out.update(_scope_modes("project", project_path))
+ return out
+
+
+def builtin_mode_realms(project_path: str = ".") -> dict:
+ """{canonical_glob: 'global'|'project'} — which scope set the live mode.
- `builtin_mode` wins when both spellings name a glob — but that state is
- also flagged corrupt at scope level, so it only matters transiently.
+ `c3 access list` and the Access tab MUST be able to say this: a mode that
+ looks global but was actually set per-project (or vice versa) is exactly
+ the confusion the two-key design exists to prevent.
"""
- out = {g: "allow" for g in _legacy_disabled()}
- out.update(builtin_modes())
+ out = {g: "global" for g in _scope_modes("global")}
+ proj_base = _scope_base("project", project_path)
+ if proj_base is None or proj_base == _global_base():
+ return out
+ out.update({g: "project" for g in _scope_modes("project", project_path)})
return out
-def disabled_builtins() -> frozenset:
+def disabled_builtins(project_path: str = ".") -> frozenset:
"""Canonical globs of builtins that are genuinely OFF right now — the
legacy two-key opt-out plus mode="allow" entries. Callers that only ask
"is it enforced?" keep working unchanged across both spellings."""
- return frozenset(g for g, m in effective_builtin_modes().items()
+ return frozenset(g for g, m in effective_builtin_modes(project_path).items()
if m == "allow")
@@ -650,23 +753,25 @@ def _read_scope_rules(base: Path, scope: str):
unknown = set(section) - _VALID_SECTION_KEYS
if unknown:
return _CORRUPT # hard error — 'allow' must never silently no-op
- # Builtin opt-out/modes are global-only, because a project scope may only
- # ever TIGHTEN. A project-scope entry is a loud error, not a silent no-op:
- # the UI must never claim a builtin is off while evaluation enforces it.
- if scope == "project" and (section.get(_KEY_DISABLE_BUILTIN)
- or section.get(_KEY_BUILTIN_MODE)):
+ # Builtin opt-out/modes are legal in BOTH scopes since v2.118.0. They were
+ # global-only because "a project scope may only ever tighten" — but that
+ # rule protects against a CLONED REPO's config, and config alone has never
+ # been able to change a builtin. The second key does, and it is now
+ # realm-bound (see `realm()`), so a project's claim is inert without an
+ # attestation a human wrote on this machine for this project. Rules
+ # (deny/read_only/confirm/mask) still only ever tighten across scopes;
+ # it is the builtin MODE dimension that became project-settable.
+ #
+ # A glob spelled in BOTH disable_builtin and builtin_mode is ambiguous in
+ # either scope. Ambiguity is a hard error, never a precedence puzzle.
+ legacy = {_norm_builtin(g)
+ for g in section.get(_KEY_DISABLE_BUILTIN, [])
+ if isinstance(g, str)}
+ modes = section.get(_KEY_BUILTIN_MODE, {})
+ moded = {_norm_builtin(g) for g in modes} \
+ if isinstance(modes, dict) else set()
+ if legacy & moded:
return _CORRUPT
- if scope == "global":
- # A glob spelled in BOTH disable_builtin and builtin_mode is
- # ambiguous. Ambiguity is a hard error, never a precedence puzzle.
- legacy = {_norm_builtin(g)
- for g in section.get(_KEY_DISABLE_BUILTIN, [])
- if isinstance(g, str)}
- modes = section.get(_KEY_BUILTIN_MODE, {})
- moded = {_norm_builtin(g) for g in modes} \
- if isinstance(modes, dict) else set()
- if legacy & moded:
- return _CORRUPT
rules = []
for kind in _LIST_KINDS:
globs = section.get(kind, [])
@@ -695,7 +800,7 @@ def _global_base() -> Path | None:
def load_all(project_path: str = ".") -> tuple:
"""(rules, mask_rules, corrupt_scopes) — one read per scope."""
rules = list(_ABSOLUTE_RULES)
- rules.extend(_builtin_rules_effective())
+ rules.extend(_builtin_rules_effective(project_path=project_path))
inst = _install_dir_rule()
if inst:
rules.append(inst)
@@ -1276,7 +1381,8 @@ def list_rules(project_path: str = ".") -> dict:
string globs are recoverable, flagged ``corrupt`` — that scope evaluates
deny-all until the human repairs config.json by hand.
"""
- modes = effective_builtin_modes()
+ modes = effective_builtin_modes(project_path)
+ realms = builtin_mode_realms(project_path)
builtin_deny = list(BUILTIN_ABSOLUTE_DENY)
builtin_ro, builtin_confirm = [], []
for rule in _builtin_rules_effective(modes):
@@ -1299,6 +1405,11 @@ def list_rules(project_path: str = ".") -> dict:
"disabled": sorted(g for g, m in modes.items() if m == "allow"),
"modes": {_norm_builtin(g): modes.get(_norm_builtin(g), "default")
for g in DISABLEABLE_BUILTINS},
+ # Which scope set each live mode. Without this a project-set `allow`
+ # is indistinguishable from a machine-wide one on screen, and the
+ # whole point of the realm split is that those are different claims.
+ "mode_realms": {_norm_builtin(g): realms.get(_norm_builtin(g), "")
+ for g in DISABLEABLE_BUILTINS},
}}
for scope in _VALID_SCOPES:
try:
@@ -1423,10 +1534,11 @@ def set_rule(glob, kind: str, scope: str, project_path: str = ".") -> dict:
return {"glob": canon, "kind": kind, "scope": scope, "added": True}
-def set_builtin_mode(glob, mode: str) -> dict:
+def set_builtin_mode(glob, mode: str, scope: str = "global",
+ project_path: str = ".") -> dict:
"""Set a Tier-1 builtin's mode: deny | confirm | allow | default.
- GLOBAL scope, human surfaces only (`c3 access builtin mode` / Access
- tab); callers log to the ledger.
+ ``global`` or ``project`` scope (v2.118.0), human surfaces only
+ (`c3 access builtin mode` / Access tab); callers log to the ledger.
Two-key like the opt-out it generalises: the config names the mode and
the keyring attests the SAME string, written attestation-first so a
@@ -1437,13 +1549,23 @@ def set_builtin_mode(glob, mode: str) -> dict:
the legacy ``disable_builtin`` spelling for that glob (lazy migration),
so the two keys can never disagree about one glob.
- Returns {"glob", "mode", "changed", "attested"}. Raises ValueError for a
- Tier-0 vault glob, an unrecognized glob or mode, an unavailable global
- scope, a malformed section, or a keyring that will not attest.
+ The attestation is REALM-BOUND: a project mode is attested under that
+ project's realm and satisfies nothing else, and a global attestation
+ never stands in for a project one. That is what makes a project scope
+ safe to loosen from — a cloned repo can carry the config half and change
+ nothing.
+
+ Returns {"glob", "mode", "scope", "changed", "attested"}. Raises
+ ValueError for a Tier-0 vault glob, an unrecognized glob, mode or scope,
+ an unavailable scope, a malformed section, or a keyring that will not
+ attest.
"""
if mode not in BUILTIN_MODES + ("default",):
raise ValueError(f"unknown mode '{mode}' — expected one of: "
f"{', '.join(BUILTIN_MODES + ('default',))}")
+ if scope not in _VALID_SCOPES:
+ raise ValueError(f"unknown scope '{scope}' — expected one of: "
+ f"{', '.join(_VALID_SCOPES)}")
canon = _norm_builtin(glob)
if canon in {_norm_builtin(g) for g in BUILTIN_ABSOLUTE_DENY}:
raise ValueError(
@@ -1456,7 +1578,7 @@ def set_builtin_mode(glob, mode: str) -> dict:
f"'{_norm_glob(glob)}' is not a disableable builtin — expected one "
f"of: {', '.join(DISABLEABLE_BUILTINS)}")
- cfg = _scope_config_path("global") # raises when there is no home
+ cfg = _scope_config_path(scope, project_path) # raises with no home
data, section = _load_config_for_write(cfg)
modes = section.get(_KEY_BUILTIN_MODE, {})
if not isinstance(modes, dict) or not all(
@@ -1487,13 +1609,13 @@ def set_builtin_mode(glob, mode: str) -> dict:
if not section[_KEY_DISABLE_BUILTIN]:
section.pop(_KEY_DISABLE_BUILTIN, None)
_write_scope_config(cfg, data)
- attested = _attest_builtin_mode(canon, "")
- _attest_builtin_disabled(canon, False)
- return {"glob": canon, "mode": "default",
+ attested = _attest_builtin_mode(canon, "", scope, project_path)
+ _attest_builtin_disabled(canon, False, scope, project_path)
+ return {"glob": canon, "mode": "default", "scope": scope,
"changed": changed, "attested": attested}
# Attestation FIRST — see docstring.
- if not _attest_builtin_mode(canon, mode):
+ if not _attest_builtin_mode(canon, mode, scope, project_path):
raise ValueError(
"keyring unavailable, so the mode cannot be attested and would "
"not take effect. The builtin keeps its current behaviour.")
@@ -1510,17 +1632,20 @@ def set_builtin_mode(glob, mode: str) -> dict:
section.pop(_KEY_DISABLE_BUILTIN, None)
_write_scope_config(cfg, data)
if legacy_present:
- _attest_builtin_disabled(canon, False)
- return {"glob": canon, "mode": mode, "changed": changed, "attested": True}
+ _attest_builtin_disabled(canon, False, scope, project_path)
+ return {"glob": canon, "mode": mode, "scope": scope,
+ "changed": changed, "attested": True}
-def set_builtin_disabled(glob, disabled: bool) -> dict:
+def set_builtin_disabled(glob, disabled: bool, scope: str = "global",
+ project_path: str = ".") -> dict:
"""Legacy verb, now a shim over :func:`set_builtin_mode` — disable is
mode="allow", re-enable is mode="default". Return shape preserved for the
CLI, mobile route, and existing tests.
"""
- out = set_builtin_mode(glob, "allow" if disabled else "default")
- return {"glob": out["glob"], "disabled": disabled,
+ out = set_builtin_mode(glob, "allow" if disabled else "default",
+ scope, project_path)
+ return {"glob": out["glob"], "disabled": disabled, "scope": out["scope"],
"changed": out["changed"], "attested": out["attested"]}
diff --git a/services/claude_md.py b/services/claude_md.py
index b4dea12..e4ed6ee 100644
--- a/services/claude_md.py
+++ b/services/claude_md.py
@@ -43,7 +43,7 @@
9.5. **LOCAL CI** (v2.79.0+): `c3_ci(action='inspect|run|rerun|status|failures|logs|runs')` — run THIS repository's real `.github/workflows/*.yml` on this machine instead of pushing to find out. C3 reads the existing workflow files; it does NOT define a second CI config. `inspect` shows the job DAG and which jobs are runnable on this host; `run` executes in `needs` order and SKIPS (never passes) a job whose dependency failed; `failures` returns `{file,line,message}` instead of raw logs; `rerun` retries only what failed. VERDICTS: `FULL_CI_PASS` means every job ran HERE and passed — the only one that means safe to push. `PARTIAL_PASS` means something did not run (targets another OS, uses an action C3 cannot execute, or you selected a subset) and is NOT a green light. Jobs targeting another OS are refused unless `allow_foreign=true`, which labels the result cross-OS and can never yield FULL_CI_PASS. ENGINES (v2.81.0+): `native` runs jobs matching this host; `act` runs Linux jobs in a real container (real `uses:` actions included) when act+Docker are installed — `c3_ci(action='doctor')` reports what is available. A container run DOES count toward FULL_CI_PASS; a cross-OS one never does. macOS jobs cannot run locally on any engine. Jobs that look like they publish or deploy are refused unless allow_side_effects=true, and C3 never passes secrets to act. See docs/agent-ci.md.
10. **BITBUCKET** (when configured, v2.30.0+): `c3_bitbucket(action='...')` — for self-hosted enterprise Bitbucket Data Center / Server: PRs, branches, builds, repo admin. Tokens live in the OS keyring (set up via `c3 bitbucket login`, or `login --global` for a home config reusable across projects; account resolution precedence is project → home). Read actions are safe in plan mode; write actions (`merge_pr`, `create_branch`, etc.) are auto-logged to the edit ledger.
10.5. **JIRA** (when configured, v2.56.0+): `c3_jira(action='...')` — Jira Cloud + Data Center: `search` (raw JQL), `my_issues`, `get_issue`, `list_transitions`, `get_create_metadata`, `search_users`, `list_link_types`, `list_boards`, `list_sprints`, `list_worklogs` reads; `create_issue` / `update_issue` / `comment` / `transition` / `assign` / `link_issues` / `unlink_issues` / `move_to_sprint` / `move_to_backlog` / `add_worklog` / `attach_file` / `delete_issue` (permanent; refuses subtask-bearing issues unless delete_subtasks=true) mutations auto-logged to the edit ledger (identifiers only, never bodies). createmeta reflects the issue type's field configuration, NOT the create screen — if create_issue rejects a listed field ('not on the appropriate screen'), create without it, then set it via `update_issue`. Epic membership: pass `parent=` to create_issue/update_issue ('none' clears) — C3 maps it per deployment (Cloud `parent` field vs Data Center Epic Link customfield); `link_issues(issue, link_type, target)` creates typed links reading '' (types via `list_link_types`). Sprints: `list_boards` → `list_sprints(board_id)` → `move_to_sprint(issue, sprint_id)`; `attach_file(issue, file_path)` uploads a local file as evidence. Tokens in the OS keyring (`c3 jira login`, or `login --global` for cross-project reuse; the jira config section resolves project → home wholesale from one file — never field-merged). Read actions are safe in plan mode.
-10.6. **CREDENTIALS** (v2.58.0+): `c3_credentials(action='...')` — named secret vault (global ~/.c3 + per-project .c3; project shadows global). `list`/`describe`/`check` return names + metadata, never values. To USE a secret do NOT reveal it — pass `env_creds='NAME1,NAME2'` to c3_shell or write `{{cred:NAME}}` inside cmd; C3 decodes at the subprocess boundary so values never enter context, and echoed values are auto-redacted to `[cred:NAME]`. `reveal` works only on entries the user marked `agent_readable` (that flag cannot be raised on an existing entry by the agent). `set`/`delete` allowed; all mutations + reveals ledger-logged by name. `import_env` (v2.93.0+) bulk-imports a .env WITHOUT you seeing a value - the server reads the file, you get names/lengths/fingerprints/reasons; `**/.env*` stays a built-in deny for your own reads. It DEFAULTS TO dry_run=true (call it bare to preview, re-run dry_run=false once the user has seen the list), is project-scope only, refuses overwrite, and refuses a path outside the project. A dry run also DIFFS (v2.94.0+): a row already matching the vault reports `unchanged`, so re-running an import that would do nothing is visible before you propose it - importing to the global vault or replacing a stored secret stays a user action. Users manage entries via the Credentials UI tab or `c3 creds`. STRUCTURED kinds (v2.87.0+: `address`/`identity`/`card`; v2.90.0+: `login`) hold named fields (card: cardholder/number/expiry[/cvc/billing_zip]; login: site_id/canonical_origin/username/password[/totp_secret]) and are inject-only: address a FIELD - `env_creds='CARD.number'` (env `$CARD_NUMBER`) or `{{cred:CARD.number}}` - reveal is permanently disabled for them, they never auto-inject, and echoes redact to `[cred:NAME.field]`. Have the user enter card/identity/address/login data via UI/CLI so it never enters the chat. `login` is STORAGE ONLY: C3 has no browser surface and MUST NOT grow one. Do not write a script that reads a login password out of the environment and types it into a page — a check you author in a process that already holds the plaintext is not a control. `canonical_origin` (https-only, stored normalized) exists so a separate, privileged runner can bind the credential to exactly one origin; that runner does not live in this package. `usage` action (v2.88.0+) shows when/where/how often a credential was used (counts + recent events for the current project).
+10.6. **CREDENTIALS** (v2.58.0+): `c3_credentials(action='...')` — named secret vault (global ~/.c3 + per-project .c3; project shadows global). `list`/`describe`/`check` return names + metadata, never values. To USE a secret do NOT reveal it — pass `env_creds='NAME1,NAME2'` to c3_shell or write `{{cred:NAME}}` inside cmd; C3 decodes at the subprocess boundary so values never enter context, and echoed values are auto-redacted to `[cred:NAME]`. `reveal` works only on entries the user marked `agent_readable` (that flag cannot be raised on an existing entry by the agent). `set`/`delete` allowed; all mutations + reveals ledger-logged by name. `import_env` (v2.93.0+) bulk-imports a .env WITHOUT you seeing a value - the server reads the file, you get names/lengths/fingerprints/reasons; `**/.env*` stays a built-in deny for your own reads. It DEFAULTS TO dry_run=true (call it bare to preview, re-run dry_run=false once the user has seen the list), is project-scope only, refuses overwrite, and refuses a path outside the project. A dry run also DIFFS (v2.94.0+): a row already matching the vault reports `unchanged`, so re-running an import that would do nothing is visible before you propose it - importing to the global vault or replacing a stored secret stays a user action. Users manage entries via the Credentials UI tab or `c3 creds`. STRUCTURED kinds (v2.87.0+: `address`/`identity`/`card`; v2.90.0+: `login`) hold named fields (card: cardholder/number/expiry[/cvc/billing_zip]; login: site_id/canonical_target/username[/password/private_key/passphrase/totp_secret], one of password/private_key required) and are inject-only: address a FIELD - `env_creds='CARD.number'` (env `$CARD_NUMBER`) or `{{cred:CARD.number}}` - reveal is permanently disabled for them, they never auto-inject, and echoes redact to `[cred:NAME.field]`. Have the user enter card/identity/address/login data via UI/CLI so it never enters the chat. `login` covers websites AND servers/databases/other non-web targets (v2.118.0+): `canonical_target` is scheme://host[:port] from an allowlist (https, ssh, sftp, rdp, smb, winrm, ldaps, imaps, smtps, postgres, mysql, mssql, mongodb, redis, …), cleartext schemes refused by name. It is STORAGE ONLY: C3 has no browser surface, opens no SSH session, and MUST NOT grow either. Do not write a script that reads a login password out of the environment and types it into a page or a session — a check you author in a process that already holds the plaintext is not a control. `canonical_target` (stored normalized) exists so a separate, privileged runner can bind the credential to exactly one destination; that runner does not live in this package. `canonical_origin` still reads back but ONLY for an https target, so a browser broker fails closed on a server entry. `usage` action (v2.88.0+) shows when/where/how often a credential was used (counts + recent events for the current project).
11. **CROSS-PROJECT** (v2.31.0+): `c3_project(action='list|scan|info|search|read|edit|shell|...', project='')` — discover and operate on OTHER c3-installed projects. `list`/`scan` need no project; reads (search/read/compress/status/memory/impact/edits/validate/filter) run freely; writes (`edit`, `shell`, memory add/update/delete) require `allow_write=true` and are logged to the target project's ledger. SUB-PROJECT HIERARCHY (project = the PARENT; strict tree, one parent, up to 8 levels): reads `subprojects` (direct children), `sub_tree` (whole hierarchy), `sub_inspect` (target=path — is there a C3 project there, what is in it, who already claims it, what it claims, which nested projects under it are NOT linked yet; mutates nothing); writes `sub_add` (initializes), `sub_link` (target=path to an EXISTING project ANYWHERE on disk, incl. another drive — a child need NOT live inside the parent), `sub_remove`, `sub_cascade` (walks the whole subtree). Nested children are excluded from the parent's index; externally linked ones were never in it. A link that would make a project its own ancestor is refused.
11.5. **MASKED PATHS** (v2.63.0+): some paths are exposed but policy-TRANSFORMED. `c3_read`/`c3_compress`/`c3_search` serve a deterministic view prefixed `[c3-mask:transformed] view=`. Treat that content as evidence of STRUCTURE, not of values: literals may be synthetic, rows may be withheld, bodies may be stripped — never copy them into other files and never draw conclusions about data volume or completeness from them. Masked paths are READ-ONLY: `c3_edit` refuses, and so do `c3_shell` content reads, git content commands, `c3_validate`, `c3_impact`, `c3_filter` and `c3_delegate` (tag `[c3-mask:unsupported]`) — that is a policy decision, not a transient error, so do not route around it via another tool or the shell. A `[c3-mask:limited]` search footer means absence is not evidence a path does not exist. Rules are human-only: report the block, do not try to change it.
11.6. **CONFIRM HOLDS** (v2.97.0+): a refusal tagged `[c3-access:confirm]` is a PAUSE, not a block — either the user set that path to "ask me first" (`user`/`global` scope) or it is the builtin agent-config tier (`builtin` scope, item 12). READ THE S8 TAIL, it says which case you are in. If it names a request id, C3 filed it for you: `c3_override(action='wait', request_id='...', timeout_s=180)` — the bare call waits only 60s, and "still pending" is NOT a denial, so wait again or do unaffected work. Retry the SAME call once, on the SAME surface, only after an approval. If the S8 says no request was filed, it names the surfaces that do file (`c3_read`, `c3_edit`, native tools) — use one of them rather than asking in chat. If it says a request COULD NOT be filed, obey the reason it gives: a denied-and-muted request means do not ask again, a rate limit means withdraw one or wait; ask in chat only when the reason carries no instruction. Never retry before a decision, never route around the hold via c3_shell or another tool, and never re-file — duplicates collapse into the pending request.
diff --git a/services/credential_store.py b/services/credential_store.py
index 855ad20..b7bb243 100644
--- a/services/credential_store.py
+++ b/services/credential_store.py
@@ -434,19 +434,41 @@ def _file_delete(scope: str, project_path: str, name: str) -> bool:
"required": ("full_name",),
"optional": ("dob", "ssn", "phone", "email"),
},
- # A website login. STORAGE ONLY — C3 never drives a browser and never
- # types these anywhere. `canonical_origin` is the binding that a external
- # runner (BrowControl's out-of-process auth broker) is required to check
- # against the live top-level frame BEFORE typing, which is why it lives in
- # the record rather than being passed in by the caller: an agent that can
- # choose the destination can exfiltrate the password to it.
+ # A login — a website, a server, a database, anything with one
+ # unambiguous target. STORAGE ONLY: C3 never drives a browser, opens an
+ # SSH session, or types these anywhere.
+ #
+ # `canonical_target` is the binding an external, out-of-process runner is
+ # required to check against where it is ACTUALLY about to authenticate,
+ # BEFORE typing. It lives in the record rather than being passed in by
+ # the caller for one reason: an agent that can choose the destination can
+ # exfiltrate the password to it.
+ #
+ # `canonical_origin` (v2.58.0-v2.118.0) stays a KNOWN field so stored
+ # entries and partial updates keep validating; on input it is an alias
+ # that normalizes into `canonical_target`, and on read it resolves ONLY
+ # for an https target (see get_value). A browser broker asking for it on
+ # an `ssh://` entry gets nothing, which is the fail-closed direction.
+ #
+ # `password` is optional because a key-based server login has none — but
+ # one of password/private_key is required, checked in _validate_structured.
"login": {
- "required": ("site_id", "canonical_origin", "username", "password"),
- "optional": ("totp_secret",),
+ "required": ("site_id", "canonical_target", "username"),
+ "optional": ("password", "private_key", "passphrase", "totp_secret",
+ "canonical_origin"),
},
}
_STRUCT_FIELD_MAX = 256 # chars per field value
+#: Per-field overrides. A PEM/OpenSSH private key is thousands of characters,
+#: and raising the global cap to fit it would loosen every other field. The
+#: oversize path already exists: a structured blob over FILE_STORAGE_THRESHOLD
+#: goes to the Fernet sidecar rather than the keyring.
+_STRUCT_FIELD_MAX_BY_FIELD = {"private_key": 8192}
+
+
+def _field_max(field: str) -> int:
+ return _STRUCT_FIELD_MAX_BY_FIELD.get(field, _STRUCT_FIELD_MAX)
def schema_fields(ctype: str) -> tuple:
@@ -502,40 +524,76 @@ def _normalize_expiry(raw: str) -> str:
_SITE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
-def _normalize_origin(raw: str) -> str:
- """Accept an https origin; return scheme://host[:port] lowercased.
+#: Schemes a login target may name. An allowlist, not a denylist: validation
+#: has to be deterministic, and the UI renders a picker from it. Every member
+#: either encrypts by definition or negotiates TLS in normal use.
+TARGET_SCHEMES = (
+ "https", # web
+ "ssh", "sftp", "ftps", "rdp", "smb", "vnc", "winrm", # hosts
+ "ldaps", "imaps", "smtps", "amqps", # services
+ "postgres", "postgresql", "mysql", "mariadb", "mssql", # databases
+ "mongodb", "redis",
+)
+#: Refused by name rather than by omission, so the error can say why.
+_CLEARTEXT_SCHEMES = {
+ "http": "https", "ftp": "ftps", "telnet": "ssh", "rsh": "ssh",
+ "smtp": "smtps", "imap": "imaps", "ldap": "ldaps", "amqp": "amqps",
+}
+#: The one scheme whose target is also a web ORIGIN, and therefore the only
+#: one `canonical_origin` may ever resolve for.
+_ORIGIN_SCHEME = "https"
+
- Rejects anything that is not a bare origin. A path, query or fragment in
- a stored origin would make prefix-style comparison in a downstream runner
- ambiguous, and ambiguity in an origin check is the whole attack. http:// is
- refused outright — typing a password over cleartext is never correct, and
- an attacker who can force a downgrade should not also inherit a match.
+def _normalize_target(raw: str, field: str = "canonical_target") -> str:
+ """Accept one authenticable target; return scheme://host[:port] lowercased.
+
+ Rejects anything that is not a bare target. A path, query, fragment or
+ userinfo would make prefix-style comparison in a downstream runner
+ ambiguous, and ambiguity in that check is the whole attack — so the rule
+ is the same for `ssh://` as it always was for `https://`.
+
+ Cleartext schemes are refused by name. Typing a password over cleartext
+ is never correct, and an attacker who can force a downgrade should not
+ also inherit a match.
"""
s = (raw or "").strip().rstrip("/")
if "://" not in s:
raise CredentialError(
- "field 'canonical_origin' must be a full origin, e.g. "
- "https://example.com")
+ f"field '{field}' must be a full target, e.g. "
+ "https://example.com or ssh://build01.lan:22")
scheme, _, rest = s.partition("://")
scheme = scheme.lower()
- if scheme != "https":
- raise CredentialError("field 'canonical_origin' must use https")
+ if scheme in _CLEARTEXT_SCHEMES:
+ raise CredentialError(
+ f"field '{field}' must not use cleartext '{scheme}' — "
+ f"use '{_CLEARTEXT_SCHEMES[scheme]}'")
+ if scheme not in TARGET_SCHEMES:
+ raise CredentialError(
+ f"field '{field}' has an unsupported scheme '{scheme}' — "
+ f"expected one of: {', '.join(TARGET_SCHEMES)}")
if any(ch in rest for ch in "/?#"):
raise CredentialError(
- "field 'canonical_origin' must be scheme://host[:port] only — "
+ f"field '{field}' must be scheme://host[:port] only — "
"no path, query or fragment")
if "@" in rest:
- raise CredentialError(
- "field 'canonical_origin' must not contain userinfo")
+ raise CredentialError(f"field '{field}' must not contain userinfo")
host, _, port = rest.partition(":")
host = host.lower()
if not host or not re.match(r"^[a-z0-9.-]+$", host) or ".." in host:
- raise CredentialError("field 'canonical_origin' has an invalid host")
+ raise CredentialError(f"field '{field}' has an invalid host")
if port:
if not port.isdigit() or not 1 <= int(port) <= 65535:
- raise CredentialError("field 'canonical_origin' has an invalid port")
- return f"https://{host}:{int(port)}"
- return f"https://{host}"
+ raise CredentialError(f"field '{field}' has an invalid port")
+ return f"{scheme}://{host}:{int(port)}"
+ return f"{scheme}://{host}"
+
+
+def target_scheme(target: str) -> str:
+ """The scheme of a stored target, or '' — never raises."""
+ return str(target or "").partition("://")[0].lower()
+
+
+_PEM_RE = re.compile(r"^-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")
def parse_structured_value(value) -> dict:
@@ -576,10 +634,25 @@ def _validate_structured(ctype: str, fields: dict) -> dict:
for key, val in fields.items():
if not val:
continue # empty/None optional fields are simply absent
- if len(val) > _STRUCT_FIELD_MAX:
+ cap = _field_max(key)
+ if len(val) > cap:
raise CredentialError(
- f"field {key!r} exceeds {_STRUCT_FIELD_MAX} characters")
+ f"field {key!r} exceeds {cap} characters")
out[key] = val
+ if ctype == "login":
+ # Accept the pre-2.118.0 spelling on input and fold it in, so stored
+ # entries and partial updates keep working without a migration.
+ # Never both: two different targets on one record is ambiguous, and
+ # ambiguity is what the field exists to remove.
+ legacy = out.pop("canonical_origin", "")
+ if legacy and out.get("canonical_target") \
+ and _normalize_target(legacy) != _normalize_target(
+ out["canonical_target"]):
+ raise CredentialError(
+ "fields 'canonical_target' and 'canonical_origin' disagree — "
+ "set only 'canonical_target'")
+ if legacy and not out.get("canonical_target"):
+ out["canonical_target"] = legacy
missing = [f for f in required if not out.get(f)]
if missing:
raise CredentialError(
@@ -600,7 +673,25 @@ def _validate_structured(ctype: str, fields: dict) -> dict:
raise CredentialError(
"field 'site_id' must be lowercase alphanumeric with "
"'.', '_' or '-' (max 64 chars)")
- out["canonical_origin"] = _normalize_origin(out["canonical_origin"])
+ out["canonical_target"] = _normalize_target(out["canonical_target"])
+ # A login with neither is not a credential. `password` stopped being
+ # a required FIELD when key-based server logins arrived, but the
+ # record must still carry a secret.
+ if not out.get("password") and not out.get("private_key"):
+ raise CredentialError(
+ "a login needs a secret: set 'password', 'private_key', or "
+ "both")
+ key_blob = out.get("private_key", "")
+ if key_blob and not _PEM_RE.match(key_blob.strip()):
+ # A pasted public key or a passphrase in the wrong box would
+ # otherwise be stored as a private key and fail much later, in a
+ # runner, as an authentication error nobody traces back here.
+ raise CredentialError(
+ "field 'private_key' must be a PEM/OpenSSH private key "
+ "(begins '-----BEGIN … PRIVATE KEY-----')")
+ if out.get("passphrase") and not key_blob:
+ raise CredentialError(
+ "field 'passphrase' has no 'private_key' to unlock")
# `[\s\-]`, NOT `[ -]`: the latter is a character RANGE from space
# (0x20) to hyphen (0x2D) and therefore swallows the digits 2-7 that
# base32 is built from. A seed pasted in the usual spaced form would
@@ -635,14 +726,17 @@ def _display_projection(ctype: str, fields: dict) -> dict:
if ctype == "identity":
return {"label": fields.get("full_name", "")}
if ctype == "login":
- # site_id and origin only. The username is deliberately withheld:
- # username + origin is half the credential, and the registry is the
+ # site_id and target only. The username is deliberately withheld:
+ # username + target is half the credential, and the registry is the
# one part of this record that non-secret surfaces are allowed to
- # render. `has_totp` is a boolean so the UI can show a 2FA badge
- # without the seed going anywhere near a projection.
+ # render. `has_totp` / `has_key` are booleans so the UI can show a
+ # 2FA or key badge without the secret going near a projection.
+ target = fields.get("canonical_target", "")
return {"site_id": fields.get("site_id", ""),
- "origin": fields.get("canonical_origin", ""),
- "has_totp": bool(fields.get("totp_secret"))}
+ "scheme": target_scheme(target),
+ "target": target,
+ "has_totp": bool(fields.get("totp_secret")),
+ "has_key": bool(fields.get("private_key"))}
return {}
@@ -1032,13 +1126,38 @@ def get_value(name: str, *, project_path: str = ".", scope: str = "",
data = json.loads(raw)
except Exception:
return None
- val = data.get(field) if isinstance(data, dict) else None
+ if not isinstance(data, dict):
+ return None
+ val = _resolve_field(stype, data, field)
return val if isinstance(val, str) and val else None
if field:
return None
return _get_raw(name, project_path=project_path, scope=scope)
+def _resolve_field(ctype: str, data: dict, field: str):
+ """One field out of a decoded structured payload, aliases applied.
+
+ The only alias is `login.canonical_origin`, and it is deliberately
+ ASYMMETRIC: it resolves to the target ONLY when that target is an https
+ origin. A browser broker that pins a credential by asking for
+ `canonical_origin` therefore gets nothing for an `ssh://` or
+ `postgres://` entry and fails closed, instead of being handed a string
+ that is not a web origin and cannot be compared to a top-level frame.
+ Old records that still store the field literally read back unchanged.
+ """
+ if field in data:
+ return data.get(field)
+ if ctype == "login" and field == "canonical_origin":
+ target = data.get("canonical_target", "")
+ if target_scheme(target) == _ORIGIN_SCHEME:
+ return target
+ return None
+ if ctype == "login" and field == "canonical_target":
+ return data.get("canonical_origin") # pre-2.118.0 records
+ return None
+
+
def get_structured_fields(name: str, *, project_path: str = ".",
scope: str = "") -> Optional[dict]:
"""Full decoded field dict of a structured entry — the human read-back
diff --git a/services/override_grants.py b/services/override_grants.py
index 0833361..91904bd 100644
--- a/services/override_grants.py
+++ b/services/override_grants.py
@@ -21,6 +21,7 @@
import secrets
import time
from datetime import datetime, timedelta, timezone
+from functools import lru_cache
from pathlib import Path
from services import override_policy as op_policy
@@ -41,6 +42,17 @@
EV_NEAR_MISS = "near_miss"
EV_CONSUMED_AFTER_EXPIRY = "consumed_after_expiry_attempt"
+#: Grant scope — how far one approval reaches (spec §4.1).
+#: ``call``: the historical shape, and still the default. One exact
+#: (session, layer, rule, tool, op, path) tuple.
+#: ``rule``: same session/layer/rule/op, ANY path the rule glob covers and
+#: any tool in the same op class. Unlimited uses, bounded by a wall-clock
+#: backstop AND an idle window. Never mintable for a synthetic rule (see
+#: ``rule_is_globbable``) because those have no glob to widen along.
+SCOPE_CALL = "call"
+SCOPE_RULE = "rule"
+SCOPES = (SCOPE_CALL, SCOPE_RULE)
+
#: How long a stale lock is honoured before it is broken. Long enough that a
#: real read-modify-write (two small JSON files) always finishes first; short
#: enough that a killed hook subprocess cannot wedge enforcement.
@@ -71,10 +83,55 @@ def parse_ts(value) -> datetime | None:
def _expired(grant: dict, at: datetime | None = None) -> bool:
+ """Wall-clock expiry, plus the idle window a rule grant also carries."""
+ at = at or now()
exp = parse_ts(grant.get("expires_at"))
if exp is None:
return True # unreadable expiry ⇒ treat as expired, never as eternal
- return (at or now()) >= exp
+ if at >= exp:
+ return True
+ return _idle_expired(grant, at)
+
+
+def _idle_expired(grant: dict, at: datetime) -> bool:
+ """True when a rule grant has sat unused past its idle window.
+
+ This is what makes "until the session ends" safe: there is no session-END
+ signal to hang a long grant on (the MCP surface's host session id is
+ snapshotted at boot and goes stale across ``/clear``), so a grant the
+ conversation stops exercising must die on its own. Only rule grants carry
+ an idle window; a ``call`` grant is already capped at 15 minutes.
+ An unreadable idle window is treated as expired, never as eternal.
+ """
+ idle = grant.get("idle_s")
+ if idle is None:
+ return False
+ try:
+ idle = int(idle)
+ except (TypeError, ValueError):
+ return True
+ if idle < 1:
+ return True
+ since = parse_ts(grant.get("last_used_at")) or parse_ts(grant.get("granted_at"))
+ if since is None:
+ return True
+ return at >= since + timedelta(seconds=idle)
+
+
+def _uses_left(grant: dict) -> int | None:
+ """Remaining uses, or ``None`` for a rule grant's unlimited budget."""
+ raw = grant.get("uses_remaining")
+ if raw is None:
+ return None
+ try:
+ return int(raw)
+ except (TypeError, ValueError):
+ return 0
+
+
+def _has_uses(grant: dict) -> bool:
+ left = _uses_left(grant)
+ return left is None or left > 0
# ── Paths ──────────────────────────────────────────────────────────────────
@@ -94,18 +151,83 @@ def path_key(path, project_path=".") -> str:
``.env`` are the same grant and neither is a new one. Returns '' when the
path is not representable (UNC, ADS, 8.3 alias); '' never matches a grant.
"""
+ return path_key_pair(path, project_path)[0]
+
+
+def path_key_pair(path, project_path=".") -> tuple:
+ """``(canon, rel)`` — the two forms a rule glob is evaluated against.
+
+ ``canon`` alone is the grant identity, but it is NOT enough to decide
+ whether a rule COVERS a path: a project-scoped glob like ``secrets/**`` is
+ written relative to the project root and only ever matches the ``rel``
+ form. Rule-scoped grants need both, and they must be the same two strings
+ ``access_guard.verdict`` used to produce the denial — anything else would
+ let a grant and the rule that filed it disagree about the same file.
+ """
try:
from services import access_guard as ag # noqa: PLC0415 — lazy
- canon, _rel, denial = ag.canonicalize(path, project_path)
- return "" if denial else canon
+ canon, rel, denial = ag.canonicalize(path, project_path)
+ return ("", "") if denial else (canon, rel)
except Exception:
- return ""
+ return "", ""
+
+
+def rule_is_globbable(rule: str) -> bool:
+ """False for the synthetic rule tokens that have no glob to widen along.
+
+ ```` and ```` name a
+ behaviour, not a path set (``override_policy.RULE_DISCIPLINE`` /
+ ``RULE_SHELL_WARN``), so a rule-scoped grant over one would either match
+ nothing or — if the token were ever treated as a glob — everything.
+ Refused at mint rather than left to fail confusingly at match time.
+ """
+ text = str(rule or "").strip()
+ return bool(text) and not text.startswith("<")
+
+
+@lru_cache(maxsize=256)
+def _compiled_rule(glob: str):
+ """The access-guard matcher for one rule glob. ``None`` when it will not
+ compile — an uncompilable rule matches nothing, which is fail-closed."""
+ try:
+ from services import access_guard as ag # noqa: PLC0415 — lazy
+ return ag._compile(glob, "deny", "grant")
+ except Exception:
+ return None
+
+
+def rule_covers(rule: str, key: str, rel: str = "") -> bool:
+ """True when a path is inside the set the rule glob describes — §4
+ condition 7 under ``scope="rule"``.
+
+ *key* and *rel* are the canon/project-relative pair from
+ :func:`path_key_pair`. Both are needed and both are passed to the SAME
+ matcher the evaluator uses, so a rule grant covers exactly the files that
+ rule would have blocked — never one more, never one fewer. Passing only
+ the canon silently drops every project-scoped glob, which is a grant that
+ matches nothing rather than a grant that matches too much, but is still
+ wrong.
+ """
+ if not key or not rule_is_globbable(rule):
+ return False
+ compiled = _compiled_rule(str(rule))
+ if compiled is None:
+ return False
+ leaf = key.rsplit("/", 1)[-1]
+ return compiled.matches(key, rel or key, leaf)
# ── Store ──────────────────────────────────────────────────────────────────
class _Lock:
- """Cross-process advisory lock for the grants read-modify-write.
+ """Cross-process advisory lock for a store's read-modify-write.
+
+ Also used by ``override_requests`` for the machine-global request store
+ (pass ``lock_file``), which had no lock at all — every mutator there does
+ an unsynchronised load→mutate→save over one file shared by every project
+ and session on the box, so two concurrent decisions could lose a row.
+ Rule-scoped grants raise the write rate, which is what made it worth
+ fixing rather than noting.
``_atomic_write_json`` guarantees a reader never sees a torn file; it does
NOT make read-modify-write atomic, and two PreToolUse hook subprocesses
@@ -116,8 +238,8 @@ class _Lock:
re-reads under the lock and decrements what it actually found.
"""
- def __init__(self, project_path):
- self.path = Path(project_path) / _LOCK_FILE
+ def __init__(self, project_path, lock_file: str = _LOCK_FILE):
+ self.path = Path(project_path) / lock_file
self.held = False
def __enter__(self):
@@ -197,7 +319,7 @@ def active(project_path=".", session_id: str = "") -> list:
return []
at = now()
live = [g for g in grants
- if not _expired(g, at) and int(g.get("uses_remaining") or 0) > 0
+ if not _expired(g, at) and _has_uses(g)
and (not session_id or g.get("session_id") == session_id)]
return sorted(live, key=lambda g: str(g.get("granted_at") or ""), reverse=True)
@@ -253,7 +375,8 @@ def new_id(prefix: str = "grt") -> str:
def mint(project_path=".", *, session_id: str, layer: str, rule: str,
tool: str, op: str, path, ttl_s: int | None = None,
uses: int | None = None, granted_by: str = "cli",
- request_id: str = "", policy=None, layers_key: str = "") -> dict:
+ request_id: str = "", policy=None, layers_key: str = "",
+ scope: str = SCOPE_CALL) -> dict:
"""Create one grant. **Human surfaces only** — callers must be authorised.
Raises ``ValueError`` when policy forbids it. The refusal is the point:
@@ -266,6 +389,12 @@ def mint(project_path=".", *, session_id: str, layer: str, rule: str,
one layer that does not require ``override.enabled`` (the confirm rule the
human wrote is the opt-in). When empty (direct CLI mints), the legacy
``enabled`` check applies unchanged.
+
+ ``scope``: ``call`` (default, the historical shape) or ``rule`` — see
+ :data:`SCOPES`. A rule grant needs ``override.allow_rule_grants``, a
+ globbable rule, and gets its own TTL ceiling plus an idle window; the
+ ``path`` it is minted from is still recorded, so the audit line and the
+ UI can say which refusal produced it.
"""
policy = policy or op_policy.resolve(project_path)
if layers_key:
@@ -283,25 +412,49 @@ def mint(project_path=".", *, session_id: str, layer: str, rule: str,
# standing capability for every future session on this project.
raise ValueError("a grant must name the session it belongs to "
"(--session); grants never cross sessions")
- key = path_key(path, project_path)
+ if scope not in SCOPES:
+ raise ValueError(f"unknown grant scope '{scope}' — expected one of: "
+ + ", ".join(SCOPES))
+ key, rel = path_key_pair(path, project_path)
if not key:
raise ValueError("target path is not representable (UNC / ADS / 8.3 "
"alias) — it can never match a grant")
if op_policy.forbidden_target(key):
raise ValueError(f"{op_policy.TAG_NOT_ESCALATABLE} {Path(key).name} is "
"a vault or policy file — no grant may ever cover it")
- ttl = policy.clamp_ttl(ttl_s)
+ if scope == SCOPE_RULE:
+ if not policy.allow_rule_grants:
+ raise ValueError(
+ "rule-scoped grants are disabled for this project "
+ "(`override.allow_rule_grants` is false)")
+ if not rule_is_globbable(rule):
+ raise ValueError(
+ f"'{rule}' names a behaviour, not a path set — it cannot be "
+ "widened to a rule-scoped grant; approve this call instead")
+ if not rule_covers(rule, key, rel):
+ # Belt and braces: if the glob the user is approving does not even
+ # cover the path they were looking at, the grant would silently
+ # authorise a DIFFERENT set of files than the one on screen.
+ raise ValueError(
+ f"rule '{rule}' does not cover {Path(key).name} — refusing to "
+ "mint a rule grant whose reach does not match the refusal")
+ ttl = (policy.clamp_rule_ttl(ttl_s) if scope == SCOPE_RULE
+ else policy.clamp_ttl(ttl_s))
grant = {
"id": new_id(),
"request_id": request_id or "",
"session_id": str(session_id or ""),
+ "scope": scope,
"layer": layer,
"rule": str(rule),
"tool": str(tool),
"op": str(op),
"path_key": key,
"expires_at": iso(now() + timedelta(seconds=ttl)),
- "uses_remaining": policy.clamp_uses(uses),
+ # None = unlimited within the TTL + idle window. Only a rule grant
+ # gets it; every other shape keeps a countable budget.
+ "uses_remaining": None if scope == SCOPE_RULE else policy.clamp_uses(uses),
+ "idle_s": policy.rule_idle_s() if scope == SCOPE_RULE else None,
"granted_at": iso(now()),
"granted_by": str(granted_by or "cli"),
}
@@ -318,6 +471,7 @@ def mint(project_path=".", *, session_id: str, layer: str, rule: str,
"session_id": grant["session_id"], "layer": layer, "rule": grant["rule"],
"tool": grant["tool"], "op": grant["op"], "path": key,
"ttl_s": ttl, "uses": grant["uses_remaining"],
+ "scope": scope, "idle_s": grant["idle_s"],
"granted_by": grant["granted_by"],
})
return grant
@@ -355,7 +509,7 @@ def sweep_expired(project_path=".") -> int:
return 0
keep = []
for g in grants:
- if _expired(g) or int(g.get("uses_remaining") or 0) <= 0:
+ if _expired(g) or not _has_uses(g):
dropped.append(g)
else:
keep.append(g)
@@ -373,25 +527,44 @@ def sweep_expired(project_path=".") -> int:
# ── Matching + consumption (§4) ────────────────────────────────────────────
def _matches(grant: dict, *, session_id: str, layer: str, rule: str,
- tool: str, op: str, key: str, at: datetime) -> bool:
- """All nine conditions of §4. Any mismatch ⇒ ordinary denial."""
- return (
- str(grant.get("session_id") or "") == str(session_id or "") # 2
- and grant.get("layer") == layer # 3
- and grant.get("rule") == rule # 4
- and grant.get("tool") == tool # 5
- and grant.get("op") == op # 6
- and grant.get("path_key") == key # 7
- and not _expired(grant, at) # 8
- and int(grant.get("uses_remaining") or 0) > 0 # 9
- )
+ tool: str, op: str, key: str, at: datetime,
+ rel: str = "") -> bool:
+ """All nine conditions of §4. Any mismatch ⇒ ordinary denial.
+
+ Conditions 2, 3, 4, 6, 8 and 9 are identical for every scope. A
+ ``scope="rule"`` grant relaxes exactly two of them, and only in the
+ directions §4.1 spells out:
+
+ * **5 (tool)** — from the exact tool to a declared op-class
+ (``override_policy.same_tool_class``). An unclassed tool is never
+ widened.
+ * **7 (path)** — from the exact canon key to "inside the path set the
+ rule glob describes", matched by the SAME compiler that produced the
+ denial, so a grant can never reach a file the rule would not have
+ blocked.
+
+ Everything else — the vault, the policy files, Tier-0 — is unreachable
+ before this function runs: ``find``/``consume`` refuse a
+ ``forbidden_target`` key on every call, not just at mint.
+ """
+ if (str(grant.get("session_id") or "") != str(session_id or "") # 2
+ or grant.get("layer") != layer # 3
+ or grant.get("rule") != rule # 4
+ or grant.get("op") != op # 6
+ or _expired(grant, at) # 8
+ or not _has_uses(grant)): # 9
+ return False
+ if grant.get("scope") == SCOPE_RULE:
+ return (op_policy.same_tool_class(grant.get("tool") or "", tool) # 5
+ and rule_covers(rule, key, rel)) # 7
+ return grant.get("tool") == tool and grant.get("path_key") == key # 5, 7
# Condition 1 (project_path) is structural: the store is project-local.
def find(project_path=".", *, session_id: str, layer: str, rule: str,
tool: str, op: str, path) -> dict | None:
"""The grant that WOULD authorise this call — no consumption, no audit."""
- key = path_key(path, project_path)
+ key, rel = path_key_pair(path, project_path)
if not key or op_policy.forbidden_target(key):
return None
grants, corrupt = load(project_path)
@@ -400,7 +573,7 @@ def find(project_path=".", *, session_id: str, layer: str, rule: str,
at = now()
for g in grants:
if _matches(g, session_id=session_id, layer=layer, rule=rule,
- tool=tool, op=op, key=key, at=at):
+ tool=tool, op=op, key=key, at=at, rel=rel):
return g
return None
@@ -411,13 +584,17 @@ def _near_miss(project_path, grants: list, *, session_id: str, layer: str,
for g in grants:
if g.get("session_id") != session_id or g.get("layer") != layer:
continue
- if _expired(g) or int(g.get("uses_remaining") or 0) <= 0:
+ if _expired(g) or not _has_uses(g):
continue
+ wide = g.get("scope") == SCOPE_RULE
differs = [f for f, want, got in (
("rule", g.get("rule"), rule),
- ("tool", g.get("tool"), tool),
+ # A rule grant is SUPPOSED to span tools and paths, so reporting
+ # those as near-misses would bury the signal ('you approved X,
+ # the agent tried Y') under noise the user deliberately allowed.
+ ("tool", g.get("tool"), tool if not wide else g.get("tool")),
("op", g.get("op"), op),
- ("path", g.get("path_key"), key),
+ ("path", g.get("path_key"), key if not wide else g.get("path_key")),
) if want != got]
if differs:
audit(project_path, EV_NEAR_MISS, {
@@ -440,7 +617,7 @@ def consume(project_path=".", *, session_id: str, layer: str, rule: str,
cheap, and that is strictly safer than consuming in PostToolUse where a
crash would leave a live grant behind (§4).
"""
- key = path_key(path, project_path)
+ key, rel = path_key_pair(path, project_path)
if not key or op_policy.forbidden_target(key):
return None
with _Lock(project_path):
@@ -451,14 +628,19 @@ def consume(project_path=".", *, session_id: str, layer: str, rule: str,
hit = None
for g in grants:
if _matches(g, session_id=session_id, layer=layer, rule=rule,
- tool=tool, op=op, key=key, at=at):
+ tool=tool, op=op, key=key, at=at, rel=rel):
hit = g
break
if hit is None:
_near_miss(project_path, grants, session_id=session_id, layer=layer,
rule=rule, tool=tool, op=op, key=key)
return None
- hit["uses_remaining"] = int(hit.get("uses_remaining") or 0) - 1
+ left = _uses_left(hit)
+ if left is not None:
+ hit["uses_remaining"] = left - 1
+ # last_used_at is not bookkeeping for a rule grant — it is what the
+ # idle window is measured from, so it must be written even when
+ # there is no counter to decrement.
hit["last_used_at"] = iso(at)
_save(project_path, grants)
used = dict(hit)
@@ -474,10 +656,17 @@ def granted_context(grant: dict, rule: str) -> str:
"""The additionalContext line an allowed-by-grant call emits (§5)."""
when = parse_ts(grant.get("granted_at"))
stamp = when.strftime("%H:%MZ") if when else "?"
+ left = _uses_left(grant)
+ if left is None:
+ # A rule grant is the one shape with no countdown, so the line has to
+ # say what it actually covers — otherwise a standing capability reads
+ # in the transcript exactly like a single approval.
+ budget = f"covers every path {rule} matches, this session"
+ else:
+ budget = f"{left} uses left"
return (
f"{op_policy.TAG_GRANTED} Allowed by override {grant.get('id', '?')} "
- f"(approved on {grant.get('granted_by', '?')} {stamp}, "
- f"{int(grant.get('uses_remaining') or 0)} uses left). "
+ f"(approved on {grant.get('granted_by', '?')} {stamp}, {budget}). "
f"The rule {rule} is still in force."
)
diff --git a/services/override_policy.py b/services/override_policy.py
index 9ff3a4d..e269023 100644
--- a/services/override_policy.py
+++ b/services/override_policy.py
@@ -56,6 +56,53 @@
#: Layers whose approval requires the rule glob typed by hand (spec §8/§11).
TYPED_CONFIRM_LAYERS = frozenset({LAYER_ACCESS_DENY, LAYER_ACCESS_BUILTIN})
+# ── Tool op-classes (rule-scoped grants, docs/override-requests.md §4.1) ────
+#
+# A `scope="rule"` grant relaxes §4 condition 5 from "this exact tool" to
+# "a tool that does the same KIND of thing". The relaxation is a closed
+# allowlist, never "any tool": a class a tool is not listed in falls back to
+# exact-tool matching, so an unrecognised or future tool can never inherit an
+# existing grant. `c3_artifacts` (restore writes a whole prior version) and
+# `c3_project` (writes into a DIFFERENT project) are deliberately absent —
+# both are materially larger actions than the edit the user approved.
+TOOL_CLASS_WRITE = "write"
+TOOL_CLASS_READ = "read"
+TOOL_CLASS_SHELL = "shell"
+
+TOOL_CLASSES = {
+ TOOL_CLASS_WRITE: frozenset({
+ "Write", "Edit", "MultiEdit", "NotebookEdit", "c3_edit",
+ }),
+ TOOL_CLASS_READ: frozenset({
+ "Read", "Grep", "Glob", "NotebookRead",
+ "c3_read", "c3_compress", "c3_search",
+ }),
+ TOOL_CLASS_SHELL: frozenset({"Bash", "c3_shell", "c3_shell_job"}),
+}
+
+_TOOL_TO_CLASS = {t: cls for cls, tools in TOOL_CLASSES.items() for t in tools}
+
+
+def tool_class(tool: str) -> str | None:
+ """The op-class *tool* belongs to, or ``None`` when it is not classed.
+
+ ``None`` means "no relaxation": a rule-scoped grant for an unclassed tool
+ still matches that tool and nothing else.
+ """
+ return _TOOL_TO_CLASS.get(str(tool or ""))
+
+
+def same_tool_class(granted: str, attempted: str) -> bool:
+ """True iff *attempted* may ride a rule grant minted for *granted*.
+
+ Exact match always qualifies. Beyond that both tools must resolve to the
+ same declared class — an unclassed tool on either side is never widened.
+ """
+ if str(granted or "") == str(attempted or ""):
+ return True
+ left, right = tool_class(granted), tool_class(attempted)
+ return left is not None and left == right
+
# ── Gate layers (the `layer` field on a Grant — spec §3.4) ──────────────────
GATE_ACCESS = "access"
GATE_DISCIPLINE = "discipline"
@@ -89,6 +136,14 @@
HARD_MAX_TTL_S = 900 # 15 minutes (spec §1)
HARD_MAX_USES = 50 # a "session grant" is still not unlimited
HARD_MAX_REQUEST_TTL_S = 3600
+# Rule-scoped grants (spec §4.1) are the one shape allowed to outlive the
+# 15-minute ceiling, because their whole point is "stop asking me about this
+# rule for the rest of the conversation". They pay for it with an IDLE
+# window: the wall-clock TTL is only the backstop, and a grant the session
+# stops using dies long before it. There is no session-END signal to hang
+# this on (docs/override-requests.md §4.1), so idle is the proxy.
+HARD_MAX_RULE_TTL_S = 28800 # 8 hours
+HARD_MAX_RULE_IDLE_S = 3600 # 1 hour without a matching call
NOTIFY_SEVERITIES = frozenset({"info", "warning", "critical"})
CHANNELS = frozenset({"mobile", "desktop", "both"})
@@ -108,6 +163,12 @@
"max_requests_per_hour": 20,
"notify_severity": "critical",
"allow_session_grants": False,
+ # Rule-scoped grants (spec §4.1). Strictly larger than a session grant —
+ # one approval covers every path the rule matches — so it gets its own
+ # switch, defaulting off, and its own typed challenge at decide time.
+ "allow_rule_grants": False,
+ "rule_grant_ttl_s": 14400, # 4h wall-clock backstop
+ "rule_grant_idle_s": 1800, # 30min since the last matching call
# The command run when a request is DECIDED, so the asking agent hears
# about it (services/override_wake.py). None = nobody is listening, which
# is the pre-2.73 behaviour and the reason a grant could expire unused
@@ -116,12 +177,14 @@
}
_VALID_KEYS = frozenset(DEFAULTS)
-_BOOL_KEYS = ("enabled", "allow_session_grants")
+_BOOL_KEYS = ("enabled", "allow_session_grants", "allow_rule_grants")
#: (key, hard ceiling or None) — every one is min-merged across scopes.
_INT_KEYS = (
("max_ttl_s", HARD_MAX_TTL_S),
("default_uses", HARD_MAX_USES),
("request_ttl_s", HARD_MAX_REQUEST_TTL_S),
+ ("rule_grant_ttl_s", HARD_MAX_RULE_TTL_S),
+ ("rule_grant_idle_s", HARD_MAX_RULE_IDLE_S),
("max_pending_per_session", None),
("max_requests_per_hour", None),
)
@@ -215,6 +278,9 @@ class OverridePolicy:
max_requests_per_hour: int = 20
notify_severity: str = "critical"
allow_session_grants: bool = False
+ allow_rule_grants: bool = False
+ rule_grant_ttl_s: int = 14400
+ rule_grant_idle_s: int = 1800
wake: dict | None = None
warnings: tuple = ()
corrupt_scopes: tuple = ()
@@ -249,6 +315,20 @@ def clamp_uses(self, requested: int | None) -> int:
ceiling = self.default_uses if not self.allow_session_grants else HARD_MAX_USES
return max(1, min(want, ceiling, HARD_MAX_USES))
+ def clamp_rule_ttl(self, requested: int | None) -> int:
+ """Wall-clock backstop for a rule-scoped grant.
+
+ Deliberately a SEPARATE ceiling from :meth:`clamp_ttl`: a rule grant
+ outliving 15 minutes is the feature, and folding the two would either
+ cripple it or silently widen every ordinary grant.
+ """
+ want = self.rule_grant_ttl_s if requested is None else int(requested)
+ return max(1, min(want, self.rule_grant_ttl_s, HARD_MAX_RULE_TTL_S))
+
+ def rule_idle_s(self) -> int:
+ """Seconds a rule grant may sit unused before it stops matching."""
+ return max(1, min(self.rule_grant_idle_s, HARD_MAX_RULE_IDLE_S))
+
def as_dict(self) -> dict:
return {
"enabled": self.enabled,
@@ -261,6 +341,9 @@ def as_dict(self) -> dict:
"max_requests_per_hour": self.max_requests_per_hour,
"notify_severity": self.notify_severity,
"allow_session_grants": self.allow_session_grants,
+ "allow_rule_grants": self.allow_rule_grants,
+ "rule_grant_ttl_s": self.rule_grant_ttl_s,
+ "rule_grant_idle_s": self.rule_grant_idle_s,
# The spec itself never crosses the wire. It is an argv the box
# will run, and it can carry a conversation id or a path that says
# more about this machine than a policy screen needs to. A phone
diff --git a/services/override_requests.py b/services/override_requests.py
index 1ea83ab..b94a951 100644
--- a/services/override_requests.py
+++ b/services/override_requests.py
@@ -22,6 +22,7 @@
from __future__ import annotations
import json
+import os
import secrets
from datetime import timedelta
from pathlib import Path
@@ -54,9 +55,15 @@
#: needs no extra switch; ``session`` is an unlimited-uses grant for the rest
#: of the request TTL window and is gated on ``allow_session_grants`` plus a
#: typed confirmation, because it is the closest thing here to a policy change.
+#: ``rule`` (§4.1) is the largest shape: one approval covers every path the
+#: rule glob matches, for any tool in the same op class, until its TTL or its
+#: idle window runs out. It is gated on ``allow_rule_grants`` AND the rule
+#: glob retyped by hand — a strictly harder challenge than ``session``,
+#: because the thing being accepted is a standing capability, not a repeat.
MODE_ONCE = "once"
MODE_SESSION = "session"
-MODES = (MODE_ONCE, MODE_SESSION)
+MODE_RULE = "rule"
+MODES = (MODE_ONCE, MODE_SESSION, MODE_RULE)
#: What the client must retype to get a session grant. Not the rule glob —
#: that challenge already means "I accept this access layer"; this one is a
@@ -97,13 +104,58 @@ def load() -> list:
return [r for r in data if isinstance(r, dict)]
+#: Lock file for the request store's read-modify-write. The store is ONE file
+#: for every project and session on the box (`STORE_REL` under ``~``), so two
+#: concurrent decides — or a decide racing an auto-file — used to be able to
+#: lose a row outright. `_store_lock()` closes that window; the tmp name is
+#: per-process besides, because a fixed `.tmp` is itself a collision.
+_LOCK_REL = ".c3/oracle/override_requests.lock"
+
+
+def _store_lock():
+ """Cross-process advisory lock around a load→mutate→_save sequence.
+
+ Reuses ``override_grants._Lock`` rather than growing a second
+ implementation: same O_EXCL primitive, same stale-break, same
+ non-fatal-on-failure contract (worst case is the pre-lock behaviour).
+ """
+ return og._Lock(Path.home(), _LOCK_REL)
+
+
def _save(rows: list) -> None:
path = store_path()
path.parent.mkdir(parents=True, exist_ok=True)
- tmp = path.with_name(f"{path.name}.tmp")
- tmp.write_text(json.dumps(rows, indent=2, ensure_ascii=False),
- encoding="utf-8")
- tmp.replace(path)
+ tmp = path.with_name(f"{path.name}.tmp-{os.getpid()}")
+ try:
+ tmp.write_text(json.dumps(rows, indent=2, ensure_ascii=False),
+ encoding="utf-8")
+ tmp.replace(path)
+ finally:
+ try:
+ tmp.unlink()
+ except OSError:
+ pass
+
+
+def _save_row(row: dict) -> None:
+ """Persist ONE mutated row without clobbering rows written meanwhile.
+
+ Every mutator here (create, withdraw, decide) touches exactly one row, but
+ used to write back the whole snapshot it had loaded — so a decide and a
+ concurrent auto-file, over a store shared by every project on the box,
+ could each save a list that was missing the other's row. Re-reading under
+ the lock and replacing only this row is the smallest fix that is actually
+ correct; the caller's other in-memory rows are deliberately discarded.
+ """
+ with _store_lock():
+ rows = load()
+ for i, existing in enumerate(rows):
+ if existing.get("id") == row.get("id"):
+ rows[i] = row
+ break
+ else:
+ rows.append(row)
+ _save(rows)
def _expired(row: dict, at=None) -> bool:
@@ -129,19 +181,33 @@ def _refresh(rows: list) -> bool:
return changed
+def _refresh_writeback() -> list:
+ """Load, lazily expire lapsed pendings, persist if anything moved.
+
+ The writeback is the only reason a read path touches the file at all, so
+ it takes the lock; a lost expiry flip is harmless on its own but would
+ drop whatever else was being written at the same moment.
+ """
+ rows = load()
+ if not _refresh(rows):
+ return rows
+ with _store_lock():
+ fresh = load()
+ if _refresh(fresh):
+ _save(fresh)
+ return fresh
+
+
def sweep_expired() -> int:
rows = load()
before = sum(1 for r in rows if r.get("status") == STATUS_PENDING)
- if _refresh(rows):
- _save(rows)
+ rows = _refresh_writeback()
after = sum(1 for r in rows if r.get("status") == STATUS_PENDING)
return before - after
def get(request_id: str) -> dict | None:
- rows = load()
- if _refresh(rows):
- _save(rows)
+ rows = _refresh_writeback()
for row in rows:
if row.get("id") == request_id:
return row
@@ -151,9 +217,7 @@ def get(request_id: str) -> dict | None:
def list_requests(*, project_path: str = "", session_id: str = "",
status: str = "", limit: int = 50) -> list:
"""Newest first. Empty filters mean 'all'."""
- rows = load()
- if _refresh(rows):
- _save(rows)
+ rows = _refresh_writeback()
key = og.path_key(project_path) if project_path else ""
out = []
for row in rows:
@@ -401,8 +465,7 @@ def create(project_path: str, *, session_id: str, tool: str, op: str, path,
"decided_by": None,
"decision_note": None,
}
- rows.append(row)
- _save(rows)
+ _save_row(row)
og.audit(project_path, og.EV_REQUESTED, {
"request_id": row["id"], "session_id": row["session_id"],
@@ -478,8 +541,11 @@ def _notify_decision(project_path: str, row: dict, grant=None) -> None:
status = row.get("status", "")
name = Path(str(row.get("path", ""))).name
if status == STATUS_APPROVED:
- detail = "once" if row.get("grant_mode") != MODE_SESSION \
- else "for this session"
+ detail = {
+ MODE_SESSION: "for this session",
+ MODE_RULE: f"for every path {row.get('rule')} matches, "
+ "this session",
+ }.get(str(row.get("grant_mode") or ""), "once")
message = (f"Granted {row.get('tool')} {row.get('op')} on {name} "
f"{detail} (rule {row.get('rule')}). "
f"Grant {(grant or {}).get('id', '')} expires "
@@ -527,7 +593,7 @@ def withdraw(request_id: str, session_id: str) -> dict:
raise OverrideError(f"request is already {row.get('status')}.")
row["status"] = STATUS_WITHDRAWN
row["resolved_at"] = og.iso(og.now())
- _save(rows)
+ _save_row(row)
return row
raise OverrideError(f"no request with id '{request_id}'.")
@@ -569,7 +635,7 @@ def decide(request_id: str, decision: str, *, uses: int | None = None,
row["decided_by"] = decided_by
row["decision_note"] = str(note or "")[:JUSTIFICATION_CAP] or None
row["muted"] = bool(mute)
- _save(rows)
+ _save_row(row)
# Mute AFTER the row is persisted: a mute whose request never landed
# as denied would suppress a question the user never actually answered.
if mute:
@@ -597,6 +663,7 @@ def decide(request_id: str, decision: str, *, uses: int | None = None,
f"approving an {layers_key} request needs the rule retyped: "
f"confirm='{row.get('rule')}'")
+ scope = og.SCOPE_CALL
if mode == MODE_SESSION:
# Two independent gates, both required. The switch is the user's
# standing policy; the challenge is their answer right now.
@@ -611,13 +678,37 @@ def decide(request_id: str, decision: str, *, uses: int | None = None,
f"confirm='{CONFIRM_SESSION}'")
# Unlimited uses within the (already clamped) TTL window.
uses = op_policy.HARD_MAX_USES
+ elif mode == MODE_RULE:
+ # Same two-gate construction, one notch harder on both. The switch is
+ # its own (`allow_rule_grants`, not the session one), and the
+ # challenge is the RULE GLOB — for every layer, including the ones
+ # that are one-tap for `once`. What is being accepted here is not
+ # "ask me less about this file", it is "stop asking me about this
+ # rule", and the keystrokes should say so.
+ if not policy.allow_rule_grants:
+ raise OverrideError(
+ "rule-scoped grants are disabled for this project "
+ "(`override.allow_rule_grants` is false). Approve once or "
+ "for this session instead, or change the policy on the "
+ "desktop.")
+ if not og.rule_is_globbable(row.get("rule", "")):
+ raise OverrideError(
+ f"'{row.get('rule')}' names a behaviour, not a path set — "
+ "there is nothing to widen along. Approve this call instead.")
+ if confirm != row.get("rule"):
+ raise OverrideError(
+ "a rule grant covers every path the rule matches, for any "
+ "tool in the same op class, until it expires: "
+ f"confirm='{row.get('rule')}'")
+ scope = og.SCOPE_RULE
+ uses = None # unlimited; mint() owns the TTL + idle window
grant = og.mint(project_path, session_id=row.get("session_id", ""),
layer=row.get("layer", ""), rule=row.get("rule", ""),
tool=row.get("tool", ""), op=row.get("op", ""),
path=row.get("path", ""), ttl_s=ttl_s, uses=uses,
granted_by=decided_by, request_id=request_id,
- policy=policy, layers_key=layers_key)
+ policy=policy, layers_key=layers_key, scope=scope)
row["status"] = STATUS_APPROVED
row["resolved_at"] = og.iso(now)
@@ -625,7 +716,8 @@ def decide(request_id: str, decision: str, *, uses: int | None = None,
row["decision_note"] = str(note or "")[:JUSTIFICATION_CAP] or None
row["grant_id"] = grant["id"]
row["grant_mode"] = mode
- _save(rows)
+ row["grant_scope"] = scope
+ _save_row(row)
_notify_decision(project_path, row, grant=grant)
# AFTER _save: the woken agent's very first move is to retry the blocked
# call, which reads this store. Waking before persisting would race the
diff --git a/services/session_benchmark.py b/services/session_benchmark.py
index 86fdcca..70067f8 100644
--- a/services/session_benchmark.py
+++ b/services/session_benchmark.py
@@ -344,6 +344,26 @@ def run_all(self) -> list:
))
return results
+ def _first_symbol_file(self):
+ """First sampled file with extractable symbols → (path, content, record, symbols).
+
+ (None, None, None, []) when nothing in the sample has any, which is a
+ real result for a docs-only project and is reported as such rather
+ than silently benchmarked as a full-file read.
+ """
+ for path, content, _tokens in self.sample:
+ rel = self._rel(path)
+ record = self.file_memory.get(rel)
+ if not record or self.file_memory.needs_update(rel):
+ record = self.file_memory.update(rel)
+ if not record or not record.get("sections"):
+ continue
+ symbols = [s["name"] for s in record["sections"]
+ if s.get("type") in ("class", "function", "method")][:3]
+ if symbols:
+ return path, content, record, symbols
+ return None, None, None, []
+
def _scenario_bug_investigation(self) -> ScenarioResult:
"""Simulate: search for error → read suspicious files → narrow to symbol → validate."""
result = ScenarioResult(
@@ -353,15 +373,18 @@ def _scenario_bug_investigation(self) -> ScenarioResult:
if len(self.sample) < 2:
return result
- # Pick a target file and a "bug query"
- target_path, target_content, _ = self.sample[0]
+ # Pick a target file and a "bug query". The target must be a file the
+ # indexer can extract symbols from — "narrow to the relevant symbol"
+ # is this scenario's whole third step, and a file with no symbols
+ # turns it into a full-file read while still paying for the map.
+ # `_select_sample` orders by SIZE alone, so the largest file in a repo
+ # can easily be a changelog or a bundled HTML page; measuring a
+ # symbol-narrowing workflow against one measures nothing.
+ target_path, target_content, record, symbols = self._first_symbol_file()
+ if target_path is None:
+ result.description = "Error: no sampled file exposes symbols"
+ return result
target_rel = self._rel(target_path)
- record = self.file_memory.get(target_rel)
- if not record or self.file_memory.needs_update(target_rel):
- record = self.file_memory.update(target_rel)
- symbols = []
- if record and record.get("sections"):
- symbols = [s["name"] for s in record["sections"] if s.get("type") in ("class", "function", "method")][:3]
# Use a query that's realistic but likely to hit — reference the filename and a symbol if available
if symbols:
query = f"{symbols[0]} bug in {target_path.stem}"
diff --git a/tests/test_access_builtin_optout.py b/tests/test_access_builtin_optout.py
index 482b125..eda3c9b 100644
--- a/tests/test_access_builtin_optout.py
+++ b/tests/test_access_builtin_optout.py
@@ -171,12 +171,25 @@ def test_unknown_glob_rejected(self):
class TestScopeRules(_Base):
- def test_project_scope_disable_is_corrupt_not_silent(self):
- """A project scope may only tighten. Loosening there is a loud error --
- silently ignoring it would let the UI claim a builtin is off."""
+ def test_project_scope_disable_needs_a_project_attestation(self):
+ """v2.118.0: a project scope may now carry the legacy opt-out too, and
+ it is still inert on its own. The config half never moved a builtin;
+ the keyring half does, and it is realm-bound to this project."""
self.write_project({"disable_builtin": ["**/.git/**"]})
_rules, corrupt = access_guard.load_rules(str(self.project))
- self.assertIn("project", corrupt)
+ self.assertEqual(corrupt, [])
+ self.assertEqual(access_guard.disabled_builtins(str(self.project)),
+ frozenset())
+ self.assertIsNotNone(access_guard.check(
+ str(self.project / ".git" / "config"), "write", str(self.project)))
+
+ def test_project_scope_disable_with_its_attestation_takes_effect(self):
+ access_guard.set_builtin_disabled("**/.git/**", True, scope="project",
+ project_path=str(self.project))
+ self.assertIn("**/.git/**",
+ access_guard.disabled_builtins(str(self.project)))
+ self.assertIsNone(access_guard.check(
+ str(self.project / ".git" / "config"), "write", str(self.project)))
def test_global_disable_is_not_corrupt(self):
self.write_global({"disable_builtin": ["**/.git/**"]})
diff --git a/tests/test_access_guard_meta.py b/tests/test_access_guard_meta.py
index cc51a36..9d6a989 100644
--- a/tests/test_access_guard_meta.py
+++ b/tests/test_access_guard_meta.py
@@ -70,7 +70,13 @@
"load_all", "load_rules", "check",
# config-store plumbing (scope dirs), not
# user-path evaluation:
- "_scope_config_path"},
+ "_scope_config_path",
+ # the one resolver for scope BASES (realms,
+ # per-scope mode lookups). Everything that
+ # needs a project's identity goes through
+ # it, so two spellings cannot become two
+ # keyring realms:
+ "_scope_base"},
}
diff --git a/tests/test_builtin_modes.py b/tests/test_builtin_modes.py
index 5fa9d53..90c6a4b 100644
--- a/tests/test_builtin_modes.py
+++ b/tests/test_builtin_modes.py
@@ -42,6 +42,7 @@ class _FakeKeyring:
def __init__(self, working=True):
self.store = {}
self.working = working
+ self.gets = 0 # hot-path budget: see test_no_keyring_read_when_…
def set_password(self, service, account, value):
if not self.working:
@@ -51,6 +52,7 @@ def set_password(self, service, account, value):
def get_password(self, service, account):
if not self.working:
raise RuntimeError("no keyring backend")
+ self.gets += 1
return self.store.get((service, account))
@@ -101,6 +103,10 @@ def write_global(self, section):
cfg = self.home / ".c3" / "config.json"
cfg.write_text(json.dumps({"access": section}), encoding="utf-8")
+ def write_project(self, section, base=None):
+ cfg = (base or self.project) / ".c3" / "config.json"
+ cfg.write_text(json.dumps({"access": section}), encoding="utf-8")
+
def check(self, path, op):
return access_guard.check(str(path), op, str(self.project))
@@ -239,12 +245,92 @@ def test_ambiguous_spelling_is_corrupt(self):
# Corrupt scope ⇒ deny-all, the loud direction.
self.assertIsNotNone(self.check(self.project / "anything.txt", "read"))
- def test_project_scope_builtin_mode_is_corrupt(self):
- (self.project / ".c3" / "config.json").write_text(
- json.dumps({"access": {"builtin_mode": {"**/.git/**": "allow"}}}),
- encoding="utf-8")
+ def test_project_scope_builtin_mode_needs_a_project_attestation(self):
+ """v2.118.0 amendment. This used to be a corrupt scope, on the
+ reasoning that a project may only tighten. What that rule actually
+ protects against is a CLONED REPO's config — and config alone has
+ never been able to change a builtin. So the scope is legal now, and
+ the config half on its own still changes nothing."""
+ self.write_project({"builtin_mode": {"**/.git/**": "allow"}})
_rules, corrupt = access_guard.load_rules(str(self.project))
- self.assertIn("project", corrupt)
+ self.assertEqual(corrupt, []) # no longer corrupt
+ self.assertEqual(access_guard.effective_builtin_modes(
+ str(self.project)), {}) # …and still inert
+ self.assertIsNotNone(self.check(self.project / ".git" / "config",
+ "write"))
+
+ def test_a_global_attestation_does_not_satisfy_a_project_entry(self):
+ """Realm-atomic, exactly like the credential vault. Otherwise one
+ machine-wide opt-out would silently license every project's config."""
+ access_guard.set_builtin_mode("**/.git/**", "allow") # global, real
+ self.write_project({"builtin_mode": {"**/.env*": "allow"}})
+ modes = access_guard.effective_builtin_modes(str(self.project))
+ self.assertEqual(modes.get("**/.git/**"), "allow")
+ self.assertNotIn("**/.env*", modes)
+ self.assertIsNotNone(self.check(self.project / ".env", "read"))
+
+ def test_a_project_attestation_does_not_leak_to_another_project(self):
+ other = self.project.parent / "other"
+ (other / ".c3").mkdir(parents=True)
+ access_guard.set_builtin_mode("**/.env*", "allow", scope="project",
+ project_path=str(self.project))
+ (other / ".c3" / "config.json").write_text(
+ json.dumps({"access": {"builtin_mode": {"**/.env*": "allow"}}}),
+ encoding="utf-8")
+ self.assertIn("**/.env*",
+ access_guard.effective_builtin_modes(str(self.project)))
+ self.assertNotIn("**/.env*",
+ access_guard.effective_builtin_modes(str(other)))
+ self.assertIsNone(access_guard.check(
+ str(self.project / ".env"), "read", str(self.project)))
+ self.assertIsNotNone(access_guard.check(
+ str(other / ".env"), "read", str(other)))
+
+ def test_a_project_mode_replaces_the_global_one(self):
+ access_guard.set_builtin_mode("**/.env*", "allow") # global: off
+ access_guard.set_builtin_mode("**/.env*", "deny", scope="project",
+ project_path=str(self.project))
+ self.assertEqual(access_guard.effective_builtin_modes(
+ str(self.project))["**/.env*"], "deny")
+ self.assertIsNotNone(self.check(self.project / ".env", "read"))
+
+ def test_list_rules_says_which_scope_set_the_mode(self):
+ access_guard.set_builtin_mode("**/.git/**", "confirm")
+ access_guard.set_builtin_mode("**/.env*", "allow", scope="project",
+ project_path=str(self.project))
+ realms = access_guard.list_rules(str(self.project))["builtin"]["mode_realms"]
+ self.assertEqual(realms["**/.git/**"], "global")
+ self.assertEqual(realms["**/.env*"], "project")
+
+ def test_tier0_stays_denied_under_any_project_mode(self):
+ for glob in access_guard.BUILTIN_ABSOLUTE_DENY:
+ with self.assertRaises(ValueError):
+ access_guard.set_builtin_mode(glob, "allow", scope="project",
+ project_path=str(self.project))
+
+ def test_a_project_allow_on_c3_never_reaches_the_policy_files(self):
+ """`builtin_mode: {'**/.c3/**': 'allow'}` un-blocks the directory, and
+ the vault + override policy files inside it stay unreachable — they
+ are Tier-0 or `forbidden_target`, neither of which a mode touches."""
+ access_guard.set_builtin_mode("**/.c3/**", "allow", scope="project",
+ project_path=str(self.project))
+ self.assertIsNone(self.check(self.project / ".c3" / "notes.txt",
+ "write"))
+ for name in ("secrets.enc", "cred_state.json"):
+ self.assertIsNotNone(
+ self.check(self.project / ".c3" / name, "write"))
+
+ def test_unknown_scope_is_rejected(self):
+ with self.assertRaises(ValueError):
+ access_guard.set_builtin_mode("**/.git/**", "allow",
+ scope="everywhere")
+
+ def test_no_keyring_read_when_no_mode_is_configured(self):
+ """The hot path: hooks import this module on every native tool call.
+ Per-project modes must cost nothing when nobody has set one."""
+ self.keyring.gets = 0
+ access_guard.effective_builtin_modes(str(self.project))
+ self.assertEqual(self.keyring.gets, 0)
class TestLegacyAndMigration(_Base):
diff --git a/tests/test_credential_login_kind.py b/tests/test_credential_login_kind.py
index 6865fb6..688af1b 100644
--- a/tests/test_credential_login_kind.py
+++ b/tests/test_credential_login_kind.py
@@ -46,18 +46,20 @@ def test_login_is_a_structured_type(self):
self.assertIn("login", cs.VALID_TYPES)
self.assertIn("login", cs.STRUCTURED_TYPES)
- def test_create_projects_site_and_origin_but_never_username(self):
+ def test_create_projects_site_and_target_but_never_username(self):
entry = cs.set_credential("GH", _login(site_id="github",
canonical_origin="https://github.com"),
scope="project", project_path=self.project,
ctype="login")
self.assertEqual(entry["type"], "login")
self.assertEqual(entry["display"], {"site_id": "github",
- "origin": "https://github.com",
- "has_totp": False})
+ "scheme": "https",
+ "target": "https://github.com",
+ "has_totp": False,
+ "has_key": False})
self.assertNotIn("username", json.dumps(entry["display"]))
self.assertEqual(entry["fields"],
- ["canonical_origin", "password", "site_id", "username"])
+ ["canonical_target", "password", "site_id", "username"])
def test_has_totp_flag_never_carries_the_seed(self):
entry = cs.set_credential("T", _login(totp_secret="JBSWY3DPEHPK3PXP"),
@@ -106,17 +108,17 @@ def test_origin_is_normalized(self):
def test_origin_rejections(self):
cases = [
- ("http://example.com", "https"),
+ ("http://example.com", "cleartext"),
("https://example.com/login", "no path"),
("https://example.com?next=1", "no path"),
("https://user@example.com", "userinfo"),
- ("example.com", "full origin"),
+ ("example.com", "full target"),
("https://example.com:99999", "port"),
("https://:8443", "host"),
("https://exa mple.com", "host"),
# trailing-slash stripping must not turn a bare scheme into a
- # silently-accepted origin
- ("https://", "full origin"),
+ # silently-accepted target
+ ("https://", "full target"),
]
for raw, needle in cases:
with self.assertRaises(cs.CredentialError) as ctx:
@@ -187,5 +189,177 @@ def test_cannot_flip_a_plain_entry_into_a_login(self):
self.assertIn("delete the entry", str(ctx.exception))
+# ── v2.118.0: logins for servers, databases and other non-web targets ──────
+
+_PEM = ("-----BEGIN OPENSSH PRIVATE KEY-----\n"
+ + "b3BlbnNzaC1rZXktdjEAAAAA\n" * 40
+ + "-----END OPENSSH PRIVATE KEY-----")
+
+
+def _server_login(**over) -> str:
+ base = {
+ "site_id": "build01",
+ "canonical_target": "ssh://build01.lan:22",
+ "username": "deploy",
+ "private_key": _PEM,
+ }
+ base.update(over)
+ return json.dumps({k: v for k, v in base.items() if v is not None})
+
+
+class TestNonWebLogins(TestCredentialStore):
+ def set_login(self, name, payload):
+ return cs.set_credential(name, payload, scope="project",
+ project_path=self.project, ctype="login")
+
+ def test_every_allowed_scheme_normalizes(self):
+ for i, scheme in enumerate(cs.TARGET_SCHEMES):
+ with self.subTest(scheme=scheme):
+ entry = self.set_login(f"S{i}", _server_login(
+ canonical_target=f"{scheme}://Host.Example.COM:0443"))
+ self.assertEqual(entry["display"]["target"],
+ f"{scheme}://host.example.com:443")
+ self.assertEqual(entry["display"]["scheme"], scheme)
+
+ def test_cleartext_schemes_name_their_replacement(self):
+ for bad, good in (("http", "https"), ("ftp", "ftps"),
+ ("telnet", "ssh"), ("imap", "imaps"),
+ ("ldap", "ldaps"), ("smtp", "smtps")):
+ with self.subTest(scheme=bad):
+ with self.assertRaises(cs.CredentialError) as ctx:
+ self.set_login("C", _server_login(
+ canonical_target=f"{bad}://host.example.com"))
+ self.assertIn("cleartext", str(ctx.exception))
+ self.assertIn(good, str(ctx.exception))
+
+ def test_unknown_scheme_is_refused_not_guessed(self):
+ with self.assertRaises(cs.CredentialError) as ctx:
+ self.set_login("U", _server_login(
+ canonical_target="gopher://host.example.com"))
+ self.assertIn("unsupported scheme", str(ctx.exception))
+
+ def test_a_server_target_keeps_every_bare_target_rule(self):
+ for raw, needle in (("ssh://h.example.com/path", "no path"),
+ ("ssh://user@h.example.com", "userinfo"),
+ ("ssh://h.example.com:99999", "port"),
+ ("ssh://", "full target")):
+ with self.subTest(raw=raw):
+ with self.assertRaises(cs.CredentialError) as ctx:
+ self.set_login("B", _server_login(canonical_target=raw))
+ self.assertIn(needle, str(ctx.exception))
+
+ # ── the origin-pinning property, which must NOT be weakened ──
+ def test_canonical_origin_resolves_only_for_https(self):
+ """The load-bearing test. A browser broker pins a credential by
+ asking for `canonical_origin`; handing it an `ssh://` string would
+ give it something it cannot compare to a top-level frame."""
+ self.set_login("WEB", _login(canonical_origin="https://bank.example.com"))
+ self.set_login("SSH", _server_login())
+ self.assertEqual(
+ cs.get_value("WEB", project_path=self.project,
+ field="canonical_origin"),
+ "https://bank.example.com")
+ self.assertIsNone(
+ cs.get_value("SSH", project_path=self.project,
+ field="canonical_origin"))
+
+ def test_canonical_target_resolves_for_both(self):
+ self.set_login("WEB", _login(canonical_origin="https://bank.example.com"))
+ self.set_login("SSH", _server_login())
+ self.assertEqual(
+ cs.get_value("WEB", project_path=self.project,
+ field="canonical_target"),
+ "https://bank.example.com")
+ self.assertEqual(
+ cs.get_value("SSH", project_path=self.project,
+ field="canonical_target"),
+ "ssh://build01.lan:22")
+
+ def test_a_pre_2118_record_still_reads_back(self):
+ """Stored blobs carry `canonical_origin` literally. No migration
+ runs, so both spellings must resolve off the old field."""
+ self.set_login("OLD", _login())
+ raw = json.loads(cs._get_raw("OLD", project_path=self.project))
+ raw["canonical_origin"] = raw.pop("canonical_target")
+ cs._store_value("OLD", json.dumps(raw), scope="project",
+ project_path=self.project,
+ realm_s=cs.realm("project", self.project))
+ self.assertEqual(
+ cs.get_value("OLD", project_path=self.project,
+ field="canonical_target"),
+ "https://login.example.com")
+ self.assertEqual(
+ cs.get_value("OLD", project_path=self.project,
+ field="canonical_origin"),
+ "https://login.example.com")
+
+ def test_the_legacy_spelling_is_accepted_on_input(self):
+ entry = self.set_login("A", _login())
+ self.assertEqual(entry["fields"],
+ ["canonical_target", "password", "site_id",
+ "username"])
+
+ def test_two_disagreeing_targets_are_refused(self):
+ with self.assertRaises(cs.CredentialError) as ctx:
+ self.set_login("X", json.dumps({
+ "site_id": "x", "username": "u", "password": "p",
+ "canonical_target": "https://a.example.com",
+ "canonical_origin": "https://b.example.com"}))
+ self.assertIn("disagree", str(ctx.exception))
+
+ # ── password-or-key ──
+ def test_a_key_only_login_is_valid(self):
+ entry = self.set_login("K", _server_login())
+ self.assertTrue(entry["display"]["has_key"])
+ self.assertNotIn("private_key", json.dumps(entry["display"]))
+
+ def test_a_login_with_neither_secret_is_refused(self):
+ with self.assertRaises(cs.CredentialError) as ctx:
+ self.set_login("N", _server_login(private_key=None))
+ self.assertIn("needs a secret", str(ctx.exception))
+
+ def test_a_non_pem_private_key_is_refused(self):
+ with self.assertRaises(cs.CredentialError) as ctx:
+ self.set_login("P", _server_login(
+ private_key="ssh-ed25519 AAAAC3Nz... deploy@build01"))
+ self.assertIn("PRIVATE KEY", str(ctx.exception))
+
+ def test_a_passphrase_without_a_key_is_refused(self):
+ with self.assertRaises(cs.CredentialError) as ctx:
+ self.set_login("Q", _login(passphrase="hunter2"))
+ self.assertIn("no 'private_key'", str(ctx.exception))
+
+ def test_an_oversize_key_is_accepted_and_goes_to_the_sidecar(self):
+ entry = self.set_login("K", _server_login())
+ self.assertGreater(len(_PEM), cs.FILE_STORAGE_THRESHOLD)
+ self.assertEqual(entry["storage"], "file")
+ self.assertEqual(
+ cs.get_value("K", project_path=self.project, field="private_key"),
+ _PEM)
+
+ def test_a_key_beyond_its_own_cap_is_refused(self):
+ cap = cs._field_max("private_key")
+ with self.assertRaises(cs.CredentialError) as ctx:
+ self.set_login("H", _server_login(
+ private_key="-----BEGIN PRIVATE KEY-----\n" + "x" * (cap + 1)))
+ self.assertIn("private_key", str(ctx.exception))
+
+ def test_other_fields_keep_the_small_cap(self):
+ with self.assertRaises(cs.CredentialError) as ctx:
+ self.set_login("W", _login(password="p" * 300))
+ self.assertIn("256", str(ctx.exception))
+
+ # ── inject-only survives the new shape ──
+ def test_reveal_is_still_refused_for_a_server_login(self):
+ self.set_login("K", _server_login())
+ self.assertIsNone(cs.get_value("K", project_path=self.project))
+
+ def test_errors_never_echo_the_key(self):
+ with self.assertRaises(cs.CredentialError) as ctx:
+ self.set_login("E", _server_login(
+ canonical_target="gopher://nope.example.com"))
+ self.assertNotIn(_PEM[:60], str(ctx.exception))
+
+
if __name__ == "__main__": # pragma: no cover
unittest.main()
diff --git a/tests/test_credential_ui_parity.py b/tests/test_credential_ui_parity.py
index d9f92d1..5c40f8f 100644
--- a/tests/test_credential_ui_parity.py
+++ b/tests/test_credential_ui_parity.py
@@ -97,16 +97,52 @@ def test_hidden_fields_are_real_fields_of_their_kind(self):
self.assertTrue(set(spec["hidden"]) <= known,
f"{spec['hidden']} not all in {sorted(known)}")
+ #: Field names that must never be echoed, in any UI or on the terminal.
+ #: Substring-matched against the schema so a NEW secret-bearing field on
+ #: a future kind is covered without anyone remembering to add it here.
+ SECRET_FIELD_MARKERS = ("password", "secret", "private_key", "passphrase",
+ "number", "cvc", "ssn")
+
+ @classmethod
+ def _secret_fields(cls):
+ """{ctype: {field, …}} — every secret-bearing field in _SCHEMAS."""
+ out = {}
+ for ctype in sorted(cs.STRUCTURED_TYPES):
+ required, optional = cs.schema_fields(ctype)
+ hit = {f for f in tuple(required) + tuple(optional)
+ if any(m in f for m in cls.SECRET_FIELD_MARKERS)}
+ if hit:
+ out[ctype] = hit
+ return out
+
def test_secret_bearing_fields_render_masked(self):
"""The fields whose whole point is secrecy must be password inputs."""
- must_hide = {"card": {"number", "cvc"},
- "identity": {"ssn"},
- "login": {"password", "totp_secret"}}
+ must_hide = self._secret_fields()
+ self.assertIn("login", must_hide) # the derivation actually found some
for name, src in self.sources.items():
table = _structured_table(src)
for ctype, fields in must_hide.items():
with self.subTest(ui=name, ctype=ctype):
- self.assertTrue(fields <= set(table[ctype]["hidden"]))
+ self.assertTrue(fields <= set(table[ctype]["hidden"]),
+ f"unmasked in the {name} UI: "
+ f"{sorted(fields - set(table[ctype]['hidden']))}")
+
+ def test_the_cli_prompts_for_secret_fields_without_echo(self):
+ """The third UI, and the one nothing asserted until v2.118.0.
+
+ `c3 creds set X --type login` prompted for the password with plain
+ `input()` from v2.90.0 to v2.117.0 and echoed it to the terminal
+ (fixed in v2.118.0),
+ while both browser UIs masked it correctly. Derived from _SCHEMAS
+ rather than hardcoded, so a future kind cannot reopen the hole.
+ """
+ from cli.c3 import _CREDS_HIDDEN_FIELDS, _CREDS_MULTILINE_FIELDS
+ covered = set(_CREDS_HIDDEN_FIELDS) | set(_CREDS_MULTILINE_FIELDS)
+ missing = {f for fields in self._secret_fields().values()
+ for f in fields} - covered
+ self.assertEqual(
+ missing, set(),
+ f"c3 creds would echo these to the terminal: {sorted(missing)}")
def test_every_valid_type_is_selectable_in_both_uis(self):
for name, src in self.sources.items():
diff --git a/tests/test_hub_override_routes.py b/tests/test_hub_override_routes.py
index 5281287..5d5ea8b 100644
--- a/tests/test_hub_override_routes.py
+++ b/tests/test_hub_override_routes.py
@@ -197,6 +197,122 @@ def test_audit_carries_ids_never_the_justification(self):
self.assertNotIn(CANARY, text)
+# ── §4.1 session and rule modes over the desktop route ─────────────────────
+#
+# The hub was the untested half: the route has accepted `mode` since it was
+# written, but nothing asserted the gates, so a UI change could have started
+# minting session grants in a project whose policy forbids them.
+
+class TestGrantModes(HubOverrideBase):
+ def _confirm_with_policy(self, **extra):
+ override = {"enabled": True,
+ "layers": {k: True for k in opol.LAYER_KEYS}}
+ override.update(extra)
+ self.write_config(override=override)
+ return self.confirm_request()
+
+ def test_unknown_mode_is_400(self):
+ row = self.confirm_request()
+ self.assertEqual(
+ self.decide(row["id"], {"decision": "approve",
+ "mode": "forever"}).status_code, 400)
+
+ def test_session_mode_refused_when_the_switch_is_off(self):
+ row = self._confirm_with_policy(allow_session_grants=False)
+ resp = self.decide(row["id"], {"decision": "approve",
+ "mode": "session",
+ "confirm": "session"})
+ self.assertEqual(resp.status_code, 400)
+ self.assertEqual(og.active(str(self.proj)), [])
+
+ def test_session_mode_mints_when_the_switch_is_on(self):
+ row = self._confirm_with_policy(allow_session_grants=True)
+ resp = self.decide(row["id"], {"decision": "approve",
+ "mode": "session",
+ "confirm": "session"})
+ self.assertEqual(resp.status_code, 200)
+ grant = og.active(str(self.proj))[0]
+ self.assertEqual(grant["scope"], og.SCOPE_CALL)
+ self.assertGreater(grant["uses_remaining"], 1)
+
+ def test_rule_mode_is_only_offered_when_policy_allows_it(self):
+ self.confirm_request()
+ entry = self.client.get(
+ "/api/hub/overrides?status=pending").get_json()["requests"][0]
+ self.assertFalse(entry["allow_rule_grants"])
+
+ def test_rule_mode_refused_when_the_switch_is_off(self):
+ row = self._confirm_with_policy(allow_rule_grants=False)
+ resp = self.decide(row["id"], {"decision": "approve", "mode": "rule",
+ "confirm": "infra/**"})
+ self.assertEqual(resp.status_code, 400)
+ self.assertEqual(og.active(str(self.proj)), [])
+
+ def test_rule_mode_demands_the_glob_even_on_a_one_tap_layer(self):
+ row = self._confirm_with_policy(allow_rule_grants=True)
+ resp = self.decide(row["id"], {"decision": "approve", "mode": "rule"})
+ self.assertEqual(resp.status_code, 400)
+ body = resp.get_json()
+ self.assertTrue(body["needs_confirmation"])
+ self.assertEqual(body["confirm_with"], "infra/**")
+ self.assertEqual(og.active(str(self.proj)), [])
+
+ def test_rule_mode_mints_a_grant_that_spans_the_rule(self):
+ row = self._confirm_with_policy(allow_rule_grants=True)
+ entry = self.client.get(
+ "/api/hub/overrides?status=pending").get_json()["requests"][0]
+ self.assertTrue(entry["allow_rule_grants"])
+ resp = self.decide(row["id"], {"decision": "approve", "mode": "rule",
+ "confirm": "infra/**"})
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.get_json()["request"]["grant_scope"],
+ og.SCOPE_RULE)
+ sibling = self.proj / "infra" / "other.tf"
+ sibling.write_text("x", encoding="utf-8")
+ self.assertIsNotNone(og.consume(
+ str(self.proj), session_id=SESSION, layer=opol.GATE_ACCESS,
+ rule="infra/**", tool="c3_edit", op="write", path=str(sibling)))
+
+
+class TestGrantsRoutes(HubOverrideBase):
+ def _mint_rule_grant(self):
+ self.write_config(override={
+ "enabled": True, "layers": {k: True for k in opol.LAYER_KEYS},
+ "allow_rule_grants": True})
+ row = self.confirm_request()
+ self.decide(row["id"], {"decision": "approve", "mode": "rule",
+ "confirm": "infra/**"})
+ return og.active(str(self.proj))[0]
+
+ def test_lists_live_grants_with_their_reach(self):
+ grant = self._mint_rule_grant()
+ resp = self.client.get(f"/api/hub/grants?path={self.proj}")
+ self.assertEqual(resp.status_code, 200)
+ row = resp.get_json()["grants"][0]
+ self.assertEqual(row["id"], grant["id"])
+ self.assertEqual(row["scope"], og.SCOPE_RULE)
+ self.assertEqual(row["rule"], "infra/**")
+ self.assertIsNone(row["uses_remaining"])
+ self.assertTrue(row["idle_s"])
+
+ def test_revoking_ends_it_immediately(self):
+ grant = self._mint_rule_grant()
+ resp = self.client.delete(
+ f"/api/hub/grants/{grant['id']}?path={self.proj}")
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(og.active(str(self.proj)), [])
+ self.assertIsNone(og.consume(
+ str(self.proj), session_id=SESSION, layer=opol.GATE_ACCESS,
+ rule="infra/**", tool="c3_edit", op="write", path=str(self.held)))
+
+ def test_revoking_an_unknown_grant_is_404(self):
+ self.assertEqual(self.client.delete(
+ f"/api/hub/grants/grt_nope?path={self.proj}").status_code, 404)
+
+ def test_path_is_required(self):
+ self.assertEqual(self.client.get("/api/hub/grants").status_code, 400)
+
+
class TestAccessView(HubOverrideBase):
def test_rules_and_policy_for_one_project(self):
resp = self.client.get(
diff --git a/tests/test_override_grants.py b/tests/test_override_grants.py
index 86d0146..2dd7fdf 100644
--- a/tests/test_override_grants.py
+++ b/tests/test_override_grants.py
@@ -295,6 +295,174 @@ def test_find_does_not_consume(self):
self.assertIsNotNone(self.consume()) # the use was still there
+# ── §4.1 rule-scoped grants ────────────────────────────────────────────────
+#
+# The negative tests are again the load-bearing ones. A rule grant relaxes
+# exactly two of the nine conditions; every OTHER condition, and every limit
+# that replaces the ones it drops, is asserted individually here. The one
+# that matters most is `test_forbidden_target_never_rides_a_rule_grant`: the
+# widened path condition is the only route by which a grant could ever reach
+# the vault or the policy files, and it must not.
+
+class TestRuleScope(OverrideBase):
+ def setUp(self):
+ super().setUp()
+ self.write_config(
+ access={"deny": ["secrets/**"], "read_only": ["docs/**"]},
+ override={"enabled": True, "layers": dict(ALL_LAYERS_ON),
+ "allow_rule_grants": True})
+ self.other = self.proj / "secrets" / "other.txt"
+ self.other.write_text("o", encoding="utf-8")
+
+ def rule_mint(self, **kw):
+ kw.setdefault("scope", og.SCOPE_RULE)
+ return self.mint(**kw)
+
+ def test_covers_a_different_path_under_the_same_rule(self):
+ self.rule_mint()
+ hit = self.consume(path=str(self.other))
+ self.assertIsNotNone(hit)
+ self.assertEqual(hit["scope"], og.SCOPE_RULE)
+
+ def test_uses_are_unlimited_and_never_burn_out(self):
+ self.rule_mint()
+ for _ in range(60): # past HARD_MAX_USES, which does not apply here
+ self.assertIsNotNone(self.consume(path=str(self.other)))
+ self.assertIsNone(og._uses_left(og.active(str(self.proj))[0]))
+
+ def test_a_tool_in_the_same_class_rides_it(self):
+ self.rule_mint(tool="Read")
+ self.assertIsNotNone(self.consume(tool="Grep"))
+
+ def test_a_tool_in_another_class_does_not(self):
+ self.rule_mint(tool="Read")
+ self.assertIsNone(self.consume(tool="Edit"))
+
+ def test_an_unclassed_tool_is_never_widened(self):
+ self.rule_mint(tool="SomeFutureTool")
+ self.assertIsNone(self.consume(tool="Read"))
+ self.assertIsNotNone(self.consume(tool="SomeFutureTool"))
+
+ def test_a_path_outside_the_rule_does_not(self):
+ outside = self.proj / "docs"
+ outside.mkdir()
+ target = outside / "note.md"
+ target.write_text("n", encoding="utf-8")
+ self.rule_mint()
+ self.assertIsNone(self.consume(path=str(target)))
+
+ def test_session_layer_op_still_pin_it(self):
+ for name, override in {
+ "session": dict(session_id="other-session"),
+ "layer": dict(layer=opol.GATE_DISCIPLINE),
+ "rule": dict(rule="secrets/other/**"),
+ "op": dict(op="write"),
+ }.items():
+ with self.subTest(condition=name):
+ self.tearDown()
+ self.setUp()
+ self.rule_mint()
+ self.assertIsNone(self.consume(path=str(self.other), **override))
+
+ def test_forbidden_target_never_rides_a_rule_grant(self):
+ """The whole reason the widened path condition is safe.
+
+ A rule grant over `**/.c3/**` would otherwise cover
+ `override_grants.json` — the agent minting its own grants (spec §11
+ threat 3). find/consume refuse a forbidden target on EVERY call, not
+ just at mint, which is what makes the relaxation survivable.
+ """
+ c3_glob = "**/.c3/**"
+ self.write_config(
+ access={"deny": [c3_glob]},
+ override={"enabled": True, "layers": dict(ALL_LAYERS_ON),
+ "allow_rule_grants": True})
+ innocent = self.proj / ".c3" / "notes.txt"
+ innocent.write_text("n", encoding="utf-8")
+ og.mint(str(self.proj), session_id=SESSION, layer=opol.GATE_ACCESS,
+ rule=c3_glob, tool="Read", op="read", path=str(innocent),
+ scope=og.SCOPE_RULE)
+ for name in ("override_grants.json", "config.json", "secrets.enc",
+ "cred_state.json", "overrides.jsonl"):
+ with self.subTest(target=name):
+ self.assertIsNone(og.consume(
+ str(self.proj), session_id=SESSION,
+ layer=opol.GATE_ACCESS, rule=c3_glob, tool="Read",
+ op="read", path=str(self.proj / ".c3" / name)))
+ # …and the innocent sibling still works, so the refusal above is the
+ # forbidden-target rule and not a broken grant.
+ self.assertIsNotNone(og.consume(
+ str(self.proj), session_id=SESSION, layer=opol.GATE_ACCESS,
+ rule=c3_glob, tool="Read", op="read", path=str(innocent)))
+
+ def test_idle_window_expires_an_unused_grant(self):
+ self.rule_mint()
+ stored = json.loads(og.grants_path(self.proj).read_text(encoding="utf-8"))
+ stored["grants"][0]["last_used_at"] = og.iso(
+ og.now() - timedelta(seconds=stored["grants"][0]["idle_s"] + 1))
+ og.grants_path(self.proj).write_text(json.dumps(stored), encoding="utf-8")
+ self.assertIsNone(self.consume(path=str(self.other)))
+ self.assertEqual(og.active(str(self.proj)), [])
+
+ def test_using_it_resets_the_idle_window(self):
+ self.rule_mint()
+ before = og.active(str(self.proj))[0].get("last_used_at")
+ self.assertIsNone(before)
+ self.consume(path=str(self.other))
+ self.assertIsNotNone(og.active(str(self.proj))[0]["last_used_at"])
+
+ def test_unreadable_idle_window_is_expired_not_eternal(self):
+ self.rule_mint()
+ stored = json.loads(og.grants_path(self.proj).read_text(encoding="utf-8"))
+ stored["grants"][0]["idle_s"] = "forever"
+ og.grants_path(self.proj).write_text(json.dumps(stored), encoding="utf-8")
+ self.assertIsNone(self.consume(path=str(self.other)))
+
+ def test_ttl_uses_the_rule_ceiling_not_the_call_one(self):
+ grant = self.rule_mint(ttl_s=opol.HARD_MAX_RULE_TTL_S * 4)
+ life = (og.parse_ts(grant["expires_at"]) - og.now()).total_seconds()
+ self.assertGreater(life, opol.HARD_MAX_TTL_S)
+ self.assertLessEqual(life, opol.HARD_MAX_RULE_TTL_S)
+
+ def test_a_call_grant_still_gets_the_15_minute_ceiling(self):
+ grant = self.mint(ttl_s=opol.HARD_MAX_RULE_TTL_S)
+ life = (og.parse_ts(grant["expires_at"]) - og.now()).total_seconds()
+ self.assertLessEqual(life, opol.HARD_MAX_TTL_S)
+
+ def test_refused_when_the_policy_switch_is_off(self):
+ self.write_config(
+ access={"deny": ["secrets/**"]},
+ override={"enabled": True, "layers": dict(ALL_LAYERS_ON),
+ "allow_rule_grants": False})
+ with self.assertRaises(ValueError):
+ self.rule_mint()
+
+ def test_refused_for_a_synthetic_rule(self):
+ with self.assertRaises(ValueError):
+ self.rule_mint(layer=opol.GATE_DISCIPLINE,
+ rule=opol.RULE_DISCIPLINE, tool="Write", op="write")
+
+ def test_refused_when_the_rule_does_not_cover_the_refusal(self):
+ with self.assertRaises(ValueError):
+ self.rule_mint(rule="docs/**")
+
+ def test_unknown_scope_is_refused(self):
+ with self.assertRaises(ValueError):
+ self.mint(scope="everything")
+
+ def test_near_miss_does_not_fire_for_the_paths_it_covers(self):
+ self.rule_mint()
+ self.consume(path=str(self.other))
+ self.assertNotIn(og.EV_NEAR_MISS, self.audit_events())
+
+ def test_context_line_says_what_it_actually_covers(self):
+ self.rule_mint()
+ hit = self.consume(path=str(self.other))
+ line = og.granted_context(hit, "secrets/**")
+ self.assertIn("every path secrets/** matches", line)
+ self.assertNotIn("uses left", line)
+
+
class TestConcurrency(OverrideBase):
def test_one_use_survives_a_race(self):
"""Two hook subprocesses racing the last use: exactly one wins."""
diff --git a/tests/test_override_requests.py b/tests/test_override_requests.py
index 86f7f04..ec81002 100644
--- a/tests/test_override_requests.py
+++ b/tests/test_override_requests.py
@@ -306,6 +306,95 @@ def test_approval_is_audited_in_the_project(self):
self.assertIn(og.EV_APPROVED, events)
+# ── §4.1 the rule-scoped decision ──────────────────────────────────────────
+#
+# decide() is where a tap becomes a capability, so the gates that stand
+# between "approve" and "a standing exception" are asserted here rather than
+# only in the grant store. Both gates are independent on purpose: the switch
+# is the user's standing policy, the challenge is their answer right now, and
+# neither alone is enough.
+
+class TestRuleMode(RequestBase):
+ def test_refused_when_the_switch_is_off(self):
+ row = self.request()
+ with self.assertRaises(orq.OverrideError):
+ orq.decide(row["id"], "approve", mode=orq.MODE_RULE,
+ confirm="secrets/**")
+
+ def test_allowed_when_the_switch_is_on(self):
+ self.write_config(extra={"allow_rule_grants": True})
+ row = self.request()
+ decided = orq.decide(row["id"], "approve", mode=orq.MODE_RULE,
+ confirm="secrets/**")
+ self.assertEqual(decided["grant_mode"], orq.MODE_RULE)
+ self.assertEqual(decided["grant_scope"], og.SCOPE_RULE)
+ grant = og.active(str(self.proj))[0]
+ self.assertEqual(grant["scope"], og.SCOPE_RULE)
+ self.assertIsNone(grant["uses_remaining"])
+
+ def test_the_challenge_is_the_glob_not_the_word_session(self):
+ self.write_config(extra={"allow_rule_grants": True})
+ row = self.request()
+ with self.assertRaises(orq.OverrideError):
+ orq.decide(row["id"], "approve", mode=orq.MODE_RULE,
+ confirm=orq.CONFIRM_SESSION)
+
+ def test_one_tap_layers_still_need_the_glob_for_a_rule_grant(self):
+ """access_confirm is deliberately one-tap for `once` (§5). It is
+ deliberately NOT one-tap for `rule`: the thing being accepted is a
+ standing capability, and the keystrokes should say so."""
+ self.write_config(extra={"allow_rule_grants": True})
+ (self.proj / "infra").mkdir()
+ target = self.proj / "infra" / "main.tf"
+ target.write_text("x", encoding="utf-8")
+ (self.proj / ".c3" / "config.json").write_text(json.dumps({
+ "access": {"confirm": ["infra/**"]},
+ "override": {"enabled": True, "layers": dict(ALL_LAYERS_ON),
+ "allow_rule_grants": True}}), encoding="utf-8")
+ row = self.request(
+ path=str(target), tool="Edit", op="write",
+ denial=ag.check(str(target), "write", str(self.proj)))
+ self.assertEqual(row["rule_class"], opol.LAYER_ACCESS_CONFIRM)
+ orq.decide(row["id"], "approve") # `once` stays one-tap
+ row2 = self.request(
+ path=str(target), tool="Edit", op="write",
+ denial=ag.check(str(target), "write", str(self.proj)))
+ with self.assertRaises(orq.OverrideError):
+ orq.decide(row2["id"], "approve", mode=orq.MODE_RULE)
+
+ def test_a_synthetic_rule_cannot_become_a_rule_grant(self):
+ """The discipline and shell layers file under a rule TOKEN, not a
+ glob. There is no path set to widen along, so `rule` mode must refuse
+ rather than mint a grant that matches nothing (or, if the token were
+ ever compiled as a glob, everything)."""
+ self.write_config(extra={"allow_rule_grants": True})
+ row = self.request()
+ rows = orq.load()
+ rows[0]["rule"] = opol.RULE_DISCIPLINE
+ orq._save(rows)
+ with self.assertRaises(orq.OverrideError):
+ orq.decide(row["id"], "approve", mode=orq.MODE_RULE,
+ confirm=opol.RULE_DISCIPLINE)
+
+ def test_unknown_mode_is_refused(self):
+ row = self.request()
+ with self.assertRaises(orq.OverrideError):
+ orq.decide(row["id"], "approve", mode="forever",
+ confirm="secrets/**")
+
+ def test_the_grant_covers_a_sibling_the_request_never_named(self):
+ self.write_config(extra={"allow_rule_grants": True})
+ sibling = self.proj / "secrets" / "other.txt"
+ sibling.write_text("o", encoding="utf-8")
+ row = self.request()
+ orq.decide(row["id"], "approve", mode=orq.MODE_RULE,
+ confirm="secrets/**")
+ out = hag.run({"tool_name": "Read",
+ "tool_input": {"file_path": str(sibling)},
+ "session_id": SESSION}, project_path=self.proj)
+ self.assertIn(opol.TAG_GRANTED, out.get("additionalContext", ""))
+
+
# ── §6 the refusal contract ────────────────────────────────────────────────
class TestOfferLine(RequestBase):