Skip to content
TAIPANBOXPublic

About

Identity Security Graph — unify humans, service accounts, keys and AI agents in one graph; detect excessive privilege and anomalous behavior (Apache-2.0)

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

idryx - Identity Security Graph

Read-only connectors stitch humans, service accounts, keys, and AI agents into one identity / access graph, then flag excessive privilege and anomalous behavior.

CI Go tests License Status

idryx architecture: the whole agent-event bus (TokenFuse, Wardryx, Mockryx, Verdryx, scopyx) and Agent Passports feed the idryx core (graph store, baseline engine, 27 detectors), which builds an identity / access graph and emits detector findings plus an Agent-BOM

idryx is a security layer on top of an organization's existing IdPs, clouds, and gateways: it reads the data Okta, Entra, AWS, GCP, and Azure already generate, plus the whole TAIPANBOX agent-event bus, and stitches every identity type, humans, service accounts, keys, MCP servers, and AI agents, into a single identity / access graph. Twenty-seven deterministic detectors then surface excessive privilege and anomalous behavior across that graph. Open source, dev-first, built for mid-market. See idryx-plan.md for the full design and roadmap.

Idryx architecture: Okta, Entra, AWS, GCP, Azure and the agent-event bus feed one graph core, which emits alerts, an Agent-BOM, a proposed Terraform diff and a read-only dashboard

The same service as its room on it-rat.com draws it, where the diagram sits next to a simulation you can scrub back and forth.


Where this fits in the stack

Idryx is the access/identity plane of the TAIPANBOX agent-governance stack: it builds the identity graph and runs detectors (runaway agents, missing attestation, incomplete BOM) from TokenFuse's event stream and Agent Passports.

flowchart TB
  Agent["AI agent (any framework)"] -->|"LLM call (base-URL swap)"| TF["TokenFuse proxy: spend + enforcement"]
  TF -->|"POST /v1/decide (PEP)"| WX["Wardryx: policy PDP"]
  WX -.->|"allow / deny / hold"| TF
  TF -->|"cheapest model, budget OK"| LLM[("LLM provider")]
  TF -->|"CallRecords"| CL["TokenFuse Cloud: control plane, incidents, replay, evidence, kill-switch"]
  VCX["Vouchryx: delegation proved, and endable"] -->|"short-lived token: act + cnf"| TF
  TF -.->|"polls /v1/revocations"| VCX
  VCX ==>|"delegation_issued / denied / revoked"| BUS
  TF ==>|"agent-event NDJSON"| BUS{{"agent-event bus + Agent Passport"}}
  WX ==> BUS
  Agent -->|"web fetch"| SCX["Scopyx: governed web egress"]
  SCX -->|"POST /v1/decide"| WX
  SCX ==>|"web_fetch / web_blocked"| BUS
  ENG["Engram: memory"] -->|"reflect via base_url"| TF
  ENG ==> BUS
  BUS ==> IDX["Idryx: identity graph, detectors, Agent-BOM"]
  IDX ==>|"identity_finding"| BUS
  BUS ==> QX["Qryx: crypto / PQC, passport + hash-chain scan"]
  QX ==>|"crypto events"| BUS
  BUS ==> VX["Verdryx: quality / drift"]
  VX ==>|"quality events"| BUS
  TF -->|"outcome-tagged traces"| VX
  MX["Mockryx: pre-prod safety rehearsal"] -->|"hostile scenarios"| TF
  MX ==>|"sim events"| BUS
  BILL[("cloud, SaaS and model bills")] --> CC["CostCrew: the bill, worked by a crew of agents"]
  CC ==>|"spend_spike / budget_threshold / crew moves"| BUS
  BUS ==> TRX["Trailryx: the record plane, sealed and packed"]
  BUS ==> HX["reads the log, mails you (heraldyx)"]
  HX -->|"one mail, a view and never an action"| OPS["your mailbox"]
  HX ==>|"alert_sent"| HJ[("heraldyx's own hash-chained journal, not this bus")]
  YOU(["you, in a browser over your own tunnel"]) --> GX[["Genaryx: the console over all of it"]]
  GX -->|"signed commands: the kill, an approval, a policy"| CL
  GX -->|"signed commands"| WX
  GX ==>|"console_command"| BUS
  GX -.->|"reads it"| IDX
  GX -.->|"reads it"| QX
  GX -.->|"reads it"| VX
  GX -.->|"reads it"| MX
  GX -.->|"reads it"| ENG
  GX -.->|"reads it"| SCX
  GX -.->|"reads it"| CC
  TFP["terraform-provider-taipan"] -->|"budgets + passports as code"| CL
  ASG[["agent-stack-go: shared Go contract"]] -.->|imported by| IDX
  ASG -.->|imported by| WX
  ASG -.->|imported by| MX
  ASG -.->|imported by| TFP
  ASG -.->|imported by| HX
  ASG -.->|imported by| QX
  SPEC[["agent-passport: the spec"]] -.->|governs| BUS
Loading
  • Consumes: agent-event NDJSON from every bus producer (TokenFuse, Wardryx, Mockryx, Verdryx, scopyx) and Agent Passports, both via agent-stack-go.
  • Produces: the identity graph, detector findings (runaway_agent, attestation_missing, bom_incomplete), and an Agent-BOM (CycloneDX).
  • Talks to: TokenFuse, Wardryx, Mockryx, Verdryx, scopyx (event sources), agent-passport (identity schema it validates against); imports agent-stack-go.

The full stack is TokenFuse (spend), Wardryx (policy), Vouchryx (delegation), Engram (memory), Idryx (access), Qryx (crypto), Verdryx (quality), Mockryx (pre-prod), scopyx (governed web egress), CostCrew (the bill), Trailryx (the record) and heraldyx (the mail out), on the shared Agent Passport + agent-event contract (agent-stack-go / agent-passport), configured via terraform-provider-taipan and driven from Genaryx, the console over all of it.

Run the whole open stack locally with one command via stack-up; the stack's home on the web is it-rat.com.

Live infrastructure validation

Before any public launch, Idryx was run against a real Postgres 16 backend and real agent-fleet event data: the integration tests passed (VALIDATION.md has the run), the delegation-chain backfill migration is correct and idempotent, and the full detector suite fires correctly off Postgres-backed state.

Idryx identity/access dashboard: agent fleet spend by non-human identity, 3 detectors fired live, CycloneDX Agent-BOM generated

Full write-up and all numbers: VALIDATION.md.

Running it on a Kubernetes cluster

The whole stack was deployed as a five-node k3s cluster on Hetzner, AWS and GCP between 25 and 27 July 2026 (six clusters, all destroyed afterwards). The manifests, the traps and the evidence are public in stack-k8s. Idryx is one of the few components that is an ordinary Deployment plus Service (port 8081), and it is also the reason the cluster needs shared storage at all. It builds its graph by READING the event log the other planes append to (--load tokenfuse:<file>, mounted read-only), so on one machine that coupling is invisible and on Kubernetes it becomes a ReadWriteMany claim. That claim turned out to be the single largest price difference between the clouds: a 5 GiB shared event log costs USD 1.80/month on EFS and USD 194.56/month on Filestore, which bills a whole TiB (both prices are from the July 2026 deployment and have not been re-checked since). It also runs a nightly identity-sweep CronJob (04:42).

To be clear about scope: those runs verified the deployment shape and the service coming up correctly on three clouds. They did not drive the detectors at scale: the graph was populated from a seeded fleet, not from a production estate. The detector evidence in this file is still the one to read.


What it does

  1. Ingest - read-only connectors to IdPs (Okta, Entra ID), clouds (AWS CloudTrail and IAM, GCP IAM, Azure), network egress, agent and MCP inventories, and the agent-event bus, normalized into one model.
  2. Graph - every identity type and its permissions, events, owners, and delegation chains in a single identity / access graph.
  3. Baseline + detection - per-identity normal behavior; deterministic detection of anomalies and excessive privilege (ITDR, NHI, least-privilege).
  4. Remediation - least-privilege recommendations and credential rotation (cloud secrets and agent tokens), delivered as PRs and alerts (SIEM / Slack / OTLP).

idryx is a complete MVP for detection and remediation and has passed a security self-review (see SECURITY.md). A first eBPF network-behavior layer has shipped since (the eBPF sensor, a kprobe on __sys_connect_file, and its unmanaged_egress detector). Beaconing shipped, and corroborating a claimed identity against the graph has begun: unrouted_egress reads the web-egress plane's own journal to decide whether a claim names an agent that plane governs, and judges its direct connections against that. JA3/JA4 and DNS-tunnel detection are decided against rather than pending, for the same reason: each needs to read what the application wrote into its socket, the ClientHello in one case and the DNS query name in the other, and this sensor promises never to read a payload. Blocking, apply-style enforcement is intentionally out of scope: idryx proposes, it never mutates.


One graph, every identity

idryx identity / access graph: node kinds (human, service_account, key, agent, mcp_server) and edge kinds (on_behalf_of delegation chain, owner, has_scope, calls_tool), plus the connectors that populate the graph

Every identity in the graph is one of five node kinds, defined in internal/model/identity.go:

Node kind Go type What it is
human IdentityHuman (the zero value) a person, the default identity type
service_account IdentityServiceAccount an IAM user/role, GCP service account, or Azure service principal
key IdentityKey an access key or credential
agent IdentityAgent an AI agent, carries OnBehalfOf and Runtime
mcp_server IdentityMCPServer an MCP server exposing tools, carries the Shadow flag

Identities are connected by a small set of real edges, resolved by internal/graph:

Edge kind Source field What it means
on_behalf_of Identity.OnBehalfOf (ordered, root-first) the dynamic delegation chain: who this identity is acting for right now
owner Identity.Owner the human or team accountable for rotating/revoking this identity
has_scope Identity.Permissions[] a tool/policy grant, each carrying Admin and Used flags
calls_tool agent Permissions[].Name matched against an mcp_server's Permissions[].Name the join agent_shadow_tool uses to trace a poisoned tool to the agent that can call it
Parent Identity.Parent the agent's static provisioning parent (agent-passport SPEC §4.2, an org-chart relationship), distinct from the dynamic on_behalf_of chain

idryx stitches humans, service accounts, keys, and AI agents into a single graph linked by ownership and on_behalf_of delegation. Resolving those edges is what lets idryx compute an identity's true blast radius: excessive_agency (OWASP LLM06) fires when an AI agent reaches admin-equivalent permissions through its delegation chain, agent -> sub-agent -> service account -> human. An agent's blast radius is the union of what every identity it can act as may do, and severity rises with delegation depth.

Both graph.WalkDelegationChain (cycle-safe) and graph.BlastRadius (the de-duplicated union of every permission reachable through the chain) are shared by excessive_agency, runaway_agent, and the dashboard's delegation view: one core, reused everywhere agent reach matters.

Agent identities and the Agent Passport

Agents and the humans in their delegation chain share one identifier scheme, agent:// and user:// URIs, across two complementary connectors that both speak the agent-passport spec:

  • Agent Passport documents (--passports <dir-or-glob>) - one small, static JSON file per agent: owner, runtime, parent, attestation.method (none / oidc / spiffe-svid / enclave-key / mtls-cert), and the LLM providers/models it declares using (models, SPEC §4.5, mapped to Identity.DeclaredModels). Capture-only metadata, layered onto whichever graph a --source, --load, or --db already built.
  • Agent-event bus behavioral events (--source tokenfuse|wardryx|mockryx|verdryx|scopyx / --load tokenfuse: / wardryx: / mockryx: / verdryx: / scopyx:<path|glob>): NDJSON taipanbox.dev/agent-event envelopes (the reader checks the required fields, not the schema string; the registry defines v0.1, v0.2, v0.3 and v1.0), the shared wire format every producer on the bus writes: TokenFuse (spend), Wardryx (policy), Mockryx (pre-prod rehearsal), Verdryx (quality/drift), scopyx (web egress: one event per fetch and one per refusal), and any future emitter. Agent/human identities come from agent_id/on_behalf_of; each event carries its producer's own source field verbatim, so a Wardryx policy event and a TokenFuse spend event on the same agent are never confused with one another. Known TokenFuse event types (budget_exhausted, sustained_loop, spend_spike, fanout_explosion, breaker_tripped, dlp_block, taint_block, mcp_drift) and scopyx's two (web_fetch, web_blocked, which unrouted_egress reads) map to named constants; any other type, from any producer, is tolerated generically. Each ingest says on stderr which event types and which sources no detector reads: they are ingested as they are, and the line says so rather than calling them unknown. Every such stream is checked against the SPEC 6.5 prev_hash integrity chain on ingest, through agent-stack-go's own event.VerifyChain, and the verdict goes to stderr: chain intact, chain broken at these lines, or no chain present at all. The three are stated separately because "the log verified" and "nobody looked" are different facts. A break is reported and never fatal: the events are still ingested, since a tool that discards a log because the log shows evidence of tampering can be silenced by tampering with it.

Three detectors read this agent-governance state directly: attestation_missing (fires on standing privilege alone, a privileged/admin agent with no attestation on record), runaway_agent (correlates a TokenFuse spend/runaway incident with everything else idryx knows about that agent: privilege, delegation depth, attestation, and blast radius), and undeclared_llm (correlates a Passport's declared models against observed LLM egress, flagging an agent that reaches a provider it never declared).


Detectors

idryx's 27 deterministic detectors grouped into six colored risk cards: ITDR, stale and orphaned NHI, over-privileged and escalation, excessive agency and shadow AI/MCP, agent governance posture, and least-privilege

Detection is deterministic (statistics + rules over the graph); the LLM is used only as an interface (natural-language queries, explanations) and is never in the detection path. --privileged raises severity for sensitive accounts. The baseline engine learns what is normal per identity and suppresses scoring during a learning period to avoid false positives.

Detector Family Severity What it flags
impossible_travel ITDR high, critical if privileged two successful logins too far apart to be feasible
mfa_fatigue ITDR high, critical if privileged a burst of MFA challenges in a short window (push-bombing)
new_device ITDR high a privileged identity logging in from an unseen device
behavior_anomaly ITDR medium, high if privileged login deviating from the identity's learned baseline (new country / device / active-hour)
stale_nhi NHI medium, high if admin a service account unused past a 90-day window (or never used)
over_privileged_nhi NHI high an NHI holding admin-equivalent permissions
orphaned_nhi NHI low an NHI with no mapped owner (nobody to rotate or revoke it)
privilege_escalation NHI high an NHI holding a stealthy escalation permission (AWS iam:PassRole/PutRolePolicy, GCP actAs/getAccessToken, Azure roleAssignments/write) that grants a path to admin without holding admin, matched against what the grant allows (an AWS policy document, a GCP predefined role, an Azure built-in role) rather than what it is called
shared_credential NHI high an NHI whose credential is used across many distinct IPs, countries, or devices, the signature of a leaked or shared key
excessive_agency Agents / AI high, critical at deeper delegation an AI agent that reaches admin-equivalent permissions through its delegation chain (OWASP LLM06)
shadow_ai Agents / AI medium, high for NHIs/agents an identity whose egress reaches a known external LLM API (OpenAI, Anthropic, Gemini, Azure OpenAI, Azure AI Foundry and Vertex AI among them). The cloud model surfaces are providers in their own right rather than spellings of OpenAI and Gemini: the provider id names where the bytes go, and through Azure that is Microsoft, through Vertex it is Google Cloud. Azure resources and Vertex regions are matched in the forms a deployment actually uses, <resource>.openai.azure.com and <region>-aiplatform.googleapis.com
unmanaged_egress Agents / AI medium, high if the destination is a known LLM API a real outbound connection observed only via the eBPF sensor (idryx ebpf-capture), attributable to a process name and nothing else: no IAM, agent-event, or Passport record for it. A provider address wears its hostname only on port 443: resolvers connect() every candidate address while choosing a source address (Go on 53, musl on 65535), and a name lookup is not an API call (SECURITY.md)
beaconing Agents / AI medium, high if the destination is a known LLM API connections to one destination on a regular cadence, measured as the coefficient of variation of their intervals: the shape of a process checking in on a timer rather than doing work. Cron and metrics agents beacon too, so the finding names the cadence for an operator to match against their own schedules
claimed_agent_unknown Agents / AI high, critical if it also reached a known LLM API a process that declared itself an agent:// identity via AGENT_PASSPORT_ID (agent-passport SPEC 3.3) which no Passport, IAM record or agent-event in the graph names: an undeclared agent, or a wrong value. A self-declaration, never proof of identity
claimed_agent_drift Agents / AI high a process that declared itself an agent:// identity (SPEC 3.3) reached an LLM provider that agent's Passport does not declare. The finding keeps both readings open: the agent drifted from its own declaration, or something else is using its name
claimed_agent_unattested Agents / AI medium, high if the claimed agent is privileged or holds admin an agent whose Passport declares its identity is bound to a workload (SPEC §4.3) and whose only runtime carrier in the graph is a process that named itself. The binding is never verified and cannot be: idryx has no connector to an attestation plane and reading a handshake is a payload the sensor promises never to read. What it compares is two facts already in the graph, and it judges nothing unless some agent-event producer feeds that graph, so "nothing established this agent" cannot quietly mean "no bus file was loaded"
unrouted_egress Agents / AI medium, high if the plane had already refused that agent, info for a coverage gap an agent whose web egress is governed by an enforcement point (scopyx, from its own journal) which the eBPF sensor nevertheless saw opening its own connections to public addresses. A governed fetch is performed by the enforcement point's process, so those connections cannot have passed it. Private, loopback and carrier-NAT destinations are not judged (the plane's own address rules refuse those ranges) and model APIs are left to the shadow-AI detectors; both are counted in the finding rather than dropped
shadow_mcp Agents / AI high, critical if high-risk tools exposed an MCP server in use but absent from the sanctioned registry (OWASP MCP Top 10: Shadow MCP Servers)
agent_shadow_tool Agents / AI high, critical if the shared tool is high-risk an AI agent whose declared tools are exposed by a shadow MCP server, the path a poisoned tool takes to reach a model
runaway_agent Agents / AI medium base, high at 2 corroborating facts, critical at 3+ a TokenFuse spend/runaway incident correlated with the agent's privilege, delegation depth, attestation, and blast radius
attestation_missing Agents / AI high a privileged AI agent whose identity has no attestation on record (agent-passport SPEC §4.3)
bom_incomplete Agents / AI medium an agent missing the governance-critical fields an Agent-BOM needs: owner, runtime, or attestation
data_exfiltration Agents / AI high, critical at 2x threshold or if privileged/admin an AI agent accumulating DLP-blocked actions within a 24h window, a repeated attempt to move sensitive data out
tainted_agent Agents / AI high, critical on repeat or if privileged/admin an AI agent with a taint-tracked action blocked, a traced injection/exfiltration attempt stopped before it landed
mcp_drift Agents / AI high, critical on repeat or if privileged/admin an MCP server's config or exposed tooling changing under an agent, a supply-chain/config-integrity signal
undeclared_llm Agents / AI high, critical if privileged/admin an AI agent whose Passport declares which LLM providers/models it uses (agent-passport SPEC §4.5) but whose observed egress reaches a different, undeclared one, declared-vs-observed AI inventory drift
least_privilege Least-privilege medium, high for an unused admin grant granted permissions never exercised, with a revocation recommendation

agent_shadow_tool needs the agents and mcp sources stitched into one graph: idryx detect --load agents:agents.json --load mcp:mcp.json. least_privilege fires only for identities that have usage data, so sources without an observed-usage signal produce no false recommendations.

undeclared_llm is the declared-vs-observed leg of a three-source AI inventory idryx is building toward: what an agent's Passport declares it uses (--passports, models, SPEC §4.5), what network egress observes it actually reaching (an egress-shaped source, or ebpf-capture), and eventually what its own source code references (not built yet). It needs both a --passports directory/glob and an egress-bearing source in the same graph to have anything to compare; an agent with no declared models at all is out of scope for it by design (a fresh, un-declared agent's LLM egress is shadow_ai's finding, not inventory drift). This is an audit tool for an operator checking their own agents' actual AI usage against what those agents' own passports say, never an enforcement point: idryx only ever reports the discrepancy.


Architecture

One core (graph + baseline + detection), many connectors on the input. Each direction, ITDR, NHI, least-privilege, eBPF, agents, is a new connector of the same core, not a separate product. Data flows source -> graph -> detectors -> output:

cmd/idryx/main.go          CLI: detect | serve | load | bom | ai-inventory | remediate | ebpf-capture | version
internal/model               Identity, Event, Permission, Alert, Severity (shared types)
internal/ingest               source connectors -> []model.Event or []model.Identity
internal/ingest/tokenfuse       TokenFuse agent-event NDJSON (identities + events)
internal/ingest/passport        Agent Passport JSON documents (identity enrichment)
internal/ingest/stream          which source a stream file may carry: the source-allowlist table and the IDRYX_STREAMS parser
internal/ebpfcapture         Linux amd64/arm64 eBPF sensor: kprobe on __sys_connect_file -> egress-shaped
                              flows (root/CAP_BPF, see SECURITY.md); feeds internal/ingest's
                              egress connector via idryx ebpf-capture -out + --load egress:
internal/graph                Store (in-memory) + PgStore (Postgres); both satisfy graph.Reader
internal/baseline            per-identity behavioral baseline (Build / NewProfile+Observe / Score)
internal/detect               Detector interface
internal/detect/detectors      the concrete detectors
internal/bom                  Agent-BOM builder + CycloneDX-shaped rendering
internal/remediation          right-sizing + credential-rotation Terraform generation
internal/enforce              opens a GitHub PR from remediation output (git+gh; never applies)
internal/report               human + JSON alert rendering
internal/sink                 Slack + generic webhook + OTLP delivery
internal/server               read-only HTTP dashboard + JSON API

Design principles, held as hard rules:

  • Deterministic detection. Detectors are statistics + rules over the graph. The LLM is used only as an interface (natural-language queries, explanations); it is never in the detection path, which stays deterministic and auditable.
  • Read-only. idryx observes; it never mutates the IdP or cloud. remediate proposes Terraform and can open a PR; it never applies.
  • One graph.Reader. Detectors depend on the interface, never the concrete *graph.Store, so the same detectors run unchanged against the Postgres backend.

Stack

One language and four direct dependencies. make build produces a single binary that carries the dashboard, the detectors and the eBPF program inside it.

  • Core, ingest, baseline, detection, remediation: Go 1.27. Detection is Go rather than a separate analytics runtime on purpose: it is statistics and rules over the graph, it has to stay deterministic, and somebody eventually has to defend a finding by reading the code that made it.
  • eBPF sensor: one C program (internal/ebpfcapture/bpf/connect.c) on a kprobe on __sys_connect_file, compiled with bpf2go and embedded as an object in the binary, driven from Go through cilium/ebpf. Linux only, on amd64 and arm64, and the only non-Go source in the repository.
  • Graph: in-memory by default, Postgres (pgx/v5, embedded schema.sql, additive IF NOT EXISTS migrations) as the alternative. Both satisfy the same graph.Reader, so detectors run unchanged against either. Delegation chains are walked in Go (graph.WalkDelegationChain, cycle-safe), not in SQL.
  • API and dashboard: Go net/http, a JSON API and one self-contained html/template page. No frontend build step, no CDN, no framework.
  • Dependencies: cilium/ebpf, pgx/v5, golang.org/x/sys, and agent-stack-go for the shared wire types. That is the entire list of direct dependencies.
  • License: Apache-2.0, fully open source. There are no paid Idryx tiers: like the rest of the open TAIPANBOX stack, everything in this repo is free to self-host.

Install

Prebuilt binaries (Linux, macOS, Windows) are published on the Releases page for every v* tag, with a SHA256SUMS file for verification:

The asset names carry no version, so releases/latest/download/<name> is a permanent address for the current build. You never look up a version number, and a link to one of these does not rot.

P=$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
U=https://github.com/TAIPANBOX/idryx/releases/latest/download

curl -fsSLO $U/idryx_$P.tar.gz
curl -fsSLO $U/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missing

tar -xzf idryx_$P.tar.gz
./idryx_$P/idryx version

The version is still there, in the binary rather than in the filename: idryx version prints the tag it was built from. That is the harder of the two places to fake, since anything between us and you can rename a file.

Or build from source (Go 1.27+):

make build   # -> ./bin/idryx

The two paths give the same bytes, and you can check that

Downloading the binary and building it yourself are not a choice between trust and effort. They produce an identical file, so you can take the fast path and still have somebody verify it afterwards.

# ours: the latest release, at an address that never changes
mkdir -p /tmp/verify && cd /tmp/verify
curl -fsSLO https://github.com/TAIPANBOX/idryx/releases/latest/download/idryx_darwin_arm64.tar.gz
tar -xzf idryx_darwin_arm64.tar.gz
TAG=$(./idryx_darwin_arm64/idryx version | awk '{print $2}')     # what those bytes claim to be

# yours, from a clean tree at exactly that tag
cd /path/to/your/idryx
git checkout "$TAG"
git status --porcelain      # must print nothing at all
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath \
  -ldflags "-s -w -X main.version=$TAG" -o /tmp/verify/mine ./cmd/idryx

sha256sum /tmp/verify/mine /tmp/verify/idryx_darwin_arm64/idryx
# macOS ships shasum -a 256 rather than sha256sum, and this example builds for
# darwin, so that is probably the one you want

Two identical digests, and note what the recipe does NOT contain: a version number. It reads the tag out of the binary it is checking, so the instruction cannot go stale and the check is stronger for it, since a build that lies about its own version now fails the comparison it invited.

Build from a clean tree, and do not unpack our archive inside it. Go stamps vcs.modified into the binary, and one untracked file anywhere in the checkout flips it to true, which changes the bytes. The release is built from a clean CI checkout, so it carries vcs.modified=false. Unpacking the download into the repository before building is enough to break the comparison on its own. git status --porcelain is in the recipe for that reason and is not decoration.

The same trap has a second door. Building from a git archive extraction or a detached git worktree leaves Go unable to read the VCS at all, so it records the module as (devel) rather than the tag. Both doors lead to a binary that legitimately differs from the release, and the difference looks enormous because a version string one byte shorter shifts everything after it. It is one field, not a different program.

Compare the binaries, not the archives. SHA256SUMS on the release page lists the .tar.gz and .zip files, and it answers a different question: did your download arrive intact. It cannot answer this one, because tar and gzip stamp times into the archive, so the archive is not reproducible even when every byte of the binary inside it is.

Measured on 5 August 2026 against the real published artifact: idryx_v0.3.0_darwin_arm64 from the Releases page (v0.3.0 was the last release whose asset names carried a version), built on a Linux runner cross-compiling to darwin/arm64, and a local build of that tag on macOS are both

8c968574341f48775e898770e98cb586b620101668b86edec24428612e979a80

Three flags make that work, CGO_ENABLED=0, -trimpath and -s -w, and losing any one would break it silently: the build would still succeed and only somebody trying to verify us would find out. CI therefore builds the same source in two directories of different lengths on every push and refuses if a byte differs (scripts/reproducible-build.sh).

Check it out, do not export it. Building from a git archive extraction or a detached git worktree leaves Go unable to read the VCS, so it stamps no revision and records the module as (devel) rather than v0.3.0. That binary genuinely differs from the release, and the difference looks enormous because a version string one byte shorter shifts everything after it. It is one field, not a different program.

A different Go version will not reproduce these bytes either. go.mod pins the toolchain, and a digest is only meaningful beside the compiler that made it.

Maintainers: a release is cut automatically by CI on git tag vX.Y.Z && git push --tags.

Verify a download

Every release is signed keyless with Sigstore and carries a build-provenance attestation and an SBOM. With cosign and gh installed:

tag=<tag>
cosign verify-blob --bundle SHA256SUMS.sigstore.json \
  --certificate-identity "https://github.com/TAIPANBOX/idryx/.github/workflows/release.yml@refs/tags/${tag}" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com SHA256SUMS
sha256sum -c SHA256SUMS
gh attestation verify <one downloaded archive> -R TAIPANBOX/idryx

The image (where one is published):

cosign verify ghcr.io/taipanbox/idryx:<tag> \
  --certificate-identity-regexp '^https://github.com/TAIPANBOX/idryx/\.github/workflows/release\.yml@refs/tags/v' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/taipanbox/idryx:<tag> -R TAIPANBOX/idryx

Releases before the tag that first carries these have none of this; the tags say so.

Quick start

make build

# detect: run detectors, print or deliver alerts
./bin/idryx detect <log.json>                       # human-readable report
./bin/idryx detect --format json <log.json>         # JSON alerts
./bin/idryx detect --source aws_iam <log.json>      # okta|entra|cloudtrail|egress|aws_iam|gcp_iam|azure|agents|mcp|tokenfuse|wardryx|mockryx|verdryx|scopyx
./bin/idryx detect --privileged alice@x.com ...     # mark privileged accounts (also applies with --db)
./bin/idryx detect --slack <url> <log.json>         # deliver alerts to Slack
./bin/idryx detect --webhook <url> <log.json>       # deliver alerts to a SIEM/SOAR
IDRYX_OTLP_ENDPOINT=<url> ./bin/idryx detect ...    # deliver alerts as OTLP/HTTP trace spans
./bin/idryx detect --min-severity critical ...      # delivery threshold (default high)

# least-privilege: enrich inventory with observed usage to flag unused grants
./bin/idryx detect --source aws_iam --cloudtrail ct.json iam.json    # mark used AWS permissions
./bin/idryx detect --source gcp_iam --gcp-audit  audit.json iam.json # mark used GCP roles
./bin/idryx detect --load aws_iam:iam.json --cloudtrail ct.json      # same enrichment in --load mode
# --cloudtrail/--gcp-audit with no aws_iam/gcp_iam source in the run (including
# --db, where enrichment happens at load time) is an error, not a silent no-op.

# agent identities: TokenFuse events + Passport enrichment (owner/runtime/parent/attestation)
./bin/idryx detect --source tokenfuse --passports ./passports tokenfuse.ndjson
./bin/idryx detect --load tokenfuse:tokenfuse.ndjson --passports "passports/*.json"

# whole agent-event bus: stitch every producer into one graph
./bin/idryx detect --load tokenfuse:tokenfuse.ndjson --load wardryx:wardryx.ndjson \
  --load mockryx:mockryx.ndjson --load verdryx:verdryx.ndjson --load scopyx:scopyx.ndjson

# a bus file is read as the source it claims only if its name says it may carry
# that source (see "What a bus file may carry" below)

# unrouted_egress: the web-egress plane's journal beside what the sensor saw
./bin/idryx detect --load scopyx:scopyx.ndjson --load egress:captured.json

# bom: Agent-BOM, a CycloneDX-shaped inventory of every agent identity
./bin/idryx bom <log.json>                          # JSON (CycloneDX-shaped), the default
./bin/idryx bom --format human <log.json>           # human-readable

# where this estate uses AI models, and where the three sources disagree
./bin/idryx ai-inventory --load agents:a.json --load egress:e.json \
  --passports ./passports --code-scan qryx-ai-inventory.json
./bin/idryx ai-inventory --format json ...           # for another program

./bin/idryx remediate --source aws_iam iam.json     # right-size + rotate stale credentials
./bin/idryx remediate --source agents agents.json   # right-size tools + rotate agent tokens
./bin/idryx remediate --source aws_iam --out ./tf iam.json  # write .tf artifacts + manifest.json (read-only)
./bin/idryx remediate --save-db "$DSN" iam.json     # persist recommendations into Postgres
./bin/idryx remediate --open-pr --repo ../iac iam.json  # open a GitHub PR with the .tf (git+gh; never applies)

# serve: read-only web dashboard + JSON API
./bin/idryx serve <log.json>                        # dashboard on 127.0.0.1:8080 (loopback only by default)
./bin/idryx serve --addr :9000 <log.json>           # wider bind, explicit opt-in (warns loudly: see SECURITY.md)

# load: persist a log into a Postgres graph, then read from it
./bin/idryx load --db "$DSN" <log.json>             # ingest into Postgres
./bin/idryx detect --db "$DSN"                      # detect from the DB
./bin/idryx serve  --db "$DSN"                      # dashboard from the DB

# ebpf-capture: Linux 5.7 or later, root (or CAP_BPF+CAP_PERFMON), see SECURITY.md
sudo ./bin/idryx ebpf-capture -duration 30s -out captured.json  # live-capture outbound connections
./bin/idryx detect --load egress:captured.json                  # same pipeline every other source uses

Run against the bundled fixtures:

make detect    # ITDR detectors over the event fixtures
make nhi       # NHI + agent + shadow-ai detectors over the inventory fixtures
make remediate # least-privilege + credential-rotation snippets over the inventory fixtures
make serve     # then open http://localhost:8080

What a bus file may carry

The agent-event bus is one directory with one NDJSON file per writer, and every plane can append to every file, so the source inside a line is not proof of who wrote it. idryx ingests an event as the source it claims only when the file it was read from may carry that source. By convention tokenfuse.ndjson carries tokenfuse, wardryx.ndjson carries wardryx, and so on for every source the agent-passport registry lists; tokenfuse-cloud.ndjson and tokenfuse-mcp.ndjson (the control plane's and the MCP broker's own files) carry tokenfuse, and so does tokenfuse-openai.ndjson (a second gateway on the OpenAI wire, which keeps its own file). demo.ndjson (what taipan demo writes, with lines from six planes) is not built in, because any co-tenant could create it: declare it with IDRYX_STREAMS=demo=tokenfuse|wardryx|engram|qryx|verdryx|mockryx when you load the demo. A line that claims anything else is not put in the graph: no identity, no event, and a stderr line says how many and which file claimed what. It never stops the load. A file whose name nothing declares is ingested when its lines claim the file's own name, and stderr names the stream.

If your files are laid out differently, say what each may carry:

IDRYX_STREAMS='events=tokenfuse|wardryx,mixed=engram|qryx' idryx detect --load tokenfuse:events.ndjson

A declaration adds to what a file may carry and never removes from it. A malformed one refuses the load and names the variable.

What works today

A CLI that ingests an identity log or inventory, normalizes it into an identity graph, builds per-identity behavioral baselines, resolves delegation chains, and runs deterministic detectors.

Source connectors

Connector Kind What it reads
okta events Okta System Log
entra events Microsoft Entra ID sign-in log
cloudtrail events AWS CloudTrail (ConsoleLogin + API activity)
egress events generic network-egress (identity -> destination host; VPC flow / proxy / CASB, or idryx's own ebpf-capture sensor, see below)
aws_iam NHI inventory IAM users/roles as service accounts, with permissions, owner tags, last-used, and the actions each grant actually allows, read out of inline policy documents and the default version of each customer-managed policy (optional CloudTrail usage enrichment via --cloudtrail)
gcp_iam NHI inventory GCP service accounts + project IAM policy, with roles, owner hints, and the escalation permissions each predefined role is known to contain (optional Cloud Audit Log usage enrichment via --gcp-audit)
azure NHI inventory Azure AD service principals + role assignments, with owners, credential expiry, and the escalation actions each built-in role definition contains
agents agent inventory AI agents with runtime, tools/scopes, used tools, and the identity each acts on_behalf_of
mcp MCP inventory MCP servers and their exposed tools, checked against the sanctioned registry to surface shadow servers
tokenfuse / wardryx / mockryx / verdryx / scopyx agent identities + behavioral events NDJSON agent-passport taipanbox.dev/agent-event envelopes (the reader checks the required fields, not the schema string; one file or a glob via --load tokenfuse:/wardryx:/mockryx:/verdryx:/scopyx:path/*.ndjson), one connector shared by every bus producer
--passports <dir-or-glob> agent identity enrichment static agent-passport taipanbox.dev/agent-passport/v0.1 or v1.0 JSON documents, one per agent, layered onto whichever source/--load/--db built the graph

Detectors - see the Detectors table above: 27 detectors across ITDR, NHI, agents/AI, and least-privilege.

Baseline engine (internal/baseline) - learns what is normal per identity and suppresses scoring during a learning period; the same engine extends to service accounts and AI agents. Detection is deterministic; LLMs are never in the path.

Delegation graph (internal/graph) - resolves on_behalf_of edges (agent -> sub-agent -> service account -> human) with cycle protection, computing each identity's effective permissions and blast radius.

AI inventory across three sources (internal/aiusage, idryx ai-inventory) answers one question about an operator's own estate: where does it use AI models, and do the three places that know disagree?

  • Declared: what an agent's Passport says it is meant to use, agent-passport SPEC 4.5's models.
  • Observed: what an identity was seen reaching, from the same egress the shadow_ai and undeclared_llm detectors read.
  • In code: what a source scan found the code can reach, read from a qryx document (qryx scan --format ai-inventory <path>) passed with --code-scan.

The three are compared on the provider id SPEC 4.7 registers, lowercased on both sides and not reshaped further, which is what 4.7 obliges a consumer to do and the most one may do. Before that registry existed the same provider was google in one source and Google Gemini in another, and set arithmetic over the three reported drift that was spelling.

It reports and does not rank. A provider found only in code is often correct, a provider reached and declared by nobody is often not, and which is which is a judgement about an estate this tool does not have; the per-identity judgement belongs to undeclared_llm, which can make it because both its sides carry the identity. The coded column has no agent at all: a source scan finds a file and a line, and nothing binds a repository to an identity, so its counts are estate-wide and it can never produce a finding about one agent.

Every report carries what none of the three can see, including the scan's own limits, and says plainly when no scan was supplied, because an empty third column otherwise reads as an estate whose code reaches no model.

Agent-BOM (internal/bom, idryx bom) - a defensive governance inventory of an operator's own AI agent identities: owner, runtime, attestation, tools/permissions, delegation chain, and blast radius, rendered as a CycloneDX-shaped document (internal/bom/cyclonedx.go). bom_incomplete is its companion detector, flagging agents the BOM cannot yet fully prove.

Alert delivery (internal/sink) - alerts at or above --min-severity are pushed to a Slack incoming webhook (--slack), a generic JSON webhook for SIEM/SOAR (--webhook), and/or an OTLP collector (IDRYX_OTLP_ENDPOINT). Fully-filtered batches make no network call to any of the three. --webhook-header "Name: Value" (repeatable) sets outbound headers, which is how the destination authenticates the sender.

A sink that fails to deliver (a dead webhook, a Slack URL returning 401 after a rotation) is never silent: idryx detect prints one stderr line per failing sink and exits 3, distinct from exit 1 (a setup/input error) and exit 0 (a clean run delivered in full). The other configured sinks still receive the alerts; only the exit code marks that one of them did not, which is what a cron or CI invocation checking $? actually needs.

idryx detect --load tokenfuse:tokenfuse.ndjson \
  --webhook 'https://cloud.example/v1/findings?source=idryx' \
  --webhook-header "Authorization: Bearer $OPS_KEY"

The webhook sends the findings as plain JSON to whatever URL you give it, so a TokenFuse Cloud /v1/findings endpoint receives an ordinary webhook body. How that endpoint presents the findings is the service's own behavior, which this repository does not test. Headers are outbound only: nothing about what idryx reads, or how it detects, changes.

Setting IDRYX_OTLP_ENDPOINT turns on a hand-rolled OTLP/HTTP JSON exporter (no OpenTelemetry SDK dependency, same approach as Wardryx's own exporter): one trace span per alert, POSTed to <endpoint>/v1/traces. The resource carries service.name=idryx; each span is named after the detector and carries idryx.detector, idryx.severity, idryx.identity, and idryx.summary attributes, so the same alert content shows up whichever sink delivered it. Unset (the default), the sink is never constructed and idryx's behavior is unchanged:

IDRYX_OTLP_ENDPOINT=http://localhost:4318 idryx detect --load tokenfuse:tokenfuse.ndjson

Web dashboard (internal/server, idryx serve) - a read-only HTTP server with a self-contained HTML dashboard and a JSON API (/api/alerts, /api/identities, /api/remediations, /healthz). Read-only by design: idryx observes, it never mutates the IdP. The served graph is live: a long-lived idryx serve re-reads its source instead of answering from a snapshot frozen at boot, so a growing event file surfaces new alerts without a restart (found and fixed on live infrastructure, where the pre-fix behavior reported "0 alerts" while the file already held detections).

Postgres graph (internal/graph, pgx) - idryx load --db <dsn> persists events into Postgres; detect / serve --db read a snapshot back. The snapshot implements the same graph.Reader the in-memory store does, so detectors run unchanged. The schema (internal/graph/schema.sql) additionally carries a producer-assigned events.severity column (model.Event.Severity, used by tokenfuse), the Passport-derived identities.parent/identities.attestation columns, the MCP identities.shadow flag, an ordered declared_models join table for the Passport's declared LLM providers (SPEC §4.5), an ordered permission_actions join table for the cloud actions each grant allows, and an ordered on_behalf_of join table for full delegation chains (agent-passport SPEC §5), all applied as additive IF NOT EXISTS migrations, so an existing database upgrades in place. "Detectors run unchanged over either backend" is only true while every field the detectors read survives the round trip, so two tests hold that with no database at all: one fails when a column is written and never read back (or read and never written, or named in SQL and never declared), the other when a field is added to model.Identity, model.Permission or model.DeclaredModel without a decision about where Postgres keeps it. Integration tests live behind the integration build tag and run in CI against a Postgres service (make test-integration with DATABASE_URL).

eBPF network sensor (internal/ebpfcapture, idryx ebpf-capture) - Linux amd64 or arm64 only (5.7 or later), root (or CAP_BPF+CAP_PERFMON) sensor attached as a kprobe on __sys_connect_file via cilium/ebpf, capturing real outbound connections (pid, process name, destination) with no dependency on IAM, agent-event, or Passport data. Writes an egress-shaped log consumed by the same --load egress: pipeline every other source uses. Known LLM API destinations on port 443 are resolved to their hostname so the existing shadow_ai detector reasons over eBPF-captured traffic for free; its own unmanaged_egress detector flags any identity this sensor is the only evidence for, and unrouted_egress asks the next question of a governed one: whether it opened its own connections to the public internet while an enforcement point was supposed to be doing that for it. See SECURITY.md for what it does and deliberately does not do.


Status

Phases 0-3 plus the eBPF network-behavior layer shipped, and corroborating a claimed identity is closed rather than outstanding. Every comparison the graph supports now exists: models both ways (undeclared_llm, claimed_agent_drift), governance (unrouted_egress), and attestation in its one graph-visible form (claimed_agent_unattested).

The other two fields of a Passport are decided AGAINST rather than pending, and saying which is the point. Owner has no observed counterpart at all: no event, packet or syscall carries ownership, so the only comparison available is one declaration against another, and even that is merged away before detection. Parent has one, and the spec allows it to differ: SPEC 4.2 is an org chart and SPEC 5 is a per-request chain, so their inequality is not evidence and a detector on it would cry wolf by construction.

None of this makes a claim attested, which stays permanently out of scope at this layer (SPEC 3.3). JA3/JA4 and DNS-tunnel detection are likewise decided against, both because they would need to read a payload, see idryx-plan.md's Phase 4:

  • Phase 0 - ITDR core, in-memory graph, CLI, CI
  • Phase 1 - baseline engine, Entra/CloudTrail connectors, Slack/SIEM delivery, web dashboard, Postgres graph
  • Phase 2 - NHI (AWS/GCP/Azure), agents + delegation graph, shadow AI/MCP, least-privilege
  • Phase 3 - remediation: right-sizing + rotation Terraform, PR enforcement (read-only)
  • 27 deterministic detectors across ITDR, NHI, agents/AI, and least-privilege
  • Agent-BOM (CycloneDX-shaped) via idryx bom, with its bom_incomplete companion detector
  • Security self-review passed (see SECURITY.md)
  • eBPF network-behavior layer (descoped first version): Linux sensor on a kprobe on __sys_connect_file (idryx ebpf-capture), its unmanaged_egress detector, live-validated against real traffic on a disposable VM (see SECURITY.md); the original larger spec's beaconing shipped 2026-08-09; JA3/JA4 and DNS-tunneling are decided against, and identity correlation is partly built: a claim is read and marked as one, and unrouted_egress checks it against an enforcement point's own journal
  • Post-campaign hardening from live infrastructure (2026-07-21): idryx serve keeps the served graph live against a growing source instead of freezing it at boot, and the generic webhook sink can carry credentials (--webhook-header, repeatable) so alerts reach a SIEM ingest endpoint or an admin-gated /v1/findings
  • OTLP alert delivery (internal/sink/otlp.go, IDRYX_OTLP_ENDPOINT): a hand-rolled OTLP/HTTP JSON exporter, one trace span per alert, no OpenTelemetry SDK dependency, so the alert destinations promised at the top of this README (SIEM / Slack / OTLP) are now all real

See idryx-plan.md for the full design and roadmap.

Security

idryx is a security product, so its own trust boundaries are documented. See SECURITY.md for the threat model, the read-only / deterministic design invariants, and how to report a vulnerability privately.

License

Apache-2.0.

About

Identity Security Graph — unify humans, service accounts, keys and AI agents in one graph; detect excessive privilege and anomalous behavior (Apache-2.0)

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages