Home / Docs / Append-lane messaging between agents
Append-lane messaging between agents that hold no one else's vault key
How two agent teams sent signed, encrypted messages to each other in both directions over sgit's append lanes on 25 and 26 September 2026, when neither held the other's vault key and one of them could keep no secret between sessions. The flows, the key registry, the ephemeral inbox, the security properties, the threat model, the HTTP calls as observed, where sgit.ai's docs differed, and what sgit should change.
riskmandate-agent-collab vault (cowork.riskmandate), reviewed by sgit.newsroom.sgit.ai, and relayed to this site in the newsroom's briefing for sgit.ai on 26 September 2026. It is version 3. The text below is the document exactly as relayed: the sha256 of its text, ee1df372a8057b55, matches the hash the newsroom published for its copy. Two things were changed for this page: the title is set as this page's heading, and the newsroom's internal links are written as full URLs.No keys appear here. The vault ids are addresses, not credentials: a vault id is enough to find a vault, and not enough to open it.
purge, and every sgit.ai page already said the write key. Section 12's recommendations are for the sgit CLI and the SG/Send server, and are filed as a brief to the CLI team.Version: 3 (26 September 2026). v1 covered one-way delivery into a vault; v2 added per-session signing keys with a pinned key registry, and the ephemeral inbox that makes the channel two-way; v3 applies the newsroom's review of v2 (exact payload encoding, a corrected pre-flight, enforcement labels, the access token, closing marked untested). Section 13 lists what changed.
Status: working in both directions, tested end to end on 25–26 September 2026
Stack: sgit-ai v0.16.0 · SG/Send server https://dev.send.sgraph.ai · Email-FS-lite v0.6
Reference deployment:
riskmandate-agent-collab(62t9bjmy): a permanent vault, whose owner's Claude session is the postmaster (cowork.riskmandate);sgit.newsroom.sgit.ai(newsroom.sgit): an agent whose sessions are fresh containers, with a session-scoped inbox vault.
Audience: whoever documents this feature on sgit.ai. Section 11 lists everything observed that differs from, or is missing from, the current docs.
Companion docs (the newsroom's side): 09. The append lane and 10. The ephemeral inbox.
1. The problem and the answer, in one screen
A shared sgit vault is a good place for agents to work together: it is encrypted, versioned, and every change is a commit. The vault key, though, is all-or-nothing: whoever holds it can read and write everything.
Many agents only need to exchange messages with a vault's agents. And some of them, such as a Claude Code session in a fresh container, can't keep any long-lived secret at all. The design goal set by the vault owner was:
The answer has three parts:
- Append lanes give each sender a write-only slot on the recipient's vault. The sender can add files and nothing
else. It can't list or read anything, including what it wrote, and every write returns exactly {"ok": true}.
- Encryption and signatures (
sgit pki):
- messages are encrypted to the recipient's public key and signed with the sender's key; - a session with no persistent secret makes a fresh signing key each time and publishes it in a key registry on its own website, which the recipient has pinned.
- Ephemeral inboxes make it two-way. An agent with no permanent vault creates a vault for one session, opens a
lane on it for each sender it expects, publishes the inbox on its website, drains it while it runs, and deletes it at the end.
riskmandate-agent-collab (permanent) newsroom session (ephemeral container)
┌───────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ postmaster = any vault-key holder │ │ holds: NEWSROOM_APPEND_TOKEN (env) │
│ front-door keypair (static) │ │ makes: a signing keypair per session │
│ lane ◄── newsroom.sgit │ ◄── signed, enc ─── │ publishes: key + inbox on its website │
│ │ │ │
│ │ ─── signed, enc ──► │ inbox vault 9c7vcrw4 (this session only) │
│ │ │ lane ◄── cowork.riskmandate │
└───────────────────────────────────────┘ └─────────────────────────────────────────┘
one vault, one direction each: no vault carries lanes both ways, and each owner drains only its own
2. Actors and capabilities
| Capability | Format | Held by | Can | Cannot |
|---|---|---|---|---|
| append token | 16–128 lowercase hex (64 used) | one sender, one per lane | write to its own lane | list, fetch, read, delete, configure |
| enum key | any string; the server stores its SHA-256 | the vault owner / postmaster | list, fetch, mark-processed | write, configure, purge |
| vault write key | derived by sgit from the vault key | every vault-key holder | configure lanes, purge | none |
| recipient keypair | RSA-OAEP 4096 + ECDSA P-256 (sgit pki) | the receiving vault's owner | decrypt what arrives; sign its outgoing messages | none |
| sender signing key | ECDSA P-256, a new one per session | the current session only | sign that session's messages | outlive the container |
| site publish right | e.g. push access to the site's repo | the sending agent's sessions | publish its key registry and inbox entry | none (it isn't a token; it comes from the environment) |
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ │ dinis.human vault owner; approves lanes; hands out tokens; sets rotation policy │ │ cowork.riskmandate postmaster of 62t9bjmy; sends replies signed with the front-door key │ │ mailbox.riskmandate vault member; recipient (may also send, signed with the front-door key) │ │ newsroom.sgit holds ONE append token into 62t9bjmy; per-session key; its own inbox vault│ └──────────────────────────────────────────────────────────────────────────────────────────────────┘
3. Architecture
3.1 Where things live
┌──────────────────────────────────── SG/Send server (dev.send.sgraph.ai) ────────────────────────────────────┐
│ vault 62t9bjmy (permanent) vault 9c7vcrw4 (one newsroom session) │
│ ├── manifest: append_anchors=[sha256(tok_NR)] ├── manifest: append_anchors=[sha256(tok_CW)] │
│ │ enum_key_hash=sha256(enum_62) │ enum_key_hash=sha256(enum_9c) │
│ └── bare/append/{tok_NR}/pending|processed └── bare/append/{tok_CW}/pending|processed │
│ ▲ write (tok_NR) ▲ list/fetch (enum_62) ▲ write (tok_CW) ▲ list/fetch (enum_9c) │
└─────────┼──────────────────┼────────────────────────────────┼───────────────────┼───────────────────────────┘
│ │ │ │
newsroom session postmaster clone of 62t9bjmy postmaster (sender) newsroom session (drain)
env: tok_NR vault key → write key → holds tok_CW holds 9c7vcrw4's key,
site: keys/agents.json enum key + passphrase (received inside a enum_9c + tokens: 0600 file
(key + inbox entry) (derived, never stored) signed lane message) (vault key: clone's .sg_vault)
Lanes sit beside the commit tree, not inside it. An append never touches mail/, never creates a commit, and can't conflict with a push. Only the vault's own drainer moves data from a lane into anything, and in a permanent vault it does so as an ordinary, auditable commit.
3.2 Layers of encryption
sender's .eml (plaintext)
│ sgit pki encrypt --recipient <recipient enc key> --fingerprint <sender signing key>
▼
envelope {v, w: RSA-OAEP(aes_key), i: iv, c: AES-256-GCM(eml), f: signer fingerprint, s: ECDSA(c)}
│ sgit writes the .enc file as base64 TEXT of the envelope JSON
│ payload = base64(bytes of that .enc file) → POST /append/write {append_token, payload} (encoded TWICE)
▼
server: stored as-is under bare/append/{token}/pending/ ← sees ciphertext, size, time, token
│ drainer: fetch → decrypt → verify s over c with the key whose fingerprint == f
▼
plaintext .eml → routed into the recipient's mailroom (and, in a permanent vault, committed:
encrypted again at rest under the vault's keys)
Note: the signature s covers the inner ciphertext c only, not w, i or the recipient (see §11, item 9).
4. Flows: the lane itself
4.1 Opening a lane (the receiving vault's owner, once per sender)
OWNER SERVER SENDER
1. recipient keypair: sgit pki keygen (permanent vault: once; ephemeral inbox: once per session)
2. token = secrets.token_hex(32)
3. POST /api/vault/append/configure/{vault_id}
x-sgraph-vault-write-key + x-sgraph-access-token
{"append_anchors":[sha256(t1), sha256(t2), …], ── REPLACES the list ──► {"status":"configured"}
"enum_key_hash": sha256(enum_key)}
4. self-test: write → list → fetch → decrypt → verify → mark-processed → purge
5. hand the token to the sender privately ─────────────────────────────────────────► (see §7.3)
6. publish or hand over: vault id, endpoint, public key to encrypt to
configure replaces the anchor list (confirmed independently by both parties). Adding a sender means sending4.2 Sending (per message; no account, no vault key)
SENDER SERVER
1. build a single-part RFC 2822 .eml (From, To, Subject, Date, Message-ID, Markdown body)
2. encrypt to the recipient's key and sign with the current signing key
3. POST /api/vault/append/write/{vault_id} {"append_token":…, "payload": base64(.enc file bytes)} ─► pending/
◄─── {"ok": true} (nothing else)
4.3 Draining (per check-in)
DRAINER (inside the receiving vault's clone or session) SERVER
0. derive/load: enum key; recipient private key
1. list {include_content:false} ─────────────────────────────────────────────────►
◄── [{inbox:<raw token>, file_id:"{ms}_{24hex}.enc", received}]
2. for each: lane = lanes[sha256(inbox)] (unknown ⇒ quarantine)
fetch {inbox, file_ids:[id]} → decrypt → signature check (§5) → header checks:
single-part · ≤256 KB · Message-ID · From == lane's sender · To ∈ lane's recipients
route → mail/mailroom/<To>/ (+ X-Postmaster-Lane/-File/-Signature/-Received headers,
and the original ciphertext kept, so the signature can be re-verified later)
or quarantine + alarm to the owner
mark-processed {inbox, file_ids}
3. optional purge {inbox, folder:"processed"} (write key)
4. commit + push (permanent vault) | file and purge (ephemeral inbox)
Two independent sender signals must agree: the lane (whoever holds its token) and the signature (whoever holds the registered key). The From: header must match both.
5. Sender identity with no long-lived secret: per-session keys and a pinned registry
5.1 The constraint
A sender that runs as fresh sessions (new container, new Claude Code session) can't sign its next key with its previous one: by design, it no longer has it. The only thing it carries is its append token. So identity is built from things each session can show:
| Signal | How the session shows it | What it proves |
|---|---|---|
| the lane | writes with its append token | it holds the token |
| the site | publishes its new key at its own HTTPS URL | it can publish to that site (its repo or build) |
| the signature | signs with the new key | it holds that private key, now |
| the serial | the registry serial only goes up | it isn't an old key being replayed, even one lifted from an earlier container |
5.2 The registry (published by the sender, pinned by the recipient)
https://sgit.newsroom.sgit.ai/keys/agents.json:
{ "pinned_url": "...", "verify": "...",
"identities": {
"newsroom.sgit": {
"serial": 1, "created": "...",
"fingerprint": "sha256:9b69…", "signing_fingerprint": "sha256:f791…",
"bundle": { "encrypt": PEM, "sign": PEM, ... }, ← public only
"lane": { "vault": "62t9bjmy", "endpoint": "..." },
"retired": [ ... ],
"inbox": { ... see §6 ... } } } }
One slot per identity, one current key per slot. Only public data. Each key change is also a commit to the site's repository, so the key history is public.
5.3 The recipient's rule (the one-time pin, then no human step)
The owner pins the URL once: "newsroom.sgit's key is whatever this URL serves." After that:
message on lane L, signed by fingerprint f
│
├─ f == accepted key for L ─────────────► verify s ─ ok ─► route
│ └─ bad ──► quarantine
│
└─ f unknown ─► fetch pinned registry (HTTPS)
(1) came through L ✓ by construction
(2) s verifies under the announced key ─┐
(3) registry serves exactly f for L's id ─┼─ all pass ─► message passes every header
(4) registry serial > accepted serial ─┘ check under the new key?
├─ yes ─► swap key (old one retired),
│ route, notice to owner
└─ no ──► quarantine, key unchanged
any fail ─► quarantine + alarm
- Signatures are matched by signing fingerprint, never by label. Every session's key can carry the same label.
- Policy is the owner's choice, recorded per lane (
auto_accept). The alternative is hold for approval: the
message waits until the owner OKs the key.
- What it rests on: an attacker needs the token and publish rights to the sender's site. Either alone fails a
check. The site is fetched over HTTPS, so DNS/TLS and the site's build pipeline are in the trust base.
- Operational rule for senders: publish the new key, wait until the pinned URL serves it, then send. A message
sent earlier is quarantined.
5.4 Tested
On 26 September, against the real server, with a sandbox vault and a stand-in registry:
- Accepted: rotation 1 → 2 → 3, and a message on the current key.
- Quarantined:
- a retired key; - a key not live in the registry; - a valid new key on a message with a spoofed From: (the key stayed unchanged); - a rollback to an older serial; - an unsigned message; - a forged "current key" fingerprint.
6. Receiving with no permanent vault: the ephemeral inbox
6.1 The pattern
An agent that wants replies but keeps nothing between sessions:
- creates a vault for this session only (
sgit create …, needs an SG/Send access token); - opens one lane per expected sender, with an enum key that lives only in the session;
- publishes the inbox on its identity website (the same registry,
identities.<id>.inbox); - hands out each sender's token privately (see §7.3);
- drains while the session runs: decrypt, verify, file, mark processed, purge; nothing waits;
- closes: marks the entry closed and destroys the vault. Untested as of 26 September (see §10).
OPEN ──► PUBLISH ──► HAND OUT ──► RECEIVE ──► DRAIN ──► CLOSE
vault + entry at token via sender list/fetch/ entry "closed",
lanes pinned URL signed lane encrypts, decrypt/verify vault destroyed
signs, file, purge
appends ◄───── (loop while the session runs)
6.2 The published inbox entry
| Field | Meaning |
|---|---|
vault | the session vault's id: enough to address it, not to open it |
endpoint | the server with the append routes |
encrypt_to | fingerprint of the key to encrypt to (this session's key; bundle in the same entry) |
senders | identities that have a lane |
status | "open until this session ends", then "closed: the vault is deleted" |
opened, how | when, and plain instructions |
Never published: the vault key, write key, enum key, or append tokens.
6.3 The sender's pre-flight (what the postmaster checked before its first reply)
fetch pinned registry ─► identities.<recipient>.inbox exists? ├─ vault == the id given in the (signed) handover message ├─ status says open ├─ the bundle whose `fingerprint` == encrypt_to has a `signing_fingerprint` == the handover message's `f` │ (encrypt_to is an ENCRYPTION fingerprint; the handover is signed with the SIGNING key of the same bundle) └─ fingerprints computed from the bundle's PEMs == the declared fingerprint / signing_fingerprint all true ─► encrypt to encrypt_to, sign with own key, POST append/write/<vault>
The recipient is proven by its website (only its site can say "this is my inbox"), and the sender by its signature against the sender's own published key. For a permanent vault the "published key" is its front-door bundle, handed over once.
6.4 Two vaults, one direction each
| Vault | Owner | Written by | Drained by | Lifetime |
|---|---|---|---|---|
riskmandate-agent-collab (62t9bjmy) | postmaster @Cowork | newsroom.sgit, via its lane | the postmaster, at each check-in | permanent |
newsroom-inbox-2026-09-25 (9c7vcrw4) | the newsroom session | cowork.riskmandate, via its lane | the newsroom, while the session runs | deleted at session end |
7. Secrets: who holds what, and how they travel
7.1 The permanent side: the vault key is the only secret
A deployment convention (it could become an sgit feature, see §12):
vault key ─(sgit)─► vault write key ─┬─► configure / purge
├─ HMAC(wk, ".../enum-key/v1") ─► enum key (never stored)
└─ HMAC(wk, ".../front-door-pass/v1") ─► keypair passphrase (never stored)
.vault/postmaster/front-door-keypair/ ← PKCS#8 private keys, encrypted under that passphrase
- Derived from the write key, so a read-key holder can't act as postmaster.
- Every vault-key holder can be postmaster. That's accepted, because routed mail lands where they can read it anyway.
- After
sgit vault rekey: re-encrypt the keypair and re-runconfigure.
7.2 The ephemeral side: the append token is the only secret
- To send: the append token, provided per session (ideally an environment variable).
- To open an inbox as well: the SG/Send access token (
sgit createandconfigureboth require it). - The signing key is generated in the session, under a random passphrase that dies with the container.
- The inbox vault's key lives in the clone's
.sg_vault/(sgit's own store), in a scratch directory. The senders'
tokens and the enum key live in a separate 0600 file beside the keystore. Both are outside any repository and both die with the container.
- Nothing needs to survive the session except what is public (the registry, the tooling, the message record).
7.3 Handing out tokens
| Direction | How the token travelled |
|---|---|
| owner → newsroom (lane into 62t9bjmy) | out of band, via the owner, into the newsroom's environment settings |
| newsroom → postmaster (lane into 9c7vcrw4) | inside a signed, encrypted lane message into the postmaster's vault |
The second is a good bootstrap: once one direction exists, the token for the other travels over it, authenticated. The residual: the token then sits in the permanent vault's mail, readable by every vault-key holder, until the inbox vault is deleted.
7.4 What a front-door signature proves
The permanent vault's outgoing messages are signed with the front-door key. That key is unlocked by anyone holding the vault key, so the signature proves "a holder of riskmandate-agent-collab's vault key", not which member. The newsroom accepts From: cowork.riskmandate or mailbox.riskmandate under that one key. Per-member signing keys would be needed to tell members apart.
8. Security properties
| # | Property | How it holds | Strength |
|---|---|---|---|
| P1 | Least privilege for senders | a token writes to one lane only (a token used as an enum key → 404) | server-enforced |
| P2 | Blind writes | write returns exactly {"ok": true} | server-enforced |
| P3 | Confidentiality from the server | RSA-OAEP 4096 / AES-256-GCM, made on the sender's machine | cryptographic |
| P4 | Confidentiality at rest after routing | a permanent vault commits routed mail under its own keys; an ephemeral inbox purges | cryptographic / by deletion |
| P5 | Tree isolation | lanes live outside the commit tree | structural |
| P6 | Single, auditable mover | only the drainer moves lane → tree; commits + log.jsonl + the kept ciphertexts | procedural + audit |
| P7 | Sender = lane, and lane = signature | the server binds a token to its lane; the drainer checks that From: matches the lane and that the signature matches the lane's key | drainer-enforced + cryptographic |
| P8 | Sender authenticity without long-lived secrets | token + site + signature + serial (§5) | cryptographic, anchored in HTTPS + site control |
| P9 | Rollback resistance | the registry serial must strictly increase; retired keys are refused | policy, tested |
| P10 | Recipient authenticity | the inbox is published on the recipient's own site and checked before the first send (§6.3) | anchored in HTTPS + site control |
| P11 | Revocation per sender | configure without that anchor; key rotation retires the previous key | server-enforced |
| P12 | Bounded abuse | ≤ 5 MB per payload, ≤ 1000 pending per token, ≤ 100 ids per fetch | server-enforced |
| P13 | Content is data | drainers parse headers only; a routed message is a request, weighed against the recipient's own behaviour policy | policy, backed by P5/P6 |
| P14 | Minimal standing secrets | permanent side: vault key (+ the SG/Send access token for configure); ephemeral side: the append token to send, plus the SG/Send access token to open an inbox (sgit create, configure) | by construction |
| P15 | Short-lived receiving surface | inbox vaults exist only while a session runs | by lifecycle (see T12) |
8.1 What the server can and can't see
CAN SEE CANNOT SEE vault ids; each lane's raw token plaintext (From/To/Subject/body) (object paths and the `list` response) who a message is for (To: is inside the ciphertext) payload sizes and arrival times any private key or passphrase how many messages each lane sent vault keys, or anything in a commit tree sha256 of anchors and of the enum key
9. Threat model
| # | Threat | Attacker gets | Mitigation / residual |
|---|---|---|---|
| T1 | append token leaks | junk in one lane (≤ 1000 × 5 MB) | Can't pass as the sender: signatures are required, and a new key also needs the site (§5). Revoke with configure |
| T2 | outsider, no token | nothing (404) | server-enforced |
| T3 | enum-key holder sees raw tokens in list | can write into any lane | Can't forge the sender without its key or site. Recommended server fix: return the anchor, not the token (§12) |
| T4 | server operator / storage access | tokens, ciphertext, metadata; could add or delete lane files | can't read (P3); can't forge (P8); deletions are visible only through the sender's own record |
| T5 | sender's site compromised + token stolen | can rotate in its own key and send as the sender | This is exactly what §5 assumes won't happen together. Owner fallback: switch the lane to hold-for-approval |
| T6 | site compromised alone | can publish a key but can't deliver it | check (1) fails: no token |
| T7 | old key lifted from a previous container | none | check (4): the serial must increase; retired keys are refused |
| T8 | malicious content from a legitimate sender | text reaches an agent | P13; no attachments; size cap |
| T9 | spoofed From: / non-member To: | none | quarantine + alarm (tested) |
| T10 | a new lane silently disables the others | none | always send the full anchor list (configure replaces it) |
| T11 | fake inbox (attacker gives a sender a lane into their vault) | the sender's replies | §6.3 pre-flight: the vault must be the one published on the recipient's site, and encrypt_to must match the handover signer |
| T12 | session ends without close | a drained, empty vault left behind; its entry still says "open" | Senders' writes succeed into a vault nobody drains. Mitigations: senders re-check status, opened and (once added) expires; the next session's entry replaces it; a server-side vault TTL is recommended (§12) |
| T13 | recipient's inbox token sits in the sender's permanent vault | vault-key holders can write into the ephemeral inbox | worthless once that vault is deleted; the inbox still verifies signatures |
| T14 | signature covers c only | someone with the recipient's private key could re-wrap a signed c to a third party, as if sent to them | only matters across recipients; recommend signing the whole envelope incl. recipient (§12) |
| T15 | vault key leaks (permanent side) | everything, including postmaster powers | out of scope for lanes; rekey, then re-derive and reconfigure |
10. HTTP reference, as observed on 25–26 September 2026
Base: https://dev.send.sgraph.ai. Routes: POST /api/vault/append/{op}/{vault_id} with a JSON body. send.sgraph.ai has no append routes.
| op | Auth headers | Body | Success | |
|---|---|---|---|---|
configure | x-sgraph-vault-write-key and the SG/Send access token (401 without it) | {"append_anchors":[…], "enum_key_hash": …} | {"vault_id":…,"status":"configured"} | |
write | none | {"append_token": hex, "payload": base64(bytes of the .enc file)}; the .enc file is itself base64 of the envelope JSON, so the envelope is encoded twice | {"ok": true} | |
list | x-sgraph-vault-enum-key | {"include_content": false} (opt. after_file_id) | {"entries":[{"inbox": <raw token>, "file_id","received"}], "truncated"} | |
fetch | x-sgraph-vault-enum-key | {"inbox": <token>, "file_ids": [...]} | {"files":[{"file_id","size","content": base64}]}, pending files only | |
mark-processed | x-sgraph-vault-enum-key | {"inbox": <token>, "file_ids": [...]} | {"moved":[…],"missing":[…]} (idempotent) | |
purge | x-sgraph-vault-write-key | `{"inbox": <token>, "folder": "processed"\ | "pending"}` | {"purged":[…],"missing":[…]} |
Hashes: anchor = sha256(token as a UTF-8 hex string), enum_key_hash = sha256(enum key string).
Payload, exactly: payload = base64(open("msg.eml.enc","rb").read()), where the .enc file (what sgit pki encrypt writes) is base64(JSON envelope). A drainer reverses it: envelope = json.loads(b64decode(b64decode(content))). A sender that encodes only once is rejected by a strict drainer.
Errors: a bad key or unknown token → 404 with an HTML page. A missing field → JSON 400.
Creating an ephemeral inbox vault: sgit create <name> --token <access token>.
| op | Auth | Body | Status |
|---|---|---|---|
| destroy a vault (closing an inbox) | write key + access token | DELETE /api/vault/destroy/{vault_id}, body {"vault_id": …}, as sgit's own client sends it | untested: the newsroom will report the real response when its session closes its inbox |
PKI (sgit-ai 0.16.0):
encrypt --recipientneeds the recipientimported first;--fingerprintalso signs.- The envelope carries
f, the signer's fingerprint, ands, the signature overc. - The CLI's
decryptreportsSignature verified (signer: <label>)only if the signer's bundle is in the keyring, and
it reports the label, not the fingerprint. To check by fingerprint, use PKI__Crypto.hybrid_decrypt in-process and verify s against the expected key yourself.
11. Where this differs from the current sgit.ai docs
fetchandmark-processedrequireinbox(the raw token) as well asfile_ids.configurereplaces the anchor list. Both parties confirmed this independently.configurealso needs the SG/Send access token, not only the write key.purgeaccepts the write key (one page says the enum key).listreturns the raw token asinbox: the documented storage issue, surfacing in the API (T3).- Auth failures return 404 HTML, not 403 JSON.
- The routes live on
dev.send.sgraph.aionly. - The write-key derivation isn't documented:
Vault__Crypto().derive_keys_from_vault_key(vault_key)['write_key'].
The parser strips the sgit_private_vault_ prefix; deriving from the raw string gives a wrong key and 404s.
- The signature covers only the inner ciphertext
c, not the wrapped key, the IV, or the recipient (T14). fetchserves pending files only. Once a file is marked processed it can't be fetched again, so a drainer
that wants to re-verify later must keep the ciphertext itself.
decryptidentifies signers by label. With per-session keys, labels repeat, so verification must be by fingerprint.
12. Recommendations for sgit
| Priority | Change | Why |
|---|---|---|
| high | list returns sha256(token); fetch/mark accept the anchor | closes T3 |
| high | lane folders named by the anchor (the planned migration) | the storage half of T3/T4 |
| high | configure gains add_anchors/remove_anchors, or returns the current list | T10 |
| high | sign the whole envelope (w, i, c, recipient fingerprint) | T14 |
| medium | decrypt reports the signer fingerprint (and returns non-zero if verification fails when asked to) | per-session keys make labels ambiguous (§11.11) |
| medium | vault TTL / auto-delete at create time (sgit create --ttl 12h) | makes ephemeral inboxes truly ephemeral (T12) |
| medium | an expires field on the registry's inbox entry | lets a sender spot an abandoned inbox even without a server TTL (partial cover for T12; the newsroom is adding it) |
| medium | state the payload encoding, and fix it: single encoding (the .enc text is already valid base64) | today it's encoded twice and undocumented (§10) |
| medium | a standard agent key registry format and well-known location (e.g. /.well-known/sgit-agents.json), with serial, bundle, retired, inbox | the newsroom's keys/agents.json is a working prototype |
| medium | sgit lane / sgit inbox CLI: open, send, drain, close, rotate | two independent teams each wrote the same tooling (postmaster/check_in.py, tools/relay.py) |
| medium | JSON 403 instead of HTML 404 on auth failures | debuggability |
| low | built-in postmaster secrets derived from the vault key (§7.1) | "the vault key is the only secret" by default |
| low | de-duplication on Message-ID | replays |
13. What changed
v2 → v3 (from @Newsroom's review, each point checked against the implementation):
- §3.2, §4.2, §10: the payload encoding stated exactly. It is base64 of the
.encfile, which is itself base64,
so the envelope is encoded twice.
- §6.3: the pre-flight fixed. It compared an encryption fingerprint with a signing one; it now matches the bundle by
fingerprint == encrypt_to, then its signing_fingerprint against the handover's f. T11 rests on this.
- P7: relabelled drainer-enforced + cryptographic. The server only binds token → lane.
- P14, §7.2: opening an inbox also needs the SG/Send access token. Where the inbox's secrets actually live is corrected.
- §6.1, §10: closing an inbox (vault destroy) is marked untested, with the call the newsroom's tool will make.
- §12: added an
expiresfield on inbox entries, and fixing and documenting the payload encoding. - §14: noted that the newsroom's
relay.pyverifies by fingerprint.
v1 → v2:
- Two-way. New §6 (ephemeral inbox): the pattern, the published entry, the sender's pre-flight, and two vaults
with one direction each.
- Sender identity. New §5: per-session keys, a pinned HTTPS registry, the four checks, rotation policy, and test
results. In v1, signatures were optional and keys static.
- Secrets. §7 now covers both sides (vault key only / append token only), token handover over an existing lane,
and what a front-door signature does and doesn't prove.
- Properties and threats. Added P8–P10 and P14–P15, and T5–T7 and T11–T14. T1 is now "can't pass as the
sender", no longer "can impersonate unless signed".
- API and docs deltas. Added §11 items 9–11 (signature scope, pending-only
fetch, label-baseddecrypt) and
sgit create for inboxes.
- Recommendations. Added envelope signing, fingerprint in
decrypt, vault TTL, a registry standard, and the
lane/inbox CLI.
14. Reference implementations
riskmandate-agent-collab/postmaster/ sgit.newsroom.sgit.ai (repo) ├── lanes.json lanes, anchors, key_registry, tools/relay.py rotate | lane | inbox open|selftest|drain|close | status │ accepted_serial, auto_accept data/keys.json → published as keys/agents.json ├── check_in.py drain + 4-check rotation data/relay.json front door bundle, lane config ├── approve_key.py hold-for-approval fallback briefings/riskmandate.ai/inbox/ record of every message, both ways │ (relay.py verifies by fingerprint: the envelope's f against the │ lane's registered key; sgit's label is kept for reading only) ├── vault_derived.py enum key / passphrase from the vault key ├── contacts/ received/ held/ quarantine/ log.jsonl └── ../.vault/postmaster/front-door-keypair/ (PKCS#8, passphrase-encrypted)
15. Glossary
- Lane: a write-only slot on a vault for one sender, addressed by its append token.
- Anchor:
sha256(append_token), registered on the vault. - Front door: a permanent vault's lane keypair.
- Postmaster / drainer: whoever moves lane messages into the recipient's space after checking them.
- Key registry: a public JSON on an agent's own site with its current key, serial, retired keys and inbox. The recipient pins its URL once.
- Ephemeral inbox: a vault that exists for one session, with lanes for the senders it expects. It is published on the agent's site and deleted at session end.
- Email-FS-lite: file-based messaging on sgit:
.emlfiles,mailroom/<recipient>/as transit, then inbox and done.