Access Profiles
Access profiles scope what one user's agent can reach: which repositories
their conversations may draw on, and which tools their agent may hold. They
exist for two situations:
- a group of less-technical users who should get a general assistant with no repository access at all — the "chat-only" case;
- narrowing a group of users to some of the repositories — a contractor who may ask about
paymentsand nothing else; - letting an outside party ask questions only through a reviewer, who decides what each question is allowed to read before it runs — see Reviewed questions (mediated Q&A).
Everything here is off by default. A deployment without an access: block
behaves exactly as before and pays nothing for the feature.
Access profiles are about scoping a user's own agent. They are not a
replacement for authentication (Authentication) or for
the sandbox (Security Architecture) — they build
on both, and refuse to start without them.
Quick start: a chat-only tier
auth:
provider: ldap # any real provider; `none` is refused (see below)
ldap:
# …
group_role_mapping:
"cn=office,ou=groups,dc=example,dc=com": [office]
plugins:
- module: codumentor.plugins.workspace_isolation
class: WorkspaceIsolationPlugin
args:
enabled: true # required — see "Why isolation is mandatory"
access:
enabled: true
profiles:
office-assistant:
repos: none # no repository is visible, mounted or searchable
capabilities: chat_only
assignments:
- roles: [office] # any-of match against the user's roles
profile: office-assistant
default_profile: unrestricted # everyone else keeps today's behaviour
A user in the office LDAP group now gets a general assistant: attachments,
the document/spreadsheet/presentation editors, web search, mail and calendar
integrations, and a private empty workspace they can actually work in — and no
repository browser, no @-mentions, no code search, no organisational memory,
and none of the tools that reach a system this deployment holds credentials
for (a configured database, an SSH host, an MCP server with a service token).
Configuration reference
access
| Key | Default | Meaning |
|---|---|---|
enabled | false | Master switch for per-user tiers. When false, every user is unrestricted — but see the note below: a task's declared profile still applies. |
profiles | {} | Named policy bundles (below). |
assignments | [] | Ordered role → profile bindings. First match wins. |
default_profile | unrestricted | Applied when no assignment matches. |
allow_unenforced_profiles | false | Development escape hatch; skips the startup guards. Never use it in a deployment that relies on profiles. |
enabled: falsedoes not switch off a task's declared profile. An
event-fired task (tasks.declared:→access_profile:) runs unattended with
its tool prompts auto-approved, and a webhook-sourced one is refused outright
without a profile. That profile is resolved from theprofiles:block below
whether or notenabledis true and applied as an intersection with what
the run's identity holds, because "the operator did not switch on per-user
tiers" is not a reason to run an outside-triggered turn with everything. A
name this block does not define makes such a run refuse rather than fall
back to something wider. See
Configuration →tasks.declared.
access.profiles.<name>
| Key | Default | Meaning |
|---|---|---|
repos | all | all, none, or a list of repository names (a scope — see Scoping to some repositories). An empty list means the same as none. |
capabilities | full | Capability policy name: full, chat_only, or qa_readonly (read/grep/search only; intended as a run_capabilities value, not as a standing tier). |
tools | (absent) | Adjusts the named capability policy for this profile alone. See Adjusting the tool set. |
mediation | (absent) | Present ⇒ this profile's users' questions must be approved by a reviewer before they run. See Reviewed questions. |
personal_connections | true | false withholds every model connection a user owns — their ChatGPT sign-in and endpoints of their own — from this profile's users. See Personal model connections. |
Two profile names are built in and cannot be redefined:
unrestricted— today's behaviour: every repository, every tool.restricted_fallback— no repositories, chat-only. Usable as adefault_profilewhen you want "deny unless explicitly granted", and applied automatically when a principal's identity cannot be resolved (below).
Assignment and roles
Roles are the group carrier. Both LDAP directories and the local provider
already map directory groups (or static config) onto roles, so a role is the
natural handle for a tier — no new identity concept is introduced.
Assignment is ordered first-match, not a merge: a user holding two roles
gets the profile of the first assignment that matches, and merge semantics for
security policy (additive for grants, restrictive for limits) have no single
sane rule. List the narrowest tier first.
Profiles are resolved server-side on every request and every turn from the
user's roles. They are never a token claim and never read from conversation
metadata, so:
- tightening a profile takes effect on the user's next turn — no token reissue, no logout;
- nothing a user can write (including conversation metadata) can influence their own tier.
What chat_only removes — and what it keeps
The chat_only capability policy denies two different things, for two
different reasons.
Tools that reach the repository estate. Retrieval over ingested
repositories (kb_search, memory lookup/store), repository search and
navigation (ripgrep, explore, agentic_developer), subagents, source
control (git_commit, the Gitea PR tools), and the out-of-sandbox doors
(goal_*, log-collection workspaces, remote SSH).
Tools that run on the deployment's own credentials. Any tool that reaches a
system with access the operator configured — a database connection, an SSH
host and key, an MCP server holding a service token. These are not repository
tools and no deny list would have caught them; see Tools that run on the
deployment's credentials.
Everything else stays, including two families it is worth being explicit about:
- The workspace tools —
read_file,write_file,replace_in_file,shelland the background shells. For arepos: noneprofile the sandbox is stacked on an empty directory, so what these reach is the conversation's own scratch space and its read-only attachments mount. Withholding them protected nothing and cost the tier its whole purpose: the user attaches a spreadsheet, and the agent has to be able to open it, edit it and hand a copy back. The boundary is the mount plan, not the tool list. - The client-filesystem bridge (
client_fs_) — the folder a user attaches from their own browser holds their* files, not the estate's, and the server-side half of every copy resolves through the same empty workspace. It is refused only for a partial scope, which is the one thing it cannot express.
…plus attachments and the office-format editors, web search and retrieval,
per-user integrations (mail, calendar, issue trackers), MCP servers that
authenticate as the calling user, and diagram rendering.
Enforcement runs at five layers, and only the first four are load-bearing:
| Layer | For a repos: none profile |
|---|---|
| Sandbox mounts | The overlay base is an empty directory. No path under repos_dir is named in the sandbox at all. /workspace exists and is writable, so attachments, generated documents and the file tools keep working — against nothing but the conversation's own content. |
| Tool registry | The denied tools are never constructed, and a run-last filter prunes anything the plugin stack contributes on top (it fails closed: an error empties the catalog rather than passing it through). |
| Retrieval | No knowledge base is built for the turn, so kb_search cannot exist. Org memory is withheld as a whole: no recall is injected into the prompt, no storage agent runs on the conversation, and nothing in the transcript says so (see Org memory). |
| API surfaces | /ui/config returns no repositories, @-mention search returns nothing, the repository-scoped plugin routers 403, and the OpenAI-compatible endpoint refuses restricted principals. |
| Prompt / UI | The repository inventory is replaced by a short note saying there are no repositories and what the workspace is for; the UI hides repo widgets. Advisory — a consequence of the layers above, never the control. |
Subagents inherit their parent's profile verbatim, so a restricted user cannot
gain tools by delegating.
**The workspace tools make the sandbox non-optional in practice, not just in
principle.** The startup guards already refuse a restricted profile without
workspace_isolation(below). If you switch them off with
allow_unenforced_profiles, a chat-only user'sshellruns on the host with
the wholerepos_dirin reach — the boot log says so on its own line.
Org memory and other estate-wide features
Some plugin functionality is not a tool at all: agentic memory injects recalled
notes into the prompt and, after each turn, runs a curator agent that distils the
conversation into shared notes. A tool decision cannot reach that, so plugins
declare it separately — as an access feature — and the profile decides.
Agentic-memory notes are distilled from the whole estate and shared across users,
with no per-repository provenance to filter on. So for any profile that does not
hold every repository (repos: none, a subset, a mediated run):
- no recall — neither turn-start cues nor the one-line term hints, nor the mid-turn scan, nor the
memory_lookuptool. The notes may hold what the user has no access to. - no storage — the curator never runs on their conversations. Their conversation is not the organisation's to distil, and a restricted user's text should not shape what every engineer is later told.
Both are withheld silently: no card, no error. The plugin's settings toggles,
welcome widgets and pages are not sent to these users either, and its API
routes refuse them. (A deployment where the memory repository is misconfigured
still shows its configuration-error card — to the users who would have had
memory.) A storage job queued before a user's profile tightened is re-checked
when it runs, and cancelled if it no longer applies.
Recall and storage are also paired with their tools, so a repos: all profile on
capabilities: chat_only loses them along with memory_lookup and
agentic_memory_store; tools.allow: [memory_lookup] brings recall back with the
tool. The repository-scope floor is not adjustable: no tools setting gives
org memory to a profile short of the whole estate.
Three other plugins put repository content in front of the agent without going
through the mount plan. They narrow to the profile's repositories rather than
switching off, so a scoped user keeps what belongs to their repositories:
| Plugin | What a user outside a repository no longer gets from it |
|---|---|
skills | Its project skills (.codumentor/skills/) — in the prompt's skill list, as / commands, and from the skills API. A hidden project skill that overrode an organization skill of the same name gives way to it. |
agent_instructions | Its CLAUDE.md / AGENTS.md / … files (only relevant with repo_local_enabled). |
permalink | Links into it in the answer — and the existence check behind a bare file name, which would otherwise confirm that the file is there. |
Tools that run on the deployment's credentials
Some tools reach a system by presenting a credential from the instance
configuration: a database connection under plugins: … database, an SSH host
with a key on the server, an MCP server with a service token in its headers.
Calling one of those launders authorization — the caller gets whatever the
operator's credential can reach, and their own entitlements in that system are
never consulted.
That is a different risk from anything a repository deny-list describes, and it
is invisible to one: sql_query is not a repository tool, and nobody adding the
database plugin for the engineering team is thinking about the office tier
that happens to share the instance.
So tools declare it and policies decide on it:
- a tool sets
shared_credentials = Truein code when its access belongs to the deployment (doc/plugin-dev/tools.md); chat_onlyandqa_readonlyhold none of them;fullholds all of them.
A tool that authenticates as the calling user is deliberately not in this
category, and that is the distinction that keeps the chat-only tier useful: an
issue tracker the user signs into with their own API key, a mailbox they granted
through OAuth, an MCP server reached with a ${secret:…} header they pasted
themselves. Nor is a public service behind a metered API key — a web-search key
is a billing identity, not an authorization one.
In tree today the flagged families are the sql_* tools and any MCP server
whose credential is not per-user.
Adjusting the tool set: tools
access.profiles.<name>.tools adjusts the named capability policy for that one
profile. Three keys:
access:
profiles:
office-assistant:
repos: none
capabilities: chat_only
tools:
allow: [sql_query, sql_list_tables] # exact names; wins over everything
deny: [web_search, gmail_*] # exact names, or a trailing `*`
shared_credentials: false # follows `capabilities` when unset
| Key | Meaning |
|---|---|
allow | Tool names this profile keeps whatever else would remove them — the capability policy's own denials, a deny below, and the shared-credentials gate. This is how you hand one tier a read-only reporting query and nothing more. |
deny | Tool names this profile loses. A trailing denies a prefix (sql_). "*" on its own closes the catalog — see below. Where allow and deny overlap, allow wins. |
shared_credentials | true re-admits the whole flagged family — for a deployment whose integrations all authenticate as the calling user. false withholds it even from capabilities: full. |
A closed catalog: deny: [""]. On its own, means *deny everything the
allow list does not name* — including a tool some plugin registers next month.
That is the shape for a tier whose tool set is a deliberate short list:
kiosk:
repos: none
capabilities: chat_only
tools:
deny: ["*"]
allow: [web_search, web_retrieve, read_content, render_mermaid]
Be exhaustive when you use it — a closed list withholds what it does not name,
so an agent missing read_content cannot read a tool result the content store
paged out, and one missing the office-editor tools cannot open the attachment
its user just sent. Prefer the deny list of names for anything narrower than
"this tier has four tools".
allow takes no * form, and the config is refused if you write one.
Capability policies have to intersect exactly (a task-declared narrowing bounds
an unattended run through that intersection), and "everything starting with
sql_ except the two names the other side denies" is not expressible as a
single policy — so the spelling is rejected at load rather than approximated at
resolution. Reach for shared_credentials: true instead, which is what sql_*
almost always means.
A tools block makes a profile restricted even when repos: all and
capabilities: full — so the surfaces that refuse restricted principals
wholesale (share viewing, /v1/chat/completions) refuse it too. That is the
same rule any other narrowing gets, and it is the conservative direction: those
two surfaces have no per-user tool set in them at all.
Two things tools does not touch:
- a mediated run's catalog, which is
mediation.run_capabilities— its own declaration, and one the boot guard checks separately; - the repository scope floors. Org-memory recall and
goal_*are denied for any profile short of the whole estate regardless of whattools.allowsays, because those follow from the scope and not from the policy.
Scoping to some repositories
repos: also takes a list. The user keeps the repository tools — that is how
they read what they were granted — and every layer narrows to the list:
access:
enabled: true
profiles:
payments-contractor:
repos: [payments, payments-docs] # exact configured repository names
capabilities: full # the scope is the restriction here
assignments:
- roles: [contractor]
profile: payments-contractor
| Layer | For a repos: [payments] profile |
|---|---|
| Sandbox mounts | The session snapshot is built from the granted repositories only, and /workspace is the overlay over it. Nothing else in repos_dir is named in the sandbox. A request for host-writable access to another repository fails the turn. |
| Retrieval | Every kb_search query carries a positive repository allowlist, so chunks of other repositories — including ones left behind by a repository you removed from repos: — cannot come back. Naming another repository in the repository argument is refused, and the error does not disclose it. |
| API surfaces | /ui/config lists the granted repositories only; @-mention search is filtered per repository (before its result cut, so the picker stays useful); file_links serves granted repositories plus the user's own conversation workspace; branch switching and commit/push resolve per repository and 403 otherwise. |
| Prompt / UI | The repository inventory names only granted repositories. Advisory, as above. |
What a scoped user loses beyond the repositories themselves:
- Org memory — recall over agentic-memory notes, the memory tools, and storage of their conversations into it. Notes are distilled from the whole estate and carry no per-repository provenance, so there is nothing to filter on yet (see Org memory). Their own conversation history is untouched.
- Shares and the OpenAI-compatible endpoint — a share link is readable by any authenticated user, and
/v1/chat/completionshas one process-wide agent with no per-user profile in it. Both refuse any restricted profile. - Ingestion triggers, and the plugin surfaces that cannot express a partial scope — goals, Gitea PRs, SVN, the client-filesystem bridge and log collection 403 wholesale for a partial scope rather than half-applying it. The
goal_andclient_fs_tools go with them. Two different reasons: the goal knowledge base is written on the real filesystem, outside the sandbox mount the scope is built from; the client-filesystem bridge names a path in the working directory and nothing downstream re-derives which repository that path came from. Note the asymmetry —client_fsis refused for a partial scope only, and works forrepos: allandrepos: nonealike. - External agents (
claude_code,codex, …) — the backend CLI runs with its own working directory and sandbox derived fromrepos_dir, so it cannot be narrowed to a subset; no backend is offered to a scoped user. - Dynamic
@-mention providers — their items carry no repository attribution to filter on.
If a profile grants repositories and also needs a reduced tool set:
qa_readonly is the read-only repository policy (read, grep, knowledge search —
no writes, no shell, no subagents). It exists for the mediated runs below, and a
standing tier that wants it can name it directly. capabilities: chat_only
alongside a grant is a thinner combination than it sounds: the workspace tools
come with it, so the granted repositories are readable — what it removes is
retrieval, search, subagents and source control. For anything more specific,
tools adjusts a policy per profile.
Changing a scope
A scope change takes effect on each conversation's next turn. A workspace built
under a wider scope is rebuilt rather than resumed — the recorded scope is
compared on resume — so a tightened profile cannot keep an older, wider
workspace alive. The cost is that the conversation's sandbox edits (its overlay)
are discarded at that point, like any workspace rebuild.
Reviewed questions (mediated Q&A)
A mediation: block on a profile makes its users mediated: their questions
do not run at all until a privileged reviewer approves them, and the approved run
reads only the repositories that approval granted. This is the shape for an
outside party — a contractor, an auditor, a supplier — who needs answers about
your code without standing access to it.
access:
enabled: true
profiles:
contractor-qa:
repos: none # no standing repository access whatsoever
capabilities: chat_only
mediation:
gates: [intake] # the question is reviewed before it runs
reviewer_roles: [qa-reviewer]
default_scope: [payments, billing]
scope_ceiling: [payments, billing, platform-core]
run_capabilities: qa_readonly
expire_after: 14d
assignments:
- roles: [contractor]
profile: contractor-qa
- roles: [qa-reviewer]
profile: unrestricted
default_profile: unrestricted
access.profiles.<name>.mediation
| Key | Default | Meaning |
|---|---|---|
gates | [intake] | [intake] reviews the question before it runs. [release] reviews the answer before the requester sees it. Either alone, or both — see Reviewing the answer. |
reviewer_roles | (required) | Roles whose holders may decide on this profile's questions. At least one — a profile nobody can review would park every turn forever. |
default_scope | the profile's own repos | Repository names an approved question runs against unless the reviewer changes it. |
scope_ceiling | default_scope | The most a reviewer may grant for one question. Unset means no widening at all. |
run_capabilities | qa_readonly | Tool policy of the approved run. Must be a closed policy (full and chat_only are refused — see below). |
expire_after | (never) | How long one review step waits before it expires: <count> + m/h/d, e.g. 48h, 14d. The clock restarts when a drafted answer starts waiting for release. |
lint | all on | Release-review assists; only used with the release gate. See the lint. |
What the requester experiences
- They ask a question as usual. The message is saved, the turn parks, and the transcript shows "waiting for a reviewer" with a Withdraw button. The conversation gets its own marker in the sidebar.
- No agent is built, no workspace is mounted, and no model call is made while the question waits — a declined question never touched a repository, and it never enters the conversation's model history either.
- When the reviewer decides, the turn continues in place: an approved question streams its answer normally; a declined or expired one ends with the reviewer's reason as a notice (not an error). With the
releasegate the answer does not stream — see Reviewing the answer. - If the reviewer edited the question, the edited text is what ran, and the requester is told so. Nothing is silently rewritten.
What the reviewer does
Reviewers get a Reviews entry in the header menu with a count badge, and a
queue at /reviews. Per question they can:
- Approve and run — with a scope picker offering exactly
scope_ceiling, pre-selected todefault_scope. The run happens in the requester's own conversation underrun_capabilities— or, with thereleasegate, out of their sight. - Edit and approve — the edited text runs, and the requester sees it.
- Decline — with a note that the requester reads.
- Answer it yourself — no agent runs at all; the reviewer's text becomes the assistant's answer. Often the cheapest and safest path.
- Claim — takes the question off the open queue so two reviewers do not write the same answer.
Rules the server enforces, whatever the UI offers:
- A reviewer may not review their own question. This holds for administrators too — holding every permission is not the same as being a second pair of eyes.
- A requested repository outside
scope_ceilingis dropped, not granted, and the reviewer is told which names were dropped. - Only the requester may withdraw their own question. A reviewer who wants a question gone declines it, which leaves a reason and an audit row.
- Two reviewers deciding at once: one wins, the other gets a conflict.
Grants
An approval writes an explicit grant row: (user, repositories, granted_by,. It is live only while that approved run is running — the run ending
origin)
is what ends the grant, with no cleanup step that a crash could skip. Two further
clamps make a stale grant safe:
- the granted names are re-checked against the profile's current
scope_ceilingon every resolution, so tightening the ceiling narrows even an in-flight run; - removing the
mediation:block stops previous approvals from widening anyone.
Outside a mediated run the requester is exactly as scoped as their profile says:
the grant does not let them browse the granted repository in the UI, only lets
that approved question read it.
Why run_capabilities must be a closed policy
A mediated run holds a repository scope its requester was never granted, so its
tool set — not the mount plan — is what bounds what can leave. full and
chat_only are allow-by-default: they can say nothing about a tool some plugin
registers next month, so startup refuses them here. qa_readonly lists what it
allows (read, grep, knowledge search) and nothing else; shell is deliberately
absent, because "read-only" is not a property of a command line.
Expiry
A question past its expire_after deadline is expired by a background sweep
(once a minute), the requester's turn is closed with a notice, and the transition
is audited. A reviewer deciding at the same moment either wins or gets a
conflict — never both.
What mediation does not defend against
Stated plainly, because the compensating control is different in each case:
- A malicious reviewer. They hold the clearance by definition; maker–checker assumes the checker's integrity, and the audit trail is what makes their decisions attributable.
- What the answer says — with
gates: [intake]only. There the reviewer approves the question, not the answer. The scope andqa_readonlybound what the run can read, but a wide-open question against a granted repository can still produce a revealing answer. If the answers are the risk, add thereleasegate (below); intake review alone is not an IP control. - Other channels. The gate lives at the shared turn-scheduling seam, so Telegram, Slack, scheduled runs and webhooks inherit it. The OpenAI-compatible endpoint (
/v1/chat/completions) has no per-user turn path at all and therefore refuses mediated principals outright.
Reviewing the answer (the release gate)
Add release to gates and the answer is reviewed too. This is the setting
for IP-sensitive work: intake review controls which questions run, release review
controls what leaves.
mediation:
gates: [intake, release] # both: the question, then the answer
reviewer_roles: [qa-reviewer]
default_scope: [payments]
scope_ceiling: [payments, billing]
run_capabilities: qa_readonly
expire_after: 48h
lint:
secret_scan: true
max_verbatim_code_lines: 10
flag_file_paths: true
The answer is written somewhere the requester cannot reach. An approved
question is not answered in their conversation. It runs in a separate,
system-owned shadow conversation, and the requester is simply not its owner —
so every route that serves conversation content refuses them, and the draft cannot
appear in their transcript, their search results, a share link, or an export.
Nothing is filtered; there is nothing there to filter. The requester's own
history therefore only ever contains released content, which is also what the
next question's context is built from.
The run itself is scoped exactly as under intake review: the granted repositories
are mounted, run_capabilities bounds its tools, and the sandbox unshares the
network — so the only way anything leaves that run is the answer a reviewer reads.
What the requester experiences
They wait, and the transcript says which wait it is: waiting for a reviewer,
then preparing an answer, then an answer is ready and waiting to be released.
Withdraw disappears once the question has run — there is nothing left to take
back. Then either the answer appears as a normal assistant message, or the turn
ends with the reviewer's reason. They are never shown the draft, the shadow
conversation, or which repositories the answer came from.
What the reviewer does
The queue holds both kinds of work in one list, tagged question or answer. On
a held answer:
- Release as drafted — the text goes to the requester unchanged.
- Release the edited answer — edit the draft first; the requester gets your version, and both texts are kept, so the redaction is auditable.
- Withhold — the requester is told the answer cannot be shared, with your note as the reason. The draft is kept but never delivered.
- Show the evidence — the shadow run's transcript: every tool call and what it returned, plus the scope that bounded it. This is a privileged read of another user's answer, so it is audited on its own event.
Rules the server enforces here too: a reviewer cannot release their own
question's answer (administrators included), a second reviewer deciding at once
gets a conflict, and releasing an empty draft is refused — a run that produced
nothing must be answered or withheld, not "released".
The lint: advice, not a veto
The release screen sorts three kinds of finding to the top. None of them blocks a
release — the reviewer judges, the lint only makes sure they are looking at the
right lines:
| Finding | What it flags |
|---|---|
secret | Lines shaped like credentials: private-key blocks, provider tokens, JWTs, credentials in URLs, an assigned password/api_key/client_secret. Obvious placeholders (${VAR}, <your-token>, changeme, masked values) are not flagged — a lint people learn to ignore is worse than no lint. |
verbatim_code | A fenced code block longer than max_verbatim_code_lines. The most useful signal on the screen: it separates an answer about the code from a copy of the code. |
file_path | The repository paths the answer names, listed so you do not have to hunt for them. |
Set max_verbatim_code_lines: 0 to disable that check, or turn the others off
with secret_scan: false / flag_file_paths: false. Turning all of them off
is supported and logs a warning at boot — make sure it is deliberate.
Timeouts under two gates
expire_after applies per step: the question waiting, then the answer waiting.
The clock restarts when a draft lands, so a slow intake review cannot expire the
answer the moment it appears. An expired draft is discarded, never released —
a deadline running out is not a clearance.
Choosing between the two gates
[intake] | [intake, release] | |
|---|---|---|
| Controls | which questions run, and against what | that, plus what the answer says |
| Requester waits | once | twice (drafting, then release) |
| Reviewer effort | one decision per question | two, but the second is reading an answer |
| Right when | the scope is the risk | the content is the risk (IP, regulated data) |
[release] on its own is also valid: nobody vets the question, but the answer is
still drafted out of sight and released only deliberately. Useful when the
question set is uncontroversial and the disclosure is not.
Personal model connections
A user may bring models of their own: Sign in with ChatGPT (the plan of
their ChatGPT account, via the openai_codex plugin) or an endpoint they type
in — an API key, a local server (the model_profiles plugin). The picker shows
them in a "Yours" group badged Leaves your organization. Every user may
connect a ChatGPT plan by default (the models:connect_personal permission is
in the user role); typed-in endpoints still need model_profiles:manage.
Two switches take them away:
model_selection:
personal_connections: false # nobody, subscriptions and endpoints alike
access:
profiles:
contractors:
personal_connections: false # this profile's users only
For a user either one applies to:
- the picker has no "Yours" group (it is absent, not greyed);
- selecting one of their connections — for a conversation, as the default for new conversations, as a Background work choice — is refused with 403 and the reason; the Sign in with ChatGPT door and the API key / Local server doors refuse the same way, and Settings → Models lists none of their own connections (the organization's stay), offers no door and says why;
- every turn decides again, unattended runs included: a conversation that was on their ChatGPT plan when the profile tightened moves to
model_selection.default.modelat its next turn, with a transcript line saying so (ChatGPT is not available here; using Ardincoder.). It stays there — if the policy loosens later, going back is the user's pick. A Background work choice or a per-role pin naming one of their connections never reaches it either.
An unattended run (a scheduled task, a webhook trigger, a goal tick) runs as
its owner, so the owner's profile decides — and a task's declared
access_profile with personal_connections: false keeps that task's runs off
the owner's plan even when the owner's own profile allows it (the declaration
only takes away). An identity that cannot be resolved gets no personal
connections (restricted_fallback).
The connections themselves are not deleted: a withheld ChatGPT account stays
connected and the user can still disconnect it. Refusals by a profile are
audited as access.denied with the door as the surface; the admin switch is
configuration and writes no audit row.
The gate is not part of what makes a profile "restricted" for the surfaces that
refuse restricted principals outright (shares, the OpenAI-compatible endpoint,
the ops ingest): the connections are withheld where they are built, so a
profile that only turns them off keeps those surfaces. The OpenAI-compatible
endpoint runs as the caller its token authenticates — the request body's
user field is only a label — so a caller cannot reach another user's
connections by naming them; a request with no user behind it (the static key,
api_provider: none) runs as api:<user>, which owns no connections.
Why isolation is mandatory
Startup refuses a restricted profile when the workspace_isolation plugin
is not active. Without it, the shell and file tools run on the host with the
whole repos_dir in reach; a profile would then be a suggestion in the prompt
rather than a boundary. The sandbox mount namespace is the confidentiality
boundary — see Security Architecture.
This guard carries more weight than it used to. chat_only holds the shell and
file tools precisely because the mount plan puts an empty directory in front
of them, so the guard is no longer a belt-and-braces check on top of a tool
list — it is the whole mechanism. allow_unenforced_profiles logs a second
warning line naming the profiles it exposed.
The other refusals, in the same spirit as the unauthenticated-bind guard:
| Refused posture | Why |
|---|---|
access.enabled with auth.provider: none | Every request is the same anonymous user; there is no principal to tier. |
| A restricted profile with isolation off | Prompt-level scoping is not scoping (above). |
repos: [names] with mode: host_readonly | That sandbox mode mounts the whole host read-only, so every repository stays reachable whatever the profile says. Use the default overlay mode for scoped principals. (repos: none is enforceable in that mode — it overrides it.) |
A mediation: block with isolation off, or a mediation scope under mode: host_readonly | An approved run mounts a scope its user has no standing access to; the sandbox is what makes that grant real. |
mediation with no reviewer_roles | Nobody could ever approve a question, so every turn by those users would park forever. |
mediation.run_capabilities that is allow-by-default, or admits a write/push/execute tool | See Why run_capabilities must be a closed policy. |
A repository name that matches no configured repository is not an error: it
grants nothing, which is the safe direction, and repos: and access: reload
independently, so naming a repository you are about to add is legitimate. It is
logged as a warning at boot — check the log after a typo.
Each refusal prints what to change. access.allow_unenforced_profiles: true
bypasses all of them for a development box and logs a warning on every boot.
Unresolvable identities fail closed
Resolution for a turn reads the user's roles from the user store (written at
every login). If the identity cannot be established — an unknown id, an
unreachable store, a synthetic principal — the user gets
restricted_fallback (no repositories, chat-only), not the default profile,
and the reason is logged as a warning. If you enable access profiles and see
those warnings, some turn path is running under a principal that is not a real
user; fix that rather than widening the default.
Changing profiles at runtime
access: is hot-reloadable (POST /ui/admin/config/reload, SIGHUP, or the
auto_reload watcher). A reload rebuilds the policy — routes see new profiles on their next request —
and flushes cached agents so each conversation rebuilds its tool catalog on its
next turn. A tightening is in force immediately even before the flush,
because the run-last filter re-resolves the profile every turn. A reload whose
access: block would not pass the startup guards is rolled back: the reload
report lists it as a failed section and the instance keeps the posture it booted
with.
Auditing
Profile decisions land in the audit log (audit.db), readable by an
administrator at GET /ui/admin/audit and exportable as JSONL at
GET /ui/admin/audit/export (both need the admin:audit permission, i.e. the
admin role):
| Event | When |
|---|---|
access.profile_resolved | A user's resolved profile changes (not once per request). |
access.denied | A surface refused a request because of the caller's profile; the metadata names the surface. |
admin.conversation_viewed | An administrator read another user's conversation. |
admin.audit_queried | Someone read or exported the audit log. |
review.requested | A mediated user's question parked for review. |
review.claimed | A reviewer took a question off the open queue. |
review.approved / review.edited | A question was approved as written / after an edit; the metadata names the granted repositories. |
review.denied / review.answered_manually | A question was declined / answered by the reviewer directly. |
review.drafted | A release-gated run finished; its answer is now waiting for a reviewer. |
review.released / review.redacted | An answer reached the requester as drafted / after the reviewer edited it. The metadata gives the size of the change, never the text. |
review.withheld | A reviewer refused to release an answer. |
review.expired / review.withdrawn | A review step timed out / the requester took the question back. |
review.queue_viewed | A reviewer read the queue or one exchange — a privileged read of someone else's question. |
review.evidence_viewed | A reviewer read a shadow run's transcript — a privileged read of everything an agent pulled out of a repository. |
grant.issued / grant.revoked | An approval granted repositories to a requester for one question / that authorisation is spent. |
Audit rows carry ids and scopes, never the question or answer text: the
conversation store holds the content, audit.db holds who was authorised to do
what. The exchange itself (question snapshot, decisions, granted scope, a
reviewer's own answer) is kept alongside the conversation database and survives
the conversation being deleted.
Export to a system outside the host is the real retention story: a SQLite file
next to the application is tamper-evident at best, never tamper-proof against
whoever owns the host.
Status
Implemented: profiles, role assignment, the chat_only and qa_readonly
capability policies, per-profile tools: adjustments over either, the
shared_credentials tool trait and the families that declare it,
repos: all / repos: none / repos: [names], the enforcement layers above,
the startup guards, the audit events and read path, the intake review gate
(mediation.gates: [intake]) with its reviewer queue, per-question grants and
expiry, and the release review gate (mediation.gates: [release]) with
shadow runs, the draft lint, the evidence view and release/redact/withhold.
Not implemented yet:
- Dual-control release (two reviewers on one answer).
- Time-boxed standing grants ("give this contractor
paymentsfor two weeks"), per-user resource limits, and path-scoped grants (only a repository's interface docs). - Reviewer notification outside the app (Telegram, e-mail, browser notifications). Reviewers see a count badge and the queue while the app is open.
- User-registered repositories and per-conversation repository selection (
doc/core-dev/design/dynamic-repo-sets.md).
These are specified in
doc/core-dev/design/access-profiles-and-mediated-qa.md (Phases C–E).