The threat model
This document names who can act on ExecBound, what each trust boundary must hold, which threats apply at each boundary, and what evidence shows that a mitigation works. It maps attacker goals to the security invariants in CLAUDE.md, the code that enforces them and the acceptance scenarios in the acceptance inventory (A to X), the context contracts guide (CC1 to CC12, CP1 to CP12 and CA1 to CA10) and the shared limit scopes (SL1 to SL12), external execution checkpoint (GC1 to GC18) and Arcade logic extension (AL1 to AL34) sections of the inventory. Residual risks are stated plainly with the issue that tracks each one. The architecture guide describes the components; this document describes how they fail and why they should not.
Scope and method
Covered: the gateway (HTTP and MCP), the human surface, PostgreSQL, the protected mock providers, monitoring intake and alert delivery, owner tooling and the CLI, offline replay and export, and the build and CI pipeline.
Not covered: real vendor systems (only controlled mock providers exist), operating-system sandboxing of the gateway process, the hosting platform of the Render pilot (Render and Supabase are trusted as described in DEPLOYMENT.md), and the security of a customer's own agent platform such as a Tines tenant. B9 below covers what a Gate caller can do to ExecBound; what it does at its own vendor with its own credential is outside this model entirely, which is why that boundary's rows are mostly limits rather than controls.
Method: for each trust boundary, threats are classified with STRIDE (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege). Each row records the mitigation, where it lives in code, the scenario or test that exercises it, and a status:
| Status | Meaning |
|---|---|
| Verified | An acceptance scenario or named test exercises the mitigation on real PostgreSQL, real Chromium or the real Cedar engine. |
| Partial | A control exists, but a documented gap remains. |
| Open | No control yet; an issue tracks the work. |
| Accepted | A design limit stated in the documentation; not scheduled for change. |
Assets
- Execution authority: the ability to make a provider perform a consequential action. Only the durable execution claim inside one tenant transaction confers it.
- Approvals: scoped, expiring, server-side human decisions consumed with exactly one execution claim.
- Impact budgets and target claims: shared limits per principal, tenant, principal group, connector account and resource selector, and one unresolved claim per canonical target.
- Frozen plans and terminal evidence: the resolved request, plan hash, provider journals and audit events that replay and reconciliation rely on.
- Audit chain: per-tenant hash-chained events with a separately retained trusted head.
- Credentials and keys: agent tokens, human sessions and CSRF tokens (stored only as hashes), continuation signing keys, provider dispatch and control credentials, provider evidence keys, monitoring source tokens and the database roles.
- Tenant isolation: every table is tenant-scoped with forced row-level security.
- Policy bundles and context settings: immutable, digest-bound content whose activation is an audited ADMIN action.
- Monitoring findings and alerts: detective evidence that must stay separate from execution authority.
- Runtime configuration: private files that name connector destinations, keys and precondition classes; never derived from a request.
- Executor keys: per-tenant Ed25519 public keys an external executor's report is verified against. ExecBound holds no private half, so the asset is the ring's integrity — which keys are registered, when each began and whether one was retired or cut off — rather than a secret at rest.
Actors
| Actor | May | Must not | Source |
|---|---|---|---|
| Protected agent | Propose one bounded action and target with its own credential; read its own execution status. | Supply trusted facts, approvals, destinations or passthrough arguments; read provider credentials, configuration, signing material or control stores. | ARCHITECTURE.md trust boundaries; SURFACES.md |
| Gate caller | Propose one bounded action and target exactly as a protected agent does; receive a grant for an operation an owner-provisioned mapping marked externally executed; act at its own vendor with its own credential; report the outcome once, with the report secret that grant returned. | Select Gate, name an executor, set a precondition class or promote an evidence tier — all four are owner-provisioned bundle content or owner-registered configuration; settle any execution but the one its grant names; settle an operation ExecBound dispatched; release its own reservation or free its own target by assertion; reconcile, approve or dispatch through ExecBound. | CHECKPOINT.md; external checkpoint design |
| Control point | Call POST /pre and POST /post with an ExecBound agent credential held in its own configuration; carry a tool identity, the declared inputs, reported metadata and an end-user string; receive one of three enumerated codes. | Select which declaration applies, choose an action, a target or an incident, move a tool between governed and ungoverned, set a precondition class or promote a tier — all of that is the operator's map or the frozen plan; supply an identity or a budget scope; settle any execution but the one its own correlation row names; release a claim or refund a reservation by anything it says; correct a settlement it made. | ARCADE.md; arcade design |
| Human approver | Sign in for at most 15 minutes; approve or deny inside an owner-assigned target and action scope. | Reconcile; decide outside the assigned scope; dispatch anything by approving. | SURFACES.md; EXECUTION.md |
| ADMIN | View tenant activity; reconcile with a reason and terminal evidence; export audit history; view monitoring pages and acknowledge findings; activate bundles; issue and revoke agent and approver credentials and manage approver assignments through the administration functions. | Stand in for an assigned approver; settle an uncertain operation without proof; create or change administrators, tenants or the registry. | ARCHITECTURE.md; OPERATIONS.md |
| Owner tooling | Write tenancy, credentials, mappings, inventory, sources, aliases, canonical identity, approval assignments and resource_facts with the owner role; the execbound administration commands cover tenants, principals, credentials, approver assignments and principal groups, and the context-source-* and context-import commands cover context sources, inventory facts and incidents from adapter-produced manifests validated against the contract catalog; every change is audited under an ADMIN actor. | Be reachable from any HTTP route; run with the runtime role. | EXECUTION.md; CONTEXT_CONTRACTS.md |
| Protected mock provider | Accept a fixed configured account and frozen command; mutate atomically against an expected version; sign journals; fence late arrivals with a cancellation tombstone. | Accept agent credentials; expose provisioning, reset or passthrough routes. | PROVIDER.md; API_REFERENCE.md |
| PostgreSQL | Hold all gateway authority, execution state, impact, approvals and audit events under forced row-level security. | Be reached with the runtime role by anything but trusted gateway code. | ARCHITECTURE.md; DATA_MODEL.md |
| Monitoring source | Post allowlisted story metadata with a source token bound to one tenant, source and story. | Authorize execution, create approvals, settle effects or change limits. | TINES_MONITORING.md |
| Notification receiver | Receive authenticated at-least-once deliveries carrying only identifiers and a rule version. | Be chosen by a finding or an event; trigger any protected resend. | external monitoring design |
| CI | Run the full suite and both demonstrations on the exact source head and publish a manifest bound to that commit. | Replace security tests with documentation checks. | VALIDATION.md |
Trust boundaries
| ID | Boundary | What must hold |
|---|---|---|
| B1 | Protected agent to gateway (HTTP and MCP) | Identity comes only from an authenticated credential; every input is untrusted until validated; nothing an agent sends can grant authority. |
| B2 | Gateway to PostgreSQL | Trusted code sets transaction-local tenant context; runtime privileges are restricted; every consequential write commits atomically under the tenant fence. |
| B3 | Gateway to protected provider | Only a configured account and frozen command can dispatch; only authenticated, command-bound evidence can settle. |
| B4 | Human browser to human surface | A current session, exact origin, CSRF token, signed state, assigned scope and current role are all required for a decision. |
| B5 | Owner tooling and ADMIN to authority data | Registry, policy and trusted facts change only through owner or audited ADMIN paths. |
| B6 | Monitoring sources and receivers | Source-reported observations stay outside execution authority; destinations are operator-configured. |
| B7 | Live system to replay and export | Export needs current ADMIN authority; replay has no live capability. |
| B8 | Build, dependencies and CI | Locked, hash-pinned environments; evidence bound to the tested commit. |
| B9 | Gate caller to gateway, and its vendor beyond it | Placement, the executor's identity, the precondition class and the evidence tier are all owner-provisioned or owner-registered; a grant reserves before it is issued and settles exactly one execution once; nothing an executor says releases a canonical claim or refunds a reservation. What happens at the vendor is outside ExecBound entirely, and no row here claims otherwise. |
| B10 | Control point to hook surface, and the correlation table behind it | The tool map is deployment configuration and no hook field selects it; the extension translates and calls the same kernel methods an agent reaches, so nothing it does can permit what the kernel did not; a pre hook that answers OK on a governed decision has reserved and claimed first; a post hook settles at most the one execution its own correlation row names, at tier ASSERTED and never higher, and neither of its two statements refunds anything. The correlation row holds a usable report secret while its grant is live, which is a new trust boundary rather than an inherited one: a reader of that table can close a row and let the scheduled run free its target, and can never reduce what the tenant is charged. |
| B11 | Console to model provider | The assistant reads only through the asking person's session, writes nothing, sends nothing secret, and every answer is text whose links are only to ids a tool returned; the feature is absent without its block and off per tenant until an ADMIN turns it on. |
Threats and mitigations
B1: Protected agent to gateway
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T1.1 | S | Forged or guessed agent credential | Opaque tokens; the SHA-256 digest is compared in constant time even when no row exists; expiry and revocation are checked in the lookup (identity.py). | K; tests/test_runtime.py::test_http_foreign_status_and_bad_identity_are_opaque | Verified |
| T1.2 | S | MCP client metadata or a cached session used as identity | Stateless transport; every tools/call authenticates; metadata never establishes identity (surfaces/mcp.py). | M; tests/test_mcp_surface.py, tests/test_identity_surfaces.py | Verified |
| T1.3 | T | Caller claims replace trusted facts such as tier, incident or environment | Claims are selectors only; facts come from the owner-provisioned registry and contract sources (resolution/). | E, T, CC6; tests/test_policy_evaluation.py::test_caller_claim_changes_cannot_change_cedar_entities | Verified |
| T1.4 | T | Unknown operation, passthrough arguments, multiple or embedded targets | Strict wire models with forbidden extras and bounded patterns (requests.py); unknown mappings rejected before admission. | F, Q; tests/test_requests.py, tests/test_resolution.py::test_unknown_operation_fails_without_foreign_disclosure | Verified |
| T1.5 | E | Agent supplies an approval flag or approval identifier | No request schema accepts approval evidence; approvals are server-side rows consumed inside the execution claim (policy/approval.py, execution/). | C, G, R | Verified |
| T1.6 | E | Agent calls human, administrative or reconciliation routes | Rejected before any provider network activity and audited as a rejection. The served routes are asserted against a declared authority map, so a route added later fails the suite until it is classified, and every route needing a session is exercised with an agent credential rather than a sample of five. Re-examined for the checkpoint unit, which added one agent route: POST /v1/executions/{id}/outcome takes the same AGENT authentication and is the first agent-authenticated route that settles an execution — before it none did, and a dispatch ticket is a secret internal capability an agent never holds (ARCHITECTURE.md), so nothing about this is a capability an agent already had. What bounds it is the list that follows and not an equivalence. It refuses an execution whose frozen mapping does not declare an external executor before the report secret is compared, so a dispatched operation is unreportable whether or not the secret matches; it refuses another principal's execution as NOT_FOUND; and the connector-free administrator exit is reachable only from an ADMIN session, never from this route. The act grant is not an authenticator here and cannot be added to a report, because the body forbids extra fields. | K, GC9, GC15; tests/test_route_authority.py, tests/test_human_surface.py::test_agent_bearer_cannot_use_human_routes, tests/test_protected_path.py::test_agent_reconciliation_rejected_before_provider_network, tests/test_checkpoint.py::test_gc9_every_report_the_route_refuses_leaves_the_row_and_the_secret_where_they_were | Verified |
| T1.7 | T | Idempotency key replayed with a changed payload, or raced across adapters | Same-key conflicts; one pending approval per racing first intent; precommit failures roll back every claim. | H, V; tests/test_execution.py::test_same_key_changed_intent_conflicts_under_race, ::test_precommit_failures_roll_back_every_claim | Verified |
| T1.8 | I | Reading another tenant's or another agent's execution | Status requires the owning agent; foreign identifiers and bad identities return opaque responses. | S; opaque status test above | Verified |
| T1.9 | I | Engine, provider or database diagnostics leak through errors | Unexpected exceptions map to a generic 503 (app.py); Cedar and provider diagnostics are never echoed. | N; tests/test_cedar.py | Verified |
| T1.10 | D | Request flood exhausts admission | A process-wide in-flight limiter (32) returns 429; fixed-window limits per authenticated credential (shared by HTTP and MCP) and per direct peer address refuse with RATE_LIMITED before parsing or admission (surfaces/limits.py). | tests/test_rate_limits.py::test_agent_requests_beyond_the_credential_limit_are_refused_then_reset, ::test_refused_execute_requests_consume_nothing | Verified |
| T1.11 | R | Failed authentication leaves no durable record | Every surface records a bounded per-minute counter in authentication_failures by surface and reason, attributed only to a credential or monitoring source that authentication verified exists; responses are unchanged, recording is best effort and audited history is untouched (identity.py). | tests/test_authentication_failures.py (AF1 to AF14) | Verified, see R8 |
| T1.12 | S | Guessed, replayed or brute-forced sandbox invitation code on the public /onboard page | A code is ebi1.<tenant>.<invitation>.<43-character secret>; only its SHA-256 is stored and the database compares it under a row lock inside the onboarding_claim definer function, which also requires the slot to be unclaimed and the invitation unexpired and unredeemed, so a replay fails on redeemed_at. Five secret mismatches revoke the invitation server-side, the count committed before the refusal; the page adds a per-selector window shared with sign-in and the per-peer window, and refuses a forged CSRF field, a bearer header, a wrong origin, an extra field or a query string before the database. Every refusal is one sentence; the code and the selector reach only the structured log (onboarding.refused). A code sent by email (invite-create --email) is minted inside the send and exists only in memory and the one message, travels only over verified TLS, and its invitation is revoked unless the relay accepted the message, so a code from an uncertain attempt never works. | tests/test_onboarding.py (SO3, SO4, SO10, SO11, SO11b), tests/test_invitation_email.py | Verified |
| T1.13 | T | An agent retries a completed action under a new idempotency key and acts twice, consuming impact a second time | A caller that has no key management sends reference, the ticket, case or run id, and the gateway derives the key server-side with requests.derive_key byte for byte as the client's intent_key does, so one ticket lands on one intent whichever surface carries it and a retry through either progresses it; ActionRequest accepts exactly one of reference and idempotency_key, so a caller cannot send both and choose which applies. | tests/test_requests.py::test_reference_derives_the_key_exactly_as_the_client_does, tests/test_requests.py::test_reference_and_key_are_exactly_one, tests/test_mcp_surface.py::test_the_same_reference_over_http_and_mcp_is_one_intent | Verified |
| T1.14 | T | An agent names or infers a space, and acts on another customer's objects through a mapping that is not its own | No request field, header or credential attribute carries a space: the space is read from the agent's own principals row inside the fence (spaces.agent_space), and a mapping whose connector_accounts row belongs to another space and is not offered tenant-wide is refused INVALID_CONFIGURATION, audited, before any provider contact (execution/admission.py). The active bundle is the space's selection, so a space with none fails closed exactly as a tenant with none did. | SP1, SP5; tests/test_spaces_kernel.py | Verified |
| T1.15 | D | An agent of an archived space keeps working | "Takes no new work" has one meaning and is checked where the emergency stop is checked: admission refuses before any dispatch, audits the rejection, and answers 423 SPACE_ARCHIVED in the stop's own shape. An archived space stays fully readable and loses no history, and a DISPATCH_COMMITTED grant already issued is not recalled, exactly as the stop does not recall one. | SP9; tests/test_spaces_kernel.py | Verified |
B2: Gateway to PostgreSQL
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T2.1 | E | Tenant data reached across tenants | Transaction-local tenant context; all 40 tenant tables force row-level security; composite keys; the runtime role cannot bypass RLS and check_runtime refuses an unsafe role (db.py). | S, CC8; tests/test_execution_database.py::test_all_execution_tables_isolate_colliding_tenant_objects | Verified, see R1 |
| T2.2 | T | Runtime role rewrites authority data (mappings, credentials, inventory, facts) | Owner-owned tables and restricted grants; resource_facts is SELECT-only for the runtime. | CC8, CC9; tests/test_resource_facts_schema.py | Verified |
| T2.3 | T | Budget, approval or target-claim races | One global lock order, tenant control row then audit head (execution/store.py); concurrency proven on real PostgreSQL. | D, L, V; tests/test_execution.py::test_concurrent_principals_cannot_exceed_shared_first_capacity | Verified |
| T2.4 | T | Audit history truncated or altered | Per-tenant hash chain with a separately retained trusted head (audit.py); export verification detects tampering relative to that head. | tests/test_replay_verify.py, tests/test_audit.py | Verified, see R2 |
| T2.5 | D | Long transactions or lock waits starve admission | Statement timeout of 10 seconds and lock timeout of 5 seconds per transaction. Measured: about 40 denied admissions per second per tenant with latency growing linearly with concurrency; the runtime's concurrency cap now equals the pool capacity so callers queue on the runtime semaphore instead of timing out on the pool. | scripts/admission_benchmark.py; OPERATIONS.md | Accepted |
| T2.6 | I | Secrets at rest in the gateway database | Only token, session and CSRF hashes are stored; provider credentials never enter the gateway database. | SURFACES.md | Verified |
| T2.7 | T | A limit scope is satisfied or violated by mutable state: a membership, account or inventory change rewrites what retained impact means, or history recorded before the scope existed counts as room in its budget | Every scope counts a filter over retained execution_impact rows whose account_id, group_ids and facts were written once at reservation from the frozen plan and the memberships read inside the fence; counting never joins current membership or inventory, a row lacking an attribute never counts toward the scope that needs it, a group, account or selector budget is one counter for every agent, run and adapter under the same tenant fence, and membership changes take that fence (execution/store.py, administration.py). Replay reconstructs every scope from captures and reports unknown membership as UNKNOWN_GROUP_MEMBERSHIP, never as room. | SL1, SL2, SL3, SL5, SL6, SL8, SL9, SL12; tests/test_limit_scopes.py, tests/test_limit_scopes_schema.py::test_backfill_derives_account_and_facts_from_frozen_plans, tests/test_replay_scenarios.py::test_sl9_unknown_membership_of_a_retained_unit_is_a_lower_bound_not_room | Verified |
| T2.8 | E | The runtime writes a signup request, a slot's lifecycle or an invitation directly, or forges a delivery the outbox never queued | Every consequential write on signup_requests, onboarding_slots and onboarding_invitations happens inside a fenced SECURITY DEFINER function with no actor (onboarding_invite, onboarding_invitation_rekey, sandbox_expire, sandbox_notice, signup_request, signup_allocate, signup_mark), owned by the schema owner and executable by the runtime; the runtime holds SELECT and nothing else on the three, and on the outbox holds UPDATE on exactly seven delivery columns and no INSERT or DELETE, so a message names the request, class, slot and invitation the function wrote and nothing can re-point one. check_runtime pins the function set, the table set and the seven columns, and both new tables are forced row-level security under the tenant policy every table has (migrations/versions/0039_signups.py, db.py, signups.py). | tests/test_signups.py (SU1, SU9b, SU9c, SU9d, SU9e) | Verified |
| T2.9 | I | A store function reads without the space predicate and returns another space's rows | Forced row-level security does not catch this. A function that forgets its tenant predicate returns nothing because the database refuses it; one that forgets its space predicate returns another customer's rows, and there is no second line. One helper (spaces.space_clause) builds every space predicate from one computed visible set (spaces.visible_spaces), no store function writes its own, and a parametrized isolation test covers every space-carrying table, taking its parameter list from the migration's own column list so a table added later cannot skip a case. The test is the control, which is why it is named here and why R25 records the residual. | SP1, SP2; tests/test_spaces_isolation.py | Verified |
| T2.10 | E | Forced row-level security is asked to do a space's work, and a policy comes to depend on a session setting | Every tenant_isolation policy stays tenant-keyed in its once-per-statement subquery form, forced, on the three new tables as on every other, and a test asserts that no policy in the schema mentions a space setting at all. The space is a column and a WHERE clause and never a second fence (migrations/versions/0041_spaces.py). | SP10; tests/test_spaces_schema.py | Verified |
B3: Gateway to protected provider
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T3.1 | S | Agent-chosen destination, server-side request forgery or passthrough | Fixed configured origin per account; HTTPX transport with no retries, no redirects, no environment proxies and a bounded timeout (provider/connector.py). | F; tests/test_provider_connector.py, tests/test_repository_rules.py | Verified |
| T3.2 | S | Forged or replayed terminal evidence | Command-bound HMAC journals with key identifiers, verified in constant time; another operation's proof cannot settle this one. | I, P, W; tests/test_identity_reconciliation.py::test_later_signed_operation_cannot_settle_or_refund_original_effect | Verified, see R3 |
| T3.3 | T | Target changed between read and mutation | The provider checks the frozen expected version atomically at mutation. | O, U; tests/test_protected_path.py::test_provider_version_changes_after_gateway_commit_fail_atomically | Verified |
| T3.4 | T | Retry or refund after an uncertain outcome | A lost response retains INDETERMINATE with capacity and target guard held; no resend; a cancellation tombstone fences a late arrival. | I, P, W; tests/test_protected_path.py::test_missing_journal_does_not_refund_and_cancel_fences_delayed_dispatch | Verified |
| T3.5 | E | Agent reads provider dispatch or control credentials or evidence keys | Providers run as separate authenticated processes; configuration is private files; startup rejects credentials or keys shared across provider families. | K; tests/test_surface_configuration.py | Verified, see R4 |
| T3.6 | D | Slow or hung provider | One send attempt with a bounded timeout; uncertainty is retained rather than retried. | I | Verified |
| T3.7 | R | Evidence key loss or rotation | Keyed rings rotate from the private configuration without a restart, atomically for continuations, form state and every connector under the startup independence rules; a removed key refuses its proofs and leaves the operation uncertain until the key is restored, and rotation-readiness shows what still depends on a key (surfaces/configuration.py, rotation.py). | tests/test_key_rotation.py (KR1 to KR8) | Verified, see R9 |
| T3.8 | R | Unsettled operations hold claims and budgets until a person notices | execbound recover settles them on a schedule through the same fence, ADMIN recheck, audited request, journal lookup and trusted verifier as manual reconciliation; it never resends, never cancels an attempted operation, requests a tombstone only for never-attempted operations when explicitly enabled, and runs with a single-connection pool (recovery_run.py). | tests/test_recovery_run.py (RC1 to RC13) | Verified |
Transport decision (September 16, 2026, #183). A provider origin is HTTPS with a certificate the gateway verifies unless it is a loopback IP address, and there is no opt-in for cleartext on a private network. Two alternatives were considered for running the mock providers on a hosting platform whose private network is cleartext: a per-connector opt-in admitting private addresses, rejected because it adds a switch that weakens this boundary and would have to be proven unreachable from every agent input; and TLS served by the mocks themselves through a mounted CA, rejected as certificates for no benefit. The accepted posture is the one the Stage 1 pilot uses: the mocks are public HTTPS web services that authenticate every call with per-account bearer tokens and sign their evidence, which is the posture of the vendor APIs they stand in for, so the pilot exercises the connector rule rather than a relaxed one. Internet reachability adds no authority: a mock answers 401 without its token and mutates nothing without the dispatch token; a leaked token is rotated like any credential; a flood against a public mock is T3.6 and leaves operations uncertain rather than wrong.
B4: Human browser to human surface
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T4.1 | S | Session forgery or theft | Opaque hashed sessions of at most 15 minutes; HttpOnly and SameSite=strict cookies with the __Host- prefix when served over HTTPS; expiry rechecked inside decision transactions (surfaces/sessions.py). | X; tests/test_human_surface.py, tests/test_human_sessions.py | Verified |
| T4.2 | T | Cross-site or forged form submission | Exact Origin check, CSRF token and an HMAC-signed continuation binding credential, tenant, execution, plan, expiry and nonce (surfaces/tokens.py). | X; tests/test_human_surface.py::test_forged_human_forms_do_not_approve | Verified |
| T4.3 | E | Approval outside the assigned scope, by an ADMIN, or against a changed plan | Server-side scope and role checks before and after lock waits; the continuation binds the plan hash; a changed plan invalidates the approval. | G, K, U; tests/test_execution.py::test_pending_authority_changes_invalidate_approval | Verified |
| T4.4 | I | Agent or provider strings executed in the browser | The console renders every string as text through React and never through a DOM sink: dangerouslySetInnerHTML, innerHTML and its relatives, eval, new Function and string timers are lint errors with a fixture apiece, and its policy is default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' with require-trusted-types-for 'script' and no policy registered, so a raw string into a sink is refused by the browser itself. The pages that stay server-rendered keep Jinja autoescape with strict undefined variables and the pages' own policy, which allows only the packaged stylesheet, the packaged fonts and the showcase's one refresh module. Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY and HSTS when secure (app.py, surfaces/console.py); reviewer comments are bounded, printable and refused when credential-shaped. | X, CC12; tests/test_browser_surfaces.py, tests/test_browser_console.py, tests/test_approval_detail.py, tests/test_browser_approval_detail.py | Verified |
| T4.5 | D | Login brute force | Sign-in attempts are limited per credential selector before any lookup and per direct peer address; refusals carry Retry-After. The password form and a provider's callback share that limiter under a key that is the SHA-256 of the normalized address, so no address is held in the limiter's memory in the clear and neither way in walks around the other's bound (surfaces/sign_in_pages.py). | tests/test_rate_limits.py::test_login_attempts_beyond_the_limit_are_refused_even_with_the_right_secret, tests/test_sign_in_surface.py::test_attempts_beyond_the_limit_are_refused_even_with_the_right_password | Verified |
| T4.6 | D | One unreadable row fails the whole tenant listing | A frozen plan naming a contract absent from the runtime catalog renders an escaped label instead of a 503. | #19, PR #69 | Partial, in review |
| T4.7 | E | A single approver suffices for a high-risk action | Bundle quorum rules require two to five distinct human approvers for an obligation, frozen on the approval at creation; distinctness is the approval_decisions primary key, one denial ends the approval, partial approvals expire with the window, and consumption verifies the exact count and every approver's current credential and scope inside the fence (execution/store.py). A quorum of one is unchanged behavior. | #43; tests/test_approval_quorum.py (QA1 to QA10, QA12) | Verified |
| T4.9 | I | A notification leaks a secret or an untrusted string, or its link decides an approval | Payloads carry identifiers, catalog labels, timestamps and the fixed approval page link only; an email's subject and body carry labels reduced to letters, digits, spaces and ._:/()-, so no header, markup or link syntax survives; destinations, recipient addresses and the relay come solely from the operator's private file, webhooks are HMAC-signed and email travels only over verified TLS; Slack and Teams URLs are secrets pinned to the vendors' hosts, never logged or rendered, and their blocks and cards are plain_text with the same reduced labels; no message carries an approve or deny control, the decision still happens on the authenticated page with the scope check, and delivery failure changes nothing (notifications.py, transports.py). | tests/test_approval_notifications.py (AN1 to AN12), tests/test_notification_email.py, tests/test_notification_chat.py, tests/test_notification_transports.py | Verified |
| T4.10 | I | A newly issued secret is stored, logged or shown again | The token exists in memory for one response and is rendered once under Cache-Control: no-store; the listing, the audit chain and the logs carry identifiers only (surfaces/admin_pages.py, surfaces/console_api/credentials.py). | CA1, CA10; tests/test_admin_pages.py, tests/test_console_api_access.py, tests/test_browser_console_directory.py | Verified |
| T4.11 | E | A person ends sessions that are not theirs, or a stolen session ends the owner's other sessions | The Settings page lists and Sign out other devices revokes only rows whose principal_id is the acting principal's, inside the tenant fence, with the current session rechecked in the same transaction and never revoked; an ADMIN reaches another person's sessions only through the People page (#241). Ending them asks for no password: the session is at most eight hours old and the form carries the CSRF token and a confirmation, which is the accepted re-authentication here, recorded as such. The device label is one of a fixed set of strings chosen from the User-Agent at sign-in; the header is never stored or rendered (surfaces/sessions.py, surfaces/settings_pages.py, surfaces/console_api/settings.py). | tests/test_settings_pages.py, tests/test_console_api_settings.py, tests/test_browser_console_tenant.py | Verified |
| T4.12 | I | The live board discloses more of a tenant than the reader's authority allows, or a request string reaches the page as markup | The board reads the rows Home reads, under the same approver-scope predicate written out in each statement, in one REPEATABLE READ snapshot on the session's own connection, with the identity and session rechecked before and after the read. It writes nothing and dispatches nothing. Agent and target lanes are named by a principal label or an eight-character prefix, never by a full identifier; lane ids are server-assigned integers, so no agent, provider or request string becomes a class, an id, a data- name or a URL. The monitoring band is drawn for an administrator only, because an observation carries no canonical target and therefore cannot be held to an approver's scope. live is the only query key the route accepts and any other is refused by the shared boundary guard, so nothing in a query string selects a tenant. The console's board streams that same value and draws it with token colours alone; the server-rendered copy that /showcase renders publicly has no inline style, no SMIL and no connect-src, and its one module reloads the page and fetches nothing. | #280; tests/test_board_read.py, tests/test_showcase.py, tests/test_console_api_board.py, tests/test_browser_board.py, tests/test_browser_console_board.py | Verified |
| T4.13 | T | An ADMIN's rule text, or a note on a rule, reaches the compiled policy or is rendered as markup | Conditions are a closed grammar over declared facts and arguments, typed by the catalog and refused with a location; the draft is re-validated by the Ruleset model on every round trip and becomes a bundle only through ruleset_bundle() and the store, whose recompile refuses a mismatch; notes are inert and reach the console as text (surfaces/console_api/policy.py, policy/rulesets.py). | RS7, RS8, RS9; tests/test_console_api_rules.py::test_the_form_refuses_a_condition_the_compiler_would_not_accept, tests/test_console_api_rules.py::test_a_change_writes_a_successor_draft_retires_the_old_one_and_audits_both_ends, tests/test_browser_console_policy.py::test_a_bundle_reads_its_rules_as_sentences_with_a_note_as_text, tests/test_rulesets.py::test_a_note_is_inert_and_reaches_no_compiled_component | Verified |
| T4.14 | T | A page's reading of the rules is taken for the kernel's decision, and the two differ: a fact the page lacks, a limit, a quorum or a person's answer | Every reading is labelled "as the rules read; the decision is the kernel's"; project() follows the compiled bundle's own precedence and is held equal to the kernel's policy decision over the whole input space of every pack and to the seeder's stated effect on every fixture host; a fact the page was not given leaves its rule unread and is named rather than guessed; limits, quorum and approvals stay in their own sections and are never folded into the reading; a hand-written bundle shows no reading at all (policy/rulesets.py, surfaces/onboarding_pages.py, surfaces/agents_pages.py). | RS10, RS11, RS12; tests/test_pack_rulesets.py::test_the_rules_read_over_every_input_as_the_kernel_decides_it, tests/test_rulesets.py::test_the_rules_read_over_every_fixture_host_as_the_seeder_states, tests/test_browser_console_policy.py::test_a_bundle_reads_its_rules_as_sentences_with_a_note_as_text | Verified |
| T4.15 | E | The console's document is served at an address the application does not have, or a page that is not the console takes the console's policy | The console does not answer from a catch-all: CONSOLE_ROUTES in surfaces/console.py names every address the application has, tests/test_console_serving.py holds that list equal to the application's own generated route tree, and an address outside it answers 404 rather than a shell that would render "not found" with a 200. The policy is chosen by matching that same list rather than by a path prefix, because the console and the pages that stay now share the address space: /approvals/{id} takes the console's policy with Trusted Types and /approvals/{id}/classic keeps the pages'. The document needs a session, is no-store, and refuses a bearer credential with 403 audited as execution.rejected; the build's own files answer under /console/ and are as public as a stylesheet. | #333; tests/test_console_serving.py, tests/test_route_authority.py | Verified |
| T4.16 | S | A redirect kept for an old bookmark sends a reader somewhere a request chose | Each moved address redirects to a constant path written in LEGACY_REDIRECTS; only the path parameters the old address carried are substituted, percent-encoded, and nothing in the request selects a host, a scheme or a target. The search the old address carried is kept as a query string and never read. A redirect reads no session and touches no row, so it discloses nothing and answers the same signed out as signed in. | #333; tests/test_console_serving.py::test_the_paths_that_moved_redirect_so_a_bookmark_still_arrives, tests/test_route_authority.py | Verified |
| T4.17 | T | The one console control that posts a form is forged, or its token is left where a script can read it | Signing out ends the gateway's own session, which is a navigation rather than a change to read back, so it is the same POST /session/logout the pages have always used: the session's token in the body, the exact Origin check, and form-action 'self' in both policies. The token is the one the session route returned, held in the client's memory alone — never in browser storage, the query cache or the URL — and the button does not exist until it has arrived. | #333; tests/test_human_surface.py::test_logout_invalidates_retained_cookie, tests/test_browser_surfaces.py::test_browser_logout_rejects_retained_session_cookies | Verified |
| T4.18 | D | A stranger mines the public signup page: enumerating which addresses already hold a sandbox, draining the slot pool, or flooding the queue from behind the proxy every visitor shares | Every submission answers one page with one status, for an allocation, a place on the waitlist and each of the eight refusals alike, so nothing on the page distinguishes them; refusals reach the structured log and the operator's own list, never the browser. The controls are counts taken inside signup_request against the snapshot the row is written in, not anything the browser is asked to prove: one request per address per thirty days, at most five live sandboxes per domain with free-mail domains exempted and held to their own lower daily cap, twenty an hour and a hundred a day overall, a vendored disposable-domain snapshot that is never fetched at runtime, a clipped honeypot field recorded as a refusal, and the slot pool as the hard ceiling. The page itself is CSRF-cookied like /login, refuses a query string, a bearer header, a wrong Origin or an extra field before the database, and keeps the per-peer window, which behind a proxy is a flood guard on the process rather than a limit on any one caller (surfaces/onboarding_pages.py, signups.py). No CAPTCHA and no proof-of-work: both need scripts these pages forbid. | tests/test_signups.py (SU5, SU5b, SU5c, SU5d, SU14c), tests/test_browser_accessibility.py | Verified |
| T4.19 | I | A prospect's email address escapes the operator's own list into an audit event, a log line, a page or a report | The address is written by a fenced function into signup_requests in the operator tenant and read back only by signups --list over the owner connection. Structured logs carry the request id, the domain and whether it is free mail, never the address; tenant.claimed, tenant.expired, tenant.extended and tenant.converted carry no address; the signup and confirmation pages render no submitted value at all; the funnel report is counts, domains and durations. Retention nulls the address thirty days after a refusal or an expiry and ninety after a claim, leaving the domain and the timestamps, and the message a prospect receives is addressed from the row rather than from anything the outbox holds (signups.py, logging_schema.py). | tests/test_signups.py (SU8, SU10, SU13) | Verified |
| T4.20 | S, D | Password guessing, credential stuffing or account enumeration at /login | A password is never stored: only a salted hashlib.scrypt hash at n=2^15, r=8, p=1, with the algorithm and all four parameters in their own columns beside it, so a row written under older parameters keeps verifying and raising them is a write. Attempts are refused per peer address and per address digest before any lookup. A wrong address and a wrong password answer 401 with one sentence, and cost the same: when the address belongs to nobody the hash runs anyway against a decoy record built at import under the current parameters. The private reason (UNKNOWN, MISMATCH, INACTIVE) reaches record_failure and the structured log alone, attributed to the person where there is one. Verification runs on a worker thread under a concurrency bound, so a flood queues rather than taking the process (passwords.py, surfaces/sign_in_pages.py). | Console sign-in; tests/test_passwords.py, tests/test_sign_in_store.py, tests/test_sign_in_surface.py, tests/test_browser_sign_in.py::test_a_wrong_password_and_an_unknown_address_say_the_same_thing | Verified |
| T4.21 | S | A forged, replayed or misdirected provider assertion signs the wrong person in | Each provider's endpoints are pinned in its own module and only the client id, the client secret and the redirect URI are configured; the redirect URI must equal this deployment's own origin plus that provider's callback path or the server refuses to start. Every flow carries PKCE S256, a random state and a random nonce in one __Host-, HttpOnly, SameSite=Lax cookie deleted on first use, so a replayed callback has nothing to match and a state mismatch is refused. Google's ID token is verified against Google's JWKS with alg settled before any key is fetched, one refetch on an unknown kid and then a refusal, and iss, aud, azp, exp, iat and nonce all checked; email_verified must be true. GitHub has no assertion to verify, so the account comes from two authenticated calls and the only address that may find a person is the one GitHub marks both primary and verified; the access token is user:email only and is discarded after the lookup. An account matching no person is refused and creates nothing, the subject is bound once and identifies the person afterwards, and a subject already bound to somebody else is refused rather than moved (oidc.py, github.py, signin.py, migration 0040_sign_in). | #177; tests/test_oidc.py, tests/test_github_sign_in.py, tests/test_sign_in_surface.py, tests/test_sign_in_store.py::test_a_subject_bound_to_one_person_is_never_moved_to_another | Verified |
| T4.22 | E, I | A set-password code reaches the wrong person, survives its use, or leaves the previous holder signed in | A code is ebp1.<tenant>.<reset>.<secret>, stored only as its SHA-256, single use, expiring between an hour and fourteen days, revoked by five wrong secrets and by the minting of the next one, and never present in a URL: what is handed over is the address to open and the code to type. An expired, redeemed, revoked, superseded and unknown code all fail identically. Redeeming revokes every live credential and unrevoked session of that person in the same statement sequence and returns their identifiers so the caller audits each one in the same transaction. The ebp1. prefix is part of the shared secret definition, so a rendered page or an export carrying a code fails the guard (migrations/versions/0040_sign_in.py, passwords.py, secrets.py). | #265; tests/test_sign_in_store.py, tests/test_secrets.py, tests/test_browser_sign_in.py::test_a_code_works_once_and_the_second_person_to_try_it_is_refused | Verified |
| T4.23 | I | Resolving an address before a tenant is known reads more than that one address | The login form takes an address and no tenant, so the read happens outside the fence — through two single-row RLS policies rather than a function with table-wide authority. A SELECT on principals is permitted only for the row whose own email equals execbound.sign_in_email, and on principal_identities only for the row whose own provider and subject equal execbound.sign_in_subject. Setting a marker therefore answers about the address or the account the caller already typed and about nothing else: no listing, no enumeration, no other tenant's row. Everything after the resolution is an ordinary fenced read (migrations/versions/0040_sign_in.py, passwords.py). | tests/test_sign_in_store.py::test_the_marker_reveals_one_row_and_never_a_listing, tests/test_sign_in_store.py::test_the_subject_marker_reveals_one_row_and_never_a_listing | Verified |
| T4.24 | T | A form submission that starts a provider flow is forged, or the redirect it follows is blocked or pointed elsewhere | Starting a flow is a POST with the login CSRF cookie compared with the field and the exact Origin check, like every other mutation, rather than a bare link. form-action governs the redirect a form submission follows, so the pages' policy names each configured provider's authorization host — pinned in that provider's module, present only because a block named the provider, and absent entirely from a deployment with none. Nothing a request sends adds a host (surfaces/console.py, app.py). | tests/test_sign_in_surface.py::test_a_configured_provider_is_named_in_form_action_and_nothing_else_is, tests/test_browser_sign_in.py::test_a_configured_provider_signs_a_matched_person_in | Verified |
| T4.25 | E | A person sets the session's current space to one they do not belong to, or a stale column grants a read after they were removed | session_set_space is a SECURITY DEFINER function rather than a runtime UPDATE grant, and it verifies the space against space_members (or tenant administration) in the database, so "a session's current space is one of the person's spaces" is a database invariant. It is belt and braces: every read recomputes the visible set inside the fence and narrows the current space to it, so a person removed between two page loads falls back to "All spaces" at once and a forged or stale column grants nothing (migrations/versions/0041_spaces.py, spaces.py). | SP4; tests/test_spaces.py, tests/test_console_api_spaces.py | Verified |
| T4.26 | I | A record in another space is opened by its identifier | Membership is the authority and the current space is only a view: a record in a space the person does not belong to is 404 whatever the session holds, and one in another of their own spaces is answered and names its space so the shell can follow it. There is no ?space= anywhere, so editing a URL cannot ask for another space's rows and a shared link is read in the reader's own space (spaces.Visibility.allows, surfaces/agents_pages.py, surfaces/space_pages.py). | SP1, SP11; tests/test_console_api_spaces.py | Verified |
| T4.27 | T | A decision lands while its space is being archived | The archive takes the tenant fence before it calls the definer function, and decide_approval rechecks the space inside the kernel's own transaction under that same fence, so the two are totally ordered: whichever takes the fence first commits and the other is refused SPACE_ARCHIVED. Exactly one of the two commits, under two connections on real PostgreSQL (surfaces/space_pages.py, execution/approvals.py). | SP9; tests/test_spaces_kernel.py | Verified |
B5: Owner tooling, ADMIN and policy
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T5.1 | E | An agent or approver creates, edits or activates a bundle | ADMIN-only lifecycle inside the tenant fence, audited; content is immutable and digest-bound (policy/store.py). | tests/test_policy_store.py | Verified |
| T5.2 | T | Malformed policy, schema mismatch or engine error changes a decision | Structured parsing and validation fail closed; both policy sets are evaluated; any error denies. | N, CC1, CC7; tests/test_cedar.py, tests/test_context_bundle_validation.py | Verified |
| T5.3 | T | A writer allowlist admits a label the agent can write | Documented misconfiguration; scenario T protection depends on ADMIN naming only trusted writer labels. | CC6; POLICY.md | Accepted, see R5 |
| T5.4 | T | Wrong trusted provisioning (inventory, canonical mapping, facts, incidents) | Outside the agent input boundary. Facts and incidents enter only through the owner-connection importer, which validates every value against the contract catalog, accepts only a writer label the owner-provisioned source allows, refuses observations that go backwards and reused versions, never creates registry rows, and audits each import with the manifest digest (provisioning.py); canonical mapping stays owner SQL. | CP1, CP4 to CP9, CP11; tests/test_context_provisioning.py | Verified for facts and incidents; Accepted for canonical mapping, see R10 |
| T5.5 | E | A Cedar binding change silently alters decisions | Pinned cedarpy; the adapter rejects incomplete diagnostics; a guard test fails with a review message if the private entry points change. | #38, PR #68 | Partial, in review, see R6 |
| T5.6 | R | Administrative actions without attribution | Every lifecycle change, reconciliation and acknowledgement is audited with current authority rechecked. | tests/test_audit.py, replay export tests | Verified |
| T5.7 | E | Agents keep acting during an incident because nothing halts one tenant | ADMIN emergency stop on the tenant control row, read inside the fence by every writer: new admission, approval decisions and dispatch attempts refuse with TENANT_STOPPED while settlement continues; activation and release are audited with reasons and replay flags decisions inside a stop period (execution/stops.py). | tests/test_emergency_stop.py (ES1 to ES12) | Verified |
| T5.8 | E | An administrator weakens a tenant to observe-only, or an observed result is mistaken for authorization | The mode lives on the forced-RLS control row and changes only through a current ADMIN inside the fence with a required reason, audited as tenant.mode_changed; an observed request never receives a ticket, approval, reservation or claim, ends in the terminal OBSERVED state whose observed object is a record and not authority, never counts toward an enforced budget, and replay reports an observed execution with any effect as OBSERVED_WITH_EFFECT (execution/modes.py, execution/admission.py). Switching to OBSERVE is a trusted ADMIN capability accepted for onboarding. | #51; tests/test_observe_mode.py (OB1 to OB12) | Verified |
| T5.9 | T | A compromised or faulty adapter feeds false context | The adapter holds no database authority; the importer refuses values outside the catalog domains, sources of another tenant or kind, unknown writers, future and regressing observations and relinked incidents, and withdrawal of evidence (retraction, deactivation) can only deny. A wrong but well-formed value remains the operator's provisioning responsibility. | CP4, CP5, CP6, CP8, CP10 | Partial, see R10 |
| T5.10 | E | A web bug or a captured ADMIN session mints administrators or widens the registry | The gateway holds no table write privilege on principals, credentials or approval scopes; the five SECURITY DEFINER functions of migration 0021 read the tenant from the fence setting, verify the acting ADMIN inside the database, refuse ADMIN kinds, principals and credentials, bound lifetimes and clock skew, and cannot touch tenants, accounts, mappings or canonical targets; the pages recheck session and role before and after each call and audit every change with the acting credential; check_runtime asserts the exact function catalog, definer mode, ownership and privilege (db.py, surfaces/admin_pages.py). | CA1 to CA9; tests/test_admin_pages.py | Verified |
| T5.11 | E | An agent, approver or the runtime role widens a limit scope by creating or joining a group, or a bundle names a group, account or fact the tenant does not have | Groups and memberships are owner-provisioned authority data like approval scopes: forced RLS, runtime SELECT only, written only by group-create, group-assign and group-unassign over the owner connection under an ADMIN --actor, active AGENT principals only, at most 64 memberships each, audited as group.created, group.assigned and group.revoked; the store refuses a rule naming an unknown action, group, inactive account or unserved fact with its field location, at validation and at activation. Admission does not re-run those checks: it reads the active bundle through execution.store.active_bundle, which revalidates no component, so a group removed after activation is not refused there. What bounds that is the write path rather than a recheck — no command deletes a group, the runtime holds SELECT only on both tables, and the membership foreign key has no cascade, so removal takes the owner connection — and store.memberships reads the principal's groups inside the fence at every decision, so a rule's counting follows current provisioning rather than a stale bundle claim (administration.py, policy/store.py, execution/store.py, migration 0022_limit_scopes). See POLICY.md. | SL7, SL10, SL11; tests/test_admin_cli.py::test_group_lifecycle, tests/test_limit_scopes_schema.py::test_runtime_cannot_write_group_tables, tests/test_policy_store.py::test_scoped_rules_fail_validation_with_locations | Partial |
| T5.12 | E | A departed or compromised person keeps a credential or a session; a console user deprovisions an administrator or themselves | Deactivation is one definer function under the 0021 actor check that sets the principal inactive and revokes every live credential and unrevoked session in the same statement sequence, and the page appends the events in the same transaction, so there is no state in which the person is inactive but a credential or session still works; the function refuses an ADMIN target and the actor, so the console still cannot create, change or remove an administrator, and nothing is deleted (migrations/versions/0036_people.py, surfaces/people_pages.py). | tests/test_people_pages.py, tests/test_console_api_people.py, tests/test_browser_console_directory.py | Verified |
| T5.13 | S | A wrong or forged link attributes a principal's executions to an agent it is not, or the runtime role moves the link | A binding is written only by admin_bind_agent and admin_unbind_agent under the 0021 actor check, joining an active AGENT principal to an agent this tenant's connection still lists, one to one by partial unique indexes; the runtime role can write neither the table nor monitor_agents.linked_principal_id; the page and link-agent append agent.bound and agent.unbound in the same transaction; unbinding takes a reason and withdraws the ExecBound reach rows at once; nothing infers a binding from a name (migrations/versions/0037_agent_bindings.py, bindings.py). | tests/test_agents_bindings.py, tests/test_console_api_agents.py, tests/test_browser_console_directory.py | Verified |
| T5.14 | E | A sandbox outlives the fourteen days it was granted, or someone who is not an administrator of it extends, revives or converts it | The end is a property of the row, kept by a trigger at the claim plus fourteen days for every writer, and moved only by extended_until, deactivated_at and converted_at. sandbox_expire refuses a slot that is unclaimed, already deactivated, converted or not yet due, so a slow or repeated cron writes one deactivation and one tenant.expired and no more; sandbox_notice refuses a second notice. sandbox-extend and sandbox-convert run over the owner connection, take at most thirty days a call, refuse a sandbox deactivated more than ninety days ago, require an active ADMIN principal of that tenant as the actor, and append tenant.extended or tenant.converted to that tenant's own chain in the same transaction. Deactivation is an inactive tenant, so sessions, credentials and agent requests fail at their next check as for any inactive tenant (migrations/versions/0039_signups.py, signups.py). | tests/test_signups.py (SU7, SU7b, SU7c, SU7d) | Verified |
| T5.15 | E | A console administrator gives themselves or somebody else an administrator's way in | admin_set_email and admin_reset_password carry the 0021 actor check and refuse an ADMIN target, so an administrator's sign-in address and set-password code stay with owner tooling exactly as their credentials do; an address another person holds anywhere is refused by a global unique index; the runtime role holds no write on principals.email, principal_passwords, password_resets or principal_identities, and check_runtime asserts the whole function catalog and the absence of those grants. password_replace refuses a person who holds no password, so the change route can never become a way to set one, and it takes the principal from the session rather than from a body (migrations/versions/0040_sign_in.py, surfaces/people_pages.py, surfaces/console_api/settings.py). | tests/test_sign_in_store.py, tests/test_console_api_people.py::test_the_console_refuses_an_administrator_an_address_or_a_code, tests/test_console_api_settings.py::test_a_person_with_no_password_cannot_set_one_through_the_change_route | Verified |
| T5.16 | E | A space membership raises a role, or the tenant is left with nobody who can administer spaces | The space role narrows and only narrows: a person's authority is min(principals.kind, space role), space_assign refuses the ADMIN role for a HUMAN_APPROVER principal, and principals.kind stays a necessary condition wherever it is checked today, so no area module changes its authority. A tenant administrator is an active ADMIN principal with no membership, and the assignment that would leave a tenant with none is refused LAST_TENANT_ADMIN under a lock on the tenant's ADMIN rows, the way revoke_executor_key refuses a tenant's last live key (migrations/versions/0041_spaces.py). | SP3; tests/test_spaces.py, tests/test_admin_cli.py | Verified |
| T5.17 | R | A space and the history it owned are removed | There is no delete path: spaces archive, memberships are removed but the rows they governed keep their space, and the runtime holds no DELETE or TRUNCATE on either new table — SELECT and nothing else on spaces and space_members, and INSERT plus three update columns on space_policy_selection alone. An archived space keeps every row it owned in every view that could see it, and archiving the tenant's last unarchived space is refused (migrations/versions/0041_spaces.py, db.py). | SP9, SP10; tests/test_spaces_schema.py | Verified |
B6: Monitoring sources and notification receivers
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T6.1 | S | Forged or replayed story events | Source tokens bound to one tenant, source and story; agent and ADMIN tokens are rejected at intake; duplicate receipts are idempotent. | tests/test_monitoring_http.py, tests/test_monitoring_store.py; live trial in TINES_MONITORING.md | Verified |
| T6.2 | E | An observation grants execution authority or settles a provider effect | Separate monitoring domain: no execution mutation, approval, provider call or impact change. | tests/test_monitoring_store.py, tests/test_monitoring_worker.py | Verified |
| T6.3 | D | Intake flood | Per-source rate limit of 120 attempts per minute, intake concurrency of 8, and bounded sources and observations per tenant (monitoring/http.py, monitoring/store.py). | tests/test_monitoring_http.py | Verified |
| T6.4 | S | A notification destination chosen by untrusted content (server-side request forgery) | Only a loopback development receiver exists; operator-configured, validated destinations are not built. | #31 | Open |
| T6.5 | R | Source token rotation or revocation | ADMIN-only rotation with a bounded grace window for the previous token and final revocation, both inside the monitoring registry lock with current authority rechecked and the acting administrator recorded on the source; ingestion accepts the previous token only until its deadline (monitoring/store.py). | tests/test_monitoring_lifecycle.py (SL1 to SL10) | Verified |
| T6.6 | D | Cooperative visibility mistaken for enforcement; total outage cannot self-alert | Documentation states that monitoring cannot prevent execution and that a heartbeat proves reporting freshness only; an independent uptime check is required. | #29, #31 | Accepted, see R7 |
| T6.7 | R | Missing telemetry, a silent source or an implausible external clock goes unnoticed | Receipt-time health is persisted per source and each new gap records a delivered source-health.v1 finding; a reported time more than five minutes ahead of receipt or a day behind it records a clock-skew.v1 finding while the reported time stays untouched (monitoring/store.py). | tests/test_monitoring_health.py (SH1 to SH10) | Verified, see R7 |
| T6.8 | T | A configured detection silently stops applying, or a profile change hides why a finding exists | Profiles are immutable versions written only by a current ADMIN under the registry lock; every finding records the version that produced it; missing configuration is stated as inactive coverage on the page, never treated as nothing to report (monitoring/store.py). | tests/test_monitoring_rules.py (RS1 to RS13) | Verified |
| T6.9 | I | A vendor management key in the gateway database, or in a log or page, widens a database or backup exposure into platform access | The collection connection retains only the key's SHA-256 fingerprint; the key stays in an operator file read without interpolation, is compared to the fingerprint before any request, and never enters the database, logs, pages or audit chain (monitoring/collection.py, monitoring/store.py). | TD1, TD2, TD12; tests/test_tines_collection.py | Verified |
| T6.10 | T | Collection mutates the connected platform, follows a redirect or a proxy to another destination, or is used as a general request tool | The reader issues GET only to the connection's pinned .tines.com/.tines.io origin with redirects and ambient proxies disabled, no retry inside a run, a 4 MiB body cap and a 10 second timeout; the paths are module constants and no response field selects a destination (monitoring/collection.py). | TD1, TD6; tests/test_tines_collection.py, tests/test_repository_rules.py | Verified |
| T6.11 | E | One credential is both the collector and the executor, so a read-only collection secret can disable an account | Collection uses its own app registration, consented to AgentIdentity.Read.All, Application.Read.All and Directory.Read.All and nothing that can write; the registration that executes is a different application with a different secret in a different private file. The two share the directory and nothing else (monitoring/graph.py, SCOUT.md). | SR1, SR13; tests/test_scout_run.py | Verified against fixtures; the live probe is separately authorized |
| T6.12 | T | A directory response redirects the collector to a destination or a collection it did not ask for | Exactly one POST per run, to the pinned login origin, for a token; every read is a GET to the pinned Graph origin with redirects and ambient proxies disabled. Graph returns the next page as a URL inside the response, and it is followed only when it is the pinned origin and the same path; four shapes of redirected continuation are refused (monitoring/graph.py). | SR13; tests/test_scout_collection.py | Verified |
| T6.13 | I | A directory read carries a person's name, address or a credential into the gateway | Every parser is an allowlist that takes named fields rather than removing known ones, because agentIdentity is an open type; a sponsor keeps an object identifier and a kind and nothing else; a blueprint records which kinds of credential it holds, never a value or a hint; drops are counted (monitoring/graph.py). | SR2, SR3; tests/test_scout_collection.py, tests/test_scout_run.py | Verified |
| T6.14 | R | Inferred reach is mistaken for proof, or a governed route is mistaken for a closed one | A certainty is only what its own path establishes and reach is stored per path, so a grant never claims an enforced mode and a governed path is an additional row; an unexplained grant produces no row at all and is counted; a lapsed claim reads as unknown with the moment it lapsed; ungoverned-reach.v1 closes on the grant disappearing or an ADMIN acceptance, never on a mapping being registered (monitoring/reach.py, monitoring/store.py). | SR4 to SR8, SR11, SR14; tests/test_scout_reach.py, tests/test_scout_rules.py, tests/test_console_api_monitoring.py | Verified |
| T6.15 | I | Collected responses carry credential values, action options, audit inputs and outputs, model text, email addresses or request addresses into storage or a page | Field allowlists run in the parser before anything is retained; options, values, inputs, outputs, prompts, completions, names, email addresses, addresses and user agents are dropped, and a record the allowlist cannot normalize is counted, never stored raw or logged (monitoring/collection.py). | TD2, TD10, TD12; tests/test_tines_collection.py, tests/test_browser_console_monitor.py | Verified |
| T6.16 | T | A crash, a page boundary, a rate limit or a retention gap silently loses or duplicates collected evidence, or a response moves rows between tenants | A page, its pending evaluations and the cursor advance commit in one transaction and the cursor never passes an unaccepted record; a five-minute overlap with (tenant, connection, event_id) uniqueness makes a re-read free of duplicates and a reused identifier with different content records an integrity finding; rate limits, lost permission, retention gaps and backlogs mark health and record collection-health.v1 per episode instead of failing quietly; the first collected record pins the platform tenant and a differing one is quarantined (monitoring/store.py). | TD3 to TD7, TD11; tests/test_tines_collection.py | Verified |
| T6.17 | R | Discovered activity is mistaken for enforcement, or an unrecognized action type or operation name silently clears a story or a change | Collected observations are labelled collected and cannot admit, approve, settle, reserve or refund; an action type outside both known sets is coverage_unknown rather than "no outbound", an operation name outside the versioned mapping evaluates INSUFFICIENT_EVIDENCE, an unmapped AI tool name stays insufficient evidence, and every page states that an ordinary action's effect is not an audit record and that capabilities are fixture-verified until the live trial. | TD8 to TD10, TD12; tests/test_tines_collection.py | Verified |
| T6.18 | S | A chat webhook URL or an SMTP password is exposed, or a delivered notification carries authority | Destinations come from deployment configuration and never from a tenant field. A slack or teams destination's URL is itself the secret: it is accepted only for a pinned host, held with repr=False, and rendered as kind and host and never as the URL; an email destination carries neither a URL nor a secret. A payload carries identifiers, catalog labels, timestamps and the fixed approval page link, so no message carries a control that decides anything. | tests/test_notification_chat.py::test_chat_urls_outside_the_rules_refuse_without_quoting_them, tests/test_notification_chat.py::test_the_tenant_overview_shows_kind_and_host_never_the_url, tests/test_notification_chat.py::test_a_chat_destination_carries_neither_a_secret_nor_a_recipient, tests/test_approval_notifications.py::test_an10_invalid_destinations_are_refused_at_startup_without_leaking_the_secret | Verified |
| T6.19 | I | A secret-bearing message reaches a shared channel, or a resent code collapses into the message whose code no longer works | The invitations class is email only, one recipient, and is never rendered for a chat destination; it is the only message kind that carries a secret. The code is minted at send, exists in memory and in that one message, and only its SHA-256 is stored; each attempt takes a fresh Message-ID so a resend cannot collapse into an earlier message, and a crash during the send revokes the invitation rather than leaving a live code nobody received. | tests/test_invitation_email.py::test_i6_the_rendered_invitation_carries_the_code_once_and_never_collapses, tests/test_invitation_email.py::test_i4_a_crash_during_the_send_revokes_the_invitation | Verified |
B7: Live system to replay and export
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T7.1 | E | Replay executes a provider or mutates live state | File-only entry point; subprocess tripwires forbid database, provider, socket and dotenv access. | J; tests/test_replay_isolation.py | Verified |
| T7.2 | I | An export leaks secrets or another tenant's history | Current ADMIN authority rechecked before publication; secret-bearing input rejected against the one definition the log guard and the test helper also read (secrets.py); tenant-scoped reads. | S; tests/test_replay_export.py::test_foreign_tenant_bundle_id_collision_cannot_enter_export, tests/test_secrets.py | Verified |
| T7.3 | T | Tampered history, head or bundle content | Chain, retained head, bundle hashes and plan and approval bindings are verified offline. | tests/test_replay_verify.py | Verified |
| T7.4 | I | Logs or an event export leak a secret or an untrusted string, or the export skips or reorders history | Logs are schema-only with a closed event catalog and a suppression filter for private text; the export sends the replay export's sanitized representation after its secret check, in sequence order, advancing a per-destination cursor only on acknowledgement, and never writes the chain (logging_schema.py, export.py, secrets.py). | tests/test_structured_logging.py, tests/test_event_export.py (SE1 to SE13), tests/test_secrets.py | Verified |
| T7.5 | T | A one-approver consumption is presented as satisfying a two-approver requirement | Approval captures carry required_approvers and every counted approver; a VERIFIED capture with fewer distinct approvers than required, a repeated principal or a denial is INSUFFICIENT_QUORUM and makes the history incomplete; candidate comparison never assumes recorded approvers satisfy a changed requirement (replay/verify.py, replay/simulate.py). | tests/test_approval_quorum.py::test_qa11_replay_captures_every_approver_and_refuses_a_short_quorum | Verified |
B8: Build, dependencies and CI
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T8.1 | T | Vulnerable or substituted dependency | Locked, hash-pinned environments (uv sync --locked; the maintained MCP client is hash-locked separately); the pip-audit vulnerability audit runs weekly and on demand in the scheduled security-scans workflow, which retains its finding population for 90 days, and locally on demand; it is not a merge check, and Dependabot security updates are off. | #46, PR #67 | Partial, in review |
| T8.2 | I | A credential committed to the repository | Environment and key files are ignored; gitleaks scans the whole history weekly and on demand in the scheduled security-scans workflow, and with a local binary on demand. It is not a merge check, and a scan that is skipped is recorded as a coverage gap rather than a clean result. | #46, PR #67 | Partial, in review |
| T8.3 | T | Evidence not tied to the tested source | The CI manifest binds artifacts and collected test nodes to the exact commit and lock hashes. | VALIDATION.md | Verified |
| T8.4 | T | Parsing or canonicalization bugs under unexpected input | Example-based tests only; property-based and fuzz tests are planned. | #49 | Open |
| T8.5 | I | Static analysis limited to open-source tooling | Bandit runs weekly and on demand in the scheduled security-scans workflow, with its findings retained, and locally on demand; it is not a merge check. CodeQL and native secret scanning need GitHub Advanced Security on this private repository, and no remediation SLA or license review is adopted yet. | #46 | Accepted |
| T8.6 | T | The runtime image drifts from the locked environment, runs as root or carries private files | Built only from uv.lock with base images pinned by digest; non-root uid 10001, whose login shell and empty ~/.ssh serve only Render's operator sessions (opened by a key on the Render account; the image runs no SSH server); allowlist build context; CI builds and smoke-tests every image, then retains a CycloneDX SBOM of the image's own dependency set and a provenance document binding the revision, the locked input digests, both pinned base images and the built image id, with signing and deployment recorded as explicit gaps. | .github/workflows/ci.yml; DEPLOYMENT.md | Verified |
| T8.7 | T | A vulnerable package in the image's base layer goes unnoticed because only Python dependencies are audited | The scheduled scan builds the runtime image, exports it and scans the tarball with a digest-pinned Trivy container (no Docker socket is exposed), covering operating-system packages and every Python distribution on the filesystem, not only the application environment the SBOM describes; the whole population is retained for 90 days and a HIGH or CRITICAL advisory with a published fix fails the run, with the policy in scripts/scan_summary.py rather than a severity flag. It is not a merge check. A finding that cannot be fixed here is accepted in writing in security/acknowledged-advisories.toml with a reason and a review_by date capped at 180 days, after which the run fails as expired; an unreadable or incomplete record suppresses nothing, and the committed file is validated by the test suite rather than only by the weekly scan. | .github/workflows/security-scans.yml; tests/test_scan_summary.py | Verified |
| T8.8 | T | A forbidden rendering, transport or TLS flag enters the tree without a reviewer noticing | tests/test_repository_rules.py is a table of patterns the tree may never contain, each with the invariant it protects and where that invariant is written down: a connection string rendered with its password, a followed redirect, a transport reading the environment, an echoed SQL parameter, unverified TLS, a bandit marker that does not name its code and its reason, a traced shell step in a workflow, and a hand-written secret assertion. It runs in the suite, so it is a merge check rather than a weekly scan, and bandit has no rule of this shape. A file is allowed only by name and reason in the table, never by an inline comment, and an allowance that no longer matches fails, so an exception disappears the day it is unnecessary. | tests/test_repository_rules.py; DEVELOPMENT.md | Verified |
B9: Gate caller to gateway
The external execution checkpoint puts the last mile on the customer's credential, so this boundary has more Accepted rows than any other. Each says which of the guide's two tables it comes from.
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T9.1 | E | A caller selects Gate for itself, names an executor, declares a precondition class or claims a tier | All four are owner-provisioned bundle content or owner-registered deployment configuration; external_executor, precondition_class and expected_version are refused on the wire as unknown fields, the class is keyed by the provider binding the frozen plan names, and the tier is derived from what ExecBound could check rather than from anything the report claims (policy/models.py, execution/admission.py, execution/outcomes.py). | GC15; tests/test_checkpoint.py | Verified |
| T9.2 | E | A grant is used as authority to execute, approve, dispatch or reconcile through ExecBound | Admission returns no dispatch ticket for a Gate mapping and the runtime's checkpoint branch never reaches the connector table; take_dispatch refuses a row carrying a report window as well as one already stamped; the grant's only power is to settle the one execution it names. The MCP tool description states that a grant is neither an execution nor an approval. | GC1, GC18; tests/test_checkpoint.py, tests/test_checkpoint_workflow.py, tests/test_mcp_surface.py | Verified |
| T9.3 | S | A forged, replayed or cross-execution report settles a grant | One report secret per execution, returned once and retained only as a SHA-256 digest in owner_hash, compared in constant time; another principal's execution answers NOT_FOUND; a settled row accepts only a restatement of its own tier, outcome and reason, and every other report against one is refused rather than discarded (execution/outcomes.py). | GC6, GC9, GC11; tests/test_checkpoint.py | Verified |
| T9.4 | S | A forged attestation, or a registered key nobody holds the private half of | Ed25519 public keys only, so no secret at rest can forge the attested tier; the ten small-order points and every non-canonical encoding are refused at registration and again when the ring is built, because such a key makes one fixed signature verify every message; the signed envelope carries its own domain and version and binds the key id, so it can never be relabelled or be the same bytes as a provider journal's (provider/executor.py, administration.py). | tests/test_checkpoint_evidence.py, tests/test_admin_cli.py | Verified |
| T9.5 | E | A stolen executor key settles grants it was holding, refunding reservations and freeing targets | A retirement is deliberately backward-permissive, so a separate act records a compromise, which the verifier reads retroactively and which reaches every id of the tenant carrying that public key; it is asked inside the settlement's own fenced transaction, so a cut-off committing while a record is being verified still refuses; recording it may empty the ring, and admission then refuses new grants for that tenant (administration.py, provider/executor.py, migration 0026_key_compromise). | tests/test_checkpoint_evidence.py, tests/test_checkpoint.py, tests/test_admin_cli.py | Verified, see R13 |
| T9.6 | T | An executor frees its own target or refunds its own budget by what it says | A reservation and a canonical claim are committed before the grant is issued; the claim is released exactly when a verifier returned the evidence, so no assertion releases a claim or refunds a reservation. What an assertion may settle is narrower than a reader of the earlier wording would take: an asserted EXECUTED settles and confirms its impact, an asserted FAILED_EFFECT_UNKNOWN with reason REPORTED_FAILURE settles terminally and confirms its impact too — so a stated failure costs exactly what a stated success costs, and there is nothing to gain by choosing between them — while an asserted FAILED_NOT_EXECUTED, the one outcome that claims the vendor was never reached, and an unknown result settle nothing at all, which is the refund on absent evidence the contract forbids (execution/outcomes.py). One consequence belongs in this row rather than in the key-compromise row above: revocation as COMPROMISED no longer freezes such a grant. The verifier is loaded only for a report presenting a signed record, so the executor's or the control point's bare word still settles the row, confirms its impact and puts it in front of the recovery run, which frees the target at the act window; what the revocation takes away for good is the tenant's ability to answer that word, because the correction needs a key usable at the grant instant. | GC7, GC8, AL12, AL13, AL18, AL33; tests/test_checkpoint.py::test_no_route_to_an_asserted_execution_releases_the_claim, tests/test_arcade.py | Verified |
| T9.7 | T | A report settles an operation ExecBound dispatched, or a provider journal settles a grant | The report route re-reads the owner-provisioned bundle the execution was frozen under, inside the fence, and refuses any execution whose mapping does not declare an external executor, before the secret is compared; the connector paths refuse a row carrying a report window; and each side refuses the other's kind of verifier, so no settlement rests on the wrong kind of evidence for the row it names (execution/outcomes.py, execution/reconciliation.py, provider/gateway.py). | GC9, GC10; tests/test_checkpoint.py | Verified |
| T9.8 | D | An executor takes grants and never reports, or spams refused reports | Outstanding grants are capped at eight per principal inside the fence and a refused attempt rolls back entirely; the report route shares the runtime's capacity bound, so refusals cannot serialize a tenant's writers; an unreported grant is swept into retained uncertainty and shown in the uncertain queue. The budget it holds until then is the point: a grant charges its scopes exactly as a dispatch does. | GC16; tests/test_checkpoint.py | Verified, see R14 |
| T9.9 | R | An executor acts and never asks, ignores a denial, or acts twice on one grant | Nothing prevents any of the three, and the guide says so in the same table. ExecBound has no record of the first at all; the second leaves a denial in the history with nothing charged; the third is invisible and is charged once. Only monitoring coverage of that path can notice an action with no matching checkpoint, and it is classified as missing evidence, never as a proven bypass. | CHECKPOINT.md §5 | Accepted, see R7 |
| T9.10 | T | The vendor's target changes between the grant and the executor's action | The frozen expected version travels with the grant and is only as strong as the vendor's conditional write. Under ASSERTED_BY_EXECUTOR the executor can only compare and refuse, and under UNAVAILABLE a concurrent change is undetectable; the class the grant carried is the only record that the question was unanswerable. Pair an UNAVAILABLE binding with an approval obligation. | GC12; tests/test_checkpoint.py | Accepted |
| T9.11 | S | An operator declares a precondition class the vendor does not offer | Nothing checks it, and nothing can: the class is a statement about a vendor's API. A declaration naming a binding the running catalog does not bind is refused at load, and an undeclared binding answers UNAVAILABLE, so the failure mode is an operator's own overclaim rather than a silent default. | SURFACES.md | Accepted, see R15 |
| T9.12 | E | A compromised Gate caller's credential also dispatches through ExecBound | A Gate caller holds a vendor credential and is a different trust class from a protected agent, which holds none, so a principal should serve Gate mappings or Execute mappings and not both. Nothing in the code enforces that separation today; admission refuses a Gate mapping whose account already has a connector, which keeps one operation from being both, and says nothing about a principal that has one mapping of each kind. | CHECKPOINT.md | Open, see R16 |
| T9.13 | R | An administrator settles a grant its executor is still authorized to act on | Deliberately admitted: the evidence decides and not the actor, so an executor-signed record presented by an administrator is the record the outcome route would accept. It closes ExecBound's route on that executor and does not stop it acting at the vendor, and the console says so where the administrator is about to act. | RECOVERY.md; tests/test_checkpoint.py | Accepted |
B10: Control point to hook surface
The Arcade logic extension makes ExecBound the decision service behind a platform's hooks, so this boundary is about a caller that calls on every tool invocation and about the correlation table that stands behind it. Every row here is fixture-verified; nothing has contacted Arcade.
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T10.1 | E | A hook field selects the mapping, the action, the target, the incident or the tier | The tool map is deployment configuration in the file that already carries connectors and precondition classes, and it is part of the configuration shape a key reload may not change; the exact toolkit, name and version pick one declaration and nothing else does; the target and the incident are each one declared input key taken by name with no expression language, path syntax or fallback; the tier is derived from what ExecBound could check. Reported metadata may only refuse (arcade/map.py). | AL8, AL9, AL10, AL24, AL34 | Verified |
| T10.2 | T | An input the map does not model changes what the vendor does behind a decision made without it | inputs is an allowlist and any other key is refused before a kernel call, so the residual is a brittle tool rather than a smuggled field: a vendor adding an optional input breaks every governed call of that tool until an operator declares it. | AL9 | Verified, see R18 |
| T10.3 | E | The extension permits something the kernel did not | It is a translator: it builds the wire bodies ActionRequest and OutcomeReport already define and calls Runtime.execute and Runtime.report, opens no transaction of its own around a decision and has no other path into the kernel; everything it decides by itself can only deny, and a tenant emergency stop is refused ahead of both of its OK answers (arcade/routes.py). | AL1, AL5, AL6, AL22 | Verified |
| T10.4 | S | A duplicate or racing delivery gets a second OK, or a lost one strands a grant the platform is told to act on | The OK rule is keyed on what the kernel returned and not on what the extension recorded: a DISPATCH_COMMITTED answer is OK only while this Arcade execution's own correlation row holds that grant's report secret. The kernel mints a grant's secret exactly once and a re-grant carries none, so the concurrent second Arcade id, the abandoned-deadline row and the crashed row are all refused with no special case. | AL2, AL3, AL4 | Verified |
| T10.5 | I | A reader of the correlation table settles a tenant's row or frees its target | Deliberately admitted and stated as the bound rather than claimed against: the secret is usable only inside the report window, it is nulled at settlement and cleared opportunistically afterwards, and a reader that presents it closes the row and lets the scheduled run free the target once the act window has passed. What no reader of it can do is reduce what the tenant is charged, because both statements the carrier can make confirm the impact. | AL18 | Accepted, see R17 |
| T10.6 | R | The platform reports a failure the tenant cannot answer | A reported failure settles terminally at FAILED_EFFECT_UNKNOWN, tier ASSERTED, with the impact confirmed — which is what keeps the project working, because silence wedges the intent. The tenant's answer is the correction: an ADMIN-signed non-execution through reconcile, on one row, restoring exactly the power the same administrator held over that execution one state earlier. It is refused in every other direction, needs a key usable at the grant instant, and an ADMIN performs it from the Activity page's settlement control on that row. | AL13, AL20, AL30, AL31, AL33 | Accepted, see R19, R20 |
| T10.7 | D | An ungoverned toolkit's traffic drives the tenant's audit head | An ungoverned pass makes no kernel call, no execution row, no reservation and no audit event, asserted against the tenant's chain head before and after. The reason is the lock rather than the volume: append_event takes the per-tenant serializing structure, and the ungoverned pass is the success path of every tool call in the project. The residual is that neither the chain nor the correlation table can say how many such calls passed. | AL8 | Accepted, see R18 |
| T10.8 | D | The deployment under load stops answering in a shape the platform can act on | The boundary rewrites the application's own non-hook answers per route: on POST /pre a 429 becomes RATE_LIMIT_EXCEEDED and everything else that is not a 200 or a 401 becomes CHECK_FAILED, so an overloaded pre hook blocks whatever failure mode the project is configured with; on POST /post nothing is rewritten, because converting an error to OK would tell the platform its report was accepted when nothing was settled. The extension's own deadline is required with no default and answers CHECK_FAILED rather than the retryable code. | AL7, AL21, AL25, AL32 | Verified |
| T10.9 | I | Third-party identity material or a vendor string reaches ExecBound's storage, logs or the agent's context | authorization[].oauth2.at is dropped at the edge — not parsed, not retained, not logged — and context.secrets carries names and no values; output is opaque, dropped before anything else happens and never retained or logged; error_message carries a stable ExecBound code, the execution id where one exists and, for a pending approval, the approvals page, and never an input value, a vendor string, a rule id, a secret or a continuation. | AL5, AL14 | Verified |
| T10.10 | E | A second logic extension changes the inputs after this one decided, or a denied call runs anyway | Neither can be prevented and the guide says so in the same table. Two arrivals are recorded: a post hook whose correlation row is a denial, and one whose inputs differ from the frozen ones, each appended once to the tenant's chain under execution.control_point_contradiction exactly when it moves the correlation row. Both are missing or contradictory evidence and neither is a proven bypass. | AL16, AL17 | Accepted |
| T10.11 | S | An operator declares a precondition class for a binding this carrier serves | Nothing checks it, and on this path it can mean nothing at all: the hook response has no field for a grant and the extension sets no override, so no version reaches Arcade and no record can say whether the target moved. A class declared for such a binding is an overclaim on every page and report that shows it; the honest declaration is none. | ARCADE.md | Accepted, see R15 |
| T10.12 | S | A caller reaches policy through a fact the surface does not evaluate | The one context field the extension fills is the incident id, and it is a claim: registry.incident binds it to the resolved target under the mapping's own tenant, account, provider and resource type inside an RLS-scoped transaction, so an id naming nothing, another tenant's, another account's and another resource's are each refused. What it does not bind is which of the target's incidents, so a second or a closed one resolves and the tenant's policy pack is what refuses it on the frozen facts. severity, environment, role and criticality are never set from a hook. | AL9, AL34 | Verified |
B11: Console to model provider
The console assistant (design, #330) puts a model between a person and the tenant's records. The boundary is about what reaches the model, what comes back from it, and who can read either. The engine is execbound/assistant/; the console routes are a later pull request and add their rows here when they land.
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T11.1 | E | The assistant reads or changes something the asking person could not | Every tool is a thin wrapper over a function a page already uses, run with the person's own session inside the tenant fence and rechecked per call; there is no generic query, no tool that writes and no tool that reaches a provider; a page function's refusal is a found: false answer; an agent principal can own no turn (assistant/tools.py, assistant/turns.py). | AS3, AS4; tests/test_assistant_tools.py::test_an_approver_reads_a_scoped_request_whole_and_nothing_outside_the_scope, tests/test_assistant_tools.py::test_agent_reads_are_the_administrators_and_every_string_is_enveloped, tests/test_assistant_turns.py::test_turns_are_private_to_the_person_who_asked | Verified |
| T11.2 | T | Text an agent, a provider or a tenant user wrote instructs the model, or reaches the console as markup | Every string from tenant data travels inside an untrusted envelope the instructions declare data; the answer is structured output of plain paragraphs the server stores as text; an id becomes a citation only if a tool returned it in the same turn; the console renders text under its policy (assistant/tools.py, assistant/render.py, assistant/loop.py). | AS5; tests/test_assistant_loop.py::test_injected_instructions_in_tenant_data_reach_the_model_only_as_data, tests/test_assistant_client.py::test_an_answer_keeps_only_the_ids_this_turn_saw_and_no_control_characters | Verified |
| T11.3 | I | A credential, token, code or key reaches the model | Tool projections select no secret column; every result passes find_secrets before it is sent and a hit fails the turn with REFUSED_SECRET, audited, never retried; the transport records what it was sent so the test asserts the absence (assistant/tools.py, assistant/loop.py). | AS6; tests/test_assistant_loop.py::test_a_secret_in_a_tool_result_fails_the_turn_and_never_reaches_the_transport, tests/test_assistant_tools.py::test_a_secret_shaped_string_in_tenant_data_ends_the_turn_before_it_leaves | Verified |
| T11.4 | E | An explanation tells an approver what to decide | The answer schema has no field for a recommendation, the instructions forbid one, and the renderer refuses a fixed phrase list in an approval context, storing the turn as FAILED/RECOMMENDATION with no answer (assistant/render.py, assistant/loop.py). | AS7; tests/test_assistant_loop.py::test_an_explanation_of_an_approval_cannot_carry_a_recommendation, tests/test_assistant_client.py::test_a_recommendation_in_an_approval_context_fails_the_answer_and_nowhere_else | Verified |
| T11.5 | I | One person reads another's questions or answers, or the audit chain carries either | Turn rows are read by the asker alone and written only through definer functions that check the actor owns the turn; the audit events carry the turn id, tools, counts and reasons and never text; retirement nulls the text at thirty days (migrations/versions/0038_assistant_turns.py, assistant/turns.py). | AS2, AS8; tests/test_assistant_turns.py::test_turns_are_private_to_the_person_who_asked, tests/test_assistant_turns.py::test_retirement_nulls_the_text_at_thirty_days_and_keeps_the_counts, tests/test_assistant_loop.py::test_a_turn_reads_through_a_tool_answers_with_citations_and_is_audited_without_text | Verified |
| T11.6 | D | A turn runs unbounded, or a tenant's use runs up the provider bill | Eight tool calls, sixty seconds and two thousand output tokens each end a turn with its reason and nothing is retried past a bound; a turn left running reads abandoned at ninety seconds; the operator's per-person hourly and per-tenant daily caps are counted in the transaction that creates a turn (assistant/loop.py, assistant/turns.py). | AS9; tests/test_assistant_loop.py::test_the_bounds_each_end_the_turn_with_their_reason_and_nothing_is_retried_past_them, tests/test_assistant_turns.py::test_the_caps_refuse_the_next_turn_and_are_counted_in_the_same_transaction, tests/test_assistant_turns.py::test_a_turn_left_running_reads_abandoned_after_ninety_seconds | Verified |
| T11.7 | I | The provider's key is written to the database, a log or a diagnostic, or the feature exists where nobody chose it | The key arrives only as EXECBOUND_ASSISTANT_API_KEY, is merged into the block by boot.py under the mail password's rule (one place, never both, a refusal names the variable and never the value), is consumed from the environment, and lives on a frozen value whose repr hides it; without the block the feature does not exist, and with it a tenant's switch is off until an ADMIN sets it with a reason, audited (boot.py, surfaces/configuration.py, assistant/config.py, assistant/turns.py). | AS1; tests/test_boot.py::test_the_assistant_key_is_merged_into_its_block_and_scrubbed, tests/test_surface_configuration.py::test_the_assistant_block_is_optional_and_makes_the_feature_exist, tests/test_assistant_turns.py::test_the_switch_is_off_until_an_admin_turns_it_on_with_a_reason | Verified |
| T11.8 | E | An assistant address is reachable where no model was configured, or for a tenant that never turned it on | The area module registers no route unless the configuration carries an assistant block, so the addresses do not exist rather than refusing; with the block, a turn for a tenant whose switch is off is refused ASSISTANT_OFF, and the switch is an ADMIN's with a reason, audited (surfaces/console_api/assistant.py, assistant/turns.py). | AS10, AS12; tests/test_console_api_assistant.py::test_the_addresses_exist_only_where_a_model_is_configured, tests/test_route_authority.py::test_an_assistant_block_adds_its_routes_and_changes_nothing_else | Verified |
| T11.9 | I | One person reads another's turn through the routes, or an agent credential reaches one | Every route reads the acting person's own rows and answers 404 for another's, on the read, the stream and the opened mark alike; an agent credential is refused 403 and audited on every assistant address, and the route inventory pins all seven as session routes (surfaces/console_api/assistant.py). | AS11; tests/test_console_api_assistant.py::test_a_turn_is_the_askers_and_no_one_elses, tests/test_console_api_assistant.py::test_every_route_makes_the_refusals_the_console_api_owes, tests/test_route_authority.py::test_an_assistant_route_refuses_an_agent_credential | Verified |
| T11.10 | D | A stream outlives its turn, or a person watching one holds a connection open indefinitely | The stream polls the turn row on an interval and closes on the turn's ending or at its own deadline, by which time a reader has marked an unfinished turn abandoned; it holds no channel to the loop, so a restart or a second process changes nothing it reports (assistant/events.py). | AS13; tests/test_assistant_events.py::test_the_stream_closes_at_its_deadline_saying_what_the_row_says, tests/test_console_api_assistant.py::test_a_turn_answers_into_its_row_and_the_stream_ends_with_the_rows_own_ending | Verified |
| T11.11 | R | A packaged help text tells a person something the product does not do | Each text is held to the document that states its facts by phrases that must appear in both, as the integrations manifest holds its claims; the texts carry no advice about a decision and read nothing of the tenant's (assistant/help/). | AS14; tests/test_assistant_help.py::test_each_text_says_what_its_document_says | Verified |
| T11.12 | E | A drafted rule reaches policy without a person, or widens what a bundle governs | The proposal path writes nothing: it creates no draft, adds no rule and activates nothing, and its answer is a form an ADMIN submits through the rules page, after which validation and activation recompile as always. A draft's mappings are the draft's, so a proposal can only name actions that draft already serves (assistant/proposals.py). | AS16, AS18; tests/test_assistant_rules.py::test_a_proposal_is_a_rule_the_compiler_took_read_back_in_the_editors_own_words, tests/test_assistant_rules.py::test_only_a_draft_written_as_rules_can_be_drafted_into | Verified |
| T11.13 | T | A model's answer reaches the form as something other than a valid rule | The answer is constrained to the rule model's own JSON schema, validated with Rule.model_validate_json (the validator the form uses) and compiled with the draft before it is offered; an unknown fact, an out-of-domain value, an unmapped action, a duplicate id or an answer that is not a rule at all is refused with the field that caused it (assistant/proposals.py, policy/rulesets.py). | AS17; tests/test_assistant_rules.py::test_a_proposal_the_compiler_would_refuse_is_refused_here_with_its_field, tests/test_assistant_rules.py::test_an_answer_that_is_not_a_rule_at_all_is_refused_and_nothing_is_written | Verified |
| T11.14 | I | A proposal is drafted for a draft the person cannot read, or by someone who cannot edit rules | The draft is read with the asking person's own authority through the store, so a bundle they cannot read refuses as the page refuses it, and the route is an administrator's alone (assistant/proposals.py, surfaces/console_api/assistant.py). | AS18; tests/test_assistant_rules.py::test_the_route_offers_the_form_to_an_administrator_and_refuses_everyone_else | Verified |
Residual risks
| ID | Risk | Where stated |
|---|---|---|
| R1 | Tenant isolation relies on trusted runtime code setting the tenant context. Anyone with arbitrary SQL access and the runtime credential can change that context; that attacker is outside the isolation guarantee. | README.md design authority; ARCHITECTURE.md trust boundaries |
| R2 | A privileged writer who replaces both the audit history and the retained head defeats chain verification. Independently retained checkpoints are the intended detection; the restore verifier compares a restored chain against them, but keeping checkpoints off the database host and scheduling backups remain the operator's responsibility. | ARCHITECTURE.md; RECOVERY.md |
| R3 | Provider HMACs establish authenticity between trusted components sharing a key, not third-party nonrepudiation. | ARCHITECTURE.md; REPLAY.md |
| R4 | The process boundary between the gateway, providers and replay is an application capability boundary, not an independently administered operating-system sandbox. A configured gateway process and its cross-tenant database credential remain trusted. | ARCHITECTURE.md; SURFACES.md; VALIDATION.md |
| R5 | A bundle whose writer allowlist admits a label the protected agent can write removes the scenario T protection against agent-written context. | CONTEXT_CONTRACTS.md; POLICY.md |
| R6 | The Cedar engine is a pinned community binding, used through private entry points because the public wrapper hides missing diagnostics. Every upgrade is a review item. | POLICY.md; #38 |
| R7 | External monitoring is detective and cooperative. It cannot prevent execution, a heartbeat proves reporting freshness rather than complete coverage, and a total ExecBound outage cannot self-alert. | TINES_MONITORING.md; external monitoring design |
| R8 | Failed authentication is counted per surface, reason, minute and verified subject, and execbound failures lists the counters; requests refused by the request limits are not counted, the counters carry no caller address or raw token, no alerting reads them, and the limits themselves remain per process rather than distributed. | EXECUTION.md; OPERATIONS.md |
| R9 | Key rotation reloads each process's private file independently, so a multi-process deployment can briefly verify with different rings; provider dispatch and control tokens still need a restart to change; and a key removed while an operation is unsettled leaves it uncertain until the key is restored. Backup, restore and verification are documented and rehearsed in CI on synthetic data, not yet on a real deployment. | OPERATIONS.md; RECOVERY.md; #50 |
| R10 | Authority is a bounded snapshot. Registry and credential changes are observed by fresh reads, but an independent writer can change them after the final read. Incorrect trusted provisioning is outside the agent input boundary. | EXECUTION.md |
| R11 | One hosted deployment has been validated: the Render pilot of September 16 to 17, 2026, with synthetic data and mock providers, TLS terminated by Render, the database reached through the Supabase pooler with a pinned CA bundle, operator identities provisioned from the workstation, and inventory freshness kept by manual imports. Secret rotation on the host, log forwarding and hosted backups remain deployment work, and named host trials are unperformed. | DEPLOYMENT.md; RENDER_PILOT.md; #26 |
| R12 | Only controlled mock providers exist. Real CrowdStrike and Entra semantics, including their precondition and journal guarantees, are unverified. | IDENTITY_PROVIDER.md; VALIDATION.md |
| R13 | Cutting off a compromised executor key leaves a grant only that key could have attested with no attested exit: every report carrying a signed record, the administrator's settlement and a replacement key are refused at the same verifier. A grant nobody reports at all keeps its reservation and its canonical claim indefinitely, because the sweep leaves the row INDETERMINATE and the write-off that would close it — releasing such a claim without settling the execution and audited as its own administrator act — does not exist. A grant whose holder still has its report secret is now a different case and a worse one: the bare word settles it, the impact is confirmed, the recovery run frees the target, and the correction of that settlement is permanently unreachable in that tenant, so the cut-off ends with charged, closed, uncorrectable rows where it used to leave open ones. | CHECKPOINT.md; ARCADE.md; OPERATIONS.md |
| R14 | The per-principal cap bounds unsettled grants, not held claims. A settlement on an executor's bare word leaves the unsettled set and frees a cap slot while its canonical claim is still held, so between that settlement and the next recovery run past the act window one principal can hold more live claims than the cap admits. Closing it means counting held claims at admission. | CHECKPOINT.md |
| R15 | A precondition class is a statement about a vendor's API that ExecBound cannot verify, and an UNAVAILABLE binding makes a concurrent change undetectable. No vendor's conditional write, version semantics or journal behaviour has been exercised: the whole checkpoint path is fixture-verified against the protected mocks, and the live half of its workflow contract is unrun pending a separately authorized trial. | CHECKPOINT.md; SURFACES.md |
| R16 | A Gate caller holds a vendor credential and a protected agent holds none, so one principal should serve Gate mappings or Execute mappings and not both. Nothing in the code enforces that separation: admission refuses a Gate mapping whose account already has a connector, which keeps one operation from being both, and says nothing about a principal holding one mapping of each kind. Owner provisioning is the only control today. | CHECKPOINT.md; ARCHITECTURE.md trust boundaries |
| R17 | The Arcade correlation table holds a usable report secret while a grant is live, which is a property this carrier introduces rather than inherits: a Gate secret is returned and never stored, and only its SHA-256 digest is kept. A reader of that table can settle the row it names and, once the act window has passed, cause the scheduled run to release its canonical claim. The secret authenticates nothing past the report window, and it is nulled at settlement and cleared opportunistically afterwards, so what is left is the bytes of a row nobody touches again. No reader of it can reduce what the tenant is charged. | ARCADE.md; CHECKPOINT.md |
| R18 | An ungoverned toolkit is a standing operator decision with no record behind it: its calls create no execution, no reservation and no audit event, so neither the tenant's hash chain nor the correlation table can say how many passed, and tools the vendor adds to that toolkit later pass under the same decision. A declared tool is the opposite trade — it fails closed on vendor schema drift, so an added optional input breaks every governed call until an operator declares it. No coverage may be claimed for an ungoverned toolkit, and its enforcement mode stays Unknown even where the governed tools are later qualified. | ARCADE.md; AGENT_PLATFORM_LANDSCAPE.md |
| R19 | A settlement at FAILED_EFFECT_UNKNOWN overcharges a call that did nothing: the state says the effect was never established, and the impact is charged as though it had been, for the length of each limit rule's own window. That is deliberate, because the alternative is a refund on nobody's evidence, and it is what stops a single failed tool call wedging the intent permanently. The way back is an administrator signing a non-execution for that one row, which is a human act and not an automatic refund, and which is unavailable in a tenant whose executor key ring has been revoked as compromised. | ARCADE.md; CHECKPOINT.md |
| R20 | The correction is a per-row human act, and ExecBound cannot tell a false report from a true one. The Activity page offers an ADMIN the control on a correctable row and the kernel checks the record against a registered key, so what is verified is that the executor signed a non-execution for this operation — never that the platform's earlier statement was a lie. Whatever established that is evidence an operator gathered outside ExecBound, one row at a time, and a tenant facing many falsely reported failures corrects them one at a time or carries the charge. execbound reconcile is not this exit: it looks the account up in the live connector inventory and refuses a grant in every mode. | CHECKPOINT.md; OPERATIONS.md |
| R21 | No claim about a real Arcade deployment is established at all. The hook contract was read from a beta published schema on one day; nothing here has contacted Arcade, installed arcade-mcp or used a real credential; the failure mode, the hook timeout, whether another extension is registered and whether the project routes every tool through this one are all on Arcade's side and unreadable from here. Three wire facts in particular cost a settlement or a tool if the deployment differs: success is optional on the wire while every settlement rests on it, execution_id is unconstrained on the wire and bounded here, and a content-type framed otherwise is a 400 the published contract does not retry. | ARCADE.md; VALIDATION.md |
| R22 | A gateway compromise could mint invitation codes for free sandbox slots and mail them to an attacker's addresses. The prize is a mock-backed sandbox tenant with no real connector in it, bounded by the seeded pool and by the per-domain and global caps, and every slot is single use and never reissued; the operator sees the funnel diverge from the pool. Nothing about it reaches a customer tenant. | open signups design; OPERATIONS.md |
| R23 | A space is ownership and view, not isolation. A compromise of the gateway, or arbitrary SQL with the runtime credential, reaches every space of a tenant exactly as it reaches every row of that tenant today. No document, page or sentence may describe a space as a boundary; the tenant is the boundary and the discipline is the one already applied to ENFORCED_BY_PROVIDER against CHECKED_BY_CONNECTOR. | spaces design |
| R24 | A tenant-wide limit and the canonical target claim are cross-space denial and inference channels by design: a tenant-scoped rule exhausted by space A denies space B, and a claim held in A refuses B's execution on the same canonical target with the conflict it returns today. B learns that something in the tenant is holding that target; the refusals say nothing about which space or what, and no page names another space's rows. A per-customer ceiling is the space-scoped limit rule. | spaces design |
| R25 | The space predicate has no database backstop. A missing one is a correctness bug with a customer-visible consequence rather than a refusal, and the parametrized isolation test over every space-carrying table is the whole of the mitigation (T2.9). | SP1; tests/test_spaces_isolation.py |
Assumptions
- The gateway process, its private configuration files and its database credential are trusted. Compromise of the host running them is outside this model.
- Owner provisioning is correct: canonical identities, approval assignments, inventory and incident sources describe the real environment.
- The PostgreSQL administrator and the owner role are trusted; migrations run only through the owner connection.
- Approvers and administrators are the people their credentials were issued to. Credential issuance happens outside the gateway.
- In a deployment, TLS termination and origin configuration are set up as the operations guide requires; the secure cookie and HSTS behavior depend on it.
- Provider origins are HTTPS with a certificate the gateway verifies; cleartext is accepted only for a loopback IP address and there is no opt-in for private networks (#183).
Out of scope
- Denial of service against the host, network or PostgreSQL from outside the application.
- Vulnerabilities in the customer's agent platform, identity provider or SOAR system.
- Physical or hypervisor-level attacks.
- Social engineering of approvers outside the approval page.
Maintaining this document
Update this document in the same PR whenever a change adds an HTTP route, MCP tool, human page, CLI command or monitoring route; adds a connector or external integration; adds a migration that touches authority tables; or closes an item marked Open or Partial. Keep threat and risk identifiers stable so issues and reviews can cite them. When an acceptance scenario is added, add its identifier to the row it evidences. A change that weakens a Verified row needs the failing test first, as the development guide requires for security behavior.
tests/test_threat_model_references.py checks what this section asks for, so a stale citation fails a run instead of waiting for a reviewer to click it: every cited test file exists and every cited node is defined in it, every acceptance identifier is a row or a group in the acceptance inventory, every document link resolves, every "see Rscripts/threat_model_index.py --next-ids, so the helper an implementing pull request uses and the check it has to pass read the table the same way (#269).
Take a new row's id from that helper rather than from the table, and take it again before merging. The helper answers with the highest id a boundary has used plus one, so two branches open at the same time will either choose the same number or leave one unused, the way two Alembic revisions collide on the same head. That is how B4 came to have no T4.8 and how three rows of B6 were numbered a second time. Because identifiers stay stable once published, the repair moved the later rows to T6.15, T6.16 and T6.17 rather than resequencing the boundary, and T4.8 stays unused for good. The check requires each boundary's ids to be unique and ascending rather than contiguous, and the helper names every unused and duplicated id, so a reader can tell a deliberate gap from a mistake.