Skip to main content

Providers and the credential boundary

The first protected action crosses a separate HTTP process with credentials and state unavailable to the protected agent. This is a local CrowdStrike-style mock, not a claim about the production CrowdStrike API. The shared runtime exposes its protected path through authenticated HTTP/MCP and human browser sessions.

The normative text of the protocol the mocks speak, for anyone building an executor, is chapter 1 of the executor protocol, with the conformance module that checks an executor against it. This page keeps the narrative: why each rule exists, what the mocks are and what the live tenant showed.

Authority and wire contract​

What a frozen expected version is worth depends on the vendor, and the deployment says which case a binding is in through its precondition class (ENFORCED_BY_PROVIDER, CHECKED_BY_CONNECTOR, ASSERTED_BY_EXECUTOR, UNAVAILABLE; an undeclared binding answers UNAVAILABLE, because a deployment that declared nothing has said nothing about the vendor). Both mocks offer a conditional write and refuse a mutation whose version has moved, which is ENFORCED_BY_PROVIDER. A vendor that offers none — Microsoft Graph returns no ETag for a user object and ignores If-Match — is CHECKED_BY_CONNECTOR: the connector reads the state, compares it with the composite the plan froze, and writes at most once, retaining both vendor responses and the measured gap between them. That is ExecBound's own check, with a time-of-check-to-time-of-use window it owns and reports rather than closes. The class is recorded on the execution row at dispatch and rendered beside the record, so nothing has to infer it from configuration that may since have changed. A vendor that co-signs nothing describes the Graph connector.

The gateway derives ProviderCommand exclusively from committed frozen fields: tenant/account, shared canonical target, provider target ID, expected version, fixed action/operation, the action's normalized arguments, execution/operation IDs and intent/plan hashes. The provider re-checks the provider and provider operation against the production action catalog binding and re-normalizes the arguments before its transaction; a command that fails either is rejected with 400 and no journal. It never forwards caller claims, retry keys or arbitrary URLs. A digest binds the entire normalized command; echoed intent/plan hashes alone cannot hide a changed target or expected version. The command, the journal record and the signed journal are published as JSON Schemas generated from the protocol package's models, with the endpoint and identity contracts as conformance vectors, in protocol/executor/v1/.

The provider accepts exactly four POST paths. One dispatch route is registered per catalog binding of the configured provider, and the service refuses to start when the catalog binds an action its store cannot execute:

PathCredentialResult
/v1/containAccount dispatch credentialConditional mutation or retained terminal evidence
/v1/lift_containmentAccount dispatch credentialConditional mutation or retained terminal evidence
/v1/journalAccount dispatch or control credentialRetained evidence; missing remains inconclusive
/v1/cancelSeparate account control credentialExisting outcome or durable cancellation tombstone

Account/tenant scope comes from the credential and must match the command. Agent credentials and dispatch credentials on cancellation are rejected. Duplicate authorization headers, alternate identity headers, query overrides, unknown/duplicate JSON fields, unsupported numbers and oversized bodies fail closed. No resource provisioning, failure injection, journal editing, reset or debug endpoint exists.

Provider transaction​

The mock owns a separate SQLite file. A physical resource/version is keyed by tenant and canonical target; trusted account representations point to that resource. A new connection runs BEGIN IMMEDIATE, checks the operation journal, verifies the expected resource version, applies at most one mutation and writes the terminal journal before COMMIT. The gateway database is not used for this provider transaction. SQLite transaction semantics

contain sets containment and lift_containment clears it; either advances the version and records one mutation even when the state already holds, matching the identity mock. An EXECUTED endpoint.lift_isolation record carries endpoint_transition, with before and after EndpointState(contained), and is invalid evidence unless after.contained is false. That field is excluded from serialization when absent, and endpoint.isolate must not carry it: new contain journals keep the original endpoint wire contract byte for byte, so the retained fixtures verify unchanged.

The operation journal is append-only with update/delete triggers. Reusing an operation ID with the identical full command returns the retained result; a changed command conflicts. Expected-version mismatch records VERSION_MISMATCH with zero mutations. Unknown targets also receive terminal nonexecution evidence for that specific operation ID, preventing later replay after provisioning. A sample account's target is the one exception: it is created the first time a command names it; see Sample accounts.

Cancellation uses the same write transaction. If dispatch committed first, cancellation returns its outcome. If cancellation committed first, a later dispatch returns CANCELLED_NOT_EXECUTED without touching the target. A missing journal lookup or another actor's current target state cannot release gateway impact. Resource re-provisioning belongs only to the private test harness; it cannot change the retained journal.

Evidence and gateway integration​

After database commit, the provider signs the complete terminal record using fixed HMAC-SHA256, a key ID and a separate per-account evidence key. This authenticates evidence between trusted components; it is not public nonrepudiation. Preserve required historical verification keys when rotating configuration.

JournalVerifier validates strict structure, MAC, trusted key selection, account/tenant scope and exact complete command equality. Settlement retains the authenticated record and its digest. It distinguishes execution, expected-version rejection, cancellation and missing-target terminal rejection. A version mismatch becomes STALE_AUTHORIZATION; other negative proof becomes FAILED_NOT_EXECUTED. Positive evidence converts one reservation to confirmed impact without double charging.

dispatch_owned() consumes the gateway's one send attempt before HTTP I/O. HTTPX uses retries=0, follow_redirects=False, trust_env=False, explicit timeouts and bounded responses. Configured HTTPS origins use certificate verification; cleartext is restricted to literal loopback IPs. Credentials, paths and destinations cannot come from caller input. HTTPX transports, HTTPX environment configuration

Timeout, disconnect, invalid evidence or failed outcome/audit settlement leaves impact and target ownership unresolved. A worker cannot obtain another send attempt. reconcile_operation() requires current ADMIN authority and an audited reason before network access. After a missing/slow lookup, cancellation requires a fresh authority check and committed cancellation intent. No SQL transaction remains open during provider I/O. A plain “failed” assertion never replenishes capacity.

A vendor that co-signs nothing​

Everything above describes a provider ExecBound configured and that shares an evidence key with it. Microsoft Graph does neither, so GraphConnector settles differently and the gateway holds a Connector family rather than one class. The protocol is stated as a structural Connector protocol in execbound_protocol.connector (the Apache-2.0 protocol/ package): dispatch, lookup and cancel, each taking the frozen ProviderCommand and never the server's dispatch record, rotate_keys, close, and a config naming its tenant, account, provider, evidence ring and the secrets that authenticate it to its vendor. A connector carries no verifier. The runtime reads each connector's configuration once, validates that copy, rotates that copy and builds the verifier the gateway settles with from it (provider/verifiers.py, Runtime.evidence_verifier), so a connector cannot vouch for its own evidence. Each connector still checks what it returns, with the protocol's check_journal or check_connector_record, the same checks the server's verifiers run, so a binding mistake is caught where it is made. The connectors themselves, MockConnector, GraphConnector and the mock vendor servers, live in the Apache-2.0 connectors/ package (execbound_connectors).

A Graph connection is configured as GraphConnectionConfig, not ConnectionConfig: it has a directory, a client id and a client secret, and its origin is pinned in source so no operator can move it. Its evidence_keys sign and verify ExecBound's own record and authenticate nothing to Microsoft; one named member of that ring signs, and a rotation that drops it is refused whole. Deployment files declare these under graph_connectors, separately from the mock connectors.

GraphConnector serves one binding, graph/identity.disable, and refuses any other provider operation; the binding is declared CHECKED_BY_CONNECTOR. GraphConnector.dispatch performs the read, computes the composite, compares it against the frozen version and writes at most once, then signs a ConnectorExchange recording the frozen and observed composites, the vendor's status and the measured window. ConnectorRecordVerifier checks that record binds this execution and returns it as terminal evidence. The signature is ExecBound's own and is never evidence Microsoft acted; every surface that renders one says whose it is. A binding declaring CHECKED_BY_CONNECTOR claims that comparison and that measured gap, and never atomicity.

The record admits EXECUTED, VERSION_MISMATCH and TARGET_NOT_FOUND, and refuses a cancellation claim. Anything else the exchange sees — a lost response, a refused write, an authentication failure — produces no record, and the execution is retained as uncertain. Graph publishes no journal, so lookup returns None and such an execution stays uncertain rather than settling on a re-read that could not tell this connector's own write from someone else's. A refusal the connector knows did not execute, such as a 403 on the write, is included in that: the verified vocabulary has no reason for a vendor refusing a write.

The measured window is established by the exchange and written at settlement, never at the dispatch attempt, which is committed before the connector performs anything.

What the live tenant established, 18 September 2026​

The Graph connector ran against an Entra lab tenant, against a disposable user the lab directory holds for this purpose. tests/test_graph_live_probe.py reproduces it and is skipped unless EXECBOUND_GRAPH_LIVE=1 is set and EXECBOUND_GRAPH_CREDENTIAL names the execute registration's credential file, so CI never reaches a vendor.

The least permission that disables a user is User.EnableDisableAccount.All. The execute registration holds exactly that plus User.Read.All for the read half, and no directory role at all. User.ReadWrite.All is not required and is not granted. Until this ran, that permission came from documentation alone; it is now confirmed against a real directory.

The composite recipe matches what Microsoft returns. $select=id,accountEnabled,userPrincipalName,onPremisesSyncEnabled answers with exactly those four fields and an @odata.context describing the response, which the reader drops before the allowlist sees it. No drift, and onPremisesSyncEnabled comes back null rather than absent for a cloud-only user, which is why the recipe admits None for it.

The write answers 204 with no body. The record carries the status the vendor returned rather than assuming one, which is what lets an EXECUTED record be held to a 2xx it actually saw.

Graph reads are not read-your-writes consistent, and staleness is not an early phase that passes. This is what the probe found that no documentation stated and no fake could show. Measured directly, with one token reused so that nothing else was in the way: 116 reads over 40 seconds following a 204, giving the sequence FFFFTFFFF... — a single stale read arriving 1.25 seconds after the write, in the middle of a run of correct ones. A stale answer is not an opening phase that ends; it is one replica answering at an arbitrary moment.

Three consequences, none of them conservatism:

  • GraphConnector.lookup returns None because no number of reads establishes current state. A re-read cannot confirm the connector's own write even before the question of who else may have changed the account, so an uncertain Graph operation stays uncertain.
  • A frozen precondition can be stale before the dispatch begins, through nobody's fault, when the version was frozen from a lagging replica. The probe's first run did exactly that and was refused STALE_AUTHORIZATION, and the module still re-issues on that refusal rather than pretending it does not happen. That is the kernel being right about a directory that had moved.
  • The measured window is a gap between two reads of a system that does not promise either is current. It is evidence a reader can weigh, and CHECKED_BY_CONNECTOR claims nothing more.

Be careful how much of this you blame on the vendor. The probe was unstable for three rounds and only one cause was Microsoft's. The other two were the probe's own: it re-issued after a stale refusal under the same idempotency key, which binds to one execution and which admission correctly refused, and it minted a fresh OAuth token for every poll, four a second. Both were visible in an exception that was not read for several rounds. With a key per re-issue and one token reused, the module ran six times consecutively with no failure, in 13 to 81 seconds depending on how quickly the directory settled. Replication is real and is measured above; it was not the reason this was hard to get green.

A denied action reached the vendor not at all. The forbidden case is asserted rather than assumed: the connector's HTTP transport counts every request it makes, and a request denied by a forbid rule produced an empty list — no token request, no read, no write. The hard denial wins before a ticket exists.

Sample accounts​

With EXECBOUND_MOCK_SAMPLE_KEY set to 32 to 128 bytes of lower-case hex, the endpoint mock also accepts sample accounts. Configured accounts behave exactly as above and are matched first. A request whose token matches no configured account is read as a sample request: the mock parses the command it already parses, derives the dispatch and control tokens for the tenant and account that command names (HMAC-SHA256 of the sample key over a versioned label, the tenant and the account), and compares the presented token with the one for the route's role in constant time. A match is a sample account whose journals are signed with the derived journal key under key id sample-v1; a command naming another tenant or account derives another token and is answered 401, as is a control token on a dispatch route. Containment is per tenant, not per account: the mock also refuses a sample token for any tenant it already holds a configured account for, whatever account id the command names inside that tenant, because a configured tenant's own resources are reachable by tenant and target alone; and samples_install marks an account sample only in a workspace holding no active connector account. That check reads the database, which a workspace whose slot was switched off there but is still in the gateway's configuration passes, so the gateway applies the same rule: it derives no sample connection in a tenant its configuration holds a connector for, and a sample request there is refused at admission instead of reaching this 401. Nothing about a sample account is stored in the mock's configuration, and the gateway computes the same values from its own copy of the key.

The one behaviour a sample account adds is provisioning on first sight: the first dispatch that names a sample account's target creates that target and its representation at sample-resource-v1, the version ExecBound's sample facts record, so the first allowed request really executes. From then on versions move as they do for any target, and a later command at a stale version is refused VERSION_MISMATCH. The mock never overwrites a target or a representation that exists, and a configured account's unknown target is still answered with terminal nonexecution evidence. The identity mock refuses to start with a sample key, and so does an endpoint mock whose key is malformed or equal to a configured account's signing key. The key is read once at start: a changed key is a restart, and it cuts every workspace's sample connection until the gateway holds the same key. Whoever holds the key can act on every workspace's sample endpoint mock and on nothing else (Threat model T3.9, R28). tests/test_sample_mock.py (SM4) and tests/test_samples_end_to_end.py (SM5) hold this behaviour.

Local service configuration​

Use the locked Python environment. The mock factory reads EXECBOUND_MOCK_DATABASE_PATH and EXECBOUND_MOCK_ACCOUNTS_PATH. The accounts file is a JSON array of trusted records with tenant_id, account_id, dispatch_token_hash, control_token_hash, signing_key_hex and key_id. Generate independent random bearer secrets and a separate evidence key of at least 32 bytes; store only SHA-256 bearer hashes in the provider account file. Gateway ConnectionConfig holds the raw bearer secrets and trusted evidence keys. Keep both configurations in ignored private local data and outside agent authority.

Provision synthetic resources and account representations with the internal MockStore harness before starting the service, or start it through python -m execbound.boot with EXECBOUND_MOCK_RESOURCES_JSON set to a JSON list of {"tenant_id", "account_id", "provider_id", "canonical_target_id", "version"} entries (optional "contained" or "account_enabled" booleans), which provisions the store idempotently at every start. The same canonical identities and expected versions must be provisioned in the trusted gateway registry. These methods have no HTTP routes.

$env:EXECBOUND_MOCK_DATABASE_PATH = "$PWD\local-data\mock\provider.sqlite3"
$env:EXECBOUND_MOCK_ACCOUNTS_PATH = "$PWD\local-data\mock\accounts.json"
uv run uvicorn execbound_connectors.mock_app:create_mock_app --factory --host 127.0.0.1 --port 8010 --no-access-log

tests/test_protected_path.py creates fresh private configurations and starts an actual separate provider process, so the full regression suite is reproducible without manual account setup. Tests inspect mutation counts and retained journals directly. They cover approval then one effect, forbids, agent credential rejection, version changes, lost responses/crashes, journal recovery, cancellation before delayed arrival, outcome-audit rollback, inverse changes and unsafe transport responses. Concurrent SQLite connections exercise first-operation and cancel/dispatch races; the gateway uses guarded local PostgreSQL.

These are controlled mock guarantees. HTTP/MCP parity, browser approval/CSRF and continuation MACs have separate surface tests; the separate identity provider reuses the protected protocol with explicit action contracts. Replay is implemented, and python3 scripts/ci.py validation reproduces the validation package.