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 no repository browser, no @-mentions, no code search, no
shell, and an empty workspace.
Configuration reference
access
| Key | Default | Meaning |
|---|---|---|
enabled | false | Master switch. When false, every user is unrestricted. |
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. |
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). |
mediation | (absent) | Present ⇒ this profile's users' questions must be approved by a reviewer before they run. See Reviewed questions. |
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
The chat_only capability policy denies the repository-facing tool families:
the workspace tools (shell, read_file, write_file, replace_in_file,
background shells), 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_, client_fs_, log-collection
workspaces, remote SSH).
Everything else stays: attachments and the office-format editors, web search
and retrieval, per-user integrations (mail, calendar, issue trackers), MCP
servers, diagram rendering.
Enforcement runs at four layers, and only the first three 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 still exists and is writable, so attachments and generated documents keep working. |
| 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 and the memory-recall path that rides on it is absent. |
| 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 empty and 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.
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, and the memory tools. Notes are distilled from the whole estate and carry no per-repository provenance, so there is nothing to filter on yet. 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_*tools go with them: the goal knowledge base is written on the real filesystem, outside the sandbox mount the scope is built from. - 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, combine it
with capabilities: chat_only — but note chat_only denies the repository tools
outright, which makes the grant pointless. 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.
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.
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.
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, 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).