Docs/Lanes Link/Under the Hood/Security model

Security model

The threat model and the guarantee table

This system holds live credentials to the owner's email and documents. This document states its limits honestly rather than implying guarantees the code does not deliver.

To report a vulnerability, see SECURITY.md. Please do not open a public issue.

Protected assets

OAuth app secrets, connection refresh tokens and app passwords, the profile bearer token, the content returned through capabilities (message bodies, documents), audit records, and the configuration itself.

Goals

Prevent a caller from reaching a connection it holds no grant for. Keep each connection's credential reachable only by its own provider invocation. Make every invocation attributable. Keep control-plane decisions outside agent reach.

Lanes Link does not attempt to guarantee correct agent behaviour, continuous availability, or that a model will not disclose data it was legitimately given.

Trust boundaries

  • The owner controls the machine or cloud project, the blob store, the credential store, the encryption key, and the config file. Lanes Link does not protect a deployment from a compromised owner environment.
  • Provider code is trusted code. It runs in-process with core and holds its connection's credential. Installing a third-party provider is equivalent to running arbitrary code with access to that account. There is no provider sandbox.
  • Clients are authenticated but untrusted in intent. A client may attempt any capability; the policy layer decides. Compromise of the profile token grants exactly that profile's grants.
  • Content returned from upstream accounts is untrusted data. An email body or document may contain prompt injection aimed at the consuming agent. Lanes Link passes content through and does not screen it. Screening, if ever added, belongs in an optional module.
  • Upstream vendors receive the requests made on the owner's behalf, subject to their own retention policies.

The two kinds of secret

This distinction is the most important one in the codebase, and collapsing it would be the most damaging single mistake available.

Credentials (SecretStore)Vault items (M3)
Whatrefresh tokens, app-specific passwords, pasted API tokens, the profile token, and an OAuth client secret where the operator registered one of their ownthe owner's own passwords, API keys
Authorisesthe system itselfnothing, because they are data the owner stores
Agent-reachablenever, in any formyes, under policy, default deny
Storeencrypted file, its own keyseparate store, separate key

One thing is a credential and does not live in that store, and it is worth naming rather than leaving to be discovered. An auth: { kind: strategy } provider may hold a vendor-issued session, and bunq's lives in scoped runtime state, the blob store on a deployed workspace, not in SecretStore. That is not a preference: AuthStrategyContext.write exists only during connect, and a deployed revision is granted write access on nothing for a non-OAuth provider, so per-request code cannot put one there. It is acceptable because of what such a token is: short-lived, revocable by the owner from the vendor's own app, and reconstructible from the durable credential, which does sit in SecretStore. It is still a bearer token in a weaker store than the material beside it, and ADR-046 argues the trade in full. It is not agent-reachable either way.

If an agent could read the Gmail refresh token it would simply call Google directly, and the entire policy layer would become decorative. That is why the infrastructure interface is called SecretStore rather than "secrets", why Vault will be a separate provider over a separate store, and why a test asserting Vault can never reach SecretStore exists in M1, before the vault does.

What the controls do and do not guarantee

Policy evaluation, scoped state, scoped credentials, and audit are designed to reduce cross-connection access and make actions attributable. They are not a proof of non-interference.

Audit records support investigation; they do not prevent an action. Encryption at rest protects stored material from direct reads, not plaintext in a running process. A credential is in memory whenever a provider makes a call, and that limit is inherent. Rate limits blunt runaway loops; they are not a security boundary.

Guarantee status

ENFORCED means code rejects or tests the property today. NOT-GUARANTEED names a documented limitation. RESERVED names a compatibility slot with no implementation.

PropertyStatusVerifier
policy.default-denyENFORCEDsrc/policy/index.test.ts
policy.tighten-onlyENFORCEDpolicy composition tests
policy.deny-beats-allowENFORCEDasserted in both rule orders
control-plane.not-agent-reachableENFORCEDsrc/dispatch/control-plane.test.ts
config.contract-major-fails-closedENFORCEDloader tests
config.no-secret-valuesENFORCEDvalidator tests with credential fixtures
credentials.connection-scopedENFORCEDScopedCredentials isolation tests
credentials.not-agent-reachableENFORCEDcontext surface assertion
state.provider-scopedENFORCEDScopedStore isolation tests
storage.namespace-containedENFORCEDtraversal rejection tests
deployed.config-not-self-writableENFORCED (deployed workspace)the revision's objectAdmin grant is conditioned on the prefixes it owns, so profiles/, lanes-link.yaml and each profile's providers.d/ are readable and not writable. The last of those sits inside the granted data/ prefix since ADR-030, so the condition carries an explicit exclusion rather than simply not naming it. Enforced by the platform; src/deployments/grants.test.ts evaluates the shipped expression, rather than scanning for prefixes, which would read straight past a negation, and asserts the keys the endpoint writes fall inside it and the config paths fall outside. This replaces the read-only image that carried the guarantee before ADR-023
knowledge.excludes-secretsENFORCEDa workspace may keep memory, skills and entity files in a GitHub repository (ADR-041, amended by ADR-056); the credential store, the vault, runtime state, the audit log, tasks and assets are excluded structurally, because knowledgeTargetSchema has no field that could name any of them, so no flag, no override, and no copied example can move one. The amendment adds a third document area and no field, which is the property this row asserts. src/profile/load.test.ts asserts that credentials: and vault: written into the block are stripped rather than honoured, and src/cli/runtime/knowledge.test.ts asserts both that everything which is not a document area stays on the workspace's own storage and that the schema's field list is exactly the five it has always been
owner-layer.granted-by-defaultWEAKENED deliberately, and only for providers with no accounta new profile is created with memory.*, tasks.*, assets.*, skills.*, vault.* and setup.* allowed, and an existing one is repaired on the next start, connect or deploy (ADR-050). Default deny still holds for everything it is for: none of these reaches a third-party account, none has a credential or an OAuth app, and each is empty until the owner puts something in it. The grant is the one lanes link connect memory already wrote, so nothing is newly expressible. See the owner-layer section below for the write half, and deny for the way off. src/cli/config-edit.test.ts asserts the template and the repair write one spelling, and that a deny covering a surface is never undone
audit.append-onlyENFORCEDthe store interface has no update or delete
audit.tamper-evidentENFORCED for edits and mid-run removalsrecords are hash-chained per run; lanes link audit verify. Truncating a run killed mid-write, or deleting a run whole, is not detectable. See ADR-020
audit.redactionENFORCEDprovider redaction tests, including on denials
discovery.policy-filteredENFORCEDtools/list and server/discover, over the wire
setup.reports-only-reachableENFORCEDsrc/server/setup-surface.test.ts; a denied connection reads as one never made
setup.no-credential-presenceENFORCEDmissingRequirements is CLI-only; the surface reports requirements, not what is stored
transport.statelessENFORCEDrestart-mid-session test
credentials.encrypted-at-restENFORCED (file adapter)nothing readable on disk; tamper detection
profile.isolatedENFORCEDcross-profile token, state, and audit tests, plus src/cli/runtime/scoping.test.ts for the owner layer. Two things were shared until ADR-030, namely skills and provider manifests, both at the workspace root, so this row was previously true of credentials, state and the log rather than of everything a profile holds
oauth.refresh-replay-is-refused-and-recordedENFORCED outside a 30-second reuse intervala spent refresh token is tombstoned rather than deleted, so presenting it again is detectable; within 30 seconds of being spent it still answers, because a client whose refresh response was lost has no other move and the reference client rethrows invalid_grant rather than recovering (ADR-036 has the client-side reasoning). After that the replayed token is refused, the replay is logged, and the family it belongs to keeps working, tested over real HTTP in src/server/oauth.test.ts from any depth in the chain. Revoking the whole family instead is what ADR-035 reversed, and what it gives up is stated there: a family is minted once and never rotates, so the old answer logged an approved client out roughly daily and a thief never
token.rotation-takes-effectENFORCED within a five-second windowsrc/auth/index.test.ts covers both halves against a real credential store: the replacement is accepted on its first call, and the rotated-away token stops working once the window passes. Both caches are dropped together, the authenticator's and the credential store's, because dropping only one re-reads the same stale value
limits.per-profileENFORCED per instancerate limit tests
edge.pre-auth-meteredENFORCED (deployed workspace)the four things that answer before the bearer check, namely /health presented with a credential, /register, /authorize and /token, each cost a credential-store read or a bucket write, and until ADR-054 none of them passed through any ceiling: the limit lived inside the 401 branch, which is reached after the 404 gate. Two buckets are now taken on each, one keyed on the caller and one keyed on nothing, and the second is the one that holds, because the caller key is the first X-Forwarded-For hop and a stranger writes that. /health with no credential stays free: it reads nothing, and it is what a platform probe sends. Off on loopback, decided beside CORS and the dashboard in serve(), for the reason ADR-054 gives. src/server/index.test.ts drives it over real HTTP
limits.map-is-boundedENFORCEDa rate-limiter key is the caller's forwarded address, which on a public URL is a header a stranger writes. RateLimiter evicts, idle buckets first and then oldest-by-last-use in batches, rather than relying on a prune a caller remembers to call, which for most of this file's life nothing did. src/policy/limits.test.ts
deployed.data-recoverableENFORCED (deployed workspace)the revision holds objectAdmin on data/, which contains storage.objects.delete, so the process most exposed to the internet can erase the record of what it did. The bucket carries a 30-day soft-delete window and object versioning with a lifecycle rule bounding it, applied by buckets update on every deploy rather than as flags on the create, which is refused as ALREADY_EXISTS from the second deploy onwards and so would never reach a bucket that already exists. This does not make deletion detectable, as audit.tamper-evident covers, but it makes it undoable
deployed.not-publicly-shareableENFORCED (deployed workspace)public access prevention is enforced on the bucket, so anonymous read cannot be granted rather than merely not being granted. Uniform bucket-level access already removed per-object ACLs; this removes the bucket-level route to the same place
audit.every-invocationENFORCED with two documented exceptionssee below
credentials.client-secret-never-localENFORCED (hosted client)there is no client secret on the machine to hold. resolveSecretRefs grants no client reference at all for a connection authorised this way, asserted in src/dispatch/context.test.ts
credentials.exchange-is-localNOT-GUARANTEED for a connection authorised against the hosted clientsee below
credentials.no-standing-grantNOT-GUARANTEED for a connection authenticated with a keysee below
credentials.plaintext-in-useNOT-GUARANTEEDinherent
provider.sandboxedNOT-GUARANTEEDprovider code is trusted
provider.response-may-carry-a-credentialNOT-GUARANTEED for two Discord operationsa capability's response is returned to the caller unread, and discord.create_webhook and discord.list_channel_webhooks include the webhook's token in theirs. A webhook token is standalone: it posts to that one channel with no other authentication. Accepted in ADR-047 because the alternative is not the same capability made safe but no posting under the operator's own name at all. Bounded to one channel, withheld from the audit log by DISCORD_REDACT and asserted so in src/providers/discord/discord.test.ts, and revocable from Discord's channel settings. See Discord. Distinct from credentials.not-agent-reachable, which is about this system's own store and still holds
egress.controlledNOT-GUARANTEEDfollows from the above
policy.approval_requiredRESERVEDthe model carries the state; no engine, and it fails closed
delegation.external-clientsRESERVEDthe principal parameter; nothing more

What credentials.no-standing-grant gives up

ADR-038 added a second way to authenticate a Google connection: a service account key, signed into a short-lived assertion and exchanged for an access token (RFC 7523). It is the only route here where nothing expires, and that is the same sentence read two ways.

Every other credential this system holds decays or can be withdrawn from the other end. An OAuth refresh token can be revoked from a Google account page, is subject to the issuer's own expiry policy, and dies with the consent that produced it. An app-specific password dies when the account password changes. A key does none of that: nobody consented, so there is no consent to withdraw, and a leaked key stays valid until somebody deletes it in a console.

What bounds it instead is reach, and the bound is real:

  • Without domain-wide delegation the key is an identity of its own, and reaches only what has been shared with its address. Nothing in the account moves until somebody shares it, and a narrower standing grant than any OAuth token here, not a wider one.
  • With domain-wide delegation the key may act as any user in the domain, for the scopes an administrator listed. That is the wide case, and it is granted by an administrator in their own console rather than by anything in this repository.

The key is stored in whichever credential store the config names, encrypted at rest under the file adapter like every other secret, and is never sent anywhere except to the token endpoint named inside the key file itself. The minted access token is held in memory for the life of the process and never written back. See ADR-038 for why persisting it would have widened what a deployed revision is granted.

Prefer sharing over delegation wherever sharing will do. One shared folder is a much smaller grant than the right to act as a person, and the two are one prompt apart.

What credentials.exchange-is-local gives up

Since ADR-028 a Google connection authorises, by default, against an OAuth client Lanes operates rather than one the operator registers. That removes a nine-step console walkthrough. Since ADR-040 a Slack connection does the same, removing a six-step one. It also moves one step off this machine, and the honest statement of that is worth more than the convenience:

  • The authorization code is sent to the Lanes API, because redeeming it needs the client secret and that secret is deliberately not here.
  • The refresh token comes back through the Lanes API, and passes through it again on every later refresh.
  • An identity assertion (a Google id_token, obtained from the openid and email scopes the flow adds for this purpose) is sent with each refresh, so the API can attribute and rate-limit per account. It is stored beside the tokens and never decoded here.

Everything else is unchanged: the browser still talks to the vendor directly, the redirect still lands on a loopback listener this process opened, the endpoint still never participates, and the tokens still live in whichever credential store the config names.

Slack differs from Google in one way worth stating, and it is in Slack's favour. It issues no refresh token unless token rotation is enabled on the app, so only the first exchange goes through the Lanes API and nothing does afterwards. Where a Google connection passes a refresh token through it for as long as the connection lives. Slack's /config also asks for no identity scopes, because there is no openid here to attribute a refresh with and nothing to attribute.

Nothing in this repository can verify what the Lanes API does with what it sees. That is the whole of the trade, and it is why this is a row in the table rather than a paragraph in a guide. An operator who does not want to make it picks "an OAuth client you register" at the connect prompt once per profile, or --auth own_client, or the older --own-client, which registers a client of their own and keeps the exchange between this machine and Google. A service account key keeps it local too, and for a different reason: there is no exchange to move, because there is no authorization code. Declaring oauth_apps in a profile is the same choice expressed in config, and a profile that declares it is never moved off it.

Failed authentication is logged, not audited

A refusal record needs a principal, and failing authentication is precisely not having one, so a rejected credential cannot be an audit row without inventing a caller to attribute it to. It goes to the endpoint's operational log instead: stderr for lanes link start, stdout for the container, where Cloud Run collects it. The line names the reason (invalid, malformed, missing, not_configured) and never the value presented.

This is a change. The warning was written from the start and every caller passed a logger whose methods were empty, so on a public URL a sustained probe left no trace anywhere.

The documented exceptions to audit.every-invocation

Every call that reaches dispatch is audited, allowed or denied. A call naming a capability that policy filtering hid is also audited, via Dispatcher.recordRefusal. Two cases are not.

Not audited: arguments that fail schema validation. A call to an advertised tool whose arguments are rejected, including a connection value outside the advertised enum. The protocol layer rejects it before dispatch runs. The caller gets a clear error naming the permitted options, and nothing is invoked, but no audit row is written.

Closing it would mean either dropping the enum from the tool schema, which is what makes connections undiscoverable in the first place, or parsing the request body at the edge. Neither trade is currently worth it.

Not audited: a pre-envelope HTTP call to a hidden capability. The refusal record for a policy-filtered capability is written at the HTTP edge, which identifies the call by reading Mcp-Method and Mcp-Name. The 2026-07-28 envelope requires both and rejects any request whose headers and body disagree, so for an envelope client the header read is exact. A 2025-era client sends neither header, and the endpoint still serves those requests, because createMcpHandler is constructed without a legacy option, and its default is 'stateless'. So the refusal check short-circuits, the legacy leg answers -32602 Tool … not found, and no row is written.

What that costs: an authenticated caller can enumerate which capabilities exist without leaving a refusal trace, by speaking the older protocol. It is not reachable unauthenticated, and nothing is invoked either way.

Closing it means reading the body at the edge for requests that arrive without the headers, a request.clone() and a parse, which is precisely what serveOverStdio already does, because a pipe has no headers to read instead. The alternative, legacy: 'reject', closes it by refusing every pre-envelope client, and several of the clients in Add it to your agent have not moved.

Both were found by end-to-end verification rather than reasoned about in advance, and both are recorded here rather than papered over. src/server/index.test.ts asserts the second one directly, next to the test that shows the envelope path recording the same probe.

Upstream credentials

The server is its own OAuth client to each vendor and never forwards an incoming bearer token upstream. A caller's token authenticates them to this endpoint and has no meaning at Google; forwarding it is the confused-deputy mistake. The separation is structural rather than a rule to remember: the caller's token never reaches provider code at all, and an integration test asserts that what Google sees is only ever derived from the connection's own refresh token.

Access tokens are derived at runtime and cached in memory only, never persisted. They are short-lived by design, so storing one would create a second credential to protect for no benefit. The cache is keyed per connection, so two accounts never share a token, and a stateless server starts cold with an empty cache.

Not every upstream credential is an OAuth token, and the ones that are not are weaker in two ways. iCloud takes an app-specific password; GitHub, Slack and Discord take a token the operator generates and pastes. The first two because neither vendor's MCP server will register a client for us (ADR-033), Discord because its bot token is a property of the application rather than anything an OAuth exchange returns (ADR-047). Such a credential is long-lived and is persisted, because there is no refresh, so the stored value is the credential itself rather than a means of obtaining one. Rotation is manual: connect --replace, after revoking upstream.

The second difference is the one worth saying out loud. For an OAuth provider, connect shows what is about to be granted and refuses to proceed if the scopes have widened without being agreed, confirmScopes is that gate. There is no equivalent here, and there cannot be: what a pasted token can do is chosen in the vendor's own console, and this endpoint has no way to read it back. So the guarantee for these providers is narrower. The policy layer still bounds what an agent may call, but the credential's own reach is the operator's to bound, at the vendor, when they create it. All three setup pages say so at the point the token is generated.

A Google Cloud project left in "Testing" publishing status expires refresh tokens after seven days. That is a policy setting, not a bug, but it presents as an authentication failure on a weekly schedule, so invalid_grant is detected specifically and the error names the cause. See setup/google.md.

Known limitations (M1)

  • Bearer tokens are bearer authorization. Anyone holding the profile token is the principal. Tokens are not bound to a device. Revocation means rotating the token and reconciling. A running endpoint notices a rotation within five seconds rather than instantly: the expected value is cached for that long so the common case is a comparison rather than a decrypt. The replacement works immediately: a token that does not match a cached value forces a re-read before it is rejected, which is what makes the rotated-in credential usable on its first call.
  • Agent config files are a real exposure. MCP client configuration often sits in plaintext on disk, so a token is roughly as protected as that file.
  • One token per profile. Two agents cannot hold different permissions against the same profile; they need separate profiles. Rotating re-authorises every agent on that profile. Audit attributes calls to the profile's principal, not to a specific agent, so the recorded clientInfo label is self-reported and never consulted for authorization.
  • An authorised remote client is the owner. Where auth.authorization is declared, a client that completed the flow resolves to the same owner principal the bearer token yields, so the scope it was granted is not a permission boundary; policy is. Revoking one client means deleting its tokens from the profile's state store; there is no per-client revocation command yet. See ADR-018.
  • Anyone who can reach the endpoint can register a client. Registration yields an identifier and nothing more: no client obtains a token without an approval performed by hand with the endpoint token. It does mean an unauthenticated caller can write rows, so the list is capped at 200 and the oldest without a live token are evicted, so a connector in use is never dropped to make room.
  • Rate limits are per instance. On a horizontally scaled deployment they are not global. What bounds the aggregate instead is max_instances, which the rollout now always sends and which defaults to four. With no ceiling on instances the aggregate had no value at all, and /docs/link/deployment-cloudrun told the reader to set one themselves because nothing did. Two ceilings sit at the HTTP edge. One is on failed authentication, and exists to bound the credential-store re-read a mismatch triggers rather than to make guessing harder; 256 bits already does that; only a failure spends it, so a valid token is never refused by it. The other is on the surface that answers before authentication, and is the subject of ADR-054. Neither is a security boundary.
  • No egress control, no provider sandbox, no secret scanning on write.
  • Content leaving the boundary is not recoverable. Lanes Link governs what an agent may fetch, not what happens to it afterwards.

Supply chain

This project holds long-lived credentials, so dependency compromise is a live threat.

bunfig.toml sets minimumReleaseAge = 604800, seven days. The common attack is to publish a compromised version and yank it within hours; a release-age floor keeps a version that young out of the lockfile entirely. This is verified rather than assumed: bun add hono resolves to the newest release older than the floor, not to latest.

Also: bun run audit for lockfile CVEs, bun install --frozen-lockfile in CI, a pinned Bun version in .bun-version, and a committed lockfile. An urgent security fix can be pulled in ahead of the window by installing an exact version explicitly.

That line used to say bun pm scan, and bun pm scan did nothing. Without an [install.security] scanner configured in bunfig.toml it prints how to configure one and exits zero, so the check was documented, scripted, and inert, on a project holding live OAuth refresh tokens. bun audit needs no scanner package: it resolves the lockfile against npm's advisory database directly. It runs in CI now rather than only in the script, because a control nobody has to act on is the state this replaces.

The base image is pinned by digest as well as by tag. A tag is a pointer its publisher can move, so it is not a promise about bytes. The release-age floor above protects the npm half of the supply chain, and the base image is the larger half and had nothing.

The runtime dependency set is deliberately small: MCP SDK v2 (core has one dependency), zod, and yaml. Argument parsing is hand-rolled rather than delegated, because a dependency that parses argv in a process holding credential-store keys is not worth the convenience.

The owner layer's own risks

Recorded before the layer was built, and kept here with what was done about each.

Writable memory is an injection persistence channel. Upstream content is already treated as potentially prompt-injecting and passed through unscreened. Owner-authored memory that an agent can write to changes the risk: an injected instruction can be stored once and re-served to every future session, including to a different agent. Read-only memory does not have this property, which is the strongest argument for memory.write being a separate capability in a non-default bundle, which is what it is.

A written skill is the same risk, one turn earlier. A skill is instructions an agent is later handed as its own turn, so an agent that can author one can shape what it does next. ADR-012 §1 first answered this by having no write path at all; ADR-014 replaced that with the same answer memory uses, skills.manage.* out of the default bundle, for a reason worth repeating here. Structural absence read stronger than it was: a skill file is writable by anything running as the owner, so "no agent can write a skill" only ever meant "not through the one path that evaluates policy and writes an audit event". Moving authoring inside that boundary made it governable.

Reading a skill is the narrower risk, and is still withheld by default. skills.manage.get is in the author bundle, not the read one, so an agent that can invoke skills cannot browse them for instructions to give itself.

Neither of these screens anything. They separate a privilege. Nothing in this codebase detects an injection, and no part of it claims to.

An entity is the same channel as memory, and it is the one that gets acted on. ADR-056 adds a store whose whole purpose is to be read just before something is sent. An injected memory entry is re-served as something true and a model may or may not use it; an injected address is used, because it is what the next message is addressed to. It answers this the way memory does, with writing in a non-default bundle, and it takes three rules rather than one: deny: [entities.write, entities.link, entities.forget].

A second, quieter property is worth naming. entities.find returns every match and sets no error when there is more than one, so nothing in the protocol stops a client taking the first. What stops it is the shape of the answer, which states the count before any candidate and says the order is not a ranking, and the instruction the endpoint sends before the first call. That is a design choice rather than an enforcement, and it is recorded as one.

A task is the same channel as memory, and an asset is a smaller one. ADR-051 adds two more stores an agent can write to and that are re-served later. A task is memory's risk with a due date: an injected instruction that presents itself as something the owner asked for. Both answer it the same way memory does, with writing in a non-default bundle: deny: [tasks.add, tasks.update, tasks.remove]. An asset is narrower, because nothing reads one back as instructions unless it is text and something asks for it. But it is bytes the owner did not choose the size of, which is why assets.store carries the same ceiling and the same SHA-256 receipt a mail attachment does.

The profile a fresh install starts from grants more than the engine does. A new profile is created with the whole owner layer allowed, write halves included, and an existing one is repaired on the next start, connect or deploy (ADR-050). Default deny is true of the policy engine, where nothing is reachable without a rule, and not of the file the CLI writes for you. That is a deliberate weakening and it is stated as one in the table above; what bounds it is that every provider in the list holds the owner's own material and reaches no account. Narrowing is one deny line or a second profile, and the ones worth knowing are deny: [memory.write, memory.forget], deny: [skills.manage.*], deny: [vault.put, vault.remove] and deny: [entities.write, entities.link, entities.forget]. Name every capability that changes something: deny: [memory.write] alone leaves memory.forget granted.

Two things it does not do. A vault read is still granted per item: vault.* covers put and remove, and a vault.get.<id> capability exists only for an item already in the store and only after a restart, so a write cannot hand itself a read. And a connection is never granted: an account still takes lanes link connect and a browser.

The repair runs on three commands, and not only for the profile you named. A profile missing a surface serves none of it silently, because the capabilities are absent from tools/list rather than refused, so start, connect and deploy add the connection row and the <provider>.* rule to any profile missing both. deploy does this for every profile it is about to upload, which without --profile is the whole workspace, because a profile it sends is a profile the endpoint will serve. All three print what they added. A deny covering a surface stops the repair and keeps it off; deleting the two lines does not, because the next command puts them back.

Vault reads deserve stricter treatment than other reads. Tools only, never resources, because resources are listable and cacheable, which is wrong for secrets, plus per-item policy through the capability name and aggressive audit redaction. A stored value is recorded as <withheld>, not as a type marker, because a secret's length is a real disclosure.

A vault write cannot hand itself a read. Item capabilities are read when the runtime is built, so an item stored by vault.put is unreadable until the next start. Granting access to a new secret is a deliberate act by the operator between two runs. ADR-014 gave the registry a replace for skills; pointing it at the vault would turn this property into a race, and the tests say so.