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?
-
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.
-
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.
-
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.
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.
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).
EndoGuest becomes a standalone interface (no longer extends EndoAgent / EndoDirectory). The host path is unchanged.
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
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
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.
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).
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 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.
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: FormulaIdentifiertype 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 }> };messageId(FormulaNumber) -- internal plumbingfrom/to(FormulaIdentifier) -- replaced byfromHandle+fromNamesidsarray on Package messages -- guest accesses values viaadopt(number, edgeName, petName)promiseId/resolverIdon request types -- internal resolution plumbing
fromHandle: the daemon callsprovide(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 withequals(), and use withE().fromNames: the daemon callspetStore.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.
A helper in guest.js transforms each StampedMessage to GuestMessage:
provide(message.from, 'handle')to getfromHandlepetStore.reverseIdentify(message.from)to getfromNames- Strip
messageId,from,to,ids,promiseId,resolverId - Pass through safe fields
No changes to mail.js internals. The host path and daemon internals continue using StampedMessage with full identifiers.
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.
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.
| 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 |
| 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 |
| 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 |
-
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
makeExopatterns. -
Live references over designators. Guests work with capability references (
Farobjects), not string identifiers.write(name, value)andequals(a, b)operate on the things themselves, not their addresses. -
Handles are real capabilities.
fromHandleon messages is the actualFar<Handle>of the sender, not a wrapper or token. The guest can useE()on it and compare it withequals(). -
Failure doesn't leak.
equals()returnsfalsefor untracked values.write()throws a generic error. Neither reveals information about the daemon's internal identity system. -
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.
The
writemethod doesn’t need to exist at all, because the signature is satisfied bystoreValue. We should make sure guests have that.