Skip to content

feat(locks): let agents lease files, and let the user switch a builtin off - #56

Merged
drknowhow merged 3 commits into
mainfrom
feat/agent-locks-p3-p4
Jul 30, 2026
Merged

feat(locks): let agents lease files, and let the user switch a builtin off#56
drknowhow merged 3 commits into
mainfrom
feat/agent-locks-p3-p4

Conversation

@drknowhow

Copy link
Copy Markdown
Owner

Three things that belong together: the guard couldn't be relaxed even when you wanted it, agents had no way to say "I'm working on this file", and FleetDeck had no way to see what C3 knows.

1789 tests pass, 0 failures (70 new).

1. Access Guard — builtins become disableable, in two tiers

Tier 0 (absolute): secrets.enc, cred_state.json. Never disableable — they already have a dedicated guard and their own human-only escalation (agent_readable), so an opt-out would be nothing but a shorter route to the same secrets.

Tier 1 (disableable): **/.env*, **/.c3/**, **/.claude/settings*.json, **/.git/**.

Switching one off needs both a config entry and a keyring attestation — the construction credential_store.verify_agent_readable already uses. Either alone is inert.

The case this defends: an agent that manages to write config.json — precisely the move that would grant itself write access to ~/.claude/settings.json — still can't produce the attestation, so the builtin keeps enforcing. test_config_without_attestation_stays_enforced pins it.

Global scope only, because project scopes may only ever tighten. A project-scope entry is a loud config error, not a silent no-op, so the UI can never claim a builtin is off while evaluation enforces it.

c3 access builtin disable <glob> requires retyping the glob, and says plainly when the glob is the file that configures the agent itself.

2. Agent Locks — leases (spec Layer B)

Layer A stopped torn writes. It does nothing about two agents refactoring one module for ten minutes.

services/agent_locks.py adopts FleetDeck's semantics deliberately: (repo_id, relpath) keys, all-or-nothing acquisition over a sorted path list (so opposite-order requests can't deadlock), monotonic fencing (so force_release makes a returning holder stale by construction), and TTL as the real release mechanism — nothing assumes an agent remembers to release.

State is a file under an OS file lock in the target project, so this works with no daemon running. That's the property that made C3 rather than FleetDeck the owner (§11).

c3_edit checks for a holder then takes its own lease — after the policy guards, never before: an agent must never be told a file is busy when it was never allowed to write it. test_access_guard_denial_outranks_lease pins the ordering.

force_release is deliberately absent from the agent tool. It lives in c3 locks force-release, ledger-logged.

Building it corrected the spec twice — recorded in place, not patched over

  • The c3_project row was wrong. That proxy already builds a foreign runtime for the target (project.py:450), so the lock file was never at risk.
  • But chasing it found something worse. Lease identity came from a session id that's empty in plenty of real contexts — including that foreign runtime. Two different agents both resolving to "" counted as one session and stopped blocking each other. Silent under-blocking: the exact failure a lock exists to prevent. Identity now falls back to pid-<os.getpid()> and never to "".

3. FleetDeck integration — read-only, and provably so

fleetdeck/c3_locks.py renders C3's leases without owning them. test_module_never_writes greps the module for write calls, because a second owner is the bug the ownership split exists to avoid. A repo it can't read reports available: False rather than 0 locks — which would claim the repo is clear when we simply don't know.

(Lands on AgentSync's local-only fleetdeck branch, same as before.)

What's NOT done, and why

The Hub UI tab. It needs a new component plus wiring in sidebar.js/app.js and REST endpoints, and it deserves browser verification rather than being rushed into a security-adjacent PR. P4 is therefore half: the FleetDeck reader is in, the tab is not.

Phase 3 was gated on denial data and built without it, on your call. §14's open questions were answered by judgement, not evidence — implicit acquisition, leases don't block reads, no c3_impact wiring, file-level granularity. The spec now lists these explicitly as unvalidated so a later reader knows which choices to revisit once real data exists.

https://claude.ai/code/session_01Sbd9NbQfDcoJvvURD7KCVf

…n off

Three things that belong together: the guard could not be relaxed even when
the user wanted it, agents had no way to say "I am working on this file", and
FleetDeck had no way to see what C3 knows.

ACCESS GUARD -- builtins become disableable, in two tiers.

The credential vault (secrets.enc, cred_state.json) stays absolute: it already
has a dedicated guard and its own human-only escalation, so an opt-out there
would be nothing but a shorter route to the same secrets. The other four --
.env*, .c3/**, .claude/settings*.json, .git/** -- a human can switch off.

Switching one off needs BOTH a config entry and a keyring attestation, the
construction the credential vault already uses. Either alone is inert: config
without attestation fails closed, attestation without config is never read.
The point is the prompt-injection case -- an agent that manages to write
config.json, which is exactly the move that would grant itself write access to
~/.claude/settings.json, still cannot produce the attestation, so the builtin
keeps enforcing. Global scope only, because project scopes may only tighten; a
project-scope entry is a loud config error rather than a silent no-op, so the
UI can never claim a builtin is off while evaluation enforces it. `c3 access
builtin disable` asks the user to retype the glob, and says plainly when the
glob is the file that configures the agent itself.

AGENT LOCKS -- leases (docs/agent-locks.md Layer B).

Layer A stopped torn writes. It does nothing about two agents refactoring the
same module for ten minutes, which needs a lease held across many edits with a
declared intent. services/agent_locks.py implements FleetDeck's semantics
deliberately -- (repo_id, relpath) keys, all-or-nothing acquisition over a
sorted path list so opposite-order requests cannot deadlock, monotonic fencing
so force-release makes a returning holder stale by construction, and TTL as
the real release mechanism because agents forget to release. State is a file
under an OS file lock in the TARGET project, so this keeps working with no
daemon running -- the property that made C3 rather than FleetDeck its owner.

c3_edit checks for a holder and takes its own lease, after the policy guards,
never before: an agent must not be told a file is busy when it was never
allowed to write it. Acquisition is implicit by default -- explicit calls
produce better intent strings but agents forget them, and a lease nobody takes
protects nobody. force_release is absent from the agent tool and lives in
`c3 locks force-release`, ledger-logged.

Building it corrected the spec twice, recorded in place rather than patched
over. The c3_project row was wrong -- that proxy already builds a foreign
runtime for the target, so the lock file was never at risk. But chasing it
found something worse: lease identity came from a session id that is empty in
plenty of real contexts, and two DIFFERENT agents both resolving to "" counted
as one session and stopped blocking each other. Silent under-blocking, the
exact failure a lock exists to prevent. Identity now falls back to the pid and
never to "".

FLEETDECK INTEGRATION -- read-only, and provably so.

fleetdeck/c3_locks.py renders C3's leases in the deck without owning them; a
test asserts the module contains no write calls at all, because a second owner
is the bug the ownership split exists to avoid. A repo it cannot read reports
available=False rather than "0 locks", which would claim the repo is clear
when we simply do not know.

Phase 3 was gated on denial data and was built without it, on Dimitri's call.
The questions that data would have answered are listed in the spec as decided
by judgement, so a later reader knows which choices are unvalidated. The Hub
UI tab is not built.

1789 tests pass (70 new).

Claude-Session: https://claude.ai/code/session_01Sbd9NbQfDcoJvvURD7KCVf
normalize_relpath came from fleetdeck/paths.py, which assumes a Windows host: only drive-letter and /mnt forms counted as absolute. C3 ships cross-platform, so on Linux and macOS every absolute path fell through to the repo-relative branch. A lease taken as 'services/router.py' was looked up as 'tmp/xyz/services/router.py' and the gate found nothing.

Same class as the Layer A sidecar bug two phases ago: a key computable two ways is not a key. Both times this Windows box hid it and CI on the other platforms caught it.

A POSIX absolute path now resolves against the root when it lives under it. A leading-slash path that does not is still read as repo-relative, because '/src/api.py' is how agents write relpaths rather than a failed absolute path.

The regression test uses a real temp root and asserts the absolute and relative spellings of one file agree. A literal '/tmp/x' assertion proves nothing on Windows, where os.path.abspath rewrites it to the current drive -- my first attempt at this test made exactly that mistake. Also added a guard that the store side and gate side compute the same key, because when they diverge every gate test passes vacuously (None reads as 'not held').

Ruff import ordering in edit.py fixed.

Claude-Session: https://claude.ai/code/session_01Sbd9NbQfDcoJvvURD7KCVf
Third instance of one bug: a key computable two ways is not a key. Windows CI home is the 8.3 alias RUNNER~1. handle_edit calls Path.resolve() before locking, expanding it to runneradmin, while project_path keeps the alias -- so normalize_relpath decided the file was outside its own repo, raised outside_repo, and the lease was silently never taken. Leases were inert on Windows.

Root cause was inherited: FleetDeck normalizes purely lexically, which is correct for a Windows-only daemon that never sees a resolved path. C3 does see one. canonical_root and normalize_relpath now put both sides through realpath, which also collapses the macOS /var to /private/var split. realpath is non-strict, so create-mode paths that do not exist yet still normalize -- the property the lexical rule protected is kept.

Reproduced the CI condition locally with GetShortPathNameW rather than reasoning about it: pre-fix the two spellings disagree (outside_repo), post-fix they agree. test_short_name_root_agrees pins it and skips where 8.3 generation is off, because a silent pass there would be the same trap. Added a POSIX symlink case for the /var split and a resolved-vs-unresolved assertion that is trivially true on a dev box and load-bearing on CI.

1794 pass, ruff clean.

Claude-Session: https://claude.ai/code/session_01Sbd9NbQfDcoJvvURD7KCVf
@drknowhow
drknowhow merged commit 263a926 into main Jul 30, 2026
11 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