The security model

Every agent connection is minted deliberately by the owner (or, for delegated workers, by a connection the owner already trusted), carries a profile and an entity scope enforced at the query layer, and reaches a fixed set of elevated writes through one approval gate no profile can skip.

Every claim on this page checked against the product source at a pinned revision — 2026-07-16 @ f34b15ff.

What this is

The security model is how the daemon decides what an agent connection can see and do: nothing is granted by dropping a token in a config file. Every connection is minted through a deliberate act — the owner in the owner UI, or, for a delegated worker, a connection the owner already authorized to spawn workers — and from that moment on carries a profile, an entity scope, and (for delegated children) a lineage that the daemon checks on every request, not once at setup.

Why it exists

An MCP connection can read the graph and, depending on profile, write to it — so the question “which connection is this, and what did the owner actually authorize it to touch” has to be answered the same way every time, not inferred from context. The Trust Covenant states the resulting guarantees (scoped access, an elevated-write gate no profile bypasses); this page is the mechanism underneath them — minting, profiles, scope, delegation, and the two failure modes that would matter if any of it were soft.

How it works

Minting is a request the owner (or an authorized delegator) makes, not a file the daemon reads at boot. A human mint goes through POST /mcp/connections, whose ownerSession flag is computed server-side from the authenticated request context — “never read from the request body/headers/query” — and never from anything an agent could forge (proxy/src/mcp/index.ts:641,707-729, auth.ts:1462-1466).

Three profiles gate what a connection can do without a fresh human gesture. observer is read-only; assistant and full can write. Regardless of profile, a fixed set of elevated writes — remember_fact, create_entity, begin_import_session, revert_mutation — always needs an explicit owner-approval round trip; only a full, pre_approved connection skips approval for everything else (ELEVATED_WRITE_TOOLS and needsApproval(), proxy/src/mcp/approval.ts:402-437).

Entity scope is enforced in the query itself. scopeClause / factScopeClause / wikiScopeClause (proxy/src/mcp/scope.ts) turn a connection’s entity_scope into a SQL fragment spliced into every scoped read and write: 'all' adds no filter, an explicit list becomes AND e.id IN (?), and an empty list becomes AND 1=0 — fail-closed, not fail-open, when a scope somehow parses to nothing (scope.ts:23-35).

Delegated minting lets an authorized connection spawn its own workers, always more restricted than itself. sophia.mint_worker_key requires the calling connection’s own can_delegate flag — set only at that connection’s own mint time by a human — and the child it creates is capped on every axis: profile hard-capped at observer or assistant no matter what the parent holds — a full parent cannot mint a full child, the ceiling is structural, not relative — entity_scope an explicit subset of the parent’s, TTL defaulting to 60 minutes and capped at 240, and can_delegate forced to 0 so a worker can never delegate again (proxy/src/mcp/mintPolicy.ts:11-14, auth.ts:1244-1263, tools/delegationTools.ts:82-94). A parent may hold at most 10 live children and mint at most 20 per hour, both counted from the audit log rather than an in-memory counter so the caps survive a restart (mintPolicy.ts:75-78). sophia.complete_worker and sophia.revoke_worker_key are parent-only — a worker cannot end its own lifecycle, and revoking a parent cascades to every live child.

A bearer is a secret the daemon never has to leak, because it never keeps it. Only bearer_token_hash, a SHA-256 digest, is stored; the raw token is returned once, at mint time, and re-derivable by no one who didn’t see that response (auth.ts:457, 706). A lighter, opt-in audit trail covers both reads and instrumented writes on the same table: a fixed registry of tools — reads like fetch_document and get_entity_profile, writes like remember_fact — logs one row per touched entity to subscriber_agent_entity_access_log, rotated to 30 days, plus a permanent per-entity counter, all queryable back through sophia.entity_access_history and sophia.agent_trail (proxy/src/mcp/tools/accessLog.ts, agentAccessLog.ts). Tools outside that registry stay unlogged by design, not by oversight: “silent over-logging is hard to undo.”

Fail-closed posture at the transport layer — the daemon refusing to bind to anything but loopback, the provider registry throwing instead of guessing — is Trust Covenant’s own pillar and isn’t re-derived here.

Recent hardening fails closed at boundaries that used to be easy to get wrong. A supplied blank or invalid bearer is a rejected credential, not an anonymous fallback, and persistence does not proceed when authentication is invalid. The test bootstrap also pins a test-only data directory, so a test run cannot quietly attach to a developer’s live local database. These are deliberately unexciting controls: they make a mistaken credential, path, or test environment stop rather than become an implicit authorization.

Skills are the sharpest case. A skill is instructions another agent loads and follows, so publishing one injects instructions into every sibling agent’s context. Agents may only draft; no agent profile can publish. That boundary is Skills and instruction trust.

What your agent does with it

// Live response from this daemon, captured 2026-07-17 (read-only):
const perms = await sophia.get_permissions({});
// → { permission_profile: 'full', entity_scope: 'all',
//     write_approval_mode: 'pre_approved',
//     approved_tools: [/* the full catalog — 179 tools at this capture */],
//     request_count: 336 }

// Schema-derived from mintWorkerKey's live Zod schema — a mint is a write,
// so it was NOT called in this read-only research session:
await sophia.mintWorkerKey({
worker_name: 'DocWorker-1',
entity_scope: ['e-1'],        // must be a subset of the caller's own scope
ttl_minutes: 60,               // capped at policy.maxDelegatedTtlMinutes (240)
task_label: 'summarize inbox documents',
});
// → { connection_id, bearer_token, expires_at, spawn_id, worker_call_recipe }
// bearer_token appears in this response exactly once.

get_permissions is the identity check every agent can run on itself before assuming a capability. mint_worker_key’s shape comes straight from its live tool schema — the call itself would create a real, attributable connection, so it stays undialed here by design.

One daemon, one owner — enforced by the kernel

Connection trust assumes one more thing this page used to leave implicit: that there is exactly one daemon answering. There wasn’t, once. Under memory pressure an accidental second copy of the daemon kept holding the encrypted state database’s lock after it had stopped serving, ignored every request to shut down, and drove the managed service into eleven busy-database restarts. Nothing was malicious — two well-behaved daemons both believed they were in charge, and the system had no way to insist that only one may be.

Now it can. Before the daemon opens either database or binds its port, it must acquire a kernel-enforced lock (flock) on a file co-located with the state database, inside a data directory whose ownership and permissions are verified first — a legacy too-permissive directory is quietly tightened rather than bricked. A process that cannot acquire ownership fails fast with a named reason — another live owner, an untrusted directory, a lockfile that isn’t what it should be — and never touches the database at all: no contention, no busy-storm, exit. The refusal codes are a frozen contract, each with its own exit code, so a supervisor can tell “someone already owns this” from “something is wrong with this install.” The hostile cases are proven by test rather than promised: a symlinked or wrong-owner lockfile is refused, a file swapped between check and open aborts the boot, a child process cannot inherit ownership — each guard demonstrated by removing it and watching the attack succeed before restoring it. Every database-opening path, including the one that mints credentials, routes through the single owner. The guarantee is scoped, and the scope is written down: it holds under an explicit, owner-ratified threat model — the accidental same-user duplicate that caused the incident — not against every adversary imaginable, and where that boundary sits is recorded rather than implied away.

On that guarantee sits workspace identity. A local broker — refusing to run at all unless the single-owner capability is proven held — issues each agent workspace a durable lease over an authenticated local socket, the caller’s identity checked at the operating-system level rather than taken on its word. Leases survive daemon restarts, an agent’s coordination work is bound to its lease, and resuming one is governed by the same authority as a fresh issue — identity that evaporates on restart is not identity, and identity issued by a daemon that might not be the real one is not identity either.

Work claims carry receipts, not reports

The newest layer governs not what an agent may do but what it may claim. Saying a change was verified is no longer something a report can assert — the change carries a receipt bound to the exact commit: its hash, its tree, its precise list of changed paths. An independent checker re-derives every one of those facts from the repository and refuses the receipt if any drifted, so a verification cannot be moved onto a commit it was not run against. Claims from a dirty working copy are rejected outright — commands run against uncommitted bytes did not test the commit being claimed. Deleting a file cannot shrink coverage, because removals count as changes; and discovering nothing to check fails rather than passing quietly. The separate attestor re-executes the claimed commands itself instead of taking the runner’s word — evidence and beneficiary are never the same party. The honest limit is enforced in the schema rather than footnoted: the receipt’s field for external enforcement only accepts the value "unverified", because no CI gate yet forces any of this to run, and the system is not permitted to claim an authority it does not have.

Boundaries

The elevated-write gate’s only code-level bypass, conn.is_portal, exists and is exercised by one test, but every live connection-creation path hardcodes it to 0 — the same dormant carve-out Trust Covenant documents in full; this page doesn’t restate it.

Beta hardening is ongoing, and two gaps are known, deliberately-accepted local-trust tradeoffs rather than oversights: the owner’s own browser session can mint an elevated or all-scope connection without the grant-token gate every other mint path requires (auth.ts:1819-1823) — that’s the mechanism by which the owner UI can mint its own first connection at all — and the daemon’s own REST layer, the owner UI’s own plane, is scope-blind by design: its session-key auth branch hardcodes scope = 'all' for every request it authenticates (proxy/src/middleware/auth.ts:131-182), so entity scopes are enforced on the MCP surface but not there. That’s a deliberate local-trust posture during beta — the REST plane binds to loopback and answers only to your own session — and narrowing it is tracked work. Both gaps are candidates for tightening before this ships to anyone running multi-user or network-exposed. The full connection lifecycle and tool catalog they operate over is MCP Surface; wiring an agent into a connection in the first place is Connect.