logic @ ac81a1554 · security @ 34d4f1816
Auto-generated STPA safety model: unsafe states this system can reach and control actions that get it there. Single or multiple lenses: logic (mission failure), security (deliberate attack), governance (data safety).
<details>
<summary><b>How to read this</b>: STPA primer and diagram legend</summary>STPA (System-Theoretic Process Analysis, Leveson) treats the system as controllers issuing control actions to controlled processes, with feedback flowing back. Instead of "what component fails," it asks "what control action, given or withheld at the wrong time, drives the system into an unsafe state?" Unsafe means a violation of this system's reason to exist. Multiple lenses (logic, security, governance) analyze the same control structure for different safety questions: mission failure, deliberate attack, data-processing violations. A finding appearing in multiple lenses is expected and correct.
Read top-down: Losses are outcomes we must prevent; Hazards are states leading to loss; diagrams show control structure (solid = control action, dashed = feedback); Unsafe Control Actions table is the core; Unsafe Feedback covers data channels (absent, stale, corrupted, spoofed). Every claim cites path:line; unbuilt elements are marked. Semantic stable IDs mean regenerating changes only findings that changed.
</details>Control structure
Logical view
flowchart TD
subgraph actor["actor"]
console-caller["Private /agents console (human operator)"]
discord-user["Discord user"]
mcp-caller["MCP-authenticated caller (agent)"]
end
subgraph boundary["boundary"]
embervm-sandbox["EmberVM per-language sandbox task guests (see projects/embervm/STPA.md)"]
embervm-shotter["EmberVM shotter task guest (see projects/embervm/STPA.md)"]
end
subgraph control_plane["control_plane"]
agent-runner["Agent session runner (claim-lease over all replicas)"]
bot["Discord bot (leader singleton)"]
kg-drainer["DBOS-scheduled routine drainer (qwen-drain, kg-drain)"]
kg-feed["Leader-owned Ember session to knowledge raw exporter"]
leader-elector["Leader elector"]
outbox["Discord outbox drain"]
scheduler-api["Scheduler read/trigger API"]
end
subgraph enforcement["enforcement"]
acl["Feature ACL (grant cache)"]
principal-mw["Principal middleware (bearer auth, cached JWKS)"]
shotter-validate["Shotter URL/host allowlist (2nd layer)"]
end
subgraph publish["publish"]
docs-manifest-gen["Docs/posts manifest generator (CI Format stage)"]
end
subgraph serving["serving"]
public-api["Public backend (read + bounded writes)"]
public-frontend["Public SSR frontend"]
sandbox-tool["run_code MCP tool (per-language sandbox broker)"]
shotter-tool["Shotter screenshot MCP tool"]
end
subgraph store["store"]
postgres["Postgres (per-domain schemas)"]
end
bot -- "acl.check" --> acl
leader-elector -- "agent.reclaim" --> agent-runner
bot -- "agent.submit" --> agent-runner
console-caller -- "console.submit" --> agent-runner
docs-manifest-gen -- "docs.publish" --> public-frontend
kg-drainer -- "kg-drainer.dispatch" --> agent-runner
kg-feed -- "kg-feed.export" --> pg-primary
leader-elector -- "leader.acquire" --> postgres
bot -- "lock.reclaim" --> postgres
mcp-caller -- "mcp.agent-session" --> agent-runner
mcp-caller -- "mcp.authorize" --> principal-mw
mcp-caller -- "mcp.chat-pardon" --> bot
mcp-caller -- "mcp.report-knowledge" --> pg-primary
outbox -- "outbox.post" --> bot
public-api -- "public.health" --> postgres
console-caller -- "raws.create" --> pg-primary
mcp-caller -- "sandbox.run" --> sandbox-tool
scheduler-api -- "scheduler.trigger" --> postgres
mcp-caller -- "shotter.capture" --> shotter-tool
shotter-tool -- "shotter.dispatch" --> embervm-shotter
shotter-tool -- "shotter.validate" --> shotter-validate
leader-elector -- "singletons.start" --> bot
embervm-sandbox -. "stdout/stderr/exit_code/files for the run (Idempotency-Key from language+code only)" .-> sandbox-tool
embervm-shotter -. "captured PNG + render metadata (random per-call Idempotency-Key)" .-> shotter-tool
postgres -. "feature grant rows (30s cached)" .-> acl
postgres -. "claimed_by_replica + claimed_at lease row" .-> agent-runner
postgres -. "lease heartbeat freshness" .-> leader-elector
postgres -. "disputed flag + verification_state on search/get_note results" .-> mcp-caller
postgres -. "observability topology/stats snapshots" .-> public-api
public-frontend -. "SSR/API proxy" .-> public-api
shotter-tool -. "stored URL + stored flag (_meta) for the captured PNG" .-> mcp-caller
Physical view
flowchart TD
subgraph compute["compute"]
frontend-ssr["SvelteKit SSR sidecar"]
private-binary["monolith (full secrets)"]
public-binary["monolith-public backend (constrained DB + FaaS access)"]
end
subgraph control_plane["control_plane"]
embervm["EmberVM / FaaS admission"]
end
subgraph data["data"]
migrations["Grant migrations"]
pg-primary["CNPG primary (-rw)"]
pg-replica["CNPG hot standby (-ro)"]
shotter-store["SeaweedFS shotter bucket (content-addressed PNGs)"]
end
subgraph ingress["ingress"]
cf-ingress["Cloudflare ingress (Envoy Gateway)"]
end
subgraph secrets["secrets"]
k8s-api["Kubernetes API"]
onepassword["1Password operator"]
end
public-binary -- "chatpublic.write" --> pg-primary
frontend-ssr -- "frontend.proxy" --> public-binary
migrations -- "grant.public-reader" --> pg-replica
private-binary -- "k8s.mutate" --> k8s-api
cf-ingress -- "route.public" --> frontend-ssr
onepassword -- "secret.deliver-public" --> public-binary
private-binary -- "shotter.store" --> shotter-store
cf-ingress -. "X-Auth-Email identity header (attribution only, allowlisted senders, not cryptographically bound)" .-> agent-runner
pg-primary -. "streaming replication lag" .-> pg-replica
Control actions
| ID |
View |
Control action |
Controller → Process |
Maturity |
Evidence |
acl.check |
logical |
Authorize a Discord feature action |
bot → acl |
built |
projects/monolith/chat/acl.py:68 |
agent.reclaim |
logical |
Reclaim stale turn claims left by a crashed replica (leader-owned 5s sweep) |
leader-elector → agent-runner |
built |
projects/monolith/factory/execution/store.py:772 |
agent.submit |
logical |
Submit a Discord-triggered agent turn to the runner |
bot → agent-runner |
built |
projects/monolith/chat/bot.py:1172 |
chatpublic.write |
physical |
Write a bounded chat_public row to the primary as public_writer |
public-binary → pg-primary |
built |
projects/monolith-public/chart/values.yaml:82 |
console.submit |
logical |
Start or continue an agent session from the private console |
console-caller → agent-runner |
built |
projects/monolith/factory/execution/router.py:544 |
docs.publish |
logical |
Copy an allowlisted repo doc's full body into the public docs/posts manifest |
docs-manifest-gen → public-frontend |
built |
projects/monolith/knowledge/tools/gen_docs_manifest.py:128 |
frontend.proxy |
physical |
Proxy SSR/API calls from the frontend to the public backend, in-cluster only |
frontend-ssr → public-binary |
built |
projects/monolith-public/chart/templates/httproute-public.yaml:33 |
grant.public-reader |
physical |
Grant public_reader SELECT on a schema/view |
migrations → pg-replica |
built |
projects/monolith/chart/migrations/20260617000000_public_reader_role.sql:24 |
k8s.mutate |
physical |
Mutate cluster state via private MCP (ArgoCD sync) |
private-binary → k8s-api |
built |
projects/monolith/cluster/mcp.py:163 |
kg-drainer.dispatch |
logical |
Claim a kg-drain job, run Luna extraction, apply the result |
kg-drainer → agent-runner |
built |
projects/monolith/factory/orchestration/drainer.py:404 |
kg-feed.export |
logical |
Export a finished Ember session as an ember-session raw |
kg-feed → pg-primary |
built |
projects/monolith/factory/execution/kg_feed.py:231 |
leader.acquire |
logical |
Acquire/renew/steal the singleton lease |
leader-elector → postgres |
built |
projects/monolith/core/leadership.py:56 |
lock.reclaim |
logical |
Reclaim an expired message-processing lock |
bot → postgres |
built |
projects/monolith/chat/leader.py:109 |
mcp.agent-session |
logical |
Start or drive an agent session via MCP |
mcp-caller → agent-runner |
built |
projects/monolith/factory/execution/mcp.py:660 |
mcp.authorize |
logical |
Verify the bearer token on every MCP message (stateless streamable HTTP) |
mcp-caller → principal-mw |
built |
projects/monolith/framework/core.py:543 |
mcp.chat-pardon |
logical |
Reset a Discord user trust score via MCP |
mcp-caller → bot |
built |
projects/monolith/agent/mcp.py:338 |
mcp.report-knowledge |
logical |
Agent-report/dispute/distress raw capture (report_knowledge, dispute_fact, report_distress) |
mcp-caller → pg-primary |
built |
projects/monolith/knowledge/mcp.py:222 |
outbox.post |
logical |
Drain and post a queued Discord message/edit/reaction |
outbox → bot |
built |
projects/monolith/chat/outbox.py:146 |
public.health |
logical |
Probe the public database as public_reader (SELECT 1) |
public-api → postgres |
built |
projects/monolith/framework/core.py:322 |
raws.create |
logical |
Generic raw-input capture (POST /api/knowledge/raws) |
console-caller → pg-primary |
built |
projects/monolith/knowledge/router.py:283 |
route.public |
physical |
Route the public hostname to the frontend SSR (no direct backend route) |
cf-ingress → frontend-ssr |
built |
projects/monolith-public/chart/templates/httproute-public.yaml:13 |
sandbox.run |
logical |
Run short code in a per-language zero-egress guest |
mcp-caller → sandbox-tool |
built |
projects/monolith/sandbox/mcp.py:14 |
scheduler.trigger |
logical |
Mark a job for immediate run |
scheduler-api → postgres |
built |
projects/monolith/scheduler/service.py:35 |
secret.deliver-public |
physical |
Deliver only the public backend's constrained DB, Turnstile, and signing material |
onepassword → public-binary |
built |
projects/monolith-public/chart/templates/onepassworditem.yaml:1 |
shotter.capture |
logical |
Request a rendered screenshot of a public/private page |
mcp-caller → shotter-tool |
built |
projects/monolith/shotter/mcp.py:123 |
shotter.dispatch |
logical |
Dispatch a fresh task-class guest to render the URL |
shotter-tool → embervm-shotter |
built |
projects/monolith/shotter/client.py:91 |
shotter.store |
physical |
Best-effort content-addressed upload of the captured PNG |
private-binary → shotter-store |
built |
projects/monolith/shotter/s3.py:83 |
shotter.validate |
logical |
Validate scheme/host/dimensions before dispatch (2nd, defense-in-depth layer) |
shotter-tool → shotter-validate |
built |
projects/monolith/shotter/mcp.py:56 |
singletons.start |
logical |
Start leader-only singletons (bot, ingest, drain, sweep) |
leader-elector → bot |
built |
projects/monolith/framework/core.py:236 |
Lens: logic
Scope. A single Postgres-backed personal platform served as two composed binaries: a private read-write binary (full app capabilities, private.jomcgi.dev) and an isolated public binary (jomcgi.dev via the separate monolith-public chart). The public binary is read-only for public datasets with two constrained write/invocation paths, the private binary now also serves an agent-session console, a six-language code sandbox, a screenshot tool, and MCP over native streamable HTTP; safety is governed data access, bounded public capabilities, the secret boundary, and correct claim/lease ownership of queued agent work.
<details>
<summary>Maturity detail</summary>
- Built: framework/core.py composition and profiles, separate private/public module registries, Postgres leader-lease singletons (Discord bot, AIS ingest, outbox drain, message-lock sweep), the agent_sessions claim-lease turn engine (atomic per-message claiming across all replicas plus a leader-owned stale-claim sweep) backing both the Discord bot and the private /agents console HTTP API, goosecracker recipe/repo catalog, chat feature ACL with 30s grant cache, public_reader/public_writer roles with schema/view confinement, public/private HTTPRoutes, Turnstile secret isolation, public chat admission/concurrency limits, public FaaS identity gate, observability snapshot rollup, the shotter MCP domain (URL/host validation, EmberVM task dispatch, best-effort SeaweedFS PNG storage with a random per-call Idempotency-Key), the sandbox MCP domain (run_code, six per-language EmberVM task workloads, zero-egress except an optional scratch-Postgres credential), the native /mcp mount on stateless streamable HTTP with PrincipalMiddleware authenticating every message via cached JWKS, and the docs/posts manifest generators that publish an exact allowlist of committed repository documents to the public site. The factory knowledge-graph evidence lanes (#5527, ADR agents/063) add a shared raw-input write boundary (knowledge/ingest_queue.py ingest_raw_with_status) reached by the generic POST /api/knowledge/raws endpoint, three MCP tools (report_knowledge, dispute_fact, report_distress), and a leader-owned Ember-session exporter (agent_sessions/kg_feed.py), a daily-capped DBOS drainer (swarm/drainer.py) that runs Luna extraction in an EmberVM guest and writes atoms with server-side provenance (knowledge/extraction.py, knowledge/atoms.py), and a live disputed/verification_state signal on search results (knowledge/store.py).
- Designed-only: Strict per-domain database isolation and the ADR 010 cross-domain contract remain architectural goals; the Module/build_app framework itself is built. ADR 059 (Draft) proposes removing Context Forge entirely as the MCP entry point and serving /mcp directly behind Cloudflare; only the first, independently-sequenced slice (the stateless-HTTP transport switch) has landed, Context Forge is still deployed and still in the request path. The Context Forge tool-visibility reconcile pass (#4569) that would scope which principal may call which tool is designed, not built, so per-tool authorization beyond bearer-token authentication does not exist for any MCP tool yet.</details>
Losses
| ID |
Loss |
L.integrity-loss |
Data is corrupted, or a side-effecting action (Discord post, agent run, chat_public write) is duplicated or forged |
L.liveness-loss |
A queued turn or locked message never makes progress |
L.provenance-loss |
An agent acts on incomplete or misattributed context, losing lineage of what it was told |
L.secret-exposure |
A secret or token reaches a tier or surface that must not hold it |
L.silent-incorrectness |
A controller serves stale or wrong data while believing it correct |
L.unauthorized-access |
Anonymous or public-tier caller reads data it is not entitled to (private rows, ungoverned paths) |
Hazards
| ID |
View |
Hazard (unsafe state) |
→ Losses |
Maturity |
duplicate-agent-run |
logical |
An agent turn runs twice: a stale-claim sweep reclaims a lease still held by an actively-executing replica, or a manual resubmission overlaps a guest-side invoke the monolith timed out on without confirming it stopped |
L.integrity-loss |
built |
header-authz-drift |
physical |
X-Auth-Email is forwarded by several allowlisted in-cluster senders (gateway, MCP gateway, Argo job pods, the WhatsApp gateway, the EmberVM progress-ingest sidecar) that do not all cryptographically bind the claim to a verified caller; today it is read only for attribution (agent_sessions triggered_by), but nothing stops a future authorization decision from keying on it without also verifying the caller's JWT |
L.unauthorized-access |
built |
kg-dead-letter-unreachable |
logical |
A raw dead-lettered on its first malformed-output failure stops at retry_count=1 and its routine_jobs row is deleted immediately, but /api/knowledge/dead-letter and its replay action both require retry_count >= MAX_GARDENER_RETRIES (3), and even a successful replay only deletes the failed provenance row without re-creating the deleted job, so no operator path re-queues a KG-lane dead letter; a disputed note stuck in this state stays marked disputed indefinitely, since Dispute.state only ever leaves 'open' inside a successful apply_extraction |
L.silent-incorrectness, L.provenance-loss |
built |
kg-enqueue-swallowed |
logical |
ingest_raw_with_status catches any exception from enqueue_extraction and continues, citing a repair sweep that does not exist anywhere in the codebase; for the ember-session feed this combines with an unconditional watermark advance, so the affected turn range is never re-offered for extraction and no health signal distinguishes the raw from one that was never meant to be extracted |
L.silent-incorrectness, L.provenance-loss |
built |
over-broad-public-grant |
physical |
public_reader is granted on a schema or view that includes non-public rows |
L.unauthorized-access, L.secret-exposure |
built |
phantom-stored-artifact |
logical |
A caller treats the returned content-addressed URL as a durable reference when the SeaweedFS write actually failed, because the URL is computed from the content hash before the upload is attempted and is returned either way |
L.silent-incorrectness |
built |
private-capture-retained |
physical |
A captured screenshot, including of the private tier, is written to SeaweedFS with no expiry policy and persists indefinitely at a stable content-addressed URL after the request that produced it |
L.unauthorized-access |
built |
public-route-exposes-private-path |
physical |
The public HTTPRoute forwards an internal or unfiltered path to a served handler |
L.unauthorized-access |
built |
public-write-admission-bypass |
physical |
The internet-adjacent public tier can write outside the intended chat_public path, or bypasses Turnstile, per-session limits, and the cluster-wide inference cap |
L.integrity-loss |
built |
sandbox-credential-egress |
logical |
The scratch-Postgres feature, when enabled, injects a database DSN into the executed code's own process environment, so a Python run in the advertised zero-network sandbox gains credentialed, in-cluster network reach to a shared datastore; the tool's own docstring still claims there is no network at all |
L.secret-exposure, L.unauthorized-access |
built |
second-layer-validation-gap |
logical |
The monolith-side host/scheme allowlist is weakened, widened without an ADR amendment, or bypassed, letting an out-of-policy top-level URL reach the EmberVM dispatch call; the guest-side in-guest proxy allowlist (projects/embervm/STPA.md) is the actual control on what gets fetched, so this alone does not open egress |
L.unauthorized-access |
built |
secret-in-wrong-tier |
physical |
A private secret or a K8s token is delivered to the public or frontend tier |
L.secret-exposure |
built |
split-brain-singletons |
logical |
Two replicas both believe they are leader and run duplicate bot/ingest/drain |
L.integrity-loss |
built |
stale-authz |
logical |
A revoked feature grant keeps authorizing an action from the 30s ACL cache |
L.unauthorized-access |
built |
stale-public-snapshot |
logical |
The public stats endpoint serves an old observability snapshot after the rollup job stops |
L.silent-incorrectness |
built |
stale-sandbox-result |
logical |
A caller re-invoking run_code with the same code but different input files receives a cached result computed against a prior submission's files instead of a fresh run |
L.silent-incorrectness |
built |
unbounded-capture-queueing |
logical |
No rate limit gates shotter.capture or sandbox.run, so a caller can queue captures/runs faster than the workload's own cap admits, consuming brick memMib capacity shared with other task-class workloads |
L.liveness-loss |
built |
unredacted-public-doc |
logical |
The docs/posts manifest generators copy an allowlisted document's full body into the public site verbatim, gating only on which path may be published, never on what the content contains, so an internal-only identifier (a cluster-internal hostname, a secret env var name) left in a published project's README/ARCHITECTURE/STPA reaches the public docs route |
L.secret-exposure |
built |
unredacted-transcript-raw |
logical |
The only redaction pass (agent_sessions.redact.redact_text) runs inside the ember-session feed's own renderer; the shared ingest_raw_with_status boundary every EXTRACTABLE_SOURCES producer funnels through, including the generic POST /api/knowledge/raws endpoint and the not-yet-shipped claude-session/codex-session producers (#5568), applies no redaction of its own, so content posted under one of those source labels is extracted into durable, searchable graph facts verbatim |
L.secret-exposure |
built |
unrestricted-tool-visibility |
logical |
PrincipalMiddleware authenticates every MCP message (a valid authentik bearer token is required), but no monolith tool authorizes on the resulting Principal: the Context Forge tool-visibility reconcile pass that would scope who may call a given tool is still designed, not built (#4569), so any authenticated caller, not only ones entitled to the private tier, can invoke shotter.capture, sandbox.run, or k8s_sync_argocd_app |
L.unauthorized-access |
built |
wedged-turn |
logical |
A queued agent turn never progresses because no replica ever holds leadership to run the stale-claim sweep, and the replica that would have executed it directly crashed or restarted before claiming it |
L.liveness-loss |
built |
Unsafe control actions
| ID |
View |
Control action |
Guideword |
Unsafe condition |
Severity |
→ Hazards |
Evidence |
agent.reclaim.wrong-timing |
logical |
agent.reclaim |
wrong-timing |
The reclaim lease and the local heartbeat check are computed from two different clocks (the lease cutoff from the pod's own monotonic-adjacent wall clock, the claimed_at write from the database's); a pod running ahead of the database clock reclaims a lease that is still being actively refreshed elsewhere, and the reclaiming replica's own self-check ('claim was stolen') only protects the replica that LOST the claim, not the window before the next 10s heartbeat catches it, so both replicas can execute the same turn concurrently for up to that window |
medium |
duplicate-agent-run, split-brain-singletons |
projects/monolith/factory/execution/store.py:791 |
chatpublic.write.providing |
physical |
chatpublic.write |
providing |
The public tier accepts a write without the Turnstile/IP-hash admission gate or writes outside the constrained chat_public schema, poisoning or escaping the public boundary |
high |
public-write-admission-bypass |
projects/monolith-public/chart/values.yaml:146 |
docs.publish.providing |
logical |
docs.publish |
providing |
The generator's only gate is an exact repo-path allowlist; it checks documents for certain markers (in-cluster hostnames, 1Password refs, private IPs, node/brick names, S3 URIs, .internal domains, secret env assignments) but internal identifiers not matching these patterns pass through unreviewed |
medium |
unredacted-public-doc |
projects/monolith/knowledge/tools/gen_docs_manifest.py:128 |
grant.public-reader.providing |
physical |
grant.public-reader |
providing |
A grant on ALL TABLES or a view lacking the visibility filter exposes private rows to the anonymous tier |
high |
over-broad-public-grant |
projects/monolith/chart/migrations/20260617000000_public_reader_role.sql:24 |
k8s.mutate.providing |
physical |
k8s.mutate |
providing |
An MCP caller with a valid bearer token issues an ArgoCD sync or resource mutation with no additional per-tool authorization of which Principal may trigger it; the delegation seam (#4940) exists but this tool does not consume it |
medium |
unrestricted-tool-visibility, secret-in-wrong-tier |
projects/monolith/cluster/mcp.py:163 |
kg-drainer.dispatch.not-providing |
logical |
kg-drainer.dispatch |
not-providing |
ExtractionOutputInvalid (malformed LLM JSON, the same shape a truncated turn produces) is caught separately from generic exceptions and dead-letters the job after exactly one attempt, while every other failure gets up to MAX_GARDENER_RETRIES bounded retries first; a transient LLM formatting miss on a dispute or report never gets the retry a comparable infra hiccup would, and the resulting dead letter is unrecoverable per kg-dead-letter-unreachable |
medium |
kg-dead-letter-unreachable |
projects/monolith/factory/orchestration/drainer.py:483 |
kg-feed.export.not-providing |
logical |
kg-feed.export |
not-providing |
enqueue_extraction can silently fail inside ingest_raw_with_status (swallowed, citing a nonexistent repair sweep) for a raw that ingest still reports as created; feed_once then unconditionally advances kg_extracted_turn_seq past that turn range regardless of enqueue outcome, so the extraction job for those turns is never registered and never re-offered |
high |
kg-enqueue-swallowed |
projects/monolith/factory/execution/kg_feed.py:258 |
lock.reclaim.wrong-timing |
logical |
lock.reclaim |
wrong-timing |
A slow handler still processing past the 30s TTL is reclaimed and the message is reprocessed concurrently |
medium |
duplicate-agent-run |
projects/monolith/chat/leader.py:109 |
raws.create.providing |
logical |
raws.create |
providing |
The endpoint accepts up to 2 MiB of content under any EXTRACTABLE_SOURCES source label (e.g. ember-session, claude-session) with no redaction step of its own, so unredacted transcript content posted under one of those labels is enqueued for extraction and, once extracted, indexed as durable private-visibility knowledge notes indistinguishable from a properly-redacted one |
high |
unredacted-transcript-raw |
projects/monolith/knowledge/router.py:283 |
route.public.providing |
physical |
route.public |
providing |
A public HTTPRoute sends an unapproved backend path to the internet, or the frontend SSR proxy exposes a private-only route |
high |
public-route-exposes-private-path |
projects/monolith-public/chart/templates/httproute-public.yaml:20 |
sandbox.run.providing |
logical |
sandbox.run |
providing |
When the scratch-Postgres feature is enabled, run_code's own docstring still advertises 'there is no network at all' for every language while the Python path silently gains a credentialed connection string to an in-cluster datastore, so a caller relies on an isolation guarantee the tool does not actually provide for that one language |
medium |
sandbox-credential-egress |
projects/monolith/sandbox/client.py:40 |
secret.deliver-public.providing |
physical |
secret.deliver-public |
providing |
A private credential, Kubernetes API-capable token, or Turnstile secret is wired into the public frontend or public backend beyond its explicitly constrained use |
high |
secret-in-wrong-tier |
projects/monolith/public_turnstile_secret_isolation_test.py:76 |
shotter.capture.providing |
logical |
shotter.capture |
providing |
PrincipalMiddleware verifies the caller holds a valid authentik-issued token, but no per-tool authorization scopes who may invoke shotter.capture: any authenticated MCP caller, not only ones entitled to the private tier, can render private.jomcgi.dev pages |
high |
unrestricted-tool-visibility |
projects/monolith/shotter/mcp.py:120 |
shotter.store.wrong-duration |
physical |
shotter.store |
wrong-duration |
The stored PNG is retained indefinitely with no expiry policy, so a private-tier capture remains fetchable at its content-addressed URL by anything that can reach the SeaweedFS S3 endpoint and obtain the hash, long after the request that produced it |
medium |
private-capture-retained |
projects/monolith/shotter/s3.py:99 |
shotter.validate.not-providing |
logical |
shotter.validate |
not-providing |
HOST_SERVICE_MAP is widened past its documented exactly-two-entries invariant, or validate_screenshot_url is bypassed, without an accompanying ADR amendment; the two allowlists (monolith host map, guest-side hard allowlist) are not tied by any build-time invariant, so they can drift independently |
low |
second-layer-validation-gap |
projects/monolith/shotter/hosts.py:20 |
Unsafe feedback
Data channels (absent, stale, corrupted, spoofed) whose failure drives a controller into a hazard.
| ID |
View |
Channel |
Guideword |
Unsafe condition |
Severity |
→ Hazards |
Evidence |
claim-lease.stale |
logical |
postgres → agent-runner: claimed_by_replica + claimed_at lease row |
stale |
A pod's own clock running ahead of the database's is the dangerous skew direction named in the reclaim function's own docstring: it sees a live claim as older than it is and reclaims a turn that is still being actively executed by its true owner. NTP keeps this far inside the 30s lease in practice, but the failure is explicitly documented as silent |
medium |
duplicate-agent-run |
projects/monolith/factory/execution/store.py:794 |
disputed-flag.stale |
logical |
postgres → mcp-caller: disputed flag + verification_state on search/get_note results |
stale |
disputed is derived live from open Dispute rows, so it correctly clears on a successful extraction, but a dead-lettered dispute raw (kg-dead-letter-unreachable) leaves its Dispute.state at 'open' forever with no other code path that ever updates it, so a caller relying on the flag to judge whether a fact is trustworthy sees it marked disputed indefinitely with no way to tell a stuck dispute from a live one |
medium |
kg-dead-letter-unreachable |
projects/monolith/knowledge/store.py:523 |
grants-cache.stale |
logical |
postgres → acl: feature grant rows for guild+subject+scope |
stale |
A revoked or newly added grant is not seen for up to the 30s cache TTL, so the ACL authorizes on old policy |
medium |
stale-authz |
projects/monolith/chat/acl.py:59 |
lease-heartbeat.stale |
logical |
postgres → leader-elector: leader_lease.heartbeat_at freshness |
stale |
A leader whose event loop stalls past the 5s TTL (e.g. a sync Session call in async) is stolen from while still holding live singletons, so two replicas run them |
high |
split-brain-singletons |
projects/monolith/core/leadership.py:31 |
obs-snapshot.stale |
logical |
postgres → public-api: observability topology/stats snapshot rows |
stale |
If the Argo rollup stops, the public stats endpoint can serve an old snapshot without an age field or freshness error |
medium |
stale-public-snapshot |
projects/monolith/home/observability/router.py:24 |
run-ack.missing |
logical |
embervm-sandbox → agent-runner: run-accepted / result acknowledgement from the EmberVM shim invoke |
missing |
A read timeout raised as EmberVMTimeout leaves the monolith unable to confirm whether the guest actually started or is still running the turn; the pending row is deleted and the turn recorded as a terminal error either way, so a caller who resubmits the same prompt can produce a second guest-side run for what they perceive as one request |
low |
duplicate-agent-run |
projects/monolith/factory/execution/transport.py:646 |
sandbox-dedupe.stale |
logical |
embervm-sandbox → sandbox-tool: stdout/stderr/exit_code/files for a (language, code) key |
stale |
The Idempotency-Key hashes only language and code, not the optional input files; EmberVM's task store dedupes a resubmit against the existing task's stored result for its result TTL (CRD default 86400s, not overridden for the sandbox workloads), so a caller who resubmits identical code with different input files within that window silently receives the first submission's output computed against the first submission's files. Shotter had the identical bug (a URL-only key colliding across distinct captures) and was fixed by switching to a random per-call nonce; that fix's own comment explains a params-derived key is only correct when EVERY input that affects the result is part of the key, which this key does not satisfy |
medium |
stale-sandbox-result |
projects/monolith/sandbox/client.py:97 |
stored-metadata.corrupted |
logical |
shotter-tool → mcp-caller: content-addressed screenshot URL + stored flag in tool metadata |
corrupted |
put_screenshot derives the URL from the content hash before attempting the upload and returns it unconditionally; when the upload fails, stored is false but the URL still looks like a valid, resolvable pointer. A caller that does not check _meta.stored persists or shares a link that 404s, indistinguishable at a glance from one that resolves |
medium |
phantom-stored-artifact |
projects/monolith/shotter/s3.py:126 |
triggered-by-header.unauthorized-source |
physical |
cf-ingress → agent-runner: X-Auth-Email identity header |
unauthorized-source |
The header is forwarded by several allowlisted in-cluster senders behind a default-deny ingress NetworkPolicy, but the policy narrows WHO can reach the pod, not whether the value each of those senders sends is itself verified; the only current consumer records it as attribution with no authorization decision resting on it, so the gap is dormant rather than exploitable today, but nothing in the code enforces that boundary if a future authorization check starts keying on the header directly instead of verifying the underlying JWT |
low |
header-authz-drift |
projects/monolith/factory/execution/router.py:546 |
<details>
<summary><b>Not UCAs</b>: 14 examined and rejected</summary>
- Luna claiming verification_state=verified with no evidence array: apply_extraction force-downgrades to unverified server-side whenever assertion.evidence is empty, regardless of what the model claimed (projects/monolith/knowledge/extraction.py:364)
- a rendered public doc linking out to a non-public repository document: buildPathIndex only maps allowlisted manifest paths to slugs; a link to anything else is stripped to plain text rather than left live (projects/monolith/frontend/src/lib/server/docs.js:9)
- a repeat POST of the same shotter.capture request colliding across two unrelated captures: Fixed: the Idempotency-Key is now a random per-call nonce, so it only buys transport-retry safety within one call and cannot collapse two distinct captures of the same URL onto one cached result (projects/monolith/shotter/client.py:82)
- a repo doc outside the seven PUBLIC_PROJECTS directories reaching the docs manifest: The generator indexes only exact path matches built from a fixed PUBLIC_PROJECTS x DOC_KINDS product, not a directory walk, so nothing else in the repo, including ADRs and nested READMEs, is reachable through this generator regardless of what git ls-files returns (projects/monolith/knowledge/tools/gen_docs_manifest.py:49)
- a single missed leadership heartbeat: Bounded by 2s renew inside the 5s TTL, and any error resolves to follower fail-safe (projects/monolith/core/leadership.py:29)
- an ungated docs/posts draft (no public key, or public: false) reaching the public site: Fails closed: _declared_public_value returns None for an ungated file and it is skipped, and a strict literal-match check on the public key raises rather than guessing (projects/monolith/knowledge/tools/gen_posts_manifest.py:64)
- apply_extraction re-running on a raw that already extracted successfully: Guarded by an early replayed-check keyed on (raw_fk, gardener_version, non-failed provenance) that returns before writing any atom (projects/monolith/knowledge/extraction.py:326)
- outbox at-least-once duplicate on crash between post and mark: Bounded and mostly idempotent: reactions/edits resolve missing targets, a duplicate notify is a rare nuisance (projects/monolith/chat/outbox.py:135)
- public_reader denied on a private schema: Not a hazard but the enforced control: DB permission denies the read; asserted by projects/monolith/public_reader_grants_test.py:44
- report_distress's Discord notification failing without the reporting agent knowing: notify() failure is caught and reflected honestly in the returned status (recorded vs notified), and the underlying enqueue reuses the durable at-least-once outbox row already covered by the existing outbox non_uca (projects/monolith/knowledge/mcp.py:446)
- scheduler.trigger submits the wrong execution path: Bounded by run-now looking up the CronWorkflow whose
replaces annotation names the requested job and creating a one-off Workflow from its workflowSpec; ARGO_JOBS only suppresses legacy registry metadata, including for suspended entries (projects/monolith/scheduler/service.py:53)
- shotter.capture exceeding its render budget: Bounded by the nested timeout chain (Context Forge 60s > client read 55s > workload 50s > guest handler 45s > CDP navigate), converted into a ToolTimeout rather than an indefinite hold (projects/monolith/shotter/client.py:96)
- the kg daily-cap deferral calling both finish_drainer_job and defer_drainer_job for the same one-shot job: complete_job's NULL next_run_at write for a one-shot row (interval_secs IS NULL) is immediately overwritten by defer_job's later write in the same call sequence, so the job still re-arms at +3600s despite the double write (projects/monolith/factory/orchestration/drainer.py:407)
- two replicas racing to claim the same pending message: claim_pending_message_for_session_sync is a single atomic UPDATE ... WHERE claimed_by_replica IS NULL, so only one of two concurrent claimants can succeed (projects/monolith/factory/execution/store.py:634)</details>
Open questions
- #5568's claude-session/codex-session producers are not yet shipped, but EXTRACTABLE_SOURCES already accepts those source labels via the generic POST /api/knowledge/raws endpoint today; will that producer route through the same unredacted shared boundary, or call agent_sessions.redact.redact_text itself the way kg_feed.py does?
- ADR 059 is still Draft: does the full Context Forge removal change anything about the ingestion allowlist in cilium-ingress-policy.yaml (the mcp entry), or does the direct-mount path simply add a second ingress source alongside it?
- Does any workload other than shotter and the six sandbox languages share the same EmberVM Idempotency-Key-from-request-parameters pattern, and if so does its key cover every parameter that affects the result?
- In production, can a chat message handler (LLM summarizer) exceed the 30s message-lock TTL under load and trip lock.reclaim.wrong-timing, or is processing reliably shorter?
- Is the X-Auth-Email header ever going to gate an authorization decision, or does #4940's delegation model deliberately avoid keying on inbound headers at all? If the latter, header-authz-drift can be downgraded once that is documented somewhere durable.
- Is the shotter SeaweedFS bucket's read path gated the way artifact's is (proxied through a monolith-mediated read), or does anything with in-cluster network reach get an anonymous GET against the S3 endpoint once it has or guesses a content hash?
- Is there an intended path to make a KG-lane dead letter recoverable (re-create the routine_jobs row on replay, or align the ExtractionOutputInvalid retry policy with MAX_GARDENER_RETRIES), or is a stuck-open dispute meant to be resolved by hand in Postgres today?
- Now that PrincipalMiddleware authenticates every MCP message, when does #4569's tool-visibility reconcile pass land, and will it gate shotter.capture, sandbox.run, and k8s_sync_argocd_app by Principal scope, or only by the coarser tool-granular ACL ADR 059 compares Context Forge against?
- Should an enqueue_extraction failure inside ingest_raw_with_status roll back the whole raw write instead of being swallowed, now that no sweep exists anywhere to repair a missed enqueue?
- The framework is now built, but strict per-domain schema isolation and the cross-domain contract are still conventions. Re-run this analysis when those controls become enforceable rather than merely compositional.
- The private chart enables HPA from 1 to 3 replicas; verify claim-lease behavior under real scale-out and termination, especially whether a terminating pod's in-flight claims are released promptly enough for the 30s lease to matter in practice.
- The public backend has a deliberate primary write path and public FaaS invocation path. Confirm production Cilium/EmberVM policy matches the chart claims: public_writer must remain limited to chat_public, and the public service account must remain identity-only with no Kubernetes RBAC.
- When the scratch-Postgres feature is re-enabled (embervm side), should run_code's docstring be corrected to stop claiming zero network for python, or should the DSN injection move behind a separate, explicitly-network-capable tool so the isolation claim stays true for the tool most callers reach for?
Lens: security
Scope. Adversarial abuse of the monolith's control structure: an attacker who deliberately forges or withholds identity, reaches a tool the entitlement model does not yet gate, or crosses a tier boundary in the control structure.
<details>
<summary>Maturity detail</summary>
- Built: Separate public and private binaries (ADR security/004), public_reader role with visibility filters on the replica, Turnstile-gated chat with three nested budgets, PrincipalMiddleware bearer-token verification on every MCP message, Discord trust ledger with per-guild per-user scoring and heuristics-fed instant enforcement, Cloudflare Access lane projecting verified email to agents console, Kubernetes RBAC scoping cluster mutation to the private pod.
- Designed-only: Per-tool authorization on the monolith's MCP surface (ADR 059 to route through delegation-consuming broker, #4569 tool-visibility reconcile pass), default-deny egress CiliumNetworkPolicy for the private pod, per-domain database isolation and cross-domain contract.</details>
Losses
| ID |
Loss |
L.capacity-exhaustion |
Attacker exhausts shared compute (GPU, sandbox, shotter) or causes denial of service |
L.integrity-loss |
Data is corrupted, or a side-effecting action (Discord post, agent run, chat_public write) is duplicated or forged |
L.secret-exposure |
A secret or token reaches a tier or surface that must not hold it |
L.silent-incorrectness |
A controller serves stale or wrong data while believing it correct |
L.unauthorized-access |
Anonymous or public-tier caller reads data it is not entitled to (private rows, ungoverned paths) |
Hazards
| ID |
View |
Hazard (unsafe state) |
→ Losses |
Maturity |
Status |
Issue |
no-egress-policy |
physical |
The private monolith pod carries no default-deny egress CiliumNetworkPolicy, so a compromised pod has open-ended cluster and internet reach |
L.unauthorized-access, L.secret-exposure |
designed |
none |
#5277 |
over-broad-public-grant |
physical |
public_reader is granted on a schema or view that includes non-public rows |
L.unauthorized-access, L.secret-exposure |
built |
|
|
private-capture-retained |
physical |
A captured screenshot, including of the private tier, is written to SeaweedFS with no expiry policy and persists indefinitely at a stable content-addressed URL after the request that produced it |
L.unauthorized-access |
built |
|
|
public-route-exposes-private-path |
physical |
The public HTTPRoute forwards an internal or unfiltered path to a served handler |
L.unauthorized-access |
built |
|
|
public-tier-broad-cluster-reach |
physical |
The public tier's CiliumNetworkPolicy scopes by 'in-cluster or not' (toEntities: cluster), allowing every in-cluster pod endpoint rather than only the intended four (Postgres, vLLM, embeddings, SeaweedFS) |
L.unauthorized-access |
built |
none |
#5276 |
public-write-admission-bypass |
physical |
The internet-adjacent public tier can write outside the intended chat_public path, or bypasses Turnstile, per-session limits, and the cluster-wide inference cap |
L.integrity-loss, L.capacity-exhaustion |
built |
|
|
sandbox-credential-egress |
logical |
The scratch-Postgres feature, when enabled, injects a database DSN into the executed code's own process environment, so a Python run in the advertised zero-network sandbox gains credentialed, in-cluster network reach to a shared datastore; the tool's own docstring still claims there is no network at all |
L.secret-exposure, L.unauthorized-access |
built |
|
|
secret-in-wrong-tier |
physical |
A private secret or a K8s token is delivered to the public or frontend tier |
L.secret-exposure |
built |
|
|
stale-sandbox-result |
logical |
A caller re-invoking run_code with the same code but different input files receives a cached result computed against a prior submission's files instead of a fresh run |
L.silent-incorrectness |
built |
|
|
unredacted-public-doc |
logical |
The docs/posts manifest generators copy an allowlisted document's full body into the public site verbatim, gating only on which path may be published, never on what the content contains, so an internal-only identifier (a cluster-internal hostname, a secret env var name) left in a published project's README/ARCHITECTURE/STPA reaches the public docs route |
L.secret-exposure |
built |
|
|
unrestricted-tool-visibility |
logical |
PrincipalMiddleware authenticates every MCP message (a valid authentik bearer token is required), but no monolith tool authorizes on the resulting Principal: the Context Forge tool-visibility reconcile pass that would scope who may call a given tool is still designed, not built (#4569), so any authenticated caller, not only ones entitled to the private tier, can invoke shotter.capture, sandbox.run, or k8s_sync_argocd_app |
L.unauthorized-access |
built |
|
|
Unsafe control actions
| ID |
View |
Control action |
Guideword |
Unsafe condition |
Severity |
→ Hazards |
Status |
Issue |
Evidence |
chatpublic.write.providing |
physical |
chatpublic.write |
providing |
Anonymous attacker bypasses Turnstile, per-session limits, or the cluster-wide inference cap to write outside chat_public or exhaust shared GPU |
high |
public-write-admission-bypass |
enforced-prod |
|
projects/monolith-public/chart/values.yaml:146 |
docs.publish.providing |
logical |
docs.publish |
providing |
A document is added to an allowlisted path and the generator publishes it unreviewed for certain markers (in-cluster hostnames, 1Password refs, private IPs, node/brick names, S3 URIs, .internal domains, secret env assignments); internal identifiers not matching these markers still pass through |
medium |
unredacted-public-doc |
enforced-prod |
#5275 |
projects/monolith/knowledge/tools/gen_docs_manifest.py:128 |
grant.public-reader.providing |
physical |
grant.public-reader |
providing |
A schema-wide grant or a missing visibility filter serves a private row through a public route, disclosing private knowledge-graph content directly |
high |
over-broad-public-grant |
enforced-prod |
|
projects/monolith/chart/migrations/20260617000000_public_reader_role.sql:24 |
k8s.mutate.providing |
physical |
k8s.mutate |
providing |
Any authenticated MCP caller triggers ArgoCD sync/prune on any Application or performs cluster-wide pod/log/configmap/event read with an unentitled token |
high |
unrestricted-tool-visibility |
none |
#4569 |
projects/monolith/cluster/mcp.py:163 |
mcp.agent-session.providing |
logical |
mcp.agent-session |
providing |
Any authenticated MCP caller starts or drives a full agent session, inheriting every tool that session can reach and compounding every other unentitled-tool row |
high |
unrestricted-tool-visibility |
none |
#4569 |
projects/monolith/factory/execution/mcp.py:660 |
mcp.chat-pardon.providing |
logical |
mcp.chat-pardon |
providing |
An MCP caller with no Discord-side privilege calls monolith_chat_trust_pardon, resetting any locked-out user's score and flipping their labels, undoing the ledger's containment of an active red-team session from a different trust boundary |
high |
unrestricted-tool-visibility |
none |
#4569 |
projects/monolith/agent/mcp.py:338 |
route.public.providing |
physical |
route.public |
providing |
Attacker reaches a private or internal path via the public HTTPRoute or the SSR proxy, reading private data or invoking an internal handler |
high |
public-route-exposes-private-path |
enforced-prod |
|
projects/monolith-public/chart/templates/httproute-public.yaml:13 |
sandbox.run.providing |
logical |
sandbox.run |
providing |
Any authenticated MCP caller spends zero-egress compute or, when scratch feature is enabled, reaches the in-cluster scratch database with credentialed DSN |
medium |
unrestricted-tool-visibility, sandbox-credential-egress |
none |
#4569 |
projects/monolith/sandbox/mcp.py:14 |
secret.deliver-public.providing |
physical |
secret.deliver-public |
providing |
A private credential, Kubernetes API-capable token, or Turnstile secret is wired into the public frontend or public backend beyond its explicitly constrained use |
high |
secret-in-wrong-tier |
enforced-prod |
|
projects/monolith/public_turnstile_secret_isolation_test.py:76 |
shotter.capture.providing |
logical |
shotter.capture |
providing |
Any authenticated MCP caller (not entitled to private tier) calls the screenshot tool for private.jomcgi.dev pages, disclosing private-tier page content retained indefinitely at a guessable URL |
high |
unrestricted-tool-visibility, private-capture-retained |
none |
#4569 |
projects/monolith/shotter/mcp.py:120 |
Unsafe feedback
Data channels (absent, stale, corrupted, spoofed) whose failure drives a controller into a hazard.
| ID |
View |
Channel |
Guideword |
Unsafe condition |
Severity |
→ Hazards |
Status |
Issue |
Evidence |
docs-publish.unauthorized-source |
logical |
docs-manifest-gen → public-frontend: allowlisted document content published to public site |
unauthorized-source |
A document is added to an allowlisted path and the generator publishes it unreviewed for certain markers (in-cluster hostnames, 1Password refs, private IPs, node/brick names, S3 URIs, .internal domains, secret env assignments); internal identifiers not matching these markers still pass through |
medium |
unredacted-public-doc |
enforced-prod |
#5275 |
projects/monolith/knowledge/tools/gen_docs_manifest.py:128 |
sandbox-dedupe.stale |
logical |
embervm-sandbox → sandbox-tool: stdout/stderr/exit_code/files for a (language, code) key |
stale |
Idempotency-Key is (language, code) only, not input files; resubmit with same code but different files within result-cache TTL receives stale output from prior submission |
medium |
stale-sandbox-result |
none |
#5304 |
projects/monolith/sandbox/client.py:97 |
<details>
<summary><b>Not UCAs</b>: 6 examined and rejected</summary>
- Authentik and Kubernetes control-plane compromise: Assumed not to hold
- Cluster layers below the monolith: Ingress tunnel, CNI policy, admission control, secret operator are owned by docs/security.md
- Cluster-wide capacity exhaustion outside modeled admission and budget gates: Not analyzed here
- Discord platform account compromise: Outside the trust ledger's design
- EmberVM guest and hypervisor isolation: Covered in projects/embervm/STPA.md (logic and security lenses); this document covers only monolith-side broker
- Supply chain of the monolith's own image build: Not analyzed here</details>
Open questions
- Should run_code's docstring be corrected to stop claiming zero network for Python, or should the scratch-Postgres DSN injection move behind a separate explicitly-network-capable tool?
- When #4569's tool-visibility reconcile pass lands, will it gate shotter.capture, sandbox.run, k8s_sync_argocd_app, and agent-session tools by Principal scope, or only by coarser tool-granular ACL?
- When will the default-deny egress CiliumNetworkPolicy (#5277) land for the private pod's open-ended cluster and internet reach?
- Will the public tier's cluster-reach policy (#5276) be scoped to only the four intended destinations (Postgres, vLLM, embeddings, SeaweedFS)?