fix: the access channel names who is driving, not which binary was invoked - #66
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacks on #64 — review that first.
An agent denied a write over MCP could see
noormon the PATH and shell out. The CLI hardcoded theuserchannel, so the second attempt ran with the human's role. Measured on a stock config before this change:Six escalation paths. The root cause was conceptual:
channeldescribed which binary was invoked when it should describe who is driving. Those coincide only when a human is at the keyboard.The change
Channelis now'user' | 'agent'andConfigAccessis{ user, agent }— the role governs agent access regardless of transport, MCP or CLI.agent: falsehides a config from agents on both.resolveChannel()resolves it:NOORM_CHANNELif explicitlyuser/agent, else a detected harness, elseuser.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,CIand TTY state are deliberately excluded: they describe the terminal or the pipeline, not the caller, and keying onTERM_PROGRAMwould 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:destroyresolves to deny for agents rather thanconfirm, so--yescannot walk through it.Also here
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 infoshows the detected harness and the markers actually set.Setting the TLS ServerName to an IP address is not permitted— tedious derives SNI fromserver, 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 movesenginesto node ≥22, pulls new@azure/*deps, and only covers one of the three cases here.Breaking
access.mcp→access.agent(state schema v3; values carry over verbatim, includingfalse).run buildandchange runare denied by default. If you drive noorm with an agent deliberately, set that config'sagentrole tooperatororadmin.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.tsnow neutralises them. Verified green with markers live in the environment.