Skip to main content
loomcycle
§ story · v1.116.0

A tool whose keys the model never sees.

In a well-designed cryptosystem, only one thing is secret.

1. What the model sees

An agent that has to sign a webhook, seal a card number, or verify a partner's signature needs a key. Until v1.116, the key arrived in the only place the agent could reach it: the prompt. From the prompt it moved into the transcript. From the transcript into the provider's logs. From the provider's logs into whatever retention window the provider's enterprise tier promises. From the transcript it also moved into the deployment-shape snapshot, and from the snapshot, into whatever archive the operator keeps.

That is a long walk for a value whose entire purpose is to stay unread.

v1.116 ships a Crypto tool whose keys the model never sees. The agent holds the name of a key. loomcycle holds the key. The agent asks for a keyed operation and the result comes back. The key material does not cross the model boundary at any step.

The agent holds the name. loomcycle holds the key.

Eleven operations: encrypt, decrypt, rewrap, mac, verify_mac, sign, verify, public_key, totp, hash, random. The algorithm is fixed by the key's type, never by the call. Keys live sealed in loomcycle under LOOMCYCLE_SECRET_KEY, or in HashiCorp Vault Transit, AWS KMS, or Google Cloud KMS. In those three cases the material never leaves the KMS. An envelope lcx1:<source>:<name>:<version>:<body> binds tenant, scope, name, version and the caller's context into the AAD, so a value moved to another key, scope or context does not open. A new AgentDef grant, crypto_scopes, is default-deny. The same operations are on the operator API over HTTP, gRPC, MCP, TypeScript and Python.

That is the whole tool in a paragraph. The rest of this piece says why each decision is the way it is.

2. What used to leak

Two bad shapes.

The first was the key in the prompt. An agent built to sign a webhook payload for a partner, or to seal a card number before writing it, got the key inlined into its system prompt or its first user turn. The key was then in the model's input window for every subsequent turn. That is a problem for the obvious reason (the model sees it) and for the less obvious one (every provider the deployment routes to also sees it, which turns a vendor outage report into a credential rotation ticket).

The second was the key in the agent's Bash environment. A code agent shelled out, read the key from an environment variable, and did the keyed operation itself. The key did not reach the model's input window. It did reach the process environment, which means it reached the audit record of what the Bash call ran, which means it reached whoever can read the audit record. On a tenant with isolated members, that is wider than the operator intended.

Both shapes fail the same test. If someone later asks "who could have read this key?", the answer is bigger than one. The Crypto tool pulls the key out of the model's reach so the answer stays small.

3. The one call that replaces both

The agent names a key and asks for an operation. The runtime does the operation and returns a result.

{"op": "encrypt",
 "key": "orders",
 "plaintext": "4111...",
 "context": "order:7"}

→ {"ciphertext": "lcx1:kt:orders:3:...",
     "key_version": 3}

key is the key's name as the operator stored it, not the key itself. context is a string the caller picks ("order:7", "user:42", "email:x@y.z") that binds the ciphertext to its subject. Decryption asks for the same context; a mismatch refuses. The ciphertext comes back as an envelope: lcx1 for the format version, kt for a crypto key in the tenant scope, orders for the key's name, 3 for the key version used, and the sealed body after that.

The AAD (the associated data the AEAD cipher binds in without encrypting) carries tenant, source, name, version and context. A value re-presented to another key, another scope, or with a different context does not open. The failure is not "wrong plaintext"; it is "did not decrypt". The error does not say which part was wrong, so an attacker trying to use a wrong-context envelope against a correct key gets no gradient to climb.

4. Eleven operations, one algorithm per key

The ops cover the shapes a loomcycle agent actually asks for.

The algorithm is a property of the key, not of the call. A key marked AES-256-GCM encrypts with AES-256-GCM. A key marked Ed25519 signs with Ed25519. A key marked ECDSA P-256 signs with ECDSA P-256 and verifies against PEM or JWK public keys, or emits a JWS. The agent cannot ask for a weaker algorithm at call time, because the call does not take one.

The algorithm families cover AES-256-GCM, XChaCha20-Poly1305, HMAC-SHA256 and HMAC-SHA512, Ed25519, ECDSA P-256, RSA-OAEP, RSA-PSS, JWE for public-key encryption, Argon2id for passwords, and SHA-2 and SHA-3 for digests.

Nonces come only from the runtime. There is no call-side nonce argument. A caller that reuses a nonce is a classic AEAD failure, and the Crypto tool refuses to expose the surface for it.

5. Where the key actually lives

There are two addresses.

The default is sealed-local. A key is a row in CredentialDef with usage: crypto, versioned numerically, with a min_version an operator sets to retire an old version. The key's material is wrapped under the deployment's own sealing key, LOOMCYCLE_SECRET_KEY, which lives in the process environment and never touches the database. A deployment that rotates its sealing key re-wraps every crypto key. The envelopes themselves do not change, because they bind to the key's name and version, not to the sealing key.

For deployments that want the key material to live outside loomcycle, the Crypto tool reaches three external key managers.

For all three, the credential loomcycle holds is the KMS address and the auth it uses to reach the KMS, not the key material. A snapshot of the loomcycle deployment carries the pointer, not the key. A snapshot restored on a new machine still needs the KMS to be reachable, which is the correct trust boundary for anyone who picked an external KMS in the first place.

6. Which partner-webhook, exactly?

A tenant might have a key called partner-webhook. A user inside the tenant might have one too. Which one does the agent get?

The resolution rule is unusual on purpose. A key name resolves ONLY when exactly one allowed scope holds it. A name that appears in two scopes refuses rather than silently preferring one.

The reason is a forgery-verification attack that scope precedence would open. If the tenant verifier accepts signatures under a key named partner-webhook, and a user plants a user-scope key under the same name, a scope-precedence rule that prefers user over tenant lets the user sign something with their own key and have the tenant verifier accept it. One-scope-only closes the attack. An envelope records its source scope in the lcx1 header, so a key that appears later in a wider scope cannot stand in for the one that signed the envelope and cannot block the one it was signed with.

Agent authority is explicit. A new AgentDef grant, crypto_scopes, lists which scopes the agent may reach: any combination of agent, user, tenant and run. Default is deny-all. An agent with no grant can still call hash and random, because those do not touch a key. Everything else refuses. Sub-agents narrow to their parent's grant; they cannot gain scopes the parent lacked. The grant is not per-run overridable (a caller cannot widen it at run start) and not part of content_sha256 (adding or removing the grant does not change the agent's content address), so an operator tightening a grant does not force a re-fork of every downstream agent.

7. The same tool outside the agent

An agent is not the only caller that needs keyed operations.

A webhook receiver verifying a partner signature. A field-sealing step before a database write. A migration that re-wraps old envelopes under a rotated key. None of these runs inside an agent, but all of them need the tenant's keys.

The same ops are on the operator API, on every transport: HTTP POST /v1/_crypto, gRPC rpc Crypto, the MCP crypto meta-tool, TypeScript crypto() plus typed key helpers (generateCryptoKey, rotateCryptoKey, exportCryptoKey and so on), Python crypto(). The call requires substrate:tenant authority, which is carved out of ordinary member access. A tenant member who could decrypt tenant data from this endpoint could read whatever the tenant sealed. The carve-out holds the API to operators and to backends the operator trusts with a tenant-scope token.

The result is that a service can use the tenant's keys without ever holding them. The service presents its token, names the key, and the KMS or the sealed-local store does the work. The service's logs, its memory dumps and its own backups do not carry the key.

The agent speaks a name. The key stays where it was put.

8. Three fittings worth knowing on first use

Masking. A decrypt call with sensitive: true stores a marker in the tool-call row instead of the result, so the decrypted value does not reach the transcript at all. CredentialDef's passphrase and blob fields are masked in the stored call. The agent gets the result in its turn's reply; the stored audit of what the agent did holds a placeholder.

Opaque failures. A failed decrypt returns "did not decrypt". A failed verify returns valid: false. The error does not disclose which component of the envelope was wrong (key, version, context, body). An attacker with partial material and a reachable endpoint gets no signal to differentiate wrong-key from wrong-context from tampered-body.

Limits. One MiB per argument. One hundred ops per run. Both are environment-tunable: LOOMCYCLE_CRYPTO_MAX_BYTES and LOOMCYCLE_CRYPTO_MAX_OPS_PER_RUN. At most 32 usable key versions per key, so a key that has rotated 32 times retires its oldest version on the next rotation. Every keyed op and every key change is audited, with no data or key material in the audit row. Metrics gain loomcycle_crypto_ops_total{op,outcome}.

9. Two siblings that landed with it

Two more fixes landed in the same release, both touching the same question of "who can see what".

Untrusted text no longer reaches memory. A call that writes a memory row from an untrusted block is held at the memory boundary, so a model cannot be tricked into persisting attacker-controlled content under a tenant's own identity.

And another user who continues an interactive session no longer gets the session user's credentials. A session carries the user who started it, and a continuation under a different user resolves credentials under its own identity, not the session's.

Both are small lines in a release note. Both close the same shape of leak the Crypto tool is built around: the value that should not have been visible to that identity, that moment, that call.

10. Where to read more

The per-release notes for v1.116 live in REVISIONS.md. The operator reference for crypto keys, the KMS backends and the envelope shape sits under "Crypto keys" in docs/CONFIGURATION.md.

In every cryptographic system, trust gathers at one point: whoever can read the key. If that number is bigger than it needs to be, the trust gets weaker in proportion. Every retention window a value passes through, every log line it touches, every snapshot it lands in widens the circle of people and processes that could, in some later question, have read it.

The Crypto tool is one answer to a plain question. We built it to keep that circle as small as the agent's job allows. The agent speaks a name. The key stays where it was put. The audit holds a record of names, never of keys. The agent does the work it was given; the one who could have read the key stays one.