Codumentor logo Codumentor

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:

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

KeyDefaultMeaning
enabledfalseMaster switch. When false, every user is unrestricted.
profiles{}Named policy bundles (below).
assignments[]Ordered role → profile bindings. First match wins.
default_profileunrestrictedApplied when no assignment matches.
allow_unenforced_profilesfalseDevelopment escape hatch; skips the startup guards. Never use it in a deployment that relies on profiles.

access.profiles.<name>

KeyDefaultMeaning
reposallall, none, or a list of repository names (a scope — see Scoping to some repositories). An empty list means the same as none.
capabilitiesfullCapability 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:

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:

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:

LayerFor a repos: none profile
Sandbox mountsThe 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 registryThe 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).
RetrievalNo 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 / UIThe 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
LayerFor a repos: [payments] profile
Sandbox mountsThe 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.
RetrievalEvery 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 / UIThe repository inventory names only granted repositories. Advisory, as above.

What a scoped user loses beyond the repositories themselves:

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

KeyDefaultMeaning
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_scopethe profile's own reposRepository names an approved question runs against unless the reviewer changes it.
scope_ceilingdefault_scopeThe most a reviewer may grant for one question. Unset means no widening at all.
run_capabilitiesqa_readonlyTool 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.
lintall onRelease-review assists; only used with the release gate. See the lint.

What the requester experiences

  1. 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.
  2. 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.
  3. 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 release gate the answer does not stream — see Reviewing the answer.
  4. 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:

Rules the server enforces, whatever the UI offers:

Grants

An approval writes an explicit grant row: (user, repositories, granted_by,
origin)
. It is live only while that approved run is running — the run ending
is what ends the grant, with no cleanup step that a crash could skip. Two further
clamps make a stale grant safe:

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:

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:

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:

FindingWhat it flags
secretLines 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_codeA 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_pathThe 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]
Controlswhich questions run, and against whatthat, plus what the answer says
Requester waitsoncetwice (drafting, then release)
Reviewer effortone decision per questiontwo, but the second is reading an answer
Right whenthe scope is the riskthe 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 postureWhy
access.enabled with auth.provider: noneEvery request is the same anonymous user; there is no principal to tier.
A restricted profile with isolation offPrompt-level scoping is not scoping (above).
repos: [names] with mode: host_readonlyThat 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_readonlyAn approved run mounts a scope its user has no standing access to; the sandbox is what makes that grant real.
mediation with no reviewer_rolesNobody 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 toolSee 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):

EventWhen
access.profile_resolvedA user's resolved profile changes (not once per request).
access.deniedA surface refused a request because of the caller's profile; the metadata names the surface.
admin.conversation_viewedAn administrator read another user's conversation.
admin.audit_queriedSomeone read or exported the audit log.
review.requestedA mediated user's question parked for review.
review.claimedA reviewer took a question off the open queue.
review.approved / review.editedA question was approved as written / after an edit; the metadata names the granted repositories.
review.denied / review.answered_manuallyA question was declined / answered by the reviewer directly.
review.draftedA release-gated run finished; its answer is now waiting for a reviewer.
review.released / review.redactedAn answer reached the requester as drafted / after the reviewer edited it. The metadata gives the size of the change, never the text.
review.withheldA reviewer refused to release an answer.
review.expired / review.withdrawnA review step timed out / the requester took the question back.
review.queue_viewedA reviewer read the queue or one exchange — a privileged read of someone else's question.
review.evidence_viewedA reviewer read a shadow run's transcript — a privileged read of everything an agent pulled out of a repository.
grant.issued / grant.revokedAn 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:

These are specified in
doc/core-dev/design/access-profiles-and-mediated-qa.md (Phases C–E).