Skip to content

feat(guard): rule-scoped approvals, per-project builtin modes, non-web logins (2.118.0) - #156

Merged
drknowhow merged 1 commit into
mainfrom
feat/access-guard-v3
Sep 4, 2026
Merged

drknowhow merged 1 commit into
mainfrom
feat/access-guard-v3

Conversation

@drknowhow

Copy link
Copy Markdown
Owner

Three gaps in the human-control layer of the guard system. Independent changes, one release.

1. One approval covered one file

mode='session' already existed end to end but was still bound to one exact
(session, layer, rule, tool, op, path_key) tuple, capped at 50 uses and 15 minutes —
and no surface ever sent it: the hub hardcoded "Approve once", c3 override approve
had no flag.

New grant scope="rule" (docs/override-requests.md §4.1). Relaxes exactly two of §4's
nine conditions:

  • tool → a declared op-class allowlist (TOOL_CLASSES). An unclassed tool is never
    widened; c3_artifacts and c3_project are deliberately unclassed.
  • path → the set the rule glob describes, evaluated by the same compiler against the
    same (canon, rel) pair that produced the denial. A rule grant covers exactly the
    files that rule would have blocked.

Replacing what it drops: unlimited uses, its own TTL ceiling (4h default / 8h hard — a
call grant keeps its 15 minutes), and an idle window. The idle window is load-bearing:
there is no session-END signal to hang a long grant on, so it dies once the conversation
stops using it.

What it still cannot reach. find/consume refuse forbidden_target() on every
call, not just at mint. Verified live through the hook: with a **/.c3/** rule grant
active, .c3/notes.txt → allow, and override_grants.json / config.json /
overrides.jsonl / cred_state.json / secrets.enc → deny, all five.

Minting needs override.allow_rule_grants (default off, AND-merged, counted as widening),
a globbable rule, a rule that covers the refusal, and the glob retyped on every layer —
including access_confirm, which stays one-tap for once.

Visible and killable: GET/DELETE /api/hub/grants, an Active grants strip in the
Access tab, and a context line that says "covers every path <rule> matches, this
session"
rather than a use count.

2. Builtin guards were adjustable globally only

One repo needing .env meant turning it off for every project on the machine. The stated
reason was "a project scope may only ever tighten" — but that threat is answered by the
second key, not the scope: the config half has never been able to change a builtin.

Both keys are now realm-bound — builtin_mode|proj|<normcased path>|<glob>, the same
realm-atomic construction as credential_store. A project mode replaces the global one for
that glob, loosening included; a cloned repo carries the config and changes nothing.

Verified on this repo: identical config in a sibling project → still denied. Deleting the
keyring attestation by hand, config untouched → immediately re-enforced.

Tier-0 and FORBIDDEN_TARGET_NAMES are unchanged at every scope, so
builtin_mode: {"**/.c3/**": "allow"} can never become self-granting.

Compatibility: a pre-2.118.0 C3 reading a project config with either key treats that
scope as corrupt ⇒ deny-all. Fail-closed, and observed live during verification.

3. login credentials were website-only

canonical_target takes a 19-scheme allowlist (https, ssh, sftp, rdp, smb, winrm, ldaps,
postgres, mysql, mongodb, redis, …); cleartext schemes are refused by name and told
which TLS variant to use. Adds private_key (PEM-checked, own size allowance) and
passphrase; one of password/private_key is required.

The origin-pinning property is preserved, not weakened. canonical_origin still reads
back — but only when the target is https — so a browser broker gets None for an
ssh:// entry and fails closed. Pre-existing entries need no migration.

Fixes found along the way

  • The request store could lose a row. ~/.c3/oracle/override_requests.json is one file
    for every project and session on the box; every mutator did an unsynchronised
    load→mutate→save, and the tmp name was a fixed .tmp. Now a single-row commit under the
    grants store's lock, per-process tmp.
  • rule_covers matched only the canonical path, so project-relative globs like
    secrets/** matched nothing. Caught by its own test before shipping.
  • c3 creds set --type login echoed the password to the terminal via plain input()
    since v2.90.0. Both browser UIs masked it; nothing asserted the CLI. The parity test now
    derives the must-mask set from _SCHEMAS.
  • The session benchmark measured symbol narrowing against a file with no symbols.
    _select_sample orders by size alone, so bug_investigation took whatever was largest —
    here, this release's own CHANGELOG. Reported −15.2%, a correct measurement of a workload
    nobody runs. Now picks the first sampled file that exposes symbols: −15.2% → +77.9%,
    symbol read 29,438 → 34 tokens.

Verification

ruff clean · 3655 passed, 6 skipped, 0 failed.

Live probes for all three (real hook, real hub HTTP routes, real keyring, real vault):
rule grant spans a sibling file and no other session; revoke and idle expiry both end it;
project builtin mode allows here and not in a sibling with identical config; an ssh://
login stores to the Fernet sidecar, refuses reveal, and yields None for
canonical_origin. All probe state restored.

Specs amended in the same commit: access-guard §1, confirm-guard §5/§7, override-requests
§3.1 and new §4.1.

https://claude.ai/code/session_01JjAQC4Uv3aBnv9oQLPMBMy

…b logins (2.118.0)

Three gaps in the human-control layer of the guard system.

Every approval covered ONE file. `mode='session'` existed end to end but was
still bound to one (session, layer, rule, tool, op, path_key) tuple, and no
surface ever sent it. New `scope="rule"` grants relax exactly two of the nine
conditions — tool, to a declared op-class allowlist; path, to the set the rule
glob describes, evaluated by the same compiler against the same (canon, rel)
pair that produced the denial. Unlimited uses, their own TTL ceiling, and an
idle window, because there is no session-END signal to hang a long grant on.
find/consume refuse forbidden_target on every call, so a `**/.c3/**` rule
grant covers .c3/notes.txt and never override_grants.json.

Builtin guards were adjustable globally only, so one repo needing .env meant
turning it off machine-wide. The reason given was "a project scope may only
tighten" — but that threat is answered by the second key, not the scope: the
config half has never been able to change a builtin. Both keys are now
realm-bound (builtin_mode|proj|<path>|<glob>), so a cloned repo carries the
config and changes nothing.

The `login` credential kind was website-only. `canonical_target` now takes a
19-scheme allowlist (cleartext refused by name) plus private_key/passphrase.
`canonical_origin` still reads back but ONLY for an https target, so a browser
broker fails closed on an ssh:// entry.

Also fixed: the request store could lose a row (unsynchronised
load-mutate-save over one machine-global file); rule_covers matched only the
canonical path, so project-relative globs matched nothing; `c3 creds set
--type login` echoed the password to the terminal via plain input() since
v2.90.0; and the session benchmark measured symbol narrowing against
whichever file was largest, which is not necessarily one with symbols.

Specs amended in the same commit: access-guard §1, confirm-guard §5/§7,
override-requests §3.1 and new §4.1.

Claude-Session: https://claude.ai/code/session_01JjAQC4Uv3aBnv9oQLPMBMy
@drknowhow
drknowhow merged commit ab5bae2 into main Sep 4, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant