Read-only connectors stitch humans, service accounts, keys, and AI agents into one identity / access graph, then flag excessive privilege and anomalous behavior.
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.
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.
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
- 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.
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.
Full write-up and all numbers: VALIDATION.md.
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.
- 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.
- Graph - every identity type and its permissions, events, owners, and delegation chains in a single identity / access graph.
- Baseline + detection - per-identity normal behavior; deterministic detection of anomalies and excessive privilege (ITDR, NHI, least-privilege).
- 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.
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.
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 toIdentity.DeclaredModels). Capture-only metadata, layered onto whichever graph a--source,--load, or--dbalready built. - Agent-event bus behavioral events (
--source tokenfuse|wardryx|mockryx|verdryx|scopyx/--load tokenfuse:/wardryx:/mockryx:/verdryx:/scopyx:<path|glob>): NDJSONtaipanbox.dev/agent-eventenvelopes (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 fromagent_id/on_behalf_of; each event carries its producer's ownsourcefield 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, whichunrouted_egressreads) 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.5prev_hashintegrity chain on ingest, throughagent-stack-go's ownevent.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).
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.
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.
remediateproposes 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.
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 withbpf2goand embedded as an object in the binary, driven from Go throughcilium/ebpf. Linux only, on amd64 and arm64, and the only non-Go source in the repository. - Graph: in-memory by default, Postgres (
pgx/v5, embeddedschema.sql, additiveIF NOT EXISTSmigrations) as the alternative. Both satisfy the samegraph.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-containedhtml/templatepage. No frontend build step, no CDN, no framework. - Dependencies:
cilium/ebpf,pgx/v5,golang.org/x/sys, andagent-stack-gofor 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.
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 versionThe 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/idryxDownloading 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 wantTwo 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.
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/idryxThe 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/idryxReleases before the tag that first carries these have none of this; the tags say so.
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 usesRun 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:8080The 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.ndjsonA declaration adds to what a file may carry and never removes from it. A malformed one refuses the load and names the variable.
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_aiandundeclared_llmdetectors 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.ndjsonWeb 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.
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 itsbom_incompletecompanion 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), itsunmanaged_egressdetector, live-validated against real traffic on a disposable VM (seeSECURITY.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, andunrouted_egresschecks it against an enforcement point's own journal - Post-campaign hardening from live infrastructure (2026-07-21):
idryx servekeeps 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.
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.


