Capability Is Not Authority
Part 4 β Make It Safe and Verifiable
A sensible correction, refused
Constructed scene. A reviewer finds an incorrect cache setting and proposes the right replacement. Its worker can edit files. The person who opened the review asked for inspection only.
The correction’s quality is beside the point. An accurate proposal grants no permission to apply it. The presence of an editing tool grants none either.
Chapter 19 separated the actor’s report from evidence about the effect. Now move back to the moment before that effect: the process is about to call a worker that can change something. Which part of the system decides whether it may?
Who is allowed to authorize an available operation?
The answer must be more specific than βthe human.β It needs an operation, a grant, an entry point that checks the grant, and a stated trust boundary around whoever supplies it.
Can, may, may accept
The distinction is can β may β may accept.
| Question | What would answer it? | What it leaves open |
|---|---|---|
| Can this worker edit the file? | An available implementation with access to the file | Permission for this request |
| May this request cause a write? | A grant checked before the execution boundary | Whether the write is correct |
| May this actor accept the result? | A separate acceptance grant and completion contract | Whether the actor’s identity is authenticated |
An instruction such as βdo not writeβ can guide a model. Refusing to invoke the worker is a different mechanism. The first depends on how the instruction affects generation; the second is a branch in the surrounding program.
Greshake and colleagues demonstrated indirect prompt injection through data retrieved by LLM-integrated applications. Their attacks exploit the blurred boundary between data and instructions and can redirect application behavior. That motivates checking permission outside generated text. Showing that CodeAI’s policy layer defeats those attacks remains the book’s design argument, not the paper’s result. Greshake et al., 2023
Saltzer and Schroeder’s principles sharpen the question. Least privilege limits grants to what the job needs. Complete mediation asks that access be checked throughout the system, including recovery paths; remembered authorization decisions need scrutiny when authority changes. These are design principles, not a certification of this implementation. Their application to CodeAI’s execution and reuse paths is the book’s mapping. Saltzer and Schroeder, 1975
There is also a vocabulary trap. In their technical terminology, a capability is an unforgeable authorization ticket for an object. This chapter uses βcanβ for technical ability. An ordinary enum value must never be mistaken for such a ticket. Saltzer and Schroeder, 1975
The grant is a set
CodeAI’s Capability enum names READ, WRITE, EXECUTE, VERSION_CONTROL, REMOTE, DESTRUCTIVE, and ACCEPT. NETWORK and VCS are absent from the members. Authority holds a set of these values; allows checks membership. WRITE stays separate from EXECUTE, and neither carries ACCEPT with it. 1
Constructed example using the actual API. These assertions illustrate membership; they are no evidence from the preserved authority stage. 2
from codeai.domain import Authority, Capability
review = Authority(frozenset({Capability.READ}))
edit = Authority(frozenset({Capability.WRITE}))
assert review.allows(Capability.READ)
assert not review.allows(Capability.WRITE)
assert not edit.allows(Capability.EXECUTE)
assert not edit.allows(Capability.ACCEPT)
This is deliberately smaller than a permissions system for a whole operating environment. The set says which category of operation is granted. File path, repository identity, network destination, expiry, and authenticated principal all live outside it. 3
A sensible application might give a reviewer READ, an editor WRITE, and a separate reviewer ACCEPT. Those are constructed role choices. An actor’s name, a tool’s installation, or the persuasive quality of its proposed change produces none of them automatically.
PolicyEngine.require is the small check that turns membership into refusal. If the requested capability is absent, it raises AuthorityDenied with a message naming the capability. No model is consulted. 4
Budget is another dimension. check_budget compares usage with declared limits and raises BudgetExceeded when a limit is exceeded. Permission to write and permission to spend remain distinct questions; the budget helper exists whether or not every action path invokes it. 5
Follow a new request to the worker
The source-inspected execute_action first appends action.requested, then looks for a prior result by idempotency key. On the new-execution branch it converts the capability and calls policy.require before the precondition check and adapter.execute; on an authority refusal it constructs a DENIED result and appends action.completed. The replay branch has its own authority check, discussed at the end of the chapter. 6
Constructed example using actual APIs. This counter makes invocation visible without a model or subprocess. The two requests use different keys so the example reaches the new-action policy check. It is teaching code, not a pinned stage run. 6
from dataclasses import replace
from codeai.adapters import ActionRequest, ActionResult, ActionStatus
from codeai.domain import Authority, Capability
from codeai.ledger import SQLiteLedger
from codeai.runtime import Runtime
class CountingWorker:
def __init__(self):
self.calls = 0
def execute(self, request):
self.calls += 1
return ActionResult(request.action_id, ActionStatus.SUCCEEDED)
runtime = Runtime(SQLiteLedger())
worker = CountingWorker()
request = ActionRequest(
action_id="review-edit", task_id="review", capability="write",
instruction="Apply the proposed correction.", precondition_hash=None,
idempotency_key="review-edit", requested_by="reviewer",
actor_id="worker", adapter="counting-worker",
)
denied = runtime.execute_action(
request, authority=Authority(frozenset({Capability.READ})), adapter=worker,
)
assert denied.status == ActionStatus.DENIED
assert worker.calls == 0
allowed = runtime.execute_action(
replace(request, action_id="approved-edit", idempotency_key="approved-edit"),
authority=Authority(frozenset({Capability.WRITE})), adapter=worker,
)
assert allowed.status == ActionStatus.SUCCEEDED
assert worker.calls == 1
The counter establishes only invocation within this constructed program. The worker edits no file. Chapter 19’s independent reading is still needed to establish a resulting file state.
Notice what crosses the API: the caller supplies authority as an argument. execute_action derives that argument from neither the recorded directive nor an authenticated requester. The supplied authority set is also missing from action.requested; that event serializes the request, which lacks an authority field. A successful denial leaves those boundary limits in place. 6
The precise claim is therefore: the new-action branch checks the supplied grant before invoking its adapter. It is not yet βevery action is authorized by its recorded directive.β 6
The two gates side by side β permission at the moment of action, narrowing at the moment of delegation:
flowchart TD
OP["operation available<br/><i>can: the worker exists</i>"] --> G{"grant checked<br/><i>may: policy gate<br/>before any effect</i>"}
G -->|"permit"| AD["adapter invoked"]
G -->|"deny"| DN["DENIED recorded<br/><i>adapter never runs</i>"]
P["recorded parent grant<br/><i>rebuilt from the ledger,<br/>not caller assertions</i>"] --> C{"child narrows parent?<br/><i>capabilities β Β· budget β€</i>"}
C -->|"yes"| REG["child registered<br/><i>causation linked</i>"]
C -->|"no"| REF["refused before append<br/><i>control flow only</i>"]
Delegation must not enlarge the grant
A review may be split into smaller jobs. The child needs some subset of the parent’s permissions. Passing a task to another worker is no occasion to manufacture a permission that the parent lacked.
Authority.narrows(parent) is a subset test. Equality counts as narrowing here: it means βdoes not expand,β not βmust remove at least one capability.β Directive.validate_child checks both authority and budget narrowing. A finite parent budget cannot become an unlimited child budget under Budget.narrows. 7
Constructed example using actual APIs. The numbers are chosen fixture limits, not measured usage. This fragment shows the existing validation helper, not registration in the ledger. 8
from codeai.domain import Authority, Budget, Capability, Directive
parent = Directive(
directive_id="review-parent", objective="Review the change",
success_criteria=(), budget=Budget(max_tokens=1000),
authority=Authority(frozenset({Capability.READ, Capability.WRITE})),
)
child = Directive(
directive_id="review-child", parent_directive_id="review-parent",
objective="Inspect only", success_criteria=(),
budget=Budget(max_tokens=500),
authority=Authority(frozenset({Capability.READ})),
)
parent.validate_child(child)
But the helper’s existence was not enough. At baseline 7a0d43b, Runtime.open_directive appended a directive without calling validate_child, even when it declared a parent. The runtime registration path left the narrowing rule unenforced. 9
The authority demo calls parent.validate_child(child) explicitly before using the child’s grant. It exercises the helper and a subsequent action denial. Registration itself performing validation is something it leaves undemonstrated. 10
This is the named gap: child validation existed outside the registration path.
Connect the check to registration
The Chapter 20 revision, now in CodeAI a1b562a, changes open_directive for directives that declare parent_directive_id. It reads the recorded parent, reconstructs its budget and authority, and calls parent.validate_child before appending the child. The child event’s causation_id names the parent event used for validation. 9
Grant provenance comes from the ledger. The caller supplies the child and its parent ID, not an alternative parent object for validation. The runtime reconstructs the parent from its recorded event. 9
The revision requires an unambiguous recorded parent. Missing or multiple matching parent records, self-parenting, and an already-registered child ID raise before a child event is appended. A narrowing failure also raises before append. Refusals on this registration path are exceptions; durable refusal events for them exist nowhere yet. 9
Refused in control flow β refusal durably recorded. Here an invalid child produces no child registration; mistaking that absent registration for a durable account of why an attempt was refused would be an error. 9
Constructed continuation using the actual revised API. Using the parent and child from the preceding example, registration now performs the check itself. This is an implementation example, not a result borrowed from the older demo. 9
from codeai.ledger import SQLiteLedger
from codeai.runtime import Runtime
runtime = Runtime(SQLiteLedger())
parent_event = runtime.open_directive(parent)
child_event = runtime.open_directive(child)
assert child_event.causation_id == parent_event.event_id
This is a small connection, not a complete delegation system. A caller can still submit a root directive without a parent, and root registration authenticates no grantor. create_task enforces no task-authority narrowing, and action execution still receives its authority separately. Direct ledger writes remain outside these entry-point checks. 11
The registration change therefore answers one specific question: does a declared child opened through this runtime narrow its recorded parent? The pinned Stage 20 run below validates the revised registration across seven cases with an independent ledger-based verifier.
What the preserved demo says
The existing authority demonstration used a counting adapter that appended markers to a temporary file. It used fresh idempotency keys, no model calls, and an in-memory ledger. Its preserved output is a summary in results.json, not a durable exported action history with the target bytes attached. 10
| Case | Recorded result | Scope of observation |
|---|---|---|
| WRITE under WRITE | succeeded, 1 adapter call, file changed |
First allowed invocation |
| WRITE under READ | denied, 0 new invocations, file unchanged |
Cumulative adapter count remains 1 |
| Narrowed child | Helper accepted; WRITE denied; 0 new invocations | Parent READ/WRITE, child READ |
| Widened child | Helper raised authority-narrowing error | Explicit helper call, not registration |
| EXECUTE under WRITE | denied, 0 new invocations |
Different capability is not implicitly granted |
| Fresh permitted action | succeeded, cumulative adapter count 2 |
A fresh key reaches the execution path |
| ACCEPT separation | WRITE cannot require ACCEPT; ACCEPT can | Policy-level check, not full task acceptance |
These are the values and distinctions in the preserved summary. In particular, the denied case’s adapter_calls: 1 is cumulative; its new_invocations: 0 is the relevant denial measurement. Mixing those columns would turn a successful refusal into an apparent violation. 10
The exact refusal message for the denied WRITE is capability denied: write. The widened child reports child directive authority must narrow the parent authority. Neither result depends on whether the proposed edit was useful. 10
A measured effect, and a separate acceptance
Stage 29B includes a commit-pinned authority control. Under READ, the resolved answer’s WRITE effect was denied with 0 apply-adapter calls, no file written, and a person-ask reason of authority_missing:write. Under WRITE, the effect ran once and wrote a configuration containing ttl_seconds = 300. The control files retain authority, invocation counts, and resulting target state. 12
This is a bounded control from another stage. It tests neither the Chapter 20 registration revision; that test is the pinned Stage 20 run below. Neither establishes comprehensive authority enforcement.
Acceptance is a different boundary. In the Stage 14 negative cases, an acceptor holding every capability except ACCEPT was refused for unauthorized, and the producing actor’s self-acceptance was refused for self_acceptance. Those are acceptance results from that pinned run, not extra cases invented for the authority demo. 13
By current source inspection, accept_task checks ACCEPT before its prior-acceptance lookup. Its source validation compares the acceptor label with the producing actor label. That comparison separates named roles, not authenticated people. 14
An edit can therefore be authorized without being accepted as finished work. Whether the result meets an adequate check is Chapter 21’s question; this chapter establishes why permission to produce carries no silent permission to accept.
Checking it without trusting it
The authority demo’s verifier imports no CodeAI code for its predicates. It reads the summary fields, checks denied-path invocation counts, the denied-file unchanged flag, narrowing flags, and ACCEPT separation. Its seeded corruption changes the denied case’s new invocation count to one and is designed to be rejected. 10
Independence of the predicate is useful, but its input is still the producer’s summary. An independently preserved target file goes unexamined, and permission is never re-derived from exported request and grant records. The allowed case’s file_changed flag and the fresh-action row go unchecked as well. Its coverage is narrower than its introductory description. 10
The pinned grant-provenance run
The Stage 20 bundle in experiments/applied-ai/evidence/grant-provenance/2026-09-14-a1b562a/ ran under a protocol frozen before execution. Two durable parents were recorded (READ+WRITE with a 1000-token budget; READ-only with 500), and seven cases ran against them on one ledger, with a second Runtime reopening the same file for the reconstruction case. A stdlib-only verifier recomputes from the ledger and the external invocation receipts.
| Case | Outcome |
|---|---|
| Valid narrowing (READ child under READ+WRITE parent) | registered, with causation to the parent event |
| Capability widening (EXECUTE under READ+WRITE) | refused at registration; no event appended |
| Budget expansion (2000 tokens under 1000) | refused at registration; no event appended |
| Missing parent | refused at registration; no event appended |
| Caller-forged broader parent (WRITE child under the recorded READ-only parent) | refused: the recorded parent governs, not the caller’s broader story |
| Unauthorized action (WRITE under READ authority) | DENIED as a durable action.completed, 0 adapter invocations |
| Reopen reconstruction (grandchild under the recorded child) | registered with causation intact; a denial after reopen stays denied with 0 invocations |
The forged-parent case is the load-bearing one for this chapter’s question: a WRITE child that would be valid under a broader imagined parent is refused because the recorded parent holds READ. Registration reads the parent from the recorded directive.opened payload; the caller supplies only the child and a parent ID.
Seeded corruptions mark where enforcement ends as well as where it holds: a deleted durable denial, a forged registration for the widened child, a rewritten grandchild causation link, and an inflated denial receipt are each rejected with the failure named. The retained limitation is stated plainly: a refused widening raises before appending, so the refusal itself lives only in control flow β control-flow refusal is not durable refusal evidence β while action denials, by contrast, are durable completions.
The replay path, then and now
At baseline 7a0d43b, a reused action result was returned before PolicyEngine.require ran. The adapter was not invoked again, but the current request’s authority went unchecked either, so “every request reaches the policy check” was false for that path. The authority demo’s own method note records the ordering; its fresh keys avoided the path rather than testing it. 10
Current CodeAI checks authority first on the replay path as well. A request that reuses a key under a grant lacking the capability receives its own DENIED completion before anything recorded is consulted, and learns nothing about the recorded operation. Chapter 22 builds that repair together with the operation-identity check beside it. Here it matters for one reason: complete mediation has to cover the path that returns old results, not only the path that creates new ones. 15
The same care applies to a changed decision basis. One proposed seam would require an action that names a Stage 18 decision to check its current standing before execution. A basis_changed result could then trigger refusal or renewed approval under a declared policy. That is a proposed rule; capability membership alone leaves it unimplemented, and it was deliberately kept out of the pinned run above. It remains future construction, not a result.
Such a test would have to distinguish intact, changed, missing, and unnamed decisions, record whether the adapter ran, and retain the historical decision and its basis. Consent must never be inferred from the mere presence of a decision ID in a payload.
What this is not
- Containment is missing. The action entry point runs the adapter with no operating-system sandbox around it. 6
- Authentication is missing. Requester and actor fields arrive as caller-supplied strings. 16
- A tamper-evident ledger is missing. The event definition and append path sign no records. 17
- Prompt-injection protection is unclaimed. The cited attacks motivate the boundary; no attack-resistance experiment is claimed here, and correctness verdicts belong to the next chapter, since permission plus an observed effect still leaves verification open.
Where it is still weak
- The caller supplies the grant. Action execution does not resolve and enforce the recorded directive’s authority. 6
- Declared children are only one entry point. Root registration and task creation still leave other ways to express broader work. 18
- Action authorization lacks a complete durable basis. The request event does not capture the authority argument. 6
- Replay authority is newer than its evidence. The replay path now checks current authority, shown by source inspection and regression tests but by no pinned stage. 15
- New registration refusals are not recorded. They raise before append; a later reader needs external evidence to reconstruct the attempt. 9
- The pinned registration stage has run. What remains: registration refusals still raise without a durable refusal event, and decision-basis gating of actions is unbuilt future work.
Do this now
Thirty minutes. Find the last branch before one effect.
- Choose a disposable file action. Write down the capability it requests and where its authority comes from. Do not infer either from the instruction text.
- Supply a grant that lacks the capability. Count adapter invocations and read the file independently. Preserve the request, supplied grant, status, counter, and bytes.
- Delegate to a child with fewer permissions, then attempt a wider child. Trace the actual registration path; do not stop at finding a validation function.
- Separate authority to edit from authority to accept. List the remaining trust assumptions: caller, actor identity, replay, file scope, and ledger access.
If you are building with an assistant:
Make authorization visible at a named application boundary.
Use explicit grants, and check new actions before invoking their adapters.
Keep technical ability, authority to act, and authority to accept separate.
Connect declared-child registration to validation against the recorded parent.
Preserve which parent was used; refuse ambiguous parentage.
Test with counters and independently read bytes, using fresh keys for new
execution and a separate replay control. Preserve failures and missing records.
Do not claim authentication, sandboxing, or complete mediation from a set
membership check. Report every path that still trusts a caller-supplied grant.
Failure modes
- Letting a good proposal authorize itself. Evidence for a correction is not permission to apply it.
- Treating an enum as a protected ticket. A name for an operation is not an authenticated grant.
- Finding a validator and stopping the audit. The path that records the child must actually call it.
- Counting cumulative calls as new calls. A denied request can follow an earlier permitted effect.
- Giving the editor acceptance by default. Producing and accepting are separate responsibilities.
- Auditing only the new-action path. A check that guards creation but not replay is not complete mediation.
What this chapter established
- Can, may, and may accept are different questions; a prompt instruction is not an execution gate.
- The preserved authority demo shows denied new actions with no new adapter invocations, within its declared fixture. 10
- The measured Stage 29B control pairs a denial with no write and a grant with an observed write. 12
- The Chapter 20 revision connects declared-child registration to recorded-parent validation, and the pinned grant-provenance run in
experiments/applied-ai/evidence/grant-provenance/2026-09-14-a1b562a/validates it across narrowing, widening, budget, parentage, forgery, denial, and reopen cases with an independent verifier that rejects four seeded corruptions. 9 - Caller-supplied grants, unauthenticated labels, and missing containment keep the authority claim bounded. The replay path skipped the check at
7a0d43band checks current authority now. 19
Next
The split matters just as much when the thing being changed is your own tooling. An assistant can propose how your software should adapt; applying that change is an authorization, not a capability, and Chapter 30 keeps it in your hands.
The process may now have permission to invoke the worker. The worker may have changed the file. Neither answers whether the resulting state satisfies the property the task actually cares about.
Continue with The Agent Cannot Grade Its Own Homework.
References
- Jerome H. Saltzer and Michael D. Schroeder. The Protection of Information in Computer Systems. Proceedings of the IEEE 63(9):1278β1308, 1975. Paper and glossary, basic principles.
- Kai Greshake, Sahar Abdelnabi, Shailesh Mishra, Christoph Endres, Thorsten Holz, and Mario Fritz. Not what you’ve signed up for: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection. arXiv:2302.12173, 2023. Paper.
Implementation sources: CodeAI baseline 7a0d43b for the historical demo; a1b562a, containing the Chapter 19 observation, Chapter 20 child-registration, and Chapter 22 replay-authority revisions, for current source. src/codeai/domain.py: Capability, Authority, Budget, Directive.validate_child; src/codeai/policy.py: PolicyEngine.require, check_budget, AuthorityDenied; src/codeai/runtime.py: open_directive, create_task, execute_action; src/codeai/acceptance.py: accept_task, _validate_source; src/codeai/adapters.py: ActionRequest; src/codeai/ledger.py: Event, SQLiteLedger.append. Tests: tests/test_directive_registration.py, tests/test_policy.py, tests/test_runtime.py, tests/test_task_acceptance.py, and Chapter 19’s tests/test_action_observation.py. Book-repository evidence: experiments/applied-ai/evidence/authority/ (README, producer, results, verifier), experiments/applied-ai/evidence/execution-ladder/2026-09-14-7a0d43b/authority/, and experiments/applied-ai/evidence/task-completion/. Historical evidence does not establish the later working-tree revision.
-
Read in
src/codeai/domain.pyβCapability, Authority. ↩︎ -
Read in
src/codeai/domain.pyβAuthority.allows. ↩︎ -
src/codeai/domain.pydefinesAuthorityas a plain set wrapper. ↩︎ -
The gate lives in
src/codeai/policy.pyβPolicyEngine.require. ↩︎ -
Budget logic in
src/codeai/policy.pyβPolicyEngine.check_budget. ↩︎ -
Traced in
src/codeai/runtime.pyβRuntime.execute_action. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ -
Narrowing helpers in
src/codeai/domain.pyβAuthority.narrows, Directive.validate_child, Budget.narrows. ↩︎ -
Directive.validate_childinsrc/codeai/domain.py. ↩︎ -
Registration path in
src/codeai/runtime.pyβRuntime.open_directive. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ -
Unpinned demonstration:
experiments/applied-ai/evidence/authority. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ -
Entry points in
src/codeai/runtime.pyβRuntime.open_directive, Runtime.create_task, Runtime.execute_action. ↩︎ -
Measured run:
experiments/applied-ai/evidence/execution-ladder/2026-09-14-7a0d43b. ↩︎ ↩︎ -
Measured run:
experiments/applied-ai/evidence/task-completion. ↩︎ -
Acceptance check in
src/codeai/acceptance.pyβaccept_task, _validate_source. ↩︎ -
Replay logic in
src/codeai/runtime.pyβRuntime._replay_action_result. ↩︎ ↩︎ -
Request shape in
src/codeai/adapters.pyβActionRequest. ↩︎ -
Append path in
src/codeai/ledger.pyβEvent, SQLiteLedger.append. ↩︎ -
Two entry points in
src/codeai/runtime.pyβRuntime.open_directive, Runtime.create_task. ↩︎ -
Both action paths in
src/codeai/runtime.pyβRuntime.execute_action, Runtime._replay_action_result. ↩︎