Skip to content

Security: irinityhq/irin

SECURITY.md

Security Overview — IRIN (Irinity)

Status: Experimental. This project binds to 127.0.0.1 by default. It is experimental and makes NO compliance claims. Localhost-only default, single-tenant v1. No certification, audit result, or conformance statement is claimed anywhere in this document.

Purpose: The honest operator-facing security overview. It states what an operator is protecting, who the adversaries are, where the trust boundaries sit, what this system does not defend against, and — plainly — what an attacker who already owns the host can still do.

Primary sources: the ASI0X threat model (sentinel/docs/security/threat-model-asi0x.md), the Gateway arming runbook (gateway/docs/runbooks/arming-authorization.md), and the per-product SECURITY.md files (sentinel/, gateway/, council-rs/). Where a mitigation is not yet built, this document says GAP and names the roadmap phase that closes it — it does not imply a control that does not ship today.

Claim-by-claim enforcement map: every public security claim, mapped to the code that enforces it (or honestly marked as not yet enforced), lives in docs/security-claims-vs-reality.md.


What this system is (one paragraph, so the threat model makes sense)

The Sovereign Triad — product brand Irinity, shipped as the IRIN mono-repo (irinityhq/irin) — is a local-first agent control plane. Deterministic Sentinel watchers (no LLM in the watch loop; fire decisions under a runtime-enforced ≤100ms budget — runtime.rs constants with per-phase sub-budgets: observe 50ms, interesting 10ms, escalate 100ms, 200ms total pipeline, violations surfaced as BudgetViolation) observe files, queues, feeds, and ledger deltas and emit evidence-only Escalations. The Gateway routes, meters, and enforces budget caps, then carries an Escalation to Council as a model: council-triage deliberation. Council is the decision authority — it deliberates across multiple models and emits a bounded Directive, which the Gateway signs (Ed25519 over RFC 8785 JCS-canonicalized bytes) into an append-only directive_outbox whose companion fire record binds it into a SHA-256 hash chain. In v0.1 the signed outbox row is the endpoint: Worker execute is not shipped. Turning the loop from dark to live requires a dual-custody arm ceremony — a host bearer token plus a live hardware attestation (Secure Enclave / Touch ID, or a FIDO2 key). Default is OFF; everything binds to loopback.

The design maxim: Watch is cheap. Thought is rare. Action is final. Each tier earns the next. The security posture follows from it — cheap deterministic observation, expensive deliberation gated behind budget, final action gated behind hardware-attested human confirm.

Legacy names

Internal hash-chain genesis labels retain the InnerWay-era string (innerway.watch.v0.1.genesis); they are frozen constants — renaming them would invalidate every existing chain. A protocol-version bump is the scheduled path.


1. Assets — what an operator is protecting

Asset Where it lives Why it matters
Signing keys — two separate materials (1) Ed25519 ledger seed at ~/.irin/ledger_key.pem (make ledger-key-dev) — signs audit_events/ledger rows. (2) Sidecar directive identity key (directive_identity.json at DIRECTIVE_IDENTITY_PATH, default /var/lib/sidecar/ inside the sidecar container volume — not under the operator home; kid sidecar-v1-…) — signs directive_outbox envelopes (JCS + Ed25519). Whoever holds a given key can produce rows on that surface that verify cleanly. Forging a verifiable outbox row requires the directive identity material, not (only) the ledger seed. Highest-value on-disk secrets — see §5.
Arm-attestation key material Secure Enclave key (~/.config/gateway/arm-attest.key); enrolled public-key registry arm_attest_keys.json (GW_ARM_ATTEST_KEYS_PATH) The private key never leaves the enclave — the only signing-relevant secret that cannot be read off disk (subject to the physical-access and enrollment caveats in §3). It is the residual control that gates going live.
Ledger / outbox integrity Append-only stores with a deliberate asymmetry: watch_fires = SHA-256 hash chain only (high-volume evidence, unsigned); audit_events + directive_outbox = hash chain + Ed25519 signatures. Pruning/compaction cryptographically forbidden (rc1-audit-chain-pruning-ban) Tamper-evidence records — any edit to recorded history is detectable, not that history cannot be appended to (see §5). The two surfaces do not provide identical properties; see the provenance-asymmetry notes in the Gateway contract docs.
Council transcripts / precedent store council-rs precedent engine (sessions/index.jsonl, Cold Eyes R2+ injection); per-tenant Librarian memory A poisoned precedent corpus slow-drips biased rulings into every future deliberation. This store is not integrity-watched today (V1 GAP).
Budget / spend ledger spend_ledger (atomic reserve-settle-or-rollback); DAILY_SPEND_CAP ($50/UTC-day, proposed canary value), MAX_FANOUT_COST_USD ($5/directive) The hard ceiling on financial blast radius. The day cap is the binding control; single-step reserve is fail-closed on any DB error.
Arm state arm_audit chain; writer_claim singleton row; default-OFF env manifest (WATCH_PRODUCER_ENABLED, WATCH_DISPATCHER_ENABLED, GW_ENABLE_COUNCIL_ENDPOINT) Whether the loop can act at all. Config-presence is execution-authority on the boot env-arm path — a copy-pasted .env with the live-switch set goes live.
API keys Virtual gateway keys (gw_live_…); host arm bearer(s) in GW_ARM_PRINCIPALS; upstream provider keys (in ~/.zshrc, never hardcoded) Caller identity, arm authority (custody domain 1), and the provider credentials the Gateway proxies with (and strips from client requests).

2. Adversaries & threat model

Mapped to the OWASP Top 10 for Agentic Applications (ASI0X) as enumerated in threat-model-asi0x.md, cross-referenced to NIST AI RMF functions (Govern / Map / Measure / Manage). Status is stated honestly per vector: Covered, Partial, or GAP. Two vectors have no working mitigation today and are called out first so no reader misses them.

The two open gaps, stated plainly

  • V1 — Memory & context poisoning: GAP. An attacker who can write to the precedent corpus or a vector/memory store can slow-drip biased rulings into every future deliberation without touching any prompt or UI. The append-only hash chain protects the fire log and the outbox; it does not watch memory stores for drift. The multi-stage decontaminator screens inbound prompts, not retrieved content. Closing work is Phase C1 (memory-integrity-watch), and it is hard-gated on first naming the watched store — no store, no sentinel. (NIST AI RMF: Measure gap on the knowledge-authority layer.)

  • V4 — Sequence-level / multi-step attacks: GAP. Each escalation may pass policy, each directive may validate against directive_fence, each spend may sit under the budget envelope — yet a crafted sequence (many small spends, a chain of tool calls, a velocity spike across directive_outbox rows) can sum to an unapproved outcome that no single-step check catches. There is no stateful watcher over the sequence today. Closing work is staged B (named) → C0 (sequence-watch-assessment.md) → D (sequence-watch, a stateful, LLM-less sentinel). (NIST AI RMF: Manage gap.)

Full ASI0X mapping

Vector Status today What mitigates it (or why it's open) Closing phase
V1 Memory & context poisoning GAP Audit chain protects fire-log/outbox, not memory stores; decontaminator screens inbound not retrieved C1 memory-integrity-watch (after naming the store)
V2 Inter-agent trust boundaries Partial Directives Ed25519-signed + validate_directive_proposal_v1 shape-validated before signing; escalation locked to one route (council-triage, no bespoke REST endpoint); Worker execute not shipped (no privilege-bearing sub-agent to compromise). GAP within: no capability-token authority attenuation D capability tokens + Gateway directive allowlist
V3 Agentic supply-chain malware Partial (deps scanned; skills GAP) Crate pinning fixes versions; on top of it a cargo audit CVE gate and CycloneDX SBOM generation are wired in CI. Authoritative surface: the mono-repo root CI (irinityhq/irin .github/workflows/ci.yml) runs ONE workspace-wide cargo-audit + SBOM job over the shared Cargo.lock (pinned tool binaries, sha256-verified). Original-repo workflows are historical: gateway triad-ci.yml (audit + SBOM), sentinel ci.yml (audit + SBOM), council-rs ci.yml (audit; its SBOM is emitted in release.yml, not ci) — T23-R. Audit reads Cargo.lock against the live RustSec DB, so it runs even without a build. During the private phase the heavy CI jobs run on self-hosted runners; runner exposure is a named flip-gate item (fork-PR execution risk) before any public repo uses them. Upstream debt is tracked, not hidden: paste (RUSTSEC-2024-0436, unmaintained via fastembed/tokenizers) is a justified --ignore — see council-rs SECURITY.md + sentinel/docs/audits/2026-06-01-dependency-audit-playbook.md. Still GAP: installable skills execute unscanned in the operator context; no compose-not-rebuild watcher Deps leg shipped (E); skills supply-chain-watch + compose-not-rebuild remain (C/future)
V4 Sequence-level / multi-step GAP Single-step checks only (per-call budget, per-directive fence, per-fire dedupe); no stateful sequence watcher B named → C0 assessment → D sequence-watch
V5 Human-agent trust exploitation Partial — structural controls only Arm is hardware-bound ceremony (finger on sensor, not a typed code), rate-limited, default-OFF; Escalation payload carries no LLM inference (deterministic evidence). GAP: no detector for persuasion in the Directive text itself — the defense is procedural + least-agency (recommend-only) Structurally bounded; graduates with D bounded execute. No new primitive
V6 Tool misuse / defend-from-own-agents Partial → strong Inbound multi-stage decontaminator + opt-in Sovereignty Gate endpoint (not default-wired outbound); execute locked (the action leg is not built, which is the most decisive mitigation); RED payloads / X-Sovereign-Mode force local-only routing D action allowlist + sandbox assessment (before any execute)
NSA MCP blind spot (cross-cutting) GAP The watch plane could observe the inverted MCP server→client channel legacy tools miss — nothing watches it today. council-rs runs an xmcp MCP client; no sentinel monitors it B CISA/NSA standards map; C3 MCP adapter; D external-sentinel registration

NIST AI RMF read-across (self-assessment, not conformance): the triad is strongest on Govern (separation of powers across five primitives; dual-custody arm; Council as decision authority; non-negotiable budget envelopes) and Measure on the transaction layer (hash-chained audit, ledger/verify, JCS canonicalization). It is weakest on Measure of the knowledge layer (V1) and on Manage of sequences (V4). These are the same two gaps named above.


3. Trust boundaries

The posture reasons outward from a single declared default: the host is the perimeter.

Host boundary (primary). In the recommended docker-compose deployment the Gateway is published only on 127.0.0.1:18080 (compose port mapping), not exposed to LAN. Precision, because it matters: the nginx listener inside the container is 0.0.0.0:8080 — loopback-only is a property of the compose publish, not of the listener itself. Bare (non-compose) runs and the Council port's token-dependent bind logic must be locked separately. The sidecar management surface is a Unix socket (0o660, fail-closed), never proxied. Client-supplied auth headers are replaced, never forwarded to upstream providers. "No authenticated remote exposure" therefore holds under the compose-publish + loopback assumption — not unconditionally.

Process boundaries inside the host.

Boundary Enforcement
Sidecar ↔ OpenResty Unix Domain Socket (/run/sidecar/sidecar.sock), bypasses the network stack. Socket mode 0o660 (owner+group, world none); invalid mode refuses startup. This is transport isolation for the management surface, which nginx does not proxy.
Caller → Gateway Virtual API keys (gw_live_…); token-bucket rate limiting global / per-IP / per-key; watch-plane auth enforced.
Escalation path One locked route (model: council-triage through the Gateway's own router). No bespoke escalation REST endpoint — removes a class of trust-boundary bypass within the current in-process routing; the boundary assumes the Gateway itself (Lua router + sidecar) is not subverted — see §5.
Directive → outbox Ed25519 signature + hash chain + directive_fence shape validation before signing.
Audit Append-only; pruning/compaction cryptographically forbidden.
Producer arm Dual custody; default-OFF; single-writer writer_claim enforced in code.

The human-in-authority-chain arm gate. This boundary is designed to survive a host compromise — with two caveats stated below, both real (see §5). Arming the producer is dual-custody: a host bearer token (custody domain 1, authorizes stage) plus a live hardware ES256 attestation (custody domain 2, verified at confirm) — a Secure Enclave / Touch ID key or a FIDO2 USB key. The second factor is a finger on a sensor, not a copy/pasteable code; the private key never leaves the hardware. An unexpected Touch ID prompt is treated as an alarm, not an inconvenience. The asymmetry is deliberate: arming always blocks on the second factor; emergency /disarm is single-principal fail-safe — any bearer in GW_ARM_PRINCIPALS can kill the producer immediately, so a fast kill never waits on a second signature (H-2 decided). Signatures carry a 30-day expiry; enclave/FIDO2 credentials are revoked by key de-enrollment, not token rotation.

Caveat 1 — the boot env-arm path (the bigger one): the boot path can arm without the interactive ceremony (still audited as a boot_env_arm row, still refused if the audit append fails). The EXECUTION_MODE=LIVE circuit-breaker is shipped (gateway b985bc2): watch flags alone no longer arm — the boot gate additionally requires EXECUTION_MODE to be exactly LIVE plus the dispatcher key present. Config-presence is still execution-authority in the reduced sense: for an operator who uses this path, the hardware boundary reduces to filesystem-write (including the explicit EXECUTION_MODE=LIVE line) + restart control. The breaker narrows accidental arming (a copy-pasted .env without the LIVE line stays dark); it does not stop a deliberate attacker with .env write.

Caveat 2 — the hardware factor's own limits: it raises the bar for a remote attacker, but it lives on the operator's machine. Physical access, firmware-level attacks, or an operator conditioned to tap through unexpected prompts can defeat it, and both the enrollment tool (bin/arm-enroll) and the enrolled-key registry (GW_ARM_ATTEST_KEYS_PATH) sit on the same host they gate.

Provider APIs as untrusted inputs. Upstream model providers are outside the trust boundary. Inbound prompts pass a multi-stage decontaminator (oversized-payload, encoding/Base64, ghost-gate, malformed-content, zero-width, homoglyph, and prompt-injection stages); a Sovereignty Gate alignment check (/guard/sovereignty) exists as an opt-in endpoint callers may invoke — it is not wired into the default outbound path. Provider egress is SSRF-guarded by pin-on-resolve (resolve + explicit IP deny + fail-closed on error/empty). RED-sensitivity payloads / X-Sovereign-Mode force local-only routing, bounding exfiltration-via-provider to the local host. A provider that returns manipulative or poisoned content is an expected adversary here — but note V5's honest gap: nothing inspects the Directive text for persuasion aimed at the human confirm.

MCP surfaces. council-rs runs an xmcp MCP client today. The NSA blind-spot warning applies: MCP inverts client-server so a server can query the client, over a channel perimeter tools don't inspect. The watch plane could observe that channel — but no sentinel watches MCP server→client traffic today. Treat any MCP server the operator connects as an untrusted, under-monitored surface (V3 supply-chain × the blind spot) until Phase C3 / the external-sentinel registration API close it.


4. Non-goals — what this does NOT defend against

Stated so an adversarial reader cannot claim the document oversold.

  • A fully compromised host. The triad is a control plane, not a sandbox boundary against root on the box. See §5 for the honest enumeration. Everything in the localhost-only posture assumes the host itself is trusted.
  • Same-uid local malware — the realistic case. Root is not required. Any code running as the operator's uid (phished binary, browser RCE, a malicious installable skill executing in the operator context — V3, unscanned today) can read the on-disk signing materials, provider keys in ~/.zshrc, and .env; edit .env for the boot-arm path; drop files for FileInboxSentinel; and poison stores watched only by the V1 gap. The Lua routing/decontaminator layer and the bin/arm* scripts are additional trusted code resident on the host. Most of §5 applies at same-uid, not just root.
  • A malicious operator. The Sovereign holds the host bearer and the hardware second factor. Dual custody is one human with two custody domains at n=1 — it raises the bar against a remote attacker who lacks the hardware factor, not against the legitimate operator choosing to arm and act.
  • Provider-side compromise. A compromised or malicious upstream model provider is treated as untrusted input (guards in §3), but the triad does not defend the provider's own infrastructure, nor guarantee provider outputs are benign — only that acting on them stays gated behind deliberation + human arm + (deliberately unbuilt) execution.
  • Multi-tenant isolation. v1 is single-tenant. The tenant field exists in the wire (APIs, comms envelope, DB) and is not stripped, but there are no cross-tenant isolation tests, no per-tenant quarantine, and no isolation proof — it is a v2 concern by locked decision. Do not run this as shared multi-tenant infrastructure.
  • CI runner exposure. Heavy CI jobs run on self-hosted runners. Fork-PR workflows do not execute automatically: repository policy requires maintainer approval of workflow runs for all external contributors, so external code never runs unreviewed on operator hardware. A hosted or ephemeral runner lane for fork PRs is a named fast-follow.
  • Multi-node / HA. Single-node by decision. There is no failover host and no high-availability boundary. Loss of the host is loss of the loop (fail-safe: it cannot fail into an armed state — see §5).
  • Autonomous execution. Worker execute is not shipped in v0.1. The signed directive_outbox row is the endpoint. Any sandbox / per-tool allowlist is a Phase D gate that must exist before execution is ever enabled.
  • Compliance / certification. No ISO/IEC 42001 certification, no NIST conformance, no CISA/NSA validation, no EU AI Act compliance claim, no SOC/SIEM posture. The standards mappings elsewhere in the repo are self-assessment for credibility, explicitly not conformance statements.
  • Critical-infrastructure-grade assurance. Explicitly out of scope.

5. What a compromised host can still do

The one-line summary: the triad bounds what an agent can do; it does not bound what an attacker on the host can do. That is the local-first design trade, and it is why "localhost only, experimental, no compliance claims" stays until a later phase says otherwise with receipts.

Note on privilege: most of this section applies at the operator's uid — root is not required. Signing materials are 0600 under the operator's home, provider keys live in ~/.zshrc, .env is operator-writable, and Docker volume access from the operator account typically bypasses in-container permissions (including the UDS mode). Root adds completeness, not most of the capability.

With the operator's uid (or root) on the box, an attacker can:

  • Read the on-disk signing keys and forge signed rows — per surface. Two separate materials: the Ed25519 ledger seed (~/.irin/ledger_key.pem) signs audit_events/ledger; the sidecar directive identity key (directive_identity.json) signs directive_outbox envelopes. An attacker with host read access to a given key can produce rows on that surface that verify cleanly — forging a verifiable outbox row requires the directive identity material, not (only) the ledger seed. The hash chain proves tamper-evidence over the recorded sequence, not that the signer was authorized at signing time. Signature validity is not the same as legitimacy once the key is on the same host as the attacker.

  • Read every other credential the loop touches. Upstream provider keys in ~/.zshrc, virtual gateway keys, the host arm bearer, and .env contents are all readable at the operator's uid. The Lua router/decontaminator and the bin/arm* tooling are modifiable trusted code at the same privilege.

  • Tamper is detected after the fact, not prevented. The append-only, hash-chained audit makes any edit to recorded history detectable on the next ledger/verify. It does not prevent an attacker from appending attacker-chosen entries, nor does it un-ring a bell — detection is retrospective. Off-host export/verification of the chain is what turns "detectable" into "detected."

  • Poison the precedent / memory store undetected. Nothing watches the precedent corpus or a vector store for drift today (V1 GAP). A host attacker can slow-drip biased rulings into every future deliberation until memory-integrity-watch (C1) ships and a store is named.

  • NOT complete an interactive arm without the hardware second factor — with the boot-path caveat first. If the operator uses the boot env-arm path, this boundary reduces to .env-write (including the explicit EXECUTION_MODE=LIVE line — the shipped circuit-breaker means watch flags alone stay dark) + restart: an attacker with deliberate filesystem write can still arm. On the interactive path the boundary holds: even with the host bearer (GW_ARM_PRINCIPALS) and full filesystem read, an attacker cannot produce the live Secure-Enclave/Touch-ID or FIDO2 signature required at confirm — that key material does not leave the hardware. A remote compromise can forge signed artifacts but not pass the interactive gate that turns the loop live (hardware-factor limits: §3 caveat 2).

  • NOT auto-execute a destructive action through the triad. Worker execute is not built. The most decisive tool-misuse mitigation is that the action leg does not exist — an attacker cannot fire a destructive tool through the loop today (they can, of course, run arbitrary commands directly as root; the point is the triad grants them no additional execution path).

  • Deny availability, but not silently strand an armed producer. A host loss destroys the enclave key (no portable copy — that is the security property) and takes the loop down; recovery requires the off-host host bearer and re-enrollment on a new host. Fail-safe holds: /disarm stays reachable on any surviving sidecar via the host bearer, and every safety invariant executes inside a DB transaction, so loss of the database can never fail into an armed or spending state.

What the host compromise does not get for free: the hardware confirm factor, a second isolated node (there is none), and — because execute is unbuilt — an autonomous action path. What it does get: read of on-disk signing keys, the ability to forge verifiable signed artifacts, and undetected memory-store poisoning. An operator protecting against a host-level adversary must keep the signing key and the host bearer off the exposed host and export the audit chain for off-host verification.

Key custody & recovery — GAP, named plainly. There is no documented off-host backup, split-knowledge, or dual-custody ceremony for the two Ed25519 seeds today: they are 0600 files with no export procedure. Host loss means re-key and re-pin (the T34 re-sign ceremony is the established pattern); the arm-attest key is deliberately non-portable (enclave-bound — that is its security property, not a gap). A documented custody/recovery procedure for the two on-disk seeds is a named roadmap item, not a shipped control.


6. Reporting a vulnerability

Please do not report security vulnerabilities through public GitHub issues.

Report privately via GitHub's private security advisory on the repository ("Report a vulnerability" under the Security tab), or by email to soc@irinity.com.

Please include:

  • A description of the vulnerability and its impact.
  • Steps to reproduce.
  • Any relevant logs, ledger/verify output, ProviderResponse trace, or code snippets.

Coordinated disclosure is expected: give the team a reasonable window to remediate before any public write-up. Supported version: main (v0.1.0-rc series); a supported-versions table lands with the first tagged public release.


Watch is cheap. Thought is rare. Action is final. Each tier earns the next.

There aren't any published security advisories