Security baseline¶
The effects loop — RunAgent .intents → outbox topic → effector → results topic
→ re-injection — is asynchronous. The pipeline and the effector never hold a
connection to each other, so "authentication between them" is not a handshake.
It splits into two layers with two different owners, and the most important
thing this page does is say which is which.
| Layer | Question it answers | Who enforces it |
|---|---|---|
| Broker access control | Who may produce to / consume from a topic? | The broker. Kafka SASL/mTLS plus per-principal ACLs; Pub/Sub IAM. The library makes it configurable and cannot make it true. |
| Application provenance | Did the pipeline mint this exact intent? | The library. WriteIntents signs each ToolIntent; the effector verifies before anything else runs. |
Neither substitutes for the other. Signing alone does not stop topic flooding,
eavesdropping, or forged results. Broker auth alone makes every holder of a
producer credential a full execution authority: without signing, write access to
the outbox topic is the authority to run any registered side_effect=True
tool with attacker-chosen arguments. The dedup store is no defense there — it
collapses duplicates of an intent_id, and a forged intent carries a novel one.
Intent signing¶
Each ToolIntent carries a signature envelope: signature_scheme,
signing_key_id, and signature, where the signature is
HMAC-SHA256(key, deterministic_serialization(intent with those three fields
cleared)). HMAC-SHA256 is stdlib, so neither the pipeline image nor the
effector extra gains a dependency, and it is deterministic — a replayed bundle
re-mints a byte-identical intent and re-signs it to byte-identical wire bytes,
which is what keeps the outbox replay-stable (correctness invariant 2).
Signing happens at the outbox writer, not in ctx.act. The signature is a
transport property: intents held in keyed state and the continuations that list
them are byte-for-byte what they were before signing existed, so there is no
state-compat question and no state_schema_version bump. The key is resolved
worker-side in DoFn.setup() from a reference, so key material never enters the
pipeline graph the runner stores.
Provisioning and rotating keys¶
The keyring is key_id=base64(key) lines, delivered to both workloads by your
secret manager and named by reference:
from beam_agents.actions.write_intents import WriteIntents
from beam_agents.intent_signing import IntentSigner
intents | WriteIntents(
"kafka://broker:9092/agent-intents",
signer=IntentSigner(key_id="k-2026-07", key_reference="file:/var/run/secrets/intent-keys"),
)
EFFECTOR_SIGNING_KEYS=file:/var/run/secrets/intent-keys \
EFFECTOR_VERIFY_INTENTS=require \
EFFECTOR_DEAD_LETTERS_TO=kafka://broker:9092/agent-intents-dead \
beam-agents-effector ...
Generate a key with 32 bytes from a CSPRNG (openssl rand -base64 32). Rotation
is three ordered steps and needs no downtime:
- Add the new key to the effector's keyring.
- Switch the pipeline's
signing_key_idto it. - Retire the old key once the outbox retention window has passed.
The window matters: a retained intent signed with a retired key dead-letters as
unknown_signing_key.
Key material must never appear on a command line. --signing-keys and every
credential flag take a reference precisely because argv is visible in
process listings.
The rollout dial¶
verify_intents has three settings and they are meant to be walked in order.
| Mode | Signed intent | Unsigned intent | Bad signature |
|---|---|---|---|
off (default) |
executes; signature ignored | executes | executes — the dial has not been turned |
permissive |
verified, executes | executes, counts unsigned_intents_accepted |
dead-lettered |
require |
verified, executes | dead-lettered (unsigned_intent) |
dead-lettered |
A bad signature is never acceptable in a verifying mode; permissive excuses
absence, not failure.
The full sequence, each step independently reversible:
- Deploy the schema and the new effector build with
verify_intents=off. Nothing changes at runtime. - Provision keys to both workloads and flip effectors to
permissive. - Create and wire
dead_letters_to, then enable the pipeline's signer and roll the pipeline. It is--update-compatible: no state schema change, no coder change, only trailing fields on the emitted outbox bytes. - Wait until
unsigned_intents_acceptedhas been zero for longer than the outbox retention window, then flip effectors torequire.
Rolling back reverses one step at a time and strands nothing: nothing in the
sequence changes intent_id derivation, dedup records, or state blobs.
Verification failures¶
Verification is the effector's phase zero — ahead of the expiry check, the
dedup store, and any tool resolution. A delivery that fails it is published
verbatim (raw payload, raw key) to dead_letters_to, counted, and its offset is
committed.
No ToolResult is published for a verification failure — not REJECTED, not
EXPIRED. A result inherits the delivery's entity_key, intent_id, and seq,
which on an unverifiable message are attacker-chosen bytes; publishing one would
put those bytes on the keyed re-injection path, letting an attacker who learns a
genuine pending intent_id race a forged refusal against the real result.
Committing past the failure rather than stalling is equally deliberate: a partition head wedged on a forged message is a denial of service an attacker can produce at will. The cost is honest — a genuine intent corrupted in flight, or signed with a key the effector no longer holds, is dead-lettered and its activation waits until the HITL timer fires its fallback. That is fail-closed at both layers, which is what correctness invariant 6 prescribes.
Counters to alarm on¶
| Counter | Means | Alarm when |
|---|---|---|
unsigned_intents_accepted |
permissive admitted an unsigned intent |
non-zero after the migration window — otherwise you have signing theater |
unsigned_intent |
require dead-lettered an unsigned intent |
non-zero after step 4 — a producer was missed |
bad_signature |
a signature did not verify | any non-zero value: tampering, or a signing-input skew between builds |
unknown_signing_key |
the signing_key_id is not in the keyring |
non-zero — almost always a mis-provisioned or prematurely retired key |
intents_dead_lettered |
total of the three above | use for a single "something is wrong" page |
unknown_signing_key is deliberately distinct from bad_signature so a bad
deploy is distinguishable from an attack at a glance.
What signing adds on top of dedup¶
- Forgery (novel
intent_id, attacker content): dedup executes it once — dedup is no defense. The signature refuses it. This is the headline gain. - Substitution (genuine pending
intent_id, alteredtool_name/args_json/expires_at_ms): the signature covers every field, so any alteration invalidates it. Without signing, whichever copy the effector sees first wins the claim — the attacker's copy can beat the genuine one. - Verbatim replay of a captured signed intent: the signature verifies (it is
the genuine message), and collapsing it is exactly dedup's job. Within
result_ttl_msthe replay returns the stored result; beyond it, the intent's own signedexpires_at_msrefuses it (signing prevents extending expiry). The residual window is a replay landing after the terminal record's TTL but beforeexpires_at_ms, which is reachable only when an intent's TTL exceedsresult_ttl_ms. Keepresult_ttl_msabove the largest intent TTL in use — a rule redelivery correctness already needs. - Duplicate delivery / bundle replay (non-adversarial): unchanged. Deterministic ids plus dedup, exactly as before.
Not covered, deliberately: ToolResult and approval envelopes are not signed
(the re-injection path admits a result only against a live continuation with a
matching pending intent_id; everything else is orphaned_result), payloads are
not encrypted, and there is no per-tool authorization policy — one key, one trust
domain.
Secrets never travel in intent payloads or tool arguments¶
args_json MUST NOT carry secrets. This is a contract, not a preference,
because of where args_json goes: it is persisted in keyed state (PENDING,
continuations), written to the outbox topic, copied verbatim into the effector's
dead-letter channel, and re-encodable into pipeline error records. A secret
placed there is a secret copied into more places than you can revoke.
A tool that needs a credential resolves it effector-side, in its own body, from the environment the deployment provisions. Only non-secret identifiers travel as arguments:
import os
import httpx
from beam_agents.tools import IntentInfo, tool
@tool(side_effect=True)
async def charge(customer_id: str, amount_cents: int, *, intent: IntentInfo) -> str:
# The credential is resolved here, from the effector's own environment.
# `customer_id` and `amount_cents` are the only things on the wire.
api_key = os.environ["PAYMENTS_API_KEY"]
async with httpx.AsyncClient() as client:
response = await client.post(
"https://payments.example/charges",
headers={
"Authorization": f"Bearer {api_key}",
# Replays and crash-window re-executions re-mint the same
# intent_id, so the provider performs the charge exactly once.
"Idempotency-Key": intent.intent_id,
},
json={"customer_id": customer_id, "amount_cents": amount_cents},
)
return response.text
This is not enforced by a linter, on purpose. Scanning args_json for
secret-shaped strings has false positives on legitimate high-entropy arguments
(ids, hashes, compressed payloads) in the hot path of a correctness-critical
writer, false negatives that manufacture confidence, and in either case the
secret it "catches" has already been minted into the intent object. The
structural fix — credentials never enter the argument path — is enforceable by
review and stated here; a scanner is neither.
Secrets by reference, never by value¶
Every credential the library reads is named by a reference:
| Form | Resolves to |
|---|---|
env:VAR_NAME |
the value of that environment variable |
file:/path |
the file's contents, stripped |
References are validated eagerly at configuration time (import-free — no client
library is loaded to check a string) and resolved once, at client construction.
The resolved value lives inside the client object and is never written back onto
the configuration, so it appears in no attribute, no repr, and no error
message.
The pattern in full: your secret manager (Google Secret Manager, Vault, SOPS, sealed secrets — whichever you run) materializes the secret into the workload's environment or a mounted file; the library only ever sees the name. The library ships no secret-manager client and does not want one.
URI credentials are redacted, not rejected¶
redis://user:password@host:6379 keeps working. Its userinfo is masked
(redis://***@host:6379) in every configuration error message, in
repr(EffectorConfig), and on the beam-agents-effector startup error path, so
a repr in a log or a traceback in CI output stops being a leak. Prefer the
reference forms anyway — redaction cannot reach the client libraries' own
logging, and aiokafka or redis-py may render a connection string you never
handed them. Keeping the credential out of the URI removes the material those
logs would leak.
Finally: never pass a credential as a command-line flag. argv is visible in
process listings to every user on the host. Every security-relevant flag on
beam-agents-effector also reads an environment variable for exactly this
reason.
Kafka hardening¶
Use SASL_SSL (or mTLS) and per-principal topic ACLs. The library threads the
settings into every Kafka client it constructs — the effector's intent source,
its result/approval/dead-letter sinks, and WriteIntents' producer:
EFFECTOR_KAFKA_SECURITY_PROTOCOL=SASL_SSL \
EFFECTOR_KAFKA_SASL_MECHANISM=SCRAM-SHA-512 \
EFFECTOR_KAFKA_SASL_USERNAME_REFERENCE=env:KAFKA_USER \
EFFECTOR_KAFKA_SASL_PASSWORD_REFERENCE=env:KAFKA_PASSWORD \
EFFECTOR_KAFKA_SSL_CA=/etc/ssl/certs/kafka-ca.pem \
beam-agents-effector ...
from beam_agents.effector.config import TransportSecurity
security = TransportSecurity(
security_protocol="SASL_SSL",
sasl_mechanism="SCRAM-SHA-512",
sasl_username_reference="env:KAFKA_USER",
sasl_password_reference="env:KAFKA_PASSWORD",
ssl_ca_location="/etc/ssl/certs/kafka-ca.pem",
)
intents | WriteIntents("kafka://broker:9092/agent-intents", transport_security=security)
One caveat, because it is a property of the clients rather than of this
configuration: the TLS material paths are read by two different client families.
The effector's Python clients (aiokafka) read PEM files. WriteIntents on a
kafka:// URI is Beam's cross-language sink, which is the Java client and
reads Java keystores (ssl.truststore.location / ssl.keystore.location).
Point each side at the format its client speaks.
Least-privilege ACLs¶
Neither principal gets cluster admin, and neither gets a wildcard topic pattern.
| Principal | Topic | Operations |
|---|---|---|
| pipeline | agent-intents |
Write, Describe |
| pipeline | agent-results, agent-approvals |
Read, Describe + Read on its consumer group |
| effector | agent-intents |
Read, Describe + Read on the effector consumer group |
| effector | agent-results, agent-approvals, agent-intents-dead |
Write, Describe |
kafka-acls --add --allow-principal User:beam-agents-pipeline \
--operation Write --operation Describe --topic agent-intents
kafka-acls --add --allow-principal User:beam-agents-effector \
--operation Read --operation Describe --topic agent-intents
kafka-acls --add --allow-principal User:beam-agents-effector \
--operation Read --group beam-agents-effector
Pub/Sub hardening¶
Pub/Sub authentication is Application Default Credentials, so there is nothing
to configure in the library — only IAM roles to grant. Bind roles per resource,
never at the project level, and grant neither principal roles/pubsub.admin.
| Principal | Resource | Role |
|---|---|---|
| pipeline | topic agent-intents |
roles/pubsub.publisher |
| pipeline | subscriptions agent-results-sub, agent-approvals-sub |
roles/pubsub.subscriber |
| effector | subscription agent-intents-sub |
roles/pubsub.subscriber |
| effector | topics agent-results, agent-approvals, agent-intents-dead |
roles/pubsub.publisher |
gcloud pubsub topics add-iam-policy-binding agent-intents \
--member=serviceAccount:beam-agents-pipeline@PROJECT.iam.gserviceaccount.com \
--role=roles/pubsub.publisher
gcloud pubsub subscriptions add-iam-policy-binding agent-intents-sub \
--member=serviceAccount:beam-agents-effector@PROJECT.iam.gserviceaccount.com \
--role=roles/pubsub.subscriber
The intents subscription must additionally have message ordering enabled — a correctness precondition, documented with the others in Running the effector.