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) | |
|---|---|---|
| What | refresh tokens, app-specific passwords, pasted API tokens, the profile token, and an OAuth client secret where the operator registered one of their own | the owner's own passwords, API keys |
| Authorises | the system itself | nothing, because they are data the owner stores |
| Agent-reachable | never, in any form | yes, under policy, default deny |
| Store | encrypted file, its own key | separate 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.
| Property | Status | Verifier |
|---|---|---|
policy.default-deny | ENFORCED | src/policy/index.test.ts |
policy.tighten-only | ENFORCED | policy composition tests |
policy.deny-beats-allow | ENFORCED | asserted in both rule orders |
control-plane.not-agent-reachable | ENFORCED | src/dispatch/control-plane.test.ts |
config.contract-major-fails-closed | ENFORCED | loader tests |
config.no-secret-values | ENFORCED | validator tests with credential fixtures |
credentials.connection-scoped | ENFORCED | ScopedCredentials isolation tests |
credentials.not-agent-reachable | ENFORCED | context surface assertion |
state.provider-scoped | ENFORCED | ScopedStore isolation tests |
storage.namespace-contained | ENFORCED | traversal rejection tests |
deployed.config-not-self-writable | ENFORCED (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-secrets | ENFORCED | a 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-default | WEAKENED deliberately, and only for providers with no account | a 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-only | ENFORCED | the store interface has no update or delete |
audit.tamper-evident | ENFORCED for edits and mid-run removals | records 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.redaction | ENFORCED | provider redaction tests, including on denials |
discovery.policy-filtered | ENFORCED | tools/list and server/discover, over the wire |
setup.reports-only-reachable | ENFORCED | src/server/setup-surface.test.ts; a denied connection reads as one never made |
setup.no-credential-presence | ENFORCED | missingRequirements is CLI-only; the surface reports requirements, not what is stored |
transport.stateless | ENFORCED | restart-mid-session test |
credentials.encrypted-at-rest | ENFORCED (file adapter) | nothing readable on disk; tamper detection |
profile.isolated | ENFORCED | cross-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-recorded | ENFORCED outside a 30-second reuse interval | a 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-effect | ENFORCED within a five-second window | src/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-profile | ENFORCED per instance | rate limit tests |
edge.pre-auth-metered | ENFORCED (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-bounded | ENFORCED | a 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-recoverable | ENFORCED (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-shareable | ENFORCED (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-invocation | ENFORCED with two documented exceptions | see below |
credentials.client-secret-never-local | ENFORCED (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-local | NOT-GUARANTEED for a connection authorised against the hosted client | see below |
credentials.no-standing-grant | NOT-GUARANTEED for a connection authenticated with a key | see below |
credentials.plaintext-in-use | NOT-GUARANTEED | inherent |
provider.sandboxed | NOT-GUARANTEED | provider code is trusted |
provider.response-may-carry-a-credential | NOT-GUARANTEED for two Discord operations | a 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.controlled | NOT-GUARANTEED | follows from the above |
policy.approval_required | RESERVED | the model carries the state; no engine, and it fails closed |
delegation.external-clients | RESERVED | the 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 theopenidandemailscopes 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
clientInfolabel is self-reported and never consulted for authorization. - An authorised remote client is the owner. Where
auth.authorizationis 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-cloudruntold 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.