Chio/Docs
LOGIN · JOIN

Least privilege

Grant a task only the tools, resources, duration, delegation depth, and use limits it needs to finish.

Concept chio@5f63e6e

On this page

An operator can limit what a mistaken or hijacked agent reaches to what its task needed. The grant names the work, the kernel checks each call it mediates against that grant, and anything else is refused before it touches a resource.

The running example is the reviewer on the pricing project: it reads the discount rules for issue #417, returns its findings, and stops. Its grant names the tool, the resource, the lifetime, the delegation depth, and the use count. Adding write access, a pull-request comment, or onward delegation to make setup easier would change the task's authority.

Task grants over time compared with a standing credential, with injected requests refused at scopestanding credentialtask grantsread the diffinspect the teststrace callersread_file src/read_file tests/search src/read_file .envshell commandS1S2S3PR #421 fixtureoutside every live grant0102030405060tfamily count c(t)M = 12
t=25t = 25
Astanding(t)=RA_{\mathrm{standing}}(t) = R
L(t)={ g:iatg≤t<expg }L(t) = \{\, g : \mathrm{iat}_g \le t < \mathrm{exp}_g \,\}
A(t)=⋃g∈L(t)SgA(t) = \textstyle\bigcup_{g \in L(t)} \htmlClass{cor-parent}{S_{g}}
r∉A(t)⇒refused at scope\htmlClass{role-call}{r} \notin A(t) \Rightarrow \htmlClass{role-deny}{\text{refused at scope}}
A(25)=S2A(25) = \htmlClass{cor-parent}{S_{2}}
r25∉A(25)⇒refused\htmlClass{role-deny}{r_{25} \notin A(25) \Rightarrow \text{refused}}
c(25)=6≤M\htmlClass{role-call}{c}(25) = 6 \le \htmlClass{cor-parent}{M}
Task grants over time compared with a standing credential, with injected requests refused at scopestanding credentialread the diffinspect the teststrace callersread_file src/read_file tests/search src/read_file .envshell commandS1S2S3PR #421 fixtureoutside every live grant0102030405060tfamily count c(t)M = 12
t=25t = 25
A(25)=S2,r25∉A(25)⇒refusedA(25) = \htmlClass{cor-parent}{S_{2}},\quad \htmlClass{role-deny}{r_{25} \notin A(25) \Rightarrow \text{refused}}

Minute 25: the grant for “inspect the tests” is live, so a request can reach read_file tests/ only. A standing credential would reach all 5 rows. The family has used 6 of 12 invocations. The injected request read_file .env falls outside every live grant and is refused at the scope check.

Astanding(t)=RL(t)={ g:iatg≤t<expg }A(t)=⋃g∈L(t)Sgr∉A(t)⇒refused at scopec(t)≤M\small \begin{aligned} &A_{\mathrm{standing}}(t) = R \\ &L(t) = \{\, g : \mathrm{iat}_g \le t < \mathrm{exp}_g \,\} \\ &A(t) = \textstyle\bigcup_{g \in L(t)} \htmlClass{cor-parent}{S_{g}} \\[6pt] &\htmlClass{role-call}{r} \notin A(t) \Rightarrow \htmlClass{role-deny}{\text{refused at scope}} \\ &\htmlClass{role-call}{c}(t) \le \htmlClass{cor-parent}{M} \end{aligned}
Minutes, paths, and counts for the tester in the pull request #421 review: each window is shorter than the Authority example’s thirty-minute cap, and this family allows 12 invocations where that example’s allows three. Each task grant admits a request only inside its scope and signed window; guards still evaluate the calls it admits.Task-Scoped Authority

The grant is written from the task's successful path

Describe the intended work in resource terms before editing a policy. The dependency upgrade in issue #415 reads pyproject.toml and the lockfile, runs the 14 pricing tests through shell/run_command, and writes its findings into the working copy; publishing a package, reading /secrets, and running an arbitrary shell command sit outside it. Name the server, tool, operation, resource constraints, recipient, and time window, then make delegation and repetition explicit.

In the Secure an agent tutorial, the smallest useful grant names one workspace tool for five minutes. This capability section can replace the broader tutorial grant once you have observed its guard refusal:

security-agent/policy-read-only.yamlyaml
kernel:
  max_capability_ttl: 900

capabilities:
  default:
    tools:
      - server: workspace
        tool: read_file
        operations: [invoke]
        ttl: 300

Under this policy write_file is outside capability scope. The recipient and the end of the task belong in the grant too: a grant for the named reviewer differs from a token any authenticated agent may present, and a grant whose lifetime covers the review differs from a reusable service credential. Choose the lifetime from the expected workflow, approval waits included, and decide what happens when it expires before the work completes.

Each permission covers one effect you can constrain

A broad permission can hide inside a narrow-looking tool. One shell/run_command tool permits an enormous range of effects through its arguments, while a read-only workspace/read_file that binds the pricing project's root and rejects paths outside it offers a much smaller contract. Work backward from the output: the review needs particular source reads, and posting it to the pull request is another effect with another destination. The rows are an application design; map each row to a tool, resource constraint, or deployment control in your integration.

Task boundaryReview-only choiceA separate authority decision
Repository contentRead /workspace/srcRead /secrets or a home directory
Local changesWrite the proposed fix in the working copyReplace configuration used by the host
External communicationReturn the findings to the fixerComment on PR #421 or open issue #419
DelegationGive a child the analysis subsetGive the child the parent's posting grant
RepetitionShare the review's finite allowanceStart another independently authorized review

Keeping the read and the post apart lets the review proceed without a posting route, and it gives approval a meaning: your pricing lead approves the reviewed content after the investigation ends. A refused row then has an obvious explanation.

Repeated denials signal either an attack or a missing requirement, so record the action the task needed before changing scope. When a task routinely needs an exception, revise its specification and grant the exception deliberately; a wildcard broadened after each failure leaves a policy that records troubleshooting history in place of the intended application.

Every dimension holds on its own

Permission is a conjunction. An operation must match the route and resource scope, fall inside its time window, belong to the intended principal, and satisfy its invocation and monetary limits; a smaller value on one dimension compensates for nothing on another. A token that lives for one minute can still authorize the wrong destination during that minute, and a short-lived but unrestricted token permits broad damage while it lasts.

A delegated grant is a subset of its parent in every dimension: scope, lifetime, budget share, and proof requirements. The kernel refuses a child token that widens any of them, and Delegation boundaries lists each rule and the refusal it produces. An aggregate invocation budget bounds how often a capability's authority may be used, and a delegation-family budget lets many child agents share one task allowance instead of each drawing an independent pool:

crates/core/chio-core-types/src/capability/aggregate_invocation.rs40–45rust
pub struct AggregateInvocationBudget {
    pub scope: AggregateInvocationScope,
    pub max_invocations: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub root_binding: Option<AggregateBudgetRootBinding>,
}

The tool's implementation is a separate boundary from its route. A workspace/read_file grant is useful only if the selected server implements that operation under the intended resource boundary; a compromised server may try to open unrelated files or use inherited sockets, and confinement and host configuration govern those paths. Data release is separate again, and each task pairs its narrow authority with another boundary:

  • Read one workspace. Specific read tools and resource scope, with filesystem confinement and output labels.
  • Review a fix that used customer terms. An attenuated child scope and a shared use budget, with principal and lineage information flow.
  • Post an approved summary. The exact destination and a time window, with declassification and broker credential use.
  • Contain a session. A bounded response target and duration, with response approval and reversible effects.

A request can satisfy capability scope and still fail an egress clearance check. A permitted filesystem operation must also fit the cage's operator ceiling and retained resource grants when it runs as a confined process.

Read more in Invocation limits, which covers the family-budget rules.

The Authority example's reviewer reads only its own tree

The Authority application implements this task with a parent and two child grants. The reviewer reads src/discount/ and the tester reads src/refund/, both through the registered workspace/read_file connector, under a signed family ceiling of three invocations:

The pricing project, by granttext
repository/src/
├── pricing.py
├── discount/rules.py      reviewer
├── refund/rules.py        tester
└── discount-other/        outside reviewer

Parent grant: 6; invoke + delegate
Each child:   2; invoke
Family:       3 shared invocations

The issuer narrows four independent dimensions. It removes onward delegation, adds the child's exact directory prefix, reduces the per-grant allowance to two, and caps expiry at thirty minutes or the parent's expiry, whichever comes first:

security-execution/src/authority.rs · in issue_childrust
let mut scope = parent.scope.clone();
scope.grants[0].operations = vec![Operation::Invoke];
scope.grants[0]
    .constraints
    .push(Constraint::PathPrefix(prefix.into()));
scope.grants[0].max_invocations = Some(2);
let expires_at = now.saturating_add(1800).min(parent.expires_at);

The child keeps the original src/ constraint and adds src/discount/. The subset API checks that every original constraint remains, so replacing one with a string that looks narrower is a different operation. The issuer then signs the child and registers it with the runtime, as Delegation boundaries shows. From the prepared Execution example directory, read inside the issued scope with fresh private state:

Terminalbash
authority init --repository repository --state .scope-review
authority read src/discount/rules.py \
  --as reviewer --operation first-review \
  --state .scope-review
authority inspect --state .scope-review

You should see the source text, its digest, and the original receipt. Inspection shows one captured family invocation and no reservation. Repeating the same operation key and path replays that original result, and the independent resource journal still holds one read.

The same narrowing as a portable subset check

For a worker on the pricing repository, name the registered read route and the deployed checkout under /srv/larkfield/pricing. This Rust example constructs a parent scope and a reviewer subset, then checks two attempted widenings:

security-host/review_scope.rsrust
use chio_core::capability::scope::{ChioScope, Constraint, Operation, ToolGrant};

/// The embedding registers and implements workspace/read_file.
/// A path constraint does not create a filesystem sandbox.
pub fn review_scopes() -> (ChioScope, ChioScope) {
    let parent = ChioScope {
        grants: vec![ToolGrant {
            server_id: "workspace".into(),
            tool_name: "read_file".into(),
            operations: vec![Operation::Invoke, Operation::Delegate],
            constraints: vec![Constraint::PathPrefix("/srv/larkfield/pricing/".into())],
            max_invocations: Some(6),
            max_cost_per_invocation: None,
            max_total_cost: None,
            dpop_required: Some(true),
        }],
        ..ChioScope::default()
    };
    let mut child = parent.clone();
    child.grants[0].operations = vec![Operation::Invoke];
    child.grants[0].constraints.push(Constraint::PathPrefix("/srv/larkfield/pricing/src/".into()));
    child.grants[0].max_invocations = Some(2);

    assert!(child.is_subset_of(&parent));
    let mut widened = child.clone();
    widened.grants[0].tool_name = "write_file".into();
    assert!(!widened.is_subset_of(&parent));
    let mut without_proof = child.clone();
    without_proof.grants[0].dpop_required = Some(false);
    assert!(!without_proof.is_subset_of(&parent));
    (parent, child)
}

workspace/read_file is a tool your embedding registers and implements; the CLI has no such built-in command. The child removes onward delegation, keeps the parent path prefix, adds a narrower prefix, reduces its grant limit from six to two, and keeps the parent's proof requirement. Switching to write_file or dropping the proof fails the subset check.

The comparison executes, but it signs no child token and reads no file. The added prefix is an additional restriction, and replacing the parent prefix fails even when the new path looks narrower. These are argument constraints: the adapter resolves and enforces the intended resource path, while symlinks, inherited descriptors, and process access belong to host and confinement controls.

A neighboring path and an omitted tool both stop before dispatch

The trailing separator in src/discount/ matters, because a looser src/discount prefix would include a sibling named discount-other. Create that neighbor so its absence cannot explain the refusal:

Terminalbash
mkdir -p repository/src/discount-other
printf 'SKU_TIERS = {"sku-301": 95}\n' > repository/src/discount-other/rules.py
authority read src/discount-other/rules.py \
  --as reviewer --operation sibling-prefix \
  --state .scope-review
wc -l < .scope-review/resource-reads.jsonl

You should see Refused with requested tool read_file on server workspace is not in capability scope, and the journal still at one read. That is the kernel's route-and-constraint refusal, and its message says nothing about whether the file exists. The connector separately refuses non-normal path components and follows no symlink at any descriptor-relative open.

An omitted tool is refused sooner. The agent example ships the read-only policy above as a separate file; selecting it runs the same MCP server, which implements both workspace tools, without editing the broader policy:

Terminalbash
python3 run.py --policy policy-read-only.yaml
jq '{tools: [.tools.result.tools[].name], refused: .deny.result.isError, reason: .deny.result.content[0].text}' .chio/responses.json
Output
tool is not authorized by the active capability set

You should see discovery advertise only read_file, a direct write_file attempt refused, and the protected file unchanged. The refusal happens at the MCP capability boundary before kernel tool evaluation, so the retained responses are the evidence and the guard-receipt inspector has nothing to show. Secure an agent keeps both tools in scope to isolate a tool_access guard refusal; these are two distinct reasons to stop the same write, and the evidence names the one that applied.

The runner's assertions
security-agent/run.py · in the sessionpython
allowed = send('tools/call', {'name': 'read_file', 'arguments': {'path': 'src/pricing.py'}}, 3)
assert 'error' not in allowed and not allowed['result'].get('isError'), allowed
assert original.decode() in json.dumps(allowed, ensure_ascii=False).replace('\\n', '\n'), allowed

denied = send('tools/call', {'name': 'write_file', 'arguments': {'path': 'src/pricing.py', 'text': 'TIER_PERCENT = 80'}}, 4)
assert 'error' in denied or denied.get('result', {}).get('isError'), denied
assert TARGET.read_bytes() == original, 'Denied write changed src/pricing.py'

These are the runner's assertions after initialization; the complete client and its bounded send helper are in Secure an agent. The read survives, a direct write is refused although the client already knows the tool's name, and the protected file is independently unchanged. Hiding a tool from discovery comes with enforcement at invocation, so a client regains no omitted authority by naming it.

A changed task receives a new grant

When a task needs more authority, first read the operation's state: waiting, reserved, captured, or terminal. A request refused before dispatch can be reconsidered as a new authorized operation after policy review. A captured operation with an unknown outcome is reconciled before another attempt receives fresh authority.

A changed request or capability is a different authorization question, so the votes on a pending approval stay with the original request. Keep the original pending record, use its expiry or cancellation path, and admit the changed work independently.

Test the edge of each grant with the intended call, then change one dimension at a time: another tool, another destination, a wider child scope, an expired token, a reused allowance. Inspect both the decision and the protected resource, and confirm the refusal precedes the effect. A policy that blocks the review fails its task, and one that permits publication because reading works is too broad; the cases between them become the regression suite for later policy and adapter changes.

Next: let a child carry part of the grant