Skip to content

fix: the access channel names who is driving, not which binary was invoked - #66

Merged
damusix merged 9 commits into
fix/v1-readinessfrom
feat/agent-channel
Jul 30, 2026
Merged

fix: the access channel names who is driving, not which binary was invoked#66
damusix merged 9 commits into
fix/v1-readinessfrom
feat/agent-channel

Conversation

@damusix

@damusix damusix commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Stacks on #64 — review that first.

An agent denied a write over MCP could see noorm on the PATH and shell out. The CLI hardcoded the user channel, so the second attempt ran with the human's role. Measured on a stock config before this change:

permission     via MCP    via CLI
sql:write      deny       ALLOW
sql:ddl        deny       ALLOW
db:create      deny       ALLOW
db:destroy     deny       confirm     <- and --yes satisfies confirm
run:build      deny       ALLOW
vault:read     deny       ALLOW

Six escalation paths. The root cause was conceptual: channel described which binary was invoked when it should describe who is driving. Those coincide only when a human is at the keyboard.

The change

Channel is now 'user' | 'agent' and ConfigAccess is { user, agent } — the role governs agent access regardless of transport, MCP or CLI. agent: false hides a config from agents on both. resolveChannel() resolves it: NOORM_CHANNEL if explicitly user/agent, else a detected harness, else user.

Detection is an allowlist of variables the harnesses set for their own children — CLAUDECODE, CODEX_SANDBOX, CURSOR_AGENT, GEMINI_CLI, AI_AGENT, NOORM_AGENT. TERM_PROGRAM, CI and TTY state are deliberately excluded: they describe the terminal or the pipeline, not the caller, and keying on TERM_PROGRAM would classify every VS Code and iTerm user as an agent and lock them out of their own CLI.

This is evadable, and is meant to be understood that way. It defends against an agent that routes around a refusal — the realistic case — not one that sets out to defeat the check. Treat it as provenance and a safer default, never as a security boundary.

After: zero escalation paths. db:destroy resolves to deny for agents rather than confirm, so --yes cannot walk through it.

Also here

  • Agent provenance. Operation records now read Danilo Alonso <d@x.com> (via Claude Code), so an agent-applied change is distinguishable from a human one. Folded into the identity string rather than a new column — a column creates a version-skew window where a CLI writing it against an un-migrated database fails every operation record. noorm info shows the detected harness and the markers actually set.
  • MSSQL by IP. Setting the TLS ServerName to an IP address is not permitted — tedious derives SNI from server, and Node rejects IP literals. Fixed while keeping encryption on. tedious 20.0.5 fixes the underlying path but that bump is deliberately separate: it moves engines to node ≥22, pulls new @azure/* deps, and only covers one of the three cases here.

Breaking

  • access.mcpaccess.agent (state schema v3; values carry over verbatim, including false).
  • An agent shelling out to the CLI now gets the agent role, not the human's. On a stock config that means run build and change run are denied by default. If you drive noorm with an agent deliberately, set that config's agent role to operator or admin.

Verification

4,607 tests pass across all four CI groups, zero failures. The escalation table is pinned as a property — for a stock config every permission must give the same answer over MCP and via the CLI-resolved channel — so a future matrix edit that reopens any cell fails without anyone remembering this PR.

One reproducibility fix worth noting: the suite previously inherited the invoker's harness markers, so results differed depending on whether a human or an agent ran bun test. tests/preload.ts now neutralises them. Verified green with markers live in the environment.

damusix added 9 commits July 29, 2026 23:01
An agent denied a write over MCP can see noorm on the PATH and shell out; the
CLI hardcoded the user channel, so that second attempt ran with the human's
role. Detection is the input to resolving the channel from provenance rather
than from which binary was invoked.

Allowlist only, from variables the harnesses set for their own children.
TERM_PROGRAM, CI and TTY state are deliberately excluded: they describe the
terminal or the pipeline, not the caller, and a false positive locks an
operator out of their own CLI.
executed_by recorded who ran an operation and nothing about what drove
the session, so an agent-applied change was indistinguishable from a
human one — the first question asked when a migration goes wrong.

Folded into the identity string rather than a dedicated column: the
audit question is binary, and a suffix answers it on all four dialects
without a schema migration or a CLI/database version skew window.
Harness detection silently changes the channel an operation is
authorised on, and a permission denial with no visible cause is bad to
debug. Reports the markers actually set, which is what an operator
would unset to be treated as human.
Tedious guards the SNI ServerName against IP literals on its TDS 8.0
path but not on the PRELOGIN path `encrypt: true` takes, so Node
rejected every IP connection. Encryption is never downgraded to work
around it: validating a certificate against an IP now fails, naming
the new tlsServerName config field as the fix.
Channel named the transport, so the CLI hardcoded `user` at every policy
call site. An agent refused a write over MCP could see noorm on the PATH,
shell out, and run the same operation with the human's role. Measured on a
stock config: sql:write, sql:ddl, db:create, run:build and vault:read all
went deny -> allow, and db:destroy dropped to a confirm that --yes satisfies.

Channel is now 'user' | 'agent' and ConfigAccess is { user, agent }. The CLI
resolves it via resolveChannel(): NOORM_CHANNEL when explicitly user/agent,
else the agent-harness allowlist, else user. `mcp serve` still passes 'agent'
literally, above the override. `agent: false` hides a config on both
transports — `config list` now filters it the way `list_configs` already did.
Stored access.mcp migrates to access.agent at state schema v3, verbatim.

The TUI keeps a literal 'user': it needs a TTY and an interactive human.
NOORM_CHANNEL is excluded from the NOORM_* config/settings env mapping, or it
would invent a `channel` key on every config.

BREAKING CHANGE: Config.access.mcp is now Config.access.agent, and
createContext's channel option takes 'agent' instead of 'mcp'. Agents
shelling out to the CLI now get the agent role rather than the human's.
@damusix
damusix merged commit 735d5ea into fix/v1-readiness Jul 30, 2026
damusix added a commit that referenced this pull request Jul 30, 2026
…t support (#64)

* feat(policy): add permissions for vault, secrets, config writes and destructive db ops

The Permission union had no member for vault, secret, config-write, truncate,
teardown, lock force or debug writes, so those operations had nothing to gate
against and shipped ungated. Adds the missing permissions and their matrix rows
so the owning modules can call assertPolicy.

* fix(identity): reject malformed key material instead of deriving a constant

Buffer.from(str, 'hex') never throws — it stops at the first invalid pair
and truncates odd lengths. Every malformed private key therefore collapsed
to a zero-length HKDF input, so deriveStateKey returned the same 32 bytes
regardless of input: a constant recomputable from this source. State written
under it was readable by anyone holding no key material at all.

isValidKeyHex existed but was called only from the CI env path. Guard the
three entry points that actually feed StateManager: deriveStateKey (the
chokepoint every state read and write passes through), loadPrivateKey (a
corrupted or partially-synced identity.key is otherwise undetectable, since
nothing verifies it against identity.pub), and setKeyOverride.

* fix(settings): stop persisting env overlay into settings.yml

SettingsManager merged every ambient NOORM_* var into #settings on load,
and save() serialised that same object — so NOORM_VAULT_TOKEN,
NOORM_DB_PASSWORD and NOORM_API_KEY were written verbatim into the
git-tracked .noorm/settings.yml by any mutation, including an unrelated
one. @logosdx/utils merge() mutates and returns its target, so there was
no copy anywhere in the path.

Split the persisted document from the resolved view: #document holds what
goes to disk, #settings holds document + env overlay and backs every
accessor. Mutators write the document and recompute the overlay.

* fix(state): stop rewriting state.enc on every load

needsMigration tested for an `identity` field migrateState deliberately
never writes, so the predicate was permanently true and every command --
including read-only ones -- re-encrypted and rewrote the whole state file.

migrateState also rebuilt State from a fixed allowlist, silently dropping
any top-level field a newer version had added; the drop was persisted
immediately, so a single downgrade was permanent loss. Carry unknown
fields through instead, still dropping legacy `identity` so pre-move key
material does not get re-persisted into state.enc.

* fix(runner): make createOperation work on MySQL

MySQL has no RETURNING clause, so every build, run, change and revert
died before executing a single file. Read the generated key off the
insert result instead of issuing LAST_INSERT_ID() as a second query —
that function is per-connection and Kysely pools connections between
statements.

* fix(change): restore re-apply after revert and teardown

A file's prior success licensed a skip forever, so every apply->revert->
apply cycle reported success over an untouched database. Retire that
success when the operation was reverted/torn down, or when an operation
ran the other way since.

`ff`/`next` also filtered `stale` out of pending work, so teardown had
no recovery path even once the files would run. Both defects had to be
fixed together to make `db teardown` -> `change ff` rebuild anything.

* fix(update): validate version before it reaches a URL or shell

npm dist-tags.latest was interpolated verbatim. compareVersions parses
loosely enough that junk still compares greater, so a poisoned tag reached
the release URL — fetch normalises `..`, relocating the binary AND its
checksums.txt to an attacker repo, where verification then passes.

* fix(ci): refuse enrollment when the stored public key differs

`ci identity enroll` matched an existing identity on hash alone, then
propagated the vault key to whatever public key that row held. The
enrollment hash is SHA256(email\0name\0publicKey\0'env'), and the
documented air-gapped flow circulates the bot's public key in the clear —
so its only secret input is published by design. Anyone able to INSERT
into the identities table, vault access not required, could pre-register
the hash under their own key and receive the vault, while the operator's
command reported success and echoed their own correct key back.

Compare the stored public_key against the presented one and refuse on
mismatch. Also validate --public-key up front: an invalid key was INSERTed
before the propagation that then failed, leaving a row no retry could
repair, under an error message promising an idempotent retry.

* fix(config): gate import --force behind config:write

Overwriting a config rewrote its access block with no authorization check,
so `config import escalate.json --force` promoted a viewer config to
admin/admin in one command — and could flip the `mcp: false` invisibility
an operator set so agents could not see the config.

The config being replaced now decides, via the config:write permission:
viewer denies, operator and admin require --yes.

* fix(identity): guard init --force behind confirmation and a key backup

The state encryption key is HKDF over the identity private key, so
regenerating the keypair orphans every state.enc on the machine — configs,
secrets and database passwords — with no recovery path and nothing that
re-encrypts existing state. The only guard was --force, documented as
"Overwrite existing identity".

Require --yes on top of --force, copy the previous key files aside first
(owner-only, timestamped), and say what is actually being destroyed. Uses
the raw flag rather than isYesMode() so an ambient NOORM_YES set for
unattended runs cannot destroy every project's state as a side effect.

* fix(logger): reopen write stream after rotation

Rotation renamed the file but kept the old fd, so every later entry went to
the rotated file, the log path never reappeared, and needsRotation on a
missing path stayed false — rotation fired once per process and the rotated
file then grew unbounded. Log files are now created 0600, not 0644.

* fix(runner): dedup templates on the rendered SQL, not raw bytes

`run build` stored the raw file hash while `needsRun` compared the
rendered hash, so no `.sql.tmpl` could ever match and every template
re-executed on every build — failing outright on non-idempotent DDL.

Rendered output is the canonical dedup key: it is what reaches the
database, it is what `run file` already used, and hashing raw bytes
would silently skip a template whose data file or secrets changed.
Consequence to note: a checksum now derives from SQL that may embed a
rendered secret. SHA-256 discloses nothing, but it is a guess oracle
for anyone holding both DB read access and a candidate value.

* fix(change): make rewind honour --dry-run and a count

`rewind` declared --dry-run/--force and advertised them in its examples
but called the SDK with neither, so the flag whose contract is "touch
nothing" reverted for real. The SDK had no options parameter to pass
them through.

The documented `rewind <N>` form also never worked: citty yields the
positional as a string and the manager branches on typeof number, so a
count fell through to name lookup and failed with no reason attached.

* fix(init): write a real ignore entry in the .gitignore block

Init wrote a bare `# noorm` comment with no patterns under it, so nothing
was ignored, and both surfaces then skipped on `includes('# noorm')` —
the empty block could never be repaired. Write `.noorm/state/` (state.enc
plus the log file) and key the skip on that entry so existing projects get
repaired on the next init.

The test asserted only that the header landed, which passed against a
block that ignored nothing; it now asserts the entry.

* chore(config): drop the debug-process test artifact

It asserted nothing and printed the PID and NOORM_* key list into CI
group 1's output.

* fix(identity): bind executed_by to the enrolled cryptographic identity

getIdentityForConfig is what the SDK calls to decide noorm.change.executed_by,
and it passed only the config's identity string. The identity that actually
authenticated to the vault therefore never reached the audit trail: an
enrolled CI bot's changes were recorded against the runner's git user or OS
username, and a bare NOORM_IDENTITY env var — unauthenticated free text —
outranked the cryptographic identity outright. The TUI passed cryptoIdentity
and the CLI/SDK did not, so one command attributed differently per surface.

Read the process-wide CI identity override here. An explicit config identity
still wins, since that override is deliberate.

* fix(identity): recompute the identity hash on CLI edit

The hash encodes email|name|machine|os. `identity edit` spread the new
values over the old record and saved it, leaving a hash that still meant the
previous person — the value the database joins on and that vault access is
granted against — while `identity list` displayed the new one. The TUI's edit
screen already recomputed it, so one operation had opposite outcomes per
surface.

Route through createIdentityForExistingKeys, carrying `machine` over so the
only hash inputs that move are the ones the user asked to change, and report
the fingerprint change since it costs vault access.

* fix(settings): type-check the env overlay before use

The overlay merged in after parseSettings had run, so nothing validated
it. NOORM_BUILD_INCLUDE=00_tables put a string in an array field, which
the build walker then iterated character by character — it matched no
files and still reported success with exit 0.

Validate the merged view so a scalar-for-array fails loudly instead.

* fix(tui): gate db transfer through the policy check

transferData's assertPolicy only rejects `allowed: false`, so a
confirm-tier destination reached the screen's plain <Confirm> and was
writable with one keypress while the CLI hard-blocked. Gate the write
target (destination for db-to-db, active config for import) the way the
SDK does, and route the confirm through SmartConfirm.

- truncateFirst had a setter that was never called and no control, so
  the TUI always transferred with it false; it is now a visible toggle
- globalModes.dryRun was never passed, so the DRY badge lied here

* fix(change): fail a revert whose state cannot be read

`canRevert` collapsed a broken history read into the same "no" as
"already reverted", and the executor turned every such no into
`status: success` with no files — so reverting against a damaged
tracking table reported success over an untouched schema.

Note SQLite never raises here: a double-quoted identifier that matches
no column is re-read as a string literal, so the status comes back as
garbage rather than an error. Both paths now carry an error.

* fix(worker-bridge): fail requests when a worker dies

request() awaited a response event with no rejection path, so a crashed
compute thread left the caller pending forever. OrderBuffer likewise
accepted indices that could never drain, stranding every later item.

* fix(dt): page by primary key and drain on settled promises

LIMIT/OFFSET with no ORDER BY has no stable window: a write to the source
between pages dropped and duplicated rows while still reporting the full
count. The drain loops spun on a counter a failed dispatch never
decremented, so one bad row hung the pipeline forever.

* fix(debug): gate internal-table operations behind policy

The debug screens delete rows from noorm.vault and noorm.identities, and
nothing checked authorization on the way — a viewer config could drop vault
rows. The gate goes at the core seam rather than in the screens so a second
surface can't inherit the tables without inheriting the check.

createDebugOperations now requires a policy context; reads take debug:read,
deletes take debug:write. Bulk delete authorizes before the empty-list short
circuit so a denied caller isn't handed a plausible-looking 0.

* fix(policy): stop EXPLAIN and quoted identifiers evading the gate

EXPLAIN was a terminal `read` verdict on both classification paths, so
`EXPLAIN (ANALYZE) DELETE FROM t` deleted rows under a viewer role on
postgres, via the CLI and over MCP. It now inherits the class of the
statement it wraps, and the CST scan walks every nested statement node
instead of an enumerated list that had no DDL entry.

Quote tracking modelled `'` only, so an MSSQL bracket identifier holding
an odd apostrophe swallowed the following `;` and hid a whole statement.
One dialect-aware masking pass replaces the four independent scanners.

* fix(state): serialize and reconcile concurrent state writes

state.enc was rewritten whole from an in-memory snapshot with no lock and
no atomicity, so two processes writing at once silently lost one of the
writes -- ten parallel `secret set` runs left five secrets, all exiting 0.

Writes now take an O_EXCL lock beside the file, stage into a temp file and
rename over the target, and fsync both. A lock older than 30s is assumed
to belong to a dead process and broken.

Locking alone is not enough: the second writer still holds a stale
snapshot, so it would serialize its own clobber. Before writing, a writer
now compares the file against the fingerprint it last saw and, if it
changed, reconciles three-way against the snapshot it loaded. Three-way is
required to tell "we never touched this key" from "we deleted this key".

Each write also copies the previous generation to state.enc.bak. There was
no backup of any kind, so a damaged state.enc meant unrecoverable loss of
every config, secret and DB password in the project.

* fix(config): gate export behind secret:read

`config export` handed over the connection password in plaintext with no
authorization check — a viewer config that refused `config rm` would still
print its own password to stdout and exit 0. It was the only config
operation with no gate at all.

* fix(config): honour --json in the add and edit stubs

Neither stub declared a json arg, so a headless caller in the config group
got prose on stderr where every sibling command returns JSON. Both now
emit a JSON error and name `config import` as the headless route, which
was already the working path but was documented nowhere.

* fix(cli): honour settings.logging in the CLI logger

enabled, file, maxSize and maxFiles were hardcoded, so every logging setting
was dead for all ~79 commands and the Shift+L overlay read a path the CLI
never wrote. Disabling logging now stops the file only — the Logger still
carries --json result output.

* fix(template): close the unsandboxed-execution paths in context building

Two ways untrusted code ran with no opt-in:

- Any `.js`/`.mjs`/`.ts` beside a SQL file was imported while building
  the context, unreferenced, during `preview`, `inspect` and `--dry-run`.
  Scripts now load only when the template names their key; inert formats
  still auto-load.
- `include()` and the `$helpers` walk tested containment with a string
  prefix, so a `<root>-evil` sibling counted as inside the project.

* test(impersonate): cover the authorization boundary and injection guard

The existing suite only exercised roles the connection was already granted,
against a container user that is a superuser — so postgres would have allowed
any SET ROLE and the tests could not have failed on an escalation. Add a
second context connected as an unprivileged login role to exercise a real
membership check, plus a nonexistent principal and a username carrying SQL
metacharacters.

* fix(debug): surface query failures instead of empty results

Every failed query collapsed into [] / null / false / 0, so a table that
errored rendered identically to one that was genuinely empty and a failed
delete rendered as "row not found". Failures now throw; the falsy returns
mean only "not found" / "nothing deleted". getTableCounts keeps its
per-table shape but reports count: null with the message, so one unreadable
table doesn't abort the whole overview or masquerade as zero rows.

sortColumn is now checked against the table's column list. Kysely quotes it
as an identifier, so it was never injectable — probed with statement-
breaking payloads against every dialect compiler, all rejected before
execution — but an unknown value produced a driver error that was then
swallowed into an empty result. Unknown tables no longer fall back to a
fabricated ['id'] column list.

Adds tests/core/debug, the first coverage this module has had.

* fix(change): give change templates their config and secrets

`.sql.tmpl` files inside a change were rendered with config, secrets and
globalSecrets all undefined, so `$.secrets` and `$.config` were unusable
and the failure claimed to have searched three tiers it never consulted.
ChangeContext already carried all three and every caller populated them.

* fix(change): keep manifest files in the order listed

A .txt manifest is an authored execution order, but resolveManifest
sorted the resolved absolute paths, reordering across directories and
running a child table before its parent. The test asserting the sort
pinned the defect and now asserts line order instead.

* fix(transfer): stop postgres self-copying on same-server path

buildDirectTransfer discarded srcDb and emitted INSERT INTO t SELECT
FROM t, and isSameServer only reported true for postgres when both
configs named one database — so the branch could do nothing but copy the
destination into itself. Postgres now always takes the batch path.

* fix(tui): honour global dry-run in truncate and teardown

Both core functions accept dryRun and the CLI passes it, but neither
screen read globalModes — so toggling D, watching the DRY badge, and
confirming wiped the schema for real.

* fix(cli): target the active config in sql history and clear

Both defaulted to a config literally named `default`, which nothing
writes history under — the TUI keys it by the active config. `sql clear
--yes` therefore reported success while leaving the query text, and any
credential in it, on disk.

* fix(logger): redact project env vars, DSNs and error messages

Masking matched key names against a fixed variant set, so it missed the
compound NOORM_* names the docs tell users to set, never inspected values
(any DSN passed verbatim), and skipped Error objects wholesale — where
connection failures routinely carry a DSN.

* test(explore): add a Kysely harness that compiles SQL for real

The dialect mocks stubbed compileQuery to return SELECT 1, so a wrong
WHERE predicate could not be observed by any assertion. Drive the real
adapter and query compiler through a recording driver instead.

* fix(explore): make explore answer with the schema it was asked about

Five defects that all produced plausible-looking wrong output rather
than an error:

- Postgres procedure detail filtered information_schema.parameters on
  the bare routine name; that column holds `proname_oid`, so every
  procedure in every database reported zero parameters while the list
  view reported the real count.
- SQLite interpolated identifiers into PRAGMA and FROM without doubling
  embedded quotes. One table named `we"ird` aborted fetchList,
  fetchOverview and the detail view of unrelated tables. bun:sqlite's
  single-statement prepare is what stopped this being injection rather
  than anything the code did.
- MySQL table detail read columns from the requested schema but indexes
  and foreign keys from the connected database, so the object
  contradicted its own label and omitted the real index. list methods
  now take the schema and bind it instead of falling back to DATABASE().
- fetchOverview's default path ran a bare Promise.all and never emitted
  observer 'error', so overview failures reached neither the logger nor
  the TUI toast on the path every caller actually uses.
- getOverview() hardcoded triggers/locks/connections to 0 in all four
  dialects, and was selected by includeNoormTables. Two implementations
  of one number disagreeing is the bug; the counts now come from the
  same listings the detail views use, and getOverview is gone.

Also: SQLite trigger timing and events are read from the CREATE TRIGGER
header rather than substring-matched over the body, a view whose base
table was dropped no longer takes down the whole view listing, MSSQL
bigint row counts are parsed to the declared number type, and pg_locks
is scoped to the current database instead of the whole cluster.

* fix(state): fail closed on unrecognised config access

Two boundaries read `access` and legacy `protected` off untyped JSON and
both failed open.

A non-boolean `protected` -- reachable for any config saved outside the
zod path -- did not match `=== true`, so `"true"` and `1` both resolved to
admin/admin: fully open, the opposite of what they ask for and of what the
code comment claims. Any truthy value now guards.

A truthy-but-malformed `access` such as `{}` or `{user:'admin'}` passed
through verbatim while `protected` was discarded, leaving a config that
zod rejects on every later command and no `protected` left on disk to
reconstruct it from. Malformed channels are now filled with the most
restrictive role, so the config stays usable and never comes out more
permissive than it went in. `mcp: false` is a valid value, not a malformed
one, and survives untouched.

* fix(sql-terminal): write query history owner-only

History is the one at-rest store noorm does not encrypt, and it sat at
0644 beside state.enc's 0600 — holding verbatim query text plus every
row those queries returned.

* fix(change): warn when the changes directory is missing

A mistyped `paths.changes`, or a CI checkout without its `changes/`
folder, made `ff`/`next` report success over zero changes and exit 0 —
indistinguishable from an up-to-date database.

Warned rather than failed, matching the `build.include` precedent: a
project with genuinely nothing pending must still succeed.

* fix(sql): keep ungated exec off the barrel, fix two dead examples

`noorm sql -c prod "<SQL>"` and `noorm sql -f <file>` print help — the
argv rewriter reads the flag's value as the query — so the examples now
show the explicit `sql query` form the rewriter is not involved in.

* fix(dt): stop trusting the contents of a .dt file

A .dt arrives from a colleague or a bucket. gzip was inflated with no
ceiling (measured 1029:1, and the OOM lands inside a compute worker),
undecodable base64 became an empty buffer, unknown encoding tags passed
through raw, and only schema.v was validated.

* fix(tui): confirm vault propagate and name recipients

`p` called propagateVaultKey straight from the keypress handler — one
key, no confirmation, for an operation that hands the vault key to every
enrolled identity. Gate it on vault:propagate and list who is being
granted before it runs.

* fix(runner): gate preview and inspect on the access policy

Rendering a template resolves every secret tier into plaintext and runs
the template's helper and side-car scripts, so `preview`, `inspect`,
`checkFilesStatus` and `templates.render` were an unrestricted read-and-
execute path for the one role the matrix denies every `run:*` permission.

Gated on `run:file` because no `run:preview` cell exists; a dedicated row
(viewer deny, operator/admin allow) belongs in the matrix so a read-only
path stops borrowing an execution's confirm semantics.

* fix(change): hide the teardown marker and show orphaned changes

`__reset__` is an audit row for `db teardown`, but it shares the change
row shape and so listed as a user change that is permanently orphaned.
Status reads now filter it; history reads still show it.

`change list` also computed `orphaned` and never rendered it, so a change
deleted from disk printed identically to a live applied one.

* feat(vault): gate vault operations on the access policy

The vault holds the team's shared secrets and sat entirely outside
`access`, the only authorization mechanism noorm has: a viewer-role
config denied `run build` could still write, delete and hand out every
vault secret.

Gated in core rather than per command so the CLI, SDK and any future
surface inherit the check. `getVaultKey` is gated too, which makes the
gate hard to route around — the value-reading helpers are useless
without the key. `getVaultStatus` stays open on purpose: it returns
counts, never key names or values, and callers need it to explain why a
user has no access.

The ungated primitives remain for the TUI, which has no config in scope
at its call sites and needs a separate migration.

* fix(vault): report partial and failed vault propagation

`propagateVaultKey` pushed only successful grants and dropped the rest,
so a run where a teammate's update failed returned success with that
teammate absent from the output entirely — both parties then believed
access had been handed over.

Failures now land in `result.failed` with an `error` event per failure,
and `getUsersWithoutVaultAccess` returns a tuple so a query failure is no
longer indistinguishable from "nobody is waiting", which surfaced to the
operator as "all users already have vault access".

Also adds `targets` so a caller can grant to specific identities instead
of everyone who has ever connected.

propagate.ts had no test coverage at all; these are its first.

* fix(vault): show who propagate grants to before granting

Identity rows are created automatically on first connect, so `vault
propagate` handed the vault key to every identity that had ever reached
the database — no listing, no confirmation, no targeting — and printed
bare hashes only after the grant. A self-registered keypair could read
the team vault in cleartext once any legitimate user ran the command.

Now lists each pending identity with name and email, withholds the grant
until `--yes`, accepts `--to` for specific identities, and exits non-zero
when any grant failed.

The TUI fires the same operation from a bare `p` keypress with no dialog;
that screen is not in this change.

* fix(vault): confirm destructive vault commands and accept stdin

`vault rm` destroys the team's only copy of a secret — no soft delete, no
history, no undo — and had no confirmation at all, while `secret rm`,
which deletes a recoverable local copy, has always required `--yes`. The
gate was on the recoverable operation and absent on the irrecoverable
one, and `--yes` was silently swallowed so defensive scripting gave false
assurance.

`--stdin` on `vault set` makes the documented `echo "$X" | noorm vault
set` recipe real; passing a secret as a positional argument publishes it
to the process table, shell history and `set -x` traces.

* docs: correct the paths.changes env var name

Both tables documented NOORM_PATHS_CHANGESETS, which maps to a key the
settings schema does not define and zod therefore strips — setting it
silently did nothing. The working name is NOORM_PATHS_CHANGES.

* fix(vault): make vault cp --dry-run run the real preflight

`--dry-run` returned before `copyVaultSecrets` was ever called, so it
checked neither vault access, nor whether the source key existed, nor the
destination collisions `--force` governs — it echoed the arguments back
and exited 0 for a key that does not exist.

The real path had the mirror problem: `success: true` alongside a
populated `errors` array while exiting 1, so a CI script branching on
`.success` read a failed copy as a success.

* fix(secret): gate secret commands and honour NOORM_YES

Config-scoped secrets feed the same `$.secrets` template namespace as the
vault but sat outside `access` entirely. Gated at the command level
because `StateManager.setSecret` takes no config and cannot consult
`access` itself — that seam still needs the gate for SDK and TUI parity.

`secret rm` read `args.yes` directly, so the documented `NOORM_YES`
escape hatch did not work there while 14 other CLI sites honoured it.
`secret list` never checked the config existed, so a typo returned an
empty key list and exit 0 — indistinguishable from a config with no
secrets. `secret set` gains `--stdin`.

Adds tests/core/secrets, which did not exist: secrets coverage rode
inside the settings and state tests and nothing asserted a secret value
stays out of state.enc, observer payloads or error messages.

* feat(sdk): gate vault and secrets namespaces, round out secrets

Both namespaces reached the secret stores with no policy check. Routed
through `checkProtectedConfig` so `confirm` cells behave as they do for
`db.truncate()`: blocked unless the context was created with `yes: true`.

`SecretsNamespace` exposed only `get` against a CLI with set/list/rm; it
now matches.

* fix(runner): execute every statement of a SQLite file

bun:sqlite compiles the first statement of a string and drops the rest
without erroring, so a multi-statement file ran one statement, reported
success, and recorded a checksum that guaranteed it was never retried.

Split before the driver sees it. Postgres and mysql keep running the
body whole: postgres wraps it in one implicit transaction, and mysql
rejects a multi-statement string loudly already.

* chore(changeset): note change apply/revert and rewind fixes

* fix(lock): store and read lock timestamps in UTC

The lock table's datetime columns are naive, and the postgres and mysql
drivers bind and parse a JS Date in the client's local offset — so a
read-only `lock status` from a zone ahead of the holder's compared two
unrelated wall clocks and deleted a live lock.

MSSQL (tedious `useUTC`) and sqlite (ISO strings) were already UTC and
are left alone; converting them would have introduced the same bug.

* test(policy): check classifier verdicts against real databases

Every classifier bypass in the audit came from running the statement and
diffing the database, none from reading the code — the unit suite was
green throughout. This asserts the one invariant the gate depends on:
a statement that changed anything did not classify `read`.

Fails at f3af9af on postgres (the EXPLAIN option forms) and mssql (the
bracket-identifier split).

* fix(runner): write dry-run output owner-only and report its path

A dry run renders every secret the templates resolve into plaintext files
under `tmp/`, which a scaffolded project does not gitignore, and it wrote
them 0644. The destination was also invisible to `--json`, so CI had no
way to know plaintext had been written or where to clean it up.

* test(policy): assert which classification path each test exercises

Six tests were named "(keyword fallback)" and never checked, so a
CST/fallback divergence — the shape of every bypass found — could not
fail them. Two of the six were in fact taking the CST path: plain INSERT
and EXECUTE are valid postgres, so mssql never reached the fallback for
them. Renamed, with the genuinely mssql-only INSERT ... OUTPUT form
added to cover what they were meant to.

* fix(state): reject malformed keys and stamp the derivation used

`Buffer.from(str, 'hex')` never throws -- it stops at the first invalid
pair -- so every non-hex private key collapsed to a zero-length HKDF
input and derived an AES key that is a constant computable from the
source. State written under it is readable by anyone. The root fix belongs
in deriveStateKey (core/identity, not this slice); encrypt/decrypt now
refuse the key rather than being the path that reaches it.

Payloads also record their key derivation. Nothing did, so changing the
derivation later would have reported every existing state file as "wrong
key or corrupted file". Absent means hkdf-sha256, the only one shipped, so
existing files still read.

Also spreads unknown top-level fields through the v1 schema migration,
which rebuilt the state object from a fixed field list like the semver
layer did.

* docs(cli): say `sql query` does not record history

`sql history` can only ever show interactive-terminal queries, so the
help text states it instead of leaving users to infer it from an empty
list. Recording from `sql query` was the alternative and was rejected:
it is the CI path, and writing query text plus returned rows to a build
agent's disk is an exposure nobody asked for.

* feat(runner): warn when a build.exclude entry matches nothing

`include` already had this; `exclude` is the half that fails dangerously.
A bad include runs nothing and announces itself, a bad exclude fences off
nothing — the seeds and destructive DDL the author held back execute, and
the build reports success with no signal anywhere.

* fix(db): gate db create on the access policy

The CLI ran no policy check at all while the TUI enforced db:create, so a
viewer -- the role defined as read-only -- created databases and wrote
noorm's tracking schema into them. Independently reproduced by three audit
agents.

Adds assertDbPolicy as a shared core seam for core/db and core/teardown,
which between them held zero policy calls, unlike core/runner, core/change,
core/transfer and core/sql-terminal. Unlike assertPolicy it also enforces
the confirm half of the matrix, which destructive lifecycle commands need.

destroyDb also loses its dead trackingOnly branch: no caller ever passed it,
and its body swallowed the change-table error into an empty block.

* fix(cli): make --schema on explore list commands actually filter

`--schema` was declared on views/procedures/functions/types but only read
inside the detail branch, so in list mode it was accepted, ignored, and
exited 0 with the full unfiltered listing. `indexes` and `fks` never
declared it, and `tables` made citty print help text to stdout under
--json and exit 1.

All seven list commands now declare it and push it into the query. They
call core fetchList directly because the SDK's list methods take no
options, so the filter cannot reach the query through them - noted as a
follow-up for the SDK surface.

Human output also qualifies names with their schema, so two same-named
tables in different schemas no longer render as identical rows.

Adds the multi-schema integration fixture the suite never had: a second
schema on postgres and mssql, a second database on mysql, plus hostile
sqlite identifiers. Without it, "detail returns another schema's
indexes" and "--schema does nothing" are both invisible.

* fix(db): require confirmation for truncate and teardown

Both consulted db:reset, which is allow for admin, so the default config
every ci init and config add produces wiped 12 tables or dropped 63 objects
with nothing asked of it: 'noorm db teardown < /dev/null' exited 0. --yes
and --force were declared args neither command read, and the shipped docs
claim in two places that the CLI refuses to wipe data otherwise.

They now consult db:truncate and db:teardown, both confirm for admin. A dry
run is checked for permission but not confirmation -- the preview is the
safety mechanism, and requiring --yes to look first would remove it.

db:teardown is deny below admin, so the operator cases in reset.test.ts and
destructive-ops.test.ts flip from success to denial. The admin-truncate
assertion in destructive-ops.test.ts encoded the defect itself.

* fix(teardown): qualify truncate statements with the table schema

truncateData passed undefined as the schema, so TRUNCATE resolved against
search_path only. A table outside the default schema aborted the run after
earlier truncates had already committed -- partial irreversible data loss,
reported as a failure naming a table the operator may not recognise, with no
record of what was destroyed.

The FK toggle had the same gap, so disable/enableForeignKeyChecks now take
table refs rather than bare names and MSSQL qualifies its per-table NOCHECK.

operations.test.ts asserted the unqualified output as correct, which is why
a green suite could not see this. Teardown objects outside the default
schema are now reported qualified too: the dry run listed a foreign
app_private.secrets as a bare 'secrets', indistinguishable from a public
table, so the one safety mechanism on offer hid the blast radius.

* fix(connection): stop testServerOnly creating the sqlite target

testServerOnly is documented as verifying credentials without requiring the
target database -- the setup-wizard case. SQLite has no system database to
swap to, so the probe opened the target path and the driver created it: a
wizard's Test Connection button left a zero-byte database behind and the
user could no longer tell a fresh target from one testing had made. Probe
the containing directory instead; an existing target is still opened.

db drop also gains a warning when NOORM_CONNECTION_* has repointed the
config: the role authorises a config name while the destruction follows the
resolved connection, and those can differ. The retargeting is deliberate
(#51); going silent about it was not.

* docs(execution): record the dedup key and the real per-dialect behaviour

States that the checksum is taken over rendered output, and what that
implies for a `$.now()` template. Corrects the claim that MySQL and SQLite
drivers accept multi-statement strings — MySQL rejects them and SQLite
silently dropped everything after the first.

* fix(lock): make lock acquisition atomic

acquire() was SELECT-then-INSERT, so the UNIQUE constraint — not the
code — decided contention: the loser got a raw driver error rather than
LockAcquireError, and `wait: true` threw in milliseconds instead of
polling to waitTimeout. One conflict-aware insert now decides the winner,
and zero rows inserted means contention.

* fix(lock): gate lock force and report what it evicted

Breaking a lock interrupts someone's in-flight migration, but the command
took no confirmation, named nobody, and reported released:true with exit 0
on an empty lock table. The gate sits on the SDK namespace so CLI, TUI and
MCP inherit one enforcement path.

BREAKING CHANGE: forceRelease() returns { released, holder } instead of a
boolean, and `noorm lock force` now requires --yes and exits 2 when there
was nothing to release.

* fix(ci): back up state before init --force destroys it, and validate the config

Two defects in `ci init`, both confirmed by the state/config audit slice.

--force deleted state.enc outright — every config and every config-scoped
secret in the project, with no backup and no way back. Copy it aside
owner-only first and report where it went. On an ephemeral runner the
destruction is the intent, so --force alone still suffices there; from an
interactive terminal, where it is far more likely to be a real project,
--yes is now required as well. Gating on TTY rather than adding a mandatory
flag keeps documented CI pipelines working.

The Config was also hand-built and written straight to state, skipping
parseConfig. Env vars run through a parser that converts numeric-looking
values, so a database named `20240101` persisted as the number 20240101 —
a shape the schema forbids and every later consumer had to survive.

* test(impersonate): drop the test role's grants so teardown is idempotent

GRANT CONNECT leaves a dependency that blocks DROP ROLE, so the role leaked
on first teardown and every later setup failed. Only surfaced in the full
integration run, where the suite runs twice against the same database.

* test(teardown): pre-confirm the sdk preserve integration context

These cases are about preserve-list plumbing reaching a real database, not
about the access gate; db:truncate and db:teardown are confirm cells even
for admin, so an unconfirmed context now stops before the plumbing runs.

* fix(transfer): report rows written, not rows attempted

An insert that does not throw is not an insert that wrote: ON CONFLICT DO
NOTHING and INSERT IGNORE both succeed silently on a conflict. Same-server
transfers reported plan.rowCount, a reltuples estimate, as the row count.

* feat(cli): add `db explore triggers` and show every listable counter

Triggers were implemented in core and counted in the CLI's --json
overview, but reachable only over RPC/MCP, so the CLI advertised a
number for something it had no way to show. The human overview also
printed 5 of the 10 counters it returns.

Now every counter the human overview prints has a subcommand that lists
it. Locks and connections stay out of the human summary deliberately -
they are runtime state rather than schema and have no CLI listing; they
remain in --json.

* test(explore): guard sqlite trigger parsing against real sqlite_master text

Body statements and a trigger name containing "before" both used to
leak into the reported timing and events.

* fix(cli): export all tables when --export omits --tables

The flag documents itself as "(default: all)" but defaulted to an empty
list, so the command wrote no files, reported success and exited 0.

* fix(transfer): make cross-dialect transfer actually work

The planner probed the destination with the source dialect, so every
cross-dialect transfer aborted there and DtStreamer had never executed.
With the probe corrected the path ran and lost two thirds of the table
in silence: MySQL rejected the unconverted jsonb object, and the skip
check read the raw option so the SDK default swallowed the error.

* fix(change): retrieve insert ids on mysql

ChangeHistory carries its own copy of the insert-and-get-id logic,
independent of Tracker's, so fixing the runner left the change module
inoperable on MySQL: both createOperation and recordReset used a
RETURNING clause MySQL does not have. No operation record was created,
so no change could run, and recordReset failed silently returning 0.

Mirror the runner's three-way branch. MySQL reads the generated key off
the insert result rather than issuing LAST_INSERT_ID() as a second
query, which is per-connection while Kysely pools connections — the
same reason that case is now gone from #lastInsertIdQuery.

* fix(transfer): gate plan reads and surface partial outcomes

getTransferPlan leaked destination table names, row estimates and the FK
graph to a role denied the transfer itself. 'partial' was unreachable —
allSuccess is false whenever hasFailures is true — so a run that
committed most of its tables looked like one that committed none; the
CLI now exits 3 for it. A row the destination rejected outright is a
failure, not a skip.

* chore(changeset): note the mysql insert-id fix

* fix(tui): add missing hook dependencies

exhaustive-deps is not enabled (eslint-plugin-react-hooks is not
installed), so these went unnoticed. Ranked by blast radius:

- dialect omitted in useLockStatus/useVaultSecretKeys, where a stale
  value silently falls back to postgres-shaped queries across 7 screens
- cryptoIdentity omitted in 6 change/run execute handlers, attributing
  history to the fallback identity when it resolves late
- settings omitted where it feeds createChangeManager and the scaffold
  target directory

ConfigExportScreen's handleExport moves above its two callers so they
can depend on it instead of capturing a first-render closure around the
encryption and secret read.

* fix(policy)!: stop granting MCP admin by default

A config with no explicit `access` gave the agent channel `admin`, so
every stock project let an MCP client write, run DDL, and drop databases
— making the rest of the matrix decorative there.

BREAKING CHANGE: default access is now `{ user: 'admin', mcp: 'viewer' }`.
Agent writes require an explicit `access.mcp` opt-in. Configs that already
store an explicit `access` are untouched.

* fix(tui): stop clobbering .noorm/.gitignore on re-init

performProjectInit writes it only when absent; the TUI's reimplementation
overwrote it every time, discarding user edits.

* test(integration): confirm destructive db operations explicitly

db truncate and db teardown now require confirmation, and the SDK equivalent
is options.yes. These tests invoked them bare and asserted success, which
encoded the defect the gating fixes: teardown dropped 63 objects and exited 0
with no confirmation. Passing --yes/yes states the intent rather than
restoring the old behaviour.

* fix(ci): gate identity enroll on vault:propagate

Enrollment ends in a vault grant, so it is a config-scoped action, but it
called the ungated propagateVaultKeyTo and never consulted the config's
access. A viewer config — denied vault:read outright — could still hand the
vault to a new identity, and no role was ever asked to confirm a grant that
cannot be revoked.

Build the gate from ctx.noorm.config, the same source the vault commands
use. Authorization is checked before any read or write, so a denied role
cannot probe the identities table. Confirmation is asked later, once the
target is known, so the operator sees the identity they are granting to —
and is skipped when that identity already holds access, since confirming a
no-op is just noise.

* fix(sdk): stop a failed or forgotten revert from wedging the connection

scope.revert() set `reverted` before awaiting revertFn, so a revert that
threw still marked the scope done: the retry the error message invites
returned early, the revert SQL never ran, and the pooled connection the
scope holds was never released. Set the flag only once revertFn resolves,
and share the in-flight promise so concurrent callers still run it once.
Idempotency after a successful revert is unchanged.

Explicit-mode scopes also held a pooled connection with nothing to release
it on teardown, so a caller who never reverted — an early return, a thrown
error, a forgotten call — left disconnect() awaiting a pool drain that could
never finish. Track the holders and release them before destroy(). The
revert SQL is skipped there deliberately: the pool is going away, so no
later query can inherit the identity.

* fix(tui): unwrap the vault recipients tuple

getUsersWithoutVaultAccess returns its own [users, err] tuple, but the vault
screen still wrapped it in attempt(), nesting one tuple inside another. The
branch did not typecheck at HEAD.

Not this slice's file — the collision landed between two merges, and fixing
it here was the only way to get a green typecheck to verify against.

* refactor(shared): unify operation-record creation across dialects

The insert-and-get-id logic was copy-pasted into runner/tracker and
twice into change/history. All three emitted RETURNING on MySQL, which
has no such clause, so fixing one copy left the others shipping broken.

* test(shared): pin the per-dialect id-retrieval contract

Compiles the insert per dialect and reads the statement back, so a
RETURNING on MySQL or a per-connection LAST_INSERT_ID follow-up fails
without needing a live server to notice.

* docs(policy): record the agent-channel access default

Spec body, MCP guide, and both migration notes described the old
admin/admin default. Changeset marks the break: MCP writes now need an
explicit `access.mcp` opt-in.

* fix(tui): consume the vault recipients tuple instead of nesting it

getUsersWithoutVaultAccess returns [users, error] so a failed lookup cannot
read as "nobody is missing access". VaultScreen still called it through
attempt(), nesting one tuple inside another, and derived its recipient type
from the union. Only surfaced once both branches landed.

* docs(policy): drop stale admin/admin references

Comments and wiki signals still named the superseded default and the old
`OPEN_ACCESS` constant.

* chore(changeset): describe the v1 readiness fixes

* feat(cli)!: separate partial, total and usage exit codes

`2` meant partial failure on the run/change batch commands, "there was
nothing to release" on `lock force`, and — on those same batch commands —
total failure as well, since `status === 'success' ? 0 : 2` collapsed
`failed` and `partial` into one code. A pipeline could not tell "retry
this" from "a human has to look at the half-written database", so in
practice everyone tested `!= 0` and threw the distinction away.

`src/cli/_exit.ts` now fixes the meanings: 0 success, 1 total failure,
2 the invocation named something that isn't there, 3 partial. Confirmation
and `--force` refusals deliberately stay at 1 rather than becoming usage
errors, so the code means one thing across every command.

BREAKING CHANGE: a script testing `[ $? -eq 2 ]` for a partial apply must
now test `-eq 3`. "Named target does not exist" moves from 1 to 2.

* feat(cli)!: pin one --json success envelope across every command

Success payloads had four incompatible shapes — `{version,…}`,
`{configs}`, a bare array from the list commands, `{success,path}` — and
only errors were uniform, so `jq -e '.success'` was absent on most
successes and errored outright on the arrays. The exit code was the only
cross-command discriminator, and it was inconsistent too.

Every `--json` payload is now an object carrying a top-level boolean
`success`, injected once in `outputResult` rather than at ~70 call sites
that would drift. `success` is derived from the payload's `status` where
there is one, so a `partial` batch can no longer announce itself as a
success, and it always agrees with the exit code.

`config export --json` gets the envelope; its default output is still the
bare artifact `config import` reads, so `config export dev > dev.json` is
unchanged. `noorm update --json` stopped printing two documents for one
error and reporting `success: true` on a failed install.

BREAKING CHANGE: commands that returned a top-level array now return a
named object — `change list` → `.changes`, `change history` → `.history`,
`db explore` lists → `.tables` / `.views` / `.indexes` / `.foreignKeys` /
`.functions` / `.procedures` / `.types` / `.triggers`.

* fix(cli): stop reporting success for targets that were never there

`run inspect sql/nope.sql.tmpl` returned a fully-populated context report
and exit 0 for a file that was never on disk — `buildContext` only reads
the template's *directory*, so nothing on that path ever checked. `run
dir` had two variants of the same shape: a missing directory surfaced as
a generic SQL failure, and a directory holding no SQL files came back as
`status: 'success'` over zero files, which is how a mistyped path passes
a pipeline silently.

All three now fail with the usage code and a message naming the path.
`run preview` gained the same up-front check so both template commands
agree, and its `--json` error field carries the message only — the stack
trace it used to emit embedded absolute filesystem paths.

* fix(cli): restore run exec under node, and let CI set lock metadata

`run exec` was the only command that hard-required the `Bun` global, so
it died with `Bun is not defined` on the documented Node dev entry — and
in the `tests/cli` harness, which spawns `node dist/cli/index.js`, which
is why the command had no coverage at all. Glob expansion falls back to
`node:fs/promises`; the compiled binary keeps Bun's own matcher so its
matching semantics are untouched.

`lock acquire` gained `--timeout` and `--reason`, which the TUI has
collected since it shipped. Without them a CI-acquired lock was always
default-timeout and reasonless, so whoever it blocked had no way to see
what they were waiting on. Both are omitted rather than passed as
`undefined`, because LockManager merges over its defaults and an explicit
`undefined` erases them.

* docs(headless): write down the --json envelope and the exit-code contract

The envelope was never specified anywhere, which is why four shapes could
coexist without anyone noticing. `docs/headless.md` now states it, names
what breaks for existing consumers, and the exit-code table carries all
four codes instead of three. Stale `jq '.[]'` examples against the
commands that used to return bare arrays are corrected in the guide, the
dev docs, and the noorm skill.

* fix: the access channel names who is driving, not which binary was invoked (#66)

* feat(policy): recognise agent harnesses from the environment they export

An agent denied a write over MCP can see noorm on the PATH and shell out; the
CLI hardcoded the user channel, so that second attempt ran with the human's
role. Detection is the input to resolving the channel from provenance rather
than from which binary was invoked.

Allowlist only, from variables the harnesses set for their own children.
TERM_PROGRAM, CI and TTY state are deliberately excluded: they describe the
terminal or the pipeline, not the caller, and a false positive locks an
operator out of their own CLI.

* feat(identity): stamp agent provenance on audit rows

executed_by recorded who ran an operation and nothing about what drove
the session, so an agent-applied change was indistinguishable from a
human one — the first question asked when a migration goes wrong.

Folded into the identity string rather than a dedicated column: the
audit question is binary, and a suffix answers it on all four dialects
without a schema migration or a CLI/database version skew window.

* feat(cli): show detected agent harness in noorm info

Harness detection silently changes the channel an operation is
authorised on, and a permission denial with no visible cause is bad to
debug. Reports the markers actually set, which is what an operator
would unset to be treated as human.

* chore(changeset): describe agent provenance in audit rows

* fix(connection): connect to MSSQL by IP address

Tedious guards the SNI ServerName against IP literals on its TDS 8.0
path but not on the PRELOGIN path `encrypt: true` takes, so Node
rejected every IP connection. Encryption is never downgraded to work
around it: validating a certificate against an IP now fails, naming
the new tlsServerName config field as the fix.

* feat(policy)!: resolve the access channel from who is driving

Channel named the transport, so the CLI hardcoded `user` at every policy
call site. An agent refused a write over MCP could see noorm on the PATH,
shell out, and run the same operation with the human's role. Measured on a
stock config: sql:write, sql:ddl, db:create, run:build and vault:read all
went deny -> allow, and db:destroy dropped to a confirm that --yes satisfies.

Channel is now 'user' | 'agent' and ConfigAccess is { user, agent }. The CLI
resolves it via resolveChannel(): NOORM_CHANNEL when explicitly user/agent,
else the agent-harness allowlist, else user. `mcp serve` still passes 'agent'
literally, above the override. `agent: false` hides a config on both
transports — `config list` now filters it the way `list_configs` already did.
Stored access.mcp migrates to access.agent at state schema v3, verbatim.

The TUI keeps a literal 'user': it needs a TTY and an interactive human.
NOORM_CHANNEL is excluded from the NOORM_* config/settings env mapping, or it
would invent a `channel` key on every config.

BREAKING CHANGE: Config.access.mcp is now Config.access.agent, and
createContext's channel option takes 'agent' instead of 'mcp'. Agents
shelling out to the CLI now get the agent role rather than the human's.

* test(cli): report what the settings layer resolved when the logger assertions fail

These four assertions all reduce to "did createCliLogger read settings.yml",
and a bare expected/received cannot tell a missing file from a stale singleton
from a load that threw. They fail on CI and pass on macOS and Linux locally —
including a faithful replay of the CI step order — so the failure needs to
describe itself.

* ci: isolate the CLI logger settings tests in their own process

bun's mock.module registry is process-global and re-registering the real
module does not restore it — measured. Two init-screen tests replace the
SettingsManager class, so getSettingsManager constructs a mock instance and
createCliLogger reads settings: {} instead of settings.yml. Their afterAll
restores are no-ops.

Load order decides who wins: root files before subdirectories on macOS, the
reverse on Linux, which is why this passed locally and failed only on CI. A
separate process is the only reliable isolation, matching tests/core/transfer.

Also corrects CLAUDE.md: the documented config env-snapshot contamination
source does not reproduce (memoizeOpts: false), and mock.module restores are
recorded as ineffective.

* test(ci): make the failure fixture actually contain invalid SQL

The e2e step asserting that a bad build does not report success was passing
for the wrong reason. Its fixture claimed an intentional syntax error from a
missing comma, but 'username display_name' is a valid implicit alias, and the
'deleted_at' column it selected is accepted too since SQLite does not resolve
a view's columns until the view is queried. Both verified against bun:sqlite.

The file only ever failed because it held two statements and SQLite's prepare
rejects the trailing one. Once the runner learned to split statements, both
ran cleanly and the build passed.

Replaced with a real parse error, and updated the expected code to 3 (partial:
one file applied, one failed). Exit 2 now means a usage error.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant