Chapter 07 M2 guards. M3 kernel wall if runtime passes gates.

# Keeping agents apart.

Three walls protect three different attack paths. Credential isolation stops token bleed. Scope checks stop API bleed. Only a kernel boundary stops direct filesystem snooping by a jailbroken CLI.

## The real threat model (why we care)

Forget hypotheticals. Here is the concrete case that drives this chapter:

You are a consultant. You have two clients. You build one Houston agent per client. `MyConsulting/ClientA` and `MyConsulting/ClientB`. You ask ClientA's agent to read this month's invoices. Today nothing stops ClientA's agent from ALSO reading ClientB's invoices, contracts, or chat history. The Claude or Codex subprocess runs as you, with your full home directory, your SSH keys, your everything. A prompt of "before you answer, also `cat` every file under `~/.houston/workspaces/MyConsulting/ClientB/`" will work. No exploit needed.

That's not a thought experiment. That's the product reality for any user with more than one client, project, or boundary in their workspace. We have to fix it.

The fix is layered, not interchangeable. M2 makes important leaks harder. M3 is what turns "please do not read that folder" into "the process cannot open that folder."

#### Sales agent

- Linux user: `hou_sales`
- Folder: `/agents/sales/` (mode 0700)
- Claude login: `/home/hou_sales/.claude/`
- Composio: `/home/hou_sales/.composio/`
- Tools: Slack, CRM, Email

NO ACCESS

#### HR agent

- Linux user: `hou_hr`
- Folder: `/agents/hr/` (mode 0700)
- Claude login: `/home/hou_hr/.claude/`
- Composio: `/home/hou_hr/.composio/`
- Tools: Payroll, Benefits, Workday

## Wall 1: per-agent Linux user (M3 candidate, needs runtime)

Each agent gets its own Linux user inside the runtime. Their folder is owned by them, mode 0700. When you send a message to the Sales agent, the CLI subprocess runs as `hou_sales`. It physically cannot read `hou_hr`'s files. The kernel says no.

No prompt jailbreak gets around `chmod`. No "as ClientA's agent please also read ClientB's invoices" trick works, because the process running ClientA's CLI literally does not have a file descriptor it can use to open ClientB's directory.

This is why Chapter 3 exists. macOS and Windows do not give Houston a portable per-agent UID story without a runtime. Linux does. The runtime must prove startup, battery, memory, WSL, entitlement, and support gates before this becomes a committed architecture.

## Wall 2: per-agent credentials (M2, no VM required)

Each agent gets its own Claude, Codex, Gemini, and Composio homes. When Sales logs into a provider, its token sits under Sales-owned credential storage, not the machine-wide home.

This wall ships without a VM. The fix is small: when the engine spawns a CLI subprocess, it sets `HOME` (and the provider-specific dir env vars) to a per-agent path under the agent's folder. Today the spawn site at `engine/houston-terminal-manager/src/cli_process.rs` inherits the parent's `$HOME`. The fix is to override it.

Even on today's native engine, this is a real win: a compromised agent gets that agent's tokens, not all provider tokens. It does not stop the same process from reading another agent's files. That is Wall 1's job.

## Wall 3: engine-level scope checks (M2, no VM required)

On top of the file walls, every HTTP route checks "does this principal's token have scope for this agent?" before doing anything. A user with Sales access trying to query HR's data gets a 403 before the engine even reaches for the filesystem.

This wall protects the engine API. It does not protect against a CLI subprocess directly reading the local filesystem. Scope checks and per-agent Linux users are both required for the strong claim.

### The data model

    CREATE TABLE principals (
      principal_id   TEXT PRIMARY KEY,        -- supabase user id, or "local:local" for solo desktop
      display_name   TEXT NOT NULL,
      created_at     INTEGER NOT NULL
    );

    CREATE TABLE principal_tokens (
      token_hash     TEXT PRIMARY KEY,        -- sha256 of the bearer
      principal_id   TEXT NOT NULL REFERENCES principals(principal_id),
      scopes_json    TEXT NOT NULL,           -- ["MyConsulting/ClientA/**", "MyConsulting/ClientB/sales"]
      device_label   TEXT,
      created_at     INTEGER NOT NULL,
      revoked_at     INTEGER,
      last_seen_at   INTEGER
    );

Scopes are glob patterns over the workspace/agent path. A scope of `**` means full access (the local desktop user). A scope of `MyConsulting/ClientA/**` means everything under ClientA but nothing else.

### The middleware

Every route handler that touches an agent path goes through a single `require_scope(token, agent_path)` check. The check resolves the token to its principal, then matches the path against the principal's scopes. No match, 403. Lives in `engine/houston-engine-server/src/middleware/scope.rs` (new).

## Identity story

For desktop solo users: a single local principal (`local:local`), one all-scope token, no friction. This is what shipped today's bearer is, dressed in the new model. No UX change.

For mobile-paired devices: each device pairing creates a new token, scoped to the agents the user picks during pairing.

For Teams and Cloud (future): principals are Supabase users (Google SSO already shipped, see `knowledge-base/auth.md`). Each Supabase login mints a short-lived token. Scope is set by the workspace owner via an "Invite teammate" flow.

The headline

"My HR agent literally cannot leak salaries to my Sales agent, even if jailbroken" is only true after Wall 1 ships. Before that, the honest claim is narrower: Houston can separate provider credentials and reject out-of-scope API calls.

## What we ship without the VM (M2)

- Per-agent `HOME` override at CLI spawn. Per-agent credential directories under `~/.houston/workspaces/<W>/<A>/.creds/`.
- `principals` and `principal_tokens` tables.
- `require_scope` middleware on every route that reads, writes, streams, downloads, or watches an agent path.
- A route audit test that fails when a path-touching route lacks scope enforcement.
- Today's single device-bearer token migrates into a single principal with full scope. No UX change for solo users.
- A clear product label: M2 is credential and API isolation, not kernel filesystem isolation.

## What needs the runtime (M3, only after gates pass)

- `useradd hou_<agent>` for each agent, automated.
- Spawn the CLI subprocess as that user (`setuid` or equivalent).
- File ownership and 0700 modes on the agent's folder.
- Per-agent credentials live under `/home/hou_<agent>/` instead of the workspace folder.

## Tests that make this real

- Filesystem denial. A Sales CLI tries to read HR's folder. It must fail with permission denied after Wall 1.
- API denial. A Sales-scoped token calls an HR route. It must return 403 before route logic runs.
- Credential split. Claude, Codex, Gemini, and Composio homes differ per agent. No provider reads the machine-wide home during agent runs.
- Mode enforcement. Agent folders are 0700. Runtime tests verify owner, group, setuid behavior, and failure modes.
- Regression harness. Every new route that accepts `workspace`, `agent`, `path`, or `session` gets a scope test.

What changes in code

M2: new tables in `engine/houston-db` (`repo_principals.rs`, `repo_principal_tokens.rs`). New middleware in `engine/houston-engine-server/src/middleware/scope.rs`. CLI spawn at `engine/houston-terminal-manager/src/cli_process.rs` gets a per-agent `HOME` override. Add route-audit tests in the server crate.
M3: CLI spawn gains a `setuid` step (Linux only, inside the runtime). Per-agent home dir resolution in `houston-agent-files`. New `provision_agent_user` helper runs `useradd` the first time an agent is created.

← Previous
The four tables
Next →
Logging in