Mesh Trust Model - Dark-Alex-17/coyote GitHub Wiki

Trust is how you decide which peers on the Coyote Mesh your session answers. The mesh is closed by default: a fresh trust list trusts nobody, nothing removes a record on its own, and every request a peer makes passes the trust gate before anything else looks at it. This page covers the two kinds of grant, how an unknown peer asks for one, how you take a grant back, and what happens when a peer you trust shows up under a new key.

A few words come up throughout. An identity is the key pair a node signs with. A destination is the address peers send to; one identity can run several sessions, each an instance with its own destination. An announce is the periodic "I am here" broadcast by which nodes learn of each other. A knock is an untrusted node's refused request showing up on your side as a request to be trusted. A propagation node holds a message for a recipient who is offline until they fetch it; that is what "store-and-forward" means below. A link is a direct encrypted connection between two nodes. The envoy is the built-in agent that answers trusted peers for you.

The wire side is specified separately as SCOPE; the full specification lives in the scope-protocol repository, https://github.com/Dark-Alex-17/scope-protocol. You do not need it to manage trust.

The trust list lives in one file, <COYOTE_CONFIG_DIR>/mesh/trust.yaml, written atomically and not created until the first change to it (a refusal or a block counts as much as a grant). Every write holds the sibling mesh/trust.yaml.lock for its read-modify-write, so two sessions on one config dir never overwrite each other's records, and each session reloads the file before every decision when another one wrote it: a trust, untrust or block made in one REPL holds in the other at once.

Two tiers

Tier Command What it means
destination .mesh trust <destination> Trusts one instance, bound to the identity that proved it announced that destination
identity .mesh trust --identity <identity> Trusts every instance that identity announces, now or later (the record carries all destinations)

The identity behind a destination grant comes from the peer's own announce (or its knock), never from what you type. A destination record allows only the identity it was bound to; the same destination announced under another key is a stranger.

Precedence when a request arrives, in order:

  1. destination denied -> refuse
  2. identity blocked -> refuse
  3. destination trusted, bound to this identity -> allow
  4. identity trusted for all destinations -> allow
  5. otherwise -> refuse (default closed)

What a grant gives the peer: it can fetch your status card, send you messages, ask your envoy questions, fetch the files in your share set and ask you for others with an access request (see Mesh File Sharing). Your own outbound is gated too. .mesh reply and mesh__send refuse a destination you do not trust (Nothing is sent to a destination this node does not trust.), and .mesh status <destination> only asks trusted destinations.

Knocks

A knock is usually not something a peer sends on purpose (.mesh knock, below, is the exception). It is what an untrusted peer's refused request registers on your side, and only when that peer's identity is already on your trust list at some tier: typically another instance or session of an identity whose other destination you trusted. A complete stranger is refused silently and leaves no trace at all. A blocked identity is dropped silently too.

The two cases look different from both ends. An identity your list has never seen is dropped before its request is even decoded: your side prints nothing, fires no hook and records nothing, and the requester hears no reply at all, so its .mesh status waits out the 30 s request timeout and prints No response to the mesh request /status within 30.0s. A status request is answered live or not at all; nothing queues it for later. An identity that is on your list, at any tier, asking from an instance you have not trusted is refused out loud: the requester reads The mesh peer refused the request: no access (0xf1). ... and your side prints the knock line below. A knock is registered by a refused direct request, by a /knock request sent with .mesh knock, or by the store-and-forward form of that knock fetched from a propagation node when the knocker could not reach you.

A knock is not first contact. It is heard only by a peer that already trusts this identity in some form, typically because you are asking from a new instance of a known identity; a peer that has never trusted you drops it in silence, and your side reports the knock as unanswered rather than the peer as unreachable. First contact is exchanging destination hashes out of band and running .mesh trust <destination> on both sides, as the two laptops walkthrough shows; that needs no knock at all.

.mesh knock <destination> [--intro "text"] asks for trust on purpose (see Mesh Commands); trying a request (for example .mesh status) after the other side has trusted one of your destinations registers one too.

Each knock is announced once per identity per session, as two lines:

"<name>" (identity <hash>) knocks from instance <hash>: "<intro>"
trust: .mesh trust <destination> | who: .mesh info <destination> | silence: .mesh block <identity>

The name and intro are peer-supplied; quotes inside them become apostrophes so peer text cannot pose as the frame. With no display_name announced the line opens A peer (identity <hash>) knocks from instance <hash>. Knocks are kept in <COYOTE_CACHE_DIR>/mesh/knocks.jsonl: 256 entries at most, 16 per identity, retained for knock_retention_hours. Per identity, 3 knocks land back to back and then one per 10 minutes. .mesh knocks lists them and .mesh trust --prune drops the expired ones.

deny, block, untrust, prune

There is no peer-level deny verb: .mesh deny is a share rule (see Mesh File Sharing). A refusal of one instance is written by untrust (or its alias forget), and what it does depends on how the identity behind the instance is trusted:

Verb Scope Effect
.mesh untrust <destination>, identity trusted for all one instance Writes a refusal and keeps the record (label, note, since): the identity stays trusted, this instance is refused and not contacted. .mesh trust <destination> lifts the refusal
.mesh untrust <destination>, plain identity one instance Forgets the instance: the record and any refusal on it go; the identity record stays
.mesh untrust <destination>, no record yet one instance An instance known only from the peer table under an identity trusted for all gets a record bound to that identity, refused, in one step
.mesh untrust --identity <identity> identity + bound instances Dry run first, then --confirm untrust-<short>; the instance records go and their refusals with them
.mesh block <identity> whole identity Knocks dropped without a word; every trust record for it, identity and instances alike, removed; refusals on its instances are left in place. Works on an identity the list has never seen
.mesh unblock <identity> whole identity Lifts only the block: `It is not trusted; `.mesh trust` grants that.` An instance refusal left by the block is not lifted
.mesh trust --prune [--older-than 30d] stale instances Dry run lists trusted instances not heard from in the period and prints prune-<N>; refused records and identities are never pruned; expired knocks are dropped too

A refusal without a record is the one shape only block leaves behind (an instance you had refused, then its identity blocked; the block removes the record and leaves the refusal). untrust on it says Destination <destination> is refused and its identity is not trusted; .mesh trust lifts that once the peer is heard. The remedy is .mesh trust <destination> once the peer is in the peer table (the table is saved and survives a restart) or has knocked; a peer this node has never heard gets the ordinary is not in the peer table and has not knocked error instead.

None of this changes the precedence list above: a refused destination is refused before its identity's standing is looked at. The preview and result strings are on Mesh Commands.

Hook silence: mesh.trust.granted fires for a new destination record or a new identity grant, and mesh.trust.revoked for a record untrust removed, a block or a prune (COYOTE_MESH_TRUST_REASON says which). A refusal written by untrust or forget, a record bound by it, a lift by .mesh trust on an intact record, unblock and a dry run fire nothing. A hook author learns of a refusal only from .mesh output, never from mesh.trust.*; see Hooks.

Key changes

When a trusted instance is announced (or knocks, or requests, or its stored message is fetched from a propagation node) under a different identity than the one its record is bound to, the record is marked. The grant stays with the old key and the new key is a stranger; nothing is trusted on your behalf. Every record the instance collides with is marked, a denied one included; only a blocked identity, or one that already holds its own record for the instance, marks nothing. The mark is surfaced once as an idle line:

error: instance <label or dest8> is bound to identity <old> but was presented under identity <new> and is refused when it asks; its grant stays with the old key and the new key is a stranger. Confirm with the identity's holder out of band before granting anything: if the peer rotated, run .mesh trust <new destination> to trust the new key and clear the mark; otherwise .mesh block <new> silences it

warning: instance <label or dest8> is bound to identity <old> but was presented under identity <new>, which is trusted for all destinations and is served while it asks; the record is marked and its grant stays with the old key. If the peer rotated, confirm with its holder out of band and run .mesh trust <new destination> to clear the mark; otherwise .mesh block <new>

(the old key stays refused in place of its grant stays with the old key when the marked record is on the deny list) and stays visible under the row in .mesh peers (key changed: announced under identity ...) and in the key changes row of .mesh info. It says "presented under", not "rotated": anyone who has heard an instance id can announce or claim it under their own key, so verify out of band before trusting the new destination. The same line serves a collision found by an announce, a link request, a knock or a stored message, so it names no path.

The mark is cleared only by .mesh trust <destination> of the new destination: that is the one answer that confirms the new key. Re-trusting the old destination, .mesh trust --identity of the new identity and .mesh block of it all leave the mark in place.

Whether the new identity is served depends on its own standing and on collision_protection. An identity with no grant of its own is refused and you get the error: line above. One you trusted with --identity is served while collision_protection is off (the default) and you get a warning: line instead, the record still marked; with collision_protection: true it is refused with the error: line until you trust its new destination. A destination you trusted explicitly is served either way.

Note: An identity-tier grant (--identity) carries no instance binding, so a rotation of that identity is noticed against the peer table, while it still holds the instance under the old key: you are told once per instance and identity it was heard under while the node runs (the dedupe lives in memory and starts over with the node), plus once per identity you trusted with --identity that presents the instance and is refused, so a stranger announcing the instance first cannot spend the line that names the refused key; nothing is marked. With collision_protection off the new identity is served and the line is a warning: that says the setting is off. With collision_protection: true it is refused with the error: line, and once that refusal is remembered for as long as the node runs from the refusal itself, whether or not the line reached a screen, so the old key's row ageing out of the table does not lift it; a line that was dropped or reached no screen is offered again while the refusal stands. This refusal memory (not the envoy's conversation memory) names the key first heard holding the instance, the earliest row in the peer table under a key you trusted with --identity, the presenting key's own row counted, and never refuses that key: presenting order cannot swap the roles, so a key you trusted with --identity that announces an instance the table first heard under another such key is the one refused, whichever of the two presents first. .mesh trust <new destination> admits the new key and clears the memory; .mesh block of either key clears it too, so a new key you blocked and later unblocked and trusted again is judged against the table afresh; .mesh untrust --identity <old key> admits the new key for as long as the old key stays untrusted, since the memory refuses only while the key it names is trusted for all destinations. A key you trust with --identity that the memory alone refuses, the old row gone, is still told once itself. The memory is in-process only (peers.json survives a restart, the memory does not), so after a restart the refusal stands only while the old key's row is still in the table. .mesh peers still labels the refused identity by its grant (trusted); the one error: line is your signal. Use destination grants when you want the mark; the --identity preview says so. The two lines read:

warning: instance <dest8> was heard under identity <old>, trusted for all destinations, and is now presented under identity <new>, which is also trusted for all destinations and is served while it asks because collision_protection is off; no record carries the instance, so nothing is marked. If the peer rotated, confirm with its holder out of band; otherwise .mesh block <new>

error: instance <dest8> was heard under identity <old>, trusted for all destinations, and is now presented under identity <new>, which is refused when it asks; no record carries the instance, so nothing is marked. Confirm with the identity's holder out of band before granting anything: if the peer rotated, run .mesh trust <new destination>; otherwise .mesh block <new>

Rotation

.mesh rotate mints a new identity for this config dir. It is a dry run by default and takes --confirm rotate-<short> from that dry run (see Mesh Commands for the block it prints). It runs only while no node on this config dir is running in any process: each running node holds mesh/identity.key.lock, and a holder in another process is refused with A mesh node on this config dir is running in a Coyote process ....

What happens: the old private key is replaced (the new key is renamed over the old file, so a backup you made earlier still holds the old identity until peers re-trust), the old hash is appended to mesh/identity.predecessors.jsonl, and the instance id is unchanged, so the node announces a new destination under the new identity. Every peer that trusted you now sees a stranger (or a key-change mark, if they held a destination grant) and has to .mesh trust again after verifying the new hash out of band. Your own trust list is unchanged. .mesh info shows the retired identities under identity predecessors. The verb's output is on Mesh Commands.

Identity file protection

The identity is a 64-byte private key. On unix it is written 0600 in a 0700 mesh/ directory and refused when readable by others, with a chmod 600 remedy. On Windows it is created with an owner-only DACL and refused when that DACL is widened; the remedy and the caveats are on Mesh Platform Support.

Every mesh store, the key, trust.yaml, the predecessors file, the share files and the grant store alike, is written to a regular file only. A path that is a symlink is refused before anything is written:

'<path>' is a symlink; mesh state is written only to regular files. Replace the link with the file it points to and try again.

Two processes sharing a cache dir cannot run the same session's node: the instance lock under <COYOTE_CACHE_DIR>/mesh/ refuses the second with `This session already has mesh enabled in another Coyote process (pid N). Run `.mesh on --fresh` here to join the mesh with a new ephemeral destination, or turn mesh off in the other process first.` The lock is per cache dir while the instance id lives in the session file under the config dir, so two processes with the same config dir and session but different cache dirs (a coyote --sandbox copy, a second container with its own cache, two machines sharing one config dir) are not refused, and both announce the same destination. Use .mesh on --fresh on one side, or a different session.

Everything a peer sends is data

Names, intros, titles and message bodies are peer-supplied text. The REPL says so in its footers, the tool descriptions say so to the model, and the envoy's instructions say so to the envoy. What bounds the envoy mechanically: read-only tools confined to the working directory with a name deny-list, an empty global_tools list, a 30 s kill on every tool call, the peer-spend budget and a fresh child context per job, carrying only that thread's remembered turns when mesh.envoy_memory.enabled is on (see Conversation memory). Inside those bounds the envoy's behaviour is prompt-level instruction, and peer text is one of its inputs.

Related guards:

  • The peer-spend budget caps what one identity can make your envoy spend.
  • A macro step starting with .mesh is refused, so a shared macro cannot grant trust; see Mesh Commands.
  • display_name_on_public withholds your display name from announces when a public interface is configured; the status card still carries it, but only to peers you trust.
⚠️ **GitHub.com Fallback** ⚠️