Skip to content

Security: Bridgeflow-Group/bf-agent-viewer

Security

docs/security.md

Security & Architecture

How BF Agent Viewer is built, and — just as important — what it does and doesn't do yet. This is a design document, not an audit; see status.md for what's actually built right now.

Why this matters now

MCP, the primary instrumentation path this platform relies on (see how-it-works.md), has a real, current vulnerability landscape: 14 publicly assigned CVEs in 2026 plus 30+ further RCE issues traced to the same root cause in the official SDKs, roughly 7,000 MCP servers confirmed publicly reachable via active scanning, and -- across a sample of 2,614 real implementations -- 82% vulnerable to path traversal, 67% using code-injection-prone APIs, and 34% susceptible to command injection. None of that is a reason to avoid MCP; it's the reason this platform treats the gateway itself as security-sensitive infrastructure rather than a thin pass-through, and why the principles below aren't boilerplate.

Principles

  • The platform itself is treated as security-sensitive infrastructure, not a passive logging shelf.
  • No third-party standing secrets are stored in plaintext.
  • Ingestion authentication (how the platform authenticates incoming events) is kept separate from agent identity (who the event is actually about) — conflating the two is how shared-credential messes happen.
  • Least privilege applies to the platform's own database and integrations, the same standard it asks agents to meet.

What v0.1.0 actually does

v0.1.0 is visibility first. It registers agents, captures their activity, and tracks who owns them. It does not evaluate individual actions against policy, though it does enforce granted_scope and per-agent rate limits on calls that present a credential, and offers credential revocation (see the known limitation under Kill switch, below) — see positioning.md for why that sequencing is deliberate, not a missing feature.

Identity. Every agent gets a first-class identity record, not just an API-key label. When one agent delegates to another (an orchestrator spawning a sub-agent, for example), the sub-agent gets its own identity, linked to its parent, and its permissions are always a subset of its parent's — authority only ever narrows moving down a delegation chain, never widens. Every event traces back through that chain to an accountable human owner.

Credentials. Agents entering the system explicitly (via API, CLI, or CI/CD) get platform-issued credentials from day one, scoped to an explicit allowlist of write actions rather than a blanket grant. Agents discovered passively (seen in traffic before anyone registered them) show up as visible-but-unowned until claimed — claiming means assigning an owner and rotating the agent onto platform-issued credentials, not just naming it. A credential can optionally be issued short-lived (register --ttl-seconds/claim, F-016) and extended with credential renew before it lapses; see Kill switch and live credential invalidation, below, for what happens once one expires or is revoked.

Kill switch and live credential invalidation (F-009/F-016, built). bf-agent-viewer credential revoke immediately marks a credential unusable, regardless of any TTL it was issued with — the actual "stop this agent now" control this platform provides. A gateway process doesn't need restarting to pick this up: it reloads its in-memory credential list from the database on a short, configurable interval (--credential-refresh-seconds, default 30s) rather than only at startup, checked lazily on the next request rather than via a background task, so there's no new concurrency to reason about beyond what the request path already handles. The bound is real but not instant — a call landing in the gap between revoking and the next refresh can still succeed; closing that gap further is a tradeoff against checking the database on every single request, not a bug. Expiry (a --ttl-seconds credential simply lapsing, with no explicit revoke call) is picked up by the exact same mechanism. A credential that was revoked or expired but is still presented is rejected outright (a PermissionError, logged as tool.rejected_unrecognized_token) rather than falling through to the same treatment as a caller with no credential at all — that distinction matters because passive discovery grants no restriction whatsoever (granted_scope=None), so treating "presented but unrecognized" the same as "nothing presented" would have made revoking a credential leave an agent less restricted than before, the opposite of what a kill switch is for. This was found and fixed during T-022/T-024's own test-writing, not just reasoned about in design. See bf_agent_viewer/identity/registry.py (issue_token/renew_token/revoke_token) and bf_agent_viewer/gateway/middleware.py (_maybe_refresh_token_registry, on_call_tool).

Known limitation: omitting the token (and --require-token). By default (v0.1.0's visibility-first posture) a call that presents no token at all is not rejected — it is passively discovered and let through unscoped. That means the kill switch and granted_scope limits only bind an agent that keeps sending its token: a revoked or scoped agent that simply stops sending X-BF-Agent-Token regains unrestricted access to the backend (pinned by tests/test_require_token.py). Likewise, if the gateway cannot read request headers for an HTTP request, the call is treated as tokenless. Start the gateway with --require-token (BF_REQUIRE_TOKEN=1) wherever revocation or scope limits must hold against a non-cooperating agent: a tokenless call is then logged (tool.rejected_no_token, still passively discovered so it stays visible) and rejected, and the OTel route returns 401 before creating any agent row. The gateway prints a startup warning while the flag is off. Making it the default is a posture change that has not been made yet. Issuing, renewing and revoking a credential are each written to the tamper-evident event log (credential.issued, credential.renewed, credential.revoked), in the same transaction as the change itself, so a kill action leaves an audit entry. The entry records a short non-secret fingerprint of the token (never the token), its expiry, and the acting human when one is supplied (actor_human_id; the CLI does not yet pass one, so CLI-initiated entries carry no actor). Because the CLI is a separate process from a running gateway, every event writer chains from the database's current tip inside a write lock (log_event(..., prev_hash=None)) rather than from an in-memory copy, which would otherwise fork the chain.

Known limitation: bearer tokens are stored in plaintext. A gateway bearer token is stored as-is as credentials.id, so anyone who can read the database can read every active token. Hashing them at rest (as console session tokens already are) is planned but not built.

Tamper-evident event records (F-014, built). Each event's hash is chained to the previous event's hash, so altering or deleting a past event breaks the chain from that point forward — detectable by re-verifying it. That in-database chain alone has a real limitation, though: it, and every row it covers, live in the one SQLite file a database-level compromise already controls, so a sufficiently capable attacker with write access to that file could in principle regenerate a whole fake-but-internally-consistent history from scratch — one that would pass an in-database chain check cleanly, since the forger controls both the data and the verifier. External chain-tip anchor (F-014/T-040, built). bf-agent-viewer checkpoint write closes that gap: a periodic signed checkpoint recording the chain's current tip, appended to a second local file entirely separate from the database, signed with an Ed25519 keypair generated once at install time and never stored in — or touched by — the database itself. Reproducing a valid checkpoint for a forged history would require that private key, not just write access to the SQLite file. Checkpoints are themselves chained to each other (independent of the event chain), and where the OS supports it (chattr +a on Linux) the checkpoint file is marked append-only after every write. bf-agent-viewer checkpoint verify cross-checks the latest signed checkpoint's chain tip against the live database, which is what actually detects a regenerated-from-scratch fake history — not just a single altered row. Deliberately not bulletproof against a root-level attacker who can also read the signing key or strip the append-only flag (chattr -a needs the same privilege); real protection against that stronger threat model means backing the checkpoint file, and ideally the signing key, up off-box, which this platform documents as an operational recommendation (see CLI.md) rather than something it can enforce itself. Not run automatically — like retention pruning below, it's meant to be scheduled (cron, or a container's own scheduler). Deliberate deletion — retention pruning (F-019, built; see below) — is the one case where old rows leaving the table is expected, not tampering: each prune run records the content_hash of the last row it removes to a retention_checkpoints table before deleting anything, and chain verification resumes from that recorded checkpoint instead of assuming an unpruned history, so a compliant retention window and tamper-evidence aren't in tension with each other. What the hash covers. Events written by this version carry event_version = v2, whose hash covers every stored column except the database-assigned created_at — including result, tool_id, metadata (the tool arguments), actor_human_id, organization_id and source, so editing any of them breaks the chain. Earlier events (v1) hashed only six fields (id, occurred_at, agent_id, event_type, action, resource_id); they still verify, but on those rows the other columns are not tamper-evident. An unrecognised event_version fails verification. (retention_checkpoints is a different mechanism from the external checkpoint file above — it lives inside the same SQLite file and solves a different problem, letting the in-database chain verify correctly across pruned rows, not anchoring it against a database-level compromise.)

Deployment. Self-hosted, single-tenant per install — one organization per instance, not a shared multi-tenant service. Default storage is embedded SQLite/libSQL; no separate database server required to start.

Org/tenant isolation (F-015, built). "Single-tenant per install" is the deployment recommendation, not a claim that the schema is incapable of holding more than one organization — it explicitly is (organizations table, org create, and the console's own --org flag, which restricts a dashboard to one organization_id while leaving the rest of the database alone). A T-014 audit went through every code path that touches organization_id and checked whether it's an enforced filter or just a passed-through, unused parameter, and found four real gaps, now fixed: (1) the gateway's bearer-token authentication (load_token_registry) loaded every organization's active tokens into one process-wide dict with no organization filter — a token issued for one organization's agent would have authenticated successfully against a gateway process configured for a completely different organization, as long as both organizations' data lived in the same database file; this was the most severe finding, since it's the actual authentication boundary the gateway relies on for every request. (2) register_identity's parent-identity lookup for delegation didn't check that the parent identity's agent belonged to the same organization as the child being registered, so a sub-agent could in principle inherit a scope ceiling checked against another organization's identity. (3) Neither register_identity nor claim_discovered_agent verified that the human being assigned as an agent's owner actually belonged to that agent's organization, undermining the accountability chain the owner field exists for. (4) The console's get_agent, list_events, get_event, and search query functions had no organization awareness at all — only the dashboard's own agent listing respected --org, so a console instance deliberately restricted to one organization still exposed full cross-org agent detail, event detail, and search results to anyone who navigated directly to another organization's URL or used the search box. All four now filter by organization_id for real; see tests/test_org_isolation.py for end-to-end coverage, including a real running gateway process proving a cross-org token is rejected, not merely mismatched.

Configurable log retention (F-019, built). A retention window (default ~180 days / ~6 months) plus a bf-agent-viewer retention prune job that deletes event rows older than it — for matching an org's activity-log retention to whatever regulatory minimum applies to them (surfaced by REQ-002's EU AI Act Art. 19/26 reading), rather than the event store growing forever. Not run automatically; intended to be scheduled (cron, or a container's own scheduler) — see CLI.md. Only ever removes a contiguous prefix of the chain (the oldest rows), never an arbitrary filtered set, which is what makes the checkpoint mechanism above work.

Console authentication (F-040, built). Password + mandatory TOTP MFA -- there is no path that grants console access with a password alone, including on first login, where enrolling an authenticator app is forced before any session is issued. Passwords are hashed with hashlib.scrypt (stdlib, per-user random salt); TOTP uses pyotp, a proven library for RFC 6238, rather than hand-rolled one-time-password crypto. Sessions are opaque random tokens where only a SHA-256 hash is ever persisted server-side (unlike gateway bearer tokens, which are currently stored in plaintext — see the known limitation under Kill switch, above), fixed 12-hour expiry, httpOnly + SameSite=Strict cookies (the CSRF mitigation here -- no separate CSRF token, since SameSite=Strict already blocks the cross-site request that a token would guard against). Five wrong passwords in a row locks the account for 15 minutes. See status.md for build status and bf_agent_viewer/console/auth.py for the implementation. An optional lightweight reverse-proxy auth gate remains a supported pattern for anyone who wants SSO in front of the console instead.

Per-agent rate limiting. Each agent gets its own token-bucket allocation (steady rate plus burst) at the gateway. A single runaway or misbehaving agent can only exhaust its own allocation -- it can't degrade visibility for the rest of the fleet by flooding the shared event pipeline. Calls beyond an agent's allocation are rejected at the gateway, not silently queued, and a rejection itself is surfaced as an alert (F-036, built) -- not just logged as an event.

Alerting (F-036, built). A small, deliberately generic alert channel: every alert is persisted to an alerts table first, independent of whether delivery succeeds, then handed to whichever channel(s) are configured -- a JSON webhook, SMTP email, or both fanned out together (one failing doesn't stop the other from being tried). No channel configured is not the same as no alerting: an always-on logging fallback means a rate-limit rejection is still visible in the gateway's own log even on a bare install. The rate-limit rejection above is the first real consumer; the gateway-outage gap-marker alert (see Gateway resilience, below) is a second; a third, the passive-discovery-of-a-new-agent alert (F-048), is now also built -- it fires (agent_discovered, severity info) off the exact same first-sighting check that already logs the one-time agent.discovered event (see Credentials, above), on both the MCP gateway path and the OTel SDK ingestion path, so a newly-appeared, unclaimed agent surfaces immediately rather than waiting for someone to notice it on the dashboard. Configured via bf-agent-viewer gateway --alert-webhook/--alert-email-* -- see CLI.md.

Gateway resilience (F-042, built). Since v0.1.0 does zero enforcement, the gateway's only job is capturing events, not gating them -- so it fails open by default: if the gateway is down or unreachable, agent traffic passes through unlogged rather than being blocked. That gap is never silent: on every gateway startup, before anything else runs, the elapsed time since the last logged event is checked against a threshold (default 60s, configurable via --gap-threshold/BF_GAP_THRESHOLD_SECONDS). Past it, an explicit gateway.gap event -- start, end, and duration of the outage -- is written into the same tamper-evident chain as every other event (not a side table), and an alert fires through the mechanism above, with severity escalating from warning to critical past an hour of unrecorded traffic. What this doesn't solve: detection is startup-only -- there's no process running to notice an outage while it's happening, only to look back at the next startup and say "there's a gap here." A gateway that never restarts after going down never gets its gap marked; in practice a process supervisor (systemd, Docker's own restart policy, k8s) is what makes "goes down -> comes back up" actually true, and the gap check runs the moment it does. See bf_agent_viewer/gateway/resilience.py. One limitation this doesn't solve: tamper-evidence protects history that already happened, but can't stop a live, compromised gateway process from forging new, correctly-chained events going forward, since a compromised process has the same signing capability a legitimate one would. The only real mitigation is keeping the gateway's own attack surface small, single-purpose, and least-privilege -- named here rather than implied away.

OTel SDK ingestion endpoint (F-024, built). The fallback instrumentation path's server side: a plain HTTP route on the gateway process itself (POST /v1/otel/events, not a separate service to stand up and secure), authenticated with the same X-BF-Agent-Token bearer credential the MCP path already uses. A caller with no token at all gets the same passive-discovery treatment as an unrecognized MCP connection (see Credentials, above) -- visible, unowned, never trusted -- unless the gateway runs with --require-token, in which case it gets 401 and no agent row is created (see the known limitation under Kill switch, above). Without that flag an unauthenticated caller can create agent rows (and agent_discovered alerts) by varying gen_ai.agent.name, and there is no request-body size cap. A caller presenting a token that's revoked, expired, or otherwise doesn't resolve gets rejected outright (401), same as the MCP gateway path -- see Kill switch and live credential invalidation, above. A registered identity reporting a tool call outside its granted_scope is logged as what actually happened (not silently reclassified) and raises an alert through the F-036 mechanism above -- but, unlike the MCP gateway path, this can never actually deny the call: by the time an event reaches this endpoint, the real call already happened somewhere this platform was never in the request path for. This endpoint's own rate limiting (the same per-agent token-bucket mechanism, above) exists purely to protect this platform's own event pipeline from being flooded by a misbehaving reporter -- it is not, and does not claim to be, a control on the underlying tool calls being reported. See CLI.md for the client and how-it-works.md for how this fits alongside the MCP gateway path.

Sandboxed backend spawning. The gateway talks to each backend MCP server by spawning it as a child process — the pattern MCP's own official security guidance flags as a privilege-escalation path if the gateway's own auth is ever compromised (see standards.md). Spawned backends get an explicit environment-variable allowlist (not the gateway's full environment — credentials, cloud metadata tokens, etc. stay withheld by default) and POSIX resource limits (file descriptors, process count, core dumps), applied inside the child itself before it runs. Where a container runtime is available, the backend runs inside a locked-down container instead — no network access, a read-only filesystem, no Linux capabilities — closing the remaining filesystem/network gap rather than leaving it to rlimits alone. Without a container runtime, the gateway falls back to the rlimit-only protection and logs that it did, rather than silently running with less isolation than configured.

Verifying your build (F-039, built). Every tagged release (.github/workflows/release.yml) builds the Python package and both container images, then signs all of it keylessly via Sigstore/Cosign using that CI run's own GitHub Actions OIDC identity -- no keys for anyone to manage, anywhere -- with signatures recorded in Sigstore's public transparency log (Rekor). That lets you verify you're running a genuine, unmodified build before trusting this as security infrastructure, rather than taking it on faith. Install cosign first, then:

# Verify the Python package checksums (covers the sdist + wheel you pip install)
cosign verify-blob \
  --bundle checksums.txt.cosign.bundle \
  --certificate-identity-regexp "^https://github.com/Bridgeflow-Group/bf-agent-viewer/" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  checksums.txt

# Verify a container image, by tag or digest
cosign verify \
  --certificate-identity-regexp "^https://github.com/Bridgeflow-Group/bf-agent-viewer/" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/bridgeflow-group/bf-agent-viewer:0.1.0

Both commands check that the release was signed by this repo's GitHub Actions workflow specifically (the --certificate-identity-regexp), not just by some Sigstore-issued certificate -- a signature from any other GitHub repo's CI would fail that check. checksums.txt and checksums.txt.cosign.bundle ship as release assets alongside the wheel and sdist.

What v0.1.0 explicitly does not do

  • No policy enforcement or action-blocking. That's the difference between visibility and control, and control comes later. Credential revocation (below) is a narrow, deliberate exception to "control comes later" — cutting an agent's access off entirely is different from, and doesn't require, evaluating or gating individual actions.
  • No fine-grained, real-time action blocking. Stopping a specific tool call in the moment is still a later capability. What v0.1.0 does have is a real kill switch (F-009/F-016, built) — credential revoke cuts an agent's access off entirely, live, within --credential-refresh-seconds of a running gateway, no restart needed (see Kill switch and live credential invalidation, above) — which is coarser than call-level interception but real, not simulated. A dedicated v0.2.0 credential broker remains planned as a separate, isolated service with its own signing key and a bounded ability to mint credentials (scope ceilings, per-agent rate limits, short key rotation), so a broker compromise is bounded rather than total -- and broker-issued credentials will be sender-constrained (DPoP) so a stolen credential alone isn't enough to use it.
  • No multi-tenant isolation enforcement. Single-tenant self-hosted doesn't need it; a future hosted offering would.
  • Coverage gap by design: an agent calling APIs directly, with no MCP layer and no SDK integration, isn't visible to the platform yet. See how-it-works.md for the instrumentation approach and its known boundary.

Why this order

A kill switch without a clear record of what happened tells you that something went wrong, not which action crossed the line. Building control before visibility is how you end up with a system you can't actually operate — so this is built visibility-first, deliberately, even though it means v0.1.0 can't stop anything yet.

There aren't any published security advisories