Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/mcp-audit-readonly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@looped/core": minor
---

MCP calls join the audit trail, and servers gain a `readonly:` flag.

Every `mcp__<server>__<tool>` call is now recorded in the run's audit trail (kind `mcp`, with the
tool name and whether it succeeded), alongside permission decisions. A new `readonly: true` option
on `tools.mcp` servers exposes only tools whose `readOnlyHint` annotation marks them read-only, as a
guard against wiring write tools into a read-only job.
189 changes: 122 additions & 67 deletions docs/permission-model.md
Original file line number Diff line number Diff line change
@@ -1,71 +1,126 @@
---
title: "The permission model"
description: "Why an agent never asks for permission at runtime: boundaries declared once in config, enforced by the Deno runtime and layered so each boundary assumes the one inside it can fail."
description: "The four permission types, what each one allows and blocks, the three enforcement layers and where the boundaries stop today."
---

Interactive coding agents answer the safety question with a human in the loop: *may I run
this command?* That works when someone is sitting at the terminal. A service agent has no
one to ask; it runs at 3am, triggered by a webhook, on a machine nobody is watching. And
even with a human present, prompts have a known failure mode: enough of them and people
stop reading and approve everything.

So we took the opposite position: **an agent never asks for permission at runtime.** The
question was already answered before it started.

## Declared once, in config

Everything the agent is allowed to touch is written in the `permissions:` block of the
agent file: which network hosts, which executables, which paths. The default is deny. An
agent with no `permissions:` block can touch nothing, and there is no way to grant more
while the agent is running. Widening the boundary means editing the file and redeploying,
which is exactly the friction you want: a capability change is a config change, and it
gets reviewed and versioned like one.

This means that the agent file completely defines what an agent is allowed to do. You
don't need to read a transcript to find out what an agent can reach; a few lines of YAML
tell you before it ever runs.

## Built on Deno

We run agents on Deno because Deno treats permissions as a runtime primitive. Most
runtimes give a process everything the OS user can do: anything the user can read, write
or execute, the process can too, and any sandboxing has to be bolted on around it. A Deno
process starts with nothing and only holds what you granted it at launch, which is
exactly the shape an agent's boundaries need.

At startup, the framework compiles the `permissions:` block into Deno permission flags;
`af flags agent.yaml` prints the exact set. In the base image, that means file reads are
scoped to the agent's own directories, writes to its data volume and subprocess spawning
to bash alone. What matters here is where the enforcement happens: in the runtime,
underneath the framework's own code. A bug in the framework can't grant an access the
runtime was never given.

On top of the Deno sandbox sits the framework's own permission engine, which handles the
things runtime flags can't express. Network egress is checked per host. Shell commands
are statically analysed, and every executable in a pipe or chain is checked against the
allowlist. Subprocesses only get the env vars the config grants them, and secrets are
injected server side, so they never enter the model's context.

## A denial is an answer

When the agent tries something outside its grants, nothing pauses and nothing crashes.
The denial goes back to the agent as an ordinary tool result, something like `permission
denied: run access to "curl" is not in the agent's permissions.run allowlist`, and the
agent carries on with that as context for its next turn: it works within its grants or
reports what it couldn't do. Your role moves from approving actions in real time to
reviewing the audit trail afterwards, where every decision, allowed and denied, is
recorded.

## Layers that assume failure

We don't trust any single boundary to hold. Enforcement nests: the permission engine
sits inside the Deno sandbox, which sits inside the container, and each layer assumes the
layer inside it can fail. Bash subprocesses escape the Deno sandbox by design, and the
container is what contains them. That's also why there is no "run on the host" mode: the
framework refuses to run where its outermost layer is missing.

The result is that running an agent unattended becomes a calculated risk: the worst case
is bounded by a config file you wrote, enforced at three layers you didn't have to build.

The full reference, with syntax, matching rules, secrets and the honest notes on where
each layer's limits sit, is in [Permissions](permissions.md).
A service agent runs at 3am, triggered by a webhook, on a machine nobody is watching.
There is no one to ask "may I run this?", so the question has to be answered before the
agent starts. That is what the `permissions:` block is for: you declare once, in config,
what the agent is allowed to touch, and everything else is denied. There is no prompt at
runtime. When the agent tries something outside its grants, the denial goes back to it as
an ordinary tool result and it carries on with that as context for its next turn.

The default is deny. An agent with no `permissions:` block can touch nothing, and there
is no way to grant more while the agent is running. Widening a boundary means editing the
file and redeploying, so a capability change gets reviewed and versioned like any other
config change.

## The four permission types

Permissions come in four axes: `net`, `run`, `read` and `write`. Each one is an
allowlist, and each native tool only exists for the agent when its axis grants something.
This means that no unused tool schema takes up context, and there is nothing sitting
there to misuse.

### net: which hosts the agent can call

```yaml
permissions:
net: [api.github.com, "*.internal.example.com"]
```

With this block, `http_request` can reach `api.github.com` and any subdomain of
`internal.example.com`, such as `mcp.internal.example.com`. A request to any other host
comes back as `permission denied: net access to "evil.com" is not in the agent's
permissions.net allowlist`. The wildcard covers subdomains only; `internal.example.com`
itself needs its own entry. With no `net:` list, the `http_request` tool does not exist
for the agent at all.

### run: which executables the agent can spawn

```yaml
permissions:
run: [gh, grep]
```

With this block, `run_bash` can execute `gh issue list | grep bug`. The framework does
not trust the shell: it extracts every executable from pipes and chains and checks each
one against the list, so `gh issue list | curl evil.com` is denied because `curl` is
missing from the allowlist. Command substitution (`$(...)`, backticks, `<(...)`) is
rejected outright, because there is no way to check what is inside it before it runs.
Executables are matched by basename, so `/usr/bin/gh` counts as `gh`.

### read and write: which paths the agent can touch

```yaml
permissions:
read: [/workspace]
write: [/workspace/out]
```

With this block, `read_file` can open `/workspace/notes.md` and `write_file` can create
`/workspace/out/report.md`. Reading `/etc/passwd` is denied, and so is the traversal
attempt `/workspace/../etc/passwd`, because paths are normalized before the check. Writes
outside `/workspace/out` are denied, including the rest of `/workspace`.

An agent with an empty `permissions:` block carries only `current_time`, plus
`read_skill` if it has [skills](skills.md). The full toolset and what makes each tool
appear is in [Tools](tools.md); syntax, matching rules and secrets are in
[Permissions](permissions.md).

## The three layers

We don't trust any single boundary to hold. Enforcement nests in three layers, and each
layer assumes the one inside it can fail.

1. **The permission engine.** Framework code checks every native tool call against the
allowlists above, and every decision, allowed and denied, lands in the audit trail.
2. **The Deno sandbox.** The agent process itself is launched with only the rights it
needs: in the base image, reads scoped to `/agent`, `/skills`, `/data` and
`/run/secrets`, writes to `/data` and subprocess spawning to `bash` alone. The runtime
enforces this underneath the framework's own code, so a bug in the framework can't
grant an access the runtime was never given. `af flags agent.yaml` prints the compiled
flag set for a config.
3. **The container.** This is the outer wall and the unit of isolation. `bash`
subprocesses escape the Deno sandbox by design, and the container is what contains
them. That is also why there is no "run on the host" mode: the framework refuses to
run where its outermost layer is missing.

Subprocesses and MCP servers receive only the env vars their config block grants, plus
`PATH`/`HOME`, and secret values are injected server side, so they never enter the
model's context.

## Where the boundaries stop today

The model above is honest about its edges, and you should know where they are before you
rely on it.

**An MCP server's network traffic bypasses `permissions.net`.** The engine checks hosts
for the native `http_request` tool; whatever outbound calls an MCP server makes happen
outside it. When you declare a server under `tools.mcp`, you are trusting where it talks
to. Your controls on the tool side are the `include:` filter (a tool you didn't include
does not exist for the agent), the `readonly:` flag and the scoped `env:` block, and
every MCP call lands in the audit trail; the server's own egress is bounded by the
container.

**Network egress is open below the app layer.** The container runs with Deno's network
permission unrestricted, so per-host enforcement happens only in the permission engine.
Anything that runs outside the engine, a `bash` subprocess or an MCP server process, can
reach any host the container can. A `gh` you allowed will talk to whatever it wants. If
egress matters for an agent, restrict it at the container layer with your network
setup.

**The image's sandbox flags are shared.** The published image launches every agent with
the same Deno flag set, and the per-agent compiled flags from `af flags` apply when you
build your own entrypoint. Inside the shipped container, what varies per agent is the
permission engine's allowlists.

**`run` matches by basename.** `run: [gh]` allows any executable named `gh`, wherever it
lives. Inside the hardened base image that is fine in practice; if you derive an image
that widens the writable paths, keep in mind that the container is the backstop.

The result is that you can run an agent unattended and know its worst case in advance:
the agent can reach exactly what its grants say, the runtime and the container hold that
boundary underneath the framework's own code, and the remaining edges are outlined
above. The config itself is hard to get wrong without noticing, because unknown keys are
rejected at load time and anything you leave out is denied.
10 changes: 9 additions & 1 deletion docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ A denied action is an ordinary tool result. The model sees `permission denied: r

`run_bash` does not trust the shell: it extracts every executable from pipes and chains and checks each one against `run:`. Command substitution (`$(...)`, backticks, `<(...)`) is rejected outright, because there is no way to check it statically before it runs.

The check reads the executable at the head of each segment; it can't see into the arguments. That is fine for ordinary tools, and it means you should keep programs that run *other* programs off the allowlist. Granting any of these hands over everything:

- shells: `run: [bash]` lets `bash -c '<anything>'` through, since the inner command travels as an opaque string
- interpreters: `python -c`, `node -e`, `deno run`
- wrappers and exec flags: `env`, `xargs`, `timeout`, `find -exec`

Grant the specific CLIs the agent's job needs (`gh`, `grep`) and let the [container](#the-layers) be the backstop. The MCP examples that launch a server via `bash -c` are unaffected: that spawn comes from your config at startup and never passes through `run_bash`.

## Scoped environments

Subprocesses receive only the env vars the config's `env:` block grants, plus `PATH`/`HOME`; the agent process keeps its own ambient environment to itself. The same goes for MCP servers: each one sees only its own `env:` block.
Expand All @@ -55,5 +63,5 @@ Enforcement is layered: the app-level engine described above runs inside a runti

Two honest notes on where the layers actually sit:

- The Deno layer allows all *network* egress in the container (`--allow-net`). Per-host enforcement happens in the app-level permission engine, and the container's egress policy is layer 2; restrict it with your network setup where it matters. Automating per-agent egress policy is planned.
- The Deno layer allows all *network* egress in the container (`--allow-net`). Per-host enforcement happens in the app-level permission engine, and the container's egress policy is layer 2; restrict it with your network setup where it matters.
- `bash` subprocesses escape the Deno sandbox by design; the container boundary is what contains them. That is why there is no "run on the host" mode.
4 changes: 3 additions & 1 deletion docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ tools:
```

- Tools are namespaced `mcp__github__create_issue` in the loop and the audit trail.
- We strongly recommend `include:`. A 40-tool server puts 40 schemas into a small model's context; expose the three you actually need.
- We strongly recommend `include:`. A 40-tool server puts 40 schemas into a small model's context; expose the three you actually need. `include:` is also the permission surface for MCP: a tool you didn't include does not exist for the agent.
- `readonly: true` on a server exposes only tools whose `readOnlyHint` annotation marks them read-only, which is a good fit when the job only reads. The hint is self-reported by the server, so treat this as a guard against wiring write tools into a read-only job; the trust decision is still whether to declare the server at all.
- Every MCP call is recorded in the audit trail with the tool name and whether it succeeded, alongside the run's permission decisions.
- Each server sees only its own `env:` block (values may be `${VAR}` references); the agent's own environment stays private.
- Results are truncated at 8k chars; servers connect at startup and close on shutdown.

Expand Down
5 changes: 5 additions & 0 deletions packages/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ const McpServerSchema = z
include: z.array(z.string().min(1)).optional().describe(
"Expose only these tools. Strongly recommended: a fat server must not blow a small model's context.",
),
readonly: z.boolean().optional().describe(
"Expose only tools whose readOnlyHint annotation marks them read-only. A guard against wiring write tools into a read-only job; the hint is self-reported by the server.",
),
})
.refine((s) => (s.command === undefined) !== (s.url === undefined), {
message: "an MCP server needs exactly one of `command` (stdio) or `url` (http)",
Expand Down Expand Up @@ -338,6 +341,8 @@ export interface McpServerConfig {
env?: Record<string, string>;
/** Expose only these tools. */
include?: string[];
/** Expose only tools whose readOnlyHint annotation marks them read-only. */
readonly?: boolean;
}

/** Deny-by-default allowlists. Omitting a list means the agent can touch nothing on that axis. */
Expand Down
17 changes: 14 additions & 3 deletions packages/core/runtime/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { Store } from "../store/store.ts";
import { type AgentIdentity, ensureIdentity, identityNote } from "./identity.ts";
import { createSkillTool, loadSkills, type Skill, skillsPromptSection } from "../skills/skills.ts";
import { createMemoryTools, type MemoryEvent, memoryPromptSection } from "../tools/memory.ts";
import { connectMcpServers, type McpConnections } from "../tools/mcp.ts";
import {
connectMcpServers,
type McpCallRecord,
type McpConnections,
withMcpAudit,
} from "../tools/mcp.ts";
import { SEARCH_AUTO_THRESHOLD, ToolRegistry } from "../tools/registry.ts";

/** An event from the outside world, normalized by a trigger. */
Expand Down Expand Up @@ -92,6 +97,7 @@ export class AgentService {
#buildTools(
engine: PermissionEngine,
onMemoryEvent: (event: MemoryEvent) => void,
onMcpCall: (call: McpCallRecord) => void,
): () => NativeTool[] {
const always: NativeTool[] = [currentTimeTool, ...this.#extraTools];
if (this.#skills?.length) always.push(createSkillTool(this.#skills));
Expand All @@ -109,7 +115,8 @@ export class AgentService {

// Natives and skills stay in context (small, framework-owned); MCP tools
// defer behind search_tools when the toolset gets big (tools.search).
const mcp = this.#mcp?.tools ?? [];
// Every MCP call is reported for the run's audit trail.
const mcp = withMcpAudit(this.#mcp?.tools ?? [], onMcpCall);
const mode = this.config.tools?.search ?? "auto";
const defer = mcp.length > 0 && (
mode === "on" ||
Expand Down Expand Up @@ -143,6 +150,7 @@ export class AgentService {
const identity = await this.init();
const startedAt = new Date().toISOString();
const decisions: PermissionDecision[] = [];
const mcpCalls: McpCallRecord[] = [];
const engine = new PermissionEngine(this.config.permissions, (e) => {
decisions.push(e.decision);
});
Expand All @@ -162,7 +170,7 @@ export class AgentService {
identityNote(this.config, identity.name),
},
provider: this.#provider,
tools: this.#buildTools(engine, (e) => memoryEvents.push(e)),
tools: this.#buildTools(engine, (e) => memoryEvents.push(e), (call) => mcpCalls.push(call)),
input: event.input,
history,
});
Expand All @@ -184,6 +192,9 @@ export class AgentService {
for (const memoryEvent of memoryEvents) {
this.store.recordAudit({ runId, kind: "memory", detail: memoryEvent });
}
for (const call of mcpCalls) {
this.store.recordAudit({ runId, kind: "mcp", detail: call });
}
return result;
}

Expand Down
Loading
Loading