Skip to main content
loomcycle
§ story · v1.116.0

A tool whose keys the model never sees.

An agent in loomcycle used to be handed its key the way any other value gets handed in. The operator sealed the key into a credential, the agent reached for it, and the key arrived in the prompt where the model could read it. From there, the key went into the transcript. From the transcript, into the provider's logs. From the provider's logs, into whatever retention window the provider promises its enterprise tier. From the transcript, into the deployment-shape snapshot. From the snapshot, into whatever archive the operator keeps.

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 short version. Eleven ops: 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 cases the key 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 ops are on the operator API over HTTP, gRPC, MCP, TypeScript and Python.

Where the key used to live

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.

The call that replaces them

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:[email protected]") 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.

Eleven ops, algorithms fixed by key type

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.

Where keys live

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.

Scope resolution and the AgentDef grant

Crypto keys live in scopes, as every credential does: a tenant scope, a user scope, an agent scope, and a per-run scope. 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.

The operator API outside a run

A backend service outside any agent run often needs the same 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.

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.

Safety fittings

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}.

Also in this release

Two sibling fixes landed with the Crypto tool. 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.

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.