Status: Principle 1 complete. Principle 2 — the tool, permission and data axes (PostgreSQL RLS / subject view delegation) — fully implemented.
Date: 2026-09-18 · Branch: master
Virtual Level is GoClaw's model of delegated permissions for SOP runs: a request at user level never reaches admin or system permissions, and within the user level it does only what a SOP defines.
A user level never reaches admin or system permissions, under any circumstances. Within the user level it does only what a SOP defines.
These are two separate statements, and both must hold:
Most systems do the first and forget the second: they hold the user at a low level, and then, when work cannot get done, add a "temporarily make them admin" escape hatch. That escape hatch undoes the first principle exactly. Virtual Level is a proposal to make that escape hatch not exist.
The app_api channel's X-User-Role header (or the JWT role claim) went straight into RuntimeRequestMeta.GoClawRole — that is, into GoClaw's permission tier. There was no check and no allowlist.
The consequence was confirmed by a test:
X-User-Role |
isPrivilegedActor |
isHumanAdmin |
|---|---|---|
user |
false | false |
admin |
true | true |
system |
true | true |
isHumanAdmin is the highest tier — it guards changing identities, reading secrets and the like. In other words, the holder of an app_api key, or a backend that passed on a user-supplied role without checking it, could become a full admin.
A second, longer-lived hole: AutoProvision(...) stored its fourth argument as the GoClawRole in the pairing store. The pairing store is shared across channels. So a single X-User-Role: admin request could create a permanent admin user that another channel would read back.
Not a blocklist. Upper/lower case, spaces, a Cyrillic а that looks like a Latin a — there is always a way around a blocklist. Instead, the data flow was cut:
// internal/channel/appapi/auth.go
const GoClawPrivilegeTier = "user"
identity.Role stays the business role (engineer, manager) — used for agent routing and SOP scoping, never for permissions. GoClawRole is constant.
Locked down by a test (app_api_privilege_test.go) with 11 spellings — admin, SYSTEM, admin , аdmin with a Cyrillic first letter, and so on. The test was confirmed to fail on the old code.
Operational impact: admin actions that used to go through app_api no longer work. An admin comes in through the Web UI or a paired channel, where the role comes from the pairing store — that is, from server-side state.
In the code as it was, two different concepts were squeezed into one field, which is the root of the vulnerability above. The design separates them:
| Axis | Values | Where it comes from | Grants permissions? |
|---|---|---|---|
| Tier | system / admin / user |
Server side: the pairing store, or fixed for app_api | Yes |
| Business role | engineer, accountant, manager |
Request headers / JWT / org directory | No |
| Virtual Level | A named, bounded grant in a SOP | The SOP definition + a scope derived from the subject | Yes, but additive; it never raises the tier |
The key difference: a Virtual Level is a union, not an escalation.
permissions = user tier ∪ the SOP's grant
never
permissions = admin tier ← this case does not exist
A named set of capabilities declared on a SOP, valid only while that SOP runs and only on that subject's rows.
# Inside a SOP definition
virtual_level:
name: expense-report-reader
grants:
tools:
- db_query
datasets:
- view: v_expenses
scope: subject # subject | team | department | tenant
# The following are NEVER written here — the validator refuses them:
# writing system/bootstrap files, secrets, managing cron/SOPs,
# moderation, memory maintenance, agent_config
How a SOP gets one. Only by sending the whole SOP JSON, virtual_level included, to
POST /api/sop (with the gateway token). The sop_save tool has no virtual_level
parameter and rebuilds the SOP from what it is sent, so a save through it removes an existing
virtual level; the Web UI's SOP form does not carry the field either, and saving there removes
it too. sop_rollback restores an archived version with the virtual level it had.
scope decides which session settings are bound:
| Scope | Settings bound |
|---|---|
subject |
subject_ref |
team |
+ subject_manager |
department |
+ subject_department |
tenant |
+ subject_tenant |
So a SOP granted only subject cannot read a view that filters by department — that setting is never bound, so current_setting fails. To see more widely you must change the virtual level; writing a different query does not help.
SQL is not parsed. Checking statements the LLM wrote with a regex or parser is the same mistake as trusting the prompt. Instead, SET LOCAL ROLE — the datasource's delegated_role has SELECT only on the granted views. Write SELECT * FROM expenses and Postgres itself answers permission denied.
Without a delegated_role configured, a delegated query is refused outright — it is not run with the pool's own permissions.
What it is NOT:
isPrivilegedActor(delegatedCtx) is always false.scope: subject is derived from the subject; a caller cannot pass user_id=<someone else> (OWASP IDOR).A constraint from an earlier discussion that still holds:
No attributes are stored in the cron/SOP tables. Only a stable subject_ref (the employee ID). Department, manager and team are resolved at run time from the org system.
Why: job titles change and people leave. A copied attribute goes wrong silently — the SOP would keep producing last month's department report.
flowchart LR
DB["<b>cron_jobs / sop_runs</b><br/>subject_ref = 'emp_10482' (only this is stored;<br/>department, manager, team are NOT stored)"] -->|"at run time"| ORG["<b>org.Resolve('emp_10482')</b><br/>→ { department, manager, team, tenant }"]
The org directory contract is modelled on the field names of the SCIM enterprise user schema (RFC 7643) — even when the backing system is an internal ERP. Why: those fields are already defined, and the translation layer stays in one place.
The internal/org package:
Person — with the field names of SCIM's core + enterprise user extension
(department, division, manager, employeeNumber, active). Even for an
internal ERP, the translation happens in one place, inside the adapter.Directory interface — Resolve(ctx, subjectRef) (*Person, error).Cached — a short TTL (default 60 s). It caches definite answers (the
person was found, or is certainly absent) and does not cache network
errors — those are not facts about the organisation, and caching one would
stretch a single outage over a minute.SQLDirectory — the first backend. It reads the organisation's own
employee table through a configured datasource. The operator writes the
lookup query (every schema differs), and columns are mapped by name.The first backend is SQL rather than MS Graph: employee data usually already lives in the ERP database, it needs no new credentials, and it can be tested directly. MS Graph / Entra is not done — it is ready to slot in behind the same interface.
At run time db_query binds goclaw.subject_department,
goclaw.subject_manager and goclaw.subject_tenant along with the subject —
only those the directory actually returned. An unresolved attribute is not
bound as an empty string: the view would then return 0 rows and look
legitimate, as if "there were no expenses this month". Left unbound,
current_setting fails, and a view that needs the attribute fails loudly.
In the data layer, not in the prompt.
-- A parameterised view. The subject comes from a connection variable,
-- not from SQL the LLM wrote.
CREATE VIEW v_expenses AS
SELECT * FROM expenses
WHERE employee_id = current_setting('goclaw.subject_ref');
The LLM calls db_query, but the view decides what comes back. Even if the LLM is talked into writing WHERE 1=1, the view's filter still applies. On Postgres, Row-Level Security gives a stronger guarantee.
This is the most important point: the LLM must not be the safeguard.
db_query binds the subject during delegated work. Four things are required:
Inside a transaction. Verified on Postgres 17: without a transaction the setting stays on the pooled connection, and the next query to take that connection — quite possibly someone else's — reads the previous person's rows. A rollback clears the setting.
Parameterised — set_config($1, $2, true); the subject goes in as a value and is never spliced into the statement.
Refused when not Postgres. ClickHouse/MySQL/Mongo have no mechanism to bind a subject, so the query is refused outright — by db_query and by mongo_query. Refusing is better than silently returning every row. db_analyze is refused in a delegated run on every driver: it reads the whole schema, and on Postgres sample values from pg_stats, as the datasource's own user.
No SQL from text. A custom setting such as goclaw.subject_ref can be set by any role, so SET LOCAL ROLE does not stop a query from changing it; the checks before the query do. set_config is refused by name, and so are the functions that run SQL given as a string — query_to_xml and its variants, ts_stat, ts_rewrite — because a set_config assembled from pieces inside that string is invisible to any check on the query text. Before they were refused, a delegated run for one employee read another's rows this way on a live Postgres (TestQueryScope_LiveDynamicSQLCannotRebindSubject). The list is a denylist (sqlForbiddenCalls in tool_db.go), so it covers the built-in functions only: a function of your own that runs SQL text (PL/pgSQL EXECUTE) and is granted to the delegated_role reopens the hole. Do not grant the delegated_role such functions, and where nothing else on the database uses them, revoke the built-in ones from PUBLIC:
REVOKE EXECUTE ON FUNCTION
query_to_xml(text, boolean, boolean, text),
query_to_xmlschema(text, boolean, boolean, text),
query_to_xml_and_xmlschema(text, boolean, boolean, text),
ts_stat(text), ts_stat(text, text),
ts_rewrite(tsquery, text)
FROM PUBLIC;
Row-Level Security policies that read current_setting('goclaw.subject_ref') trust the same setting and have the same limit.
When nothing is bound, current_setting('goclaw.subject_ref') fails — fail closed.
system splitIn the code as it was (cron_scheduler.go, runCronJob; today cronRequestMeta):
scopedCtx = WithRuntimeRequestMeta(scopedCtx, RuntimeRequestMeta{
UnifiedUserID: "cron-system",
GoClawRole: "system", // ← isPrivilegedActor = true
Channel: "cron",
})
Every cron job ran with system permissions. A delegated SOP must not inherit that — otherwise a SOP acting on an employee's behalf would have full permissions.
So cron is split into two kinds:
| Kind | Who wrote it | Runs as |
|---|---|---|
| system cron | Admin (maintenance, indexing, cleanup) | cron-system / system — as before |
| delegated cron | A SOP on an employee's behalf | subject_ref / user + the virtual grant |
This split is the prerequisite for everything else. Without it, there is no point building Virtual Level.
Implemented (stage 2).
cronRequestMeta()branches on whetherCronJob.SubjectRefis empty. Empty: the previouscron-system/system. Set: it runs as the subject, at theuserlevel, with aDelegationattached. The trace's owner is the subject too — otherwise the audit record would look as if the system itself had pulled the employee's data. Anagent …command job with a subject sends it to/api/webhookassubject_ref, and the gateway builds the turn's identity throughDelegatedCronMeta, which callscronRequestMetawith no SOP, in a session of the subject's own. It used to run as its creator, or as the system on the web channel. With no SOP there is no virtual level: the turn is granted no view, and itsdb_queryis always refused. Like a delegated SOP run, it works on org memory only (no personal memory, no global oragent_configwrites).
// RuntimeRequestMeta.Delegation (internal/agent/scope.go)
type Delegation struct {
SubjectRef string // on whose behalf the run acts
SOPID string // the SOP whose definition bounds the run
Scope string // subject | team | department | tenant; empty = subject
Views []string // the relations the SOP grants, lowercased
Datasets []DatasetGrant // the dataset grants, with their datasource constraints
}
cronRequestMeta fills Scope, Views and Datasets from the SOP's virtual level. A SOP
with none still runs delegated, with the narrowest scope and no granted views, so a scoped
query has nothing it may read.
The invariant — implemented as the first check of each of the three
predicates in authz.go:
if meta.Delegation != nil {
return false // no later branch can reverse this
}
This holds even when GoClawRole says "system", because meta is copied
between contexts — RunSOPV2 copies its parent's meta, and so do subagents. A
check that looked only at the role field would let one privileged ancestor
escalate everything below it.
Test coverage (delegation_authz_test.go):
it fails when the ceiling is removed; the ceiling survives a copied meta;
non-delegated work keeps its permissions; a real tool (cron create) refuses;
SubjectRef survives a DB round trip.
This does not start from zero. The code already has:
SOP.Tools — SOP-level tool scoping, merged with the step's. Virtual Level's grants.tools builds on it.SOP.Prerequisites — the artifacts a SOP needs (tool/view/table/permission), checked on save and before a run.CapabilityRequest (capability_requests.go) — a record of requests such as "this SOP needs this capability, please approve, admin". Its Kind is tool | view | table | permission — exactly the vocabulary Virtual Level needs.In other words, the approval workflow already exists. What is missing: an approved capability does not become a run-time grant.
Established by reading the code on 2026-08-29. The actual state, not a design statement.
| Component | State | Evidence |
|---|---|---|
| The app_api escalation path | ✅ cut | GoClawPrivilegeTier is constant; app_api_privilege_test.go locks it down with 11 spellings and was confirmed to fail on the old code |
| Pairing store contamination | ✅ stopped | AutoProvision no longer stores the caller's role |
| Other channels | ✅ already correct | Audited — below |
The audit of the other channels (checked afresh during this work):
handler_api.go, handler_websocket.go — the role comes from the pairing store (server-side state). The channel=web → system path is protected by the dashboard token (/gateway and /api/ are behind AuthMiddleware).openai_compat.go — never sets GoClawRole → empty → no privileges. The correct fail-closed behaviour.webhook_router.go — webhook-system, deliberately excluded from isPrivilegedActor.app_api was the only gap. It is now closed.
This principle has two axes. The tool axis was ready; the data axis was at zero when this work started and is now in place for delegated runs.
| Axis | State | Details |
|---|---|---|
| Tool axis | ✅ working | SOP.Tools + step.Tools → SkillsAllow. Not merely what the LLM is offered: dispatch (Execute in registry.go) refuses a tool missing from the list. In SOPStepMode only internal, side-effect-free tools pass automatically — even message must be declared. An empty step falls back to the SOP-level tools; when both are empty it runs text-only; "*" = every tool; Virtual Level always narrows and never widens. RAG tool selection never runs in a SOP. |
| Data axis | ✅ confined (delegated runs) | A delegated db_query (db_scope.go) runs only on Postgres, in a read-only transaction, under the datasource's delegated_role (SET LOCAL ROLE), with search_path pinned to public, pg_catalog, pg_temp, and with the subject and its resolved attributes bound by set_config(..., true). The relations the query reads are checked against the granted views first. Without a delegated_role, or on another driver, the query is refused. A run that is not delegated (no subject_ref: a system cron job, a manual run) is not confined this way and reads what the datasource's own user can. |
A related safeguard that holds: db_query is restricted to SELECT/SHOW/DESCRIBE/EXPLAIN by isReadOnlyQuery — enforced in code (tool_db.go), not promised in a prompt. There is no write risk; the read scope was the problem.
| Component | State | Note |
|---|---|---|
cron system/delegated split |
✅ done | CronJob.SubjectRef → branches cronRequestMeta() |
Delegation structure |
✅ done | RuntimeRequestMeta.Delegation: SubjectRef, SOPID, Scope, Views, Datasets, filled from the SOP's virtual level |
| No-escalation invariant test (delegated) | ✅ done | delegation_authz_test.go — confirmed to fail when the ceiling is removed |
| Org directory adapter | ✅ done | internal/org: SCIM-shaped Person, TTL cache, SQL backend. MS Graph not done |
| Binding the subject on the datasource | ✅ done | db_query uses a transaction + set_config when delegated; refuses when not Postgres. Has a live Postgres test |
| Parameterised views / RLS | ⏳ operator's job | The mechanism is complete; the organisation defines the views/policies and the role itself |
virtual_level in the SOP schema |
✅ done | SOP.VirtualLevel; the scope levels are enforced |
| Grant validator | ✅ done | ValidateVirtualLevel — refuses admin tools, on save and at run time |
| Restricting by role | ✅ done | SET LOCAL ROLE — Postgres closes the base tables. Has a live test |
CronJob already had CreatedByUserID, described as "audit logging only — execution always runs as a system (cron) identity". From that, the conclusion had been: "stage 2 is not adding a new field but making an existing one effective."
That conclusion was wrong. As found during implementation: CreatedByUserID and the subject answer different questions.
| Field | Question |
|---|---|
CreatedByUserID |
Who set up this schedule |
SubjectRef |
Whose permissions it runs with |
When an admin schedules a report for an employee, these diverge: the creator is the admin, the subject is the employee. Had CreatedByUserID become the run-time permission, every job an admin created would have run with admin permissions — not a fix but a new escalation path. So SubjectRef was added as a separate field.
The tool | view | table | permission vocabulary of CapabilityRequest and the checks of SOP.Prerequisites are also ready (section 8).
Principle 1 (no escalation) ████████████████████ complete
Principle 2 — tool axis ████████████████████ complete (already existed)
Principle 2 — permission axis ████████████████████ complete (stage 2)
Principle 2 — data axis ████████████████████ complete (PostgreSQL RLS / view scoping)
The riskiest combined gap used to be: a SOP run by cron has system permissions AND reads every row.
Stage 2 made sure a job with a subject_ref now runs at the user level, with no way to escalate.
The next stage closed the data axis completely, restricting each request to the employee's permitted rows through DatasetGrant and queryScope (goclaw.subject_ref parameterised views, lexically scoped CTE validation, transaction search_path isolation).
Org directory — should the first adapter be MS Graph / Entra, or a direct connection to the internal ERP? Run-time resolution adds to every SOP run; how long should the cache last (proposal: 60 s, 0 for risky SOPs)?
Database — do the views to scope already exist, or do they need defining? Is it Postgres (RLS available), or another source?
Approval flow — should a Virtual Level grant require a CapabilityRequest approval? (Proposal: yes — a SOP author must not be able to write grants at will.)
scope levels — are subject | team | department | tenant enough? A manager's direct reports ("my team") is a separate shape that comes from the org directory.