DEV Community

Steve Gonzalez
Steve Gonzalez

Posted on

Seven Agents, Zero Trust: How I Made an Agentic Shell Safe by Design

Earlier this year I wrote about CAS, a terminal shell where conversation and direct manipulation live in the same window. That post was about the interface. This one is about the part underneath that I care about more: how the thing decides what it's allowed to do.

The question that drove the whole design is simple to state and hard to answer well: when an LLM can call tools, browse the web, and act across multiple connected services, how do you keep it inside the lines?

The usual answer is to ask the model nicely in a system prompt and hope. I didn't want to hope.


The shape of the problem

CAS grew a set of capabilities that each let the model reach further out into the world:

  • It connects to MCP servers and calls their tools.
  • It fetches and reads web pages.
  • It coordinates tasks that span two or more of those connected workspaces at once.

Each of those is a place where a confused or adversarial model could do something I didn't ask for. Call the wrong tool. Navigate somewhere it invented. Route a step to a workspace that wasn't part of the task. The more capable the system got, the more these seams mattered.

I decided early that the safety story could not live in prompts. Prompts are suggestions. I wanted guarantees that hold regardless of what the model outputs.


Every LLM call has a named owner

The first move was structural. In CAS, the shell — the thing that routes your messages — makes zero LLM calls. Not one. Every call to a model is owned by a named agent with a single responsibility:

GenerationAgent    creates new workspace content
EditAgent          applies a change to existing content
CombineAgent       merges multiple workspaces into one
ChatAgent          handles conversational turns
MCPAgent           plans and executes MCP tool calls
WebAgent           plans and executes web actions
OrchestratorAgent  coordinates multi-workspace tasks
Enter fullscreen mode Exit fullscreen mode

Seven agents. The shell detects intent (with regex, no model involved — that part's in the last post) and delegates to exactly one of them. The agent owns the call, the prompt, and crucially the contract around the call.

The shell routes each message to exactly one of seven named agents; every model call has a named owner.

This matters because it gives every model interaction a boundary with a name on it. When something goes wrong, the error says mcp-agent: precondition violation: connection_has_tools. You always know which agent, which phase, which rule.


Contracts the model can't see

The boundary itself is a contract, in the Bertrand Meyer sense — preconditions checked before the work, postconditions checked after. The whole type is about thirty lines of Go:

type Rule struct {
    Name        string
    Description string
    Check       func() bool
}

type Contract struct {
    AgentName      string
    Preconditions  []Rule
    Postconditions []Rule
    frozen         bool
}
Enter fullscreen mode Exit fullscreen mode

Three properties make this work as a safety mechanism rather than decoration.

It runs in Go, outside the model. The checks are ordinary functions. The model never sees them, can't reason about them, and has no path to modify them. There is no prompt injection that reaches a func() bool.

It's frozen before the call. An agent constructs its contract and calls Freeze() before it ever talks to the model. The agent cannot loosen its own constraints partway through. The contract for a given operation is fixed before the operation begins.

It fails closed. A violation returns an error and stops the operation. No fallback, no retry, no "let me ask the model to fix it." If a postcondition fails, the work is discarded.

The contract envelope: preconditions, the LLM call, then postconditions; any violation discards the operation, fail-closed.

Here's what that looks like in practice for the agent that creates content:

c.Preconditions = []contract.Rule{
    {
        Name:  "workspace_type_allowed",
        Check: func() bool {
            return req.WSType == "document" ||
                   req.WSType == "code" ||
                   req.WSType == "list"
        },
    },
    {
        Name:  "prompt_not_empty",
        Check: func() bool { return strings.TrimSpace(req.Prompt) != "" },
    },
}
c.Postconditions = []contract.Rule{
    {
        Name:  "content_not_empty",
        Check: func() bool { return contentSize > 0 },
    },
    {
        Name:  "content_size_within_limit",
        Check: func() bool { return contentSize <= maxContentBytes },
    },
}
Enter fullscreen mode Exit fullscreen mode

Nothing surprising. That's the point. The constraints are boring, legible, and enforced by the compiler-checked language rather than the probabilistic one.


The interesting postconditions

The boring checks earn their keep. But a few postconditions are doing real work that a prompt could never guarantee.

The edit agent has a truncation guard. A common failure mode when you ask a model to "add a section" to a long document is that it returns just the new section — or a summarized version of the whole thing — instead of the full updated content. Silent data loss. The postcondition catches it:

{
    Name: "result_not_drastically_shorter",
    Check: func() bool { return contentSize >= minExpected },
    // minExpected is 10% of the original length
}
Enter fullscreen mode Exit fullscreen mode

If the model returns something less than a tenth the size of what it was editing, the operation fails closed and the original content is untouched. The model can't lose your document by misunderstanding the task.

The MCP agent has a tool-existence check. When the model decides which tool to call, the postcondition verifies the chosen tool name actually exists on the connected server:

{
    Name: "tool_name_known",
    Check: func() bool {
        if toolCall == nil {
            return true
        }
        for _, t := range req.Connection.Tools {
            if t.Name == toolCall.ToolName {
                return true
            }
        }
        return false
    },
}
Enter fullscreen mode Exit fullscreen mode

A model that hallucinates a tool called delete_everything never gets to call it, because delete_everything isn't in the server's advertised tool list and the contract rejects the plan before execution.

The web agent does the same thing for URLs — a navigation target has to be a real, parseable, absolute URL, not something the model invented mid-sentence.


Coordinating agents without trusting them

The hardest case is the orchestrator. When you say "read the issue in the Linear workspace and open a GitHub PR for it," one instruction spans two connected services. The orchestrator's job is to decompose that into ordered steps and run them.

This is exactly where an agentic system can go off the rails — a multi-step plan is a lot of surface area. So the orchestrator never touches a workspace directly. It produces a plan, and a separate interface executes each step:

type StepExecutor interface {
    ExecuteStep(ctx context.Context, wsID, instruction, prior string) (string, error)
}
Enter fullscreen mode Exit fullscreen mode

The orchestrator knows nothing about MCP servers or web sessions. It emits steps; the shell routes each to the right agent. And the orchestrator's contract enforces the one rule that matters: every step in the plan must target a workspace that actually exists.

{
    Name: "plan_steps_have_known_workspaces",
    Check: func() bool {
        for _, step := range plan.Steps {
            if !idSet[step.WorkspaceID] {
                return false
            }
        }
        return true
    },
}
Enter fullscreen mode Exit fullscreen mode

The model is handed a list of real workspace IDs and asked to plan against them. If its plan references an ID that wasn't in the list, the plan is rejected before a single step runs. The model can't route work to a service it dreamed up.

The orchestrator produces a validated plan and emits steps through StepExecutor; the shell routes each step to the right agent.

Output from each step is fed as context into the next, so the GitHub step sees what the Linear step found — but the structure of the plan is validated by code, not trusted from the model.


A dial for how much rope

Validation handles "is this action well-formed." It doesn't handle "do I actually want this action to happen." For that there's an autonomy dial, and it's per-operation:

  • suggest — the agent plans the action and shows it to you. Nothing executes.
  • confirm — the agent executes, but pauses before each step for your approval.
  • run — the agent executes freely within its workspace scope.

In the terminal, confirm mode is a real pause. The agent's goroutine blocks on a channel while the UI shows you what's about to happen:

  CONFIRM  │  [ws-linear] list open issues  │  y: proceed  │  n: skip  │  esc: cancel
Enter fullscreen mode Exit fullscreen mode

Press y and the step runs. Press n and it's skipped. Press esc and you bail. The goroutine is genuinely waiting — no polling, no timeout games, just a blocking read on a channel that the keypress writes to. Human-in-the-loop, implemented as backpressure.

The autonomy dial: suggest plans only, confirm pauses before each step, run executes freely within scope.

And because every step — what it was, what it returned — is written to SQLite as it happens, there's a full audit trail afterward. You can reconstruct exactly what the system did and what flowed between steps.


What this buys

None of these pieces is clever on its own. A contract is an if-statement. A tool-existence check is a loop. The autonomy dial is a blocking channel read.

The value is in where they sit. The model proposes; Go disposes. Every reach into the outside world passes through a named boundary with constraints that were fixed before the model spoke, that the model can't see or alter, and that fail closed when violated. The security-relevant decisions live in the deterministic layer, not the probabilistic one.

It turns out a 40-year-old idea about software correctness — preconditions, postconditions, fail closed — is a remarkably good fit for the problem of keeping a language model inside the lines. Meyer was writing about catching programmer mistakes. The same machinery catches model mistakes just as well, and for the same reason: it doesn't trust the caller to be correct.

CAS is open source — github.com/goweft/cas. The contract layer is internal/contract, and each agent's contract lives next to it in internal/agent. It's all about as boring as the snippets above, which is exactly what I wanted from a safety layer.

Top comments (0)