# Two-Key

Authorization layer for AI agents. A tool call runs only if Path A (a policy VM)
and Path B (a judge quorum) both allow. Beta (pre-1.0): maintainer-reviewed with live LLM
testing and automated scanners, not independently audited. Apache-2.0.

Package: two-key
Import: two_key
Package version: 0.7.0 (the SIEM export, a start-up check for the account the monitored agent runs as, a count of refused gateway tokens). Tag v0.7.0 is on main; so are v0.6.0 (Windows support, content scanning through your antivirus or DLP product, a start-up check for writable paths), v0.5.0 (MCP proxy, caller-supplied facts, constitution v3) and v0.4.0 (package renamed from two-key-concept). Tags v0.3.1 and earlier are in the archived two-key-concept repository; there v0.3.0 stays on e44be56. Tag v0.2.4 stays on fc836bb. Tag v0.2.3 stays on 4a4352e.
Tag v0.2.0 stays on commit 2f756ac. v0.1.12 and v0.1.6 stay on their trees.
0.7.0 is on PyPI. Distribution name two-key, import two_key, extras yaml and mcp. Install two-key[yaml]==0.7.0, or two-key[mcp]==0.7.0 for the proxy. Tag v0.7.0 was not moved. docs/PYPI.md is the release process: trusted publishing, no token in the repo.
uv.lock pins cryptography and PyYAML for CI (wheels only). pyproject.toml ranges are unchanged.
0.2.3 refuses vendor:/min_vendors:, refuses a judge holding the agent's credential even unsent or as
user:pass, and pins each ledger's witness key on first open: CHANGES.md
"Upgrading from 0.2.2" (then "Upgrading from 0.2.1" and "Upgrading from 0.1.12" for older).
0.2.4 writes head.anchor on first open and refuses a rolled-back ledger directory: "Upgrading from 0.2.3".
0.3.0 backs up principal, capability, ledger, and witness keys in three passphrase bundles
(pbkdf2-sha256, 600000 iterations, AES-256-GCM). backup-restore writes head.anchor from the same
moment as the ledger and does not delete it. Not a seed phrase. "Upgrading from 0.2.4".

Does: signed constitution, Path A bytecode, Path B judges (xAI/Grok, OpenAI,
Anthropic, Gemini, Ollama), monitored agents, encrypted ledger, single-use token.
Does not: a scanning, antivirus, or DLP engine of its own, a SIEM or log management product of its
own, PKI, chain anchoring, seed phrases.
From 0.6.0: Windows support, and optional content scanning that calls
your antivirus or DLP product (clamd, ICAP, a command, or Python; docs/SCANNERS.md). Two-Key
starts every scan of a tool the constitution marks; the agent cannot ask for, skip, or answer
one. The signed rule on the scan field (clean, flagged, unavailable) is the stance. Each declared
file states max_bytes; a file over it, more than 32 files or 100 MiB in a call, or a file Two-Key
cannot read is denied before the scanner and both paths under every stance. An encrypted file the
scanner cannot open is flagged, unless the tool's signed spec says encrypted: pass and encryption is
the only finding (verdict encrypted).

From 0.7.0: optional SIEM export (docs/SIEM.md). TwoKey(...,
siem=SiemConfig(sinks=[...])), or the proxy.yaml siem: block, copies every ledger entry, after it is
written and fsynced, to a syslog sink (transport tls on 6514 by default; tcp or udp on 514; every
port configurable) or an HTTPS webhook (batched newline JSON, an authorization header read from an
owner-only file, optional HMAC, redirects never followed). Schemas: native two-key-siem-v1
(default), ecs, ocsf. Formats: json (default), cef (native only). An explicit per-kind field
allowlist decides what leaves: kind, seq, the entry hash, reason codes, digests, sizes, tool id,
data class, quorum counts, the scan verdict; never argument values, proposal or decline text, the
derived amount, counterparties, custom field values, scanner threat names, or any credential. A
collector that is down, slow, or gone never blocks or delays an authorization: entries queue on a
sender thread, delivery resumes from the cursor (<ledger>.siem/cursor.json), and late events carry
replay: true. TLS verifies the certificate and the host name with no setting to disable either; a
plaintext sink is refused under high_assurance (siem_plaintext_refused) and warns at start
otherwise. two-key siem-export writes the same events to a file (owner-only, never over an existing
file, a signed summary last) and two-key siem-verify re-derives each event from the ledger offline
(changed fields, gaps, repeats, events from another ledger). The gateway counts tokens it refuses
without a ledger entry by class (bad_signature, expired, malformed, other) and reports the counts at
most once an interval, on stderr and as a gateway_token_refused event marked ledgered: false; no
token text is kept and nothing is written to the ledger. Standard library only, no new dependency.
Two-Key is not a SIEM: storing, searching, retaining, and alerting stay with the collector.

Read docs/FIT.md, docs/COMPARISON.md, docs/THREAT_MODEL.md, and docs/SCOPE.md
before README.md and docs/HOWTO.md. docs/CONNECTING.md: how to connect the agent and every hook so
it cannot go around Two-Key. Coding agents: AGENTS.md. Tests: python -m unittest discover -s tests
(no network).

authorize_from_agent does not execute a tool. A missing ballot is not a yes.
Both paths run once the call is well-formed, within limits, and readable (an oversized or
malformed call is an early deny before either path; a derive_failed call skips the judges unless
tool_args_on_derive_deny, and path_b is ledgered as skipped:derive_failed). The default needs one judge and has no diversity
floors; required_yes defaults to min(2, judges). With required_yes 1, two or more judges, and
min_responding 1 (its default then), one judge approves alone while the others are down: TwoKey warns
at start (lone_judge_warning) and does not refuse. QuorumPolicy.high_assurance
(profile: high_assurance) needs judges from 2 makers (min_makers; maker: is the operator's
label for who made a judge's model, not verified, never compared with the agent), 1 local judge,
and a local yes. The old names vendor/min_vendors are refused (removed in 0.2.3). At start, TwoKey
warns (writable_path) for each file or folder of the running Python, or of check_paths (the proxy
passes proxy.yaml, the files it names, hook modules, the upstream program and folder), that another
account could change; on_writable_paths: refuse refuses, and high_assurance always refuses (0.6.0).
Run Two-Key as a service account (docs/CONNECTING.md). Each ballot is one
stateless request at temperature 0; a judge endpoint that keeps memory across ballots carries agent text from one
call into the vote on another, which Two-Key cannot see: give each judge a fresh context per ballot.
TwoKey needs monitored_agent, declared by the operator. A judge may run any
model from any vendor. A judge is not the monitored agent: TwoKey refuses to
start (judge_matches_agent:) when a judge holds the agent's credential (the
same API token, or the same username and password; compared by fingerprint
under a per-install key, HMAC-SHA256 for a token, PBKDF2-HMAC-SHA256 600,000
iterations for a username and password as one pair, and for any secret containing ':';
Basic over HTTPS only) at any address, sent or not, or both sides
keyless on one address (host:port as configured, local aliases folded, no
DNS). A judge holding agent_session, sent or not, abstains at any address. Other
overlaps start with a warning on stderr and in constitution_loaded:
same_address_one_side_keyless, same_model_same_address,
same_model_shared_route, same_model_unknown_proxy, shared_tenant,
unresolved_identity. allow_same_model_distinct_tenant and
allow_same_provider_judge are deprecated no-ops. Local means local_weights true, host loopback / RFC 1918 / fc00::/7 (names not resolved), and not a :cloud/-cloud model.
JSON and YAML inputs refuse duplicate keys, YAML merge keys (<<), and integers over 4,300
digits. Arguments or proposals over
256 KiB are denied. Ballots are paired with judges by position.
require_path_a_first is not a skip. The scanner (0.6.0) is optional; without one nothing is
scanned. There is no PKI and no external anchor.
The ledger head is signed by the principal and a witness key. The witness public key is pinned
in the ledger (first entry witness_pinned; a ledger from 0.2.2 or earlier pins its head's key on
first open) and optionally outside it (Ledger(witness_public_key=...), --witness-public-key).
Another witness key is refused (witness_key_changed). It changes only by rotate_witness
(witness_rotated, signed by the principal and the old and new witness keys). two-key audit
prints both pins. A head anchor beside the ledger key (head.anchor, or --head-anchor) refuses a
rollback or wipe of the ledger directory alone. Deleting the anchor accepts the ledger as it stands.
It is not an external anchor. two-key recover drops an uncheckpointed tail and does not run on open.
Creating a ledger at a new path warns on stderr. A non-empty agent_session satisfies the cloud-judge
presence check, including a placeholder; that does not fail closed. The check fails closed only when
the session is missing or equals a judge credential. revoke ends earlier tokens only; an optional
reputation halt is on the roadmap after 1.0 and is not built.
The token TTL default is 120 seconds, at most 300. It is signed by the capability key, not
the principal key. The gateway verifies only. It runs in the TwoKey process on the
TwoKey ledger object (TwoKey.gateway()); a gateway in another process or host is not
supported (#45), and verify-only is not key isolation: that process also holds the
minting, principal, witness, and ledger keys, and the registered tools. It binds the tool, the argument
hash, the ledger root and size, the constitution hashes, spec_hash, and the
derived form. tool_specs is required. A disagreeing claim is a deny.
deny_unmapped defaults off. An unnamed argument key does not reach the tool.
Set the flag to deny the call instead. A counterparty path requires allow.
The tool receives the canonical party. A payload path is not interpreted.
Optional shape locks it to a string, a number, or a list of strings and
does not read the contents. Omit shape and the value's children are copied
unread. After a derive deny, tool_args are withheld from
judges unless tool_args_on_derive_deny is set. A derive_failed call (unreadable under its spec)
then reaches no judge (0.5.0); amount/counterparty/irreversible mismatches and rule denies still do.
Custom Path A fields (0.3.0): a rules file may declare fields: (boolean, integer, enum, or
string; up to 64), each with a signed default and an optional description. A tool spec maps
a field to an argument path; a mapped field is required. A counterparty allow entry "@example.org"
(0.5.0, v3) admits any plain email address at exactly that ASCII domain (no subdomains, no lookalike
letters, no header-carrying local parts; never in deny_counterparties; no wildcards); signing warns when
the tool also passes free text Path A does not read. Every tool must map each field a
deny_if_fields rule reads or mark it not_applicable (fields: {name: not_applicable}; it then gets
the default; signs as v3); a forgotten decision is refused at signing and on load (0.5.0).
Rules name them with deny_if_fields (eq, neq, gt, gte, lt, lte, in, not_in by type). Values
come from the argument bytes only; an agent cannot claim them, but the agent writes the
arguments, so a field is the call's content, not a verified fact (po_matched: true is an
assertion). Facts about the world must be checked by the tool or supplied by the integration:
a field declared source: caller (0.5.0; signs as a v3 constitution, which 0.4.0 refuses)
is mapped {source: caller} or {source: caller, json_path: P} and takes its value from
authorize(..., facts={...}) only; the agent writing it is caller_field_in_arguments, a missing
fact field_missing, an unmapped fact fact_not_mapped. Pass the same lookup to
gateway.invoke(..., facts=...); a changed value is fact_mismatch. With a json_path the tool
receives the fact there. two_key.guard.Guard does both lookups for any agent loop
(GuardedTool(name, fn, claim, facts={field: hook(tool, arguments)})). Enum and string values are
printable ASCII by default (trimmed, lower-cased; the tool receives that value), or Unicode with a
declared script (charset: unicode, scripts:). An ASCII string field may declare case: exact: compared
and passed to the tool as written (for case-sensitive ids; v3); signing warns when it sits in an eq/in
deny-list rule, and when a lower-cased string field maps an id-like path (*_id).
A description is never compiled, sent to a judge, or used in a reason. A rules file with
fields signs as a v2 constitution (v3 with a caller field); --envelope-format v2 forces v2 without fields.
Ledger deny/error type labels use type_tag (≤32 chars); derive_failed and EncodingError paths are capped. Same-jti deny waiters time out (default 30s; deny_inflight_wait_seconds must be finite positive). Free text is not classified. There is no per-value information-flow tracking.
two-key authorize omits claim fields the operator did not pass. An omitted
amount or counterparty is read from the argument bytes. An omitted
irreversible follows the tool spec. An omitted data_class is classified,
then the tool floor, which never lowers it. Classes combine: public < personal < financial,
medical < classified; financial and medical together are financial+medical (a data_class_in rule
naming either matches), never an invented classified. A claim may be financial+medical in either order;
a repeated class (financial+financial) is malformed_action. sign-constitution prints a note for each
not_applicable mark (never denies / reads the default; always denies is a WARNING). --amount-usd, --counterparty,
--data-class, and --irreversible / --no-irreversible set a claim. A claim
that disagrees with the bytes or the spec is a deny.
MonitoredAgent.complete sends the agent the constitution prose and the instruction. It sends
tool_specs only with the opt-in vocabulary=tk.agent_vocabulary(tools=None|[...]) (tool ids,
argument paths, value syntax, floors, field types, data classes; never a value: no allow list,
enum value, integer bound, rule, threshold, blocked party, description, default, or
irreversible flag; grants nothing). Exact values (payee id, enum value) come from the
instruction or the prose; ids in the prose reach the agent too. An agent proposal that omits data_class is classified, whatever the floor.
No display-name-to-id mapping or fuzzy matching. An agent declines with {"decline": "<reason>"}
(call keys may be present only if empty): authorize_from_agent denies agent_declined, neither path
runs, no token; complete() raises AgentDeclined. A decline mixed with a call is malformed_proposal. Judges do not enforce allow lists or data classes; Path A does.
MCP proxy (0.5.0; extra two-key[mcp], the official MCP Python SDK, only
two_key/mcp_proxy/sdk.py imports it): two-key mcp-proxy --config proxy.yaml starts one upstream MCP
server as a child over stdio (upstream.command as a list, no shell; only upstream.env, literal or
{env: NAME}, plus HOME/LOGNAME/PATH/SHELL/TERM/USER), shows the agent only signed tools under their
signed ids (name, title, description, inputSchema, outputSchema, annotations), and refuses to start
when upstream.schema_digest does not match those fields (upstream_schema_changed) unless
on_schema_change: warn. It refuses an unresolved_identity warning unless allow_unresolved_identity:
true (the library only warns). Each tools/call: authorize with the per-tool claim (claim: floor, a
data class, or none = classified; amount, counterparty, irreversible never claimed; fixed proposal),
then gateway.invoke in the same process (#45), whose tool is the upstream tools/call with projected
arguments only. tools.<id>.facts: {field: {hook: "module:function"}} runs a fact hook; caller
paths are removed from the inputSchema shown. Other names are tool_not_offered (ledgered);
mcp_proxy_started is ledgered at start. A deny is an MCP tool error "two-key
denied this call: <reason>" (reason code only). One call at a time; tools listed once at start; no
resources, prompts, sampling, elicitation, or roots. The proxy serves the agent's host over stdio only (no
network listener); an agent on another machine starts it over SSH with a forced command (docs/CONNECTING.md).
The proxy refuses to start (on_agent_access: refuse, the default; warn starts; high_assurance always
refuses) when the real account of its parent process is Two-Key's own (agent_same_account), root
(agent_account_root), unknown (agent_account_unknown, which includes Windows for now), or can change a
path it runs or reads (agent_can_write). Processes sharing a ledger read and verify each other's entries
under the lock before writing; a shorter file or a partial line is still refused. Remote agents are
little tested (ROADMAP). two-key mcp-draft-specs --out DIR -- COMMAND
drafts tool_specs (signing refuses until each floor, amount unit, and allow list is decided;
*_matched/*_verified/*_approved/... left out as caller-fact candidates) and prints the digest;
it warns when a payee or amount it maps has no description in the upstream schema (the agent's only
format guide behind the proxy); --constitution and proxy start warn the same way for every input the
signed specs read (amount, currency, counterparties, argument-mapped custom fields), whatever the data. --constitution FILE prints the digest of a signed constitution's tools.
An agent that can reach the upstream directly bypasses the proxy; run the upstream as its own OS user.
decision.reason names one cause (parse/limit/derive deny, then Path A, then Path B); read
path_a.reason and path_b.reason too. judges.yaml and agents.yaml are trusted operator config:
a callback auth entry imports the module it names.
