Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/wise-owls-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@noormdev/cli": minor
"@noormdev/sdk": minor
---

## Access Roles

Replace the per-config `protected: boolean` flag with per-channel access roles.

**Breaking:** `Config.protected` is removed from the exported types and `access: ConfigAccess` is now required on `Config`/`ConfigSummary`. TypeScript consumers that read `.protected` or construct `Config` literals must move to `access`. Stored state auto-migrates on load (see below), so runtime configs are unaffected. (Released as `minor` under the `1.0.0-alpha` pre-release line, where `^` ranges pin within the alpha series; it is a breaking type change and will be treated as such at the 1.0 boundary.)

* `feat(policy):` Configs now carry `access: { user, mcp }`, each `'viewer' | 'operator' | 'admin'` (or `mcp: false` to hide the config entirely). Roles are enforced by one policy matrix instead of scattered `protected` checks — `viewer` reads only, `operator` writes and confirms destructive operations, `admin` is frictionless. Enforcement runs at the core seam, so the SDK, TUI, and CLI all inherit it: `run`, `changes`, `transfer`, and the SQL terminal are gated, not just MCP.
* `feat(mcp):` Every MCP command is now gated on `access.mcp` before it runs, closing the gap where `change_run`/`change_ff`/`change_revert`/`run_file`/`run_build` reached the database unchecked. `access.mcp: false` makes a config invisible to agents — absent from `list_configs`, and `connect` fails with the same error an unknown config produces. `confirm`-tier permissions collapse to a hard deny on the MCP channel (no human to type a confirmation phrase); give a config `mcp: 'admin'` if an agent legitimately needs to run changes there.
* `feat(sql):` Raw SQL (`noorm sql`, MCP `sql`, the TUI SQL terminal) is now gated by what the statement actually does — classified `read`/`write`/`ddl` — instead of a blanket read-only check. Data-modifying CTEs (`WITH … AS (DELETE …) SELECT …`) and a denylist of side-effecting functions (`pg_terminate_backend`, `dblink_exec`, `query_to_xml`, … — bare or schema-qualified) classify as writes so a `viewer` cannot mutate through the read path. Multi-statement input takes the highest class present; unparseable input or `EXEC`/`CALL` classify as `ddl` (fail closed). The classifier guards against mutation, not disclosure — back hard confidentiality with database `GRANT`s.
* `feat(sdk):` `createContext({ channel })` — defaults to `'user'`; set `'mcp'` when embedding the SDK behind an MCP-like surface. New exported types `Channel`, `ConfigAccess`, `Role`. `ProtectedConfigError` is now raised from `checkPolicy` denials/unconfirmable actions rather than a bare `protected` check; match on `err.name`/`instanceof`, not the message string (the message text changed).
* **Behavior change:** on the `user` channel, `NOORM_YES=1` resolves an operator `confirm` to allow — including in the SDK guards. A migrated `protected: true` config becomes `operator`, so an SDK/CI caller with `NOORM_YES=1` set now *executes* `truncate`/`teardown`/`reset`/`dt.import`/`changes.*`/`run.*` where the previous `protected` hard-block threw unconditionally. Unset `NOORM_YES` (or use `viewer`/`mcp:false`) where the block must hold.
* `fix(config):` A legacy `protected: true` migrates automatically to `{ user: 'operator', mcp: 'viewer' }`, and `protected: false`/absent to `{ user: 'admin', mcp: 'admin' }`. The `protected` field is still accepted on `config import`/settings input for this release, then removed. Note: downgrading to a prior binary after this migration silently unprotects configs (older binaries have no access concept) — avoid rolling back once state is migrated.
18 changes: 11 additions & 7 deletions .claude/project/followups/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@

Auto-generated by `atomic followups render`. Do not edit.

Open: 2 • Stale: 0 • Last rendered: 2026-06-06
Open: 7 • Stale: 0 • Last rendered: 2026-07-08

## 📋 plans (0)
## 📋 plans (1)

(none)
- [configurable-sql-function-policy](configurable-sql-function-policy.md) — Configurable per-config SQL function allow/deny list + TUI editor → src/core/policy/classify.ts:79 (DESTRUCTIVE_FUNCTIONS)

## 🟡 risks (1)
## 🟡 risks (4)

- [config-module-scope-env-snapshot](config-module-scope-env-snapshot.md) — Move makeNestedConfig to call-time in config module (5d)
- [config-module-scope-env-snapshot](config-module-scope-env-snapshot.md) — Move makeNestedConfig to call-time in config module (37d)
- [downgrade-unprotects-configs](downgrade-unprotects-configs.md) — Downgrade after schemaVersion-2 migration silently unprotects all configs (0d)
- [policy-denial-observability](policy-denial-observability.md) — Policy denials leave no server-side trace; MCP server never inits logger (0d)
- [state-enc-atomic-write-lock](state-enc-atomic-write-lock.md) — state.enc: atomic write + inter-process lock + pre-migration backup (0d)

## 🔵 nits (1)
## 🔵 nits (2)

- [debug-process-test-no-assertions](debug-process-test-no-assertions.md) — debug-process.test.ts has no assertions (passes unconditionally) (5d)
- [debug-process-test-no-assertions](debug-process-test-no-assertions.md) — debug-process.test.ts has no assertions (passes unconditionally) (37d)
- [legacy-protected-removal-trigger](legacy-protected-removal-trigger.md) — Legacy 'protected' input path has no removal trigger; export still mints it (0d)

## ❓ questions (0)

Expand Down
36 changes: 36 additions & 0 deletions .claude/project/followups/configurable-sql-function-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
id: configurable-sql-function-policy
title: Configurable per-config SQL function allow/deny list + TUI editor
created: "2026-07-08"
origin: |
user idea 2026-07-08, post-#40
kind: plan
severity: question
review_by: "2026-09-06"
status: open
file: src/core/policy/classify.ts:79 (DESTRUCTIVE_FUNCTIONS)
---

Make the hardcoded DESTRUCTIVE_FUNCTIONS denylist user-extensible and DB-embedded.

Model (proposed): per-config sqlFunctionPolicy { mode: 'denylist'|'allowlist' (mutually exclusive), functions: string[] (schema-qualified supported) }, persisted in encrypted state (new schemaVersion migration), so it travels with the project.

THREE-BUCKET SEMANTICS (critical — the user lists govern ONLY the unknown/custom bucket, never the internals):

1. Known-pure builtins (count, min, max, avg, sum, now, coalesce, lower, upper, …): ALWAYS `read`. NEVER subject to the user's lists. An allowlist must not cause `SELECT count(*)` to be denied — this is the explicit user requirement.
2. Known-destructive builtins (pg_terminate_backend, dblink_exec, query_to_xml family, …): the baseline denylist, always bump to >=write.
3. Unknown / user-custom functions (everything not in bucket 1 or 2): the ONLY bucket the config governs.
- denylist mode: user names the dangerous customs -> those bump to >=write; other unknowns stay read (fail-open guardrail). Extends today's behavior.
- allowlist mode: user names the safe customs -> those stay read; every OTHER unknown -> treated as write -> denied for viewer (fail-closed sandbox). Bucket 1 stays read regardless; also closes the disclosure gap (pg_read_file, if unlisted, -> denied for viewer). "allowlist" = "deny unrecognized customs not listed", NOT "deny everything not listed".

Implies the classifier needs an explicit KNOWN_PURE builtin set (bucket 1) alongside the existing DESTRUCTIVE set (bucket 2), so allowlist mode can exempt internals.

Per-role differentiation is FREE via the existing matrix — no per-role lists needed. A denylisted function reclassifies to write/ddl; the matrix already says admin CAN write and viewer CANNOT. So the user's example ("my sensitive admin-only proc the MCP viewer must not call") works with a SINGLE per-config list: admin keeps calling it, viewer is denied. v1 = one per-config list + reclassify + existing matrix. A finer viewer-vs-operator split on the same custom function would need per-role lists — defer unless asked.

Layer: consumed by the CLASSIFIER (classifyStatements takes the config's function policy, or a post-classify refinement step that has the called-function set), then the EXISTING role matrix gates unchanged. One enforcement path; no parallel per-role permission engine.

TUI: a screen that POPULATES the picker from live DB introspection (explore domain already has list_functions) — show actual functions/procedures as a checklist, toggle mode, check the sensitive ones. Encrypted, per-config.

CAUTION (hard boundary): configurable FUNCTION lists only. Do NOT make the role->permission MATRIX user-editable — #40 deliberately fixed the three roles in-app ('User-defined or custom roles ... out of scope'). Keep that constraint.

Size: comparable to one CP (state schema + migration, classifier change, TUI screen + introspection wiring, tests). Separate PR after #40 ships. This turns the hardcoded denylist into the default rather than the whole answer.
14 changes: 14 additions & 0 deletions .claude/project/followups/downgrade-unprotects-configs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
id: downgrade-unprotects-configs
title: Downgrade after schemaVersion-2 migration silently unprotects all configs
created: "2026-07-08"
origin: |
challenge-swarm #40 (ops F1, migration F1/F2)
kind: finding
severity: risk
review_by: "2026-09-06"
status: open
file: src/core/version/state/migrations/v2.ts:46
---

DEFERRED by product owner (alpha). After state migrates to schemaVersion 2 (protected dropped), any prior binary reads protected as undefined -> fail-open, and re-persists without the marker. No shipped release has a version guard (schemaVersion system was dead until this branch). When leaving alpha: ship a forward version-guard in load() (refuse/warn on newer state) + state.enc.bak, and document the caveat. Not actionable retroactively for already-shipped binaries.
14 changes: 14 additions & 0 deletions .claude/project/followups/legacy-protected-removal-trigger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
id: legacy-protected-removal-trigger
title: Legacy 'protected' input path has no removal trigger; export still mints it
created: "2026-07-08"
origin: |
challenge-swarm #40 (migration F5, tester F7, maintainer F6)
kind: finding
severity: nit
review_by: "2026-09-06"
status: open
file: src/core/config/schema.ts:44; src/tui/screens/config/ConfigExportScreen.tsx:142
---

'Accepted for one version then removed' appears in 8 places but nothing operationalizes it — no version-keyed guard test, no tracked removal. Export actively writes protected:guarded(config) into every new export. Classic permanent-temporary: 3 accept-sites + 1 produce-site, 0 removal triggers. Add a test keyed to CURRENT_VERSIONS.state that fails when state version advances past 2 with the legacy input path still present.
14 changes: 14 additions & 0 deletions .claude/project/followups/policy-denial-observability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
id: policy-denial-observability
title: Policy denials leave no server-side trace; MCP server never inits logger
created: "2026-07-08"
origin: |
challenge-swarm #40 (ops F3/F4)
kind: finding
severity: risk
review_by: "2026-09-06"
status: open
file: src/mcp/server.ts:106; src/mcp/index.ts:12
---

Denied actions (MCP gate, checkPolicy, SDK guards) emit no event/log. The MCP server process never calls enableAutoLoggerInit (only ui.ts and sql/repl.ts do), so even an added denial event would not record. Ops cannot answer 'which agent actions were denied on prod this week'. mcp:false invisibility has no ops-facing counterpart either: hidden-vs-typo'd config is indistinguishable server-side. Add a stderr/log (never the MCP response) denial trace and wire the MCP logger.
14 changes: 14 additions & 0 deletions .claude/project/followups/state-enc-atomic-write-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
id: state-enc-atomic-write-lock
title: 'state.enc: atomic write + inter-process lock + pre-migration backup'
created: "2026-07-08"
origin: |
challenge-swarm #40 (migration F3/F4, ops F2/F6)
kind: finding
severity: risk
review_by: "2026-09-06"
status: open
file: src/core/state/manager.ts:304
---

persist() is a bare writeFileSync to the live path — no temp+rename, no lock, no .bak. needsMigration is effectively always-true (identity never emitted by migrateState) so every load persists. Concurrent MCP-server + CLI/TUI on one state.enc can corrupt the single encrypted store holding all configs+secrets; a crash mid-write loses everything with no backup. Pre-exists the access-roles branch. Fix: write-temp+rename (atomic on POSIX), a lockfile around load/persist, state.enc.bak before first migration.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ skills/*/evals/
graphify-out/
.claude/.scratchpad/
.claude/project/.deterministic-signals.prev.md
.claude/.atomic-index/
116 changes: 116 additions & 0 deletions docs/design/config-access-roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Per-config access roles

Issue: https://github.com/noormdev/noorm/issues/40


## Problem


Access control is a single `protected: boolean` per config. It cannot express intermediate access levels, cannot give different levels to different callers on the same config, and cannot hide a config from MCP entirely. Enforcement is also scattered: only `sql`/`run_sql` check `protected` on the MCP path — `change_run`, `change_ff`, `change_revert`, `run_file`, and `run_build` reach `ctx.noorm.*` with no gate, and `checkProtection` has no runtime callers at all.


## Model


Roles live on the config, not the actor. The actor is just a **channel** — who is asking:

- `user` — CLI, TUI, and SDK (`createContext` defaults here)
- `mcp` — the MCP server

Each config declares what each channel gets:

access: {
user: 'viewer' | 'operator' | 'admin',
mcp: false | 'viewer' | 'operator' | 'admin',
}

`mcp: false` means invisible: absent from `list_configs`, and `connect` fails with the byte-identical error an unknown config produces. Omission must not leak existence. `mcp: 'viewer'` is the softer posture — the agent sees schema and reads, touches nothing.

The `access: {}` grouping (rather than flat top-level `user:` / `mcp:` keys) is deliberate: a top-level `user` key would sit lines away from `connection.user` (the database login) and read ambiguously.


## Role matrix


Hard-coded. Cells: allow (✓) / confirm / deny (✗). Not user-extensible.

| permission | viewer | operator | admin |
|---|---|---|---|
| explore | ✓ | ✓ | ✓ |
| sql:read | ✓ | ✓ | ✓ |
| sql:write | ✗ | ✓ | ✓ |
| sql:ddl | ✗ | ✗ | ✓ |
| change:run / change:ff | ✗ | confirm | ✓ |
| change:revert | ✗ | confirm | ✓ |
| run:build / run:file / run:dir | ✗ | confirm | ✓ |
| db:create | ✗ | confirm | ✓ |
| db:reset | ✗ | confirm | ✓ |
| db:destroy | ✗ | ✗ | confirm |
| config:rm | ✗ | confirm | confirm |

`confirm` is channel-resolved:

- **user channel** — prompt for `yes-<config>` (the phrase `protected` used to own). `NOORM_YES=1` still skips it, so CI is unaffected.
- **mcp channel** — collapses to deny, with a message directing to the CLI. There is no human on the other end of stdio; an agent typing its own confirmation phrase is theater. An agent that legitimately needs to run changes on a dev database gets `mcp: 'admin'` on that config — dev is disposable.

The confirm-in-role design is what lets `protected` die without losing the prod guardrail: `operator` *is* "can do it, but types `yes-prod` first"; `admin` is frictionless. A human on an `operator` config cannot ad-hoc DDL prod — they go through a change file. That is a posture, not a limitation.


## SQL classification


Raw SQL (`sql`, `run_sql`) is gated by what the statements actually do. Generalize the existing `isReadOnlyStatement` (sql-parser-cst first, keyword fallback) into:

classifyStatements(sql, dialect) → 'read' | 'write' | 'ddl'

- Statement classes: SELECT/EXPLAIN/SHOW/DESCRIBE → `read`; INSERT/UPDATE/DELETE/MERGE → `write`; CREATE/ALTER/DROP/TRUNCATE/GRANT/REVOKE → `ddl`.
- Multi-statement input takes the highest class present.
- Unparseable input or unknown statement types classify as `ddl` — **fail closed**.
- `EXEC` / `CALL` classify as `ddl`. A stored procedure is opaque and can do anything; fail-closed wins. This is painful for proc-heavy MSSQL shops and is the first cell to revisit with real usage — but loosening later is safe, tightening later is a breaking change.

The classifier applies **only** to the raw-SQL surface. Change files and run files are command-gated (`change:*`, `run:*`), not content-classified. The resulting posture is coherent with the product premise: ad-hoc DML is a role question, but DDL only travels through tracked, revertible change files.


## Enforcement


One function in core:

checkPolicy(channel, config, permission) → { allowed, requiresConfirmation, confirmationPhrase?, blockedReason? }

Same result shape as the old `ProtectionCheck`, so confirm-dialog plumbing carries over.

- **MCP**: `RpcCommand` gains a required `permission` field. A single gate in `run_noorm_cmd` dispatch resolves (channel, config, command.permission) before any handler runs. Every current and future command is covered by construction — the class of bug where a new command forgets its check cannot exist.
- **CLI/TUI**: same `checkPolicy`, prompting on `confirm`.
- **SDK**: `createContext` stamps channel `user`. Side effect worth having: a `viewer` config is read-only even from application code — enforced above the driver, belt to the DB-grant suspenders.
- **Invisibility**: `list_configs` filters `mcp: false` configs for the mcp channel; `SessionManager.connect`/`getContext` reject them with the unknown-config error.


## Settings stages and display


Scoping surfaced two `protected` consumers beyond the config itself; both get explicit mappings rather than silent deletion:

- **Stage defaults** (`settings.yml`) may set `protected: true`, and stage enforcement forbids overriding it to `false`. The stage keyword survives as authoring vocabulary, redefined as an **access ceiling**: a stage with `protected: true` clamps resolved access to at most `{ user: 'operator', mcp: 'viewer' }`. A config may be stricter (`viewer`, `mcp: false`), never looser. Adding full `access` blocks to stage defaults is a separate issue.
- **Rule matching and display** need a one-word notion of "this config is guarded." Defined as `guarded(config) := access.user !== 'admin'`. Settings rules' `match.protected` matches `guarded`, TUI yellow-border styling keys off `guarded`, and `config list`'s `[protected]` tag becomes the access levels.

The SDK's existing destructive-op guards (`src/sdk/guards.ts`) stop consulting `protected` and consult `checkPolicy` on the `user` channel — one enforcement path, not two.


## Migration


`protected` dies as a concept. On state load, one version migration maps it:

- `protected: true` → `access: { user: 'operator', mcp: 'viewer' }`
- `protected: false` → `access: { user: 'admin', mcp: 'admin' }`

Both preserve today's observed behavior, with one intended tightening: protected configs newly deny MCP `change_run` et al. — that is the enforcement-gap fix riding along. The stored field is parsed and migrated for one version, then removed from types and code.


## Out of scope


- User-defined or custom roles — the set is fixed in the app.
- Identity/authn — channel is process context; no user accounts.
- Content-classifying change/run files — they are command-gated by design.
2 changes: 1 addition & 1 deletion docs/dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Configs define database connections. They merge from multiple sources with clear
CLI flags > Environment > Stored config > Stage defaults > Defaults
```

Protected configs require confirmation for dangerous operations. Stages enforce team-wide constraints.
Per-channel access roles (`viewer`/`operator`/`admin`) gate dangerous operations. Stages enforce team-wide constraints.

[Read more about Configuration](./config.md)

Expand Down
2 changes: 1 addition & 1 deletion docs/dev/config-sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ The exported data includes everything needed to recreate the config except crede
name: 'production',
type: 'remote',
isTest: false,
protected: true,
access: { user: 'operator', mcp: 'viewer' },
connection: {
dialect: 'postgres',
host: 'db.example.com',
Expand Down
Loading
Loading