Chio/Docs
LOGIN · JOIN

Secure an agent

Put a governed MCP edge in front of a working tool server, allow a useful read, refuse a write, and prove the file never changed.

Guide chio@5f63e6e

On this page

Put the pricing project's workspace server behind Chio, let the fixer read src/pricing.py while it diagnoses issue #412, and watch its attempt to raise the discount tier to 80 percent refused before the server runs. You finish with two signed receipts and a file whose bytes you checked yourself.

Starts from chio new security-agent --example security-agent, and needs the installed CLI, Python 3.11 or newer, and a non-root user.

Tool discovery tells an agent that an operation exists; it does not decide whether this caller may use it. The calls in this tutorial are deterministic, sent by a small driver, so the result does not depend on a model choosing the right tool, and the refused call is sent directly, the way a misbehaving client would send it.

1. Get the example and read the server

Terminalbash
chio new security-agent --example security-agent
cd security-agent
cat workspace/src/pricing.py

The project holds server.py, run.py, inspect.py, two policies, and a copy of the pricing project under workspace/, where discount() applies a 10 percent tier. The server is an ordinary Python subprocess with two tools. Its complete tool-call branch is below; resolve keeps every path inside workspace/.

security-agent/server.py · in the request looppython
elif method == 'tools/call':
    params = request['params']
    arguments = params['arguments']
    if params['name'] == 'read_file':
        text = resolve(arguments['path']).read_text()
    elif params['name'] == 'write_file':
        text = arguments['text']
        resolve(arguments['path']).write_text(text)
    else:
        raise ValueError('Unknown tool')
    result = {'content': [{'type': 'text', 'text': text}]}

The branch has no denial of its own. If mediation failed, the server would replace the file, which is what makes the byte check in step 3 meaningful. The MCP initialization and line-oriented loop are in the complete server.

2. Grant both tools and block the write with a guard

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

guards:
  tool_access:
    enabled: true
    block: ["write_file"]

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

Both tools are in capability scope for five minutes, inside the kernel's 15-minute ceiling, and the tool_access guard blocks write_file. The write is therefore refused by evaluated policy after scope is established. Step 5 removes the write grant instead, so you can compare the two refusals.

3. Run the allowed read and the refused write

Terminalbash
python3 run.py
Output
ALLOW read_file: src/pricing.py returned
DENY write_file: src/pricing.py unchanged
Evidence: .chio/receipts.db and .chio/responses.json

You should see three lines, each printed only after its assertion passes. The runner first calls chio security provision-native-mcp-demo, which signs a tool manifest and a native-launch policy for this directory, this Python interpreter, and your user ID, and writes them to .chio/launch. It then starts chio mcp serve with the policy, the workspace server ID, persistent receipt and session databases, and that launch material, completes MCP initialization, reads src/pricing.py, and sends the write. Last, it compares the file's bytes with the original.

The protocol responses stay unedited in .chio/responses.json, and stderr goes to .chio/server.log, which separates startup, policy, and protocol failures. Run the command again to exercise the same policy against existing durable state; the driver issues a new pair of calls rather than replaying the first.

4. Read the two signed decisions

Terminalbash
python3 inspect.py
Output
{"id": "rcpt-01a8…5d17", "tool_name": "read_file", "decision": {"verdict": "allow"}}
{"id": "rcpt-01a8…5e40", "tool_name": "write_file", "decision": {"verdict": "deny", "reason": "guard denied the request: guard \"guard-pipeline\" denied the request"}}

The inspector runs chio receipt list for the workspace server, with --admin-all because you own this local store, asserts that an allow receipt for read_file and a deny receipt for write_file exist, and prints their IDs, tools, and decisions. IDs differ on every run. The projection omits the signed envelope, so keep .chio/receipts.db when you need to verify a run independently. A bridge or protocol failure before kernel evaluation returns an error with no kernel receipt; keep that apart from the policy denial shown here.

5. Remove the write grant and compare the refusal

policy-read-only.yaml grants read_file alone. Under it, discovery lists only the read, and the driver sends the write directly anyway:

Two ways to refuse the writeChoose a policy.
tools/listcapability scopeguardsserversrc/pricing.pyread_filewrite_filefunchangedlistedscopeguardsserverfileread_filewrite_filefunchanged

Both tools are granted; the guard refuses the write

The capability covers read_file and write_file, so discovery lists both and the write passes the scope check. The tool_access guard blocks write_file, and the kernel signs a deny receipt whose guard evidence names the refusing guard.

An evaluated policy denial, retained as a deny receipt in .chio/receipts.db.

The tutorial's two calls under each of its two policies, from discovery to the file. The server implements both tools, so only the refusal keeps the pricing file at a 10 percent tier. Initialization and the response plumbing are left out.
Terminalbash
python3 run.py --policy policy-read-only.yaml
jq '.deny' .chio/responses.json
.chio/responses.json · .denyjson
{
  "id": 4,
  "jsonrpc": "2.0",
  "result": {
    "content": [
      {
        "text": "tool is not authorized by the active capability set",
        "type": "text"
      }
    ],
    "isError": true
  }
}

Both runs allow the read and leave the file unchanged. The guard refusal shows that policy narrowed an established grant; the scope refusal shows that the capability never covered the tool, and that the edge refuses a direct invocation as well as filtering discovery. The least privilege page builds on the read-only design. responses.json holds only the latest pair, while the receipt database keeps every run, so copy the file aside before the next run if you need both pairs.

6. Register the same boundary with your coding host

Run python3 run.py at least once first. The launch material in .chio/launch is bound to this directory, the interpreter that ran it, and your user ID, and every run replaces it with new keys, so the command reads both public keys at start. When the host launches servers from another directory, set DIR to the example's absolute path with symbolic links resolved.

Terminalbash
DIR="$(pwd -P)"
LAUNCH="$DIR/.chio/launch"
PYTHON="$(python3 -c 'import os, sys; print(os.path.realpath(sys.executable))')"
chio --receipt-db "$DIR/.chio/receipts.db" \
  --session-db "$DIR/.chio/session.db" \
  mcp serve --policy "$DIR/policy.yaml" --server-id workspace \
  --server-name "Larkfield pricing workspace" --server-version 1 \
  --signed-manifest "$LAUNCH/signed-manifest.json" \
  --manifest-public-key "$(cat "$LAUNCH/manifest-public-key")" \
  --cage-policy "$LAUNCH/cage-launch-policy.json" \
  --cage-policy-signer "$(cat "$LAUNCH/cage-policy-signer")" \
  -- "$PYTHON" "$DIR/server.py"

The global database options choose where receipts and sessions persist. --signed-manifest and --manifest-public-key supply the publisher-signed tool manifest and the key registered apart from it; --cage-policy and --cage-policy-signer supply the signed launch policy and its signer's pinned key. Everything after -- is the child server command, and Chio starts it only when its path and arguments match the launch policy exactly. The signed launch mode verifies the child; it does not install Linux confinement, which process confinement adds.

Follow govern a coding agent to register the command, then ask the agent to explain discount() and to raise the tier to 80 percent. Its requests cross the same boundary you exercised. The host's own shell and other servers stay separate paths that the deployment must control.

7. Move the boundary to your own server

Start from the resource a tool can affect, since a tool named quote may read the customers table, call a model provider, and write a cache. Replace the child command with your MCP server, align the policy's server ID and tool names with its discovery result, and keep one harmless read as the positive control. Pick a refused action whose effect you can observe independently, such as a write to a disposable copy, and change one constraint at a time; widening scope to a wildcard to silence a denial removes the boundary you are testing.

Log to standard error so diagnostics do not corrupt MCP messages, and use absolute paths for the policy, the child, and the state directories. The edge can send a logging notification before the matching response, so a client reads until the response with its request ID arrives. When you finish, remove only the example directory you created, after copying any evidence you want to keep.

SymptomInspectRepair
Provisioning failedThe error the runner printsFix the cause and rerun; each run provisions fresh material
Chio stops at startup.chio/server.logFix policy syntax, the executable, database access, or a child command that differs from the launch policy
Initialization failsThe returned protocol errorUse the server's supported protocol version
The read is refusedServer ID, tool name, capability scopeRestore the read grant
No expected receiptThe receipt path and the responseSeparate a pre-kernel error from an evaluated denial
src/pricing.py changedThe process and route that wrote itFind the bypass before widening the workflow
The driver's client calls and independent assertions

send writes one MCP message and reads complete lines from a bounded byte buffer until the matching response ID arrives, so a notification and a response that arrive together cannot be mistaken for a timeout. The buffer and timeout are in run.py.

security-agent/run.py · in the sessionpython
initialized = send('initialize', {'protocolVersion': '2025-11-25', 'capabilities': {},
                   'clientInfo': {'name': 'security-docs', 'version': '1.0.0'}}, 1)
assert 'result' in initialized, initialized
send('notifications/initialized', {})
tools = send('tools/list', {}, 2)
assert 'read_file' in {t['name'] for t in tools['result']['tools']}, tools
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
print('ALLOW read_file: src/pricing.py returned')
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'
print('DENY write_file: src/pricing.py unchanged')

The read assertion proves the profile still permits useful work; the error assertion and the byte comparison prove this replacement was refused without touching the file. Both belong in the check, since refusing every call would also preserve the file. An error response alone would miss a server that wrote the file and then reported failure.

Next: narrow the grant to the task