Skip to content

Instantly share code, notes, and snippets.

@zmanian
Created February 14, 2026 06:25
Show Gist options
  • Select an option

  • Save zmanian/1d5d0dcaff2b8c7001d968f786cc3751 to your computer and use it in GitHub Desktop.

Select an option

Save zmanian/1d5d0dcaff2b8c7001d968f786cc3751 to your computer and use it in GitHub Desktop.
Endo Guest Confinement Design: Deny identifiers, work in petnames and live values

Guest Confinement: Deny Identifiers, Work in Petnames and Live Values

Motivation

From Kris Kowal:

We should probably deny "guests" the use and visibility into identifiers/locators and just make them work in terms of petnames and live values. That may require us to introduce E(guest).equals(a, b) and to create more methods that operate on values (where the engine looks up the identifier behind the scenes). This would be consistent with MarkM's "distributed confinement" notions, where the confined applications don't get to see swissnums and addresses. This is also consistent with our value proposition: Giving an LLM cryptographic data is bad. Providing fake cryptographic data as a stand-in for real cryptographic data is cute, but why do that when you can give the LLM the ability to choose its own names and never expose them to large, immemorable numbers?

Why this matters

  1. Distributed confinement. MarkM's principle: confined programs should not see Swiss numbers or addresses. An identifier IS authority in a distributed capability system. Leaking it to a guest enables out-of-band exfiltration.

  2. LLM safety. 128-character hex strings in an LLM's context window invite hallucination, confusion, and accidental leakage. Petnames are human-friendly, memorable, and locally scoped.

  3. Minimal surface area. The fewer methods a guest has, the harder it is to misuse. The guest's world should be petnames and live values, nothing more.

Design Approach: Attenuation, Not Forking

Following MarkM's preference, confinement is achieved by attenuating the existing directory, not by creating a parallel type hierarchy. The guest is built from the existing EndoDirectory internals but only exposes the safe subset. No new ConfinedNameHub or ConfinedDirectory types.

The makeGuest function in guest.js already destructures the directory and cherry-picks methods. It simply stops picking the identifier methods and wraps write with value-to-id resolution.

Current Identifier Leak Surface

8 methods currently leak identifiers/locators to guests:

Method What it leaks
identify(...petNamePath) Returns FormulaIdentifier (128-char hex pair)
locate(...petNamePath) Returns locator URL containing the identifier
reverseIdentify(id) Takes a FormulaIdentifier, returns petnames
reverseLocate(locator) Takes a locator string, returns petnames
listIdentifiers(...petNamePath) Returns array of FormulaIdentifier strings
followLocatorNameChanges(locator) Takes a locator, streams name changes
write(petNamePath, id) Accepts a FormulaIdentifier parameter
Message from/to fields Raw FormulaIdentifier on every message

Additionally, message objects expose messageId (FormulaNumber), ids array (Package), promiseId/resolverId (request types).

Section 1: New Guest Interface Shape

EndoGuest becomes a standalone interface (no longer extends EndoAgent / EndoDirectory). The host path is unchanged.

Methods retained (petname/value operations)

has(...petNamePath): Promise<boolean>
list(...petNamePath): Promise<Name[]>
followNameChanges(): AsyncGenerator<PetStoreNameChange>
lookup(petNamePath): Promise<unknown>
reverseLookup(value): Promise<Name[]>
remove(...petNamePath): Promise<void>
move(fromPath, toPath): Promise<void>
copy(fromPath, toPath): Promise<void>
makeDirectory(petNamePath): Promise<EndoDirectory>
help(topic?): string

Methods removed (identifier exposure)

identify(...petNamePath)          -- returns FormulaIdentifier
locate(...petNamePath)            -- returns locator string
reverseIdentify(id)               -- takes FormulaIdentifier
reverseLocate(locator)            -- takes locator string
listIdentifiers(...petNamePath)   -- returns FormulaIdentifiers
followLocatorNameChanges(locator) -- takes locator string

Methods changed

write(petNamePath, value) -- takes a live value instead of a FormulaIdentifier.

The guest implementation calls getIdForRef(value) internally to resolve the value back to its identifier, then delegates to the underlying directory's write().

const write = async (petNamePath, value) => {
  const namePath = namePathFrom(petNamePath);
  assertPetNamePath(namePath);
  const resolvedValue = await value;
  const id = getIdForRef(resolvedValue);
  if (id === undefined) {
    throw new TypeError('Cannot name a value that is not a known reference');
  }
  await directory.write(namePath, id);
};

Error message is deliberately vague -- does not reveal whether the value is "tracked" or anything about internal state.

Methods added

equals(a, b): Promise<boolean> -- identity comparison on live values.

const equals = async (a, b) => {
  const resolvedA = await a;
  const resolvedB = await b;
  const idA = getIdForRef(resolvedA);
  const idB = getIdForRef(resolvedB);
  if (idA === undefined || idB === undefined) {
    return false;
  }
  return idA === idB;
};

Design decisions:

  • Returns false (not an error) when a value isn't tracked. Avoids leaking information about whether a value is "known" to the daemon.
  • Awaits both arguments -- guests work with promises from E() calls.
  • String comparison on FormulaIdentifiers is correct since they're canonical ({number}:{node}).
  • Not added to the host interface (hosts can compare identifiers directly).

Type hierarchy

NameHub -> EndoDirectory -> EndoAgent -> EndoHost   (unchanged, full identifier access)

EndoGuest   (standalone interface, attenuated subset)

EndoGuest no longer extends EndoAgent. This is honest: a guest is not a directory, it's a confined agent that happens to expose some directory-like operations.

getIdForRef threading

getIdForRef is on DaemonCore. It needs to be added to the arguments of makeGuestMaker (currently receives provide, formulateMarshalValue, makeMailbox, makeDirectoryNode). It's the same provenance as the other daemon core functions.

Section 2: Message Shape for Confined Guests

Current message types (identifier-heavy)

EnvelopedMessage = Message & {
  to: FormulaIdentifier;
  from: FormulaIdentifier;
};

StampedMessage = EnvelopedMessage & {
  number: bigint;
  date: string;
  dismissed: Promise<void>;
  dismisser: ERef<Dismisser>;
};

MessageBase = { messageId: FormulaNumber };

Package = MessageBase & {
  type: 'package';
  strings: string[];
  names: Name[];                  // edge names (safe)
  ids: FormulaIdentifier[];       // leak!
};

// EvalRequest, DefineRequest, FormRequest all carry:
//   promiseId: FormulaIdentifier
//   resolverId: FormulaIdentifier

New guest-visible message type

type GuestMessage = {
  number: bigint;
  date: string;
  type: string;
  fromHandle: FarRef<Handle>;     // sender's actual handle capability
  fromNames: Name[];              // guest's petnames for sender (may be empty)
  dismissed: Promise<void>;
  dismisser: ERef<Dismisser>;
} & GuestMessageContent;

type GuestMessageContent =
  | { type: 'request'; description: string }
  | { type: 'package'; strings: string[]; names: Name[] }
  | { type: 'eval-request'; source: string; codeNames: string[]; petNamePaths: NamePath[] }
  | { type: 'definition'; source: string; slots: Record<string, { label: string; pattern?: unknown }> }
  | { type: 'form-request'; description: string; fields: Record<string, { label: string; pattern?: unknown }> };

What's stripped

  • messageId (FormulaNumber) -- internal plumbing
  • from / to (FormulaIdentifier) -- replaced by fromHandle + fromNames
  • ids array on Package messages -- guest accesses values via adopt(number, edgeName, petName)
  • promiseId / resolverId on request types -- internal resolution plumbing

What's added

  • fromHandle: the daemon calls provide(fromId, 'handle') to get the actual Far reference to the sender's handle. This is a real capability reference, not a token. The guest can hold it, compare with equals(), and use with E().
  • fromNames: the daemon calls petStore.reverseIdentify(fromId) to resolve all petnames the guest has for the sender. If the sender is the guest itself, this includes 'SELF'. If the sender is the host's handle, this includes 'HOST'.

No toHandle/toNames -- the recipient is always the guest itself (messages in its own mailbox), so it would always be 'SELF'. Redundant.

Transformation layer

A helper in guest.js transforms each StampedMessage to GuestMessage:

  1. provide(message.from, 'handle') to get fromHandle
  2. petStore.reverseIdentify(message.from) to get fromNames
  3. Strip messageId, from, to, ids, promiseId, resolverId
  4. Pass through safe fields

No changes to mail.js internals. The host path and daemon internals continue using StampedMessage with full identifiers.

Impact on llamadrome

Current self-message filtering:

const selfId = await E(powers).identify('SELF');
// ...
if (fromId === selfId) continue;

Becomes:

if (message.fromNames.includes('SELF')) continue;

Or using equals():

const selfHandle = await E(powers).lookup('SELF');
if (await E(powers).equals(message.fromHandle, selfHandle)) continue;

The petname check is simpler and sufficient.

Section 3: interfaces.js Changes

GuestInterface M.interface

export const GuestInterface = M.interface('EndoGuest', {
  help: M.call().optional(M.string()).returns(M.string()),
  // Confined directory (petname/value only)
  has: M.call().rest(NamePathShape).returns(M.promise()),
  list: M.call().rest(NamePathShape).returns(M.promise()),
  followNameChanges: M.call().returns(M.remotable()),
  lookup: M.call(NameOrPathShape).returns(M.promise()),
  reverseLookup: M.call(M.any()).returns(M.promise()),
  write: M.call(NameOrPathShape, M.any()).returns(M.promise()),  // M.any() not IdShape
  remove: M.call().rest(NamePathShape).returns(M.promise()),
  move: M.call(NamePathShape, NamePathShape).returns(M.promise()),
  copy: M.call(NamePathShape, NamePathShape).returns(M.promise()),
  makeDirectory: M.call(NamePathShape).returns(M.promise()),
  equals: M.call(M.any(), M.any()).returns(M.promise()),         // new
  // Mail
  handle: M.call().returns(M.remotable()),
  listMessages: M.call().returns(M.promise()),
  followMessages: M.call().returns(M.remotable()),
  resolve: M.call(MessageNumberShape, NameOrPathShape).returns(M.promise()),
  reject: M.call(MessageNumberShape).optional(M.string()).returns(M.promise()),
  adopt: M.call(MessageNumberShape, NameOrPathShape, NameOrPathShape).returns(M.promise()),
  dismiss: M.call(MessageNumberShape).returns(M.promise()),
  request: M.call(NameOrPathShape, M.string()).optional(NameOrPathShape).returns(M.promise()),
  send: M.call(NameOrPathShape, M.arrayOf(M.string()), EdgeNamesShape, NamesOrPathsShape)
    .returns(M.promise()),
  requestEvaluation: M.call(M.string(), M.arrayOf(M.string()), NamesOrPathsShape)
    .optional(NameOrPathShape).returns(M.promise()),
  deliver: M.call(M.record()).returns(),
});

DirectoryInterface and HostInterface are unchanged. Hosts retain full identifier access.

Section 4: Files Changed

Must change

File Change
packages/daemon/src/interfaces.js Remove 6 methods from GuestInterface, change write param, add equals
packages/daemon/src/guest.js Add getIdForRef param, implement write(value) and equals(a,b), add message transformation, stop destructuring identifier methods from directory
packages/daemon/src/types.d.ts Make EndoGuest standalone (not extend EndoAgent), add GuestMessage type, change write signature
packages/llamadrome/llm-agent.js Replace identify('SELF') + id comparison with fromNames.includes('SELF')
packages/cli/src/commands/inbox.js Replace reverseIdentify(from) with reading fromNames from message

Must check / may need changes

File Reason
packages/daemon/src/daemon.js Where makeGuestMaker is called -- needs to pass getIdForRef
packages/daemon/src/mail.js Internal -- should be unchanged, but verify deliver path
packages/cli/src/commands/invite.js Uses locate() on an invitation, not on a guest -- should be unaffected
packages/daemon/test/endo.test.js Tests that exercise guest identifier methods need updating
packages/llamadrome/system-prompt.js Already omits identifier methods, but should document equals() and new write() semantics

Unchanged

File Reason
packages/daemon/src/directory.js Internal directory implementation, no changes needed
packages/daemon/src/host.js Host retains full identifier access
packages/daemon/src/pet-store.js Internal storage, unchanged
packages/daemon/src/pet-sitter.js Internal wrapping, unchanged
packages/daemon/src/locator.js Used by host/directory, not guest
packages/daemon/src/mail.js Internal mail system, unchanged

Design Principles

  1. Attenuation over forking. The guest is a restricted view of the existing directory, not a parallel implementation. Following MarkM's membrane philosophy adapted to Endo's makeExo patterns.

  2. Live references over designators. Guests work with capability references (Far objects), not string identifiers. write(name, value) and equals(a, b) operate on the things themselves, not their addresses.

  3. Handles are real capabilities. fromHandle on messages is the actual Far<Handle> of the sender, not a wrapper or token. The guest can use E() on it and compare it with equals().

  4. Failure doesn't leak. equals() returns false for untracked values. write() throws a generic error. Neither reveals information about the daemon's internal identity system.

  5. Clean break. Identifier methods are removed entirely from the guest interface, not deprecated. The consumer surface is small enough (llamadrome, 2 CLI commands, tests) to migrate in one pass.

@kriskowal
Copy link

The write method doesn’t need to exist at all, because the signature is satisfied by storeValue. We should make sure guests have that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment