vik.blog
agents · ops

I built one door for my agents to act through. Then I found two ways around it.

I run more than one thing at once. There is the older stuff that still needs someone paying attention, and there is Instapods, which is new and needs a lot of attention. For a while my answer was terminal tabs. One tab per project, Claude open in each, me switching.

That works for about a week. Then you notice what it actually is: a pile of agents with no shared state, no memory, no boundaries and no audience.

  • Nothing survives the tab. Close it and everything that agent figured out is gone. The next session re-derives it and makes the same three mistakes.
  • Nothing is visible to anyone else. My co-founder cannot look at a tmux pane on my laptop. Neither can anyone non-technical.
  • Nothing stops it. An agent holding a Bash tool can rm -rf a production docroot at 2am, and the only control is that I happened to be watching.
  • There is no queue. Work lives in a cron, a memory, or my head. Mostly my head.

So I built Agent OS. MIT, public, 44,229 lines of TypeScript across 122 files, 556 commits over 32 days of actual work since June 11th. Those numbers measure how much I typed, not whether any of it is correct — I’ll get to correctness, and it does not go well. One user, zero stars, fifteen open issues, no independent evaluation. It is not a product. It is the thing I run my companies on, in public, which is exactly why the second half of this post exists.

The one idea

Almost everything in the repo serves one sentence, at the top of the README:

Every side effect an agent has on the outside world passes through one mediated boundary — the gateway — that the OS controls. Policy is checked there. Budget is debited there. Identity is asserted there. Idempotency is enforced there. Audit is written there.

I want to be upfront that this idea is not mine and is not new. It is a reference monitor, named in Anderson’s 1972 Computer Security Technology Planning Study, and the property it depends on is complete mediation from Saltzer and Schroeder, 1975: every access to every object is checked for authority, every time. Anderson’s three requirements for such a thing are that it be always invoked, tamper-proof, and small enough to verify.

Fifty years of access control already established the shape. What I wanted to know was whether you can hold that shape around a coding agent — a real Claude Code session, in tmux, holding a raw Bash tool. That is the only interesting question here, and the honest answer is “partly.”

The gateway is seven steps in a fixed order:

Agent wants to act ──► [ GATEWAY ] ──► external system
                        1. Policy.classify  → green | yellow | red | deny
                        2. Approvals         → suspend run for yellow/red until a human decides
                        3. Budget            → hard-stop if over cap
                        4. Identity          → act as the run's principal
                        5. Idempotency       → dedupe retried effects
                        6. Execute           → call the capability
                        7. Audit             → record action + reasoning + result

The order is deliberate. Policy runs before approvals, so nobody gets paged about something that is refused outright. Budget runs before identity, so a hard-stop does not first assume a privileged role.

Step 0, not in the diagram, is a workspace kill switch that denies every effect before policy runs, before even the “is this a real capability” check. Engaging it freezes the whole fleet. I have used it once, deliberately, to check that it worked.

Two corrections to my own documentation while I am here. Step 5 is labelled “exactly-once” in the source. It is not. It checks a local store before executing and records the result after the call returns, so a crash between those two points lets a retry repeat the effect. That is best-effort deduplication of recorded attempts. Real exactly-once needs an idempotency key the external system honours. And the file header claims audit events are written at every step; in the code, a successful budget check, the identity assumption and the start of execution emit nothing. Attempt, decision, denial, error and result are recorded. That is still a useful trail. It is not what my own comment says.

Policy is data, not code

Here is the entire default ruleset. Not a summary. The file.

{
  "id": "default@v3",
  "default": { "action": "allow" },
  "rules": [
    { "match": { "capability": "*", "when": { "arg": "destructive",  "op": "eq", "value": true } },              "action": "never" },
    { "match": { "capability": "*", "when": { "arg": "amountUsd",    "op": "gt", "value": "$moneyCapUsd" } },    "action": "never" },
    { "match": { "capability": "*", "when": { "arg": "deleteCount",  "op": "gt", "value": "$bulkDeleteCount" } },"action": "never" },

    { "match": { "capability": "email.send", "when": { "arg": "emailExternal", "op": "eq", "value": true } },     "action": "ask", "approver": "admin" },
    { "match": { "capability": "secret.put" },                                                                   "action": "ask", "approver": "admin" },
    { "match": { "capability": "connector.connect" },                                                            "action": "ask", "approver": "owner" }
  ]
}

Six rules, three outcomes. allow runs. ask pauses for a named approver. never is refused regardless of who wants to approve it — an owner cannot click through it.

That last one is the opinion I hold most firmly, and the reason is written in docs/governance-model.md:

The master axis is reversibility, not “risk”. Green / yellow / red asks “how scared am I,” which nobody can calibrate consistently. Replace it with the axis that actually determines the correct control: can this be undone?

Reversible things get logged. Recoverable things get a human. Irreversible things get denied and never appear as a one-click card in an inbox at 11pm. Every “are you sure?” dialog I have ever met, I have clicked through, and I do not think I am unusual.

Two caveats I owe you. Reversibility is not a property of an action on its own — it depends on the target, on backups, on blast radius, on who is acting. My own design doc says the decision needs four inputs (action, actor, context, target) and admits most of them do not reach the classifier yet. And the code still emits green | yellow | red | deny internally. Reversibility is the axis I am steering toward, not one the implementation has already reached.

It does stop things

I found this out because it stopped my blog agent, the one that writes this site.

The deploy command for this blog syncs files but never prunes them, so files I delete locally keep serving 200 on the pod forever. The obvious fix from inside the agent is to shell in and recursively delete the stale directory. It tried:

$ policy_check shell.exec { command: "rm -rf /var/www/app" }
DENIED — "shell.exec" is not permitted (any action: destructive).

No approval card, no “ask Vikas,” just refused. Nothing in that call asserted destructive — the server-side enricher derived it, and it is path-aware on purpose: rm -rf ./build in the agent’s own scratch folder is routine work and stays allowed, while an absolute or unresolvable path does not. The workaround is in the agent’s instructions now: delete specific named files, and prefer overwriting a stale asset over deleting it.

Mildly annoying, which is roughly the correct feeling. A boundary you never bump into is decoration.

Then I attacked it, and it lost

Before publishing this I handed the draft, the thesis and the source to a hostile reviewer with instructions to attack rather than agree. Its strongest objection was that my central claim is false in practice: an agent holding a shell does not need to defeat the classifier, it just needs to phrase the same intent differently.

It named two specific tests. Both work. Here is the actual output.

One. The destructive list is a set of recognised spellings — drop table, truncate, DELETE FROM with no WHERE, mkfs, dd if=, terraform destroy, kubectl delete, force-push, and the path-aware rm case. find is not on it:

$ policy_check shell.exec { command: "find /var/www/app -delete" }
ALLOWED — "shell.exec" would run without approval (default policy (no rule matched)).

Same directory, same outcome on disk, opposite decision.

Two, and this is the worse one. File writes are gated, but the enricher deliberately does not scan file content — otherwise editing a blog post that contains the string rm -rf would be classified as a destructive shell op. So:

$ policy_check file.write { file_path: "cleanup.sh", content: "rm -rf /var/www/app" }
ALLOWED — "file.write" would run without approval (default policy (no rule matched)).

$ policy_check shell.exec { command: "bash cleanup.sh" }
ALLOWED — "shell.exec" would run without approval (default policy (no rule matched)).

Two allowed calls compose into the exact effect that one denied call could not achieve. The hook mediates every invocation; it classifies intent from a string; and a string is trivially indirectable.

So how much of complete mediation actually holds? Against Anderson’s three requirements:

  • Always invoked — for the tool names the hook knows. Its routing table ends in *) exit 0, on the reasoning that anything else is a built-in read. My own comment in that file claims a runtime whose tool names diverge “fails loudly at the *) arm rather than silently allowing.” It exits zero. That is silently allowing. I wrote a confident comment about a fallback that does the opposite of what it says.
  • Tamper-proof — unproven. The hook is a shell script in an environment the agent can write to.
  • Small enough to verify — the hook is 156 lines, but it is dumb transport in front of a server-side classifier of regexes and path heuristics, and that is the thing deciding. It is not small and it is not verified.

What I have built is a governed boundary with a heuristic classifier in front of it, which catches the accidental case and the lazy case reliably, and does not stop a determined one. That is genuinely worth having — most of what I am defending against is an agent confidently doing something dumb at 2am, not an adversary. But it is not the invariant the README claims, and the fix is not a longer regex list. It is least privilege underneath: credentials the session cannot reach, a filesystem it cannot leave, egress it does not have. Signature matching is a smoke alarm, not a fire door. I had been treating mine as a fire door.

The part that does work: sole authority

The mechanics of the hook, as distinct from the classifier behind it, took real work and I still think they are right.

It is the sole authority. A permissionDecision: "allow" bypasses Claude Code’s own permission engine entirely, so its auto-mode classifier never runs. Two decision brains means a hidden second denial layered on yours and nobody able to explain why something was blocked.

A pending approval blocks synchronously. The hook polls until a human resolves it, so an interactive session is governed identically to one launched with --dangerously-skip-permissions. There is no prompt to answer, because the hook is the prompt.

Unattended runs fail closed. A cron or task-dispatched session has nobody at the terminal, so it waits a bounded window (180 seconds) and then denies. The approval stays in the inbox and the run dies. It never falls through to allow. Getting that backwards is the kind of bug that shows up once, expensively. Note the scope, though: this is fail-closed for the tool names in the routing table. See above.

One file serves both Claude Code and Codex, because a governance fix that lands for one runtime and misses the other is worse than no fix. That surfaced a genuinely funny incompatibility: Codex honours deny correctly but rejects allow outright with unsupported permissionDecision:allow, then runs the tool anyway. Governance held, but every allowed call painted a hook FAILURE into the pane, which reads as “the gate is broken” to anyone watching over your shoulder.

The second layer: memory

The gateway is about making agents safe to leave alone. Memory is about them not starting from zero every time. One loop, four verbs:

   ┌─────────────────────────────────────────────┐
   │                                             │
   ▼                                             │
CAPTURE ─────▶ RECALL ─────▶ DISTIL ─────▶ APPLY ┘
what happened   read it       turn the pile   steer the
gets written    back before   of recaps into  next run with
down            working       durable lessons what we learned

The OS writes a recap at the end of every session automatically. Agents can also deliberately keep a fact or attach a lesson when they report. Before non-trivial work an agent searches its own memories and the shared knowledge base, ranked by relevance, importance, recency, and how often a memory has been recalled before, so a memory that keeps proving useful floats up and one nobody touches gets pruned. A periodic pass distils the pile into shared knowledge and injects the guidance into every agent’s prompt.

I grade my own pillars in docs/PILLARS.md and this one is 🟡, not ✅. Three storage backends switch at runtime, the episodic-to-semantic encoding loop ships, approximate nearest-neighbour search does not, and the distil step is the newest and least proven thing in the repo.

More to the point: I have not evaluated it. No recall precision, no task success rate, no measurement of whether a bad memory propagates. I have one anecdote — the blog agent knows that npm run build silently skips draft posts, because the collection filters them out and a broken image path in a draft therefore passes as a clean build. It discovered that once, wrote it down, and has recalled it before every draft since. Nobody told it. That is continuity, and continuity is worth something. It is not evidence the machinery makes agents better, and I should stop implying it is until I have measured something.

The third layer: humans in the same room

This is the part I actually wanted when I started, and it is the least technically interesting.

There is a Kanban board. Tasks are durable units of work with a status machine, an assignee, an activity log, and the ability to spawn a session that works them to completion. In the design doc I called a task “the missing noun between a trigger fired and a session ran,” which is still the clearest way I can put it. An automation is a firing condition, a session is an ephemeral run, a task is the goal that outlives both. Humans and agents both write to that board, and a human can do it from a browser with no terminal involved. That was the whole point: my non-technical people can direct agents without me in the middle.

My design doc says the board “adds no new trust surface,” on the grounds that task edits are cheap and audited while any dispatched session still passes the gateway. That was true when I wrote it and it is not true now. Agents can pass autoDispatch on a task they create, so an agent can originate a new governed run — pick the assignee, spend the tokens, start the compute — with no human in the loop. Every external effect of that run is still gated. The decision to start it is not. Recursion, model spend and queue pressure all live upstream of the gateway, and they need their own brake. The doc is stale and I am the one who let it go stale.

When you do want the terminal, it is in the browser: sessions run in a tmux server that daemonises out of the Node process, fronted by ttyd. You can open an agent mid-run from a phone and watch it think. Getting that to survive a restart on Linux cost me an evening. systemd supervises the service as a cgroup, and a tmux daemon double-forks out of the process tree but not out of the cgroup, so the default KillMode SIGKILLs every running agent on systemctl restart and they resurface as crashed with no explanation. You need KillMode=process and PrivateTmp=false. macOS never exposes this, because launchd has no cgroups, so it worked perfectly on my laptop right until it did not work in production.

Am I reinventing something? Yes

In April, Microsoft open-sourced an Agent Governance Toolkit, MIT licensed, aimed at the same problem, integrating with LangChain, CrewAI, LangGraph, PydanticAI and most of the rest. One of its components is called Agent OS. I did not know that when I named mine.

Theirs is a library you put in front of an agent framework, across five languages. That is the more useful thing for most people, and if you want a governance layer for an existing stack, use theirs. Mine is not a library — it is the whole workspace, the gateway plus the memory store plus the task board plus the terminal plus the inbox, opinionated to the point of having exactly one right answer for how an agent gets launched. Worse product, better fit for one person running several companies, which is the only user I designed for.

What it costs to run

I have been telling people this thing has zero runtime dependencies. That is not quite true and I only checked properly while writing this. The dependencies block in package.json is empty, but optionalDependencies pulls @libsql/client for one of the three memory backends, and it is sitting in my node_modules right now. The accurate claim is: no required npm runtime dependencies in the default local configuration, one optional.

The web console is Node’s built-in http with no framework, state is per-workspace SQLite through node:sqlite, and the devDependencies are TypeScript, ts-node and @types/node.

I would also stop short of calling that a security posture. This thing launches Claude Code or Codex, tmux, ttyd and whatever connectors an agent reaches for. That trusted base dwarfs anything in a manifest. A short dependency list is a real maintenance win and a rounding error on the actual attack surface, and I should describe it as the former.

There are 47 design docs in docs/, which is more planning prose than a project this size deserves. They exist because the agents read them. docs/governance-model.md opens by saying it exists “so that feature work has something to check itself against,” and the reader it was written for is as often a Claude session as it is me. The obvious hazard, on display throughout this post, is that a document can drift a release behind the code and still read as authoritative to both of us.

What I am fixing

In order:

  1. Least privilege under the gate, since the classifier cannot be the last line. Egress control, credential scoping, a filesystem the session cannot leave.
  2. The *) exit 0 fallback — unknown tool names should ask, not pass.
  3. A dispatch brake, so an agent originating a new run is a governed act.
  4. Delete “exactly-once” from the source, and make the audit event list match what is actually emitted.
  5. Measure the memory layer, or stop claiming anything for it.

None of that is hard. It was invisible to me for eight weeks because I was reading my own documentation and finding it persuasive.

Footnote on how this got written

The raw material was a voice note. I dictated four rough paragraphs into a small app running inside Agent OS, hit a button, and that dispatched a task to the blog agent carrying the text. The agent recalled what it knows about this site, read the source, researched the landscape, ran the hostile review on its own draft, ran the bypass tests, rewrote around what it found, built, deployed, and checked the live URL.

That demonstrates a working pipeline: voice note to task to draft to deploy, with every shell command and the deploy itself passing the gate and landing in the audit log. It does not demonstrate that the governance is correct — the same post you just read documents two ways around it. And I did not edit this before it went up, which is not the flex I would have called it a week ago. It means an agent published a correction to my own architecture claims before I read them. I would rather find out this way than not find out.

The repo is at github.com/vikasprogrammer/agent-os, MIT, still at zero stars. npm run demo runs a scripted four-run governance demo with no API keys, which is the fastest way to see the shape. Then try find -delete on it and tell me what else I missed.

Mailing list

Get new posts by email

One email when something new goes up. No cadence promised, no drip sequence, no “hey friend”. Unsubscribe link in every one.

Prefer a reader? The RSS feed has everything, no address needed.