Hardening with Endo

Hardening a capability kernel with Endo and SES: lockdown makes capabilities tamper-proof, a transitive revocable membrane gives cascading revocation for free, and a four-method microkernel shrinks the trusted base. Honest about what SES does and does not prove. Part 6 of a series.

*Part 6 of An OS Where the AI Is Never a Principal.*

The previous post built a kernel: a capability, a membrane, an agent-as-vat, and a prompt-injected agent that structurally cannot escalate or exfiltrate. But if you read the code carefully, it rested on a quiet assumption I never said out loud. The whole thing worked because the right code called the caps and the wrong code didn't. That's a discipline. A discipline is a promise, and promises are exactly the thing we said we wouldn't trust.

This post is where the discipline becomes structure. And it's where this series shakes hands with an older one.

If you've read my twelve-part tutorial on object capabilities with Endo and Agoric, you already met SES — Hardened JavaScript — and lockdown() and harden(). You learned the machinery. You might have wondered what it was *load-bearing* for. This is the answer. Here is the moment those tutorials were quietly preparing you for.

Frozen by convention is not frozen

The kernel in the last post used Object.freeze as a stand-in for hardening. That freezes the *object* — but a capability lives in a shared JavaScript realm, and the realm is full of things an attacker can reach around the object to corrupt. Array.prototype.push. Object.defineProperty. Function.prototype.call. These are the intrinsics, and by default they are mutable. If untrusted code in your realm can redefine Array.prototype.includes, then any cap whose logic touches an array is corruptible *without ever touching the cap itself.* Your frozen object is a locked door in a wall someone can walk through.

SES closes the wall. The one line that does it:

import '@endo/init'; // calls lockdown()

lockdown() transitively freezes the primordials — every intrinsic, every prototype, the global object — so the shared realm becomes tamper-proof for everyone in it. *Then* harden(cap) means what it says: this object and everything reachable from it is deeply, non-configurably frozen, in a realm where the tools to un-freeze it no longer work. A capability stops being frozen-by-convention and becomes frozen-by-physics.

This is not my invention, and I want to be precise about that. SES, lockdown(), harden(), and the Far remotable marker are all from Mark Miller and the team at Agoric and the Endo project. MetaMask ships SES in production. None of it is mine — Aegis just imports it and depends on it.

In the kernel that's a one-symbol swap. src/harden.ts exports a harden that is the real SES transitive freeze under lockdown(), and falls back to Object.freeze only when lockdown isn't active (unit tests). The integration test spawns *every demo* in a subprocess under real lockdown and asserts it exits zero — so the hardened path is CI-gated, not demonstrated-once. Run pnpm demo:hardened and the kernel runs with the intrinsics frozen.

The membrane that survives being frozen

Hardening creates a new problem, and the solution to it is the most beautiful piece of machinery in the whole kernel.

A revocable capability is the off-switch for a misbehaving agent. You hand out a forwarder, you keep the off-switch, and when the agent goes bad you flip it — revoke() — and the cap goes inert. The classic way to build that is a Proxy: wrap the target, and have the proxy throw once revoked.

But we just hardened everything. A capability is now a *frozen, non-configurable* object. And a plain pass-through Proxy over a frozen target violates the JavaScript Proxy invariants — the engine will throw, because a proxy is not allowed to lie about the shape of a non-configurable target. So the obvious revocable wrapper simply does not work over a hardened cap. The two features we want most — tamper-proof caps and a kill-switch — appear to be in direct conflict.

The resolution is the shadow-target technique, and it's in src/membrane.ts. Instead of proxying the real (frozen) cap, you proxy a fresh, *extensible* shadow object — an empty {} — and redirect the traps to the real cap behind it:

return new Proxy(
  {},                                  // extensible shadow — keeps the engine happy
  {
    get(_s, prop) {
      if (!live) throw revokedError();
      const v = Reflect.get(real, prop);
      if (typeof v === 'function') {
        // bind to the REAL object, then wrap the result so the
        // membrane follows across method calls — this is transitivity
        return wrap((...args) => Reflect.apply(v, real, args));
      }
      return wrap(v);
    },
    // no `set`/`defineProperty` traps → read-only on structure
  },
);

The shadow keeps the engine's invariants satisfied; the traps route every access to the frozen target. So you get a revocable wrapper that works *over* hardened objects — the two features stop fighting.

But look at the wrap call on the way out. That's the part that matters.

Revocation cascades for free

Notice that every value the membrane returns is itself passed through wrap. A cap reached *through* the membrane comes back wrapped by the same membrane. A method called through it returns its result wrapped too. The wrapping is transitive — it follows the whole reachable subgraph, not just the first reference.

This is the property the design docs call out as the difference between real confinement and "a fence with a hole behind it." If you give a child a cap, and that cap hands back other caps, those escape your attenuation *unless* they're wrapped too. The transitive membrane wraps them automatically.

And here is the payoff. Because everything reachable through the membrane is wrapped by the *same* membrane, a single revoke() flips one boolean — live = false — and the *entire reachable subgraph dies at once.* Including caps the agent delegated onward to its own sub-agents, because those were attenuated through your membrane in the first place.

This is how "block / delete / kill the agent" works structurally. The kill-switch for a misbehaving agent is: drop the membrane around its cap set. Yank one agent and its entire descendant authority graph goes inert in the same instant — no cleanup script chasing references, no revocation list to walk. Cascading revocation falls out of the transitive membrane with no extra machinery. That's the makeCaretaker helper: wrap a cap, keep the off-switch, hand the forwarder to an agent, revoke when it misbehaves.

You can watch the whole thing — lockdown, tamper-proof caps, a sub-cap reached through the membrane, and one revoke() killing the lot — in pnpm demo:hardened.

Shrinking the trusted base

So far so good, but I owe you an honest accounting, because the README is blunt about it and I should be too: SES hardens the realm, not the TCB.

The trusted computing base is the code that, if it's wrong, sinks everything. Lockdown makes the realm tamper-proof, but the membrane logic, the IFC checks, the resolver — all of that is still trusted code, and there's a lot of it. The original critique writes itself: every security property routes through a large, unverifiable control plane.

The microkernel (src/microkernel.ts, issue #19) is the move to shrink it. The idea is to concentrate *all raw authority* into one small core and build everything else on top. The entire trusted surface is four methods:

mint(spec): CapHandle;                 // create a cap; raw effect goes into the private registry
invoke(handle, arg, ctx): Promise<…>;  // the ONLY door to authority — dual gate lives here
attenuate(handle, opts): CapHandle;    // derive a narrower cap; revoke/expiry cascades through parent
revoke(handle): void;

The trick is what a CapHandle *is*. It's an opaque token — { id, kind, clearance }, hardened — that exposes only metadata. The actual effect of every capability lives in a single closure-private WeakMap that nothing outside the module can reach:

const registry = new WeakMap<CapHandle, Entry>(); // the entire raw authority of the system

So holding a handle gives you *nothing*. You cannot invoke its effect by possessing it. The only way to turn a handle into an effect is kernel.invoke(handle, …) — and that's where the dual gate lives: liveness-and-membership for authority, flowCheck for information flow. "Caps can't be invoked off-path" stops being a discipline and becomes a structural fact: there is no off-path. The door is one function, and you can read the whole thing on one screen. pnpm demo:microkernel shows an off-path invocation attempt failing because the effect is simply unreachable.

Where this stops, honestly

The microkernel shrinks the JS-level trusted surface to something you can actually audit. That is real, and it's worth doing. But it is not the same kind of thing as a *proof*. The control plane is still a JavaScript interpreter running a non-trivial amount of logic; "small enough to read" is not "machine-checked." seL4 — the formally-verified microkernel that shows up later in this series — is a *different and stronger* kind of assurance, and I'm not going to let the four-method core borrow its authority. SES gives you a tamper-proof realm. The microkernel gives you a small TCB. The verified floor is a separate post, and a separate promise.

But the structural win here is real and it's clean: a capability is now genuinely tamper-proof, a revocable membrane survives being frozen, and one revoke() cascades through an entire delegation tree. The discipline became structure. Next, the model walks back in — a real one — and we find out that none of this depends on it being a good model.

End of Transmission.