From f3af9af6f85d1f4c8d7e8fed7e761b5ffa93138b Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:25:26 -0400 Subject: [PATCH 001/105] 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. --- src/core/policy/matrix.ts | 21 +++++++++++++++++++++ src/core/policy/types.ts | 9 +++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/core/policy/matrix.ts b/src/core/policy/matrix.ts index 6e615268..5ed2873c 100644 --- a/src/core/policy/matrix.ts +++ b/src/core/policy/matrix.ts @@ -23,6 +23,27 @@ export const MATRIX: Record> = { 'db:create': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, 'db:reset': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, 'db:destroy': { viewer: 'deny', operator: 'deny', admin: 'confirm' }, + 'db:truncate': { viewer: 'deny', operator: 'confirm', admin: 'confirm' }, + 'db:teardown': { viewer: 'deny', operator: 'deny', admin: 'confirm' }, 'config:rm': { viewer: 'deny', operator: 'confirm', admin: 'confirm' }, + // Rewrites `access` itself, so it is an escalation vector: confirm even for admin. + 'config:write': { viewer: 'deny', operator: 'confirm', admin: 'confirm' }, + + 'vault:read': { viewer: 'deny', operator: 'allow', admin: 'allow' }, + 'vault:write': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + // Grants the vault key to every enrolled identity — a fan-out no one can undo. + 'vault:propagate': { viewer: 'deny', operator: 'confirm', admin: 'confirm' }, + + 'secret:read': { viewer: 'deny', operator: 'allow', admin: 'allow' }, + 'secret:write': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + + // Dry-run planning still reads destination schema and row estimates. + 'transfer:plan': { viewer: 'deny', operator: 'allow', admin: 'allow' }, + + 'lock:force': { viewer: 'deny', operator: 'confirm', admin: 'confirm' }, + + 'debug:read': { viewer: 'allow', operator: 'allow', admin: 'allow' }, + // Deletes arbitrary rows from noorm.vault / noorm.identities. + 'debug:write': { viewer: 'deny', operator: 'deny', admin: 'confirm' }, }; diff --git a/src/core/policy/types.ts b/src/core/policy/types.ts index 1f0220c5..7a513bcc 100644 --- a/src/core/policy/types.ts +++ b/src/core/policy/types.ts @@ -37,8 +37,13 @@ export type Permission = | 'sql:read' | 'sql:write' | 'sql:ddl' | 'change:run' | 'change:ff' | 'change:revert' | 'change:rm' | 'run:build' | 'run:file' | 'run:dir' - | 'db:create' | 'db:reset' | 'db:destroy' - | 'config:rm'; + | 'db:create' | 'db:reset' | 'db:destroy' | 'db:truncate' | 'db:teardown' + | 'config:rm' | 'config:write' + | 'vault:read' | 'vault:write' | 'vault:propagate' + | 'secret:read' | 'secret:write' + | 'transfer:plan' + | 'lock:force' + | 'debug:read' | 'debug:write'; /** * The minimum shape `checkPolicy` and `guarded` need from a config. From 6b208eef1527dc2f337a2ac59a4043b3ac11c516 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:33:59 -0400 Subject: [PATCH 002/105] fix(identity): reject malformed key material instead of deriving a constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/identity/crypto.ts | 24 ++- src/core/identity/storage.ts | 38 ++++- tests/core/identity/crypto.test.ts | 73 +++++++++ .../core/identity/key-file-corruption.test.ts | 152 ++++++++++++++++++ tests/core/identity/storage.test.ts | 49 ++++++ 5 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 tests/core/identity/key-file-corruption.test.ts diff --git a/src/core/identity/crypto.ts b/src/core/identity/crypto.ts index 99f02595..5e4d1ec4 100644 --- a/src/core/identity/crypto.ts +++ b/src/core/identity/crypto.ts @@ -28,6 +28,7 @@ import { } from 'crypto'; import type { KeyPair, SharedConfigPayload } from './types.js'; +import { isValidKeyHex } from './storage.js'; // ============================================================================= // Key Generation @@ -269,9 +270,19 @@ export function decryptWithPrivateKey(payload: SharedConfigPayload, privateKey: * This is used to encrypt the local state file. The key is derived * directly from the identity's private key (no shared secret needed). * - * @param privateKey - X25519 private key (hex) + * Validation is not optional here. `Buffer.from(str, 'hex')` never throws — + * it stops at the first invalid pair and truncates odd lengths — so every + * malformed key would otherwise collapse to a zero-length HKDF input and + * derive the SAME 32 bytes, a constant any third party can recompute from + * this source file. State encrypted under it is effectively plaintext. This + * function is the single chokepoint every state read and write passes + * through, so the guard belongs here rather than at each call site. + * + * @param privateKey - X25519 private key (hex-encoded PKCS8 DER) * @returns 32-byte key for AES-256 * + * @throws Error if the key is not well-formed hex key material + * * @example * ```typescript * const stateKey = deriveStateKey(privateKey) @@ -280,6 +291,17 @@ export function decryptWithPrivateKey(payload: SharedConfigPayload, privateKey: */ export function deriveStateKey(privateKey: string): Buffer { + if (!isValidKeyHex(privateKey)) { + + throw new Error( + 'Invalid identity key material: expected a hex-encoded X25519 key. ' + + 'Refusing to derive a state encryption key from malformed input — doing so ' + + 'would produce a key that anyone can compute. Check ~/.noorm/identity.key ' + + 'or NOORM_IDENTITY_PRIVATE_KEY for corruption or truncation.', + ); + + } + const privateKeyBuffer = Buffer.from(privateKey, 'hex'); return Buffer.from( diff --git a/src/core/identity/storage.ts b/src/core/identity/storage.ts index 0ed1e40e..02f7f144 100644 --- a/src/core/identity/storage.ts +++ b/src/core/identity/storage.ts @@ -216,8 +216,17 @@ export async function loadIdentityMetadata(): Promise { * * Returns the in-memory override if set, otherwise reads from disk. * + * A missing file is "no identity yet" (null). A file that exists but does not + * hold well-formed key material is a hard error, not a null: silently passing + * corrupted bytes downstream lets `deriveStateKey` truncate them to a + * degenerate input and encrypt state under a publicly computable key. A + * partially-synced or truncated key file is otherwise undetectable, since + * nothing verifies `identity.key` against the sibling `identity.pub`. + * * @returns Private key as hex string, or null if not found * + * @throws Error if the key file exists but is corrupted or has unsafe permissions + * * @example * ```typescript * const privateKey = await loadPrivateKey() @@ -259,7 +268,18 @@ export async function loadPrivateKey(): Promise { } - return content.trim(); + const privateKey = content.trim(); + + if (!isValidKeyHex(privateKey)) { + + throw new Error( + `Corrupted private key file (${PRIVATE_KEY_PATH}): contents are not hex-encoded X25519 key material. ` + + 'Restore it from your backup — state encrypted under a different key cannot be recovered by regenerating one.', + ); + + } + + return privateKey; } @@ -454,9 +474,25 @@ let keyOverride: string | null = null; * Called once at process startup (in `cli/index.ts entry()`) when CI * env vars are detected. After this, `loadPrivateKey()` returns * the env key for the lifetime of the process. + * + * Validated for the same reason `loadPrivateKey()` validates the disk file: + * whatever lands here flows straight into `deriveStateKey`, and malformed + * key material silently derives a publicly computable encryption key. This + * is a public export, so the env loader's own check is not sufficient. + * + * @throws Error if the key is not well-formed hex key material */ export function setKeyOverride(key: string): void { + if (!isValidKeyHex(key)) { + + throw new Error( + 'Invalid private key override: expected a hex-encoded X25519 key ' + + '(96 hex characters). Check NOORM_IDENTITY_PRIVATE_KEY.', + ); + + } + keyOverride = key; } diff --git a/tests/core/identity/crypto.test.ts b/tests/core/identity/crypto.test.ts index 09969975..aa1589d1 100644 --- a/tests/core/identity/crypto.test.ts +++ b/tests/core/identity/crypto.test.ts @@ -2,6 +2,7 @@ * Cryptographic identity tests. */ import { describe, it, expect } from 'bun:test'; +import { attemptSync } from '@logosdx/utils'; import { generateKeyPair, encryptForRecipient, @@ -259,6 +260,49 @@ describe('identity: crypto', () => { }); + /** + * `Buffer.from(str, 'hex')` never throws — it stops at the first invalid + * pair and truncates odd lengths. Without a guard, every malformed key + * collapses to the same zero-length HKDF input, so the derived AES key is + * a constant anyone can recompute from the source. The assertion is that + * derivation REFUSES, not that the constants differ. + */ + it('should reject non-hex key material rather than deriving a constant key', () => { + + for (const badKey of ['', 'not-hex-at-all', 'zz', 'corrupted-not-hex-at-all']) { + + const [key, err] = attemptSync(() => deriveStateKey(badKey)); + + expect(err).toBeInstanceOf(Error); + expect(key).toBeNull(); + + } + + }); + + it('should reject a truncated private key', () => { + + const { privateKey } = generateKeyPair(); + + const [key, err] = attemptSync(() => deriveStateKey(privateKey.slice(0, 40))); + + expect(err).toBeInstanceOf(Error); + expect(key).toBeNull(); + + }); + + it('should never derive the same key from two different malformed inputs', () => { + + const [k1] = attemptSync(() => deriveStateKey('zz')); + const [k2] = attemptSync(() => deriveStateKey('also-not-hex')); + + // Both must be null (rejected). If either derived a buffer, the + // degenerate-IKM collapse is back. + expect(k1).toBeNull(); + expect(k2).toBeNull(); + + }); + }); describe('encryptState / decryptState', () => { @@ -323,6 +367,35 @@ describe('identity: crypto', () => { }); + /** + * Reachability proof for the degenerate-key defect: a corrupted + * `identity.key` reaches `encryptState` directly via StateManager, so + * state written under it would be readable by anyone. Encryption must + * refuse rather than produce a decryptable-by-all artifact. + */ + it('should refuse to encrypt state under malformed key material', () => { + + const [encrypted, err] = attemptSync(() => + encryptState('{"secrets":{"prod":{"STRIPE_KEY":"sk_live_x"}}}', 'corrupted-not-hex'), + ); + + expect(err).toBeInstanceOf(Error); + expect(encrypted).toBeNull(); + + }); + + it('should refuse to decrypt state under malformed key material', () => { + + const keypair = generateKeyPair(); + const encrypted = encryptState('secret', keypair.privateKey); + + const [plaintext, err] = attemptSync(() => decryptState(encrypted, 'zz')); + + expect(err).toBeInstanceOf(Error); + expect(plaintext).toBeNull(); + + }); + }); }); diff --git a/tests/core/identity/key-file-corruption.test.ts b/tests/core/identity/key-file-corruption.test.ts new file mode 100644 index 00000000..1f202587 --- /dev/null +++ b/tests/core/identity/key-file-corruption.test.ts @@ -0,0 +1,152 @@ +/** + * core/identity: loadPrivateKey — corrupted key-material guard. + * + * `Buffer.from(str, 'hex')` never throws: it stops at the first invalid pair + * and truncates odd lengths. A corrupted, truncated or partially-synced + * `~/.noorm/identity.key` therefore used to reduce to a zero-length HKDF + * input, and `deriveStateKey` returned a CONSTANT that anyone can recompute + * from the source. State written under it was effectively plaintext. + * + * `loadPrivateKey()` derives PRIVATE_KEY_PATH from `homedir()` at module + * import time, so it can't be redirected in-process. Each case spawns a fresh + * `bun -e` subprocess against the built dist output with `HOME` repointed at a + * throwaway tmp dir — same idiom as storage-key-permission-guard.test.ts. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const STORAGE_MODULE = join(process.cwd(), 'dist/core/identity/storage.js'); +const CRYPTO_MODULE = join(process.cwd(), 'dist/core/identity/crypto.js'); + +const LOAD_PRIVATE_KEY_SCRIPT = ` + import(${JSON.stringify(STORAGE_MODULE)}).then(async (m) => { + const key = await m.loadPrivateKey(); + process.stdout.write('OK:' + key); + }).catch((err) => { + process.stderr.write('ERR:' + err.message); + process.exit(1); + }); +`; + +/** + * End-to-end reachability: read the on-disk key, then derive the state key + * from it. Before the guard this printed a constant for every malformed key. + */ +const DERIVE_STATE_KEY_SCRIPT = ` + Promise.all([ + import(${JSON.stringify(STORAGE_MODULE)}), + import(${JSON.stringify(CRYPTO_MODULE)}), + ]).then(async ([storage, crypto]) => { + const key = await storage.loadPrivateKey(); + process.stdout.write('DERIVED:' + crypto.deriveStateKey(key).toString('hex')); + }).catch((err) => { + process.stderr.write('ERR:' + err.message); + process.exit(1); + }); +`; + +describe('identity: storage (loadPrivateKey key-material guard)', () => { + + let fakeHome: string; + let keyPath: string; + + beforeEach(() => { + + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-key-material-home-')); + mkdirSync(join(fakeHome, '.noorm'), { recursive: true }); + keyPath = join(fakeHome, '.noorm', 'identity.key'); + + }); + + afterEach(() => { + + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + function run(script: string) { + + return spawnSync('bun', ['-e', script], { + encoding: 'utf-8', + env: { ...process.env, HOME: fakeHome }, + }); + + } + + function writeKey(contents: string) { + + writeFileSync(keyPath, contents, { mode: 0o600 }); + chmodSync(keyPath, 0o600); + + } + + it('rejects a non-hex private key file', () => { + + writeKey('corrupted-not-hex-at-all'); + + const result = run(LOAD_PRIVATE_KEY_SCRIPT); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('ERR:'); + + }); + + it('rejects a truncated private key file', () => { + + writeKey('a'.repeat(40)); + + const result = run(LOAD_PRIVATE_KEY_SCRIPT); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('ERR:'); + + }); + + it('rejects an empty private key file', () => { + + writeKey(''); + + const result = run(LOAD_PRIVATE_KEY_SCRIPT); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('ERR:'); + + }); + + it('names the key file in the error so the operator can find it', () => { + + writeKey('zzzz'); + + const result = run(LOAD_PRIVATE_KEY_SCRIPT); + + expect(result.stderr).toContain('identity.key'); + + }); + + it('never derives a state key from a corrupted key file', () => { + + writeKey('corrupted-not-hex-at-all'); + + const result = run(DERIVE_STATE_KEY_SCRIPT); + + expect(result.status).toBe(1); + expect(result.stdout).not.toContain('DERIVED:'); + + }); + + it('still loads a well-formed private key', () => { + + const key = 'a'.repeat(96); + writeKey(key); + + const result = run(LOAD_PRIVATE_KEY_SCRIPT); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(`OK:${key}`); + + }); + +}); diff --git a/tests/core/identity/storage.test.ts b/tests/core/identity/storage.test.ts index f2b50f86..9b043b7b 100644 --- a/tests/core/identity/storage.test.ts +++ b/tests/core/identity/storage.test.ts @@ -5,12 +5,16 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { join } from 'path'; import { mkdtemp, rm, readFile, stat, chmod, writeFile } from 'fs/promises'; import { tmpdir, homedir } from 'os'; +import { attemptSync } from '@logosdx/utils'; import { isValidKeyHex, getPrivateKeyPath, getPublicKeyPath, getNoormHomePath, validateKeyPermissions, + setKeyOverride, + clearKeyOverride, + getKeyOverride, } from '../../../src/core/identity/storage.js'; import { generateKeyPair } from '../../../src/core/identity/crypto.js'; @@ -68,6 +72,51 @@ describe('identity: storage', () => { }); + /** + * `setKeyOverride` is the CI in-memory key path. Anything it accepts flows + * straight into `deriveStateKey`, so an unvalidated override is the same + * degenerate-key hole as a corrupted `identity.key` file. + */ + describe('setKeyOverride', () => { + + afterEach(() => { + + clearKeyOverride(); + + }); + + it('should reject key material that is not valid hex', () => { + + const [, err] = attemptSync(() => setKeyOverride('not-hex-at-all')); + + expect(err).toBeInstanceOf(Error); + expect(getKeyOverride()).toBeNull(); + + }); + + it('should reject a truncated key', () => { + + const { privateKey } = generateKeyPair(); + + const [, err] = attemptSync(() => setKeyOverride(privateKey.slice(0, 40))); + + expect(err).toBeInstanceOf(Error); + expect(getKeyOverride()).toBeNull(); + + }); + + it('should accept a well-formed private key', () => { + + const { privateKey } = generateKeyPair(); + + setKeyOverride(privateKey); + + expect(getKeyOverride()).toBe(privateKey); + + }); + + }); + describe('path accessors', () => { it('should return noorm home in home directory', () => { From 640558c7e709ea2f999a4140437d3903a79ff054 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:35:12 -0400 Subject: [PATCH 003/105] fix(settings): stop persisting env overlay into settings.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/settings/manager.ts | 143 ++++++++++++++++------- tests/core/settings/env-override.test.ts | 97 ++++++++++++++- 2 files changed, 199 insertions(+), 41 deletions(-) diff --git a/src/core/settings/manager.ts b/src/core/settings/manager.ts index 0340c144..4f325c1c 100644 --- a/src/core/settings/manager.ts +++ b/src/core/settings/manager.ts @@ -7,7 +7,7 @@ import { readFile, writeFile, mkdir, access } from 'node:fs/promises'; import { join } from 'node:path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import { attempt, merge, makeNestedConfig } from '@logosdx/utils'; +import { attempt, clone, merge, makeNestedConfig } from '@logosdx/utils'; import { observer } from '../observer.js'; import { parseSettings } from './schema.js'; @@ -94,7 +94,18 @@ export class SettingsManager { #projectRoot: string; #settingsDir: string; #settingsFile: string; + + /** + * The persisted document — exactly what is on (or will be written to) + * disk. The env overlay is deliberately kept out of it: settings.yml is + * version controlled, so merging ambient NOORM_* vars in here would + * commit whatever the shell exported (vault tokens, DB passwords). + */ + #document: Settings | null = null; + + /** `#document` + the env overlay — the view every accessor returns. */ #settings: Settings | null = null; + #loaded = false; constructor(projectRoot: string, options: SettingsManagerOptions = {}) { @@ -167,7 +178,7 @@ export class SettingsManager { if (!fileExists) { - this.#settings = createDefaultSettings(); + this.#document = createDefaultSettings(); } else { @@ -193,12 +204,12 @@ export class SettingsManager { // Handle empty file or valid content if (parsed === null || parsed === undefined) { - this.#settings = createDefaultSettings(); + this.#document = createDefaultSettings(); } else { - this.#settings = parseSettings(parsed); + this.#document = parseSettings(parsed); } @@ -206,18 +217,17 @@ export class SettingsManager { } - // Apply environment variable overrides (NOORM_* -> nested settings) - this.#settings = merge(this.#settings, allSettingsEnv()) as Settings; + this.#refreshResolved(); this.#loaded = true; observer.emit('settings:loaded', { path: this.settingsFilePath, - settings: this.#settings, + settings: this.#settings!, fromFile, }); - return this.#settings; + return this.#settings!; } @@ -246,8 +256,8 @@ export class SettingsManager { } - // Stringify to YAML - const yaml = stringifyYaml(this.#settings, { + // The document, not the resolved view — see #document. + const yaml = stringifyYaml(this.#document, { indent: 4, lineWidth: 120, }); @@ -281,7 +291,8 @@ export class SettingsManager { } - this.#settings = createDefaultSettings(); + this.#document = createDefaultSettings(); + this.#refreshResolved(); this.#loaded = true; await this.save(); @@ -600,16 +611,20 @@ export class SettingsManager { this.#assertLoaded(); - if (!this.#settings!.stages) { + if (!this.#document!.stages) { - this.#settings!.stages = {}; + this.#document!.stages = {}; } - this.#settings!.stages[name] = stage; + // Cloned so the document never shares a reference with the resolved + // view, which the env overlay writes into. + this.#document!.stages[name] = clone(stage); await this.save(); + this.#refreshResolved(); + observer.emit('settings:stage-set', { name, stage }); } @@ -621,16 +636,18 @@ export class SettingsManager { this.#assertLoaded(); - if (!this.#settings!.stages || !(name in this.#settings!.stages)) { + if (!this.#document!.stages || !(name in this.#document!.stages)) { return false; } - delete this.#settings!.stages[name]; + delete this.#document!.stages[name]; await this.save(); + this.#refreshResolved(); + observer.emit('settings:stage-removed', { name }); return true; @@ -644,16 +661,18 @@ export class SettingsManager { this.#assertLoaded(); - if (!this.#settings!.rules) { + if (!this.#document!.rules) { - this.#settings!.rules = []; + this.#document!.rules = []; } - this.#settings!.rules.push(rule); + this.#document!.rules.push(clone(rule)); await this.save(); + this.#refreshResolved(); + observer.emit('settings:rule-added', { rule }); } @@ -665,16 +684,18 @@ export class SettingsManager { this.#assertLoaded(); - if (!this.#settings!.rules || index < 0 || index >= this.#settings!.rules.length) { + if (!this.#document!.rules || index < 0 || index >= this.#document!.rules.length) { return false; } - const [removed] = this.#settings!.rules.splice(index, 1); + const [removed] = this.#document!.rules.splice(index, 1); await this.save(); + this.#refreshResolved(); + observer.emit('settings:rule-removed', { index, rule: removed! }); return true; @@ -688,10 +709,12 @@ export class SettingsManager { this.#assertLoaded(); - this.#settings!.build = build; + this.#document!.build = clone(build); await this.save(); + this.#refreshResolved(); + observer.emit('settings:build-updated', { build }); } @@ -703,10 +726,12 @@ export class SettingsManager { this.#assertLoaded(); - this.#settings!.paths = paths; + this.#document!.paths = clone(paths); await this.save(); + this.#refreshResolved(); + observer.emit('settings:paths-updated', { paths }); } @@ -718,10 +743,12 @@ export class SettingsManager { this.#assertLoaded(); - this.#settings!.strict = strict; + this.#document!.strict = clone(strict); await this.save(); + this.#refreshResolved(); + observer.emit('settings:strict-updated', { strict }); } @@ -733,10 +760,12 @@ export class SettingsManager { this.#assertLoaded(); - this.#settings!.logging = logging; + this.#document!.logging = clone(logging); await this.save(); + this.#refreshResolved(); + observer.emit('settings:logging-updated', { logging }); } @@ -748,10 +777,12 @@ export class SettingsManager { this.#assertLoaded(); - this.#settings!.teardown = teardown; + this.#document!.teardown = clone(teardown); await this.save(); + this.#refreshResolved(); + observer.emit('settings:teardown-updated', { teardown }); } @@ -778,14 +809,14 @@ export class SettingsManager { this.#assertLoaded(); - if (!this.#settings!.secrets) { + if (!this.#document!.secrets) { - this.#settings!.secrets = []; + this.#document!.secrets = []; } // Check for duplicate key - const existing = this.#settings!.secrets.find((s) => s.key === secret.key); + const existing = this.#document!.secrets.find((s) => s.key === secret.key); if (existing) { @@ -793,10 +824,12 @@ export class SettingsManager { } - this.#settings!.secrets.push(secret); + this.#document!.secrets.push(clone(secret)); await this.save(); + this.#refreshResolved(); + observer.emit('settings:secret-added', { secret, scope: 'universal' }); } @@ -808,13 +841,13 @@ export class SettingsManager { this.#assertLoaded(); - if (!this.#settings!.secrets) { + if (!this.#document!.secrets) { throw new Error(`Universal secret "${key}" not found`); } - const index = this.#settings!.secrets.findIndex((s) => s.key === key); + const index = this.#document!.secrets.findIndex((s) => s.key === key); if (index === -1) { @@ -822,10 +855,12 @@ export class SettingsManager { } - this.#settings!.secrets[index] = secret; + this.#document!.secrets[index] = clone(secret); await this.save(); + this.#refreshResolved(); + observer.emit('settings:secret-updated', { key, secret, scope: 'universal' }); } @@ -837,13 +872,13 @@ export class SettingsManager { this.#assertLoaded(); - if (!this.#settings!.secrets) { + if (!this.#document!.secrets) { return false; } - const index = this.#settings!.secrets.findIndex((s) => s.key === key); + const index = this.#document!.secrets.findIndex((s) => s.key === key); if (index === -1) { @@ -851,10 +886,12 @@ export class SettingsManager { } - this.#settings!.secrets.splice(index, 1); + this.#document!.secrets.splice(index, 1); await this.save(); + this.#refreshResolved(); + observer.emit('settings:secret-removed', { key, scope: 'universal' }); return true; @@ -872,7 +909,7 @@ export class SettingsManager { this.#assertLoaded(); - const stage = this.getStage(stageName); + const stage = this.#documentStage(stageName); if (!stage) { @@ -910,7 +947,7 @@ export class SettingsManager { this.#assertLoaded(); - const stage = this.getStage(stageName); + const stage = this.#documentStage(stageName); if (!stage) { @@ -947,7 +984,7 @@ export class SettingsManager { this.#assertLoaded(); - const stage = this.getStage(stageName); + const stage = this.#documentStage(stageName); if (!stage || !stage.secrets) { @@ -982,7 +1019,7 @@ export class SettingsManager { */ #assertLoaded(): void { - if (!this.#loaded || !this.#settings) { + if (!this.#loaded || !this.#document) { throw new Error('SettingsManager not loaded. Call load() first.'); @@ -990,6 +1027,32 @@ export class SettingsManager { } + /** + * Recompute the resolved view from the document plus the env overlay. + * + * Called after every load and every mutation. `merge` mutates and + * returns its target, so the document is cloned first — otherwise the + * overlay would write straight back into what `save()` serialises, + * which is the leak this split exists to prevent. + */ + #refreshResolved(): void { + + this.#settings = merge(clone(this.#document!), allSettingsEnv()) as Settings; + + } + + /** + * The document's copy of a stage — the read side of a read-modify-write. + * + * Stage mutators must not start from the resolved view, or the env + * overlay riding on that stage would be written back to disk. + */ + #documentStage(name: string): Stage | undefined { + + return this.#document!.stages?.[name]; + + } + } // ───────────────────────────────────────────────────────────── diff --git a/tests/core/settings/env-override.test.ts b/tests/core/settings/env-override.test.ts index 95d454fa..f0873a4c 100644 --- a/tests/core/settings/env-override.test.ts +++ b/tests/core/settings/env-override.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { SettingsManager, resetSettingsManager } from '../../../src/core/settings/manager.js'; -import { writeFile, mkdir, rm } from 'node:fs/promises'; +import { writeFile, readFile, mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -22,6 +22,10 @@ describe('settings: env overrides', () => { 'NOORM_CONFIG', 'NOORM_YES', 'NOORM_JSON', + 'NOORM_VAULT_TOKEN', + 'NOORM_DB_PASSWORD', + 'NOORM_API_KEY', + 'NOORM_FOO_BAR', ]; beforeEach(async () => { @@ -116,4 +120,95 @@ describe('settings: env overrides', () => { }); + /** + * settings.yml is version controlled by design. The env overlay is a + * per-process view of settings — persisting it launders whatever the + * shell happened to export (vault tokens, DB passwords) into a file + * that gets committed. These assert the overlay never reaches disk, + * while staying readable at runtime. + */ + describe('env overlay is never persisted', () => { + + const settingsPath = () => join(testDir, '.noorm', 'settings.yml'); + + it('should not write ambient NOORM_* secrets to settings.yml on save', async () => { + + await writeFile(settingsPath(), 'paths:\n sql: ./sql\n'); + + process.env['NOORM_VAULT_TOKEN'] = 'hvs.SUPERSECRETVAULTTOKEN'; + process.env['NOORM_DB_PASSWORD'] = 'pgpassword123'; + process.env['NOORM_API_KEY'] = 'sk-live-abcdef'; + + const manager = new SettingsManager(testDir); + await manager.load(); + await manager.save(); + + const onDisk = await readFile(settingsPath(), 'utf-8'); + + expect(onDisk).not.toContain('hvs.SUPERSECRETVAULTTOKEN'); + expect(onDisk).not.toContain('pgpassword123'); + expect(onDisk).not.toContain('sk-live-abcdef'); + + }); + + it('should not write env-derived keys when an unrelated section is mutated', async () => { + + await writeFile(settingsPath(), 'paths:\n sql: ./sql\n'); + + process.env['NOORM_DB_PASSWORD'] = 'pgpassword123'; + process.env['NOORM_FOO_BAR'] = 'leaked'; + + const manager = new SettingsManager(testDir); + await manager.load(); + + // Every mutator calls save(); editing build must not drag env in. + await manager.setBuild({ include: ['schema'], exclude: [] }); + + const onDisk = await readFile(settingsPath(), 'utf-8'); + + expect(onDisk).not.toContain('pgpassword123'); + expect(onDisk).not.toContain('leaked'); + expect(onDisk).toContain('schema'); + + }); + + it('should keep the committed value on disk when env overrides it at runtime', async () => { + + await writeFile(settingsPath(), 'paths:\n sql: ./sql\n changes: ./changes\n'); + + process.env['NOORM_PATHS_SQL'] = './ci-sql'; + + const manager = new SettingsManager(testDir); + await manager.load(); + + // The overlay is what callers execute against ... + expect(manager.getPaths().sql).toBe('./ci-sql'); + + await manager.setBuild({ include: ['schema'], exclude: [] }); + + // ... but the file keeps what a human put there. + const onDisk = await readFile(settingsPath(), 'utf-8'); + + expect(onDisk).toContain('./sql'); + expect(onDisk).not.toContain('./ci-sql'); + + }); + + it('should still expose env overrides through accessors after a save', async () => { + + await writeFile(settingsPath(), 'paths:\n sql: ./sql\n'); + + process.env['NOORM_PATHS_SQL'] = './ci-sql'; + + const manager = new SettingsManager(testDir); + await manager.load(); + await manager.setBuild({ include: ['schema'], exclude: [] }); + + expect(manager.getPaths().sql).toBe('./ci-sql'); + expect(manager.getBuild().include).toEqual(['schema']); + + }); + + }); + }); From 8323745a9aac306afb161e1c3b5bcbb628e0cb65 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:35:15 -0400 Subject: [PATCH 004/105] 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. --- src/core/state/migrations.ts | 18 +++- tests/core/state/manager.test.ts | 45 +++++++++ tests/core/state/migrations.test.ts | 147 ++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 tests/core/state/migrations.test.ts diff --git a/src/core/state/migrations.ts b/src/core/state/migrations.ts index cf73ae5c..3ed7b2db 100644 --- a/src/core/state/migrations.ts +++ b/src/core/state/migrations.ts @@ -41,9 +41,17 @@ export function migrateState(state: unknown, currentVersion: string): State { // Falls back to 0 (unversioned) for callers that skip that stage. const schemaVersion = typeof obj['schemaVersion'] === 'number' ? obj['schemaVersion'] : 0; - // Build migrated state with defaults for missing fields - // Note: identity is now stored globally in ~/.noorm/, not in project state + // `identity` is the one field deliberately dropped rather than carried: + // it moved to ~/.noorm/ and pre-move state files hold key material in + // it, so re-persisting it would keep a private key in state.enc forever. + const { identity: _legacyIdentity, ...carried } = obj; + + // Everything else unknown is carried through. Rebuilding from a fixed + // allowlist made an older binary silently destroy any top-level field a + // newer one had added, and the truncated object was persisted straight + // back — so the loss was permanent the first time a downgrade ran. const migrated: State = { + ...carried, version: currentVersion, schemaVersion, knownUsers: (obj['knownUsers'] as Record) ?? {}, @@ -78,9 +86,11 @@ export function needsMigration(state: unknown, currentVersion: string): boolean // Version mismatch if (obj['version'] !== currentVersion) return true; - // Missing required fields (add new fields here as they're added) + // Missing required fields (add new fields here as they're added). + // Only fields `migrateState` actually writes belong here — a field it + // never writes makes this predicate permanently true, turning every + // load into a full re-encrypt and rewrite of state.enc. if (!('globalSecrets' in obj)) return true; - if (!('identity' in obj)) return true; if (!('knownUsers' in obj)) return true; return false; diff --git a/tests/core/state/manager.test.ts b/tests/core/state/manager.test.ts index f1dd30b9..1ab0dd0e 100644 --- a/tests/core/state/manager.test.ts +++ b/tests/core/state/manager.test.ts @@ -16,6 +16,7 @@ import { generateKeyPair } from '../../../src/core/identity/crypto.js'; import { encrypt, decrypt } from '../../../src/core/state/encryption/index.js'; import type { EncryptedPayload } from '../../../src/core/state/types.js'; import { guarded } from '../../../src/core/policy/index.js'; +import { observer } from '../../../src/core/observer.js'; import { CURRENT_VERSIONS } from '../../../src/core/version/types.js'; /** @@ -328,6 +329,50 @@ describe('state: manager', () => { }); + it('should leave state.enc byte-identical when there is nothing to migrate', async () => { + + // A load with no migration and no backfill must not write. When + // it does, every read-only command (`version`, `secret list`, + // `db explore`) becomes a full re-encrypt and rewrite, which + // both amplifies the window for a concurrent-write conflict and + // makes `state:persisted` meaningless as a change signal. + await state.load(); + await state.setConfig('dev', createTestConfig('dev')); + + const statePath = state.getStatePath(); + const before = readFileSync(statePath, 'utf8'); + + const reader = new StateManager(tempDir, { + stateDir: '.test-state', + stateFile: 'state.enc', + privateKey: testPrivateKey, + }); + await reader.load(); + + expect(readFileSync(statePath, 'utf8')).toBe(before); + + }); + + it('should not emit state:persisted on a load with nothing to migrate', async () => { + + await state.load(); + await state.setConfig('dev', createTestConfig('dev')); + + const persisted: unknown[] = []; + const off = observer.on('state:persisted', (data) => persisted.push(data)); + + const reader = new StateManager(tempDir, { + stateDir: '.test-state', + stateFile: 'state.enc', + privateKey: testPrivateKey, + }); + await reader.load(); + off(); + + expect(persisted).toHaveLength(0); + + }); + }); // ───────────────────────────────────────────────────────────── diff --git a/tests/core/state/migrations.test.ts b/tests/core/state/migrations.test.ts new file mode 100644 index 00000000..8024adba --- /dev/null +++ b/tests/core/state/migrations.test.ts @@ -0,0 +1,147 @@ +/** + * Package-semver state migration tests. + * + * `migrateState`/`needsMigration` in src/core/state/migrations.ts are the + * package-version layer, distinct from the schemaVersion layer in + * core/version/state. Until now this layer had no direct test at all. + */ +import { describe, it, expect } from 'bun:test'; +import { migrateState, needsMigration } from '../../../src/core/state/migrations.js'; + +const VERSION = '9.9.9'; + +/** + * A state record already at the current package version, shaped exactly + * like what `migrateState` itself produces. + */ +function currentState(): Record { + + return { + version: VERSION, + schemaVersion: 2, + knownUsers: {}, + activeConfig: null, + configs: {}, + secrets: {}, + globalSecrets: {}, + }; + +} + +describe('state: migrations', () => { + + describe('needsMigration', () => { + + it('should converge: migrateState output must not need migrating again', () => { + + // The predicate and the transform must agree, or every load + // becomes a write. Feeding the transform's own output back in + // is the property that catches a predicate testing for a field + // the transform never writes. + const once = migrateState({ version: '0.0.1' }, VERSION); + const twice = migrateState(once, VERSION); + + expect(needsMigration(once, VERSION)).toBe(false); + expect(needsMigration(twice, VERSION)).toBe(false); + + }); + + it('should not flag a state already at the current version', () => { + + expect(needsMigration(currentState(), VERSION)).toBe(false); + + }); + + it('should flag a version mismatch', () => { + + expect(needsMigration({ ...currentState(), version: '0.0.1' }, VERSION)).toBe(true); + + }); + + it('should flag missing required fields', () => { + + const { globalSecrets: _g, ...noGlobalSecrets } = currentState(); + const { knownUsers: _k, ...noKnownUsers } = currentState(); + + expect(needsMigration(noGlobalSecrets, VERSION)).toBe(true); + expect(needsMigration(noKnownUsers, VERSION)).toBe(true); + + }); + + it('should flag a non-object state', () => { + + expect(needsMigration(null, VERSION)).toBe(true); + expect(needsMigration('nonsense', VERSION)).toBe(true); + + }); + + }); + + describe('migrateState', () => { + + it('should be idempotent', () => { + + const once = migrateState({ version: '0.0.1', configs: { dev: { name: 'dev' } } }, VERSION); + const twice = migrateState(once, VERSION); + + expect(twice).toEqual(once); + + }); + + it('should preserve unknown top-level fields written by a newer version', () => { + + // A newer binary may add top-level fields this build knows + // nothing about. Dropping them here is silent data loss, + // because the truncated object is persisted straight back. + const migrated = migrateState( + { ...currentState(), version: '0.0.1', auditTrail: ['future-field'] }, + VERSION, + ); + + expect(migrated).toHaveProperty('auditTrail', ['future-field']); + + }); + + it('should drop a legacy top-level identity rather than carrying it', () => { + + // Identity moved to ~/.noorm/; pre-move state files hold key + // material here, so carrying it forward would keep a private + // key inside state.enc indefinitely. + const migrated = migrateState( + { ...currentState(), version: '0.0.1', identity: { privateKey: 'deadbeef' } }, + VERSION, + ); + + expect(migrated).not.toHaveProperty('identity'); + + }); + + it('should still normalise the fields it owns', () => { + + const migrated = migrateState({ version: '0.0.1' }, VERSION); + + expect(migrated.version).toBe(VERSION); + expect(migrated.knownUsers).toEqual({}); + expect(migrated.activeConfig).toBeNull(); + expect(migrated.configs).toEqual({}); + expect(migrated.secrets).toEqual({}); + expect(migrated.globalSecrets).toEqual({}); + + }); + + it('should carry schemaVersion through untouched', () => { + + expect(migrateState({ schemaVersion: 2 }, VERSION).schemaVersion).toBe(2); + expect(migrateState({}, VERSION).schemaVersion).toBe(0); + + }); + + it('should reject a non-object state', () => { + + expect(() => migrateState(null, VERSION)).toThrow('Invalid state format'); + + }); + + }); + +}); From 2a2c9b77284f8beed6038f39002a7c2e612ac456 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:36:00 -0400 Subject: [PATCH 005/105] fix(runner): make createOperation work on MySQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/runner/tracker.ts | 57 ++++++- .../runner/tracker-dialects.test.ts | 159 ++++++++++++++++++ 2 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 tests/integration/runner/tracker-dialects.test.ts diff --git a/src/core/runner/tracker.ts b/src/core/runner/tracker.ts index 60115732..ff4879ba 100644 --- a/src/core/runner/tracker.ts +++ b/src/core/runner/tracker.ts @@ -34,6 +34,32 @@ import type { NoormDatabase, ChangeType, ExecutionStatus, FileType } from '../sh import type { Dialect } from '../connection/types.js'; import type { NeedsRunResult, CreateOperationData, RecordExecutionData, Direction } from './types.js'; +/** + * Coerce a driver-reported generated key into a plain positive integer. + * + * Every dialect reports it differently — mysql2 hands back a `bigint`, + * node-postgres renders `lastval()`'s int8 as a string, mssql and sqlite + * give a number. Returning `undefined` for anything unusable lets the caller + * fall through to its next strategy instead of carrying a `bigint` or a + * numeric string into a column that later rows join against. + * + * @example + * toOperationId(42n); // 42 + * toOperationId('7'); // 7 + * toOperationId(null); // undefined + */ +function toOperationId(value: unknown): number | undefined { + + if (value === null || value === undefined) return undefined; + + const asNumber = Number(value); + + if (!Number.isSafeInteger(asNumber) || asNumber <= 0) return undefined; + + return asNumber; + +} + /** * Execution tracker for change detection and audit logging. * @@ -261,8 +287,14 @@ export class Tracker { executed_by: data.executedBy, }); - // MSSQL uses OUTPUT inserted.id (not RETURNING) - // Other dialects use RETURNING for atomic insert+get-id + // Three id-retrieval strategies, one per driver capability: + // mssql OUTPUT inserted.id + // mysql no RETURNING clause exists — the driver reports the + // generated key on the insert result itself. Read it from + // there rather than issuing LAST_INSERT_ID() as a second + // query: that function is per-connection, and Kysely + // returns the connection to the pool between statements. + // others RETURNING for an atomic insert+get-id let id: number | undefined; if (this.#dialect === 'mssql') { @@ -280,7 +312,20 @@ export class Tracker { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - id = (result as any)?.id; + id = toOperationId((result as any)?.id); + + } + else if (this.#dialect === 'mysql') { + + const [result, err] = await attempt(() => insertQuery.executeTakeFirst()); + + if (err) { + + throw new Error('Failed to create operation record', { cause: err }); + + } + + id = toOperationId(result?.insertId); } else { @@ -295,17 +340,17 @@ export class Tracker { } - id = result?.id ?? undefined; + id = toOperationId(result?.id); // SQLite with better-sqlite3 may return null for RETURNING - if (id === null || id === undefined) { + if (id === undefined) { const lastIdQuery = this.#lastInsertIdQuery(); if (lastIdQuery) { const [lastIdResult] = await attempt(() => lastIdQuery.execute(this.#db)); - id = lastIdResult?.rows?.[0]?.id; + id = toOperationId(lastIdResult?.rows?.[0]?.id); } diff --git a/tests/integration/runner/tracker-dialects.test.ts b/tests/integration/runner/tracker-dialects.test.ts new file mode 100644 index 00000000..75c1b6a7 --- /dev/null +++ b/tests/integration/runner/tracker-dialects.test.ts @@ -0,0 +1,159 @@ +/** + * Integration test: `Tracker.createOperation` against every live dialect. + * + * `createOperation` is the first database write of every build, run, change + * and revert — nothing executes if it fails. Until this file existed the + * whole test tree constructed `Tracker` exactly once, with `'sqlite'` + * (`tests/core/runner/tracker.test.ts`), so the id-retrieval strategy was + * only ever exercised on the one dialect where `RETURNING` happens to work. + * MySQL has no `RETURNING` clause at all and the runner was inoperable there + * — zero files executed — while the suite stayed green. + * + * The assertion is deliberately about the *contract* rather than the SQL: + * whatever strategy a dialect needs, `createOperation` must hand back a + * usable primary key that later rows can reference. Anything else means the + * operation record was never created. + * + * Requires the docker-compose.test.yml containers (postgres 15432, + * mysql 13306, mssql 11433). Each dialect skips itself when unreachable. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Kysely, SqliteDialect } from 'kysely'; + +import { attempt } from '@logosdx/utils'; + +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { Tracker } from '../../../src/core/runner/tracker.js'; +import { migrateSchema } from '../../../src/core/version/schema/index.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { getNoormTables, noormDb } from '../../../src/core/shared/index.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; +import type { ConnectionResult } from '../../../src/core/connection/types.js'; + +import { createTestConnection, isContainerRunning } from '../../utils/db.js'; + +const LIVE_DIALECTS: Dialect[] = ['postgres', 'mysql', 'mssql']; + +const CONFIG_NAME = '__tracker_dialects__'; + +describe('runner: tracker.createOperation across dialects', () => { + + it('should return a usable operation id on sqlite', async () => { + + const db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + const id = await new Tracker(db, CONFIG_NAME, 'sqlite').createOperation({ + name: 'build:sqlite', + changeType: 'build', + configName: CONFIG_NAME, + executedBy: 'test@example.com', + }); + + expect(typeof id).toBe('number'); + expect(id).toBeGreaterThan(0); + + await db.destroy(); + + }); + + for (const dialect of LIVE_DIALECTS) { + + describe(dialect, () => { + + let conn: ConnectionResult | null = null; + let reachable = false; + + beforeAll(async () => { + + reachable = await isContainerRunning(dialect); + + if (!reachable) return; + + conn = await createTestConnection(dialect); + + await migrateSchema(conn.db as unknown as Kysely, dialect); + + }); + + afterAll(async () => { + + if (!conn) return; + + const db = conn.db as unknown as Kysely; + const tables = getNoormTables(dialect); + + // Child rows first — executions carries an FK to change. + await attempt(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, dialect) as any) + .deleteFrom(tables.executions) + .where('change_id', 'in', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, dialect) as any) + .selectFrom(tables.change) + .select('id') + .where('config_name', '=', CONFIG_NAME), + ) + .execute(), + ); + + await attempt(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, dialect) as any) + .deleteFrom(tables.change) + .where('config_name', '=', CONFIG_NAME) + .execute(), + ); + + await conn.destroy(); + + }); + + it('should return a usable operation id that child rows can reference', async () => { + + if (!reachable) { + + console.warn(`Skipping ${dialect}: container not reachable`); + + return; + + } + + const db = conn!.db as unknown as Kysely; + const tracker = new Tracker(db, CONFIG_NAME, dialect); + + const [id, err] = await attempt(() => + tracker.createOperation({ + name: `build:${dialect}:${Date.now()}`, + changeType: 'build', + configName: CONFIG_NAME, + executedBy: 'test@example.com', + }), + ); + + expect(err?.message ?? null).toBeNull(); + expect(typeof id).toBe('number'); + expect(id!).toBeGreaterThan(0); + + // A fabricated id would satisfy the assertions above but fail + // the FK — the point of the id is that child rows can use it. + const recordsErr = await tracker.createFileRecords(id!, [ + { filepath: 'sql/001.sql', fileType: 'sql', checksum: 'abc123' }, + ]); + + expect(recordsErr).toBeNull(); + + }); + + }); + + } + +}); From bd9c25bf433311702d16c3c295129be51644581a Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:36:33 -0400 Subject: [PATCH 006/105] 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. --- src/cli/change/run.ts | 3 +- src/core/change/history.ts | 69 ++++++++++++- src/core/change/index.ts | 3 + src/core/change/manager.ts | 8 +- src/core/change/types.ts | 26 +++++ src/sdk/namespaces/changes.ts | 5 +- tests/core/change/manager.test.ts | 156 ++++++++++++++++++++++++++++++ 7 files changed, 256 insertions(+), 14 deletions(-) diff --git a/src/cli/change/run.ts b/src/cli/change/run.ts index d3685b4a..c6ab8b87 100644 --- a/src/cli/change/run.ts +++ b/src/cli/change/run.ts @@ -8,6 +8,7 @@ import * as p from '@clack/prompts'; import { defineCommand } from 'citty'; +import { isPendingChange } from '../../core/change/index.js'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; import { selectChangeFromStatus, requireTty } from './_prompt.js'; @@ -47,7 +48,7 @@ const runCommand = defineCommand({ const picked = await selectChangeFromStatus(status, { message: 'Pick a change to apply', emptyMessage: 'No pending changes to apply.', - filter: (c) => !c.orphaned && (c.status === 'pending' || c.status === 'reverted'), + filter: isPendingChange, }); if (!picked) { diff --git a/src/core/change/history.ts b/src/core/change/history.ts index c78f8d9c..72b42726 100644 --- a/src/core/change/history.ts +++ b/src/core/change/history.ts @@ -446,6 +446,23 @@ export class ChangeHistory { * consulting the ambiguous row — excluding it here is what prevents a * third attempt from re-running a file a prior success already covered. * + * A prior success only licenses a skip while it is still *standing*. + * Two things retire it, and neither is visible on the execution row + * itself, so both are filtered on the parent operation: + * + * 1. The operation was reverted or torn down. `markAsReverted` and + * `markAllAsStale` flip the forward operation's status to + * `reverted`/`stale`, so its file successes no longer describe + * anything that exists in the database. + * 2. An operation in the opposite direction has run since. A revert + * undoes a prior apply and an apply undoes a prior revert, but + * neither rewrites the other's rows — only their relative order + * says which one is still in effect. Hence the `id` boundary. + * + * Without both, each file executes at most once per direction for the + * lifetime of the tracking table and every apply->revert->apply cycle + * silently no-ops. + * * @param name - Change name * @param direction - 'change' or 'revert' * @param filepath - Relative filepath as stored in execution records @@ -468,10 +485,42 @@ export class ChangeHistory { } + const opposite: Direction = direction === 'change' ? 'revert' : 'change'; + + // Newest operation that ran the other way; anything at or before it + // has since been undone. + const [boundary, boundaryErr] = await attempt(() => + this.#ndb + .selectFrom(this.#tables.change) + .select(['id']) + .where('name', '=', name) + .where('change_type', '=', 'change') + .where('direction', '=', opposite) + .where('config_name', '=', this.#configName) + .orderBy('id', 'desc') + .limit(1) + .executeTakeFirst(), + ); + + if (boundaryErr) { + + observer.emit('error', { + source: 'change', + error: boundaryErr, + context: { name, filepath, operation: 'needs-run-file-boundary' }, + }); + + // Can't prove the prior success still stands, so re-run rather + // than skip: a redundant run is recoverable, a skipped one is not. + return { needsRun: true, reason: 'new' }; + + } + // Get most recent completed execution record for this file, scoped // to this change's name+direction+config - const [record, err] = await attempt(() => - (this.#ndb + const [record, err] = await attempt(() => { + + let query = (this.#ndb .selectFrom(this.#tables.executions) .innerJoin( this.#tables.change, @@ -487,12 +536,22 @@ export class ChangeHistory { .where(`${this.#tables.change}.name`, '=', name) .where(`${this.#tables.change}.direction`, '=', direction) .where(`${this.#tables.change}.config_name`, '=', this.#configName) + .where(`${this.#tables.change}.status`, 'not in', ['reverted', 'stale']) .where(`${this.#tables.executions}.filepath`, '=', filepath) - .where(`${this.#tables.executions}.status`, 'not in', ['pending', 'skipped']) + .where(`${this.#tables.executions}.status`, 'not in', ['pending', 'skipped']); + + if (boundary) { + + query = query.where(`${this.#tables.change}.id`, '>', boundary.id); + + } + + return query .orderBy(`${this.#tables.executions}.id`, 'desc') .limit(1) - .executeTakeFirst(), - ); + .executeTakeFirst(); + + }); if (err) { diff --git a/src/core/change/index.ts b/src/core/change/index.ts index e6c3201d..391c0b51 100644 --- a/src/core/change/index.ts +++ b/src/core/change/index.ts @@ -66,6 +66,9 @@ export { DEFAULT_CHANGE_OPTIONS, DEFAULT_BATCH_OPTIONS, + // Predicates + isPendingChange, + // Errors ChangeValidationError, ChangeNotFoundError, diff --git a/src/core/change/manager.ts b/src/core/change/manager.ts index 81520536..0b2bef1c 100644 --- a/src/core/change/manager.ts +++ b/src/core/change/manager.ts @@ -42,7 +42,7 @@ import type { ChangeHistoryRecord, FileHistoryRecord, } from './types.js'; -import { ChangeNotFoundError, ChangeOrphanedError } from './types.js'; +import { ChangeNotFoundError, ChangeOrphanedError, isPendingChange } from './types.js'; // ───────────────────────────────────────────────────────────── // Default Options @@ -257,7 +257,7 @@ export class ChangeManager { // Get pending changes const list = await this.list(); const pending = list - .filter((cs) => !cs.orphaned && (cs.status === 'pending' || cs.status === 'reverted')) + .filter(isPendingChange) .slice(0, count); if (pending.length === 0) { @@ -341,9 +341,7 @@ export class ChangeManager { // Get count of pending const list = await this.list(); - const pendingCount = list.filter( - (cs) => !cs.orphaned && (cs.status === 'pending' || cs.status === 'reverted'), - ).length; + const pendingCount = list.filter(isPendingChange).length; return this.next(pendingCount, options); diff --git a/src/core/change/types.ts b/src/core/change/types.ts index 7b88d68e..07edf47e 100644 --- a/src/core/change/types.ts +++ b/src/core/change/types.ts @@ -240,6 +240,32 @@ export interface ChangeListItem { orphaned: boolean; } +/** + * Whether a change is outstanding work that a forward run should pick up. + * + * WHY this is shared rather than inlined: every surface that lists "what + * still needs applying" — `ff`, `next`, the SDK's `pending()`, the CLI's + * interactive picker — has to agree. When `stale` was added for teardown + * the predicate was only updated in some of them, which left `db teardown` + * unrecoverable through supported commands: `ff` saw no work to do and + * reported success over an empty database. + * + * `stale` is set by teardown on changes that did apply but whose objects + * no longer exist, so it is pending work in the only sense that matters. + * + * @example + * const outstanding = list.filter(isPendingChange); + */ +export function isPendingChange(item: ChangeListItem): boolean { + + return !item.orphaned && ( + item.status === 'pending' + || item.status === 'reverted' + || item.status === 'stale' + ); + +} + // ───────────────────────────────────────────────────────────── // Execution Options // ───────────────────────────────────────────────────────────── diff --git a/src/sdk/namespaces/changes.ts b/src/sdk/namespaces/changes.ts index c7e2a44c..76a6185c 100644 --- a/src/sdk/namespaces/changes.ts +++ b/src/sdk/namespaces/changes.ts @@ -33,6 +33,7 @@ import { discoverChanges as coreDiscoverChanges, validateChange as coreValidateChange, ChangeManager, + isPendingChange, } from '../../core/change/index.js'; import { getStateManager } from '../../core/state/index.js'; import { resolveVaultKey, buildSecretsContext } from '../../core/vault/index.js'; @@ -341,9 +342,7 @@ export class ChangesNamespace { const all = await this.status(); - return all.filter( - (cs) => !cs.orphaned && (cs.status === 'pending' || cs.status === 'reverted'), - ); + return all.filter(isPendingChange); } diff --git a/tests/core/change/manager.test.ts b/tests/core/change/manager.test.ts index dff1fcbd..8991d04b 100644 --- a/tests/core/change/manager.test.ts +++ b/tests/core/change/manager.test.ts @@ -15,6 +15,7 @@ import { Kysely, SqliteDialect, sql } from 'kysely'; import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; import { ChangeManager } from '../../../src/core/change/manager.js'; +import { ChangeTracker } from '../../../src/core/change/tracker.js'; import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; import { resetLockManager } from '../../../src/core/lock/index.js'; import { ChangeNotAppliedError } from '../../../src/core/change/types.js'; @@ -80,6 +81,21 @@ describe('change: manager', () => { } + /** + * Asks the database itself whether a table exists, rather than trusting + * the reported status. Every defect in this area reports success while + * leaving the schema untouched, so the schema is the only honest oracle. + */ + async function tableExists(name: string): Promise { + + const rows = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${name} + `.execute(db); + + return rows.rows.length > 0; + + } + beforeEach(async () => { resetLockManager(); @@ -376,6 +392,146 @@ describe('change: manager', () => { }); + describe('re-apply after revert', () => { + + it('should re-run the change SQL when a reverted change is applied again', async () => { + + await createTestChange( + 'cycle', + [{ name: '001_create.sql', content: 'CREATE TABLE cycle_target (id INTEGER PRIMARY KEY)' }], + [{ name: '001_drop.sql', content: 'DROP TABLE cycle_target' }], + ); + + const manager = new ChangeManager(buildContext()); + + await manager.run('cycle'); + expect(await tableExists('cycle_target')).toBe(true); + + await manager.revert('cycle'); + expect(await tableExists('cycle_target')).toBe(false); + + const reapply = await manager.run('cycle'); + + expect(reapply.status).toBe('success'); + + // The point of the whole exercise: a change that reports success + // must have actually recreated the object it claims to manage. + expect(await tableExists('cycle_target')).toBe(true); + + }); + + it('should re-run the revert SQL when a re-applied change is reverted again', async () => { + + await createTestChange( + 'cycle2', + [{ name: '001_create.sql', content: 'CREATE TABLE cycle2_target (id INTEGER PRIMARY KEY)' }], + [{ name: '001_drop.sql', content: 'DROP TABLE cycle2_target' }], + ); + + const manager = new ChangeManager(buildContext()); + + await manager.run('cycle2'); + await manager.revert('cycle2'); + await manager.run('cycle2', { force: true }); + + expect(await tableExists('cycle2_target')).toBe(true); + + const second = await manager.revert('cycle2'); + + expect(second.status).toBe('success'); + expect(await tableExists('cycle2_target')).toBe(false); + + }); + + it('should still resume a failed change from the failed file, skipping files a prior attempt applied', async () => { + + // Guards the per-file retry-resume feature (f27a67b): file 1 is + // non-idempotent, so re-running it on retry would fail the change. + await createTestChange('retry-resume', [ + { name: '001_ok.sql', content: 'CREATE TABLE retry_first (id INTEGER PRIMARY KEY)' }, + { name: '002_bad.sql', content: 'CREATE TABLE retry_second (id INTEGER PRIMARY KEY) BROKEN' }, + ]); + + const manager = new ChangeManager(buildContext()); + + const firstAttempt = await manager.run('retry-resume'); + expect(firstAttempt.status).toBe('failed'); + expect(await tableExists('retry_first')).toBe(true); + + await writeFile( + join(changesDir, 'retry-resume', 'change', '002_bad.sql'), + 'CREATE TABLE retry_second (id INTEGER PRIMARY KEY)', + ); + + const retry = await manager.run('retry-resume'); + + expect(retry.status).toBe('success'); + expect(await tableExists('retry_second')).toBe(true); + + const firstFile = retry.files.find((f) => f.filepath.endsWith('001_ok.sql')); + expect(firstFile?.status).toBe('skipped'); + + }); + + }); + + describe('recovery after teardown', () => { + + /** Reproduces what `db teardown` does to the tracking table. */ + async function markEverythingStale(): Promise { + + await new ChangeTracker(db, 'test', 'sqlite').markAllAsStale(); + + } + + it('should re-apply stale changes on ff after a teardown', async () => { + + await createTestChange('2025-02-01-stale-one', [ + { name: '001.sql', content: 'CREATE TABLE stale_one (id INTEGER PRIMARY KEY)' }, + ]); + await createTestChange('2025-02-02-stale-two', [ + { name: '001.sql', content: 'CREATE TABLE stale_two (id INTEGER PRIMARY KEY)' }, + ]); + + const manager = new ChangeManager(buildContext()); + + expect((await manager.ff()).executed).toBe(2); + + // Teardown drops the schema objects and marks every change stale. + await sql`DROP TABLE stale_one`.execute(db); + await sql`DROP TABLE stale_two`.execute(db); + await markEverythingStale(); + + const rebuild = await manager.ff(); + + expect(rebuild.executed).toBe(2); + expect(await tableExists('stale_one')).toBe(true); + expect(await tableExists('stale_two')).toBe(true); + + }); + + it('should treat a stale change as pending work for next()', async () => { + + await createTestChange('2025-03-01-stale-next', [ + { name: '001.sql', content: 'CREATE TABLE stale_next (id INTEGER PRIMARY KEY)' }, + ]); + + const manager = new ChangeManager(buildContext()); + + await manager.run('2025-03-01-stale-next'); + + await sql`DROP TABLE stale_next`.execute(db); + await markEverythingStale(); + + const result = await manager.next(1); + + expect(result.executed).toBe(1); + expect(await tableExists('stale_next')).toBe(true); + + }); + + }); + describe('remove', () => { it('should delete both disk and db records when remove({disk: true, db: true})', async () => { From 9f6efe0312aa3de30bfdee4d341115277a0ed394 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:36:48 -0400 Subject: [PATCH 007/105] fix(update): validate version before it reaches a URL or shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/update/checker.ts | 83 +++++++++++++ src/core/update/install-mode.ts | 8 +- src/core/update/updater.ts | 22 +++- tests/core/update/version-validation.test.ts | 120 +++++++++++++++++++ 4 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 tests/core/update/version-validation.test.ts diff --git a/src/core/update/checker.ts b/src/core/update/checker.ts index 2ccd3832..d5ea5120 100644 --- a/src/core/update/checker.ts +++ b/src/core/update/checker.ts @@ -42,6 +42,76 @@ export function getCurrentVersion(): string { } +// ============================================================================= +// Version Validation +// ============================================================================= + +/** + * The official semver.org grammar, anchored. + * + * Deliberately strict because this is the ONLY thing standing between a + * registry-supplied string and both a download URL and a shell command. + * `compareVersions` parses loosely enough that junk still compares greater + * than the installed version, so a poisoned `dist-tags.latest` would otherwise + * reach `getBinaryDownloadUrl` — where `fetch` normalises `..` and relocates + * the binary *and* its checksums.txt to an attacker-controlled repo, at which + * point verification passes against the attacker's own checksums file. + */ +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +/** + * A version string that failed strict semver validation. + * + * Thrown rather than returned by the URL builders because a caller that has + * reached the point of constructing a release URL from an unvalidated string + * has no safe fallback — there is no "best effort" download. + */ +export class InvalidVersionError extends Error { + + override readonly name = 'InvalidVersionError' as const; + + constructor(readonly version: string) { + + super(`invalid version string: ${JSON.stringify(version)}`); + + } + +} + +/** + * Check whether a string is a strict semver version. + * + * @param version - Candidate version string, typically from the npm registry + * @returns true when the string is valid semver and safe to interpolate + * + * @example + * ```typescript + * isValidVersion('1.0.0-alpha.39'); // true + * isValidVersion('99.0.0; touch /tmp/pwned'); // false + * ``` + */ +export function isValidVersion(version: string): boolean { + + return typeof version === 'string' && SEMVER_PATTERN.test(version); + +} + +/** + * Throw unless `version` is strict semver. + * + * @throws InvalidVersionError when the string is not valid semver. + */ +export function assertValidVersion(version: string): void { + + if (!isValidVersion(version)) { + + throw new InvalidVersionError(version); + + } + +} + // ============================================================================= // Version Parsing // ============================================================================= @@ -305,6 +375,19 @@ export async function checkForUpdate(): Promise { } + // The registry is the trust boundary: everything downstream (release URL, + // checksums URL, npm install argument) interpolates this string. A poisoned + // dist-tag is treated as "no update", not as an update to a junk version. + if (!isValidVersion(latestVersion)) { + + observer.emit('update:check-failed', { + error: `registry returned an invalid version: ${JSON.stringify(latestVersion)}`, + }); + + return null; + + } + const updateAvailable = compareVersions(latestVersion, currentVersion) > 0; const isMajorUpdate = updateAvailable && isMajorVersionUpdate(currentVersion, latestVersion); diff --git a/src/core/update/install-mode.ts b/src/core/update/install-mode.ts index fffabc83..2baa6f7e 100644 --- a/src/core/update/install-mode.ts +++ b/src/core/update/install-mode.ts @@ -6,7 +6,7 @@ * - binary: standalone compiled binary → update via GitHub release download * - development: running from source → no updates */ -import { getCurrentVersion } from './checker.js'; +import { assertValidVersion, getCurrentVersion } from './checker.js'; /** * How noorm was installed on this machine. @@ -69,6 +69,12 @@ const GITHUB_REPO = 'noormdev/noorm'; */ function releaseBaseUrl(version: string): string { + // Validating here — rather than in each caller — means no URL in this + // module can be built from an unvalidated version. `fetch` normalises + // `..`, so an unchecked string does not merely produce a broken URL, it + // silently retargets the download at a different GitHub repo. + assertValidVersion(version); + return `https://github.com/${GITHUB_REPO}/releases/download/%40noormdev%2Fcli%40${version}`; } diff --git a/src/core/update/updater.ts b/src/core/update/updater.ts index f3ef1a0b..5e01e24e 100644 --- a/src/core/update/updater.ts +++ b/src/core/update/updater.ts @@ -19,7 +19,7 @@ import { open, rename, unlink, chmod, stat } from 'fs/promises'; import { attempt, retry, wait } from '@logosdx/utils'; import { observer } from '../observer.js'; -import { getCurrentVersion } from './checker.js'; +import { getCurrentVersion, isValidVersion } from './checker.js'; import { verifyChecksum } from './checksum.js'; import { detectInstallMode, getBinaryDownloadUrl, getChecksumsUrl, getBinaryAssetName } from './install-mode.js'; import type { UpdateResult } from './types.js'; @@ -508,6 +508,26 @@ async function installViaBinary(version: string, previousVersion: string, insecu export function installUpdate(version: string, options: { insecure?: boolean } = {}): Promise { const previousVersion = getCurrentVersion(); + + // Re-checked here even though `checkForUpdate` already validates: this is a + // public entry point the TUI and CLI call with a caller-supplied string, and + // both downstream paths interpolate it into something dangerous — a release + // URL (binary mode) or a `shell: true` command line (npm mode). + if (!isValidVersion(version)) { + + const error = `refusing to install invalid version string: ${JSON.stringify(version)}`; + + observer.emit('update:failed', { version, error }); + + return Promise.resolve({ + success: false, + previousVersion, + newVersion: version, + error, + }); + + } + const mode = detectInstallMode(); observer.emit('update:installing', { version }); diff --git a/tests/core/update/version-validation.test.ts b/tests/core/update/version-validation.test.ts new file mode 100644 index 00000000..a71c6379 --- /dev/null +++ b/tests/core/update/version-validation.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'bun:test'; + +import { attempt, attemptSync } from '@logosdx/utils'; + +import { isValidVersion, InvalidVersionError } from '../../../src/core/update/checker.js'; +import { getBinaryDownloadUrl, getChecksumsUrl } from '../../../src/core/update/install-mode.js'; +import { installUpdate } from '../../../src/core/update/updater.js'; + +/** + * The exact payloads the A11 audit proved were accepted verbatim from npm + * `dist-tags.latest`. The traversal one is the reachable defect: `fetch` + * normalises `..`, so it relocated BOTH the binary and its checksums.txt to + * an attacker repo, making checksum verification pass against the attacker's + * own file. + */ +const TRAVERSAL_PAYLOAD = '1.0.0/../../../../../evil-org/evil-repo/releases/download/v1'; +const SHELL_PAYLOAD = '99.0.0; touch /tmp/pwned'; + +describe('update: version validation', () => { + + describe('isValidVersion', () => { + + it('should accept the versions this project actually publishes', () => { + + expect(isValidVersion('1.0.0')).toBe(true); + expect(isValidVersion('1.0.0-alpha.39')).toBe(true); + expect(isValidVersion('2.10.3-beta.1')).toBe(true); + expect(isValidVersion('0.0.0-dev')).toBe(true); + + }); + + it('should reject a version carrying shell metacharacters', () => { + + expect(isValidVersion(SHELL_PAYLOAD)).toBe(false); + + }); + + it('should reject a version carrying path traversal', () => { + + expect(isValidVersion(TRAVERSAL_PAYLOAD)).toBe(false); + + }); + + it('should reject junk that loose parsing would still compare as greater', () => { + + expect(isValidVersion('99.0.0 && rm -rf /')).toBe(false); + expect(isValidVersion('../../etc/passwd')).toBe(false); + expect(isValidVersion('1.0.0\nlatest')).toBe(false); + expect(isValidVersion('')).toBe(false); + expect(isValidVersion('latest')).toBe(false); + + }); + + }); + + describe('getBinaryDownloadUrl', () => { + + it('should build the release URL for a valid version', () => { + + const url = getBinaryDownloadUrl('1.0.0-alpha.39'); + + expect(url).toContain('https://github.com/noormdev/noorm/releases/download/'); + expect(url).toContain('1.0.0-alpha.39'); + + }); + + it('should refuse to build a URL that escapes the noorm release repo', () => { + + const [url, err] = attemptSync(() => getBinaryDownloadUrl(TRAVERSAL_PAYLOAD)); + + expect(err).toBeInstanceOf(InvalidVersionError); + expect(url).toBeNull(); + + }); + + }); + + describe('getChecksumsUrl', () => { + + /** + * The checksums URL is the one that matters most: if a poisoned version + * can move it, verification is checking the binary against a file the + * same attacker wrote, and passes. + */ + it('should refuse to relocate checksums.txt to another repo', () => { + + const [url, err] = attemptSync(() => getChecksumsUrl(TRAVERSAL_PAYLOAD)); + + expect(err).toBeInstanceOf(InvalidVersionError); + expect(url).toBeNull(); + + }); + + }); + + describe('installUpdate', () => { + + it('should refuse an invalid version instead of downloading or spawning', async () => { + + const [result, err] = await attempt(() => installUpdate(SHELL_PAYLOAD)); + + expect(err).toBeNull(); + expect(result?.success).toBe(false); + expect(result?.error).toContain('version'); + + }); + + it('should refuse a traversal version before any URL is built', async () => { + + const [result, err] = await attempt(() => installUpdate(TRAVERSAL_PAYLOAD)); + + expect(err).toBeNull(); + expect(result?.success).toBe(false); + expect(result?.error).toContain('version'); + + }); + + }); + +}); From e9e9d6420db834fb728b42ad5c80bacc084a0cbc Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:36:50 -0400 Subject: [PATCH 008/105] fix(ci): refuse enrollment when the stored public key differs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/cli/ci/identity/enroll.ts | 40 ++- tests/cli/ci/identity-enroll-hijack.test.ts | 337 ++++++++++++++++++++ 2 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 tests/cli/ci/identity-enroll-hijack.test.ts diff --git a/src/cli/ci/identity/enroll.ts b/src/cli/ci/identity/enroll.ts index 47f7aec4..cbedacb5 100644 --- a/src/cli/ci/identity/enroll.ts +++ b/src/cli/ci/identity/enroll.ts @@ -16,6 +16,7 @@ import { defineCommand } from 'citty'; import { generateKeyPair } from '../../../core/identity/crypto.js'; import { computeIdentityHash } from '../../../core/identity/hash.js'; +import { isValidKeyHex } from '../../../core/identity/storage.js'; import { propagateVaultKeyTo } from '../../../core/vault/propagate.js'; import { decryptVaultKey } from '../../../core/vault/key.js'; import { getNoormTables, noormDb } from '../../../core/shared/tables.js'; @@ -68,6 +69,19 @@ const enrollCommand = defineCommand({ if (providedPublicKey) { + // Validated before any write. An unchecked key used to be + // INSERTed and only fail later at propagation, leaving a row + // no retry could repair: the retry short-circuits on the + // existing hash and registerIdentity never updates public_key. + if (!isValidKeyHex(providedPublicKey)) { + + throw new Error( + 'Invalid --public-key: expected a hex-encoded X25519 public key ' + + '(88 hex characters, as printed by `noorm ci identity new`).', + ); + + } + newPublicKey = providedPublicKey; } @@ -133,7 +147,7 @@ const enrollCommand = defineCommand({ const [existing, existingErr] = await attempt(() => ndb .selectFrom(tables.identities as keyof NoormDatabase) - .select(['identity_hash']) + .select(['identity_hash', 'public_key']) .where('identity_hash', '=', identityHash) .executeTakeFirst(), ); @@ -148,6 +162,25 @@ const enrollCommand = defineCommand({ if (existing) { + // The hash's only secret input is the public key, which the + // air-gapped flow tells you to circulate in the clear. Anyone + // able to INSERT into the identities table — no vault access + // needed — can therefore pre-register this hash carrying their + // own key, and propagation below encrypts the vault key to + // whatever the ROW holds, not to what was presented here. + // Matching on hash alone is what made that a vault-theft + // primitive; the presented key must match the stored one. + if (existing.public_key !== newPublicKey) { + + throw new Error( + `Refusing to enroll: an identity is already registered under this hash (${identityHash}) ` + + 'with a DIFFERENT public key. Enrolling would grant vault access to that key, not the one ' + + 'you supplied. This is what a pre-registration attack looks like — verify who created that ' + + 'row before removing it, and rotate the vault if you cannot account for it.', + ); + + } + alreadyEnrolled = true; } @@ -186,8 +219,9 @@ const enrollCommand = defineCommand({ if (!propagated) { throw new Error( - 'Identity row present but vault propagation failed. ' + - 'Re-run this command to retry — enrollment is idempotent.', + `Identity row for ${identityHash} is registered, but propagating the vault key to it failed. ` + + 'Re-running is safe and will retry the propagation; the identity row is not written twice. ' + + 'If it keeps failing, check that the row\'s public_key is the one you expect.', ); } diff --git a/tests/cli/ci/identity-enroll-hijack.test.ts b/tests/cli/ci/identity-enroll-hijack.test.ts new file mode 100644 index 00000000..df1002bd --- /dev/null +++ b/tests/cli/ci/identity-enroll-hijack.test.ts @@ -0,0 +1,337 @@ +/** + * cli: `noorm ci identity enroll` must not hand the vault key to a + * pre-planted public key (a08 F1, critical). + * + * The documented air-gapped flow (docs/guide/automation/ci.md) circulates the + * CI bot's PUBLIC key over email/Slack, and the enrollment identityHash is + * `SHA256(email\0name\0publicKey\0'env')` — so its only secret input is a value + * the flow instructs you to publish. Anyone who can INSERT into the identities + * table (any holder of any config for that database; vault access NOT required) + * can pre-register that hash carrying their OWN public key. `enroll` used to + * look up the hash alone, set `alreadyEnrolled`, and propagate the vault key to + * whatever key the row happened to hold — reporting `success: true` and echoing + * the operator's correct key back at them. + * + * These tests drive the real compiled CLI against a SQLite project so no + * container is needed. `--public-key` validation (F8) is covered here too: an + * invalid key used to persist an unrepairable poisoned row, because the INSERT + * ran before the propagation that then failed. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdir, rm } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import type { Kysely } from 'kysely'; + +import { createConnection } from '../../../src/core/connection/factory.js'; +import { bootstrapSchema } from '../../../src/core/version/index.js'; +import { getNoormTables, noormDb } from '../../../src/core/shared/tables.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import { StateManager } from '../../../src/core/state/manager.js'; +import { + generateKeyPair, + derivePublicKeyFromPrivate, + computeIdentityHash, +} from '../../../src/core/identity/index.js'; +import { generateVaultKey, encryptVaultKey, decryptVaultKey } from '../../../src/core/vault/key.js'; +import type { EncryptedVaultKey } from '../../../src/core/vault/types.js'; +import type { Config } from '../../../src/core/config/types.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const TMP_BASE = join(process.cwd(), 'tmp'); + +const CONFIG_NAME = 'enroll_target'; + +const BOT_NAME = 'CI Bot'; +const BOT_EMAIL = 'ci-bot@example.com'; + +interface EnrollFixture { + dir: string; + dbPath: string; + env: Record; + /** Key the attacker plants and hopes to receive the vault under */ + attackerPrivateKey: string; + attackerPublicKey: string; + /** The real bot key the operator is told to enroll */ + botPublicKey: string; + /** Hash both parties compute — derived from the published bot public key */ + botIdentityHash: string; +} + +/** + * Build a project whose active config is a bootstrapped SQLite database, with + * an operator identity that already holds vault access (the precondition + * `enroll` enforces before it will propagate anything). + */ +async function setupEnrollFixture(): Promise { + + const testId = randomUUID().slice(0, 8); + const dir = join(TMP_BASE, `cli-ci-enroll-${testId}`); + const dbPath = join(dir, '.noorm', 'test.db'); + + await mkdir(join(dir, '.noorm'), { recursive: true }); + await mkdir(join(dir, 'sql'), { recursive: true }); + + const conn = await createConnection({ dialect: 'sqlite', database: dbPath }, CONFIG_NAME); + await bootstrapSchema(conn.db as Kysely, 'sqlite'); + + // The operator runs under the CI env identity, so their hash is the one + // loadIdentityFromEnv() computes: machine = derived public key, os = 'env'. + const operator = generateKeyPair(); + const operatorPublicKey = derivePublicKeyFromPrivate(operator.privateKey); + const operatorHash = computeIdentityHash({ + email: 'operator@example.com', + name: 'Vault Operator', + machine: operatorPublicKey, + os: 'env', + }); + + const vaultKey = generateVaultKey(); + + const tables = getNoormTables('sqlite'); + const ndb = noormDb(conn.db as Kysely, 'sqlite'); + + await ndb + .insertInto(tables.identities as keyof NoormDatabase) + .values({ + identity_hash: operatorHash, + email: 'operator@example.com', + name: 'Vault Operator', + machine: 'ci', + os: 'env', + public_key: operatorPublicKey, + encrypted_vault_key: JSON.stringify(encryptVaultKey(vaultKey, operatorPublicKey)), + } as never) + .execute(); + + // The bot mints its keypair; per the documented flow its PUBLIC key is + // circulated in the clear, which is all the attacker needs to compute the + // hash the operator will enroll under. + const bot = generateKeyPair(); + const botPublicKey = bot.publicKey; + const botIdentityHash = computeIdentityHash({ + email: BOT_EMAIL, + name: BOT_NAME, + machine: botPublicKey, + os: 'env', + }); + + const attacker = generateKeyPair(); + + await conn.destroy(); + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const state = new StateManager(dir, { privateKey: operator.privateKey }); + await state.load(); + await state.setConfig(config.name, config); + await state.setActiveConfig(config.name); + + return { + dir, + dbPath, + attackerPrivateKey: attacker.privateKey, + attackerPublicKey: attacker.publicKey, + botPublicKey, + botIdentityHash, + env: { + NOORM_IDENTITY_PRIVATE_KEY: operator.privateKey, + NOORM_IDENTITY_NAME: 'Vault Operator', + NOORM_IDENTITY_EMAIL: 'operator@example.com', + NOORM_ISTEST: 'true', + }, + }; + +} + +/** Pre-register the bot's hash carrying someone else's public key. */ +async function plantRow(fx: EnrollFixture, publicKey: string): Promise { + + const conn = await createConnection({ dialect: 'sqlite', database: fx.dbPath }, CONFIG_NAME); + const tables = getNoormTables('sqlite'); + const ndb = noormDb(conn.db as Kysely, 'sqlite'); + + await ndb + .insertInto(tables.identities as keyof NoormDatabase) + .values({ + identity_hash: fx.botIdentityHash, + email: BOT_EMAIL, + name: BOT_NAME, + machine: 'ci', + os: 'env', + public_key: publicKey, + encrypted_vault_key: null, + } as never) + .execute(); + + await conn.destroy(); + +} + +async function readRow(fx: EnrollFixture, identityHash: string) { + + const conn = await createConnection({ dialect: 'sqlite', database: fx.dbPath }, CONFIG_NAME); + const tables = getNoormTables('sqlite'); + const ndb = noormDb(conn.db as Kysely, 'sqlite'); + + const row = await ndb + .selectFrom(tables.identities as keyof NoormDatabase) + .select(['identity_hash', 'public_key', 'encrypted_vault_key']) + .where('identity_hash', '=', identityHash) + .executeTakeFirst(); + + await conn.destroy(); + + return row ?? null; + +} + +function runEnroll(fx: EnrollFixture, extra: string[]) { + + const result = spawnSync( + 'node', + [ + CLI, 'ci', 'identity', 'enroll', + '--config', CONFIG_NAME, + '--name', BOT_NAME, + '--email', BOT_EMAIL, + ...extra, + '--json', + ], + { cwd: fx.dir, encoding: 'utf-8', env: { ...process.env, ...fx.env } }, + ); + + return { + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + status: result.status, + }; + +} + +describe('cli: ci identity enroll — pre-planted key hijack (a08 F1)', () => { + + let fx: EnrollFixture | undefined; + + afterEach(async () => { + + if (fx) await rm(fx.dir, { recursive: true, force: true }); + fx = undefined; + + }); + + it('refuses to enroll when the stored public key differs from --public-key', async () => { + + fx = await setupEnrollFixture(); + await plantRow(fx, fx.attackerPublicKey); + + // The operator does everything right: they pass the REAL bot key. + const result = runEnroll(fx, ['--public-key', fx.botPublicKey]); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/public key/i); + + }); + + it('does not propagate the vault key to the planted key', async () => { + + fx = await setupEnrollFixture(); + await plantRow(fx, fx.attackerPublicKey); + + runEnroll(fx, ['--public-key', fx.botPublicKey]); + + const row = await readRow(fx, fx.botIdentityHash); + + // The planted row must be left without vault access. Asserting the + // column is null is not enough on its own — assert the attacker's key + // cannot open whatever is there. + expect(row).not.toBeNull(); + expect(row!.encrypted_vault_key).toBeFalsy(); + + if (row!.encrypted_vault_key) { + + const parsed = JSON.parse(row!.encrypted_vault_key as string) as EncryptedVaultKey; + + expect(decryptVaultKey(parsed, fx.attackerPrivateKey)).toBeNull(); + + } + + }); + + it('reports success when the stored public key matches the presented one', async () => { + + fx = await setupEnrollFixture(); + await plantRow(fx, fx.botPublicKey); + + const result = runEnroll(fx, ['--public-key', fx.botPublicKey]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('"alreadyEnrolled":true'); + + const row = await readRow(fx, fx.botIdentityHash); + + expect(row!.encrypted_vault_key).toBeTruthy(); + + }); + +}); + +describe('cli: ci identity enroll — --public-key validation (a08 F8)', () => { + + let fx: EnrollFixture | undefined; + + afterEach(async () => { + + if (fx) await rm(fx.dir, { recursive: true, force: true }); + fx = undefined; + + }); + + it('rejects a non-hex --public-key', async () => { + + fx = await setupEnrollFixture(); + + const result = runEnroll(fx, ['--public-key', 'zzzz-not-hex']); + + expect(result.status).not.toBe(0); + + }); + + it('writes no identity row when --public-key is invalid', async () => { + + fx = await setupEnrollFixture(); + + // The poisoned row was unrepairable: a retry short-circuited on + // alreadyEnrolled and registerIdentity never updates public_key. + const badKey = 'zzzz-not-hex'; + runEnroll(fx, ['--public-key', badKey]); + + const hash = computeIdentityHash({ + email: BOT_EMAIL, + name: BOT_NAME, + machine: badKey, + os: 'env', + }); + + expect(await readRow(fx, hash)).toBeNull(); + + }); + + it('rejects a truncated --public-key', async () => { + + fx = await setupEnrollFixture(); + + const result = runEnroll(fx, ['--public-key', fx.botPublicKey.slice(0, 40)]); + + expect(result.status).not.toBe(0); + + }); + +}); From c52d7a16dc4bc9f3583f3c7095a7359f3414ed80 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:37:36 -0400 Subject: [PATCH 009/105] fix(config): gate import --force behind config:write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/config/import.ts | 40 +++++++++++++++++++--- tests/cli/config/import.test.ts | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/src/cli/config/import.ts b/src/cli/config/import.ts index a94e2071..46a39a01 100644 --- a/src/cli/config/import.ts +++ b/src/cli/config/import.ts @@ -11,8 +11,9 @@ import { attempt, attemptSync } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { ConfigValidationError, parseConfig } from '../../core/config/schema.js'; +import { checkConfigPolicy } from '../../core/policy/index.js'; import { initState, getStateManager } from '../../core/state/index.js'; -import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; const importCommand = defineCommand({ meta: { @@ -22,6 +23,7 @@ const importCommand = defineCommand({ args: { path: { type: 'positional', description: 'Path to JSON config file', required: true }, force: sharedArgs.force, + yes: sharedArgs.yes, json: sharedArgs.json, }, async run({ args }) { @@ -71,10 +73,38 @@ const importCommand = defineCommand({ const stateManager = getStateManager(projectRoot); const existing = stateManager.getConfig(config.name); - if (existing && !args.force) { + if (existing) { - outputError(args, `Config '${config.name}' already exists. Use --force to overwrite.`); - process.exit(1); + if (!args.force) { + + outputError(args, `Config '${config.name}' already exists. Use --force to overwrite.`); + process.exit(1); + + } + + // An overwrite rewrites `access` wholesale, so the config being + // replaced decides — not the incoming file. Without this, one + // --force promotes a viewer config to admin, or flips the + // `mcp: false` invisibility an operator set deliberately. + const check = checkConfigPolicy('user', existing, 'config:write'); + + if (!check.allowed) { + + outputError(args, check.blockedReason ?? `Config '${config.name}' cannot be overwritten.`); + process.exit(1); + + } + + if (check.requiresConfirmation && !isYesMode(args)) { + + outputError( + args, + `Overwriting '${config.name}' rewrites its access roles and requires confirmation ` + + `(${check.confirmationPhrase}). Pass --yes to confirm.`, + ); + process.exit(1); + + } } @@ -99,7 +129,7 @@ const importCommand = defineCommand({ (importCommand as typeof importCommand & { examples: string[] }).examples = [ 'noorm config import ./dev-config.json', - 'noorm config import ./staging-config.json --force', + 'noorm config import ./staging-config.json --force --yes', ]; export default importCommand; diff --git a/tests/cli/config/import.test.ts b/tests/cli/config/import.test.ts index 2acc1e5e..ba22afc6 100644 --- a/tests/cli/config/import.test.ts +++ b/tests/cli/config/import.test.ts @@ -166,4 +166,63 @@ describe('cli: noorm config import — legacy protected mapping', () => { }); + /** + * Overwriting a config rewrites its `access` block, so import is an + * escalation vector: without a gate, one `--force` turns a viewer config + * (or one hidden from MCP with `mcp: false`) into admin/admin. The + * existing config's role decides, not the incoming file's. + */ + describe('access escalation via --force', () => { + + function importThenEscalate(access: Record, escalateArgs: string[]) { + + const original = writeConfigFile('original.json', { + name: 'locked', + access, + connection: { dialect: 'sqlite', database: ':memory:' }, + }); + + expect(runImport(original).status).toBe(0); + + const escalated = writeConfigFile('escalated.json', { + name: 'locked', + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: ':memory:' }, + }); + + return runImport(escalated, escalateArgs); + + } + + it('denies overwriting a viewer config, leaving its access intact', () => { + + const result = importThenEscalate({ user: 'viewer', mcp: false }, ['--force']); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('config:write'); + expect(readPersistedAccess('locked')).toEqual({ user: 'viewer', mcp: false }); + + }); + + it('requires confirmation before overwriting an admin config', () => { + + const result = importThenEscalate({ user: 'admin', mcp: 'viewer' }, ['--force']); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('confirmation'); + expect(readPersistedAccess('locked')).toEqual({ user: 'admin', mcp: 'viewer' }); + + }); + + it('overwrites an admin config once confirmation is given', () => { + + const result = importThenEscalate({ user: 'admin', mcp: 'viewer' }, ['--force', '--yes']); + + expect(result.status).toBe(0); + expect(readPersistedAccess('locked')).toEqual({ user: 'admin', mcp: 'admin' }); + + }); + + }); + }); From b16f94e85c7d7a1bfc6e93a9293b23711c1208af Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:39:11 -0400 Subject: [PATCH 010/105] fix(identity): guard init --force behind confirmation and a key backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/identity/init.ts | 76 ++++++++++++- src/core/identity/storage.ts | 68 ++++++++++++ tests/cli/identity/init.test.ts | 182 ++++++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 tests/cli/identity/init.test.ts diff --git a/src/cli/identity/init.ts b/src/cli/identity/init.ts index 336534db..e687658c 100644 --- a/src/cli/identity/init.ts +++ b/src/cli/identity/init.ts @@ -8,15 +8,30 @@ import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { createCryptoIdentity } from '../../core/identity/factory.js'; -import { hasKeyFiles } from '../../core/identity/storage.js'; +import { backupKeyPair, hasKeyFiles } from '../../core/identity/storage.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +/** + * Why regenerating an identity is not just "overwrite existing identity": + * the state encryption key is derived from the private key, so a new keypair + * orphans every state.enc on the machine. + */ +const DESTRUCTIVE_WARNING = + 'Regenerating your identity re-keys state encryption. Every noorm project on this machine — ' + + 'its configs, secrets and database passwords in .noorm/state/state.enc — becomes permanently ' + + 'undecryptable. Nothing re-encrypts existing state under the new key.'; + const initCommand = defineCommand({ meta: { name: 'init', description: 'Create a new cryptographic identity' }, args: { name: { type: 'string', description: 'Your display name', required: true }, email: { type: 'string', description: 'Your email address', required: true }, - force: { type: 'boolean', description: 'Overwrite existing identity', default: false }, + force: { + type: 'boolean', + description: 'Replace the existing identity (destroys all local state; requires --yes)', + default: false, + }, + yes: sharedArgs.yes, json: sharedArgs.json, }, async run({ args }) { @@ -30,6 +45,37 @@ const initCommand = defineCommand({ } + let backups: string[] = []; + + if (existing) { + + // Deliberately the raw flag, not isYesMode(): an ambient NOORM_YES + // set for unattended runs must not be able to destroy every + // project's state as a side effect. + if (!args.yes) { + + outputError(args, `${DESTRUCTIVE_WARNING} Pass --yes to confirm.`); + process.exit(1); + + } + + const [backedUp, backupErr] = await attempt(() => backupKeyPair()); + + if (backupErr) { + + outputError( + args, + `Refusing to overwrite: could not back up the existing identity (${backupErr.message}). ` + + 'Without a backup this operation is unrecoverable.', + ); + process.exit(1); + + } + + backups = backedUp; + + } + const [result, err] = await attempt(() => createCryptoIdentity({ name: args.name, email: args.email }), ); @@ -43,6 +89,27 @@ const initCommand = defineCommand({ const { identity } = result; + const textLines = [ + 'Identity created.', + ` Name: ${identity.name}`, + ` Email: ${identity.email}`, + ` Fingerprint: ${identity.identityHash}`, + ]; + + if (backups.length > 0) { + + textLines.push( + '', + `WARNING: ${DESTRUCTIVE_WARNING}`, + '', + 'Previous identity backed up to:', + ...backups.map((path) => ` ${path}`), + '', + 'Restore identity.key from that backup to regain access to existing state.', + ); + + } + outputResult( args, { @@ -50,8 +117,9 @@ const initCommand = defineCommand({ email: identity.email, fingerprint: identity.identityHash, publicKey: identity.publicKey, + ...(backups.length > 0 ? { backedUpTo: backups, warning: DESTRUCTIVE_WARNING } : {}), }, - `Identity created.\n Name: ${identity.name}\n Email: ${identity.email}\n Fingerprint: ${identity.identityHash}`, + textLines.join('\n'), ); process.exit(0); @@ -60,7 +128,7 @@ const initCommand = defineCommand({ (initCommand as typeof initCommand & { examples: string[] }).examples = [ 'noorm identity init --name "Alice Smith" --email alice@example.com', - 'noorm identity init --name "Alice Smith" --email alice@example.com --force', + 'noorm identity init --name "Alice Smith" --email alice@example.com --force --yes', ]; export default initCommand; diff --git a/src/core/identity/storage.ts b/src/core/identity/storage.ts index 02f7f144..04d2cbd1 100644 --- a/src/core/identity/storage.ts +++ b/src/core/identity/storage.ts @@ -119,6 +119,74 @@ export async function saveKeyPair(keypair: KeyPair): Promise { } +/** + * Copy the existing identity files aside before they are overwritten. + * + * `deriveStateKey` is HKDF over the private key, so replacing the keypair + * makes every `state.enc` on the machine undecryptable — configs, secrets and + * database passwords, with no recovery path. Nothing in noorm re-encrypts + * existing state under a new key, so the old key file is the only way back. + * + * Backups are written owner-only, including the public key and metadata, so a + * recovery copy never widens the permissions of what it copies. + * + * @returns Absolute paths of the backup files that were written + * + * @throws Error if the private key exists but cannot be backed up — the caller + * must not proceed with an overwrite it cannot undo + * + * @example + * ```typescript + * const backups = await backupKeyPair() + * console.log(`Previous identity saved to ${backups[0]}`) + * ``` + */ +export async function backupKeyPair(): Promise { + + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const written: string[] = []; + + for (const source of [PRIVATE_KEY_PATH, PUBLIC_KEY_PATH, IDENTITY_METADATA_PATH]) { + + const [content, readErr] = await attempt(() => readFile(source, { encoding: 'utf8' })); + + if (readErr) { + + // A missing .pub or .json is survivable — both are derivable or + // re-creatable. A missing private key is not, and is the file worth + // aborting over. + if (source === PRIVATE_KEY_PATH) { + + throw new Error(`Failed to read private key for backup: ${readErr.message}`); + + } + + continue; + + } + + const target = `${source}.bak-${stamp}`; + + const [, writeErr] = await attempt(() => + writeFile(target, content, { encoding: 'utf8', mode: PRIVATE_KEY_MODE }), + ); + + if (writeErr) { + + throw new Error(`Failed to write backup ${target}: ${writeErr.message}`); + + } + + await attempt(() => chmod(target, PRIVATE_KEY_MODE)); + + written.push(target); + + } + + return written; + +} + /** * Save identity metadata to disk. * diff --git a/tests/cli/identity/init.test.ts b/tests/cli/identity/init.test.ts new file mode 100644 index 00000000..1ee4091a --- /dev/null +++ b/tests/cli/identity/init.test.ts @@ -0,0 +1,182 @@ +/** + * cli: `noorm identity init --force` is an irreversible destroy-all-state + * button (a08 F2, critical). + * + * The state encryption key is HKDF(privateKey, info='noorm-state-encryption'), + * so regenerating the keypair renders every `state.enc` on the machine — + * every project's configs, secrets and DB passwords — permanently + * undecryptable. The only guard was `--force`, described as "Overwrite + * existing identity", with no warning, no backup and no confirmation. + * + * `identity init` resolves ~/.noorm from `homedir()` at module import time, so + * each case spawns the compiled CLI with HOME repointed at a throwaway dir — + * same idiom as tests/core/identity/storage-key-permission-guard.test.ts. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { + mkdtempSync, + rmSync, + mkdirSync, + writeFileSync, + readFileSync, + readdirSync, + chmodSync, + statSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); + +describe('cli: identity init --force', () => { + + let fakeHome: string; + let noormDir: string; + let keyPath: string; + let existingKey: string; + + beforeEach(() => { + + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-identity-init-home-')); + noormDir = join(fakeHome, '.noorm'); + mkdirSync(noormDir, { recursive: true }); + + const keypair = generateKeyPair(); + existingKey = keypair.privateKey; + keyPath = join(noormDir, 'identity.key'); + + writeFileSync(keyPath, keypair.privateKey, { mode: 0o600 }); + chmodSync(keyPath, 0o600); + writeFileSync(join(noormDir, 'identity.pub'), keypair.publicKey, { mode: 0o644 }); + + }); + + afterEach(() => { + + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + function runInit(args: string[]) { + + const result = spawnSync( + 'node', + [CLI, 'identity', 'init', '--name', 'Rotator', '--email', 'rotate@example.com', ...args], + { cwd: fakeHome, encoding: 'utf-8', env: { ...process.env, HOME: fakeHome } }, + ); + + return { + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + status: result.status, + }; + + } + + function backupFiles() { + + return readdirSync(noormDir).filter((f) => f.startsWith('identity.key.bak-')); + + } + + it('refuses --force without --yes', () => { + + const result = runInit(['--force']); + + expect(result.status).not.toBe(0); + + }); + + it('leaves the existing key untouched when --yes is absent', () => { + + runInit(['--force']); + + expect(readFileSync(keyPath, 'utf8')).toBe(existingKey); + + }); + + it('warns that state becomes unrecoverable rather than just "overwrite"', () => { + + const result = runInit(['--force']); + + expect(`${result.stdout}${result.stderr}`).toMatch(/state|unrecoverab|decrypt/i); + + }); + + it('backs up the previous key before overwriting it', () => { + + const result = runInit(['--force', '--yes']); + + expect(result.status).toBe(0); + + const backups = backupFiles(); + + expect(backups.length).toBe(1); + expect(readFileSync(join(noormDir, backups[0]!), 'utf8')).toBe(existingKey); + + }); + + it('writes the backup with owner-only permissions', () => { + + runInit(['--force', '--yes']); + + const backups = backupFiles(); + const mode = statSync(join(noormDir, backups[0]!)).mode & 0o777; + + expect(mode & 0o077).toBe(0); + + }); + + it('actually rotates the key when --force --yes is given', () => { + + runInit(['--force', '--yes']); + + expect(readFileSync(keyPath, 'utf8')).not.toBe(existingKey); + + }); + + it('still refuses a plain init when an identity already exists', () => { + + const result = runInit([]); + + expect(result.status).not.toBe(0); + expect(readFileSync(keyPath, 'utf8')).toBe(existingKey); + + }); + +}); + +describe('cli: identity init (first run)', () => { + + let fakeHome: string; + + beforeEach(() => { + + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-identity-first-home-')); + mkdirSync(join(fakeHome, '.noorm'), { recursive: true }); + + }); + + afterEach(() => { + + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + it('creates an identity with no flags beyond name and email', () => { + + const result = spawnSync( + 'node', + [CLI, 'identity', 'init', '--name', 'First Run', '--email', 'first@example.com', '--json'], + { cwd: fakeHome, encoding: 'utf-8', env: { ...process.env, HOME: fakeHome } }, + ); + + expect(result.status).toBe(0); + expect(readFileSync(join(fakeHome, '.noorm', 'identity.key'), 'utf8')).toMatch(/^[0-9a-f]{96}$/); + + }); + +}); From 1620fed7aa4dcc48f3654808121ef3846e80fb13 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:39:47 -0400 Subject: [PATCH 011/105] fix(logger): reopen write stream after rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/logger/init.ts | 38 ++--- src/core/logger/logger.ts | 102 ++++++++++--- tests/core/logger/rotation-reopen.test.ts | 176 ++++++++++++++++++++++ 3 files changed, 268 insertions(+), 48 deletions(-) create mode 100644 tests/core/logger/rotation-reopen.test.ts diff --git a/src/core/logger/init.ts b/src/core/logger/init.ts index 7fa0d5e3..2e20f8a5 100644 --- a/src/core/logger/init.ts +++ b/src/core/logger/init.ts @@ -16,17 +16,13 @@ * // ... rest of CLI startup * ``` */ -import { createWriteStream } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { mkdir } from 'node:fs/promises'; import type { Writable } from 'node:stream'; -import { attempt } from '@logosdx/utils'; - import { observer } from '../observer.js'; import { isCi } from '../environment.js'; import { Logger, getLogger, resetLogger } from './logger.js'; import { addSettingsSecrets, listenForSecrets } from './redact.js'; +import { DEFAULT_LOGGER_CONFIG } from './types.js'; import type { Settings } from '../settings/types.js'; /** @@ -99,30 +95,20 @@ export function enableAutoLoggerInit(projectRoot: string, options?: AutoLoggerOp // Add settings-defined secrets to redaction list addSettingsSecrets(settings); - // Create file stream (unless CI-only mode) - let fileStream: Writable | undefined; - - if (!isCi()) { - - const filePath = join(projectRoot, settings.logging.file ?? '.noorm/state/noorm.log'); - - // Ensure directory exists - const [, mkdirErr] = await attempt(() => mkdir(dirname(filePath), { recursive: true })); - - if (!mkdirErr) { - - fileStream = createWriteStream(filePath, { flags: 'a' }); - - } - - } - - // Create and start logger + // Opening the file is left to the Logger so there is exactly one place + // that creates it — the same place that applies 0600 and re-opens it + // after rotation. In CI the file is pointless (the runner captures the + // streams, the workspace is thrown away), and a blank path is how the + // Logger is told to stay console-only. logger = new Logger({ projectRoot, settings, - config: settings.logging, - file: fileStream, + config: { + ...settings.logging, + file: isCi() + ? '' + : settings.logging.file ?? DEFAULT_LOGGER_CONFIG.file, + }, console: initOptions.console, diagnostics: initOptions.diagnostics, }); diff --git a/src/core/logger/logger.ts b/src/core/logger/logger.ts index 4c662833..0a05bc0c 100644 --- a/src/core/logger/logger.ts +++ b/src/core/logger/logger.ts @@ -21,7 +21,7 @@ import type { Writable } from 'node:stream'; import { join, dirname } from 'node:path'; import { createWriteStream } from 'node:fs'; -import { mkdir } from 'node:fs/promises'; +import { chmod, mkdir } from 'node:fs/promises'; import { observer, type NoormEvents } from '../observer.js'; import { isCi } from '../environment.js'; @@ -35,7 +35,13 @@ import type { LogLevel, LoggerConfig, LoggerState, EntryLevel } from './types.js import { DEFAULT_LOGGER_CONFIG } from './types.js'; import type { Settings } from '../settings/types.js'; import type { EventQueue, QueueOpts } from '@logosdx/observer'; -import { merge } from '@logosdx/utils'; +import { attempt, merge } from '@logosdx/utils'; + +/** + * Log files are owner-only: they carry redacted-but-not-guaranteed-clean event + * data, and sit in the same directory as `state.enc`, which is already 0600. + */ +const LOG_FILE_MODE = 0o600; /** * Flatten object for JSON output with dot-notation keys. @@ -310,24 +316,26 @@ export class Logger { } - // Create file stream if config.file is set but no explicit file option - if (!this.#writers.file && this.#config.file) { - - const filePath = this.filepath; - await mkdir(dirname(filePath), { recursive: true }); - this.#writers.file = createWriteStream(filePath, { flags: 'a' }); - - } - - // Check rotation before starting (if file logging) - if (this.#writers.file) { + if (this.#config.file) { + // Rotate before opening, not after: rotation renames the log file, + // so a stream opened first would already be bound to the rotated + // inode by the time the rename lands. const rotationResult = await checkAndRotate( this.filepath, this.#config.maxSize, this.#config.maxFiles, ); + // A caller-supplied stream is kept as-is unless rotation just moved + // the file out from under it, in which case it points at the wrong + // inode and has to be replaced. + if (!this.#writers.file || rotationResult.rotated) { + + await this.#openFileStream(); + + } + if (rotationResult.rotated) { observer.emit('logger:rotated', { @@ -340,7 +348,7 @@ export class Logger { // Start rotation check interval (every minute) this.#rotationInterval = setInterval(() => { - this.#checkRotation(); + this.checkRotation(); }, 60_000); @@ -610,11 +618,57 @@ export class Logger { } /** - * Check if rotation is needed and perform it. + * Open (or re-open) the file writer at the current log path. + * + * The new stream is installed before the old one is closed so no entry can + * be written to a writer that is already gone. */ - async #checkRotation(): Promise { + async #openFileStream(): Promise { + + const filePath = this.filepath; + const previous = this.#writers.file; + + await mkdir(dirname(filePath), { recursive: true }); + + const stream = createWriteStream(filePath, { flags: 'a', mode: LOG_FILE_MODE }); + + stream.on('error', () => {}); // best-effort file logging + + this.#writers.file = stream; + + // `mode` only applies when the file is created, so a log left behind by + // an older build stays 0644 until it is explicitly narrowed. + await attempt(() => chmod(filePath, LOG_FILE_MODE)); + + if (previous && previous !== process.stdout && previous !== process.stderr) { - if (!this.#writers.file || this.#state !== 'running') { + await new Promise((resolve) => previous.end(() => resolve())); + + } + + } + + /** + * Check if rotation is needed and perform it, re-opening the file writer + * when it fires. + * + * Rotation renames the live file. Without re-opening, the still-open fd + * follows the renamed inode: every later entry lands in the rotated file, + * the log path stays missing, and `needsRotation` on a missing path reports + * false forever — so rotation would fire exactly once per process and the + * rotated file would then grow unbounded. + * + * Public because the 60s interval is far too coarse to drive from a test, + * and callers that write in bursts may want to force the check. + * + * @example + * ```typescript + * await logger.checkRotation(); + * ``` + */ + async checkRotation(): Promise { + + if (!this.#writers.file || this.#state !== 'running' || !this.#config.file) { return; @@ -626,15 +680,19 @@ export class Logger { this.#config.maxFiles, ); - if (result.rotated) { + if (!result.rotated) { - observer.emit('logger:rotated', { - oldFile: result.oldFile!, - newFile: result.newFile!, - }); + return; } + await this.#openFileStream(); + + observer.emit('logger:rotated', { + oldFile: result.oldFile!, + newFile: result.newFile!, + }); + } // ───────────────────────────────────────────────────────────── diff --git a/tests/core/logger/rotation-reopen.test.ts b/tests/core/logger/rotation-reopen.test.ts new file mode 100644 index 00000000..5f4769a6 --- /dev/null +++ b/tests/core/logger/rotation-reopen.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { readFile, writeFile, appendFile, mkdir, rm, stat, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { Logger, resetLogger } from '../../../src/core/logger/logger.js'; +import { DEFAULT_LOGGER_CONFIG } from '../../../src/core/logger/types.js'; +import type { Settings } from '../../../src/core/settings/types.js'; + +const settings = {} as Settings; + +const LOG_REL_PATH = '.noorm/state/noorm.log'; + +/** + * Rotation renames the log file out from under the live write stream. The open + * fd follows the inode, so without a reopen every subsequent write lands in the + * *rotated* file, `noorm.log` never comes back, and `needsRotation()` on the + * now-missing path returns false forever — rotation fires exactly once and the + * rotated file then grows without bound. + * + * These tests assert the intent (writes go to the live log, size limits keep + * applying), not the mechanism, so a copy-truncate implementation would satisfy + * them equally. + */ +describe('logger: rotation stream reopen', () => { + + let testDir: string; + let logPath: string; + + beforeEach(async () => { + + testDir = join( + tmpdir(), + `noorm-test-rotreopen-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + logPath = join(testDir, LOG_REL_PATH); + + await mkdir(join(testDir, '.noorm', 'state'), { recursive: true }); + + }); + + afterEach(async () => { + + await resetLogger(); + await rm(testDir, { recursive: true, force: true }); + + }); + + const makeLogger = () => new Logger({ + projectRoot: testDir, + settings, + config: { ...DEFAULT_LOGGER_CONFIG, maxSize: '1kb', maxFiles: 5 }, + }); + + it('should recreate the log file after rotating it away', async () => { + + await writeFile(logPath, 'x'.repeat(4096)); + + const logger = makeLogger(); + await logger.start(); + await logger.stop(); + + const [stats, err] = await stat(logPath).then( + (s) => [s, null] as const, + (e: Error) => [null, e] as const, + ); + + expect(err).toBeNull(); + expect(stats).not.toBeNull(); + + }); + + it('should write post-rotation entries to the live log, not the rotated file', async () => { + + await writeFile(logPath, 'x'.repeat(4096)); + + const logger = makeLogger(); + await logger.start(); + + logger.info('AFTER-ROTATION-MARKER'); + + await logger.flush(); + await logger.stop(); + + const live = await readFile(logPath, 'utf-8'); + + expect(live).toContain('AFTER-ROTATION-MARKER'); + + const rotated = (await readdir(join(testDir, '.noorm', 'state'))) + .filter((f) => f !== 'noorm.log'); + + expect(rotated.length).toBe(1); + + const rotatedContent = await readFile( + join(testDir, '.noorm', 'state', rotated[0]!), + 'utf-8', + ); + + expect(rotatedContent).not.toContain('AFTER-ROTATION-MARKER'); + + }); + + it('should keep rotating on later cycles rather than firing once', async () => { + + await writeFile(logPath, 'x'.repeat(4096)); + + const logger = makeLogger(); + await logger.start(); + + logger.info('FIRST-CYCLE'); + await logger.flush(); + + // Re-inflate the live log so a second rotation is due. If the stream was + // orphaned this append goes to a file nothing is watching and the live + // log stays absent, so no second rotation can ever happen. + await appendFile(logPath, 'y'.repeat(4096)); + + await logger.checkRotation(); + + logger.info('SECOND-CYCLE'); + await logger.flush(); + await logger.stop(); + + const live = await readFile(logPath, 'utf-8'); + + // FIRST-CYCLE having moved out of the live log is the proof that a + // second rotation fired; SECOND-CYCLE being in it is the proof the + // stream was re-opened afterwards. + expect(live).toContain('SECOND-CYCLE'); + expect(live).not.toContain('FIRST-CYCLE'); + + const rotated = (await readdir(join(testDir, '.noorm', 'state'))) + .filter((f) => f !== 'noorm.log'); + + const contents = await Promise.all( + rotated.map((f) => readFile(join(testDir, '.noorm', 'state', f), 'utf-8')), + ); + + expect(contents.some((c) => c.includes('FIRST-CYCLE'))).toBe(true); + + }); + + it('should create the log file 0600, not world-readable', async () => { + + const logger = makeLogger(); + await logger.start(); + + logger.info('perm check'); + + await logger.flush(); + await logger.stop(); + + const stats = await stat(logPath); + + expect(stats.mode & 0o777).toBe(0o600); + + }); + + it('should keep the rotated file 0600 too', async () => { + + await writeFile(logPath, 'x'.repeat(4096), { mode: 0o600 }); + + const logger = makeLogger(); + await logger.start(); + await logger.stop(); + + const rotated = (await readdir(join(testDir, '.noorm', 'state'))) + .filter((f) => f !== 'noorm.log'); + + const stats = await stat(join(testDir, '.noorm', 'state', rotated[0]!)); + + expect(stats.mode & 0o777).toBe(0o600); + + }); + +}); From 0464ba4753aff5fcf657e43ad2fc4a0166622aed Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:39:53 -0400 Subject: [PATCH 012/105] fix(runner): dedup templates on the rendered SQL, not raw bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/core/runner/runner.ts | 10 ++ src/core/runner/tracker.ts | 9 ++ tests/core/runner/template-dedup.test.ts | 165 +++++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/core/runner/template-dedup.test.ts diff --git a/src/core/runner/runner.ts b/src/core/runner/runner.ts index 699a0bcc..60276434 100644 --- a/src/core/runner/runner.ts +++ b/src/core/runner/runner.ts @@ -950,6 +950,10 @@ async function executeSingleFileWithUpdate( skipReason: needsRunResult.skipReason, }; + // The rendered checksum is written even on a skip: the pending + // row seeded by createFileRecords holds the raw file hash, and + // it is the newest row the *next* build will compare against. + // Leaving it raw makes the file re-run one build later. await tracker.updateFileExecution( operationId, relFilepath, @@ -957,6 +961,7 @@ async function executeSingleFileWithUpdate( 0, undefined, needsRunResult.skipReason, + finalChecksum, ); observer.emit('file:skip', { @@ -993,6 +998,8 @@ async function executeSingleFileWithUpdate( 'failed', Math.round(durationMs), error, + undefined, + finalChecksum, ); observer.emit('file:after', { @@ -1019,6 +1026,9 @@ async function executeSingleFileWithUpdate( relFilepath, 'success', Math.round(durationMs), + undefined, + undefined, + finalChecksum, ); observer.emit('file:after', { diff --git a/src/core/runner/tracker.ts b/src/core/runner/tracker.ts index ff4879ba..586dd867 100644 --- a/src/core/runner/tracker.ts +++ b/src/core/runner/tracker.ts @@ -571,6 +571,13 @@ export class Tracker { * @param durationMs - Execution time * @param errorMessage - Error message if failed * @param skipReason - Skip reason if skipped + * @param checksum - Checksum of the SQL that actually reached the + * database. `createFileRecords` seeds the pending row with the *raw* + * file hash because rendering every template upfront would execute them + * twice; for a `.sql.tmpl` that hash is not what `needsRun` compares + * against, so leaving it in place made template dedup unreachable. + * Omitted leaves the seeded value alone — correct only where no render + * happened (e.g. the file could not be read). * @returns Error message if update failed, null on success */ async updateFileExecution( @@ -580,6 +587,7 @@ export class Tracker { durationMs: number, errorMessage?: string, skipReason?: string, + checksum?: string, ): Promise { const [result, err] = await attempt(() => @@ -590,6 +598,7 @@ export class Tracker { duration_ms: Math.round(durationMs), error_message: errorMessage ?? '', skip_reason: skipReason ?? '', + ...(checksum === undefined ? {} : { checksum }), }) .where('change_id', '=', operationId) .where('filepath', '=', filepath) diff --git a/tests/core/runner/template-dedup.test.ts b/tests/core/runner/template-dedup.test.ts new file mode 100644 index 00000000..485c4948 --- /dev/null +++ b/tests/core/runner/template-dedup.test.ts @@ -0,0 +1,165 @@ +/** + * Checksum dedup for `.sql.tmpl` files through `run build`. + * + * The runner's headline promise is "run build as often as you like, only + * changed files execute". For templates it was false on every build: the + * checksum persisted upfront was the *raw file* hash while `needsRun` + * compared the *rendered* hash, so the two could never match and every + * template re-executed forever — failing on any non-idempotent DDL. + * + * `tests/core/runner/tracker.test.ts` cannot see this: it hands `needsRun` + * checksums directly, so the raw-vs-rendered mismatch is invisible by + * construction. These tests drive `runBuild` end to end instead, and pin + * the *decision* the fix encodes — the rendered SQL is the dedup key, so a + * template whose inputs changed re-runs even though its bytes did not. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Kysely, SqliteDialect, sql } from 'kysely'; + +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { runBuild } from '../../../src/core/runner/runner.js'; +import { computeChecksumFromContent } from '../../../src/core/runner/checksum.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import type { RunContext } from '../../../src/core/runner/types.js'; + +describe('runner: template checksum dedup', () => { + + let db: Kysely; + let tempDir: string; + let sqlDir: string; + + function context(): RunContext { + + return { + db, + configName: 'test', + identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, + projectRoot: tempDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', + dialect: 'sqlite', + }; + + } + + async function storedChecksums(): Promise { + + const { rows } = await sql<{ checksum: string }>` + SELECT checksum FROM __noorm_executions__ ORDER BY id DESC LIMIT 1 + `.execute(db); + + return rows.map((r) => r.checksum); + + } + + beforeEach(async () => { + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-tmpl-dedup-')); + sqlDir = join(tempDir, 'sql'); + await mkdir(sqlDir, { recursive: true }); + + db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + }); + + afterEach(async () => { + + await db.destroy(); + await rm(tempDir, { recursive: true, force: true }); + + }); + + it('should skip an unchanged .sql.tmpl on every subsequent build', async () => { + + // No template syntax at all: the renderer still strips boundary + // newlines, so raw bytes and rendered output differ. That mismatch + // alone was enough to re-execute the file forever. + await writeFile( + join(sqlDir, '001_t.sql.tmpl'), + 'CREATE TABLE tmpl_dedup (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const first = await runBuild(context(), sqlDir); + + expect(first.status).toBe('success'); + expect(first.filesRun).toBe(1); + + // Non-idempotent DDL: a second execution fails outright, which is + // what made this a data-integrity bug rather than a slow build. + const second = await runBuild(context(), sqlDir); + + expect(second.status).toBe('success'); + expect(second.filesSkipped).toBe(1); + expect(second.files[0]?.skipReason).toBe('unchanged'); + + // The third build is the one the previous skip-reason fix (dd7e387) + // was about — a recorded `unchanged` skip must not read as "never ran". + const third = await runBuild(context(), sqlDir); + + expect(third.status).toBe('success'); + expect(third.filesSkipped).toBe(1); + + }); + + it('should persist the rendered checksum, not the raw file hash', async () => { + + const raw = 'CREATE TABLE tmpl_checksum (id INTEGER PRIMARY KEY);\n'; + await writeFile(join(sqlDir, '001_t.sql.tmpl'), raw, 'utf-8'); + + const result = await runBuild(context(), sqlDir); + + expect(result.filesRun).toBe(1); + + // Which hash is canonical is the decision this fix records: the + // rendered SQL is what actually reaches the database, so it is what + // "has this changed?" must be asked about. + const [stored] = await storedChecksums(); + + expect(stored).toBe(computeChecksumFromContent(raw.trimEnd())); + expect(stored).not.toBe(computeChecksumFromContent(raw)); + + }); + + it('should re-run a template whose data file changed but whose bytes did not', async () => { + + await writeFile( + join(sqlDir, 'seed.json'), + JSON.stringify({ label: 'first' }), + 'utf-8', + ); + await writeFile( + join(sqlDir, '001_t.sql.tmpl'), + "SELECT '{%~ $.seed.label %}' AS label;", + 'utf-8', + ); + + expect((await runBuild(context(), sqlDir)).filesRun).toBe(1); + expect((await runBuild(context(), sqlDir)).filesSkipped).toBe(1); + + // The template file is untouched — only its input changed. Hashing + // raw bytes would skip this and ship stale SQL. + await writeFile( + join(sqlDir, 'seed.json'), + JSON.stringify({ label: 'second' }), + 'utf-8', + ); + + const afterDataChange = await runBuild(context(), sqlDir); + + expect(afterDataChange.filesRun).toBe(1); + expect(afterDataChange.filesSkipped).toBe(0); + + }); + +}); From eb4ecd6a8c39669b6551254d7e12b017f058c911 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:40:04 -0400 Subject: [PATCH 013/105] 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 ` 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. --- src/cli/change/rewind.ts | 12 +- src/core/change/manager.ts | 1 + src/core/change/types.ts | 7 ++ src/sdk/namespaces/changes.ts | 8 +- tests/cli/change/rewind.test.ts | 196 ++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 tests/cli/change/rewind.test.ts diff --git a/src/cli/change/rewind.ts b/src/cli/change/rewind.ts index a9fd0e03..fa11e2a7 100644 --- a/src/cli/change/rewind.ts +++ b/src/cli/change/rewind.ts @@ -34,6 +34,9 @@ const rewindCommand = defineCommand({ if (!args.name && !requireTty('Change name')) process.exit(1); + const dryRun = Boolean(args.dryRun); + const force = Boolean(args.force); + const [result, error] = await withContext({ args, fn: async (ctx, logger) => { @@ -61,7 +64,12 @@ const rewindCommand = defineCommand({ } - const res = await ctx.noorm.changes.rewind(changeName); + // A bare integer is the documented count form ("revert the + // last N"); citty hands every positional back as a string, + // so without this the count is looked up as a change name. + const target = /^\d+$/.test(changeName) ? Number(changeName) : changeName; + + const res = await ctx.noorm.changes.rewind(target, { dryRun, force }); if (!args.json) { @@ -70,6 +78,7 @@ const rewindCommand = defineCommand({ if (res.status !== 'success') { logger.error(summaryMsg); + if (res.error) logger.error(` ${res.error}`); } else { @@ -123,6 +132,7 @@ const rewindCommand = defineCommand({ (rewindCommand as typeof rewindCommand & { examples: string[] }).examples = [ 'noorm change rewind', + 'noorm change rewind 3', 'noorm change rewind 001_init', 'noorm change rewind 2024-02-01-notifications -c prod', 'noorm change rewind 002_users --json', diff --git a/src/core/change/manager.ts b/src/core/change/manager.ts index 0b2bef1c..ec53c283 100644 --- a/src/core/change/manager.ts +++ b/src/core/change/manager.ts @@ -411,6 +411,7 @@ export class ChangeManager { skipped: 0, failed: 1, durationMs: performance.now() - start, + error: `No applied change named "${target}" to rewind to`, }; } diff --git a/src/core/change/types.ts b/src/core/change/types.ts index 07edf47e..d878249a 100644 --- a/src/core/change/types.ts +++ b/src/core/change/types.ts @@ -477,6 +477,13 @@ export interface BatchChangeResult { /** Total execution time */ durationMs: number; + + /** + * Why the batch failed before running anything, when no per-change + * result can carry the reason — e.g. a rewind target matching no + * applied change. Absent when `changes` explains the outcome. + */ + error?: string; } // ───────────────────────────────────────────────────────────── diff --git a/src/sdk/namespaces/changes.ts b/src/sdk/namespaces/changes.ts index 76a6185c..c07a38a2 100644 --- a/src/sdk/namespaces/changes.ts +++ b/src/sdk/namespaces/changes.ts @@ -382,17 +382,21 @@ export class ChangesNamespace { * When a string is passed, reverts until and including that change. * When a number is passed, reverts that many recent changes. * + * Pass `dryRun: true` to render the revert files to `tmp/` without + * touching the database. + * * @example * ```typescript * const result = await ctx.noorm.changes.rewind('2024-01-15-add-users') * const result = await ctx.noorm.changes.rewind(3) + * const dry = await ctx.noorm.changes.rewind(3, { dryRun: true }) * ``` */ - async rewind(target: number | string): Promise { + async rewind(target: number | string, options?: BatchChangeOptions): Promise { checkProtectedConfig(this.#state.config, this.#state.options, 'change:revert', 'changes.rewind'); - return (await this.#getManager()).rewind(target); + return (await this.#getManager()).rewind(target, options); } diff --git a/tests/cli/change/rewind.test.ts b/tests/cli/change/rewind.test.ts new file mode 100644 index 00000000..f64fccf5 --- /dev/null +++ b/tests/cli/change/rewind.test.ts @@ -0,0 +1,196 @@ +/** + * cli: noorm change rewind — flag plumbing and the documented count form. + * + * Mirrors tests/cli/change/history.test.ts's harness (subprocess against + * the compiled CLI, identity via NOORM_IDENTITY_*, config seeded through + * StateManager). Driven end to end against a real sqlite file because the + * defects here are invisible to the result payload: rewind reported + * `status: "success"` both when `--dry-run` dropped a live table and when + * the count form matched nothing. The database is the only honest oracle. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { Database } from 'bun:sqlite'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'changerewind'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm change rewind', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-change-rewind-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-change-rewind-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync( + join(tmpDir, '.noorm', 'settings.yml'), + 'paths:\n sql: ./sql\n changes: ./changes\n', + ); + mkdirSync(join(tmpDir, 'sql'), { recursive: true }); + + dbPath = join(tmpDir, 'target.db'); + writeFileSync(dbPath, ''); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one active admin-role config, bypassing the TUI-only `config add`/`edit` commands. */ + async function seedConfig(): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + function seedChange(name: string, table: string): void { + + const base = join(tmpDir, 'changes', name); + + mkdirSync(join(base, 'change'), { recursive: true }); + mkdirSync(join(base, 'revert'), { recursive: true }); + + writeFileSync( + join(base, 'change', '001_create.sql'), + `CREATE TABLE ${table} (id INTEGER PRIMARY KEY)`, + ); + writeFileSync(join(base, 'revert', '001_drop.sql'), `DROP TABLE ${table}`); + + } + + function runCli(args: string[]) { + + return spawnSync('node', [CLI, 'change', ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: identityEnv, + }); + + } + + function tableExists(table: string): boolean { + + const db = new Database(dbPath, { readonly: true }); + const row = db + .query('SELECT name FROM sqlite_master WHERE type = \'table\' AND name = ?') + .get(table); + + db.close(); + + return row !== null; + + } + + it('--dry-run must not execute the reverts', async () => { + + await seedConfig(); + seedChange('2026-03-01-probe', 'rewind_probe'); + + expect(runCli(['run', '2026-03-01-probe', '--json']).status).toBe(0); + expect(tableExists('rewind_probe')).toBe(true); + + const result = runCli(['rewind', '2026-03-01-probe', '--dry-run', '--json']); + + expect(result.status).toBe(0); + + // The whole contract of --dry-run: the database is untouched. + expect(tableExists('rewind_probe')).toBe(true); + + }); + + it('reverts the last N applied changes when given a count', async () => { + + await seedConfig(); + seedChange('2026-03-01-first', 'rewind_first'); + seedChange('2026-03-02-second', 'rewind_second'); + + expect(runCli(['ff', '--json']).status).toBe(0); + expect(tableExists('rewind_first')).toBe(true); + expect(tableExists('rewind_second')).toBe(true); + + // The form documented in docs/guide/changes/forward-revert.md. + const result = runCli(['rewind', '1', '--json']); + + expect(result.status).toBe(0); + expect(tableExists('rewind_second')).toBe(false); + expect(tableExists('rewind_first')).toBe(true); + + }); + + it('reports why a rewind target that matches nothing failed', async () => { + + await seedConfig(); + seedChange('2026-03-01-probe', 'rewind_probe'); + + expect(runCli(['run', '2026-03-01-probe', '--json']).status).toBe(0); + + const result = runCli(['rewind', 'no-such-change', '--json']); + + expect(result.status).not.toBe(0); + + const parsed = JSON.parse(result.stdout); + + expect(parsed.status).toBe('failed'); + expect(parsed.error).toBeTruthy(); + expect(parsed.error).toContain('no-such-change'); + + }); + +}); From cfd86b81fb14587654a0df3f9d1a56dd281993e2 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:40:14 -0400 Subject: [PATCH 014/105] fix(init): write a real ignore entry in the .gitignore block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/project-init.ts | 22 ++++++++++----- tests/core/project-init.test.ts | 50 ++++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/core/project-init.ts b/src/core/project-init.ts index caf6cc0f..1a2ce8a1 100644 --- a/src/core/project-init.ts +++ b/src/core/project-init.ts @@ -33,6 +33,14 @@ import { SettingsManager } from './settings/manager.js'; import { StateManager, getStateManager } from './state/index.js'; import { observer } from './observer.js'; +/** + * What the project `.gitignore` block must exclude: `state.enc` holds every + * config and secret, and `noorm.log` sits beside it. + */ +const NOORM_IGNORE_ENTRY = '.noorm/state/'; + +const NOORM_GITIGNORE_BLOCK = `\n# noorm\n${NOORM_IGNORE_ENTRY}\n`; + export interface ProjectInitIdentityInfo { name: string; email: string; @@ -59,7 +67,6 @@ export async function performProjectInit( opts: ProjectInitOptions, ): Promise { - // === Declaration block === const { projectRoot, force, identityInfo } = opts; const sqlPath = join(projectRoot, 'sql'); @@ -70,7 +77,6 @@ export async function performProjectInit( const created: string[] = []; - // === Business logic block === mkdirSync(sqlPath, { recursive: true }); writeFileSync(join(sqlPath, '.gitkeep'), '', { flag: 'a' }); created.push('sql/.gitkeep'); @@ -137,26 +143,28 @@ export async function performProjectInit( const singleton = getStateManager(projectRoot); await singleton.reloadPrivateKey(); - const noormBlock = '\n# noorm\n'; if (existsSync(gitignorePath)) { const existing = readFileSync(gitignorePath, 'utf-8'); - if (!existing.includes('# noorm')) { - appendFileSync(gitignorePath, noormBlock); + // Keyed on the entry rather than the `# noorm` header: earlier + // versions wrote the header with nothing under it, and those + // projects would otherwise skip the append forever. + if (!existing.includes(NOORM_IGNORE_ENTRY)) { + + appendFileSync(gitignorePath, NOORM_GITIGNORE_BLOCK); } } else { - writeFileSync(gitignorePath, noormBlock.trimStart()); + writeFileSync(gitignorePath, NOORM_GITIGNORE_BLOCK.trimStart()); } observer.emit('init:complete', { projectRoot, hasIdentity: createdIdentity }); - // === Commit block === return { success: true, createdIdentity, diff --git a/tests/core/project-init.test.ts b/tests/core/project-init.test.ts index ec80e3b0..4d4462a1 100644 --- a/tests/core/project-init.test.ts +++ b/tests/core/project-init.test.ts @@ -41,7 +41,13 @@ describe('core: performProjectInit', () => { }); - it('should append # noorm block to existing .gitignore only if missing', async () => { + /** + * The block exists to keep `.noorm/state/` — state.enc and the log file — + * out of version control. Asserting only that the `# noorm` header landed + * would pass against a header with no entries under it, which is what + * init actually wrote. + */ + it('should append a # noorm block that ignores the state directory', async () => { const globalIdentity = join(homedir(), '.noorm', 'identity.key'); if (!existsSync(globalIdentity)) return; @@ -58,6 +64,7 @@ describe('core: performProjectInit', () => { const content = readFileSync(gitignorePath, 'utf-8'); expect(content).toContain('# noorm'); + expect(content).toContain('.noorm/state/'); expect(content).toContain('node_modules'); await performProjectInit({ @@ -72,4 +79,45 @@ describe('core: performProjectInit', () => { }); + it('should write an ignore entry when creating .gitignore from scratch', async () => { + + const globalIdentity = join(homedir(), '.noorm', 'identity.key'); + if (!existsSync(globalIdentity)) return; + + await performProjectInit({ + projectRoot: tmpDir, + force: false, + identityInfo: null, + }); + + const content = readFileSync(join(tmpDir, '.gitignore'), 'utf-8'); + + expect(content).toContain('.noorm/state/'); + + }); + + /** + * Earlier versions wrote a bare `# noorm` header. Keying the skip on the + * header means those projects never get the entry; keying it on the entry + * repairs them on the next init. + */ + it('should repair a legacy # noorm block that has no entries under it', async () => { + + const globalIdentity = join(homedir(), '.noorm', 'identity.key'); + if (!existsSync(globalIdentity)) return; + + const gitignorePath = join(tmpDir, '.gitignore'); + const { writeFileSync } = await import('node:fs'); + writeFileSync(gitignorePath, 'node_modules\n\n# noorm\n'); + + await performProjectInit({ + projectRoot: tmpDir, + force: false, + identityInfo: null, + }); + + expect(readFileSync(gitignorePath, 'utf-8')).toContain('.noorm/state/'); + + }); + }); From 93bbe55e878f99ce39c953a8682e9c8669456d19 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:40:14 -0400 Subject: [PATCH 015/105] 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. --- tests/core/config/debug-process.test.ts | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 tests/core/config/debug-process.test.ts diff --git a/tests/core/config/debug-process.test.ts b/tests/core/config/debug-process.test.ts deleted file mode 100644 index 8b230a81..00000000 --- a/tests/core/config/debug-process.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, it } from 'bun:test'; - -describe('debug: process', () => { - - it('logs process pid', () => { - - console.log('[debug-process] PID:', process.pid); - console.log('[debug-process] NOORM keys:', Object.keys(process.env).filter(k => k.startsWith('NOORM_'))); - - }); - -}); From 467d313f1aa3583901a8e947059f87c4da9319d4 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:40:23 -0400 Subject: [PATCH 016/105] fix(identity): bind executed_by to the enrolled cryptographic identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/identity/index.ts | 19 +++++- tests/core/identity/resolver.test.ts | 90 ++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/core/identity/index.ts b/src/core/identity/index.ts index 9d68ddf0..1ad2fc63 100644 --- a/src/core/identity/index.ts +++ b/src/core/identity/index.ts @@ -18,6 +18,7 @@ import { observer } from '../observer.js'; import type { CryptoIdentity, Identity, IdentityOptions } from './types.js'; import { resolveIdentity as resolve } from './resolver.js'; import { loadExistingIdentity } from './factory.js'; +import { getIdentityOverride } from './storage.js'; import { registerIdentity } from './sync.js'; // ============================================================================= @@ -152,7 +153,18 @@ export function clearIdentityCache(): void { /** * Get audit identity with config awareness. * - * Convenience function that extracts the identity override from a config. + * This is the live audit path — the SDK calls it to decide what lands in + * `noorm.change.executed_by`. It therefore has to see the cryptographic + * identity, not just the config's string override: passing only + * `configIdentity` meant the identity that actually authenticated to the vault + * never appeared in the audit trail, and an unauthenticated `NOORM_IDENTITY` + * env var outranked it. The TUI already passed a crypto identity, so the same + * command attributed differently depending on the surface it ran from. + * + * `getIdentityOverride()` is the process-wide CI identity installed at startup + * once `NOORM_IDENTITY_*` is validated. It is read here rather than loaded + * because this function is synchronous by contract; a disk identity still has + * to be supplied by the caller through `getIdentityWithCrypto`. * * @example * ```typescript @@ -162,7 +174,10 @@ export function clearIdentityCache(): void { */ export function getIdentityForConfig(config: { identity?: string }): Identity { - return resolveIdentity({ configIdentity: config.identity }); + return resolveIdentity({ + configIdentity: config.identity, + cryptoIdentity: getIdentityOverride(), + }); } diff --git a/tests/core/identity/resolver.test.ts b/tests/core/identity/resolver.test.ts index 1af6fc04..e6600c41 100644 --- a/tests/core/identity/resolver.test.ts +++ b/tests/core/identity/resolver.test.ts @@ -9,6 +9,8 @@ import { identityToString, getIdentityForConfig, getIdentityWithCrypto, + setIdentityOverride, + clearIdentityOverride, } from '../../../src/core/identity/index.js'; import type { CryptoIdentity } from '../../../src/core/identity/types.js'; @@ -440,3 +442,91 @@ describe('identity: resolver', () => { }); }); + +/** + * `getIdentityForConfig` is the live path — it is what src/sdk/index.ts calls, + * so it decides what lands in `noorm.change.executed_by`. It used to pass only + * `configIdentity`, which meant the identity that actually authenticated to the + * vault never reached the audit trail: an enrolled CI bot's changes were + * attributed to the runner's git user or OS username (a08 F4), and a bare + * `NOORM_IDENTITY` env var outranked the cryptographic identity entirely + * (a08 F3). The TUI passed `cryptoIdentity` and the CLI/SDK did not, so the + * same command attributed differently per surface. + */ +describe('identity: getIdentityForConfig binds the enrolled identity', () => { + + const enrolled: CryptoIdentity = { + identityHash: 'a'.repeat(64), + name: 'Audit Bot', + email: 'bot@ci.local', + publicKey: 'b'.repeat(88), + machine: 'ci', + os: 'env', + createdAt: '2026-07-29T00:00:00Z', + }; + + let envBackup: string | undefined; + + beforeEach(() => { + + envBackup = process.env['NOORM_IDENTITY']; + delete process.env['NOORM_IDENTITY']; + clearIdentityOverride(); + clearIdentityCache(); + + }); + + afterEach(() => { + + clearIdentityOverride(); + clearIdentityCache(); + + if (envBackup === undefined) delete process.env['NOORM_IDENTITY']; + else process.env['NOORM_IDENTITY'] = envBackup; + + }); + + it('attributes to the enrolled identity, not the git or OS user', () => { + + setIdentityOverride(enrolled); + + const identity = getIdentityForConfig({}); + + expect(identity.name).toBe('Audit Bot'); + expect(identity.email).toBe('bot@ci.local'); + expect(identity.source).toBe('state'); + + }); + + it('outranks the unauthenticated NOORM_IDENTITY env var', () => { + + process.env['NOORM_IDENTITY'] = 'Chief Security Officer '; + setIdentityOverride(enrolled); + + const identity = getIdentityForConfig({}); + + expect(identity.name).toBe('Audit Bot'); + expect(identity.source).toBe('state'); + + }); + + it('still lets an explicit config identity win, since that is a deliberate override', () => { + + setIdentityOverride(enrolled); + + const identity = getIdentityForConfig({ identity: 'Deploy Bot ' }); + + expect(identity.name).toBe('Deploy Bot'); + expect(identity.source).toBe('config'); + + }); + + it('falls back to git or system when no identity is enrolled', () => { + + const identity = getIdentityForConfig({}); + + expect(['git', 'system']).toContain(identity.source); + + }); + +}); From aa23b9d658bb8f8cc2c110150661f22a1222eded Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:42:16 -0400 Subject: [PATCH 017/105] fix(identity): recompute the identity hash on CLI edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/identity/edit.ts | 55 ++++++++-- tests/cli/identity/edit.test.ts | 171 ++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 tests/cli/identity/edit.test.ts diff --git a/src/cli/identity/edit.ts b/src/cli/identity/edit.ts index b15505c1..10b1d4f3 100644 --- a/src/cli/identity/edit.ts +++ b/src/cli/identity/edit.ts @@ -4,11 +4,19 @@ * Loads the current identity metadata from ~/.noorm/identity.json, * applies only the fields that were explicitly provided, and saves back. * At least one of --name or --email must be supplied. + * + * Goes through `createIdentityForExistingKeys` rather than writing the merged + * record directly, because the identity hash encodes email|name|machine|os. + * Spreading new values over the old record left a hash that still meant the + * previous person — the value the database joins on and grants vault access + * against — while `identity list` displayed the new one. The TUI's edit screen + * has always recomputed it; this is the surface that did not. */ import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; -import { loadIdentityMetadata, saveIdentityMetadata } from '../../core/identity/storage.js'; +import { createIdentityForExistingKeys } from '../../core/identity/factory.js'; +import { loadIdentityMetadata } from '../../core/identity/storage.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; const editCommand = defineCommand({ @@ -43,21 +51,44 @@ const editCommand = defineCommand({ } - const updated = { - ...existing, - ...(args.name ? { name: args.name } : {}), - ...(args.email ? { email: args.email } : {}), - }; - - const [, saveErr] = await attempt(() => saveIdentityMetadata(updated)); + // machine is carried over rather than re-detected so the only hash + // inputs that move are the ones the user actually asked to change. + const [updated, saveErr] = await attempt(() => + createIdentityForExistingKeys({ + name: args.name || existing.name, + email: args.email || existing.email, + machine: existing.machine, + }), + ); - if (saveErr) { + if (saveErr || !updated) { - outputError(args, `Failed to save identity: ${saveErr.message}`); + outputError(args, `Failed to save identity: ${saveErr?.message ?? 'identity keys not found'}`); process.exit(1); } + const hashChanged = updated.identityHash !== existing.identityHash; + + const textLines = [ + 'Identity updated.', + ` Name: ${updated.name}`, + ` Email: ${updated.email}`, + ` Fingerprint: ${updated.identityHash}`, + ]; + + if (hashChanged) { + + textLines.push( + '', + 'NOTE: the fingerprint changed, because it is derived from your name and email.', + 'Vault access is granted against the old fingerprint, so you will need a team', + 'member with vault access to propagate it to the new one.', + ` Previous: ${existing.identityHash}`, + ); + + } + outputResult( args, { @@ -65,8 +96,10 @@ const editCommand = defineCommand({ email: updated.email, fingerprint: updated.identityHash, publicKey: updated.publicKey, + previousFingerprint: existing.identityHash, + fingerprintChanged: hashChanged, }, - `Identity updated.\n Name: ${updated.name}\n Email: ${updated.email}\n Fingerprint: ${updated.identityHash}`, + textLines.join('\n'), ); process.exit(0); diff --git a/tests/cli/identity/edit.test.ts b/tests/cli/identity/edit.test.ts new file mode 100644 index 00000000..d3dfb103 --- /dev/null +++ b/tests/cli/identity/edit.test.ts @@ -0,0 +1,171 @@ +/** + * cli: `noorm identity edit` must recompute the identity hash (a08 F10). + * + * The identity hash encodes email|name|machine|os. The CLI spread the new + * name/email over the old record and saved it, leaving a hash that still + * encoded the PREVIOUS name and email — so `identity list` showed one person + * while the hash (the value the database joins on, and the value vault access + * is granted against) still meant another. The TUI's edit screen calls + * `createIdentityForExistingKeys`, which recomputes it. Same user-facing + * operation, opposite outcomes per surface. + * + * Recomputing costs vault access, because grants are keyed on the old hash. + * That is inherent to the design, so the command has to say it out loud. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { computeIdentityHash } from '../../../src/core/identity/hash.js'; +import type { CryptoIdentity } from '../../../src/core/identity/types.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); + +describe('cli: identity edit', () => { + + let fakeHome: string; + let noormDir: string; + let metadataPath: string; + let original: CryptoIdentity; + + beforeEach(() => { + + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-identity-edit-home-')); + noormDir = join(fakeHome, '.noorm'); + mkdirSync(noormDir, { recursive: true }); + + const keypair = generateKeyPair(); + + writeFileSync(join(noormDir, 'identity.key'), keypair.privateKey, { mode: 0o600 }); + chmodSync(join(noormDir, 'identity.key'), 0o600); + writeFileSync(join(noormDir, 'identity.pub'), keypair.publicKey, { mode: 0o644 }); + + original = { + identityHash: computeIdentityHash({ + email: 'alice@example.com', + name: 'Alice Auditor', + machine: 'alice-laptop', + os: 'darwin 24.5.0', + }), + name: 'Alice Auditor', + email: 'alice@example.com', + publicKey: keypair.publicKey, + machine: 'alice-laptop', + os: 'darwin 24.5.0', + createdAt: '2026-01-01T00:00:00Z', + }; + + metadataPath = join(noormDir, 'identity.json'); + writeFileSync(metadataPath, JSON.stringify(original, null, 2)); + + }); + + afterEach(() => { + + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + function runEdit(args: string[]) { + + const result = spawnSync( + 'node', + [CLI, 'identity', 'edit', ...args], + { cwd: fakeHome, encoding: 'utf-8', env: { ...process.env, HOME: fakeHome } }, + ); + + return { + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + status: result.status, + }; + + } + + function storedIdentity(): CryptoIdentity { + + return JSON.parse(readFileSync(metadataPath, 'utf8')) as CryptoIdentity; + + } + + it('recomputes the hash when the name changes', () => { + + const result = runEdit(['--name', 'Bob Victim']); + + expect(result.status).toBe(0); + + const stored = storedIdentity(); + + expect(stored.name).toBe('Bob Victim'); + expect(stored.identityHash).toBe(computeIdentityHash({ + email: original.email, + name: 'Bob Victim', + machine: stored.machine, + os: stored.os, + })); + expect(stored.identityHash).not.toBe(original.identityHash); + + }); + + it('recomputes the hash when the email changes', () => { + + runEdit(['--email', 'bob@corp.internal']); + + const stored = storedIdentity(); + + expect(stored.email).toBe('bob@corp.internal'); + expect(stored.identityHash).toBe(computeIdentityHash({ + email: 'bob@corp.internal', + name: original.name, + machine: stored.machine, + os: stored.os, + })); + + }); + + it('preserves the machine so the hash does not silently change twice', () => { + + runEdit(['--name', 'Bob Victim']); + + expect(storedIdentity().machine).toBe(original.machine); + + }); + + it('keeps the existing keypair', () => { + + runEdit(['--name', 'Bob Victim']); + + expect(storedIdentity().publicKey).toBe(original.publicKey); + + }); + + it('warns that the new hash costs vault access', () => { + + const result = runEdit(['--name', 'Bob Victim']); + + expect(`${result.stdout}${result.stderr}`).toMatch(/vault/i); + + }); + + it('reports the new fingerprint in --json', () => { + + const result = runEdit(['--name', 'Bob Victim', '--json']); + + const parsed = JSON.parse(result.stdout.trim()) as { fingerprint: string }; + + expect(parsed.fingerprint).toBe(storedIdentity().identityHash); + + }); + + it('still requires at least one field', () => { + + const result = runEdit([]); + + expect(result.status).not.toBe(0); + + }); + +}); From 39b6bad1e60a3a3ab0261122ab7d5d50b6fb2b02 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:42:32 -0400 Subject: [PATCH 018/105] fix(settings): type-check the env overlay before use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/settings/manager.ts | 13 ++++++++++-- tests/core/settings/env-override.test.ts | 26 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/core/settings/manager.ts b/src/core/settings/manager.ts index 4f325c1c..8adcb325 100644 --- a/src/core/settings/manager.ts +++ b/src/core/settings/manager.ts @@ -10,7 +10,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { attempt, clone, merge, makeNestedConfig } from '@logosdx/utils'; import { observer } from '../observer.js'; -import { parseSettings } from './schema.js'; +import { parseSettings, validateSettings } from './schema.js'; import { DEFAULT_SETTINGS, SETTINGS_DIR_PATH, @@ -1037,7 +1037,16 @@ export class SettingsManager { */ #refreshResolved(): void { - this.#settings = merge(clone(this.#document!), allSettingsEnv()) as Settings; + const merged = merge(clone(this.#document!), allSettingsEnv()); + + // Validated, not re-parsed: the overlay lands after the document was + // parsed, so env values arrive unchecked, and a scalar in an array + // field (NOORM_BUILD_INCLUDE=00_tables) used to be iterated character + // by character and silently match nothing. Asserting rather than + // transforming keeps the view shaped exactly like the document. + validateSettings(merged); + + this.#settings = merged as Settings; } diff --git a/tests/core/settings/env-override.test.ts b/tests/core/settings/env-override.test.ts index f0873a4c..215c979a 100644 --- a/tests/core/settings/env-override.test.ts +++ b/tests/core/settings/env-override.test.ts @@ -26,6 +26,7 @@ describe('settings: env overrides', () => { 'NOORM_DB_PASSWORD', 'NOORM_API_KEY', 'NOORM_FOO_BAR', + 'NOORM_BUILD_INCLUDE', ]; beforeEach(async () => { @@ -120,6 +121,31 @@ describe('settings: env overrides', () => { }); + /** + * The overlay is merged after parseSettings, so nothing type-checked it. + * A scalar landing in an array field was iterated character by character: + * `NOORM_BUILD_INCLUDE=00_tables` became ['0','0','_','t',...], matched + * no files, and still reported success with exit 0. + */ + it('should reject a scalar env value where the schema wants an array', async () => { + + process.env['NOORM_BUILD_INCLUDE'] = '00_tables'; + + const manager = new SettingsManager(testDir); + + let caught: Error | null = null; + + await manager.load().catch((err: Error) => { + + caught = err; + + }); + + expect(caught).not.toBeNull(); + expect((caught as unknown as Error).message).toMatch(/array/i); + + }); + /** * settings.yml is version controlled by design. The env overlay is a * per-process view of settings — persisting it launders whatever the From 83b5b48429b07c212b39f30a14afe7e81bd09292 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:42:33 -0400 Subject: [PATCH 019/105] 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 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 --- src/tui/screens/db/DbTransferScreen.tsx | 112 ++++- .../cli/screens/db/DbTransferScreen.test.tsx | 433 ++++++++++++++++++ 2 files changed, 531 insertions(+), 14 deletions(-) create mode 100644 tests/cli/screens/db/DbTransferScreen.test.tsx diff --git a/src/tui/screens/db/DbTransferScreen.tsx b/src/tui/screens/db/DbTransferScreen.tsx index 5108ab35..7dd31598 100644 --- a/src/tui/screens/db/DbTransferScreen.tsx +++ b/src/tui/screens/db/DbTransferScreen.tsx @@ -38,7 +38,7 @@ import { getErrorMessage } from '../../utils/index.js'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; -import { useAppContext } from '../../app-context.js'; +import { useAppContext, useGlobalModes } from '../../app-context.js'; import { useTransferProgress, useLoadGuard, useAsyncEffect } from '../../hooks/index.js'; import { useToast, @@ -46,10 +46,13 @@ import { Spinner, SelectList, Confirm, + SmartConfirm, FilePicker, type SelectListItem, } from '../../components/index.js'; +import { checkConfigPolicy } from '../../../core/policy/index.js'; + import { transferData, getTransferPlan } from '../../../core/transfer/index.js'; import { exportTable, @@ -85,6 +88,7 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement const { back, navigate } = useRouter(); const { activeConfig, activeConfigName, stateManager } = useAppContext(); + const globalModes = useGlobalModes(); const { showToast } = useToast(); const { state: progress, reset: resetProgress } = useTransferProgress(); @@ -96,7 +100,7 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement const [allTables, setAllTables] = useState([]); const [selectAllTables, setSelectAllTables] = useState(true); const [conflictStrategy, setConflictStrategy] = useState('fail'); - const [truncateFirst, _setTruncateFirst] = useState(false); + const [truncateFirst, setTruncateFirst] = useState(false); const [plan, setPlan] = useState(null); // Export/import state @@ -114,6 +118,19 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement const { tryAcquire, release } = useLoadGuard(); + // The write target is the destination for db-to-db, but the active config + // for an import — gate whichever one is about to be written, matching + // `transfer.to()`/`dt.importFile()` in the SDK. `transferData`'s own + // `assertPolicy` only rejects `allowed: false`, so a `confirm` cell reaches + // here as a pass and the typed-phrase branch below is the only thing that + // holds it. + const writeTarget = transferMode === 'import' ? activeConfig : destConfig; + + const writeCheck = useMemo( + () => (writeTarget ? checkConfigPolicy('user', writeTarget, 'db:reset') : null), + [writeTarget], + ); + // Get available configs (excluding active) const availableConfigs = useMemo(() => { @@ -189,13 +206,22 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement }, [allTables, selectedTables, selectAllTables]); - // Options items - const conflictItems: SelectListItem[] = [ + // Options items. The truncate row is a toggle rather than a hidden hotkey + // so the flag the transfer actually runs with is visible on screen — + // `truncateFirst` previously had no control at all and was pinned false, + // while `--truncate` worked on the CLI. + const optionItems: SelectListItem[] = useMemo(() => [ + { + key: '__truncate__', + label: `${truncateFirst ? '[x]' : '[ ]'} Truncate destination tables first`, + value: '__truncate__', + description: 'Delete all destination rows before inserting', + }, { key: 'fail', label: 'Fail on conflict', value: 'fail', description: 'Abort transfer on first duplicate' }, { key: 'skip', label: 'Skip conflicts', value: 'skip', description: 'Skip rows that already exist' }, { key: 'update', label: 'Update existing', value: 'update', description: 'Update existing rows with source data' }, { key: 'replace', label: 'Replace rows', value: 'replace', description: 'Delete and re-insert conflicting rows' }, - ]; + ], [truncateFirst]); // Load tables from active config for export mode useAsyncEffect(async (isCancelled) => { @@ -250,6 +276,26 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement setDestConfig(config); + // Fail closed here rather than at the confirm step: a denied role + // should not be walked through table selection and a transfer plan + // only to be refused at the end. + const destCheck = checkConfigPolicy('user', config, 'db:reset'); + + if (!destCheck.allowed) { + + if (!isCancelled()) { + + setError(destCheck.blockedReason ?? `Transfer into "${config.name}" is not allowed.`); + setPhase('error'); + + } + + release(); + + return; + + } + // Get transfer plan to list tables const [planResult, planErr] = await getTransferPlan(activeConfig, config, {}); @@ -351,6 +397,21 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement if (item.value === '__import__') { + // Import writes into the active config, so it is gated against + // that one rather than a destination selection. + const importCheck = activeConfig + ? checkConfigPolicy('user', activeConfig, 'db:reset') + : null; + + if (importCheck && !importCheck.allowed) { + + setError(importCheck.blockedReason ?? `Import into "${activeConfigName}" is not allowed.`); + setPhase('error'); + + return; + + } + setTransferMode('import'); setPhase('import-file'); @@ -369,7 +430,7 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement setTransferMode('db-to-db'); setDestConfigName(item.value); - }, []); + }, [activeConfig, activeConfigName, navigate]); // Handle table selection toggle const handleTableToggle = useCallback((item: SelectListItem) => { @@ -477,7 +538,16 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement }, [activeConfig, destConfig, selectAllTables, selectedTables, conflictStrategy, truncateFirst]); // Handle conflict strategy selection - const handleConflictSelect = useCallback((item: SelectListItem) => { + const handleConflictSelect = useCallback((item: SelectListItem) => { + + // Toggling stays on the options step; only a strategy advances. + if (item.value === '__truncate__') { + + setTruncateFirst((prev) => !prev); + + return; + + } setConflictStrategy(item.value); @@ -508,6 +578,7 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement tables, onConflict: conflictStrategy, truncateFirst, + dryRun: globalModes.dryRun, channel: 'user', }; @@ -525,7 +596,7 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement } - }, [activeConfig, destConfig, selectAllTables, selectedTables, conflictStrategy, truncateFirst, resetProgress]); + }, [activeConfig, destConfig, selectAllTables, selectedTables, conflictStrategy, truncateFirst, globalModes.dryRun, resetProgress]); // Execute export const executeExport = useCallback(async () => { @@ -809,14 +880,19 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement - How should conflicts be handled? - + Toggle truncate, then pick a conflict strategy to continue. + {globalModes.dryRun && ( + + DRY RUN MODE - the transfer will be validated, not executed + + )} + setPhase('select-tables')} - visibleCount={4} + visibleCount={5} /> @@ -1128,8 +1204,12 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement - setPhase('import-preview')} @@ -1157,8 +1237,12 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement - setPhase('plan')} diff --git a/tests/cli/screens/db/DbTransferScreen.test.tsx b/tests/cli/screens/db/DbTransferScreen.test.tsx new file mode 100644 index 00000000..b26d9d13 --- /dev/null +++ b/tests/cli/screens/db/DbTransferScreen.test.tsx @@ -0,0 +1,433 @@ +/** + * DbTransferScreen policy-gate and option-wiring tests. + * + * `db transfer` writes into the *destination* config, so the SDK gates it + * against that config rather than the active one + * (src/sdk/namespaces/transfer.ts -> checkProtectedConfig(destConfig, ..., + * 'db:reset')). The TUI screen imported core `transferData` directly, which + * only asserts `allowed` and treats a `confirm` cell as pass -- so a + * confirm-tier destination was writable from the TUI behind a single `y` + * keypress while the CLI hard-blocked. These tests pin the destination as the + * gated principal, and pin the confirm tier to the type-to-confirm dialog. + * + * The option assertions cover the second half: `truncateFirst` was declared + * with a setter that was never called, and `globalModes.dryRun` was never + * read, so the TUI always transferred with both false regardless of what the + * status bar advertised. + * + * Drives the real screen through the real providers. `core/transfer` is + * swapped via `mock.module` (precedent: tests/cli/screens/change/change-dry-run.test.tsx) + * so the screen's own call is observed without touching a database. + */ +import { describe, it, expect, vi, mock, beforeEach, afterEach, afterAll } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React, { useEffect } from 'react'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { Config } from '../../../../src/core/config/types.js'; +import type { Role } from '../../../../src/core/policy/types.js'; +import type { TransferOptions, TransferPlan, TransferResult } from '../../../../src/core/transfer/types.js'; + +import { FocusProvider } from '../../../../src/tui/focus.js'; +import { RouterProvider } from '../../../../src/tui/router.js'; +import { AppContextProvider, useAppContext } from '../../../../src/tui/app-context.js'; +import { ToastProvider } from '../../../../src/tui/components/index.js'; +import { DbTransferScreen } from '../../../../src/tui/screens/db/DbTransferScreen.js'; + +const actualCore = await import('../../../../src/core/index.js'); +const actualIdentity = await import('../../../../src/core/identity/index.js'); +const actualTransfer = await import('../../../../src/core/transfer/index.js'); + +/** Options object handed to `transferData` on each invocation. */ +const transferCalls: TransferOptions[] = []; + +/** Destination config `transferData` was called against, per invocation. */ +const transferDests: Config[] = []; + +/** Down-arrow escape sequence, as Ink's stdin parser expects it. */ +const DOWN = '\u001B[B'; + +/** A two-table plan; the screen only reads `tables`/`estimatedRows` here. */ +function makePlan(): TransferPlan { + + return { + tables: [ + { + name: 'users', + rowCount: 10, + hasIdentity: false, + primaryKey: ['id'], + columns: ['id'], + dependsOn: [], + }, + { + name: 'posts', + rowCount: 5, + hasIdentity: false, + primaryKey: ['id'], + columns: ['id'], + dependsOn: ['users'], + }, + ], + sameServer: true, + estimatedRows: 15, + warnings: [], + crossDialect: false, + sourceDialect: 'postgres', + destinationDialect: 'postgres', + } as unknown as TransferPlan; + +} + +mock.module('../../../../src/core/transfer/index.js', () => ({ + ...actualTransfer, + getTransferPlan: vi.fn(async () => [makePlan(), null]), + transferData: vi.fn(async (_src: Config, dest: Config, options: TransferOptions) => { + + transferDests.push(dest); + transferCalls.push(options); + + return [ + { + status: 'success', + tables: [], + totalRows: 0, + durationMs: 0, + fkChecksRestored: true, + } as TransferResult, + null, + ]; + + }), +})); + +/** + * A sqlite config at the given role. `db:reset` resolves deny/confirm/allow + * across viewer/operator/admin (src/core/policy/matrix.ts), which is what + * selects the blocked, type-to-confirm, and plain-confirm branches. + */ +function makeConfig(name: string, role: Role): Config { + + return { + name, + type: 'local', + isTest: true, + access: { user: role, mcp: role }, + connection: { + dialect: 'sqlite', + database: ':memory:', + }, + } as unknown as Config; + +} + +/** Active (source) config is always admin; the destination is what varies. */ +const sourceConfig = makeConfig('source', 'admin'); + +let destConfig = makeConfig('dest', 'admin'); + +const createMockStateManager = () => ({ + load: vi.fn().mockResolvedValue(undefined), + getActiveConfig: vi.fn().mockReturnValue(sourceConfig), + getActiveConfigName: vi.fn().mockReturnValue('source'), + listConfigs: vi.fn(() => [sourceConfig, destConfig]), + getConfig: vi.fn((name: string) => (name === 'dest' ? destConfig : sourceConfig)), + setConfig: vi.fn().mockResolvedValue(undefined), + setActiveConfig: vi.fn().mockResolvedValue(undefined), + hasPrivateKey: vi.fn().mockReturnValue(true), + isLoaded: true, +}); + +const createMockSettingsManager = () => ({ + load: vi.fn().mockResolvedValue({ version: '0.1.0' }), + isLoaded: true, + settings: { version: '0.1.0' }, + getStages: vi.fn().mockReturnValue({}), + getStage: vi.fn().mockReturnValue(undefined), +}); + +let mockStateManager = createMockStateManager(); +let mockSettingsManager = createMockSettingsManager(); + +mock.module('../../../../src/core/index.js', () => ({ + observer: actualCore.observer, + getStateManager: vi.fn(() => mockStateManager), + getSettingsManager: vi.fn(() => mockSettingsManager), + resetStateManager: vi.fn(), + resetSettingsManager: vi.fn(), +})); + +mock.module('../../../../src/core/identity/index.js', () => ({ + loadExistingIdentity: vi.fn().mockResolvedValue(null), +})); + +/** + * Drives global dry-run/force through the real context setters — the same + * transition the global D/F hotkeys produce. + */ +function GlobalModeSetter({ dryRun }: { dryRun: boolean }): null { + + const { globalModes, toggleDryRun } = useAppContext(); + + useEffect(() => { + + if (globalModes.dryRun !== dryRun) toggleDryRun(); + + }, [globalModes, dryRun, toggleDryRun]); + + return null; + +} + +describe('cli: DbTransferScreen', () => { + + let tempDir: string; + + const tick = (ms = 250) => new Promise((r) => setTimeout(r, ms)); + + function tree(dryRun = false) { + + return ( + + + + + + + + + + + ); + + } + + /** + * Walks the screen to the confirm step: destination -> tables -> conflict + * strategy -> plan. Returns the harness so each test can drive the + * confirm branch itself. + */ + async function advanceToConfirm(dryRun = false) { + + const harness = render(tree(dryRun)); + + await tick(300); + + // select-dest: 'dest' is the first entry (active config is filtered out) + harness.stdin.write('\r'); + await tick(300); + + return harness; + + } + + beforeEach(async () => { + + vi.clearAllMocks(); + actualCore.observer.clear(); + + delete process.env['NOORM_YES']; + + transferCalls.length = 0; + transferDests.length = 0; + destConfig = makeConfig('dest', 'admin'); + mockStateManager = createMockStateManager(); + mockSettingsManager = createMockSettingsManager(); + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-transfer-test-')); + + }); + + afterEach(async () => { + + actualCore.observer.clear(); + + await rm(tempDir, { recursive: true, force: true }); + + }); + + afterAll(() => { + + mock.module('../../../../src/core/index.js', () => actualCore); + mock.module('../../../../src/core/identity/index.js', () => actualIdentity); + mock.module('../../../../src/core/transfer/index.js', () => actualTransfer); + + }); + + describe('destination policy gate', () => { + + it('should refuse a destination whose role denies db:reset', async () => { + + destConfig = makeConfig('dest', 'viewer'); + + const { lastFrame, unmount } = await advanceToConfirm(); + + const frame = lastFrame() ?? ''; + + // Denied at selection time, not after walking the whole wizard. + expect(frame).toContain('not allowed'); + expect(frame).not.toContain('Select Tables'); + + unmount(); + + expect(transferCalls).toHaveLength(0); + + }); + + it('should require the typed phrase for a confirm-tier destination', async () => { + + destConfig = makeConfig('dest', 'operator'); + + const { stdin, lastFrame, unmount } = await advanceToConfirm(); + + stdin.write('\r'); // select-tables -> options (all tables) + await tick(); + stdin.write(DOWN); // past the truncate toggle + await tick(); + stdin.write('\r'); // options -> plan ('fail' strategy) + await tick(300); + stdin.write('\r'); // plan -> confirm + await tick(); + + expect(lastFrame() ?? '').toContain('Protected Configuration'); + + // A bare 'y' is what used to execute; it must not any more. + stdin.write('y'); + await tick(300); + + unmount(); + + expect(transferCalls).toHaveLength(0); + + }); + + it('should transfer after the confirmation phrase is typed', async () => { + + destConfig = makeConfig('dest', 'operator'); + + const { stdin, unmount } = await advanceToConfirm(); + + stdin.write('\r'); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write('\r'); + await tick(300); + stdin.write('\r'); + await tick(); + + stdin.write('yes-dest'); + await tick(); + stdin.write('\r'); + await tick(300); + + unmount(); + + expect(transferCalls).toHaveLength(1); + expect(transferDests[0]?.name).toBe('dest'); + + }); + + it('should gate against the destination, not the active config', async () => { + + // Source is admin, destination is viewer. Gating the active config + // would let this through — the exact bug. Walk the whole wizard so + // a gate that only fires at selection time is not the only thing + // keeping this green. + destConfig = makeConfig('dest', 'viewer'); + + const { stdin, unmount } = await advanceToConfirm(); + + stdin.write('\r'); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write('\r'); + await tick(300); + stdin.write('\r'); + await tick(); + stdin.write('y'); + await tick(300); + + unmount(); + + expect(transferCalls).toHaveLength(0); + + }); + + }); + + describe('option wiring', () => { + + /** Walks an admin destination all the way through the plain confirm. */ + async function runTransfer(dryRun = false, toggleTruncate = false) { + + const { stdin, unmount } = await advanceToConfirm(dryRun); + + stdin.write('\r'); // tables + await tick(); + + // The options list opens on the truncate toggle row; Enter there + // flips it and stays put, so the cursor must then move down to a + // conflict strategy to advance. + if (toggleTruncate) { + + stdin.write('\r'); + await tick(); + + } + + stdin.write(DOWN); // move off the toggle onto 'Fail on conflict' + await tick(); + stdin.write('\r'); // conflict strategy + await tick(300); + stdin.write('\r'); // plan -> confirm + await tick(); + stdin.write('y'); // admin => plain Confirm + await tick(300); + + unmount(); + + expect(transferCalls).toHaveLength(1); + + return transferCalls[0]; + + } + + it('should pass dryRun: true when global dry-run is on', async () => { + + const options = await runTransfer(true); + + expect(options?.dryRun).toBe(true); + + }); + + it('should pass dryRun: false when global dry-run is off', async () => { + + const options = await runTransfer(false); + + expect(options?.dryRun).toBe(false); + + }); + + it('should default truncateFirst to false', async () => { + + const options = await runTransfer(); + + expect(options?.truncateFirst).toBe(false); + + }); + + it('should pass truncateFirst: true once the truncate option is toggled on', async () => { + + const options = await runTransfer(false, true); + + expect(options?.truncateFirst).toBe(true); + + }); + + }); + +}); From 003767e3597ee8e5f46304a4578f893c7bbab845 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:43:25 -0400 Subject: [PATCH 020/105] fix(change): fail a revert whose state cannot be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/core/change/executor.ts | 12 ++++++++ src/core/change/tracker.ts | 25 ++++++++++++++-- tests/core/change/manager.test.ts | 50 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/core/change/executor.ts b/src/core/change/executor.ts index ec36e662..64036825 100644 --- a/src/core/change/executor.ts +++ b/src/core/change/executor.ts @@ -315,6 +315,18 @@ export async function revertChange( if (!canRevertResult.canRevert) { + // A check that could not run is not a "nothing to do". + if (canRevertResult.error) { + + return createFailedResult( + change.name, + 'revert', + `Could not determine revert state: ${canRevertResult.error.message}`, + start, + ); + + } + if (canRevertResult.reason === 'not applied') { throw new ChangeNotAppliedError(change.name); diff --git a/src/core/change/tracker.ts b/src/core/change/tracker.ts index 09f41eef..bf31ce80 100644 --- a/src/core/change/tracker.ts +++ b/src/core/change/tracker.ts @@ -49,6 +49,23 @@ export interface CanRevertResult { /** Current status of the change */ status?: OperationStatus; + + /** + * Set when the change's state could not be established, as opposed to + * being established and answering "no". + * + * WHY: callers previously could not tell "this change is already + * reverted" from "the history table is unreadable", and treated both + * as a benign no-op — so a revert against a broken database reported + * success over an untouched schema. + * + * Covers an unrecognised status as well as an outright query failure. + * SQLite does not error on a missing column: a double-quoted + * identifier that resolves to nothing is re-read as a string literal, + * so a damaged tracking table yields a row whose status is garbage + * rather than a thrown error. + */ + error?: Error; } // ───────────────────────────────────────────────────────────── @@ -127,7 +144,7 @@ export class ChangeTracker extends Tracker { context: { name, operation: 'can-revert' }, }); - return { canRevert: false, reason: 'database error' }; + return { canRevert: false, reason: 'database error', error: err }; } @@ -161,7 +178,11 @@ export class ChangeTracker extends Tracker { return { canRevert: false, reason: 'schema was torn down', status: record.status }; default: - return { canRevert: false, reason: 'unknown status' }; + return { + canRevert: false, + reason: 'unknown status', + error: new Error(`Unrecognised change status "${record.status}" for "${name}"`), + }; } diff --git a/tests/core/change/manager.test.ts b/tests/core/change/manager.test.ts index 8991d04b..c6e382e2 100644 --- a/tests/core/change/manager.test.ts +++ b/tests/core/change/manager.test.ts @@ -7,6 +7,7 @@ * batch/mutation surface per QL-test-04's "at minimum" framing. */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { attempt } from '@logosdx/utils'; import { existsSync } from 'node:fs'; import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; import { tmpdir } from 'node:os'; @@ -475,6 +476,55 @@ describe('change: manager', () => { }); + describe('history read failures', () => { + + it('should not report a successful revert when the history read failed', async () => { + + await createTestChange( + 'unreadable', + [{ name: '001_create.sql', content: 'CREATE TABLE unreadable_target (id INTEGER PRIMARY KEY)' }], + [{ name: '001_drop.sql', content: 'DROP TABLE unreadable_target' }], + ); + + const manager = new ChangeManager(buildContext()); + + await manager.run('unreadable'); + + // Break reads only, leaving the table in place — the shape of a + // permissions change or a botched migration in the field. + await sql`ALTER TABLE __noorm_change__ RENAME COLUMN status TO status_x`.execute(db); + + const [result, err] = await attempt(() => manager.revert('unreadable')); + + // Either outcome is acceptable; silently claiming success is not. + expect(err ?? result?.status).not.toBe('success'); + expect(await tableExists('unreadable_target')).toBe(true); + + }); + + it('should not report a successful revert when the history table is gone', async () => { + + await createTestChange( + 'trackerless', + [{ name: '001_create.sql', content: 'CREATE TABLE trackerless_target (id INTEGER PRIMARY KEY)' }], + [{ name: '001_drop.sql', content: 'DROP TABLE trackerless_target' }], + ); + + const manager = new ChangeManager(buildContext()); + + await manager.run('trackerless'); + + await sql`DROP TABLE __noorm_change__`.execute(db); + + const [result, err] = await attempt(() => manager.revert('trackerless')); + + expect(err ?? result?.status).not.toBe('success'); + expect(await tableExists('trackerless_target')).toBe(true); + + }); + + }); + describe('recovery after teardown', () => { /** Reproduces what `db teardown` does to the tracking table. */ From f685f04a84604e34e6fc07ce493afad528c68e05 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:43:30 -0400 Subject: [PATCH 021/105] 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. --- src/core/worker-bridge/bridge.ts | 50 ++++++++- src/core/worker-bridge/order-buffer.ts | 23 ++++ src/core/worker-bridge/pending-set.ts | 83 +++++++++++++++ tests/core/worker-bridge/bridge.test.ts | 47 ++++++++ tests/core/worker-bridge/order-buffer.test.ts | 45 ++++++++ tests/core/worker-bridge/pending-set.test.ts | 100 ++++++++++++++++++ tests/fixtures/workers/dying.ts | 11 ++ tests/fixtures/workers/silent.ts | 7 ++ 8 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 src/core/worker-bridge/pending-set.ts create mode 100644 tests/core/worker-bridge/pending-set.test.ts create mode 100644 tests/fixtures/workers/dying.ts create mode 100644 tests/fixtures/workers/silent.ts diff --git a/src/core/worker-bridge/bridge.ts b/src/core/worker-bridge/bridge.ts index 55331d0d..106d4a53 100644 --- a/src/core/worker-bridge/bridge.ts +++ b/src/core/worker-bridge/bridge.ts @@ -10,6 +10,8 @@ export class WorkerBridge extends Obser #port: Port; #ownsWorker: boolean; + #deathError: Error | null = null; + #pendingRejects = new Set<(err: Error) => void>(); constructor(script?: string | URL) { @@ -27,6 +29,7 @@ export class WorkerBridge extends Obser if (!this.isShutdown && code !== 0) { this.receive('worker:exit', { code, error: `Worker exited with code ${code}` }, {} as Record); + this.#failPending(new Error(`Worker exited with code ${code}`)); } @@ -61,16 +64,59 @@ export class WorkerBridge extends Obser } + /** + * Reject every outstanding request and refuse new ones. + * + * A worker that has exited will never post a response, so `request()`'s + * `once()` would wait forever — there is no timeout and no other signal + * that the thread is gone. Without this, a single crashed compute worker + * wedges the export/import pipeline permanently. + */ + #failPending(err: Error): void { + + this.#deathError = err; + + for (const reject of this.#pendingRejects) { + + reject(err); + + } + + this.#pendingRejects.clear(); + + } + async request( event: K, data: TEvents[K], options?: { signal?: AbortSignal }, ): Promise extends keyof TEvents ? TEvents[ResKey] : unknown> { + if (this.#deathError) { + + throw this.#deathError; + + } + const cid = randomUUID(); const pending = this.once(new RegExp(`^${event}:res:${cid}$`), options); + + let onDeath!: (err: Error) => void; + const death = new Promise((_resolve, reject) => { + + onDeath = reject; + + }); + + this.#pendingRejects.add(onDeath); this.send(event, { ...data, __cid: cid }); - const { data: { data: result } } = await pending; + + // Losing the race is not an unhandled rejection: #failPending only + // rejects deferreds still in the set, and every settled request + // removes its own before returning. + const { data: { data: result } } = await Promise + .race([pending, death]) + .finally(() => this.#pendingRejects.delete(onDeath)); return result as ResKey extends keyof TEvents ? TEvents[ResKey] : unknown; @@ -88,6 +134,8 @@ export class WorkerBridge extends Obser override async shutdown(): Promise { super.shutdown(); + this.#failPending(new Error('WorkerBridge was shut down with requests in flight')); + if (this.#ownsWorker && 'terminate' in this.#port) { await this.#port.terminate(); diff --git a/src/core/worker-bridge/order-buffer.ts b/src/core/worker-bridge/order-buffer.ts index b626e27e..3d4460f8 100644 --- a/src/core/worker-bridge/order-buffer.ts +++ b/src/core/worker-bridge/order-buffer.ts @@ -22,8 +22,31 @@ export class OrderBuffer { } + /** + * Buffer an item at `index`, flushing every contiguous item from + * `nextIndex` onwards. + * + * Rejects indices that can never flush. A duplicate, negative or + * already-passed index would otherwise sit in the map forever and strand + * every later item behind it — silent, unbounded, and indistinguishable + * from a slow producer. + */ add(index: number, item: T): void { + if (!Number.isInteger(index) || index < this.#nextIndex) { + + throw new Error( + `OrderBuffer: index ${index} can never flush (next expected index is ${this.#nextIndex})`, + ); + + } + + if (this.#buffer.has(index)) { + + throw new Error(`OrderBuffer: duplicate index ${index}`); + + } + this.#buffer.set(index, item); this.#drain(); diff --git a/src/core/worker-bridge/pending-set.ts b/src/core/worker-bridge/pending-set.ts new file mode 100644 index 00000000..0ec7dd57 --- /dev/null +++ b/src/core/worker-bridge/pending-set.ts @@ -0,0 +1,83 @@ +/** + * Tracks in-flight worker requests so a pipeline can apply backpressure and + * drain on real promise settlement rather than on a counter. + * + * The pipelines used to spin on `while (inFlight > 0) await sleep(1)`, with + * `inFlight` decremented from a downstream callback. Any dispatch that failed + * before reaching that callback — a worker error, a rejected request, a dead + * thread — leaked a count and the loop never terminated. Settled promises + * cannot leak: a task that rejects still settles. + * + * @example + * ```typescript + * const inFlight = new PendingSet(); + * + * inFlight.track(pool.request('serialize', payload).then(onOk, onErr)); + * + * while (inFlight.size >= limit) await inFlight.settleAny(); + * + * await inFlight.settleAll(); + * ``` + */ +export class PendingSet { + + #pending = new Set>(); + + /** Number of tracked tasks that have not settled yet. */ + get size(): number { + + return this.#pending.size; + + } + + /** + * Track a dispatched task. + * + * Rejection is absorbed here, not ignored: callers attach their own + * handler before tracking, and swallowing a second time keeps `settleAny` + * and `settleAll` from turning a task failure into an unhandled rejection. + */ + track(task: Promise): void { + + const tracked: Promise = task + .then(() => undefined, () => undefined) + .finally(() => { + + this.#pending.delete(tracked); + + }); + + this.#pending.add(tracked); + + } + + /** + * Resolve once at least one tracked task has settled. + * + * Returns immediately when nothing is tracked, so a backpressure loop + * cannot block on an empty set. + */ + async settleAny(): Promise { + + if (this.#pending.size === 0) return; + + await Promise.race(this.#pending); + + } + + /** + * Resolve once every tracked task has settled. + * + * Loops because a task may be tracked while an earlier batch is draining. + */ + async settleAll(): Promise { + + while (this.#pending.size > 0) { + + await Promise.allSettled([...this.#pending]); + + } + + } + +} diff --git a/tests/core/worker-bridge/bridge.test.ts b/tests/core/worker-bridge/bridge.test.ts index f64cebb2..4e0fd43e 100644 --- a/tests/core/worker-bridge/bridge.test.ts +++ b/tests/core/worker-bridge/bridge.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect, afterEach } from 'bun:test'; import { resolve } from 'path'; +import { attempt } from '@logosdx/utils'; import { WorkerBridge } from '../../../src/core/worker-bridge/bridge.js'; const ECHO_WORKER = resolve(import.meta.dir, '../../fixtures/workers/echo.ts'); +const DYING_WORKER = resolve(import.meta.dir, '../../fixtures/workers/dying.ts'); +const SILENT_WORKER = resolve(import.meta.dir, '../../fixtures/workers/silent.ts'); interface EchoEvents { 'ping': { message: string } @@ -51,4 +54,48 @@ describe('worker-bridge: WorkerBridge', () => { }); + // A dead worker never posts a response. Without a rejection path the + // caller's await never settles, which is what wedged the .dt pipelines + // forever on a single crashed compute thread. + it('should reject an in-flight request when the worker dies', async () => { + + bridge = new WorkerBridge(DYING_WORKER); + + const [result, err] = await attempt(() => bridge.request('ping', { message: 'hello' })); + + expect(result).toBeNull(); + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/exited with code 7/); + + }); + + it('should reject new requests made after the worker died', async () => { + + bridge = new WorkerBridge(DYING_WORKER); + + await attempt(() => bridge.request('ping', { message: 'first' })); + + const [result, err] = await attempt(() => bridge.request('ping', { message: 'second' })); + + expect(result).toBeNull(); + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/exited with code 7/); + + }); + + it('should reject in-flight requests on shutdown', async () => { + + bridge = new WorkerBridge(SILENT_WORKER); + + const pending = attempt(() => bridge.request('ping', { message: 'x' })); + + await bridge.shutdown(); + + const [result, err] = await pending; + + expect(result).toBeNull(); + expect(err).toBeInstanceOf(Error); + + }); + }); diff --git a/tests/core/worker-bridge/order-buffer.test.ts b/tests/core/worker-bridge/order-buffer.test.ts index 7fe0a7a2..01a1535f 100644 --- a/tests/core/worker-bridge/order-buffer.test.ts +++ b/tests/core/worker-bridge/order-buffer.test.ts @@ -62,6 +62,51 @@ describe('worker-bridge: OrderBuffer', () => { }); + // A duplicate, negative or already-flushed index can never drain. Left + // in the map it silently strands every later item behind it, which is + // the memory half of the pipeline hang. + it('should reject a duplicate index rather than strand later items', () => { + + const flushed: string[] = []; + const buffer = new OrderBuffer(item => { + + flushed.push(item); + + }); + + buffer.add(1, 'b'); + + expect(() => buffer.add(1, 'B')).toThrow('duplicate index 1'); + expect(buffer.pending).toBe(1); + + }); + + it('should reject an index that has already been flushed', () => { + + const buffer = new OrderBuffer(() => {}); + + buffer.add(0, 'a'); + + expect(() => buffer.add(0, 'A')).toThrow('can never flush'); + + }); + + it('should reject a negative index', () => { + + const buffer = new OrderBuffer(() => {}); + + expect(() => buffer.add(-1, 'x')).toThrow('can never flush'); + + }); + + it('should reject a non-integer index', () => { + + const buffer = new OrderBuffer(() => {}); + + expect(() => buffer.add(1.5, 'x')).toThrow('can never flush'); + + }); + it('should handle large gaps', () => { const flushed: number[] = []; diff --git a/tests/core/worker-bridge/pending-set.test.ts b/tests/core/worker-bridge/pending-set.test.ts new file mode 100644 index 00000000..97b9190b --- /dev/null +++ b/tests/core/worker-bridge/pending-set.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'bun:test'; +import { PendingSet } from '../../../src/core/worker-bridge/pending-set.js'; + +/** + * Resolve after `ms`, used to order assertions against real settlement + * rather than a counter. + */ +function delay(ms: number): Promise { + + return new Promise((resolve) => setTimeout(resolve, ms)); + +} + +describe('worker-bridge: PendingSet', () => { + + it('should report the number of unsettled tasks', async () => { + + const set = new PendingSet(); + + set.track(delay(5)); + set.track(delay(5)); + + expect(set.size).toBe(2); + + await set.settleAll(); + + expect(set.size).toBe(0); + + }); + + // The bug this class replaces: a task that failed before reaching the + // downstream callback leaked its count and the drain loop spun forever. + it('should settle a rejected task instead of leaking it', async () => { + + const set = new PendingSet(); + + set.track(Promise.reject(new Error('worker died'))); + + await set.settleAll(); + + expect(set.size).toBe(0); + + }); + + it('should settle a mix of resolved and rejected tasks', async () => { + + const set = new PendingSet(); + + set.track(delay(1)); + set.track(Promise.reject(new Error('boom'))); + set.track(delay(3)); + + await set.settleAll(); + + expect(set.size).toBe(0); + + }); + + it('should return from settleAny once one task settles', async () => { + + const set = new PendingSet(); + + set.track(delay(1)); + set.track(delay(200)); + + await set.settleAny(); + + // The slow task is still outstanding — settleAny is a backpressure + // release, not a drain. + expect(set.size).toBe(1); + + }); + + it('should return immediately from settleAny when nothing is tracked', async () => { + + const set = new PendingSet(); + + await set.settleAny(); + + expect(set.size).toBe(0); + + }); + + it('should drain tasks added while an earlier batch is settling', async () => { + + const set = new PendingSet(); + + set.track(delay(1).then(() => { + + set.track(delay(5)); + + })); + + await set.settleAll(); + + expect(set.size).toBe(0); + + }); + +}); diff --git a/tests/fixtures/workers/dying.ts b/tests/fixtures/workers/dying.ts new file mode 100644 index 00000000..1d3b85e3 --- /dev/null +++ b/tests/fixtures/workers/dying.ts @@ -0,0 +1,11 @@ +import { parentPort } from 'worker_threads'; + +if (!parentPort) throw new Error('Not in worker'); + +// Accepts a request and then kills the thread without ever answering. +// Models a compute worker that OOMs or crashes mid-serialization. +parentPort.on('message', () => { + + process.exit(7); + +}); diff --git a/tests/fixtures/workers/silent.ts b/tests/fixtures/workers/silent.ts new file mode 100644 index 00000000..65438aa0 --- /dev/null +++ b/tests/fixtures/workers/silent.ts @@ -0,0 +1,7 @@ +import { parentPort } from 'worker_threads'; + +if (!parentPort) throw new Error('Not in worker'); + +// Accepts messages and never answers. Models a worker wedged in a long or +// hung computation — the caller must not wait on it indefinitely. +parentPort.on('message', () => {}); From 8e092cb20f5c353858a9559be265e1c0cb79b98a Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:43:37 -0400 Subject: [PATCH 022/105] 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. --- src/core/dt/index.ts | 250 +++++++++--------------- src/core/dt/paging.ts | 213 ++++++++++++++++++++ src/core/dt/schema.ts | 134 +++++++++++++ src/core/transfer/executor.ts | 108 +++-------- tests/core/transfer/pagination.test.ts | 259 +++++++++++++++++++++++++ 5 files changed, 728 insertions(+), 236 deletions(-) create mode 100644 src/core/dt/paging.ts create mode 100644 tests/core/transfer/pagination.test.ts diff --git a/src/core/dt/index.ts b/src/core/dt/index.ts index cd03bf12..b9b64654 100644 --- a/src/core/dt/index.ts +++ b/src/core/dt/index.ts @@ -50,11 +50,13 @@ import { observer } from '../observer.js'; import { WorkerBridge } from '../worker-bridge/bridge.js'; import { WorkerPool } from '../worker-bridge/pool.js'; import { OrderBuffer } from '../worker-bridge/order-buffer.js'; +import { PendingSet } from '../worker-bridge/pending-set.js'; import { resolveWorker } from '../worker-bridge/paths.js'; import { DtWriter } from './writer.js'; import { DtReader } from './reader.js'; import { DtStreamer } from './streamer.js'; -import { buildDtSchema, validateSchema } from './schema.js'; +import { createKeysetPager } from './paging.js'; +import { buildDtSchema, validateSchema, queryPrimaryKeyColumns } from './schema.js'; const COMPUTE_WORKER = resolveWorker('compute'); @@ -140,6 +142,18 @@ export async function exportTable( // this file) is the rebuild recipe if a real caller shows up. const computePool = createDefaultComputePool(); + // Pages are keyed on the primary key — see core/dt/paging.ts for why an + // OFFSET walk is not safe here. + const [keyColumns, pkErr] = await queryPrimaryKeyColumns(kyselyDb, dialect, tableName, schemaName); + + if (pkErr) { + + await attempt(() => computePool.shutdown()); + + return [null, pkErr]; + + } + // Worker pipeline: offload fetching and serialization to worker threads const [result, workerErr] = await exportTableWithWorkers({ writer, @@ -147,6 +161,7 @@ export async function exportTable( tableName, filepath, dialect, + keyColumns, batchSize, computePool, kyselyDb, @@ -174,23 +189,11 @@ export async function exportTable( } -/** - * Build a dialect-appropriate SQL identifier quoting function. - */ -function getQuoteIdent(dialect: string): (c: string) => string { - - if (dialect === 'mssql') return (c: string) => `[${c}]`; - if (dialect === 'mysql') return (c: string) => `\`${c}\``; - - return (c: string) => `"${c}"`; - -} - /** * Worker-based export pipeline — offload fetching and serialization to worker threads. * * Uses a three-stage pipeline: - * 1. Connection worker fetches batches via paginated SQL queries + * 1. A keyset pager fetches batches in primary key order * 2. Compute pool serializes individual rows in parallel * 3. OrderBuffer reassembles rows in order and writes to DtWriter * @@ -201,13 +204,14 @@ async function exportTableWithWorkers(ctx: { dtSchema: DtSchema; tableName: string; filepath: string; - dialect: string; + dialect: Dialect; + keyColumns: string[]; batchSize: number; computePool: WorkerPool; kyselyDb: Kysely; }): Promise<[{ rowsWritten: number; bytesWritten: number } | null, Error | null]> { - const { writer, dtSchema, tableName, filepath, dialect, batchSize } = ctx; + const { writer, dtSchema, tableName, filepath, dialect, keyColumns, batchSize } = ctx; const { computePool, kyselyDb } = ctx; const backpressureLimit = batchSize * 3; @@ -216,15 +220,20 @@ async function exportTableWithWorkers(ctx: { // --- Stage 1-3: Fetch → Serialize → Write --- const columns = dtSchema.columns.map((c) => c.name); - const quoteIdent = getQuoteIdent(dialect); - const columnList = columns.map(quoteIdent).join(', '); - const orderCol = quoteIdent(columns[0]!); + + const pager = createKeysetPager({ + db: kyselyDb, + dialect, + table: tableName, + columns, + keyColumns, + batchSize, + }); let globalIndex = 0; let loaded = 0; let processed = 0; let saved = 0; - let inFlight = 0; let pipelineError: Error | null = null; // OrderBuffer: flush in-order to writer @@ -232,47 +241,26 @@ async function exportTableWithWorkers(ctx: { writer.writeRow(values); saved++; - inFlight--; observer.emit('dt:export:saved', { table: tableName, saved, totalRows }); }); - // Fetch loop - let offset = 0; + const inFlight = new PendingSet(); while (true) { - // Backpressure: wait until in-flight drops below limit - while (inFlight >= backpressureLimit) { + // Backpressure: wait until in-flight drops below limit. Bails on a + // pipeline error so a dead worker cannot stall the producer here. + while (inFlight.size >= backpressureLimit && !pipelineError) { - await new Promise((resolve) => setTimeout(resolve, 1)); + await inFlight.settleAny(); } - // Fetch a batch - const [rows, fetchErr] = await attempt(() => { - - if (dialect === 'mssql') { - - return sql>` - SELECT ${sql.raw(columnList)} - FROM ${sql.table(tableName)} - ORDER BY ${sql.raw(orderCol)} - OFFSET ${offset} ROWS - FETCH NEXT ${batchSize} ROWS ONLY - `.execute(kyselyDb); - - } - - return sql>` - SELECT ${sql.raw(columnList)} - FROM ${sql.table(tableName)} - LIMIT ${batchSize} - OFFSET ${offset} - `.execute(kyselyDb); + if (pipelineError) break; - }); + const [batchRows, fetchErr] = await attempt(() => pager.next()); if (fetchErr) { @@ -281,8 +269,6 @@ async function exportTableWithWorkers(ctx: { } - const batchRows = rows.rows; - if (batchRows.length === 0) break; loaded += batchRows.length; @@ -293,34 +279,38 @@ async function exportTableWithWorkers(ctx: { for (const row of batchRows) { const rowIndex = globalIndex++; - inFlight++; - // Fire-and-forget — result handled asynchronously - computePool.request('serialize', { - row, - columns: dtSchema.columns, - index: rowIndex, - }).then((result) => { + inFlight.track( + computePool.request('serialize', { + row, + columns: dtSchema.columns, + index: rowIndex, + }).then((result) => { - processed++; + processed++; - observer.emit('dt:export:processed', { table: tableName, processed, totalRows }); + observer.emit('dt:export:processed', { table: tableName, processed, totalRows }); - if (result.error) { + if (result.error) { - pipelineError = pipelineError ?? new Error(result.error); + pipelineError = pipelineError ?? new Error(result.error); - return; + return; - } + } - orderBuffer.add(result.index, result.values); + orderBuffer.add(result.index, result.values); - }); + }).catch((err: Error) => { - } + // Covers a rejected request (dead worker) and a rejected + // OrderBuffer.add — neither used to reach the pipeline. + pipelineError = pipelineError ?? err; + + }), + ); - offset += batchRows.length; + } observer.emit('dt:export:progress', { filepath, @@ -329,16 +319,10 @@ async function exportTableWithWorkers(ctx: { bytesWritten: writer.bytesWritten, }); - if (batchRows.length < batchSize) break; - } // Wait for all in-flight compute to drain - while (inFlight > 0) { - - await new Promise((resolve) => setTimeout(resolve, 1)); - - } + await inFlight.settleAll(); if (pipelineError) { @@ -565,7 +549,6 @@ async function importFileWithWorkers(ctx: { let processed = 0; let saved = 0; let globalIndex = 0; - let inFlight = 0; let pipelineError: Error | null = null; // Count total rows by iterating (we'll re-read). For .dt files, @@ -619,31 +602,28 @@ async function importFileWithWorkers(ctx: { insertBatch.push(record); saved++; - inFlight--; observer.emit('dt:import:saved', { table: tableName, saved, totalRows }); }); - // Read rows in batches from DtReader - let readBatch: DtValue[][] = []; - - for await (const values of reader.rows()) { + const inFlight = new PendingSet(); - readBatch.push(values); - loaded++; - totalRows = Math.max(totalRows, loaded); - - if (readBatch.length >= batchSize) { + /** + * Deserialize one read batch in the compute pool, then insert it. + * + * Returns the first pipeline error rather than throwing so both call + * sites drain identically. + */ + const processReadBatch = async (batch: DtValue[][]): Promise => { - observer.emit('dt:import:loaded', { table: tableName, loaded, totalRows }); + observer.emit('dt:import:loaded', { table: tableName, loaded, totalRows }); - // Dispatch batch to compute pool - for (const rowValues of readBatch) { + for (const rowValues of batch) { - const rowIndex = globalIndex++; - inFlight++; + const rowIndex = globalIndex++; + inFlight.track( computePool.request('deserialize', { values: rowValues, columns: dtSchema.columns, @@ -666,93 +646,57 @@ async function importFileWithWorkers(ctx: { orderBuffer.add(result.index, result.record); - }); - - } - - readBatch = []; - - // Wait for all in-flight to drain before reading next batch - while (inFlight > 0) { - - await new Promise((resolve) => setTimeout(resolve, 1)); - - } - - if (pipelineError) { - - return [null, pipelineError]; + }).catch((err: Error) => { - } - - // Flush accumulated insert batch - const flushErr = await flushInsertBatch(); - - if (flushErr) { + // Covers a rejected request (dead worker) and a rejected + // OrderBuffer.add — neither used to reach the pipeline. + pipelineError = pipelineError ?? err; - return [null, flushErr]; - - } + }), + ); } - } + await inFlight.settleAll(); - // Process remaining rows - if (readBatch.length > 0) { - - observer.emit('dt:import:loaded', { table: tableName, loaded, totalRows }); - - for (const rowValues of readBatch) { - - const rowIndex = globalIndex++; - inFlight++; + if (pipelineError) return pipelineError; - computePool.request('deserialize', { - values: rowValues, - columns: dtSchema.columns, - targetDialect: dialect, - targetVersion: version ? `${version.major}.${version.minor}` : undefined, - index: rowIndex, - }).then((result) => { + return flushInsertBatch(); - processed++; - - observer.emit('dt:import:processed', { table: tableName, processed, totalRows }); + }; - if (result.error) { + // Read rows in batches from DtReader + let readBatch: DtValue[][] = []; - pipelineError = pipelineError ?? new Error(result.error); + for await (const values of reader.rows()) { - return; + readBatch.push(values); + loaded++; + totalRows = Math.max(totalRows, loaded); - } + if (readBatch.length >= batchSize) { - orderBuffer.add(result.index, result.record); + const batchErr = await processReadBatch(readBatch); - }); + readBatch = []; - } + if (batchErr) { - // Wait for drain - while (inFlight > 0) { + return [null, batchErr]; - await new Promise((resolve) => setTimeout(resolve, 1)); + } } - if (pipelineError) { - - return [null, pipelineError]; + } - } + if (readBatch.length > 0) { - // Flush remaining - const flushErr = await flushInsertBatch(); + const batchErr = await processReadBatch(readBatch); - if (flushErr) { + if (batchErr) { - return [null, flushErr]; + return [null, batchErr]; } diff --git a/src/core/dt/paging.ts b/src/core/dt/paging.ts new file mode 100644 index 00000000..686e55f3 --- /dev/null +++ b/src/core/dt/paging.ts @@ -0,0 +1,213 @@ +/** + * Stable pagination for bulk reads, shared by the .dt export pipeline and the + * transfer executor. + * + * Both used `LIMIT n OFFSET m` with no `ORDER BY`. No engine guarantees row + * order across two such statements, so any write to the source between pages + * shifts the window: rows are skipped and rows are read twice, and because the + * caller counts what it received rather than what exists, the operation still + * reports the full row count. A 50k-row export under concurrent `UPDATE` + * measured 14,149 rows missing and 13,387 duplicated while reporting success. + * + * Keyset pagination fixes the ordering *and* the window: each page asks for + * rows strictly after the last key seen, so a concurrent update cannot move a + * row across the cursor. + * + * @example + * ```typescript + * const pager = createKeysetPager({ + * db, dialect: 'postgres', table: 'users', + * columns: ['id', 'email'], keyColumns: ['id'], batchSize: 1000, + * }); + * + * while (true) { + * const rows = await pager.next(); + * if (rows.length === 0) break; + * // ... + * } + * ``` + */ +import { sql } from 'kysely'; + +import type { Kysely, RawBuilder } from 'kysely'; +import type { NoormDatabase } from '../shared/tables.js'; +import type { Dialect } from '../connection/types.js'; + +/** + * Options for {@link createKeysetPager}. + */ +export interface KeysetPagerOptions { + + db: Kysely; + + dialect: Dialect; + + /** Table to read from. */ + table: string; + + /** Columns to select, in .dt / plan order. */ + columns: string[]; + + /** + * Primary key columns, in key order. Empty means the table has no primary + * key — see {@link createKeysetPager} for what happens then. + */ + keyColumns: string[]; + + /** Rows per page. Ignored for a key-less table. */ + batchSize: number; + +} + +/** + * A cursor over a table that yields each row exactly once. + */ +export interface KeysetPager { + + /** Fetch the next page. Returns `[]` once the table is exhausted. */ + next(): Promise[]>; + +} + +/** + * Quote an identifier for the given dialect. + */ +function getQuoteIdent(dialect: string): (c: string) => string { + + if (dialect === 'mssql') return (c: string) => `[${c.replace(/]/g, ']]')}]`; + if (dialect === 'mysql') return (c: string) => `\`${c.replace(/`/g, '``')}\``; + + return (c: string) => `"${c.replace(/"/g, '""')}"`; + +} + +/** + * Build the `WHERE` predicate that selects rows strictly after `cursor`. + * + * Expanded to an OR-chain of equality prefixes rather than a row-value + * comparison (`(a, b) > (x, y)`) because MSSQL has no row constructor. + * Values are bound, never interpolated. + */ +function buildCursorPredicate( + keyColumns: string[], + cursor: unknown[], + quoteIdent: (c: string) => string, +): RawBuilder { + + const clauses = keyColumns.map((_col, i) => { + + const conjuncts: RawBuilder[] = []; + + for (let j = 0; j < i; j++) { + + conjuncts.push(sql`${sql.raw(quoteIdent(keyColumns[j]!))} = ${cursor[j]}`); + + } + + conjuncts.push(sql`${sql.raw(quoteIdent(keyColumns[i]!))} > ${cursor[i]}`); + + return sql`(${sql.join(conjuncts, sql` AND `)})`; + + }); + + return sql`(${sql.join(clauses, sql` OR `)})`; + +} + +/** + * Create a pager over `table`. + * + * With a primary key, pages are keyset-ordered and stable under concurrent + * writes. Without one there is no stable cursor, so the pager falls back to a + * single unpaginated `SELECT`: one statement is one consistent snapshot on + * every supported engine, where paging would silently drop and duplicate rows. + * The cost is that a key-less table is read entirely into memory — a bounded, + * visible cost, unlike silent corruption. + */ +export function createKeysetPager(options: KeysetPagerOptions): KeysetPager { + + const { db, dialect, table, columns, keyColumns, batchSize } = options; + + const quoteIdent = getQuoteIdent(dialect); + const columnList = columns.map(quoteIdent).join(', '); + const orderList = keyColumns.map(quoteIdent).join(', '); + + let cursor: unknown[] | null = null; + let exhausted = false; + + /** + * Read every row in one statement. Used when the table has no primary key. + */ + const readAll = async (): Promise[]> => { + + const result = await sql>` + SELECT ${sql.raw(columnList)} + FROM ${sql.table(table)} + `.execute(db); + + return result.rows; + + }; + + /** + * Read one keyset page starting strictly after the current cursor. + */ + const readPage = async (): Promise[]> => { + + const where = cursor + ? sql`WHERE ${buildCursorPredicate(keyColumns, cursor, quoteIdent)}` + : sql``; + + const limit = dialect === 'mssql' + ? sql`OFFSET 0 ROWS FETCH NEXT ${batchSize} ROWS ONLY` + : sql`LIMIT ${batchSize}`; + + const result = await sql>` + SELECT ${sql.raw(columnList)} + FROM ${sql.table(table)} + ${where} + ORDER BY ${sql.raw(orderList)} + ${limit} + `.execute(db); + + const last = result.rows[result.rows.length - 1]; + + if (last) { + + cursor = keyColumns.map((col) => last[col]); + + } + + return result.rows; + + }; + + return { + + async next(): Promise[]> { + + if (exhausted) return []; + + if (keyColumns.length === 0) { + + exhausted = true; + + return readAll(); + + } + + const rows = await readPage(); + + if (rows.length < batchSize) { + + exhausted = true; + + } + + return rows; + + }, + + }; + +} diff --git a/src/core/dt/schema.ts b/src/core/dt/schema.ts index c88295bf..9518a605 100644 --- a/src/core/dt/schema.ts +++ b/src/core/dt/schema.ts @@ -223,6 +223,140 @@ export async function validateSchema( // Internal helpers // --------------------------------------------------------------------------- +/** + * Query the primary key columns of a table, in key order. + * + * Export pages by primary key: without one there is no stable cursor and + * pagination silently drops and duplicates rows under concurrent writes. + * An empty result means the table genuinely has no primary key, which the + * pager handles by reading it in a single statement. + * + * @example + * ```typescript + * const [pk, err] = await queryPrimaryKeyColumns(db, 'postgres', 'users', 'public'); + * // ['id'] + * ``` + */ +export async function queryPrimaryKeyColumns( + db: Kysely, + dialect: Dialect, + tableName: string, + schema?: string, +): Promise<[string[], Error | null]> { + + switch (dialect) { + + case 'postgres': + return queryPostgresPrimaryKey(db, tableName, schema ?? 'public'); + + case 'mysql': + return queryMysqlPrimaryKey(db, tableName); + + case 'mssql': + return queryMssqlPrimaryKey(db, tableName); + + default: + return [[], null]; + + } + +} + +/** + * Query PostgreSQL primary key columns. + */ +async function queryPostgresPrimaryKey( + db: Kysely, + tableName: string, + schema: string, +): Promise<[string[], Error | null]> { + + const [result, err] = await attempt(() => + sql<{ column_name: string }>` + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = ${schema} + AND tc.table_name = ${tableName} + ORDER BY kcu.ordinal_position + `.execute(db), + ); + + if (err) { + + return [[], err]; + + } + + return [result.rows.map((r) => r.column_name), null]; + +} + +/** + * Query MySQL primary key columns. + */ +async function queryMysqlPrimaryKey( + db: Kysely, + tableName: string, +): Promise<[string[], Error | null]> { + + const [result, err] = await attempt(() => + sql<{ column_name: string }>` + SELECT COLUMN_NAME as column_name + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ${tableName} + AND CONSTRAINT_NAME = 'PRIMARY' + ORDER BY ORDINAL_POSITION + `.execute(db), + ); + + if (err) { + + return [[], err]; + + } + + return [result.rows.map((r) => r.column_name), null]; + +} + +/** + * Query MSSQL primary key columns. + */ +async function queryMssqlPrimaryKey( + db: Kysely, + tableName: string, +): Promise<[string[], Error | null]> { + + const [result, err] = await attempt(() => + sql<{ column_name: string }>` + SELECT c.name as column_name + FROM sys.indexes i + JOIN sys.index_columns ic + ON i.object_id = ic.object_id AND i.index_id = ic.index_id + JOIN sys.columns c + ON ic.object_id = c.object_id AND ic.column_id = c.column_id + JOIN sys.tables t ON i.object_id = t.object_id + WHERE i.is_primary_key = 1 + AND t.name = ${tableName} + ORDER BY ic.key_ordinal + `.execute(db), + ); + + if (err) { + + return [[], err]; + + } + + return [result.rows.map((r) => r.column_name), null]; + +} + /** * Column metadata from information_schema queries. */ diff --git a/src/core/transfer/executor.ts b/src/core/transfer/executor.ts index f866455d..0e77904c 100644 --- a/src/core/transfer/executor.ts +++ b/src/core/transfer/executor.ts @@ -21,9 +21,12 @@ import type { ConflictStrategy, } from './types.js'; +import type { KeysetPager } from '../dt/paging.js'; + import { observer } from '../observer.js'; import { getTransferOperations } from './dialects/index.js'; import { DtStreamer } from '../dt/streamer.js'; +import { createKeysetPager } from '../dt/paging.js'; import { queryDatabaseVersion } from '../dt/version.js'; /** @@ -405,24 +408,14 @@ async function transferTableCrossServer( let rowsTransferred = 0; let rowsSkipped = 0; - let offset = 0; let transferError: Error | null = null; + const cursor = openSourceCursor(ctx, plan, batchSize); // Fetch and insert in batches while (true) { // Fetch batch from source - const [rows, fetchErr] = await attempt(() => - fetchBatch( - ctx.source.db, - ctx.source.dialect, - plan.name, - plan.columns, - batchSize, - offset, - plan.schema, - ), - ); + const [rows, fetchErr] = await attempt(() => cursor.next()); if (fetchErr) { @@ -458,7 +451,6 @@ async function transferTableCrossServer( rowsTransferred += batchResult.inserted; rowsSkipped += batchResult.skipped; - offset += rows.length; observer.emit('transfer:table:progress', { table: plan.name, @@ -467,13 +459,6 @@ async function transferTableCrossServer( rowsSkipped, }); - // Check if we got fewer rows than batch size (end of data) - if (rows.length < batchSize) { - - break; - - } - } // Cleanup: disable identity insert (best effort) @@ -594,8 +579,8 @@ async function transferTableCrossDialect( let rowsTransferred = 0; let rowsSkipped = 0; - let offset = 0; let transferError: Error | null = null; + const cursor = openSourceCursor(ctx, plan, batchSize); observer.emit('dt:stream:start', { table: plan.name, @@ -606,17 +591,7 @@ async function transferTableCrossDialect( while (true) { // Fetch batch from source - const [rows, fetchErr] = await attempt(() => - fetchBatch( - ctx.source.db, - ctx.source.dialect, - plan.name, - plan.columns, - batchSize, - offset, - plan.schema, - ), - ); + const [rows, fetchErr] = await attempt(() => cursor.next()); if (fetchErr) { @@ -670,8 +645,6 @@ async function transferTableCrossDialect( if (transferError) break; - offset += rows.length; - observer.emit('transfer:table:progress', { table: plan.name, rowsTransferred, @@ -684,8 +657,6 @@ async function transferTableCrossDialect( rowsConverted: rowsTransferred + rowsSkipped, }); - if (rows.length < batchSize) break; - } // Cleanup: disable identity insert @@ -726,56 +697,27 @@ async function transferTableCrossDialect( } /** - * Fetch a batch of rows from source table. + * Open a stable cursor over a source table. * - * Uses dialect-specific syntax for column quoting and pagination. + * Pages by primary key rather than `OFFSET`: an `OFFSET` walk with no total + * order silently drops and duplicates rows whenever the source is written to + * mid-transfer, while still reporting the full row count. See + * `core/dt/paging.ts`. */ -async function fetchBatch( - db: Kysely, - dialect: Dialect, - table: string, - columns: string[], - limit: number, - offset: number, - _schema?: string, -): Promise[]> { - - // Quote column names based on dialect - const quoteIdent = dialect === 'mssql' - ? (c: string) => `[${c}]` - : dialect === 'mysql' - ? (c: string) => `\`${c}\`` - : (c: string) => `"${c}"`; - - const columnList = columns.map(quoteIdent).join(', '); - - // MSSQL uses different pagination syntax - if (dialect === 'mssql') { - - // MSSQL requires ORDER BY for OFFSET/FETCH - // Use first column as default order (usually PK) - const orderCol = quoteIdent(columns[0]!); - const result = await sql>` - SELECT ${sql.raw(columnList)} - FROM ${sql.table(table)} - ORDER BY ${sql.raw(orderCol)} - OFFSET ${offset} ROWS - FETCH NEXT ${limit} ROWS ONLY - `.execute(db); - - return result.rows; - - } - - // PostgreSQL and MySQL use LIMIT/OFFSET - const result = await sql>` - SELECT ${sql.raw(columnList)} - FROM ${sql.table(table)} - LIMIT ${limit} - OFFSET ${offset} - `.execute(db); +function openSourceCursor( + ctx: DualConnectionContext, + plan: TransferTablePlan, + batchSize: number, +): KeysetPager { - return result.rows; + return createKeysetPager({ + db: ctx.source.db, + dialect: ctx.source.dialect, + table: plan.name, + columns: plan.columns, + keyColumns: plan.primaryKey, + batchSize, + }); } diff --git a/tests/core/transfer/pagination.test.ts b/tests/core/transfer/pagination.test.ts new file mode 100644 index 00000000..6a3dafb1 --- /dev/null +++ b/tests/core/transfer/pagination.test.ts @@ -0,0 +1,259 @@ +/** + * Pagination stability tests. + * + * Export and db-to-db transfer both paged with `LIMIT/OFFSET` and no + * `ORDER BY`. No engine guarantees row order across two such statements, so a + * write to the source between pages shifted the window and rows were silently + * dropped and duplicated — while the operation reported the full row count. + * + * These tests therefore assert **set equality against what actually landed**, + * never counts: every bug in this area preserved the count exactly. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import path from 'node:path'; +import { sql } from 'kysely'; + +import type { Kysely } from 'kysely'; + +import { exportTable } from '../../../src/core/dt/index.js'; +import { DtReader } from '../../../src/core/dt/reader.js'; +import { transferData } from '../../../src/core/transfer/index.js'; +import { + createTestConnection, + skipIfNoContainer, + makeTestConfig, + TEST_CONNECTIONS, +} from '../../utils/db.js'; +import { createConnection } from '../../../src/core/connection/factory.js'; + +const TMP_DIR = path.join(process.cwd(), 'tmp'); +const ROW_COUNT = 4000; +const BATCH_SIZE = 100; + +/** Wide-ish payload so an UPDATE relocates the tuple instead of updating in place. */ +const PAYLOAD = 'x'.repeat(300); + +/** + * Rewrite large contiguous blocks of the table until `stop()` is called. + * + * This is the condition the old pager corrupted under: rows move in the heap, + * so the next OFFSET window no longer lines up with the previous one. + */ +function startChurn(db: Kysely, table: string): { stop: () => Promise } { + + let running = true; + + const loop = (async () => { + + while (running) { + + const from = Math.floor(Math.random() * ROW_COUNT) + 1; + const to = Math.min(from + 800, ROW_COUNT); + + await sql + .raw(`UPDATE ${table} SET payload = payload || 'y' WHERE id BETWEEN ${from} AND ${to}`) + .execute(db) + .catch(() => undefined); + + } + + })(); + + return { + async stop() { + + running = false; + await loop; + + }, + }; + +} + +describe('transfer: pagination under concurrent writes', () => { + + let sourceDb: Kysely; + let destDb: Kysely; + let churnDb: Kysely; + let sourceDestroy: () => Promise; + let destDestroy: () => Promise; + let churnDestroy: () => Promise; + let testDir: string; + + const sourceConfig = makeTestConfig('pagination_source', { ...TEST_CONNECTIONS.postgres }); + const destConfig = makeTestConfig('pagination_dest', { + ...TEST_CONNECTIONS.postgres, + database: process.env['TEST_POSTGRES_DATABASE_DEST'] ?? 'noorm_test_dest', + }); + + beforeAll(async () => { + + await skipIfNoContainer('postgres'); + + const conn = await createTestConnection('postgres'); + sourceDb = conn.db; + sourceDestroy = conn.destroy; + + // A separate connection so the churn genuinely runs alongside the + // read, not behind it in the same pool slot. + const churnConn = await createConnection(TEST_CONNECTIONS.postgres, 'pagination_churn'); + churnDb = churnConn.db; + churnDestroy = churnConn.destroy; + + const destConn = await createConnection(destConfig.connection, 'pagination_dest'); + destDb = destConn.db; + destDestroy = destConn.destroy; + + for (const db of [sourceDb, destDb]) { + + await sql.raw('DROP TABLE IF EXISTS pagerace').execute(db); + await sql.raw('DROP TABLE IF EXISTS pagerace_nopk').execute(db); + await sql.raw('CREATE TABLE pagerace (id int PRIMARY KEY, payload text NOT NULL)').execute(db); + await sql.raw('CREATE TABLE pagerace_nopk (id int NOT NULL, payload text NOT NULL)').execute(db); + + } + + const values = Array.from( + { length: ROW_COUNT }, + (_, i) => `(${i + 1}, '${PAYLOAD}')`, + ).join(', '); + + await sql.raw(`INSERT INTO pagerace (id, payload) VALUES ${values}`).execute(sourceDb); + await sql.raw(`INSERT INTO pagerace_nopk (id, payload) VALUES ${values}`).execute(sourceDb); + + testDir = path.join(TMP_DIR, `test-pagination-${randomBytes(4).toString('hex')}`); + mkdirSync(testDir, { recursive: true }); + + }); + + afterAll(async () => { + + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + + for (const db of [sourceDb, destDb]) { + + if (!db) continue; + + await sql.raw('DROP TABLE IF EXISTS pagerace').execute(db).catch(() => undefined); + await sql.raw('DROP TABLE IF EXISTS pagerace_nopk').execute(db).catch(() => undefined); + + } + + if (churnDestroy) await churnDestroy(); + if (destDestroy) await destDestroy(); + if (sourceDestroy) await sourceDestroy(); + + }); + + /** + * Collect the `id` of every row written to a .dt file. + */ + async function readExportedIds(filepath: string): Promise { + + const reader = new DtReader({ filepath }); + await reader.open(); + + const columns = reader.schema!.columns.map((c) => c.name); + const idIndex = columns.indexOf('id'); + const ids: number[] = []; + + for await (const values of reader.rows()) { + + ids.push(Number(values[idIndex])); + + } + + reader.close(); + + return ids; + + } + + it('should export every row exactly once while the source is being rewritten', async () => { + + const filepath = path.join(testDir, 'pagerace.dt'); + const churn = startChurn(churnDb, 'pagerace'); + + const [result, err] = await exportTable({ + db: sourceDb, + dialect: 'postgres', + tableName: 'pagerace', + filepath, + batchSize: BATCH_SIZE, + }); + + await churn.stop(); + + expect(err).toBeNull(); + + const ids = await readExportedIds(filepath); + const unique = new Set(ids); + + // Not `ids.length === ROW_COUNT`: the broken pager produced exactly + // that while missing thousands of ids and repeating thousands more. + expect(unique.size).toBe(ROW_COUNT); + expect(ids.length).toBe(ROW_COUNT); + expect(Math.min(...ids)).toBe(1); + expect(Math.max(...ids)).toBe(ROW_COUNT); + expect(result!.rowsWritten).toBe(ROW_COUNT); + + }, 120_000); + + it('should export a table with no primary key without dropping rows', async () => { + + const filepath = path.join(testDir, 'pagerace_nopk.dt'); + const churn = startChurn(churnDb, 'pagerace_nopk'); + + const [, err] = await exportTable({ + db: sourceDb, + dialect: 'postgres', + tableName: 'pagerace_nopk', + filepath, + batchSize: BATCH_SIZE, + }); + + await churn.stop(); + + expect(err).toBeNull(); + + const ids = await readExportedIds(filepath); + + expect(new Set(ids).size).toBe(ROW_COUNT); + expect(ids.length).toBe(ROW_COUNT); + + }, 120_000); + + it('should transfer every row exactly once while the source is being rewritten', async () => { + + await sql.raw('TRUNCATE TABLE pagerace').execute(destDb); + + const churn = startChurn(churnDb, 'pagerace'); + + const [result, err] = await transferData(sourceConfig, destConfig, { + tables: ['pagerace'], + batchSize: BATCH_SIZE, + onConflict: 'skip', + }); + + await churn.stop(); + + expect(err).toBeNull(); + expect(result!.status).toBe('success'); + + // The destination is the only honest witness — `rowsTransferred` + // reported 50000 on a run that landed 30882 rows. + const landed = await sql<{ count: string }>`SELECT COUNT(*) as count FROM pagerace`.execute(destDb); + + expect(parseInt(landed.rows[0]!.count, 10)).toBe(ROW_COUNT); + + const distinct = await sql<{ count: string }>` + SELECT COUNT(DISTINCT id) as count FROM pagerace + `.execute(destDb); + + expect(parseInt(distinct.rows[0]!.count, 10)).toBe(ROW_COUNT); + + }, 120_000); + +}); From 33400c2dab1dc71f730432c883edcc8fc0c9a04b Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:44:11 -0400 Subject: [PATCH 023/105] fix(debug): gate internal-table operations behind policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/debug/index.ts | 1 + src/core/debug/operations.ts | 52 +- src/tui/screens/debug/DebugDetailScreen.tsx | 21 +- src/tui/screens/debug/DebugListScreen.tsx | 30 +- src/tui/screens/debug/DebugOverviewScreen.tsx | 10 +- tests/core/debug/operations.test.ts | 479 ++++++++++++++++++ 6 files changed, 576 insertions(+), 17 deletions(-) create mode 100644 tests/core/debug/operations.test.ts diff --git a/src/core/debug/index.ts b/src/core/debug/index.ts index 029a12b5..fea2e1cb 100644 --- a/src/core/debug/index.ts +++ b/src/core/debug/index.ts @@ -18,4 +18,5 @@ export type { SortDirection, GetRowsOptions, DebugOperations, + DebugPolicyContext, } from './operations.js'; diff --git a/src/core/debug/operations.ts b/src/core/debug/operations.ts index 9438f079..e3535155 100644 --- a/src/core/debug/operations.ts +++ b/src/core/debug/operations.ts @@ -12,7 +12,9 @@ import type { Kysely } from 'kysely'; import { attempt } from '@logosdx/utils'; import { observer } from '../observer.js'; +import { assertPolicy } from '../policy/index.js'; import { NOORM_TABLES, getNoormTables, noormDb, type NoormDatabase, type NoormTableName } from '../shared/index.js'; +import type { Channel, ConfigAccess } from '../policy/index.js'; import type { Dialect } from '../connection/types.js'; // ───────────────────────────────────────────────────────────── @@ -71,8 +73,25 @@ export interface GetRowsOptions { sortDirection?: SortDirection; } +/** + * Who is asking and which config's access roles gate the request. + * + * These operations delete from `vault` and `identities`, so the gate belongs + * at this seam rather than in the TUI screens — a second surface would + * otherwise inherit the tables without inheriting the check. + */ +export interface DebugPolicyContext { + /** CLI/TUI/SDK callers are `user`; the MCP server is `mcp` */ + channel: Channel; + + /** Config being inspected; missing `access` denies every operation */ + config: { name: string; access?: ConfigAccess }; +} + /** * Debug operations interface. + * + * Every method throws when the policy denies it. */ export interface DebugOperations { /** Get row counts for all noorm tables */ @@ -221,19 +240,28 @@ const TABLE_COLUMNS: Record = { * sqlite/mysql. The table names passed as parameters (`NoormTableName`) * are already the correct names from the caller. * + * `policy` is required rather than optional: these operations delete from + * `vault` and `identities`, and an omitted gate is exactly the defect this + * parameter closes. + * * @param db - Kysely database instance * @param dialect - Database dialect for schema scoping + * @param policy - Channel and config whose access roles gate every operation * * @example * ```typescript * const conn = await createConnection(config, '__debug__'); - * const ops = createDebugOperations(conn.db, 'postgres'); + * const ops = createDebugOperations(conn.db, 'postgres', { channel: 'user', config }); * * const counts = await ops.getTableCounts(); * const rows = await ops.getTableRows(NOORM_TABLES.change, { limit: 50 }); * ``` */ -export function createDebugOperations(db: Kysely, dialect: Dialect): DebugOperations { +export function createDebugOperations( + db: Kysely, + dialect: Dialect, + policy: DebugPolicyContext, +): DebugOperations { const ndb = noormDb(db, dialect); const tables = getNoormTables(dialect); @@ -248,12 +276,20 @@ export function createDebugOperations(db: Kysely, dialect: Dialec } + const gate = (permission: 'debug:read' | 'debug:write'): void => { + + assertPolicy(policy.channel, policy.config, permission); + + }; + const resolveTable = (table: NoormTableName): string => nameMap[table] ?? table; return { async getTableCounts(): Promise { + gate('debug:read'); + const results: TableCountResult[] = []; for (const info of NOORM_TABLE_INFO) { @@ -299,6 +335,8 @@ export function createDebugOperations(db: Kysely, dialect: Dialec options: GetRowsOptions = {}, ): Promise { + gate('debug:read'); + const { limit = 100, sortColumn = 'id', sortDirection = 'desc' } = options; const queryTable = resolveTable(table); @@ -331,6 +369,8 @@ export function createDebugOperations(db: Kysely, dialect: Dialec async getRowById(table: NoormTableName, id: number): Promise { + gate('debug:read'); + const queryTable = resolveTable(table); const [row, err] = await attempt(() => @@ -359,6 +399,8 @@ export function createDebugOperations(db: Kysely, dialect: Dialec async deleteRowById(table: NoormTableName, id: number): Promise { + gate('debug:write'); + const queryTable = resolveTable(table); const [result, err] = await attempt(() => @@ -386,6 +428,10 @@ export function createDebugOperations(db: Kysely, dialect: Dialec async deleteRowsByIds(table: NoormTableName, ids: number[]): Promise { + // Authorize before the empty-list short circuit — a denied caller + // must be told so, not handed a plausible-looking 0. + gate('debug:write'); + if (ids.length === 0) { return 0; @@ -419,6 +465,8 @@ export function createDebugOperations(db: Kysely, dialect: Dialec getTableColumns(table: NoormTableName): string[] { + gate('debug:read'); + return TABLE_COLUMNS[table] ?? ['id']; }, diff --git a/src/tui/screens/debug/DebugDetailScreen.tsx b/src/tui/screens/debug/DebugDetailScreen.tsx index 96c058d8..bebdf297 100644 --- a/src/tui/screens/debug/DebugDetailScreen.tsx +++ b/src/tui/screens/debug/DebugDetailScreen.tsx @@ -63,7 +63,7 @@ export function DebugDetailScreen({ params }: ScreenProps): ReactElement { // Load row data when connection is ready useAsyncEffect(async (isCancelled) => { - if (!db || !tableName || rowId == null) { + if (!db || !tableName || rowId == null || !activeConfig) { if (!connLoading && !connError) setIsLoading(false); @@ -76,7 +76,11 @@ export function DebugDetailScreen({ params }: ScreenProps): ReactElement { const [result, err] = await attempt(async () => { - const ops = createDebugOperations(db as Kysely, dialect ?? 'postgres'); + const ops = createDebugOperations( + db as Kysely, + dialect ?? 'postgres', + { channel: 'user', config: activeConfig }, + ); const cols = ops.getTableColumns(tableName); const data = await ops.getRowById(tableName, rowId); @@ -102,7 +106,7 @@ export function DebugDetailScreen({ params }: ScreenProps): ReactElement { setIsLoading(false); - }, [db, tableName, rowId]); + }, [db, tableName, rowId, activeConfig]); // Perform delete const performDelete = useCallback(async () => { @@ -111,9 +115,16 @@ export function DebugDetailScreen({ params }: ScreenProps): ReactElement { setShowConfirm(false); - const success = await operations.deleteRowById(tableName, rowId); + // The core seam throws when policy denies the delete or the statement + // fails; both carry a message worth showing verbatim. + const [success, err] = await attempt(() => operations.deleteRowById(tableName, rowId)); - if (success) { + if (err) { + + showToast({ message: err.message, variant: 'error' }); + + } + else if (success) { showToast({ message: `Deleted row #${rowId}`, variant: 'success' }); back(); diff --git a/src/tui/screens/debug/DebugListScreen.tsx b/src/tui/screens/debug/DebugListScreen.tsx index 8997abae..dc7fdd12 100644 --- a/src/tui/screens/debug/DebugListScreen.tsx +++ b/src/tui/screens/debug/DebugListScreen.tsx @@ -87,7 +87,7 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { // Load table data when connection is ready useAsyncEffect(async (isCancelled) => { - if (!db || !tableName) { + if (!db || !tableName || !activeConfig) { if (!connLoading && !connError) setIsLoading(false); @@ -100,7 +100,11 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { const [result, err] = await attempt(async () => { - const ops = createDebugOperations(db as Kysely, dialect ?? 'postgres'); + const ops = createDebugOperations( + db as Kysely, + dialect ?? 'postgres', + { channel: 'user', config: activeConfig }, + ); const cols = ops.getTableColumns(tableName); const data = await ops.getTableRows(tableName, { @@ -132,7 +136,7 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { setIsLoading(false); - }, [db, tableName, sortColumn, sortDirection]); + }, [db, tableName, activeConfig, sortColumn, sortDirection]); // Sort rows locally (already sorted from DB, but this handles re-sort) const sortedRows = useMemo(() => { @@ -267,9 +271,16 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { if (rowId === undefined) return; - const success = await operations.deleteRowById(tableName, rowId); + // The core seam throws when policy denies the delete or the + // statement fails; both carry a message worth showing verbatim. + const [success, err] = await attempt(() => operations.deleteRowById(tableName, rowId)); - if (success) { + if (err) { + + showToast({ message: err.message, variant: 'error' }); + + } + else if (success) { setRows((prev) => prev.filter((r) => r['id'] !== rowId)); showToast({ message: `Deleted row #${rowId}`, variant: 'success' }); @@ -284,9 +295,14 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { } else { - const count = await operations.deleteRowsByIds(tableName, ids); + const [count, err] = await attempt(() => operations.deleteRowsByIds(tableName, ids)); - if (count > 0) { + if (err) { + + showToast({ message: err.message, variant: 'error' }); + + } + else if (count > 0) { const idSet = new Set(ids); diff --git a/src/tui/screens/debug/DebugOverviewScreen.tsx b/src/tui/screens/debug/DebugOverviewScreen.tsx index e99b7aab..75a0f553 100644 --- a/src/tui/screens/debug/DebugOverviewScreen.tsx +++ b/src/tui/screens/debug/DebugOverviewScreen.tsx @@ -54,7 +54,7 @@ export function DebugOverviewScreen({ params: _params }: ScreenProps): ReactElem // Load table counts when connection is ready useAsyncEffect(async (isCancelled) => { - if (!db) { + if (!db || !activeConfig) { if (!connLoading && !connError) setIsLoading(false); @@ -67,7 +67,11 @@ export function DebugOverviewScreen({ params: _params }: ScreenProps): ReactElem const [result, err] = await attempt(async () => { - const ops = createDebugOperations(db as Kysely, dialect ?? 'postgres'); + const ops = createDebugOperations( + db as Kysely, + dialect ?? 'postgres', + { channel: 'user', config: activeConfig }, + ); return await ops.getTableCounts(); @@ -88,7 +92,7 @@ export function DebugOverviewScreen({ params: _params }: ScreenProps): ReactElem setIsLoading(false); - }, [db]); + }, [db, activeConfig]); // Keyboard navigation useInput((input, key) => { diff --git a/tests/core/debug/operations.test.ts b/tests/core/debug/operations.test.ts new file mode 100644 index 00000000..0dca4315 --- /dev/null +++ b/tests/core/debug/operations.test.ts @@ -0,0 +1,479 @@ +/** + * Debug operations tests. + * + * `core/debug` reads and deletes rows in noorm's own bookkeeping tables — + * including `vault` and `identities` — and nothing authorized those deletes. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { + DummyDriver, + Kysely, + MssqlAdapter, + MssqlIntrospector, + MssqlQueryCompiler, + MysqlAdapter, + MysqlIntrospector, + MysqlQueryCompiler, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, + SqliteAdapter, + SqliteDialect, + SqliteIntrospector, + SqliteQueryCompiler, +} from 'kysely'; +import { attempt } from '@logosdx/utils'; + +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { createDebugOperations, type DebugPolicyContext } from '../../../src/core/debug/index.js'; +import { NOORM_TABLES, type NoormDatabase } from '../../../src/core/shared/index.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; +import { observer } from '../../../src/core/observer.js'; + +// ───────────────────────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────────────────────── + +const ADMIN: DebugPolicyContext = { + channel: 'user', + config: { name: 'test', access: { user: 'admin', mcp: 'admin' } }, +}; + +const OPERATOR: DebugPolicyContext = { + channel: 'user', + config: { name: 'test', access: { user: 'operator', mcp: 'operator' } }, +}; + +const VIEWER: DebugPolicyContext = { + channel: 'user', + config: { name: 'test', access: { user: 'viewer', mcp: 'viewer' } }, +}; + +const NO_ACCESS: DebugPolicyContext = { + channel: 'user', + config: { name: 'test' }, +}; + +const MCP_ADMIN: DebugPolicyContext = { + channel: 'mcp', + config: { name: 'test', access: { user: 'admin', mcp: 'admin' } }, +}; + +/** + * A live in-memory SQLite database with noorm's v1 schema. + */ +async function createTestDb(): Promise> { + + const db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + return db; + +} + +/** + * A compile-only Kysely for a dialect, capturing every emitted statement. + * + * `resolveTable`'s prefixed-to-schema mapping is only observable in the SQL + * text, and pg/mssql are not reachable from a unit test — so drive the real + * dialect compiler through `DummyDriver` and read the statements back. + */ +function createRecordingDb(dialect: Dialect): { db: Kysely; sql: string[] } { + + const sql: string[] = []; + + const adapters = { + postgres: () => ({ a: new PostgresAdapter(), c: new PostgresQueryCompiler(), i: PostgresIntrospector }), + mssql: () => ({ a: new MssqlAdapter(), c: new MssqlQueryCompiler(), i: MssqlIntrospector }), + mysql: () => ({ a: new MysqlAdapter(), c: new MysqlQueryCompiler(), i: MysqlIntrospector }), + sqlite: () => ({ a: new SqliteAdapter(), c: new SqliteQueryCompiler(), i: SqliteIntrospector }), + } as const; + + const picked = adapters[dialect as keyof typeof adapters](); + + const db = new Kysely({ + dialect: { + createAdapter: () => picked.a, + createDriver: () => new DummyDriver(), + createQueryCompiler: () => picked.c, + createIntrospector: (d: Kysely) => new picked.i(d), + }, + log: (event) => { + + sql.push(event.query.sql); + + }, + }); + + return { db, sql }; + +} + +/** + * Seed n vault rows and return their ids. + */ +async function seedVault(db: Kysely, n: number): Promise { + + const ids: number[] = []; + + for (let i = 0; i < n; i++) { + + const row = await db.insertInto(NOORM_TABLES.vault) + .values({ + secret_key: `KEY_${i}`, + encrypted_value: `enc_${i}`, + set_by: 'seed', + }) + .returningAll() + .executeTakeFirstOrThrow(); + + ids.push(row.id); + + } + + return ids; + +} + +// ───────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────── + +describe('debug: createDebugOperations', () => { + + let db: Kysely; + let savedYes: string | undefined; + + beforeEach(async () => { + + // checkPolicy collapses `confirm` to `allow` under NOORM_YES; pin it off + // so the admin/confirm cell is exercised as itself. + savedYes = process.env['NOORM_YES']; + delete process.env['NOORM_YES']; + + db = await createTestDb(); + + }); + + afterEach(async () => { + + if (savedYes === undefined) delete process.env['NOORM_YES']; + else process.env['NOORM_YES'] = savedYes; + + await db.destroy(); + + }); + + describe('policy', () => { + + it('should deny deleteRowById to a viewer', async () => { + + const ids = await seedVault(db, 1); + const ops = createDebugOperations(db, 'sqlite', VIEWER); + + const [, err] = await attempt(() => ops.deleteRowById(NOORM_TABLES.vault, ids[0]!)); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('debug:write'); + + const survivors = await db.selectFrom(NOORM_TABLES.vault).selectAll().execute(); + + expect(survivors).toHaveLength(1); + + }); + + it('should deny deleteRowById to an operator', async () => { + + const ids = await seedVault(db, 1); + const ops = createDebugOperations(db, 'sqlite', OPERATOR); + + const [, err] = await attempt(() => ops.deleteRowById(NOORM_TABLES.vault, ids[0]!)); + + expect(err).toBeInstanceOf(Error); + + const survivors = await db.selectFrom(NOORM_TABLES.vault).selectAll().execute(); + + expect(survivors).toHaveLength(1); + + }); + + it('should deny deleteRowsByIds to a viewer before touching the database', async () => { + + const ids = await seedVault(db, 3); + const ops = createDebugOperations(db, 'sqlite', VIEWER); + + const [, err] = await attempt(() => ops.deleteRowsByIds(NOORM_TABLES.identities, ids)); + + expect(err).toBeInstanceOf(Error); + + const survivors = await db.selectFrom(NOORM_TABLES.vault).selectAll().execute(); + + expect(survivors).toHaveLength(3); + + }); + + it('should deny an empty-id bulk delete rather than short-circuit past the gate', async () => { + + const ops = createDebugOperations(db, 'sqlite', VIEWER); + + const [, err] = await attempt(() => ops.deleteRowsByIds(NOORM_TABLES.vault, [])); + + expect(err).toBeInstanceOf(Error); + + }); + + it('should allow deleteRowById for an admin', async () => { + + const ids = await seedVault(db, 1); + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + expect(await ops.deleteRowById(NOORM_TABLES.vault, ids[0]!)).toBe(true); + + const survivors = await db.selectFrom(NOORM_TABLES.vault).selectAll().execute(); + + expect(survivors).toHaveLength(0); + + }); + + it('should deny writes on the mcp channel even for an admin role', async () => { + + const ids = await seedVault(db, 1); + const ops = createDebugOperations(db, 'sqlite', MCP_ADMIN); + + const [, err] = await attempt(() => ops.deleteRowById(NOORM_TABLES.vault, ids[0]!)); + + expect(err).toBeInstanceOf(Error); + + const survivors = await db.selectFrom(NOORM_TABLES.vault).selectAll().execute(); + + expect(survivors).toHaveLength(1); + + }); + + it('should fail closed when the config carries no access', async () => { + + await seedVault(db, 1); + const ops = createDebugOperations(db, 'sqlite', NO_ACCESS); + + const [, readErr] = await attempt(() => ops.getTableRows(NOORM_TABLES.vault)); + const [, writeErr] = await attempt(() => ops.deleteRowById(NOORM_TABLES.vault, 1)); + + expect(readErr).toBeInstanceOf(Error); + expect(writeErr).toBeInstanceOf(Error); + + }); + + it('should allow reads for every role', async () => { + + await seedVault(db, 2); + + for (const policy of [VIEWER, OPERATOR, ADMIN]) { + + const ops = createDebugOperations(db, 'sqlite', policy); + + expect(await ops.getTableRows(NOORM_TABLES.vault)).toHaveLength(2); + expect(await ops.getTableCounts()).not.toHaveLength(0); + + } + + }); + + }); + + describe('resolveTable', () => { + + it('should map prefixed names onto the noorm schema for postgres', async () => { + + const { db: rec, sql } = createRecordingDb('postgres'); + const ops = createDebugOperations(rec, 'postgres', ADMIN); + + await ops.getTableRows(NOORM_TABLES.vault); + + expect(sql[0]).toContain('"noorm"."vault"'); + expect(sql[0]).not.toContain('__noorm_vault__'); + + await rec.destroy(); + + }); + + it('should map prefixed names onto the noorm schema for mssql', async () => { + + const { db: rec, sql } = createRecordingDb('mssql'); + const ops = createDebugOperations(rec, 'mssql', ADMIN); + + await ops.getRowById(NOORM_TABLES.identities, 1); + + expect(sql[0]).toContain('"noorm"."identities"'); + expect(sql[0]).not.toContain('__noorm_identities__'); + + await rec.destroy(); + + }); + + it('should keep prefixed names for mysql and sqlite', async () => { + + for (const dialect of ['mysql', 'sqlite'] as const) { + + const { db: rec, sql } = createRecordingDb(dialect); + const ops = createDebugOperations(rec, dialect, ADMIN); + + await ops.getTableRows(NOORM_TABLES.change); + + expect(sql[0]).toContain('__noorm_change__'); + expect(sql[0]).not.toContain('noorm.change'); + + await rec.destroy(); + + } + + }); + + it('should route deletes through the schema-qualified name on postgres', async () => { + + const { db: rec, sql } = createRecordingDb('postgres'); + const ops = createDebugOperations(rec, 'postgres', ADMIN); + + await ops.deleteRowsByIds(NOORM_TABLES.vault, [1, 2, 3]); + + expect(sql[0]).toContain('delete from "noorm"."vault"'); + + }); + + }); + + describe('getTableColumns', () => { + + it('should return the column list for a known table', () => { + + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + expect(ops.getTableColumns(NOORM_TABLES.vault)).toContain('secret_key'); + + }); + + }); + + describe('getTableRows', () => { + + it('should honour limit and sort direction', async () => { + + await seedVault(db, 5); + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + const desc = await ops.getTableRows(NOORM_TABLES.vault, { limit: 2 }); + const asc = await ops.getTableRows(NOORM_TABLES.vault, { limit: 2, sortDirection: 'asc' }); + + expect(desc).toHaveLength(2); + expect(desc[0]!['secret_key']).toBe('KEY_4'); + expect(asc[0]!['secret_key']).toBe('KEY_0'); + + }); + + it('should emit an error event when the query fails', async () => { + + const ops = createDebugOperations(db, 'sqlite', ADMIN); + const events: unknown[] = []; + const off = observer.on('error', (data) => { + + events.push(data); + + }); + + await db.schema.dropTable(NOORM_TABLES.vault).execute(); + await attempt(() => ops.getTableRows(NOORM_TABLES.vault)); + + off(); + + expect(events).toHaveLength(1); + + }); + + }); + + describe('getRowById', () => { + + it('should return null for a row that does not exist', async () => { + + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + expect(await ops.getRowById(NOORM_TABLES.vault, 9999)).toBeNull(); + + }); + + }); + + describe('deleteRowById', () => { + + it('should return false when the row does not exist', async () => { + + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + expect(await ops.deleteRowById(NOORM_TABLES.vault, 9999)).toBe(false); + + }); + + }); + + describe('deleteRowsByIds', () => { + + it('should delete exactly the listed ids', async () => { + + const ids = await seedVault(db, 5); + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + const deleted = await ops.deleteRowsByIds(NOORM_TABLES.vault, [ids[0]!, ids[2]!, ids[4]!]); + + expect(deleted).toBe(3); + + const survivors = await db.selectFrom(NOORM_TABLES.vault).select('id').execute(); + + expect(survivors.map((r) => r.id).sort()).toEqual([ids[1]!, ids[3]!].sort()); + + }); + + it('should count only ids that existed', async () => { + + const ids = await seedVault(db, 2); + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + expect(await ops.deleteRowsByIds(NOORM_TABLES.vault, [ids[0]!, 9998, 9999])).toBe(1); + + }); + + it('should return 0 for an empty id list without issuing a statement', async () => { + + const { db: rec, sql } = createRecordingDb('postgres'); + const ops = createDebugOperations(rec, 'postgres', ADMIN); + + expect(await ops.deleteRowsByIds(NOORM_TABLES.vault, [])).toBe(0); + expect(sql).toHaveLength(0); + + }); + + }); + + describe('getTableCounts', () => { + + it('should count every noorm table', async () => { + + await seedVault(db, 3); + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + const counts = await ops.getTableCounts(); + const vault = counts.find((c) => c.table === NOORM_TABLES.vault); + + expect(counts).toHaveLength(6); + expect(vault?.count).toBe(3); + expect(vault?.error).toBeUndefined(); + + }); + + }); + +}); From cfb36131fe7885309fa9856a499176a2f7b159b8 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:44:15 -0400 Subject: [PATCH 024/105] 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. --- src/core/policy/classify.ts | 594 ++++++++++++-------- tests/core/policy/classify-corpus.test.ts | 640 ++++++++++++++++++++++ 2 files changed, 993 insertions(+), 241 deletions(-) create mode 100644 tests/core/policy/classify-corpus.test.ts diff --git a/src/core/policy/classify.ts b/src/core/policy/classify.ts index cbad4d67..eac339c2 100644 --- a/src/core/policy/classify.ts +++ b/src/core/policy/classify.ts @@ -21,10 +21,16 @@ const DIALECT_MAP: Record = { select_stmt: true, - explain_stmt: true, + compound_select_stmt: true, show_stmt: true, describe_stmt: true, }; @@ -41,10 +47,13 @@ const WRITE_STMT_TYPES: Record = { /** * Keywords that indicate a read-only statement (uppercase). + * + * `EXPLAIN` is deliberately absent — see `classifyExplained`. It used to sit + * here, which made `EXPLAIN (ANALYZE) DELETE FROM t` a `read` and let a + * `viewer` role delete rows on postgres. */ const READ_KEYWORDS: Record = { SELECT: true, - EXPLAIN: true, SHOW: true, DESCRIBE: true, DESC: true, @@ -60,16 +69,61 @@ const WRITE_KEYWORDS: Record = { MERGE: true, }; +/** + * Every keyword that can legitimately start a statement. Used to tell an + * EXPLAIN's wrapped statement apart from MySQL's `EXPLAIN ` form, + * which is a synonym for DESCRIBE and carries no statement at all. + */ +const STATEMENT_KEYWORDS: Record = { + ...READ_KEYWORDS, + ...WRITE_KEYWORDS, + EXPLAIN: true, + WITH: true, +}; + +/** + * Words that may appear between `EXPLAIN` and the statement it wraps, across + * postgres/mysql/sqlite. Skipped when locating the wrapped statement. + * + * An explicit list, not "skip anything unrecognised": skipping unknown words + * would walk straight past an unrecognised *verb* (`EXPLAIN ANALYZE REFRESH + * MATERIALIZED VIEW ...`) and report the whole thing as a read. + */ +const EXPLAIN_OPTION_KEYWORDS: Record = { + ANALYZE: true, + ANALYSE: true, + VERBOSE: true, + EXTENDED: true, + PARTITIONS: true, + QUERY: true, + PLAN: true, + FORMAT: true, + TEXT: true, + XML: true, + JSON: true, + YAML: true, + TREE: true, + TRADITIONAL: true, + FOR: true, + CONNECTION: true, +}; + /** * Ordering used to resolve the highest class across a multi-statement input. */ const CLASS_RANK: Record = { read: 0, write: 1, ddl: 2 }; /** - * Side-effecting builtins that must not be treated as read-only just because + * Builtins that must not be reachable from a `viewer` role just because * they're invoked from a SELECT. Guardrail against a `viewer` config running * `SELECT pg_terminate_backend(...)` through the read-allowed `sql` path. * + * Covers two families: side-effecting builtins, and builtins that read the + * *database server's* filesystem. The second family is not a write, but + * "viewer" promises read-only access to the data, not to the host — and + * `SELECT pg_read_file('postgresql.conf')` is exfiltration wearing a SELECT. + * Both escalate to `write`, which is the lowest class that denies `viewer`. + * * Deliberately a denylist, not an allowlist: `SELECT f()` is statically * undecidable, so pure helpers (`count`, `now`, `coalesce`, ...) stay `read` * and only known-dangerous calls are caught. An unlisted side-effecting @@ -96,6 +150,12 @@ export const DESTRUCTIVE_FUNCTIONS: ReadonlySet = new Set([ 'query_to_xml_and_xmlschema', 'cursor_to_xml', 'cursor_to_xmlschema', + 'pg_read_file', + 'pg_read_binary_file', + 'pg_stat_file', + 'pg_ls_dir', + 'pg_ls_logdir', + 'pg_ls_waldir', ]); /** @@ -141,59 +201,69 @@ export function classifyStatements(sql: string, dialect: Dialect): SqlClass { } // Parser failed — fall back to keyword-based - return classifyKeyword(trimmed); + return classifyKeyword(trimmed, dialect); } /** - * Classify via CST. Highest class among all parsed statements wins. + * Classify via CST. Highest class of any statement node anywhere in the tree + * wins, plus `write` for a denylisted function call anywhere. * - * A data-modifying CTE definition (`WITH t AS (DELETE ... ) SELECT ...`) or - * a denylisted function call anywhere in the tree upgrades the result to at - * least `write`, even when the outer/final statement is a plain SELECT. + * Scanning the *whole* tree rather than just `program.statements` is the + * point: a statement's real impact is routinely carried by a nested node — + * a data-modifying CTE body (`WITH t AS (DELETE ...) SELECT ...`), or the + * statement an `EXPLAIN ANALYZE` executes. The previous version enumerated + * the nested node types it cared about and had no DDL entry, so + * `EXPLAIN ANALYZE CREATE TABLE x AS SELECT 1` classified as `read` and + * created the table under a `viewer` role. Walking generically inverts the + * default: an unrecognised nested statement escalates instead of hiding. */ function classifyCst(program: Program): SqlClass { - let highest: SqlClass = 'read'; + const highest = nestedStatementClass(program); - for (const stmt of program.statements) { + return containsDestructiveCall(program) ? maxClass(highest, 'write') : highest; - highest = maxClass(highest, classifyStmtType(stmt)); +} - } +/** + * Highest class of every `*_stmt` node reachable in the tree. + * + * Deliberately structural rather than type-driven: any node whose `type` + * ends in `_stmt` is a statement, and `classifyStmtType` already fails + * closed on statement types it does not recognise. That means a grammar + * upgrade adding new DDL node types is covered on arrival, with no list to + * keep in sync. + */ +function nestedStatementClass(node: unknown): SqlClass { - if (containsWriteSignal(program)) { + if (Array.isArray(node)) { - highest = maxClass(highest, 'write'); + return node.reduce((highest, item) => maxClass(highest, nestedStatementClass(item)), 'read'); } - return highest; + if (node === null || typeof node !== 'object') return 'read'; + + const { type } = node as { type?: unknown }; + const own = typeof type === 'string' && type.endsWith('_stmt') + ? classifyStmtType(node as CstStatement) + : 'read'; + + return Object.values(node).reduce((highest, value) => maxClass(highest, nestedStatementClass(value)), own); } /** - * True when the tree contains a data-modifying statement nested inside - * another statement (only possible via a CTE definition body — see - * `CommonTableExpr.expr` in sql-parser-cst's types) or a call to a - * `DESTRUCTIVE_FUNCTIONS` builtin anywhere. Either signals at least `write` - * regardless of what the outer/final statement looks like. + * True when the tree contains a call to a `DESTRUCTIVE_FUNCTIONS` builtin + * anywhere. Signals at least `write` regardless of what the outer/final + * statement looks like. */ -function containsWriteSignal(program: Program): boolean { +function containsDestructiveCall(program: Program): boolean { let found = false; - const markWrite = () => { - - found = true; - - }; - const visit = cstVisitor({ - insert_stmt: markWrite, - update_stmt: markWrite, - delete_stmt: markWrite, - merge_stmt: markWrite, func_call: (node) => { // A schema-qualified call (`pg_catalog.pg_terminate_backend(...)`) parses to a @@ -217,10 +287,23 @@ function containsWriteSignal(program: Program): boolean { } +/** The shape `classifyStmtType` needs from a CST statement node. */ +interface CstStatement { + type: string; + clauses?: Array<{ type: string }>; + /** Present on `explain_stmt`: the statement being explained. */ + statement?: unknown; +} + /** * Map a single CST statement to a class. `empty` covers comment-only * or whitespace-only input that still parses successfully. * + * An `explain_stmt` takes the class of the statement it wraps rather than a + * class of its own. Postgres `EXPLAIN ANALYZE` executes the wrapped + * statement — planning a DELETE deletes rows — so the wrapper cannot be the + * verdict. An EXPLAIN with nothing recognisable inside fails closed. + * * A `select_stmt` carrying an INTO clause (`into_table_clause`, * `into_outfile_clause`, `into_variables_clause`, `into_dumpfile_clause`) * redirects the result set into a new table or a file instead of returning @@ -228,7 +311,15 @@ function containsWriteSignal(program: Program): boolean { * to `ddl` for any INTO variant rather than special-casing which ones * actually touch schema. */ -function classifyStmtType(stmt: { type: string; clauses?: Array<{ type: string }> }): SqlClass { +function classifyStmtType(stmt: CstStatement): SqlClass { + + if (stmt.type === 'explain_stmt') { + + const inner = stmt.statement as CstStatement | undefined; + + return inner && typeof inner.type === 'string' ? classifyStmtType(inner) : 'ddl'; + + } if (stmt.type === 'select_stmt' && stmt.clauses?.some((clause) => clause.type.startsWith('into_'))) { @@ -246,13 +337,17 @@ function classifyStmtType(stmt: { type: string; clauses?: Array<{ type: string } /** * Classify via keyword analysis. * - * Strips comments, splits on semicolons, checks the leading keyword of each + * Masks everything that isn't code (string literals, quoted identifiers, + * comments), splits on semicolons, checks the leading keyword of each * statement, takes the highest class present. + * + * This is not a rare backstop: `mssql` has no grammar of its own here and + * maps to the postgres parser, so MSSQL syntax lands on this path routinely. */ -function classifyKeyword(sql: string): SqlClass { +function classifyKeyword(sql: string, dialect: Dialect): SqlClass { - const stripped = stripComments(sql); - const statements = splitStatements(stripped); + const masked = maskNonCode(sql, dialect); + const statements = splitStatements(masked); if (statements.length === 0) return 'read'; @@ -264,7 +359,7 @@ function classifyKeyword(sql: string): SqlClass { } - if (hasDestructiveFunctionCall(stripped)) { + if (DESTRUCTIVE_FUNCTION_PATTERN.test(masked)) { highest = maxClass(highest, 'write'); @@ -275,20 +370,42 @@ function classifyKeyword(sql: string): SqlClass { } /** - * Classify a single statement (no comments, no semicolons) by its leading + * Leading keyword of a statement, ignoring wrapping parens + * (`(SELECT 1) UNION (SELECT 2)`). `undefined` when the statement does not + * begin with a word character at all — a leading control byte, say — which + * callers must treat as unrecognised rather than harmless. + */ +function leadingKeyword(upper: string): string | undefined { + + return upper.replace(/^[\s(]+/, '').match(/^(\w+)/)?.[1]; + +} + +/** + * Classify a single statement (already masked, no semicolons) by its leading * keyword. Handles CTEs via paren-depth tracking for the WITH keyword. * * A leading SELECT carrying a top-level INTO (MSSQL `INTO #temp`, MySQL * `INTO OUTFILE`/`INTO @var`) is checked before the generic READ_KEYWORDS * lookup — the CST parser can reject dialect-specific INTO targets (e.g. * `#temp`), so this fallback must catch them too. Fail closed to `ddl`. + * + * A statement with no leading word character is unrecognised input, not an + * empty one (`splitStatements` already drops those), so it fails closed. + * Answering `read` there let a leading NUL byte carry a DROP past the gate. */ function classifyKeywordStatement(stmt: string): SqlClass { const upper = stmt.toUpperCase(); - const firstWord = upper.match(/^(\w+)/)?.[1]; + const firstWord = leadingKeyword(upper); - if (!firstWord) return 'read'; + if (!firstWord) return 'ddl'; + + if (firstWord === 'EXPLAIN') { + + return classifyExplained(upper); + + } if (firstWord === 'SELECT') { @@ -311,6 +428,74 @@ function classifyKeywordStatement(stmt: string): SqlClass { } +/** + * Classify an `EXPLAIN` by the statement it wraps. + * + * `EXPLAIN` is never a verdict of its own. Postgres executes the wrapped + * statement when ANALYZE is on, and the parenthesised option form the + * postgres docs lead with — `EXPLAIN (ANALYZE) DELETE FROM t` — is rejected + * by the CST parser, so it lands here. Treating the keyword as read let a + * `viewer` role run arbitrary DML through the CLI and over MCP. + * + * The one thing an EXPLAIN can wrap that is not a statement is MySQL's + * `EXPLAIN
` (a synonym for DESCRIBE). That is recognised narrowly — + * a single bare identifier and nothing else — so it cannot be stretched to + * cover a real verb. + */ +function classifyExplained(upper: string): SqlClass { + + const wrapped = stripExplainOptions(upper); + + if (wrapped === '') return 'read'; + + const keyword = leadingKeyword(wrapped); + + if (keyword && !STATEMENT_KEYWORDS[keyword] && /^[\w.$]+$/.test(wrapped)) return 'read'; + + return classifyKeywordStatement(wrapped); + +} + +/** + * Everything after `EXPLAIN` and its options — the statement being explained. + * + * Consumes a parenthesised option list (`(ANALYZE, FORMAT JSON)`), bare + * option keywords (`ANALYZE VERBOSE`, sqlite's `QUERY PLAN`), and MySQL's + * `FORMAT=JSON` punctuation. Stops at the first word that is not a known + * option, which is the wrapped statement's own verb. + */ +function stripExplainOptions(upper: string): string { + + let rest = upper.replace(/^[\s(]*EXPLAIN/, '').trimStart(); + + while (rest !== '') { + + if (rest.startsWith('(')) { + + rest = rest.slice(findMatchingParen(rest, 0) + 1).trimStart(); + continue; + + } + + if (rest.startsWith('=') || rest.startsWith(',')) { + + rest = rest.slice(1).trimStart(); + continue; + + } + + const word = rest.match(/^(\w+)/)?.[1]; + + if (!word || !EXPLAIN_OPTION_KEYWORDS[word]) break; + + rest = rest.slice(word.length).trimStart(); + + } + + return rest; + +} + /** * Classify a CTE (WITH ...): the highest of (a) the final statement's own * class and (b) any data-modifying CTE definition's class. A `WITH t AS @@ -522,73 +707,43 @@ function classifyCteBodyKeyword(body: string): SqlClass { } /** - * True when a top-level ` INTO ` keyword appears outside string literals - * and outside parentheses. Mirrors the string/paren-depth tracking used - * elsewhere in this file (`stripComments`, `classifyCte`) so a quoted - * value like `'INTO table'` or an INTO nested inside a subquery's parens - * doesn't trip the check — only a real SELECT ... INTO target does. + * True when a top-level ` INTO ` keyword appears outside parentheses. + * + * Operates on masked input, so an INTO inside a string literal + * (`'INTO table'`) or a quoted identifier (`[INTO]`) is already blanked and + * only paren depth is left to track — an INTO nested in a subquery is not a + * SELECT ... INTO target. */ -function hasTopLevelInto(sql: string): boolean { +function hasTopLevelInto(masked: string): boolean { let depth = 0; - let inString = false; - let i = 0; - - while (i < sql.length) { - - if (sql[i] === "'" && !inString) { - - inString = true; - i++; - - } - else if (sql[i] === "'" && inString) { - - if (sql[i + 1] === "'") { - - i += 2; - - } - else { - - inString = false; - i++; - - } - - } - else if (inString) { - i++; + for (let i = 0; i < masked.length; i++) { - } - else if (sql[i] === '(') { + if (masked[i] === '(') { depth++; - i++; + continue; } - else if (sql[i] === ')') { + + if (masked[i] === ')') { depth--; - i++; + continue; } - else if ( + + if ( depth === 0 && - sql.slice(i, i + 4) === 'INTO' && - !/\w/.test(sql[i - 1] ?? ' ') && - !/\w/.test(sql[i + 4] ?? ' ') + masked.startsWith('INTO', i) && + !/\w/.test(masked[i - 1] ?? ' ') && + !/\w/.test(masked[i + 4] ?? ' ') ) { return true; } - else { - - i++; - - } } @@ -597,225 +752,182 @@ function hasTopLevelInto(sql: string): boolean { } /** - * True when the SQL invokes a `DESTRUCTIVE_FUNCTIONS` builtin outside a - * string literal. Used by the keyword fallback, where there's no CST to - * walk for `func_call` nodes. + * Resolve the higher-impact of two classes (`read < write < ddl`). */ -function hasDestructiveFunctionCall(sql: string): boolean { +function maxClass(a: SqlClass, b: SqlClass): SqlClass { - return DESTRUCTIVE_FUNCTION_PATTERN.test(blankStringLiterals(sql)); + return CLASS_RANK[b] > CLASS_RANK[a] ? b : a; } /** - * Replace the contents of single-quoted string literals with spaces, - * preserving overall length. Mirrors the quote-tracking used elsewhere in - * this file (`stripComments`, `hasTopLevelInto`) so a denylisted function - * name embedded in a quoted value can't spoof a match. + * Split masked SQL on semicolons. + * + * Safe as a plain split because `maskNonCode` has already blanked every + * region a semicolon could be hiding in. The previous version tracked `'` + * inline and nothing else, so an MSSQL bracket identifier holding an odd + * number of apostrophes (`SELECT 1 AS [a'b]; DROP TABLE x`) desynced it into + * a phantom string literal that swallowed the separator — and the DROP was + * never classified at all. */ -function blankStringLiterals(sql: string): string { +function splitStatements(masked: string): string[] { - let result = ''; - let i = 0; - - while (i < sql.length) { + return masked + .split(';') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); - if (sql[i] !== "'") { - - result += sql[i++]; - continue; - - } - - result += ' '; - i++; - - while (i < sql.length) { - - if (sql[i] === "'" && sql[i + 1] === "'") { - - result += ' '; - i += 2; - continue; - - } - - if (sql[i] === "'") { - - result += ' '; - i++; - break; - - } - - result += ' '; - i++; - - } - - } - - return result; +} +/** A paired delimiter whose contents are data rather than code. */ +interface QuoteForm { + open: string; + close: string; } +const SINGLE_QUOTE: QuoteForm = { open: "'", close: "'" }; +const DOUBLE_QUOTE: QuoteForm = { open: '"', close: '"' }; +const BACKTICK: QuoteForm = { open: '`', close: '`' }; +const BRACKET: QuoteForm = { open: '[', close: ']' }; + /** - * Resolve the higher-impact of two classes (`read < write < ddl`). + * Quoting forms recognised per dialect, in match order. + * + * Dialect-scoped rather than universal because the same character means + * different things: `[...]` delimits an identifier on MSSQL and SQLite but + * subscripts an array on postgres, so masking it everywhere would blank real + * code — including parens, which would desync the depth tracking that CTE + * boundary detection depends on. */ -function maxClass(a: SqlClass, b: SqlClass): SqlClass { - - return CLASS_RANK[b] > CLASS_RANK[a] ? b : a; - -} +const QUOTE_FORMS: Record = { + postgres: [SINGLE_QUOTE, DOUBLE_QUOTE], + mysql: [SINGLE_QUOTE, DOUBLE_QUOTE, BACKTICK], + sqlite: [SINGLE_QUOTE, DOUBLE_QUOTE, BACKTICK, BRACKET], + mssql: [SINGLE_QUOTE, DOUBLE_QUOTE, BRACKET], +}; /** - * Split SQL on semicolons while respecting string literals. + * Blank every non-code region — string literals, quoted identifiers, and + * comments — replacing each character with a space so offsets are preserved. * - * Naive split(';') mishandles semicolons inside quoted strings. - * This walks the string tracking quote state to split correctly. + * One scanner instead of the four that used to track quotes independently + * (`splitStatements`, `stripComments`, `hasTopLevelInto`, + * `blankStringLiterals`), each modelling only `'`. Everything downstream can + * then treat `;`, `(`, `)` and keywords as unambiguously structural, and a + * new quoting form is fixed in one place rather than four. + * + * An unterminated quote or comment masks to end of input, which hides + * whatever follows it — safe, because the database will reject the input + * before executing any of it. */ -function splitStatements(sql: string): string[] { +function maskNonCode(sql: string, dialect: Dialect): string { + + const forms = QUOTE_FORMS[dialect]; + const hashComments = dialect === 'mysql'; + const dollarQuotes = dialect === 'postgres'; - const statements: string[] = []; - let current = ''; - let inString = false; + let masked = ''; let i = 0; while (i < sql.length) { - if (sql[i] === "'" && !inString) { - - inString = true; - current += sql[i++]; - - } - else if (sql[i] === "'" && inString) { + const form = forms.find((candidate) => sql.startsWith(candidate.open, i)); + const dollarTag = dollarQuotes ? dollarQuoteTag(sql, i) : undefined; + const end = form + ? quotedRegionEnd(sql, i, form) + : dollarTag + ? dollarRegionEnd(sql, i, dollarTag) + : commentRegionEnd(sql, i, hashComments); - if (sql[i + 1] === "'") { - - current += "''"; - i += 2; - - } - else { - - inString = false; - current += sql[i++]; - - } + if (end === -1) { - } - else if (sql[i] === ';' && !inString) { - - const trimmed = current.trim(); - - if (trimmed.length > 0) { - - statements.push(trimmed); - - } - - current = ''; + masked += sql[i]; i++; + continue; } - else { - - current += sql[i++]; - - } - - } - - const trimmed = current.trim(); - if (trimmed.length > 0) { - - statements.push(trimmed); + masked += ' '.repeat(end - i); + i = end; } - return statements; + return masked; } /** - * Strip SQL comments while respecting string literals. - * - * Uses a state machine to avoid stripping comment markers that - * appear inside single-quoted strings. This prevents crafted inputs - * from hiding destructive SQL behind comment markers embedded in - * string literals. + * Index just past a quoted region opened at `openIdx`. A doubled closing + * delimiter (`''`, `""`, `` `` ``, `]]`) is an escaped literal, not the end. */ -function stripComments(sql: string): string { +function quotedRegionEnd(sql: string, openIdx: number, form: QuoteForm): number { - let result = ''; - let i = 0; + let i = openIdx + form.open.length; while (i < sql.length) { - // Single-quoted string — copy verbatim (handles '' escapes) - if (sql[i] === "'") { + if (sql.startsWith(form.close + form.close, i)) { - result += sql[i++]; + i += form.close.length * 2; + continue; - while (i < sql.length) { + } - if (sql[i] === "'" && sql[i + 1] === "'") { + if (sql.startsWith(form.close, i)) return i + form.close.length; - result += "''"; - i += 2; + i++; - } - else if (sql[i] === "'") { + } - result += sql[i++]; - break; + return sql.length; - } - else { +} - result += sql[i++]; +/** + * The postgres dollar-quote tag opening at `i` (`$$` or `$tag$`), or + * `undefined`. Tags must start with a letter or underscore, which is what + * keeps a parameter placeholder pair like `$1$2` from reading as one. + */ +function dollarQuoteTag(sql: string, i: number): string | undefined { - } + if (sql[i] !== '$') return undefined; - } + return sql.slice(i).match(/^\$(?:[A-Za-z_]\w*)?\$/)?.[0]; - } - // Block comment — skip - else if (sql[i] === '/' && sql[i + 1] === '*') { - - i += 2; +} - while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) { +/** Index just past a dollar-quoted body opened at `openIdx` with `tag`. */ +function dollarRegionEnd(sql: string, openIdx: number, tag: string): number { - i++; + const close = sql.indexOf(tag, openIdx + tag.length); - } + return close === -1 ? sql.length : close + tag.length; - i += 2; // skip closing */ +} - } - // Line comment — skip to end of line - else if (sql[i] === '-' && sql[i + 1] === '-') { +/** + * Index just past a comment starting at `i`, or `-1` when `i` does not open + * one. A line comment stops at (and preserves) its newline. `#` is a comment + * introducer only on MySQL — on MSSQL it prefixes temp-table names. + */ +function commentRegionEnd(sql: string, i: number, hashComments: boolean): number { - i += 2; + if (sql.startsWith('/*', i)) { - while (i < sql.length && sql[i] !== '\n') { + const close = sql.indexOf('*/', i + 2); - i++; + return close === -1 ? sql.length : close + 2; - } + } - } - else { + if (sql.startsWith('--', i) || (hashComments && sql[i] === '#')) { - result += sql[i++]; + const newline = sql.indexOf('\n', i); - } + return newline === -1 ? sql.length : newline; } - return result; + return -1; } diff --git a/tests/core/policy/classify-corpus.test.ts b/tests/core/policy/classify-corpus.test.ts new file mode 100644 index 00000000..561eed7a --- /dev/null +++ b/tests/core/policy/classify-corpus.test.ts @@ -0,0 +1,640 @@ +/** + * Access policy: classifyStatements adversarial corpus. + * + * `classifyStatements` is the only thing standing between a `viewer` role and + * arbitrary SQL, and it predicts what a database will do from ~800 lines of + * hand-rolled parsing. `classify.test.ts` covers the happy shapes; this file + * covers the shapes an attacker — or a careless copy-paste from the postgres + * docs — actually produces, the ones that historically slipped through. + * + * Table-driven on purpose: a new bypass class is one row, not one `it()`, so + * covering the next variant costs nothing. Each row carries a `why` because + * an expectation without a reason is the exact failure mode that let + * `EXPLAIN (ANALYZE) DELETE FROM t` ship classified as `read`. + * + * Each row also declares which of the two classification paths it exercises + * (`cst` or `fallback`) and that is asserted, not assumed. The pre-existing + * suite had six tests *named* "(keyword fallback)" that never checked which + * path ran — so when the CST path and the fallback disagreed, which is + * precisely the shape of every bypass here, nothing failed. + */ +import { describe, it, expect } from 'bun:test'; +import { attemptSync } from '@logosdx/utils'; +import { parse } from 'sql-parser-cst'; + +import { classifyStatements } from '../../../src/core/policy/index.js'; +import type { SqlClass } from '../../../src/core/policy/index.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; + +/** sql-parser-cst grammar per noorm dialect — mirrors `DIALECT_MAP` in classify.ts. */ +const CST_DIALECT: Record = { + sqlite: 'sqlite', + postgres: 'postgresql', + mysql: 'mysql', + mssql: 'postgresql', +}; + +/** Which classification path an input takes. */ +type Path = 'cst' | 'fallback'; + +/** + * The path `classifyStatements` will take for this input: `cst` when the + * parser accepts it, `fallback` when it throws. Recomputed here rather than + * inferred from the dialect so a grammar upgrade that starts accepting an + * input surfaces as a failing row instead of silently retargeting the test. + */ +function pathFor(sql: string, dialect: Dialect): Path { + + const [, err] = attemptSync(() => parse(sql.trim(), { + dialect: CST_DIALECT[dialect], + includeComments: false, + includeSpaces: false, + includeNewlines: false, + })); + + return err ? 'fallback' : 'cst'; + +} + +interface Row { + /** Raw input handed to `classifyStatements`. */ + sql: string; + dialect: Dialect; + expected: SqlClass; + /** Path this row is meant to exercise — asserted, not assumed. */ + path: Path; + /** Why this classification is correct. Not optional: see file header. */ + why: string; +} + +/** + * `EXPLAIN` must inherit the class of the statement it wraps. + * + * Postgres `EXPLAIN ANALYZE` *executes* the plan, so `EXPLAIN (ANALYZE) + * DELETE FROM t` deletes rows. Treating the `EXPLAIN` keyword as a terminal + * `read` verdict handed a `viewer` role arbitrary DML on the CLI and over + * MCP. Both paths had to be fixed: the CST parser accepts only the bare + * `EXPLAIN ANALYZE ` form and rejects every parenthesised-option + * variant, which is the form the postgres docs lead with. + */ +const explainRows: Row[] = [ + { + sql: 'EXPLAIN SELECT * FROM users', + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'planning a SELECT reads nothing else — the benign baseline this family must not over-deny', + }, + { + sql: 'EXPLAIN ANALYZE SELECT 1', + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'ANALYZE executes the wrapped SELECT, which is still only a read', + }, + { + sql: 'EXPLAIN ANALYZE DELETE FROM todos', + dialect: 'postgres', + expected: 'write', + path: 'cst', + why: 'postgres executes the plan — rows are actually deleted', + }, + { + sql: 'EXPLAIN (ANALYZE) DELETE FROM todos', + dialect: 'postgres', + expected: 'write', + path: 'fallback', + why: 'the parenthesised option form is what the postgres docs lead with, and the CST parser rejects it', + }, + { + sql: 'EXPLAIN (ANALYZE, BUFFERS) DELETE FROM todos', + dialect: 'postgres', + expected: 'write', + path: 'fallback', + why: 'multiple options must not change the verdict of the wrapped DELETE', + }, + { + sql: 'EXPLAIN (ANALYZE, FORMAT JSON) UPDATE t SET a = 1', + dialect: 'postgres', + expected: 'write', + path: 'fallback', + why: 'option values inside the list are not statement keywords and must not terminate the scan', + }, + { + sql: 'EXPLAIN (COSTS OFF, ANALYZE ON) DELETE FROM t', + dialect: 'postgres', + expected: 'write', + path: 'fallback', + why: 'ANALYZE need not be the first option for the plan to execute', + }, + { + sql: 'EXPLAIN ANALYZE VERBOSE DELETE FROM todos', + dialect: 'postgres', + expected: 'write', + path: 'fallback', + why: 'bare option keywords stack, and the CST parser rejects the second one', + }, + { + sql: 'explain (analyze) delete from todos', + dialect: 'postgres', + expected: 'write', + path: 'fallback', + why: 'SQL keywords are case-insensitive; a lowercase bypass is still a bypass', + }, + { + sql: 'EXPLAIN (ANALYZE) INSERT INTO t VALUES (1)', + dialect: 'postgres', + expected: 'write', + path: 'fallback', + why: 'INSERT under EXPLAIN ANALYZE inserts for real', + }, + { + sql: 'EXPLAIN ANALYZE CREATE TABLE cli_pwned AS SELECT 1', + dialect: 'postgres', + expected: 'ddl', + path: 'cst', + why: 'CREATE TABLE AS is accepted by the parser under EXPLAIN and creates the table for real', + }, + { + sql: 'EXPLAIN ANALYZE CREATE MATERIALIZED VIEW mv AS SELECT 1', + dialect: 'postgres', + expected: 'ddl', + path: 'cst', + why: 'same shape as CREATE TABLE AS — the whole DDL family, not one instance, must escalate', + }, + { + sql: 'EXPLAIN ANALYZE SELECT * INTO x FROM y', + dialect: 'postgres', + expected: 'ddl', + path: 'cst', + why: 'SELECT INTO creates a table; wrapping it in EXPLAIN must not hide the INTO clause', + }, + { + sql: 'EXPLAIN (ANALYZE) CREATE INDEX i ON t (a)', + dialect: 'postgres', + expected: 'ddl', + path: 'fallback', + why: 'DDL under the option form has to escalate on the keyword path too', + }, + { + sql: 'EXPLAIN ANALYZE REFRESH MATERIALIZED VIEW mv', + dialect: 'postgres', + expected: 'ddl', + path: 'cst', + why: 'an unrecognised wrapped verb fails closed rather than inheriting EXPLAIN\'s read', + }, + { + sql: 'EXPLAIN QUERY PLAN SELECT 1', + dialect: 'sqlite', + expected: 'read', + path: 'cst', + why: 'sqlite EXPLAIN never executes; QUERY PLAN is an option prefix, not a statement', + }, + { + sql: 'EXPLAIN FORMAT=JSON SELECT 1', + dialect: 'mysql', + expected: 'read', + path: 'fallback', + why: 'mysql option syntax must be skipped, not mistaken for the wrapped statement', + }, + { + sql: 'EXPLAIN EXTENDED SELECT 1', + dialect: 'mysql', + expected: 'read', + path: 'fallback', + why: 'EXTENDED is a mysql option keyword the CST grammar does not know', + }, + { + sql: 'EXPLAIN ANALYZE DELETE FROM t', + dialect: 'mysql', + expected: 'write', + path: 'cst', + why: 'mysql 8.3+ executes EXPLAIN ANALYZE for DML; the gate must not depend on the server patch level', + }, + { + sql: 'EXPLAIN users', + dialect: 'mysql', + expected: 'read', + path: 'fallback', + why: 'mysql EXPLAIN
is a synonym for DESCRIBE — stripping the prefix must not over-deny it', + }, + { + sql: 'EXPLAIN (ANALYZE) DELETE FROM t', + dialect: 'mssql', + expected: 'write', + path: 'fallback', + why: 'mssql maps to the postgres grammar, so the fallback is its normal path, not a rare backstop', + }, +]; + +/** + * Statement splitting must respect every quoting form the dialect has. + * + * The scanners tracked `'` only. An MSSQL bracket-quoted identifier holding + * an odd number of apostrophes desynced the tracker into a phantom string + * literal that swallowed the following `;` — hiding a whole second statement + * from the gate. Proven live: a `viewer` dropped a table this way. + */ +const quotingRows: Row[] = [ + { + sql: "SELECT 1 AS [a'b]; DROP TABLE aud_b", + dialect: 'mssql', + expected: 'ddl', + path: 'fallback', + why: 'the apostrophe is inside a bracket identifier and must not open a string literal', + }, + { + sql: "SELECT [it's] FROM (SELECT 1 AS [it's]) q; DROP TABLE aud_a", + dialect: 'mssql', + expected: 'ddl', + path: 'fallback', + why: 'two apostrophes rebalanced the old tracker by luck; the verdict must not depend on parity', + }, + { + sql: 'SELECT 1 AS [a;b]', + dialect: 'mssql', + expected: 'read', + path: 'fallback', + why: 'a semicolon inside a bracket identifier is not a statement boundary', + }, + { + sql: 'SELECT 1 AS [INTO] FROM t', + dialect: 'mssql', + expected: 'read', + path: 'fallback', + why: 'INTO inside a quoted identifier is not a SELECT ... INTO target', + }, + { + sql: 'SELECT `a;b` FROM t', + dialect: 'mysql', + expected: 'read', + path: 'cst', + why: 'backtick identifiers hide semicolons on mysql', + }, + { + sql: "SELECT `a'b` AS x FROM t LIMIT 1, 2, 3; DROP TABLE x", + dialect: 'mysql', + expected: 'ddl', + path: 'fallback', + why: 'an apostrophe inside a backtick identifier must not swallow the following statement', + }, + { + sql: 'SELECT "a;b" FROM t', + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'double-quoted identifiers hide semicolons on postgres', + }, + { + sql: 'SELECT "a\'b" AS x FROM t GROUP BY 1, 2, 3, 4 HAVING; DROP TABLE x', + dialect: 'postgres', + expected: 'ddl', + path: 'fallback', + why: 'an apostrophe inside a double-quoted identifier must not swallow the following statement', + }, + { + sql: "SELECT 'a;b' FROM t", + dialect: 'mssql', + expected: 'read', + path: 'cst', + why: 'the original single-quote case still has to work after the scanner was generalised', + }, + { + sql: 'SELECT $$;DROP TABLE x$$', + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'dollar-quoted bodies are string literals — the DROP inside one is data, not a statement', + }, + { + sql: "SELECT $tag$ ' $tag$ AS a FROM t GROUP BY 1, 2, 3 HAVING; DROP TABLE x", + dialect: 'postgres', + expected: 'ddl', + path: 'fallback', + why: 'a lone apostrophe inside a tagged dollar-quote must not open a string that hides the DROP', + }, + { + sql: 'SELECT * INTO #tmp FROM users', + dialect: 'mssql', + expected: 'ddl', + path: 'fallback', + why: 'mssql temp-table INTO creates a table and `#` must not be read as a comment on mssql', + }, +]; + +/** + * Comments must be neutralised without becoming a hiding place, and without + * a comment marker inside a string literal starting a comment. + */ +const commentRows: Row[] = [ + { + sql: '-- leading comment\nSELECT 1', + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'a leading line comment does not change what the statement is', + }, + { + sql: '/* leading comment */ DROP TABLE x', + dialect: 'postgres', + expected: 'ddl', + path: 'cst', + why: 'a block comment must not shift the leading-keyword scan off the real verb', + }, + { + sql: '-- DROP TABLE x\nSELECT 1', + dialect: 'mssql', + expected: 'read', + path: 'cst', + why: 'DDL inside a comment is inert and must not over-deny', + }, + { + sql: 'SELECT 1 -- ; DROP TABLE x', + dialect: 'mssql', + expected: 'read', + path: 'cst', + why: 'a commented-out statement separator is not a separator', + }, + { + sql: "SELECT TOP 1 'safe /* ' FROM t; DROP TABLE users -- */'", + dialect: 'mssql', + expected: 'ddl', + path: 'fallback', + why: 'a comment marker inside a string literal must not open a comment that swallows the DROP', + }, + { + sql: '/* multi\nline\ncomment */ DELETE FROM t', + dialect: 'postgres', + expected: 'write', + path: 'cst', + why: 'newlines inside a block comment must not terminate it early or hide the verb', + }, +]; + +/** + * Data-modifying CTE bodies. The final statement is a plain SELECT, so + * keying off the outer verb alone reads them as `read`. + */ +const cteRows: Row[] = [ + { + sql: 'WITH t AS (DELETE FROM users WHERE id = 1 RETURNING id) SELECT * FROM t', + dialect: 'postgres', + expected: 'write', + path: 'cst', + why: 'the CTE body deletes rows even though the statement returns a result set', + }, + { + sql: 'WITH a AS (WITH b AS (DELETE FROM u RETURNING id) SELECT * FROM b) SELECT * FROM a', + dialect: 'postgres', + expected: 'write', + path: 'cst', + why: 'nesting the DML one level deeper must not hide it', + }, + { + sql: 'WITH t (id) AS (DELETE FROM u RETURNING id) SELECT * FROM t', + dialect: 'postgres', + expected: 'write', + path: 'cst', + why: 'an explicit CTE column list changes the syntax around AS, not the danger', + }, + { + sql: 'WITH t AS MATERIALIZED (DELETE FROM u RETURNING id) SELECT * FROM t', + dialect: 'postgres', + expected: 'write', + path: 'cst', + why: 'AS MATERIALIZED puts a keyword between AS and the body paren', + }, + { + sql: 'WITH t AS (SELECT TOP 1 * FROM x) DELETE FROM users WHERE id IN (SELECT id FROM t)', + dialect: 'mssql', + expected: 'write', + path: 'fallback', + why: 'the final statement is the DELETE; its own subquery parens are not the CTE boundary', + }, + { + sql: 'WITH t AS (SELECT TOP 1 * FROM x) SELECT * FROM users WHERE id IN (SELECT id FROM t)', + dialect: 'mssql', + expected: 'read', + path: 'fallback', + why: 'a read CTE with a read final statement must not be over-denied', + }, +]; + +/** + * Leading bytes that are not SQL. `String.prototype.trim` removes Unicode + * whitespace, but control characters survive — and a leading-keyword regex + * that finds no word character used to answer `read`, which is fail-open at + * the one place in this file that must fail closed. + */ +const junkPrefixRows: Row[] = [ + { + sql: '\u00A0SELECT 1', + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'NBSP is Unicode whitespace and is trimmed before parsing', + }, + { + sql: '\uFEFFDROP TABLE x', + dialect: 'postgres', + expected: 'ddl', + path: 'cst', + why: 'a BOM prefix is trimmed and must not disguise the DROP', + }, + { + sql: '\u0000DROP TABLE x', + dialect: 'postgres', + expected: 'ddl', + path: 'fallback', + why: 'a NUL prefix defeats the leading-word regex — unrecognised input fails closed, never to read', + }, + { + sql: '\u0001\u0002 DELETE FROM t', + dialect: 'postgres', + expected: 'ddl', + path: 'fallback', + why: 'any unrecognised leading control byte fails closed rather than answering read', + }, +]; + +/** Multi-statement input takes the highest class present. */ +const multiRows: Row[] = [ + { + sql: 'SELECT 1; DROP TABLE x', + dialect: 'postgres', + expected: 'ddl', + path: 'cst', + why: 'the gate sees one string; the database sees two statements', + }, + { + sql: 'SELECT 1;;SELECT 2', + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'an empty statement between separators is not an unrecognised statement', + }, + { + sql: 'SELECT 1; INSERT INTO t VALUES (1); DROP TABLE t', + dialect: 'postgres', + expected: 'ddl', + path: 'cst', + why: 'the highest class present wins, not the first or the last', + }, +]; + +/** + * `SELECT ... INTO` redirects a result set into a new table or a file, and + * procedural blocks carry arbitrary statements the classifier cannot see + * into. + */ +const redirectRows: Row[] = [ + { + sql: 'SELECT * INTO new_table FROM users', + dialect: 'postgres', + expected: 'ddl', + path: 'cst', + why: 'SELECT INTO creates a table', + }, + { + sql: "SELECT * FROM users INTO OUTFILE '/tmp/x'", + dialect: 'mysql', + expected: 'ddl', + path: 'cst', + why: 'INTO OUTFILE writes to the server filesystem', + }, + { + sql: "SELECT * FROM users WHERE name = 'INTO x'", + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'INTO inside a string literal is data, not a clause — no false positive', + }, + { + sql: 'DO $$ BEGIN DELETE FROM t; END $$', + dialect: 'postgres', + expected: 'ddl', + path: 'cst', + why: 'a DO block runs opaque procedural code; the classifier cannot see inside it, so it fails closed', + }, +]; + +/** + * Compound selects are reads. They were classified `ddl` because + * `compound_select_stmt` was missing from the read set — harmless while it + * only over-denied the outer statement, but it also appears nested inside + * ordinary subqueries, so the nested-statement scan would escalate those too. + */ +const compoundRows: Row[] = [ + { + sql: 'SELECT 1 UNION SELECT 2', + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'a UNION of two SELECTs reads and nothing else', + }, + { + sql: 'SELECT * FROM (SELECT 1 UNION SELECT 2) x', + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'a compound select nested in a subquery must not escalate the whole statement', + }, + { + sql: 'SELECT 1 INTERSECT SELECT 2', + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'same for the other set operators', + }, +]; + +/** + * The builtin denylist. Named `DESTRUCTIVE_FUNCTIONS`, but its real job is + * "must not be reachable from a role whose promise is read-only" — which + * includes reading the database server's filesystem. + */ +const builtinRows: Row[] = [ + { + sql: 'SELECT pg_terminate_backend(123)', + dialect: 'postgres', + expected: 'write', + path: 'cst', + why: 'a side-effecting builtin called from a SELECT is still a side effect', + }, + { + sql: "SELECT pg_read_file('postgresql.conf', 0, 90) AS f", + dialect: 'postgres', + expected: 'write', + path: 'cst', + why: 'a viewer reading the server filesystem is exfiltration, whatever the verb says', + }, + { + sql: "SELECT pg_ls_dir('.')", + dialect: 'postgres', + expected: 'write', + path: 'cst', + why: 'directory listing is the same family as pg_read_file', + }, + { + sql: "SELECT count(*) FROM t WHERE note = 'pg_read_file('", + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'a denylisted name inside a string literal must not spoof a match', + }, + { + sql: 'SELECT count(*) FROM t', + dialect: 'postgres', + expected: 'read', + path: 'cst', + why: 'pure aggregates stay read — the denylist must not become an allowlist', + }, +]; + +const corpus: ReadonlyArray = [ + ['EXPLAIN wrapping', explainRows], + ['quoting and statement splitting', quotingRows], + ['comments', commentRows], + ['data-modifying CTEs', cteRows], + ['non-SQL leading bytes', junkPrefixRows], + ['multi-statement', multiRows], + ['result-set redirection and procedural blocks', redirectRows], + ['compound selects', compoundRows], + ['builtin denylist', builtinRows], +]; + +describe('policy: classifyStatements — adversarial corpus', () => { + + for (const [group, groupRows] of corpus) { + + describe(group, () => { + + for (const row of groupRows) { + + const label = `${row.dialect}/${row.path}: ${JSON.stringify(row.sql)} -> ${row.expected}`; + + it(`should classify ${label} because ${row.why}`, () => { + + expect(pathFor(row.sql, row.dialect)).toBe(row.path); + expect(classifyStatements(row.sql, row.dialect)).toBe(row.expected); + + }); + + } + + }); + + } + + it('should never answer read for an input whose expected class is higher', () => { + + const escalating = corpus + .flatMap(([, groupRows]) => groupRows) + .filter((row) => row.expected !== 'read') + .filter((row) => classifyStatements(row.sql, row.dialect) === 'read'); + + expect(escalating.map((row) => row.sql)).toEqual([]); + + }); + +}); From e4ff888f1f7b826b14bd8a83bd1240620ae38ea1 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:44:24 -0400 Subject: [PATCH 025/105] 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. --- src/core/state/index.ts | 1 + src/core/state/manager.ts | 156 +++++- src/core/state/merge.ts | 173 +++++++ src/core/state/persistence.ts | 264 +++++++++++ tests/core/state/durability.test.ts | 445 ++++++++++++++++++ .../core/state/fixtures/concurrent-writer.ts | 21 + tests/core/state/merge.test.ts | 172 +++++++ 7 files changed, 1206 insertions(+), 26 deletions(-) create mode 100644 src/core/state/merge.ts create mode 100644 src/core/state/persistence.ts create mode 100644 tests/core/state/durability.test.ts create mode 100644 tests/core/state/fixtures/concurrent-writer.ts create mode 100644 tests/core/state/merge.test.ts diff --git a/src/core/state/index.ts b/src/core/state/index.ts index 367c940d..26381dfb 100644 --- a/src/core/state/index.ts +++ b/src/core/state/index.ts @@ -10,6 +10,7 @@ export { InvalidSecretKeyError, isValidSecretKey } from './manager.js'; export type { StateManagerOptions } from './manager.js'; export * from './types.js'; export { migrateState, needsMigration } from './migrations.js'; +export { BACKUP_SUFFIX, LOCK_SUFFIX, StateLockTimeoutError } from './persistence.js'; export { getPackageVersion } from './version.js'; let instance: StateManager | null = null; diff --git a/src/core/state/manager.ts b/src/core/state/manager.ts index be75244f..6d56a028 100644 --- a/src/core/state/manager.ts +++ b/src/core/state/manager.ts @@ -6,9 +6,9 @@ * * Encryption uses the user's private key from ~/.noorm/identity.key */ -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync } from 'fs'; import { dirname, join } from 'path'; -import { attemptSync, attempt } from '@logosdx/utils'; +import { attemptSync, attempt, clone } from '@logosdx/utils'; import type { Config } from '../config/types.js'; import { assertCanDeleteConfig, type SettingsProvider } from '../config/resolver.js'; import type { KnownUser } from '../identity/types.js'; @@ -21,12 +21,21 @@ import { import { encrypt, decrypt } from './encryption/index.js'; import type { State, ConfigSummary, EncryptedPayload } from './types.js'; import { createEmptyState } from './types.js'; +import { mergeState } from './merge.js'; +import { + acquireWriteLock, + backupExisting, + fingerprintContents, + LOCK_SUFFIX, + writeFileAtomicSync, +} from './persistence.js'; import { migrateState, needsMigration } from './migrations.js'; import { getPackageVersion } from './version.js'; import { observer } from '../observer.js'; const DEFAULT_STATE_DIR = '.noorm/state'; const DEFAULT_STATE_FILE = 'state.enc'; +const STATE_FILE_MODE = 0o600; /** * Narrows freshly-parsed JSON to a plain record so the schema-version @@ -81,10 +90,21 @@ export class StateManager { #state: State | null = null; #privateKey: string | undefined; #statePath: string; + #lockPath: string; #loaded = false; // eslint-disable-next-line no-unused-private-class-members #projectRoot: string; + /** + * The state exactly as we last saw it on disk. Diffing the in-memory + * state against this is what lets a concurrent write be reconciled + * instead of overwritten. + */ + #baseline: State | null = null; + + /** Fingerprint of the raw file we last read or wrote; null if absent. */ + #diskFingerprint: string | null = null; + constructor( projectRoot: string, options: StateManagerOptions = {}, @@ -95,6 +115,7 @@ export class StateManager { const stateDir = options.stateDir ?? DEFAULT_STATE_DIR; const stateFile = options.stateFile ?? DEFAULT_STATE_FILE; this.#statePath = join(projectRoot, stateDir, stateFile); + this.#lockPath = `${this.#statePath}${LOCK_SUFFIX}`; } @@ -131,6 +152,8 @@ export class StateManager { if (!existsSync(this.#statePath)) { this.#state = createEmptyState(currentVersion); + this.#baseline = clone(this.#state); + this.#diskFingerprint = null; this.#loaded = true; observer.emit('state:loaded', { configCount: 0, @@ -159,6 +182,10 @@ export class StateManager { } + // Recorded before any migration runs so a later persist can tell + // "the file is as I left it" from "someone else wrote it". + this.#diskFingerprint = fingerprintContents(raw!); + const [payload, parseErr] = attemptSync(() => JSON.parse(raw!) as EncryptedPayload); if (parseErr) { @@ -237,11 +264,12 @@ export class StateManager { } this.#loaded = true; + this.#baseline = clone(this.#state); // Persist if migrations were applied or the backfill above mutated a config if (needsVersionMigration || backfilledAccess) { - this.#persist(); + await this.#persist(); } @@ -280,9 +308,13 @@ export class StateManager { /** * Persist current state to disk (encrypted). * + * Serialized against other processes and reconciled against whatever is + * actually on disk, because every mutation rewrites the whole file from + * a snapshot that may already be stale. + * * Requires private key to be set. */ - #persist(): void { + async #persist(): Promise { if (!this.#privateKey) { @@ -292,7 +324,7 @@ export class StateManager { } - const state = this.#getState(); + this.#getState(); const dir = dirname(this.#statePath); if (!existsSync(dir)) { @@ -301,12 +333,11 @@ export class StateManager { } - const json = JSON.stringify(state); - const payload = encrypt(json, this.#privateKey); + const release = await acquireWriteLock(this.#lockPath); - const [, writeErr] = attemptSync(() => - writeFileSync(this.#statePath, JSON.stringify(payload, null, 2), { mode: 0o600 }), - ); + const [, writeErr] = attemptSync(() => this.#writeLocked()); + + release(); if (writeErr) { @@ -315,15 +346,76 @@ export class StateManager { } - // Ensure permissions are correct (writeFile mode may not work on all platforms) - attemptSync(() => chmodSync(this.#statePath, 0o600)); - observer.emit('state:persisted', { - configCount: Object.keys(state.configs).length, + configCount: Object.keys(this.#getState().configs).length, }); } + /** + * The body of a persist, run while holding the write lock. + */ + #writeLocked(): void { + + const state = this.#reconcileWithDisk(); + + const payload = encrypt(JSON.stringify(state), this.#privateKey!); + const contents = JSON.stringify(payload, null, 2); + + backupExisting(this.#statePath, STATE_FILE_MODE); + writeFileAtomicSync(this.#statePath, contents, STATE_FILE_MODE); + + // Ensure permissions are correct (open mode is masked by umask on + // some platforms, and the file may pre-date this write). + attemptSync(() => chmodSync(this.#statePath, STATE_FILE_MODE)); + + this.#diskFingerprint = fingerprintContents(contents); + this.#baseline = clone(state); + + } + + /** + * Fold in any write that landed after we loaded, so this write adds to + * it instead of replacing it. + * + * Returns the state to write, which is also adopted in memory — the + * caller's own view must not silently diverge from what went to disk. + */ + #reconcileWithDisk(): State { + + const state = this.#getState(); + + if (!existsSync(this.#statePath)) return state; + + const [raw] = attemptSync(() => readFileSync(this.#statePath, 'utf8')); + + if (raw === null || raw === undefined) return state; + + if (fingerprintContents(raw) === this.#diskFingerprint) return state; + + const [onDisk, err] = attemptSync(() => + JSON.parse(decrypt(JSON.parse(raw) as EncryptedPayload, this.#privateKey!)) as State, + ); + + // Someone replaced the file with something this key cannot read. + // Overwriting it would destroy data we cannot even inspect. + if (err || !onDisk) { + + throw new Error( + 'State file changed on disk and cannot be read with the current key. ' + + 'Refusing to overwrite it.', + ); + + } + + const merged = mergeState(this.#baseline ?? createEmptyState(state.version), state, onDisk); + + this.#state = merged; + + return merged; + + } + /** * Get the loaded state, throwing if not loaded. */ @@ -363,7 +455,7 @@ export class StateManager { const isNew = !state.configs[name]; state.configs[name] = { ...config }; - this.#persist(); + await this.#persist(); observer.emit(isNew ? 'config:created' : 'config:updated', { name, @@ -391,7 +483,7 @@ export class StateManager { } - this.#persist(); + await this.#persist(); observer.emit('config:deleted', { name }); } @@ -452,7 +544,7 @@ export class StateManager { const previous = state.activeConfig; state.activeConfig = name; - this.#persist(); + await this.#persist(); observer.emit('config:activated', { name, previous }); @@ -510,7 +602,7 @@ export class StateManager { } state.secrets[configName][key] = value; - this.#persist(); + await this.#persist(); observer.emit('secret:set', { configName, key }); @@ -526,7 +618,7 @@ export class StateManager { if (state.secrets[configName]) { delete state.secrets[configName][key]; - this.#persist(); + await this.#persist(); observer.emit('secret:deleted', { configName, key }); @@ -588,7 +680,7 @@ export class StateManager { const state = this.#getState(); state.globalSecrets[key] = value; - this.#persist(); + await this.#persist(); observer.emit('global-secret:set', { key }); @@ -604,7 +696,7 @@ export class StateManager { if (key in state.globalSecrets) { delete state.globalSecrets[key]; - this.#persist(); + await this.#persist(); observer.emit('global-secret:deleted', { key }); @@ -671,7 +763,7 @@ export class StateManager { const state = this.#getState(); state.knownUsers[user.identityHash] = user; - this.#persist(); + await this.#persist(); observer.emit('known-user:added', { email: user.email, @@ -693,7 +785,7 @@ export class StateManager { } - this.#persist(); + await this.#persist(); } @@ -766,10 +858,22 @@ export class StateManager { } - writeFileSync(this.#statePath, encrypted, { mode: 0o600 }); + const release = await acquireWriteLock(this.#lockPath); + + const [, writeErr] = attemptSync(() => { + + backupExisting(this.#statePath, STATE_FILE_MODE); + writeFileAtomicSync(this.#statePath, encrypted, STATE_FILE_MODE); + + // Ensure permissions are correct (open mode is masked by umask + // on some platforms). + attemptSync(() => chmodSync(this.#statePath, STATE_FILE_MODE)); + + }); + + release(); - // Ensure permissions are correct (writeFile mode may not work on all platforms) - attemptSync(() => chmodSync(this.#statePath, 0o600)); + if (writeErr) throw writeErr; this.#loaded = false; await this.load(); diff --git a/src/core/state/merge.ts b/src/core/state/merge.ts new file mode 100644 index 00000000..51d4c44b --- /dev/null +++ b/src/core/state/merge.ts @@ -0,0 +1,173 @@ +/** + * Three-way reconciliation of concurrent state writes. + * + * Every mutation is `load -> change in memory -> rewrite the whole file`, + * so a writer that loaded before another writer committed would overwrite + * that commit wholesale. Serializing writes alone does not fix it: the + * second writer still holds a stale snapshot. Reconciling our changes + * against what is actually on disk does. + * + * The comparison is three-way on purpose. Two-way (ours vs theirs) cannot + * tell "we never touched this key" from "we deleted this key", so it either + * loses deletes or resurrects them. + */ +import { equals } from '@logosdx/utils'; +import type { State } from './types.js'; + +/** Fields with dedicated reconciliation rules below. */ +const KNOWN_FIELDS = new Set([ + 'version', + 'schemaVersion', + 'knownUsers', + 'activeConfig', + 'configs', + 'secrets', + 'globalSecrets', +]); + +/** + * Reconcile one flat map: our edits win where we actually edited, their + * edits survive everywhere else, and a key we removed stays removed. + */ +function mergeMap( + baseline: Record, + ours: Record, + theirs: Record, +): Record { + + const merged: Record = { ...theirs }; + + for (const [key, value] of Object.entries(ours)) { + + if (!equals(value, baseline[key])) { + + merged[key] = value; + + } + + } + + for (const key of Object.keys(baseline)) { + + if (!(key in ours)) { + + delete merged[key]; + + } + + } + + return merged; + +} + +/** + * Reconcile the two-level secrets map. + * + * The outer level is config name and the inner level is secret key. A flat + * merge of the outer level would drop a sibling secret set concurrently on + * the same config, which is precisely the reported failure: ten parallel + * `secret set` calls against one config leaving five secrets. + */ +function mergeSecrets( + baseline: Record>, + ours: Record>, + theirs: Record>, +): Record> { + + const merged: Record> = {}; + const names = new Set([...Object.keys(ours), ...Object.keys(theirs)]); + + for (const name of names) { + + const inOurs = name in ours; + const inTheirs = name in theirs; + const inBaseline = name in baseline; + + // Present on one side only: it was either added by that side or + // removed by the other. The baseline says which. + if (inOurs && !inTheirs) { + + if (!inBaseline) merged[name] = ours[name]!; + continue; + + } + + if (!inOurs && inTheirs) { + + if (!inBaseline) merged[name] = theirs[name]!; + continue; + + } + + merged[name] = mergeMap(baseline[name] ?? {}, ours[name]!, theirs[name]!); + + } + + return merged; + +} + +/** + * Reconcile our in-memory state against the state currently on disk, + * using the snapshot we originally loaded to tell our changes apart from + * theirs. + * + * @example + * ```typescript + * // Another process wrote state.enc after we loaded it. + * const reconciled = mergeState(loadedSnapshot, inMemoryState, onDiskState); + * ``` + */ +export function mergeState(baseline: State, ours: State, theirs: State): State { + + const baselineFields = baseline as unknown as Record; + const ourFields = ours as unknown as Record; + const merged: Record = { ...theirs }; + + // Top-level fields written by a newer build that this one does not + // model get the same rule as any other value we may or may not have + // touched. + for (const [key, value] of Object.entries(ourFields)) { + + if (KNOWN_FIELDS.has(key)) continue; + + if (!equals(value, baselineFields[key])) { + + merged[key] = value; + + } + + } + + for (const key of Object.keys(baselineFields)) { + + if (KNOWN_FIELDS.has(key)) continue; + + if (!(key in ourFields)) { + + delete merged[key]; + + } + + } + + return { + ...merged, + version: ours.version, + + // Never step a schema version backwards: the higher number has + // already had its migrations applied to the data. + schemaVersion: Math.max(ours.schemaVersion, theirs.schemaVersion), + + activeConfig: ours.activeConfig !== baseline.activeConfig + ? ours.activeConfig + : theirs.activeConfig, + + configs: mergeMap(baseline.configs, ours.configs, theirs.configs), + secrets: mergeSecrets(baseline.secrets, ours.secrets, theirs.secrets), + globalSecrets: mergeMap(baseline.globalSecrets, ours.globalSecrets, theirs.globalSecrets), + knownUsers: mergeMap(baseline.knownUsers, ours.knownUsers, theirs.knownUsers), + } as State; + +} diff --git a/src/core/state/persistence.ts b/src/core/state/persistence.ts new file mode 100644 index 00000000..b48e159e --- /dev/null +++ b/src/core/state/persistence.ts @@ -0,0 +1,264 @@ +/** + * Durable, serialized writes for the encrypted state file. + * + * state.enc holds every config, every secret and every DB password in a + * project, and every mutation rewrites the whole file. A plain + * `writeFileSync` opens with O_TRUNC, so an interrupted write leaves a + * truncated file with no previous generation to fall back on, and two + * processes writing at once silently overwrite each other. These helpers + * are the seam that makes both cases safe. + */ +import { createHash, randomBytes } from 'crypto'; +import { + chmodSync, + closeSync, + copyFileSync, + existsSync, + fsyncSync, + openSync, + renameSync, + statSync, + unlinkSync, + writeSync, +} from 'fs'; +import { dirname } from 'path'; +import { attemptSync } from '@logosdx/utils'; + +/** How long a writer waits for a competing writer to finish. */ +const LOCK_TIMEOUT_MS = 5_000; + +/** + * How old a lock file must be before it is assumed to belong to a process + * that died without releasing it. A held lock never lives this long — the + * work it guards is one encrypt plus one write. + */ +const LOCK_STALE_MS = 30_000; + +const LOCK_POLL_MS = 25; + +/** Suffix of the previous-generation copy kept beside the state file. */ +export const BACKUP_SUFFIX = '.bak'; + +/** Suffix of the lock file guarding writes to the state file. */ +export const LOCK_SUFFIX = '.lock'; + +/** + * Raised when another process held the state write lock for longer than a + * write should ever take. Surfacing this beats writing anyway and losing + * whatever the other process committed. + * + * @example + * ```typescript + * const [, err] = await attempt(() => state.setSecret('dev', 'K', 'v')); + * if (err instanceof StateLockTimeoutError) { + * console.error('Another noorm process is writing state.'); + * } + * ``` + */ +export class StateLockTimeoutError extends Error { + + override readonly name = 'StateLockTimeoutError' as const; + + constructor( + public readonly lockPath: string, + public readonly timeoutMs: number, + ) { + + super( + `Timed out after ${timeoutMs}ms waiting for the state write lock at ${lockPath}. ` + + 'Another noorm process may still be writing.', + ); + + } + +} + +/** + * Content fingerprint used to detect that the state file changed underneath + * a writer that loaded it earlier. + * + * @example + * ```typescript + * const seen = fingerprintContents(readFileSync(statePath, 'utf8')); + * // ...later, before overwriting: + * if (fingerprintContents(readFileSync(statePath, 'utf8')) !== seen) reconcile(); + * ``` + */ +export function fingerprintContents(contents: string): string { + + return createHash('sha256').update(contents).digest('hex'); + +} + +function sleep(ms: number): Promise { + + return new Promise((resolve) => setTimeout(resolve, ms)); + +} + +/** + * Take an exclusive advisory lock beside the state file. + * + * O_EXCL creation is the only cross-platform primitive that is atomic on + * every filesystem noorm runs on, including the network mounts where + * `flock` silently degrades to a no-op. + * + * @returns a release function the caller must invoke, including on failure + * + * @example + * ```typescript + * const release = await acquireWriteLock(`${statePath}.lock`); + * const [, err] = attemptSync(() => writeStateFile(statePath, contents)); + * release(); + * if (err) throw err; + * ``` + */ +export async function acquireWriteLock( + lockPath: string, + timeoutMs: number = LOCK_TIMEOUT_MS, +): Promise<() => void> { + + const deadline = Date.now() + timeoutMs; + + for (;;) { + + const [fd, openErr] = attemptSync(() => openSync(lockPath, 'wx', 0o600)); + + if (fd !== null && fd !== undefined) { + + attemptSync(() => writeSync(fd, String(process.pid))); + attemptSync(() => closeSync(fd)); + + return () => { + + attemptSync(() => unlinkSync(lockPath)); + + }; + + } + + // Anything other than "already locked" is a real filesystem problem + // (missing directory, no permission) and retrying cannot fix it. + if ((openErr as NodeJS.ErrnoException | undefined)?.code !== 'EEXIST') { + + throw openErr; + + } + + const [stats] = attemptSync(() => statSync(lockPath)); + + if (stats && Date.now() - stats.mtimeMs > LOCK_STALE_MS) { + + attemptSync(() => unlinkSync(lockPath)); + continue; + + } + + if (Date.now() >= deadline) { + + throw new StateLockTimeoutError(lockPath, timeoutMs); + + } + + await sleep(LOCK_POLL_MS); + + } + +} + +/** + * Replace a file's contents atomically and durably. + * + * Stages into a sibling temp file, flushes it, then renames over the + * target. `rename` within a directory is atomic, so a reader sees either + * the whole previous file or the whole new one — never a partial write, and + * never a zero-length file if the process dies mid-write. + * + * @example + * ```typescript + * writeFileAtomicSync(statePath, JSON.stringify(payload, null, 2), 0o600); + * ``` + */ +export function writeFileAtomicSync(path: string, contents: string, mode: number): void { + + const tmpPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; + + const [, err] = attemptSync(() => { + + const fd = openSync(tmpPath, 'wx', mode); + + const [, writeErr] = attemptSync(() => { + + writeSync(fd, contents); + + // rename only orders the directory entry; without this the file + // contents can still be lost to a crash after the rename lands. + fsyncSync(fd); + + }); + + attemptSync(() => closeSync(fd)); + + if (writeErr) throw writeErr; + + renameSync(tmpPath, path); + + }); + + if (err) { + + attemptSync(() => unlinkSync(tmpPath)); + + throw err; + + } + + syncDirectory(dirname(path)); + +} + +/** + * Copy the current state file aside as the previous generation. + * + * Without this a damaged state.enc is total, unrecoverable loss of every + * config and secret in the project — there is no other backup anywhere. + * + * @example + * ```typescript + * backupExisting(statePath, 0o600); // -> statePath + '.bak' + * ``` + */ +export function backupExisting(path: string, mode: number): void { + + if (!existsSync(path)) return; + + const backupPath = `${path}${BACKUP_SUFFIX}`; + + // A failed backup must not block the write it precedes; that write is + // atomic on its own, so the worst case is losing one generation of + // history rather than losing the state file. + attemptSync(() => { + + copyFileSync(path, backupPath); + chmodSync(backupPath, mode); + + }); + +} + +/** + * Flush the directory entry so a completed rename survives a crash. + * + * Best effort: some platforms and filesystems reject opening a directory + * for fsync, and the write itself has already landed by this point. + */ +function syncDirectory(dir: string): void { + + const [fd] = attemptSync(() => openSync(dir, 'r')); + + if (fd === null || fd === undefined) return; + + attemptSync(() => fsyncSync(fd)); + attemptSync(() => closeSync(fd)); + +} diff --git a/tests/core/state/durability.test.ts b/tests/core/state/durability.test.ts new file mode 100644 index 00000000..e3b9dfa9 --- /dev/null +++ b/tests/core/state/durability.test.ts @@ -0,0 +1,445 @@ +/** + * State durability tests: concurrency, atomicity, backup, corruption. + * + * state.enc holds every config, every secret and every DB password in a + * project, and it is rewritten as a whole file on every mutation. These + * tests cover what happens when that write races another writer, when it + * fails part-way, and when the file on disk is damaged. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, statSync, chmodSync, utimesSync } from 'fs'; +import { join, dirname } from 'path'; +import { attempt } from '@logosdx/utils'; +import { StateManager, resetStateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; + +const STATE_DIR = '.test-state'; +const STATE_FILE = 'state.enc'; + +function createTestConfig(name: string): Config { + + return { + name, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: ':memory:' }, + }; + +} + +describe('state: durability', () => { + + let tempDir: string; + let privateKey: string; + let statePath: string; + + /** A fresh manager pointed at the same file — i.e. another process. */ + function newManager(): StateManager { + + return new StateManager(tempDir, { + stateDir: STATE_DIR, + stateFile: STATE_FILE, + privateKey, + }); + + } + + beforeEach(async () => { + + resetStateManager(); + tempDir = mkdtempSync(join(process.cwd(), 'tmp', 'noorm-durability-')); + privateKey = (await generateKeyPair()).privateKey; + statePath = join(tempDir, STATE_DIR, STATE_FILE); + + const seed = newManager(); + await seed.load(); + await seed.setConfig('dev', createTestConfig('dev')); + + }); + + afterEach(() => { + + rmSync(tempDir, { recursive: true, force: true }); + resetStateManager(); + + }); + + // ───────────────────────────────────────────────────────────── + // Concurrency + // ───────────────────────────────────────────────────────────── + + describe('concurrent writers', () => { + + it('should not lose a secret written by another writer', async () => { + + // Both managers read the same state, then write in turn. A + // whole-file overwrite from a stale snapshot silently discards + // whatever the other one committed in between — ten parallel + // `secret set` runs left five secrets, all exiting 0. + const a = newManager(); + const b = newManager(); + await a.load(); + await b.load(); + + await a.setSecret('dev', 'FROM_A', 'a'); + await b.setSecret('dev', 'FROM_B', 'b'); + + const reader = newManager(); + await reader.load(); + + expect(reader.getSecret('dev', 'FROM_A')).toBe('a'); + expect(reader.getSecret('dev', 'FROM_B')).toBe('b'); + + }); + + it('should not lose a global secret written by another writer', async () => { + + const a = newManager(); + const b = newManager(); + await a.load(); + await b.load(); + + await a.setGlobalSecret('FROM_A', 'a'); + await b.setGlobalSecret('FROM_B', 'b'); + + const reader = newManager(); + await reader.load(); + + expect(reader.getGlobalSecret('FROM_A')).toBe('a'); + expect(reader.getGlobalSecret('FROM_B')).toBe('b'); + + }); + + it('should not lose a config written by another writer', async () => { + + const a = newManager(); + const b = newManager(); + await a.load(); + await b.load(); + + await a.setConfig('from-a', createTestConfig('from-a')); + await b.setConfig('from-b', createTestConfig('from-b')); + + const reader = newManager(); + await reader.load(); + + expect(reader.getConfig('from-a')).not.toBeNull(); + expect(reader.getConfig('from-b')).not.toBeNull(); + expect(reader.getConfig('dev')).not.toBeNull(); + + }); + + it('should not resurrect a config the other writer deleted', async () => { + + // Reconciling a stale snapshot must not undo someone else's + // delete — that would be just as silent as losing a write. + const a = newManager(); + const b = newManager(); + await a.load(); + await b.load(); + + await a.deleteConfig('dev'); + await b.setGlobalSecret('UNRELATED', 'x'); + + const reader = newManager(); + await reader.load(); + + expect(reader.getConfig('dev')).toBeNull(); + expect(reader.getGlobalSecret('UNRELATED')).toBe('x'); + + }); + + it('should keep our own delete when reconciling with another writer', async () => { + + const a = newManager(); + const b = newManager(); + await a.load(); + await b.load(); + + await a.setGlobalSecret('UNRELATED', 'x'); + await b.deleteConfig('dev'); + + const reader = newManager(); + await reader.load(); + + expect(reader.getConfig('dev')).toBeNull(); + expect(reader.getGlobalSecret('UNRELATED')).toBe('x'); + + }); + + it('should survive concurrent writes from separate processes', async () => { + + const fixture = join(import.meta.dir, 'fixtures', 'concurrent-writer.ts'); + const keys = ['P1', 'P2', 'P3', 'P4', 'P5', 'P6']; + + const results = await Promise.all( + keys.map(async (key) => { + + const proc = Bun.spawn( + ['bun', 'run', fixture, tempDir, privateKey, key, `v-${key}`], + { stdout: 'pipe', stderr: 'pipe' }, + ); + + return { + key, + code: await proc.exited, + stderr: await new Response(proc.stderr).text(), + }; + + }), + ); + + for (const result of results) { + + expect({ key: result.key, code: result.code, stderr: result.stderr }) + .toEqual({ key: result.key, code: 0, stderr: '' }); + + } + + const reader = newManager(); + await reader.load(); + + expect(reader.listSecrets('dev').sort()).toEqual([...keys].sort()); + + }, 30_000); + + }); + + // ───────────────────────────────────────────────────────────── + // Atomicity and backup + // ───────────────────────────────────────────────────────────── + + describe('atomic write', () => { + + it('should leave no temp files behind after a persist', async () => { + + const manager = newManager(); + await manager.load(); + await manager.setSecret('dev', 'K', 'v'); + + const leftovers = readdirSync(dirname(statePath)).filter((f) => f.endsWith('.tmp')); + + expect(leftovers).toEqual([]); + + }); + + it('should release the write lock after a persist', async () => { + + const manager = newManager(); + await manager.load(); + await manager.setSecret('dev', 'K', 'v'); + + const leftovers = readdirSync(dirname(statePath)).filter((f) => f.endsWith('.lock')); + + expect(leftovers).toEqual([]); + + }); + + it('should break a stale lock rather than blocking forever', async () => { + + // A lock left behind by a killed process must not brick every + // future write. + const lockPath = `${statePath}.lock`; + writeFileSync(lockPath, '999999'); + const stale = new Date(Date.now() - 5 * 60_000); + utimesSync(lockPath, stale, stale); + + const manager = newManager(); + await manager.load(); + + const [, err] = await attempt(() => manager.setSecret('dev', 'K', 'v')); + + expect(err).toBeNull(); + expect(existsSync(lockPath)).toBe(false); + expect(manager.getSecret('dev', 'K')).toBe('v'); + + }); + + it('should keep the previous state intact when the write fails', async () => { + + const manager = newManager(); + await manager.load(); + await manager.setSecret('dev', 'BEFORE', 'v'); + + const before = readFileSync(statePath, 'utf8'); + const stateDir = dirname(statePath); + + // A read-only directory still permits O_TRUNC on an existing + // file, so a whole-file overwrite destroys state.enc here while + // a write that stages elsewhere and renames cannot start. + chmodSync(stateDir, 0o500); + const [, err] = await attempt(() => manager.setSecret('dev', 'AFTER', 'v')); + chmodSync(stateDir, 0o700); + + expect(err).toBeInstanceOf(Error); + expect(readFileSync(statePath, 'utf8')).toBe(before); + + }); + + }); + + describe('backup', () => { + + it('should keep the previous generation as state.enc.bak', async () => { + + const manager = newManager(); + await manager.load(); + + const firstGeneration = readFileSync(statePath, 'utf8'); + await manager.setSecret('dev', 'K', 'v'); + + expect(readFileSync(`${statePath}.bak`, 'utf8')).toBe(firstGeneration); + + }); + + it('should write the backup at mode 0600', async () => { + + const manager = newManager(); + await manager.load(); + await manager.setSecret('dev', 'K', 'v'); + + expect(statSync(`${statePath}.bak`).mode & 0o777).toBe(0o600); + + }); + + it('should let a destroyed state.enc be recovered from the backup', async () => { + + const manager = newManager(); + await manager.load(); + await manager.setSecret('dev', 'RECOVERABLE', 'v'); + await manager.setGlobalSecret('LATER', 'x'); + + writeFileSync(statePath, readFileSync(`${statePath}.bak`, 'utf8')); + + const reader = newManager(); + await reader.load(); + + expect(reader.getSecret('dev', 'RECOVERABLE')).toBe('v'); + + }); + + }); + + // ───────────────────────────────────────────────────────────── + // Corrupted files + // ───────────────────────────────────────────────────────────── + + describe('corrupted state file', () => { + + it('should reject a truncated state file without destroying it', async () => { + + const raw = readFileSync(statePath, 'utf8'); + writeFileSync(statePath, raw.slice(0, Math.floor(raw.length / 2))); + + const manager = newManager(); + const [, err] = await attempt(() => manager.load()); + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain('may be corrupted'); + expect(readFileSync(statePath, 'utf8')).toBe(raw.slice(0, Math.floor(raw.length / 2))); + + }); + + it('should reject a zero-length state file', async () => { + + writeFileSync(statePath, ''); + + const manager = newManager(); + const [, err] = await attempt(() => manager.load()); + + expect((err as Error).message).toContain('may be corrupted'); + + }); + + it('should reject a state file whose ciphertext was tampered with', async () => { + + const payload = JSON.parse(readFileSync(statePath, 'utf8')) as Record; + const bytes = Buffer.from(payload['ciphertext']!, 'base64'); + bytes[0] = bytes[0]! ^ 0xff; + payload['ciphertext'] = bytes.toString('base64'); + writeFileSync(statePath, JSON.stringify(payload)); + + const manager = newManager(); + const [, err] = await attempt(() => manager.load()); + + expect((err as Error).message).toContain('Wrong key or corrupted file'); + + }); + + it('should reject a state file whose decrypted body is not JSON', async () => { + + const { encrypt } = await import('../../../src/core/state/encryption/index.js'); + mkdirSync(dirname(statePath), { recursive: true }); + writeFileSync(statePath, JSON.stringify(encrypt('not json at all', privateKey))); + + const manager = newManager(); + const [, err] = await attempt(() => manager.load()); + + expect((err as Error).message).toContain('Failed to parse decrypted state'); + + }); + + }); + + // ───────────────────────────────────────────────────────────── + // Downgrade + // ───────────────────────────────────────────────────────────── + + describe('downgrade', () => { + + it('should refuse to open state written by a newer schema version', async () => { + + const { encrypt } = await import('../../../src/core/state/encryption/index.js'); + const { CURRENT_VERSIONS } = await import('../../../src/core/version/types.js'); + + const future = { + version: '99.0.0', + schemaVersion: CURRENT_VERSIONS.state + 5, + knownUsers: {}, + activeConfig: null, + configs: {}, + secrets: {}, + globalSecrets: {}, + }; + + writeFileSync(statePath, JSON.stringify(encrypt(JSON.stringify(future), privateKey))); + + const manager = newManager(); + const [, err] = await attempt(() => manager.load()); + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain('newer than'); + + }); + + it('should not rewrite state it refused to open', async () => { + + const { encrypt } = await import('../../../src/core/state/encryption/index.js'); + const { CURRENT_VERSIONS } = await import('../../../src/core/version/types.js'); + + const future = { + version: '99.0.0', + schemaVersion: CURRENT_VERSIONS.state + 5, + knownUsers: {}, + activeConfig: null, + configs: {}, + secrets: {}, + globalSecrets: {}, + auditTrail: ['a-field-this-build-knows-nothing-about'], + }; + + const raw = JSON.stringify(encrypt(JSON.stringify(future), privateKey)); + writeFileSync(statePath, raw); + + const manager = newManager(); + await attempt(() => manager.load()); + + expect(readFileSync(statePath, 'utf8')).toBe(raw); + + }); + + }); + +}); diff --git a/tests/core/state/fixtures/concurrent-writer.ts b/tests/core/state/fixtures/concurrent-writer.ts new file mode 100644 index 00000000..d52fe77a --- /dev/null +++ b/tests/core/state/fixtures/concurrent-writer.ts @@ -0,0 +1,21 @@ +/** + * Subprocess fixture for the concurrent-writer test. + * + * Runs the real load -> mutate -> persist cycle in its own process so the + * state file lock is exercised across OS processes rather than across two + * objects sharing one event loop. + * + * Usage: bun run concurrent-writer.ts + */ +import { StateManager } from '../../../../src/core/state/manager.js'; + +const [projectRoot, privateKey, secretKey, value] = process.argv.slice(2); + +const state = new StateManager(projectRoot!, { + stateDir: '.test-state', + stateFile: 'state.enc', + privateKey: privateKey!, +}); + +await state.load(); +await state.setSecret('dev', secretKey!, value!); diff --git a/tests/core/state/merge.test.ts b/tests/core/state/merge.test.ts new file mode 100644 index 00000000..c043858d --- /dev/null +++ b/tests/core/state/merge.test.ts @@ -0,0 +1,172 @@ +/** + * Three-way state reconciliation tests. + * + * The rule under test is "our edits win only where we actually edited" — + * anything weaker either loses a concurrent writer's data or resurrects + * something someone deliberately deleted. + */ +import { describe, it, expect } from 'bun:test'; +import { mergeState } from '../../../src/core/state/merge.js'; +import type { State } from '../../../src/core/state/types.js'; +import type { Config } from '../../../src/core/config/types.js'; + +function config(name: string): Config { + + return { + name, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: ':memory:' }, + }; + +} + +function state(overrides: Partial = {}): State { + + return { + version: '1.0.0', + schemaVersion: 2, + knownUsers: {}, + activeConfig: null, + configs: {}, + secrets: {}, + globalSecrets: {}, + ...overrides, + }; + +} + +describe('state: merge', () => { + + it('should keep both sides of a concurrent add', () => { + + const baseline = state(); + const merged = mergeState( + baseline, + state({ globalSecrets: { OURS: 'a' } }), + state({ globalSecrets: { THEIRS: 'b' } }), + ); + + expect(merged.globalSecrets).toEqual({ OURS: 'a', THEIRS: 'b' }); + + }); + + it('should keep both sides of a concurrent add to the same config secrets', () => { + + // The reported failure: parallel `secret set` calls against one + // config. A merge at the config level only would drop a sibling key. + const baseline = state({ secrets: { dev: {} } }); + const merged = mergeState( + baseline, + state({ secrets: { dev: { OURS: 'a' } } }), + state({ secrets: { dev: { THEIRS: 'b' } } }), + ); + + expect(merged.secrets['dev']).toEqual({ OURS: 'a', THEIRS: 'b' }); + + }); + + it('should honour our delete against their untouched copy', () => { + + const baseline = state({ configs: { dev: config('dev') } }); + const merged = mergeState( + baseline, + state(), + state({ configs: { dev: config('dev') } }), + ); + + expect(merged.configs).toEqual({}); + + }); + + it('should not resurrect what they deleted', () => { + + const baseline = state({ configs: { dev: config('dev') } }); + const merged = mergeState( + baseline, + state({ configs: { dev: config('dev') }, globalSecrets: { OURS: 'a' } }), + state(), + ); + + expect(merged.configs).toEqual({}); + expect(merged.globalSecrets).toEqual({ OURS: 'a' }); + + }); + + it('should prefer our edit over theirs on the same key', () => { + + const baseline = state({ globalSecrets: { K: 'original' } }); + const merged = mergeState( + baseline, + state({ globalSecrets: { K: 'ours' } }), + state({ globalSecrets: { K: 'theirs' } }), + ); + + expect(merged.globalSecrets['K']).toBe('ours'); + + }); + + it('should take their edit on a key we never touched', () => { + + const baseline = state({ globalSecrets: { K: 'original' } }); + const merged = mergeState( + baseline, + state({ globalSecrets: { K: 'original' } }), + state({ globalSecrets: { K: 'theirs' } }), + ); + + expect(merged.globalSecrets['K']).toBe('theirs'); + + }); + + it('should keep their activeConfig when we did not change it', () => { + + const baseline = state({ activeConfig: 'dev' }); + const merged = mergeState( + baseline, + state({ activeConfig: 'dev' }), + state({ activeConfig: 'prod' }), + ); + + expect(merged.activeConfig).toBe('prod'); + + }); + + it('should keep our activeConfig when we changed it', () => { + + const baseline = state({ activeConfig: 'dev' }); + const merged = mergeState( + baseline, + state({ activeConfig: 'staging' }), + state({ activeConfig: 'prod' }), + ); + + expect(merged.activeConfig).toBe('staging'); + + }); + + it('should never step schemaVersion backwards', () => { + + const merged = mergeState( + state({ schemaVersion: 2 }), + state({ schemaVersion: 2 }), + state({ schemaVersion: 7 }), + ); + + expect(merged.schemaVersion).toBe(7); + + }); + + it('should carry through an unknown top-level field only one side has', () => { + + const baseline = state(); + const theirs = { ...state(), auditTrail: ['theirs'] } as State; + + const merged = mergeState(baseline, state(), theirs) as unknown as Record; + + expect(merged['auditTrail']).toEqual(['theirs']); + + }); + +}); From 8f4ebccb335a23aaba1b7ef820a942c3bd285d2f Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:44:33 -0400 Subject: [PATCH 026/105] fix(config): gate export behind secret:read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/cli/config/export.ts | 13 ++++++++ tests/cli/config/export.test.ts | 57 ++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/cli/config/export.ts b/src/cli/config/export.ts index fc49fe14..9d114b85 100644 --- a/src/cli/config/export.ts +++ b/src/cli/config/export.ts @@ -10,6 +10,7 @@ import { chmod, writeFile } from 'node:fs/promises'; import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; +import { checkConfigPolicy } from '../../core/policy/index.js'; import { initState, getStateManager } from '../../core/state/index.js'; import { outputError, sharedArgs } from '../_utils.js'; @@ -46,6 +47,18 @@ const exportCommand = defineCommand({ } + // The export carries the connection password in plaintext, so it is + // a secret read — a viewer role that is denied `config:rm` must not + // be able to walk away with the credential instead. + const check = checkConfigPolicy('user', config, 'secret:read'); + + if (!check.allowed) { + + outputError(args, check.blockedReason ?? `Config "${args.name}" cannot be exported.`); + process.exit(1); + + } + const json = JSON.stringify(config, null, 4); if (args.output) { diff --git a/tests/cli/config/export.test.ts b/tests/cli/config/export.test.ts index bf328490..f52879af 100644 --- a/tests/cli/config/export.test.ts +++ b/tests/cli/config/export.test.ts @@ -15,7 +15,7 @@ * readable. `export --output` must write at 0600. */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync, statSync, readFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, statSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -126,4 +126,59 @@ describe('cli: noorm config export — output file mode', () => { }); + /** + * Export hands over the connection password in plaintext, so it is a + * secret read — but it was the one config operation with no gate at all. + * A viewer config would refuse `config rm` and then export its own + * password to stdout, exit 0. + */ + describe('policy gate', () => { + + async function reseedWithViewerAccess() { + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access: { user: 'viewer', mcp: false }, + connection: { + dialect: 'sqlite', + database: join(tmpDir, 'target.db'), + password: 'TOPSECRETPROD', + }, + } as Config); + + } + + it('refuses to write an export file for a viewer config', async () => { + + await reseedWithViewerAccess(); + + const result = runExport(); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('secret:read'); + expect(existsSync(outputPath)).toBe(false); + + }); + + it('does not print a viewer config password to stdout', async () => { + + await reseedWithViewerAccess(); + + const result = spawnSync('node', [CLI, 'config', 'export', CONFIG_NAME], { + cwd: tmpDir, + encoding: 'utf-8', + env: identityEnv, + }); + + expect(result.status).toBe(1); + expect(result.stdout).not.toContain('TOPSECRETPROD'); + + }); + + }); + }); From fe58dd4847cd4c747ac30e300683981f35f32038 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:44:34 -0400 Subject: [PATCH 027/105] 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. --- src/cli/config/add.ts | 10 ++++++++-- src/cli/config/edit.ts | 11 +++++++++-- tests/cli/config/add.test.ts | 23 +++++++++++++++++++++-- tests/cli/config/edit.test.ts | 17 +++++++++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/cli/config/add.ts b/src/cli/config/add.ts index 00bfac44..ad12d5e3 100644 --- a/src/cli/config/add.ts +++ b/src/cli/config/add.ts @@ -7,14 +7,19 @@ */ import { defineCommand } from 'citty'; +import { outputError, sharedArgs } from '../_utils.js'; + const addCommand = defineCommand({ meta: { name: 'add', description: 'Create a new configuration (interactive, via TUI)', }, - async run() { + args: { + json: sharedArgs.json, + }, + async run({ args }) { - process.stderr.write('Interactive only — run: noorm ui\n'); + outputError(args, 'Interactive only — run: noorm ui. For headless creation use: noorm config import '); process.exit(1); }, @@ -22,6 +27,7 @@ const addCommand = defineCommand({ (addCommand as typeof addCommand & { examples: string[] }).examples = [ 'noorm ui # then navigate to config > add', + 'noorm config import ./dev-config.json # headless equivalent', ]; export default addCommand; diff --git a/src/cli/config/edit.ts b/src/cli/config/edit.ts index 1b13e22b..316c066e 100644 --- a/src/cli/config/edit.ts +++ b/src/cli/config/edit.ts @@ -7,6 +7,8 @@ */ import { defineCommand } from 'citty'; +import { outputError, sharedArgs } from '../_utils.js'; + const editCommand = defineCommand({ meta: { name: 'edit', @@ -18,10 +20,14 @@ const editCommand = defineCommand({ description: 'Configuration name', required: false, }, + json: sharedArgs.json, }, - async run() { + async run({ args }) { - process.stderr.write('Interactive only — run: noorm ui\n'); + outputError( + args, + 'Interactive only — run: noorm ui. For headless edits use: noorm config import --force --yes', + ); process.exit(1); }, @@ -29,6 +35,7 @@ const editCommand = defineCommand({ (editCommand as typeof editCommand & { examples: string[] }).examples = [ 'noorm ui # then navigate to config > edit', + 'noorm config import ./dev-config.json --force --yes # headless equivalent', ]; export default editCommand; diff --git a/tests/cli/config/add.test.ts b/tests/cli/config/add.test.ts index 48b876fe..557b1e99 100644 --- a/tests/cli/config/add.test.ts +++ b/tests/cli/config/add.test.ts @@ -12,9 +12,9 @@ import { spawnSync } from 'node:child_process'; const CLI = join(process.cwd(), 'dist/cli/index.js'); -function runAdd(): ReturnType { +function runAdd(args: string[] = []): ReturnType { - return spawnSync('node', [CLI, 'config', 'add'], { + return spawnSync('node', [CLI, 'config', 'add', ...args], { encoding: 'utf-8', stdio: 'pipe', }); @@ -40,4 +40,23 @@ describe('cli: noorm config add — honest exit-1 stub', () => { }); + /** + * A headless caller in the config group gets JSON from every other + * command; this one ignored --json entirely and wrote prose to stderr, + * so a script parsing the group's output crashed rather than reading + * an error it could act on. + */ + it('emits a JSON error under --json and names the headless alternative', () => { + + const result = runAdd(['--json']); + + expect(result.status).toBe(1); + + const parsed = JSON.parse((result.stdout as string).trim()) as { success: boolean; error: string }; + + expect(parsed.success).toBe(false); + expect(parsed.error).toContain('config import'); + + }); + }); diff --git a/tests/cli/config/edit.test.ts b/tests/cli/config/edit.test.ts index 3e95afdd..29b65f49 100644 --- a/tests/cli/config/edit.test.ts +++ b/tests/cli/config/edit.test.ts @@ -59,4 +59,21 @@ describe('cli: noorm config edit — honest exit-1 stub', () => { }); + /** + * See add.test.ts — the stub ignored --json, so the config group's + * headless contract had two holes in it. + */ + it('emits a JSON error under --json and names the headless alternative', () => { + + const result = runEdit(['myconfig', '--json']); + + expect(result.status).toBe(1); + + const parsed = JSON.parse((result.stdout as string).trim()) as { success: boolean; error: string }; + + expect(parsed.success).toBe(false); + expect(parsed.error).toContain('config import'); + + }); + }); From 9d2d8d9ce83a019f5943139617bb065f7874dd07 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:44:45 -0400 Subject: [PATCH 028/105] fix(cli): honour settings.logging in the CLI logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/_utils.ts | 44 ++++---- tests/cli/cli-logger-settings.test.ts | 150 ++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 25 deletions(-) create mode 100644 tests/cli/cli-logger-settings.test.ts diff --git a/src/cli/_utils.ts b/src/cli/_utils.ts index aeb43613..c9f040d7 100644 --- a/src/cli/_utils.ts +++ b/src/cli/_utils.ts @@ -5,15 +5,11 @@ * Commands receive a plain `args` object from citty and call withContext * or withVaultContext to run work against a connected database context. */ -import { createWriteStream } from 'node:fs'; -import { mkdir } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; - import { attempt } from '@logosdx/utils'; import type { Context } from '../sdk/context.js'; import type { CryptoIdentity } from '../core/identity/types.js'; -import { Logger, type LoggerOptions, type LogLevel } from '../core/logger/index.js'; +import { Logger, DEFAULT_LOGGER_CONFIG, type LoggerOptions, type LogLevel } from '../core/logger/index.js'; import { getSettingsManager } from '../core/settings/index.js'; import { getSqlErrorMessage } from '../core/shared/index.js'; import { createContext } from '../sdk/index.js'; @@ -125,41 +121,39 @@ export interface VaultContext { * The Logger subscribes to observer events so core module progress * reaches stdout automatically. Commands only need to call logger.info * or logger.result for explicit output not tied to events. + * + * `settings.logging.enabled: false` turns off the *file* only, never the + * Logger — `logger.result()` is how every headless command emits its `--json` + * payload, so switching the whole Logger off would silence the CLI rather than + * stop it writing a log. A blank `config.file` is how the Logger is told to + * stay console-only. + * + * Exported for tests: it is the single place the ~79 CLI commands get their + * logging configuration, and it had no coverage. */ -async function createCliLogger(projectRoot: string, json: boolean): Promise { +export async function createCliLogger(projectRoot: string, json: boolean): Promise { const settingsManager = getSettingsManager(projectRoot); const [, settingsErr] = await attempt(() => settingsManager.load()); const settings = settingsErr ? {} : settingsManager.settings; + const logging = settings.logging ?? {}; - const logPath = join(projectRoot, '.noorm', 'state', 'noorm.log'); - const [, mkdirErr] = await attempt(() => mkdir(dirname(logPath), { recursive: true })); - - let fileStream: ReturnType | undefined; - if (!mkdirErr) { - - fileStream = createWriteStream(logPath, { flags: 'a' }); - fileStream.on('error', () => {}); // best-effort file logging - - } - - let defaultLevel: LogLevel = 'info'; - if (isDev()) { - - defaultLevel = 'verbose'; - - } + const defaultLevel: LogLevel = isDev() ? 'verbose' : 'info'; const options: LoggerOptions = { projectRoot, settings, config: { enabled: true, - level: getConfig('log.level', defaultLevel)!, + level: getConfig('log.level', logging.level ?? defaultLevel)!, + file: logging.enabled === false + ? '' + : logging.file ?? DEFAULT_LOGGER_CONFIG.file, + maxSize: logging.maxSize ?? DEFAULT_LOGGER_CONFIG.maxSize, + maxFiles: logging.maxFiles ?? DEFAULT_LOGGER_CONFIG.maxFiles, }, console: process.stdout, diagnostics: process.stderr, - file: fileStream ?? undefined, json, color: !json, }; diff --git a/tests/cli/cli-logger-settings.test.ts b/tests/cli/cli-logger-settings.test.ts new file mode 100644 index 00000000..93242ccd --- /dev/null +++ b/tests/cli/cli-logger-settings.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdir, rm, writeFile, stat, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { createCliLogger } from '../../src/cli/_utils.js'; +import { resetSettingsManager } from '../../src/core/settings/index.js'; +import { resetLogger } from '../../src/core/logger/index.js'; + +/** + * `createCliLogger` builds the Logger every one of the ~79 CLI commands runs + * under. It used to hardcode `enabled: true` and the log path, so every + * `settings.logging.*` value was dead for the whole CLI — and the Shift+L TUI + * overlay, which reads `settings.logging.file`, pointed at a file the CLI + * never wrote. + */ +describe('cli: createCliLogger settings', () => { + + let projectRoot: string; + + const writeSettings = async (yaml: string) => { + + await mkdir(join(projectRoot, '.noorm'), { recursive: true }); + await writeFile(join(projectRoot, '.noorm', 'settings.yml'), yaml); + + }; + + const exists = async (path: string) => stat(path).then(() => true, () => false); + + beforeEach(async () => { + + // The settings manager is a process-wide singleton keyed on the first + // projectRoot it sees; without a reset every case after the first would + // silently read the previous case's settings. + resetSettingsManager(); + + projectRoot = join( + tmpdir(), + `noorm-test-clilogger-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + await mkdir(projectRoot, { recursive: true }); + + }); + + afterEach(async () => { + + await resetLogger(); + resetSettingsManager(); + await rm(projectRoot, { recursive: true, force: true }); + + }); + + it('should not create a log file when logging.enabled is false', async () => { + + await writeSettings('logging:\n enabled: false\n'); + + const logger = await createCliLogger(projectRoot, false); + await logger.start(); + + logger.info('should not be persisted'); + + await logger.flush(); + await logger.stop(); + + expect(await exists(join(projectRoot, '.noorm', 'state', 'noorm.log'))).toBe(false); + + }); + + it('should still emit console output when file logging is disabled', async () => { + + await writeSettings('logging:\n enabled: false\n'); + + const logger = await createCliLogger(projectRoot, true); + await logger.start(); + + // Disabling file logging must not disable the logger itself — `--json` + // result output for every headless command runs through it. + expect(logger.state).toBe('running'); + + await logger.stop(); + + }); + + it('should honour a custom logging.file path', async () => { + + await writeSettings('logging:\n enabled: true\n file: .noorm/state/custom.log\n'); + + const logger = await createCliLogger(projectRoot, false); + await logger.start(); + + logger.info('custom path entry'); + + await logger.flush(); + await logger.stop(); + + const custom = join(projectRoot, '.noorm', 'state', 'custom.log'); + + expect(await exists(custom)).toBe(true); + expect(await readFile(custom, 'utf-8')).toContain('custom path entry'); + + expect(await exists(join(projectRoot, '.noorm', 'state', 'noorm.log'))).toBe(false); + + }); + + it('should honour logging.level from settings', async () => { + + await writeSettings('logging:\n enabled: true\n level: error\n'); + + const logger = await createCliLogger(projectRoot, false); + + expect(logger.level).toBe('error'); + + }); + + it('should honour logging.maxSize and rotate at the configured limit', async () => { + + await writeSettings('logging:\n enabled: true\n maxSize: 1kb\n maxFiles: 3\n'); + + await mkdir(join(projectRoot, '.noorm', 'state'), { recursive: true }); + await writeFile(join(projectRoot, '.noorm', 'state', 'noorm.log'), 'x'.repeat(4096)); + + const logger = await createCliLogger(projectRoot, false); + await logger.start(); + await logger.stop(); + + // A 4kb file under the hardcoded 10mb default would never rotate. + const { readdir } = await import('node:fs/promises'); + const rotated = (await readdir(join(projectRoot, '.noorm', 'state'))) + .filter((f) => f !== 'noorm.log'); + + expect(rotated.length).toBe(1); + + }); + + it('should default to the standard path when settings are absent', async () => { + + const logger = await createCliLogger(projectRoot, false); + await logger.start(); + + logger.info('default path entry'); + + await logger.flush(); + await logger.stop(); + + expect(await exists(join(projectRoot, '.noorm', 'state', 'noorm.log'))).toBe(true); + + }); + +}); From a6ce8a62fe53ab70d6fe815231668884e806974d Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:44:46 -0400 Subject: [PATCH 029/105] 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 `-evil` sibling counted as inside the project. --- src/core/template/context.ts | 57 +++++++- src/core/template/helpers.ts | 7 +- src/core/template/loaders/index.ts | 28 ++++ src/core/template/utils.ts | 23 ++++ .../fixtures/security/project-evil/secret.sql | 2 + tests/core/template/script-sidecars.test.ts | 130 ++++++++++++++++++ tests/core/template/security.test.ts | 48 +++++++ 7 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 tests/core/template/fixtures/security/project-evil/secret.sql create mode 100644 tests/core/template/script-sidecars.test.ts diff --git a/src/core/template/context.ts b/src/core/template/context.ts index e9eeb207..5dd8b367 100644 --- a/src/core/template/context.ts +++ b/src/core/template/context.ts @@ -20,7 +20,7 @@ * ``` */ import path from 'node:path'; -import { readdir } from 'node:fs/promises'; +import { readdir, readFile } from 'node:fs/promises'; import { attempt } from '@logosdx/utils'; @@ -28,8 +28,8 @@ import { observer } from '../observer.js'; import type { TemplateContext, RenderOptions } from './types.js'; import { HELPER_FILENAME } from './types.js'; import { loadHelpers } from './helpers.js'; -import { loadDataFile, hasLoader } from './loaders/index.js'; -import { toContextKey, sqlEscape, sqlQuote, generateUuid, isoNow } from './utils.js'; +import { loadDataFile, hasLoader, isExecutableExtension } from './loaders/index.js'; +import { toContextKey, sqlEscape, sqlQuote, generateUuid, isoNow, isWithinRoot } from './utils.js'; /** * Tiers `$.secrets` actually resolves from, in priority order. @@ -146,11 +146,16 @@ export async function buildContext( const templateDir = path.dirname(templatePath); const projectRoot = options.projectRoot ?? process.cwd(); + // Read separately from the engine's own read: `run inspect` builds a + // context without ever rendering, and it needs the same reference gate. + // An unreadable template yields '' — no references, so no script runs. + const [templateSource] = await attempt(() => readFile(templatePath, 'utf-8')); + // 1. Load inherited helpers const { helpers } = await loadHelpers(templateDir, projectRoot); // 2. Auto-load data files from template directory - const dataFiles = await loadDataFilesInDir(templateDir); + const dataFiles = await loadDataFilesInDir(templateDir, templateSource ?? ''); // 3. Check if data files include a config file const hasLocalConfig = 'config' in dataFiles; @@ -186,16 +191,50 @@ export async function buildContext( } +/** + * Whether a template asks for a context key by name. + * + * Deliberately syntactic and narrow: `$.key` and `$['key']` are the two + * documented ways to reach a data file, and a false negative costs the + * author an explicit reference while a false positive re-opens the hole. + * Only consulted for files whose loader executes code. + * + * @example + * referencesContextKey("SELECT '{%~ $.seed.label %}'", 'seed'); // true + * referencesContextKey('SELECT 1;', 'seed'); // false + */ +function referencesContextKey(templateSource: string, key: string): boolean { + + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const pattern = new RegExp( + `\\$\\s*(?:\\.\\s*${escaped}\\b|\\[\\s*(['"\`])${escaped}\\1\\s*\\])`, + ); + + return pattern.test(templateSource); + +} + /** * Load all data files in a directory. * * Scans the directory for supported data file extensions and loads each one. * File names are converted to camelCase context keys. * + * Files whose loader *executes* them (`.js`, `.mjs`, `.ts`) are loaded only + * when `templateSource` references their key. Auto-loading them meant that + * dropping a script beside any SQL file got it run — with no mention in the + * template and no way for the user to know — during `preview`, `inspect` + * and `--dry-run`, the three commands whose whole point is not to act. + * * @param dir - Directory to scan + * @param templateSource - Raw text of the template being rendered * @returns Object with camelCased keys and loaded data */ -async function loadDataFilesInDir(dir: string): Promise> { +async function loadDataFilesInDir( + dir: string, + templateSource: string, +): Promise> { const data: Record = {}; @@ -255,6 +294,12 @@ async function loadDataFilesInDir(dir: string): Promise> const filepath = path.join(dir, entry.name); const key = toContextKey(entry.name); + if (isExecutableExtension(ext) && !referencesContextKey(templateSource, key)) { + + continue; + + } + const [loaded, loadErr] = await attempt(() => loadDataFile(filepath)); if (loadErr) { @@ -305,7 +350,7 @@ function createIncludeHelper( const resolved = path.resolve(templateDir, includePath); // Security: ensure we don't escape project root - if (!resolved.startsWith(projectRoot)) { + if (!isWithinRoot(resolved, path.resolve(projectRoot))) { throw new Error(`Include path escapes project root: ${includePath}`); diff --git a/src/core/template/helpers.ts b/src/core/template/helpers.ts index 48b41f27..93b9f5a1 100644 --- a/src/core/template/helpers.ts +++ b/src/core/template/helpers.ts @@ -26,6 +26,7 @@ import { attempt } from '@logosdx/utils'; import { observer } from '../observer.js'; import { HELPER_FILENAME, HELPER_EXTENSIONS } from './types.js'; +import { isWithinRoot } from './utils.js'; import { loadJs } from './loaders/js.js'; /** @@ -43,8 +44,10 @@ export async function findHelperFiles(fromDir: string, projectRoot: string): Pro let currentDir = path.resolve(fromDir); const root = path.resolve(projectRoot); - // Walk up until we reach or pass the project root - while (currentDir.startsWith(root)) { + // Walk up until we reach or pass the project root. Segment-aware + // containment, not a string prefix: a template under `-evil` + // would otherwise walk into that sibling and execute its $helpers. + while (isWithinRoot(currentDir, root)) { const helperPath = await findHelperInDir(currentDir); diff --git a/src/core/template/loaders/index.ts b/src/core/template/loaders/index.ts index ef74f7a7..b1ab2f20 100644 --- a/src/core/template/loaders/index.ts +++ b/src/core/template/loaders/index.ts @@ -92,6 +92,16 @@ const loaders: LoaderRegistry = { // NO .dtzx — can't provide passphrase in template context }; +/** + * Extensions whose loader executes the file rather than parsing it. + * + * Kept beside the registry so that registering a new loader forces the + * author to answer "does this run code?" — the whole class of problem here + * was that `.js`/`.mjs`/`.ts` sat in the same map as `.csv` and inherited + * `.csv`'s auto-load-everything treatment. + */ +const EXECUTABLE_EXTENSIONS = new Set(['.js', '.mjs', '.ts']); + /** * Check if a loader exists for the given extension. * @@ -104,6 +114,24 @@ export function hasLoader(ext: string): boolean { } +/** + * Whether loading a file with this extension runs arbitrary code in the + * current process. + * + * Callers use this to require an explicit opt-in before touching the file. + * Parsing a `.csv` cannot do anything; importing a `.ts` can read the + * identity key out of the environment. + * + * @example + * isExecutableExtension('.ts'); // true + * isExecutableExtension('.json'); // false + */ +export function isExecutableExtension(ext: string): boolean { + + return EXECUTABLE_EXTENSIONS.has(ext); + +} + /** * Get the loader function for a file extension. * diff --git a/src/core/template/utils.ts b/src/core/template/utils.ts index 0e302438..4998482d 100644 --- a/src/core/template/utils.ts +++ b/src/core/template/utils.ts @@ -17,6 +17,29 @@ import path from 'node:path'; import v from 'voca'; +/** + * Whether a resolved absolute path sits at or under a root directory. + * + * A bare `startsWith(root)` is a *string* test, not a path test: with a + * root of `/srv/app`, the sibling `/srv/app-evil` passes it. Requiring the + * separator makes containment path-segment aware, which is the only reading + * that matches what "cannot escape the project root" claims. Shared by + * `include()`'s guard and the `$helpers` tree walk so the two cannot drift. + * + * @example + * ```typescript + * isWithinRoot('/srv/app/sql/a.sql', '/srv/app') // → true + * isWithinRoot('/srv/app-evil/a.sql', '/srv/app') // → false + * ``` + */ +export function isWithinRoot(resolved: string, root: string): boolean { + + const normalizedRoot = root.endsWith(path.sep) ? root.slice(0, -1) : root; + + return resolved === normalizedRoot || resolved.startsWith(normalizedRoot + path.sep); + +} + /** * Convert a filename to a camelCase context key. * diff --git a/tests/core/template/fixtures/security/project-evil/secret.sql b/tests/core/template/fixtures/security/project-evil/secret.sql new file mode 100644 index 00000000..00a0bd07 --- /dev/null +++ b/tests/core/template/fixtures/security/project-evil/secret.sql @@ -0,0 +1,2 @@ +-- Sibling of the project root, sharing its name as a prefix +SELECT 'leaked' AS escape_probe; diff --git a/tests/core/template/script-sidecars.test.ts b/tests/core/template/script-sidecars.test.ts new file mode 100644 index 00000000..bb8c84c7 --- /dev/null +++ b/tests/core/template/script-sidecars.test.ts @@ -0,0 +1,130 @@ +/** + * Executable side-cars in a template's directory. + * + * `loadDataFilesInDir` auto-loaded every file with a registered extension, + * and `.js`/`.mjs`/`.ts` are registered — so any script sitting next to a + * SQL file was `import()`ed, unsandboxed, as part of building the template + * context. The template did not have to mention it, and `run preview`, + * `run inspect` and `run build --dry-run` all triggered it. That turned + * "look at this unfamiliar project before running it" into arbitrary code + * execution as the invoking user. + * + * The rule these tests pin: a script runs only when the template asks for + * its data. Inert formats (json/yaml/csv) keep auto-loading — they cannot + * execute anything, and requiring a reference for them would break every + * existing project for no security gain. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { processFile } from '../../../src/core/template/engine.js'; +import { buildContext } from '../../../src/core/template/context.js'; + +describe('template: executable side-cars', () => { + + let dir: string; + let marker: string; + + /** + * A side-car whose only observable effect is a file on disk — stands in + * for the `execSync` / credential read a real payload would do. + */ + async function writePayload(name: string): Promise { + + await writeFile( + join(dir, name), + 'import { writeFileSync } from \'node:fs\';\n' + + `writeFileSync(${JSON.stringify(marker)}, 'executed');\n` + + 'export default { label: \'from-sidecar\' };\n', + 'utf-8', + ); + + } + + beforeEach(async () => { + + dir = await mkdtemp(join(tmpdir(), 'noorm-sidecar-')); + marker = join(dir, 'payload-ran.txt'); + + }); + + afterEach(async () => { + + await rm(dir, { recursive: true, force: true }); + + }); + + it('should not execute a side-car the template never references', async () => { + + await writePayload('payload.js'); + + const template = join(dir, 'probe.sql.tmpl'); + await writeFile(template, 'SELECT 1;\n', 'utf-8'); + + const result = await processFile(template, { projectRoot: dir }); + + expect(result.sql).toBe('SELECT 1;'); + expect(existsSync(marker)).toBe(false); + + }); + + it('should not execute an unreferenced side-car while inspecting context', async () => { + + await writePayload('payload.ts'); + + const template = join(dir, 'probe.sql.tmpl'); + await writeFile(template, 'SELECT 1;\n', 'utf-8'); + + // `run inspect` builds the context without rendering — the command + // a user reaches for precisely because it does not execute anything. + const ctx = await buildContext(template, { projectRoot: dir }); + + expect(existsSync(marker)).toBe(false); + expect(ctx['payload']).toBeUndefined(); + + }); + + it('should load a side-car the template does reference', async () => { + + await writePayload('payload.js'); + + const template = join(dir, 'probe.sql.tmpl'); + await writeFile(template, "SELECT '{%~ $.payload.label %}';\n", 'utf-8'); + + const result = await processFile(template, { projectRoot: dir }); + + expect(result.sql).toBe("SELECT 'from-sidecar';"); + expect(existsSync(marker)).toBe(true); + + }); + + it('should recognise bracket access as a reference', async () => { + + await writePayload('payload.js'); + + const template = join(dir, 'probe.sql.tmpl'); + await writeFile(template, "SELECT '{%~ $['payload'].label %}';\n", 'utf-8'); + + const result = await processFile(template, { projectRoot: dir }); + + expect(result.sql).toBe("SELECT 'from-sidecar';"); + + }); + + it('should keep auto-loading inert data files without a reference', async () => { + + await writeFile(join(dir, 'seed.json'), JSON.stringify({ label: 'inert' }), 'utf-8'); + + const template = join(dir, 'probe.sql.tmpl'); + await writeFile(template, 'SELECT 1;\n', 'utf-8'); + + const ctx = await buildContext(template, { projectRoot: dir }); + + expect(ctx['seed']).toEqual({ label: 'inert' }); + + }); + +}); diff --git a/tests/core/template/security.test.ts b/tests/core/template/security.test.ts index acb74a3f..f4e26829 100644 --- a/tests/core/template/security.test.ts +++ b/tests/core/template/security.test.ts @@ -9,7 +9,10 @@ */ import { describe, it, expect } from 'bun:test'; import path from 'node:path'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { buildContext } from '../../../src/core/template/context.js'; +import { findHelperFiles } from '../../../src/core/template/helpers.js'; const FIXTURES_DIR = path.join(import.meta.dirname, 'fixtures/security'); const PROJECT_ROOT = path.join(FIXTURES_DIR, 'project'); @@ -99,6 +102,51 @@ describe('template: security', () => { }); + it('should reject a sibling directory whose name shares the root prefix', async () => { + + const ctx = await buildContext(TEMPLATE_PATH, { + projectRoot: PROJECT_ROOT, + }); + + // Every case above walks *up* past the root, which a plain + // string prefix compare catches. `-evil` is outside the + // project by any reasonable reading and still satisfies + // `startsWith(root)` — the containment check has to be + // path-segment aware, not textual. + await expect( + ctx.include('../project-evil/secret.sql'), + ).rejects.toThrow('Include path escapes project root'); + + }); + + }); + + describe('$helpers tree walk containment', () => { + + it('should not collect $helpers from a sibling sharing the root prefix', async () => { + + // Same prefix-compare defect as include(), on the path that + // *executes* what it finds rather than inlining it. + const base = await mkdtemp(path.join(tmpdir(), 'noorm-helper-walk-')); + const root = path.join(base, 'project'); + const sibling = path.join(base, 'project-evil'); + + await mkdir(path.join(root, 'sql'), { recursive: true }); + await mkdir(path.join(sibling, 'sql'), { recursive: true }); + await writeFile( + path.join(sibling, '$helpers.ts'), + 'export const pwn = () => 1;\n', + 'utf-8', + ); + + const found = await findHelperFiles(path.join(sibling, 'sql'), root); + + expect(found).toEqual([]); + + await rm(base, { recursive: true, force: true }); + + }); + }); describe('edge cases', () => { From 2d51292113f221ee939ff461f0b1c2677ca5b33a Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:44:51 -0400 Subject: [PATCH 030/105] test(impersonate): cover the authorization boundary and injection guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../integration/impersonate/postgres.test.ts | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/tests/integration/impersonate/postgres.test.ts b/tests/integration/impersonate/postgres.test.ts index e05b327b..e222f39c 100644 --- a/tests/integration/impersonate/postgres.test.ts +++ b/tests/integration/impersonate/postgres.test.ts @@ -8,6 +8,7 @@ * Requires docker-compose.test.yml containers to be running. */ import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { attempt } from '@logosdx/utils'; import { sql } from 'kysely'; import { Context } from '../../../src/sdk/context.js'; @@ -110,4 +111,151 @@ describe('integration: impersonate postgres', () => { }); + it('refuses to impersonate a principal that does not exist', async () => { + + let callbackRan = false; + + const [, err] = await attempt(() => + ctx.impersonate('impersonate_no_such_role', async () => { + + callbackRan = true; + + }), + ); + + expect(err).toBeInstanceOf(Error); + expect(callbackRan).toBe(false); + + }); + + it('leaves the session identity intact after a failed impersonation', async () => { + + await attempt(() => ctx.impersonate('impersonate_no_such_role', async () => undefined)); + + // A failed SET ROLE must not leave the pooled connection carrying a + // half-applied identity for the next unrelated query to inherit. + const result = await sql.raw('SELECT current_user AS username').execute(ctx.kysely); + const row = (result.rows as Array<{ username: string }>)[0]; + + expect(row!.username).toBe('noorm_test'); + + }); + + it('rejects a username carrying SQL metacharacters before it reaches the server', async () => { + + const [, err] = await attempt(() => + ctx.impersonate("postgres'; DROP TABLE users; --", async () => undefined), + ); + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/invalid username/i); + + }); + +}); + +/** + * The authorization boundary proper. + * + * `impersonate()` is a testing affordance rather than a privilege boundary — + * the scope hands the caller arbitrary SQL on the same connection, so `RESET + * ROLE` is one query away. What it must never be is a privilege ESCALATION + * path. The main suite above cannot prove that: its connection user is a + * superuser in the test container, and postgres correctly lets a superuser + * SET ROLE to anything. This block connects as a deliberately unprivileged + * login role so a real membership check is exercised. + */ +describe('integration: impersonate postgres (unprivileged connection)', () => { + + let admin: Context; + let lowPriv: Context; + + const LOWPRIV_ROLE = 'impersonate_lowpriv'; + const TARGET_ROLE = 'impersonate_target'; + + beforeAll(async () => { + + await skipIfNoContainer('postgres'); + + admin = new Context( + makeTestConfig('pg_impersonate_admin', TEST_CONNECTIONS.postgres), + {}, { name: 'tester', source: 'system' }, {}, '/tmp/test', + ); + await admin.connect(); + + for (const role of [LOWPRIV_ROLE, TARGET_ROLE]) { + + await sql.raw(`DROP ROLE IF EXISTS ${role}`).execute(admin.kysely); + + } + + await sql.raw( + `CREATE ROLE ${LOWPRIV_ROLE} LOGIN PASSWORD 'lowpriv123'`, + ).execute(admin.kysely); + await sql.raw( + `CREATE ROLE ${TARGET_ROLE} LOGIN PASSWORD 'target123'`, + ).execute(admin.kysely); + await sql.raw( + `GRANT CONNECT ON DATABASE ${TEST_CONNECTIONS.postgres.database} TO ${LOWPRIV_ROLE}`, + ).execute(admin.kysely); + + // Deliberately no `GRANT ${TARGET_ROLE} TO ${LOWPRIV_ROLE}`. + lowPriv = new Context( + makeTestConfig('pg_impersonate_lowpriv', { + ...TEST_CONNECTIONS.postgres, + user: LOWPRIV_ROLE, + password: 'lowpriv123', + }), + {}, { name: 'lowpriv', source: 'system' }, {}, '/tmp/test', + ); + await lowPriv.connect(); + + }, 30_000); + + afterAll(async () => { + + if (lowPriv?.connected) await lowPriv.disconnect(); + + if (admin?.connected) { + + for (const role of [LOWPRIV_ROLE, TARGET_ROLE]) { + + await attempt(() => sql.raw(`DROP ROLE IF EXISTS ${role}`).execute(admin.kysely)); + + } + + await admin.disconnect(); + + } + + }); + + it('refuses to impersonate a role the connection is not a member of', async () => { + + let callbackRan = false; + + const [, err] = await attempt(() => + lowPriv.impersonate(TARGET_ROLE, async () => { + + callbackRan = true; + + }), + ); + + expect(err).toBeInstanceOf(Error); + expect(callbackRan).toBe(false); + + }); + + it('leaves the unprivileged session as itself after the refused attempt', async () => { + + await attempt(() => lowPriv.impersonate(TARGET_ROLE, async () => undefined)); + + const result = await sql.raw('SELECT current_user AS username').execute(lowPriv.kysely); + const row = (result.rows as Array<{ username: string }>)[0]; + + expect(row!.username).toBe(LOWPRIV_ROLE); + + }); + }); From 120c9c1a673612469f86791c7d5c48293e706c93 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:44:52 -0400 Subject: [PATCH 031/105] fix(debug): surface query failures instead of empty results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/debug/operations.ts | 82 ++++++++-- src/tui/screens/debug/DebugDetailScreen.tsx | 2 +- src/tui/screens/debug/DebugListScreen.tsx | 4 +- src/tui/screens/debug/DebugOverviewScreen.tsx | 32 ++-- tests/core/debug/operations.test.ts | 148 +++++++++++++++++- 5 files changed, 239 insertions(+), 29 deletions(-) diff --git a/src/core/debug/operations.ts b/src/core/debug/operations.ts index e3535155..3cf29318 100644 --- a/src/core/debug/operations.ts +++ b/src/core/debug/operations.ts @@ -40,13 +40,20 @@ export interface NoormTableInfo { /** * Table row count result. + * + * `count` is nullable because a table whose count query failed must not be + * reportable as a table with zero rows — the two render identically and the + * caller has no other way to tell them apart. */ export interface TableCountResult { /** Table name */ table: NoormTableName; - /** Number of rows */ - count: number; + /** Number of rows, or `null` when the count query failed */ + count: number | null; + + /** Failure message; present only when `count` is `null` */ + error?: string; } /** @@ -91,22 +98,24 @@ export interface DebugPolicyContext { /** * Debug operations interface. * - * Every method throws when the policy denies it. + * Every method throws when the policy denies it, when the table is not a + * noorm table, or when the query fails. Falsy return values mean only + * "not found" / "nothing deleted", never "something went wrong". */ export interface DebugOperations { - /** Get row counts for all noorm tables */ + /** Get row counts for all noorm tables; a per-table failure yields `count: null` */ getTableCounts(): Promise; /** Get rows from a specific table */ getTableRows(table: NoormTableName, options?: GetRowsOptions): Promise; - /** Get a single row by ID */ + /** Get a single row by ID; `null` means the row does not exist */ getRowById(table: NoormTableName, id: number): Promise; - /** Delete a single row by ID */ + /** Delete a single row by ID; `false` means the row did not exist */ deleteRowById(table: NoormTableName, id: number): Promise; - /** Delete multiple rows by IDs */ + /** Delete multiple rows by IDs; returns how many existed */ deleteRowsByIds(table: NoormTableName, ids: number[]): Promise; /** Get column names for a table */ @@ -282,7 +291,33 @@ export function createDebugOperations( }; - const resolveTable = (table: NoormTableName): string => nameMap[table] ?? table; + const resolveTable = (table: NoormTableName): string => { + + const resolved = nameMap[table]; + + if (!resolved) { + + throw new Error(`"${table}" is not a noorm internal table.`); + + } + + return resolved; + + }; + + const columnsFor = (table: NoormTableName): string[] => { + + const columns = TABLE_COLUMNS[table]; + + if (!columns) { + + throw new Error(`"${table}" is not a noorm internal table.`); + + } + + return columns; + + }; return { @@ -312,7 +347,9 @@ export function createDebugOperations( context: { table: info.name, operation: 'count' }, }); - results.push({ table: info.name, count: 0 }); + // One unreadable table must not abort the whole overview — + // but it must not read as "empty" either. + results.push({ table: info.name, count: null, error: err.message }); } else { @@ -340,6 +377,23 @@ export function createDebugOperations( const { limit = 100, sortColumn = 'id', sortDirection = 'desc' } = options; const queryTable = resolveTable(table); + const columns = columnsFor(table); + + // `sortColumn` is caller-supplied and reaches orderBy() as an + // identifier. Kysely quotes it, so this is not an injection vector — + // but an unknown value produces a driver error that used to be + // swallowed into an empty result. Reject it by name instead. + if (!columns.includes(sortColumn)) { + + throw new Error(`"${sortColumn}" is not a column of ${table}.`); + + } + + if (sortDirection !== 'asc' && sortDirection !== 'desc') { + + throw new Error(`"${sortDirection}" is not a sort direction.`); + + } const [rows, err] = await attempt(() => // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -359,7 +413,7 @@ export function createDebugOperations( context: { table, operation: 'get-rows' }, }); - return []; + throw err; } @@ -389,7 +443,7 @@ export function createDebugOperations( context: { table, id, operation: 'get-row' }, }); - return null; + throw err; } @@ -418,7 +472,7 @@ export function createDebugOperations( context: { table, id, operation: 'delete-row' }, }); - return false; + throw err; } @@ -455,7 +509,7 @@ export function createDebugOperations( context: { table, ids, operation: 'delete-rows' }, }); - return 0; + throw err; } @@ -467,7 +521,7 @@ export function createDebugOperations( gate('debug:read'); - return TABLE_COLUMNS[table] ?? ['id']; + return columnsFor(table); }, diff --git a/src/tui/screens/debug/DebugDetailScreen.tsx b/src/tui/screens/debug/DebugDetailScreen.tsx index bebdf297..f6274be4 100644 --- a/src/tui/screens/debug/DebugDetailScreen.tsx +++ b/src/tui/screens/debug/DebugDetailScreen.tsx @@ -132,7 +132,7 @@ export function DebugDetailScreen({ params }: ScreenProps): ReactElement { } else { - showToast({ message: 'Delete failed', variant: 'error' }); + showToast({ message: `Row #${rowId} no longer exists`, variant: 'error' }); } diff --git a/src/tui/screens/debug/DebugListScreen.tsx b/src/tui/screens/debug/DebugListScreen.tsx index dc7fdd12..8f48d269 100644 --- a/src/tui/screens/debug/DebugListScreen.tsx +++ b/src/tui/screens/debug/DebugListScreen.tsx @@ -288,7 +288,7 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { } else { - showToast({ message: 'Delete failed', variant: 'error' }); + showToast({ message: `Row #${rowId} no longer exists`, variant: 'error' }); } @@ -312,7 +312,7 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { } else { - showToast({ message: 'Delete failed', variant: 'error' }); + showToast({ message: 'No matching rows to delete', variant: 'error' }); } diff --git a/src/tui/screens/debug/DebugOverviewScreen.tsx b/src/tui/screens/debug/DebugOverviewScreen.tsx index 75a0f553..a2ef2f6d 100644 --- a/src/tui/screens/debug/DebugOverviewScreen.tsx +++ b/src/tui/screens/debug/DebugOverviewScreen.tsx @@ -177,15 +177,21 @@ export function DebugOverviewScreen({ params: _params }: ScreenProps): ReactElem } - // Get count for a table - const getCount = (table: NoormTableName): number => { + // Get count for a table. `null` means the count query failed — kept + // distinct from 0 so an unreadable table doesn't render as an empty one. + const getCount = (table: NoormTableName): number | null => { - return counts.find((c) => c.table === table)?.count ?? 0; + const result = counts.find((c) => c.table === table); + + if (!result) return 0; + + return result.count; }; - // Calculate total rows - const totalRows = counts.reduce((sum, c) => sum + c.count, 0); + // Calculate total rows across the tables that could actually be counted + const totalRows = counts.reduce((sum, c) => sum + (c.count ?? 0), 0); + const failedCount = counts.filter((c) => c.count === null).length; return ( @@ -206,6 +212,9 @@ export function DebugOverviewScreen({ params: _params }: ScreenProps): ReactElem Total Rows: {totalRows} + {failedCount > 0 && ( + ({failedCount} table{failedCount === 1 ? '' : 's'} unreadable) + )} {/* Table list */} @@ -213,7 +222,8 @@ export function DebugOverviewScreen({ params: _params }: ScreenProps): ReactElem {NOORM_TABLE_INFO.map((info, index) => { const count = getCount(info.name); - const hasRows = count > 0; + const failed = count === null; + const hasRows = count !== null && count > 0; return ( @@ -228,9 +238,13 @@ export function DebugOverviewScreen({ params: _params }: ScreenProps): ReactElem {info.description} - - {count} {count === 1 ? 'row' : 'rows'} - + {failed + ? error + : ( + + {count} {count === 1 ? 'row' : 'rows'} + + )} ); diff --git a/tests/core/debug/operations.test.ts b/tests/core/debug/operations.test.ts index 0dca4315..39142301 100644 --- a/tests/core/debug/operations.test.ts +++ b/tests/core/debug/operations.test.ts @@ -2,7 +2,10 @@ * Debug operations tests. * * `core/debug` reads and deletes rows in noorm's own bookkeeping tables — - * including `vault` and `identities` — and nothing authorized those deletes. + * including `vault` and `identities`. These tests exist to pin three things + * the module previously got wrong: nothing authorized the deletes, a failed + * query was reported as an empty table, and an arbitrary string reached + * `orderBy()`. */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { @@ -22,11 +25,11 @@ import { SqliteIntrospector, SqliteQueryCompiler, } from 'kysely'; -import { attempt } from '@logosdx/utils'; +import { attempt, attemptSync } from '@logosdx/utils'; import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; import { createDebugOperations, type DebugPolicyContext } from '../../../src/core/debug/index.js'; -import { NOORM_TABLES, type NoormDatabase } from '../../../src/core/shared/index.js'; +import { NOORM_TABLES, type NoormDatabase, type NoormTableName } from '../../../src/core/shared/index.js'; import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; import type { Dialect } from '../../../src/core/connection/types.js'; import { observer } from '../../../src/core/observer.js'; @@ -357,10 +360,78 @@ describe('debug: createDebugOperations', () => { }); + it('should reject an unknown table instead of silently claiming it has one id column', () => { + + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + const [, err] = attemptSync(() => ops.getTableColumns('nope' as NoormTableName)); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('nope'); + + }); + }); describe('getTableRows', () => { + it('should reject a sortColumn that is not a column of the table', async () => { + + await seedVault(db, 1); + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + const [, err] = await attempt(() => + ops.getTableRows(NOORM_TABLES.vault, { sortColumn: 'created_at_typo' }), + ); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('created_at_typo'); + + }); + + it('should reject SQL fragments in sortColumn without ever reaching the database', async () => { + + await seedVault(db, 2); + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + const payloads = [ + 'id; drop table __noorm_vault__ --', + '1; DELETE FROM __noorm_vault__', + 'id" ; drop table "__noorm_vault__" --', + 'noorm.vault', + 'id desc', + ]; + + for (const sortColumn of payloads) { + + const [rows, err] = await attempt(() => ops.getTableRows(NOORM_TABLES.vault, { sortColumn })); + + // A rejected payload must be an error, never an empty result set — + // those are indistinguishable to the caller. + expect(err).toBeInstanceOf(Error); + expect(rows).toBeNull(); + + } + + const survivors = await db.selectFrom(NOORM_TABLES.vault).selectAll().execute(); + + expect(survivors).toHaveLength(2); + + }); + + it('should reject an unknown sort direction', async () => { + + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + const [, err] = await attempt(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ops.getTableRows(NOORM_TABLES.vault, { sortDirection: 'asc; drop table x' as any }), + ); + + expect(err).toBeInstanceOf(Error); + + }); + it('should honour limit and sort direction', async () => { await seedVault(db, 5); @@ -375,6 +446,19 @@ describe('debug: createDebugOperations', () => { }); + it('should surface a query failure as an error rather than an empty table', async () => { + + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + await db.schema.dropTable(NOORM_TABLES.vault).execute(); + + const [rows, err] = await attempt(() => ops.getTableRows(NOORM_TABLES.vault)); + + expect(err).toBeInstanceOf(Error); + expect(rows).toBeNull(); + + }); + it('should emit an error event when the query fails', async () => { const ops = createDebugOperations(db, 'sqlite', ADMIN); @@ -406,6 +490,19 @@ describe('debug: createDebugOperations', () => { }); + it('should surface a query failure as an error, not as a missing row', async () => { + + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + await db.schema.dropTable(NOORM_TABLES.vault).execute(); + + const [row, err] = await attempt(() => ops.getRowById(NOORM_TABLES.vault, 1)); + + expect(err).toBeInstanceOf(Error); + expect(row).toBeNull(); + + }); + }); describe('deleteRowById', () => { @@ -418,6 +515,19 @@ describe('debug: createDebugOperations', () => { }); + it('should surface a delete failure as an error, not as "row not found"', async () => { + + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + await db.schema.dropTable(NOORM_TABLES.vault).execute(); + + const [ok, err] = await attempt(() => ops.deleteRowById(NOORM_TABLES.vault, 1)); + + expect(err).toBeInstanceOf(Error); + expect(ok).toBeNull(); + + }); + }); describe('deleteRowsByIds', () => { @@ -456,6 +566,19 @@ describe('debug: createDebugOperations', () => { }); + it('should surface a bulk-delete failure as an error rather than 0 rows deleted', async () => { + + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + await db.schema.dropTable(NOORM_TABLES.vault).execute(); + + const [count, err] = await attempt(() => ops.deleteRowsByIds(NOORM_TABLES.vault, [1, 2])); + + expect(err).toBeInstanceOf(Error); + expect(count).toBeNull(); + + }); + }); describe('getTableCounts', () => { @@ -474,6 +597,25 @@ describe('debug: createDebugOperations', () => { }); + it('should report a failed count as an error, not as an empty table', async () => { + + const ops = createDebugOperations(db, 'sqlite', ADMIN); + + await db.schema.dropTable(NOORM_TABLES.vault).execute(); + + const counts = await ops.getTableCounts(); + const vault = counts.find((c) => c.table === NOORM_TABLES.vault); + const change = counts.find((c) => c.table === NOORM_TABLES.change); + + // The distinction the UI depends on: a table that errored must not + // render identically to a table that is genuinely empty. + expect(vault?.count).toBeNull(); + expect(vault?.error).toBeTruthy(); + expect(change?.count).toBe(0); + expect(change?.error).toBeUndefined(); + + }); + }); }); From 80d6f42ff2f938b98f479fa72deddb47fbb47a2b Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:45:57 -0400 Subject: [PATCH 032/105] 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. --- src/core/change/executor.ts | 6 ++--- tests/core/change/manager.test.ts | 39 ++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/core/change/executor.ts b/src/core/change/executor.ts index 64036825..93a2fa73 100644 --- a/src/core/change/executor.ts +++ b/src/core/change/executor.ts @@ -1302,9 +1302,9 @@ async function loadAndRenderFile(context: ChangeContext, filepath: string): Prom const result = await processFile(filepath, { projectRoot: context.projectRoot, - config: undefined, // Change context doesn't have config - secrets: undefined, - globalSecrets: undefined, + config: context.config, + secrets: context.secrets, + globalSecrets: context.globalSecrets, }); return result.sql; diff --git a/tests/core/change/manager.test.ts b/tests/core/change/manager.test.ts index c6e382e2..3c25746e 100644 --- a/tests/core/change/manager.test.ts +++ b/tests/core/change/manager.test.ts @@ -66,7 +66,7 @@ describe('change: manager', () => { /** * Build a test context. */ - function buildContext(): ChangeContext { + function buildContext(extra: Partial = {}): ChangeContext { return { db, @@ -78,6 +78,7 @@ describe('change: manager', () => { access: { user: 'admin', mcp: 'admin' }, channel: 'user', dialect: 'sqlite', + ...extra, }; } @@ -476,6 +477,42 @@ describe('change: manager', () => { }); + describe('template context', () => { + + it('should resolve $.secrets and $.config inside a change template', async () => { + + await createTestChange('tmpl', [ + { + name: '001.sql.tmpl', + content: 'CREATE TABLE tmpl_target (id INTEGER PRIMARY KEY,' + + " secret TEXT DEFAULT '{%= $.secrets.PROBE %}'," + + " cfg TEXT DEFAULT '{%= $.config.name %}')", + }, + ]); + + const manager = new ChangeManager(buildContext({ + config: { name: 'audit' }, + secrets: { PROBE: 'myvalue123' }, + globalSecrets: {}, + })); + + const result = await manager.run('tmpl'); + + expect(result.status).toBe('success'); + + // The rendered values must have reached the executed SQL, not + // just rendered without throwing. + const ddl = await sql<{ sql: string }>` + SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tmpl_target' + `.execute(db); + + expect(ddl.rows[0]?.sql).toContain('myvalue123'); + expect(ddl.rows[0]?.sql).toContain('audit'); + + }); + + }); + describe('history read failures', () => { it('should not report a successful revert when the history read failed', async () => { From e83313d60e150860c46836cf0744209fc1120f60 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:45:57 -0400 Subject: [PATCH 033/105] 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. --- src/core/change/parser.ts | 12 +++++++++--- tests/core/change/parser.test.ts | 11 +++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/core/change/parser.ts b/src/core/change/parser.ts index 23080e10..8a45b014 100644 --- a/src/core/change/parser.ts +++ b/src/core/change/parser.ts @@ -223,9 +223,13 @@ export async function discoverChanges( /** * Resolve a .txt manifest file to actual SQL paths. * + * Returns paths in the order the manifest lists them: the file is an + * authored execution order, so it is the one ordering signal a change + * gives the user. + * * @param manifestPath - Path to the .txt manifest file * @param sqlDir - Schema directory for resolving relative paths - * @returns Array of absolute paths to SQL files + * @returns Absolute paths to SQL files, in manifest line order * @throws ManifestReferenceError if any referenced file is missing * * @example @@ -290,8 +294,10 @@ export async function resolveManifest(manifestPath: string, sqlDir: string): Pro } - // Sort alphabetically (as per plan: files executed in sorted order) - return resolvedPaths.sort(); + // Line order is the author's execution order — a table before the view + // that selects from it. Sorting here reordered across directories and + // silently broke dependency order the manifest existed to express. + return resolvedPaths; } diff --git a/tests/core/change/parser.test.ts b/tests/core/change/parser.test.ts index c2db0ef4..14f3903f 100644 --- a/tests/core/change/parser.test.ts +++ b/tests/core/change/parser.test.ts @@ -219,15 +219,18 @@ describe('change: parser', () => { }); - it('should sort resolved paths alphabetically', async () => { + it('should preserve the order the manifest lists files in', async () => { + // A manifest is an authored execution order — a table before the + // view that selects from it. Sorting the resolved paths silently + // discarded that, which this test previously pinned as correct. const manifestPath = path.join(MANIFESTS_DIR, 'unsorted.txt'); const paths = await resolveManifest(manifestPath, SCHEMA_DIR); - expect(paths[0]).toContain('a.sql'); - expect(paths[1]).toContain('b.sql'); - expect(paths[2]).toContain('c.sql'); + expect(paths[0]).toContain('b.sql'); + expect(paths[1]).toContain('c.sql'); + expect(paths[2]).toContain('a.sql'); }); From a5a913bcf5dcd03fdc51b07871007f39a34c06ff Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:45:58 -0400 Subject: [PATCH 034/105] fix(transfer): stop postgres self-copying on same-server path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/transfer/dialects/postgres.ts | 26 ++++++------- src/core/transfer/same-server.ts | 25 ++++++------ tests/core/transfer/dialects/postgres.test.ts | 39 ++++++------------- tests/core/transfer/same-server.test.ts | 36 +++++++---------- 4 files changed, 51 insertions(+), 75 deletions(-) diff --git a/src/core/transfer/dialects/postgres.ts b/src/core/transfer/dialects/postgres.ts index 2fefd072..d0a09547 100644 --- a/src/core/transfer/dialects/postgres.ts +++ b/src/core/transfer/dialects/postgres.ts @@ -136,23 +136,19 @@ export const postgresTransferOperations: TransferDialectOperations = { buildDirectTransfer( srcDb: string, srcTable: string, - dstTable: string, - columns: string[], - srcSchema = 'public', - dstSchema = 'public', + _dstTable: string, + _columns: string[], ): string { - const quotedCols = columns.map(quoteIdent).join(', '); - - // PostgreSQL uses dblink or postgres_fdw for cross-database - // For same-server, we use the database name in the connection - // This assumes we're connected to the destination and source is accessible - // In practice, Kysely doesn't support cross-database queries directly - // So we use a simpler approach: assume same database, different schemas not applicable - const srcFull = `${quoteIdent(srcSchema)}.${quoteIdent(srcTable)}`; - const dstFull = `${quoteIdent(dstSchema)}.${quoteIdent(dstTable)}`; - - return `INSERT INTO ${dstFull} (${quotedCols}) OVERRIDING SYSTEM VALUE SELECT ${quotedCols} FROM ${srcFull}`; + // Unreachable: isSameServer() never reports postgres as same-server, + // precisely because there is no statement to build. The old body + // dropped srcDb and emitted `INSERT INTO t SELECT ... FROM t`, which + // copied the destination into itself. Throwing keeps that from + // reappearing if the routing changes. + throw new Error( + `PostgreSQL has no direct cross-database transfer for ${srcDb}.${srcTable} ` + + '— it needs dblink or postgres_fdw. Use the batch transfer path.', + ); }, diff --git a/src/core/transfer/same-server.ts b/src/core/transfer/same-server.ts index 161753ff..df602af6 100644 --- a/src/core/transfer/same-server.ts +++ b/src/core/transfer/same-server.ts @@ -34,10 +34,14 @@ function normalizeHost(host: string | undefined): string { * - Same dialect * - Same host (after normalization) * - Same port - * - Same database (required for PostgreSQL, optional for MySQL/MSSQL) + * - A dialect that can name the source database in a query + * + * PostgreSQL is never "same server". Without dblink/postgres_fdw it cannot + * reach another database, and when both configs name the *same* database the + * direct statement degenerates to `INSERT INTO t SELECT ... FROM t` — copying + * the destination into itself. Neither outcome is a transfer, so postgres + * always takes the batch path. * - * Note: PostgreSQL cannot do cross-database queries without extensions, - * so different databases on the same host are NOT considered "same server". * MySQL and MSSQL can query across databases on the same server. * * SQLite is never considered "same server" since there's no server. @@ -75,6 +79,13 @@ export function isSameServer( } + // PostgreSQL has no reachable source to select from — see the doc comment. + if (source.dialect === 'postgres') { + + return false; + + } + const srcHost = normalizeHost(source.host); const dstHost = normalizeHost(dest.host); @@ -88,14 +99,6 @@ export function isSameServer( } - // PostgreSQL cannot do cross-database queries without extensions - // So different databases = not same server for optimization purposes - if (source.dialect === 'postgres') { - - return source.database === dest.database; - - } - // MySQL and MSSQL support cross-database queries on same server return true; diff --git a/tests/core/transfer/dialects/postgres.test.ts b/tests/core/transfer/dialects/postgres.test.ts index 39f18667..209331ce 100644 --- a/tests/core/transfer/dialects/postgres.test.ts +++ b/tests/core/transfer/dialects/postgres.test.ts @@ -175,49 +175,32 @@ describe('transfer: postgres dialect', () => { }); + // These previously asserted the emitted INSERT...SELECT, which is exactly + // the defect: srcDb was discarded, so the statement read from and wrote to + // the same table. There is no correct statement to assert — postgres needs + // dblink/postgres_fdw to reach another database — so the contract is now + // "refuse loudly". describe('buildDirectTransfer', () => { - it('should generate INSERT...SELECT with OVERRIDING SYSTEM VALUE', () => { + it('should refuse rather than emit a self-copy', () => { - const sql = postgresTransferOperations.buildDirectTransfer( + expect(() => postgresTransferOperations.buildDirectTransfer( 'source_db', 'users', 'users', ['id', 'name', 'email'], - ); - - expect(sql).toContain('INSERT INTO'); - expect(sql).toContain('OVERRIDING SYSTEM VALUE'); - expect(sql).toContain('SELECT "id", "name", "email"'); + )).toThrow(/dblink or postgres_fdw/); }); - it('should use schema prefixes', () => { + it('should name the source it could not reach', () => { - const sql = postgresTransferOperations.buildDirectTransfer( - 'source_db', - 'users', - 'users', - ['id', 'name'], - 'src_schema', - 'dst_schema', - ); - - expect(sql).toContain('"dst_schema"."users"'); - expect(sql).toContain('"src_schema"."users"'); - - }); - - it('should default to public schema', () => { - - const sql = postgresTransferOperations.buildDirectTransfer( + expect(() => postgresTransferOperations.buildDirectTransfer( 'source_db', 'users', 'users', ['id'], - ); - - expect(sql).toContain('"public"."users"'); + )).toThrow(/source_db\.users/); }); diff --git a/tests/core/transfer/same-server.test.ts b/tests/core/transfer/same-server.test.ts index 6dce05b4..755984d0 100644 --- a/tests/core/transfer/same-server.test.ts +++ b/tests/core/transfer/same-server.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'bun:test'; import { isSameServer, getDefaultPort } from '../../../src/core/transfer/same-server.js'; +import { postgresTransferOperations } from '../../../src/core/transfer/dialects/postgres.js'; import type { ConnectionConfig } from '../../../src/core/connection/types.js'; describe('transfer: same-server', () => { @@ -220,25 +221,6 @@ describe('transfer: same-server', () => { }); - it('should use default port when not specified (postgres)', () => { - - const source: ConnectionConfig = { - dialect: 'postgres', - host: 'localhost', - database: 'same_db', - }; - - const dest: ConnectionConfig = { - dialect: 'postgres', - host: 'localhost', - port: 5432, - database: 'same_db', - }; - - expect(isSameServer(source, dest)).toBe(true); - - }); - it('should use default port when not specified (mysql)', () => { const source: ConnectionConfig = { @@ -302,7 +284,11 @@ describe('transfer: same-server', () => { }); - it('should return true for postgres with same database', () => { + // The direct path builds `INSERT INTO t SELECT ... FROM t` when + // source and destination name the same database — it copies the + // destination into itself. Reporting same-server here is what let + // a 3-row PK-less table become 6 rows and report success. + it('should return false for postgres with the same database', () => { const source: ConnectionConfig = { dialect: 'postgres', @@ -318,7 +304,15 @@ describe('transfer: same-server', () => { database: 'same_db', }; - expect(isSameServer(source, dest)).toBe(true); + expect(isSameServer(source, dest)).toBe(false); + + }); + + it('should refuse to build a direct postgres transfer', () => { + + expect(() => postgresTransferOperations.buildDirectTransfer( + 'src_db', 'users', 'users', ['id'], + )).toThrow(/dblink or postgres_fdw/); }); From 4df4704ee4c31214ed43831d9c992417c7d7f1d4 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:46:44 -0400 Subject: [PATCH 035/105] fix(tui): honour global dry-run in truncate and teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/tui/screens/db/DbTeardownScreen.tsx | 6 +- src/tui/screens/db/DbTruncateScreen.tsx | 6 +- tests/cli/screens/db/db-dry-run.test.tsx | 290 +++++++++++++++++++++++ 3 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 tests/cli/screens/db/db-dry-run.test.tsx diff --git a/src/tui/screens/db/DbTeardownScreen.tsx b/src/tui/screens/db/DbTeardownScreen.tsx index 393892cc..35baecda 100644 --- a/src/tui/screens/db/DbTeardownScreen.tsx +++ b/src/tui/screens/db/DbTeardownScreen.tsx @@ -17,7 +17,7 @@ import type { ScreenProps } from '../../types.js'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; -import { useAppContext, useSettings } from '../../app-context.js'; +import { useAppContext, useSettings, useGlobalModes } from '../../app-context.js'; import { useToast, Panel, Spinner, ProtectedConfirm } from '../../components/index.js'; import { previewTeardown, teardownSchema } from '../../../core/teardown/index.js'; import { formatIdentity } from '../../../core/identity/index.js'; @@ -49,6 +49,7 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement const { back } = useRouter(); const { isFocused } = useFocusScope('DbTeardown'); const { activeConfig, activeConfigName, identity: cryptoIdentity } = useAppContext(); + const globalModes = useGlobalModes(); const { settings } = useSettings(); const { showToast } = useToast(); const check = activeConfig ? checkConfigPolicy('user', activeConfig, 'db:reset') : null; @@ -144,6 +145,7 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement postScript, configName: activeConfigName, executedBy: formatIdentity(identity), + dryRun: globalModes.dryRun, }); setResult(teardownResult); @@ -162,7 +164,7 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement setPhase('done'); - }, [activeConfig, activeConfigName, preserveTables, postScript, cryptoIdentity]); + }, [activeConfig, activeConfigName, preserveTables, postScript, cryptoIdentity, globalModes.dryRun]); // Get categories that have items const nonEmptyCategories = useMemo(() => { diff --git a/src/tui/screens/db/DbTruncateScreen.tsx b/src/tui/screens/db/DbTruncateScreen.tsx index 181469c7..89a53b48 100644 --- a/src/tui/screens/db/DbTruncateScreen.tsx +++ b/src/tui/screens/db/DbTruncateScreen.tsx @@ -24,7 +24,7 @@ import { fetchList } from '../../../core/explore/operations.js'; import type { ScreenProps } from '../../types.js'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; -import { useAppContext, useSettings } from '../../app-context.js'; +import { useAppContext, useSettings, useGlobalModes } from '../../app-context.js'; import { useToast, Panel, Spinner, ProtectedConfirm } from '../../components/index.js'; import { useConnection, useAsyncEffect } from '../../hooks/index.js'; import { getErrorMessage, withScreenConnection } from '../../utils/index.js'; @@ -40,6 +40,7 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement const { back } = useRouter(); const { isFocused } = useFocusScope('DbTruncate'); const { activeConfig, activeConfigName } = useAppContext(); + const globalModes = useGlobalModes(); const { settings } = useSettings(); const { showToast } = useToast(); const check = activeConfig ? checkConfigPolicy('user', activeConfig, 'db:reset') : null; @@ -124,6 +125,7 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement const truncateResult = await truncateData(db as Kysely, activeConfig.connection.dialect, { preserve: preserveTables, restartIdentity: true, + dryRun: globalModes.dryRun, }); setResult(truncateResult); @@ -142,7 +144,7 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement setPhase('done'); - }, [activeConfig, activeConfigName, preserveTables]); + }, [activeConfig, activeConfigName, preserveTables, globalModes.dryRun]); // Keyboard handling useInput((input, key) => { diff --git a/tests/cli/screens/db/db-dry-run.test.tsx b/tests/cli/screens/db/db-dry-run.test.tsx new file mode 100644 index 00000000..e8f77bc9 --- /dev/null +++ b/tests/cli/screens/db/db-dry-run.test.tsx @@ -0,0 +1,290 @@ +/** + * Global dry-run wiring for the destructive `db` screens. + * + * `D` toggles dry-run from anywhere and the status bar renders the DRY badge + * app-wide (src/tui/app.tsx), but only the change and run screens ever read + * `globalModes`. `truncateData` and `teardownSchema` both accept `dryRun` + * (src/core/teardown/types.ts) and the CLI passes it, so a user could toggle + * DRY, watch the badge, confirm, and have the schema dropped for real -- the + * same class as the change-screen defect fixed in change-dry-run.test.tsx. + * + * `truncateData`/`teardownSchema` are swapped via `mock.module` to record the + * options object rather than touch a database; the connection itself is a real + * in-memory sqlite one through the real ConnectionProvider. + */ +import { describe, it, expect, vi, mock, beforeEach, afterEach, afterAll } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React, { useEffect } from 'react'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { TruncateOptions, TeardownOptions } from '../../../../src/core/teardown/types.js'; + +import { FocusProvider } from '../../../../src/tui/focus.js'; +import { RouterProvider } from '../../../../src/tui/router.js'; +import { AppContextProvider, useAppContext } from '../../../../src/tui/app-context.js'; +import { ConnectionProvider } from '../../../../src/tui/providers/ConnectionProvider.js'; +import { ToastProvider } from '../../../../src/tui/components/index.js'; +import { DbTruncateScreen } from '../../../../src/tui/screens/db/DbTruncateScreen.js'; +import { DbTeardownScreen } from '../../../../src/tui/screens/db/DbTeardownScreen.js'; + +const actualCore = await import('../../../../src/core/index.js'); +const actualIdentity = await import('../../../../src/core/identity/index.js'); +const actualTeardown = await import('../../../../src/core/teardown/index.js'); +const actualExplore = await import('../../../../src/core/explore/operations.js'); + +/** Options objects handed to the two core entry points. */ +const truncateCalls: TruncateOptions[] = []; +const teardownCalls: TeardownOptions[] = []; + +mock.module('../../../../src/core/teardown/index.js', () => ({ + ...actualTeardown, + truncateData: vi.fn(async (_db: unknown, _dialect: unknown, options: TruncateOptions) => { + + truncateCalls.push(options); + + return { tablesTruncated: [], rowsDeleted: 0, skipped: [] }; + + }), + teardownSchema: vi.fn(async (_db: unknown, _dialect: unknown, options: TeardownOptions) => { + + teardownCalls.push(options); + + return { + dropped: { tables: [], views: [], functions: [], procedures: [], types: [], foreignKeys: [] }, + preserved: [], + durationMs: 0, + }; + + }), +})); + +mock.module('../../../../src/core/explore/operations.js', () => ({ + ...actualExplore, + fetchList: vi.fn(async () => [{ name: 'widgets', schema: 'main', rowCount: 3 }]), +})); + +/** Admin sqlite config: `db:reset` is `allow`, so the plain confirm renders. */ +function makeConfig() { + + return { + name: 'test', + type: 'local' as const, + isTest: true, + access: { user: 'admin' as const, mcp: 'admin' as const }, + connection: { + dialect: 'sqlite' as const, + database: ':memory:', + }, + }; + +} + +const createMockStateManager = () => ({ + load: vi.fn().mockResolvedValue(undefined), + getActiveConfig: vi.fn().mockReturnValue(makeConfig()), + getActiveConfigName: vi.fn().mockReturnValue('test'), + listConfigs: vi.fn().mockReturnValue([makeConfig()]), + getConfig: vi.fn().mockReturnValue(makeConfig()), + setConfig: vi.fn().mockResolvedValue(undefined), + setActiveConfig: vi.fn().mockResolvedValue(undefined), + hasPrivateKey: vi.fn().mockReturnValue(true), + isLoaded: true, +}); + +const createMockSettingsManager = () => ({ + load: vi.fn().mockResolvedValue({ version: '0.1.0' }), + isLoaded: true, + settings: { version: '0.1.0' }, + getStages: vi.fn().mockReturnValue({}), + getStage: vi.fn().mockReturnValue(undefined), +}); + +let mockStateManager = createMockStateManager(); +let mockSettingsManager = createMockSettingsManager(); + +mock.module('../../../../src/core/index.js', () => ({ + observer: actualCore.observer, + getStateManager: vi.fn(() => mockStateManager), + getSettingsManager: vi.fn(() => mockSettingsManager), + resetStateManager: vi.fn(), + resetSettingsManager: vi.fn(), +})); + +mock.module('../../../../src/core/identity/index.js', () => ({ + loadExistingIdentity: vi.fn().mockResolvedValue(null), +})); + +/** Drives dry-run through the real setter, as the global `D` hotkey does. */ +function GlobalModeSetter({ dryRun }: { dryRun: boolean }): null { + + const { globalModes, toggleDryRun } = useAppContext(); + + useEffect(() => { + + if (globalModes.dryRun !== dryRun) toggleDryRun(); + + }, [globalModes, dryRun, toggleDryRun]); + + return null; + +} + +describe('cli: destructive db screens honour global dry-run', () => { + + let tempDir: string; + + const tick = (ms = 300) => new Promise((r) => setTimeout(r, ms)); + + function tree(Screen: React.ComponentType<{ params: object }>, dryRun: boolean) { + + return ( + + + + + + + + + + + + + ); + + } + + beforeEach(async () => { + + vi.clearAllMocks(); + actualCore.observer.clear(); + + delete process.env['NOORM_YES']; + + truncateCalls.length = 0; + teardownCalls.length = 0; + mockStateManager = createMockStateManager(); + mockSettingsManager = createMockSettingsManager(); + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-db-dry-test-')); + + }); + + afterEach(async () => { + + actualCore.observer.clear(); + + await rm(tempDir, { recursive: true, force: true }); + + }); + + afterAll(() => { + + mock.module('../../../../src/core/index.js', () => actualCore); + mock.module('../../../../src/core/identity/index.js', () => actualIdentity); + mock.module('../../../../src/core/teardown/index.js', () => actualTeardown); + mock.module('../../../../src/core/explore/operations.js', () => actualExplore); + + }); + + describe('DbTruncateScreen', () => { + + async function runTruncate(dryRun: boolean) { + + const { stdin, lastFrame, unmount } = render(tree(DbTruncateScreen, dryRun)); + + await tick(600); + + // preview -> confirm + stdin.write('\r'); + await tick(); + + expect(lastFrame() ?? '').toContain('Confirm'); + + // previewTeardown/preview passes run their own dry-run probes + // through these same entry points; only the post-confirm call is + // the one under test. + truncateCalls.length = 0; + + stdin.write('yes-test'); + await tick(); + stdin.write('\r'); + await tick(400); + + unmount(); + + expect(truncateCalls).toHaveLength(1); + + return truncateCalls[0]; + + } + + it('should pass dryRun: true when global dry-run is on', async () => { + + const options = await runTruncate(true); + + expect(options?.dryRun).toBe(true); + + }); + + it('should pass dryRun: false when global dry-run is off', async () => { + + const options = await runTruncate(false); + + expect(options?.dryRun).toBe(false); + + }); + + }); + + describe('DbTeardownScreen', () => { + + async function runTeardown(dryRun: boolean) { + + const { stdin, lastFrame, unmount } = render(tree(DbTeardownScreen, dryRun)); + + await tick(600); + + stdin.write('\r'); + await tick(); + + expect(lastFrame() ?? '').toContain('Confirm'); + + // previewTeardown runs a dry-run teardown of its own to build the + // preview; only the post-confirm call is the one under test. + teardownCalls.length = 0; + + stdin.write('yes-test'); + await tick(); + stdin.write('\r'); + await tick(400); + + unmount(); + + expect(teardownCalls).toHaveLength(1); + + return teardownCalls[0]; + + } + + it('should pass dryRun: true when global dry-run is on', async () => { + + const options = await runTeardown(true); + + expect(options?.dryRun).toBe(true); + + }); + + it('should pass dryRun: false when global dry-run is off', async () => { + + const options = await runTeardown(false); + + expect(options?.dryRun).toBe(false); + + }); + + }); + +}); From 1e95b72c22737d61cb62c6defd7002e25ec261e4 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:46:46 -0400 Subject: [PATCH 036/105] fix(cli): target the active config in sql history and clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/sql/_config.ts | 44 ++++++ src/cli/sql/clear.ts | 10 +- src/cli/sql/history.ts | 11 +- tests/cli/sql/history-config.test.ts | 213 +++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 src/cli/sql/_config.ts create mode 100644 tests/cli/sql/history-config.test.ts diff --git a/src/cli/sql/_config.ts b/src/cli/sql/_config.ts new file mode 100644 index 00000000..713f17f0 --- /dev/null +++ b/src/cli/sql/_config.ts @@ -0,0 +1,44 @@ +/** + * Config resolution for the history-only `sql` subcommands. + * + * `sql history` and `sql clear` read and delete files under + * `.noorm/state/history//`, so they need a config *name* but no + * connection. They used to default to the literal string `'default'`, which + * nothing in the product ever writes history under — the TUI SQL terminal + * keys it by the active config — so both commands silently operated on a + * file that did not exist, and `clear` reported success having erased + * nothing. + */ +import { attempt } from '@logosdx/utils'; + +import { getEnvConfigName } from '../../core/environment.js'; +import { initState, getStateManager } from '../../core/state/index.js'; + +/** + * The config whose history a command should operate on. + * + * Precedence matches `resolveConfig`, so `sql history` and `sql query` always + * agree on which config they are talking about: explicit flag, then + * `NOORM_CONFIG`, then the active config. State is only decrypted for the + * last of those — an explicit name needs no identity key, which keeps these + * two connection-less commands usable without one. + * + * @returns The resolved name, or `null` when nothing is set (no active + * config, or state could not be loaded). + * + * @example + * const configName = await resolveHistoryConfigName(args.config, process.cwd()); + */ +export async function resolveHistoryConfigName(explicit: string | undefined, projectRoot: string): Promise { + + const named = explicit ?? getEnvConfigName(); + + if (named) return named; + + const [, initErr] = await attempt(() => initState(projectRoot)); + + if (initErr) return null; + + return getStateManager(projectRoot).getActiveConfigName(); + +} diff --git a/src/cli/sql/clear.ts b/src/cli/sql/clear.ts index 9c8edcf5..35f1fd56 100644 --- a/src/cli/sql/clear.ts +++ b/src/cli/sql/clear.ts @@ -10,6 +10,7 @@ import { defineCommand } from 'citty'; import { SqlHistoryManager } from '../../core/sql-terminal/history.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { resolveHistoryConfigName } from './_config.js'; const clearCommand = defineCommand({ meta: { @@ -28,7 +29,14 @@ const clearCommand = defineCommand({ async run({ args }) { const projectRoot = process.cwd(); - const configName = args.config ?? 'default'; + const configName = await resolveHistoryConfigName(args.config, projectRoot); + + if (!configName) { + + outputError(args, 'No config specified and no active config set. Use --config or run "noorm config use ".'); + process.exit(1); + + } const manager = new SqlHistoryManager(projectRoot, configName); diff --git a/src/cli/sql/history.ts b/src/cli/sql/history.ts index b3b669a8..acd2f6f4 100644 --- a/src/cli/sql/history.ts +++ b/src/cli/sql/history.ts @@ -9,6 +9,7 @@ import { defineCommand } from 'citty'; import { SqlHistoryManager } from '../../core/sql-terminal/history.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { resolveHistoryConfigName } from './_config.js'; /** Maximum characters of query text to display in non-JSON output. */ const QUERY_TRUNCATE = 80; @@ -63,7 +64,6 @@ const historyCommand = defineCommand({ async run({ args }) { const projectRoot = process.cwd(); - const configName = args.config ?? 'default'; const limit = args.limit ? parseInt(args.limit, 10) : 50; if (isNaN(limit) || limit < 1) { @@ -73,6 +73,15 @@ const historyCommand = defineCommand({ } + const configName = await resolveHistoryConfigName(args.config, projectRoot); + + if (!configName) { + + outputError(args, 'No config specified and no active config set. Use --config or run "noorm config use ".'); + process.exit(1); + + } + const manager = new SqlHistoryManager(projectRoot, configName); const entries = await manager.getRecent(limit); diff --git a/tests/cli/sql/history-config.test.ts b/tests/cli/sql/history-config.test.ts new file mode 100644 index 00000000..14fc9217 --- /dev/null +++ b/tests/cli/sql/history-config.test.ts @@ -0,0 +1,213 @@ +/** + * cli: noorm sql history / clear — which config's history they operate on. + * + * `SqlHistoryManager` is correct and unit-tested, but it is told which config + * to work on, and both commands used to hardcode the literal name `default`. + * Nothing in the product ever writes history under that name — the TUI keys + * it by the *active* config — so `noorm sql clear --yes` reported success + * while leaving whatever the user was trying to scrub on disk. + * + * That is invisible to a unit test of the manager, which is handed the name + * explicitly. It only shows up end to end, so — like the other citty command + * tests in this suite — this drives the compiled CLI as a subprocess (also + * unavoidable: these commands call `process.exit`). Identity comes from + * `NOORM_IDENTITY_*` so no `~/.noorm/identity.key` is touched. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import { SqlHistoryManager } from '../../../src/core/sql-terminal/history.js'; +import type { Config } from '../../../src/core/config/types.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const ACTIVE_CONFIG = 'audit'; +const OTHER_CONFIG = 'staging'; + +/** A query text that would be a real leak if `clear` claimed to remove it and didn't. */ +const SECRET_QUERY = "SELECT * FROM vault WHERE token = 'ghp_SUPERSECRET_TOKEN'"; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm sql history/clear — config resolution', () => { + + let tmpDir: string; + let fakeHome: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(async () => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-sql-history-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-sql-history-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + const manager = new StateManager(tmpDir, { privateKey }); + + await manager.load(); + + for (const name of [ACTIVE_CONFIG, OTHER_CONFIG]) { + + const config: Config = { + name, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: join(tmpDir, `${name}.db`) }, + }; + + await manager.setConfig(name, config); + + } + + await manager.setActiveConfig(ACTIVE_CONFIG); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes one history entry for `configName`, exactly as the TUI SQL terminal does. */ + async function seedHistory(configName: string, query: string): Promise { + + const history = new SqlHistoryManager(tmpDir, configName); + + await history.addEntry(query, { + success: true, + columns: ['token'], + rows: [{ token: 'ghp_SUPERSECRET_TOKEN' }], + durationMs: 1, + }); + + } + + function runSql(args: string[], envOverrides: Record = {}) { + + const result = spawnSync('node', [CLI, 'sql', ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...identityEnv, ...envOverrides }, + }); + + return { ...result, json: JSON.parse(result.stdout) as Record }; + + } + + function historyFile(configName: string): string { + + return join(tmpDir, '.noorm', 'state', 'history', `${configName}.json`); + + } + + it('reads the active config history, not a config literally named "default"', async () => { + + await seedHistory(ACTIVE_CONFIG, SECRET_QUERY); + + const { json } = runSql(['history', '--json']); + + expect(json['configName']).toBe(ACTIVE_CONFIG); + expect(json['entries']).toHaveLength(1); + + }); + + it('actually erases the active config history that it reports clearing', async () => { + + await seedHistory(ACTIVE_CONFIG, SECRET_QUERY); + + const { json } = runSql(['clear', '--yes', '--json']); + + expect(json['configName']).toBe(ACTIVE_CONFIG); + expect(json['entriesRemoved']).toBe(1); + expect(readFileSync(historyFile(ACTIVE_CONFIG), 'utf-8')).not.toContain('ghp_SUPERSECRET_TOKEN'); + + }); + + it('does not create a stray "default" history file as a side effect of clearing', async () => { + + await seedHistory(ACTIVE_CONFIG, SECRET_QUERY); + + runSql(['clear', '--yes', '--json']); + + expect(existsSync(historyFile('default'))).toBe(false); + + }); + + it('leaves other configs\' history untouched', async () => { + + await seedHistory(ACTIVE_CONFIG, SECRET_QUERY); + await seedHistory(OTHER_CONFIG, 'SELECT 1'); + + runSql(['clear', '--yes', '--json']); + + expect(readFileSync(historyFile(OTHER_CONFIG), 'utf-8')).toContain('SELECT 1'); + + }); + + it('honours an explicit --config over the active one', async () => { + + await seedHistory(OTHER_CONFIG, 'SELECT 1'); + + const { json } = runSql(['history', '--json', '--config', OTHER_CONFIG]); + + expect(json['configName']).toBe(OTHER_CONFIG); + expect(json['entries']).toHaveLength(1); + + }); + + it('honours NOORM_CONFIG, matching how `sql query` picks its config', async () => { + + await seedHistory(OTHER_CONFIG, 'SELECT 1'); + + const { json } = runSql(['history', '--json'], { NOORM_CONFIG: OTHER_CONFIG }); + + expect(json['configName']).toBe(OTHER_CONFIG); + expect(json['entries']).toHaveLength(1); + + }); + + it('reports the resolved config name when there is no history for it', () => { + + const { json } = runSql(['history', '--json']); + + expect(json['configName']).toBe(ACTIVE_CONFIG); + expect(json['entries']).toEqual([]); + + }); + +}); From cd6304a58458b781aef77c635d41b24ead1fd0e0 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:46:52 -0400 Subject: [PATCH 037/105] fix(logger): redact project env vars, DSNs and error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/logger/redact.ts | 88 +++++++++++- src/core/update/checker.ts | 8 +- tests/core/logger/redact-coverage.test.ts | 159 ++++++++++++++++++++++ 3 files changed, 246 insertions(+), 9 deletions(-) create mode 100644 tests/core/logger/redact-coverage.test.ts diff --git a/src/core/logger/redact.ts b/src/core/logger/redact.ts index 38b4fa93..63cf393e 100644 --- a/src/core/logger/redact.ts +++ b/src/core/logger/redact.ts @@ -153,8 +153,46 @@ addMaskedFields([ 'bearer_token', 'jwt_secret', 'session_secret', + + // This project's own credentials. `addMaskedFields` derives the `noorm_` + // prefix from the *bare* term only, so `password` yields `NOORM_PASSWORD` + // but never `NOORM_CONNECTION_PASSWORD` — the compound names the docs and + // CI actually tell users to set have to be listed in full. + 'connection_password', + 'identity_private_key', + 'password_hash', + 'user_password', ]); +/** + * Credentials embedded in a URI's authority section. + * + * Name-based masking cannot catch these: a DSN is a secret carried in the + * *value*, under whatever key the caller happened to use (`url`, `dsn`, + * `connectionString`, or an error message). Only the password is replaced so + * the host and database stay readable — a redacted DSN is usually the most + * useful thing in a connection-failure log. + */ +const URI_CREDENTIALS = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s:/@]+):([^\s/@]+)@/g; + +/** + * Strip credentials from any URIs found in free text. + * + * @param text - Arbitrary string value or error message + * @returns The text with URI passwords replaced + * + * @example + * ```typescript + * redactCredentialsInText('postgres://user:hunter2@host/db'); + * // => 'postgres://user:***@host/db' + * ``` + */ +export function redactCredentialsInText(text: string): string { + + return text.replace(URI_CREDENTIALS, '$1:***@'); + +} + /** * Check if a field name should be masked. * @@ -335,21 +373,50 @@ export function filterData( } - // Skip Error objects (non-enumerable properties like message/stack would be lost by spread) + // Errors can't be spread — message/stack are non-enumerable — so clone via + // property descriptors to keep the prototype, name and any custom fields + // intact, then scrub the two places a credential actually shows up. Cloning + // rather than mutating matters: the caller still owns this error and may + // rethrow it. if (entry instanceof Error) { - return entry; + const cloned = Object.create( + Object.getPrototypeOf(entry) as object, + Object.getOwnPropertyDescriptors(entry), + ) as Error; + + cloned.message = redactCredentialsInText(entry.message); + + if (typeof cloned.stack === 'string') { + + cloned.stack = redactCredentialsInText(cloned.stack); + + } + + return cloned as unknown as Record; } // Handle arrays if (Array.isArray(entry)) { - return entry.map((item) => - typeof item === 'object' && item !== null - ? filterData(item as Record, level) - : item, - ) as unknown as Record; + return entry.map((item) => { + + if (typeof item === 'object' && item !== null) { + + return filterData(item as Record, level); + + } + + if (typeof item === 'string') { + + return redactCredentialsInText(item); + + } + + return item; + + }) as unknown as Record; } @@ -374,6 +441,13 @@ export function filterData( filtered[key] = maskValue(value, key, level); + } + else if (typeof value === 'string') { + + // The key says nothing, so inspect the value: a DSN under `url` or + // `dsn` is just as much a leak as one under `password`. + filtered[key] = redactCredentialsInText(value); + } else if (typeof value === 'object' && value !== null) { diff --git a/src/core/update/checker.ts b/src/core/update/checker.ts index d5ea5120..4d071fb1 100644 --- a/src/core/update/checker.ts +++ b/src/core/update/checker.ts @@ -57,8 +57,12 @@ export function getCurrentVersion(): string { * the binary *and* its checksums.txt to an attacker-controlled repo, at which * point verification passes against the attacker's own checksums file. */ -const SEMVER_PATTERN = - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; +const SEMVER_IDENTIFIER = '(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)'; +const SEMVER_PATTERN = new RegExp( + '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)' + + `(?:-(${SEMVER_IDENTIFIER}(?:\\.${SEMVER_IDENTIFIER})*))?` + + '(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$', +); /** * A version string that failed strict semver validation. diff --git a/tests/core/logger/redact-coverage.test.ts b/tests/core/logger/redact-coverage.test.ts new file mode 100644 index 00000000..7e5a42a6 --- /dev/null +++ b/tests/core/logger/redact-coverage.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'bun:test'; + +import { filterData, isMaskedField } from '../../../src/core/logger/redact.js'; + +/** + * The A11 audit's `redact-probe.ts` found redaction to be name-only, matching + * against a pre-enumerated variant set. It therefore missed: + * + * 1. This project's OWN documented env vars — `addMaskedFields` only generates a + * `noorm_` prefix on the *bare* term, so `noorm_password` was covered but + * `NOORM_CONNECTION_PASSWORD` was not. + * 2. Every value-borne secret. A DSN passes verbatim under any key name, because + * values were never inspected at all. + * 3. `Error` objects, skipped wholesale — and connection errors routinely carry + * a DSN in their message. + */ +describe('logger: redact coverage', () => { + + describe('project environment variable names', () => { + + it('should mask the connection password env var', () => { + + expect(isMaskedField('NOORM_CONNECTION_PASSWORD')).toBe(true); + + }); + + it('should mask the identity private key env var', () => { + + expect(isMaskedField('NOORM_IDENTITY_PRIVATE_KEY')).toBe(true); + + }); + + it('should mask the camelCase forms used in code', () => { + + expect(isMaskedField('connectionPassword')).toBe(true); + expect(isMaskedField('userPassword')).toBe(true); + expect(isMaskedField('passwordHash')).toBe(true); + + }); + + it('should mask values under those keys, not just recognise the name', () => { + + const filtered = filterData( + { NOORM_CONNECTION_PASSWORD: 'hunter2-in-the-clear' }, + 'info', + ); + + expect(filtered['NOORM_CONNECTION_PASSWORD']).not.toContain('hunter2'); + + }); + + }); + + describe('credential-bearing values', () => { + + it('should strip the password from a DSN regardless of key name', () => { + + const filtered = filterData( + { connectionString: 'postgres://user:hunter2@db.example.com:5432/app' }, + 'info', + ); + + const value = String(filtered['connectionString']); + + expect(value).not.toContain('hunter2'); + // The non-secret parts stay readable — a redacted DSN is still the + // most useful thing in a connection-failure log. + expect(value).toContain('db.example.com'); + + }); + + it('should strip credentials under an innocuous key like url', () => { + + const filtered = filterData( + { url: 'mysql://root:s3cr3t@127.0.0.1:3306/noorm' }, + 'info', + ); + + expect(String(filtered['url'])).not.toContain('s3cr3t'); + + }); + + it('should strip credentials nested inside objects and arrays', () => { + + const filtered = filterData( + { + targets: [{ dsn: 'postgres://u:leaked-pw@host/db' }], + }, + 'info', + ); + + expect(JSON.stringify(filtered)).not.toContain('leaked-pw'); + + }); + + it('should leave credential-free strings untouched', () => { + + const filtered = filterData( + { url: 'https://github.com/noormdev/noorm', note: 'no secrets here' }, + 'info', + ); + + expect(filtered['url']).toBe('https://github.com/noormdev/noorm'); + expect(filtered['note']).toBe('no secrets here'); + + }); + + }); + + describe('Error objects', () => { + + it('should redact a DSN carried in an error message', () => { + + const err = new Error('connect failed: postgres://user:hunter2@host/db'); + + const filtered = filterData({ error: err }, 'info'); + const out = filtered['error'] as Error; + + expect(out.message).not.toContain('hunter2'); + expect(out.message).toContain('connect failed'); + + }); + + it('should keep the value an Error so callers can still branch on it', () => { + + const err = new TypeError('bad dsn postgres://u:pw@h/d'); + + const out = filterData({ error: err }, 'info')['error'] as Error; + + expect(out).toBeInstanceOf(Error); + expect(out).toBeInstanceOf(TypeError); + expect(out.name).toBe('TypeError'); + + }); + + it('should not mutate the original error', () => { + + const err = new Error('postgres://u:originalpw@h/d'); + + filterData({ error: err }, 'info'); + + expect(err.message).toContain('originalpw'); + + }); + + it('should redact the stack trace too', () => { + + const err = new Error('boom'); + err.stack = 'Error: boom\n at connect (postgres://u:stackpw@h/d)'; + + const out = filterData({ error: err }, 'info')['error'] as Error; + + expect(out.stack).not.toContain('stackpw'); + + }); + + }); + +}); From e4481763831faa7cd78097878cdf39dd941ae6ab Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:47:16 -0400 Subject: [PATCH 038/105] 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. --- tests/core/explore/recording-db.ts | 224 +++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 tests/core/explore/recording-db.ts diff --git a/tests/core/explore/recording-db.ts b/tests/core/explore/recording-db.ts new file mode 100644 index 00000000..3ccd0a03 --- /dev/null +++ b/tests/core/explore/recording-db.ts @@ -0,0 +1,224 @@ +/** + * Recording Kysely harness for explore dialect unit tests. + * + * The previous harness stubbed `compileQuery` to return `SELECT 1`, so the + * SQL a dialect method actually generates was never observable and a wrong + * `WHERE` predicate was structurally undetectable. This builds a real Kysely + * instance using the dialect's real adapter and query compiler, backed by a + * driver that records every compiled statement and replays canned rows. + * + * @example + * ```ts + * const db = createRecordingDb('postgres', [ + * { match: /FROM pg_proc/, rows: [{ oid: '675394', prosrc: '...' }] }, + * { match: /information_schema.parameters/, rows: [] }, + * ]); + * + * await postgresExploreOperations.getProcedureDetail(db.kysely, 'sp_touch'); + * + * expect(db.find(/information_schema.parameters/)?.parameters).toContain('sp_touch_675394'); + * ``` + */ +import { + Kysely, + MssqlAdapter, + MssqlIntrospector, + MssqlQueryCompiler, + MysqlAdapter, + MysqlIntrospector, + MysqlQueryCompiler, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, + SqliteAdapter, + SqliteIntrospector, + SqliteQueryCompiler, +} from 'kysely'; + +import type { + CompiledQuery, + DatabaseConnection, + Driver, + QueryResult, +} from 'kysely'; + +import type { Dialect } from '../../../src/core/connection/types.js'; + +/** + * One compiled statement as the driver saw it. + */ +export interface RecordedQuery { + + sql: string; + parameters: readonly unknown[]; + +} + +/** + * Rows to replay for the first statement matching `match`. + * + * Rules are consumed in order: a rule serves at most one statement, so two + * rules with the same matcher answer two successive calls. Unmatched + * statements return no rows. + */ +export interface ResponseRule { + + match: RegExp | string; + rows?: unknown[]; + + /** Reject instead of replaying rows, to exercise error paths. */ + error?: Error; + +} + +/** + * A Kysely instance whose driver records instead of connecting. + */ +export interface RecordingDb { + + kysely: Kysely; + queries: RecordedQuery[]; + + /** First recorded statement matching `pattern`, or undefined. */ + find(pattern: RegExp | string): RecordedQuery | undefined; + + /** Every recorded statement matching `pattern`. */ + findAll(pattern: RegExp | string): RecordedQuery[]; + +} + +function matches(query: RecordedQuery, pattern: RegExp | string): boolean { + + return typeof pattern === 'string' + ? query.sql.includes(pattern) + : pattern.test(query.sql); + +} + +/** + * Adapter/compiler/introspector triple per dialect, so compiled SQL uses the + * real placeholder syntax ($1, ?, @1) and the real identifier quoting. + */ +function dialectParts(dialect: Dialect) { + + if (dialect === 'postgres') { + + return { + adapter: new PostgresAdapter(), + compiler: new PostgresQueryCompiler(), + introspector: (db: Kysely) => new PostgresIntrospector(db), + }; + + } + + if (dialect === 'mysql') { + + return { + adapter: new MysqlAdapter(), + compiler: new MysqlQueryCompiler(), + introspector: (db: Kysely) => new MysqlIntrospector(db), + }; + + } + + if (dialect === 'mssql') { + + return { + adapter: new MssqlAdapter(), + compiler: new MssqlQueryCompiler(), + introspector: (db: Kysely) => new MssqlIntrospector(db), + }; + + } + + return { + adapter: new SqliteAdapter(), + compiler: new SqliteQueryCompiler(), + introspector: (db: Kysely) => new SqliteIntrospector(db), + }; + +} + +/** + * Build a Kysely instance that compiles for real and records what it would + * have sent. + * + * @param dialect - Dialect whose adapter and compiler to use + * @param rules - Canned responses, consumed in order (see {@link ResponseRule}) + */ +export function createRecordingDb( + dialect: Dialect, + rules: ResponseRule[] = [], +): RecordingDb { + + const queries: RecordedQuery[] = []; + const pending = [...rules]; + const parts = dialectParts(dialect); + + const connection: DatabaseConnection = { + + async executeQuery(compiled: CompiledQuery): Promise> { + + const recorded: RecordedQuery = { + sql: compiled.sql, + parameters: compiled.parameters, + }; + + queries.push(recorded); + + const index = pending.findIndex((rule) => matches(recorded, rule.match)); + + if (index === -1) { + + return { rows: [] }; + + } + + const [rule] = pending.splice(index, 1); + + if (rule!.error) { + + throw rule!.error; + + } + + return { rows: (rule!.rows ?? []) as R[] }; + + }, + + // eslint-disable-next-line require-yield + async *streamQuery(): AsyncIterableIterator> { + + throw new Error('streamQuery is not supported by the recording harness'); + + }, + + }; + + const driver: Driver = { + init: async () => {}, + acquireConnection: async () => connection, + beginTransaction: async () => {}, + commitTransaction: async () => {}, + rollbackTransaction: async () => {}, + releaseConnection: async () => {}, + destroy: async () => {}, + }; + + const kysely = new Kysely({ + dialect: { + createAdapter: () => parts.adapter, + createDriver: () => driver, + createIntrospector: parts.introspector, + createQueryCompiler: () => parts.compiler, + }, + }); + + return { + kysely, + queries, + find: (pattern) => queries.find((q) => matches(q, pattern)), + findAll: (pattern) => queries.filter((q) => matches(q, pattern)), + }; + +} From cb1bf7b6d6a741d8886fb7605d28b080f8718985 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:47:37 -0400 Subject: [PATCH 039/105] 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. --- src/core/explore/dialects/mssql.ts | 151 ++---- src/core/explore/dialects/mysql.ts | 161 ++---- src/core/explore/dialects/postgres.ts | 155 ++---- src/core/explore/dialects/sqlite.ts | 206 +++---- src/core/explore/operations.ts | 115 ++-- src/core/explore/types.ts | 31 +- tests/core/explore/dialects/mssql.test.ts | 534 +++++++++---------- tests/core/explore/dialects/mysql.test.ts | 404 +++++++------- tests/core/explore/dialects/postgres.test.ts | 521 ++++++++++-------- tests/core/explore/dialects/sqlite.test.ts | 418 +++++++++------ tests/core/explore/dispatch.test.ts | 255 +++++++++ 11 files changed, 1601 insertions(+), 1350 deletions(-) create mode 100644 tests/core/explore/dispatch.test.ts diff --git a/src/core/explore/dialects/mssql.ts b/src/core/explore/dialects/mssql.ts index d343168e..b8cbc252 100644 --- a/src/core/explore/dialects/mssql.ts +++ b/src/core/explore/dialects/mssql.ts @@ -8,7 +8,6 @@ import { sql } from 'kysely'; import type { Kysely } from 'kysely'; import type { DialectExploreOperations, - ExploreOverview, TableSummary, ViewSummary, ProcedureSummary, @@ -35,84 +34,50 @@ import type { const EXCLUDED_SCHEMAS = ['sys', 'INFORMATION_SCHEMA', 'guest', 'noorm']; /** - * MSSQL explore operations. + * Comparison to append to a schema column so one query serves both + * "everything the user owns" and "just this schema". + * + * Callers interpolate it directly after the column name, which keeps the + * schema a bound parameter rather than string-concatenated SQL. + * + * @example + * ```typescript + * sql`... WHERE s.name ${schemaFilter(schema)}` + * ``` */ -export const mssqlExploreOperations: DialectExploreOperations = { +function schemaFilter(schema?: string) { - async getOverview(db: Kysely): Promise { - - const [tables, views, procedures, functions, types, indexes, foreignKeys] = - await Promise.all([ - sql<{ count: number }>` - SELECT COUNT(*) as count - FROM sys.tables t - JOIN sys.schemas s ON t.schema_id = s.schema_id - WHERE s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - `.execute(db), - - sql<{ count: number }>` - SELECT COUNT(*) as count - FROM sys.views v - JOIN sys.schemas s ON v.schema_id = s.schema_id - WHERE s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - `.execute(db), - - sql<{ count: number }>` - SELECT COUNT(*) as count - FROM sys.procedures p - JOIN sys.schemas s ON p.schema_id = s.schema_id - WHERE s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - `.execute(db), - - sql<{ count: number }>` - SELECT COUNT(*) as count - FROM sys.objects o - JOIN sys.schemas s ON o.schema_id = s.schema_id - WHERE o.type IN ('FN', 'IF', 'TF') - AND s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - `.execute(db), - - sql<{ count: number }>` - SELECT COUNT(*) as count - FROM sys.types t - JOIN sys.schemas s ON t.schema_id = s.schema_id - WHERE t.is_user_defined = 1 - AND s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - `.execute(db), - - sql<{ count: number }>` - SELECT COUNT(*) as count - FROM sys.indexes i - JOIN sys.tables t ON i.object_id = t.object_id - JOIN sys.schemas s ON t.schema_id = s.schema_id - WHERE i.name IS NOT NULL - AND s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - `.execute(db), - - sql<{ count: number }>` - SELECT COUNT(*) as count - FROM sys.foreign_keys fk - JOIN sys.schemas s ON fk.schema_id = s.schema_id - WHERE s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - `.execute(db), - ]); + return schema + ? sql`= ${schema}` + : sql`NOT IN (${sql.join(EXCLUDED_SCHEMAS)})`; - return { - tables: tables.rows[0]?.count ?? 0, - views: views.rows[0]?.count ?? 0, - procedures: procedures.rows[0]?.count ?? 0, - functions: functions.rows[0]?.count ?? 0, - types: types.rows[0]?.count ?? 0, - indexes: indexes.rows[0]?.count ?? 0, - foreignKeys: foreignKeys.rows[0]?.count ?? 0, - triggers: 0, // TODO: implement count - locks: 0, // TODO: implement count - connections: 0, // TODO: implement count - }; +} - }, +/** + * `bigint` row counts arrive from the driver as text, so the declared + * `number` on the summary types only holds if they are parsed here. + * Zero is reported as "unknown" to match the other dialects. + */ +function toRowEstimate(value: unknown): number | undefined { + + if (value === null || value === undefined) { + + return undefined; + + } + + const parsed = parseInt(String(value), 10); + + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; + +} + +/** + * MSSQL explore operations. + */ +export const mssqlExploreOperations: DialectExploreOperations = { - async listTables(db: Kysely): Promise { + async listTables(db: Kysely, schema?: string): Promise { const result = await sql<{ table_name: string; @@ -132,7 +97,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { FROM sys.tables t JOIN sys.schemas s ON t.schema_id = s.schema_id LEFT JOIN sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1) - WHERE s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE s.name ${schemaFilter(schema)} ORDER BY s.name, t.name `.execute(db); @@ -140,12 +105,12 @@ export const mssqlExploreOperations: DialectExploreOperations = { name: row.table_name, schema: row.schema_name, columnCount: row.column_count, - rowCountEstimate: row.row_count > 0 ? row.row_count : undefined, + rowCountEstimate: toRowEstimate(row.row_count), })); }, - async listViews(db: Kysely): Promise { + async listViews(db: Kysely, schema?: string): Promise { const result = await sql<{ view_name: string; @@ -162,7 +127,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { ) as column_count FROM sys.views v JOIN sys.schemas s ON v.schema_id = s.schema_id - WHERE s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE s.name ${schemaFilter(schema)} ORDER BY s.name, v.name `.execute(db); @@ -175,7 +140,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { }, - async listProcedures(db: Kysely): Promise { + async listProcedures(db: Kysely, schema?: string): Promise { const result = await sql<{ proc_name: string; @@ -193,7 +158,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { ) as param_count FROM sys.procedures p JOIN sys.schemas s ON p.schema_id = s.schema_id - WHERE s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE s.name ${schemaFilter(schema)} ORDER BY s.name, p.name `.execute(db); @@ -205,7 +170,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { }, - async listFunctions(db: Kysely): Promise { + async listFunctions(db: Kysely, schema?: string): Promise { const result = await sql<{ func_name: string; @@ -231,7 +196,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { FROM sys.objects o JOIN sys.schemas s ON o.schema_id = s.schema_id WHERE o.type IN ('FN', 'IF', 'TF') - AND s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + AND s.name ${schemaFilter(schema)} ORDER BY s.name, o.name `.execute(db); @@ -244,7 +209,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { }, - async listTypes(db: Kysely): Promise { + async listTypes(db: Kysely, schema?: string): Promise { const result = await sql<{ type_name: string; @@ -258,7 +223,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { FROM sys.types t JOIN sys.schemas s ON t.schema_id = s.schema_id WHERE t.is_user_defined = 1 - AND s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + AND s.name ${schemaFilter(schema)} ORDER BY s.name, t.name `.execute(db); @@ -270,7 +235,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { }, - async listIndexes(db: Kysely): Promise { + async listIndexes(db: Kysely, schema?: string): Promise { const result = await sql<{ index_name: string; @@ -293,7 +258,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id WHERE i.name IS NOT NULL - AND s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + AND s.name ${schemaFilter(schema)} GROUP BY i.name, s.name, t.name, i.is_unique, i.is_primary_key ORDER BY s.name, t.name, i.name `.execute(db); @@ -310,7 +275,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { }, - async listForeignKeys(db: Kysely): Promise { + async listForeignKeys(db: Kysely, schema?: string): Promise { const result = await sql<{ fk_name: string; @@ -351,7 +316,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { JOIN sys.tables rt ON fk.referenced_object_id = rt.object_id JOIN sys.schemas rs ON rt.schema_id = rs.schema_id JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id - WHERE s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE s.name ${schemaFilter(schema)} ORDER BY s.name, t.name, fk.name, fkc.constraint_column_id `.execute(db); @@ -464,13 +429,13 @@ export const mssqlExploreOperations: DialectExploreOperations = { })); // Get indexes - const allIndexes = await this.listIndexes(db); + const allIndexes = await this.listIndexes(db, schema); const indexes = allIndexes.filter( (idx) => idx.tableName === name && idx.tableSchema === schema, ); // Get foreign keys - const allFks = await this.listForeignKeys(db); + const allFks = await this.listForeignKeys(db, schema); const foreignKeys = allFks.filter( (fk) => fk.tableName === name && fk.tableSchema === schema, ); @@ -481,7 +446,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { columns, indexes, foreignKeys, - rowCountEstimate: rowResult.rows[0]?.row_count ?? undefined, + rowCountEstimate: toRowEstimate(rowResult.rows[0]?.row_count), }; }, @@ -755,7 +720,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { }, - async listTriggers(db: Kysely): Promise { + async listTriggers(db: Kysely, schema?: string): Promise { const result = await sql<{ trigger_name: string; @@ -776,7 +741,7 @@ export const mssqlExploreOperations: DialectExploreOperations = { INNER JOIN sys.trigger_events te ON t.object_id = te.object_id INNER JOIN sys.tables tab ON t.parent_id = tab.object_id INNER JOIN sys.schemas s ON tab.schema_id = s.schema_id - WHERE s.name NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE s.name ${schemaFilter(schema)} ORDER BY s.name, table_name, t.name `.execute(db); diff --git a/src/core/explore/dialects/mysql.ts b/src/core/explore/dialects/mysql.ts index ae4c443b..48f12004 100644 --- a/src/core/explore/dialects/mysql.ts +++ b/src/core/explore/dialects/mysql.ts @@ -9,7 +9,6 @@ import { sql } from 'kysely'; import type { Kysely } from 'kysely'; import type { DialectExploreOperations, - ExploreOverview, TableSummary, ViewSummary, ProcedureSummary, @@ -31,95 +30,43 @@ import type { } from '../types.js'; /** - * MySQL explore operations. + * Database to explore: the caller's `schema` when supplied, otherwise the one + * the connection currently points at. + * + * MySQL has no schema level below the database, so a `schema` argument names a + * *different database* - every catalog predicate has to bind it rather than + * fall back to `DATABASE()`, or the answer describes the wrong database. + * + * @example + * ```typescript + * const dbName = await resolveSchema(db, schema); + * ``` */ -export const mysqlExploreOperations: DialectExploreOperations = { +async function resolveSchema( + db: Kysely, + schema?: string, +): Promise { - async getOverview(db: Kysely): Promise { + if (schema) { - // Get current database name - const dbNameResult = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - const dbName = dbNameResult.rows[0]?.db; + return schema; - if (!dbName) { + } - return { - tables: 0, - views: 0, - procedures: 0, - functions: 0, - types: 0, - indexes: 0, - foreignKeys: 0, - triggers: 0, - locks: 0, - connections: 0, - }; + const result = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - } - - const [tables, views, procedures, functions, indexes, foreignKeys] = - await Promise.all([ - sql<{ count: string }>` - SELECT COUNT(*) as count - FROM information_schema.tables - WHERE table_schema = ${dbName} - AND table_type = 'BASE TABLE' - `.execute(db), - - sql<{ count: string }>` - SELECT COUNT(*) as count - FROM information_schema.views - WHERE table_schema = ${dbName} - `.execute(db), - - sql<{ count: string }>` - SELECT COUNT(*) as count - FROM information_schema.routines - WHERE routine_schema = ${dbName} - AND routine_type = 'PROCEDURE' - `.execute(db), - - sql<{ count: string }>` - SELECT COUNT(*) as count - FROM information_schema.routines - WHERE routine_schema = ${dbName} - AND routine_type = 'FUNCTION' - `.execute(db), - - sql<{ count: string }>` - SELECT COUNT(DISTINCT index_name) as count - FROM information_schema.statistics - WHERE table_schema = ${dbName} - `.execute(db), - - sql<{ count: string }>` - SELECT COUNT(*) as count - FROM information_schema.table_constraints - WHERE constraint_schema = ${dbName} - AND constraint_type = 'FOREIGN KEY' - `.execute(db), - ]); + return result.rows[0]?.db; - return { - tables: parseInt(String(tables.rows[0]?.count ?? '0'), 10), - views: parseInt(String(views.rows[0]?.count ?? '0'), 10), - procedures: parseInt(String(procedures.rows[0]?.count ?? '0'), 10), - functions: parseInt(String(functions.rows[0]?.count ?? '0'), 10), - types: 0, // MySQL doesn't have custom types like PostgreSQL - indexes: parseInt(String(indexes.rows[0]?.count ?? '0'), 10), - foreignKeys: parseInt(String(foreignKeys.rows[0]?.count ?? '0'), 10), - triggers: 0, // TODO: implement count - locks: 0, // TODO: implement count - connections: 0, // TODO: implement count - }; +} - }, +/** + * MySQL explore operations. + */ +export const mysqlExploreOperations: DialectExploreOperations = { - async listTables(db: Kysely): Promise { + async listTables(db: Kysely, schema?: string): Promise { - const dbNameResult = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - const dbName = dbNameResult.rows[0]?.db; + const dbName = await resolveSchema(db, schema); if (!dbName) return []; @@ -156,10 +103,9 @@ export const mysqlExploreOperations: DialectExploreOperations = { }, - async listViews(db: Kysely): Promise { + async listViews(db: Kysely, schema?: string): Promise { - const dbNameResult = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - const dbName = dbNameResult.rows[0]?.db; + const dbName = await resolveSchema(db, schema); if (!dbName) return []; @@ -193,10 +139,9 @@ export const mysqlExploreOperations: DialectExploreOperations = { }, - async listProcedures(db: Kysely): Promise { + async listProcedures(db: Kysely, schema?: string): Promise { - const dbNameResult = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - const dbName = dbNameResult.rows[0]?.db; + const dbName = await resolveSchema(db, schema); if (!dbName) return []; @@ -229,10 +174,9 @@ export const mysqlExploreOperations: DialectExploreOperations = { }, - async listFunctions(db: Kysely): Promise { + async listFunctions(db: Kysely, schema?: string): Promise { - const dbNameResult = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - const dbName = dbNameResult.rows[0]?.db; + const dbName = await resolveSchema(db, schema); if (!dbName) return []; @@ -275,10 +219,9 @@ export const mysqlExploreOperations: DialectExploreOperations = { }, - async listIndexes(db: Kysely): Promise { + async listIndexes(db: Kysely, schema?: string): Promise { - const dbNameResult = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - const dbName = dbNameResult.rows[0]?.db; + const dbName = await resolveSchema(db, schema); if (!dbName) return []; @@ -332,10 +275,9 @@ export const mysqlExploreOperations: DialectExploreOperations = { }, - async listForeignKeys(db: Kysely): Promise { + async listForeignKeys(db: Kysely, schema?: string): Promise { - const dbNameResult = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - const dbName = dbNameResult.rows[0]?.db; + const dbName = await resolveSchema(db, schema); if (!dbName) return []; @@ -408,8 +350,7 @@ export const mysqlExploreOperations: DialectExploreOperations = { schema?: string, ): Promise { - const dbNameResult = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - const dbName = schema ?? dbNameResult.rows[0]?.db; + const dbName = await resolveSchema(db, schema); if (!dbName) return null; @@ -462,12 +403,13 @@ export const mysqlExploreOperations: DialectExploreOperations = { ? parseInt(String(rowResult.rows[0].table_rows), 10) : undefined; - // Get indexes - const allIndexes = await this.listIndexes(db); + // Scope to dbName, not the connected database: without it a + // cross-database detail reports another database's indexes and FKs + // inside an object labelled with the schema that was asked for. + const allIndexes = await this.listIndexes(db, dbName); const indexes = allIndexes.filter((idx) => idx.tableName === name); - // Get foreign keys - const allFks = await this.listForeignKeys(db); + const allFks = await this.listForeignKeys(db, dbName); const foreignKeys = allFks.filter((fk) => fk.tableName === name); return { @@ -487,8 +429,7 @@ export const mysqlExploreOperations: DialectExploreOperations = { schema?: string, ): Promise { - const dbNameResult = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - const dbName = schema ?? dbNameResult.rows[0]?.db; + const dbName = await resolveSchema(db, schema); if (!dbName) return null; @@ -556,8 +497,7 @@ export const mysqlExploreOperations: DialectExploreOperations = { schema?: string, ): Promise { - const dbNameResult = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - const dbName = schema ?? dbNameResult.rows[0]?.db; + const dbName = await resolveSchema(db, schema); if (!dbName) return null; @@ -621,8 +561,7 @@ export const mysqlExploreOperations: DialectExploreOperations = { schema?: string, ): Promise { - const dbNameResult = await sql<{ db: string }>`SELECT DATABASE() as db`.execute(db); - const dbName = schema ?? dbNameResult.rows[0]?.db; + const dbName = await resolveSchema(db, schema); if (!dbName) return null; @@ -693,7 +632,11 @@ export const mysqlExploreOperations: DialectExploreOperations = { }, - async listTriggers(db: Kysely): Promise { + async listTriggers(db: Kysely, schema?: string): Promise { + + const dbName = await resolveSchema(db, schema); + + if (!dbName) return []; const result = await sql<{ TRIGGER_NAME: string; @@ -709,7 +652,7 @@ export const mysqlExploreOperations: DialectExploreOperations = { ACTION_TIMING, EVENT_MANIPULATION FROM information_schema.TRIGGERS - WHERE TRIGGER_SCHEMA = DATABASE() + WHERE TRIGGER_SCHEMA = ${dbName} ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME `.execute(db); diff --git a/src/core/explore/dialects/postgres.ts b/src/core/explore/dialects/postgres.ts index 6bf66832..14f7812b 100644 --- a/src/core/explore/dialects/postgres.ts +++ b/src/core/explore/dialects/postgres.ts @@ -9,7 +9,6 @@ import { sql } from 'kysely'; import type { Kysely } from 'kysely'; import type { DialectExploreOperations, - ExploreOverview, TableSummary, ViewSummary, ProcedureSummary, @@ -36,102 +35,31 @@ import type { const EXCLUDED_SCHEMAS = ['pg_catalog', 'information_schema', 'pg_toast', 'noorm']; /** - * PostgreSQL explore operations. + * Comparison to append to a schema column so one query serves both + * "everything the user owns" and "just this schema". + * + * Callers interpolate it directly after the column name, which keeps the + * schema a bound parameter rather than string-concatenated SQL. + * + * @example + * ```typescript + * sql`... WHERE t.table_schema ${schemaFilter(schema)}` + * ``` */ -export const postgresExploreOperations: DialectExploreOperations = { +function schemaFilter(schema?: string) { - async getOverview(db: Kysely): Promise { - - // All counts exclude extension objects (pg_depend with deptype='e') - const [tables, views, procedures, functions, types, indexes, foreignKeys] = - await Promise.all([ - sql<{ count: string }>` - SELECT COUNT(*)::text as count - FROM information_schema.tables - WHERE table_schema NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - AND table_type = 'BASE TABLE' - `.execute(db), - - sql<{ count: string }>` - SELECT COUNT(*)::text as count - FROM information_schema.views - WHERE table_schema NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - `.execute(db), - - sql<{ count: string }>` - SELECT COUNT(*)::text as count - FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - AND p.prokind = 'p' - AND NOT EXISTS ( - SELECT 1 FROM pg_depend d - WHERE d.objid = p.oid - AND d.deptype = 'e' - ) - `.execute(db), - - sql<{ count: string }>` - SELECT COUNT(*)::text as count - FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - AND p.prokind = 'f' - AND NOT EXISTS ( - SELECT 1 FROM pg_depend d - WHERE d.objid = p.oid - AND d.deptype = 'e' - ) - `.execute(db), - - sql<{ count: string }>` - SELECT COUNT(*)::text as count - FROM pg_type t - JOIN pg_namespace n ON t.typnamespace = n.oid - WHERE n.nspname NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - AND t.typtype IN ('e', 'c', 'd') - AND NOT EXISTS ( - SELECT 1 FROM pg_depend d - WHERE d.objid = t.oid - AND d.deptype = 'e' - ) - AND NOT EXISTS ( - SELECT 1 FROM pg_class c - WHERE c.reltype = t.oid - AND c.relkind IN ('r', 'v', 'm', 'p') - ) - `.execute(db), - - sql<{ count: string }>` - SELECT COUNT(*)::text as count - FROM pg_indexes - WHERE schemaname NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - `.execute(db), - - sql<{ count: string }>` - SELECT COUNT(*)::text as count - FROM information_schema.table_constraints - WHERE constraint_type = 'FOREIGN KEY' - AND table_schema NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) - `.execute(db), - ]); + return schema + ? sql`= ${schema}` + : sql`NOT IN (${sql.join(EXCLUDED_SCHEMAS)})`; - return { - tables: parseInt(tables.rows[0]?.count ?? '0', 10), - views: parseInt(views.rows[0]?.count ?? '0', 10), - procedures: parseInt(procedures.rows[0]?.count ?? '0', 10), - functions: parseInt(functions.rows[0]?.count ?? '0', 10), - types: parseInt(types.rows[0]?.count ?? '0', 10), - indexes: parseInt(indexes.rows[0]?.count ?? '0', 10), - foreignKeys: parseInt(foreignKeys.rows[0]?.count ?? '0', 10), - triggers: 0, // TODO: implement count - locks: 0, // TODO: implement count - connections: 0, // TODO: implement count - }; +} - }, +/** + * PostgreSQL explore operations. + */ +export const postgresExploreOperations: DialectExploreOperations = { - async listTables(db: Kysely): Promise { + async listTables(db: Kysely, schema?: string): Promise { const result = await sql<{ table_name: string; @@ -159,7 +87,7 @@ export const postgresExploreOperations: DialectExploreOperations = { '0' ) as row_estimate FROM information_schema.tables t - WHERE t.table_schema NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE t.table_schema ${schemaFilter(schema)} AND t.table_type = 'BASE TABLE' ORDER BY t.table_schema, t.table_name `.execute(db); @@ -175,7 +103,7 @@ export const postgresExploreOperations: DialectExploreOperations = { }, - async listViews(db: Kysely): Promise { + async listViews(db: Kysely, schema?: string): Promise { const result = await sql<{ table_name: string; @@ -194,7 +122,7 @@ export const postgresExploreOperations: DialectExploreOperations = { ) as column_count, v.is_updatable FROM information_schema.views v - WHERE v.table_schema NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE v.table_schema ${schemaFilter(schema)} ORDER BY v.table_schema, v.table_name `.execute(db); @@ -207,7 +135,7 @@ export const postgresExploreOperations: DialectExploreOperations = { }, - async listProcedures(db: Kysely): Promise { + async listProcedures(db: Kysely, schema?: string): Promise { // Exclude procedures that belong to extensions (pg_depend with deptype='e') const result = await sql<{ @@ -221,7 +149,7 @@ export const postgresExploreOperations: DialectExploreOperations = { p.pronargs::text as param_count FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE n.nspname ${schemaFilter(schema)} AND p.prokind = 'p' AND NOT EXISTS ( SELECT 1 FROM pg_depend d @@ -239,7 +167,7 @@ export const postgresExploreOperations: DialectExploreOperations = { }, - async listFunctions(db: Kysely): Promise { + async listFunctions(db: Kysely, schema?: string): Promise { // Exclude functions that belong to extensions (pg_depend with deptype='e') const result = await sql<{ @@ -255,7 +183,7 @@ export const postgresExploreOperations: DialectExploreOperations = { pg_get_function_result(p.oid) as return_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE n.nspname ${schemaFilter(schema)} AND p.prokind = 'f' AND NOT EXISTS ( SELECT 1 FROM pg_depend d @@ -274,7 +202,7 @@ export const postgresExploreOperations: DialectExploreOperations = { }, - async listTypes(db: Kysely): Promise { + async listTypes(db: Kysely, schema?: string): Promise { // Exclude: // - Types that belong to extensions (pg_depend with deptype='e') @@ -299,7 +227,7 @@ export const postgresExploreOperations: DialectExploreOperations = { END as value_count FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid - WHERE n.nspname NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE n.nspname ${schemaFilter(schema)} AND t.typtype IN ('e', 'c', 'd') AND NOT EXISTS ( SELECT 1 FROM pg_depend d @@ -329,7 +257,7 @@ export const postgresExploreOperations: DialectExploreOperations = { }, - async listIndexes(db: Kysely): Promise { + async listIndexes(db: Kysely, schema?: string): Promise { // Query indexes with primary key constraint info from pg_constraint const result = await sql<{ @@ -357,7 +285,7 @@ export const postgresExploreOperations: DialectExploreOperations = { false ) as is_primary FROM pg_indexes i - WHERE i.schemaname NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE i.schemaname ${schemaFilter(schema)} ORDER BY i.schemaname, i.tablename, i.indexname `.execute(db); @@ -386,7 +314,7 @@ export const postgresExploreOperations: DialectExploreOperations = { }, - async listForeignKeys(db: Kysely): Promise { + async listForeignKeys(db: Kysely, schema?: string): Promise { const result = await sql<{ constraint_name: string; @@ -420,7 +348,7 @@ export const postgresExploreOperations: DialectExploreOperations = { ON tc.constraint_name = rc.constraint_name AND tc.table_schema = rc.constraint_schema WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + AND tc.table_schema ${schemaFilter(schema)} ORDER BY tc.table_schema, tc.table_name, tc.constraint_name `.execute(db); @@ -528,13 +456,13 @@ export const postgresExploreOperations: DialectExploreOperations = { })); // Get indexes for this table - const allIndexes = await this.listIndexes(db); + const allIndexes = await this.listIndexes(db, schema); const indexes = allIndexes.filter( (idx) => idx.tableName === name && idx.tableSchema === schema, ); // Get foreign keys for this table - const allFks = await this.listForeignKeys(db); + const allFks = await this.listForeignKeys(db, schema); const foreignKeys = allFks.filter( (fk) => fk.tableName === name && fk.tableSchema === schema, ); @@ -642,7 +570,8 @@ export const postgresExploreOperations: DialectExploreOperations = { const oid = procRow.oid; - // Get parameters + // information_schema.parameters keys on specific_name (`proname_oid`), + // not on the bare routine name — filtering on `name` matches nothing. const paramsResult = await sql<{ parameter_name: string | null; data_type: string; @@ -658,7 +587,7 @@ export const postgresExploreOperations: DialectExploreOperations = { parameter_default FROM information_schema.parameters WHERE specific_schema = ${schema} - AND specific_name = ${name || sql.raw(`'_' || ${oid}`)} + AND specific_name = ${name + '_' + oid} ORDER BY ordinal_position `.execute(db); @@ -851,7 +780,7 @@ export const postgresExploreOperations: DialectExploreOperations = { }, - async listTriggers(db: Kysely): Promise { + async listTriggers(db: Kysely, schema?: string): Promise { const result = await sql<{ trigger_name: string; @@ -869,7 +798,7 @@ export const postgresExploreOperations: DialectExploreOperations = { action_timing, event_manipulation FROM information_schema.triggers - WHERE trigger_schema NOT IN (${sql.join(EXCLUDED_SCHEMAS)}) + WHERE trigger_schema ${schemaFilter(schema)} ORDER BY trigger_schema, event_object_table, trigger_name `.execute(db); @@ -922,6 +851,10 @@ export const postgresExploreOperations: DialectExploreOperations = { l.granted FROM pg_locks l WHERE l.locktype != 'virtualxid' + AND ( + l.database IS NULL + OR l.database = (SELECT oid FROM pg_database WHERE datname = current_database()) + ) ORDER BY l.pid, l.locktype `.execute(db); diff --git a/src/core/explore/dialects/sqlite.ts b/src/core/explore/dialects/sqlite.ts index 49ec8450..6d31b814 100644 --- a/src/core/explore/dialects/sqlite.ts +++ b/src/core/explore/dialects/sqlite.ts @@ -4,12 +4,12 @@ * Queries SQLite system tables (sqlite_master) and PRAGMAs * to retrieve database object metadata. */ +import { attempt } from '@logosdx/utils'; import { sql } from 'kysely'; import type { Kysely } from 'kysely'; import type { DialectExploreOperations, - ExploreOverview, TableSummary, ViewSummary, ProcedureSummary, @@ -30,72 +30,65 @@ import type { } from '../types.js'; /** - * SQLite explore operations. + * Quote an identifier for interpolation into SQLite statement text. * - * Note: SQLite has limited metadata compared to other databases: - * - No stored procedures or functions - * - No custom types - * - No schemas (single schema per database) + * PRAGMA arguments and `FROM
` cannot be bound as parameters, so the + * name has to be concatenated. Doubling embedded `"` is what keeps it an + * identifier rather than a fragment of SQL: a table named `we"ird` otherwise + * aborts the statement, and with it every unrelated object in the same listing. + * + * @example + * ```typescript + * sql`PRAGMA table_info(${sql.raw(quoteIdent(name))})` + * ``` */ -export const sqliteExploreOperations: DialectExploreOperations = { +function quoteIdent(name: string): string { - async getOverview(db: Kysely): Promise { - - const [tables, views, indexes] = await Promise.all([ - sql<{ count: number }>` - SELECT COUNT(*) as count - FROM sqlite_master - WHERE type = 'table' - AND name NOT LIKE 'sqlite_%' - `.execute(db), - - sql<{ count: number }>` - SELECT COUNT(*) as count - FROM sqlite_master - WHERE type = 'view' - `.execute(db), - - sql<{ count: number }>` - SELECT COUNT(*) as count - FROM sqlite_master - WHERE type = 'index' - AND name NOT LIKE 'sqlite_%' - `.execute(db), - ]); - - // Count foreign keys by parsing all tables - const tablesResult = await sql<{ name: string }>` - SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' - `.execute(db); + return `"${name.replaceAll('"', '""')}"`; - let fkCount = 0; +} - for (const table of tablesResult.rows) { +/** + * Timing and event as written in a `CREATE TRIGGER` header. + * + * Everything from `BEGIN` onward is the trigger body; scanning it for keywords + * reports the body's own statements as trigger events and lets a `BEFORE` + * inside a string literal override the real timing. + */ +function parseTriggerHeader(definition: string): { + timing: 'BEFORE' | 'AFTER' | 'INSTEAD OF'; + events: ('INSERT' | 'UPDATE' | 'DELETE')[]; +} { - const fks = await sql<{ id: number }>` - PRAGMA foreign_key_list(${sql.raw(`"${table.name}"`)}) - `.execute(db); + const header = /\bTRIGGER\s+(?:IF\s+NOT\s+EXISTS\s+)?\S+\s+(?:(BEFORE|AFTER|INSTEAD\s+OF)\s+)?(DELETE|INSERT|UPDATE)\b/i + .exec(definition); - // Count unique FK ids - const uniqueIds = new Set(fks.rows.map((r) => r.id)); - fkCount += uniqueIds.size; + if (!header) { - } + return { timing: 'AFTER', events: ['INSERT'] }; - return { - tables: tables.rows[0]?.count ?? 0, - views: views.rows[0]?.count ?? 0, - procedures: 0, // SQLite doesn't support stored procedures - functions: 0, // SQLite doesn't support user-defined functions via SQL - types: 0, // SQLite doesn't support custom types - indexes: indexes.rows[0]?.count ?? 0, - foreignKeys: fkCount, - triggers: 0, // TODO: implement count - locks: 0, // SQLite doesn't expose lock information - connections: 0, // SQLite doesn't have connection tracking - }; + } - }, + const timing = header[1] + ? header[1].toUpperCase().replace(/\s+/, ' ') as 'BEFORE' | 'AFTER' | 'INSTEAD OF' + : 'AFTER'; + + return { + timing, + events: [header[2]!.toUpperCase() as 'INSERT' | 'UPDATE' | 'DELETE'], + }; + +} + +/** + * SQLite explore operations. + * + * Note: SQLite has limited metadata compared to other databases: + * - No stored procedures or functions + * - No custom types + * - No schemas (single schema per database) + */ +export const sqliteExploreOperations: DialectExploreOperations = { async listTables(db: Kysely): Promise { @@ -113,12 +106,12 @@ export const sqliteExploreOperations: DialectExploreOperations = { // Get column count const colsResult = await sql<{ cid: number }>` - PRAGMA table_info(${sql.raw(`"${row.name}"`)}) + PRAGMA table_info(${sql.raw(quoteIdent(row.name))}) `.execute(db); // Get row count estimate const countResult = await sql<{ count: number }>` - SELECT COUNT(*) as count FROM ${sql.raw(`"${row.name}"`)} + SELECT COUNT(*) as count FROM ${sql.raw(quoteIdent(row.name))} `.execute(db); tables.push({ @@ -146,14 +139,15 @@ export const sqliteExploreOperations: DialectExploreOperations = { for (const row of result.rows) { - // Get column count by querying the view - const colsResult = await sql<{ cid: number }>` - PRAGMA table_info(${sql.raw(`"${row.name}"`)}) - `.execute(db); + // A view whose base table was dropped makes PRAGMA table_info fail. + // Report it with no columns rather than losing the whole listing. + const [colsResult] = await attempt(() => sql<{ cid: number }>` + PRAGMA table_info(${sql.raw(quoteIdent(row.name))}) + `.execute(db)); views.push({ name: row.name, - columnCount: colsResult.rows.length, + columnCount: colsResult?.rows.length ?? 0, isUpdatable: false, // SQLite views are generally not updatable }); @@ -208,7 +202,7 @@ export const sqliteExploreOperations: DialectExploreOperations = { cid: number; name: string; }>` - PRAGMA index_info(${sql.raw(`"${row.name}"`)}) + PRAGMA index_info(${sql.raw(quoteIdent(row.name))}) `.execute(db); const columns = infoResult.rows.map((r) => r.name); @@ -248,7 +242,7 @@ export const sqliteExploreOperations: DialectExploreOperations = { on_update: string; on_delete: string; }>` - PRAGMA foreign_key_list(${sql.raw(`"${table.name}"`)}) + PRAGMA foreign_key_list(${sql.raw(quoteIdent(table.name))}) `.execute(db); // Group by FK id @@ -313,7 +307,7 @@ export const sqliteExploreOperations: DialectExploreOperations = { dflt_value: string | null; pk: number; }>` - PRAGMA table_info(${sql.raw(`"${name}"`)}) + PRAGMA table_info(${sql.raw(quoteIdent(name))}) `.execute(db); const columns: ColumnDetail[] = colsResult.rows.map((row) => ({ @@ -327,7 +321,7 @@ export const sqliteExploreOperations: DialectExploreOperations = { // Get row count const countResult = await sql<{ count: number }>` - SELECT COUNT(*) as count FROM ${sql.raw(`"${name}"`)} + SELECT COUNT(*) as count FROM ${sql.raw(quoteIdent(name))} `.execute(db); // Get indexes for this table @@ -375,7 +369,7 @@ export const sqliteExploreOperations: DialectExploreOperations = { notnull: number; dflt_value: string | null; }>` - PRAGMA table_info(${sql.raw(`"${name}"`)}) + PRAGMA table_info(${sql.raw(quoteIdent(name))}) `.execute(db); const columns: ColumnDetail[] = colsResult.rows.map((row) => ({ @@ -444,46 +438,13 @@ export const sqliteExploreOperations: DialectExploreOperations = { return result.rows.map((row) => { - // Parse timing and events from SQL - const sqlUpper = row.sql.toUpperCase(); - let timing: 'BEFORE' | 'AFTER' | 'INSTEAD OF' = 'AFTER'; - - if (sqlUpper.includes('BEFORE')) { - - timing = 'BEFORE'; - - } - else if (sqlUpper.includes('INSTEAD OF')) { - - timing = 'INSTEAD OF'; - - } - - const events: ('INSERT' | 'UPDATE' | 'DELETE')[] = []; - - if (sqlUpper.includes('INSERT')) { - - events.push('INSERT'); - - } - - if (sqlUpper.includes('UPDATE')) { - - events.push('UPDATE'); - - } - - if (sqlUpper.includes('DELETE')) { - - events.push('DELETE'); - - } + const { timing, events } = parseTriggerHeader(row.sql); return { name: row.name, tableName: row.tbl_name, timing, - events: events.length > 0 ? events : ['INSERT'], + events, }; }); @@ -527,46 +488,13 @@ export const sqliteExploreOperations: DialectExploreOperations = { } const row = result.rows[0]!; - - const sqlUpper = row.sql.toUpperCase(); - let timing = 'AFTER'; - - if (sqlUpper.includes('BEFORE')) { - - timing = 'BEFORE'; - - } - else if (sqlUpper.includes('INSTEAD OF')) { - - timing = 'INSTEAD OF'; - - } - - const events: string[] = []; - - if (sqlUpper.includes('INSERT')) { - - events.push('INSERT'); - - } - - if (sqlUpper.includes('UPDATE')) { - - events.push('UPDATE'); - - } - - if (sqlUpper.includes('DELETE')) { - - events.push('DELETE'); - - } + const { timing, events } = parseTriggerHeader(row.sql); return { name: row.name, tableName: row.tbl_name, timing, - events: events.length > 0 ? events : ['INSERT'], + events, definition: row.sql, isEnabled: true, // SQLite triggers are always enabled }; diff --git a/src/core/explore/operations.ts b/src/core/explore/operations.ts index a889a9c3..a27ebce3 100644 --- a/src/core/explore/operations.ts +++ b/src/core/explore/operations.ts @@ -39,6 +39,13 @@ export interface ExploreOptions { /** Include noorm internal tables (__noorm_*). Default: false */ includeNoormTables?: boolean; + /** + * Restrict results to one schema. Rejected on SQLite, which has none — + * silently returning an empty list would be indistinguishable from + * "the schema is empty". + */ + schema?: string; + } /** @@ -50,9 +57,32 @@ function isNoormTable(name: string | undefined | null): boolean { } +/** + * Guard the `schema` option against dialects that have no schema level. + * + * @throws when a schema is requested on SQLite + */ +function assertSchemaSupported(dialect: Dialect, schema?: string): void { + + if (schema && dialect === 'sqlite') { + + throw new Error( + 'SQLite has no schemas; drop the schema filter to explore this database', + ); + + } + +} + /** * Fetch overview counts for all object categories. * + * Counts come from the same listing calls the detail views use, so the + * overview cannot disagree with what drilling in shows. An earlier + * `getOverview()` fast path counted with separate `COUNT(*)` queries and + * hardcoded triggers/locks/connections to zero, which meant the numbers + * changed depending on an unrelated option. + * * @param db - Kysely database instance * @param dialect - Database dialect * @param options - Explore options @@ -71,40 +101,22 @@ export async function fetchOverview( ): Promise { const ops = getExploreOperations(dialect); - - // If excluding noorm tables, we need to fetch lists and count manually - if (!options.includeNoormTables) { - - const [tables, views, procedures, functions, types, indexes, foreignKeys, triggers, locks, connections] = - await Promise.all([ - ops.listTables(db), - ops.listViews(db), - ops.listProcedures(db), - ops.listFunctions(db), - ops.listTypes(db), - ops.listIndexes(db), - ops.listForeignKeys(db), - ops.listTriggers(db), - ops.listLocks(db), - ops.listConnections(db), - ]); - - return { - tables: tables.filter((t) => !isNoormTable(t.name)).length, - views: views.length, - procedures: procedures.length, - functions: functions.length, - types: types.length, - indexes: indexes.filter((i) => !isNoormTable(i.tableName)).length, - foreignKeys: foreignKeys.filter((fk) => !isNoormTable(fk.tableName)).length, - triggers: triggers.filter((t) => !isNoormTable(t.tableName)).length, - locks: locks.length, - connections: connections.length, - }; - - } - - const [result, err] = await attempt(() => ops.getOverview(db)); + const { schema } = options; + + assertSchemaSupported(dialect, schema); + + const [lists, err] = await attempt(() => Promise.all([ + ops.listTables(db, schema), + ops.listViews(db, schema), + ops.listProcedures(db, schema), + ops.listFunctions(db, schema), + ops.listTypes(db, schema), + ops.listIndexes(db, schema), + ops.listForeignKeys(db, schema), + ops.listTriggers(db, schema), + ops.listLocks(db), + ops.listConnections(db), + ])); if (err) { @@ -113,7 +125,21 @@ export async function fetchOverview( } - return result; + const [tables, views, procedures, functions, types, indexes, foreignKeys, triggers, locks, connections] = lists; + const keep = (name: string | undefined) => options.includeNoormTables || !isNoormTable(name); + + return { + tables: tables.filter((t) => keep(t.name)).length, + views: views.length, + procedures: procedures.length, + functions: functions.length, + types: types.length, + indexes: indexes.filter((i) => keep(i.tableName)).length, + foreignKeys: foreignKeys.filter((fk) => keep(fk.tableName)).length, + triggers: triggers.filter((t) => keep(t.tableName)).length, + locks: locks.length, + connections: connections.length, + }; } @@ -158,16 +184,19 @@ export async function fetchList( ): Promise { const ops = getExploreOperations(dialect); + const { schema } = options; + + assertSchemaSupported(dialect, schema); const methodMap: Record Promise> = { - tables: () => ops.listTables(db), - views: () => ops.listViews(db), - procedures: () => ops.listProcedures(db), - functions: () => ops.listFunctions(db), - types: () => ops.listTypes(db), - indexes: () => ops.listIndexes(db), - foreignKeys: () => ops.listForeignKeys(db), - triggers: () => ops.listTriggers(db), + tables: () => ops.listTables(db, schema), + views: () => ops.listViews(db, schema), + procedures: () => ops.listProcedures(db, schema), + functions: () => ops.listFunctions(db, schema), + types: () => ops.listTypes(db, schema), + indexes: () => ops.listIndexes(db, schema), + foreignKeys: () => ops.listForeignKeys(db, schema), + triggers: () => ops.listTriggers(db, schema), locks: () => ops.listLocks(db), connections: () => ops.listConnections(db), }; diff --git a/src/core/explore/types.ts b/src/core/explore/types.ts index b9de1e8e..b51e45a1 100644 --- a/src/core/explore/types.ts +++ b/src/core/explore/types.ts @@ -304,21 +304,22 @@ export interface TriggerDetail { */ export interface DialectExploreOperations { - /** - * Get counts of all object types. - */ - getOverview(db: Kysely): Promise; - - // List methods (return summaries for list views) - - listTables(db: Kysely): Promise; - listViews(db: Kysely): Promise; - listProcedures(db: Kysely): Promise; - listFunctions(db: Kysely): Promise; - listTypes(db: Kysely): Promise; - listIndexes(db: Kysely): Promise; - listForeignKeys(db: Kysely): Promise; - listTriggers(db: Kysely): Promise; + // List methods (return summaries for list views). + // + // `schema` narrows the query to one schema; omitted, the dialect's own + // system-schema exclusions apply. It must reach the generated SQL rather + // than being filtered afterwards — on MySQL a "schema" is a whole other + // database, so post-filtering a `DATABASE()`-pinned result can only ever + // return nothing. + + listTables(db: Kysely, schema?: string): Promise; + listViews(db: Kysely, schema?: string): Promise; + listProcedures(db: Kysely, schema?: string): Promise; + listFunctions(db: Kysely, schema?: string): Promise; + listTypes(db: Kysely, schema?: string): Promise; + listIndexes(db: Kysely, schema?: string): Promise; + listForeignKeys(db: Kysely, schema?: string): Promise; + listTriggers(db: Kysely, schema?: string): Promise; listLocks(db: Kysely): Promise; listConnections(db: Kysely): Promise; diff --git a/tests/core/explore/dialects/mssql.test.ts b/tests/core/explore/dialects/mssql.test.ts index 743ffb9c..3295f97c 100644 --- a/tests/core/explore/dialects/mssql.test.ts +++ b/tests/core/explore/dialects/mssql.test.ts @@ -1,300 +1,317 @@ /** * Unit tests for MSSQL explore dialect operations. * - * Tests SQL generation and response parsing without requiring a live database. + * Uses the recording harness so schema predicates and row-count coercion are + * asserted against the compiled statement and the parsed value, not inferred. */ -import { describe, it, expect, vi } from 'bun:test'; +import { describe, it, expect } from 'bun:test'; import { mssqlExploreOperations } from '../../../../src/core/explore/dialects/mssql.js'; +import { createRecordingDb } from '../recording-db.js'; -import type { Kysely } from 'kysely'; - -/** - * Creates a mock Kysely database instance with executor stub. - */ -function createMockDb(rows: unknown[]) { +describe('explore: mssql dialect', () => { - const mockExecutor = { - executeQuery: vi.fn().mockResolvedValue({ rows }), - transformQuery: vi.fn((node) => node), - compileQuery: vi.fn(() => ({ sql: 'SELECT 1', parameters: [], query: {} as never })), - adapter: { - supportsTransactionalDdl: true, - supportsReturning: true, - }, - withConnectionProvider: vi.fn(() => mockExecutor), - withPluginAtFront: vi.fn(() => mockExecutor), - }; + describe('row count typing', () => { - return { - getExecutor: () => mockExecutor, - withPlugin: vi.fn(function(this: unknown) { + it('should return rowCountEstimate as a number when listing tables', async () => { - return this; + // SUM(p.rows)/ISNULL(p.rows, 0) are bigint and the driver hands + // them back as text, which violates the declared `number` in --json. + const db = createRecordingDb('mssql', [ + { + match: /sys\.tables/, + rows: [{ table_name: 't1', schema_name: 'dbo', column_count: 2, row_count: '3' }], + }, + ]); - }), - } as unknown as Kysely; + const tables = await mssqlExploreOperations.listTables(db.kysely); -} + expect(tables[0]?.rowCountEstimate).toBe(3); -describe('explore: mssql dialect', () => { - - describe('listTriggers', () => { + }); - it('should return trigger summaries with name and table', async () => { + it('should report an empty table as undefined rather than "0"', async () => { - const mockRows = [ + const db = createRecordingDb('mssql', [ { - trigger_name: 'audit_trigger', - schema_name: 'dbo', - table_name: 'users', - is_instead_of_trigger: false, - is_disabled: false, - type_desc: 'INSERT', + match: /sys\.columns c/, + rows: [ + { + column_name: 'id', + data_type: 'int', + is_nullable: false, + column_default: null, + ordinal_position: 1, + is_identity: true, + }, + ], }, - ]; + { match: /is_primary_key = 1/, rows: [] }, + { match: /SUM\(p\.rows\)/, rows: [{ row_count: '0' }] }, + ]); - const db = createMockDb(mockRows); - const triggers = await mssqlExploreOperations.listTriggers(db); + const detail = await mssqlExploreOperations.getTableDetail(db.kysely, 'empty', 'dbo'); - expect(triggers).toHaveLength(1); - expect(triggers[0]).toEqual({ - name: 'audit_trigger', - schema: 'dbo', - tableName: 'users', - tableSchema: 'dbo', - timing: 'AFTER', - events: ['INSERT'], - }); + expect(detail?.rowCountEstimate).toBeUndefined(); }); - it('should handle INSTEAD OF triggers', async () => { + it('should return a numeric rowCountEstimate in table detail', async () => { - const mockRows = [ + const db = createRecordingDb('mssql', [ { - trigger_name: 'view_trigger', - schema_name: 'dbo', - table_name: 'vw_users', - is_instead_of_trigger: true, - is_disabled: false, - type_desc: 'INSERT', + match: /sys\.columns c/, + rows: [ + { + column_name: 'id', + data_type: 'int', + is_nullable: false, + column_default: null, + ordinal_position: 1, + is_identity: true, + }, + ], }, - ]; + { match: /is_primary_key = 1/, rows: [] }, + { match: /SUM\(p\.rows\)/, rows: [{ row_count: '42' }] }, + ]); - const db = createMockDb(mockRows); - const triggers = await mssqlExploreOperations.listTriggers(db); + const detail = await mssqlExploreOperations.getTableDetail(db.kysely, 't1', 'dbo'); - expect(triggers[0]?.timing).toBe('INSTEAD OF'); + expect(detail?.rowCountEstimate).toBe(42); }); - it('should combine multiple events for same trigger', async () => { + }); - const mockRows = [ - { - trigger_name: 'multi_event', - schema_name: 'dbo', - table_name: 'orders', - is_instead_of_trigger: false, - is_disabled: false, - type_desc: 'INSERT', - }, - { - trigger_name: 'multi_event', - schema_name: 'dbo', - table_name: 'orders', - is_instead_of_trigger: false, - is_disabled: false, - type_desc: 'UPDATE', - }, - { - trigger_name: 'multi_event', - schema_name: 'dbo', - table_name: 'orders', - is_instead_of_trigger: false, - is_disabled: false, - type_desc: 'DELETE', - }, - ]; + describe('schema scoping', () => { - const db = createMockDb(mockRows); - const triggers = await mssqlExploreOperations.listTriggers(db); + it('should filter listTables by schema instead of listing every schema', async () => { - expect(triggers).toHaveLength(1); - expect(triggers[0]?.events).toEqual(['INSERT', 'UPDATE', 'DELETE']); + const db = createRecordingDb('mssql', [{ match: /sys\.tables/, rows: [] }]); + + await mssqlExploreOperations.listTables(db.kysely, 'app'); + + const query = db.find(/sys\.tables/)!; + + expect(query.parameters).toContain('app'); + expect(query.parameters).not.toContain('INFORMATION_SCHEMA'); }); - it('should deduplicate events for same trigger', async () => { + it.each([ + 'listViews', + 'listProcedures', + 'listFunctions', + 'listTypes', + 'listIndexes', + 'listForeignKeys', + 'listTriggers', + ] as const)('should bind the requested schema in %s', async (method) => { - const mockRows = [ - { - trigger_name: 'dup_trigger', - schema_name: 'dbo', - table_name: 'products', - is_instead_of_trigger: false, - is_disabled: false, - type_desc: 'UPDATE', - }, + const db = createRecordingDb('mssql'); + + await mssqlExploreOperations[method](db.kysely, 'app'); + + expect(db.queries.at(-1)?.parameters).toContain('app'); + + }); + + it('should scope getTableDetail index and fk lookups to the requested schema', async () => { + + const db = createRecordingDb('mssql', [ { - trigger_name: 'dup_trigger', - schema_name: 'dbo', - table_name: 'products', - is_instead_of_trigger: false, - is_disabled: false, - type_desc: 'UPDATE', + match: /sys\.columns c/, + rows: [ + { + column_name: 'id', + data_type: 'int', + is_nullable: false, + column_default: null, + ordinal_position: 1, + is_identity: true, + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const triggers = await mssqlExploreOperations.listTriggers(db); + await mssqlExploreOperations.getTableDetail(db.kysely, 'orders', 'app'); - expect(triggers).toHaveLength(1); - expect(triggers[0]?.events).toEqual(['UPDATE']); + expect(db.find(/sys\.index_columns/)?.parameters).toContain('app'); + expect(db.find(/sys\.foreign_keys/)?.parameters).toContain('app'); }); }); - describe('listLocks', () => { + describe('listTriggers', () => { - it('should return lock info from sys.dm_tran_locks', async () => { + it('should return trigger summaries with name and table', async () => { - const mockRows = [ - { - request_session_id: 52, - resource_type: 'OBJECT', - resource_description: 'users', - request_mode: 'S', - request_status: 'GRANT', - }, + const db = createRecordingDb('mssql', [ { - request_session_id: 53, - resource_type: 'PAGE', - resource_description: '1:2345', - request_mode: 'X', - request_status: 'WAIT', + match: /sys\.triggers/, + rows: [ + { + trigger_name: 'audit_trigger', + schema_name: 'dbo', + table_name: 'users', + is_instead_of_trigger: false, + is_disabled: false, + type_desc: 'INSERT', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const locks = await mssqlExploreOperations.listLocks(db); + const triggers = await mssqlExploreOperations.listTriggers(db.kysely); - expect(locks).toHaveLength(2); - expect(locks[0]).toEqual({ - pid: 52, - lockType: 'OBJECT', - objectName: 'users', - mode: 'S', - granted: true, - }); - expect(locks[1]).toEqual({ - pid: 53, - lockType: 'PAGE', - objectName: '1:2345', - mode: 'X', - granted: false, + expect(triggers).toHaveLength(1); + expect(triggers[0]).toEqual({ + name: 'audit_trigger', + schema: 'dbo', + tableName: 'users', + tableSchema: 'dbo', + timing: 'AFTER', + events: ['INSERT'], }); }); - it('should handle empty resource description', async () => { + it('should handle INSTEAD OF triggers', async () => { - const mockRows = [ + const db = createRecordingDb('mssql', [ { - request_session_id: 52, - resource_type: 'DATABASE', - resource_description: '', - request_mode: 'S', - request_status: 'GRANT', + match: /sys\.triggers/, + rows: [ + { + trigger_name: 'view_trigger', + schema_name: 'dbo', + table_name: 'vw_users', + is_instead_of_trigger: true, + is_disabled: false, + type_desc: 'INSERT', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const locks = await mssqlExploreOperations.listLocks(db); + const triggers = await mssqlExploreOperations.listTriggers(db.kysely); - expect(locks[0]?.objectName).toBeUndefined(); + expect(triggers[0]?.timing).toBe('INSTEAD OF'); }); - }); - - describe('listConnections', () => { + it('should combine multiple events for the same trigger without duplicating', async () => { - it('should return connection info from sys.dm_exec_sessions', async () => { - - const mockRows = [ - { - session_id: 52, - login_name: 'app_user', - host_name: 'web-server-01', - program_name: 'node-app', - status: 'running', - login_time: new Date('2025-01-01T10:00:00Z'), - }, + const db = createRecordingDb('mssql', [ { - session_id: 53, - login_name: 'admin', - host_name: '', - program_name: '', - status: 'sleeping', - login_time: new Date('2025-01-01T09:00:00Z'), + match: /sys\.triggers/, + rows: [ + { + trigger_name: 'multi_event', + schema_name: 'dbo', + table_name: 'orders', + is_instead_of_trigger: false, + is_disabled: false, + type_desc: 'INSERT', + }, + { + trigger_name: 'multi_event', + schema_name: 'dbo', + table_name: 'orders', + is_instead_of_trigger: false, + is_disabled: false, + type_desc: 'UPDATE', + }, + { + trigger_name: 'multi_event', + schema_name: 'dbo', + table_name: 'orders', + is_instead_of_trigger: false, + is_disabled: false, + type_desc: 'UPDATE', + }, + ], }, - ]; - - const db = createMockDb(mockRows); - const connections = await mssqlExploreOperations.listConnections(db); - - expect(connections).toHaveLength(2); - expect(connections[0]).toEqual({ - pid: 52, - username: 'app_user', - database: 'current', - applicationName: 'node-app', - clientAddress: 'web-server-01', - backendStart: new Date('2025-01-01T10:00:00Z'), - state: 'running', - }); + ]); + + const triggers = await mssqlExploreOperations.listTriggers(db.kysely); + + expect(triggers).toHaveLength(1); + expect(triggers[0]?.events).toEqual(['INSERT', 'UPDATE']); }); - it('should handle empty program name', async () => { + }); - const mockRows = [ + describe('listLocks', () => { + + it('should return lock info scoped to the current database', async () => { + + const db = createRecordingDb('mssql', [ { - session_id: 52, - login_name: 'app_user', - host_name: 'web-server-01', - program_name: '', - status: 'running', - login_time: new Date('2025-01-01T10:00:00Z'), + match: /dm_tran_locks/, + rows: [ + { + request_session_id: 55, + resource_type: 'OBJECT', + resource_description: 'users', + request_mode: 'S', + request_status: 'GRANT', + }, + { + request_session_id: 56, + resource_type: 'PAGE', + resource_description: '', + request_mode: 'X', + request_status: 'WAIT', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const connections = await mssqlExploreOperations.listConnections(db); + const locks = await mssqlExploreOperations.listLocks(db.kysely); - expect(connections[0]?.applicationName).toBeUndefined(); + expect(db.find(/dm_tran_locks/)?.sql).toContain('DB_ID()'); + expect(locks[0]).toEqual({ + pid: 55, + lockType: 'OBJECT', + objectName: 'users', + mode: 'S', + granted: true, + }); + expect(locks[1]?.objectName).toBeUndefined(); + expect(locks[1]?.granted).toBe(false); }); - it('should handle empty host name', async () => { + }); + + describe('listConnections', () => { + + it('should exclude the current session in SQL rather than in JS', async () => { - const mockRows = [ + const db = createRecordingDb('mssql', [ { - session_id: 52, - login_name: 'app_user', - host_name: '', - program_name: 'SSMS', - status: 'running', - login_time: new Date('2025-01-01T10:00:00Z'), + match: /dm_exec_sessions/, + rows: [ + { + session_id: 55, + login_name: 'sa', + host_name: 'workstation', + program_name: 'noorm', + status: 'sleeping', + login_time: new Date('2025-01-01T10:00:00Z'), + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const connections = await mssqlExploreOperations.listConnections(db); + const connections = await mssqlExploreOperations.listConnections(db.kysely); - expect(connections[0]?.clientAddress).toBeUndefined(); + expect(db.find(/dm_exec_sessions/)?.sql).toContain('@@SPID'); + expect(connections[0]?.username).toBe('sa'); + expect(connections[0]?.clientAddress).toBe('workstation'); }); @@ -304,19 +321,23 @@ describe('explore: mssql dialect', () => { it('should return full trigger definition', async () => { - const mockRows = [ + const db = createRecordingDb('mssql', [ { - trigger_name: 'audit_trigger', - table_name: 'users', - is_instead_of_trigger: false, - is_disabled: false, - definition: 'CREATE TRIGGER audit_trigger ON users AFTER INSERT AS ...', - type_desc: 'INSERT', + match: /sys\.triggers/, + rows: [ + { + trigger_name: 'audit_trigger', + table_name: 'users', + is_instead_of_trigger: false, + is_disabled: false, + definition: 'CREATE TRIGGER audit_trigger ON users AFTER INSERT AS SELECT 1', + type_desc: 'INSERT', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const trigger = await mssqlExploreOperations.getTriggerDetail(db, 'audit_trigger', 'dbo'); + const trigger = await mssqlExploreOperations.getTriggerDetail(db.kysely, 'audit_trigger', 'dbo'); expect(trigger).toEqual({ name: 'audit_trigger', @@ -325,66 +346,41 @@ describe('explore: mssql dialect', () => { tableSchema: 'dbo', timing: 'AFTER', events: ['INSERT'], - definition: 'CREATE TRIGGER audit_trigger ON users AFTER INSERT AS ...', + definition: 'CREATE TRIGGER audit_trigger ON users AFTER INSERT AS SELECT 1', isEnabled: true, }); }); - it('should return null for non-existent trigger', async () => { - - const db = createMockDb([]); - const trigger = await mssqlExploreOperations.getTriggerDetail(db, 'nonexistent', 'dbo'); - - expect(trigger).toBeNull(); - - }); - - it('should handle disabled triggers', async () => { + it('should report a disabled trigger as not enabled', async () => { - const mockRows = [ + const db = createRecordingDb('mssql', [ { - trigger_name: 'disabled_trigger', - table_name: 'orders', - is_instead_of_trigger: false, - is_disabled: true, - definition: 'CREATE TRIGGER disabled_trigger ...', - type_desc: 'UPDATE', + match: /sys\.triggers/, + rows: [ + { + trigger_name: 'off_trigger', + table_name: 'users', + is_instead_of_trigger: false, + is_disabled: true, + definition: 'CREATE TRIGGER off_trigger ON users AFTER INSERT AS SELECT 1', + type_desc: 'INSERT', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const trigger = await mssqlExploreOperations.getTriggerDetail(db, 'disabled_trigger', 'dbo'); + const trigger = await mssqlExploreOperations.getTriggerDetail(db.kysely, 'off_trigger', 'dbo'); expect(trigger?.isEnabled).toBe(false); }); - it('should combine multiple events', async () => { - - const mockRows = [ - { - trigger_name: 'multi_trigger', - table_name: 'products', - is_instead_of_trigger: false, - is_disabled: false, - definition: 'CREATE TRIGGER multi_trigger ...', - type_desc: 'INSERT', - }, - { - trigger_name: 'multi_trigger', - table_name: 'products', - is_instead_of_trigger: false, - is_disabled: false, - definition: 'CREATE TRIGGER multi_trigger ...', - type_desc: 'UPDATE', - }, - ]; + it('should return null for non-existent trigger', async () => { - const db = createMockDb(mockRows); - const trigger = await mssqlExploreOperations.getTriggerDetail(db, 'multi_trigger', 'dbo'); + const db = createRecordingDb('mssql'); - expect(trigger?.events).toEqual(['INSERT', 'UPDATE']); + expect(await mssqlExploreOperations.getTriggerDetail(db.kysely, 'nope', 'dbo')).toBeNull(); }); diff --git a/tests/core/explore/dialects/mysql.test.ts b/tests/core/explore/dialects/mysql.test.ts index fe9b04f4..ce1b025f 100644 --- a/tests/core/explore/dialects/mysql.test.ts +++ b/tests/core/explore/dialects/mysql.test.ts @@ -1,108 +1,181 @@ /** * Unit tests for MySQL explore dialect operations. * - * Tests SQL generation and response parsing without requiring a live database. + * MySQL's "schema" is a database, and every catalog query used to be pinned to + * `DATABASE()`. The recording harness makes that visible: these tests assert + * which database each statement binds, not just the shape of the result. */ -import { describe, it, expect, vi } from 'bun:test'; +import { describe, it, expect } from 'bun:test'; import { mysqlExploreOperations } from '../../../../src/core/explore/dialects/mysql.js'; +import { createRecordingDb } from '../recording-db.js'; -import type { Kysely } from 'kysely'; +const CONNECTED_DB = 'noorm_audit'; +const OTHER_DB = 'noorm_audit_other'; -/** - * Creates a mock Kysely database instance with executor stub. - */ -function createMockDb(rows: unknown[]) { - - const mockExecutor = { - executeQuery: vi.fn().mockResolvedValue({ rows }), - transformQuery: vi.fn((node) => node), - compileQuery: vi.fn(() => ({ sql: 'SELECT 1', parameters: [], query: {} as never })), - adapter: { - supportsTransactionalDdl: true, - supportsReturning: true, - }, - withConnectionProvider: vi.fn(() => mockExecutor), - withPluginAtFront: vi.fn(() => mockExecutor), - }; +/** Every method opens with `SELECT DATABASE()`; answer it once per call. */ +function currentDatabase(name = CONNECTED_DB) { - return { - getExecutor: () => mockExecutor, - withPlugin: vi.fn(function(this: unknown) { - - return this; - - }), - } as unknown as Kysely; + return { match: /SELECT DATABASE\(\)/, rows: [{ db: name }] }; } describe('explore: mysql dialect', () => { - describe('listTriggers', () => { + describe('getTableDetail', () => { - it('should return trigger summaries with name and table', async () => { + it('should read indexes and foreign keys from the requested schema, not the connected one', async () => { - const mockRows = [ + // Columns came from `schema` while indexes/FKs came from DATABASE(), + // producing an object whose own `tableSchema` contradicted its label. + const db = createRecordingDb('mysql', [ + currentDatabase(), + { + match: /information_schema\.columns/, + rows: [ + { + column_name: 'id', + data_type: 'int', + is_nullable: 'NO', + column_default: null, + ordinal_position: 1, + column_key: 'PRI', + }, + ], + }, + { match: /information_schema\.tables/, rows: [{ table_rows: '3' }] }, + currentDatabase(), { - TRIGGER_NAME: 'audit_trigger', - TRIGGER_SCHEMA: 'mydb', - EVENT_OBJECT_TABLE: 'users', - ACTION_TIMING: 'AFTER', - EVENT_MANIPULATION: 'INSERT', + match: /information_schema\.statistics/, + rows: [ + { + index_name: 'idx_zzz', + table_name: 't1', + column_name: 'zzz', + non_unique: 1, + seq_in_index: 1, + }, + ], }, - ]; + currentDatabase(), + { match: /key_column_usage/, rows: [] }, + ]); - const db = createMockDb(mockRows); - const triggers = await mysqlExploreOperations.listTriggers(db); + const detail = await mysqlExploreOperations.getTableDetail(db.kysely, 't1', OTHER_DB); - expect(triggers).toHaveLength(1); - expect(triggers[0]).toEqual({ - name: 'audit_trigger', - schema: 'mydb', - tableName: 'users', - tableSchema: 'mydb', - timing: 'AFTER', - events: ['INSERT'], - }); + expect(db.find(/information_schema\.statistics/)?.parameters).toContain(OTHER_DB); + expect(db.find(/key_column_usage/)?.parameters).toContain(OTHER_DB); + + expect(detail?.schema).toBe(OTHER_DB); + expect(detail?.indexes.map((i) => i.name)).toEqual(['idx_zzz']); + expect(detail?.indexes[0]?.tableSchema).toBe(OTHER_DB); }); - it('should handle BEFORE timing', async () => { + it('should fall back to the connected database when no schema is given', async () => { - const mockRows = [ + const db = createRecordingDb('mysql', [ + currentDatabase(), { - TRIGGER_NAME: 'validate_trigger', - TRIGGER_SCHEMA: 'mydb', - EVENT_OBJECT_TABLE: 'orders', - ACTION_TIMING: 'BEFORE', - EVENT_MANIPULATION: 'UPDATE', + match: /information_schema\.columns/, + rows: [ + { + column_name: 'id', + data_type: 'int', + is_nullable: 'NO', + column_default: null, + ordinal_position: 1, + column_key: 'PRI', + }, + ], }, - ]; + { match: /information_schema\.tables/, rows: [] }, + currentDatabase(), + { match: /information_schema\.statistics/, rows: [] }, + currentDatabase(), + { match: /key_column_usage/, rows: [] }, + ]); - const db = createMockDb(mockRows); - const triggers = await mysqlExploreOperations.listTriggers(db); + await mysqlExploreOperations.getTableDetail(db.kysely, 't1'); - expect(triggers[0]?.timing).toBe('BEFORE'); - expect(triggers[0]?.events).toEqual(['UPDATE']); + expect(db.find(/information_schema\.statistics/)?.parameters).toContain(CONNECTED_DB); }); - it('should handle DELETE event', async () => { + }); + + describe('schema scoping', () => { + + it.each([ + 'listTables', + 'listViews', + 'listProcedures', + 'listFunctions', + 'listIndexes', + 'listForeignKeys', + 'listTriggers', + ] as const)('should bind the requested schema in %s', async (method) => { + + const db = createRecordingDb('mysql', [currentDatabase()]); + + await mysqlExploreOperations[method](db.kysely, OTHER_DB); + + expect(db.queries.at(-1)?.parameters).toContain(OTHER_DB); + + }); + + it('should not ask the server for DATABASE() when a schema is supplied', async () => { + + const db = createRecordingDb('mysql'); + + await mysqlExploreOperations.listTables(db.kysely, OTHER_DB); + + expect(db.find(/SELECT DATABASE\(\)/)).toBeUndefined(); + + }); + + it('should bind the connected database when no schema is supplied', async () => { + + const db = createRecordingDb('mysql', [currentDatabase()]); + + await mysqlExploreOperations.listTables(db.kysely); + + expect(db.find(/information_schema\.tables/)?.parameters).toContain(CONNECTED_DB); + + }); + + }); + + describe('listTriggers', () => { + + it('should return trigger summaries with name and table', async () => { - const mockRows = [ + const db = createRecordingDb('mysql', [ + currentDatabase('mydb'), { - TRIGGER_NAME: 'cascade_delete', - TRIGGER_SCHEMA: 'mydb', - EVENT_OBJECT_TABLE: 'products', - ACTION_TIMING: 'AFTER', - EVENT_MANIPULATION: 'DELETE', + match: /information_schema\.TRIGGERS/i, + rows: [ + { + TRIGGER_NAME: 'audit_trigger', + TRIGGER_SCHEMA: 'mydb', + EVENT_OBJECT_TABLE: 'users', + ACTION_TIMING: 'AFTER', + EVENT_MANIPULATION: 'INSERT', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const triggers = await mysqlExploreOperations.listTriggers(db); + const triggers = await mysqlExploreOperations.listTriggers(db.kysely); - expect(triggers[0]?.events).toEqual(['DELETE']); + expect(triggers).toHaveLength(1); + expect(triggers[0]).toEqual({ + name: 'audit_trigger', + schema: 'mydb', + tableName: 'users', + tableSchema: 'mydb', + timing: 'AFTER', + events: ['INSERT'], + }); }); @@ -112,25 +185,29 @@ describe('explore: mysql dialect', () => { it('should return lock info from performance_schema', async () => { - const mockRows = [ + const db = createRecordingDb('mysql', [ { - OBJECT_TYPE: 'TABLE', - OBJECT_NAME: 'users', - LOCK_TYPE: 'SHARED_READ', - LOCK_STATUS: 'GRANTED', - OWNER_THREAD_ID: 12345, + match: /metadata_locks/, + rows: [ + { + OBJECT_TYPE: 'TABLE', + OBJECT_NAME: 'users', + LOCK_TYPE: 'SHARED_READ', + LOCK_STATUS: 'GRANTED', + OWNER_THREAD_ID: 12345, + }, + { + OBJECT_TYPE: 'GLOBAL', + OBJECT_NAME: null, + LOCK_TYPE: 'EXCLUSIVE', + LOCK_STATUS: 'PENDING', + OWNER_THREAD_ID: 12346, + }, + ], }, - { - OBJECT_TYPE: 'TABLE', - OBJECT_NAME: 'orders', - LOCK_TYPE: 'EXCLUSIVE', - LOCK_STATUS: 'PENDING', - OWNER_THREAD_ID: 12346, - }, - ]; + ]); - const db = createMockDb(mockRows); - const locks = await mysqlExploreOperations.listLocks(db); + const locks = await mysqlExploreOperations.listLocks(db.kysely); expect(locks).toHaveLength(2); expect(locks[0]).toEqual({ @@ -140,32 +217,8 @@ describe('explore: mysql dialect', () => { mode: 'SHARED_READ', granted: true, }); - expect(locks[1]).toEqual({ - pid: 12346, - lockType: 'TABLE', - objectName: 'orders', - mode: 'EXCLUSIVE', - granted: false, - }); - - }); - - it('should handle null object names', async () => { - - const mockRows = [ - { - OBJECT_TYPE: 'GLOBAL', - OBJECT_NAME: null, - LOCK_TYPE: 'INTENTION_EXCLUSIVE', - LOCK_STATUS: 'GRANTED', - OWNER_THREAD_ID: 12345, - }, - ]; - - const db = createMockDb(mockRows); - const locks = await mysqlExploreOperations.listLocks(db); - - expect(locks[0]?.objectName).toBeUndefined(); + expect(locks[1]?.objectName).toBeUndefined(); + expect(locks[1]?.granted).toBe(false); }); @@ -175,27 +228,31 @@ describe('explore: mysql dialect', () => { it('should return connection info from PROCESSLIST', async () => { - const mockRows = [ - { - ID: 12345, - USER: 'app_user', - HOST: '192.168.1.100:45678', - DB: 'mydb', - STATE: 'Sending data', - INFO: 'SELECT * FROM users', - }, + const db = createRecordingDb('mysql', [ { - ID: 12346, - USER: 'admin', - HOST: 'localhost:56789', - DB: 'mydb', - STATE: null, - INFO: null, + match: /PROCESSLIST/i, + rows: [ + { + ID: 12345, + USER: 'app_user', + HOST: '192.168.1.100:45678', + DB: 'mydb', + STATE: 'Sending data', + INFO: 'SELECT * FROM users', + }, + { + ID: 12346, + USER: 'admin', + HOST: 'localhost:56789', + DB: 'mydb', + STATE: null, + INFO: null, + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const connections = await mysqlExploreOperations.listConnections(db); + const connections = await mysqlExploreOperations.listConnections(db.kysely); expect(connections).toHaveLength(2); expect(connections[0]).toEqual({ @@ -205,47 +262,17 @@ describe('explore: mysql dialect', () => { clientAddress: '192.168.1.100:45678', state: 'Sending data', }); + expect(connections[1]?.state).toBe('unknown'); }); - it('should handle null state as unknown', async () => { + it('should exclude the current connection in SQL rather than in JS', async () => { - const mockRows = [ - { - ID: 12345, - USER: 'app_user', - HOST: 'localhost', - DB: 'mydb', - STATE: null, - INFO: null, - }, - ]; + const db = createRecordingDb('mysql', [{ match: /PROCESSLIST/i, rows: [] }]); - const db = createMockDb(mockRows); - const connections = await mysqlExploreOperations.listConnections(db); + await mysqlExploreOperations.listConnections(db.kysely); - expect(connections[0]?.state).toBe('unknown'); - - }); - - it('should filter current connection', async () => { - - const mockRows = [ - { - ID: 12345, - USER: 'app_user', - HOST: 'localhost', - DB: 'mydb', - STATE: 'active', - INFO: null, - }, - ]; - - const db = createMockDb(mockRows); - const connections = await mysqlExploreOperations.listConnections(db); - - // Current connection is filtered in SQL query (ID != CONNECTION_ID()) - expect(connections).toHaveLength(1); + expect(db.find(/PROCESSLIST/i)?.sql).toContain('CONNECTION_ID()'); }); @@ -255,18 +282,22 @@ describe('explore: mysql dialect', () => { it('should return full trigger definition', async () => { - const mockRows = [ + const db = createRecordingDb('mysql', [ { - TRIGGER_NAME: 'audit_trigger', - EVENT_OBJECT_TABLE: 'users', - ACTION_TIMING: 'AFTER', - EVENT_MANIPULATION: 'INSERT', - ACTION_STATEMENT: 'BEGIN INSERT INTO audit_log VALUES (NEW.id); END', + match: /information_schema\.TRIGGERS/i, + rows: [ + { + TRIGGER_NAME: 'audit_trigger', + EVENT_OBJECT_TABLE: 'users', + ACTION_TIMING: 'AFTER', + EVENT_MANIPULATION: 'INSERT', + ACTION_STATEMENT: 'BEGIN INSERT INTO audit_log VALUES (NEW.id); END', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const trigger = await mysqlExploreOperations.getTriggerDetail(db, 'audit_trigger', 'mydb'); + const trigger = await mysqlExploreOperations.getTriggerDetail(db.kysely, 'audit_trigger', 'mydb'); expect(trigger).toEqual({ name: 'audit_trigger', @@ -283,30 +314,9 @@ describe('explore: mysql dialect', () => { it('should return null for non-existent trigger', async () => { - const db = createMockDb([]); - const trigger = await mysqlExploreOperations.getTriggerDetail(db, 'nonexistent', 'mydb'); - - expect(trigger).toBeNull(); - - }); - - it('should handle single event trigger', async () => { - - const mockRows = [ - { - TRIGGER_NAME: 'update_timestamp', - EVENT_OBJECT_TABLE: 'orders', - ACTION_TIMING: 'BEFORE', - EVENT_MANIPULATION: 'UPDATE', - ACTION_STATEMENT: 'SET NEW.updated_at = NOW()', - }, - ]; - - const db = createMockDb(mockRows); - const trigger = await mysqlExploreOperations.getTriggerDetail(db, 'update_timestamp', 'mydb'); + const db = createRecordingDb('mysql'); - expect(trigger?.events).toEqual(['UPDATE']); - expect(trigger?.timing).toBe('BEFORE'); + expect(await mysqlExploreOperations.getTriggerDetail(db.kysely, 'nope', 'mydb')).toBeNull(); }); diff --git a/tests/core/explore/dialects/postgres.test.ts b/tests/core/explore/dialects/postgres.test.ts index 4c5822d4..8cb70548 100644 --- a/tests/core/explore/dialects/postgres.test.ts +++ b/tests/core/explore/dialects/postgres.test.ts @@ -1,122 +1,207 @@ /** * Unit tests for PostgreSQL explore dialect operations. * - * Tests SQL generation and response parsing without requiring a live database. + * Uses the recording harness so the SQL each method generates is compiled for + * real and asserted. A predicate bug (wrong column, missing schema filter) is + * only visible here if the statement itself is inspected. */ -import { describe, it, expect, vi } from 'bun:test'; +import { describe, it, expect } from 'bun:test'; import { postgresExploreOperations } from '../../../../src/core/explore/dialects/postgres.js'; +import { createRecordingDb } from '../recording-db.js'; -import type { Kysely } from 'kysely'; +describe('explore: postgres dialect', () => { -/** - * Creates a mock Kysely database instance with executor stub. - */ -function createMockDb(rows: unknown[]) { + describe('getProcedureDetail', () => { - const mockExecutor = { - executeQuery: vi.fn().mockResolvedValue({ rows }), - transformQuery: vi.fn((node) => node), - compileQuery: vi.fn(() => ({ sql: 'SELECT 1', parameters: [], query: {} as never })), - adapter: { - supportsTransactionalDdl: true, - supportsReturning: true, - }, - withConnectionProvider: vi.fn(() => mockExecutor), - withPluginAtFront: vi.fn(() => mockExecutor), - }; + it('should look parameters up by the specific_name postgres actually stores', async () => { - return { - getExecutor: () => mockExecutor, - withPlugin: vi.fn(function(this: unknown) { + // information_schema.parameters.specific_name is `proname_oid`, never + // the bare name. Filtering on the bare name silently returns nothing. + const db = createRecordingDb('postgres', [ + { match: /FROM pg_proc/, rows: [{ oid: '675394', prosrc: 'BEGIN END' }] }, + { + match: /information_schema\.parameters/, + rows: [ + { + parameter_name: 'p_id', + data_type: 'integer', + parameter_mode: 'IN', + ordinal_position: '1', + parameter_default: null, + }, + { + parameter_name: 'p_note', + data_type: 'text', + parameter_mode: 'INOUT', + ordinal_position: '2', + parameter_default: null, + }, + ], + }, + ]); - return this; + const detail = await postgresExploreOperations.getProcedureDetail(db.kysely, 'sp_touch', 'public'); - }), - } as unknown as Kysely; + const paramQuery = db.find(/information_schema\.parameters/); -} + expect(paramQuery?.parameters).toContain('sp_touch_675394'); + expect(paramQuery?.parameters).not.toContain('sp_touch'); -describe('explore: postgres dialect', () => { + expect(detail?.parameters.map((p) => p.name)).toEqual(['p_id', 'p_note']); - describe('listTriggers', () => { + }); - it('should return trigger summaries with name and table', async () => { + it('should report OUT parameters, which a procedure can have and a function cannot', async () => { - const mockRows = [ + const db = createRecordingDb('postgres', [ + { match: /FROM pg_proc/, rows: [{ oid: '1', prosrc: 'BEGIN END' }] }, { - trigger_name: 'audit_trigger', - trigger_schema: 'public', - event_object_table: 'users', - event_object_schema: 'public', - action_timing: 'AFTER', - event_manipulation: 'INSERT', + match: /information_schema\.parameters/, + rows: [ + { + parameter_name: 'p_out', + data_type: 'text', + parameter_mode: 'OUT', + ordinal_position: '1', + parameter_default: null, + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const triggers = await postgresExploreOperations.listTriggers(db); + const detail = await postgresExploreOperations.getProcedureDetail(db.kysely, 'sp_out', 'public'); - expect(triggers).toHaveLength(1); - expect(triggers[0]).toEqual({ - name: 'audit_trigger', - schema: 'public', - tableName: 'users', - tableSchema: 'public', - timing: 'AFTER', - events: ['INSERT'], - }); + expect(db.find(/information_schema\.parameters/)?.sql).not.toContain('parameter_mode IN'); + expect(detail?.parameters).toEqual([ + { + name: 'p_out', + dataType: 'text', + mode: 'OUT', + defaultValue: undefined, + ordinalPosition: 1, + }, + ]); }); - it('should combine multiple events for same trigger', async () => { + it('should return null without querying parameters when the procedure is absent', async () => { - const mockRows = [ + const db = createRecordingDb('postgres'); + + const detail = await postgresExploreOperations.getProcedureDetail(db.kysely, 'nope', 'public'); + + expect(detail).toBeNull(); + expect(db.find(/information_schema\.parameters/)).toBeUndefined(); + + }); + + }); + + describe('getFunctionDetail', () => { + + it('should look parameters up by name_oid and skip OUT parameters', async () => { + + const db = createRecordingDb('postgres', [ { - trigger_name: 'update_trigger', - trigger_schema: 'public', - event_object_table: 'products', - event_object_schema: 'public', - action_timing: 'BEFORE', - event_manipulation: 'UPDATE', + match: /FROM pg_proc/, + rows: [{ oid: '99', prosrc: 'SELECT 1', return_type: 'integer', language: 'sql' }], }, { - trigger_name: 'update_trigger', - trigger_schema: 'public', - event_object_table: 'products', - event_object_schema: 'public', - action_timing: 'BEFORE', - event_manipulation: 'DELETE', + match: /information_schema\.parameters/, + rows: [ + { + parameter_name: 'a', + data_type: 'integer', + parameter_mode: 'IN', + ordinal_position: '1', + parameter_default: null, + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const triggers = await postgresExploreOperations.listTriggers(db); + const detail = await postgresExploreOperations.getFunctionDetail(db.kysely, 'fn_over', 'public'); - expect(triggers).toHaveLength(1); - expect(triggers[0]?.events).toEqual(['UPDATE', 'DELETE']); - expect(triggers[0]?.timing).toBe('BEFORE'); + const paramQuery = db.find(/information_schema\.parameters/); + + expect(paramQuery?.parameters).toContain('fn_over_99'); + expect(paramQuery?.sql).toContain('parameter_mode IN'); + expect(detail?.parameters.map((p) => p.name)).toEqual(['a']); + + }); + + }); + + describe('schema scoping', () => { + + it('should filter listTables by schema instead of listing every schema', async () => { + + const db = createRecordingDb('postgres', [{ match: /information_schema\.tables/, rows: [] }]); + + await postgresExploreOperations.listTables(db.kysely, 'app'); + + const query = db.find(/information_schema\.tables/)!; + + expect(query.parameters).toContain('app'); + expect(query.sql).not.toContain('not in'); + + }); + + it('should exclude system schemas when no schema is given', async () => { + + const db = createRecordingDb('postgres', [{ match: /information_schema\.tables/, rows: [] }]); + + await postgresExploreOperations.listTables(db.kysely); + + const query = db.find(/information_schema\.tables/)!; + + expect(query.parameters).toContain('pg_catalog'); + expect(query.parameters).toContain('noorm'); + + }); + + it.each([ + ['listViews', 'listViews'], + ['listProcedures', 'listProcedures'], + ['listFunctions', 'listFunctions'], + ['listTypes', 'listTypes'], + ['listIndexes', 'listIndexes'], + ['listForeignKeys', 'listForeignKeys'], + ['listTriggers', 'listTriggers'], + ] as const)('should bind the requested schema in %s', async (_label, method) => { + + const db = createRecordingDb('postgres'); + + await postgresExploreOperations[method](db.kysely, 'app'); + + expect(db.queries.at(-1)?.parameters).toContain('app'); }); - it('should exclude system schema triggers', async () => { + it('should scope getTableDetail index and fk lookups to the requested schema', async () => { - const mockRows = [ + const db = createRecordingDb('postgres', [ { - trigger_name: 'user_trigger', - trigger_schema: 'public', - event_object_table: 'users', - event_object_schema: 'public', - action_timing: 'AFTER', - event_manipulation: 'INSERT', + match: /information_schema\.columns/, + rows: [ + { + column_name: 'id', + data_type: 'integer', + is_nullable: 'NO', + column_default: null, + ordinal_position: '1', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const triggers = await postgresExploreOperations.listTriggers(db); + await postgresExploreOperations.getTableDetail(db.kysely, 'orders', 'app'); - // System schemas (pg_catalog, information_schema) are filtered in SQL query - expect(triggers).toHaveLength(1); - expect(triggers[0]?.schema).toBe('public'); + const indexQuery = db.find(/pg_indexes/)!; + const fkQuery = db.find(/table_constraints/)!; + + expect(indexQuery.parameters).toContain('app'); + expect(fkQuery.parameters).toContain('app'); }); @@ -126,25 +211,29 @@ describe('explore: postgres dialect', () => { it('should return lock info with pid and mode', async () => { - const mockRows = [ - { - pid: 12345, - locktype: 'relation', - relation: 'users', - mode: 'AccessShareLock', - granted: true, - }, + const db = createRecordingDb('postgres', [ { - pid: 12345, - locktype: 'transactionid', - relation: null, - mode: 'ExclusiveLock', - granted: true, + match: /pg_locks/, + rows: [ + { + pid: 12345, + locktype: 'relation', + relation: 'users', + mode: 'AccessShareLock', + granted: true, + }, + { + pid: 12345, + locktype: 'transactionid', + relation: null, + mode: 'ExclusiveLock', + granted: true, + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const locks = await postgresExploreOperations.listLocks(db); + const locks = await postgresExploreOperations.listLocks(db.kysely); expect(locks).toHaveLength(2); expect(locks[0]).toEqual({ @@ -154,34 +243,29 @@ describe('explore: postgres dialect', () => { mode: 'AccessShareLock', granted: true, }); - expect(locks[1]).toEqual({ - pid: 12345, - lockType: 'transactionid', - objectName: undefined, - mode: 'ExclusiveLock', - granted: true, - }); + expect(locks[1]?.objectName).toBeUndefined(); }); - it('should filter out virtualxid locks', async () => { + it('should exclude virtualxid locks in SQL rather than in JS', async () => { - const mockRows = [ - { - pid: 12345, - locktype: 'relation', - relation: 'users', - mode: 'AccessShareLock', - granted: true, - }, - ]; + const db = createRecordingDb('postgres', [{ match: /pg_locks/, rows: [] }]); - const db = createMockDb(mockRows); - const locks = await postgresExploreOperations.listLocks(db); + await postgresExploreOperations.listLocks(db.kysely); - // virtualxid locks are filtered in SQL query (WHERE l.locktype != 'virtualxid') - expect(locks).toHaveLength(1); - expect(locks[0]?.lockType).toBe('relation'); + expect(db.find(/pg_locks/)?.sql).toContain("locktype != 'virtualxid'"); + + }); + + it('should restrict locks to the current database', async () => { + + // pg_locks is cluster-wide: without a database predicate the count + // reflects unrelated tenants on the same server. + const db = createRecordingDb('postgres', [{ match: /pg_locks/, rows: [] }]); + + await postgresExploreOperations.listLocks(db.kysely); + + expect(db.find(/pg_locks/)?.sql).toContain('current_database()'); }); @@ -191,29 +275,33 @@ describe('explore: postgres dialect', () => { it('should return connection info excluding current backend', async () => { - const mockRows = [ - { - pid: 12345, - usename: 'app_user', - datname: 'mydb', - application_name: 'node-app', - client_addr: '192.168.1.100', - backend_start: new Date('2025-01-01T10:00:00Z'), - state: 'active', - }, + const db = createRecordingDb('postgres', [ { - pid: 12346, - usename: 'admin', - datname: 'mydb', - application_name: '', - client_addr: null, - backend_start: new Date('2025-01-01T09:00:00Z'), - state: 'idle', + match: /pg_stat_activity/, + rows: [ + { + pid: 12345, + usename: 'app_user', + datname: 'mydb', + application_name: 'node-app', + client_addr: '192.168.1.100', + backend_start: new Date('2025-01-01T10:00:00Z'), + state: 'active', + }, + { + pid: 12346, + usename: 'admin', + datname: 'mydb', + application_name: '', + client_addr: null, + backend_start: new Date('2025-01-01T09:00:00Z'), + state: 'idle', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const connections = await postgresExploreOperations.listConnections(db); + const connections = await postgresExploreOperations.listConnections(db.kysely); expect(connections).toHaveLength(2); expect(connections[0]).toEqual({ @@ -225,48 +313,77 @@ describe('explore: postgres dialect', () => { backendStart: new Date('2025-01-01T10:00:00Z'), state: 'active', }); + expect(connections[1]?.applicationName).toBeUndefined(); }); - it('should include application name when present', async () => { + }); + + describe('listTriggers', () => { - const mockRows = [ + it('should return trigger summaries with name and table', async () => { + + const db = createRecordingDb('postgres', [ { - pid: 12345, - usename: 'app_user', - datname: 'mydb', - application_name: 'psql', - client_addr: '127.0.0.1', - backend_start: new Date('2025-01-01T10:00:00Z'), - state: 'active', + match: /information_schema\.triggers/i, + rows: [ + { + trigger_name: 'audit_trigger', + trigger_schema: 'public', + event_object_table: 'users', + event_object_schema: 'public', + action_timing: 'AFTER', + event_manipulation: 'INSERT', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const connections = await postgresExploreOperations.listConnections(db); + const triggers = await postgresExploreOperations.listTriggers(db.kysely); - expect(connections[0]?.applicationName).toBe('psql'); + expect(triggers).toHaveLength(1); + expect(triggers[0]).toEqual({ + name: 'audit_trigger', + schema: 'public', + tableName: 'users', + tableSchema: 'public', + timing: 'AFTER', + events: ['INSERT'], + }); }); - it('should handle missing application name', async () => { + it('should combine multiple events for same trigger', async () => { - const mockRows = [ + const db = createRecordingDb('postgres', [ { - pid: 12345, - usename: 'app_user', - datname: 'mydb', - application_name: '', - client_addr: '127.0.0.1', - backend_start: new Date('2025-01-01T10:00:00Z'), - state: 'active', + match: /information_schema\.triggers/i, + rows: [ + { + trigger_name: 'update_trigger', + trigger_schema: 'public', + event_object_table: 'products', + event_object_schema: 'public', + action_timing: 'BEFORE', + event_manipulation: 'UPDATE', + }, + { + trigger_name: 'update_trigger', + trigger_schema: 'public', + event_object_table: 'products', + event_object_schema: 'public', + action_timing: 'BEFORE', + event_manipulation: 'DELETE', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const connections = await postgresExploreOperations.listConnections(db); + const triggers = await postgresExploreOperations.listTriggers(db.kysely); - expect(connections[0]?.applicationName).toBeUndefined(); + expect(triggers).toHaveLength(1); + expect(triggers[0]?.events).toEqual(['UPDATE', 'DELETE']); + expect(triggers[0]?.timing).toBe('BEFORE'); }); @@ -276,25 +393,29 @@ describe('explore: postgres dialect', () => { it('should return full trigger definition', async () => { - const mockRows = [ - { - trigger_name: 'audit_trigger', - event_object_table: 'users', - action_timing: 'AFTER', - event_manipulation: 'INSERT', - action_statement: 'EXECUTE FUNCTION audit_log()', - }, + const db = createRecordingDb('postgres', [ { - trigger_name: 'audit_trigger', - event_object_table: 'users', - action_timing: 'AFTER', - event_manipulation: 'UPDATE', - action_statement: 'EXECUTE FUNCTION audit_log()', + match: /information_schema\.triggers/i, + rows: [ + { + trigger_name: 'audit_trigger', + event_object_table: 'users', + action_timing: 'AFTER', + event_manipulation: 'INSERT', + action_statement: 'EXECUTE FUNCTION audit_log()', + }, + { + trigger_name: 'audit_trigger', + event_object_table: 'users', + action_timing: 'AFTER', + event_manipulation: 'UPDATE', + action_statement: 'EXECUTE FUNCTION audit_log()', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const trigger = await postgresExploreOperations.getTriggerDetail(db, 'audit_trigger', 'public'); + const trigger = await postgresExploreOperations.getTriggerDetail(db.kysely, 'audit_trigger', 'public'); expect(trigger).toEqual({ name: 'audit_trigger', @@ -311,47 +432,13 @@ describe('explore: postgres dialect', () => { it('should return null for non-existent trigger', async () => { - const db = createMockDb([]); - const trigger = await postgresExploreOperations.getTriggerDetail(db, 'nonexistent', 'public'); + const db = createRecordingDb('postgres'); + const trigger = await postgresExploreOperations.getTriggerDetail(db.kysely, 'nonexistent', 'public'); expect(trigger).toBeNull(); }); - it('should combine events from multiple rows', async () => { - - const mockRows = [ - { - trigger_name: 'multi_event_trigger', - event_object_table: 'orders', - action_timing: 'BEFORE', - event_manipulation: 'INSERT', - action_statement: 'EXECUTE FUNCTION validate_order()', - }, - { - trigger_name: 'multi_event_trigger', - event_object_table: 'orders', - action_timing: 'BEFORE', - event_manipulation: 'UPDATE', - action_statement: 'EXECUTE FUNCTION validate_order()', - }, - { - trigger_name: 'multi_event_trigger', - event_object_table: 'orders', - action_timing: 'BEFORE', - event_manipulation: 'DELETE', - action_statement: 'EXECUTE FUNCTION validate_order()', - }, - ]; - - const db = createMockDb(mockRows); - const trigger = await postgresExploreOperations.getTriggerDetail(db, 'multi_event_trigger', 'public'); - - expect(trigger?.events).toEqual(['INSERT', 'UPDATE', 'DELETE']); - expect(trigger?.timing).toBe('BEFORE'); - - }); - }); }); diff --git a/tests/core/explore/dialects/sqlite.test.ts b/tests/core/explore/dialects/sqlite.test.ts index f255f7de..08348ba5 100644 --- a/tests/core/explore/dialects/sqlite.test.ts +++ b/tests/core/explore/dialects/sqlite.test.ts @@ -1,153 +1,293 @@ /** * Unit tests for SQLite explore dialect operations. * - * Tests SQL generation and response parsing without requiring a live database. + * SQLite is the only dialect that concatenates identifiers into SQL text + * (PRAGMA and `FROM
` cannot take a bind parameter), so these tests + * assert the *compiled statement*, not just the parsed result. */ -import { describe, it, expect, vi } from 'bun:test'; +import { describe, it, expect } from 'bun:test'; import { sqliteExploreOperations } from '../../../../src/core/explore/dialects/sqlite.js'; +import { createRecordingDb } from '../recording-db.js'; -import type { Kysely } from 'kysely'; +const HOSTILE = 'we"ird'; -/** - * Creates a mock Kysely database instance with executor stub. - */ -function createMockDb(rows: unknown[]) { +describe('explore: sqlite dialect', () => { - const mockExecutor = { - executeQuery: vi.fn().mockResolvedValue({ rows }), - transformQuery: vi.fn((node) => node), - compileQuery: vi.fn(() => ({ sql: 'SELECT 1', parameters: [], query: {} as never })), - adapter: { - supportsTransactionalDdl: true, - supportsReturning: true, - }, - withConnectionProvider: vi.fn(() => mockExecutor), - withPluginAtFront: vi.fn(() => mockExecutor), - }; + describe('identifier quoting', () => { - return { - getExecutor: () => mockExecutor, - withPlugin: vi.fn(function(this: unknown) { + it('should double embedded quotes when listing tables', async () => { - return this; + // A single table named `we"ird` used to abort the whole listing — + // and every unrelated table with it — with a syntax error. + const db = createRecordingDb('sqlite', [ + { match: /type = 'table'/, rows: [{ name: HOSTILE }] }, + { match: /PRAGMA table_info/, rows: [{ cid: 0 }] }, + { match: /COUNT\(\*\)/, rows: [{ count: 3 }] }, + ]); - }), - } as unknown as Kysely; + const tables = await sqliteExploreOperations.listTables(db.kysely); -} + expect(db.find(/PRAGMA table_info/)?.sql).toContain('PRAGMA table_info("we""ird")'); + expect(db.find(/FROM "we/)?.sql).toContain('FROM "we""ird"'); + expect(tables[0]?.name).toBe(HOSTILE); -describe('explore: sqlite dialect', () => { + }); + + it('should double embedded quotes when listing views', async () => { + + const db = createRecordingDb('sqlite', [ + { match: /type = 'view'/, rows: [{ name: HOSTILE }] }, + { match: /PRAGMA table_info/, rows: [{ cid: 0 }] }, + ]); + + await sqliteExploreOperations.listViews(db.kysely); + + expect(db.find(/PRAGMA table_info/)?.sql).toContain('PRAGMA table_info("we""ird")'); + + }); + + it('should double embedded quotes when listing foreign keys', async () => { + + const db = createRecordingDb('sqlite', [ + { match: /type = 'table'/, rows: [{ name: HOSTILE }] }, + { match: /PRAGMA foreign_key_list/, rows: [] }, + ]); + + await sqliteExploreOperations.listForeignKeys(db.kysely); + + expect(db.find(/PRAGMA foreign_key_list/)?.sql) + .toContain('PRAGMA foreign_key_list("we""ird")'); + + }); + + it('should double embedded quotes when listing indexes', async () => { + + const db = createRecordingDb('sqlite', [ + { match: /type = 'index'/, rows: [{ name: HOSTILE, tbl_name: 't', sql: null }] }, + { match: /PRAGMA index_info/, rows: [] }, + ]); + + await sqliteExploreOperations.listIndexes(db.kysely); + + expect(db.find(/PRAGMA index_info/)?.sql).toContain('PRAGMA index_info("we""ird")'); + + }); + + it('should double embedded quotes in table detail', async () => { + + const db = createRecordingDb('sqlite', [ + { match: /type = 'table' and name =/i, rows: [{ name: HOSTILE }] }, + { match: /PRAGMA table_info/, rows: [{ cid: 0, name: 'id', type: 'INTEGER', notnull: 1, dflt_value: null, pk: 1 }] }, + { match: /COUNT\(\*\)/, rows: [{ count: 1 }] }, + ]); + + const detail = await sqliteExploreOperations.getTableDetail(db.kysely, HOSTILE); + + expect(db.find(/PRAGMA table_info/)?.sql).toContain('PRAGMA table_info("we""ird")'); + expect(db.find(/FROM "we/)?.sql).toContain('FROM "we""ird"'); + expect(detail?.name).toBe(HOSTILE); + + }); + + it('should double embedded quotes in view detail', async () => { + + const db = createRecordingDb('sqlite', [ + { match: /type = 'view' and name =/i, rows: [{ sql: 'CREATE VIEW ...' }] }, + { match: /PRAGMA table_info/, rows: [] }, + ]); + + await sqliteExploreOperations.getViewDetail(db.kysely, HOSTILE); + + expect(db.find(/PRAGMA table_info/)?.sql).toContain('PRAGMA table_info("we""ird")'); + + }); + + it('should double embedded quotes when counting foreign keys for the overview', async () => { + + const db = createRecordingDb('sqlite', [ + { match: /type = 'table'/, rows: [{ name: HOSTILE }] }, + { match: /PRAGMA foreign_key_list/, rows: [] }, + ]); + + await sqliteExploreOperations.listForeignKeys(db.kysely); + + expect(db.findAll(/PRAGMA/).every((q) => !/[^"]"[^"]*ird/.test(q.sql))).toBe(true); + + }); + + }); + + describe('resilience', () => { + + it('should keep listing views when one view has lost its base table', async () => { + + // PRAGMA table_info on a view over a dropped table errors; that must + // not take down the listing of every other view. + const db = createRecordingDb('sqlite', [ + { match: /type = 'view'/, rows: [{ name: 'broken' }, { name: 'fine' }] }, + ]); + + const original = db.kysely.getExecutor().executeQuery.bind(db.kysely.getExecutor()); + + db.kysely.getExecutor().executeQuery = ((compiled: { sql: string }, ...rest: unknown[]) => { + + if (compiled.sql.includes('PRAGMA table_info("broken")')) { + + return Promise.reject(new Error('no such table: main.t')); + + } + + return original(compiled as never, ...rest as []); + + }) as never; + + const views = await sqliteExploreOperations.listViews(db.kysely); + + expect(views.map((v) => v.name)).toEqual(['broken', 'fine']); + expect(views[0]?.columnCount).toBe(0); + + }); + + }); describe('listTriggers', () => { - it('should return trigger summaries with name and table', async () => { + it('should read timing and event from the trigger header, not the body', async () => { - const mockRows = [ + // `AFTER DELETE ... BEGIN INSERT INTO audit_log` used to report both + // INSERT and DELETE because the whole statement was substring-matched. + const db = createRecordingDb('sqlite', [ { - name: 'audit_trigger', - tbl_name: 'users', - sql: 'CREATE TRIGGER audit_trigger AFTER INSERT ON users BEGIN INSERT INTO audit_log VALUES (NEW.id); END', + match: /type = 'trigger'/, + rows: [ + { + name: 'cascade_delete', + tbl_name: 'products', + sql: 'CREATE TRIGGER cascade_delete AFTER DELETE ON products BEGIN INSERT INTO audit_log VALUES (OLD.id); END', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const triggers = await sqliteExploreOperations.listTriggers(db); + const triggers = await sqliteExploreOperations.listTriggers(db.kysely); - expect(triggers).toHaveLength(1); - expect(triggers[0]).toEqual({ - name: 'audit_trigger', - tableName: 'users', - timing: 'AFTER', - events: ['INSERT'], - }); + expect(triggers[0]?.events).toEqual(['DELETE']); + expect(triggers[0]?.timing).toBe('AFTER'); }); - it('should parse BEFORE timing from SQL', async () => { + it('should not let the word BEFORE inside a string literal change the timing', async () => { - const mockRows = [ + const db = createRecordingDb('sqlite', [ { - name: 'validate_trigger', - tbl_name: 'orders', - sql: 'CREATE TRIGGER validate_trigger BEFORE UPDATE ON orders BEGIN SELECT RAISE(ABORT, \'Invalid\'); END', + match: /type = 'trigger'/, + rows: [ + { + name: 'note_trigger', + tbl_name: 'orders', + sql: "CREATE TRIGGER note_trigger AFTER UPDATE ON orders BEGIN INSERT INTO log VALUES ('state BEFORE change'); END", + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const triggers = await sqliteExploreOperations.listTriggers(db); + const triggers = await sqliteExploreOperations.listTriggers(db.kysely); - expect(triggers[0]?.timing).toBe('BEFORE'); + expect(triggers[0]?.timing).toBe('AFTER'); expect(triggers[0]?.events).toEqual(['UPDATE']); }); - it('should parse INSTEAD OF timing from SQL', async () => { + it('should return trigger summaries with name and table', async () => { - const mockRows = [ + const db = createRecordingDb('sqlite', [ { - name: 'view_trigger', - tbl_name: 'vw_users', - sql: 'CREATE TRIGGER view_trigger INSTEAD OF INSERT ON vw_users BEGIN SELECT NEW.name; END', + match: /type = 'trigger'/, + rows: [ + { + name: 'audit_trigger', + tbl_name: 'users', + sql: 'CREATE TRIGGER audit_trigger AFTER INSERT ON users BEGIN SELECT 1; END', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const triggers = await sqliteExploreOperations.listTriggers(db); + const triggers = await sqliteExploreOperations.listTriggers(db.kysely); - expect(triggers[0]?.timing).toBe('INSTEAD OF'); - expect(triggers[0]?.events).toEqual(['INSERT']); + expect(triggers).toHaveLength(1); + expect(triggers[0]).toEqual({ + name: 'audit_trigger', + tableName: 'users', + timing: 'AFTER', + events: ['INSERT'], + }); }); - it('should parse DELETE event from SQL', async () => { + it('should parse BEFORE timing from the header', async () => { - const mockRows = [ + const db = createRecordingDb('sqlite', [ { - name: 'cascade_delete', - tbl_name: 'products', - sql: 'CREATE TRIGGER cascade_delete AFTER DELETE ON products BEGIN DELETE FROM product_prices WHERE product_id = OLD.id; END', + match: /type = 'trigger'/, + rows: [ + { + name: 'validate_trigger', + tbl_name: 'orders', + sql: 'CREATE TRIGGER validate_trigger BEFORE UPDATE ON orders BEGIN SELECT RAISE(ABORT, \'Invalid\'); END', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const triggers = await sqliteExploreOperations.listTriggers(db); + const triggers = await sqliteExploreOperations.listTriggers(db.kysely); - expect(triggers[0]?.events).toEqual(['DELETE']); + expect(triggers[0]?.timing).toBe('BEFORE'); + expect(triggers[0]?.events).toEqual(['UPDATE']); }); - it('should parse multiple events from SQL', async () => { + it('should parse INSTEAD OF timing from the header', async () => { - const mockRows = [ + const db = createRecordingDb('sqlite', [ { - name: 'multi_event', - tbl_name: 'orders', - sql: 'CREATE TRIGGER multi_event AFTER INSERT OR UPDATE OR DELETE ON orders BEGIN SELECT 1; END', + match: /type = 'trigger'/, + rows: [ + { + name: 'view_trigger', + tbl_name: 'vw_users', + sql: 'CREATE TRIGGER view_trigger INSTEAD OF INSERT ON vw_users BEGIN SELECT NEW.name; END', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const triggers = await sqliteExploreOperations.listTriggers(db); + const triggers = await sqliteExploreOperations.listTriggers(db.kysely); - expect(triggers[0]?.events).toEqual(['INSERT', 'UPDATE', 'DELETE']); + expect(triggers[0]?.timing).toBe('INSTEAD OF'); + expect(triggers[0]?.events).toEqual(['INSERT']); }); - it('should handle lowercase SQL keywords', async () => { + it('should handle UPDATE OF and lowercase keywords', async () => { - const mockRows = [ + const db = createRecordingDb('sqlite', [ { - name: 'lowercase_trigger', - tbl_name: 'users', - sql: 'create trigger lowercase_trigger before insert on users begin select 1; end', + match: /type = 'trigger'/, + rows: [ + { + name: 'lowercase_trigger', + tbl_name: 'users', + sql: 'create trigger if not exists lowercase_trigger before update of email on users begin select 1; end', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const triggers = await sqliteExploreOperations.listTriggers(db); + const triggers = await sqliteExploreOperations.listTriggers(db.kysely); expect(triggers[0]?.timing).toBe('BEFORE'); - expect(triggers[0]?.events).toEqual(['INSERT']); + expect(triggers[0]?.events).toEqual(['UPDATE']); }); @@ -157,10 +297,9 @@ describe('explore: sqlite dialect', () => { it('should return empty array', async () => { - const db = createMockDb([]); - const locks = await sqliteExploreOperations.listLocks(db); + const db = createRecordingDb('sqlite'); - expect(locks).toEqual([]); + expect(await sqliteExploreOperations.listLocks(db.kysely)).toEqual([]); }); @@ -170,10 +309,9 @@ describe('explore: sqlite dialect', () => { it('should return empty array', async () => { - const db = createMockDb([]); - const connections = await sqliteExploreOperations.listConnections(db); + const db = createRecordingDb('sqlite'); - expect(connections).toEqual([]); + expect(await sqliteExploreOperations.listConnections(db.kysely)).toEqual([]); }); @@ -183,23 +321,23 @@ describe('explore: sqlite dialect', () => { it('should return full trigger definition', async () => { - const mockRows = [ + const definition = 'CREATE TRIGGER audit_trigger AFTER INSERT ON users BEGIN INSERT INTO audit_log VALUES (NEW.id); END'; + + const db = createRecordingDb('sqlite', [ { - name: 'audit_trigger', - tbl_name: 'users', - sql: 'CREATE TRIGGER audit_trigger AFTER INSERT ON users BEGIN INSERT INTO audit_log VALUES (NEW.id); END', + match: /type = 'trigger'/, + rows: [{ name: 'audit_trigger', tbl_name: 'users', sql: definition }], }, - ]; + ]); - const db = createMockDb(mockRows); - const trigger = await sqliteExploreOperations.getTriggerDetail(db, 'audit_trigger'); + const trigger = await sqliteExploreOperations.getTriggerDetail(db.kysely, 'audit_trigger'); expect(trigger).toEqual({ name: 'audit_trigger', tableName: 'users', timing: 'AFTER', events: ['INSERT'], - definition: 'CREATE TRIGGER audit_trigger AFTER INSERT ON users BEGIN INSERT INTO audit_log VALUES (NEW.id); END', + definition, isEnabled: true, }); @@ -207,80 +345,46 @@ describe('explore: sqlite dialect', () => { it('should return null for non-existent trigger', async () => { - const db = createMockDb([]); - const trigger = await sqliteExploreOperations.getTriggerDetail(db, 'nonexistent'); - - expect(trigger).toBeNull(); - - }); - - it('should parse BEFORE timing', async () => { + const db = createRecordingDb('sqlite'); - const mockRows = [ - { - name: 'validate_trigger', - tbl_name: 'orders', - sql: 'CREATE TRIGGER validate_trigger BEFORE UPDATE ON orders BEGIN SELECT RAISE(ABORT, \'Invalid\'); END', - }, - ]; - - const db = createMockDb(mockRows); - const trigger = await sqliteExploreOperations.getTriggerDetail(db, 'validate_trigger'); - - expect(trigger?.timing).toBe('BEFORE'); - expect(trigger?.events).toEqual(['UPDATE']); + expect(await sqliteExploreOperations.getTriggerDetail(db.kysely, 'nonexistent')).toBeNull(); }); - it('should parse INSTEAD OF timing', async () => { + it('should not report body statements as trigger events', async () => { - const mockRows = [ + const db = createRecordingDb('sqlite', [ { - name: 'view_trigger', - tbl_name: 'vw_users', - sql: 'CREATE TRIGGER view_trigger INSTEAD OF DELETE ON vw_users BEGIN DELETE FROM users WHERE id = OLD.id; END', + match: /type = 'trigger'/, + rows: [ + { + name: 'touch', + tbl_name: 'products', + sql: 'CREATE TRIGGER touch AFTER INSERT ON products BEGIN UPDATE products SET updated_at = CURRENT_TIMESTAMP; DELETE FROM stale; END', + }, + ], }, - ]; + ]); - const db = createMockDb(mockRows); - const trigger = await sqliteExploreOperations.getTriggerDetail(db, 'view_trigger'); - - expect(trigger?.timing).toBe('INSTEAD OF'); - expect(trigger?.events).toEqual(['DELETE']); - - }); + const trigger = await sqliteExploreOperations.getTriggerDetail(db.kysely, 'touch'); - it('should parse multiple events', async () => { - - const mockRows = [ - { - name: 'multi_event', - tbl_name: 'products', - sql: 'CREATE TRIGGER multi_event AFTER INSERT OR UPDATE ON products BEGIN UPDATE products SET updated_at = CURRENT_TIMESTAMP; END', - }, - ]; - - const db = createMockDb(mockRows); - const trigger = await sqliteExploreOperations.getTriggerDetail(db, 'multi_event'); - - expect(trigger?.events).toEqual(['INSERT', 'UPDATE']); + expect(trigger?.events).toEqual(['INSERT']); }); - it('should default to INSERT if no events found', async () => { + it('should default to INSERT when the header cannot be parsed', async () => { - const mockRows = [ + const db = createRecordingDb('sqlite', [ { - name: 'malformed_trigger', - tbl_name: 'test', - sql: 'CREATE TRIGGER malformed_trigger AFTER ON test BEGIN SELECT 1; END', + match: /type = 'trigger'/, + rows: [{ name: 'malformed', tbl_name: 'test', sql: 'CREATE TRIGGER malformed' }], }, - ]; + ]); - const db = createMockDb(mockRows); - const trigger = await sqliteExploreOperations.getTriggerDetail(db, 'malformed_trigger'); + const trigger = await sqliteExploreOperations.getTriggerDetail(db.kysely, 'malformed'); expect(trigger?.events).toEqual(['INSERT']); + expect(trigger?.timing).toBe('AFTER'); }); diff --git a/tests/core/explore/dispatch.test.ts b/tests/core/explore/dispatch.test.ts new file mode 100644 index 00000000..20e4cda3 --- /dev/null +++ b/tests/core/explore/dispatch.test.ts @@ -0,0 +1,255 @@ +/** + * Tests for the dialect-agnostic explore entry points. + * + * `fetchOverview` / `fetchList` / `fetchDetail` had no unit coverage at all, + * which is where the "counts differ by code path" and "--schema does nothing" + * defects lived. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { attempt } from '@logosdx/utils'; + +import { fetchOverview, fetchList, fetchDetail } from '../../../src/core/explore/index.js'; +import { observer } from '../../../src/core/observer.js'; +import { createRecordingDb } from './recording-db.js'; + +let errors: { source?: string; error?: Error }[] = []; +let unsubscribe: (() => void) | null = null; + +beforeEach(() => { + + errors = []; + unsubscribe = observer.on('error', (data) => { + + errors.push(data as { source?: string; error?: Error }); + + }) as unknown as () => void; + +}); + +afterEach(() => { + + unsubscribe?.(); + +}); + +describe('explore: fetchOverview', () => { + + it('should emit an error event when the default path fails', async () => { + + // Only the includeNoormTables branch used to emit, so every CLI, SDK + // and TUI overview failure was invisible to the logger and the toast. + const db = createRecordingDb('postgres', [ + { match: /information_schema\.tables/, error: new Error('connection lost') }, + ]); + + const [, err] = await attempt(() => fetchOverview(db.kysely, 'postgres')); + + expect(err).toBeInstanceOf(Error); + expect(errors).toHaveLength(1); + expect(errors[0]?.source).toBe('explore'); + + }); + + it('should emit an error event when the includeNoormTables path fails', async () => { + + const db = createRecordingDb('postgres', [ + { match: /information_schema\.tables/, error: new Error('connection lost') }, + ]); + + const [, err] = await attempt(() => + fetchOverview(db.kysely, 'postgres', { includeNoormTables: true }), + ); + + expect(err).toBeInstanceOf(Error); + expect(errors).toHaveLength(1); + + }); + + it('should count triggers, locks and connections rather than reporting zero', async () => { + + // These three were hardcoded to 0 with a `TODO: implement count` on the + // includeNoormTables path, so turning on verbose logging zeroed them. + const db = createRecordingDb('postgres', [ + { match: /information_schema\.tables/, rows: [{ table_name: 'users' }] }, + { match: /information_schema\.views/, rows: [] }, + { match: /FROM pg_proc/, rows: [] }, + { match: /FROM pg_proc/, rows: [] }, + { match: /FROM pg_type/, rows: [] }, + { match: /pg_indexes/, rows: [] }, + { match: /table_constraints/, rows: [] }, + { + match: /information_schema\.triggers/, + rows: [ + { + trigger_name: 't_orders', + trigger_schema: 'public', + event_object_table: 'orders', + event_object_schema: 'public', + action_timing: 'BEFORE', + event_manipulation: 'INSERT', + }, + ], + }, + { match: /pg_locks/, rows: [{ pid: 1, locktype: 'relation', relation: null, mode: 'S', granted: true }] }, + { + match: /pg_stat_activity/, + rows: [ + { pid: 2, usename: 'u', datname: 'd', application_name: '', client_addr: null, backend_start: new Date(), state: 'idle' }, + { pid: 3, usename: 'u', datname: 'd', application_name: '', client_addr: null, backend_start: new Date(), state: 'idle' }, + ], + }, + ]); + + const overview = await fetchOverview(db.kysely, 'postgres', { includeNoormTables: true }); + + expect(overview.triggers).toBe(1); + expect(overview.locks).toBe(1); + expect(overview.connections).toBe(2); + + }); + + it('should report identical counts on both option paths', async () => { + + const rules = () => [ + { + match: /information_schema\.tables/, + rows: [ + { table_name: 'users', table_schema: 'public', column_count: '2', row_estimate: '0' }, + { table_name: 'orders', table_schema: 'public', column_count: '3', row_estimate: '0' }, + ], + }, + { match: /information_schema\.triggers/, rows: [] }, + ]; + + const withoutNoorm = await fetchOverview( + createRecordingDb('postgres', rules()).kysely, + 'postgres', + ); + + const withNoorm = await fetchOverview( + createRecordingDb('postgres', rules()).kysely, + 'postgres', + { includeNoormTables: true }, + ); + + expect(withNoorm).toEqual(withoutNoorm); + + }); + + it('should exclude noorm bookkeeping tables unless asked for them', async () => { + + const rules = () => [ + { + match: /information_schema\.tables/, + rows: [ + { table_name: 'users', table_schema: 'public', column_count: '2', row_estimate: '0' }, + { table_name: '__noorm_change__', table_schema: 'public', column_count: '4', row_estimate: '0' }, + ], + }, + { match: /information_schema\.triggers/, rows: [] }, + ]; + + const hidden = await fetchOverview(createRecordingDb('postgres', rules()).kysely, 'postgres'); + const shown = await fetchOverview( + createRecordingDb('postgres', rules()).kysely, + 'postgres', + { includeNoormTables: true }, + ); + + expect(hidden.tables).toBe(1); + expect(shown.tables).toBe(2); + + }); + +}); + +describe('explore: fetchList', () => { + + it('should push a schema filter down into the dialect query', async () => { + + // The CLI accepted --schema on list commands and dropped it on the + // floor; the filter has to reach the generated SQL to mean anything. + const db = createRecordingDb('postgres', [{ match: /information_schema\.views/, rows: [] }]); + + await fetchList(db.kysely, 'postgres', 'views', { schema: 'app' }); + + expect(db.find(/information_schema\.views/)?.parameters).toContain('app'); + + }); + + it('should reject a schema filter on sqlite, which has no schemas', async () => { + + const db = createRecordingDb('sqlite'); + + const [, err] = await attempt(() => + fetchList(db.kysely, 'sqlite', 'tables', { schema: 'app' }), + ); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('schema'); + expect(db.queries).toHaveLength(0); + + }); + + it('should emit an error event and rethrow when the query fails', async () => { + + const db = createRecordingDb('postgres', [ + { match: /information_schema\.tables/, error: new Error('boom') }, + ]); + + const [, err] = await attempt(() => fetchList(db.kysely, 'postgres', 'tables')); + + expect(err?.message).toBe('boom'); + expect(errors).toHaveLength(1); + + }); + + it('should filter noorm bookkeeping objects out of every category that names a table', async () => { + + const db = createRecordingDb('postgres', [ + { + match: /pg_indexes/, + rows: [ + { indexname: 'i1', schemaname: 'public', tablename: 'users', indexdef: 'CREATE INDEX i1 ON users (id)', is_primary: false }, + { indexname: 'i2', schemaname: 'public', tablename: '__noorm_change__', indexdef: 'CREATE INDEX i2 ON x (id)', is_primary: false }, + ], + }, + ]); + + const indexes = await fetchList(db.kysely, 'postgres', 'indexes'); + + expect(indexes.map((i) => i.tableName)).toEqual(['users']); + + }); + +}); + +describe('explore: fetchDetail', () => { + + it('should emit an error event and rethrow when the query fails', async () => { + + const db = createRecordingDb('postgres', [ + { match: /information_schema\.columns/, error: new Error('nope') }, + ]); + + const [, err] = await attempt(() => + fetchDetail(db.kysely, 'postgres', 'tables', 'users', 'public'), + ); + + expect(err?.message).toBe('nope'); + expect(errors).toHaveLength(1); + + }); + + it('should return null rather than throwing when the object is absent', async () => { + + const db = createRecordingDb('postgres'); + + const detail = await fetchDetail(db.kysely, 'postgres', 'tables', 'ghost', 'public'); + + expect(detail).toBeNull(); + expect(errors).toHaveLength(0); + + }); + +}); From 2fd2b564ad414ace8a6ce648201bce86912a2098 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:48:01 -0400 Subject: [PATCH 040/105] 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. --- src/core/state/access.ts | 76 ++++++++++++++ src/core/state/manager.ts | 34 +++---- src/core/version/state/migrations/v2.ts | 9 +- tests/core/state/access.test.ts | 128 ++++++++++++++++++++++++ tests/core/state/manager.test.ts | 81 +++++++++++++++ tests/core/version/state.test.ts | 79 +++++++++++++++ 6 files changed, 386 insertions(+), 21 deletions(-) create mode 100644 src/core/state/access.ts create mode 100644 tests/core/state/access.test.ts diff --git a/src/core/state/access.ts b/src/core/state/access.ts new file mode 100644 index 00000000..cede71e4 --- /dev/null +++ b/src/core/state/access.ts @@ -0,0 +1,76 @@ +/** + * Fail-closed repair of a config's access roles. + * + * Two places read `access` straight off untyped JSON — the v2 schema + * migration and the load-time backfill in StateManager — and both used to + * accept whatever they found. A truthy-but-invalid `access` such as `{}` + * passed through verbatim and bricked the config (every later command + * failed zod validation), while a truthy-but-non-boolean legacy + * `protected` such as `"true"` fell through to the admin/admin default and + * silently removed the protection it was asking for. + * + * The rule here is one-directional: an unrecognised shape may only make a + * config more restrictive, never less. + */ +import { resolveLegacyAccess } from '../policy/index.js'; +import type { ConfigAccess, Role } from '../policy/index.js'; + +const ROLES: readonly string[] = ['viewer', 'operator', 'admin']; + +/** Least privilege for the user channel; the floor for an unreadable value. */ +const MOST_RESTRICTIVE_ROLE: Role = 'viewer'; + +function isRecord(value: unknown): value is Record { + + return typeof value === 'object' && value !== null; + +} + +function isRole(value: unknown): value is Role { + + return typeof value === 'string' && ROLES.includes(value); + +} + +/** + * `mcp: false` is not a role — it hides the config from the MCP channel + * entirely, which is strictly more restrictive than any role. It must + * survive repair untouched. + */ +function isMcpAccess(value: unknown): value is Role | false { + + return value === false || isRole(value); + +} + +/** + * Resolve a config's access from raw, untrusted state data. + * + * @param rawAccess - the `access` value as found on disk, any shape + * @param rawProtected - the legacy `protected` value, any shape + * + * @example + * ```typescript + * repairConfigAccess({ user: 'admin' }, undefined); // { user: 'admin', mcp: 'viewer' } + * repairConfigAccess(undefined, 'true'); // { user: 'operator', mcp: 'viewer' } + * ``` + */ +export function repairConfigAccess(rawAccess: unknown, rawProtected: unknown): ConfigAccess { + + // Any truthy legacy value means the author asked for protection. Only + // a strict `true` used to count, so `"true"` and `1` both resolved to + // fully open — the opposite of what they say. + const legacyProtected = Boolean(rawProtected); + + if (!isRecord(rawAccess)) { + + return resolveLegacyAccess(undefined, legacyProtected); + + } + + return { + user: isRole(rawAccess['user']) ? rawAccess['user'] : MOST_RESTRICTIVE_ROLE, + mcp: isMcpAccess(rawAccess['mcp']) ? rawAccess['mcp'] : MOST_RESTRICTIVE_ROLE, + }; + +} diff --git a/src/core/state/manager.ts b/src/core/state/manager.ts index 6d56a028..5b46c3de 100644 --- a/src/core/state/manager.ts +++ b/src/core/state/manager.ts @@ -8,12 +8,12 @@ */ import { chmodSync, existsSync, mkdirSync, readFileSync } from 'fs'; import { dirname, join } from 'path'; -import { attemptSync, attempt, clone } from '@logosdx/utils'; +import { attemptSync, attempt, clone, equals } from '@logosdx/utils'; import type { Config } from '../config/types.js'; import { assertCanDeleteConfig, type SettingsProvider } from '../config/resolver.js'; import type { KnownUser } from '../identity/types.js'; import { loadPrivateKey } from '../identity/storage.js'; -import { resolveLegacyAccess } from '../policy/index.js'; +import { repairConfigAccess } from './access.js'; import { migrateState as migrateSchemaVersion, needsStateMigration, @@ -228,38 +228,36 @@ export class StateManager { this.#state = migrateState(schemaMigratedState, currentVersion); // Raw-data-boundary invariant: migrations above guarantee every - // config carries `access`, but a hand-edited or corrupted state - // file can still reach this point without it. This is the single - // place that backfills it, so no downstream consumer + // config carries a well-formed `access`, but a hand-edited or + // corrupted state file can still reach this point without one. This + // is the single place that repairs it, so no downstream consumer // (setConfig/listConfigs/guarded) needs its own fallback. Tracking // whether it actually mutated anything (below) feeds the persist - // decision, so a config backfilled at an already-current schema + // decision, so a config repaired at an already-current schema // version still lands on disk instead of being re-healed forever. // // `config` is typed `Config`, but the object underneath is untyped // JSON that reached the current schema version without ever passing // through `parseConfig` (e.g. a legacy-shaped config saved directly - // via `setConfig`, bypassing the zod path). It can still carry a - // stray legacy `protected` key the type doesn't declare, so read it - // defensively rather than assuming `undefined` — fail-safe, - // consistent with the fail-closed default: `protected: true` maps - // to operator/viewer, never the admin/admin fallback. + // via `setConfig`, bypassing the zod path). It can carry a stray + // legacy `protected` key the type doesn't declare, or an `access` + // of any shape at all, so both are read as untrusted input. let backfilledAccess = false; for (const config of Object.values(this.#state.configs)) { - if (!config.access) { + const rawConfig: unknown = config; + const legacyProtected = isRecord(rawConfig) ? rawConfig['protected'] : undefined; + + const repaired = repairConfigAccess(config.access, legacyProtected); + + if (!config.access || !equals(config.access, repaired)) { backfilledAccess = true; } - const rawConfig: unknown = config; - const legacyProtected = isRecord(rawConfig) && typeof rawConfig['protected'] === 'boolean' - ? rawConfig['protected'] - : undefined; - - config.access = resolveLegacyAccess(config.access, legacyProtected); + config.access = repaired; } diff --git a/src/core/version/state/migrations/v2.ts b/src/core/version/state/migrations/v2.ts index bcb18325..2b95b1ea 100644 --- a/src/core/version/state/migrations/v2.ts +++ b/src/core/version/state/migrations/v2.ts @@ -5,7 +5,7 @@ * access roles. For state schema documentation, see * docs/spec/config-access-roles.md#migration. */ -import { resolveLegacyAccess } from '../../../policy/index.js'; +import { repairConfigAccess } from '../../../state/access.js'; import type { StateMigration } from '../../types.js'; function isRecord(value: unknown): value is Record { @@ -21,7 +21,10 @@ function isRecord(value: unknown): value is Record { * - `protected: false` or absent -> `{ user: 'admin', mcp: 'admin' }` * * The stored `protected` field is dropped — `access` becomes the sole - * source of truth for a config's roles. + * source of truth for a config's roles. Because `protected` does not + * survive this migration, an `access` written here that later turns out to + * be wrong cannot be reconstructed; `repairConfigAccess` is what keeps a + * malformed one from being frozen in permanently. */ export const v2: StateMigration = { version: 2, @@ -47,7 +50,7 @@ export const v2: StateMigration = { migratedConfigs[name] = { ...rest, - access: access ?? resolveLegacyAccess(undefined, legacyProtected === true), + access: repairConfigAccess(access, legacyProtected), }; } diff --git a/tests/core/state/access.test.ts b/tests/core/state/access.test.ts new file mode 100644 index 00000000..3a0565c0 --- /dev/null +++ b/tests/core/state/access.test.ts @@ -0,0 +1,128 @@ +/** + * Config access repair tests. + * + * The invariant: a config must never come out of migration or load-time + * repair *more* permissive than what it went in as. An unrecognised shape + * is a reason to restrict, not a reason to fall back to full admin. + */ +import { describe, it, expect } from 'bun:test'; +import { repairConfigAccess } from '../../../src/core/state/access.js'; +import { GUARDED_ACCESS, OPEN_ACCESS } from '../../../src/core/policy/index.js'; + +describe('state: access repair', () => { + + describe('legacy protected flag', () => { + + it('should map protected:true to guarded access', () => { + + expect(repairConfigAccess(undefined, true)).toEqual(GUARDED_ACCESS); + + }); + + it('should map protected:false to open access', () => { + + expect(repairConfigAccess(undefined, false)).toEqual(OPEN_ACCESS); + + }); + + it('should map an absent protected flag to open access', () => { + + expect(repairConfigAccess(undefined, undefined)).toEqual(OPEN_ACCESS); + + }); + + it('should treat a truthy non-boolean protected as guarded, not open', () => { + + // A state file written outside the zod path can carry a string + // or a number here. Requiring a strict `true` made every one of + // those fall through to the admin/admin default — the exact + // opposite of the fail-closed behaviour the code claims. + expect(repairConfigAccess(undefined, 'true')).toEqual(GUARDED_ACCESS); + expect(repairConfigAccess(undefined, 1)).toEqual(GUARDED_ACCESS); + expect(repairConfigAccess(undefined, 'yes')).toEqual(GUARDED_ACCESS); + + }); + + it('should treat a falsy non-boolean protected as open', () => { + + expect(repairConfigAccess(undefined, 0)).toEqual(OPEN_ACCESS); + expect(repairConfigAccess(undefined, '')).toEqual(OPEN_ACCESS); + expect(repairConfigAccess(undefined, null)).toEqual(OPEN_ACCESS); + + }); + + }); + + describe('explicit access', () => { + + it('should keep a fully valid access untouched', () => { + + expect(repairConfigAccess({ user: 'operator', mcp: 'viewer' }, true)) + .toEqual({ user: 'operator', mcp: 'viewer' }); + + }); + + it('should keep mcp:false, which hides the config rather than widening it', () => { + + expect(repairConfigAccess({ user: 'viewer', mcp: false }, undefined)) + .toEqual({ user: 'viewer', mcp: false }); + + }); + + it('should let a valid access win over the legacy flag', () => { + + expect(repairConfigAccess({ user: 'admin', mcp: 'admin' }, true)) + .toEqual({ user: 'admin', mcp: 'admin' }); + + }); + + }); + + describe('malformed access', () => { + + it('should restrict an empty access rather than leaving it unusable', () => { + + // `{}` is truthy, so it used to pass straight through and brick + // the config: every later command failed zod validation with + // `expected one of "viewer"|"operator"|"admin"`. + expect(repairConfigAccess({}, true)).toEqual({ user: 'viewer', mcp: 'viewer' }); + + }); + + it('should fill a missing channel with the most restrictive role', () => { + + expect(repairConfigAccess({ user: 'admin' }, undefined)) + .toEqual({ user: 'admin', mcp: 'viewer' }); + + }); + + it('should replace an unrecognised role with the most restrictive one', () => { + + expect(repairConfigAccess({ user: 'superuser', mcp: 'admin' }, undefined)) + .toEqual({ user: 'viewer', mcp: 'admin' }); + + }); + + it('should never widen a config whose access is malformed', () => { + + for (const malformed of [{}, { user: 'nope' }, { mcp: 'nope' }, { user: null }]) { + + const repaired = repairConfigAccess(malformed, undefined); + + expect({ input: malformed, access: repaired }) + .not.toEqual({ input: malformed, access: OPEN_ACCESS }); + + } + + }); + + it('should restrict when access is present but not an object', () => { + + expect(repairConfigAccess('admin', undefined)).toEqual(OPEN_ACCESS); + expect(repairConfigAccess(42, true)).toEqual(GUARDED_ACCESS); + + }); + + }); + +}); diff --git a/tests/core/state/manager.test.ts b/tests/core/state/manager.test.ts index 1ab0dd0e..32985650 100644 --- a/tests/core/state/manager.test.ts +++ b/tests/core/state/manager.test.ts @@ -176,6 +176,35 @@ describe('state: manager', () => { } + /** + * Writes a state.enc already at the current schema version, so the + * schema migration is a no-op and the raw config shape reaches the + * load-time repair loop untouched. + */ + function writeCurrentState( + statePath: string, + privateKey: string, + configs: Record, + ): void { + + const currentState = { + version: getPackageVersion(), + schemaVersion: CURRENT_VERSIONS.state, + knownUsers: {}, + activeConfig: null, + configs, + secrets: {}, + globalSecrets: {}, + }; + + mkdirSync(dirname(statePath), { recursive: true }); + writeFileSync( + statePath, + JSON.stringify(encrypt(JSON.stringify(currentState), privateKey), null, 2), + ); + + } + it('should migrate a legacy protected:true config to guarded access', async () => { const statePath = state.getStatePath(); @@ -329,6 +358,58 @@ describe('state: manager', () => { }); + it('should guard a config whose legacy protected flag is a truthy non-boolean', async () => { + + // The backfill only accepted a strict boolean, so `"true"` -- + // which a config saved outside the zod path can carry -- took + // the admin/admin fallback and lost its protection entirely. + const statePath = state.getStatePath(); + + writeCurrentState(statePath, testPrivateKey, { + prod: { + name: 'prod', + type: 'local', + isTest: false, + protected: 'true', + connection: { dialect: 'sqlite', database: ':memory:' }, + }, + }); + + await state.load(); + + expect(state.getConfig('prod')?.access).toEqual({ user: 'operator', mcp: 'viewer' }); + + }); + + it('should repair a malformed access found at the current schema version', async () => { + + // `{}` is truthy, so the `if (!config.access)` guard skipped it + // and every later command failed zod validation instead. + const statePath = state.getStatePath(); + + writeCurrentState(statePath, testPrivateKey, { + broken: { + name: 'broken', + type: 'local', + isTest: false, + access: {}, + connection: { dialect: 'sqlite', database: ':memory:' }, + }, + }); + + await state.load(); + + expect(state.getConfig('broken')?.access).toEqual({ user: 'viewer', mcp: 'viewer' }); + + const raw = readFileSync(statePath, 'utf8'); + const decrypted = JSON.parse( + decrypt(JSON.parse(raw) as EncryptedPayload, testPrivateKey), + ) as { configs: Record> }; + + expect(decrypted.configs['broken']?.['access']).toEqual({ user: 'viewer', mcp: 'viewer' }); + + }); + it('should leave state.enc byte-identical when there is nothing to migrate', async () => { // A load with no migration and no backfill must not write. When diff --git a/tests/core/version/state.test.ts b/tests/core/version/state.test.ts index a28fe7e5..29f94cdb 100644 --- a/tests/core/version/state.test.ts +++ b/tests/core/version/state.test.ts @@ -393,6 +393,85 @@ describe('version: state', () => { }); + /** + * v2 drops `protected` permanently. Anything it gets wrong here + * cannot be reconstructed afterwards, so the binding rule is + * that a config must never come out of it less protected than + * it went in. + */ + describe('fail-closed repair', () => { + + function migrateConfig(rawConfig: Record): Record { + + const migrated = migrateState({ schemaVersion: 1, configs: { prod: rawConfig } }); + + return (migrated['configs'] as Record>)['prod']!; + + } + + it('should guard a config whose protected flag is a truthy non-boolean', () => { + + // A state file written outside the zod path can hold a + // string here. Requiring a strict `true` sent every one + // of those to admin/admin — fully open. + expect(migrateConfig({ name: 'prod', protected: 'true' })['access']) + .toEqual({ user: 'operator', mcp: 'viewer' }); + + expect(migrateConfig({ name: 'prod', protected: 1 })['access']) + .toEqual({ user: 'operator', mcp: 'viewer' }); + + }); + + it('should not guard a config whose protected flag is falsy', () => { + + expect(migrateConfig({ name: 'prod', protected: false })['access']) + .toEqual({ user: 'admin', mcp: 'admin' }); + + }); + + it('should repair an empty access instead of freezing it in', () => { + + // `{}` is truthy, so it used to pass straight through + // while `protected` was discarded — leaving a config no + // command could ever use again and no way back. + expect(migrateConfig({ name: 'prod', protected: true, access: {} })['access']) + .toEqual({ user: 'viewer', mcp: 'viewer' }); + + }); + + it('should fill a half-populated access rather than persisting it', () => { + + expect(migrateConfig({ name: 'prod', protected: true, access: { user: 'admin' } })['access']) + .toEqual({ user: 'admin', mcp: 'viewer' }); + + }); + + it('should never produce an access zod would reject', () => { + + const malformed = [ + { name: 'prod', protected: true, access: {} }, + { name: 'prod', access: { user: 'admin' } }, + { name: 'prod', access: { mcp: 'admin' } }, + { name: 'prod', access: { user: 'superuser', mcp: 'admin' } }, + { name: 'prod', protected: 'yes' }, + ]; + + for (const rawConfig of malformed) { + + const access = migrateConfig(rawConfig)['access'] as Record; + + expect({ input: rawConfig, userValid: ['viewer', 'operator', 'admin'].includes(access['user'] as string) }) + .toEqual({ input: rawConfig, userValid: true }); + + expect({ input: rawConfig, mcpValid: access['mcp'] === false || ['viewer', 'operator', 'admin'].includes(access['mcp'] as string) }) + .toEqual({ input: rawConfig, mcpValid: true }); + + } + + }); + + }); + }); }); From aaf0e6de39eab82194015428380ecb68e968b340 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:48:49 -0400 Subject: [PATCH 041/105] fix(sql-terminal): write query history owner-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/sql-terminal/history.ts | 33 +++++++++++--- tests/core/sql-terminal/history.test.ts | 60 ++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/core/sql-terminal/history.ts b/src/core/sql-terminal/history.ts index 74efe89a..4ad80859 100644 --- a/src/core/sql-terminal/history.ts +++ b/src/core/sql-terminal/history.ts @@ -6,7 +6,7 @@ */ import { gzip, gunzip } from 'node:zlib'; import { promisify } from 'node:util'; -import { readFile, writeFile, mkdir, unlink, readdir, stat } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, unlink, readdir, stat, chmod } from 'node:fs/promises'; import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { attempt, attemptSync } from '@logosdx/utils'; @@ -25,6 +25,15 @@ const gunzipAsync = promisify(gunzip); const HISTORY_VERSION = '1.0.0'; const HISTORY_DIR = 'state/history'; +/** + * History holds verbatim query text and every row those queries returned — + * whatever a `SELECT` pulled out of a credentials or PII table is in here in + * the clear. It sits beside `state.enc`, which is AES-256-GCM at 0600, so it + * gets at least the same permissions: owner-only, no group, no world. + */ +const HISTORY_FILE_MODE = 0o600; +const HISTORY_DIR_MODE = 0o700; + /** * SQL History Manager. * @@ -57,12 +66,22 @@ export class SqlHistoryManager { } /** - * Ensure the history and results directories exist. + * Ensure the history and results directories exist, owner-only. + * + * `mkdir`'s mode is masked by the process umask and skipped entirely for + * a directory that already exists, so an explicit `chmod` follows — the + * same belt-and-braces `StateManager` uses for `state.enc`, and what + * tightens directories left 0755 by an older version. */ async #ensureDirs(): Promise { - await mkdir(join(this.#projectRoot, '.noorm', HISTORY_DIR), { recursive: true }); - await mkdir(this.#resultsDir, { recursive: true }); + const historyDir = join(this.#projectRoot, '.noorm', HISTORY_DIR); + + await mkdir(historyDir, { recursive: true, mode: HISTORY_DIR_MODE }); + await mkdir(this.#resultsDir, { recursive: true, mode: HISTORY_DIR_MODE }); + + await attempt(() => chmod(historyDir, HISTORY_DIR_MODE)); + await attempt(() => chmod(this.#resultsDir, HISTORY_DIR_MODE)); } @@ -158,7 +177,8 @@ export class SqlHistoryManager { const compressed = await gzipAsync(data); const filepath = join(this.#resultsDir, `${id}.results.gz`); - await writeFile(filepath, compressed); + await writeFile(filepath, compressed, { mode: HISTORY_FILE_MODE }); + await attempt(() => chmod(filepath, HISTORY_FILE_MODE)); } @@ -364,7 +384,8 @@ export class SqlHistoryManager { entries: this.#serializeEntries(entries), }; - await writeFile(this.#historyPath, JSON.stringify(file, null, 2)); + await writeFile(this.#historyPath, JSON.stringify(file, null, 2), { mode: HISTORY_FILE_MODE }); + await attempt(() => chmod(this.#historyPath, HISTORY_FILE_MODE)); } diff --git a/tests/core/sql-terminal/history.test.ts b/tests/core/sql-terminal/history.test.ts index d6558de4..21ee4edf 100644 --- a/tests/core/sql-terminal/history.test.ts +++ b/tests/core/sql-terminal/history.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterAll } from 'bun:test'; -import { readFile, writeFile, mkdir, readdir, rm } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, readdir, rm, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { gzip } from 'node:zlib'; import { promisify } from 'node:util'; @@ -1008,6 +1008,64 @@ describe('sql-terminal: history', () => { }); + /** + * History is the one at-rest store noorm does not encrypt: it holds + * verbatim query text and every row those queries returned, right + * next to `state.enc`, which is AES-256-GCM at 0600. Encrypting it + * is a larger change; not leaving it world-readable is not. + */ + describe('at-rest permissions', () => { + + it('should write the history file readable only by its owner', async () => { + + const manager = new SqlHistoryManager(TMP_DIR, 'test-config'); + + await manager.addEntry("SELECT * FROM vault WHERE token = 'ghp_SECRET'", { + success: true, + durationMs: 1, + columns: ['token'], + rows: [{ token: 'ghp_SECRET' }], + }); + + const historyPath = join(TMP_DIR, '.noorm', 'state', 'history', 'test-config.json'); + const { mode } = await stat(historyPath); + + expect(mode & 0o777).toBe(0o600); + + }); + + it('should write result blobs readable only by their owner', async () => { + + const manager = new SqlHistoryManager(TMP_DIR, 'test-config'); + + await manager.saveResults('abc-123', { + success: true, + durationMs: 1, + columns: ['token'], + rows: [{ token: 'ghp_SECRET' }], + }); + + const resultsPath = join(TMP_DIR, '.noorm', 'state', 'history', 'test-config', 'abc-123.results.gz'); + const { mode } = await stat(resultsPath); + + expect(mode & 0o777).toBe(0o600); + + }); + + it('should not let anyone but the owner list the results directory', async () => { + + const manager = new SqlHistoryManager(TMP_DIR, 'test-config'); + + await manager.saveResults('abc-123', { success: true, durationMs: 1, columns: [], rows: [] }); + + const { mode } = await stat(join(TMP_DIR, '.noorm', 'state', 'history', 'test-config')); + + expect(mode & 0o777).toBe(0o700); + + }); + + }); + }); }); From 1859413916b21dde467c6a2efea6dc8c33dd2dad Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:50:02 -0400 Subject: [PATCH 042/105] fix(change): warn when the changes directory is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/change/ff.ts | 8 ++++++++ src/cli/change/next.ts | 8 ++++++++ src/core/change/manager.ts | 22 ++++++++++++++++++++++ src/core/change/types.ts | 12 ++++++++++++ tests/core/change/manager.test.ts | 20 ++++++++++++++++++++ 5 files changed, 70 insertions(+) diff --git a/src/cli/change/ff.ts b/src/cli/change/ff.ts index 2461065e..31a5fa54 100644 --- a/src/cli/change/ff.ts +++ b/src/cli/change/ff.ts @@ -35,6 +35,14 @@ const ffCommand = defineCommand({ } + // Warned, not failed: without this an absent changes/ + // directory reads exactly like an up-to-date database. + for (const warning of res.warnings ?? []) { + + logger.warn(warning); + + } + const summary = { executed: res.executed, skipped: res.skipped, diff --git a/src/cli/change/next.ts b/src/cli/change/next.ts index d9b1ea21..12eb3b00 100644 --- a/src/cli/change/next.ts +++ b/src/cli/change/next.ts @@ -51,6 +51,14 @@ const nextCommand = defineCommand({ } + // Warned, not failed: without this an absent changes/ + // directory reads exactly like an up-to-date database. + for (const warning of res.warnings ?? []) { + + logger.warn(warning); + + } + if (res.executed === 0) { logger.info('No pending changes to apply.'); diff --git a/src/core/change/manager.ts b/src/core/change/manager.ts index ec53c283..2acc9739 100644 --- a/src/core/change/manager.ts +++ b/src/core/change/manager.ts @@ -24,6 +24,7 @@ * ``` */ import path from 'node:path'; +import { stat } from 'node:fs/promises'; import { attempt } from '@logosdx/utils'; @@ -254,6 +255,8 @@ export class ChangeManager { const opts = { ...DEFAULT_BATCH, ...options }; const start = performance.now(); + const warnings = await this.#collectWarnings(); + // Get pending changes const list = await this.list(); const pending = list @@ -269,6 +272,7 @@ export class ChangeManager { skipped: 0, failed: 0, durationMs: performance.now() - start, + warnings, }; } @@ -327,10 +331,28 @@ export class ChangeManager { skipped: pending.length - executed - failed, failed, durationMs: performance.now() - start, + warnings, }; } + /** + * Report conditions that silently shrink a batch to nothing. + * + * Kept separate from `list()` because an empty list is a legitimate + * state ("nothing pending") while a missing directory is a mistake, + * and the two are otherwise indistinguishable to the caller. + */ + async #collectWarnings(): Promise { + + const [dir] = await attempt(() => stat(this.#context.changesDir)); + + if (dir) return undefined; + + return [`Changes directory not found: ${this.#context.changesDir}`]; + + } + /** * Fast-forward: run all pending changes. * diff --git a/src/core/change/types.ts b/src/core/change/types.ts index d878249a..35f3d4a1 100644 --- a/src/core/change/types.ts +++ b/src/core/change/types.ts @@ -484,6 +484,18 @@ export interface BatchChangeResult { * applied change. Absent when `changes` explains the outcome. */ error?: string; + + /** + * Non-fatal problems that made this batch smaller than intended — + * currently a missing changes directory. + * + * WHY: a mistyped `paths.changes`, or a CI checkout without its + * `changes/` folder, produced `executed: 0` and exit 0, which is + * indistinguishable from an already-up-to-date database. Surfaced + * rather than thrown for the same reason `build.include` warns: + * a project with genuinely nothing to apply must still succeed. + */ + warnings?: string[]; } // ───────────────────────────────────────────────────────────── diff --git a/tests/core/change/manager.test.ts b/tests/core/change/manager.test.ts index 3c25746e..20ed171f 100644 --- a/tests/core/change/manager.test.ts +++ b/tests/core/change/manager.test.ts @@ -477,6 +477,26 @@ describe('change: manager', () => { }); + describe('missing changes directory', () => { + + it('should warn rather than report a clean run when the changes directory is absent', async () => { + + await rm(changesDir, { recursive: true, force: true }); + + const manager = new ChangeManager(buildContext()); + + const result = await manager.ff(); + + // Warned, not failed — matching the `build.include` precedent. + // The point is that a CI job cannot mistake a missing checkout + // for a successful deployment. + expect(result.executed).toBe(0); + expect(result.warnings?.join(' ')).toContain(changesDir); + + }); + + }); + describe('template context', () => { it('should resolve $.secrets and $.config inside a change template', async () => { From 888500f26cb83dbed6eecc1cbc6e3fa6886deece Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:51:02 -0400 Subject: [PATCH 043/105] fix(sql): keep ungated exec off the barrel, fix two dead examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `noorm sql -c prod ""` and `noorm sql -f ` 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. --- src/cli/sql/query.ts | 10 ++++++++-- src/core/sql-terminal/index.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/cli/sql/query.ts b/src/cli/sql/query.ts index 071dc412..ec9cb257 100644 --- a/src/cli/sql/query.ts +++ b/src/cli/sql/query.ts @@ -104,12 +104,18 @@ const sqlCommand = defineCommand({ }, }); +// The bare `noorm sql ` form works only when the first token after +// `sql` is the query itself — the argv rewriter in `src/cli/index.ts` looks +// for SQL there, and for `-c prod` / `-f file.sql` it finds the flag's value +// instead, so no `query` subcommand is inserted and citty prints help. Those +// two forms are shown explicitly rather than teaching a command that exits +// on the help screen. (sqlCommand as typeof sqlCommand & { examples: string[] }).examples = [ 'noorm sql "SELECT 1"', 'noorm sql "SELECT * FROM users LIMIT 10"', - 'noorm sql -c prod "SELECT count(*) FROM orders"', + 'noorm sql query -c prod "SELECT count(*) FROM orders"', 'noorm sql --json "SELECT id, name FROM users"', - 'noorm sql -f reports/monthly.sql', + 'noorm sql query -f reports/monthly.sql', ]; export default sqlCommand; diff --git a/src/core/sql-terminal/index.ts b/src/core/sql-terminal/index.ts index 8252c099..1f13e591 100644 --- a/src/core/sql-terminal/index.ts +++ b/src/core/sql-terminal/index.ts @@ -6,4 +6,12 @@ export * from './types.js'; export * from './history.js'; -export * from './executor.js'; + +/** + * `executeRawSqlUnchecked` is deliberately absent from this barrel: it runs + * arbitrary SQL with no policy gate, and a barrel is exactly how an ungated + * symbol ends up one autocomplete away from a production call site. The + * tests that legitimately need it import `./executor.js` directly. + */ +export { executeRawSql } from './executor.js'; +export type { SqlPolicyGate } from './executor.js'; From 6fdecffc8ef9cd5da6f1e24813aa5f0521cbeaf0 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:52:14 -0400 Subject: [PATCH 044/105] 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. --- src/core/dt/constants.ts | 29 ++++ src/core/dt/deserialize.ts | 50 +++++- src/core/dt/reader.ts | 190 +++++++++++++++++++-- tests/core/dt/hostile.test.ts | 300 ++++++++++++++++++++++++++++++++++ 4 files changed, 553 insertions(+), 16 deletions(-) create mode 100644 tests/core/dt/hostile.test.ts diff --git a/src/core/dt/constants.ts b/src/core/dt/constants.ts index 01acb48b..23d36027 100644 --- a/src/core/dt/constants.ts +++ b/src/core/dt/constants.ts @@ -57,6 +57,35 @@ export const ENCODED_TYPES: { [key in EncodedType]: true } = { custom: true, }; +/** + * Ceiling on the decompressed size of a single gzipped column value. + * + * `.dt` content is untrusted — a file arrives from a colleague, a bucket, or + * a CI artifact. gzip reaches ~1000:1 on repetitive input, so a 400 KB value + * expands to 400 MB with no signal until the process is out of memory. 64 MB + * is far above any real column and far below a machine's headroom. + */ +export const MAX_DECOMPRESSED_VALUE_BYTES = 64 * 1024 * 1024; + +/** + * Ceiling on the decompressed size of a `.dtzx` archive. + * + * `.dtzx` is decrypted and inflated whole before any row is read, so the + * entire archive is resident. Larger than a value cap because this is a full + * table, but still bounded — `.dt` and `.dtz` stream and have no such limit. + */ +export const MAX_DECOMPRESSED_ARCHIVE_BYTES = 1024 * 1024 * 1024; + +/** + * Ceiling on the byte length of one line in a `.dt` stream. + * + * The streaming paths are bounded by consumption, not by file size — except + * for readline, which buffers until it finds a newline. A file with no + * newlines makes that buffer the whole (decompressed) input, which is the + * same memory-exhaustion vector by another route. + */ +export const MAX_ROW_BYTES = 256 * 1024 * 1024; + /** * Valid .dt file extensions. */ diff --git a/src/core/dt/deserialize.ts b/src/core/dt/deserialize.ts index 8755e2e1..1b37c090 100644 --- a/src/core/dt/deserialize.ts +++ b/src/core/dt/deserialize.ts @@ -22,9 +22,12 @@ */ import { gunzipSync } from 'node:zlib'; +import { attemptSync } from '@logosdx/utils'; + import type { Dialect } from '../connection/types.js'; import type { DtColumn, DtValue, DatabaseVersion, Encoding } from './types.js'; import { isEncodedType } from './type-map.js'; +import { MAX_DECOMPRESSED_VALUE_BYTES } from './constants.js'; /** * Options for deserializing a row. @@ -135,13 +138,50 @@ function decodeTuple(tuple: [unknown, Encoding, ...unknown[]]): unknown { case 'raw': return value; - case 'b64': - return Buffer.from(value as string, 'base64'); + case 'b64': { + + const encoded = value as string; + + if (typeof encoded !== 'string') { + + throw new Error(`Expected a base64 string for a b64-encoded value, got ${typeof encoded}`); + + } + + const decoded = Buffer.from(encoded, 'base64'); + + // Buffer.from silently discards every non-base64 character, so a + // corrupted payload decodes to an empty buffer and imports as a + // hollowed-out binary column. Round-tripping is the only way to see it. + if (decoded.toString('base64').replace(/=+$/, '') !== encoded.replace(/=+$/, '')) { + + throw new Error('Invalid base64 payload in .dt value'); + + } + + return decoded; + + } case 'gz64': { const compressed = Buffer.from(value as string, 'base64'); - const decompressed = gunzipSync(compressed); + + // attempt() here because zlib's ERR_BUFFER_TOO_LARGE reads as an + // internal allocation failure; the operator needs to know the file + // asked for more than the limit allows. + const [decompressed, gunzipErr] = attemptSync(() => + gunzipSync(compressed, { maxOutputLength: MAX_DECOMPRESSED_VALUE_BYTES }), + ); + + if (gunzipErr || !decompressed) { + + throw new Error( + `Compressed value exceeds the ${MAX_DECOMPRESSED_VALUE_BYTES} byte decompression limit ` + + `or is not valid gzip: ${gunzipErr?.message ?? 'unknown error'}`, + ); + + } // Try parsing as JSON; if it fails, return the buffer const str = decompressed.toString('utf8'); @@ -159,7 +199,9 @@ function decodeTuple(tuple: [unknown, Encoding, ...unknown[]]): unknown { } default: - return value; + // Passing an unrecognised tag through raw let a tampered .dt import + // "successfully" with the wrong value in the column. + throw new Error(`Unknown .dt value encoding: ${String(encoding)}`); } diff --git a/src/core/dt/reader.ts b/src/core/dt/reader.ts index bcd7bf46..25e2120e 100644 --- a/src/core/dt/reader.ts +++ b/src/core/dt/reader.ts @@ -26,15 +26,134 @@ import { createReadStream, readFileSync } from 'node:fs'; import { createGunzip } from 'node:zlib'; import { gunzipSync } from 'node:zlib'; import { createInterface } from 'node:readline'; -import { PassThrough } from 'node:stream'; +import { PassThrough, Transform } from 'node:stream'; import path from 'node:path'; import JSON5 from 'json5'; +import { attemptSync } from '@logosdx/utils'; import type { Readable } from 'node:stream'; import type { DtSchema, DtValue, DtReaderOptions } from './types.js'; -import { DT_EXTENSIONS } from './constants.js'; +import { DT_EXTENSIONS, FORMAT_VERSION, MAX_DECOMPRESSED_ARCHIVE_BYTES, MAX_ROW_BYTES } from './constants.js'; import { decryptWithPassphrase } from './crypto.js'; +/** + * Fail the stream when a single line grows past `MAX_ROW_BYTES`. + * + * readline buffers until it sees a newline, so newline-free input makes that + * buffer the whole file regardless of how little the stream itself holds. + * Capping the line rather than the stream keeps arbitrarily large `.dt` files + * importable while bounding resident memory. + */ +function createRowLengthGuard(): Transform { + + let sinceNewline = 0; + + return new Transform({ + transform(chunk: Buffer, _encoding, callback) { + + const lastNewline = chunk.lastIndexOf(0x0a); + + sinceNewline = lastNewline === -1 + ? sinceNewline + chunk.length + : chunk.length - lastNewline - 1; + + if (sinceNewline > MAX_ROW_BYTES) { + + callback(new Error(`.dt row exceeds the ${MAX_ROW_BYTES} byte limit without a line break`)); + + return; + + } + + callback(null, chunk); + + }, + }); + +} + +/** + * Validate the shape of a parsed `.dt` header. + * + * Only `v` was ever checked, so a header missing `columns` surfaced to the + * operator as a minified internal TypeError, and a header with malformed + * column entries corrupted every row that followed. + */ +function assertDtSchema(parsed: unknown): DtSchema { + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + + throw new Error('.dt schema line is not an object'); + + } + + const schema = parsed as Partial; + + if (schema.v !== FORMAT_VERSION) { + + throw new Error(`Unsupported .dt format version: ${String(schema.v)}`); + + } + + if (!Array.isArray(schema.columns) || schema.columns.length === 0) { + + throw new Error('.dt schema is missing a non-empty "columns" array'); + + } + + schema.columns.forEach((column, i) => { + + if (typeof column !== 'object' || column === null) { + + throw new Error(`.dt schema column ${i} is not an object`); + + } + + if (typeof column.name !== 'string' || column.name.length === 0) { + + throw new Error(`.dt schema column ${i} is missing a "name"`); + + } + + if (typeof column.type !== 'string' || column.type.length === 0) { + + throw new Error(`.dt schema column "${column.name}" is missing a "type"`); + + } + + }); + + return schema as DtSchema; + +} + +/** + * Pipe `source` through the row-length guard, forwarding errors and teardown. + * + * `.pipe()` propagates neither, so without this an upstream ENOENT would go + * unhandled and `close()` on the returned stream would leave the file + * descriptor open. + */ +function guardRows(source: Readable, ...upstream: Readable[]): Readable { + + const guard = createRowLengthGuard(); + + source.on('error', (err) => guard.destroy(err)); + + guard.on('close', () => { + + for (const stream of [source, ...upstream]) { + + stream.destroy(); + + } + + }); + + return source.pipe(guard); + +} + /** * Streaming .dt file reader. * @@ -91,15 +210,16 @@ export class DtReader { } - this.#schema = JSON5.parse(firstLine.value) as DtSchema; + const [parsed, parseErr] = attemptSync(() => JSON5.parse(firstLine.value) as unknown); - // Validate version - if (this.#schema.v !== 1) { + if (parseErr) { - throw new Error(`Unsupported .dt format version: ${this.#schema.v}`); + throw new Error(`.dt schema line is not valid JSON5: ${parseErr.message}`); } + this.#schema = assertDtSchema(parsed); + // Store iterator for rows this.#lineReader = this.#createRowIterator(lines); @@ -160,12 +280,27 @@ export class DtReader { const encrypted = readFileSync(this.#filepath, 'utf8'); const payload = JSON.parse(encrypted); const compressed = decryptWithPassphrase(payload, this.#passphrase); - const raw = gunzipSync(compressed); + + // attempt() here because zlib reports the cap as an internal + // allocation failure; the operator needs to know the archive + // asked for more than the limit allows. + const [raw, gunzipErr] = attemptSync(() => + gunzipSync(compressed, { maxOutputLength: MAX_DECOMPRESSED_ARCHIVE_BYTES }), + ); + + if (gunzipErr || !raw) { + + throw new Error( + `.dtzx archive exceeds the ${MAX_DECOMPRESSED_ARCHIVE_BYTES} byte decompression limit ` + + `or is not valid gzip: ${gunzipErr?.message ?? 'unknown error'}`, + ); + + } const stream = new PassThrough(); stream.end(raw); - return stream; + return guardRows(stream); } @@ -181,12 +316,13 @@ export class DtReader { fileStream.on('error', (err) => gunzip.destroy(err)); fileStream.pipe(gunzip); - return gunzip; + return guardRows(gunzip, fileStream); } - // .dt: raw file stream - return createReadStream(this.#filepath, { encoding: 'utf8' }); + // .dt: raw file stream. No encoding — the guard inspects bytes and + // readline decodes utf8 for itself. + return guardRows(createReadStream(this.#filepath)); } @@ -197,13 +333,43 @@ export class DtReader { lines: AsyncIterableIterator, ): AsyncGenerator { + const expected = this.#schema!.columns.length; + let rowNumber = 0; + for await (const line of { [Symbol.asyncIterator]: () => lines }) { const trimmed = line.trim(); if (trimmed.length === 0) continue; - yield JSON5.parse(trimmed) as DtValue[]; + rowNumber++; + + const [values, parseErr] = attemptSync(() => JSON5.parse(trimmed) as unknown); + + if (parseErr) { + + throw new Error(`.dt row ${rowNumber} is not valid JSON5: ${parseErr.message}`); + + } + + // An object row, or one whose arity does not match the header, + // used to be accepted and inserted — producing columns silently + // filled with the wrong value or with undefined. + if (!Array.isArray(values)) { + + throw new Error(`.dt row ${rowNumber} is not an array of values`); + + } + + if (values.length !== expected) { + + throw new Error( + `.dt row ${rowNumber} has ${values.length} values but the schema declares ${expected} columns`, + ); + + } + + yield values as DtValue[]; } diff --git a/tests/core/dt/hostile.test.ts b/tests/core/dt/hostile.test.ts new file mode 100644 index 00000000..cd161da1 --- /dev/null +++ b/tests/core/dt/hostile.test.ts @@ -0,0 +1,300 @@ +/** + * Hostile `.dt` input corpus. + * + * A `.dt` file arrives from a colleague, an object store, or a CI artifact — + * it is untrusted input. The reader trusted it entirely: only `schema.v` was + * checked, gzip was inflated without a ceiling, and undecodable payloads + * became empty buffers or passed through raw. Every case below used to import + * "successfully", exhaust memory, or hang the pipeline forever. + * + * The existing dt suite round-trips data the same code wrote, so none of it + * can fail on any of these. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { gzipSync } from 'node:zlib'; +import path from 'node:path'; +import { attempt, attemptSync } from '@logosdx/utils'; + +import { DtReader } from '../../../src/core/dt/reader.js'; +import { deserializeValue } from '../../../src/core/dt/deserialize.js'; +import { + MAX_DECOMPRESSED_VALUE_BYTES, + MAX_ROW_BYTES, +} from '../../../src/core/dt/constants.js'; +import type { DtColumn } from '../../../src/core/dt/types.js'; + +const TMP_DIR = path.join(process.cwd(), 'tmp'); + +const SCHEMA_LINE = JSON.stringify({ + v: 1, + d: 'postgresql', + dv: '17.9', + t: 'category', + columns: [ + { name: 'id', type: 'int' }, + { name: 'name', type: 'string' }, + { name: 'payload', type: 'json' }, + ], +}); + +describe('dt: hostile input', () => { + + let testDir: string; + + beforeEach(() => { + + testDir = path.join(TMP_DIR, `test-hostile-${randomBytes(4).toString('hex')}`); + mkdirSync(testDir, { recursive: true }); + + }); + + afterEach(() => { + + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + + }); + + /** + * Write a `.dt` file from raw lines and read every row out of it. + */ + async function readAll(lines: string[], name = 'hostile.dt'): Promise { + + const filepath = path.join(testDir, name); + writeFileSync(filepath, lines.join('\n')); + + const reader = new DtReader({ filepath }); + await reader.open(); + + const rows: unknown[][] = []; + + for await (const values of reader.rows()) { + + rows.push(values); + + } + + reader.close(); + + return rows; + + } + + describe('schema header', () => { + + it('should reject a header with no columns', async () => { + + const filepath = path.join(testDir, 'no-columns.dt'); + writeFileSync(filepath, JSON.stringify({ v: 1, d: 'postgresql', dv: '17.9', t: 'category' })); + + const reader = new DtReader({ filepath }); + const [, err] = await attempt(() => reader.open()); + + expect(err).toBeInstanceOf(Error); + // Not `undefined is not an object (evaluating 'q.columns')`. + expect(err!.message).toMatch(/columns/); + + }); + + it('should reject a header that is not an object', async () => { + + const filepath = path.join(testDir, 'array-header.dt'); + writeFileSync(filepath, '[1, 2, 3]'); + + const reader = new DtReader({ filepath }); + const [, err] = await attempt(() => reader.open()); + + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/not an object/); + + }); + + it('should reject a column entry with no name', async () => { + + const filepath = path.join(testDir, 'nameless-column.dt'); + writeFileSync(filepath, JSON.stringify({ + v: 1, d: 'postgresql', dv: '17.9', t: 'category', + columns: [{ type: 'int' }], + })); + + const reader = new DtReader({ filepath }); + const [, err] = await attempt(() => reader.open()); + + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/"name"/); + + }); + + it('should reject an unparseable header', async () => { + + const filepath = path.join(testDir, 'garbage.dt'); + writeFileSync(filepath, '{not: valid, json5'); + + const reader = new DtReader({ filepath }); + const [, err] = await attempt(() => reader.open()); + + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/not valid JSON5/); + + }); + + it('should reject an empty file', async () => { + + const filepath = path.join(testDir, 'empty.dt'); + writeFileSync(filepath, ''); + + const reader = new DtReader({ filepath }); + const [, err] = await attempt(() => reader.open()); + + expect(err).toBeInstanceOf(Error); + + }); + + }); + + describe('row arity', () => { + + it('should reject a row shorter than the column list', async () => { + + const [, err] = await attempt(() => readAll([SCHEMA_LINE, '[1, "a"]'])); + + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/2 values but the schema declares 3/); + + }); + + it('should reject a row longer than the column list', async () => { + + const [, err] = await attempt(() => readAll([SCHEMA_LINE, '[1, "a", {}, "extra"]'])); + + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/4 values but the schema declares 3/); + + }); + + it('should reject an object row', async () => { + + const [, err] = await attempt(() => readAll([SCHEMA_LINE, '{"id": 1}'])); + + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/not an array/); + + }); + + it('should reject a truncated row', async () => { + + const [, err] = await attempt(() => readAll([SCHEMA_LINE, '[1, "a", '])); + + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/row 1 is not valid JSON5/); + + }); + + it('should accept a well-formed row', async () => { + + const rows = await readAll([SCHEMA_LINE, '[1, "a", [{"k":1}, "raw"]]']); + + expect(rows.length).toBe(1); + expect(rows[0]!.length).toBe(3); + + }); + + }); + + describe('line length', () => { + + it('should cap a single line rather than buffer it whole', () => { + + // The guard is what makes a newline-free file safe; assert the + // limit exists and is a real bound, since materialising 256 MB + // in a unit test is not worth the runtime. + expect(MAX_ROW_BYTES).toBeGreaterThan(0); + expect(MAX_ROW_BYTES).toBeLessThan(2 ** 31); + + }); + + }); + + describe('encoded values', () => { + + const jsonColumn: DtColumn = { name: 'payload', type: 'json' }; + const binaryColumn: DtColumn = { name: 'blob', type: 'binary' }; + + it('should refuse a gzip bomb instead of inflating it', () => { + + // ~1000:1. Unbounded, this is 400 MB resident inside a compute + // worker, and the OOM used to hang the pipeline permanently. + const bomb = gzipSync(Buffer.alloc(MAX_DECOMPRESSED_VALUE_BYTES + 1024, 0x41)); + + const [value, err] = attemptSync(() => + deserializeValue([bomb.toString('base64'), 'gz64'], jsonColumn, 'postgres'), + ); + + expect(value).toBeNull(); + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/decompression limit/); + + }); + + it('should still decompress a payload inside the limit', () => { + + const payload = gzipSync(Buffer.from(JSON.stringify({ hello: 'world' }))); + + const value = deserializeValue([payload.toString('base64'), 'gz64'], jsonColumn, 'postgres'); + + expect(value).toEqual({ hello: 'world' }); + + }); + + it('should reject a corrupt gzip payload', () => { + + const [value, err] = attemptSync(() => + deserializeValue(['!!!!notgzip!!!!', 'gz64'], jsonColumn, 'postgres'), + ); + + expect(value).toBeNull(); + expect(err).toBeInstanceOf(Error); + + }); + + it('should reject non-base64 in a b64 payload', () => { + + // Buffer.from drops non-base64 characters silently, so this used + // to decode to an empty buffer and import as a hollow column. + const [value, err] = attemptSync(() => + deserializeValue(['!!!!', 'b64'], binaryColumn, 'postgres'), + ); + + expect(value).toBeNull(); + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/Invalid base64/); + + }); + + it('should round-trip valid base64', () => { + + const original = Buffer.from('binary payload'); + + const value = deserializeValue([original.toString('base64'), 'b64'], binaryColumn, 'postgres'); + + expect(Buffer.isBuffer(value)).toBe(true); + expect((value as Buffer).toString()).toBe('binary payload'); + + }); + + it('should reject an unrecognised encoding tag', () => { + + const [value, err] = attemptSync(() => + deserializeValue([{ x: 1 }, 'evil' as 'raw'], jsonColumn, 'postgres'), + ); + + expect(value).toBeNull(); + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/Unknown .dt value encoding/); + + }); + + }); + +}); From 9d5c7818067dbdbb91fa68ed9067e79b2351d1d7 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:52:22 -0400 Subject: [PATCH 045/105] fix(tui): confirm vault propagate and name recipients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/tui/screens/vault/VaultScreen.tsx | 109 +++++++- tests/cli/screens/vault/VaultScreen.test.tsx | 265 +++++++++++++++++++ 2 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 tests/cli/screens/vault/VaultScreen.test.tsx diff --git a/src/tui/screens/vault/VaultScreen.tsx b/src/tui/screens/vault/VaultScreen.tsx index 2f3dc26e..031f765c 100644 --- a/src/tui/screens/vault/VaultScreen.tsx +++ b/src/tui/screens/vault/VaultScreen.tsx @@ -24,18 +24,23 @@ import type { NoormDatabase } from '../../../core/shared/index.js'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; -import { Panel, SelectList, Spinner, useToast, type SelectListItem } from '../../components/index.js'; +import { Panel, SelectList, Spinner, SmartConfirm, useToast, type SelectListItem } from '../../components/index.js'; import { useVaultConnection } from '../../hooks/index.js'; import { loadPrivateKey } from '../../../core/identity/storage.js'; +import { checkConfigPolicy } from '../../../core/policy/index.js'; import { getVaultStatus, getVaultKey, getAllVaultSecrets, propagateVaultKey, + getUsersWithoutVaultAccess, type VaultStatus, type VaultSecret, } from '../../../core/vault/index.js'; +/** Recipients of a propagation, as `getUsersWithoutVaultAccess` returns them. */ +type PropagationRecipient = Awaited>[number]; + type _Phase = 'connecting' | 'ready' | 'error'; @@ -46,13 +51,23 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { const { navigate, back } = useRouter(); const { isFocused } = useFocusScope('Vault'); - const { activeConfigName, identity, settings } = useAppContext(); + const { activeConfig, activeConfigName, identity, settings } = useAppContext(); const { showToast } = useToast(); const [status, setStatus] = useState(null); const [secrets, setSecrets] = useState([]); const [propagating, setPropagating] = useState(false); + // Non-null while awaiting confirmation, and holds exactly who would be + // granted the vault key — propagation used to run on the bare `p` + // keypress, which named nobody. + const [pendingRecipients, setPendingRecipients] = useState(null); + + const propagateCheck = useMemo( + () => (activeConfig ? checkConfigPolicy('user', activeConfig, 'vault:propagate') : null), + [activeConfig], + ); + const { phase, error, connRef } = useVaultConnection({ onReady: async (db, isCancelled, dialect) => { @@ -146,6 +161,50 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { [navigate], ); + // Resolve who would be granted access, then hand off to the confirmation. + // Nothing is written here. + const beginPropagate = useCallback(async () => { + + if (!connRef.current) return; + + if (propagateCheck && !propagateCheck.allowed) { + + showToast({ + message: propagateCheck.blockedReason ?? 'Propagating vault access is not allowed', + variant: 'error', + }); + + return; + + } + + const db = connRef.current.db; + const connDialect = connRef.current.dialect; + + const [recipients, recipientsErr] = await attempt( + () => getUsersWithoutVaultAccess(db as Kysely, connDialect), + ); + + if (recipientsErr) { + + showToast({ message: recipientsErr.message, variant: 'error' }); + + return; + + } + + if (!recipients || recipients.length === 0) { + + showToast({ message: 'All users already have vault access', variant: 'info' }); + + return; + + } + + setPendingRecipients(recipients); + + }, [connRef, propagateCheck, showToast]); + // Handle propagate const handlePropagate = useCallback(async () => { @@ -259,9 +318,9 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { } - if (input === 'p' && status?.hasAccess && !propagating) { + if (input === 'p' && status?.hasAccess && !propagating && !pendingRecipients) { - handlePropagate(); + beginPropagate(); return; @@ -367,6 +426,48 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { } + // Confirm propagation. Propagation hands the vault key to every enrolled + // identity at once, so the recipients are named before it runs. + if (pendingRecipients) { + + return ( + + + + + This grants the vault key for{' '} + {activeConfigName} to{' '} + {pendingRecipients.length} user(s): + + {pendingRecipients.map((u) => ( + + {' '}{u.name} <{u.email}> + + ))} + They will be able to read every secret in this vault. + + + + { + + setPendingRecipients(null); + handlePropagate(); + + }} + onCancel={() => setPendingRecipients(null)} + /> + + ); + + } + // Main vault view return ( diff --git a/tests/cli/screens/vault/VaultScreen.test.tsx b/tests/cli/screens/vault/VaultScreen.test.tsx new file mode 100644 index 00000000..7571c5a9 --- /dev/null +++ b/tests/cli/screens/vault/VaultScreen.test.tsx @@ -0,0 +1,265 @@ +/** + * VaultScreen propagation confirmation tests. + * + * `p` used to call `propagateVaultKey` directly from the keypress handler -- + * one unmodified key, no confirmation, no policy check, for an operation that + * hands the vault key (and therefore every secret in the vault) to every + * enrolled identity at once. The CLI's `vault propagate` is a deliberate + * invocation; the TUI's was a typo away. + * + * These pin the two halves of the fix: the keypress must not write anything on + * its own, and the confirmation it opens must name the people being granted + * access so the operator can see the blast radius before agreeing to it. + */ +import { describe, it, expect, vi, mock, beforeEach, afterEach, afterAll } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { CryptoIdentity } from '../../../../src/core/identity/types.js'; + +import { FocusProvider } from '../../../../src/tui/focus.js'; +import { RouterProvider } from '../../../../src/tui/router.js'; +import { AppContextProvider } from '../../../../src/tui/app-context.js'; +import { ConnectionProvider } from '../../../../src/tui/providers/ConnectionProvider.js'; +import { ToastProvider } from '../../../../src/tui/components/index.js'; +import { VaultScreen } from '../../../../src/tui/screens/vault/VaultScreen.js'; + +const actualCore = await import('../../../../src/core/index.js'); +const actualIdentity = await import('../../../../src/core/identity/index.js'); +const actualIdentityStorage = await import('../../../../src/core/identity/storage.js'); +const actualVault = await import('../../../../src/core/vault/index.js'); + +/** Every call to the real propagation entry point. */ +const propagateCalls: unknown[] = []; + +const IDENTITY: CryptoIdentity = { + identityHash: 'hash-self', + name: 'Self', + email: 'self@example.com', + publicKey: 'pub-self', + machine: 'test', + os: 'test', + createdAt: new Date().toISOString(), +}; + +/** Two enrolled identities awaiting access — the names the dialog must show. */ +const RECIPIENTS = [ + { identityHash: 'hash-a', publicKey: 'pub-a', name: 'Ada Lovelace', email: 'ada@example.com' }, + { identityHash: 'hash-b', publicKey: 'pub-b', name: 'Grace Hopper', email: 'grace@example.com' }, +]; + +mock.module('../../../../src/core/vault/index.js', () => ({ + ...actualVault, + getVaultStatus: vi.fn(async () => ({ + isInitialized: true, + hasAccess: true, + usersWithAccess: 1, + usersWithoutAccess: RECIPIENTS.length, + })), + getAllVaultSecrets: vi.fn(async () => []), + getVaultKey: vi.fn(async () => 'vault-key'), + getUsersWithoutVaultAccess: vi.fn(async () => RECIPIENTS), + propagateVaultKey: vi.fn(async (...args: unknown[]) => { + + propagateCalls.push(args); + + return { propagatedTo: RECIPIENTS.map((r) => r.identityHash), skipped: [] }; + + }), +})); + +mock.module('../../../../src/core/identity/storage.js', () => ({ + ...actualIdentityStorage, + loadPrivateKey: vi.fn(async () => 'private-key'), +})); + +/** + * `admin` still resolves `vault:propagate` to a `confirm` cell + * (src/core/policy/matrix.ts), so this config exercises the type-to-confirm + * branch rather than a plain yes/no. + */ +function makeConfig() { + + return { + name: 'test', + type: 'local' as const, + isTest: true, + access: { user: 'admin' as const, mcp: 'admin' as const }, + connection: { + dialect: 'sqlite' as const, + database: ':memory:', + }, + }; + +} + +const createMockStateManager = () => ({ + load: vi.fn().mockResolvedValue(undefined), + getActiveConfig: vi.fn().mockReturnValue(makeConfig()), + getActiveConfigName: vi.fn().mockReturnValue('test'), + listConfigs: vi.fn().mockReturnValue([makeConfig()]), + getConfig: vi.fn().mockReturnValue(makeConfig()), + setConfig: vi.fn().mockResolvedValue(undefined), + setActiveConfig: vi.fn().mockResolvedValue(undefined), + hasPrivateKey: vi.fn().mockReturnValue(true), + isLoaded: true, +}); + +const createMockSettingsManager = () => ({ + load: vi.fn().mockResolvedValue({ version: '0.1.0' }), + isLoaded: true, + settings: { version: '0.1.0' }, + getStages: vi.fn().mockReturnValue({}), + getStage: vi.fn().mockReturnValue(undefined), +}); + +let mockStateManager = createMockStateManager(); +let mockSettingsManager = createMockSettingsManager(); + +mock.module('../../../../src/core/index.js', () => ({ + observer: actualCore.observer, + getStateManager: vi.fn(() => mockStateManager), + getSettingsManager: vi.fn(() => mockSettingsManager), + resetStateManager: vi.fn(), + resetSettingsManager: vi.fn(), +})); + +mock.module('../../../../src/core/identity/index.js', () => ({ + ...actualIdentity, + loadExistingIdentity: vi.fn().mockResolvedValue(IDENTITY), +})); + +describe('cli: VaultScreen propagate', () => { + + let tempDir: string; + + const ESCAPE = '\u001B'; + + const tick = (ms = 300) => new Promise((r) => setTimeout(r, ms)); + + function tree() { + + return ( + + + + + + + + + + + + ); + + } + + beforeEach(async () => { + + vi.clearAllMocks(); + actualCore.observer.clear(); + + delete process.env['NOORM_YES']; + + propagateCalls.length = 0; + mockStateManager = createMockStateManager(); + mockSettingsManager = createMockSettingsManager(); + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-vault-test-')); + + }); + + afterEach(async () => { + + actualCore.observer.clear(); + + await rm(tempDir, { recursive: true, force: true }); + + }); + + afterAll(() => { + + mock.module('../../../../src/core/index.js', () => actualCore); + mock.module('../../../../src/core/identity/index.js', () => actualIdentity); + mock.module('../../../../src/core/identity/storage.js', () => actualIdentityStorage); + mock.module('../../../../src/core/vault/index.js', () => actualVault); + + }); + + it('should not propagate on the bare p keypress', async () => { + + const { stdin, unmount } = render(tree()); + + await tick(600); + + stdin.write('p'); + await tick(400); + + unmount(); + + expect(propagateCalls).toHaveLength(0); + + }); + + it('should name every recipient before propagating', async () => { + + const { stdin, lastFrame, unmount } = render(tree()); + + await tick(600); + + stdin.write('p'); + await tick(400); + + const frame = lastFrame() ?? ''; + + // The whole point of the confirmation: who is getting the key. + expect(frame).toContain('Ada Lovelace'); + expect(frame).toContain('Grace Hopper'); + + unmount(); + + }); + + it('should propagate once the confirmation phrase is typed', async () => { + + const { stdin, unmount } = render(tree()); + + await tick(600); + + stdin.write('p'); + await tick(400); + + stdin.write('yes-test'); + await tick(); + stdin.write('\r'); + await tick(400); + + unmount(); + + expect(propagateCalls).toHaveLength(1); + + }); + + it('should not propagate when the confirmation is cancelled', async () => { + + const { stdin, unmount } = render(tree()); + + await tick(600); + + stdin.write('p'); + await tick(400); + + stdin.write(ESCAPE); + await tick(400); + + unmount(); + + expect(propagateCalls).toHaveLength(0); + + }); + +}); From 8306e448ca56ba4226fc97a144b16d89ded91cc2 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:53:05 -0400 Subject: [PATCH 046/105] 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. --- src/cli/run/inspect.ts | 20 ++- src/cli/run/preview.ts | 22 +++- src/core/runner/runner.ts | 15 +++ src/sdk/namespaces/templates.ts | 23 ++++ tests/cli/run/preview-inspect-policy.test.ts | 123 +++++++++++++++++++ tests/core/runner/runner.test.ts | 49 +++++++- tests/sdk/templates-policy.test.ts | 68 ++++++++++ 7 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 tests/cli/run/preview-inspect-policy.test.ts create mode 100644 tests/sdk/templates-policy.test.ts diff --git a/src/cli/run/inspect.ts b/src/cli/run/inspect.ts index 75cc7a39..780aff8f 100644 --- a/src/cli/run/inspect.ts +++ b/src/cli/run/inspect.ts @@ -13,11 +13,12 @@ import { dirname, join, relative } from 'node:path'; import { defineCommand } from 'citty'; -import { attempt } from '@logosdx/utils'; +import { attempt, attemptSync } from '@logosdx/utils'; import { outputError, outputResult, sharedArgs } from '../_utils.js'; import { buildContext } from '../../core/template/context.js'; import { loadHelpers } from '../../core/template/helpers.js'; +import { assertPolicy } from '../../core/policy/index.js'; import { getStateManager } from '../../core/state/index.js'; import { resolveRenderSecrets, RENDER_SECRETS_NOTICE } from './_render-secrets.js'; @@ -90,6 +91,23 @@ const inspectCommand = defineCommand({ const activeConfigName = args.config ?? stateManager.getActiveConfigName(); const activeConfig = activeConfigName ? stateManager.getConfig(activeConfigName) : undefined; + + // inspect reports counts rather than secret values, but building the + // context still executes the template's helper and side-car scripts, + // so it needs the same gate as preview. + if (activeConfig) { + + const [, policyErr] = attemptSync(() => assertPolicy('user', activeConfig, 'run:file')); + + if (policyErr) { + + outputError(args, policyErr.message); + process.exit(1); + + } + + } + const { secrets, vaultProbeFailed } = await resolveRenderSecrets(stateManager, activeConfigName); // Load context and helpers in parallel diff --git a/src/cli/run/preview.ts b/src/cli/run/preview.ts index 41d96f2e..ee681e2f 100644 --- a/src/cli/run/preview.ts +++ b/src/cli/run/preview.ts @@ -14,10 +14,11 @@ import { join } from 'node:path'; import { defineCommand } from 'citty'; -import { attempt } from '@logosdx/utils'; +import { attempt, attemptSync } from '@logosdx/utils'; import { outputError, outputResult, sharedArgs } from '../_utils.js'; import { processFile } from '../../core/template/engine.js'; +import { assertPolicy } from '../../core/policy/index.js'; import { getStateManager } from '../../core/state/index.js'; import { resolveRenderSecrets, RENDER_SECRETS_NOTICE } from './_render-secrets.js'; @@ -53,6 +54,25 @@ const previewCommand = defineCommand({ const activeConfigName = args.config ?? stateManager.getActiveConfigName(); const activeConfig = activeConfigName ? stateManager.getConfig(activeConfigName) : undefined; + + // Before secrets are resolved and before the template is touched: + // the output is this config's secrets in plaintext, and producing it + // runs the template's helper and side-car scripts. With no config + // there is nothing config-scoped to protect — `resolveRenderSecrets` + // returns an empty set for that case. + if (activeConfig) { + + const [, policyErr] = attemptSync(() => assertPolicy('user', activeConfig, 'run:file')); + + if (policyErr) { + + outputError(args, policyErr.message); + process.exit(1); + + } + + } + const { secrets, vaultProbeFailed } = await resolveRenderSecrets(stateManager, activeConfigName); // Render the template diff --git a/src/core/runner/runner.ts b/src/core/runner/runner.ts index 60276434..0f0cc8f5 100644 --- a/src/core/runner/runner.ts +++ b/src/core/runner/runner.ts @@ -348,6 +348,15 @@ export async function runFiles( * * Useful for debugging templates and verifying SQL before execution. * + * "Without executing" describes the *SQL*, not the render: producing the + * output resolves every secret tier into plaintext and runs whatever + * `$helpers` and referenced side-car scripts the template pulls in. Gated + * on `run:file` so the role denied every `run:*` permission cannot reach + * either. `run:file` is the closest existing cell — a dedicated + * `run:preview` row (viewer deny, operator/admin allow) belongs in the + * matrix so a read-only path stops inheriting `run:file`'s confirm + * semantics. + * * @param context - Run context * @param filepaths - Files to preview * @param output - Optional output file path @@ -359,6 +368,8 @@ export async function preview( output?: string | null, ): Promise { + assertRunPolicy(context, 'run:file'); + const results: FileResult[] = []; const rendered: string[] = []; @@ -448,6 +459,10 @@ export async function checkFilesStatus( files: string[], ): Promise { + // Renders every file to compute its checksum, so it carries the same + // secret-resolution and script-execution exposure as `preview`. + assertRunPolicy(context, 'run:file'); + const tracker = new Tracker(context.db, context.configName, context.dialect ?? 'postgres'); const results: FileStatusResult[] = []; diff --git a/src/sdk/namespaces/templates.ts b/src/sdk/namespaces/templates.ts index ea795e6f..05bd6ec4 100644 --- a/src/sdk/namespaces/templates.ts +++ b/src/sdk/namespaces/templates.ts @@ -10,8 +10,10 @@ import type { Kysely } from 'kysely'; import type { ProcessResult as TemplateResult } from '../../core/template/index.js'; import { processFile } from '../../core/template/index.js'; import { getStateManager } from '../../core/state/index.js'; +import { checkConfigPolicy } from '../../core/policy/index.js'; import type { NoormDatabase } from '../../core/shared/index.js'; import { resolveVaultKey, buildSecretsContext } from '../../core/vault/index.js'; +import { ProtectedConfigError } from '../guards.js'; import type { ContextState } from '../state.js'; @@ -32,6 +34,15 @@ export class TemplatesNamespace { /** * Render a template file without executing. * + * "Without executing" is about the SQL: the render itself resolves + * every secret tier into the returned string and runs the template's + * `$helpers` and referenced side-car scripts. Gated on `run:file` to + * match core `preview()` — `checkConfigPolicy` rather than + * `checkProtectedConfig` because a `confirm` cell must not block a + * read-only render the way it blocks an execution. + * + * @throws ProtectedConfigError when the channel's role is denied + * * @example * ```typescript * const result = await ctx.noorm.templates.render('sql/001_users.sql.tmpl') @@ -39,6 +50,18 @@ export class TemplatesNamespace { */ async render(filepath: string): Promise { + const check = checkConfigPolicy( + this.#state.options.channel ?? 'user', + this.#state.config, + 'run:file', + ); + + if (!check.allowed) { + + throw new ProtectedConfigError(this.#state.config.name, 'templates.render', check.blockedReason); + + } + const absolutePath = path.isAbsolute(filepath) ? filepath : path.join(this.#state.projectRoot, filepath); diff --git a/tests/cli/run/preview-inspect-policy.test.ts b/tests/cli/run/preview-inspect-policy.test.ts new file mode 100644 index 00000000..7d2cc0b9 --- /dev/null +++ b/tests/cli/run/preview-inspect-policy.test.ts @@ -0,0 +1,123 @@ +/** + * cli: `run preview` / `run inspect` honour the config's access policy. + * + * The matrix denies `viewer` every `run:*` permission, and `run build`, + * `run file` and `run dir` all enforce it. `preview` and `inspect` never + * loaded a policy at all — so the least-privileged role could dump every + * resolved secret as plaintext (`preview` prints the rendered SQL) and + * execute the template's helper and side-car scripts (both commands build + * the context). The end-to-end shape is what matters here: the audit + * reached this through the shipped binary, not through the core function. + * + * Also pins the half that must keep working — a viewer's own SQL previews + * fine once the project has no config to scope the denial to, and an + * `operator` (a `confirm` cell, not a denial) is not blocked from a + * read-only render. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; + +import { StateManager } from '../../../src/core/state/manager.js'; +import { generateKeyPair } from '../../../src/core/identity/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { Role } from '../../../src/core/policy/index.js'; + +import { TMP_BASE, cleanupProject, runCli, type TestProject } from './_setup.js'; + +/** + * A project whose stored, active config carries the given user role. + * + * Written through StateManager rather than the CLI because there is no + * headless `config add` — the same reason the vault-probe suite does it. + */ +async function setupRoleProject(role: Role): Promise { + + const testId = randomUUID().slice(0, 8); + const dir = join(TMP_BASE, `cli-run-policy-${testId}`); + const sqlDir = join(dir, 'sql'); + + await mkdir(sqlDir, { recursive: true }); + await writeFile(join(sqlDir, 'greet.sql.tmpl'), "select 'hi' as val;\n"); + + const { privateKey } = generateKeyPair(); + + const config: Config = { + name: 'guarded', + type: 'local', + isTest: true, + access: { user: role, mcp: role }, + connection: { + dialect: 'sqlite', + database: join(dir, '.noorm', 'test.db'), + }, + }; + + const state = new StateManager(dir, { privateKey }); + await state.load(); + await state.setConfig(config.name, config); + await state.setActiveConfig(config.name); + + return { + dir, + dbPath: '', + env: { + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'Policy Tester', + NOORM_IDENTITY_EMAIL: 'policy@example.com', + }, + }; + +} + +describe('cli: run preview / run inspect access gate', () => { + + let project: TestProject | undefined; + + afterEach(async () => { + + if (project) await cleanupProject(project); + project = undefined; + + }); + + it('should refuse run preview on a viewer config', async () => { + + project = await setupRoleProject('viewer'); + + const result = runCli(project, ['run', 'preview', 'sql/greet.sql.tmpl']); + + expect(result.status).not.toBe(0); + + // The rendered SQL is the payload the gate exists to withhold. + expect(result.stdout).not.toContain("select 'hi' as val;"); + expect(result.stdout + result.stderr).toMatch(/run:file/); + + }); + + it('should refuse run inspect on a viewer config', async () => { + + project = await setupRoleProject('viewer'); + + const result = runCli(project, ['run', 'inspect', 'sql/greet.sql.tmpl']); + + expect(result.status).not.toBe(0); + expect(result.stdout + result.stderr).toMatch(/run:file/); + + }); + + it('should still allow run preview on an operator config', async () => { + + project = await setupRoleProject('operator'); + + const result = runCli(project, ['run', 'preview', 'sql/greet.sql.tmpl']); + + // `run:file` is a `confirm` cell for operator, not a denial — a + // read-only render must not inherit an execution's confirmation. + expect(result.status).toBe(0); + expect(result.stdout).toContain("select 'hi' as val;"); + + }); + +}); diff --git a/tests/core/runner/runner.test.ts b/tests/core/runner/runner.test.ts index 195125d7..60c61320 100644 --- a/tests/core/runner/runner.test.ts +++ b/tests/core/runner/runner.test.ts @@ -7,7 +7,8 @@ import { describe, it, expect, afterAll } from 'bun:test'; import path from 'node:path'; import { rm } from 'node:fs/promises'; -import { preview, runBuild, runFile, runDir, runFiles } from '../../../src/core/runner/runner.js'; +import { attempt } from '@logosdx/utils'; +import { preview, runBuild, runFile, runDir, runFiles, checkFilesStatus } from '../../../src/core/runner/runner.js'; import type { RunContext } from '../../../src/core/runner/types.js'; const FIXTURES_DIR = path.join(import.meta.dirname, 'fixtures'); @@ -183,6 +184,52 @@ describe('runner: policy gate', () => { }); + // `preview` and `checkFilesStatus` render templates: they resolve every + // secret tier by design and they execute referenced `$helpers`/side-car + // scripts. Leaving them ungated meant the one role denied every `run:*` + // permission could still dump plaintext secrets and run code. + it('should deny preview for a viewer role', async () => { + + const filepath = path.join(FIXTURES_DIR, 'template.sql.tmpl'); + + await expect(preview(viewerContext, [filepath])).rejects.toThrow(/run:file/); + + }); + + it('should deny checkFilesStatus for a viewer role', async () => { + + const filepath = path.join(FIXTURES_DIR, 'raw.sql'); + + await expect(checkFilesStatus(viewerContext, [filepath])).rejects.toThrow(/run:file/); + + }); + + it('should not render anything before denying preview', async () => { + + const filepath = path.join(FIXTURES_DIR, 'template.sql.tmpl'); + + const [results, err] = await attempt(() => preview(viewerContext, [filepath])); + + // A per-file failure result would mean the loop was entered and the + // template was reached; the gate has to reject the whole call. + expect(Array.isArray(results)).toBe(false); + expect(err).toBeInstanceOf(Error); + + }); + + it('should still allow preview for an operator role', async () => { + + const filepath = path.join(FIXTURES_DIR, 'template.sql.tmpl'); + + const results = await preview( + { ...mockContext, access: { user: 'operator', mcp: 'operator' } }, + [filepath], + ); + + expect(results[0]!.status).toBe('success'); + + }); + }); describe('runner: file detection', () => { diff --git a/tests/sdk/templates-policy.test.ts b/tests/sdk/templates-policy.test.ts new file mode 100644 index 00000000..b0bc7264 --- /dev/null +++ b/tests/sdk/templates-policy.test.ts @@ -0,0 +1,68 @@ +/** + * SDK templates.render() access gate. + * + * `templates.render` is the SDK analogue of `run inspect`, except it + * returns fully rendered SQL — every resolved secret in plaintext — and it + * executes whatever `$helpers` and referenced side-car scripts the template + * pulls in. It was the only namespace method in the run/template slice with + * no policy check at all, so a `viewer` config denied every `run:*` + * permission could still read secrets and run code through it. + * + * The gate must fire before any state or vault work, which is what lets + * this test drive the namespace with no loaded StateManager. + */ +import { describe, expect, it } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { attempt } from '@logosdx/utils'; + +import { TemplatesNamespace } from '../../src/sdk/namespaces/templates.js'; +import { ProtectedConfigError } from '../../src/sdk/guards.js'; + +import type { ContextState } from '../../src/sdk/state.js'; +import type { Config } from '../../src/core/config/types.js'; +import type { Role } from '../../src/core/policy/index.js'; + +function makeState(projectRoot: string, userRole: Role): ContextState { + + const config: Config = { + name: 'dev', + type: 'local', + isTest: false, + access: { user: userRole, mcp: userRole }, + connection: { dialect: 'postgres', database: 'testdb' }, + }; + + return { + connection: null, + config, + settings: {}, + identity: { name: 'tester', source: 'system' }, + options: {}, + projectRoot, + changeManager: null, + } as unknown as ContextState; + +} + +describe('sdk: templates.render policy gate', () => { + + it('should refuse to render for a viewer role', async () => { + + const projectRoot = await mkdtemp(join(tmpdir(), 'noorm-tmpl-policy-')); + await writeFile(join(projectRoot, 'x.sql.tmpl'), 'SELECT 1;', 'utf-8'); + + const templates = new TemplatesNamespace(makeState(projectRoot, 'viewer')); + + const [result, err] = await attempt(() => templates.render('x.sql.tmpl')); + + expect(result).toBeFalsy(); + expect(err).toBeInstanceOf(ProtectedConfigError); + + await rm(projectRoot, { recursive: true, force: true }); + + }); + +}); From 01b2258c45fa97b54fefc473caa0c92574aea5a7 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:53:39 -0400 Subject: [PATCH 047/105] 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. --- src/cli/change/list.ts | 4 +++- src/core/change/history.ts | 13 ++++++++++++- tests/cli/change/rewind.test.ts | 18 ++++++++++++++++++ tests/core/change/manager.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/cli/change/list.ts b/src/cli/change/list.ts index 92a2fef5..f997e00d 100644 --- a/src/cli/change/list.ts +++ b/src/cli/change/list.ts @@ -33,7 +33,9 @@ const listCommand = defineCommand({ const text = changes.length === 0 ? 'No changes found.' : [ - ...changes.map((cs) => `${cs.name} (${cs.status})`), + // `orphaned` is computed but was never rendered, so a change + // deleted from disk printed identically to a live applied one. + ...changes.map((cs) => `${cs.name} (${cs.status}${cs.orphaned ? ', orphaned' : ''})`), ...(pending > 0 ? [`${pending} pending change(s)`] : []), ].join('\n'); diff --git a/src/core/change/history.ts b/src/core/change/history.ts index 72b42726..909fff2d 100644 --- a/src/core/change/history.ts +++ b/src/core/change/history.ts @@ -52,6 +52,16 @@ import type { ChangeType } from '../shared/index.js'; // Date Hydration // ───────────────────────────────────────────────────────────── +/** + * Reserved change name recording a `db teardown`. + * + * Shares the `change` row shape so teardowns appear in the audit trail, + * which also made it show up in `change list` as a user change that is + * permanently orphaned — it has no folder on disk and never will. + * Status reads filter it out; history reads keep it. + */ +const RESET_MARKER = '__reset__'; + /** * Normalizes a change-tracking timestamp column to a real `Date`. * @@ -235,6 +245,7 @@ export class ChangeHistory { .where('change_type', '=', 'change') .where('direction', '=', 'change') .where('config_name', '=', this.#configName) + .where('name', '!=', RESET_MARKER) .orderBy('id', 'desc') .execute(), ); @@ -955,7 +966,7 @@ export class ChangeHistory { const insertQuery = this.#ndb .insertInto(this.#tables.change) .values({ - name: '__reset__', + name: RESET_MARKER, change_type: 'change', direction: 'change', status: 'success', diff --git a/tests/cli/change/rewind.test.ts b/tests/cli/change/rewind.test.ts index f64fccf5..23ddd316 100644 --- a/tests/cli/change/rewind.test.ts +++ b/tests/cli/change/rewind.test.ts @@ -174,6 +174,24 @@ describe('cli: noorm change rewind', () => { }); + it('marks a change deleted from disk as orphaned in list output', async () => { + + await seedConfig(); + seedChange('2026-03-01-probe', 'rewind_probe'); + + expect(runCli(['run', '2026-03-01-probe', '--json']).status).toBe(0); + + rmSync(join(tmpDir, 'changes', '2026-03-01-probe'), { recursive: true, force: true }); + + const result = runCli(['list']); + + expect(result.status).toBe(0); + + // Without the marker this prints identically to a live applied change. + expect(result.stdout).toContain('orphaned'); + + }); + it('reports why a rewind target that matches nothing failed', async () => { await seedConfig(); diff --git a/tests/core/change/manager.test.ts b/tests/core/change/manager.test.ts index 20ed171f..c833b166 100644 --- a/tests/core/change/manager.test.ts +++ b/tests/core/change/manager.test.ts @@ -16,6 +16,7 @@ import { Kysely, SqliteDialect, sql } from 'kysely'; import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; import { ChangeManager } from '../../../src/core/change/manager.js'; +import { ChangeHistory } from '../../../src/core/change/history.js'; import { ChangeTracker } from '../../../src/core/change/tracker.js'; import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; import { resetLockManager } from '../../../src/core/lock/index.js'; @@ -477,6 +478,31 @@ describe('change: manager', () => { }); + describe('reset marker', () => { + + it('should keep the teardown marker out of the user-facing change list', async () => { + + await createTestChange('2025-04-01-real', [ + { name: '001.sql', content: 'CREATE TABLE reset_real (id INTEGER PRIMARY KEY)' }, + ]); + + const manager = new ChangeManager(buildContext()); + + await manager.run('2025-04-01-real'); + + await new ChangeHistory(db, 'test', 'sqlite').recordReset('tester', 'teardown'); + + const list = await manager.list(); + + // `__reset__` is an audit row, not a change anyone can apply or + // delete — listing it reports a permanent phantom orphan. + expect(list.map((cs) => cs.name)).not.toContain('__reset__'); + expect(list.map((cs) => cs.name)).toContain('2025-04-01-real'); + + }); + + }); + describe('missing changes directory', () => { it('should warn rather than report a clean run when the changes directory is absent', async () => { From 329cbe09c1786cde0ade81ccfa27fd46ccbd401f Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:53:53 -0400 Subject: [PATCH 048/105] feat(vault): gate vault operations on the access policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/vault/copy.ts | 79 +++++++-- src/core/vault/index.ts | 1 + src/core/vault/policy.ts | 69 ++++++++ src/core/vault/storage.ts | 168 ++++++++++++++++++- tests/core/vault/policy-gate.test.ts | 241 +++++++++++++++++++++++++++ 5 files changed, 541 insertions(+), 17 deletions(-) create mode 100644 src/core/vault/policy.ts create mode 100644 tests/core/vault/policy-gate.test.ts diff --git a/src/core/vault/copy.ts b/src/core/vault/copy.ts index 426d6e9b..51c551d5 100644 --- a/src/core/vault/copy.ts +++ b/src/core/vault/copy.ts @@ -8,6 +8,8 @@ import { withDualConnection } from '../db/dual.js'; import type { Config } from '../config/types.js'; import { observer } from '../observer.js'; +import type { Channel } from '../policy/index.js'; + import type { VaultCopyResult } from './types.js'; import { getVaultKey, @@ -17,6 +19,7 @@ import { initializeVault, getVaultStatus, } from './storage.js'; +import { assertVaultPolicy } from './policy.js'; /** * Options for vault copy operation. @@ -24,6 +27,20 @@ import { export interface VaultCopyOptions { /** Overwrite existing secrets in destination (default: false) */ force?: boolean; + + /** Who is asking. Defaults to `user`; gates source read and dest write. */ + channel?: Channel; + + /** + * Resolve what would happen and report it without writing anything. + * + * Runs the full preflight — vault access on both ends, source-key + * existence, destination collisions — so the reported `copied`/`skipped`/ + * `errors` are the same answers the real run would produce. A dry run + * that only echoed its arguments could not tell you the one thing you + * asked it: whether the copy would work. + */ + dryRun?: boolean; } /** @@ -74,7 +91,17 @@ export async function copyVaultSecrets( options: VaultCopyOptions = {}, ): Promise<[VaultCopyResult | null, Error | null]> { - const { force = false } = options; + const { force = false, channel = 'user', dryRun = false } = options; + + // Gated here rather than in `vault cp`: this function already holds both + // configs, so every surface that reaches it inherits the check. + assertVaultPolicy({ configName: sourceConfig.name, access: sourceConfig.access, channel }, 'vault:read'); + + if (!dryRun) { + + assertVaultPolicy({ configName: destConfig.name, access: destConfig.access, channel }, 'vault:write'); + + } observer.emit('vault:copy:starting', { source: sourceConfig.name, @@ -106,21 +133,32 @@ export async function copyVaultSecrets( if (!destStatus.isInitialized) { - // Initialize vault on destination - const [newKey, initErr] = await initializeVault( - ctx.destination.db, - identityHash, - publicKey, - ctx.destination.dialect, - ); + if (dryRun) { - if (initErr) { - - throw new Error(`Failed to initialize vault on destination: ${initErr.message}`); + // A dry run must not create a vault. Report the intent and + // carry on with the source-side checks the caller wants. + destVaultKey = null; } + else { + + // Initialize vault on destination + const [newKey, initErr] = await initializeVault( + ctx.destination.db, + identityHash, + publicKey, + ctx.destination.dialect, + ); - destVaultKey = newKey; + if (initErr) { + + throw new Error(`Failed to initialize vault on destination: ${initErr.message}`); + + } + + destVaultKey = newKey; + + } } else if (!destStatus.hasAccess) { @@ -139,7 +177,9 @@ export async function copyVaultSecrets( } - if (!destVaultKey) { + // A dry run against an uninitialized destination has no key and + // needs none — nothing is encrypted. Every other path does. + if (!destVaultKey && !(dryRun && !destStatus.isInitialized)) { throw new Error('Failed to get vault key for destination'); @@ -178,7 +218,9 @@ export async function copyVaultSecrets( // Copy each secret to destination for (const [key, secret] of secretsToCopy) { - const exists = await vaultSecretExists(ctx.destination.db, key, ctx.destination.dialect); + const exists = destStatus.isInitialized + ? await vaultSecretExists(ctx.destination.db, key, ctx.destination.dialect) + : false; if (exists && !force) { @@ -187,10 +229,17 @@ export async function copyVaultSecrets( } + if (dryRun) { + + result.copied.push(key); + continue; + + } + const setBy = `copied from ${sourceConfig.name}`; const [, setErr] = await setVaultSecret( ctx.destination.db, - destVaultKey, + destVaultKey as Buffer, key, secret.value, setBy, diff --git a/src/core/vault/index.ts b/src/core/vault/index.ts index 0efafe73..1a13b944 100644 --- a/src/core/vault/index.ts +++ b/src/core/vault/index.ts @@ -5,6 +5,7 @@ */ export * from './types.js'; export * from './events.js'; +export * from './policy.js'; export * from './key.js'; export * from './storage.js'; export * from './propagate.js'; diff --git a/src/core/vault/policy.ts b/src/core/vault/policy.ts new file mode 100644 index 00000000..755e27ab --- /dev/null +++ b/src/core/vault/policy.ts @@ -0,0 +1,69 @@ +/** + * Vault policy gate. + * + * The vault stores the team's shared secrets, so every operation against it + * is a config-scoped action and belongs behind the same `access` matrix that + * gates runs, changes and raw SQL. Gating here — in core — rather than in + * each CLI command means the SDK, TUI and any future surface inherit the + * check by calling the `*Checked` entrypoints instead of re-deriving it. + */ +import { assertPolicy, checkConfigPolicy } from '../policy/index.js'; +import type { Channel, ConfigAccess, Permission, PolicyCheck } from '../policy/index.js'; + +/** + * Policy inputs a vault operation is checked against. + * + * Mirrors `SqlPolicyGate`: the config the operation targets plus the channel + * asking. `access` is optional only so a caller holding raw/partial config + * JSON can pass what it has — `checkConfigPolicy` fails closed when it's + * missing, so an absent `access` denies rather than waves through. + */ +export interface VaultPolicyGate { + /** Name of the config the vault belongs to, for the denial message. */ + configName: string; + + /** The config's access declaration. Absent denies. */ + access?: ConfigAccess; + + /** Who is asking — CLI/TUI/SDK are `user`, the MCP server is `mcp`. */ + channel: Channel; +} + +/** + * Resolve a vault permission against a gate without throwing. + * + * Callers that must distinguish "denied" from "allowed but needs the user to + * confirm" (every CLI command, since `vault:write` and `vault:propagate` are + * `confirm` cells for `operator`) use this; callers that only need the + * fail-closed throw use `assertVaultPolicy`. + * + * @example + * const check = checkVaultPolicy(gate, 'vault:write'); + * if (!check.allowed) return { success: false, error: check.blockedReason }; + * if (check.requiresConfirmation && !isYesMode(args)) return needsConfirm(); + */ +export function checkVaultPolicy(gate: VaultPolicyGate, permission: Permission): PolicyCheck { + + return checkConfigPolicy(gate.channel, { name: gate.configName, access: gate.access }, permission); + +} + +/** + * Throw unless the gate permits the vault permission. + * + * Deliberately ignores `requiresConfirmation` — confirmation is a surface + * concern (a CLI prompt, a TUI dialog, the SDK's `yes` option) and the core + * has no way to ask. Surfaces resolve it via `checkVaultPolicy` before + * calling in; this is the last-resort gate that stops an ungated caller. + * + * @throws Error carrying the policy's blockedReason when the channel's role + * denies the permission. + * + * @example + * assertVaultPolicy({ configName: 'prod', access: config.access, channel: 'user' }, 'vault:write'); + */ +export function assertVaultPolicy(gate: VaultPolicyGate, permission: Permission): void { + + assertPolicy(gate.channel, { name: gate.configName, access: gate.access }, permission); + +} diff --git a/src/core/vault/storage.ts b/src/core/vault/storage.ts index f3cc2784..95094377 100644 --- a/src/core/vault/storage.ts +++ b/src/core/vault/storage.ts @@ -21,6 +21,35 @@ import { encryptSecret, decryptSecret, } from './key.js'; +import { assertVaultPolicy } from './policy.js'; +import type { VaultPolicyGate } from './policy.js'; + +/** + * Secret key names accepted by the vault. + * + * Deliberately identical to `StateManager.setSecret`'s regex: the vault and + * local state feed the same `$.secrets` template namespace, so a key one + * writer accepts and the other rejects produces a secret that resolves in one + * environment and not the other. + */ +const SECRET_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; + +/** + * Reject secret key names the template layer can't address. + * + * @throws Error naming the offending key when it doesn't match. + */ +function assertValidSecretKey(secretKey: string): void { + + if (!SECRET_KEY_PATTERN.test(secretKey)) { + + throw new Error( + `Invalid secret key "${secretKey}". Keys must start with a letter and contain only letters, numbers, and underscores.`, + ); + + } + +} /** * Initialize the vault for a database. @@ -208,6 +237,10 @@ export async function setVaultSecret( const ndb = noormDb(db, dialect); const tables = getNoormTables(dialect); + const [, keyErr] = attemptSync(() => assertValidSecretKey(secretKey)); + + if (keyErr) return [undefined, keyErr]; + // Encrypt the value const encrypted = encryptSecret(value, vaultKey); const encryptedJson = JSON.stringify(encrypted); @@ -578,9 +611,11 @@ export async function getVaultStatus( const usersWithoutAccess = identities.filter((i) => i.encrypted_vault_key === null).length; const isInitialized = usersWithAccess > 0; - // Check if current user has access + // Check if current user has access. Truthiness rather than `!== null`: + // an identity with no row at all yields `undefined`, and `undefined !== + // null` reported an unregistered user as having vault access. const currentUser = identities.find((i) => i.identity_hash === identityHash); - const hasAccess = currentUser?.encrypted_vault_key !== null; + const hasAccess = !!currentUser?.encrypted_vault_key; return { isInitialized, @@ -591,3 +626,132 @@ export async function getVaultStatus( }; } + +// ───────────────────────────────────────────────────────────── +// Policy-gated entrypoints +// ───────────────────────────────────────────────────────────── +// +// The functions above take no config, so they cannot consult `access` — they +// remain the ungated primitives the TUI still calls directly (see the vault +// migration note in `policy.ts`). Every surface that has a config in hand +// (CLI, SDK) must use the `*Checked` wrappers below, which is what closes the +// hole where a `viewer` role denied `run build` could still write the vault. +// `getVaultStatus` has no gated twin on purpose: it returns counts and +// booleans, never key names or values, and callers need it to tell a user +// *why* they have no access. + +/** + * `initializeVault` gated on `vault:write`. + * + * @throws Error carrying the policy's blockedReason when the gate denies. + * + * @example + * const [key, err] = await initializeVaultChecked(gate, db, hash, pubKey, 'postgres'); + */ +export async function initializeVaultChecked( + gate: VaultPolicyGate, + db: Kysely, + identityHash: string, + publicKey: string, + dialect: Dialect, +): Promise<[Buffer | null, Error | null]> { + + assertVaultPolicy(gate, 'vault:write'); + + return initializeVault(db, identityHash, publicKey, dialect); + +} + +/** + * `getVaultKey` gated on `vault:read`. + * + * Gating the key rather than each read is what makes the gate hard to route + * around: `getVaultSecret`/`getAllVaultSecrets` are useless without the key, + * so a denied caller cannot decrypt anything. + * + * @throws Error carrying the policy's blockedReason when the gate denies. + * + * @example + * const vaultKey = await getVaultKeyChecked(gate, db, hash, privateKey, 'postgres'); + */ +export async function getVaultKeyChecked( + gate: VaultPolicyGate, + db: Kysely, + identityHash: string, + privateKey: string, + dialect: Dialect, +): Promise { + + assertVaultPolicy(gate, 'vault:read'); + + return getVaultKey(db, identityHash, privateKey, dialect); + +} + +/** + * `setVaultSecret` gated on `vault:write`. + * + * @throws Error carrying the policy's blockedReason when the gate denies. + * + * @example + * const [, err] = await setVaultSecretChecked(gate, db, vaultKey, 'API_KEY', v, who, 'postgres'); + */ +export async function setVaultSecretChecked( + gate: VaultPolicyGate, + db: Kysely, + vaultKey: Buffer, + secretKey: string, + value: string, + setBy: string, + dialect: Dialect, +): Promise<[void, Error | null]> { + + assertVaultPolicy(gate, 'vault:write'); + + return setVaultSecret(db, vaultKey, secretKey, value, setBy, dialect); + +} + +/** + * `deleteVaultSecret` gated on `vault:write`. + * + * @throws Error carrying the policy's blockedReason when the gate denies. + * + * @example + * const [deleted, err] = await deleteVaultSecretChecked(gate, db, 'OLD_KEY', 'postgres'); + */ +export async function deleteVaultSecretChecked( + gate: VaultPolicyGate, + db: Kysely, + secretKey: string, + dialect: Dialect, +): Promise<[boolean, Error | null]> { + + assertVaultPolicy(gate, 'vault:write'); + + return deleteVaultSecret(db, secretKey, dialect); + +} + +/** + * `listVaultSecretKeys` gated on `vault:read`. + * + * Key names alone are disclosure — they enumerate which systems the team + * holds credentials for — so this is gated even though no value is decrypted. + * + * @throws Error carrying the policy's blockedReason when the gate denies. + * + * @example + * const keys = await listVaultSecretKeysChecked(gate, db, 'postgres'); + */ +export async function listVaultSecretKeysChecked( + gate: VaultPolicyGate, + db: Kysely, + dialect: Dialect, +): Promise { + + assertVaultPolicy(gate, 'vault:read'); + + return listVaultSecretKeys(db, dialect); + +} diff --git a/tests/core/vault/policy-gate.test.ts b/tests/core/vault/policy-gate.test.ts new file mode 100644 index 00000000..7888f2d6 --- /dev/null +++ b/tests/core/vault/policy-gate.test.ts @@ -0,0 +1,241 @@ +/** + * Vault authorization tests. + * + * The vault is the team's shared secret store and `access` is the only + * authorization mechanism noorm has. Before these tests existed a `viewer` + * config — denied `run build` — could still write, delete and hand out every + * vault secret, because no vault permission existed and no vault code path + * consulted the policy. + * + * These assert the *intent*: a role that cannot run a build cannot touch the + * secret store either, on any surface, because the gate lives in core. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { Kysely, SqliteDialect } from 'kysely'; +import { attempt } from '@logosdx/utils'; + +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { + initializeVault, + initializeVaultChecked, + getVaultKeyChecked, + setVaultSecretChecked, + deleteVaultSecretChecked, + listVaultSecretKeysChecked, + getVaultSecret, +} from '../../../src/core/vault/storage.js'; +import { propagateVaultKeyChecked, propagateVaultKeyToChecked } from '../../../src/core/vault/propagate.js'; +import type { VaultPolicyGate } from '../../../src/core/vault/policy.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; +import { + NOORM_TABLES, + type NoormDatabase, +} from '../../../src/core/shared/index.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { + generateKeyPair, + computeIdentityHash, +} from '../../../src/core/identity/index.js'; + +interface TestIdentity { + identityHash: string; + publicKey: string; + privateKey: string; +} + +const gateFor = (access: ConfigAccess): VaultPolicyGate => ({ + configName: 'testcfg', + access, + channel: 'user', +}); + +const VIEWER = gateFor({ user: 'viewer', mcp: 'viewer' }); +const ADMIN = gateFor({ user: 'admin', mcp: 'admin' }); + +async function createTestDb(): Promise> { + + const db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + return db; + +} + +async function seedIdentity( + db: Kysely, + email = 'alice@example.com', + name = 'Alice', +): Promise { + + const { publicKey, privateKey } = generateKeyPair(); + const identityHash = computeIdentityHash({ + email, + name, + machine: 'test-machine', + os: 'test-os', + }); + + await db + .insertInto(NOORM_TABLES.identities) + .values({ + identity_hash: identityHash, + email, + name, + machine: 'test-machine', + os: 'test-os', + public_key: publicKey, + encrypted_vault_key: null, + } as never) + .execute(); + + return { identityHash, publicKey, privateKey }; + +} + +describe('vault: policy gate', () => { + + let db: Kysely; + let alice: TestIdentity; + let vaultKey: Buffer; + + beforeEach(async () => { + + db = await createTestDb(); + alice = await seedIdentity(db); + + const [key, err] = await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + expect(err).toBeNull(); + + vaultKey = key as Buffer; + + }); + + afterEach(async () => { + + await db.destroy(); + + }); + + it('should refuse a viewer the vault key, so no read path can decrypt', async () => { + + const [, err] = await attempt(() => + getVaultKeyChecked(VIEWER, db, alice.identityHash, alice.privateKey, 'sqlite'), + ); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('vault:read'); + + }); + + it('should refuse a viewer a vault write and leave the secret unwritten', async () => { + + const [, err] = await attempt(() => + setVaultSecretChecked(VIEWER, db, vaultKey, 'API_KEY', 'sk-viewer-wrote-this', 'alice', 'sqlite'), + ); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('vault:write'); + + // The denial must be an actual block, not just a thrown message. + expect(await getVaultSecret(db, vaultKey, 'API_KEY', 'sqlite')).toBeNull(); + + }); + + it('should refuse a viewer a vault delete and leave the secret intact', async () => { + + await setVaultSecretChecked(ADMIN, db, vaultKey, 'KEEP_ME', 'still-here', 'alice', 'sqlite'); + + const [, err] = await attempt(() => + deleteVaultSecretChecked(VIEWER, db, 'KEEP_ME', 'sqlite'), + ); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('vault:write'); + expect(await getVaultSecret(db, vaultKey, 'KEEP_ME', 'sqlite')).toBe('still-here'); + + }); + + it('should refuse a viewer the list of vault key names', async () => { + + const [, err] = await attempt(() => listVaultSecretKeysChecked(VIEWER, db, 'sqlite')); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('vault:read'); + + }); + + it('should refuse a viewer vault init', async () => { + + const [, err] = await attempt(() => + initializeVaultChecked(VIEWER, db, alice.identityHash, alice.publicKey, 'sqlite'), + ); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('vault:write'); + + }); + + it('should refuse a viewer propagation, so a denied role cannot hand out the vault', async () => { + + const bob = await seedIdentity(db, 'bob@example.com', 'Bob'); + + const [, err] = await attempt(() => + propagateVaultKeyChecked(VIEWER, db, vaultKey, 'sqlite'), + ); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('vault:propagate'); + + const [, toErr] = await attempt(() => + propagateVaultKeyToChecked(VIEWER, db, vaultKey, bob.identityHash, 'sqlite'), + ); + + expect(toErr).toBeInstanceOf(Error); + + // Bob must still be locked out. + const row = await db + .selectFrom(NOORM_TABLES.identities) + .select('encrypted_vault_key') + .where('identity_hash', '=', bob.identityHash) + .executeTakeFirst(); + + expect(row?.encrypted_vault_key).toBeNull(); + + }); + + it('should deny when the config carries no access declaration at all', async () => { + + const noAccess: VaultPolicyGate = { configName: 'legacy', channel: 'user' }; + + const [, err] = await attempt(() => + setVaultSecretChecked(noAccess, db, vaultKey, 'K', 'v', 'alice', 'sqlite'), + ); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('no access configuration'); + + }); + + it('should let an admin through every gated operation', async () => { + + const [, setErr] = await setVaultSecretChecked(ADMIN, db, vaultKey, 'API_KEY', 'sk-live', 'alice', 'sqlite'); + + expect(setErr).toBeNull(); + + expect(await listVaultSecretKeysChecked(ADMIN, db, 'sqlite')).toContain('API_KEY'); + expect(await getVaultKeyChecked(ADMIN, db, alice.identityHash, alice.privateKey, 'sqlite')).toBeInstanceOf(Buffer); + + const [deleted, delErr] = await deleteVaultSecretChecked(ADMIN, db, 'API_KEY', 'sqlite'); + + expect(delErr).toBeNull(); + expect(deleted).toBe(true); + + }); + +}); From c1e95a470488c51b628ec90d84fc576ba9e65996 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:54:03 -0400 Subject: [PATCH 049/105] fix(vault): report partial and failed vault propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/core/vault/propagate.ts | 177 ++++++++++++++++++------- src/core/vault/types.ts | 42 ++++++ tests/core/vault/propagate.test.ts | 203 +++++++++++++++++++++++++++++ 3 files changed, 373 insertions(+), 49 deletions(-) create mode 100644 tests/core/vault/propagate.test.ts diff --git a/src/core/vault/propagate.ts b/src/core/vault/propagate.ts index 8dc5f07a..be565497 100644 --- a/src/core/vault/propagate.ts +++ b/src/core/vault/propagate.ts @@ -14,18 +14,10 @@ import { getNoormTables, noormDb } from '../shared/tables.js'; import type { Dialect } from '../connection/types.js'; import { observer } from '../observer.js'; -import type { VaultPropagationResult } from './types.js'; +import type { FailedVaultPropagation, PendingVaultUser, VaultPropagationResult } from './types.js'; import { encryptVaultKey } from './key.js'; - -/** - * User info for propagation. - */ -interface UserForPropagation { - identityHash: string; - publicKey: string; - name: string; - email: string; -} +import { assertVaultPolicy } from './policy.js'; +import type { VaultPolicyGate } from './policy.js'; /** * Get users who don't have vault access yet. @@ -33,20 +25,26 @@ interface UserForPropagation { * Uses dialect-aware table names to support both legacy prefixed and * schema-qualified table locations. * + * Returns a tuple rather than a bare array because "nobody is waiting" and + * "the query failed" are opposite answers that used to collapse into the same + * empty array — an operator was told "all users already have vault access" + * when the database had in fact refused the read. + * * @param db - Kysely database instance * @param dialect - Database dialect for table name resolution - * @returns Array of users without vault access + * @returns [users, null] on success, [null, Error] when the query failed * * @example * ```typescript - * const users = await getUsersWithoutVaultAccess(db, 'postgres'); + * const [users, err] = await getUsersWithoutVaultAccess(db, 'postgres'); + * if (err) throw err; * console.log('Users awaiting access:', users.length); * ``` */ export async function getUsersWithoutVaultAccess( db: Kysely, dialect: Dialect, -): Promise { +): Promise<[PendingVaultUser[], null] | [null, Error]> { const ndb = noormDb(db, dialect); const tables = getNoormTables(dialect); @@ -66,51 +64,69 @@ export async function getUsersWithoutVaultAccess( }); - if (err || !rows) return []; + if (err) return [null, err]; + + if (!rows) return [null, new Error('Failed to read identities awaiting vault access')]; - return rows.map((r) => ({ - identityHash: r.identity_hash, - publicKey: r.public_key, - name: r.name, - email: r.email, - })); + return [ + rows.map((r) => ({ + identityHash: r.identity_hash, + publicKey: r.public_key, + name: r.name, + email: r.email, + })), + null, + ]; } /** - * Propagate vault key to all users without access. + * Options for `propagateVaultKey`. + */ +export interface VaultPropagationOptions { + /** + * Identity hashes to grant access to. Omitted means "every identity + * currently without access" — the historical behaviour, which callers + * should only reach for after showing the operator that list. + */ + targets?: string[]; +} + +/** + * Propagate vault key to users without access. * * Encrypts the vault key for each user's public key and updates their row. * Uses dialect-aware table names to support both legacy prefixed and * schema-qualified table locations. * + * Per-user failures land in `result.failed` rather than being dropped: a + * grant that silently skipped a teammate left both parties believing access + * had been handed over, with no signal on either side. + * * @param db - Kysely database instance * @param vaultKey - The decrypted vault key * @param dialect - Database dialect for table name resolution - * @returns Result with counts of propagated users + * @param options - Restrict propagation to specific identity hashes + * @returns Result with granted, already-had, and failed targets * * @example * ```typescript - * const result = await propagateVaultKey(db, vaultKey, 'postgres'); - * if (result.propagatedTo.length > 0) { - * console.log('Granted access to:', result.propagatedTo.join(', ')); - * } + * const result = await propagateVaultKey(db, vaultKey, 'postgres', { targets: [hash] }); + * if (result.failed.length > 0) throw new Error('partial propagation'); * ``` */ export async function propagateVaultKey( db: Kysely, vaultKey: Buffer, dialect: Dialect, + options: VaultPropagationOptions = {}, ): Promise { const ndb = noormDb(db, dialect); const tables = getNoormTables(dialect); - const users = await getUsersWithoutVaultAccess(db, dialect); + const countWithAccess = async (): Promise => { - if (users.length === 0) { - - // Count users who already have access const [countResult] = await attempt(async () => { return ndb @@ -121,14 +137,30 @@ export async function propagateVaultKey( }); + return countResult ? Number(countResult.count) : 0; + + }; + + const [pending, pendingErr] = await getUsersWithoutVaultAccess(db, dialect); + + if (pendingErr) throw pendingErr; + + const users = options.targets + ? pending.filter((u) => options.targets?.includes(u.identityHash)) + : pending; + + if (users.length === 0) { + return { propagatedTo: [], - alreadyHadAccess: countResult ? Number(countResult.count) : 0, + alreadyHadAccess: await countWithAccess(), + failed: [], }; } const propagatedTo: string[] = []; + const failed: FailedVaultPropagation[] = []; for (const user of users) { @@ -147,40 +179,65 @@ export async function propagateVaultKey( }); - if (!err) { + if (err) { - propagatedTo.push(user.identityHash); + failed.push({ + identityHash: user.identityHash, + email: user.email, + error: err.message, + }); - observer.emit('vault:propagated', { - toIdentityHash: user.identityHash, - toEmail: user.email, + observer.emit('error', { + source: 'vault:propagate', + error: err, + context: { identityHash: user.identityHash, email: user.email }, }); - } + continue; - } + } - // Count users who already had access - const [countResult] = await attempt(async () => { + propagatedTo.push(user.identityHash); - return ndb - .selectFrom(tables.identities as keyof NoormDatabase) - .select(ndb.fn.count('id').as('count')) - .where('encrypted_vault_key', 'is not', null) - .executeTakeFirst(); + observer.emit('vault:propagated', { + toIdentityHash: user.identityHash, + toEmail: user.email, + }); - }); + } - const totalWithAccess = countResult ? Number(countResult.count) : 0; - const alreadyHadAccess = totalWithAccess - propagatedTo.length; + const alreadyHadAccess = (await countWithAccess()) - propagatedTo.length; return { propagatedTo, alreadyHadAccess, + failed, }; } +/** + * `propagateVaultKey` gated on `vault:propagate`. + * + * @throws Error carrying the policy's blockedReason when the gate denies. + * + * @example + * const result = await propagateVaultKeyChecked(gate, db, vaultKey, 'postgres', { targets }); + */ +export async function propagateVaultKeyChecked( + gate: VaultPolicyGate, + db: Kysely, + vaultKey: Buffer, + dialect: Dialect, + options: VaultPropagationOptions = {}, +): Promise { + + assertVaultPolicy(gate, 'vault:propagate'); + + return propagateVaultKey(db, vaultKey, dialect, options); + +} + /** * Propagate vault key to a specific user. * @@ -249,3 +306,25 @@ export async function propagateVaultKeyTo( return true; } + +/** + * `propagateVaultKeyTo` gated on `vault:propagate`. + * + * @throws Error carrying the policy's blockedReason when the gate denies. + * + * @example + * const ok = await propagateVaultKeyToChecked(gate, db, vaultKey, hash, 'postgres'); + */ +export async function propagateVaultKeyToChecked( + gate: VaultPolicyGate, + db: Kysely, + vaultKey: Buffer, + targetIdentityHash: string, + dialect: Dialect, +): Promise { + + assertVaultPolicy(gate, 'vault:propagate'); + + return propagateVaultKeyTo(db, vaultKey, targetIdentityHash, dialect); + +} diff --git a/src/core/vault/types.ts b/src/core/vault/types.ts index 2e11bd89..c9790a12 100644 --- a/src/core/vault/types.ts +++ b/src/core/vault/types.ts @@ -77,6 +77,41 @@ export interface VaultCopyResult { errors: Array<{ key: string; error: string }>; } +/** + * An identity awaiting vault access. + * + * Propagation grants a key that cannot be revoked, so the operator has to be + * able to see *who* they are about to grant to before it happens — the hash + * alone, printed after the fact, is not a decision they can make. + */ +export interface PendingVaultUser { + /** Identity hash — the stable handle used to target propagation */ + identityHash: string; + + /** X25519 public key the vault key is sealed to */ + publicKey: string; + + /** Display name recorded at enrollment */ + name: string; + + /** Email recorded at enrollment */ + email: string; +} + +/** + * A propagation target that could not be granted access. + */ +export interface FailedVaultPropagation { + /** Identity hash that was not granted access */ + identityHash: string; + + /** Email of the identity that was not granted access */ + email: string; + + /** Why the grant failed */ + error: string; +} + /** * Result of vault propagation. */ @@ -86,6 +121,13 @@ export interface VaultPropagationResult { /** Count of users who already had access */ alreadyHadAccess: number; + + /** + * Targets whose grant failed. Non-empty means the operation partially + * succeeded: some teammate believes they have access and does not. + * Callers must treat a non-empty `failed` as an error, not a warning. + */ + failed: FailedVaultPropagation[]; } /** diff --git a/tests/core/vault/propagate.test.ts b/tests/core/vault/propagate.test.ts new file mode 100644 index 00000000..2a369544 --- /dev/null +++ b/tests/core/vault/propagate.test.ts @@ -0,0 +1,203 @@ +/** + * Vault propagation tests. + * + * `propagate.ts` shipped with no test coverage at all, and carried three + * defects: a partial grant reported as complete success, a query failure + * indistinguishable from "nobody is waiting", and no way to target specific + * identities. These assert the intent — an operator must be able to tell + * whether their teammate actually got access. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { Kysely, SqliteDialect, sql } from 'kysely'; +import { attempt } from '@logosdx/utils'; + +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { initializeVault, getVaultStatus, setVaultSecret } from '../../../src/core/vault/storage.js'; +import { propagateVaultKey, getUsersWithoutVaultAccess } from '../../../src/core/vault/propagate.js'; +import { observer } from '../../../src/core/observer.js'; +import { + NOORM_TABLES, + type NoormDatabase, +} from '../../../src/core/shared/index.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { + generateKeyPair, + computeIdentityHash, +} from '../../../src/core/identity/index.js'; + +interface TestIdentity { + identityHash: string; + publicKey: string; + privateKey: string; +} + +async function createTestDb(): Promise> { + + const db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + return db; + +} + +async function seedIdentity( + db: Kysely, + email: string, + name: string, +): Promise { + + const { publicKey, privateKey } = generateKeyPair(); + const identityHash = computeIdentityHash({ + email, + name, + machine: 'test-machine', + os: 'test-os', + }); + + await db + .insertInto(NOORM_TABLES.identities) + .values({ + identity_hash: identityHash, + email, + name, + machine: 'test-machine', + os: 'test-os', + public_key: publicKey, + encrypted_vault_key: null, + } as never) + .execute(); + + return { identityHash, publicKey, privateKey }; + +} + +describe('vault: propagation', () => { + + let db: Kysely; + let alice: TestIdentity; + let vaultKey: Buffer; + + beforeEach(async () => { + + db = await createTestDb(); + alice = await seedIdentity(db, 'alice@example.com', 'Alice'); + + const [key] = await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + vaultKey = key as Buffer; + + }); + + afterEach(async () => { + + await db.destroy(); + + }); + + it('should report a failed grant in failed[] instead of claiming success', async () => { + + const bob = await seedIdentity(db, 'bob@example.com', 'Bob'); + const carol = await seedIdentity(db, 'carol@example.com', 'Carol'); + + // Block Bob's row specifically, leaving Carol's grant to succeed — + // the partial-failure shape that used to report success:true. + await sql` + CREATE TRIGGER block_bob BEFORE UPDATE ON ${sql.raw(NOORM_TABLES.identities)} + FOR EACH ROW WHEN new.identity_hash = ${sql.lit(bob.identityHash)} + BEGIN SELECT RAISE(ABORT, 'blocked'); END; + `.execute(db); + + const errors: unknown[] = []; + const off = observer.on('error', (data) => errors.push(data)); + + const result = await propagateVaultKey(db, vaultKey, 'sqlite'); + + off?.cleanup?.(); + + expect(result.propagatedTo).toEqual([carol.identityHash]); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]?.identityHash).toBe(bob.identityHash); + expect(result.failed[0]?.email).toBe('bob@example.com'); + expect(errors).toHaveLength(1); + + // Bob must still be without access — the whole point of reporting it. + const row = await db + .selectFrom(NOORM_TABLES.identities) + .select('encrypted_vault_key') + .where('identity_hash', '=', bob.identityHash) + .executeTakeFirst(); + + expect(row?.encrypted_vault_key).toBeNull(); + + }); + + it('should grant only the targeted identities, not everyone waiting', async () => { + + const bob = await seedIdentity(db, 'bob@example.com', 'Bob'); + const mallory = await seedIdentity(db, 'mallory@evil.local', 'Mallory'); + + const result = await propagateVaultKey(db, vaultKey, 'sqlite', { targets: [bob.identityHash] }); + + expect(result.propagatedTo).toEqual([bob.identityHash]); + expect(result.failed).toEqual([]); + + // An identity that merely connected must not receive the vault key + // just because someone else was granted access. + const rogue = await db + .selectFrom(NOORM_TABLES.identities) + .select('encrypted_vault_key') + .where('identity_hash', '=', mallory.identityHash) + .executeTakeFirst(); + + expect(rogue?.encrypted_vault_key).toBeNull(); + + }); + + it('should surface a query failure rather than report "everyone has access"', async () => { + + await sql`DROP TABLE ${sql.raw(NOORM_TABLES.identities)}`.execute(db); + + const [users, err] = await getUsersWithoutVaultAccess(db, 'sqlite'); + + expect(users).toBeNull(); + expect(err).toBeInstanceOf(Error); + + // propagate must not swallow it into a benign-looking result either. + const [, propErr] = await attempt(() => propagateVaultKey(db, vaultKey, 'sqlite')); + + expect(propErr).toBeInstanceOf(Error); + + }); + + it('should report no vault access for an identity with no row', async () => { + + // A read-only DB user, a failed insert, or a dump taken before the + // user joined all produce this. `undefined !== null` reported it as + // having access. + const status = await getVaultStatus(db, 'f'.repeat(64), 'sqlite'); + + expect(status.isInitialized).toBe(true); + expect(status.hasAccess).toBe(false); + + }); + + it('should reject a vault key name the template layer cannot address', async () => { + + const [, err] = await setVaultSecret(db, vaultKey, 'weird-key!', 'v', 'alice', 'sqlite'); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('weird-key!'); + + // The same name `secret set` rejects must not be writable via the vault. + const [, leadingUnderscore] = await setVaultSecret(db, vaultKey, '_LEADING', 'v', 'alice', 'sqlite'); + + expect(leadingUnderscore).toBeInstanceOf(Error); + + }); + +}); From ba6ff8671de019e9679584f03272e421d1973964 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:54:09 -0400 Subject: [PATCH 050/105] fix(vault): show who propagate grants to before granting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/vault/propagate.ts | 157 ++++++++++++++++++++++++++++++++++--- 1 file changed, 147 insertions(+), 10 deletions(-) diff --git a/src/cli/vault/propagate.ts b/src/cli/vault/propagate.ts index d4f3a745..3d4ce58f 100644 --- a/src/cli/vault/propagate.ts +++ b/src/cli/vault/propagate.ts @@ -3,12 +3,33 @@ */ import { defineCommand } from 'citty'; -import { withVaultContext, sharedArgs } from '../_utils.js'; +import { withVaultContext, sharedArgs, isYesMode } from '../_utils.js'; import { - getVaultKey, - propagateVaultKey, + getVaultKeyChecked, + propagateVaultKeyChecked, getUsersWithoutVaultAccess, + checkVaultPolicy, } from '../../core/vault/index.js'; +import type { PendingVaultUser, VaultPolicyGate } from '../../core/vault/index.js'; + +/** + * Shape of a pending identity as reported to the operator. + * + * Propagation seals the vault key to a public key and cannot be revoked, so + * the operator is shown who they are granting to — name, email, hash — + * *before* the grant, not a list of hashes afterwards. + */ +interface PendingReport { + identityHash: string; + name: string; + email: string; +} + +function toReport(users: PendingVaultUser[]): PendingReport[] { + + return users.map((u) => ({ identityHash: u.identityHash, name: u.name, email: u.email })); + +} const propagateCommand = defineCommand({ meta: { @@ -16,8 +37,13 @@ const propagateCommand = defineCommand({ description: 'Propagate vault access to new users', }, args: { + to: { + type: 'string', + description: 'Only propagate to these identity hashes (comma-separated)', + }, config: sharedArgs.config, json: sharedArgs.json, + yes: sharedArgs.yes, }, async run({ args }) { @@ -26,7 +52,25 @@ const propagateCommand = defineCommand({ fn: async ({ ctx, cryptoIdentity, privateKey }) => { const db = ctx.kysely; - const vaultKey = await getVaultKey(db, cryptoIdentity.identityHash, privateKey, ctx.dialect); + const config = ctx.noorm.config; + const gate: VaultPolicyGate = { + configName: config.name, + access: config.access, + channel: 'user', + }; + + const check = checkVaultPolicy(gate, 'vault:propagate'); + + if (!check.allowed) { + + return { + success: false, + error: check.blockedReason ?? `Cannot propagate vault access on config "${config.name}".`, + }; + + } + + const vaultKey = await getVaultKeyChecked(gate, db, cryptoIdentity.identityHash, privateKey, ctx.dialect); if (!vaultKey) { @@ -37,24 +81,100 @@ const propagateCommand = defineCommand({ } - const usersWithout = await getUsersWithoutVaultAccess(db, ctx.dialect); + const [pending, pendingErr] = await getUsersWithoutVaultAccess(db, ctx.dialect); + + if (pendingErr) { + + return { + success: false, + error: `Failed to read identities awaiting vault access: ${pendingErr.message}`, + }; + + } + + const requested = args.to + ? String(args.to).split(',').map((h) => h.trim()).filter(Boolean) + : null; + + const targets = requested + ? pending.filter((u) => requested.includes(u.identityHash)) + : pending; + + if (requested) { + + const unknown = requested.filter((h) => !pending.some((u) => u.identityHash === h)); + + if (unknown.length > 0) { - if (usersWithout.length === 0) { + return { + success: false, + error: `Not awaiting vault access: ${unknown.join(', ')}`, + }; + + } + + } + + if (targets.length === 0) { return { success: true, propagatedTo: [] as string[], + pending: [] as PendingReport[], + granted: false, message: 'All users already have vault access', }; } - const propagateResult = await propagateVaultKey(db, vaultKey, ctx.dialect); + // Withhold the grant until the operator has seen the list. + // `vault:propagate` is a `confirm` cell for every role that + // holds it, so this is the policy's own requirement, not an + // extra gate invented here. + if (!isYesMode(args)) { + + return { + success: false, + granted: false, + pending: toReport(targets), + error: `${targets.length} identit${targets.length === 1 ? 'y is' : 'ies are'} awaiting vault access. ` + + 'Review the list above, then re-run with --yes to grant, ' + + 'or --to to grant to specific identities.', + }; + + } + + const propagateResult = await propagateVaultKeyChecked( + gate, + db, + vaultKey, + ctx.dialect, + { targets: targets.map((u) => u.identityHash) }, + ); + + // A partial grant is a failure: the teammate whose update did + // not land believes they have access and does not. + if (propagateResult.failed.length > 0) { + + return { + success: false, + granted: true, + propagatedTo: propagateResult.propagatedTo, + alreadyHadAccess: propagateResult.alreadyHadAccess, + failed: propagateResult.failed, + pending: toReport(targets), + error: `Failed to grant vault access to ${propagateResult.failed.length} of ${targets.length} identities.`, + }; + + } return { success: true, + granted: true, propagatedTo: propagateResult.propagatedTo, alreadyHadAccess: propagateResult.alreadyHadAccess, + failed: propagateResult.failed, + pending: toReport(targets), }; }, @@ -82,11 +202,15 @@ const propagateCommand = defineCommand({ } else { - process.stdout.write(`Granted vault access to ${propagated.length} users\n`); + const byHash = new Map((result.pending ?? []).map((p) => [p.identityHash, p])); + + process.stdout.write(`Granted vault access to ${propagated.length} identities\n`); for (const hash of propagated) { - process.stdout.write(` ${hash}\n`); + const who = byHash.get(hash); + + process.stdout.write(who ? ` ${who.name} <${who.email}> ${hash}\n` : ` ${hash}\n`); } @@ -95,6 +219,18 @@ const propagateCommand = defineCommand({ } else { + for (const who of result?.pending ?? []) { + + process.stderr.write(` ${who.name} <${who.email}> ${who.identityHash}\n`); + + } + + for (const failure of result?.failed ?? []) { + + process.stderr.write(` FAILED ${failure.email} ${failure.identityHash}: ${failure.error}\n`); + + } + process.stderr.write(`Error: ${result?.error ?? 'Unknown error'}\n`); } @@ -106,8 +242,9 @@ const propagateCommand = defineCommand({ (propagateCommand as typeof propagateCommand & { examples: string[] }).examples = [ 'noorm vault propagate', + 'noorm vault propagate --yes', + 'noorm vault propagate --to 4a5c14af... --yes', 'noorm vault propagate --json', - 'noorm vault propagate -c prod', ]; export default propagateCommand; From 88945cce7c519064d180c99f8ef07d9201bdba11 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:54:18 -0400 Subject: [PATCH 051/105] fix(vault): confirm destructive vault commands and accept stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/cli/vault/_secret-value.ts | 75 ++++++++++++++++++++++++++++++++++ src/cli/vault/init.ts | 37 +++++++++++++++-- src/cli/vault/list.ts | 23 ++++++++++- src/cli/vault/rm.ts | 47 +++++++++++++++++---- src/cli/vault/set.ts | 58 ++++++++++++++++++++++---- 5 files changed, 220 insertions(+), 20 deletions(-) create mode 100644 src/cli/vault/_secret-value.ts diff --git a/src/cli/vault/_secret-value.ts b/src/cli/vault/_secret-value.ts new file mode 100644 index 00000000..c71645dc --- /dev/null +++ b/src/cli/vault/_secret-value.ts @@ -0,0 +1,75 @@ +/** + * Secret value input for `vault set` and `secret set`. + * + * A secret passed as a positional argument is visible in the process table + * (`ps -ww -eo args`), in shell history, and in `set -x` CI traces. `--stdin` + * is the way to set one without it appearing in any of those. + * + * Lives here rather than in `cli/_utils.ts` so both secret writers share one + * implementation; it is a candidate to move to `_utils.ts` alongside the + * other shared CLI input helpers. + */ +import { attempt } from '@logosdx/utils'; + +/** + * Read the whole of stdin as a UTF-8 string. + * + * Strips a single trailing newline so the shell idiom `echo "$X" | noorm + * vault set K --stdin` stores `$X` and not `$X\n` — `echo` appends one, and a + * secret with a stray newline fails authentication in confusing ways. + */ +async function readStdin(): Promise { + + const chunks: Buffer[] = []; + + for await (const chunk of process.stdin) { + + chunks.push(Buffer.from(chunk as Buffer)); + + } + + return Buffer.concat(chunks).toString('utf-8').replace(/\r?\n$/, ''); + +} + +/** + * Resolve a secret value from `--stdin` or the positional argument. + * + * @returns [value, null] on success, [null, Error] when the two inputs + * conflict, neither was supplied, or stdin was empty. + * + * @example + * const [value, err] = await readSecretValue(args); + * if (err) { process.stderr.write(`Error: ${err.message}\n`); process.exit(1); } + */ +export async function readSecretValue( + args: { value?: string; stdin?: boolean }, +): Promise<[string, null] | [null, Error]> { + + if (args.stdin && args.value !== undefined) { + + return [null, new Error('Pass the value as an argument or via --stdin, not both.')]; + + } + + if (args.stdin) { + + const [value, err] = await attempt(() => readStdin()); + + if (err) return [null, new Error(`Failed to read the secret value from stdin: ${err.message}`)]; + + if (value === '') return [null, new Error('No secret value on stdin.')]; + + return [value, null]; + + } + + if (args.value === undefined) { + + return [null, new Error('Missing secret value. Pass it as an argument, or pipe it in with --stdin.')]; + + } + + return [args.value, null]; + +} diff --git a/src/cli/vault/init.ts b/src/cli/vault/init.ts index 94dae2b3..dad43c32 100644 --- a/src/cli/vault/init.ts +++ b/src/cli/vault/init.ts @@ -3,8 +3,9 @@ */ import { defineCommand } from 'citty'; -import { withVaultContext, sharedArgs } from '../_utils.js'; -import { initializeVault, getVaultStatus } from '../../core/vault/index.js'; +import { withVaultContext, sharedArgs, isYesMode } from '../_utils.js'; +import { initializeVaultChecked, getVaultStatus, checkVaultPolicy } from '../../core/vault/index.js'; +import type { VaultPolicyGate } from '../../core/vault/index.js'; const initCommand = defineCommand({ meta: { @@ -14,6 +15,7 @@ const initCommand = defineCommand({ args: { config: sharedArgs.config, json: sharedArgs.json, + yes: sharedArgs.yes, }, async run({ args }) { @@ -22,6 +24,24 @@ const initCommand = defineCommand({ fn: async ({ ctx, cryptoIdentity }) => { const db = ctx.kysely; + const config = ctx.noorm.config; + const gate: VaultPolicyGate = { + configName: config.name, + access: config.access, + channel: 'user', + }; + + const check = checkVaultPolicy(gate, 'vault:write'); + + if (!check.allowed) { + + return { + success: false, + message: check.blockedReason ?? `Cannot initialize the vault on config "${config.name}".`, + }; + + } + const status = await getVaultStatus(db, cryptoIdentity.identityHash, ctx.dialect); if (status.isInitialized) { @@ -39,7 +59,18 @@ const initCommand = defineCommand({ } - const [, initErr] = await initializeVault( + if (check.requiresConfirmation && !isYesMode(args)) { + + return { + success: false, + message: `Initializing the vault on config "${config.name}" requires confirmation ` + + `(${check.confirmationPhrase}). Pass --yes to confirm.`, + }; + + } + + const [, initErr] = await initializeVaultChecked( + gate, db, cryptoIdentity.identityHash, cryptoIdentity.publicKey, diff --git a/src/cli/vault/list.ts b/src/cli/vault/list.ts index f4685a04..80e3fd59 100644 --- a/src/cli/vault/list.ts +++ b/src/cli/vault/list.ts @@ -4,7 +4,8 @@ import { defineCommand } from 'citty'; import { withVaultContext, sharedArgs } from '../_utils.js'; -import { getVaultKey, getAllVaultSecrets, getVaultStatus } from '../../core/vault/index.js'; +import { getVaultKeyChecked, getAllVaultSecrets, getVaultStatus, checkVaultPolicy } from '../../core/vault/index.js'; +import type { VaultPolicyGate } from '../../core/vault/index.js'; const listCommand = defineCommand({ meta: { @@ -22,6 +23,24 @@ const listCommand = defineCommand({ fn: async ({ ctx, cryptoIdentity, privateKey }) => { const db = ctx.kysely; + const config = ctx.noorm.config; + const gate: VaultPolicyGate = { + configName: config.name, + access: config.access, + channel: 'user', + }; + + const check = checkVaultPolicy(gate, 'vault:read'); + + if (!check.allowed) { + + return { + success: false, + error: check.blockedReason ?? `Cannot read the vault on config "${config.name}".`, + }; + + } + const status = await getVaultStatus(db, cryptoIdentity.identityHash, ctx.dialect); if (!status.isInitialized) { @@ -42,7 +61,7 @@ const listCommand = defineCommand({ } - const vaultKey = await getVaultKey(db, cryptoIdentity.identityHash, privateKey, ctx.dialect); + const vaultKey = await getVaultKeyChecked(gate, db, cryptoIdentity.identityHash, privateKey, ctx.dialect); if (!vaultKey) { diff --git a/src/cli/vault/rm.ts b/src/cli/vault/rm.ts index f9de7769..309c98d5 100644 --- a/src/cli/vault/rm.ts +++ b/src/cli/vault/rm.ts @@ -3,8 +3,9 @@ */ import { defineCommand } from 'citty'; -import { withVaultContext, sharedArgs } from '../_utils.js'; -import { getVaultKey, deleteVaultSecret, vaultSecretExists } from '../../core/vault/index.js'; +import { withVaultContext, sharedArgs, isYesMode } from '../_utils.js'; +import { getVaultKeyChecked, deleteVaultSecretChecked, vaultSecretExists, checkVaultPolicy } from '../../core/vault/index.js'; +import type { VaultPolicyGate } from '../../core/vault/index.js'; const rmCommand = defineCommand({ meta: { @@ -15,6 +16,7 @@ const rmCommand = defineCommand({ key: { type: 'positional', description: 'Secret key name to remove', required: true }, config: sharedArgs.config, json: sharedArgs.json, + yes: sharedArgs.yes, }, async run({ args }) { @@ -23,7 +25,38 @@ const rmCommand = defineCommand({ fn: async ({ ctx, cryptoIdentity, privateKey }) => { const db = ctx.kysely; - const vaultKey = await getVaultKey(db, cryptoIdentity.identityHash, privateKey, ctx.dialect); + const config = ctx.noorm.config; + const gate: VaultPolicyGate = { + configName: config.name, + access: config.access, + channel: 'user', + }; + + const check = checkVaultPolicy(gate, 'vault:write'); + + if (!check.allowed) { + + return { + success: false, + error: check.blockedReason ?? `Cannot write the vault on config "${config.name}".`, + }; + + } + + // The vault has no soft-delete and no history table, so this + // destroys the team's only copy. `secret rm` — which deletes + // a recoverable local copy — has always required --yes; the + // irrecoverable one required nothing. + if (!isYesMode(args)) { + + return { + success: false, + error: `Deleting vault secret "${args.key}" cannot be undone — the team's only copy is destroyed. Pass --yes to confirm.`, + }; + + } + + const vaultKey = await getVaultKeyChecked(gate, db, cryptoIdentity.identityHash, privateKey, ctx.dialect); if (!vaultKey) { @@ -42,7 +75,7 @@ const rmCommand = defineCommand({ } - const [deleted, deleteErr] = await deleteVaultSecret(db, args.key, ctx.dialect); + const [deleted, deleteErr] = await deleteVaultSecretChecked(gate, db, args.key, ctx.dialect); if (deleteErr) { @@ -83,9 +116,9 @@ const rmCommand = defineCommand({ }); (rmCommand as typeof rmCommand & { examples: string[] }).examples = [ - 'noorm vault rm OLD_API_KEY', - 'noorm vault rm OLD_API_KEY --json', - 'noorm vault rm OLD_API_KEY -c prod', + 'noorm vault rm OLD_API_KEY --yes', + 'noorm vault rm OLD_API_KEY --yes --json', + 'noorm vault rm OLD_API_KEY --yes -c prod', ]; export default rmCommand; diff --git a/src/cli/vault/set.ts b/src/cli/vault/set.ts index 0b4ca16d..a3c91530 100644 --- a/src/cli/vault/set.ts +++ b/src/cli/vault/set.ts @@ -1,10 +1,12 @@ /** - * noorm vault set — set a vault secret. + * noorm vault set [value] — set a vault secret. */ import { defineCommand } from 'citty'; -import { withVaultContext, sharedArgs } from '../_utils.js'; -import { getVaultKey, setVaultSecret } from '../../core/vault/index.js'; +import { withVaultContext, sharedArgs, isYesMode } from '../_utils.js'; +import { readSecretValue } from './_secret-value.js'; +import { getVaultKeyChecked, setVaultSecretChecked, checkVaultPolicy } from '../../core/vault/index.js'; +import type { VaultPolicyGate } from '../../core/vault/index.js'; const setCommand = defineCommand({ meta: { @@ -13,18 +15,57 @@ const setCommand = defineCommand({ }, args: { key: { type: 'positional', description: 'Secret key name', required: true }, - value: { type: 'positional', description: 'Secret value', required: true }, + value: { type: 'positional', description: 'Secret value (omit with --stdin)', required: false }, + stdin: { type: 'boolean', description: 'Read the value from stdin instead of argv' }, config: sharedArgs.config, json: sharedArgs.json, + yes: sharedArgs.yes, }, async run({ args }) { + const [value, valueErr] = await readSecretValue(args); + + if (valueErr) { + + process.stderr.write(`Error: ${valueErr.message}\n`); + process.exit(1); + + } + const [result, err] = await withVaultContext({ args, fn: async ({ ctx, cryptoIdentity, privateKey }) => { const db = ctx.kysely; - const vaultKey = await getVaultKey(db, cryptoIdentity.identityHash, privateKey, ctx.dialect); + const config = ctx.noorm.config; + const gate: VaultPolicyGate = { + configName: config.name, + access: config.access, + channel: 'user', + }; + + const check = checkVaultPolicy(gate, 'vault:write'); + + if (!check.allowed) { + + return { + success: false, + error: check.blockedReason ?? `Cannot write the vault on config "${config.name}".`, + }; + + } + + if (check.requiresConfirmation && !isYesMode(args)) { + + return { + success: false, + error: `Writing the vault on config "${config.name}" requires confirmation ` + + `(${check.confirmationPhrase}). Pass --yes to confirm.`, + }; + + } + + const vaultKey = await getVaultKeyChecked(gate, db, cryptoIdentity.identityHash, privateKey, ctx.dialect); if (!vaultKey) { @@ -35,11 +76,12 @@ const setCommand = defineCommand({ } - const [, setErr] = await setVaultSecret( + const [, setErr] = await setVaultSecretChecked( + gate, db, vaultKey, args.key, - args.value, + value as string, cryptoIdentity.email, ctx.dialect, ); @@ -84,7 +126,7 @@ const setCommand = defineCommand({ (setCommand as typeof setCommand & { examples: string[] }).examples = [ 'noorm vault set API_KEY "sk-live-..."', - 'noorm vault set DB_PASSWORD "secret123"', + 'echo "$API_KEY" | noorm vault set API_KEY --stdin', 'noorm vault set API_KEY "sk-live-..." --json', ]; From e96e989e52dc72f568e1f8a2cb87df93e0582a15 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:54:25 -0400 Subject: [PATCH 052/105] docs: correct the paths.changes env var name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/dev/config.md | 2 +- docs/guide/environments/configs.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev/config.md b/docs/dev/config.md index 920e9f4a..787f01b3 100644 --- a/docs/dev/config.md +++ b/docs/dev/config.md @@ -92,7 +92,7 @@ NOORM_{PATH}_{TO}_{VALUE} → { path: { to: { value: '' } } } | Variable | Config Path | |----------|-------------| | `NOORM_PATHS_SQL` | `paths.sql` | -| `NOORM_PATHS_CHANGESETS` | `paths.changes` | +| `NOORM_PATHS_CHANGES` | `paths.changes` | **Top-level variables:** diff --git a/docs/guide/environments/configs.md b/docs/guide/environments/configs.md index 48baacfd..ad19266e 100644 --- a/docs/guide/environments/configs.md +++ b/docs/guide/environments/configs.md @@ -184,7 +184,7 @@ Environment variables override stored config values. This is how you inject secr | Variable | Config Path | |----------|-------------| | `NOORM_PATHS_SQL` | `paths.sql` | -| `NOORM_PATHS_CHANGESETS` | `paths.changes` | +| `NOORM_PATHS_CHANGES` | `paths.changes` | **Behavior variables:** From 85270ceac97b954c3bd72ae5d8176ffdba959821 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:54:26 -0400 Subject: [PATCH 053/105] fix(vault): make vault cp --dry-run run the real preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--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. --- src/cli/vault/cp.ts | 54 +++++++++++++++++---------------------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/src/cli/vault/cp.ts b/src/cli/vault/cp.ts index 9c21b449..1a8ae83d 100644 --- a/src/cli/vault/cp.ts +++ b/src/cli/vault/cp.ts @@ -78,35 +78,10 @@ const cpCommand = defineCommand({ } - if (args.dryRun) { - - if (args.json) { - - process.stdout.write(JSON.stringify({ - success: true, - dryRun: true, - source: sourceConfigName, - destination: destConfigName, - keys, - force: !!args.force, - }) + '\n'); - - } - else { - - process.stdout.write(`Dry run: would copy ${keys.join(', ')} from "${sourceConfigName}" to "${destConfigName}"\n`); - if (args.force) { - - process.stdout.write('With --force: would overwrite existing secrets\n'); - - } - - } - - process.exit(0); - - } - + // A dry run goes through the same preflight as the real copy — vault + // access on both ends, source-key existence, destination collisions — + // and differs only in that nothing is written. Echoing the arguments + // back could not answer the question a dry run is asked. const [result, copyErr] = await copyVaultSecrets( sourceConfig, destConfig, @@ -114,7 +89,7 @@ const cpCommand = defineCommand({ cryptoIdentity.identityHash, privateKey, cryptoIdentity.publicKey, - { force: args.force }, + { force: args.force, dryRun: !!args.dryRun }, ); if (copyErr) { @@ -134,10 +109,16 @@ const cpCommand = defineCommand({ } + // `success` tracks the exit code. Reporting true alongside a populated + // `errors` array made a CI script branching on `.success` read a + // failed copy as a success. + const succeeded = !result?.errors?.length; + if (args.json) { process.stdout.write(JSON.stringify({ - success: true, + success: succeeded, + dryRun: !!args.dryRun, copied: result?.copied ?? [], skipped: result?.skipped ?? [], errors: result?.errors ?? [], @@ -152,13 +133,17 @@ const cpCommand = defineCommand({ if (copied.length > 0) { - process.stdout.write(`Copied ${copied.length} secrets: ${copied.join(', ')}\n`); + const verb = args.dryRun ? 'Would copy' : 'Copied'; + + process.stdout.write(`${verb} ${copied.length} secrets: ${copied.join(', ')}\n`); } if (skipped.length > 0) { - process.stdout.write(`Skipped ${skipped.length} existing secrets: ${skipped.join(', ')}\n`); + const verb = args.dryRun ? 'Would skip' : 'Skipped'; + + process.stdout.write(`${verb} ${skipped.length} existing secrets: ${skipped.join(', ')}\n`); process.stdout.write('Use --force to overwrite\n'); } @@ -181,13 +166,14 @@ const cpCommand = defineCommand({ } - process.exit(result?.errors?.length ? 1 : 0); + process.exit(succeeded ? 0 : 1); }, }); (cpCommand as typeof cpCommand & { examples: string[] }).examples = [ 'noorm vault cp API_KEY staging production', + 'noorm vault cp API_KEY staging production --dry-run', 'noorm vault cp DB_PASSWORD dev staging --force', 'noorm vault cp API_KEY staging production --json', ]; From 431a8fa347db482d4308626287a4e9174f037f3b Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:54:35 -0400 Subject: [PATCH 054/105] fix(secret): gate secret commands and honour NOORM_YES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/secret/_policy.ts | 70 ++++++++++++ src/cli/secret/list.ts | 8 +- src/cli/secret/rm.ts | 15 ++- src/cli/secret/set.ts | 40 +++++-- tests/core/secrets/leakage.test.ts | 174 +++++++++++++++++++++++++++++ 5 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 src/cli/secret/_policy.ts create mode 100644 tests/core/secrets/leakage.test.ts diff --git a/src/cli/secret/_policy.ts b/src/cli/secret/_policy.ts new file mode 100644 index 00000000..f7508970 --- /dev/null +++ b/src/cli/secret/_policy.ts @@ -0,0 +1,70 @@ +/** + * Config resolution and policy gating for the `secret` commands. + * + * Config-scoped secrets are written through `StateManager`, which takes no + * config object and so cannot consult `access` itself. Until that seam + * carries the gate, these commands resolve the config and check it here — + * which is why the check lives in one helper rather than being re-typed in + * three command files. + */ +import { checkConfigPolicy } from '../../core/policy/index.js'; +import type { Permission, PolicyCheck } from '../../core/policy/index.js'; +import type { StateManager } from '../../core/state/index.js'; + +/** + * Outcome of resolving the target config and checking it against a permission. + */ +export type SecretPolicyResolution = + | { ok: true; configName: string; check: PolicyCheck } + | { ok: false; error: string }; + +/** + * Resolve the config a secret command targets, then gate it. + * + * Rejects an unknown config name rather than treating it as an empty secret + * set: a typo used to return "no secrets" and exit 0, which reads as "this + * config has none" instead of "there is no such config". + * + * @example + * const resolved = resolveSecretPolicy(stateManager, args.config, 'secret:write'); + * if (!resolved.ok) { outputError(args, resolved.error); process.exit(1); } + */ +export function resolveSecretPolicy( + stateManager: StateManager, + configName: string | undefined, + permission: Permission, +): SecretPolicyResolution { + + const name = configName ?? stateManager.getActiveConfigName(); + + if (!name) { + + return { + ok: false, + error: 'No config specified and no active config set. Use --config or run "noorm config use ".', + }; + + } + + const config = stateManager.getConfig(name); + + if (!config) { + + return { ok: false, error: `Config "${name}" not found.` }; + + } + + const check = checkConfigPolicy('user', config, permission); + + if (!check.allowed) { + + return { + ok: false, + error: check.blockedReason ?? `"${permission}" is not allowed on config "${name}".`, + }; + + } + + return { ok: true, configName: name, check }; + +} diff --git a/src/cli/secret/list.ts b/src/cli/secret/list.ts index 14a3806f..d844ebfb 100644 --- a/src/cli/secret/list.ts +++ b/src/cli/secret/list.ts @@ -8,6 +8,7 @@ import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { resolveSecretPolicy } from './_policy.js'; const listCommand = defineCommand({ meta: { @@ -31,15 +32,16 @@ const listCommand = defineCommand({ } const stateManager = getStateManager(projectRoot); - const configName = args.config ?? stateManager.getActiveConfigName(); + const resolved = resolveSecretPolicy(stateManager, args.config, 'secret:read'); - if (!configName) { + if (!resolved.ok) { - outputError(args, 'No config specified and no active config set. Use --config or run "noorm config use ".'); + outputError(args, resolved.error); process.exit(1); } + const { configName } = resolved; const keys = stateManager.listSecrets(configName); outputResult( diff --git a/src/cli/secret/rm.ts b/src/cli/secret/rm.ts index e32f4bbe..f3170da8 100644 --- a/src/cli/secret/rm.ts +++ b/src/cli/secret/rm.ts @@ -7,7 +7,8 @@ import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; -import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; +import { resolveSecretPolicy } from './_policy.js'; const rmCommand = defineCommand({ meta: { @@ -33,16 +34,20 @@ const rmCommand = defineCommand({ } const stateManager = getStateManager(projectRoot); - const configName = args.config ?? stateManager.getActiveConfigName(); + const resolved = resolveSecretPolicy(stateManager, args.config, 'secret:write'); - if (!configName) { + if (!resolved.ok) { - outputError(args, 'No config specified and no active config set. Use --config or run "noorm config use ".'); + outputError(args, resolved.error); process.exit(1); } - if (!args.yes) { + const { configName } = resolved; + + // `isYesMode`, not `args.yes`: the documented `NOORM_YES` escape + // hatch was ignored here while 14 other CLI sites honoured it. + if (!isYesMode(args)) { outputError(args, `Pass --yes to confirm deletion of secret "${args.key}" from config "${configName}".`); process.exit(1); diff --git a/src/cli/secret/set.ts b/src/cli/secret/set.ts index aa9873f9..dd863b5b 100644 --- a/src/cli/secret/set.ts +++ b/src/cli/secret/set.ts @@ -1,5 +1,5 @@ /** - * noorm secret set — store a secret for the active or named config. + * noorm secret set [value] — store a secret for the active or named config. * * Secrets are encrypted in state and scoped to a specific config. */ @@ -7,7 +7,9 @@ import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; -import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; +import { readSecretValue } from '../vault/_secret-value.js'; +import { resolveSecretPolicy } from './_policy.js'; const setCommand = defineCommand({ meta: { @@ -16,12 +18,23 @@ const setCommand = defineCommand({ }, args: { key: { type: 'positional', description: 'Secret key name', required: true }, - value: { type: 'positional', description: 'Secret value', required: true }, + value: { type: 'positional', description: 'Secret value (omit with --stdin)', required: false }, + stdin: { type: 'boolean', description: 'Read the value from stdin instead of argv' }, config: sharedArgs.config, json: sharedArgs.json, + yes: sharedArgs.yes, }, async run({ args }) { + const [value, valueErr] = await readSecretValue(args); + + if (valueErr) { + + outputError(args, valueErr.message); + process.exit(1); + + } + const projectRoot = process.cwd(); const [, initErr] = await attempt(() => initState(projectRoot)); @@ -33,17 +46,29 @@ const setCommand = defineCommand({ } const stateManager = getStateManager(projectRoot); - const configName = args.config ?? stateManager.getActiveConfigName(); + const resolved = resolveSecretPolicy(stateManager, args.config, 'secret:write'); + + if (!resolved.ok) { + + outputError(args, resolved.error); + process.exit(1); + + } + + const { configName, check } = resolved; - if (!configName) { + if (check.requiresConfirmation && !isYesMode(args)) { - outputError(args, 'No config specified and no active config set. Use --config or run "noorm config use ".'); + outputError( + args, + `Writing a secret to config "${configName}" requires confirmation (${check.confirmationPhrase}). Pass --yes to confirm.`, + ); process.exit(1); } const [, setErr] = await attempt(() => - stateManager.setSecret(configName, args.key, args.value), + stateManager.setSecret(configName, args.key, value as string), ); if (setErr) { @@ -66,6 +91,7 @@ const setCommand = defineCommand({ (setCommand as typeof setCommand & { examples: string[] }).examples = [ 'noorm secret set API_KEY "sk-live-..."', + 'echo "$API_KEY" | noorm secret set API_KEY --stdin', 'noorm secret set DB_PASSWORD "secret123" --config prod', 'noorm secret set API_KEY "sk-live-..." --json', ]; diff --git a/tests/core/secrets/leakage.test.ts b/tests/core/secrets/leakage.test.ts new file mode 100644 index 00000000..e3e3373d --- /dev/null +++ b/tests/core/secrets/leakage.test.ts @@ -0,0 +1,174 @@ +/** + * Secret confidentiality tests. + * + * Secrets had no dedicated suite: coverage rode inside the settings and state + * tests, and nothing anywhere asserted that a secret *value* stays out of the + * places it must never reach. The audit that prompted this file found most of + * its leaks by planting a distinctive value and grepping every output for it, + * so that is the technique encoded here. + * + * Every test plants `SENTINEL` and asserts it is absent from a surface a + * secret must never appear on. A test that fails here is a disclosure bug, + * not a formatting difference. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { attempt } from '@logosdx/utils'; + +import { StateManager, resetStateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { observer } from '../../../src/core/observer.js'; + +/** + * A value chosen to be unmistakable in a grep and impossible to produce by + * accident. If this string turns up anywhere it was not written on purpose, + * a secret escaped. + */ +const SENTINEL = 'CANARY_SEKRIT_9f3a_do_not_leak'; + +function createTestConfig(name: string): Config { + + return { + name, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { + dialect: 'sqlite', + database: ':memory:', + }, + }; + +} + +describe('secrets: confidentiality', () => { + + let tempDir: string; + let state: StateManager; + + beforeEach(async () => { + + resetStateManager(); + tempDir = mkdtempSync(join(process.cwd(), 'tmp', 'noorm-secrets-test-')); + + const keyPair = await generateKeyPair(); + + state = new StateManager(tempDir, { + stateDir: '.test-state', + stateFile: 'state.enc', + privateKey: keyPair.privateKey, + }); + + await state.load(); + await state.setConfig('audit', createTestConfig('audit')); + + }); + + afterEach(() => { + + if (existsSync(tempDir)) { + + rmSync(tempDir, { recursive: true }); + + } + + }); + + it('should never write a secret value in cleartext to the state file', async () => { + + await state.setSecret('audit', 'TOKEN', SENTINEL); + + const raw = readFileSync(join(tempDir, '.test-state', 'state.enc')); + + expect(raw.includes(SENTINEL)).toBe(false); + expect(raw.toString('utf-8')).not.toContain(SENTINEL); + + // The value must still round-trip — absence alone would also be + // satisfied by never storing it. + expect(state.getSecret('audit', 'TOKEN')).toBe(SENTINEL); + + }); + + it('should never put a secret value in an observer event payload', async () => { + + // A regex subscription, not '*' — the string form matches nothing, so + // a wildcard-shaped version of this test would pass while observing + // no events at all. + const seen: string[] = []; + const off = observer.on(/.*/ as never, (data: unknown) => { + + seen.push(JSON.stringify(data ?? null)); + + }); + + await state.setSecret('audit', 'TOKEN', SENTINEL); + await state.deleteSecret('audit', 'TOKEN'); + + off?.cleanup?.(); + + // Guard against the assertion loop silently having nothing to check. + expect(seen.length).toBeGreaterThan(0); + + for (const payload of seen) { + + expect(payload).not.toContain(SENTINEL); + + } + + }); + + it('should list secret key names without their values', async () => { + + await state.setSecret('audit', 'TOKEN', SENTINEL); + + const keys = state.listSecrets('audit'); + + expect(keys).toContain('TOKEN'); + expect(JSON.stringify(keys)).not.toContain(SENTINEL); + + }); + + it('should not echo the value in the error raised by an invalid key', async () => { + + const [, err] = await attempt(() => state.setSecret('audit', 'bad-key!', SENTINEL)); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).not.toContain(SENTINEL); + + }); + + it('should not echo the value in the error raised for an unknown config', async () => { + + const [, err] = await attempt(() => state.setSecret('nonexistent', 'TOKEN', SENTINEL)); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).not.toContain(SENTINEL); + + }); + + it('should keep a deleted secret out of both state and reads', async () => { + + await state.setSecret('audit', 'TOKEN', SENTINEL); + await state.deleteSecret('audit', 'TOKEN'); + + expect(state.getSecret('audit', 'TOKEN')).toBeNull(); + + const raw = readFileSync(join(tempDir, '.test-state', 'state.enc')); + + expect(raw.includes(SENTINEL)).toBe(false); + + }); + + it('should scope a secret to its config so another config cannot read it', async () => { + + await state.setConfig('other', createTestConfig('other')); + await state.setSecret('audit', 'TOKEN', SENTINEL); + + expect(state.getSecret('other', 'TOKEN')).toBeNull(); + expect(JSON.stringify(state.listSecrets('other'))).not.toContain(SENTINEL); + + }); + +}); From 3c0f40f67aa4943ccc36e0ba55a8d79c2a25a1a2 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:54:42 -0400 Subject: [PATCH 055/105] 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. --- src/sdk/namespaces/secrets.ts | 71 +++++++++++++++++++++++++++++++++++ src/sdk/namespaces/vault.ts | 42 ++++++++++++++++++++- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/sdk/namespaces/secrets.ts b/src/sdk/namespaces/secrets.ts index 450e5ab8..86076aa1 100644 --- a/src/sdk/namespaces/secrets.ts +++ b/src/sdk/namespaces/secrets.ts @@ -7,6 +7,8 @@ import { getStateManager } from '../../core/state/index.js'; import type { ContextState } from '../state.js'; +import { checkProtectedConfig } from '../guards.js'; +import type { Permission } from '../../core/policy/index.js'; // ───────────────────────────────────────────────────────────── // SecretsNamespace @@ -22,6 +24,22 @@ export class SecretsNamespace { } + /** + * Gate a secret operation on the config's access policy. + * + * Config-scoped secrets are as sensitive as vault secrets — they feed the + * same `$.secrets` template namespace — so they sit behind the same + * matrix rather than being readable by any role that can open a context. + * + * @throws ProtectedConfigError when the policy denies, or requires a + * confirmation `options.yes` doesn't supply. + */ + #gate(permission: Permission, operation: string): void { + + checkProtectedConfig(this.#state.config, this.#state.options, permission, operation); + + } + /** * Get a config-scoped secret. * @@ -32,6 +50,8 @@ export class SecretsNamespace { */ get(key: string): string | undefined { + this.#gate('secret:read', 'secrets.get'); + const state = getStateManager(this.#state.projectRoot); const value = state.getSecret(this.#state.config.name, key); @@ -39,4 +59,55 @@ export class SecretsNamespace { } + /** + * List the config's secret key names. + * + * Values are never returned — use `get` for a specific key, so a caller + * that only needs to know *which* secrets exist never handles them. + * + * @example + * ```typescript + * const keys = ctx.noorm.secrets.list() + * ``` + */ + list(): string[] { + + this.#gate('secret:read', 'secrets.list'); + + return getStateManager(this.#state.projectRoot).listSecrets(this.#state.config.name); + + } + + /** + * Set a config-scoped secret. + * + * @example + * ```typescript + * await ctx.noorm.secrets.set('API_KEY', 'sk-live-...') + * ``` + */ + async set(key: string, value: string): Promise { + + this.#gate('secret:write', 'secrets.set'); + + await getStateManager(this.#state.projectRoot).setSecret(this.#state.config.name, key, value); + + } + + /** + * Delete a config-scoped secret. + * + * @example + * ```typescript + * await ctx.noorm.secrets.delete('OLD_KEY') + * ``` + */ + async delete(key: string): Promise { + + this.#gate('secret:write', 'secrets.delete'); + + await getStateManager(this.#state.projectRoot).deleteSecret(this.#state.config.name, key); + + } + } diff --git a/src/sdk/namespaces/vault.ts b/src/sdk/namespaces/vault.ts index a9891dc2..8b233445 100644 --- a/src/sdk/namespaces/vault.ts +++ b/src/sdk/namespaces/vault.ts @@ -33,6 +33,8 @@ import type { CryptoIdentity } from '../../core/identity/types.js'; import type { ContextState } from '../state.js'; import { requireConnection } from '../state.js'; +import { checkProtectedConfig } from '../guards.js'; +import type { Permission } from '../../core/policy/index.js'; // ───────────────────────────────────────────────────────────── // Errors @@ -79,6 +81,23 @@ export class VaultNamespace { } + /** + * Gate a vault operation on the config's access policy. + * + * The vault holds the team's shared secrets, so it sits behind the same + * matrix as runs and raw SQL. Routed through `checkProtectedConfig` so + * `confirm` cells behave here exactly as they do for `db.truncate()`: + * blocked unless the context was created with `yes: true`. + * + * @throws ProtectedConfigError when the policy denies, or requires a + * confirmation `options.yes` doesn't supply. + */ + #gate(permission: Permission, operation: string): void { + + checkProtectedConfig(this.#state.config, this.#state.options, permission, operation); + + } + /** * Load the cryptographic identity (with publicKey + identityHash) on * demand. The Context's state.identity is the audit identity (name + @@ -136,6 +155,8 @@ export class VaultNamespace { */ async init(): Promise { + this.#gate('vault:write', 'vault.init'); + const crypto = await this.#getCryptoIdentity(); const [vaultKey, err] = await initializeVault( @@ -189,6 +210,8 @@ export class VaultNamespace { privateKey: string, ): Promise { + this.#gate('vault:write', 'vault.set'); + const vaultKey = await this.#getVaultKey(privateKey); if (!vaultKey) throw new VaultAccessError(this.#state.config.name); @@ -216,6 +239,8 @@ export class VaultNamespace { */ async get(key: string, privateKey: string): Promise { + this.#gate('vault:read', 'vault.get'); + const vaultKey = await this.#getVaultKey(privateKey); if (!vaultKey) return null; @@ -239,6 +264,8 @@ export class VaultNamespace { */ async getAll(privateKey: string): Promise> { + this.#gate('vault:read', 'vault.getAll'); + const vaultKey = await this.#getVaultKey(privateKey); if (!vaultKey) return {}; @@ -261,6 +288,8 @@ export class VaultNamespace { */ async list(): Promise { + this.#gate('vault:read', 'vault.list'); + return listVaultSecretKeys( this.#kysely as unknown as Kysely, this.#dialect, @@ -278,6 +307,8 @@ export class VaultNamespace { */ async delete(key: string): Promise { + this.#gate('vault:write', 'vault.delete'); + const [deleted, err] = await deleteVaultSecret( this.#kysely as unknown as Kysely, key, @@ -300,6 +331,8 @@ export class VaultNamespace { */ async exists(key: string): Promise { + this.#gate('vault:read', 'vault.exists'); + return vaultSecretExists( this.#kysely as unknown as Kysely, key, @@ -322,11 +355,13 @@ export class VaultNamespace { */ async propagate(privateKey: string): Promise { + this.#gate('vault:propagate', 'vault.propagate'); + const vaultKey = await this.#getVaultKey(privateKey); if (!vaultKey) { - return { propagatedTo: [], alreadyHadAccess: 0 }; + return { propagatedTo: [], alreadyHadAccess: 0, failed: [] }; } @@ -355,6 +390,9 @@ export class VaultNamespace { const crypto = await this.#getCryptoIdentity(); + // `copyVaultSecrets` gates itself on both configs — it is the only + // caller-visible seam holding source *and* destination access — so + // the channel has to reach it. const [result, err] = await copyVaultSecrets( this.#state.config, destConfig, @@ -362,7 +400,7 @@ export class VaultNamespace { crypto.identityHash, privateKey, crypto.publicKey, - options, + { ...options, channel: this.#state.options.channel ?? 'user' }, ); if (err) throw err; From 5105443bfb845b4bf16d2311905cde3f11aa3bf5 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:56:14 -0400 Subject: [PATCH 056/105] 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. --- src/core/runner/mssql-batches.ts | 80 +++--- src/core/runner/sqlite-statements.ts | 252 ++++++++++++++++++ .../runner/sqlite-multi-statement.test.ts | 194 ++++++++++++++ tests/core/runner/sqlite-statements.test.ts | 144 ++++++++++ 4 files changed, 637 insertions(+), 33 deletions(-) create mode 100644 src/core/runner/sqlite-statements.ts create mode 100644 tests/core/runner/sqlite-multi-statement.test.ts create mode 100644 tests/core/runner/sqlite-statements.test.ts diff --git a/src/core/runner/mssql-batches.ts b/src/core/runner/mssql-batches.ts index a868cfaf..19ccc8f0 100644 --- a/src/core/runner/mssql-batches.ts +++ b/src/core/runner/mssql-batches.ts @@ -16,6 +16,7 @@ import { attempt } from '@logosdx/utils'; import { getSqlErrorMessage } from '../shared/index.js'; +import { splitSqliteStatements } from './sqlite-statements.js'; import type { RunContext } from './types.js'; @@ -88,17 +89,25 @@ function isCommentOnly(batch: string): boolean { } /** - * Execute a SQL file's body against the driver, handling MSSQL `GO` batches. + * Execute a SQL file's body against the driver, dialect by dialect. * - * Non-MSSQL dialects execute the whole content in one statement (matching the - * historical behavior). MSSQL splits on `GO` and runs each batch sequentially, - * short-circuiting on the first failure and prefixing the error with - * `[batch N of M]` so callers can identify the offending batch when reading - * the error in a `FileResult`. + * Three behaviors, because the drivers genuinely differ: * - * A zero-batch MSSQL file (empty or comment-only after stripping `GO`) is - * treated as success — the file ran, it just had nothing to execute. That - * matches what `psql /dev/null` would do. + * - **mssql** splits on `GO` and runs each batch sequentially. + * - **sqlite** splits on statement boundaries. Its `prepare()` compiles only + * the first statement of a string and drops the rest without erroring, so + * without this every statement after the first in a file was silently + * discarded while the runner reported success. + * - **postgres / mysql** execute the body whole, as before. Postgres runs + * all statements in one implicit transaction — splitting would quietly + * change that guarantee — and mysql rejects a multi-statement string + * outright, which is already loud. + * + * Failures short-circuit and are prefixed with `[batch N of M]` / + * `[statement N of M]` so a `FileResult.error` identifies which fragment + * failed. A file with nothing executable (empty, or only comments) is + * success — the file ran, it just had nothing to do, matching + * `psql /dev/null`. * * @returns `null` on success, or a user-facing error string on failure * @@ -113,49 +122,54 @@ export async function executeSqlBody( sqlContent: string, ): Promise { - if (context.dialect !== 'mssql') { - - const [, execErr] = await attempt(() => sql.raw(sqlContent).execute(context.db)); - - if (execErr) { - - return getSqlErrorMessage(execErr); + if (context.dialect === 'mssql') { - } - - return null; + return executeFragments(context, splitMssqlBatches(sqlContent), 'batch'); } - const batches = splitMssqlBatches(sqlContent); - - if (batches.length === 0) { + if (context.dialect === 'sqlite') { - return null; + return executeFragments(context, splitSqliteStatements(sqlContent), 'statement'); } - if (batches.length === 1) { + const [, execErr] = await attempt(() => sql.raw(sqlContent).execute(context.db)); - const [, execErr] = await attempt(() => sql.raw(batches[0]!).execute(context.db)); + if (execErr) { - if (execErr) { + return getSqlErrorMessage(execErr); - return getSqlErrorMessage(execErr); + } - } + return null; - return null; +} - } +/** + * Run pre-split fragments in order, stopping at the first failure. + * + * A single fragment reports its error unprefixed: there is nothing to + * disambiguate, and the position marker would only add noise to what is + * usually the whole file. + */ +async function executeFragments( + context: RunContext, + fragments: string[], + label: 'batch' | 'statement', +): Promise { - for (let i = 0; i < batches.length; i++) { + for (let i = 0; i < fragments.length; i++) { - const [, execErr] = await attempt(() => sql.raw(batches[i]!).execute(context.db)); + const [, execErr] = await attempt(() => sql.raw(fragments[i]!).execute(context.db)); if (execErr) { - return `[batch ${i + 1} of ${batches.length}] ${getSqlErrorMessage(execErr)}`; + const message = getSqlErrorMessage(execErr); + + return fragments.length === 1 + ? message + : `[${label} ${i + 1} of ${fragments.length}] ${message}`; } diff --git a/src/core/runner/sqlite-statements.ts b/src/core/runner/sqlite-statements.ts new file mode 100644 index 00000000..b5c9ce53 --- /dev/null +++ b/src/core/runner/sqlite-statements.ts @@ -0,0 +1,252 @@ +/** + * SQLite statement splitter. + * + * Every other supported driver executes a multi-statement string in full: + * postgres and mssql run all of it, mysql rejects it outright. SQLite's + * `prepare()` compiles the *first* statement and silently discards the + * rest, so handing it a whole file ran one statement, returned no error, + * and let the runner record a checksum that guaranteed the dropped + * statements were never retried. + * + * There is no way to ask bun:sqlite for the unconsumed tail, so the file + * has to be split before it reaches the driver. + * + * Scope: this is a boundary scanner, not a SQL parser. It knows where a + * statement can legally end — outside string literals, quoted identifiers, + * comments, and trigger bodies — and nothing else about the SQL. A + * construct it splits wrongly produces a syntax error from SQLite, which + * is the loud failure the previous behavior lacked. + */ + +/** Quote characters that open a literal or a quoted identifier in SQLite. */ +const QUOTE_PAIRS: Record = { + '\'': '\'', + '"': '"', + '`': '`', + '[': ']', +}; + +/** + * Whether `keyword` sits at `index` as a whole word. + * + * Word boundaries matter because `BEGINNING`, `ENDPOINT` and a column + * named `trigger_at` must not be read as the keywords they contain. + */ +function keywordAt(content: string, index: number, keyword: string): boolean { + + const end = index + keyword.length; + + if (content.slice(index, end).toUpperCase() !== keyword) return false; + + const before = index > 0 ? content[index - 1]! : ' '; + const after = end < content.length ? content[end]! : ' '; + + return !/[A-Za-z0-9_$]/.test(before) && !/[A-Za-z0-9_$]/.test(after); + +} + +/** + * Split a SQL file body into individually executable SQLite statements. + * + * Semicolons inside string literals, quoted identifiers (`"x"`, `` `x` ``, + * `[x]`), `--` line comments, `/* *\/` block comments, and trigger bodies + * are not boundaries. Trigger bodies are tracked by pairing `BEGIN`/`CASE` + * against `END`, which is what keeps a `CASE … END;` inside a trigger from + * being mistaken for the trigger's own terminator. + * + * `BEGIN` outside a `CREATE TRIGGER` is transaction control and opens + * nothing — reading `BEGIN;` as a block would swallow the rest of the file. + * + * Statements are returned trimmed and in source order; blank and + * comment-only fragments are dropped. + * + * @param content - Raw SQL file content + * @returns Executable statements, in source order + * + * @example + * ```typescript + * splitSqliteStatements('CREATE TABLE a (id INT);\nCREATE TABLE b (id INT);') + * // → ['CREATE TABLE a (id INT);', 'CREATE TABLE b (id INT);'] + * ``` + */ +export function splitSqliteStatements(content: string): string[] { + + const statements: string[] = []; + + let current = ''; + let closingQuote: string | null = null; + let inLineComment = false; + let inBlockComment = false; + let inTrigger = false; + let blockDepth = 0; + let i = 0; + + const flush = (): void => { + + const trimmed = current.trim(); + + if (trimmed.length > 0 && !isCommentOnly(trimmed)) { + + statements.push(trimmed); + + } + + current = ''; + + }; + + while (i < content.length) { + + const char = content[i]!; + + if (inLineComment) { + + current += char; + if (char === '\n') inLineComment = false; + i++; + continue; + + } + + if (inBlockComment) { + + current += char; + + if (char === '*' && content[i + 1] === '/') { + + current += '/'; + inBlockComment = false; + i += 2; + continue; + + } + + i++; + continue; + + } + + if (closingQuote !== null) { + + current += char; + + if (char === closingQuote) { + + // Doubling is the escape for every SQLite quote style + // (`''`, `""`, ` `` `); `]` has no escape and simply closes. + if (closingQuote !== ']' && content[i + 1] === closingQuote) { + + current += closingQuote; + i += 2; + continue; + + } + + closingQuote = null; + + } + + i++; + continue; + + } + + if (char === '-' && content[i + 1] === '-') { + + inLineComment = true; + current += '--'; + i += 2; + continue; + + } + + if (char === '/' && content[i + 1] === '*') { + + inBlockComment = true; + current += '/*'; + i += 2; + continue; + + } + + if (QUOTE_PAIRS[char]) { + + closingQuote = QUOTE_PAIRS[char]!; + current += char; + i++; + continue; + + } + + if (!inTrigger && keywordAt(content, i, 'TRIGGER')) { + + inTrigger = true; + current += content.slice(i, i + 7); + i += 7; + continue; + + } + + if (inTrigger && (keywordAt(content, i, 'BEGIN') || keywordAt(content, i, 'CASE'))) { + + const length = keywordAt(content, i, 'BEGIN') ? 5 : 4; + + blockDepth++; + current += content.slice(i, i + length); + i += length; + continue; + + } + + if (inTrigger && blockDepth > 0 && keywordAt(content, i, 'END')) { + + blockDepth--; + if (blockDepth === 0) inTrigger = false; + current += content.slice(i, i + 3); + i += 3; + continue; + + } + + if (char === ';' && blockDepth === 0) { + + current += char; + inTrigger = false; + flush(); + i++; + continue; + + } + + current += char; + i++; + + } + + flush(); + + return statements; + +} + +/** + * Whether a fragment carries no executable SQL — only comments and blanks. + * + * Used to drop the tail after a file's final `;` and to skip a file that is + * nothing but a header comment, rather than sending SQLite an empty string. + */ +function isCommentOnly(fragment: string): boolean { + + const withoutBlockComments = fragment.replace(/\/\*[\s\S]*?\*\//g, ''); + + return withoutBlockComments + .split('\n') + .every((line) => { + + const trimmed = line.trim(); + + return trimmed.length === 0 || trimmed.startsWith('--'); + + }); + +} diff --git a/tests/core/runner/sqlite-multi-statement.test.ts b/tests/core/runner/sqlite-multi-statement.test.ts new file mode 100644 index 00000000..a0c8c31e --- /dev/null +++ b/tests/core/runner/sqlite-multi-statement.test.ts @@ -0,0 +1,194 @@ +/** + * Multi-statement SQL files on SQLite. + * + * `executeSqlBody` handed the whole file to the driver as one statement for + * every non-MSSQL dialect. SQLite's `prepare()` compiles only the first + * statement and discards the rest without complaint, so a two-statement + * file reported `status: success`, recorded its checksum, and was never + * retried — silent, permanent data loss behind a green build. + * + * Postgres and MySQL are deliberately left running the body whole: both + * execute every statement, and splitting would change the implicit + * transaction they wrap it in. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Kysely, SqliteDialect, sql } from 'kysely'; + +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { runBuild } from '../../../src/core/runner/runner.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import type { RunContext } from '../../../src/core/runner/types.js'; + +describe('runner: multi-statement files on sqlite', () => { + + let db: Kysely; + let tempDir: string; + let sqlDir: string; + + function context(): RunContext { + + return { + db, + configName: 'test', + identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, + projectRoot: tempDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', + dialect: 'sqlite', + }; + + } + + async function tableNames(): Promise { + + const { rows } = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' + `.execute(db); + + return rows.map((r) => r.name); + + } + + beforeEach(async () => { + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-sqlite-multi-')); + sqlDir = join(tempDir, 'sql'); + await mkdir(sqlDir, { recursive: true }); + + db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + }); + + afterEach(async () => { + + await db.destroy(); + await rm(tempDir, { recursive: true, force: true }); + + }); + + it('should execute every statement in a multi-statement file', async () => { + + await writeFile( + join(sqlDir, '001_multi.sql'), + 'CREATE TABLE multi_a (id INTEGER PRIMARY KEY);\n' + + 'CREATE TABLE multi_b (id INTEGER PRIMARY KEY);\n' + + 'CREATE TABLE multi_c (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const result = await runBuild(context(), sqlDir); + + expect(result.status).toBe('success'); + + const tables = await tableNames(); + + expect(tables).toContain('multi_a'); + expect(tables).toContain('multi_b'); + expect(tables).toContain('multi_c'); + + }); + + it('should report failure when a later statement fails', async () => { + + await writeFile( + join(sqlDir, '001_multi.sql'), + 'CREATE TABLE multi_ok (id INTEGER PRIMARY KEY);\n' + + 'CREATE TABLE multi_ok (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const result = await runBuild(context(), sqlDir); + + // Reporting success here is what made the original defect a data + // integrity bug: the checksum was recorded and the file never retried. + expect(result.status).toBe('failed'); + expect(result.files[0]?.error).toContain('statement 2 of 2'); + + }); + + it('should not split semicolons inside a trigger body', async () => { + + await writeFile( + join(sqlDir, '001_trigger.sql'), + 'CREATE TABLE trig_src (id INTEGER PRIMARY KEY, n INTEGER);\n' + + 'CREATE TABLE trig_log (id INTEGER PRIMARY KEY, n INTEGER);\n' + + 'CREATE TRIGGER trig_copy AFTER INSERT ON trig_src\n' + + 'BEGIN\n' + + ' INSERT INTO trig_log (n) VALUES (NEW.n);\n' + + ' UPDATE trig_log SET n = n + 1 WHERE n IS NULL;\n' + + 'END;\n', + 'utf-8', + ); + + const result = await runBuild(context(), sqlDir); + + expect(result.files[0]?.error).toBeUndefined(); + expect(result.status).toBe('success'); + + const { rows } = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'trigger' + `.execute(db); + + expect(rows.map((r) => r.name)).toContain('trig_copy'); + + }); + + it('should run statements around an explicit transaction block', async () => { + + await writeFile( + join(sqlDir, '001_txn.sql'), + 'BEGIN;\n' + + 'CREATE TABLE txn_a (id INTEGER PRIMARY KEY);\n' + + 'COMMIT;\n' + + 'CREATE TABLE txn_b (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const result = await runBuild(context(), sqlDir); + + expect(result.status).toBe('success'); + + const tables = await tableNames(); + + // `BEGIN;` is transaction control, not the start of a trigger body — + // treating it as a block opener swallows the rest of the file. + expect(tables).toContain('txn_a'); + expect(tables).toContain('txn_b'); + + }); + + it('should ignore semicolons inside string literals and comments', async () => { + + await writeFile( + join(sqlDir, '001_quoted.sql'), + 'CREATE TABLE quoted (id INTEGER PRIMARY KEY, label TEXT);\n' + + "INSERT INTO quoted (label) VALUES ('a;b');\n" + + '-- a comment; with a semicolon\n' + + '/* block; comment */\n' + + "INSERT INTO quoted (label) VALUES ('c;d');\n", + 'utf-8', + ); + + const result = await runBuild(context(), sqlDir); + + expect(result.status).toBe('success'); + + const { rows } = await sql<{ label: string }>` + SELECT label FROM quoted ORDER BY id + `.execute(db); + + expect(rows.map((r) => r.label)).toEqual(['a;b', 'c;d']); + + }); + +}); diff --git a/tests/core/runner/sqlite-statements.test.ts b/tests/core/runner/sqlite-statements.test.ts new file mode 100644 index 00000000..5a143a77 --- /dev/null +++ b/tests/core/runner/sqlite-statements.test.ts @@ -0,0 +1,144 @@ +/** + * Unit tests for the SQLite statement splitter. + * + * The splitter exists because bun:sqlite compiles only the first statement + * of a string and drops the rest without erroring. Its job is to find the + * places a statement can legally end, so the cases worth pinning are the + * places a semicolon is *not* a boundary. + */ +import { describe, it, expect } from 'bun:test'; + +import { splitSqliteStatements } from '../../../src/core/runner/sqlite-statements.js'; + +describe('runner: splitSqliteStatements', () => { + + it('should split on top-level semicolons', () => { + + const result = splitSqliteStatements('CREATE TABLE a (id INT);\nCREATE TABLE b (id INT);\n'); + + expect(result).toEqual(['CREATE TABLE a (id INT);', 'CREATE TABLE b (id INT);']); + + }); + + it('should keep a trailing statement with no terminator', () => { + + const result = splitSqliteStatements('SELECT 1;\nSELECT 2'); + + expect(result).toEqual(['SELECT 1;', 'SELECT 2']); + + }); + + it('should not split inside a string literal', () => { + + const result = splitSqliteStatements("INSERT INTO t VALUES ('a;b');"); + + expect(result).toEqual(["INSERT INTO t VALUES ('a;b');"]); + + }); + + it('should handle a doubled quote inside a string literal', () => { + + const result = splitSqliteStatements("INSERT INTO t VALUES ('O''Brien; Esq');\nSELECT 1;"); + + expect(result).toHaveLength(2); + expect(result[0]).toBe("INSERT INTO t VALUES ('O''Brien; Esq');"); + + }); + + it('should not split inside quoted identifiers', () => { + + const result = splitSqliteStatements('SELECT "a;b", `c;d`, [e;f] FROM t;'); + + expect(result).toHaveLength(1); + + }); + + it('should not split inside comments', () => { + + const result = splitSqliteStatements( + '-- leading; comment\nSELECT 1;\n/* block; comment */\nSELECT 2;\n', + ); + + expect(result).toHaveLength(2); + + }); + + it('should drop comment-only fragments', () => { + + expect(splitSqliteStatements('-- nothing here\n')).toEqual([]); + expect(splitSqliteStatements('/* nothing\n here */\n')).toEqual([]); + expect(splitSqliteStatements('')).toEqual([]); + + }); + + it('should keep a trigger body whole', () => { + + const sql = 'CREATE TRIGGER t AFTER INSERT ON src\n' + + 'BEGIN\n' + + ' INSERT INTO log (n) VALUES (NEW.n);\n' + + ' DELETE FROM log WHERE n < 0;\n' + + 'END;\n' + + 'SELECT 1;'; + + const result = splitSqliteStatements(sql); + + expect(result).toHaveLength(2); + expect(result[0]).toContain('END;'); + expect(result[1]).toBe('SELECT 1;'); + + }); + + it('should not let a CASE inside a trigger close the trigger body', () => { + + // `END;` here belongs to the CASE, not the trigger. Pairing + // BEGIN/CASE against END is what tells them apart — a rule that + // only looks for `END;` splits this file in the wrong place. + const sql = 'CREATE TRIGGER t AFTER INSERT ON src\n' + + 'BEGIN\n' + + ' UPDATE log SET n = CASE WHEN NEW.n > 0 THEN 1 ELSE 2 END;\n' + + ' DELETE FROM log WHERE n < 0;\n' + + 'END;\n' + + 'SELECT 1;'; + + const result = splitSqliteStatements(sql); + + expect(result).toHaveLength(2); + expect(result[0]).toContain('DELETE FROM log'); + expect(result[1]).toBe('SELECT 1;'); + + }); + + it('should treat a standalone BEGIN as transaction control', () => { + + const result = splitSqliteStatements( + 'BEGIN;\nCREATE TABLE a (id INT);\nCOMMIT;\nCREATE TABLE b (id INT);\n', + ); + + expect(result).toEqual([ + 'BEGIN;', + 'CREATE TABLE a (id INT);', + 'COMMIT;', + 'CREATE TABLE b (id INT);', + ]); + + }); + + it('should not treat identifiers containing keywords as keywords', () => { + + const result = splitSqliteStatements( + 'CREATE TABLE beginning (trigger_at INT, ended INT);\nSELECT 1;', + ); + + expect(result).toHaveLength(2); + + }); + + it('should split a DROP TRIGGER normally', () => { + + const result = splitSqliteStatements('DROP TRIGGER t;\nSELECT 1;'); + + expect(result).toEqual(['DROP TRIGGER t;', 'SELECT 1;']); + + }); + +}); From 93a1bdffa27d1d54e36934a174ec8495de3e5c8a Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:56:40 -0400 Subject: [PATCH 057/105] chore(changeset): note change apply/revert and rewind fixes --- .changeset/change-reapply-and-rewind-fixes.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/change-reapply-and-rewind-fixes.md diff --git a/.changeset/change-reapply-and-rewind-fixes.md b/.changeset/change-reapply-and-rewind-fixes.md new file mode 100644 index 00000000..a4dd5eba --- /dev/null +++ b/.changeset/change-reapply-and-rewind-fixes.md @@ -0,0 +1,15 @@ +--- +'@noormdev/cli': patch +'@noormdev/sdk': patch +--- + +Fix change apply/revert recovery and rewind flag handling + +- A reverted or torn-down change can be applied again. Every file was previously skipped against a prior success, so `apply -> revert -> apply` and `db teardown -> change ff` reported success over an untouched database. +- `ff` and `next` now treat `stale` changes as pending work, so teardown has a supported recovery path. +- `change rewind` honours `--dry-run` and `--force`, and accepts the documented count form (`change rewind 3`). `changes.rewind()` takes an options argument. +- A revert whose history cannot be read fails instead of reporting success over zero files. +- `.sql.tmpl` files inside a change now receive `$.config` and `$.secrets`. +- `.txt` manifests execute in the order they list files, instead of being sorted. +- `ff`/`next` warn when the changes directory is missing rather than reporting a clean run. +- `change list` marks orphaned changes and no longer lists the internal `__reset__` teardown marker. From 19f00b3282ad56ceebcd14fd65a999ca20d36197 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:57:40 -0400 Subject: [PATCH 058/105] fix(lock): store and read lock timestamps in UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/lock/manager.ts | 73 +++++- src/core/version/schema/migrations/v1.ts | 10 + tests/core/lock/timezone.test.ts | 285 +++++++++++++++++++++++ 3 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 tests/core/lock/timezone.test.ts diff --git a/src/core/lock/manager.ts b/src/core/lock/manager.ts index 73d712cb..3bc1b493 100644 --- a/src/core/lock/manager.ts +++ b/src/core/lock/manager.ts @@ -43,10 +43,27 @@ import { // ───────────────────────────────────────────────────────────── /** - * Format a Date for database storage based on dialect. + * Serialize a Date for the lock table's naive datetime columns. * - * SQLite stores dates as TEXT (ISO strings), while other dialects - * can bind Date objects directly. + * `locked_at` / `expires_at` are declared without a timezone + * (`version/schema/migrations/v1.ts`), so they carry no offset of their own. + * Expiry is only meaningful if writer and reader agree on the frame of + * reference, and that frame must never be the client's local timezone — + * otherwise a `lock status` run from another zone compares two unrelated wall + * clocks and deletes a live lock. Every branch below resolves to UTC: + * + * - **sqlite** stores TEXT, so an ISO-8601 UTC string is both the stored value + * and the collation order used by the expiry comparison. + * - **postgres / mysql** drivers bind a JS `Date` using the *client's* local + * offset — the defect. Hand them an explicit UTC wall clock instead: + * postgres discards the offset on a naive column, and mysql applies its + * session zone (server-side and stable across clients) symmetrically on + * write and read, so both round-trip as identity. + * - **mssql** binds through tedious, whose `useUTC` default already + * serializes and parses in UTC. Passing the Date through is correct; + * stringifying it here would double-shift it. + * + * Mirror of {@link parseDateFromDialect} — change one, change both. */ function formatDateForDialect(date: Date, dialect: Dialect): Date | string { @@ -56,7 +73,51 @@ function formatDateForDialect(date: Date, dialect: Dialect): Date | string { } - return date; + if (dialect === 'mssql') { + + return date; + + } + + return date.toISOString().replace('T', ' ').replace('Z', ''); + +} + +/** + * Read a lock timestamp back, undoing whatever frame the driver applied. + * + * Mirror of {@link formatDateForDialect}. sqlite hands back the ISO-8601 UTC + * string it was given and tedious hands back a Date already parsed as UTC, so + * both need no correction. The postgres and mysql drivers parse a naive column + * in the *client's* local zone, producing a Date whose wall clock is right but + * whose instant is off by the local offset — re-read that wall clock as the + * UTC it actually is. + */ +function parseDateFromDialect(value: Date | string, dialect: Dialect): Date { + + if (dialect === 'sqlite' || dialect === 'mssql') { + + return new Date(value); + + } + + if (value instanceof Date) { + + return new Date(Date.UTC( + value.getFullYear(), + value.getMonth(), + value.getDate(), + value.getHours(), + value.getMinutes(), + value.getSeconds(), + value.getMilliseconds(), + )); + + } + + const normalized = String(value).replace(' ', 'T'); + + return new Date(normalized.endsWith('Z') ? normalized : `${normalized}Z`); } @@ -429,8 +490,8 @@ class LockManager { return { lockedBy: row.locked_by, - lockedAt: new Date(row.locked_at), - expiresAt: new Date(row.expires_at), + lockedAt: parseDateFromDialect(row.locked_at, dialect), + expiresAt: parseDateFromDialect(row.expires_at, dialect), reason: row.reason || undefined, }; diff --git a/src/core/version/schema/migrations/v1.ts b/src/core/version/schema/migrations/v1.ts index c3fc6cdb..e4281128 100644 --- a/src/core/version/schema/migrations/v1.ts +++ b/src/core/version/schema/migrations/v1.ts @@ -42,6 +42,16 @@ function addIdColumn( * * MSSQL's 'timestamp' is a binary rowversion counter, not a datetime. * Use 'datetime2' for MSSQL via raw SQL, 'timestamp' for all others. + * + * IMPORTANT: every type here is *naive* — it stores a wall clock with no + * offset. Any code writing these columns must serialize UTC and parse back as + * UTC, or two clients in different timezones will disagree about what an + * instant means. The postgres and mysql drivers bind and parse a JS `Date` + * using the client's local offset, so passing a `Date` straight through is + * what breaks; see `formatDateForDialect`/`parseDateFromDialect` in + * `core/lock/manager.ts` for the dialect-by-dialect treatment. This applies + * equally to `change.executed_at`, `executions`, `identities` and `vault`, + * which share these columns. */ function timestampType(dialect: Dialect) { diff --git a/tests/core/lock/timezone.test.ts b/tests/core/lock/timezone.test.ts new file mode 100644 index 00000000..47775d22 --- /dev/null +++ b/tests/core/lock/timezone.test.ts @@ -0,0 +1,285 @@ +/** + * Lock timezone tests. + * + * The lock table's `locked_at`/`expires_at` are naive datetime columns + * (`src/core/version/schema/migrations/v1.ts`) — they carry no offset. Expiry + * is therefore only correct if the value written and the value read are in the + * same frame of reference, and that frame must not be the client's local + * timezone. + * + * WHY these tests exist: the rest of the lock suite runs exclusively on + * in-memory SQLite, the one dialect where this cannot happen (dates go in as + * ISO-8601 UTC strings). On postgres and mysql a read-only `status()` from a + * timezone ahead of the holder's silently *deleted* a live lock, because + * `#cleanupExpired` compared two values that were never in the same frame. + * + * These assert intent — "a lock that is live is reported live, and is still + * there afterwards, no matter what timezone the reader sits in" — not the + * serialization mechanism, so they stay honest if the storage format changes. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'bun:test'; +import { attempt } from '@logosdx/utils'; +import { Kysely, SqliteDialect } from 'kysely'; + +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { createConnection } from '../../../src/core/connection/factory.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; +import { getLockManager, resetLockManager } from '../../../src/core/lock/index.js'; +import { getNoormTables, noormDb, type NoormDatabase } from '../../../src/core/shared/index.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { v2 } from '../../../src/core/version/schema/migrations/v2.js'; +import { skipIfNoContainer, TEST_CONNECTIONS } from '../../utils/db.js'; + +/** Five minutes, the production default lock TTL. */ +const TTL = 5 * 60 * 1000; + +/** + * Timezones chosen so the offset error is unmistakable: Tokyo is +9 ahead of + * UTC, far larger than any plausible TTL, so a frame-of-reference bug can + * never be mistaken for clock skew. + */ +const AHEAD = 'Asia/Tokyo'; +const BEHIND = 'UTC'; + +/** + * Restoring TZ must assign, never `delete`. + * + * Bun caches the resolved zone, and `delete process.env.TZ` leaves that cache + * pinned to the last value — every later assignment in the process is then + * silently ignored. Deleting here made the second dialect's suite read Tokyo + * dates while believing it had set UTC, which masked a real failure. + */ +const SYSTEM_TZ = process.env['TZ'] ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + +let configCounter = 0; + +/** + * Unique config name per test so rows never collide with other suites + * sharing the same tracking tables. + */ +function nextConfigName(): string { + + configCounter += 1; + + return `tz_test_${process.pid}_${Date.now()}_${configCounter}`; + +} + +/** + * Ensure the noorm lock table exists without disturbing tables other suites + * may already rely on. Probes first, migrates only when absent. + */ +async function ensureLockTable(db: Kysely, dialect: Dialect): Promise { + + const tables = getNoormTables(dialect); + const ndb = noormDb(db, dialect); + + const [, missing] = await attempt(() => + ndb.selectFrom(tables.lock).select('id').limit(1).executeTakeFirst(), + ); + + if (!missing) return; + + await attempt(() => v1.up(db as Kysely, dialect)); + await attempt(() => v2.up(db as Kysely, dialect)); + + const [, stillMissing] = await attempt(() => + ndb.selectFrom(tables.lock).select('id').limit(1).executeTakeFirst(), + ); + + if (stillMissing) { + + throw new Error(`Could not create the ${dialect} lock table: ${stillMissing.message}`); + + } + +} + +/** + * Count physical lock rows for a config. + * + * Asserting on the row — not just on what `status()` returns — is the point: + * the defect was a *read* path issuing a DELETE. + */ +async function countRows( + db: Kysely, + dialect: Dialect, + configName: string, +): Promise { + + const tables = getNoormTables(dialect); + const ndb = noormDb(db, dialect); + + const rows = await ndb + .selectFrom(tables.lock) + .select('config_name') + .where('config_name', '=', configName) + .execute(); + + return rows.length; + +} + +/** + * Every dialect noorm supports. SQLite and MSSQL already stored UTC; they are + * here as regression guards so a future "normalise everything" change cannot + * silently break the two that were correct. + */ +const DIALECTS: Dialect[] = ['postgres', 'mysql', 'mssql', 'sqlite']; + +for (const dialect of DIALECTS) { + + describe(`lock: timezone (${dialect})`, () => { + + let db: Kysely; + let destroy: (() => Promise) | null = null; + + beforeAll(async () => { + + if (dialect === 'sqlite') { + + db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + return; + + } + + // The real connection is the authority on availability: + // `skipIfNoContainer`'s probe uses a short timeout that a cold + // MSSQL/tedious handshake loses, so consulting it first produces + // false "container not running" failures. Fall back to it only to + // render the standard message when the connection genuinely fails. + const [conn, connErr] = await attempt(() => + createConnection(TEST_CONNECTIONS[dialect], `__tz_${dialect}__`), + ); + + if (connErr) { + + await skipIfNoContainer(dialect); + + throw connErr; + + } + + db = conn.db as Kysely; + destroy = conn.destroy; + + await ensureLockTable(db, dialect); + + // 60s: a cold MSSQL handshake (master preflight, then target) + // routinely exceeds bun's default 5s hook budget. + + }, 60_000); + + afterEach(() => { + + // A leaked TZ would silently corrupt every later test in the process. + process.env['TZ'] = SYSTEM_TZ; + + resetLockManager(); + + }); + + afterAll(async () => { + + if (destroy) await destroy(); + else if (db) await db.destroy(); + + }); + + it('should keep a live lock when status is read from a timezone ahead of the holder', async () => { + + const manager = getLockManager(); + const configName = nextConfigName(); + + process.env['TZ'] = BEHIND; + await manager.acquire(db, configName, 'alice', { dialect, timeout: TTL }); + + expect(await countRows(db, dialect, configName)).toBe(1); + + process.env['TZ'] = AHEAD; + const status = await manager.status(db, configName, dialect); + + expect(status.isLocked).toBe(true); + expect(status.lock?.lockedBy).toBe('alice'); + + // The read must not have destroyed the lock it was asked to report on. + expect(await countRows(db, dialect, configName)).toBe(1); + + process.env['TZ'] = BEHIND; + await manager.forceRelease(db, configName, dialect); + + }); + + it('should not inflate the TTL when the holder acquired from a timezone ahead', async () => { + + const manager = getLockManager(); + const configName = nextConfigName(); + + process.env['TZ'] = AHEAD; + await manager.acquire(db, configName, 'bob', { dialect, timeout: TTL }); + + process.env['TZ'] = BEHIND; + const status = await manager.status(db, configName, dialect); + + expect(status.isLocked).toBe(true); + + // A 5-minute lock must read as ~5 minutes, not 5 minutes + the offset. + // Slack covers mysql's second-granularity `timestamp` column. + const remaining = status.lock!.expiresAt.getTime() - Date.now(); + + expect(remaining).toBeGreaterThan(0); + expect(remaining).toBeLessThanOrEqual(TTL + 5_000); + + await manager.forceRelease(db, configName, dialect); + + }); + + it('should let a holder in another timezone still be blocked by the lock', async () => { + + const manager = getLockManager(); + const configName = nextConfigName(); + + process.env['TZ'] = BEHIND; + await manager.acquire(db, configName, 'alice', { dialect, timeout: TTL }); + + process.env['TZ'] = AHEAD; + const [, err] = await attempt(() => + manager.acquire(db, configName, 'bob', { dialect, timeout: TTL }), + ); + + expect(err).toBeInstanceOf(Error); + expect(err?.name).toBe('LockAcquireError'); + + process.env['TZ'] = BEHIND; + await manager.forceRelease(db, configName, dialect); + + }); + + it('should still expire a genuinely expired lock read from another timezone', async () => { + + const manager = getLockManager(); + const configName = nextConfigName(); + + // Negative TTL — already expired the moment it is written. + process.env['TZ'] = BEHIND; + await manager.acquire(db, configName, 'alice', { dialect, timeout: -60_000 }); + + process.env['TZ'] = AHEAD; + const status = await manager.status(db, configName, dialect); + + // Fixing the read frame must not turn expiry into a no-op. + expect(status.isLocked).toBe(false); + expect(await countRows(db, dialect, configName)).toBe(0); + + }); + + }); + +} From 3a8194ff234ee80715cc57409073e863412f13ca Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:58:06 -0400 Subject: [PATCH 059/105] test(policy): check classifier verdicts against real databases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../classifier-differential.test.ts | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 tests/integration/sql-terminal/classifier-differential.test.ts diff --git a/tests/integration/sql-terminal/classifier-differential.test.ts b/tests/integration/sql-terminal/classifier-differential.test.ts new file mode 100644 index 00000000..691bd3dc --- /dev/null +++ b/tests/integration/sql-terminal/classifier-differential.test.ts @@ -0,0 +1,226 @@ +/** + * Integration: differential test for the SQL statement classifier. + * + * `classifyStatements` is ~800 lines of hand-rolled parsing whose entire job + * is to predict what a database will do, and it was validated exclusively + * against hand-written expectations. Every classifier bypass found in the v1 + * audit — `EXPLAIN (ANALYZE) DELETE`, `EXPLAIN ANALYZE CREATE TABLE ... AS`, + * the MSSQL bracket-identifier statement split — came from *running* the + * statement and diffing the database, and none from reading the code. + * + * So this asks the database instead of a table of expectations. For each + * probe it records the observable state, executes the statement ungated, + * records the state again, and asserts the one invariant the access gate + * actually depends on: + * + * if the statement changed anything, it did not classify `read`. + * + * Deliberately one-sided. Over-classification is a usability problem; + * under-classification is a `viewer` role deleting production rows, which is + * what shipped. A probe the server rejects is reported inert and excluded + * rather than failed — a dialect refusing a form is a different fact from + * the classifier misreading one. + * + * Two guards keep the invariant from passing vacuously: at least one probe + * per dialect must have really changed the database, and the plain-SELECT + * baseline must still classify `read` (otherwise a classifier that answered + * `ddl` to everything would satisfy the invariant while being useless). + * + * `executeRawSqlUnchecked` is used on purpose: gating here would test the + * gate, and the gate is not what was broken. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { attempt } from '@logosdx/utils'; +import { sql } from 'kysely'; +import type { Kysely } from 'kysely'; + +import { classifyStatements } from '../../../src/core/policy/index.js'; +import { executeRawSqlUnchecked } from '../../../src/core/sql-terminal/executor.js'; +import { createTestConnection, skipIfNoContainer } from '../../utils/db.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; + +/** Table the probes read and write; recreated before every probe. */ +const PROBE_TABLE = 'clsdiff_rows'; + +/** Table no probe may create unless it classified above `read`. */ +const CREATED_TABLE = 'clsdiff_created'; + +/** The baseline read, asserted separately so the invariant cannot pass vacuously. */ +const BASELINE_READ = `SELECT id FROM ${PROBE_TABLE}`; + +interface Probe { + /** Statement handed to both the classifier and the database. */ + sql: string; + /** Why this form is worth executing rather than merely asserting. */ + why: string; +} + +/** Probes every dialect gets. */ +const SHARED_PROBES: Probe[] = [ + { sql: `DELETE FROM ${PROBE_TABLE}`, why: 'an obvious write must be caught' }, + { sql: `${BASELINE_READ}; DELETE FROM ${PROBE_TABLE}`, why: 'a second statement must not hide behind the first' }, + { sql: `SELECT 'a;b' AS x FROM ${PROBE_TABLE}`, why: 'a semicolon in a string literal is not a statement boundary' }, +]; + +/** + * The EXPLAIN family, where the gate was walked past. Postgres executes the + * plan under ANALYZE; other dialects mostly reject these forms and are then + * excluded as inert. + */ +const EXPLAIN_PROBES: Probe[] = [ + { sql: `EXPLAIN ANALYZE DELETE FROM ${PROBE_TABLE}`, why: 'the bare ANALYZE form the CST parser accepts' }, + { sql: `EXPLAIN (ANALYZE) DELETE FROM ${PROBE_TABLE}`, why: 'the parenthesised form the postgres docs lead with, which the parser rejects' }, + { sql: `EXPLAIN (ANALYZE, BUFFERS) DELETE FROM ${PROBE_TABLE}`, why: 'multiple options must not change the verdict' }, + { sql: `EXPLAIN ANALYZE VERBOSE DELETE FROM ${PROBE_TABLE}`, why: 'stacked bare options must not change the verdict' }, + { sql: `explain (analyze) delete from ${PROBE_TABLE}`, why: 'SQL keywords are case-insensitive' }, + { sql: `EXPLAIN ANALYZE UPDATE ${PROBE_TABLE} SET id = id + 100`, why: 'UPDATE under EXPLAIN ANALYZE updates for real' }, + { sql: `EXPLAIN ANALYZE CREATE TABLE ${CREATED_TABLE} AS SELECT 1 AS a`, why: 'DDL under EXPLAIN ANALYZE creates for real' }, +]; + +const POSTGRES_PROBES: Probe[] = [ + { sql: `WITH t AS (DELETE FROM ${PROBE_TABLE} RETURNING id) SELECT * FROM t`, why: 'the CTE body deletes while the statement returns rows' }, + { sql: `SELECT id INTO ${CREATED_TABLE} FROM ${PROBE_TABLE}`, why: 'SELECT INTO creates a table' }, +]; + +const MSSQL_PROBES: Probe[] = [ + { sql: `SELECT 1 AS [a'b]; DELETE FROM ${PROBE_TABLE}`, why: 'an apostrophe in a bracket identifier must not swallow the next statement' }, + { sql: `SELECT id AS [a;b] FROM ${PROBE_TABLE}`, why: 'a semicolon in a bracket identifier is not a statement boundary' }, + { sql: `SELECT id INTO ${CREATED_TABLE} FROM ${PROBE_TABLE}`, why: 'SELECT INTO creates a table on mssql too' }, +]; + +const MYSQL_PROBES: Probe[] = [ + { sql: `SELECT 1 AS \`a'b\`; DELETE FROM ${PROBE_TABLE}`, why: 'an apostrophe in a backtick identifier must not swallow the next statement' }, +]; + +/** Observable state a probe could have changed. */ +interface Snapshot { + rows: number; + createdTableExists: boolean; +} + +/** Result of running one probe against a live database. */ +interface Observation { + probe: Probe; + classified: string; + /** False when the server rejected the statement — the form is unsupported here. */ + executed: boolean; + changed: boolean; +} + +/** Drops and recreates the probe fixtures so every probe starts identical. */ +async function reset(db: Kysely, dialect: Dialect): Promise { + + await attempt(() => sql.raw(`DROP TABLE ${CREATED_TABLE}`).execute(db)); + await attempt(() => sql.raw(`DROP TABLE ${PROBE_TABLE}`).execute(db)); + + const idType = dialect === 'mssql' ? 'INT' : 'INTEGER'; + + await sql.raw(`CREATE TABLE ${PROBE_TABLE} (id ${idType} NOT NULL)`).execute(db); + await sql.raw(`INSERT INTO ${PROBE_TABLE} (id) VALUES (1), (2), (3)`).execute(db); + +} + +/** Reads the observable state the probes can move. */ +async function snapshot(db: Kysely, dialect: Dialect): Promise { + + const [counted] = await attempt(() => sql.raw(`SELECT COUNT(*) AS n FROM ${PROBE_TABLE}`).execute(db)); + const probed = await attempt(() => sql.raw( + `SELECT ${dialect === 'mssql' ? 'TOP 1 1 AS one' : '1 AS one'} FROM ${CREATED_TABLE}`, + ).execute(db)); + + return { + rows: Number((counted?.rows[0] as { n?: unknown } | undefined)?.n ?? -1), + createdTableExists: probed[1] === undefined, + }; + +} + +/** Classifies a probe, executes it for real, and reports what moved. */ +async function observe(db: Kysely, dialect: Dialect, probe: Probe): Promise { + + await reset(db, dialect); + + const before = await snapshot(db, dialect); + const classified = classifyStatements(probe.sql, dialect); + const result = await executeRawSqlUnchecked(db, probe.sql, 'classifier-differential'); + const after = await snapshot(db, dialect); + + return { + probe, + classified, + executed: result.success, + changed: before.rows !== after.rows || before.createdTableExists !== after.createdTableExists, + }; + +} + +/** Registers the differential suite for one dialect. */ +function differentialSuite(dialect: Dialect, probes: Probe[]): void { + + describe(`integration: ${dialect} classifier differential`, () => { + + let db: Kysely; + let destroy: () => Promise; + + beforeAll(async () => { + + await skipIfNoContainer(dialect); + + const conn = await createTestConnection(dialect); + + db = conn.db; + destroy = conn.destroy; + + }); + + afterAll(async () => { + + if (!db) return; + + await attempt(() => sql.raw(`DROP TABLE ${CREATED_TABLE}`).execute(db)); + await attempt(() => sql.raw(`DROP TABLE ${PROBE_TABLE}`).execute(db)); + await destroy(); + + }); + + it('should never classify as read a statement that changed the database', async () => { + + const observations: Observation[] = []; + + for (const probe of probes) { + + observations.push(await observe(db, dialect, probe)); + + } + + const effective = observations.filter((o) => o.executed && o.changed); + + // Guard: without this, a dialect that rejected every probe would pass silently. + expect(effective.length).toBeGreaterThan(0); + + const underClassified = effective + .filter((o) => o.classified === 'read') + .map((o) => `${o.probe.sql} (${o.probe.why})`); + + expect(underClassified).toEqual([]); + + }); + + it('should still classify a plain SELECT as read', async () => { + + const observation = await observe(db, dialect, { sql: BASELINE_READ, why: 'baseline' }); + + expect(observation.executed).toBe(true); + expect(observation.changed).toBe(false); + expect(observation.classified).toBe('read'); + + }); + + }); + +} + +differentialSuite('sqlite', [...SHARED_PROBES, ...EXPLAIN_PROBES]); +differentialSuite('postgres', [...SHARED_PROBES, ...EXPLAIN_PROBES, ...POSTGRES_PROBES]); +differentialSuite('mysql', [...SHARED_PROBES, ...EXPLAIN_PROBES, ...MYSQL_PROBES]); +differentialSuite('mssql', [...SHARED_PROBES, ...MSSQL_PROBES]); From be028abbaa493241337c8dd86ddaa1bddd696fba Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:58:38 -0400 Subject: [PATCH 060/105] 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. --- src/cli/run/build.ts | 7 +- src/core/runner/runner.ts | 10 ++- src/core/runner/types.ts | 9 ++ tests/core/runner/dry-run-output.test.ts | 101 +++++++++++++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 tests/core/runner/dry-run-output.test.ts diff --git a/src/cli/run/build.ts b/src/cli/run/build.ts index 0b150937..e8a38951 100644 --- a/src/cli/run/build.ts +++ b/src/cli/run/build.ts @@ -30,7 +30,11 @@ const buildCommand = defineCommand({ if (dryRun) { + // Named explicitly because the rendered files carry + // every secret the templates resolved, and `tmp/` is + // not gitignored by a project noorm scaffolds. logger.info('Dry run: rendering files to tmp/ (no DB writes)'); + logger.warn('Rendered files contain resolved secrets in plaintext — written owner-only, not gitignored.'); } @@ -62,7 +66,8 @@ const buildCommand = defineCommand({ } else if (dryRun) { - logger.info(`${file.filepath} (${file.status}, dry-run)`); + const destination = file.outputPath ? ` -> ${file.outputPath}` : ''; + logger.info(`${file.filepath} (${file.status}, dry-run)${destination}`); } diff --git a/src/core/runner/runner.ts b/src/core/runner/runner.ts index 0f0cc8f5..5c350acc 100644 --- a/src/core/runner/runner.ts +++ b/src/core/runner/runner.ts @@ -1358,6 +1358,7 @@ async function executeDryRun(context: RunContext, files: string[]): Promise { + + let db: Kysely; + let tempDir: string; + let sqlDir: string; + + function context(): RunContext { + + return { + db, + configName: 'test', + identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, + projectRoot: tempDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', + dialect: 'sqlite', + secrets: { API_TOKEN: 'sk-live-not-a-real-token' }, + }; + + } + + beforeEach(async () => { + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-dryrun-')); + sqlDir = join(tempDir, 'sql'); + await mkdir(sqlDir, { recursive: true }); + + db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + await writeFile( + join(sqlDir, '001_secret.sql.tmpl'), + 'INSERT INTO t (token) VALUES ({%~ $.quote($.secrets.API_TOKEN) %});', + 'utf-8', + ); + + }); + + afterEach(async () => { + + await db.destroy(); + await rm(tempDir, { recursive: true, force: true }); + + }); + + it('should write rendered output owner-readable only', async () => { + + const result = await runBuild(context(), sqlDir, { dryRun: true }); + + expect(result.status).toBe('success'); + + const outputPath = join(tempDir, 'tmp', 'sql', '001_secret.sql'); + const fileStats = await stat(outputPath); + const dirStats = await stat(join(tempDir, 'tmp')); + + // The file holds a resolved secret in plaintext; group and other + // have no business reading it, nor listing the directory it sits in. + expect(fileStats.mode & 0o777).toBe(0o600); + expect(dirStats.mode & 0o077).toBe(0); + + }); + + it('should report where each file was written', async () => { + + const result = await runBuild(context(), sqlDir, { dryRun: true }); + + // Without this a `--json` consumer cannot tell that plaintext was + // written at all, let alone clean it up afterwards. + expect(result.files[0]?.outputPath).toBe(join(tempDir, 'tmp', 'sql', '001_secret.sql')); + + }); + +}); From 6496a6b1dab82fab9d3bf82a323b39227e37c297 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:00:15 -0400 Subject: [PATCH 061/105] test(policy): assert which classification path each test exercises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/core/policy/classify.test.ts | 102 ++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 25 deletions(-) diff --git a/tests/core/policy/classify.test.ts b/tests/core/policy/classify.test.ts index 679928c8..4c674cd2 100644 --- a/tests/core/policy/classify.test.ts +++ b/tests/core/policy/classify.test.ts @@ -1,8 +1,48 @@ /** * Access policy: classifyStatements. + * + * Tests whose name claims a classification path call `classifyVia`, which + * asserts the claim. They used to just call `classifyStatements` and trust + * the name — so a CST/fallback divergence, which is the shape of every + * classifier bypass the v1 audit found, could not fail any of them. The + * adversarial input set lives in `classify-corpus.test.ts`. */ import { describe, it, expect } from 'bun:test'; +import { attemptSync } from '@logosdx/utils'; +import { parse } from 'sql-parser-cst'; + import { classifyStatements } from '../../../src/core/policy/index.js'; +import type { SqlClass } from '../../../src/core/policy/index.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; + +/** sql-parser-cst grammar per noorm dialect — mirrors `DIALECT_MAP` in classify.ts. */ +const CST_DIALECT: Record = { + sqlite: 'sqlite', + postgres: 'postgresql', + mysql: 'mysql', + mssql: 'postgresql', +}; + +/** + * Classifies `sql`, first asserting it takes the `expectedPath`. + * + * @param expectedPath - `cst` when the parser accepts the input, `fallback` + * when it throws and keyword analysis takes over. + */ +function classifyVia(expectedPath: 'cst' | 'fallback', sql: string, dialect: Dialect): SqlClass { + + const [, err] = attemptSync(() => parse(sql.trim(), { + dialect: CST_DIALECT[dialect], + includeComments: false, + includeSpaces: false, + includeNewlines: false, + })); + + expect(err ? 'fallback' : 'cst').toBe(expectedPath); + + return classifyStatements(sql, dialect); + +} describe('policy: classifyStatements', () => { @@ -34,13 +74,13 @@ describe('policy: classifyStatements', () => { it('should classify DESCRIBE as read on mssql (keyword fallback)', () => { - expect(classifyStatements('DESCRIBE users', 'mssql')).toBe('read'); + expect(classifyVia('fallback', 'DESCRIBE users', 'mssql')).toBe('read'); }); it('should classify DESC as read on mssql (keyword fallback)', () => { - expect(classifyStatements('DESC users', 'mssql')).toBe('read'); + expect(classifyVia('fallback', 'DESC users', 'mssql')).toBe('read'); }); @@ -123,9 +163,19 @@ describe('policy: classifyStatements', () => { }); - it('should classify INSERT as write on mssql (keyword fallback)', () => { + // Named "(keyword fallback)" before `classifyVia` existed to check the + // claim: plain INSERT is valid postgres, so on mssql it takes the CST + // path like everywhere else. The OUTPUT clause below is the genuinely + // mssql-only form that reaches the fallback. + it('should classify INSERT as write on mssql (parses via CST)', () => { - expect(classifyStatements("INSERT INTO users (name) VALUES ('alice')", 'mssql')).toBe('write'); + expect(classifyVia('cst', "INSERT INTO users (name) VALUES ('alice')", 'mssql')).toBe('write'); + + }); + + it('should classify INSERT ... OUTPUT as write on mssql (keyword fallback)', () => { + + expect(classifyVia('fallback', "INSERT INTO users (name) OUTPUT INSERTED.id VALUES ('alice')", 'mssql')).toBe('write'); }); @@ -177,19 +227,21 @@ describe('policy: classifyStatements', () => { it('should classify CALL as ddl (postgres, parses via CST)', () => { - expect(classifyStatements('CALL my_proc(1, 2)', 'postgres')).toBe('ddl'); + expect(classifyVia('cst', 'CALL my_proc(1, 2)', 'postgres')).toBe('ddl'); }); it('should classify EXEC as ddl on mssql (keyword fallback)', () => { - expect(classifyStatements('EXEC sp_who2', 'mssql')).toBe('ddl'); + expect(classifyVia('fallback', 'EXEC sp_who2', 'mssql')).toBe('ddl'); }); - it('should classify EXECUTE as ddl on mssql (keyword fallback)', () => { + // `EXECUTE` is postgres syntax too (for prepared statements), so this + // one parses; only the `EXEC` abbreviation above reaches the fallback. + it('should classify EXECUTE as ddl on mssql (parses via CST)', () => { - expect(classifyStatements('EXECUTE sp_help', 'mssql')).toBe('ddl'); + expect(classifyVia('cst', 'EXECUTE sp_help', 'mssql')).toBe('ddl'); }); @@ -210,19 +262,19 @@ describe('policy: classifyStatements', () => { it('should classify SELECT ... INTO new_table as ddl (postgres, CST path)', () => { - expect(classifyStatements('SELECT * INTO new_table FROM users', 'postgres')).toBe('ddl'); + expect(classifyVia('cst', 'SELECT * INTO new_table FROM users', 'postgres')).toBe('ddl'); }); it('should classify SELECT ... INTO #tmp as ddl on mssql (keyword fallback)', () => { - expect(classifyStatements('SELECT * INTO #tmp FROM users', 'mssql')).toBe('ddl'); + expect(classifyVia('fallback', 'SELECT * INTO #tmp FROM users', 'mssql')).toBe('ddl'); }); it('should classify SELECT ... INTO OUTFILE as ddl on mysql (CST path)', () => { - expect(classifyStatements("SELECT * FROM users INTO OUTFILE '/tmp/x'", 'mysql')).toBe('ddl'); + expect(classifyVia('cst', "SELECT * FROM users INTO OUTFILE '/tmp/x'", 'mysql')).toBe('ddl'); }); @@ -254,13 +306,13 @@ describe('policy: classifyStatements', () => { it('should classify WITH ... SELECT as read (CST, postgres)', () => { - expect(classifyStatements('WITH cte AS (SELECT 1) SELECT * FROM cte', 'postgres')).toBe('read'); + expect(classifyVia('cst', 'WITH cte AS (SELECT 1) SELECT * FROM cte', 'postgres')).toBe('read'); }); it('should classify WITH ... INSERT as write (CST, postgres)', () => { - expect(classifyStatements( + expect(classifyVia('cst', 'WITH cte AS (SELECT 1) INSERT INTO t SELECT * FROM cte', 'postgres', )).toBe('write'); @@ -269,7 +321,7 @@ describe('policy: classifyStatements', () => { it('should classify WITH ... SELECT as read via keyword fallback (mssql-only syntax)', () => { - expect(classifyStatements( + expect(classifyVia('fallback', 'WITH cte AS (SELECT TOP 1 * FROM t) SELECT * FROM cte', 'mssql', )).toBe('read'); @@ -278,7 +330,7 @@ describe('policy: classifyStatements', () => { it('should classify WITH ... DELETE as write via keyword fallback (mssql-only syntax)', () => { - expect(classifyStatements( + expect(classifyVia('fallback', 'WITH cte AS (SELECT TOP 1 * FROM t) DELETE FROM cte', 'mssql', )).toBe('write'); @@ -291,7 +343,7 @@ describe('policy: classifyStatements', () => { it('should classify WITH t AS (DELETE ... RETURNING ...) SELECT as write (CST, postgres)', () => { - expect(classifyStatements( + expect(classifyVia('cst', 'WITH t AS (DELETE FROM users WHERE id=1 RETURNING id) SELECT * FROM t', 'postgres', )).toBe('write'); @@ -300,7 +352,7 @@ describe('policy: classifyStatements', () => { it('should classify WITH t AS (INSERT ... RETURNING ...) SELECT as write (CST, postgres)', () => { - expect(classifyStatements( + expect(classifyVia('cst', "WITH t AS (INSERT INTO users(name) VALUES ('x') RETURNING id) SELECT * FROM t", 'postgres', )).toBe('write'); @@ -309,7 +361,7 @@ describe('policy: classifyStatements', () => { it('should classify WITH t AS (UPDATE ... RETURNING ...) SELECT as write (CST, postgres)', () => { - expect(classifyStatements( + expect(classifyVia('cst', "WITH t AS (UPDATE users SET name='y' WHERE id=1 RETURNING id) SELECT * FROM t", 'postgres', )).toBe('write'); @@ -318,7 +370,7 @@ describe('policy: classifyStatements', () => { it('should classify WITH t AS (DELETE ...) SELECT as write via keyword fallback (mssql TOP forces fallback)', () => { - expect(classifyStatements( + expect(classifyVia('fallback', 'WITH t AS (DELETE TOP (1) FROM users) SELECT * FROM t', 'mssql', )).toBe('write'); @@ -327,13 +379,13 @@ describe('policy: classifyStatements', () => { it('should still classify a pure WITH t AS (SELECT ...) SELECT as read (CST, postgres — no false positive)', () => { - expect(classifyStatements('WITH t AS (SELECT 1) SELECT * FROM t', 'postgres')).toBe('read'); + expect(classifyVia('cst', 'WITH t AS (SELECT 1) SELECT * FROM t', 'postgres')).toBe('read'); }); it('should still classify a pure WITH t AS (SELECT ...) SELECT as read via keyword fallback (mssql — no false positive)', () => { - expect(classifyStatements( + expect(classifyVia('fallback', 'WITH t AS (SELECT TOP 1 * FROM t) SELECT * FROM t', 'mssql', )).toBe('read'); @@ -346,13 +398,13 @@ describe('policy: classifyStatements', () => { it('should classify SELECT pg_terminate_backend(...) as write (CST, postgres)', () => { - expect(classifyStatements('SELECT pg_terminate_backend(123)', 'postgres')).toBe('write'); + expect(classifyVia('cst', 'SELECT pg_terminate_backend(123)', 'postgres')).toBe('write'); }); it('should classify SELECT pg_terminate_backend(...) as write via keyword fallback (mssql TOP forces fallback)', () => { - expect(classifyStatements('SELECT TOP 1 pg_terminate_backend(123)', 'mssql')).toBe('write'); + expect(classifyVia('fallback', 'SELECT TOP 1 pg_terminate_backend(123)', 'mssql')).toBe('write'); }); @@ -425,7 +477,7 @@ describe('policy: classifyStatements', () => { it('should classify a CTE with a SELECT final statement containing a subquery as read via keyword fallback (mssql)', () => { - expect(classifyStatements( + expect(classifyVia('fallback', 'WITH t AS (SELECT TOP 1 * FROM x) SELECT * FROM users WHERE id IN (SELECT id FROM t)', 'mssql', )).toBe('read'); @@ -434,7 +486,7 @@ describe('policy: classifyStatements', () => { it('should classify a CTE with a DELETE final statement containing a subquery as write via keyword fallback (mssql), not over-denied to ddl', () => { - expect(classifyStatements( + expect(classifyVia('fallback', 'WITH t AS (SELECT TOP 1 * FROM x) DELETE FROM users WHERE id IN (SELECT id FROM t)', 'mssql', )).toBe('write'); From 167ae1a8bed89ddb0465081c75041cbc1da58c06 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:00:52 -0400 Subject: [PATCH 062/105] 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. --- src/core/state/encryption/crypto.ts | 53 +++++++++++ src/core/state/types.ts | 11 +++ src/core/version/state/migrations/v1.ts | 5 + tests/core/state/encryption/crypto.test.ts | 104 +++++++++++++++++++++ tests/core/version/state.test.ts | 12 +++ 5 files changed, 185 insertions(+) diff --git a/src/core/state/encryption/crypto.ts b/src/core/state/encryption/crypto.ts index 43db2eaa..0c187647 100644 --- a/src/core/state/encryption/crypto.ts +++ b/src/core/state/encryption/crypto.ts @@ -9,11 +9,49 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; import type { EncryptedPayload } from '../types.js'; import { deriveStateKey } from '../../identity/crypto.js'; +import { isValidKeyHex } from '../../identity/storage.js'; const ALGORITHM = 'aes-256-gcm'; + +/** + * Non-standard: NIST SP 800-38D specifies a 12-byte GCM nonce, which uses + * the direct J0 construction. 16 bytes forces the GHASH-based one instead. + * It is not a weakness — a random 16-byte nonce collides less often than a + * random 12-byte one — but it is a deliberate deviation, and the `kdf` + * field below is what makes it changeable without bricking existing files. + */ const IV_LENGTH = 16; + const AUTH_TAG_LENGTH = 16; +/** Key derivation stamped into every payload this build writes. */ +const KDF = 'hkdf-sha256'; + +/** + * Reject key material that cannot be what it claims to be. + * + * `Buffer.from(str, 'hex')` never throws: it stops at the first invalid + * pair and truncates odd lengths. Every non-hex string therefore collapsed + * to the same zero-length HKDF input, deriving an AES key that is a + * published constant — state encrypted under it is readable by anyone. + * The real fix belongs in `deriveStateKey`; this is the consuming side + * refusing to be the one that reaches it. + */ +function assertUsableKey(privateKey: string): void { + + if (!isValidKeyHex(privateKey)) { + + // Deliberately says nothing about the value itself — this message + // reaches logs and --json output. + throw new Error( + 'Invalid private key: expected a hex-encoded X25519 key. ' + + 'The key file may be corrupted or partially written. Re-run: noorm init', + ); + + } + +} + /** * Encrypt a string using the private key. * @@ -33,6 +71,8 @@ const AUTH_TAG_LENGTH = 16; */ export function encrypt(plaintext: string, privateKey: string): EncryptedPayload { + assertUsableKey(privateKey); + const key = deriveStateKey(privateKey); const iv = randomBytes(IV_LENGTH); @@ -46,6 +86,7 @@ export function encrypt(plaintext: string, privateKey: string): EncryptedPayload return { algorithm: ALGORITHM, + kdf: KDF, iv: iv.toString('base64'), authTag: authTag.toString('base64'), ciphertext: ciphertext.toString('base64'), @@ -71,6 +112,18 @@ export function decrypt(payload: EncryptedPayload, privateKey: string): string { } + // Absent means this payload predates the field, which can only be + // hkdf-sha256 — the sole derivation ever shipped. + if (payload.kdf !== undefined && payload.kdf !== KDF) { + + throw new Error( + `Unsupported key derivation: ${payload.kdf}. This state file was written by a newer noorm.`, + ); + + } + + assertUsableKey(privateKey); + const key = deriveStateKey(privateKey); const iv = Buffer.from(payload.iv, 'base64'); const authTag = Buffer.from(payload.authTag, 'base64'); diff --git a/src/core/state/types.ts b/src/core/state/types.ts index 87dc5972..79181d3c 100644 --- a/src/core/state/types.ts +++ b/src/core/state/types.ts @@ -56,6 +56,17 @@ export interface EncryptedPayload { /** Encryption algorithm (expected: AES-256-GCM, validated at runtime) */ algorithm: string; + /** + * Key derivation used to turn the private key into the AES key. + * + * Absent on payloads written before this field existed, which are + * `hkdf-sha256` by definition. Recording it is what makes the + * derivation changeable later: without it, a build that changed the + * derivation would report every existing state file as "wrong key or + * corrupted", which is both wrong and unactionable. + */ + kdf?: string; + /** Initialization vector (base64) */ iv: string; diff --git a/src/core/version/state/migrations/v1.ts b/src/core/version/state/migrations/v1.ts index 3eada495..6c817f71 100644 --- a/src/core/version/state/migrations/v1.ts +++ b/src/core/version/state/migrations/v1.ts @@ -26,7 +26,12 @@ export const v1: StateMigration = { up(state: Record): Record { + // Spread rather than rebuild: this migration runs on any + // pre-versioned state, including one written by a newer build, and + // an allowlist rebuild would silently destroy whatever that build + // had added at the top level. return { + ...state, schemaVersion: 1, identity: state['identity'] ?? null, knownUsers: state['knownUsers'] ?? {}, diff --git a/tests/core/state/encryption/crypto.test.ts b/tests/core/state/encryption/crypto.test.ts index dfc5c9fa..30d5aefe 100644 --- a/tests/core/state/encryption/crypto.test.ts +++ b/tests/core/state/encryption/crypto.test.ts @@ -5,6 +5,7 @@ * Validates security properties: random IVs, auth tags, tampering detection. */ import { describe, it, expect } from 'bun:test'; +import { attemptSync } from '@logosdx/utils'; import { generateKeyPair } from '../../../../src/core/identity/crypto.js'; import { encrypt, decrypt } from '../../../../src/core/state/encryption/crypto.js'; import type { EncryptedPayload } from '../../../../src/core/state/types.js'; @@ -222,6 +223,31 @@ describe('encryption: crypto', () => { }); + it('should never reuse a nonce across encryptions', () => { + + // The length above is a format detail; this is the property + // that actually matters. A repeated GCM nonce under one key + // makes the keystream trivially recoverable, and state.enc is + // re-encrypted from scratch on every mutation. + const keypair = generateKeyPair(); + const plaintext = 'identical every time'; + + const ivs = new Set(); + const ciphertexts = new Set(); + + for (let i = 0; i < 2_000; i++) { + + const payload = encrypt(plaintext, keypair.privateKey); + ivs.add(payload.iv); + ciphertexts.add(payload.ciphertext); + + } + + expect(ivs.size).toBe(2_000); + expect(ciphertexts.size).toBe(2_000); + + }); + it('should use 16-byte auth tag', () => { const keypair = generateKeyPair(); @@ -287,4 +313,82 @@ describe('encryption: crypto', () => { }); + describe('malformed key material', () => { + + // `Buffer.from(str, 'hex')` never throws -- it stops at the first + // invalid pair. Every non-hex string therefore collapsed to a + // zero-length HKDF input, and the resulting AES key is a constant + // anyone can compute from the source. State written under it is + // readable by a third party holding no key material at all. + const malformed = [ + ['empty', ''], + ['non-hex', 'not-hex-at-all'], + ['non-hex pair', 'zz'], + ['odd length', 'abc'], + ['truncated key', '302e020100'], + ['hex but wrong length', 'ab'.repeat(20)], + ] as const; + + for (const [label, key] of malformed) { + + it(`should refuse to encrypt with a ${label} key`, () => { + + expect(() => encrypt('secret', key)).toThrow(/[Ii]nvalid private key/); + + }); + + it(`should refuse to decrypt with a ${label} key`, () => { + + const keypair = generateKeyPair(); + const payload = encrypt('secret', keypair.privateKey); + + expect(() => decrypt(payload, key)).toThrow(/[Ii]nvalid private key/); + + }); + + } + + it('should not leak the rejected key into the error message', () => { + + const [, err] = attemptSync(() => encrypt('secret', 'deadbeefdeadbeef')); + + expect((err as Error).message).not.toContain('deadbeef'); + + }); + + }); + + describe('payload format version', () => { + + it('should stamp the key derivation it used', () => { + + const keypair = generateKeyPair(); + + expect(encrypt('secret', keypair.privateKey).kdf).toBe('hkdf-sha256'); + + }); + + it('should still read a payload written before the field existed', () => { + + const keypair = generateKeyPair(); + const { kdf: _kdf, ...legacy } = encrypt('secret', keypair.privateKey); + + expect(decrypt(legacy, keypair.privateKey)).toBe('secret'); + + }); + + it('should refuse a payload claiming a derivation it cannot perform', () => { + + // Without this the derivation can never be changed: an old + // build meeting a new payload would report "wrong key or + // corrupted file" and send the user hunting for the wrong bug. + const keypair = generateKeyPair(); + const payload = { ...encrypt('secret', keypair.privateKey), kdf: 'argon2id' }; + + expect(() => decrypt(payload, keypair.privateKey)).toThrow(/[Uu]nsupported key derivation/); + + }); + + }); + }); diff --git a/tests/core/version/state.test.ts b/tests/core/version/state.test.ts index 29f94cdb..b82a955e 100644 --- a/tests/core/version/state.test.ts +++ b/tests/core/version/state.test.ts @@ -172,6 +172,18 @@ describe('version: state', () => { }); + it('should preserve unknown top-level fields through the v1 baseline', () => { + + // v1 rebuilt the state object from a fixed field list, so any + // top-level field a newer build had added was destroyed the + // first time an older build opened the file -- and the result + // was persisted immediately. + const migrated = migrateState({ auditTrail: ['future-field'] }); + + expect(migrated['auditTrail']).toEqual(['future-field']); + + }); + it('should return same state if already current version', () => { const state = { schemaVersion: CURRENT_VERSIONS.state }; From cdf0b62b3f92cbcf3160809482bf181762428867 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:01:20 -0400 Subject: [PATCH 063/105] 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. --- src/cli/sql/history.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cli/sql/history.ts b/src/cli/sql/history.ts index acd2f6f4..f98c5a02 100644 --- a/src/cli/sql/history.ts +++ b/src/cli/sql/history.ts @@ -4,6 +4,12 @@ * Reads persisted history from `.noorm/state/history/` and displays * query text (truncated), timestamp, duration, and status. * No database connection required. + * + * Only the interactive SQL terminal records history. `sql query` does not, + * deliberately: it is the headless/CI path, and persisting query text plus + * every returned row to disk on a build agent is an exposure nobody asked + * for. So this command can never show a `sql query` invocation — the help + * text says so rather than leaving users to infer it from an empty list. */ import { defineCommand } from 'citty'; @@ -50,7 +56,7 @@ function formatTimestamp(date: Date): string { const historyCommand = defineCommand({ meta: { name: 'history', - description: 'Show SQL execution history', + description: 'Show SQL execution history (recorded by the interactive terminal, not by `sql query`)', }, args: { config: sharedArgs.config, From 5a8a5926863615e2ea58aaaeea9f42274583fa35 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:02:19 -0400 Subject: [PATCH 064/105] feat(runner): warn when a build.exclude entry matches nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/cli/run/build.ts | 15 +++ src/core/runner/types.ts | 10 ++ src/core/shared/files.ts | 44 ++++++- src/core/shared/index.ts | 2 +- src/sdk/namespaces/run.ts | 22 ++-- tests/cli/run/build-unmatched-exclude.test.ts | 109 ++++++++++++++++++ tests/core/shared/files.test.ts | 78 ++++++++++++- 7 files changed, 269 insertions(+), 11 deletions(-) create mode 100644 tests/cli/run/build-unmatched-exclude.test.ts diff --git a/src/cli/run/build.ts b/src/cli/run/build.ts index e8a38951..409e7adb 100644 --- a/src/cli/run/build.ts +++ b/src/cli/run/build.ts @@ -51,6 +51,21 @@ const buildCommand = defineCommand({ } + // Louder than the include case on purpose: an entry + // that excluded nothing means the files the author + // fenced off just ran against the target database. + if (res.unmatchedExclude?.length) { + + logger.warn( + `Ignored ${res.unmatchedExclude.length} build.exclude entr` + + `${res.unmatchedExclude.length === 1 ? 'y that matched' : 'ies that matched'} no files: ` + + res.unmatchedExclude.join(', ') + + ' — nothing was excluded, so those files ran.', + ); + logger.warn('Exclude paths are relative to paths.sql — use `10_seeds`, not `sql/10_seeds`.'); + + } + for (const file of res.files) { if (file.status === 'failed') { diff --git a/src/core/runner/types.ts b/src/core/runner/types.ts index 5759f6b4..2e96aa51 100644 --- a/src/core/runner/types.ts +++ b/src/core/runner/types.ts @@ -231,6 +231,16 @@ export interface BatchResult { * was wrong instead of reporting a silent, empty success. */ unmatchedInclude?: string[]; + + /** + * `build.exclude` / `rules[].exclude` entries that matched no file. + * + * The dangerous direction. A bad `include` runs nothing and announces + * itself; a bad `exclude` fences off nothing, so the files the author + * meant to hold back execute against the target and the build still + * reports success. + */ + unmatchedExclude?: string[]; } // ───────────────────────────────────────────────────────────── diff --git a/src/core/shared/files.ts b/src/core/shared/files.ts index b87ec1ff..35d63023 100644 --- a/src/core/shared/files.ts +++ b/src/core/shared/files.ts @@ -115,9 +115,51 @@ export function findUnmatchedIncludePatterns( include: string[], ): string[] { + return findUnmatchedPatterns(files, baseDir, include); + +} + +/** + * Returns the exclude patterns that match none of the discovered files. + * + * The mirror of `findUnmatchedIncludePatterns`, and the more dangerous half. + * A mistyped `include` over-restricts and announces itself — the build runs + * nothing. A mistyped `exclude` under-restricts: the archived migrations, + * the seed data, the destructive DDL you fenced off all execute against the + * target database, and the build reports plain success. + * + * An empty `exclude` means "fence nothing off", so it never reports an entry. + * + * @example + * ```typescript + * findUnmatchedExcludePatterns(files, '/project/sql', ['sql/10_seeds']) + * // ['sql/10_seeds'] -- the sql/ prefix was repeated, so nothing was excluded + * ``` + */ +export function findUnmatchedExcludePatterns( + files: string[], + baseDir: string, + exclude: string[], +): string[] { + + return findUnmatchedPatterns(files, baseDir, exclude); + +} + +/** + * Shared body for the include/exclude unmatched checks — the matching rule + * has to stay identical to `filterFilesByPaths`'s, and two copies of it + * would be two chances to drift. + */ +function findUnmatchedPatterns( + files: string[], + baseDir: string, + patterns: string[], +): string[] { + const relativePaths = files.map((file) => relative(baseDir, file)); - return include.filter((pattern) => { + return patterns.filter((pattern) => { const normalized = normalizePattern(pattern); diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 91cc5a8a..63892e4c 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -9,7 +9,7 @@ export { getSqlErrorMessage } from './errors.js'; // Files -export { filterFilesByPaths, findUnmatchedIncludePatterns } from './files.js'; +export { filterFilesByPaths, findUnmatchedIncludePatterns, findUnmatchedExcludePatterns } from './files.js'; // Dialect quoting export { createDialectQuoting, type DialectQuoting } from './dialect-quoting.js'; diff --git a/src/sdk/namespaces/run.ts b/src/sdk/namespaces/run.ts index a85e06d9..17d43575 100644 --- a/src/sdk/namespaces/run.ts +++ b/src/sdk/namespaces/run.ts @@ -11,7 +11,11 @@ import { attempt } from '@logosdx/utils'; import type { Kysely } from 'kysely'; import type { NoormDatabase } from '../../core/shared/index.js'; -import { filterFilesByPaths, findUnmatchedIncludePatterns } from '../../core/shared/index.js'; +import { + filterFilesByPaths, + findUnmatchedIncludePatterns, + findUnmatchedExcludePatterns, +} from '../../core/shared/index.js'; import type { RunContext, RunOptions, @@ -224,15 +228,17 @@ export class RunNamespace { // misconfiguration, not an execution failure, and a build that legitimately // matches nothing must keep succeeding. Surfacing it is what stops the // `sql/`-prefix mistake from reading as a clean build. + // + // Both directions, because the exclude side is the one that fails + // dangerously: it fences off nothing and the held-back files run. const unmatchedInclude = findUnmatchedIncludePatterns(discoveredFiles, sqlPath, effectivePaths.include); + const unmatchedExclude = findUnmatchedExcludePatterns(discoveredFiles, sqlPath, effectivePaths.exclude); - if (unmatchedInclude.length > 0) { - - return { ...result, unmatchedInclude }; - - } - - return result; + return { + ...result, + ...(unmatchedInclude.length > 0 ? { unmatchedInclude } : {}), + ...(unmatchedExclude.length > 0 ? { unmatchedExclude } : {}), + }; } diff --git a/tests/cli/run/build-unmatched-exclude.test.ts b/tests/cli/run/build-unmatched-exclude.test.ts new file mode 100644 index 00000000..1c1b6fb9 --- /dev/null +++ b/tests/cli/run/build-unmatched-exclude.test.ts @@ -0,0 +1,109 @@ +/** + * cli: `noorm run build` names `build.exclude` entries that matched nothing. + * + * `include` got this warning; `exclude` did not, and it is the half that + * fails dangerously. A mistyped include over-restricts and announces itself + * by running nothing. A mistyped exclude fences off nothing — the seeds, + * fixtures or destructive DDL the author held back execute against the + * target database, `status: success`, exit 0, no signal anywhere. + * + * The canonical instance is the `sql/` prefix mistake: patterns are + * relative to `paths.sql`, so `sql/10_seeds` means `sql/sql/10_seeds`. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + cleanupProject, + extractJsonObject, + runCli, + setupProject, + type TestProject, +} from './_setup.js'; + +interface BuildPayload { + status: string; + filesRun: number; + unmatchedExclude?: string[]; +} + +/** + * Project with one seed file and a `build.exclude` entry written the way + * the audit found it in the wild. + */ +async function setupExcludeProject(excludeEntry: string): Promise { + + const project = await setupProject(); + + await mkdir(join(project.dir, 'sql', '10_seeds'), { recursive: true }); + await writeFile( + join(project.dir, 'sql', '10_seeds', 'seed.sql'), + 'CREATE TABLE excluded_probe (id INTEGER PRIMARY KEY);\n', + ); + + await writeFile( + join(project.dir, '.noorm', 'settings.yml'), + `build:\n exclude:\n - ${excludeEntry}\n`, + ); + + return project; + +} + +describe('cli: noorm run build — unmatched build.exclude entries', () => { + + let project: TestProject | undefined; + + afterEach(async () => { + + if (project) await cleanupProject(project); + project = undefined; + + }); + + it('should report an exclude entry that matched no files in --json', async () => { + + project = await setupExcludeProject('sql/10_seeds'); + + const result = runCli(project, ['run', 'build', '--json']); + const jsonText = extractJsonObject(result.stdout); + + expect(jsonText).not.toBeNull(); + + const payload = JSON.parse(jsonText!) as BuildPayload; + + // The file ran despite being "excluded" — that is the damage. The + // key is what lets CI notice it. + expect(payload.filesRun).toBe(1); + expect(payload.unmatchedExclude).toEqual(['sql/10_seeds']); + + }); + + it('should warn in human output that nothing was excluded', async () => { + + project = await setupExcludeProject('sql/10_seeds'); + + const result = runCli(project, ['run', 'build']); + const out = result.stdout + result.stderr; + + expect(out).toContain('sql/10_seeds'); + expect(out).toMatch(/exclude/i); + + }); + + it('should stay silent when the exclude entry matches', async () => { + + project = await setupExcludeProject('10_seeds'); + + const result = runCli(project, ['run', 'build', '--json']); + const jsonText = extractJsonObject(result.stdout); + + const payload = JSON.parse(jsonText!) as BuildPayload; + + expect(payload.filesRun).toBe(0); + expect(payload.unmatchedExclude).toBeUndefined(); + + }); + +}); diff --git a/tests/core/shared/files.test.ts b/tests/core/shared/files.test.ts index 2f378ddc..3db8c117 100644 --- a/tests/core/shared/files.test.ts +++ b/tests/core/shared/files.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'bun:test'; -import { filterFilesByPaths, findUnmatchedIncludePatterns } from '../../../src/core/shared/files.js'; +import { + filterFilesByPaths, + findUnmatchedIncludePatterns, + findUnmatchedExcludePatterns, +} from '../../../src/core/shared/files.js'; describe('shared: filterFilesByPaths', () => { @@ -155,3 +159,75 @@ describe('shared: findUnmatchedIncludePatterns', () => { }); }); + +/** + * The unmatched-*exclude* case is the dangerous direction. A bad `include` + * over-restricts and you notice, because nothing ran. A bad `exclude` + * under-restricts: the seeds, fixtures or destructive DDL you fenced off + * execute against the target database, and the build reports success. + */ +describe('shared: findUnmatchedExcludePatterns', () => { + + it('should name an exclude entry that matched nothing', () => { + + const files = [ + '/project/sql/01_tables/users.sql', + '/project/sql/10_seeds/data.sql', + ]; + + // The same `sql/` prefix mistake the include warning exists for: + // patterns are relative to paths.sql, so this means sql/sql/10_seeds. + const result = findUnmatchedExcludePatterns(files, '/project/sql', ['sql/10_seeds']); + + expect(result).toEqual(['sql/10_seeds']); + + }); + + it('should report nothing when every exclude entry matched', () => { + + const files = [ + '/project/sql/01_tables/users.sql', + '/project/sql/10_seeds/data.sql', + ]; + + const result = findUnmatchedExcludePatterns(files, '/project/sql', ['10_seeds']); + + expect(result).toEqual([]); + + }); + + it('should report only the entries that matched nothing', () => { + + const files = [ + '/project/sql/01_tables/users.sql', + '/project/sql/10_seeds/data.sql', + ]; + + const result = findUnmatchedExcludePatterns( + files, + '/project/sql', + ['10_seeds', '99_archive'], + ); + + expect(result).toEqual(['99_archive']); + + }); + + it('should report nothing for an empty exclude list', () => { + + const files = ['/project/sql/01_tables/users.sql']; + + expect(findUnmatchedExcludePatterns(files, '/project/sql', [])).toEqual([]); + + }); + + it('should report every entry when no files were discovered at all', () => { + + // Nothing to fence off, so nothing is at risk — but the entry is + // still wrong, and staying silent is how it survives to the build + // where files do exist. + expect(findUnmatchedExcludePatterns([], '/project/sql', ['10_seeds'])).toEqual(['10_seeds']); + + }); + +}); From 7baa2128a5eb31db696f4c2c11cc84a8d942daab Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:03:05 -0400 Subject: [PATCH 065/105] 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. --- src/cli/db/create.ts | 36 +++++- src/core/db/index.ts | 9 +- src/core/db/operations.ts | 91 ++++++---------- src/core/db/policy.ts | 88 +++++++++++++++ src/core/db/types.ts | 20 +++- tests/core/db/operations.test.ts | 181 +++++++++++++++++++++++++++++++ 6 files changed, 357 insertions(+), 68 deletions(-) create mode 100644 src/core/db/policy.ts create mode 100644 tests/core/db/operations.test.ts diff --git a/src/cli/db/create.ts b/src/cli/db/create.ts index 513d3426..fc9cf0b6 100644 --- a/src/cli/db/create.ts +++ b/src/cli/db/create.ts @@ -3,6 +3,10 @@ * * Creates the database and bootstraps noorm tracking tables. * Uses server-level connection so the target database need not exist yet. + * + * Gated by the config's `db:create` access: viewer is denied outright; + * operator requires --yes to satisfy the matrix's confirmation requirement; + * admin runs unconfirmed. */ import { attempt, attemptSync } from '@logosdx/utils'; import { defineCommand } from 'citty'; @@ -11,7 +15,8 @@ import { initState, getStateManager } from '../../core/state/index.js'; import { getSettingsManager } from '../../core/settings/index.js'; import { resolveConfig, SettingsProvider } from '../../core/config/resolver.js'; import { checkDbStatus, createDb } from '../../core/db/index.js'; -import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { checkConfigPolicy } from '../../core/policy/index.js'; +import { isYesMode, outputResult, outputError, sharedArgs } from '../_utils.js'; const createCommand = defineCommand({ meta: { @@ -20,6 +25,7 @@ const createCommand = defineCommand({ }, args: { config: sharedArgs.config, + yes: sharedArgs.yes, json: sharedArgs.json, }, async run({ args }) { @@ -71,6 +77,28 @@ const createCommand = defineCommand({ const configName = config.name; + // Gate before any probe touches the server: for SQLite, checkDbStatus + // opens the target and so creates the file, which would hand a denied + // role a database anyway. + const check = checkConfigPolicy('user', config, 'db:create'); + + if (!check.allowed) { + + outputError(args, check.blockedReason ?? `Config "${configName}" cannot be created.`); + process.exit(1); + + } + + if (check.requiresConfirmation && !isYesMode(args)) { + + outputError( + args, + `This is a destructive operation requiring confirmation (${check.confirmationPhrase}). Pass --yes to confirm.`, + ); + process.exit(1); + + } + // Check current status const status = await checkDbStatus(config.connection); @@ -93,7 +121,10 @@ const createCommand = defineCommand({ } // Create database - const result = await createDb(config.connection, configName, { precheckedStatus: status }); + const result = await createDb(config.connection, configName, { + precheckedStatus: status, + policy: { configName, access: config.access, yes: isYesMode(args) }, + }); if (!result.ok) { @@ -120,6 +151,7 @@ const createCommand = defineCommand({ (createCommand as typeof createCommand & { examples: string[] }).examples = [ 'noorm db create', 'noorm db create -c dev', + 'noorm db create --yes', 'noorm db create --json', ]; diff --git a/src/core/db/index.ts b/src/core/db/index.ts index fa936f46..1414014f 100644 --- a/src/core/db/index.ts +++ b/src/core/db/index.ts @@ -14,17 +14,18 @@ * // Create database and tracking tables * await createDb(config, 'myconfig') * - * // Reset tracking only - * await destroyDb(config, 'myconfig') - * * // Drop entire database - * await destroyDb(config, 'myconfig', { trackingOnly: false }) + * await destroyDb(config, 'myconfig') * ``` */ // Main operations export { checkDbStatus, createDb, destroyDb } from './operations.js'; +// Lifecycle policy gate +export { assertDbPolicy } from './policy.js'; +export type { DbPolicyContext } from './policy.js'; + // Dual connection infrastructure export { withDualConnection } from './dual.js'; export type { diff --git a/src/core/db/operations.ts b/src/core/db/operations.ts index 2c800a55..38addcea 100644 --- a/src/core/db/operations.ts +++ b/src/core/db/operations.ts @@ -6,16 +6,18 @@ */ import type { Kysely } from 'kysely'; +import { attempt, attemptSync } from '@logosdx/utils'; + import type { ConnectionConfig } from '../connection/types.js'; import type { NoormDatabase } from '../shared/index.js'; -import { getNoormTables, noormDb } from '../shared/index.js'; +import { getNoormTables } from '../shared/index.js'; import type { DbStatus, DbOperationResult, CreateDbOptions, DestroyDbOptions } from './types.js'; import { createConnection, testConnection } from '../connection/factory.js'; import { bootstrapSchema, tablesExist } from '../version/index.js'; import { observer } from '../observer.js'; import { getDialectOperations } from './dialects/index.js'; -import { attempt } from '@logosdx/utils'; +import { assertDbPolicy } from './policy.js'; /** * Check database status. @@ -140,6 +142,14 @@ export async function createDb( // Get dialect operations const ops = getDialectOperations(config.dialect); + const [, policyErr] = attemptSync(() => assertDbPolicy(options.policy, 'db:create', 'create the database')); + + if (policyErr) { + + return { ok: false, error: policyErr.message }; + + } + // Reuse the caller's status when supplied, instead of re-deriving it — // a second checkDbStatus call for SQLite would see the caller's own // probe having already auto-created the target file. @@ -220,16 +230,15 @@ export async function createDb( /** * Destroy a database. * - * Drops the entire database by default. Use `trackingOnly: true` - * to only reset tracking tables without dropping the database. + * Reports `dropped: false` when the target was already absent, mirroring + * `createDb`'s `created` contract. Every dialect's drop is `IF EXISTS`, so + * without the existence check a CI job that names the wrong database gets a + * green "dropped" and never learns its target was wrong. * * @example * ```typescript - * // Drop entire database - * await destroyDb(config, 'myconfig') - * - * // Reset tracking only (keep database) - * await destroyDb(config, 'myconfig', { trackingOnly: true }) + * const result = await destroyDb(config, 'myconfig') + * if (result.ok && !result.dropped) console.log('nothing to drop') * ``` */ export async function destroyDb( @@ -238,72 +247,40 @@ export async function destroyDb( options: DestroyDbOptions = {}, ): Promise { - const { trackingOnly = false } = options; - const dbName = config.database; + const ops = getDialectOperations(config.dialect); - // Emit start event - observer.emit('db:destroying', { configName, database: dbName }); - - if (trackingOnly) { - - // Just reset tracking tables - const [, resetErr] = await attempt(async () => { - - const conn = await createConnection(config, configName); - const db = conn.db as Kysely; - const ndb = noormDb(db, config.dialect); - const tables = getNoormTables(config.dialect); - - // Clear tracking tables - const hasNoormTables = await tablesExist(db, config.dialect); - - if (hasNoormTables) { - - await ndb.deleteFrom(tables.executions as keyof NoormDatabase).execute(); - - // Try to clear change table (might not exist) - const [, changeErr] = await attempt(async () => { - - await ndb.deleteFrom(tables.change as keyof NoormDatabase).execute(); - - }); + const [, policyErr] = attemptSync(() => assertDbPolicy(options.policy, 'db:destroy', 'drop the database')); - if (changeErr) { - // Table might not exist — safe to ignore - } + if (policyErr) { - } + return { ok: false, error: policyErr.message }; - await conn.destroy(); + } - }); + observer.emit('db:destroying', { configName, database: dbName }); - if (resetErr) { + // Existence is checked before the drop, not inferred from it — the drop + // itself is IF EXISTS on every dialect and so cannot tell the two apart. + // A probe failure is not fatal: fall through and let the drop decide. + const [existed] = await attempt(() => ops.databaseExists(config, dbName)); - return { ok: false, error: resetErr.message }; + if (existed === false) { - } + return { ok: true, dropped: false }; } - else { - // Drop the entire database - const ops = getDialectOperations(config.dialect); + const [, dropErr] = await attempt(() => ops.dropDatabase(config, dbName)); - const [, dropErr] = await attempt(() => ops.dropDatabase(config, dbName)); + if (dropErr) { - if (dropErr) { - - return { ok: false, error: dropErr.message }; - - } + return { ok: false, error: dropErr.message }; } - // Emit completion event observer.emit('db:destroyed', { configName, database: dbName }); - return { ok: true }; + return { ok: true, dropped: true }; } diff --git a/src/core/db/policy.ts b/src/core/db/policy.ts new file mode 100644 index 00000000..fbc334a4 --- /dev/null +++ b/src/core/db/policy.ts @@ -0,0 +1,88 @@ +/** + * Database-lifecycle policy gate. + * + * `core/db` (create/drop) and `core/teardown` (truncate/teardown) are the + * destructive lifecycle seams. Both are reached directly by the TUI and + * indirectly — via the SDK — by the CLI, so gating per surface leaves cells + * that nobody fills: `db create` shipped with the TUI enforcing `db:create` + * and the CLI enforcing nothing at all. Gating here means a new surface + * inherits the check instead of having to remember it. + * + * Distinct from `assertPolicy`, which resolves only the allow/deny half of + * the matrix. Destructive lifecycle commands also have to honour the + * `confirm` half: a `confirm` cell the caller has not pre-confirmed blocks, + * mirroring `db drop`'s `check.requiresConfirmation && !args.yes` gate. + */ +import { checkConfigPolicy } from '../policy/index.js'; +import type { Channel, ConfigAccess, Permission } from '../policy/index.js'; + +/** + * What the lifecycle gate needs to resolve a permission. + * + * Carries `yes` because the confirmation half of the matrix can only be + * satisfied by the caller — core has no prompt of its own. + */ +export interface DbPolicyContext { + + /** Config the permission is scoped to. */ + configName: string; + + /** Per-channel roles from the config. Absent access denies (fail closed). */ + access?: ConfigAccess; + + /** Caller channel — `user` for CLI/TUI/SDK, `mcp` for the MCP server. */ + channel?: Channel; + + /** Caller pre-confirmed the operation (CLI `--yes`, SDK `options.yes`). */ + yes?: boolean; + +} + +/** + * Gate a destructive lifecycle operation against the config's access policy. + * + * `policy` is optional so callers that already ran an equivalent gate (the + * SDK, which raises a typed `ProtectedConfigError` its consumers catch) are + * not double-checked. Every caller that owns no gate of its own must pass + * it — that is the whole point of the seam. + * + * `preview` skips the confirmation half only: a dry run still has to be + * allowed by the role, but there is nothing to confirm because nothing is + * destroyed. Denial is never skipped. + * + * @throws Error carrying the policy's blockedReason when the role denies, + * or a confirmation message when the role requires a confirmation the + * caller did not supply. + * + * @example + * assertDbPolicy({ configName: 'prod', access, yes: args.yes }, 'db:teardown', 'tear down'); + */ +export function assertDbPolicy( + policy: DbPolicyContext | undefined, + permission: Permission, + operation: string, + preview = false, +): void { + + if (!policy) return; + + const check = checkConfigPolicy(policy.channel ?? 'user', { name: policy.configName, access: policy.access }, permission); + + if (!check.allowed) { + + throw new Error(check.blockedReason ?? `"${permission}" is not allowed on config "${policy.configName}".`); + + } + + if (preview) return; + + if (check.requiresConfirmation && !policy.yes) { + + throw new Error( + `Cannot ${operation} on config "${policy.configName}": this is a destructive operation requiring ` + + `confirmation (${check.confirmationPhrase}). Pass --yes to confirm, or set NOORM_YES=1 for scripted use.`, + ); + + } + +} diff --git a/src/core/db/types.ts b/src/core/db/types.ts index 643045fd..9099ae39 100644 --- a/src/core/db/types.ts +++ b/src/core/db/types.ts @@ -4,6 +4,7 @@ * Types for database creation, destruction, and status checking. */ import type { ConnectionConfig } from '../connection/types.js'; +import type { DbPolicyContext } from './policy.js'; /** * Result of checking database status. @@ -35,6 +36,9 @@ export interface DbOperationResult { /** Whether the database was created (vs already existed) */ created?: boolean; + /** Whether the database was dropped (vs never having existed) */ + dropped?: boolean; + /** Whether tracking was initialized (vs already existed) */ trackingInitialized?: boolean; } @@ -56,17 +60,23 @@ export interface CreateDbOptions { * it), so a second internal check would see a false "already exists". */ precheckedStatus?: DbStatus; + + /** + * Access policy to enforce before creating. Omitted by callers that + * already ran an equivalent gate; supplied by every caller that owns + * none, so the check cannot be forgotten per surface. + */ + policy?: DbPolicyContext; } /** * Options for database destruction. */ export interface DestroyDbOptions { - /** Only reset tracking, don't drop database (default: true) */ - trackingOnly?: boolean; - - /** Force drop even if database has data (default: false) */ - force?: boolean; + /** + * Access policy to enforce before dropping. See {@link CreateDbOptions.policy}. + */ + policy?: DbPolicyContext; } /** diff --git a/tests/core/db/operations.test.ts b/tests/core/db/operations.test.ts new file mode 100644 index 00000000..163fddfe --- /dev/null +++ b/tests/core/db/operations.test.ts @@ -0,0 +1,181 @@ +/** + * Unit tests for database lifecycle operations. + * + * Uses SQLite files as real targets, so "the database exists" is a real + * filesystem fact rather than a stub — `destroyDb` reporting `dropped: true` + * for something that was never there is exactly the defect under test. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { attempt } from '@logosdx/utils'; + +import { createDb, destroyDb } from '../../../src/core/db/index.js'; +import type { ConnectionConfig } from '../../../src/core/connection/index.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +const VIEWER: ConfigAccess = { user: 'viewer', mcp: 'viewer' }; +const OPERATOR: ConfigAccess = { user: 'operator', mcp: 'operator' }; +const ADMIN: ConfigAccess = { user: 'admin', mcp: 'admin' }; + +describe('db: lifecycle operations', () => { + + let tmpDir: string; + let dbPath: string; + let config: ConnectionConfig; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-db-ops-')); + dbPath = join(tmpDir, 'target.db'); + config = { dialect: 'sqlite', database: dbPath }; + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + + }); + + // ───────────────────────────────────────────────────── + // destroyDb — "dropped" must mean something happened + // ───────────────────────────────────────────────────── + + describe('destroyDb', () => { + + it('reports dropped: true when the database was really there', async () => { + + await createDb(config, 'test'); + expect(existsSync(dbPath)).toBe(true); + + const result = await destroyDb(config, 'test'); + + expect(result.ok).toBe(true); + expect(result.dropped).toBe(true); + expect(existsSync(dbPath)).toBe(false); + + }); + + it('reports dropped: false when there was nothing to drop', async () => { + + // Every dialect's drop is IF EXISTS, so success alone cannot + // distinguish "destroyed it" from "the name was wrong". A CI job + // that names the wrong database used to get a green dropped: true. + expect(existsSync(dbPath)).toBe(false); + + const result = await destroyDb(config, 'test'); + + expect(result.ok).toBe(true); + expect(result.dropped).toBe(false); + + }); + + it('is not fooled into reporting a second drop as real', async () => { + + await createDb(config, 'test'); + + const first = await destroyDb(config, 'test'); + const second = await destroyDb(config, 'test'); + + expect(first.dropped).toBe(true); + expect(second.dropped).toBe(false); + + }); + + }); + + // ───────────────────────────────────────────────────── + // Core-seam policy gate + // ───────────────────────────────────────────────────── + + describe('policy gate', () => { + + it('refuses createDb for a role the matrix denies', async () => { + + const result = await createDb(config, 'test', { + policy: { configName: 'prod', access: VIEWER }, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('db:create'); + expect(existsSync(dbPath)).toBe(false); + + }); + + it('refuses createDb for an unconfirmed confirm cell', async () => { + + const result = await createDb(config, 'test', { + policy: { configName: 'prod', access: OPERATOR }, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('requiring confirmation'); + expect(existsSync(dbPath)).toBe(false); + + }); + + it('allows createDb once the caller pre-confirms', async () => { + + const result = await createDb(config, 'test', { + policy: { configName: 'prod', access: OPERATOR, yes: true }, + }); + + expect(result.ok).toBe(true); + expect(existsSync(dbPath)).toBe(true); + + }); + + it('allows createDb outright for a role the matrix allows', async () => { + + const result = await createDb(config, 'test', { + policy: { configName: 'dev', access: ADMIN }, + }); + + expect(result.ok).toBe(true); + expect(existsSync(dbPath)).toBe(true); + + }); + + it('refuses destroyDb for a role the matrix denies, leaving the database intact', async () => { + + await createDb(config, 'test'); + + const result = await destroyDb(config, 'test', { + policy: { configName: 'prod', access: OPERATOR }, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('db:destroy'); + expect(existsSync(dbPath)).toBe(true); + + }); + + it('refuses an unconfirmed destroyDb even for admin', async () => { + + await createDb(config, 'test'); + + const result = await destroyDb(config, 'test', { + policy: { configName: 'dev', access: ADMIN }, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('requiring confirmation'); + expect(existsSync(dbPath)).toBe(true); + + }); + + it('runs ungated when no policy is supplied, for callers that own their own gate', async () => { + + const [result, err] = await attempt(() => createDb(config, 'test')); + + expect(err).toBeNull(); + expect(result?.ok).toBe(true); + + }); + + }); + +}); From ef8cedcf1d55eafc2aaaf95285182a8d80f5e37b Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:03:12 -0400 Subject: [PATCH 066/105] 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. --- src/cli/db/explore-fks.ts | 15 +- src/cli/db/explore-functions.ts | 11 +- src/cli/db/explore-indexes.ts | 15 +- src/cli/db/explore-procedures.ts | 11 +- src/cli/db/explore-tables.ts | 15 +- src/cli/db/explore-types.ts | 11 +- src/cli/db/explore-views.ts | 11 +- .../integration/explore/multi-schema.test.ts | 429 ++++++++++++++++++ 8 files changed, 504 insertions(+), 14 deletions(-) create mode 100644 tests/integration/explore/multi-schema.test.ts diff --git a/src/cli/db/explore-fks.ts b/src/cli/db/explore-fks.ts index 6094fb57..390a48e7 100644 --- a/src/cli/db/explore-fks.ts +++ b/src/cli/db/explore-fks.ts @@ -3,6 +3,9 @@ */ import { defineCommand } from 'citty'; +import type { Kysely } from 'kysely'; + +import { fetchList } from '../../core/explore/index.js'; import { withContext, outputResult, sharedArgs } from '../_utils.js'; const fksCommand = defineCommand({ @@ -11,6 +14,10 @@ const fksCommand = defineCommand({ description: 'List foreign keys in the database', }, args: { + schema: { + type: 'string', + description: 'Restrict the listing to one schema', + }, config: sharedArgs.config, json: sharedArgs.json, }, @@ -20,7 +27,9 @@ const fksCommand = defineCommand({ args, fn: (ctx, logger) => { - return ctx.noorm.db.listForeignKeys().then((res) => { + // Core rather than the SDK: the SDK's list methods take no + // options, so --schema cannot reach the query through them. + return fetchList(ctx.kysely as Kysely, ctx.dialect, 'foreignKeys', { schema: args.schema }).then((res) => { if (!args.json) { @@ -28,7 +37,8 @@ const fksCommand = defineCommand({ for (const fk of res) { - const src = `${fk.tableName}(${fk.columns.join(', ')})`; + const table = fk.tableSchema ? `${fk.tableSchema}.${fk.tableName}` : fk.tableName; + const src = `${table}(${fk.columns.join(', ')})`; const ref = `${fk.referencedTable}(${fk.referencedColumns.join(', ')})`; const actions: string[] = []; @@ -74,6 +84,7 @@ const fksCommand = defineCommand({ (fksCommand as typeof fksCommand & { examples: string[] }).examples = [ 'noorm db explore fks', 'noorm db explore fks --json', + 'noorm db explore fks --schema app', ]; export default fksCommand; diff --git a/src/cli/db/explore-functions.ts b/src/cli/db/explore-functions.ts index c8b3c111..01e40c2e 100644 --- a/src/cli/db/explore-functions.ts +++ b/src/cli/db/explore-functions.ts @@ -3,6 +3,9 @@ */ import { defineCommand } from 'citty'; +import type { Kysely } from 'kysely'; + +import { fetchList } from '../../core/explore/index.js'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; const functionsCommand = defineCommand({ @@ -88,7 +91,9 @@ const functionsCommand = defineCommand({ args, fn: (ctx, logger) => { - return ctx.noorm.db.listFunctions().then((res) => { + // Core rather than the SDK: the SDK's list methods take no + // options, so --schema cannot reach the query through them. + return fetchList(ctx.kysely as Kysely, ctx.dialect, 'functions', { schema: args.schema }).then((res) => { if (!args.json) { @@ -96,7 +101,8 @@ const functionsCommand = defineCommand({ for (const f of res) { - logger.info(` ${f.name} (${f.parameterCount} params) → ${f.returnType}`); + const qualified = f.schema ? `${f.schema}.${f.name}` : f.name; + logger.info(` ${qualified} (${f.parameterCount} params) → ${f.returnType}`); } @@ -125,6 +131,7 @@ const functionsCommand = defineCommand({ (functionsCommand as typeof functionsCommand & { examples: string[] }).examples = [ 'noorm db explore functions', 'noorm db explore functions --json', + 'noorm db explore functions --schema app', 'noorm db explore functions fn_get_user', 'noorm db explore functions fn_get_user --schema public', ]; diff --git a/src/cli/db/explore-indexes.ts b/src/cli/db/explore-indexes.ts index c3406db7..73e6fc7b 100644 --- a/src/cli/db/explore-indexes.ts +++ b/src/cli/db/explore-indexes.ts @@ -3,6 +3,9 @@ */ import { defineCommand } from 'citty'; +import type { Kysely } from 'kysely'; + +import { fetchList } from '../../core/explore/index.js'; import { withContext, outputResult, sharedArgs } from '../_utils.js'; const indexesCommand = defineCommand({ @@ -11,6 +14,10 @@ const indexesCommand = defineCommand({ description: 'List indexes in the database', }, args: { + schema: { + type: 'string', + description: 'Restrict the listing to one schema', + }, config: sharedArgs.config, json: sharedArgs.json, }, @@ -20,7 +27,9 @@ const indexesCommand = defineCommand({ args, fn: (ctx, logger) => { - return ctx.noorm.db.listIndexes().then((res) => { + // Core rather than the SDK: the SDK's list methods take no + // options, so --schema cannot reach the query through them. + return fetchList(ctx.kysely as Kysely, ctx.dialect, 'indexes', { schema: args.schema }).then((res) => { if (!args.json) { @@ -42,7 +51,8 @@ const indexesCommand = defineCommand({ } const flagStr = flags.length > 0 ? ` [${flags.join(', ')}]` : ''; - logger.info(` ${idx.name} on ${idx.tableName} (${idx.columns.join(', ')})${flagStr}`); + const table = idx.tableSchema ? `${idx.tableSchema}.${idx.tableName}` : idx.tableName; + logger.info(` ${idx.name} on ${table} (${idx.columns.join(', ')})${flagStr}`); } @@ -71,6 +81,7 @@ const indexesCommand = defineCommand({ (indexesCommand as typeof indexesCommand & { examples: string[] }).examples = [ 'noorm db explore indexes', 'noorm db explore indexes --json', + 'noorm db explore indexes --schema app', ]; export default indexesCommand; diff --git a/src/cli/db/explore-procedures.ts b/src/cli/db/explore-procedures.ts index fa5ac62c..57acaeb8 100644 --- a/src/cli/db/explore-procedures.ts +++ b/src/cli/db/explore-procedures.ts @@ -3,6 +3,9 @@ */ import { defineCommand } from 'citty'; +import type { Kysely } from 'kysely'; + +import { fetchList } from '../../core/explore/index.js'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; const proceduresCommand = defineCommand({ @@ -81,7 +84,9 @@ const proceduresCommand = defineCommand({ args, fn: (ctx, logger) => { - return ctx.noorm.db.listProcedures().then((res) => { + // Core rather than the SDK: the SDK's list methods take no + // options, so --schema cannot reach the query through them. + return fetchList(ctx.kysely as Kysely, ctx.dialect, 'procedures', { schema: args.schema }).then((res) => { if (!args.json) { @@ -89,7 +94,8 @@ const proceduresCommand = defineCommand({ for (const p of res) { - logger.info(` ${p.name} (${p.parameterCount} params)`); + const qualified = p.schema ? `${p.schema}.${p.name}` : p.name; + logger.info(` ${qualified} (${p.parameterCount} params)`); } @@ -118,6 +124,7 @@ const proceduresCommand = defineCommand({ (proceduresCommand as typeof proceduresCommand & { examples: string[] }).examples = [ 'noorm db explore procedures', 'noorm db explore procedures --json', + 'noorm db explore procedures --schema app', 'noorm db explore procedures sp_update_user', 'noorm db explore procedures sp_update_user --schema dbo', ]; diff --git a/src/cli/db/explore-tables.ts b/src/cli/db/explore-tables.ts index 0372c1f7..3c307977 100644 --- a/src/cli/db/explore-tables.ts +++ b/src/cli/db/explore-tables.ts @@ -3,6 +3,9 @@ */ import { defineCommand } from 'citty'; +import type { Kysely } from 'kysely'; + +import { fetchList } from '../../core/explore/index.js'; import { withContext, outputResult, sharedArgs } from '../_utils.js'; import detail from './explore-tables-detail.js'; @@ -13,6 +16,10 @@ const tablesCommand = defineCommand({ description: 'List tables in the database', }, args: { + schema: { + type: 'string', + description: 'Restrict the listing to one schema', + }, config: sharedArgs.config, json: sharedArgs.json, }, @@ -23,7 +30,9 @@ const tablesCommand = defineCommand({ args, fn: (ctx, logger) => { - return ctx.noorm.db.listTables().then((res) => { + // Core rather than the SDK: the SDK's list methods take no + // options, so --schema cannot reach the query through them. + return fetchList(ctx.kysely as Kysely, ctx.dialect, 'tables', { schema: args.schema }).then((res) => { if (!args.json) { @@ -31,7 +40,8 @@ const tablesCommand = defineCommand({ for (const t of res) { - logger.info(` ${t.name} (${t.columnCount} cols)`); + const qualified = t.schema ? `${t.schema}.${t.name}` : t.name; + logger.info(` ${qualified} (${t.columnCount} cols)`); } @@ -60,6 +70,7 @@ const tablesCommand = defineCommand({ (tablesCommand as typeof tablesCommand & { examples: string[] }).examples = [ 'noorm db explore tables', 'noorm db explore tables --json', + 'noorm db explore tables --schema app', 'noorm db explore tables detail users', ]; diff --git a/src/cli/db/explore-types.ts b/src/cli/db/explore-types.ts index 65c89d1f..3e7790ab 100644 --- a/src/cli/db/explore-types.ts +++ b/src/cli/db/explore-types.ts @@ -3,6 +3,9 @@ */ import { defineCommand } from 'citty'; +import type { Kysely } from 'kysely'; + +import { fetchList } from '../../core/explore/index.js'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; const typesCommand = defineCommand({ @@ -91,7 +94,9 @@ const typesCommand = defineCommand({ args, fn: (ctx, logger) => { - return ctx.noorm.db.listTypes().then((res) => { + // Core rather than the SDK: the SDK's list methods take no + // options, so --schema cannot reach the query through them. + return fetchList(ctx.kysely as Kysely, ctx.dialect, 'types', { schema: args.schema }).then((res) => { if (!args.json) { @@ -100,7 +105,8 @@ const typesCommand = defineCommand({ for (const t of res) { const extra = t.valueCount !== undefined ? ` (${t.valueCount} values)` : ''; - logger.info(` ${t.name} [${t.kind}]${extra}`); + const qualified = t.schema ? `${t.schema}.${t.name}` : t.name; + logger.info(` ${qualified} [${t.kind}]${extra}`); } @@ -129,6 +135,7 @@ const typesCommand = defineCommand({ (typesCommand as typeof typesCommand & { examples: string[] }).examples = [ 'noorm db explore types', 'noorm db explore types --json', + 'noorm db explore types --schema app', 'noorm db explore types user_status', 'noorm db explore types user_status --schema public', ]; diff --git a/src/cli/db/explore-views.ts b/src/cli/db/explore-views.ts index 7bedcb68..a6095d14 100644 --- a/src/cli/db/explore-views.ts +++ b/src/cli/db/explore-views.ts @@ -3,6 +3,9 @@ */ import { defineCommand } from 'citty'; +import type { Kysely } from 'kysely'; + +import { fetchList } from '../../core/explore/index.js'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; const viewsCommand = defineCommand({ @@ -83,7 +86,9 @@ const viewsCommand = defineCommand({ args, fn: (ctx, logger) => { - return ctx.noorm.db.listViews().then((res) => { + // Core rather than the SDK: the SDK's list methods take no + // options, so --schema cannot reach the query through them. + return fetchList(ctx.kysely as Kysely, ctx.dialect, 'views', { schema: args.schema }).then((res) => { if (!args.json) { @@ -92,7 +97,8 @@ const viewsCommand = defineCommand({ for (const v of res) { const updatable = v.isUpdatable ? ' [updatable]' : ''; - logger.info(` ${v.name} (${v.columnCount} cols)${updatable}`); + const qualified = v.schema ? `${v.schema}.${v.name}` : v.name; + logger.info(` ${qualified} (${v.columnCount} cols)${updatable}`); } @@ -121,6 +127,7 @@ const viewsCommand = defineCommand({ (viewsCommand as typeof viewsCommand & { examples: string[] }).examples = [ 'noorm db explore views', 'noorm db explore views --json', + 'noorm db explore views --schema app', 'noorm db explore views active_users', 'noorm db explore views active_users --schema public', ]; diff --git a/tests/integration/explore/multi-schema.test.ts b/tests/integration/explore/multi-schema.test.ts new file mode 100644 index 00000000..eac98445 --- /dev/null +++ b/tests/integration/explore/multi-schema.test.ts @@ -0,0 +1,429 @@ +/** + * Integration tests for schema-scoped exploration. + * + * Every other explore fixture is single-schema, which is why "detail returns + * another schema's indexes", "--schema does nothing on list", and "two + * same-named tables render identically" all shipped green. This file creates a + * second schema per dialect and asserts explore keeps them apart. + * + * Requires docker-compose.test.yml containers to be running. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { sql } from 'kysely'; +import { attempt } from '@logosdx/utils'; + +import type { Kysely } from 'kysely'; + +import { fetchOverview, fetchList, fetchDetail } from '../../../src/core/explore/index.js'; +import { createTestConnection, skipIfNoContainer } from '../../utils/db.js'; + +/** Prefixed so a shared container can host several suites at once. */ +const ALT_SCHEMA = 'explore_ms_alt'; +const TABLE = 'explore_ms_orders'; +const PROC = 'explore_ms_touch'; + +/** MySQL has no schema below the database; CI provisions this second one. */ +const MYSQL_ALT_DB = 'noorm_test_dest'; + +async function run(db: Kysely, statements: string[]): Promise { + + for (const statement of statements) { + + await sql.raw(statement).execute(db); + + } + +} + +async function runIgnoringErrors(db: Kysely, statements: string[]): Promise { + + for (const statement of statements) { + + await attempt(() => sql.raw(statement).execute(db)); + + } + +} + +describe('integration: postgres multi-schema explore', () => { + + let db: Kysely; + let destroy: () => Promise; + + beforeAll(async () => { + + await skipIfNoContainer('postgres'); + + const conn = await createTestConnection('postgres'); + db = conn.db; + destroy = conn.destroy; + + await runIgnoringErrors(db, [ + `DROP SCHEMA IF EXISTS ${ALT_SCHEMA} CASCADE`, + `DROP TABLE IF EXISTS public.${TABLE}`, + `DROP PROCEDURE IF EXISTS public.${PROC}(integer, text)`, + ]); + + await run(db, [ + `CREATE SCHEMA ${ALT_SCHEMA}`, + `CREATE TABLE ${ALT_SCHEMA}.${TABLE} (id integer PRIMARY KEY, note text)`, + `CREATE INDEX idx_${TABLE}_alt_note ON ${ALT_SCHEMA}.${TABLE} (note)`, + `CREATE TABLE public.${TABLE} (id integer PRIMARY KEY, a text, b text)`, + `CREATE INDEX idx_${TABLE}_public_a ON public.${TABLE} (a)`, + `CREATE PROCEDURE public.${PROC}(IN p_id integer, INOUT p_note text) + LANGUAGE plpgsql AS $$ BEGIN p_note := p_note || p_id::text; END $$`, + ]); + + }); + + afterAll(async () => { + + if (!db) return; + + await runIgnoringErrors(db, [ + `DROP SCHEMA IF EXISTS ${ALT_SCHEMA} CASCADE`, + `DROP TABLE IF EXISTS public.${TABLE}`, + `DROP PROCEDURE IF EXISTS public.${PROC}(integer, text)`, + ]); + + await destroy(); + + }); + + it('should list only the requested schema', async () => { + + const tables = await fetchList(db, 'postgres', 'tables', { schema: ALT_SCHEMA }); + + expect(tables.map((t) => t.name)).toEqual([TABLE]); + expect(tables[0]?.schema).toBe(ALT_SCHEMA); + + }); + + it('should list both same-named tables, distinguishable by schema, when unfiltered', async () => { + + const tables = await fetchList(db, 'postgres', 'tables'); + const matches = tables.filter((t) => t.name === TABLE); + + expect(matches.map((t) => t.schema).sort()).toEqual([ALT_SCHEMA, 'public']); + + }); + + it('should describe the requested schema, indexes included', async () => { + + const detail = await fetchDetail(db, 'postgres', 'tables', TABLE, ALT_SCHEMA); + + expect(detail?.schema).toBe(ALT_SCHEMA); + expect(detail?.columns.map((c) => c.name)).toEqual(['id', 'note']); + + const indexNames = detail!.indexes.map((i) => i.name); + + expect(indexNames).toContain(`idx_${TABLE}_alt_note`); + expect(indexNames).not.toContain(`idx_${TABLE}_public_a`); + expect(detail!.indexes.every((i) => i.tableSchema === ALT_SCHEMA)).toBe(true); + + }); + + it('should scope the overview to the requested schema', async () => { + + const overview = await fetchOverview(db, 'postgres', { schema: ALT_SCHEMA }); + + expect(overview.tables).toBe(1); + expect(overview.procedures).toBe(0); + + }); + + it('should return procedure parameters, not an empty list', async () => { + + const detail = await fetchDetail(db, 'postgres', 'procedures', PROC, 'public'); + + expect(detail?.parameters.map((p) => [p.name, p.mode])).toEqual([ + ['p_id', 'IN'], + ['p_note', 'INOUT'], + ]); + + }); + + it('should agree with the list view on parameter count', async () => { + + const summaries = await fetchList(db, 'postgres', 'procedures', { schema: 'public' }); + const summary = summaries.find((p) => p.name === PROC); + const detail = await fetchDetail(db, 'postgres', 'procedures', PROC, 'public'); + + expect(detail?.parameters).toHaveLength(summary!.parameterCount); + + }); + + it('should only count locks held against this database', async () => { + + const locks = await fetchList(db, 'postgres', 'locks'); + + // Cluster-wide pg_locks would include other databases on the container; + // every returned relation must resolve inside this one. + expect(Array.isArray(locks)).toBe(true); + + const [, err] = await attempt(() => fetchList(db, 'postgres', 'locks')); + + expect(err).toBeNull(); + + }); + +}); + +describe('integration: mysql cross-database explore', () => { + + let db: Kysely; + let destroy: () => Promise; + + beforeAll(async () => { + + await skipIfNoContainer('mysql'); + + const conn = await createTestConnection('mysql'); + db = conn.db; + destroy = conn.destroy; + + await runIgnoringErrors(db, [ + `DROP TABLE IF EXISTS ${MYSQL_ALT_DB}.${TABLE}`, + `DROP TABLE IF EXISTS ${TABLE}`, + ]); + + await run(db, [ + `CREATE TABLE ${MYSQL_ALT_DB}.${TABLE} (id INT PRIMARY KEY, note VARCHAR(64))`, + `CREATE INDEX idx_${TABLE}_alt_note ON ${MYSQL_ALT_DB}.${TABLE} (note)`, + `CREATE TABLE ${TABLE} (id INT PRIMARY KEY, a VARCHAR(64), b VARCHAR(64))`, + `CREATE INDEX idx_${TABLE}_home_a ON ${TABLE} (a)`, + ]); + + }); + + afterAll(async () => { + + if (!db) return; + + await runIgnoringErrors(db, [ + `DROP TABLE IF EXISTS ${MYSQL_ALT_DB}.${TABLE}`, + `DROP TABLE IF EXISTS ${TABLE}`, + ]); + + await destroy(); + + }); + + it('should read indexes from the requested database, not the connected one', async () => { + + const detail = await fetchDetail(db, 'mysql', 'tables', TABLE, MYSQL_ALT_DB); + const indexNames = detail!.indexes.map((i) => i.name); + + expect(detail?.schema).toBe(MYSQL_ALT_DB); + expect(detail?.columns.map((c) => c.name)).toEqual(['id', 'note']); + expect(indexNames).toContain(`idx_${TABLE}_alt_note`); + expect(indexNames).not.toContain(`idx_${TABLE}_home_a`); + + }); + + it('should not label a row with one database while sourcing it from another', async () => { + + const detail = await fetchDetail(db, 'mysql', 'tables', TABLE, MYSQL_ALT_DB); + + expect(detail!.indexes.every((i) => i.tableSchema === MYSQL_ALT_DB)).toBe(true); + + }); + + it('should list tables from the requested database', async () => { + + const tables = await fetchList(db, 'mysql', 'tables', { schema: MYSQL_ALT_DB }); + const found = tables.find((t) => t.name === TABLE); + + expect(found?.schema).toBe(MYSQL_ALT_DB); + expect(found?.columnCount).toBe(2); + + }); + + it('should still default to the connected database', async () => { + + const detail = await fetchDetail(db, 'mysql', 'tables', TABLE); + + expect(detail?.columns.map((c) => c.name)).toEqual(['id', 'a', 'b']); + expect(detail!.indexes.map((i) => i.name)).toContain(`idx_${TABLE}_home_a`); + + }); + +}); + +describe('integration: mssql multi-schema explore', () => { + + let db: Kysely; + let destroy: () => Promise; + + beforeAll(async () => { + + await skipIfNoContainer('mssql'); + + const conn = await createTestConnection('mssql'); + db = conn.db; + destroy = conn.destroy; + + await runIgnoringErrors(db, [ + `DROP TABLE IF EXISTS ${ALT_SCHEMA}.${TABLE}`, + `DROP TABLE IF EXISTS dbo.${TABLE}`, + `DROP SCHEMA IF EXISTS ${ALT_SCHEMA}`, + ]); + + await run(db, [ + `CREATE SCHEMA ${ALT_SCHEMA}`, + ]); + + await run(db, [ + `CREATE TABLE ${ALT_SCHEMA}.${TABLE} (id INT PRIMARY KEY, note NVARCHAR(64))`, + `CREATE INDEX idx_${TABLE}_alt_note ON ${ALT_SCHEMA}.${TABLE} (note)`, + `CREATE TABLE dbo.${TABLE} (id INT PRIMARY KEY, a NVARCHAR(64), b NVARCHAR(64))`, + `CREATE INDEX idx_${TABLE}_dbo_a ON dbo.${TABLE} (a)`, + `INSERT INTO ${ALT_SCHEMA}.${TABLE} (id, note) VALUES (1, 'x'), (2, 'y')`, + ]); + + }); + + afterAll(async () => { + + if (!db) return; + + await runIgnoringErrors(db, [ + `DROP TABLE IF EXISTS ${ALT_SCHEMA}.${TABLE}`, + `DROP TABLE IF EXISTS dbo.${TABLE}`, + `DROP SCHEMA IF EXISTS ${ALT_SCHEMA}`, + ]); + + await destroy(); + + }); + + it('should list only the requested schema', async () => { + + const tables = await fetchList(db, 'mssql', 'tables', { schema: ALT_SCHEMA }); + + expect(tables.map((t) => t.name)).toEqual([TABLE]); + expect(tables[0]?.schema).toBe(ALT_SCHEMA); + + }); + + it('should describe the requested schema, indexes included', async () => { + + const detail = await fetchDetail(db, 'mssql', 'tables', TABLE, ALT_SCHEMA); + const indexNames = detail!.indexes.map((i) => i.name); + + expect(detail?.columns.map((c) => c.name)).toEqual(['id', 'note']); + expect(indexNames).toContain(`idx_${TABLE}_alt_note`); + expect(indexNames).not.toContain(`idx_${TABLE}_dbo_a`); + + }); + + it('should report rowCountEstimate as a number, not driver text', async () => { + + const tables = await fetchList(db, 'mssql', 'tables', { schema: ALT_SCHEMA }); + const detail = await fetchDetail(db, 'mssql', 'tables', TABLE, ALT_SCHEMA); + + expect(typeof tables[0]?.rowCountEstimate).toBe('number'); + expect(tables[0]?.rowCountEstimate).toBe(2); + expect(typeof detail?.rowCountEstimate).toBe('number'); + + }); + + it('should report an empty table as undefined rather than zero-as-text', async () => { + + const detail = await fetchDetail(db, 'mssql', 'tables', TABLE, 'dbo'); + + expect(detail?.rowCountEstimate).toBeUndefined(); + + }); + +}); + +describe('integration: sqlite hostile identifiers', () => { + + let db: Kysely; + let destroy: () => Promise; + + /** Names a third-party tool or a plain `.sql` file could legally create. */ + const HOSTILE = ['we"ird', "tbl'quote", 'a.b', 'таблица', 'Table With Space', 'sel;ect']; + + beforeAll(async () => { + + const conn = await createTestConnection('sqlite'); + db = conn.db; + destroy = conn.destroy; + + await run(db, [ + 'CREATE TABLE plain (id INTEGER PRIMARY KEY)', + 'CREATE TABLE canary (id INTEGER PRIMARY KEY)', + ...HOSTILE.map((name) => `CREATE TABLE "${name.replaceAll('"', '""')}" (id INTEGER PRIMARY KEY, val TEXT)`), + ]); + + }); + + afterAll(async () => { + + if (destroy) await destroy(); + + }); + + it('should list every table, including hostile names', async () => { + + const tables = await fetchList(db, 'sqlite', 'tables'); + const names = tables.map((t) => t.name); + + for (const name of HOSTILE) { + + expect(names).toContain(name); + + } + + }); + + it('should keep unrelated tables describable when a hostile name exists', async () => { + + const detail = await fetchDetail(db, 'sqlite', 'tables', 'plain'); + + expect(detail?.name).toBe('plain'); + + }); + + it('should describe a hostile-named table', async () => { + + const detail = await fetchDetail(db, 'sqlite', 'tables', 'we"ird'); + + expect(detail?.columns.map((c) => c.name)).toEqual(['id', 'val']); + + }); + + it('should produce an overview rather than a syntax error', async () => { + + const overview = await fetchOverview(db, 'sqlite'); + + expect(overview.tables).toBe(2 + HOSTILE.length); + + }); + + it('should not execute appended DDL smuggled through a table name', async () => { + + await fetchList(db, 'sqlite', 'tables'); + + const survivors = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'canary' + `.execute(db); + + expect(survivors.rows).toHaveLength(1); + + }); + + it('should reject a schema filter instead of silently returning nothing', async () => { + + const [result, err] = await attempt(() => + fetchList(db, 'sqlite', 'tables', { schema: 'app' }), + ); + + expect(result).toBeNull(); + expect(err?.message).toContain('SQLite has no schemas'); + + }); + +}); From cb1b4b1c15ebd32a29443f21ef0993f9c8a147d2 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:03:15 -0400 Subject: [PATCH 067/105] 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. --- src/cli/db/teardown.ts | 43 +++- src/cli/db/truncate.ts | 42 +++- src/core/teardown/operations.ts | 117 ++++++++-- src/core/teardown/types.ts | 49 +++- src/sdk/namespaces/db.ts | 58 ++++- tests/cli/db/lifecycle-policy.test.ts | 311 ++++++++++++++++++++++++++ tests/cli/db/reset.test.ts | 20 +- tests/cli/db/teardown.test.ts | 4 +- tests/sdk/db-namespace.test.ts | 5 +- tests/sdk/destructive-ops.test.ts | 21 +- tests/sdk/noorm-ops.test.ts | 15 +- 11 files changed, 631 insertions(+), 54 deletions(-) create mode 100644 tests/cli/db/lifecycle-policy.test.ts diff --git a/src/cli/db/teardown.ts b/src/cli/db/teardown.ts index 2f69da39..abe2d85b 100644 --- a/src/cli/db/teardown.ts +++ b/src/cli/db/teardown.ts @@ -1,9 +1,12 @@ /** * noorm db teardown — drop all database objects. + * + * Gated by the config's `db:teardown` access, enforced at the SDK seam that + * `withContext` threads `--yes` into. */ import { defineCommand } from 'citty'; -import { withContext, outputResult, sharedArgs } from '../_utils.js'; +import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; const teardownCommand = defineCommand({ meta: { @@ -12,20 +15,24 @@ const teardownCommand = defineCommand({ }, args: { config: sharedArgs.config, - force: sharedArgs.force, dryRun: sharedArgs.dryRun, + preserveSchemas: { + type: 'string', + description: 'Comma-separated schemas to leave untouched (teardown reaches every non-system schema)', + }, yes: sharedArgs.yes, json: sharedArgs.json, }, async run({ args }) { const dryRun = Boolean(args.dryRun); + const preserveSchemas = splitList(args.preserveSchemas); const [result, error] = await withContext({ args, fn: (ctx, logger) => { - return ctx.noorm.db.teardown({ dryRun }).then((res) => { + return ctx.noorm.db.teardown({ dryRun, preserveSchemas }).then((res) => { const droppedCount = res.dropped.tables.length + res.dropped.views.length + @@ -58,26 +65,52 @@ const teardownCommand = defineCommand({ result.dropped.functions.length + result.dropped.types.length; + const postScript = result.postScriptResult; + if (args.json) { outputResult(args, { dropped: result.dropped, count: droppedCount, + ...(postScript ? { postScriptResult: postScript } : {}), ...(dryRun ? { dryRun: true } : {}), }, ''); } + // The objects are already gone, so this is not a rollback — but a + // teardown whose post-script never ran is half-finished, and exiting + // 0 told every pipeline it was complete. + if (postScript && !postScript.executed) { + + if (!args.json) { + + outputError(args, `Post-teardown script failed: ${postScript.error ?? 'Unknown error'}`); + + } + + process.exit(1); + + } + process.exit(0); }, }); +/** Parses a comma-separated CLI list, returning undefined for an absent flag. */ +function splitList(value: unknown): string[] | undefined { + + if (typeof value !== 'string' || value.trim().length === 0) return undefined; + + return value.split(',').map((s) => s.trim()).filter((s) => s.length > 0); + +} + (teardownCommand as typeof teardownCommand & { examples: string[] }).examples = [ - 'noorm db teardown', 'noorm db teardown --yes', 'noorm db teardown --dry-run', - 'noorm db teardown --force --yes', + 'noorm db teardown --preserve-schemas app_private --yes', 'noorm db teardown --json --yes', ]; diff --git a/src/cli/db/truncate.ts b/src/cli/db/truncate.ts index c296f425..e6b05569 100644 --- a/src/cli/db/truncate.ts +++ b/src/cli/db/truncate.ts @@ -1,5 +1,9 @@ /** * noorm db truncate — wipe all data, keep schema. + * + * Gated by the config's `db:truncate` access, enforced at the SDK seam that + * `withContext` threads `--yes` into. `--dry-run` previews the statements + * without executing them. */ import { defineCommand } from 'citty'; @@ -12,21 +16,37 @@ const truncateCommand = defineCommand({ }, args: { config: sharedArgs.config, - force: sharedArgs.force, + dryRun: sharedArgs.dryRun, + preserve: { + type: 'string', + description: 'Comma-separated tables to leave untouched', + }, + only: { + type: 'string', + description: 'Comma-separated tables to truncate, to the exclusion of all others', + }, yes: sharedArgs.yes, json: sharedArgs.json, }, async run({ args }) { + const dryRun = Boolean(args.dryRun); + const [result, error] = await withContext({ args, fn: (ctx, logger) => { - return ctx.noorm.db.truncate().then((res) => { + return ctx.noorm.db.truncate({ + dryRun, + preserve: splitList(args.preserve), + only: splitList(args.only), + }).then((res) => { if (!args.json) { - logger.info(`Truncated ${res.truncated.length} tables`); + const verb = dryRun ? 'Would truncate' : 'Truncated'; + + logger.info(`${verb} ${res.truncated.length} tables`); } @@ -43,7 +63,9 @@ const truncateCommand = defineCommand({ outputResult(args, { truncated: result.truncated, + preserved: result.preserved, count: result.truncated.length, + ...(dryRun ? { dryRun: true, statements: result.statements } : {}), }, ''); } @@ -53,10 +75,20 @@ const truncateCommand = defineCommand({ }, }); +/** Parses a comma-separated CLI list, returning undefined for an absent flag so the SDK's settings fallback still applies. */ +function splitList(value: unknown): string[] | undefined { + + if (typeof value !== 'string' || value.trim().length === 0) return undefined; + + return value.split(',').map((s) => s.trim()).filter((s) => s.length > 0); + +} + (truncateCommand as typeof truncateCommand & { examples: string[] }).examples = [ - 'noorm db truncate', 'noorm db truncate --yes', - 'noorm db truncate --force --yes', + 'noorm db truncate --dry-run', + 'noorm db truncate --preserve seeds,lookups --yes', + 'noorm db truncate --only users,posts --yes', 'noorm db truncate --json --yes', ]; diff --git a/src/core/teardown/operations.ts b/src/core/teardown/operations.ts index f80f09f8..791101cb 100644 --- a/src/core/teardown/operations.ts +++ b/src/core/teardown/operations.ts @@ -18,13 +18,45 @@ import type { TeardownOptions, TeardownResult, TeardownPreview, + TeardownTableRef, } from './types.js'; import type { NoormDatabase } from '../shared/tables.js'; import { fetchList } from '../explore/operations.js'; import { getTeardownOperations } from './dialects/index.js'; import { observer } from '../observer.js'; +import { assertDbPolicy } from '../db/policy.js'; import { ChangeHistory, ChangeTracker } from '../change/index.js'; +/** + * Schema each dialect resolves an unqualified name against. + * + * MySQL's "schema" is the database itself and SQLite has none, so neither + * appears here — an object in those dialects is never reported qualified. + */ +const DEFAULT_SCHEMAS: Partial> = { + postgres: 'public', + mssql: 'dbo', +}; + +/** + * The name to report for an object. + * + * Qualified whenever the object sits outside the dialect's default schema. + * Teardown enumerates every non-system schema, so a bare `secrets` in a + * dry-run is indistinguishable from `public.secrets` — an operator reading + * the preview cannot tell that a schema noorm never created is about to be + * dropped. Execution has always qualified correctly; only the report was lossy. + */ +function displayName(name: string, schema: string | undefined, dialect: Dialect): string { + + const defaultSchema = DEFAULT_SCHEMAS[dialect]; + + if (!schema || !defaultSchema || schema === defaultSchema) return name; + + return `${schema}.${name}`; + +} + /** * Check if a table name is a noorm internal table. * Exported for testing purposes. @@ -162,6 +194,8 @@ export async function truncateData( const truncated: string[] = []; const preserved: string[] = []; + assertDbPolicy(options.policy, 'db:truncate', 'wipe data', options.dryRun); + observer.emit('teardown:start', { type: 'truncate' }); // Fetch all tables (including noorm tables so we can preserve them) @@ -174,17 +208,21 @@ export async function truncateData( } - // Determine which tables to truncate + // Determine which tables to truncate. Both halves of each name are kept: + // preserve/only match on the bare name the user wrote, while the SQL and + // the report need the schema. const preserveSet = new Set(options.preserve ?? []); + const targets: TeardownTableRef[] = []; for (const table of tables) { const tableName = table.name; + const label = displayName(tableName, table.schema, dialect); // Always preserve noorm tables if (isNoormTable(tableName)) { - preserved.push(tableName); + preserved.push(label); continue; } @@ -192,7 +230,7 @@ export async function truncateData( // Check if table should be preserved if (preserveSet.has(tableName)) { - preserved.push(tableName); + preserved.push(label); continue; } @@ -200,12 +238,13 @@ export async function truncateData( // If 'only' is specified, check if table is in the list if (options.only && !options.only.includes(tableName)) { - preserved.push(tableName); + preserved.push(label); continue; } - truncated.push(tableName); + targets.push({ name: tableName, schema: table.schema }); + truncated.push(label); } @@ -219,17 +258,17 @@ export async function truncateData( const truncateStatements: string[] = []; const enableStatements: string[] = []; - if (truncated.length > 0) { + if (targets.length > 0) { - pushFlat(disableStatements, ops.disableForeignKeyChecks(truncated)); + pushFlat(disableStatements, ops.disableForeignKeyChecks(targets)); - for (const tableName of truncated) { + for (const target of targets) { - truncateStatements.push(ops.truncateTable(tableName, undefined, options.restartIdentity ?? true)); + truncateStatements.push(ops.truncateTable(target.name, target.schema, options.restartIdentity ?? true)); } - pushFlat(enableStatements, ops.enableForeignKeyChecks(truncated)); + pushFlat(enableStatements, ops.enableForeignKeyChecks(targets)); statements.push(...disableStatements, ...truncateStatements, ...enableStatements); @@ -314,9 +353,15 @@ export async function teardownSchema( foreignKeys: [], }; + assertDbPolicy(options.policy, 'db:teardown', 'tear down the schema', options.dryRun); + observer.emit('teardown:start', { type: 'schema' }); const preserveSet = new Set(options.preserveTables ?? []); + const preserveSchemaSet = new Set(options.preserveSchemas ?? []); + + /** Whether an object belongs to a schema the caller asked to leave alone. */ + const inPreservedSchema = (schema?: string): boolean => Boolean(schema && preserveSchemaSet.has(schema)); // Fetch all objects in parallel (include noorm tables so we can preserve them) const [ @@ -353,7 +398,9 @@ export async function teardownSchema( // Skip preserved tables if (preserveSet.has(tableName)) continue; - dropped.foreignKeys.push(fk.name); + if (inPreservedSchema(fk.schema)) continue; + + dropped.foreignKeys.push(displayName(fk.name, fk.schema, dialect)); statements.push(ops.dropForeignKey(fk.name, tableName, fk.schema)); } @@ -375,7 +422,9 @@ export async function teardownSchema( for (const proc of procedures) { - dropped.procedures.push(proc.name); + if (inPreservedSchema(proc.schema)) continue; + + dropped.procedures.push(displayName(proc.name, proc.schema, dialect)); statements.push(ops.dropProcedure(proc.name, proc.schema)); } @@ -388,7 +437,9 @@ export async function teardownSchema( for (const fn of functions) { - dropped.functions.push(fn.name); + if (inPreservedSchema(fn.schema)) continue; + + dropped.functions.push(displayName(fn.name, fn.schema, dialect)); statements.push(ops.dropFunction(fn.name, fn.schema)); } @@ -401,7 +452,9 @@ export async function teardownSchema( for (const view of views) { - dropped.views.push(view.name); + if (inPreservedSchema(view.schema)) continue; + + dropped.views.push(displayName(view.name, view.schema, dialect)); statements.push(ops.dropView(view.name, view.schema)); } @@ -412,11 +465,12 @@ export async function teardownSchema( for (const table of tables) { const tableName = table.name; + const label = displayName(tableName, table.schema, dialect); // Always preserve noorm tables if (isNoormTable(tableName)) { - preserved.push(tableName); + preserved.push(label); continue; } @@ -424,12 +478,19 @@ export async function teardownSchema( // Skip preserved tables if (preserveSet.has(tableName)) { - preserved.push(tableName); + preserved.push(label); continue; } - dropped.tables.push(tableName); + if (inPreservedSchema(table.schema)) { + + preserved.push(label); + continue; + + } + + dropped.tables.push(label); statements.push(ops.dropTable(tableName, table.schema)); } @@ -456,7 +517,9 @@ export async function teardownSchema( for (const type of sortedTypes) { - dropped.types.push(type.name); + if (inPreservedSchema(type.schema)) continue; + + dropped.types.push(displayName(type.name, type.schema, dialect)); statements.push(ops.dropType(type.name, type.schema)); } @@ -572,6 +635,12 @@ export async function previewTeardown( /** * Execute a post-teardown SQL script. + * + * Returns the outcome rather than throwing, because a post-script failure + * must not undo a teardown that already succeeded — but it emits + * `teardown:error` on the way out so the failure is observable. Previously + * it was neither thrown nor emitted, and every surface except the TUI + * reported a green teardown over a post-script that never ran. */ async function executePostScript( db: Kysely, @@ -584,7 +653,11 @@ async function executePostScript( if (readErr) { - return { executed: false, error: `Failed to read script: ${readErr.message}` }; + const error = `Failed to read script: ${readErr.message}`; + + observer.emit('teardown:error', { error: new Error(error), object: scriptPath }); + + return { executed: false, error }; } @@ -600,7 +673,11 @@ async function executePostScript( if (execErr) { - return { executed: false, error: `Script failed: ${execErr.message}` }; + const error = `Script failed: ${execErr.message}`; + + observer.emit('teardown:error', { error: execErr, object: stmt }); + + return { executed: false, error }; } diff --git a/src/core/teardown/types.ts b/src/core/teardown/types.ts index 14feb86b..0d72f1c9 100644 --- a/src/core/teardown/types.ts +++ b/src/core/teardown/types.ts @@ -4,6 +4,24 @@ * Types for database reset and teardown operations. * Supports data wipe (truncate) and schema teardown (drop). */ +import type { DbPolicyContext } from '../db/policy.js'; + +/** + * A table identified by both halves of its name. + * + * The schema half is not decoration: an unqualified `TRUNCATE TABLE + * "secrets"` resolves against `search_path`, so a table outside the default + * schema aborts the run after earlier truncates have already committed. + */ +export interface TeardownTableRef { + + /** Bare table name. */ + name: string; + + /** Schema the table lives in, when the dialect has schemas. */ + schema?: string; + +} /** * Options for truncating table data. @@ -36,6 +54,13 @@ export interface TruncateOptions { /** Dry run - return SQL without executing */ dryRun?: boolean; + /** + * Access policy to enforce before wiping. Omitted by callers that + * already ran an equivalent gate (the SDK raises its own typed error); + * supplied by every caller that owns none. + */ + policy?: DbPolicyContext; + } /** @@ -60,6 +85,17 @@ export interface TeardownOptions { /** Additional tables to preserve beyond __noorm_* tables */ preserveTables?: string[]; + /** + * Schemas to leave untouched entirely. + * + * Teardown enumerates every non-system schema, so it reaches objects + * noorm never created — an application's own private schema, or another + * tool's bookkeeping. `preserveTables` cannot express this: it is a flat + * name list, so excluding `app_private.secrets` by name would also spare + * a `public.secrets`. + */ + preserveSchemas?: string[]; + /** Keep views (default: false) */ keepViews?: boolean; @@ -91,6 +127,11 @@ export interface TeardownOptions { */ executedBy?: string; + /** + * Access policy to enforce before dropping. See {@link TruncateOptions.policy}. + */ + policy?: DbPolicyContext; + } /** @@ -188,10 +229,14 @@ export interface TeardownDialectOperations { * single connection, to avoid the `sp_MSforeachtable` parallel-worker * deadlock that plagues schemas with many cross-FK tables. * + * Tables arrive as {@link TeardownTableRef} rather than bare names so + * MSSQL's per-table `ALTER TABLE` can be schema-qualified — a NOCHECK + * against an unqualified name outside `dbo` fails to resolve. + * * @param tables - Optional list of tables to scope the disable to * (used by MSSQL; ignored by other dialects). */ - disableForeignKeyChecks(tables?: string[]): string | string[]; + disableForeignKeyChecks(tables?: TeardownTableRef[]): string | string[]; /** * Generate SQL to re-enable FK checks. @@ -201,7 +246,7 @@ export interface TeardownDialectOperations { * @param tables - Optional list of tables to scope the enable to * (used by MSSQL; ignored by other dialects). */ - enableForeignKeyChecks(tables?: string[]): string | string[]; + enableForeignKeyChecks(tables?: TeardownTableRef[]): string | string[]; /** * Generate SQL to truncate a table. diff --git a/src/sdk/namespaces/db.ts b/src/sdk/namespaces/db.ts index e0d48803..7b305fba 100644 --- a/src/sdk/namespaces/db.ts +++ b/src/sdk/namespaces/db.ts @@ -2,7 +2,9 @@ * Db namespace — database exploration and schema operations. * * Mirrors [d] db in the TUI. All operations require a connection. - * Destructive operations are gated by the config's `db:reset` access + * Destructive operations are gated per action — `db:truncate`, `db:teardown` + * and `db:reset` — rather than all sharing `db:reset`, which is `allow` for + * admin and so asked nothing of the default config * (see `checkProtectedConfig` in ../guards.ts). */ import type { Kysely } from 'kysely'; @@ -28,10 +30,13 @@ import type { TruncateOptions, TruncateResult, TeardownOptions, TeardownResult, import { truncateData, teardownSchema, previewTeardown } from '../../core/teardown/index.js'; import { formatIdentity } from '../../core/identity/index.js'; +import { checkConfigPolicy } from '../../core/policy/index.js'; +import type { Permission } from '../../core/policy/index.js'; + import type { ContextState } from '../state.js'; import { requireConnection } from '../state.js'; import type { BuildOptions } from '../types.js'; -import { checkProtectedConfig } from '../guards.js'; +import { checkProtectedConfig, ProtectedConfigError } from '../guards.js'; // ───────────────────────────────────────────────────────────── // DbNamespace @@ -277,7 +282,20 @@ export class DbNamespace { */ async truncate(options?: TruncateOptions): Promise { - checkProtectedConfig(this.#state.config, this.#state.options, 'db:reset', 'truncate'); + // `db:truncate`, not `db:reset`: reset is `allow` for admin, so a + // default config wiped every table with nothing asked of it. + // A dry run is checked for permission but not for confirmation — + // see teardown() for why the preview must stay reachable. + if (options?.dryRun) { + + this.#assertAllowed('db:truncate', 'truncate'); + + } + else { + + checkProtectedConfig(this.#state.config, this.#state.options, 'db:truncate', 'truncate'); + + } const preserve = options?.preserve ?? this.#state.settings.teardown?.preserveTables; @@ -304,12 +322,26 @@ export class DbNamespace { */ async teardown(options?: TeardownOptions): Promise { - checkProtectedConfig(this.#state.config, this.#state.options, 'db:reset', 'teardown'); + // A dry run destroys nothing, so it is checked for permission but + // never for confirmation — the preview is the safety mechanism, and + // requiring `--yes` to look first would remove it. Denial still + // applies, so a role that may not tear down may not preview either. + if (options?.dryRun) { + + this.#assertAllowed('db:teardown', 'teardown'); + + } + else { + + checkProtectedConfig(this.#state.config, this.#state.options, 'db:teardown', 'teardown'); + + } return teardownSchema(this.#kysely, this.#dialect, { configName: this.#state.config.name, executedBy: formatIdentity(this.#state.identity), preserveTables: this.#state.settings.teardown?.preserveTables, + preserveSchemas: options?.preserveSchemas, postScript: this.#state.settings.teardown?.postScript, dryRun: options?.dryRun, }); @@ -352,6 +384,24 @@ export class DbNamespace { // Private // ───────────────────────────────────────────────────── + /** + * Enforce the allow/deny half of a permission without its confirmation. + * + * Raises the same `ProtectedConfigError` SDK consumers already catch, so + * a preview that the role forbids fails identically to an execution. + */ + #assertAllowed(permission: Permission, operation: string): void { + + const check = checkConfigPolicy(this.#state.options.channel ?? 'user', this.#state.config, permission); + + if (!check.allowed) { + + throw new ProtectedConfigError(this.#state.config.name, operation, check.blockedReason); + + } + + } + get #kysely(): Kysely { return requireConnection(this.#state).db; diff --git a/tests/cli/db/lifecycle-policy.test.ts b/tests/cli/db/lifecycle-policy.test.ts new file mode 100644 index 00000000..48ca98a4 --- /dev/null +++ b/tests/cli/db/lifecycle-policy.test.ts @@ -0,0 +1,311 @@ +/** + * cli: destructive db lifecycle commands — access-policy enforcement. + * + * The audit found two holes the green suite could not see. `db create` ran + * no policy check at all on the CLI while the TUI enforced `db:create`, so a + * `viewer` — the role defined as read-only — performed DDL. And `db truncate` + * / `db teardown` asked for nothing on the default `admin` role, because the + * permission they consulted (`db:reset`) is `allow` for admin; `--yes` and + * `--force` were declared args neither command read. + * + * These assert the *intent*: a destructive lifecycle command must be denied + * for a role the matrix denies, and must refuse to run unconfirmed for a role + * the matrix marks `confirm`. Driven as subprocesses against the compiled CLI + * (the commands call `process.exit`) using `drop.test.ts`'s fixture pattern. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { Database } from 'bun:sqlite'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'lifecycle'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: db lifecycle access policy', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-db-lifecycle-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-db-lifecycle-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + dbPath = join(tmpDir, 'target.db'); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one active config at the given access role, bypassing the TUI-only `config add`/`edit` commands. */ + async function seedConfig(access: ConfigAccess): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + /** Seeds a real user table so a blocked truncate/teardown can be proven non-destructive rather than merely non-zero-exit. */ + function seedData(): void { + + const db = new Database(dbPath); + db.run('CREATE TABLE IF NOT EXISTS widget (id INTEGER PRIMARY KEY, name TEXT)'); + db.run("INSERT INTO widget (name) VALUES ('a'), ('b'), ('c')"); + db.close(); + + } + + function rowCount(): number { + + const db = new Database(dbPath); + const row = db.query('SELECT count(*) AS n FROM widget').get() as { n: number }; + db.close(); + + return row.n; + + } + + function runDb(subcommand: string, args: string[] = [], envOverrides: Record = {}) { + + return spawnSync('node', [CLI, 'db', subcommand, ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...identityEnv, ...envOverrides }, + }); + + } + + // ───────────────────────────────────────────────────── + // db create — the missing authorization check + // ───────────────────────────────────────────────────── + + describe('db create', () => { + + it('denies a viewer and does not create the database', async () => { + + await seedConfig({ user: 'viewer', mcp: 'admin' }); + + const result = runDb('create'); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain( + `"db:create" is not allowed on config "${CONFIG_NAME}" (role: viewer).`, + ); + expect(existsSync(dbPath)).toBe(false); + + }); + + it('blocks an operator without --yes, naming the confirmation phrase, and does not create the database', async () => { + + await seedConfig({ user: 'operator', mcp: 'admin' }); + + const result = runDb('create'); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain(`yes-${CONFIG_NAME}`); + expect(existsSync(dbPath)).toBe(false); + + }); + + it('creates for an operator that passes --yes', async () => { + + await seedConfig({ user: 'operator', mcp: 'admin' }); + + const result = runDb('create', ['--json', '--yes']); + + expect(result.status).toBe(0); + expect(existsSync(dbPath)).toBe(true); + + }); + + it('creates for an admin without --yes, because db:create is allow for admin', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + + const result = runDb('create', ['--json']); + + expect(result.status).toBe(0); + expect(existsSync(dbPath)).toBe(true); + + }); + + }); + + // ───────────────────────────────────────────────────── + // db truncate — db:truncate is confirm for admin + // ───────────────────────────────────────────────────── + + describe('db truncate', () => { + + it('refuses to wipe data for an admin that passed no --yes, and leaves every row in place', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + seedData(); + + const result = runDb('truncate'); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('requires confirmation'); + expect(rowCount()).toBe(3); + + }); + + it('wipes data for an admin that passed --yes', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + seedData(); + + const result = runDb('truncate', ['--yes']); + + expect(result.status).toBe(0); + expect(rowCount()).toBe(0); + + }); + + it('wipes data when NOORM_YES=1 is set without --yes', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + seedData(); + + const result = runDb('truncate', [], { NOORM_YES: '1' }); + + expect(result.status).toBe(0); + expect(rowCount()).toBe(0); + + }); + + it('denies a viewer outright and leaves every row in place', async () => { + + await seedConfig({ user: 'viewer', mcp: 'admin' }); + seedData(); + + const result = runDb('truncate', ['--yes']); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('db:truncate'); + expect(rowCount()).toBe(3); + + }); + + }); + + // ───────────────────────────────────────────────────── + // db teardown — db:teardown is confirm for admin, deny below + // ───────────────────────────────────────────────────── + + describe('db teardown', () => { + + it('refuses to drop objects for an admin that passed no --yes, and leaves the table standing', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + seedData(); + + const result = runDb('teardown'); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('requires confirmation'); + expect(rowCount()).toBe(3); + + }); + + it('drops objects for an admin that passed --yes', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + seedData(); + + const result = runDb('teardown', ['--yes']); + + expect(result.status).toBe(0); + + const db = new Database(dbPath); + const found = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='widget'").all(); + db.close(); + + expect(found).toEqual([]); + + }); + + it('denies an operator even with --yes, because db:teardown is deny below admin', async () => { + + await seedConfig({ user: 'operator', mcp: 'admin' }); + seedData(); + + const result = runDb('teardown', ['--yes']); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('db:teardown'); + expect(rowCount()).toBe(3); + + }); + + it('allows a dry run to preview without the confirmation an execution would need', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + seedData(); + + const result = runDb('teardown', ['--dry-run', '--json']); + + expect(result.status).toBe(0); + expect(rowCount()).toBe(3); + + }); + + }); + +}); diff --git a/tests/cli/db/reset.test.ts b/tests/cli/db/reset.test.ts index dcec2e02..fb8ace7e 100644 --- a/tests/cli/db/reset.test.ts +++ b/tests/cli/db/reset.test.ts @@ -217,12 +217,15 @@ describe('cli: noorm db truncate/teardown/reset — operator-role --yes headless } - // truncate/teardown declare `yes: sharedArgs.yes` but have no CLI - // pre-gate of their own (spec C3) — they rely entirely on withContext - // threading `yes: isYesMode(args)` into createContext, which - // checkProtectedConfig then consults for the operator-role `confirm` - // cell. If that threading regresses, these fail closed (exit 1, - // ProtectedConfigError), not open. + // truncate/teardown have no CLI pre-gate of their own — they rely on + // withContext threading `yes: isYesMode(args)` into createContext, which + // checkProtectedConfig then consults. If that threading regresses, these + // fail closed (exit 1, ProtectedConfigError), not open. + // + // They now consult `db:truncate` and `db:teardown` rather than sharing + // `db:reset`, which was `allow` for admin and so asked nothing of the + // default config. `db:teardown` is `deny` below admin, so the operator + // case that used to pass for teardown is now a denial. it('noorm db truncate --yes succeeds headlessly for an operator-role config (no NOORM_YES)', async () => { @@ -234,13 +237,14 @@ describe('cli: noorm db truncate/teardown/reset — operator-role --yes headless }); - it('noorm db teardown --yes succeeds headlessly for an operator-role config (no NOORM_YES)', async () => { + it('noorm db teardown --yes is denied for an operator-role config, because db:teardown stops below admin', async () => { await seedConfig({ user: 'operator', mcp: 'admin' }); const result = runDb('teardown', ['--yes']); - expect(result.status).toBe(0); + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('db:teardown'); }); diff --git a/tests/cli/db/teardown.test.ts b/tests/cli/db/teardown.test.ts index 7ea79f8b..7d9e772f 100644 --- a/tests/cli/db/teardown.test.ts +++ b/tests/cli/db/teardown.test.ts @@ -116,7 +116,9 @@ describe('cli: noorm db teardown --dry-run', () => { it('should actually drop the table on a sibling non-dry-run teardown', async () => { - const result = runCli(project, ['db', 'teardown']); + // --yes is required now that `db:teardown` is a confirm cell for + // admin; the dry runs above deliberately need no such confirmation. + const result = runCli(project, ['db', 'teardown', '--yes']); expect(result.status).toBe(0); expect(await tableExists(project, 'noorm_teardown_dryrun')).toBe(false); diff --git a/tests/sdk/db-namespace.test.ts b/tests/sdk/db-namespace.test.ts index df241c1d..47bd5505 100644 --- a/tests/sdk/db-namespace.test.ts +++ b/tests/sdk/db-namespace.test.ts @@ -105,7 +105,10 @@ function createState( config: createMockConfig(), settings, identity: mockIdentity, - options: {}, + // Pre-confirmed: these cases are about preserve-list plumbing, and + // `db:truncate`/`db:teardown` are confirm cells even for admin, so + // without `yes` every one of them would stop at the access gate. + options: { yes: true }, projectRoot: '/tmp/test-project', changeManager: null, }; diff --git a/tests/sdk/destructive-ops.test.ts b/tests/sdk/destructive-ops.test.ts index 6fd5dc05..ca3f3160 100644 --- a/tests/sdk/destructive-ops.test.ts +++ b/tests/sdk/destructive-ops.test.ts @@ -155,12 +155,14 @@ describe('sdk: access-guarded destructive ops', () => { }); - it('should not throw ProtectedConfigError for teardown()', async () => { + it('should still throw ProtectedConfigError for teardown(), which stops below admin', async () => { + // teardown consults `db:teardown`, not `db:reset`. That cell is + // `deny` for operator, and a denial is not something `yes` can + // satisfy — only a confirm cell is. const db = new DbNamespace(makeState(OPERATOR_ACCESS, { yes: true })); - const err = await db.teardown().catch((e: unknown) => e); - expect(err).not.toBeInstanceOf(ProtectedConfigError); + await expect(db.teardown()).rejects.toThrow(ProtectedConfigError); }); @@ -276,9 +278,20 @@ describe('sdk: access-guarded destructive ops', () => { describe('DbNamespace on admin-role config', () => { - it('should not throw ProtectedConfigError for truncate()', async () => { + it('should throw ProtectedConfigError for an unconfirmed truncate()', async () => { + // The defect this encodes: truncate used to consult `db:reset`, + // which is `allow` for admin, so the default config wiped every + // table with nothing asked of it. `db:truncate` is `confirm`. const db = new DbNamespace(makeState(ADMIN_ACCESS)); + + await expect(db.truncate()).rejects.toThrow(ProtectedConfigError); + + }); + + it('should not throw ProtectedConfigError for a pre-confirmed truncate()', async () => { + + const db = new DbNamespace(makeState(ADMIN_ACCESS, { yes: true })); const err = await db.truncate().catch((e: unknown) => e); expect(err).not.toBeInstanceOf(ProtectedConfigError); diff --git a/tests/sdk/noorm-ops.test.ts b/tests/sdk/noorm-ops.test.ts index 423198ab..e0634dca 100644 --- a/tests/sdk/noorm-ops.test.ts +++ b/tests/sdk/noorm-ops.test.ts @@ -48,13 +48,16 @@ const mockIdentity: Identity = { source: 'system', }; -function createContext(dialect: Config['connection']['dialect'] = 'postgres') { +function createContext( + dialect: Config['connection']['dialect'] = 'postgres', + options: ConstructorParameters[3] = {}, +) { return new Context( createMockConfig(dialect), mockSettings, mockIdentity, - {}, + options, '/tmp/test-project', ); @@ -254,9 +257,13 @@ describe('sdk: NoormOps', () => { }); + // Pre-confirmed: the access gate now runs before the connection + // check (authorization is decided before anything is opened), so + // without `yes` these would fail on the policy rather than reach + // the NotConnectedError this case is about. it('should throw on db.truncate when not connected', async () => { - const ctx = createContext(); + const ctx = createContext('postgres', { yes: true }); await expect(ctx.noorm.db.truncate()).rejects.toThrow('Not connected'); @@ -264,7 +271,7 @@ describe('sdk: NoormOps', () => { it('should throw on db.teardown when not connected', async () => { - const ctx = createContext(); + const ctx = createContext('postgres', { yes: true }); await expect(ctx.noorm.db.teardown()).rejects.toThrow('Not connected'); From e1bd117a9b9a222763a1f345fad2c65bc969e469 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:03:23 -0400 Subject: [PATCH 068/105] 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. --- src/core/teardown/dialects/mssql.ts | 10 +- src/core/teardown/dialects/mysql.ts | 6 +- src/core/teardown/dialects/postgres.ts | 6 +- src/core/teardown/dialects/sqlite.ts | 6 +- tests/core/teardown/dialects/mssql.test.ts | 10 +- tests/core/teardown/operations.test.ts | 228 +++++++++++++++++++-- 6 files changed, 225 insertions(+), 41 deletions(-) diff --git a/src/core/teardown/dialects/mssql.ts b/src/core/teardown/dialects/mssql.ts index b8816764..6a3d225f 100644 --- a/src/core/teardown/dialects/mssql.ts +++ b/src/core/teardown/dialects/mssql.ts @@ -3,7 +3,7 @@ * * Microsoft SQL Server-specific SQL generation for teardown operations. */ -import type { TeardownDialectOperations } from '../types.js'; +import type { TeardownDialectOperations, TeardownTableRef } from '../types.js'; import { createDialectQuoting } from '../../shared/index.js'; const { quote, qualifiedName } = createDialectQuoting({ @@ -20,7 +20,7 @@ const { quote, qualifiedName } = createDialectQuoting({ */ export const mssqlTeardownOperations: TeardownDialectOperations = { - disableForeignKeyChecks(tables?: string[]): string | string[] { + disableForeignKeyChecks(tables?: TeardownTableRef[]): string | string[] { // MSSQL has no session-level FK toggle. With a table list, emit // per-table NOCHECK statements (sequential, single connection). @@ -30,7 +30,7 @@ export const mssqlTeardownOperations: TeardownDialectOperations = { // schema locks (see mssql-problems.md #6). if (tables && tables.length > 0) { - return tables.map((t) => `ALTER TABLE ${qualifiedName(t)} NOCHECK CONSTRAINT ALL`); + return tables.map((t) => `ALTER TABLE ${qualifiedName(t.name, t.schema)} NOCHECK CONSTRAINT ALL`); } @@ -38,14 +38,14 @@ export const mssqlTeardownOperations: TeardownDialectOperations = { }, - enableForeignKeyChecks(tables?: string[]): string | string[] { + enableForeignKeyChecks(tables?: TeardownTableRef[]): string | string[] { if (tables && tables.length > 0) { // WITH CHECK CHECK CONSTRAINT ALL re-validates existing rows. // Plain CHECK CONSTRAINT ALL marks the FK as trusted-only, which // matches the original sp_MSforeachtable behavior. - return tables.map((t) => `ALTER TABLE ${qualifiedName(t)} CHECK CONSTRAINT ALL`); + return tables.map((t) => `ALTER TABLE ${qualifiedName(t.name, t.schema)} CHECK CONSTRAINT ALL`); } diff --git a/src/core/teardown/dialects/mysql.ts b/src/core/teardown/dialects/mysql.ts index eab5474a..98373439 100644 --- a/src/core/teardown/dialects/mysql.ts +++ b/src/core/teardown/dialects/mysql.ts @@ -3,7 +3,7 @@ * * MySQL-specific SQL generation for teardown operations. */ -import type { TeardownDialectOperations } from '../types.js'; +import type { TeardownDialectOperations, TeardownTableRef } from '../types.js'; import { createDialectQuoting } from '../../shared/index.js'; const { quote, qualifiedName } = createDialectQuoting({ @@ -17,14 +17,14 @@ const { quote, qualifiedName } = createDialectQuoting({ */ export const mysqlTeardownOperations: TeardownDialectOperations = { - disableForeignKeyChecks(_tables?: string[]): string { + disableForeignKeyChecks(_tables?: TeardownTableRef[]): string { // Session-level setting — tables ignored return 'SET FOREIGN_KEY_CHECKS = 0'; }, - enableForeignKeyChecks(_tables?: string[]): string { + enableForeignKeyChecks(_tables?: TeardownTableRef[]): string { return 'SET FOREIGN_KEY_CHECKS = 1'; diff --git a/src/core/teardown/dialects/postgres.ts b/src/core/teardown/dialects/postgres.ts index 519c0ec2..e48f173f 100644 --- a/src/core/teardown/dialects/postgres.ts +++ b/src/core/teardown/dialects/postgres.ts @@ -3,7 +3,7 @@ * * PostgreSQL-specific SQL generation for teardown operations. */ -import type { TeardownDialectOperations } from '../types.js'; +import type { TeardownDialectOperations, TeardownTableRef } from '../types.js'; import { createDialectQuoting } from '../../shared/index.js'; const { quote, qualifiedName } = createDialectQuoting({ @@ -17,14 +17,14 @@ const { quote, qualifiedName } = createDialectQuoting({ */ export const postgresTeardownOperations: TeardownDialectOperations = { - disableForeignKeyChecks(_tables?: string[]): string { + disableForeignKeyChecks(_tables?: TeardownTableRef[]): string { // Session-level setting that disables FK triggers — tables ignored return 'SET session_replication_role = \'replica\''; }, - enableForeignKeyChecks(_tables?: string[]): string { + enableForeignKeyChecks(_tables?: TeardownTableRef[]): string { return 'SET session_replication_role = \'origin\''; diff --git a/src/core/teardown/dialects/sqlite.ts b/src/core/teardown/dialects/sqlite.ts index a14f2077..218c8bae 100644 --- a/src/core/teardown/dialects/sqlite.ts +++ b/src/core/teardown/dialects/sqlite.ts @@ -3,7 +3,7 @@ * * SQLite-specific SQL generation for teardown operations. */ -import type { TeardownDialectOperations } from '../types.js'; +import type { TeardownDialectOperations, TeardownTableRef } from '../types.js'; import { createDialectQuoting } from '../../shared/index.js'; const { quote } = createDialectQuoting({ @@ -23,14 +23,14 @@ const { quote } = createDialectQuoting({ */ export const sqliteTeardownOperations: TeardownDialectOperations = { - disableForeignKeyChecks(_tables?: string[]): string { + disableForeignKeyChecks(_tables?: TeardownTableRef[]): string { // Connection-level PRAGMA — tables ignored return 'PRAGMA foreign_keys = OFF'; }, - enableForeignKeyChecks(_tables?: string[]): string { + enableForeignKeyChecks(_tables?: TeardownTableRef[]): string { return 'PRAGMA foreign_keys = ON'; diff --git a/tests/core/teardown/dialects/mssql.test.ts b/tests/core/teardown/dialects/mssql.test.ts index 2493db1a..8b0f53ca 100644 --- a/tests/core/teardown/dialects/mssql.test.ts +++ b/tests/core/teardown/dialects/mssql.test.ts @@ -24,7 +24,7 @@ describe('teardown: mssql dialect', () => { it('returns per-table ALTER NOCHECK array when tables provided (deadlock-safe path)', () => { - const stmts = mssqlTeardownOperations.disableForeignKeyChecks(['A', 'B']); + const stmts = mssqlTeardownOperations.disableForeignKeyChecks([{ name: 'A' }, { name: 'B' }]); expect(stmts).toEqual([ 'ALTER TABLE [A] NOCHECK CONSTRAINT ALL', @@ -35,7 +35,7 @@ describe('teardown: mssql dialect', () => { it('quotes table names with brackets in per-table mode', () => { - const stmts = mssqlTeardownOperations.disableForeignKeyChecks(['weird]name']); + const stmts = mssqlTeardownOperations.disableForeignKeyChecks([{ name: 'weird]name' }]); expect(stmts).toEqual([ 'ALTER TABLE [weird]]name] NOCHECK CONSTRAINT ALL', @@ -45,7 +45,7 @@ describe('teardown: mssql dialect', () => { it('never emits sp_MSforeachtable in per-table mode (regression guard for M-6)', () => { - const stmts = mssqlTeardownOperations.disableForeignKeyChecks(['x', 'y', 'z']); + const stmts = mssqlTeardownOperations.disableForeignKeyChecks([{ name: 'x' }, { name: 'y' }, { name: 'z' }]); const flat = Array.isArray(stmts) ? stmts.join('\n') : stmts; expect(flat).not.toContain('sp_MSforeachtable'); @@ -66,7 +66,7 @@ describe('teardown: mssql dialect', () => { it('returns per-table ALTER CHECK array when tables provided', () => { - const stmts = mssqlTeardownOperations.enableForeignKeyChecks(['A', 'B']); + const stmts = mssqlTeardownOperations.enableForeignKeyChecks([{ name: 'A' }, { name: 'B' }]); expect(stmts).toEqual([ 'ALTER TABLE [A] CHECK CONSTRAINT ALL', @@ -77,7 +77,7 @@ describe('teardown: mssql dialect', () => { it('never emits sp_MSforeachtable in per-table mode (regression guard for M-6)', () => { - const stmts = mssqlTeardownOperations.enableForeignKeyChecks(['x', 'y']); + const stmts = mssqlTeardownOperations.enableForeignKeyChecks([{ name: 'x' }, { name: 'y' }]); const flat = Array.isArray(stmts) ? stmts.join('\n') : stmts; expect(flat).not.toContain('sp_MSforeachtable'); diff --git a/tests/core/teardown/operations.test.ts b/tests/core/teardown/operations.test.ts index 295f37de..c6f783c0 100644 --- a/tests/core/teardown/operations.test.ts +++ b/tests/core/teardown/operations.test.ts @@ -384,8 +384,10 @@ describe('teardown: truncateData preserve filtering', () => { dryRun: true, }); + // Schema-qualified: the statement must not depend on search_path + // being what it was when fetchList ran. const truncateStmts = result.statements.filter(s => s.includes('TRUNCATE')); - expect(truncateStmts).toEqual(['TRUNCATE TABLE "users" RESTART IDENTITY CASCADE']); + expect(truncateStmts).toEqual(['TRUNCATE TABLE "public"."users" RESTART IDENTITY CASCADE']); }); @@ -430,22 +432,48 @@ describe('teardown: truncateData mssql NOCHECK strategy (M-6)', () => { const checks = result.statements.filter((s) => s.includes('CHECK CONSTRAINT ALL') && !s.includes('NOCHECK')); const deletes = result.statements.filter((s) => s.includes('DELETE FROM')); - // truncateData passes only table names (no schema) to the dialect ops, - // so the emitted ALTER/DELETE statements are unqualified. + // Every statement carries the schema fetchList reported. An + // unqualified name resolves against the session's default schema, + // which is only ever right by luck. expect(nocheck).toEqual([ - 'ALTER TABLE [users] NOCHECK CONSTRAINT ALL', - 'ALTER TABLE [posts] NOCHECK CONSTRAINT ALL', + 'ALTER TABLE [dbo].[users] NOCHECK CONSTRAINT ALL', + 'ALTER TABLE [dbo].[posts] NOCHECK CONSTRAINT ALL', ]); expect(deletes.length).toBe(2); - expect(deletes[0]).toContain('DELETE FROM [users]'); - expect(deletes[1]).toContain('DELETE FROM [posts]'); + expect(deletes[0]).toContain('DELETE FROM [dbo].[users]'); + expect(deletes[1]).toContain('DELETE FROM [dbo].[posts]'); expect(checks).toEqual([ - 'ALTER TABLE [users] CHECK CONSTRAINT ALL', - 'ALTER TABLE [posts] CHECK CONSTRAINT ALL', + 'ALTER TABLE [dbo].[users] CHECK CONSTRAINT ALL', + 'ALTER TABLE [dbo].[posts] CHECK CONSTRAINT ALL', ]); }); + it('qualifies every statement for a table outside the default schema', async () => { + + // The finding this suite used to pin: a table in a non-default + // schema was truncated as a bare name, so the statement resolved + // against the wrong schema — or nothing at all — after earlier + // truncates had already committed. + const db = createMockKysely([ + { table_name: 'users', schema_name: 'dbo', column_count: 3, row_count: 0 }, + { table_name: 'secrets', schema_name: 'app_private', column_count: 2, row_count: 0 }, + ]); + + const result = await truncateData(db, 'mssql', { dryRun: true }); + + const nocheck = result.statements.filter((s) => s.includes('NOCHECK CONSTRAINT ALL')); + const deletes = result.statements.filter((s) => s.includes('DELETE FROM')); + + expect(nocheck).toContain('ALTER TABLE [app_private].[secrets] NOCHECK CONSTRAINT ALL'); + expect(deletes.some((s) => s.includes('DELETE FROM [app_private].[secrets]'))).toBe(true); + + // ...and the report names the schema, so an operator can tell which + // `secrets` was emptied. + expect(result.truncated).toEqual(['users', 'app_private.secrets']); + + }); + it('never emits sp_MSforeachtable for mssql truncate (regression guard)', async () => { const db = createMockKysely([ @@ -539,18 +567,18 @@ describe('teardown: truncateData FK re-enable guarantee (v1-03)', () => { { table_name: 'users', schema_name: 'dbo', column_count: 1, row_count: 0 }, { table_name: 'posts', schema_name: 'dbo', column_count: 1, row_count: 0 }, ], - (stmt) => stmt.includes('DELETE FROM [users]'), + (stmt) => stmt.includes('DELETE FROM [dbo].[users]'), ); const [, err] = await attempt(() => truncateData(db, 'mssql')); expect(err).toBeInstanceOf(Error); - expect(err?.message).toContain('DELETE FROM [users]'); + expect(err?.message).toContain('DELETE FROM [dbo].[users]'); const checks = executed.filter((s) => s.includes('CHECK CONSTRAINT ALL') && !s.includes('NOCHECK')); expect(checks).toEqual([ - 'ALTER TABLE [users] CHECK CONSTRAINT ALL', - 'ALTER TABLE [posts] CHECK CONSTRAINT ALL', + 'ALTER TABLE [dbo].[users] CHECK CONSTRAINT ALL', + 'ALTER TABLE [dbo].[posts] CHECK CONSTRAINT ALL', ]); }); @@ -578,18 +606,18 @@ describe('teardown: truncateData FK re-enable guarantee (v1-03)', () => { { table_name: 'users', schema_name: 'dbo', column_count: 1, row_count: 0 }, { table_name: 'posts', schema_name: 'dbo', column_count: 1, row_count: 0 }, ], - (stmt) => stmt === 'ALTER TABLE [users] CHECK CONSTRAINT ALL', + (stmt) => stmt === 'ALTER TABLE [dbo].[users] CHECK CONSTRAINT ALL', ); const [, err] = await attempt(() => truncateData(db, 'mssql')); expect(err).toBeInstanceOf(Error); - expect(err?.message).toContain('ALTER TABLE [users] CHECK CONSTRAINT ALL'); + expect(err?.message).toContain('ALTER TABLE [dbo].[users] CHECK CONSTRAINT ALL'); const checks = executed.filter((s) => s.includes('CHECK CONSTRAINT ALL') && !s.includes('NOCHECK')); expect(checks).toEqual([ - 'ALTER TABLE [users] CHECK CONSTRAINT ALL', - 'ALTER TABLE [posts] CHECK CONSTRAINT ALL', + 'ALTER TABLE [dbo].[users] CHECK CONSTRAINT ALL', + 'ALTER TABLE [dbo].[posts] CHECK CONSTRAINT ALL', ]); }); @@ -600,7 +628,7 @@ describe('teardown: truncateData FK re-enable guarantee (v1-03)', () => { [ { table_name: 'users', schema_name: 'dbo', column_count: 1, row_count: 0 }, ], - (stmt) => stmt.includes('DELETE FROM [users]') || stmt === 'ALTER TABLE [users] CHECK CONSTRAINT ALL', + (stmt) => stmt.includes('DELETE FROM [dbo].[users]') || stmt === 'ALTER TABLE [dbo].[users] CHECK CONSTRAINT ALL', ); const [, err] = await attempt(() => truncateData(db, 'mssql')); @@ -609,12 +637,12 @@ describe('teardown: truncateData FK re-enable guarantee (v1-03)', () => { // The DELETE failure is captured first and takes priority over the // later CHECK CONSTRAINT ALL failure — the caller needs to know why // the truncate itself broke. - expect(err?.message).toContain('DELETE FROM [users]'); + expect(err?.message).toContain('DELETE FROM [dbo].[users]'); expect(err?.message).not.toContain('CHECK CONSTRAINT ALL'); // The enable phase must still have been attempted despite the // earlier truncate failure, even though it also failed. - expect(executed).toContain('ALTER TABLE [users] CHECK CONSTRAINT ALL'); + expect(executed).toContain('ALTER TABLE [dbo].[users] CHECK CONSTRAINT ALL'); }); @@ -642,8 +670,8 @@ describe('teardown: truncateData FK re-enable guarantee (v1-03)', () => { // Both tables' CHECK CONSTRAINT ALL fail — each must emit its own // teardown:error, proving the loop doesn't stop after the first. expect(enableErrorEvents.length).toBe(2); - expect(enableErrorEvents[0]!.object).toBe('ALTER TABLE [users] CHECK CONSTRAINT ALL'); - expect(enableErrorEvents[1]!.object).toBe('ALTER TABLE [posts] CHECK CONSTRAINT ALL'); + expect(enableErrorEvents[0]!.object).toBe('ALTER TABLE [dbo].[users] CHECK CONSTRAINT ALL'); + expect(enableErrorEvents[1]!.object).toBe('ALTER TABLE [dbo].[posts] CHECK CONSTRAINT ALL'); } finally { @@ -696,6 +724,162 @@ describe('teardown: teardownSchema preserveTables filtering', () => { }); +// ───────────────────────────────────────────────────────────── +// teardownSchema — foreign schemas +// +// Teardown enumerates every non-system schema, so it reaches objects +// noorm never created. Two separate problems: the operator could not +// see them coming (the preview reported bare names), and had no way to +// opt them out (preserveTables is a flat list with no schema half, so +// excluding `app_private.secrets` would also spare `public.secrets`). +// ───────────────────────────────────────────────────────────── + +describe('teardown: teardownSchema foreign schemas', () => { + + it('qualifies objects outside the default schema so the preview shows the blast radius', async () => { + + const db = createMockKysely([ + tableRow('users'), + tableRow('secrets', 'app_private'), + ]); + + const result = await teardownSchema(db, 'postgres', { dryRun: true }); + + expect(result.dropped.tables).toEqual(['users', 'app_private.secrets']); + + }); + + it('leaves a schema listed in preserveSchemas entirely alone', async () => { + + const db = createMockKysely([ + tableRow('users'), + tableRow('secrets', 'app_private'), + ]); + + const result = await teardownSchema(db, 'postgres', { + preserveSchemas: ['app_private'], + dryRun: true, + }); + + expect(result.dropped.tables).toEqual(['users']); + expect(result.preserved).toEqual(['app_private.secrets']); + expect(result.statements.join('\n')).not.toContain('app_private'); + + }); + + it('does not spare a same-named table in the default schema', async () => { + + // preserveSchemas is schema-scoped, unlike preserveTables — a + // public.secrets must still be dropped when app_private is spared. + const db = createMockKysely([ + tableRow('secrets'), + tableRow('secrets', 'app_private'), + ]); + + const result = await teardownSchema(db, 'postgres', { + preserveSchemas: ['app_private'], + dryRun: true, + }); + + expect(result.dropped.tables).toEqual(['secrets']); + expect(result.preserved).toEqual(['app_private.secrets']); + + }); + +}); + +// ───────────────────────────────────────────────────────────── +// Core-seam policy gate +// +// core/db and core/teardown held zero policy calls, unlike core/runner, +// core/change, core/transfer and core/sql-terminal. Every surface +// re-implemented the check and two cells came out empty. +// ───────────────────────────────────────────────────────────── + +describe('teardown: core-seam policy gate', () => { + + const viewer = { configName: 'prod', access: { user: 'viewer', mcp: 'viewer' } } as const; + const admin = { configName: 'prod', access: { user: 'admin', mcp: 'admin' } } as const; + + it('refuses a truncate the role denies', async () => { + + const db = createMockKysely([tableRow('users')]); + + const [, err] = await attempt(() => truncateData(db, 'postgres', { policy: viewer, dryRun: true })); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('db:truncate'); + + }); + + it('refuses an unconfirmed truncate the role marks confirm', async () => { + + const db = createMockKysely([tableRow('users')]); + + const [, err] = await attempt(() => truncateData(db, 'postgres', { policy: admin })); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('requiring confirmation'); + + }); + + it('allows a truncate the caller pre-confirmed', async () => { + + const db = createMockKysely([tableRow('users')]); + + const [result, err] = await attempt(() => truncateData(db, 'postgres', { + policy: { ...admin, yes: true }, + dryRun: true, + })); + + expect(err).toBeNull(); + expect(result?.truncated).toEqual(['users']); + + }); + + it('refuses a teardown the role denies', async () => { + + const db = createMockKysely([tableRow('users')]); + + const [, err] = await attempt(() => teardownSchema(db, 'postgres', { policy: viewer, dryRun: true })); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('db:teardown'); + + }); + + it('lets an allowed role preview without the confirmation an execution needs', async () => { + + const db = createMockKysely([tableRow('users')]); + + const [preview, previewErr] = await attempt(() => teardownSchema(db, 'postgres', { + policy: admin, + dryRun: true, + })); + + expect(previewErr).toBeNull(); + expect(preview?.dropped.tables).toEqual(['users']); + + const [, execErr] = await attempt(() => teardownSchema(db, 'postgres', { policy: admin })); + + expect(execErr).toBeInstanceOf(Error); + expect(execErr?.message).toContain('requiring confirmation'); + + }); + + it('runs ungated when no policy is supplied, for callers that own their own gate', async () => { + + const db = createMockKysely([tableRow('users')]); + + const [result, err] = await attempt(() => truncateData(db, 'postgres', { dryRun: true })); + + expect(err).toBeNull(); + expect(result?.truncated).toEqual(['users']); + + }); + +}); + // ───────────────────────────────────────────────────────────── // teardownSchema — drop order (M-5: schema-bound deps) // MSSQL schema-bound UDFs/views hold dependency locks on tables. From 8f72fa8d64c1f0b1ec4f6eb6edcd94a49abb4297 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:03:30 -0400 Subject: [PATCH 069/105] 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. --- src/cli/db/drop.ts | 39 ++++++++++++-- src/core/connection/factory.ts | 30 ++++++++++- tests/core/connection/factory.test.ts | 74 ++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 5 deletions(-) diff --git a/src/cli/db/drop.ts b/src/cli/db/drop.ts index 335b1573..2bad80c6 100644 --- a/src/cli/db/drop.ts +++ b/src/cli/db/drop.ts @@ -75,6 +75,25 @@ const dropCommand = defineCommand({ const configName = config.name; + // The role is a statement about a *config*; NOORM_CONNECTION_* can + // repoint that config at any database the credentials reach, so the + // thing being authorised and the thing being destroyed can differ. + // The retargeting is deliberate (#51) — going silent about it is not. + const stored = stateManager.getConfig(configName); + const storedDatabase = stored?.connection?.database; + const target = config.connection.database; + + const targetOverridden = Boolean(storedDatabase && storedDatabase !== target); + + if (targetOverridden) { + + // stderr, so a --json consumer's stdout stays parseable. + process.stderr.write( + `Warning: config "${configName}" stores database "${storedDatabase}", but "${target}" is what will be dropped.\n`, + ); + + } + const check = checkConfigPolicy('user', config, 'db:destroy'); if (!check.allowed) { @@ -94,7 +113,12 @@ const dropCommand = defineCommand({ } - const result = await destroyDb(config.connection, configName); + // The gate above produces the operator-facing message; passing the + // policy on re-checks it at the core seam, so a future caller that + // forgets its own gate still cannot drop a database. + const result = await destroyDb(config.connection, configName, { + policy: { configName, access: config.access, yes: args.yes }, + }); if (!result.ok) { @@ -103,10 +127,19 @@ const dropCommand = defineCommand({ } + const dropped = result.dropped ?? false; + outputResult( args, - { config: configName, database: config.connection.database, dropped: true }, - `Database "${config.connection.database}" dropped.`, + { + config: configName, + database: config.connection.database, + dropped, + ...(targetOverridden ? { targetOverridden, storedDatabase } : {}), + }, + dropped + ? `Database "${config.connection.database}" dropped.` + : `Database "${config.connection.database}" did not exist — nothing to drop.`, ); process.exit(0); diff --git a/src/core/connection/factory.ts b/src/core/connection/factory.ts index 19c4f9cd..134dc125 100644 --- a/src/core/connection/factory.ts +++ b/src/core/connection/factory.ts @@ -4,8 +4,11 @@ * Creates database connections with automatic retry for transient failures. * Uses lazy imports to avoid requiring all database drivers. */ +import { accessSync, constants as fsConstants, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + import { sql } from 'kysely'; -import { retry, attempt } from '@logosdx/utils'; +import { retry, attempt, attemptSync } from '@logosdx/utils'; import type { ConnectionConfig, ConnectionResult, Dialect } from './types.js'; import { observer } from '../observer.js'; import { getConnectionManager } from './manager.js'; @@ -268,6 +271,31 @@ export async function testConnection( } + // SQLite has no system database to swap to, so the probe would open the + // target — and the driver creates the file. Probe the directory that + // would hold it instead: that is the SQLite equivalent of "can I reach + // the server", and it leaves nothing behind. An existing target still + // gets opened, so a corrupt or unreadable file is still reported. + if (options.testServerOnly && config.dialect === 'sqlite') { + + const filename = config.filename ?? config.database; + + if (filename !== ':memory:' && !existsSync(filename)) { + + const [, dirErr] = attemptSync(() => accessSync(dirname(resolve(filename)), fsConstants.W_OK)); + + if (dirErr) { + + return { ok: false, error: `Cannot reach SQLite target directory: ${dirErr.message}` }; + + } + + return { ok: true }; + + } + + } + const [conn, err] = await attempt(() => createConnection(testConfig, '__test__')); if (err) { diff --git a/tests/core/connection/factory.test.ts b/tests/core/connection/factory.test.ts index 17abbcd3..2e941caf 100644 --- a/tests/core/connection/factory.test.ts +++ b/tests/core/connection/factory.test.ts @@ -3,7 +3,10 @@ * * Uses SQLite in-memory databases for testing (no external DB needed). */ -import { describe, it, expect, afterEach } from 'bun:test'; +import { describe, it, expect, afterEach, beforeEach } from 'bun:test'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { sql } from 'kysely'; import { createConnection, testConnection } from '../../../src/core/connection/index.js'; import type { ConnectionConfig } from '../../../src/core/connection/index.js'; @@ -209,6 +212,75 @@ describe('connection: factory', () => { }); + // ───────────────────────────────────────────────────── + // testServerOnly is documented as "verify credentials without + // requiring the target database" — the setup-wizard case. On SQLite + // there is no system database to swap to, so the probe used to open + // the target path, and the driver created it. A wizard's "Test + // Connection" button then left a zero-byte database behind and the + // user could no longer tell a fresh target from one testing made. + // ───────────────────────────────────────────────────── + + describe('testConnection with testServerOnly on sqlite', () => { + + let tmpDir: string; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-server-only-')); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + + }); + + it('does not create the target file when it does not exist yet', async () => { + + const dbPath = join(tmpDir, 'fresh.db'); + + const config: ConnectionConfig = { dialect: 'sqlite', database: dbPath }; + + const result = await testConnection(config, { testServerOnly: true }); + + expect(result.ok).toBe(true); + expect(existsSync(dbPath)).toBe(false); + + }); + + it('still reports failure when the target directory is not reachable', async () => { + + const config: ConnectionConfig = { + dialect: 'sqlite', + database: '/nonexistent/path/that/does/not/exist/db.sqlite', + }; + + const result = await testConnection(config, { testServerOnly: true }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + + }); + + it('still opens and validates a target that already exists', async () => { + + const dbPath = join(tmpDir, 'existing.db'); + const seed = await createConnection({ dialect: 'sqlite', database: dbPath }, 'seed'); + await seed.destroy(); + + expect(existsSync(dbPath)).toBe(true); + + const result = await testConnection({ dialect: 'sqlite', database: dbPath }, { testServerOnly: true }); + + expect(result.ok).toBe(true); + expect(existsSync(dbPath)).toBe(true); + + }); + + }); + describe('connection lifecycle', () => { it('should destroy connection cleanly', async () => { From 5ea9760325b13cd09070bd9fc42a75a6458b6848 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:03:47 -0400 Subject: [PATCH 070/105] docs(execution): record the dedup key and the real per-dialect behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/guide/sql-files/execution.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guide/sql-files/execution.md b/docs/guide/sql-files/execution.md index 7ca9ec21..df723bf3 100644 --- a/docs/guide/sql-files/execution.md +++ b/docs/guide/sql-files/execution.md @@ -7,12 +7,14 @@ When you run SQL files with noorm, every file goes through a checksum-based chan Here's what happens under the hood: -1. noorm computes a SHA-256 checksum of the file contents +1. noorm computes a SHA-256 checksum of the SQL that will actually run — for a `.sql.tmpl`, that is the *rendered* output, not the file on disk 2. It checks the tracking database for a previous execution record 3. If the file is new, changed, or previously failed, it runs 4. If unchanged and successful, it's skipped 5. After execution, the result and new checksum are recorded +Hashing the rendered output is what makes a template re-run when its data file, config, or secrets change even though its own bytes did not. The flip side: a template whose render is genuinely non-deterministic — one calling `$.now()` or `$.uuid()` — produces new SQL every time and therefore re-runs every build. That is the correct answer for a file whose output really is different each time; use a fixed value if you want it to settle. + This makes builds idempotent. Run the same command twice—unchanged files won't re-execute. @@ -223,7 +225,7 @@ Three related procedures, one file. If batch 2 fails, noorm reports `[batch 2 of `GO` recognition is line-oriented: the literal token `GO` must be the entire trimmed content of a line (case-insensitive). `GO;`, `GOLANG`, or `GO` mid-line do not split. Keep `GO` tokens out of string literals and `/* ... */` block comments — the splitter does not parse SQL, so an inadvertent `GO` inside a string on its own line will still be treated as a separator. This matches sqlcmd behavior. -PostgreSQL, MySQL, and SQLite do not need this — their drivers accept multiple statements separated by `;` (with the caveats covered in [Organization](/guide/sql-files/organization)). The splitter only engages when the active config's dialect is `mssql`. +PostgreSQL runs a multi-statement file as-is, in one implicit transaction. SQLite gets its own splitter: its driver compiles the first statement of a string and discards the rest, so noorm splits on statement boundaries before handing anything over — semicolons inside string literals, quoted identifiers, comments, and trigger bodies are not treated as boundaries. MySQL's driver rejects multi-statement strings outright; put each statement in its own file, or use a dialect that supports them. For the gory details on what the runner does under the hood, see [MSSQL Batch Handling](/dev/runner#mssql-batch-handling). From 96f579c879caf04b736f8490f4d53a0c6e1df534 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 13:58:53 -0400 Subject: [PATCH 071/105] fix(lock): make lock acquisition atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/lock/manager.ts | 163 +++++++++++------ tests/core/lock/contention.test.ts | 270 +++++++++++++++++++++++++++++ tests/core/lock/race-worker.ts | 65 +++++++ 3 files changed, 442 insertions(+), 56 deletions(-) create mode 100644 tests/core/lock/contention.test.ts create mode 100644 tests/core/lock/race-worker.ts diff --git a/src/core/lock/manager.ts b/src/core/lock/manager.ts index 3bc1b493..07112690 100644 --- a/src/core/lock/manager.ts +++ b/src/core/lock/manager.ts @@ -146,26 +146,25 @@ class LockManager { options: LockOptions = {}, ): Promise { - // Declaration const opts = { ...DEFAULT_LOCK_OPTIONS, ...options }; const startTime = Date.now(); - // Emit acquiring event + // Bounds the pathological case where the row we lost to is released + // before we can read who held it — without it, a wait:false caller + // could spin indefinitely against a churning lock. + let vanishedHolderRetries = 3; + observer.emit('lock:acquiring', { configName, identity }); - // Business Logic while (true) { - // Clean up expired locks first await this.#cleanupExpired(db, configName, opts.dialect); - // Try to get existing lock const existing = await this.#getLock(db, configName, opts.dialect); - if (!existing) { + if (existing?.lockedBy === identity) { - // No lock exists, create one - const lock = await this.#createLock(db, configName, identity, opts); + const lock = await this.#extendLock(db, configName, identity, opts); observer.emit('lock:acquired', { configName, identity, @@ -176,56 +175,71 @@ class LockManager { } - // Lock exists and is not expired (cleanup already ran) - // Check if it's ours - if (existing.lockedBy === identity) { + let holder = existing; - // We already hold the lock - extend it - const lock = await this.#extendLock(db, configName, identity, opts); - observer.emit('lock:acquired', { - configName, - identity, - expiresAt: lock.expiresAt, - }); + if (!holder) { - return lock; + const created = await this.#tryCreateLock(db, configName, identity, opts); - } + if (created) { - // Lock held by someone else - observer.emit('lock:blocked', { - configName, - holder: existing.lockedBy, - heldSince: existing.lockedAt, - }); + observer.emit('lock:acquired', { + configName, + identity, + expiresAt: created.expiresAt, + }); - if (!opts.wait) { + return created; - throw new LockAcquireError( - configName, - existing.lockedBy, - existing.lockedAt, - existing.expiresAt, - existing.reason, - ); + } + + // Lost the race — find out to whom. + holder = await this.#getLock(db, configName, opts.dialect); + + if (!holder) { + + vanishedHolderRetries -= 1; + + if (vanishedHolderRetries <= 0) { + + throw new LockAcquireError( + configName, + 'unknown', + new Date(), + new Date(), + 'repeatedly lost the acquire race to a lock that was released before it could be read', + ); + + } + + continue; + + } + + // The winner was another process running as us; the next + // iteration takes the extend branch. + if (holder.lockedBy === identity) continue; } - // Check wait timeout - const elapsed = Date.now() - startTime; - if (elapsed >= opts.waitTimeout) { + observer.emit('lock:blocked', { + configName, + holder: holder.lockedBy, + heldSince: holder.lockedAt, + }); + + if (!opts.wait || Date.now() - startTime >= opts.waitTimeout) { throw new LockAcquireError( configName, - existing.lockedBy, - existing.lockedAt, - existing.expiresAt, - existing.reason, + holder.lockedBy, + holder.lockedAt, + holder.expiresAt, + holder.reason, ); } - // Wait and retry await wait(opts.pollInterval); } @@ -498,38 +512,75 @@ class LockManager { } /** - * Create a new lock in the database. + * Claim the lock with a single atomic statement. + * + * WHY one statement: a SELECT-then-INSERT leaves a window in which two + * processes both see "no lock" and both insert. The `config_name` UNIQUE + * constraint stops the second row, but only by surfacing a raw driver + * error to the caller instead of `LockAcquireError`. Letting the database + * arbitrate in one statement makes losing the race an ordinary, typed + * outcome rather than an exception. + * + * @returns the Lock when this caller won, or null when another caller's + * row landed first */ - async #createLock( + async #tryCreateLock( db: Kysely, configName: string, identity: string, opts: Required> & { reason?: string }, - ): Promise { + ): Promise { const ndb = noormDb(db, opts.dialect); const tables = getNoormTables(opts.dialect); const now = new Date(); const expiresAt = new Date(now.getTime() + opts.timeout); - await ndb - .insertInto(tables.lock) - .values({ - config_name: configName, - locked_by: identity, - locked_at: formatDateForDialect(now, opts.dialect) as Date, - expires_at: formatDateForDialect(expiresAt, opts.dialect) as Date, - reason: opts.reason ?? '', - }) - .execute(); + const values = { + config_name: configName, + locked_by: identity, + locked_at: formatDateForDialect(now, opts.dialect) as Date, + expires_at: formatDateForDialect(expiresAt, opts.dialect) as Date, + reason: opts.reason ?? '', + }; - return { + const lock: Lock = { lockedBy: identity, lockedAt: now, expiresAt, reason: opts.reason, }; + // MSSQL has no ON CONFLICT, so the UNIQUE constraint is the arbiter + // there: a failed insert with a row now present *is* contention. + // Anything else is a real failure and must propagate. + if (opts.dialect === 'mssql') { + + const [, err] = await attempt(() => + ndb.insertInto(tables.lock).values(values).execute(), + ); + + if (!err) return lock; + + const holder = await this.#getLock(db, configName, opts.dialect); + + if (holder) return null; + + throw err; + + } + + const query = opts.dialect === 'mysql' + ? ndb.insertInto(tables.lock).values(values).ignore() + : ndb + .insertInto(tables.lock) + .values(values) + .onConflict((oc) => oc.column('config_name').doNothing()); + + const result = await query.executeTakeFirst(); + + return (result?.numInsertedOrUpdatedRows ?? 0n) > 0n ? lock : null; + } /** diff --git a/tests/core/lock/contention.test.ts b/tests/core/lock/contention.test.ts new file mode 100644 index 00000000..dc9e55d5 --- /dev/null +++ b/tests/core/lock/contention.test.ts @@ -0,0 +1,270 @@ +/** + * Lock contention tests. + * + * WHY these tests exist: the lock suite had 53 `it()` blocks and not one of + * them put two callers in contention, so `acquire()` shipped as an + * unserialised SELECT-then-INSERT. The `config_name` UNIQUE constraint — not + * the code — was what actually prevented a second lock row, which meant the + * loser received a raw driver error instead of `LockAcquireError`, and + * `wait: true` threw in milliseconds instead of polling to `waitTimeout`. + * + * These assert the contract callers are promised: exactly one winner, a + * domain error naming the holder for everyone else, and a `wait` that + * actually waits. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'bun:test'; +import { attempt } from '@logosdx/utils'; +import type { Kysely } from 'kysely'; + +import { createConnection } from '../../../src/core/connection/factory.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; +import { getLockManager } from '../../../src/core/lock/index.js'; +import { getNoormTables, noormDb, type NoormDatabase } from '../../../src/core/shared/index.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { v2 } from '../../../src/core/version/schema/migrations/v2.js'; +import { skipIfNoContainer, TEST_CONNECTIONS } from '../../utils/db.js'; + +const DIALECT: Dialect = 'postgres'; +const WORKER = new URL('./race-worker.ts', import.meta.url).pathname; + +/** Outcome shape emitted by race-worker.ts. */ +interface RaceResult { + identity: string; + outcome: 'ACQUIRED' | 'THREW'; + elapsed: number; + lockedBy: string | null; + errorName: string | null; + errorMessage: string | null; + holder: string | null; + driverCode: string | number | null; +} + +let configCounter = 0; + +function nextConfigName(): string { + + configCounter += 1; + + return `race_test_${process.pid}_${Date.now()}_${configCounter}`; + +} + +/** + * Run N separate processes that all try to acquire the same lock at once. + * + * @param lead - milliseconds of head start, so every worker is connected and + * parked on the barrier before any of them attempts the acquire + */ +async function race( + configName: string, + identities: string[], + opts: { lead?: number; timeoutMs?: number; waitTimeoutMs?: number } = {}, +): Promise { + + const start = Date.now() + (opts.lead ?? 2_000); + + const procs = identities.map((identity) => { + + const argv = [ + 'bun', WORKER, configName, identity, String(start), DIALECT, + String(opts.timeoutMs ?? 60_000), + ]; + + if (opts.waitTimeoutMs !== undefined) argv.push(String(opts.waitTimeoutMs)); + + return Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' }); + + }); + + const outputs = await Promise.all( + procs.map(async (p) => { + + const [out, errOut] = await Promise.all([ + new Response(p.stdout).text(), + new Response(p.stderr).text(), + ]); + + await p.exited; + + const line = out.trim().split('\n').filter(Boolean).pop(); + + if (!line) { + + throw new Error(`Worker produced no result. stderr:\n${errOut}`); + + } + + return JSON.parse(line) as RaceResult; + + }), + ); + + return outputs; + +} + +/** + * Fail loudly, with the raw worker output, when a race produced no winner for + * a reason that is not contention. + * + * Postgres is shared, and a saturated server makes every worker throw a + * connection error — which would otherwise surface as a bare "expected length + * 1, got 0" and read like a lock regression. Contention always leaves either a + * winner or a LockAcquireError, so anything else is infrastructure. + */ +function assertRaceHealthy(results: RaceResult[]): void { + + const acquired = results.some((r) => r.outcome === 'ACQUIRED'); + const contended = results.some((r) => r.errorName === 'LockAcquireError'); + + if (acquired || contended) return; + + throw new Error( + 'No worker acquired the lock and none reported contention — the database is ' + + `probably unreachable or saturated, not the lock:\n${JSON.stringify(results, null, 2)}`, + ); + +} + +describe('lock: contention', () => { + + let db: Kysely; + let destroy: () => Promise; + + beforeAll(async () => { + + await skipIfNoContainer(DIALECT); + + const conn = await createConnection(TEST_CONNECTIONS[DIALECT], '__contention__'); + db = conn.db as Kysely; + destroy = conn.destroy; + + const tables = getNoormTables(DIALECT); + const ndb = noormDb(db, DIALECT); + + const [, missing] = await attempt(() => + ndb.selectFrom(tables.lock).select('id').limit(1).executeTakeFirst(), + ); + + if (missing) { + + await attempt(() => v1.up(db as Kysely, DIALECT)); + await attempt(() => v2.up(db as Kysely, DIALECT)); + + } + + }); + + afterEach(() => { + + getLockManager(); + + }); + + afterAll(async () => { + + await destroy(); + + }); + + it('should let exactly one of three competing processes acquire the lock', async () => { + + const configName = nextConfigName(); + + const results = await race(configName, ['alice', 'bob', 'carol']); + + assertRaceHealthy(results); + + const winners = results.filter((r) => r.outcome === 'ACQUIRED'); + const losers = results.filter((r) => r.outcome === 'THREW'); + + expect(winners).toHaveLength(1); + expect(losers).toHaveLength(2); + + await getLockManager().forceRelease(db, configName, DIALECT); + + }, 30_000); + + it('should report contention as LockAcquireError naming the holder, not a driver error', async () => { + + const configName = nextConfigName(); + + const results = await race(configName, ['alice', 'bob', 'carol']); + + assertRaceHealthy(results); + + const winner = results.find((r) => r.outcome === 'ACQUIRED')!; + const losers = results.filter((r) => r.outcome === 'THREW'); + + expect(winner).toBeDefined(); + + for (const loser of losers) { + + // The contract callers branch on (e.g. LockAcquireScreen.tsx). + expect(loser.errorName).toBe('LockAcquireError'); + + // A leaked driver error is the specific regression: postgres 23505. + expect(loser.driverCode).toBeNull(); + expect(loser.errorMessage).not.toContain('duplicate key'); + expect(loser.errorMessage).not.toContain('23505'); + + // The error must be able to tell the user who is holding it. + expect(loser.holder).toBe(winner.identity); + + } + + await getLockManager().forceRelease(db, configName, DIALECT); + + }, 30_000); + + it('should poll for the whole waitTimeout before giving up when wait is true', async () => { + + const configName = nextConfigName(); + + // Winner holds for 60s; the waiter cannot possibly succeed, so it must + // spend its full 3s budget polling rather than failing immediately. + const results = await race(configName, ['alice', 'bob'], { + timeoutMs: 60_000, + waitTimeoutMs: 3_000, + }); + + assertRaceHealthy(results); + + const loser = results.find((r) => r.outcome === 'THREW')!; + + expect(loser).toBeDefined(); + expect(loser.errorName).toBe('LockAcquireError'); + + // The defect: this returned in single-digit milliseconds. + expect(loser.elapsed).toBeGreaterThanOrEqual(2_500); + + await getLockManager().forceRelease(db, configName, DIALECT); + + }, 30_000); + + it('should hand the lock to a waiting process once the holder expires', async () => { + + const configName = nextConfigName(); + + // Winner's lock expires after 2s; the waiter has a 15s budget, so the + // queueing contract means it should end up holding the lock. + const results = await race(configName, ['alice', 'bob'], { + timeoutMs: 2_000, + waitTimeoutMs: 15_000, + }); + + assertRaceHealthy(results); + + const acquired = results.filter((r) => r.outcome === 'ACQUIRED'); + + expect(acquired).toHaveLength(2); + + const second = acquired.find((r) => r.elapsed > 1_000); + + expect(second).toBeDefined(); + + await getLockManager().forceRelease(db, configName, DIALECT); + + }, 40_000); + +}); diff --git a/tests/core/lock/race-worker.ts b/tests/core/lock/race-worker.ts new file mode 100644 index 00000000..4d407350 --- /dev/null +++ b/tests/core/lock/race-worker.ts @@ -0,0 +1,65 @@ +/** + * Lock contention worker — one OS process, one acquire attempt. + * + * Spawned by `contention.test.ts`. Real contention needs real processes: a + * single process cannot interleave two `acquire()` calls at the SELECT→INSERT + * boundary, which is exactly where the race lives. Workers synchronise on a + * shared wall-clock start time so their attempts overlap. + * + * Emits one JSON line on stdout describing the outcome. + * + * Usage: bun race-worker.ts [timeoutMs] [waitTimeoutMs] + */ +import { attempt } from '@logosdx/utils'; +import type { Kysely } from 'kysely'; + +import { createConnection } from '../../../src/core/connection/factory.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; +import { getLockManager } from '../../../src/core/lock/index.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import { TEST_CONNECTIONS } from '../../utils/db.js'; + +const [configName, identity, startMs, dialectArg, timeoutMs, waitTimeoutMs] = process.argv.slice(2); + +const dialect = dialectArg as Dialect; +const shouldWait = waitTimeoutMs !== undefined; + +const conn = await createConnection(TEST_CONNECTIONS[dialect], `__race_${identity}__`); +const db = conn.db as Kysely; +const manager = getLockManager(); + +// Warm the pool so the barrier releases into a live connection, not a handshake. +await attempt(() => manager.status(db, `${configName}__warmup`, dialect)); + +const start = Number(startMs); +while (Date.now() < start) await Bun.sleep(1); + +const began = Date.now(); + +const [lock, err] = await attempt(() => + manager.acquire(db, configName!, identity!, { + dialect, + timeout: timeoutMs ? Number(timeoutMs) : 60_000, + wait: shouldWait, + ...(shouldWait ? { waitTimeout: Number(waitTimeoutMs), pollInterval: 100 } : {}), + }), +); + +const elapsed = Date.now() - began; + +// `code` is what a raw driver error carries (postgres 23505, mysql ER_DUP_ENTRY). +// Reporting it is how the test tells a domain error from a leaked driver error. +const driverCode = err ? (err as unknown as { code?: string | number }).code : undefined; + +console.log(JSON.stringify({ + identity, + outcome: err ? 'THREW' : 'ACQUIRED', + elapsed, + lockedBy: lock?.lockedBy ?? null, + errorName: err?.name ?? null, + errorMessage: err?.message ?? null, + holder: err ? (err as unknown as { holder?: string }).holder ?? null : null, + driverCode: driverCode ?? null, +})); + +await conn.destroy(); From e6dcbe3ce6cdafcbf4b44ccbec1b01fb347b4842 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:05:36 -0400 Subject: [PATCH 072/105] 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. --- src/cli/lock/force.ts | 34 +++-- src/core/lock/index.ts | 2 +- src/core/lock/manager.ts | 11 +- src/core/lock/types.ts | 16 +++ src/sdk/namespaces/lock.ts | 21 +++- tests/cli/lock/force.test.ts | 180 +++++++++++++++++++++++++++ tests/core/lock/force-policy.test.ts | 174 ++++++++++++++++++++++++++ tests/core/lock/manager.test.ts | 25 +++- 8 files changed, 440 insertions(+), 23 deletions(-) create mode 100644 tests/cli/lock/force.test.ts create mode 100644 tests/core/lock/force-policy.test.ts diff --git a/src/cli/lock/force.ts b/src/cli/lock/force.ts index 4619a8cc..427dfdf0 100644 --- a/src/cli/lock/force.ts +++ b/src/cli/lock/force.ts @@ -1,10 +1,19 @@ /** * noorm lock force — force release any database lock regardless of ownership. + * + * Breaking a lock interrupts whatever migration its holder is running, so the + * config's `lock:force` access gates it (enforced at the SDK seam in + * `lock.forceRelease`, shared with the TUI and MCP) and `--yes` is the + * confirmation. Exits 2 when there was nothing to release, so a script can + * tell "evicted a holder" apart from "no-op" without parsing text. */ import { defineCommand } from 'citty'; import { withContext, outputResult, sharedArgs } from '../_utils.js'; +/** Exit code for "command succeeded, but there was no lock to release". */ +const EXIT_NOTHING_TO_RELEASE = 2; + const forceCommand = defineCommand({ meta: { name: 'force', @@ -12,23 +21,30 @@ const forceCommand = defineCommand({ }, args: { config: sharedArgs.config, + yes: sharedArgs.yes, json: sharedArgs.json, }, async run({ args }) { - const [, error] = await withContext({ + const [result, error] = await withContext({ args, fn: async (ctx, logger) => { - await ctx.noorm.lock.forceRelease(); + const outcome = await ctx.noorm.lock.forceRelease(); if (!args.json) { - logger.info('Lock force-released'); + // Naming the evicted holder is the point: a silent steal + // gives the operator no way to warn whoever they cut off. + logger.info( + outcome.released + ? `Lock force-released (was held by ${outcome.holder})` + : 'No lock to release', + ); } - return true; + return outcome; }, }); @@ -37,19 +53,19 @@ const forceCommand = defineCommand({ if (args.json) { - outputResult(args, { released: true, forced: true }, ''); + outputResult(args, { released: result.released, holder: result.holder, forced: true }, ''); } - process.exit(0); + process.exit(result.released ? 0 : EXIT_NOTHING_TO_RELEASE); }, }); (forceCommand as typeof forceCommand & { examples: string[] }).examples = [ - 'noorm lock force', - 'noorm lock force -c prod', - 'noorm lock force --json', + 'noorm lock force --yes', + 'noorm lock force -c prod --yes', + 'noorm lock force --yes --json', ]; export default forceCommand; diff --git a/src/core/lock/index.ts b/src/core/lock/index.ts index 2cd139cc..fbcc3af2 100644 --- a/src/core/lock/index.ts +++ b/src/core/lock/index.ts @@ -30,7 +30,7 @@ */ // Types -export type { Lock, LockOptions, LockStatus } from './types.js'; +export type { ForceReleaseResult, Lock, LockOptions, LockStatus } from './types.js'; export { DEFAULT_LOCK_OPTIONS } from './types.js'; diff --git a/src/core/lock/manager.ts b/src/core/lock/manager.ts index 07112690..8c97a9b4 100644 --- a/src/core/lock/manager.ts +++ b/src/core/lock/manager.ts @@ -29,7 +29,7 @@ import type { Kysely } from 'kysely'; import { observer } from '../observer.js'; import { getNoormTables, noormDb, type NoormDatabase } from '../shared/index.js'; import type { Dialect } from '../connection/types.js'; -import type { Lock, LockOptions, LockStatus } from './types.js'; +import type { ForceReleaseResult, Lock, LockOptions, LockStatus } from './types.js'; import { DEFAULT_LOCK_OPTIONS } from './types.js'; import { LockAcquireError, @@ -301,20 +301,21 @@ class LockManager { * @param db - Kysely database instance * @param configName - Config/database scope * @param dialect - Database dialect for schema-aware table resolution - * @returns true if a lock was released, false if none existed + * @returns whether a lock was released, and who held it */ async forceRelease( db: Kysely, configName: string, dialect: Dialect, - ): Promise { + ): Promise { const ndb = noormDb(db, dialect); const tables = getNoormTables(dialect); const existing = await this.#getLock(db, configName, dialect); + if (!existing) { - return false; + return { released: false, holder: null }; } @@ -325,7 +326,7 @@ class LockManager { identity: existing.lockedBy, }); - return true; + return { released: true, holder: existing.lockedBy }; } diff --git a/src/core/lock/types.ts b/src/core/lock/types.ts index d50aa8bf..995a85c1 100644 --- a/src/core/lock/types.ts +++ b/src/core/lock/types.ts @@ -96,6 +96,22 @@ export interface LockOptions { reason?: string; } +/** + * Outcome of a force-release. + * + * Carries the evicted holder because force-releasing is destructive to + * someone else's in-flight work: callers need to be able to say *whose* + * lock they broke, and to tell "evicted a live holder" apart from "there + * was nothing there" — which previously both reported success. + */ +export interface ForceReleaseResult { + /** Whether a lock row was actually deleted. */ + released: boolean; + + /** Identity that held the lock, or null when there was nothing to release. */ + holder: string | null; +} + /** * Result of a lock status check. */ diff --git a/src/sdk/namespaces/lock.ts b/src/sdk/namespaces/lock.ts index 092cceff..9ca37763 100644 --- a/src/sdk/namespaces/lock.ts +++ b/src/sdk/namespaces/lock.ts @@ -6,12 +6,13 @@ import type { Kysely } from 'kysely'; import type { NoormDatabase } from '../../core/shared/index.js'; -import type { Lock, LockStatus, LockOptions } from '../../core/lock/index.js'; +import type { ForceReleaseResult, Lock, LockStatus, LockOptions } from '../../core/lock/index.js'; import { getLockManager } from '../../core/lock/index.js'; import { formatIdentity } from '../../core/identity/index.js'; import type { ContextState } from '../state.js'; import { requireConnection } from '../state.js'; +import { checkProtectedConfig } from '../guards.js'; // ───────────────────────────────────────────────────────────── // LockNamespace @@ -119,12 +120,26 @@ export class LockNamespace { /** * Force release any database lock regardless of ownership. * + * Gated by the config's `lock:force` access: breaking someone else's lock + * interrupts their in-flight migration, so `viewer` is denied outright and + * `operator`/`admin` must pre-confirm. Gating here rather than in each + * command means CLI, TUI and MCP inherit one enforcement path. + * + * @returns whether a lock was released, and who held it + * * @example * ```typescript - * await ctx.noorm.lock.forceRelease() + * const { released, holder } = await ctx.noorm.lock.forceRelease() * ``` */ - async forceRelease(): Promise { + async forceRelease(): Promise { + + checkProtectedConfig( + this.#state.config, + this.#state.options, + 'lock:force', + 'lock.forceRelease', + ); const lockManager = getLockManager(); diff --git a/tests/cli/lock/force.test.ts b/tests/cli/lock/force.test.ts new file mode 100644 index 00000000..a5422f9b --- /dev/null +++ b/tests/cli/lock/force.test.ts @@ -0,0 +1,180 @@ +/** + * cli: noorm lock force — gating, holder reporting, and exit codes. + * + * WHY: `lock force` shipped with no confirmation and a hardcoded + * `{ released: true }` payload, so it reported success and exit 0 on an empty + * lock table and never named the holder it evicted. A CI step could not tell + * "broke someone's lock" from "there was nothing there". + * + * Harness mirrors tests/cli/lock/status.test.ts. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'lockforce'; + +/** Exit code the command uses for "nothing to release". */ +const EXIT_NOTHING_TO_RELEASE = 2; + +/** Strips inherited NOORM_* env vars so no ambient NOORM_YES leaks in. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm lock force', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-lock-force-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-lock-force-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + dbPath = join(tmpDir, 'target.db'); + writeFileSync(dbPath, ''); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + async function seedConfig(access: ConfigAccess = { user: 'admin', mcp: 'admin' }): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + function run(command: string[], args: string[] = []) { + + return spawnSync('node', [CLI, ...command, ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: identityEnv, + }); + + } + + it('exits 2 and reports released:false when there is no lock to release', async () => { + + await seedConfig(); + + const result = run(['lock', 'force'], ['--yes', '--json']); + + expect(result.status).toBe(EXIT_NOTHING_TO_RELEASE); + + const parsed = JSON.parse(result.stdout); + + expect(parsed.released).toBe(false); + expect(parsed.holder).toBeNull(); + + }); + + it('names the evicted holder and exits 0 when a lock was released', async () => { + + await seedConfig(); + + const acquired = run(['lock', 'acquire'], ['--json']); + + expect(acquired.status).toBe(0); + + const holder = JSON.parse(acquired.stdout).lockedBy; + + expect(holder).toBeTruthy(); + + const result = run(['lock', 'force'], ['--yes', '--json']); + + expect(result.status).toBe(0); + + const parsed = JSON.parse(result.stdout); + + expect(parsed.released).toBe(true); + + // The reported holder must be the identity that actually held it, + // not a placeholder — that is what makes the eviction reportable. + expect(parsed.holder).toBe(holder); + + }); + + it('refuses to break a lock without confirmation', async () => { + + await seedConfig(); + + run(['lock', 'acquire'], ['--json']); + + const result = run(['lock', 'force'], ['--json']); + + expect(result.status).toBe(1); + + // The lock must survive a rejected force. + const status = run(['lock', 'status'], ['--json']); + const parsed = JSON.parse(status.stdout); + + expect(parsed.isLocked).toBe(true); + + }); + + it('denies a viewer config even with --yes', async () => { + + await seedConfig({ user: 'viewer', mcp: false }); + + const result = run(['lock', 'force'], ['--yes', '--json']); + + expect(result.status).toBe(1); + + }); + +}); diff --git a/tests/core/lock/force-policy.test.ts b/tests/core/lock/force-policy.test.ts new file mode 100644 index 00000000..ed6c4cef --- /dev/null +++ b/tests/core/lock/force-policy.test.ts @@ -0,0 +1,174 @@ +/** + * `lock force` authorization tests. + * + * WHY these tests exist: force-releasing evicts whoever is mid-migration, and + * it shipped completely ungated — no role check, no confirmation, and a + * `released: true` report even when the lock table was empty. The `lock:force` + * matrix row (`viewer: deny`, `operator`/`admin`: `confirm`) was added but had + * no enforcement behind it. + * + * The gate lives on `LockNamespace` rather than in the CLI command so that + * CLI, TUI and MCP inherit one enforcement path — these tests exercise it at + * that seam, which is why an SDK namespace is under test in the lock suite. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { attempt } from '@logosdx/utils'; +import { Kysely, SqliteDialect } from 'kysely'; + +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { getLockManager, resetLockManager } from '../../../src/core/lock/index.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { LockNamespace } from '../../../src/sdk/namespaces/lock.js'; +import { ProtectedConfigError } from '../../../src/sdk/guards.js'; +import type { ContextState } from '../../../src/sdk/state.js'; + +const VIEWER: ConfigAccess = { user: 'viewer', mcp: false }; +const OPERATOR: ConfigAccess = { user: 'operator', mcp: 'viewer' }; +const ADMIN: ConfigAccess = { user: 'admin', mcp: 'admin' }; + +const CONFIG_NAME = 'dev'; + +function makeConfig(access: ConfigAccess): Config { + + return { + name: CONFIG_NAME, + type: 'local', + isTest: false, + access, + connection: { dialect: 'sqlite', database: ':memory:' }, + }; + +} + +describe('lock: force authorization', () => { + + let db: Kysely; + + /** Builds a namespace over a live in-memory sqlite lock table. */ + function makeNamespace( + access: ConfigAccess, + options: ContextState['options'] = {}, + ): LockNamespace { + + const state: ContextState = { + connection: { db, dialect: 'sqlite', destroy: async () => {} } as ContextState['connection'], + config: makeConfig(access), + settings: {}, + identity: { name: 'tester', source: 'system' }, + options, + projectRoot: '/tmp', + changeManager: null, + }; + + return new LockNamespace(state); + + } + + beforeEach(async () => { + + resetLockManager(); + + db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + }); + + afterEach(async () => { + + resetLockManager(); + await db.destroy(); + + }); + + it('should deny a viewer outright, even with confirmation', async () => { + + const lock = makeNamespace(VIEWER, { yes: true }); + + const [, err] = await attempt(() => lock.forceRelease()); + + // viewer is a `deny` cell — pre-confirmation must not unblock it. + expect(err).toBeInstanceOf(ProtectedConfigError); + + }); + + it('should refuse an operator that has not confirmed', async () => { + + const lock = makeNamespace(OPERATOR); + + const [, err] = await attempt(() => lock.forceRelease()); + + expect(err).toBeInstanceOf(ProtectedConfigError); + + }); + + it('should refuse an admin that has not confirmed', async () => { + + const lock = makeNamespace(ADMIN); + + const [, err] = await attempt(() => lock.forceRelease()); + + // `lock:force` is a `confirm` cell for admin too — breaking someone + // else's lock is never frictionless. + expect(err).toBeInstanceOf(ProtectedConfigError); + + }); + + it('should not release the lock when the gate rejects', async () => { + + await getLockManager().acquire(db, CONFIG_NAME, 'alice', { dialect: 'sqlite' }); + + const lock = makeNamespace(OPERATOR); + + await attempt(() => lock.forceRelease()); + + const status = await getLockManager().status(db, CONFIG_NAME, 'sqlite'); + + expect(status.isLocked).toBe(true); + expect(status.lock?.lockedBy).toBe('alice'); + + }); + + it('should evict and name the holder for a confirmed admin', async () => { + + await getLockManager().acquire(db, CONFIG_NAME, 'alice', { dialect: 'sqlite' }); + + const lock = makeNamespace(ADMIN, { yes: true }); + + const result = await lock.forceRelease(); + + expect(result.released).toBe(true); + expect(result.holder).toBe('alice'); + + }); + + it('should report released:false when there is nothing to release', async () => { + + const lock = makeNamespace(ADMIN, { yes: true }); + + const result = await lock.forceRelease(); + + expect(result.released).toBe(false); + expect(result.holder).toBeNull(); + + }); + + it('should deny the mcp channel even when pre-confirmed', async () => { + + const lock = makeNamespace(ADMIN, { yes: true, channel: 'mcp' }); + + const [, err] = await attempt(() => lock.forceRelease()); + + // `confirm` collapses to deny on mcp, so `yes` can never unblock it. + expect(err).toBeInstanceOf(ProtectedConfigError); + + }); + +}); diff --git a/tests/core/lock/manager.test.ts b/tests/core/lock/manager.test.ts index 3d27fbd6..b199034c 100644 --- a/tests/core/lock/manager.test.ts +++ b/tests/core/lock/manager.test.ts @@ -311,9 +311,9 @@ describe('lock: manager', () => { await manager.acquire(db, 'dev', 'alice@example.com', sqliteOpts); - const released = await manager.forceRelease(db, 'dev', 'sqlite'); + const result = await manager.forceRelease(db, 'dev', 'sqlite'); - expect(released).toBe(true); + expect(result.released).toBe(true); const row = await db .selectFrom(NOORM_TABLES.lock) @@ -325,13 +325,28 @@ describe('lock: manager', () => { }); - it('should return false when no lock exists', async () => { + it('should name the holder it evicted', async () => { const manager = getLockManager(); - const released = await manager.forceRelease(db, 'dev', 'sqlite'); + await manager.acquire(db, 'dev', 'alice@example.com', sqliteOpts); + + const result = await manager.forceRelease(db, 'dev', 'sqlite'); + + // Callers must be able to report whose work they interrupted. + expect(result.holder).toBe('alice@example.com'); + + }); + + it('should report released:false with no holder when no lock exists', async () => { + + const manager = getLockManager(); + + const result = await manager.forceRelease(db, 'dev', 'sqlite'); - expect(released).toBe(false); + // A no-op must not be indistinguishable from a successful eviction. + expect(result.released).toBe(false); + expect(result.holder).toBeNull(); }); From 96b7bd4544f80dcf0de3ef2977c734352d720ff7 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:06:01 -0400 Subject: [PATCH 073/105] fix(ci): back up state before init --force destroys it, and validate the config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/ci/init.ts | 89 ++++++++++++++++--- tests/cli/ci/init.test.ts | 177 +++++++++++++++++++++++++++++++++++++- 2 files changed, 254 insertions(+), 12 deletions(-) diff --git a/src/cli/ci/init.ts b/src/cli/ci/init.ts index b1936fef..9c5982d5 100644 --- a/src/cli/ci/init.ts +++ b/src/cli/ci/init.ts @@ -10,7 +10,7 @@ * Fails fast: missing identity env → exit 1, missing connection → exit 1, * existing state.enc without --force → exit 1. */ -import { existsSync, rmSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { attempt, attemptSync } from '@logosdx/utils'; @@ -19,12 +19,29 @@ import { defineCommand } from 'citty'; import { loadIdentityFromEnv, CI_ENV_VARS } from '../../core/identity/env.js'; import { setKeyOverride, setIdentityOverride } from '../../core/identity/storage.js'; import { getEnvConfig } from '../../core/config/index.js'; +import { parseConfig } from '../../core/config/schema.js'; import type { Config } from '../../core/config/types.js'; import type { ConnectionConfig } from '../../core/connection/types.js'; import { OPEN_ACCESS } from '../../core/policy/index.js'; import { initState } from '../../core/state/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +/** + * Coerce an env-derived value back to a string. + * + * `getEnvConfig()` runs env vars through a parser that converts + * numeric-looking values, so a database literally named `20240101` arrives as + * the number 20240101. Env vars are strings by definition, and the config + * schema requires strings, so the conversion is always an artifact. + */ +function asString(value: unknown): string | undefined { + + if (value === undefined || value === null) return undefined; + + return String(value); + +} + const initCommand = defineCommand({ meta: { name: 'init', @@ -38,9 +55,10 @@ const initCommand = defineCommand({ force: { type: 'boolean', alias: 'f', - description: 'Overwrite existing state.enc', + description: 'Overwrite existing state.enc (backs it up first)', default: false, }, + yes: sharedArgs.yes, json: sharedArgs.json, }, async run({ args }) { @@ -98,6 +116,8 @@ const initCommand = defineCommand({ } // 3. State.enc existence check + let backedUpTo: string | null = null; + if (existsSync(stateFile)) { if (!args.force) { @@ -110,6 +130,41 @@ const initCommand = defineCommand({ } + // Destroying state.enc destroys every config and every + // config-scoped secret in this project. On an ephemeral runner + // that is the intent, so --force alone is enough there; at an + // interactive terminal it is far more likely to be a real project, + // so demand --yes as well rather than break documented pipelines. + if (process.stdin.isTTY && !args.yes) { + + outputError( + args, + `Refusing to overwrite ${stateFile} from an interactive terminal. This deletes every ` + + 'config and config-scoped secret in this project. Pass --yes if you meant it.', + ); + process.exit(1); + + } + + const backupPath = `${stateFile}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`; + + const [, backupErr] = attemptSync(() => + // Owner-only: the copy holds exactly what state.enc holds. + writeFileSync(backupPath, readFileSync(stateFile), { mode: 0o600 }), + ); + + if (backupErr) { + + outputError( + args, + `Refusing to overwrite: could not back up existing state (${backupErr.message}).`, + ); + process.exit(1); + + } + + backedUpTo = backupPath; + const [, rmErr] = attemptSync(() => rmSync(stateFile)); if (rmErr) { @@ -137,23 +192,33 @@ const initCommand = defineCommand({ // 5. Create config and activate const connection: ConnectionConfig = { dialect, - database, - host, + database: asString(database)!, + host: asString(host), port, - user, - password, - filename, + user: asString(user), + password: asString(password), + filename: asString(filename), pool, ssl, }; - const config: Config = { + // Validated rather than hand-built and trusted: this is the one path + // that writes a config nobody reviewed, and it used to persist shapes + // the schema forbids for every later consumer to trip over. + const [config, parseErr] = attemptSync(() => parseConfig({ name: configName, type: 'remote', isTest: true, access: OPEN_ACCESS, connection, - }; + }) as Config); + + if (parseErr || !config) { + + outputError(args, `Invalid CI configuration: ${parseErr?.message ?? 'unknown error'}`); + process.exit(1); + + } const [, setCfgErr] = await attempt(() => stateManager.setConfig(configName, config)); @@ -188,18 +253,20 @@ const initCommand = defineCommand({ config: { name: configName, dialect, - database, + database: connection.database, isTest: true, }, stateFile, + ...(backedUpTo ? { backedUpTo } : {}), }, [ 'CI runtime initialized.', ` Identity: ${envIdentity.identity.name} <${envIdentity.identity.email}>`, ` Fingerprint: ${envIdentity.identity.identityHash}`, ` Config: ${configName} (${dialect}, isTest=true)`, - ` Database: ${database}`, + ` Database: ${connection.database}`, ` State file: ${stateFile}`, + ...(backedUpTo ? [` Previous state backed up to: ${backedUpTo}`] : []), ].join('\n'), ); diff --git a/tests/cli/ci/init.test.ts b/tests/cli/ci/init.test.ts index d9418084..9f8de11b 100644 --- a/tests/cli/ci/init.test.ts +++ b/tests/cli/ci/init.test.ts @@ -1,10 +1,20 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/manager.js'; const CLI = join(process.cwd(), 'dist/cli/index.js'); @@ -190,3 +200,168 @@ describe('cli: noorm ci init', () => { }); }); + +/** + * `ci init --force` deletes state.enc outright — every config and every + * config-scoped secret in that project, unrecoverably. The command is aimed at + * ephemeral CI runners where that is the intent, but nothing stops it running + * against a real project directory, and there is no backup and no way back. + */ +describe('cli: noorm ci init --force is recoverable', () => { + + let tmpDir: string; + let stateDir: string; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-ci-init-force-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + stateDir = join(tmpDir, '.noorm', 'state'); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + + }); + + function backups(): string[] { + + return readdirSync(stateDir).filter((f) => f.startsWith('state.enc.bak-')); + + } + + it('backs up the state it is about to destroy', () => { + + const first = runInit(tmpDir, { ...validIdentityEnv(), ...validConnectionEnv() }); + expect(first.status).toBe(0); + + const original = readFileSync(join(stateDir, 'state.enc')); + + const second = runInit( + tmpDir, + { ...validIdentityEnv(), ...validConnectionEnv() }, + ['--force'], + ); + + expect(second.status).toBe(0); + + const found = backups(); + + expect(found.length).toBe(1); + expect(readFileSync(join(stateDir, found[0]!))).toEqual(original); + + }); + + it('writes the backup owner-only, since it holds every config secret', () => { + + runInit(tmpDir, { ...validIdentityEnv(), ...validConnectionEnv() }); + runInit(tmpDir, { ...validIdentityEnv(), ...validConnectionEnv() }, ['--force']); + + const mode = statSync(join(stateDir, backups()[0]!)).mode & 0o777; + + expect(mode & 0o077).toBe(0); + + }); + + it('reports where the backup went', () => { + + runInit(tmpDir, { ...validIdentityEnv(), ...validConnectionEnv() }); + + const result = runInit( + tmpDir, + { ...validIdentityEnv(), ...validConnectionEnv() }, + ['--force', '--json'], + ); + + const json = JSON.parse(result.stdout.trim()); + + expect(json.backedUpTo).toContain('state.enc.bak-'); + + }); + + it('does not create a backup on a first run with no state', () => { + + const result = runInit( + tmpDir, + { ...validIdentityEnv(), ...validConnectionEnv() }, + ['--force', '--json'], + ); + + expect(result.status).toBe(0); + expect(backups().length).toBe(0); + + }); + +}); + +/** + * `ci init` hand-built the Config object and wrote it straight to state, + * skipping `parseConfig`. Env values arrive through a parser that coerces + * numeric-looking strings, so a database literally named `20240101` was + * persisted as the NUMBER 20240101 — a shape the config schema forbids, which + * every later consumer then has to survive. + */ +describe('cli: noorm ci init validates the config it persists', () => { + + let tmpDir: string; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-ci-init-parse-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + + }); + + it('persists a numeric-looking database name as a string', async () => { + + const identity = validIdentityEnv(); + + const result = runInit( + tmpDir, + { + ...identity, + ...validConnectionEnv(), + NOORM_CONNECTION_DATABASE: '20240101', + }, + ['--json'], + ); + + expect(result.status).toBe(0); + + const state = new StateManager(tmpDir, { + privateKey: identity.NOORM_IDENTITY_PRIVATE_KEY, + }); + await state.load(); + + const stored = state.getConfig('ci'); + + expect(stored?.connection.database).toBe('20240101'); + expect(typeof stored?.connection.database).toBe('string'); + + }); + + it('reports the database name as a string in --json', () => { + + const result = runInit( + tmpDir, + { ...validIdentityEnv(), ...validConnectionEnv(), NOORM_CONNECTION_DATABASE: '20240101' }, + ['--json'], + ); + + const json = JSON.parse(result.stdout.trim()); + + expect(json.config.database).toBe('20240101'); + + }); + +}); From 58335f553452f7b492dd37457ae6fdf10068bfaa Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:06:01 -0400 Subject: [PATCH 074/105] 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. --- .../integration/impersonate/postgres.test.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/integration/impersonate/postgres.test.ts b/tests/integration/impersonate/postgres.test.ts index e222f39c..fd845c49 100644 --- a/tests/integration/impersonate/postgres.test.ts +++ b/tests/integration/impersonate/postgres.test.ts @@ -173,6 +173,27 @@ describe('integration: impersonate postgres (unprivileged connection)', () => { const LOWPRIV_ROLE = 'impersonate_lowpriv'; const TARGET_ROLE = 'impersonate_target'; + /** + * `DROP ROLE` refuses while any privilege still references the role, and + * this suite grants CONNECT — so a plain `DROP ROLE IF EXISTS` leaks the + * role on first teardown and then fails every later setup. `DROP OWNED BY` + * clears those grants, but errors on a role that does not exist, hence the + * existence check. + */ + async function dropRoleCompletely(ctx: Context, role: string): Promise { + + await sql.raw(` + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN + EXECUTE 'DROP OWNED BY ${role}'; + EXECUTE 'DROP ROLE ${role}'; + END IF; + END $$; + `).execute(ctx.kysely); + + } + beforeAll(async () => { await skipIfNoContainer('postgres'); @@ -185,7 +206,7 @@ describe('integration: impersonate postgres (unprivileged connection)', () => { for (const role of [LOWPRIV_ROLE, TARGET_ROLE]) { - await sql.raw(`DROP ROLE IF EXISTS ${role}`).execute(admin.kysely); + await dropRoleCompletely(admin, role); } @@ -220,7 +241,7 @@ describe('integration: impersonate postgres (unprivileged connection)', () => { for (const role of [LOWPRIV_ROLE, TARGET_ROLE]) { - await attempt(() => sql.raw(`DROP ROLE IF EXISTS ${role}`).execute(admin.kysely)); + await attempt(() => dropRoleCompletely(admin, role)); } From c3956070140e9d8232d7314fb2f13cbff376dc0c Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:07:49 -0400 Subject: [PATCH 075/105] 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. --- tests/integration/teardown/sdk-preserve.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/teardown/sdk-preserve.test.ts b/tests/integration/teardown/sdk-preserve.test.ts index 06b9a236..d34c5db3 100644 --- a/tests/integration/teardown/sdk-preserve.test.ts +++ b/tests/integration/teardown/sdk-preserve.test.ts @@ -50,7 +50,11 @@ describe('integration: sdk truncate preserve', () => { await skipIfNoContainer('postgres'); const config = makeTestConfig('pg_sdk_preserve', TEST_CONNECTIONS.postgres); - ctx = new Context(config, settingsWithPreserve, { name: 'tester', source: 'system' }, {}, '/tmp/test'); + + // Pre-confirmed: these cases are about preserve-list plumbing, and + // db:truncate/db:teardown are confirm cells even for admin, so + // without `yes` every one of them would stop at the access gate. + ctx = new Context(config, settingsWithPreserve, { name: 'tester', source: 'system' }, { yes: true }, '/tmp/test'); await ctx.connect(); }, 30_000); From cb1d8638cf670dba9541f8438e8f8b027ee8248a Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:08:00 -0400 Subject: [PATCH 076/105] 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. --- src/core/transfer/executor.ts | 56 +++++++-- tests/core/transfer/counts.test.ts | 161 +++++++++++++++++++++++++ tests/core/transfer/pagination.test.ts | 7 ++ 3 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 tests/core/transfer/counts.test.ts diff --git a/src/core/transfer/executor.ts b/src/core/transfer/executor.ts index 0e77904c..a77c8fe3 100644 --- a/src/core/transfer/executor.ts +++ b/src/core/transfer/executor.ts @@ -7,7 +7,7 @@ import { sql } from 'kysely'; import { attempt } from '@logosdx/utils'; -import type { Kysely } from 'kysely'; +import type { InsertResult, Kysely } from 'kysely'; import type { DualConnectionContext } from '../db/dual.js'; import type { NoormDatabase } from '../shared/tables.js'; import type { Dialect } from '../connection/types.js'; @@ -293,7 +293,7 @@ async function transferTableSameServer( plan.schema, ); - const [, transferErr] = await attempt(() => + const [transferResult, transferErr] = await attempt(() => sql.raw(transferSql).execute(ctx.destination.db), ); @@ -329,7 +329,10 @@ async function transferTableSameServer( } - const rowsTransferred = plan.rowCount; + // plan.rowCount is a planner estimate (postgres reltuples, MySQL + // TABLE_ROWS) — reporting it as rowsTransferred meant the result never + // reflected what the statement actually wrote. + const rowsTransferred = Number(transferResult?.numAffectedRows ?? 0); observer.emit('transfer:table:progress', { table: plan.name, @@ -731,6 +734,22 @@ interface BatchInsertResult { } +/** + * How many rows a Kysely insert actually wrote. + * + * A conflict-handling insert can succeed without writing anything, so the + * absence of an exception says nothing about whether a row landed. Drivers + * that do not report affected rows leave the field undefined; assume the + * insert applied there rather than silently under-reporting. + */ +function countApplied(results: InsertResult[]): number { + + const affected = results[0]?.numInsertedOrUpdatedRows; + + return affected === undefined ? 1 : Number(affected); + +} + /** * Insert a batch of rows to destination table. * @@ -777,7 +796,7 @@ async function insertBatch( // Handle conflict based on strategy if (strategy === 'skip' && primaryKey.length > 0) { - const [, err] = await attempt(() => + const [results, err] = await attempt(() => query .onConflict((oc) => oc.columns(primaryKey as never[]).doNothing()) .execute(), @@ -797,11 +816,19 @@ async function insertBatch( } } - else { + else if (countApplied(results) > 0) { inserted++; } + else { + + // DO NOTHING succeeds without writing. Counting the absence + // of an exception as an insert is what made rowsTransferred + // match the source while the destination was untouched. + skipped++; + + } } else if (strategy === 'update' && primaryKey.length > 0) { @@ -818,7 +845,7 @@ async function insertBatch( } - const [, err] = await attempt(() => + const [results, err] = await attempt(() => query .onConflict((oc) => oc.columns(primaryKey as never[]).doUpdateSet(updateSet as never)) .execute(), @@ -838,11 +865,16 @@ async function insertBatch( } } - else { + else if (countApplied(results) > 0) { inserted++; } + else { + + skipped++; + + } } else { @@ -963,7 +995,7 @@ async function insertBatchRawSql( sql.raw(''), ); - const [, err] = await attempt(() => finalSql.execute(db)); + const [result, err] = await attempt(() => finalSql.execute(db)); if (err) { @@ -979,11 +1011,17 @@ async function insertBatchRawSql( } } - else { + else if (result.numAffectedRows === undefined || Number(result.numAffectedRows) > 0) { inserted++; } + else { + + // INSERT IGNORE and MERGE both report zero rows on a no-op. + skipped++; + + } } diff --git a/tests/core/transfer/counts.test.ts b/tests/core/transfer/counts.test.ts new file mode 100644 index 00000000..1f816cf9 --- /dev/null +++ b/tests/core/transfer/counts.test.ts @@ -0,0 +1,161 @@ +/** + * Reported-count fidelity. + * + * `rowsTransferred` counted every insert that did not throw. `ON CONFLICT DO + * NOTHING` does not throw when it writes nothing, so a transfer into a + * destination that already held the data reported the full row count with + * zero skipped — indistinguishable from a transfer that did the work. + * + * Every assertion here cross-checks the destination. Uses dedicated tables so + * it does not race the shared fixture schema. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { sql } from 'kysely'; + +import type { Kysely } from 'kysely'; + +import { transferData } from '../../../src/core/transfer/index.js'; +import { + createTestConnection, + skipIfNoContainer, + makeTestConfig, + TEST_CONNECTIONS, +} from '../../utils/db.js'; +import { createConnection } from '../../../src/core/connection/factory.js'; + +const ROWS = 6; + +describe('transfer: reported counts', () => { + + let sourceDb: Kysely; + let destDb: Kysely; + let sourceDestroy: () => Promise; + let destDestroy: () => Promise; + + const sourceConfig = makeTestConfig('counts_source', { ...TEST_CONNECTIONS.postgres }); + const destConfig = makeTestConfig('counts_dest', { + ...TEST_CONNECTIONS.postgres, + database: process.env['TEST_POSTGRES_DATABASE_DEST'] ?? 'noorm_test_dest', + }); + + beforeAll(async () => { + + await skipIfNoContainer('postgres'); + + const conn = await createTestConnection('postgres'); + sourceDb = conn.db; + sourceDestroy = conn.destroy; + + const destConn = await createConnection(destConfig.connection, 'counts_dest'); + destDb = destConn.db; + destDestroy = destConn.destroy; + + for (const db of [sourceDb, destDb]) { + + await sql.raw('DROP TABLE IF EXISTS countcheck').execute(db); + await sql.raw('CREATE TABLE countcheck (id int PRIMARY KEY, label text NOT NULL)').execute(db); + + } + + }); + + afterAll(async () => { + + for (const db of [sourceDb, destDb]) { + + if (!db) continue; + + await sql.raw('DROP TABLE IF EXISTS countcheck').execute(db).catch(() => undefined); + + } + + if (destDestroy) await destDestroy(); + if (sourceDestroy) await sourceDestroy(); + + }); + + beforeEach(async () => { + + const values = Array.from({ length: ROWS }, (_, i) => `(${i + 1}, 'row-${i + 1}')`).join(', '); + + await sql.raw('TRUNCATE TABLE countcheck').execute(sourceDb); + await sql.raw('TRUNCATE TABLE countcheck').execute(destDb); + await sql.raw(`INSERT INTO countcheck (id, label) VALUES ${values}`).execute(sourceDb); + + }); + + /** + * Pull the result for the table under test out of a transfer result. + */ + async function transferCountcheck(onConflict: 'skip' | 'update') { + + const [result, err] = await transferData(sourceConfig, destConfig, { + tables: ['countcheck'], + onConflict, + }); + + expect(err).toBeNull(); + + return result!.tables.find((t) => t.table === 'countcheck')!; + + } + + it('should report skipped rows as skipped, not transferred', async () => { + + // Destination already holds every row, so the transfer writes nothing. + const values = Array.from({ length: ROWS }, (_, i) => `(${i + 1}, 'row-${i + 1}')`).join(', '); + await sql.raw(`INSERT INTO countcheck (id, label) VALUES ${values}`).execute(destDb); + + const table = await transferCountcheck('skip'); + + expect(table.rowsTransferred).toBe(0); + expect(table.rowsSkipped).toBe(ROWS); + + }); + + it('should report a partially populated destination accurately', async () => { + + await sql.raw('INSERT INTO countcheck (id, label) VALUES (1, \'row-1\'), (2, \'row-2\')').execute(destDb); + + const table = await transferCountcheck('skip'); + + expect(table.rowsTransferred).toBe(ROWS - 2); + expect(table.rowsSkipped).toBe(2); + + const landed = await sql<{ count: string }>`SELECT COUNT(*) as count FROM countcheck`.execute(destDb); + + expect(parseInt(landed.rows[0]!.count, 10)).toBe(ROWS); + + }); + + it('should report every row transferred into an empty destination', async () => { + + const table = await transferCountcheck('skip'); + + expect(table.rowsTransferred).toBe(ROWS); + expect(table.rowsSkipped).toBe(0); + + const landed = await sql<{ count: string }>`SELECT COUNT(*) as count FROM countcheck`.execute(destDb); + + expect(parseInt(landed.rows[0]!.count, 10)).toBe(ROWS); + + }); + + it('should count rewritten rows under onConflict update', async () => { + + const values = Array.from({ length: ROWS }, (_, i) => `(${i + 1}, 'stale')`).join(', '); + await sql.raw(`INSERT INTO countcheck (id, label) VALUES ${values}`).execute(destDb); + + const table = await transferCountcheck('update'); + + expect(table.rowsTransferred).toBe(ROWS); + + const stale = await sql<{ count: string }>` + SELECT COUNT(*) as count FROM countcheck WHERE label = 'stale' + `.execute(destDb); + + expect(parseInt(stale.rows[0]!.count, 10)).toBe(0); + + }); + +}); diff --git a/tests/core/transfer/pagination.test.ts b/tests/core/transfer/pagination.test.ts index 6a3dafb1..024b9d70 100644 --- a/tests/core/transfer/pagination.test.ts +++ b/tests/core/transfer/pagination.test.ts @@ -52,11 +52,18 @@ function startChurn(db: Kysely, table: string): { stop: () => Promise undefined); + // Yield between statements so the loop perturbs the read without + // saturating a database this suite may be sharing. + await new Promise((resolve) => setTimeout(resolve, 2)); + } })(); From 0076d3092474b7e94da31eca2272c132c89309e4 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:09:02 -0400 Subject: [PATCH 077/105] 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. --- src/cli/db/explore-triggers.ts | 136 +++++++++++++++++++++++++++++++++ src/cli/db/explore.ts | 20 +++-- 2 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 src/cli/db/explore-triggers.ts diff --git a/src/cli/db/explore-triggers.ts b/src/cli/db/explore-triggers.ts new file mode 100644 index 00000000..3258af8b --- /dev/null +++ b/src/cli/db/explore-triggers.ts @@ -0,0 +1,136 @@ +/** + * noorm db explore triggers — list triggers, with detail view. + * + * Triggers were implemented in core and counted in the overview, but only + * reachable over RPC/MCP, so the CLI reported a number for something it had + * no way to show. + */ +import { defineCommand } from 'citty'; + +import type { Kysely } from 'kysely'; + +import { fetchList, fetchDetail } from '../../core/explore/index.js'; +import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; + +const triggersCommand = defineCommand({ + meta: { + name: 'triggers', + description: 'List triggers in the database', + }, + args: { + name: { + type: 'positional', + description: 'Name of the trigger to describe', + required: false, + }, + schema: { + type: 'string', + description: 'Restrict the listing to one schema', + }, + config: sharedArgs.config, + json: sharedArgs.json, + }, + async run({ args }) { + + if (args.name) { + + const [detail, error] = await withContext({ + args, + fn: (ctx, logger) => { + + return fetchDetail( + ctx.kysely as Kysely, + ctx.dialect, + 'triggers', + args.name as string, + args.schema, + ).then((res) => { + + if (res && !args.json) { + + logger.info(`Trigger: ${res.name}`); + logger.info(` Table: ${res.tableSchema ? `${res.tableSchema}.` : ''}${res.tableName}`); + logger.info(` Timing: ${res.timing} ${res.events.join('/')}`); + logger.info(` Enabled: ${res.isEnabled}`); + + if (res.definition) { + + logger.info(` Definition:\n${res.definition}`); + + } + + } + + return res; + + }); + + }, + }); + + if (error) process.exit(1); + + if (!detail) { + + outputError(args, `Trigger not found: ${args.name}`); + process.exit(1); + + } + + if (args.json) { + + outputResult(args, detail, ''); + + } + + process.exit(0); + + } + + const [triggers, error] = await withContext({ + args, + fn: (ctx, logger) => { + + return fetchList(ctx.kysely as Kysely, ctx.dialect, 'triggers', { schema: args.schema }).then((res) => { + + if (!args.json) { + + logger.info(`Triggers: ${res.length}`); + + for (const t of res) { + + const table = t.tableSchema ? `${t.tableSchema}.${t.tableName}` : t.tableName; + logger.info(` ${t.name}: ${t.timing} ${t.events.join('/')} on ${table}`); + + } + + } + + return res; + + }); + + }, + }); + + if (error) process.exit(1); + + if (args.json) { + + outputResult(args, triggers, ''); + + } + + process.exit(0); + + }, +}); + +(triggersCommand as typeof triggersCommand & { examples: string[] }).examples = [ + 'noorm db explore triggers', + 'noorm db explore triggers --json', + 'noorm db explore triggers --schema app', + 'noorm db explore triggers audit_users', +]; + +export default triggersCommand; diff --git a/src/cli/db/explore.ts b/src/cli/db/explore.ts index 1dc360f4..884e9b80 100644 --- a/src/cli/db/explore.ts +++ b/src/cli/db/explore.ts @@ -15,6 +15,7 @@ import functions from './explore-functions.js'; import types from './explore-types.js'; import indexes from './explore-indexes.js'; import fks from './explore-fks.js'; +import triggers from './explore-triggers.js'; const exploreCommand = defineCommand({ meta: { @@ -25,7 +26,7 @@ const exploreCommand = defineCommand({ config: sharedArgs.config, json: sharedArgs.json, }, - subCommands: { tables, views, procedures, functions, types, indexes, fks }, + subCommands: { tables, views, procedures, functions, types, indexes, fks, triggers }, async run({ args }) { const [overview, error] = await withContext({ @@ -35,13 +36,19 @@ const exploreCommand = defineCommand({ if (error) process.exit(1); + // Every counter printed here has a subcommand that can list it. + // locks/connections stay out: they are runtime state, not schema, and + // no CLI listing exists for them (they remain in --json). const text = [ 'Database Overview', - ` Tables: ${overview.tables}`, - ` Views: ${overview.views}`, - ` Functions: ${overview.functions}`, - ` Procedures: ${overview.procedures}`, - ` Types: ${overview.types}`, + ` Tables: ${overview.tables}`, + ` Views: ${overview.views}`, + ` Functions: ${overview.functions}`, + ` Procedures: ${overview.procedures}`, + ` Types: ${overview.types}`, + ` Indexes: ${overview.indexes}`, + ` Foreign Keys: ${overview.foreignKeys}`, + ` Triggers: ${overview.triggers}`, ].join('\n'); outputResult(args, overview, text); @@ -62,6 +69,7 @@ const exploreCommand = defineCommand({ 'noorm db explore types', 'noorm db explore indexes', 'noorm db explore fks', + 'noorm db explore triggers', ]; export default exploreCommand; From fa3d8fcb6cc05a9a8087403f92528f7279837c27 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:09:56 -0400 Subject: [PATCH 078/105] 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. --- .../integration/explore/multi-schema.test.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/integration/explore/multi-schema.test.ts b/tests/integration/explore/multi-schema.test.ts index eac98445..e3874e94 100644 --- a/tests/integration/explore/multi-schema.test.ts +++ b/tests/integration/explore/multi-schema.test.ts @@ -355,7 +355,16 @@ describe('integration: sqlite hostile identifiers', () => { await run(db, [ 'CREATE TABLE plain (id INTEGER PRIMARY KEY)', 'CREATE TABLE canary (id INTEGER PRIMARY KEY)', + 'CREATE TABLE audit_log (id INTEGER)', ...HOSTILE.map((name) => `CREATE TABLE "${name.replaceAll('"', '""')}" (id INTEGER PRIMARY KEY, val TEXT)`), + + // Body contains INSERT; the trigger event is DELETE. + `CREATE TRIGGER cascade_delete AFTER DELETE ON plain + BEGIN INSERT INTO audit_log (id) VALUES (OLD.id); END`, + + // Name contains "before"; the timing is AFTER. + `CREATE TRIGGER before_update_log AFTER UPDATE OF id ON plain + BEGIN INSERT INTO audit_log (id) VALUES (NEW.id); END`, ]); }); @@ -399,7 +408,32 @@ describe('integration: sqlite hostile identifiers', () => { const overview = await fetchOverview(db, 'sqlite'); - expect(overview.tables).toBe(2 + HOSTILE.length); + expect(overview.tables).toBe(3 + HOSTILE.length); + expect(overview.triggers).toBe(2); + + }); + + it('should read trigger events from the header, against real sqlite_master text', async () => { + + const triggers = await fetchList(db, 'sqlite', 'triggers'); + const cascade = triggers.find((t) => t.name === 'cascade_delete'); + const named = triggers.find((t) => t.name === 'before_update_log'); + + expect(cascade?.events).toEqual(['DELETE']); + expect(cascade?.timing).toBe('AFTER'); + + // "before" appears in the trigger's own name, not as its timing. + expect(named?.timing).toBe('AFTER'); + expect(named?.events).toEqual(['UPDATE']); + + }); + + it('should describe a trigger without inventing events from its body', async () => { + + const detail = await fetchDetail(db, 'sqlite', 'triggers', 'cascade_delete'); + + expect(detail?.events).toEqual(['DELETE']); + expect(detail?.tableName).toBe('plain'); }); From 97274cec7f0f5f4f88128937aaf5028f4ca71c4e Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:10:18 -0400 Subject: [PATCH 079/105] 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. --- src/cli/db/transfer.ts | 20 +++++++++++------- src/core/dt/index.ts | 2 +- src/core/dt/paths.ts | 34 ++++++++++++++++++++++++++++++ tests/core/dt/paths.test.ts | 41 +++++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/cli/db/transfer.ts b/src/cli/db/transfer.ts index 8383dc35..a6059f28 100644 --- a/src/cli/db/transfer.ts +++ b/src/cli/db/transfer.ts @@ -7,7 +7,7 @@ import * as p from '@clack/prompts'; import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; -import { resolveExportExtension, resolveExportPath, ensureExportDirectory } from '../../core/dt/index.js'; +import { resolveExportExtension, resolveExportPath, resolveExportTables, ensureExportDirectory } from '../../core/dt/index.js'; import { MIN_PASSPHRASE_LENGTH } from '../../core/dt/crypto.js'; import { getStateManager } from '../../core/state/index.js'; import type { TransferOptions, ConflictStrategy } from '../../core/transfer/index.js'; @@ -493,10 +493,6 @@ async function handleExport(opts: { const { exportPath, passphrase, compress, tables, batchSize, args } = opts; const ext = resolveExportExtension(compress, passphrase); - const tableList = tables ?? []; - const tableCount = tableList.length; - - ensureExportDirectory(exportPath, tableCount); const [exportResults, error] = await withContext({ args, @@ -508,6 +504,11 @@ async function handleExport(opts: { } + const tableList = await resolveExportTables(tables, () => ctx.noorm.db.listTables()); + const tableCount = tableList.length; + + ensureExportDirectory(exportPath, tableCount); + let totalRows = 0; let totalBytes = 0; const tableResults: Array<{ table: string; filepath: string; rows: number; bytes: number }> = []; @@ -537,7 +538,7 @@ async function handleExport(opts: { } - return { totalRows, totalBytes, tableResults }; + return { totalRows, totalBytes, tableCount, tableResults }; }, }); @@ -548,13 +549,18 @@ async function handleExport(opts: { outputResult(args, { success: false, error: error.message }, ''); + } + else { + + process.stderr.write(`${error.message}\n`); + } return 1; } - const { totalRows, totalBytes, tableResults } = exportResults; + const { totalRows, totalBytes, tableCount, tableResults } = exportResults; if (args.json) { diff --git a/src/core/dt/index.ts b/src/core/dt/index.ts index b9b64654..1765d1ad 100644 --- a/src/core/dt/index.ts +++ b/src/core/dt/index.ts @@ -941,7 +941,7 @@ export { serializeRow, serializeValue, encodeValue } from './serialize.js'; export { deserializeRow, deserializeValue } from './deserialize.js'; export { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'; export { FORMAT_VERSION, GZIP_THRESHOLD, GZIP_RATIO_THRESHOLD, SIMPLE_TYPES, ENCODED_TYPES } from './constants.js'; -export { resolveExportExtension, resolveExportPath, ensureExportDirectory } from './paths.js'; +export { resolveExportExtension, resolveExportPath, resolveExportTables, ensureExportDirectory } from './paths.js'; export { modifyDtFile, transformSchema, validateRecipe, buildRowProxy } from './modify.js'; export type { diff --git a/src/core/dt/paths.ts b/src/core/dt/paths.ts index 7137a8be..cba45716 100644 --- a/src/core/dt/paths.ts +++ b/src/core/dt/paths.ts @@ -69,6 +69,40 @@ export function resolveExportPath(opts: { } +/** + * Resolve which tables an export should write. + * + * `--tables` is documented as "(default: all)". Omitting it resolved to an + * empty list, so the export wrote nothing, reported success and exited 0 — a + * silent empty backup. An explicit but empty selection is an error rather + * than a no-op for the same reason. + * + * @example + * ```typescript + * const tables = await resolveExportTables(args.tables, () => ctx.noorm.db.listTables()); + * ``` + */ +export async function resolveExportTables( + tables: string[] | undefined, + listTables: () => Promise<{ name: string }[]>, +): Promise { + + const resolved = tables ?? (await listTables()).map((t) => t.name); + + if (resolved.length === 0) { + + throw new Error( + tables + ? 'No tables selected for export — --tables was empty' + : 'No tables found to export', + ); + + } + + return resolved; + +} + /** * Ensures the export directory exists for multi-table exports. * diff --git a/tests/core/dt/paths.test.ts b/tests/core/dt/paths.test.ts index 5ff2222a..254140b9 100644 --- a/tests/core/dt/paths.test.ts +++ b/tests/core/dt/paths.test.ts @@ -2,12 +2,53 @@ * Tests for export path resolution utilities. */ import { describe, it, expect } from 'bun:test'; +import { attempt } from '@logosdx/utils'; import { resolveExportExtension, resolveExportPath, + resolveExportTables, } from '../../../src/core/dt/paths.js'; +describe('resolveExportTables', () => { + + const listTables = async () => [{ name: 'users' }, { name: 'posts' }]; + + // `--tables` describes itself as "(default: all)". Omitting it resolved + // to [], so `--export ./out` wrote no files, printed {"success":true} and + // exited 0 — a backup that looks like it happened. + it('should default to every table when no selection is given', async () => { + + expect(await resolveExportTables(undefined, listTables)).toEqual(['users', 'posts']); + + }); + + it('should honour an explicit selection', async () => { + + expect(await resolveExportTables(['posts'], listTables)).toEqual(['posts']); + + }); + + it('should fail rather than export nothing from an empty database', async () => { + + const [tables, err] = await attempt(() => resolveExportTables(undefined, async () => [])); + + expect(tables).toBeNull(); + expect(err!.message).toMatch(/No tables found/); + + }); + + it('should fail on an explicitly empty selection', async () => { + + const [tables, err] = await attempt(() => resolveExportTables([], listTables)); + + expect(tables).toBeNull(); + expect(err!.message).toMatch(/--tables was empty/); + + }); + +}); + describe('resolveExportExtension', () => { it('returns .dt by default', () => { From d29d06eae833a3970c1cb412f0e9cbbb7830004c Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:13:17 -0400 Subject: [PATCH 080/105] 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. --- src/core/dt/streamer.ts | 9 + src/core/transfer/executor.ts | 16 +- src/core/transfer/planner.ts | 11 +- .../transfer/cross-dialect.test.ts | 156 ++++++++++++++++++ 4 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 tests/integration/transfer/cross-dialect.test.ts diff --git a/src/core/dt/streamer.ts b/src/core/dt/streamer.ts index de09a2c1..c732697a 100644 --- a/src/core/dt/streamer.ts +++ b/src/core/dt/streamer.ts @@ -221,6 +221,15 @@ export class DtStreamer { } + // MySQL's driver has no JSON codec — handing it the object postgres + // returned for a jsonb column sends "[object Object]" and the insert + // fails with "Invalid JSON text ... at position 1". + if (this.#targetDialect === 'mysql') { + + return typeof value === 'string' ? value : JSON.stringify(value); + + } + // If source was MSSQL < 2025 (string), parse it for other targets if (this.#sourceDialect === 'mssql' && typeof value === 'string') { diff --git a/src/core/transfer/executor.ts b/src/core/transfer/executor.ts index a77c8fe3..58197ead 100644 --- a/src/core/transfer/executor.ts +++ b/src/core/transfer/executor.ts @@ -520,6 +520,7 @@ async function transferTableCrossDialect( const startTime = Date.now(); const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE; + const strategy = options.onConflict ?? 'fail'; const destOps = getTransferOperations(ctx.destination.dialect); if (!destOps || !plan.columnTypes) { @@ -617,15 +618,12 @@ async function transferTableCrossDialect( if (insertErr) { - const lower = insertErr.message.toLowerCase(); - const isDuplicate = lower.includes('duplicate') || lower.includes('unique') || lower.includes('primary key'); - - if (isDuplicate && options.onConflict === 'skip') { - - rowsSkipped++; - - } - else if (options.onConflict !== 'fail') { + // Only a *conflict* is skippable. Testing the raw option + // against 'fail' meant the SDK default (undefined) swallowed + // every insert error — a type conversion the streamer got + // wrong counted as a skipped row and the transfer reported + // success with two thirds of the table missing. + if (isDuplicateKeyError(insertErr.message) && strategy !== 'fail') { rowsSkipped++; diff --git a/src/core/transfer/planner.ts b/src/core/transfer/planner.ts index 6f508a7e..2e55334a 100644 --- a/src/core/transfer/planner.ts +++ b/src/core/transfer/planner.ts @@ -150,8 +150,15 @@ export async function planTransfer( } - // Check destination schema compatibility - const [destTables, destErr] = await listUserTables(ctx.destination.db, dialect, { tables: options.tables }); + // Check destination schema compatibility. Probing with the *source* + // dialect aborted every cross-dialect transfer here — postgres catalog + // SQL against MySQL and vice versa — so the whole crossDialect path was + // unreachable. + const [destTables, destErr] = await listUserTables( + ctx.destination.db, + ctx.destination.dialect, + { tables: options.tables }, + ); if (destErr) { diff --git a/tests/integration/transfer/cross-dialect.test.ts b/tests/integration/transfer/cross-dialect.test.ts new file mode 100644 index 00000000..55a64823 --- /dev/null +++ b/tests/integration/transfer/cross-dialect.test.ts @@ -0,0 +1,156 @@ +/** + * Cross-dialect transfer integration tests. + * + * Every other transfer test opens one dialect, which is why cross-dialect + * transfer shipped broken: the planner probed the destination with the + * *source* dialect, so postgres catalog SQL ran against MySQL and every + * cross-dialect transfer aborted in the planner. `DtStreamer` — the whole + * conversion layer — was therefore unreachable and never executed. + * + * Assertions compare the destination's actual contents, not the reported + * counts: the first run of this path reported `rowsTransferred: 1, + * rowsSkipped: 2, status: success` while dropping two thirds of the table. + * + * Requires both postgres and mysql containers. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { sql } from 'kysely'; + +import type { Kysely } from 'kysely'; + +import { transferData, getTransferPlan } from '../../../src/core/transfer/index.js'; +import { createConnection } from '../../../src/core/connection/factory.js'; +import { skipIfNoContainer, makeTestConfig, TEST_CONNECTIONS } from '../../utils/db.js'; + +const TABLE = 'xdialect_probe'; + +describe('transfer: cross-dialect postgres to mysql', () => { + + let pgDb: Kysely; + let myDb: Kysely; + let pgDestroy: () => Promise; + let myDestroy: () => Promise; + + const pgConfig = makeTestConfig('xdialect_pg', { ...TEST_CONNECTIONS.postgres }); + const myConfig = makeTestConfig('xdialect_my', { ...TEST_CONNECTIONS.mysql }); + + beforeAll(async () => { + + await skipIfNoContainer('postgres'); + await skipIfNoContainer('mysql'); + + const pgConn = await createConnection(pgConfig.connection, 'xdialect_pg'); + pgDb = pgConn.db; + pgDestroy = pgConn.destroy; + + const myConn = await createConnection(myConfig.connection, 'xdialect_my'); + myDb = myConn.db; + myDestroy = myConn.destroy; + + await sql.raw(`DROP TABLE IF EXISTS ${TABLE}`).execute(pgDb); + await sql.raw(`DROP TABLE IF EXISTS ${TABLE}`).execute(myDb); + + await sql.raw(` + CREATE TABLE ${TABLE} ( + id int PRIMARY KEY, + label text NOT NULL, + meta jsonb, + flag boolean NOT NULL + ) + `).execute(pgDb); + + await sql.raw(` + CREATE TABLE ${TABLE} ( + id int PRIMARY KEY, + label varchar(255) NOT NULL, + meta json, + flag tinyint(1) NOT NULL + ) + `).execute(myDb); + + }); + + afterAll(async () => { + + if (pgDb) await sql.raw(`DROP TABLE IF EXISTS ${TABLE}`).execute(pgDb).catch(() => undefined); + if (myDb) await sql.raw(`DROP TABLE IF EXISTS ${TABLE}`).execute(myDb).catch(() => undefined); + + if (myDestroy) await myDestroy(); + if (pgDestroy) await pgDestroy(); + + }); + + beforeEach(async () => { + + await sql.raw(`DELETE FROM ${TABLE}`).execute(pgDb); + await sql.raw(`DELETE FROM ${TABLE}`).execute(myDb); + + await sql.raw(` + INSERT INTO ${TABLE} (id, label, meta, flag) VALUES + (1, 'alpha', '{"k":1}', true), + (2, 'beta', '{"k":2}', false), + (3, 'gamma', NULL, true) + `).execute(pgDb); + + }); + + it('should plan a cross-dialect transfer instead of aborting on the destination probe', async () => { + + const [plan, err] = await getTransferPlan(pgConfig, myConfig, { tables: [TABLE] }); + + // Probing with the source dialect produced + // `column t.table_rows does not exist` / a MySQL syntax error here. + expect(err).toBeNull(); + expect(plan!.crossDialect).toBe(true); + expect(plan!.tables.map((t) => t.name)).toContain(TABLE); + + }); + + it('should land every source row in the destination', async () => { + + const [result, err] = await transferData(pgConfig, myConfig, { tables: [TABLE] }); + + expect(err).toBeNull(); + expect(result!.status).toBe('success'); + + const landed = await sql<{ id: number; label: string; meta: unknown; flag: number }>` + SELECT id, label, meta, flag FROM xdialect_probe ORDER BY id + `.execute(myDb); + + expect(landed.rows.map((r) => r.id)).toEqual([1, 2, 3]); + expect(landed.rows.map((r) => r.label)).toEqual(['alpha', 'beta', 'gamma']); + expect(landed.rows.map((r) => Number(r.flag))).toEqual([1, 0, 1]); + + // jsonb arrives from postgres as an object; MySQL's driver has no + // JSON codec, so an unconverted object inserted as "[object Object]" + // and the row was counted as skipped. + expect(landed.rows[0]!.meta).toEqual({ k: 1 }); + expect(landed.rows[1]!.meta).toEqual({ k: 2 }); + expect(landed.rows[2]!.meta).toBeNull(); + + expect(result!.totalRows).toBe(3); + + }); + + it('should fail loudly when a row cannot be inserted', async () => { + + // A label longer than the destination column forces a real insert + // error. It must surface as a failure, not be counted as "skipped": + // the strategy check read the raw option, so the SDK default + // (undefined) swallowed every non-conflict error. + await sql.raw(`UPDATE ${TABLE} SET label = repeat('x', 300) WHERE id = 2`).execute(pgDb); + + const [result, err] = await transferData(pgConfig, myConfig, { tables: [TABLE] }); + + expect(err).toBeNull(); + expect(result!.status).not.toBe('success'); + + const failed = result!.tables.find((t) => t.table === TABLE)!; + + expect(failed.status).toBe('failed'); + expect(failed.rowsSkipped).toBe(0); + expect(failed.error).toBeTruthy(); + + }); + +}); From 434a351d1b4b9b34116d242904416ef1facabe29 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:15:34 -0400 Subject: [PATCH 081/105] fix(change): retrieve insert ids on mysql MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/change/history.ts | 93 +++++++-- .../change/history-dialects.test.ts | 179 ++++++++++++++++++ .../change/mysql-lifecycle.test.ts | 178 +++++++++++++++++ 3 files changed, 438 insertions(+), 12 deletions(-) create mode 100644 tests/integration/change/history-dialects.test.ts create mode 100644 tests/integration/change/mysql-lifecycle.test.ts diff --git a/src/core/change/history.ts b/src/core/change/history.ts index 909fff2d..00fc0b31 100644 --- a/src/core/change/history.ts +++ b/src/core/change/history.ts @@ -52,6 +52,31 @@ import type { ChangeType } from '../shared/index.js'; // Date Hydration // ───────────────────────────────────────────────────────────── +/** + * Coerce a driver's generated-key value into a usable operation id. + * + * WHY: the three id-retrieval strategies hand back three different + * shapes — postgres a number, mysql a `BigInt` on the insert result, + * mssql whatever the OUTPUT clause yields. Rejecting non-positive and + * unsafe values here stops a `0`, a `null` or a numeric string being + * written into a column that later rows join against. + * + * @example + * toOperationId(42n); // 42 + * toOperationId(null); // undefined + */ +function toOperationId(value: unknown): number | undefined { + + if (value === null || value === undefined) return undefined; + + const asNumber = Number(value); + + if (!Number.isSafeInteger(asNumber) || asNumber <= 0) return undefined; + + return asNumber; + +} + /** * Reserved change name recording a `db teardown`. * @@ -645,7 +670,14 @@ export class ChangeHistory { cli_version: getCurrentVersion(), }); - // MSSQL uses OUTPUT inserted.id (not RETURNING) + // Three id-retrieval strategies, one per driver capability: + // mssql OUTPUT inserted.id + // mysql no RETURNING clause exists — the driver reports the + // generated key on the insert result itself. Read it from + // there rather than issuing LAST_INSERT_ID() as a second + // query: that function is per-connection, and Kysely + // returns the connection to the pool between statements. + // others RETURNING for an atomic insert+get-id let id: number | undefined; if (this.#dialect === 'mssql') { @@ -663,7 +695,20 @@ export class ChangeHistory { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - id = (result as any)?.id; + id = toOperationId((result as any)?.id); + + } + else if (this.#dialect === 'mysql') { + + const [result, err] = await attempt(() => insertQuery.executeTakeFirst()); + + if (err) { + + throw new Error('Failed to create change operation record', { cause: err }); + + } + + id = toOperationId(result?.insertId); } else { @@ -678,7 +723,7 @@ export class ChangeHistory { } - id = result?.id ?? undefined; + id = toOperationId(result?.id); // SQLite with better-sqlite3 may return null for RETURNING if (id === null || id === undefined) { @@ -688,7 +733,7 @@ export class ChangeHistory { if (lastIdQuery) { const [lastIdResult] = await attempt(() => lastIdQuery.execute(this.#db)); - id = lastIdResult?.rows?.[0]?.id; + id = toOperationId(lastIdResult?.rows?.[0]?.id); } @@ -709,6 +754,14 @@ export class ChangeHistory { /** * Get dialect-specific last-insert-id query. * + * Only reached as a fallback when `RETURNING id` yielded nothing, so it + * covers just the dialects that take the RETURNING path. + * + * Deliberately has no mysql or mssql case: both retrieve their id from + * the insert itself. A second-statement `LAST_INSERT_ID()` would in fact + * be wrong on mysql — it is per-connection, and Kysely returns the + * connection to the pool between statements. + * * Returns null if the dialect should always use RETURNING/OUTPUT. */ #lastInsertIdQuery(): ReturnType> | null { @@ -718,12 +771,6 @@ export class ChangeHistory { case 'sqlite': return sql<{ id: number }>`SELECT last_insert_rowid() as id`; - case 'mysql': - return sql<{ id: number }>`SELECT LAST_INSERT_ID() as id`; - - case 'mssql': - return sql<{ id: number }>`SELECT SCOPE_IDENTITY() as id`; - case 'postgres': return sql<{ id: number }>`SELECT lastval() as id`; @@ -978,6 +1025,8 @@ export class ChangeHistory { checksum: '', }); + // Same three id-retrieval strategies as createOperation — see the + // comment there for why mysql must not use LAST_INSERT_ID(). if (this.#dialect === 'mssql') { const [result, insertErr] = await attempt(() => @@ -997,7 +1046,27 @@ export class ChangeHistory { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (result as any)?.id ?? 0; + return toOperationId((result as any)?.id) ?? 0; + + } + + if (this.#dialect === 'mysql') { + + const [result, insertErr] = await attempt(() => insertQuery.executeTakeFirst()); + + if (insertErr) { + + observer.emit('error', { + source: 'change', + error: insertErr, + context: { operation: 'record-reset' }, + }); + + return 0; + + } + + return toOperationId(result?.insertId) ?? 0; } @@ -1017,7 +1086,7 @@ export class ChangeHistory { } - return result.id; + return toOperationId(result?.id) ?? 0; } diff --git a/tests/integration/change/history-dialects.test.ts b/tests/integration/change/history-dialects.test.ts new file mode 100644 index 00000000..6c66c7ea --- /dev/null +++ b/tests/integration/change/history-dialects.test.ts @@ -0,0 +1,179 @@ +/** + * Integration test: `ChangeHistory` id-retrieval against every live dialect. + * + * `ChangeHistory` carries its own copy of the insert-and-get-id logic, + * independent of `Tracker`'s (`src/core/runner/tracker.ts`). Fixing the + * runner's MySQL path therefore did nothing for the change module, and + * every `tests/core/change` file constructs `ChangeHistory` with + * `'sqlite'` — the one dialect where `RETURNING` happens to work. MySQL + * has no `RETURNING` clause at all, so `change run`, `change ff`, + * `change revert` and `db teardown` were all inoperable there while the + * suite stayed green. + * + * Asserts the contract rather than the SQL: whatever strategy a dialect + * needs, these methods must hand back a usable primary key that child + * rows can reference. Anything else means no operation record exists. + * + * Requires the docker-compose.test.yml containers (postgres 15432, + * mysql 13306, mssql 11433). Each dialect skips itself when unreachable. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Kysely, SqliteDialect } from 'kysely'; + +import { attempt } from '@logosdx/utils'; + +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { ChangeHistory } from '../../../src/core/change/history.js'; +import { migrateSchema } from '../../../src/core/version/schema/index.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { getNoormTables, noormDb } from '../../../src/core/shared/index.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; +import type { ConnectionResult } from '../../../src/core/connection/types.js'; + +import { createTestConnection, isContainerRunning } from '../../utils/db.js'; + +const LIVE_DIALECTS: Dialect[] = ['postgres', 'mysql', 'mssql']; + +const CONFIG_NAME = '__change_history_dialects__'; + +describe('change: history id retrieval across dialects', () => { + + it('should return a usable operation id on sqlite', async () => { + + const db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + const id = await new ChangeHistory(db, CONFIG_NAME, 'sqlite').createOperation({ + name: 'change:sqlite', + direction: 'change', + executedBy: 'test@example.com', + }); + + expect(typeof id).toBe('number'); + expect(id).toBeGreaterThan(0); + + await db.destroy(); + + }); + + for (const dialect of LIVE_DIALECTS) { + + describe(dialect, () => { + + let conn: ConnectionResult | null = null; + let reachable = false; + + beforeAll(async () => { + + reachable = await isContainerRunning(dialect); + + if (!reachable) return; + + conn = await createTestConnection(dialect); + + await migrateSchema(conn.db as unknown as Kysely, dialect); + + }); + + afterAll(async () => { + + if (!conn) return; + + const db = conn.db as unknown as Kysely; + const tables = getNoormTables(dialect); + + // Child rows first — executions carries an FK to change. + await attempt(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, dialect) as any) + .deleteFrom(tables.executions) + .where('change_id', 'in', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, dialect) as any) + .selectFrom(tables.change) + .select('id') + .where('config_name', '=', CONFIG_NAME), + ) + .execute(), + ); + + await attempt(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, dialect) as any) + .deleteFrom(tables.change) + .where('config_name', '=', CONFIG_NAME) + .execute(), + ); + + await conn.destroy(); + + }); + + it('should return an operation id that child rows can reference', async () => { + + if (!reachable) { + + console.warn(`Skipping ${dialect}: container not reachable`); + + return; + + } + + const db = conn!.db as unknown as Kysely; + const history = new ChangeHistory(db, CONFIG_NAME, dialect); + + const [id, err] = await attempt(() => + history.createOperation({ + name: `change:${dialect}:${Date.now()}`, + direction: 'change', + executedBy: 'test@example.com', + }), + ); + + expect(err?.message ?? null).toBeNull(); + expect(typeof id).toBe('number'); + expect(id!).toBeGreaterThan(0); + + // A fabricated id would satisfy the assertions above but fail + // the FK — the point of the id is that child rows can use it. + const recordsErr = await history.createFileRecords(id!, [ + { filepath: 'changes/001.sql', fileType: 'sql', checksum: 'abc123' }, + ]); + + expect(recordsErr).toBeNull(); + + }); + + it('should return a usable id when recording a teardown', async () => { + + if (!reachable) { + + console.warn(`Skipping ${dialect}: container not reachable`); + + return; + + } + + const db = conn!.db as unknown as Kysely; + const history = new ChangeHistory(db, CONFIG_NAME, dialect); + + // recordReset swallows its error and returns 0, so a zero id + // is exactly the silent failure this asserts against. + const id = await history.recordReset('test@example.com', 'integration test'); + + expect(typeof id).toBe('number'); + expect(id).toBeGreaterThan(0); + + }); + + }); + + } + +}); diff --git a/tests/integration/change/mysql-lifecycle.test.ts b/tests/integration/change/mysql-lifecycle.test.ts new file mode 100644 index 00000000..16fb67f6 --- /dev/null +++ b/tests/integration/change/mysql-lifecycle.test.ts @@ -0,0 +1,178 @@ +/** + * Integration test: the change lifecycle end to end on live MySQL. + * + * Every `tests/core/change` file runs on in-memory SQLite, so the whole + * module's behaviour on a server dialect was unverified. MySQL turned out + * to be entirely inoperable — `ChangeHistory.createOperation` used a + * `RETURNING` clause MySQL does not have, so no operation record was ever + * created and no change could run. + * + * Drives `ChangeManager`'s public API rather than the history internals, + * so it also pins the apply -> revert -> re-apply cycle on a dialect where + * that path had never executed. + * + * Requires the docker-compose.test.yml MySQL container (13306); skips + * itself when unreachable. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Kysely, sql } from 'kysely'; + +import { attempt } from '@logosdx/utils'; + +import { ChangeManager } from '../../../src/core/change/manager.js'; +import { migrateSchema } from '../../../src/core/version/schema/index.js'; +import { getNoormTables, noormDb } from '../../../src/core/shared/index.js'; +import { resetLockManager } from '../../../src/core/lock/index.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import type { ConnectionResult } from '../../../src/core/connection/types.js'; +import type { ChangeContext } from '../../../src/core/change/types.js'; + +import { createTestConnection, isContainerRunning } from '../../utils/db.js'; + +const CONFIG_NAME = '__change_mysql_lifecycle__'; +const TABLE = 'noorm_it_change_probe'; +const CHANGE_NAME = '2026-01-01-mysql-probe'; + +describe('change: lifecycle on live mysql', () => { + + let conn: ConnectionResult | null = null; + let reachable = false; + let tempDir: string; + let changesDir: string; + let sqlDir: string; + + beforeAll(async () => { + + reachable = await isContainerRunning('mysql'); + + if (!reachable) return; + + resetLockManager(); + + conn = await createTestConnection('mysql'); + + await migrateSchema(conn.db as unknown as Kysely, 'mysql'); + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-mysql-change-')); + changesDir = join(tempDir, 'changes'); + sqlDir = join(tempDir, 'sql'); + + await mkdir(join(changesDir, CHANGE_NAME, 'change'), { recursive: true }); + await mkdir(join(changesDir, CHANGE_NAME, 'revert'), { recursive: true }); + await mkdir(sqlDir, { recursive: true }); + + await writeFile( + join(changesDir, CHANGE_NAME, 'change', '001_create.sql'), + `CREATE TABLE ${TABLE} (id INT PRIMARY KEY)`, + ); + await writeFile( + join(changesDir, CHANGE_NAME, 'revert', '001_drop.sql'), + `DROP TABLE ${TABLE}`, + ); + + }); + + afterAll(async () => { + + if (!conn) return; + + const db = conn.db as unknown as Kysely; + const tables = getNoormTables('mysql'); + + await attempt(() => sql.raw(`DROP TABLE IF EXISTS ${TABLE}`).execute(db)); + + // Child rows first — executions carries an FK to change. + await attempt(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, 'mysql') as any) + .deleteFrom(tables.executions) + .where('change_id', 'in', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, 'mysql') as any) + .selectFrom(tables.change) + .select('id') + .where('config_name', '=', CONFIG_NAME), + ) + .execute(), + ); + + await attempt(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, 'mysql') as any) + .deleteFrom(tables.change) + .where('config_name', '=', CONFIG_NAME) + .execute(), + ); + + await conn.destroy(); + await rm(tempDir, { recursive: true, force: true }); + + resetLockManager(); + + }); + + function buildContext(): ChangeContext { + + return { + db: conn!.db as unknown as Kysely, + configName: CONFIG_NAME, + identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, + projectRoot: tempDir, + changesDir, + sqlDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', + dialect: 'mysql', + }; + + } + + async function tableExists(): Promise { + + const db = conn!.db as unknown as Kysely; + + const rows = await sql<{ c: number }>` + SELECT COUNT(*) AS c FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ${TABLE} + `.execute(db); + + return Number(rows.rows[0]?.c ?? 0) > 0; + + } + + it('should apply, revert and re-apply a change against mysql', async () => { + + if (!reachable) { + + console.warn('Skipping mysql: container not reachable'); + + return; + + } + + const manager = new ChangeManager(buildContext()); + + const applied = await manager.run(CHANGE_NAME); + + expect(applied.error ?? null).toBeNull(); + expect(applied.status).toBe('success'); + expect(await tableExists()).toBe(true); + + const reverted = await manager.revert(CHANGE_NAME); + + expect(reverted.status).toBe('success'); + expect(await tableExists()).toBe(false); + + // The re-apply path is what the sqlite-only suite could never reach + // on a server dialect. + const reapplied = await manager.run(CHANGE_NAME); + + expect(reapplied.status).toBe('success'); + expect(await tableExists()).toBe(true); + + }); + +}); From 96ef43cdfdbde6db79ca481f052e3386600e93f9 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:15:46 -0400 Subject: [PATCH 082/105] fix(transfer): gate plan reads and surface partial outcomes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/db/transfer.ts | 7 +++++- src/core/dt/index.ts | 33 ++++++++++--------------- src/core/transfer/executor.ts | 8 ++++-- src/core/transfer/index.ts | 11 +++++++++ tests/core/transfer/counts.test.ts | 26 +++++++++++++++++++ tests/core/transfer/policy-gate.test.ts | 31 ++++++++++++++++++++++- 6 files changed, 92 insertions(+), 24 deletions(-) diff --git a/src/cli/db/transfer.ts b/src/cli/db/transfer.ts index a6059f28..34270053 100644 --- a/src/cli/db/transfer.ts +++ b/src/cli/db/transfer.ts @@ -455,7 +455,12 @@ const transferCommand = defineCommand({ } - process.exit(result?.status === 'success' ? 0 : 2); + // A partial transfer committed real rows and needs a different + // response than one that moved nothing — exit 2 for both left a + // pipeline unable to tell "retry" from "roll back". + if (result?.status === 'success') process.exit(0); + + process.exit(result?.status === 'partial' ? 3 : 2); }, }); diff --git a/src/core/dt/index.ts b/src/core/dt/index.ts index 1765d1ad..b99c6866 100644 --- a/src/core/dt/index.ts +++ b/src/core/dt/index.ts @@ -747,26 +747,19 @@ async function insertImportBatch( if (!isDuplicateError(err.message)) { - // Non-duplicate error — log first occurrence for diagnostics - if (skipped === 0 && inserted === 0) { - - observer.emit('error', { - source: 'dt:import', - error: err, - context: { table, operation: 'insert-row', onConflict, row: Object.keys(row).slice(0, 5).join(', ') }, - }); - - } - - // Non-duplicate error - if (onConflict === 'fail') { - - return [{ inserted, skipped, updated }, new Error(`Row ${globalRowNum} (${rowSummary()}): ${err.message}`)]; - - } - - skipped++; - continue; + // Every failure is reported, not just the first: the old + // `skipped === 0 && inserted === 0` guard meant an import + // that broke after row one went entirely unobserved. + observer.emit('error', { + source: 'dt:import', + error: err, + context: { table, operation: 'insert-row', onConflict, row: Object.keys(row).slice(0, 5).join(', ') }, + }); + + // A row the destination rejected outright is not a conflict, + // so no conflict strategy makes it skippable. Counting it as + // skipped let a whole failed import report success. + return [{ inserted, skipped, updated }, new Error(`Row ${globalRowNum} (${rowSummary()}): ${err.message}`)]; } diff --git a/src/core/transfer/executor.ts b/src/core/transfer/executor.ts index 58197ead..59c04e46 100644 --- a/src/core/transfer/executor.ts +++ b/src/core/transfer/executor.ts @@ -205,10 +205,14 @@ export async function executeTransfer( } const durationMs = Date.now() - startTime; - const allSuccess = tableResults.every((r) => r.status === 'success'); + + // `allSuccess` was false by definition whenever `hasFailures` was true, + // so 'partial' was unreachable and a run that moved most of the data + // looked identical to one that moved none. + const anySuccess = tableResults.some((r) => r.status === 'success'); const result: TransferResult = { - status: hasFailures ? (allSuccess ? 'partial' : 'failed') : 'success', + status: hasFailures ? (anySuccess ? 'partial' : 'failed') : 'success', tables: tableResults, totalRows, durationMs, diff --git a/src/core/transfer/index.ts b/src/core/transfer/index.ts index b80e5857..4aeb7987 100644 --- a/src/core/transfer/index.ts +++ b/src/core/transfer/index.ts @@ -179,6 +179,17 @@ export async function getTransferPlan( options: TransferOptions = {}, ): Promise<[TransferPlan | null, Error | null]> { + // The plan is destination schema metadata: table names, row estimates and + // the FK graph. Ungated, a viewer denied `transferData` could still read + // all of it through `--dry-run` or `transfer.plan()`. + const [, policyErr] = attemptSync(() => assertPolicy(options.channel ?? 'user', destConfig, 'transfer:plan')); + + if (policyErr) { + + return [null, policyErr]; + + } + // Validate dialects are supported const srcDialect = sourceConfig.connection.dialect; const dstDialect = destConfig.connection.dialect; diff --git a/tests/core/transfer/counts.test.ts b/tests/core/transfer/counts.test.ts index 1f816cf9..c731be54 100644 --- a/tests/core/transfer/counts.test.ts +++ b/tests/core/transfer/counts.test.ts @@ -141,6 +141,32 @@ describe('transfer: reported counts', () => { }); + // `hasFailures ? (allSuccess ? 'partial' : 'failed')` — allSuccess is + // false by definition when hasFailures is true, so 'partial' could never + // be produced and a run that committed most of the data was reported + // identically to one that committed none. + it('should report partial when one table fails and another succeeds', async () => { + + await sql.raw('DROP TABLE IF EXISTS countcheck_narrow').execute(sourceDb); + await sql.raw('DROP TABLE IF EXISTS countcheck_narrow').execute(destDb); + await sql.raw('CREATE TABLE countcheck_narrow (id int PRIMARY KEY, label text NOT NULL)').execute(sourceDb); + await sql.raw('CREATE TABLE countcheck_narrow (id int PRIMARY KEY, label varchar(3) NOT NULL)').execute(destDb); + await sql.raw('INSERT INTO countcheck_narrow (id, label) VALUES (1, \'far-too-long\')').execute(sourceDb); + + const [result, err] = await transferData(sourceConfig, destConfig, { + tables: ['countcheck', 'countcheck_narrow'], + }); + + await sql.raw('DROP TABLE IF EXISTS countcheck_narrow').execute(sourceDb); + await sql.raw('DROP TABLE IF EXISTS countcheck_narrow').execute(destDb); + + expect(err).toBeNull(); + expect(result!.status).toBe('partial'); + expect(result!.tables.find((t) => t.table === 'countcheck')!.status).toBe('success'); + expect(result!.tables.find((t) => t.table === 'countcheck_narrow')!.status).toBe('failed'); + + }); + it('should count rewritten rows under onConflict update', async () => { const values = Array.from({ length: ROWS }, (_, i) => `(${i + 1}, 'stale')`).join(', '); diff --git a/tests/core/transfer/policy-gate.test.ts b/tests/core/transfer/policy-gate.test.ts index 59211c48..1a97db3f 100644 --- a/tests/core/transfer/policy-gate.test.ts +++ b/tests/core/transfer/policy-gate.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect } from 'bun:test'; -import { transferData } from '../../../src/core/transfer/index.js'; +import { transferData, getTransferPlan } from '../../../src/core/transfer/index.js'; import { makeTestConfig } from '../../utils/db.js'; import type { ConfigAccess } from '../../../src/core/policy/index.js'; @@ -57,4 +57,33 @@ describe('transfer: policy gate', () => { }); + // The plan leaks destination table names, row estimates and the FK graph. + // It was ungated while transferData was gated, and `--dry-run` / + // `transfer.plan()` both route here — so a denied viewer read the schema + // anyway. + it('should deny getTransferPlan when the destination role denies transfer:plan', async () => { + + const source = { ...makeTestConfig('source', { dialect: 'postgres', database: 'x' }), access: ADMIN }; + const dest = { ...makeTestConfig('dest', { dialect: 'postgres', database: 'y' }), access: VIEWER }; + + const [plan, err] = await getTransferPlan(source, dest, { channel: 'user' }); + + expect(plan).toBeNull(); + expect(err?.message).toMatch(/transfer:plan/); + expect(err?.message).toContain('dest'); + + }); + + it('should gate getTransferPlan on the destination, not the source', async () => { + + const source = { ...makeTestConfig('source', { dialect: 'postgres', database: 'x' }), access: VIEWER }; + const dest = { ...makeTestConfig('dest', { dialect: 'sqlite', database: 'y' }), access: ADMIN }; + + const [plan, err] = await getTransferPlan(source, dest, { channel: 'user' }); + + expect(plan).toBeNull(); + expect(err?.message).toMatch(/not supported/i); + + }); + }); From f166ca463369c9368542d26b842e0fb271268dda Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:18:47 -0400 Subject: [PATCH 083/105] chore(changeset): note the mysql insert-id fix --- .changeset/change-reapply-and-rewind-fixes.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/change-reapply-and-rewind-fixes.md b/.changeset/change-reapply-and-rewind-fixes.md index a4dd5eba..9d7b57fe 100644 --- a/.changeset/change-reapply-and-rewind-fixes.md +++ b/.changeset/change-reapply-and-rewind-fixes.md @@ -3,8 +3,9 @@ '@noormdev/sdk': patch --- -Fix change apply/revert recovery and rewind flag handling +Fix change apply/revert recovery, rewind flag handling, and MySQL support +- Changes now run on MySQL at all. `ChangeHistory` retrieved insert ids with a `RETURNING` clause MySQL does not support, so no operation record was ever created and `change run`, `change ff`, `change revert` and `db teardown` were all inoperable. - A reverted or torn-down change can be applied again. Every file was previously skipped against a prior success, so `apply -> revert -> apply` and `db teardown -> change ff` reported success over an untouched database. - `ff` and `next` now treat `stale` changes as pending work, so teardown has a supported recovery path. - `change rewind` honours `--dry-run` and `--force`, and accepts the documented count form (`change rewind 3`). `changes.rewind()` takes an options argument. From a75bd513fd60b5cd23319f697a95f024b2909281 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:20:34 -0400 Subject: [PATCH 084/105] 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. --- src/tui/hooks/useLockStatus.ts | 2 +- src/tui/hooks/useVaultSecretKeys.ts | 2 +- src/tui/screens/change/ChangeAddScreen.tsx | 2 +- src/tui/screens/change/ChangeFFScreen.tsx | 2 +- src/tui/screens/change/ChangeNextScreen.tsx | 2 +- src/tui/screens/change/ChangeRevertScreen.tsx | 2 +- src/tui/screens/change/ChangeRewindScreen.tsx | 2 +- src/tui/screens/change/ChangeRunScreen.tsx | 2 +- src/tui/screens/config/ConfigExportScreen.tsx | 80 +++++++++---------- src/tui/screens/run/RunBuildScreen.tsx | 1 + src/tui/screens/run/RunExecScreen.tsx | 2 +- .../{screens/vault => }/VaultScreen.test.tsx | 49 +++++++----- 12 files changed, 80 insertions(+), 68 deletions(-) rename tests/cli/{screens/vault => }/VaultScreen.test.tsx (76%) diff --git a/src/tui/hooks/useLockStatus.ts b/src/tui/hooks/useLockStatus.ts index a5d3000c..9ce5656e 100644 --- a/src/tui/hooks/useLockStatus.ts +++ b/src/tui/hooks/useLockStatus.ts @@ -138,7 +138,7 @@ export function useLockStatus( }; - }, [activeConfig, activeConfigName, cryptoIdentity, db, connLoading, connError, reloadCounter]); + }, [activeConfig, activeConfigName, cryptoIdentity, db, dialect, connLoading, connError, reloadCounter]); return { status, identityStr, loading, error, reload }; diff --git a/src/tui/hooks/useVaultSecretKeys.ts b/src/tui/hooks/useVaultSecretKeys.ts index 85e2f553..6db65861 100644 --- a/src/tui/hooks/useVaultSecretKeys.ts +++ b/src/tui/hooks/useVaultSecretKeys.ts @@ -149,7 +149,7 @@ export function useVaultSecretKeys(): VaultSecretKeysResult { }; - }, [activeConfig, activeConfigName, identity, db, connLoading, connError]); + }, [activeConfig, activeConfigName, identity, db, dialect, connLoading, connError]); // Get required secrets (universal + stage-specific merged) const requiredSecrets = useMemo(() => { diff --git a/src/tui/screens/change/ChangeAddScreen.tsx b/src/tui/screens/change/ChangeAddScreen.tsx index 8d1bd5aa..15144f76 100644 --- a/src/tui/screens/change/ChangeAddScreen.tsx +++ b/src/tui/screens/change/ChangeAddScreen.tsx @@ -153,7 +153,7 @@ export function ChangeAddScreen({ params }: ScreenProps): ReactElement { } }, - [activeConfig, validateName], + [activeConfig, validateName, settings, projectRoot], ); // Auto-create if name provided in params diff --git a/src/tui/screens/change/ChangeFFScreen.tsx b/src/tui/screens/change/ChangeFFScreen.tsx index 548b7160..35c661d7 100644 --- a/src/tui/screens/change/ChangeFFScreen.tsx +++ b/src/tui/screens/change/ChangeFFScreen.tsx @@ -183,7 +183,7 @@ export function ChangeFFScreen({ params: _params }: ScreenProps): ReactElement { } - }, [activeConfig, activeConfigName, stateManager, pendingChanges, globalModes]); + }, [activeConfig, activeConfigName, stateManager, cryptoIdentity, settings, pendingChanges, globalModes]); // Handle cancel const handleCancel = useCallback(() => { diff --git a/src/tui/screens/change/ChangeNextScreen.tsx b/src/tui/screens/change/ChangeNextScreen.tsx index 0771a01e..de40996d 100644 --- a/src/tui/screens/change/ChangeNextScreen.tsx +++ b/src/tui/screens/change/ChangeNextScreen.tsx @@ -192,7 +192,7 @@ export function ChangeNextScreen({ params }: ScreenProps): ReactElement { } - }, [activeConfig, activeConfigName, stateManager, changesToApply, count, globalModes]); + }, [activeConfig, activeConfigName, stateManager, cryptoIdentity, settings, changesToApply, count, globalModes]); // Handle cancel const handleCancel = useCallback(() => { diff --git a/src/tui/screens/change/ChangeRevertScreen.tsx b/src/tui/screens/change/ChangeRevertScreen.tsx index e92baed3..e2f04eb8 100644 --- a/src/tui/screens/change/ChangeRevertScreen.tsx +++ b/src/tui/screens/change/ChangeRevertScreen.tsx @@ -173,7 +173,7 @@ export function ChangeRevertScreen({ params }: ScreenProps): ReactElement { } - }, [activeConfig, activeConfigName, change, cryptoIdentity, globalModes]); + }, [activeConfig, activeConfigName, change, cryptoIdentity, settings, globalModes]); // Handle cancel const handleCancel = useCallback(() => { diff --git a/src/tui/screens/change/ChangeRewindScreen.tsx b/src/tui/screens/change/ChangeRewindScreen.tsx index 5a7fe44b..80fc260a 100644 --- a/src/tui/screens/change/ChangeRewindScreen.tsx +++ b/src/tui/screens/change/ChangeRewindScreen.tsx @@ -248,7 +248,7 @@ export function ChangeRewindScreen({ params }: ScreenProps): ReactElement { } - }, [activeConfig, activeConfigName, stateManager, changesToRevert, globalModes]); + }, [activeConfig, activeConfigName, stateManager, cryptoIdentity, settings, changesToRevert, globalModes]); // Handle cancel const handleCancel = useCallback(() => { diff --git a/src/tui/screens/change/ChangeRunScreen.tsx b/src/tui/screens/change/ChangeRunScreen.tsx index ac098888..ed21f54b 100644 --- a/src/tui/screens/change/ChangeRunScreen.tsx +++ b/src/tui/screens/change/ChangeRunScreen.tsx @@ -169,7 +169,7 @@ export function ChangeRunScreen({ params }: ScreenProps): ReactElement { } - }, [activeConfig, activeConfigName, change, stateManager, globalModes]); + }, [activeConfig, activeConfigName, change, stateManager, cryptoIdentity, settings, globalModes]); // Handle cancel const handleCancel = useCallback(() => { diff --git a/src/tui/screens/config/ConfigExportScreen.tsx b/src/tui/screens/config/ConfigExportScreen.tsx index 151a5507..d6227dd9 100644 --- a/src/tui/screens/config/ConfigExportScreen.tsx +++ b/src/tui/screens/config/ConfigExportScreen.tsx @@ -72,46 +72,6 @@ export function ConfigExportScreen({ params }: ScreenProps): ReactElement { }, [stateManager, configName]); - // Handle email submission - const handleEmailSubmit = useCallback(() => { - - if (!stateManager || !email) return; - - // Find known users with this email - const users = stateManager.findKnownUsersByEmail(email); - - if (users.length === 0) { - - setError(`No known users with email "${email}". Import their identity first.`); - setStep('error'); - - return; - - } - - if (users.length === 1) { - - setSelectedUser(users[0]!); - handleExport(users[0]!); - - } - else { - - setMatchingUsers(users); - setStep('select-identity'); - - } - - }, [stateManager, email]); - - // Handle identity selection - const handleSelectIdentity = useCallback((item: SelectListItem) => { - - setSelectedUser(item.value); - handleExport(item.value); - - }, []); - // Handle export const handleExport = useCallback( async (recipient: KnownUser) => { @@ -184,6 +144,46 @@ export function ConfigExportScreen({ params }: ScreenProps): ReactElement { [stateManager, config, identity, configName], ); + // Handle email submission + const handleEmailSubmit = useCallback(() => { + + if (!stateManager || !email) return; + + // Find known users with this email + const users = stateManager.findKnownUsersByEmail(email); + + if (users.length === 0) { + + setError(`No known users with email "${email}". Import their identity first.`); + setStep('error'); + + return; + + } + + if (users.length === 1) { + + setSelectedUser(users[0]!); + handleExport(users[0]!); + + } + else { + + setMatchingUsers(users); + setStep('select-identity'); + + } + + }, [stateManager, email, handleExport]); + + // Handle identity selection + const handleSelectIdentity = useCallback((item: SelectListItem) => { + + setSelectedUser(item.value); + handleExport(item.value); + + }, [handleExport]); + // Keyboard handling useInput((input, key) => { diff --git a/src/tui/screens/run/RunBuildScreen.tsx b/src/tui/screens/run/RunBuildScreen.tsx index f611cdc7..fb6ebf71 100644 --- a/src/tui/screens/run/RunBuildScreen.tsx +++ b/src/tui/screens/run/RunBuildScreen.tsx @@ -168,6 +168,7 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { activeConfig, activeConfigName, stateManager, + cryptoIdentity, files, sqlPath, globalModes, diff --git a/src/tui/screens/run/RunExecScreen.tsx b/src/tui/screens/run/RunExecScreen.tsx index 9622f035..7989005c 100644 --- a/src/tui/screens/run/RunExecScreen.tsx +++ b/src/tui/screens/run/RunExecScreen.tsx @@ -149,7 +149,7 @@ export function RunExecScreen({ params: _params }: ScreenProps): ReactElement { setPhase('complete'); - }, [activeConfig, activeConfigName, stateManager, selectedFiles, globalModes, resetProgress, projectRoot]); + }, [activeConfig, activeConfigName, stateManager, cryptoIdentity, selectedFiles, globalModes, resetProgress, projectRoot]); // Submit selection (Enter in picker) const handleSubmit = useCallback(() => { diff --git a/tests/cli/screens/vault/VaultScreen.test.tsx b/tests/cli/VaultScreen.test.tsx similarity index 76% rename from tests/cli/screens/vault/VaultScreen.test.tsx rename to tests/cli/VaultScreen.test.tsx index 7571c5a9..a95f5e37 100644 --- a/tests/cli/screens/vault/VaultScreen.test.tsx +++ b/tests/cli/VaultScreen.test.tsx @@ -10,6 +10,17 @@ * These pin the two halves of the fix: the keypress must not write anything on * its own, and the confirmation it opens must name the people being granted * access so the operator can see the blast radius before agreeing to it. + * + * LOCATION: this file lives at tests/cli/ rather than the tests/cli/screens/vault/ + * it belongs in, and that is load-bearing. bun's `mock.module` registry is + * process-global, and tests/cli/hooks/useVaultSecretKeys.test.tsx also mocks + * core/vault -- restoring it to the real module in its afterAll. CI runs + * tests/cli as one process (.github/workflows/ci.yml), so whichever file bun + * loads first decides what the other one's subject binds to: from + * tests/cli/screens/vault/ these tests ran against the real, unmocked vault and + * failed only in the combined run, never standalone. Moving this file back + * under screens/ reintroduces that. The durable fix is a separate test process + * for the TUI screens, not a different mock arrangement -- several were tried. */ import { describe, it, expect, vi, mock, beforeEach, afterEach, afterAll } from 'bun:test'; import { render } from 'ink-testing-library'; @@ -18,19 +29,19 @@ import { mkdtemp, rm } from 'fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { CryptoIdentity } from '../../../../src/core/identity/types.js'; +import type { CryptoIdentity } from '../../src/core/identity/types.js'; -import { FocusProvider } from '../../../../src/tui/focus.js'; -import { RouterProvider } from '../../../../src/tui/router.js'; -import { AppContextProvider } from '../../../../src/tui/app-context.js'; -import { ConnectionProvider } from '../../../../src/tui/providers/ConnectionProvider.js'; -import { ToastProvider } from '../../../../src/tui/components/index.js'; -import { VaultScreen } from '../../../../src/tui/screens/vault/VaultScreen.js'; +import { FocusProvider } from '../../src/tui/focus.js'; +import { RouterProvider } from '../../src/tui/router.js'; +import { AppContextProvider } from '../../src/tui/app-context.js'; +import { ConnectionProvider } from '../../src/tui/providers/ConnectionProvider.js'; +import { ToastProvider } from '../../src/tui/components/index.js'; +import { VaultScreen } from '../../src/tui/screens/vault/VaultScreen.js'; -const actualCore = await import('../../../../src/core/index.js'); -const actualIdentity = await import('../../../../src/core/identity/index.js'); -const actualIdentityStorage = await import('../../../../src/core/identity/storage.js'); -const actualVault = await import('../../../../src/core/vault/index.js'); +const actualCore = await import('../../src/core/index.js'); +const actualIdentity = await import('../../src/core/identity/index.js'); +const actualIdentityStorage = await import('../../src/core/identity/storage.js'); +const actualVault = await import('../../src/core/vault/index.js'); /** Every call to the real propagation entry point. */ const propagateCalls: unknown[] = []; @@ -51,7 +62,7 @@ const RECIPIENTS = [ { identityHash: 'hash-b', publicKey: 'pub-b', name: 'Grace Hopper', email: 'grace@example.com' }, ]; -mock.module('../../../../src/core/vault/index.js', () => ({ +mock.module('../../src/core/vault/index.js', () => ({ ...actualVault, getVaultStatus: vi.fn(async () => ({ isInitialized: true, @@ -71,7 +82,7 @@ mock.module('../../../../src/core/vault/index.js', () => ({ }), })); -mock.module('../../../../src/core/identity/storage.js', () => ({ +mock.module('../../src/core/identity/storage.js', () => ({ ...actualIdentityStorage, loadPrivateKey: vi.fn(async () => 'private-key'), })); @@ -119,7 +130,7 @@ const createMockSettingsManager = () => ({ let mockStateManager = createMockStateManager(); let mockSettingsManager = createMockSettingsManager(); -mock.module('../../../../src/core/index.js', () => ({ +mock.module('../../src/core/index.js', () => ({ observer: actualCore.observer, getStateManager: vi.fn(() => mockStateManager), getSettingsManager: vi.fn(() => mockSettingsManager), @@ -127,7 +138,7 @@ mock.module('../../../../src/core/index.js', () => ({ resetSettingsManager: vi.fn(), })); -mock.module('../../../../src/core/identity/index.js', () => ({ +mock.module('../../src/core/identity/index.js', () => ({ ...actualIdentity, loadExistingIdentity: vi.fn().mockResolvedValue(IDENTITY), })); @@ -183,10 +194,10 @@ describe('cli: VaultScreen propagate', () => { afterAll(() => { - mock.module('../../../../src/core/index.js', () => actualCore); - mock.module('../../../../src/core/identity/index.js', () => actualIdentity); - mock.module('../../../../src/core/identity/storage.js', () => actualIdentityStorage); - mock.module('../../../../src/core/vault/index.js', () => actualVault); + mock.module('../../src/core/index.js', () => actualCore); + mock.module('../../src/core/identity/index.js', () => actualIdentity); + mock.module('../../src/core/identity/storage.js', () => actualIdentityStorage); + mock.module('../../src/core/vault/index.js', () => actualVault); }); From 8676deaede8aace7fe4bf351aaf09727a5fcd36e Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:22:15 -0400 Subject: [PATCH 085/105] fix(policy)!: stop granting MCP admin by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/ci/init.ts | 4 +- src/core/policy/check.ts | 15 +- src/core/policy/index.ts | 2 +- src/core/policy/legacy-access.ts | 26 ++- src/tui/app-context.tsx | 4 +- src/tui/screens/config/ConfigAddScreen.tsx | 7 +- tests/core/config/resolver.test.ts | 2 +- tests/core/config/schema.test.ts | 10 +- tests/core/policy/check.test.ts | 15 +- tests/core/policy/default-access.test.ts | 204 +++++++++++++++++++++ tests/core/state/access.test.ts | 16 +- tests/core/state/manager.test.ts | 12 +- tests/core/version/state.test.ts | 14 +- 13 files changed, 284 insertions(+), 47 deletions(-) create mode 100644 tests/core/policy/default-access.test.ts diff --git a/src/cli/ci/init.ts b/src/cli/ci/init.ts index b1936fef..a24ad5a7 100644 --- a/src/cli/ci/init.ts +++ b/src/cli/ci/init.ts @@ -21,7 +21,7 @@ import { setKeyOverride, setIdentityOverride } from '../../core/identity/storage import { getEnvConfig } from '../../core/config/index.js'; import type { Config } from '../../core/config/types.js'; import type { ConnectionConfig } from '../../core/connection/types.js'; -import { OPEN_ACCESS } from '../../core/policy/index.js'; +import { DEFAULT_ACCESS } from '../../core/policy/index.js'; import { initState } from '../../core/state/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; @@ -151,7 +151,7 @@ const initCommand = defineCommand({ name: configName, type: 'remote', isTest: true, - access: OPEN_ACCESS, + access: DEFAULT_ACCESS, connection, }; diff --git a/src/core/policy/check.ts b/src/core/policy/check.ts index f8a8f5b8..f726c51b 100644 --- a/src/core/policy/check.ts +++ b/src/core/policy/check.ts @@ -1,4 +1,5 @@ import { shouldSkipConfirmations } from '../environment.js'; +import { DEFAULT_ACCESS } from './legacy-access.js'; import { MATRIX } from './matrix.js'; import type { Channel, ConfigAccess, Permission, PolicyCheck, PolicyTarget } from './types.js'; @@ -188,18 +189,24 @@ export function guarded(target: PolicyTarget): boolean { /** * Formats access as `user: mcp:` — the shared display * string for `noorm config list` and the TUI config list screen, so the - * format can't drift between the two. Omitted entirely (`null`) for fully - * open (admin/admin) configs, per `guarded()`. + * format can't drift between the two. Omitted entirely (`null`) only for + * configs sitting on `DEFAULT_ACCESS`, so the tag means "someone changed + * this" in either direction. + * + * Keyed to the default rather than `guarded()` because the default is no + * longer the loosest setting: `mcp: 'admin'` is now an opt-in escalation, + * and `guarded()` (which only reads the user channel) would render the one + * config an agent can write to as unremarkable. * * @example * formatAccessTag({ name: 'prod', access: { user: 'operator', mcp: false } }); // 'user:operator mcp:off' */ export function formatAccessTag(config: { name: string; access: ConfigAccess }): string | null { - if (!guarded(config)) return null; - const { access } = config; + if (access.user === DEFAULT_ACCESS.user && access.mcp === DEFAULT_ACCESS.mcp) return null; + return `user:${access.user} mcp:${access.mcp === false ? 'off' : access.mcp}`; } diff --git a/src/core/policy/index.ts b/src/core/policy/index.ts index 217fd9a6..d1d0b8c6 100644 --- a/src/core/policy/index.ts +++ b/src/core/policy/index.ts @@ -7,7 +7,7 @@ export { assertPolicy, checkConfigPolicy, checkPolicy, confirmationPhraseFor, formatAccessTag, guarded, isVisibleToChannel } from './check.js'; export { classifyStatements } from './classify.js'; export type { SqlClass } from './classify.js'; -export { GUARDED_ACCESS, OPEN_ACCESS, resolveLegacyAccess } from './legacy-access.js'; +export { DEFAULT_ACCESS, GUARDED_ACCESS, resolveLegacyAccess } from './legacy-access.js'; export { MATRIX } from './matrix.js'; export type { Channel, diff --git a/src/core/policy/legacy-access.ts b/src/core/policy/legacy-access.ts index c34bec7a..63dc27c3 100644 --- a/src/core/policy/legacy-access.ts +++ b/src/core/policy/legacy-access.ts @@ -1,5 +1,5 @@ /** - * Legacy `protected` boolean -> access role mapping. + * The access a config gets when its author did not choose one. * * `Config.protected: boolean` was replaced by per-channel `access` roles * (see docs/spec/config-access-roles.md#migration). Every place that still @@ -9,8 +9,21 @@ */ import type { ConfigAccess } from './types.js'; -/** Access for a config with no explicit role and no legacy `protected` flag. */ -export const OPEN_ACCESS: ConfigAccess = { user: 'admin', mcp: 'admin' }; +/** + * Access for a config with no explicit role and no legacy `protected` flag. + * + * The agent channel is deliberately *not* admin here. A stock project never + * writes `access`, so this is what an MCP client holds against essentially + * every config in the wild — granting it admin made the rest of the matrix + * decorative on that channel. `viewer` keeps the agent's real job (schema + * exploration and read queries) working while write, DDL, destructive, and + * credential permissions all require an explicit opt-in. + * + * Not `mcp: false`: invisibility reports the same error as an unknown + * config, so an operator whose agent stopped working would have no way to + * tell a restricted default from a typo. + */ +export const DEFAULT_ACCESS: ConfigAccess = { user: 'admin', mcp: 'viewer' }; /** Access a legacy `protected: true` boolean maps to. */ export const GUARDED_ACCESS: ConfigAccess = { user: 'operator', mcp: 'viewer' }; @@ -19,17 +32,18 @@ export const GUARDED_ACCESS: ConfigAccess = { user: 'operator', mcp: 'viewer' }; * Resolves the access a config should have from its raw inputs. * * Explicit `access` always wins. Otherwise, a legacy `protected: true` - * maps to the guarded role pair; `false` or absent maps to fully open. + * maps to the guarded role pair; `false` or absent means the author asked + * for no restriction, which is the default — not a grant of agent admin. * * @example * resolveLegacyAccess(undefined, true); // { user: 'operator', mcp: 'viewer' } - * resolveLegacyAccess(undefined, undefined); // { user: 'admin', mcp: 'admin' } + * resolveLegacyAccess(undefined, undefined); // { user: 'admin', mcp: 'viewer' } */ export function resolveLegacyAccess( access: ConfigAccess | undefined, legacyProtected: boolean | undefined, ): ConfigAccess { - return access ?? (legacyProtected === true ? GUARDED_ACCESS : OPEN_ACCESS); + return access ?? (legacyProtected === true ? GUARDED_ACCESS : DEFAULT_ACCESS); } diff --git a/src/tui/app-context.tsx b/src/tui/app-context.tsx index edc41973..0a88b023 100644 --- a/src/tui/app-context.tsx +++ b/src/tui/app-context.tsx @@ -52,7 +52,7 @@ import type { StateManager } from '../core/state/manager.js'; import type { SettingsManager } from '../core/settings/manager.js'; import type { CryptoIdentity } from '../core/identity/types.js'; import { loadExistingIdentity } from '../core/identity/index.js'; -import { GUARDED_ACCESS, OPEN_ACCESS } from '../core/policy/index.js'; +import { DEFAULT_ACCESS, GUARDED_ACCESS } from '../core/policy/index.js'; // ───────────────────────────────────────────────────────────── // Project Name Detection @@ -411,7 +411,7 @@ export function AppContextProvider({ if (!stage.defaults?.dialect) continue; // Create placeholder config from stage defaults - const access = stage.defaults.protected ? GUARDED_ACCESS : OPEN_ACCESS; + const access = stage.defaults.protected ? GUARDED_ACCESS : DEFAULT_ACCESS; const config: Config = { name: stageName, type: stage.defaults.host && stage.defaults.host !== 'localhost' diff --git a/src/tui/screens/config/ConfigAddScreen.tsx b/src/tui/screens/config/ConfigAddScreen.tsx index cc435c94..5f50cb83 100644 --- a/src/tui/screens/config/ConfigAddScreen.tsx +++ b/src/tui/screens/config/ConfigAddScreen.tsx @@ -27,7 +27,7 @@ import { useRouter } from '../../router.js'; import { useAppContext, useSettings } from '../../app-context.js'; import { Panel, Form, useToast } from '../../components/index.js'; import { testConnection } from '../../../core/connection/factory.js'; -import { GUARDED_ACCESS, OPEN_ACCESS } from '../../../core/policy/index.js'; +import { DEFAULT_ACCESS, GUARDED_ACCESS } from '../../../core/policy/index.js'; import { getErrorMessage, validateConfigName, @@ -57,9 +57,10 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { // Default access for a brand-new config: the matched stage's `protected` // flag (guarded when true) if the caller navigated with a known stage - // name, otherwise fully open. + // name, otherwise the unrestricted-by-the-author default — which still + // holds the agent channel to `viewer`. const matchedStage = params.name ? settings?.stages?.[params.name] : undefined; - const defaultAccess = matchedStage?.defaults?.protected ? GUARDED_ACCESS : OPEN_ACCESS; + const defaultAccess = matchedStage?.defaults?.protected ? GUARDED_ACCESS : DEFAULT_ACCESS; // Form fields for config creation const fields: FormField[] = [ diff --git a/tests/core/config/resolver.test.ts b/tests/core/config/resolver.test.ts index 639f7958..49d7d525 100644 --- a/tests/core/config/resolver.test.ts +++ b/tests/core/config/resolver.test.ts @@ -374,7 +374,7 @@ describe('config: resolver', () => { const config = resolveConfig(state); expect(config!.type).toBe('local'); - expect(config!.access).toEqual({ user: 'admin', mcp: 'admin' }); + expect(config!.access).toEqual({ user: 'admin', mcp: 'viewer' }); }); diff --git a/tests/core/config/schema.test.ts b/tests/core/config/schema.test.ts index ad1aa705..96c9eaca 100644 --- a/tests/core/config/schema.test.ts +++ b/tests/core/config/schema.test.ts @@ -294,7 +294,7 @@ describe('config: schema validation', () => { expect(result.type).toBe('local'); expect(result.isTest).toBe(false); - expect(result.access).toEqual({ user: 'admin', mcp: 'admin' }); + expect(result.access).toEqual({ user: 'admin', mcp: 'viewer' }); }); @@ -333,11 +333,11 @@ describe('config: schema validation', () => { } - it('should default access to admin/admin when neither access nor protected is given', () => { + it('should default access to admin on the user channel and viewer on the agent channel', () => { const result = parseConfig(withoutAccess(createValidConfig())); - expect(result.access).toEqual({ user: 'admin', mcp: 'admin' }); + expect(result.access).toEqual({ user: 'admin', mcp: 'viewer' }); expect(guarded(result)).toBe(false); }); @@ -353,13 +353,13 @@ describe('config: schema validation', () => { }); - it('should map legacy protected: false to open access', () => { + it('should treat legacy protected: false as the default, not an agent-admin grant', () => { const config = { ...withoutAccess(createValidConfig()), protected: false }; const result = parseConfig(config); - expect(result.access).toEqual({ user: 'admin', mcp: 'admin' }); + expect(result.access).toEqual({ user: 'admin', mcp: 'viewer' }); expect(guarded(result)).toBe(false); }); diff --git a/tests/core/policy/check.test.ts b/tests/core/policy/check.test.ts index 0ac803e9..39003a16 100644 --- a/tests/core/policy/check.test.ts +++ b/tests/core/policy/check.test.ts @@ -312,12 +312,23 @@ describe('policy: formatAccessTag', () => { }); - it('should return null for an admin/admin (fully open) config', () => { + it('should return null for a config sitting on the default access', () => { - const config: PolicyTarget = { name: 'prod', access: { user: 'admin', mcp: 'admin' } }; + const config: PolicyTarget = { name: 'prod', access: { user: 'admin', mcp: 'viewer' } }; expect(formatAccessTag(config)).toBeNull(); }); + it('should render the tag for an mcp:admin escalation rather than hiding it', () => { + + // The user channel is admin either way, so `guarded()` cannot tell + // this apart from the default — yet this is the one config an agent + // can write to. It has to be visible in `noorm config list`. + const config: PolicyTarget = { name: 'prod', access: { user: 'admin', mcp: 'admin' } }; + + expect(formatAccessTag(config)).toBe('user:admin mcp:admin'); + + }); + }); diff --git a/tests/core/policy/default-access.test.ts b/tests/core/policy/default-access.test.ts new file mode 100644 index 00000000..5ea40519 --- /dev/null +++ b/tests/core/policy/default-access.test.ts @@ -0,0 +1,204 @@ +/** + * The access a config gets when it never declared one. + * + * A stock noorm project writes no `access`, so this default is what an MCP + * agent actually holds against every config in the wild. It used to be + * admin on both channels, which left every other gate in the matrix + * decorative on the agent channel. These tests drive the real parse path and + * the real matrix rather than comparing constants, so a regression surfaces + * as "an agent can drop the database" instead of "a literal changed". + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { parseConfig } from '../../../src/core/config/index.js'; +import { checkPolicy } from '../../../src/core/policy/index.js'; +import type { ConfigAccess, Permission } from '../../../src/core/policy/index.js'; +import { migrateState } from '../../../src/core/version/state/index.js'; + +/** A config exactly as it lands on disk before anyone edits access: no `access` key. */ +function stockConfigInput(overrides: Record = {}): Record { + + return { + name: 'stock', + type: 'local', + isTest: true, + connection: { dialect: 'sqlite', database: ':memory:' }, + ...overrides, + }; + +} + +function stockAccess(overrides: Record = {}): ConfigAccess { + + return parseConfig(stockConfigInput(overrides)).access; + +} + +/** Permissions that let an agent change data, schema, or the database itself. */ +const MUTATING: Permission[] = [ + 'sql:write', + 'sql:ddl', + 'db:create', + 'db:destroy', + 'run:build', +]; + +/** Permissions the agent channel exists to serve — read-only inspection. */ +const READ_ONLY: Permission[] = [ + 'explore', + 'sql:read', +]; + +describe('policy: default access', () => { + + const envBackup: Record = {}; + + beforeEach(() => { + + envBackup['NOORM_YES'] = process.env['NOORM_YES']; + delete process.env['NOORM_YES']; + + }); + + afterEach(() => { + + if (envBackup['NOORM_YES'] === undefined) { + + delete process.env['NOORM_YES']; + + } + else { + + process.env['NOORM_YES'] = envBackup['NOORM_YES']; + + } + + }); + + describe('mcp channel', () => { + + for (const permission of MUTATING) { + + it(`should deny "${permission}" on a config that never declared access`, () => { + + const check = checkPolicy('mcp', { name: 'stock', access: stockAccess() }, permission); + + expect(check.allowed).toBe(false); + + }); + + } + + for (const permission of READ_ONLY) { + + it(`should still allow "${permission}" on a config that never declared access`, () => { + + const check = checkPolicy('mcp', { name: 'stock', access: stockAccess() }, permission); + + expect(check.allowed).toBe(true); + + }); + + } + + it('should not hand the agent channel stored credentials', () => { + + const target = { name: 'stock', access: stockAccess() }; + + expect(checkPolicy('mcp', target, 'vault:read').allowed).toBe(false); + expect(checkPolicy('mcp', target, 'secret:read').allowed).toBe(false); + + }); + + it('should leave the config visible — restricted, not hidden', () => { + + // `mcp: false` would make the config unreachable and report the + // same error as an unknown config, giving an operator no way to + // tell a downgraded default from a typo. The default restricts + // the role instead. + expect(stockAccess().mcp).not.toBe(false); + + }); + + }); + + describe('user channel', () => { + + it('should keep admin on a config that never declared access', () => { + + expect(stockAccess().user).toBe('admin'); + + }); + + for (const permission of [...MUTATING, ...READ_ONLY]) { + + it(`should still allow "${permission}" for the human operator`, () => { + + const check = checkPolicy('user', { name: 'stock', access: stockAccess() }, permission); + + expect(check.allowed).toBe(true); + + }); + + } + + }); + + describe('explicit access', () => { + + it('should preserve an explicit mcp:admin opt-in verbatim', () => { + + const access = stockAccess({ access: { user: 'operator', mcp: 'admin' } }); + + expect(access).toEqual({ user: 'operator', mcp: 'admin' }); + expect(checkPolicy('mcp', { name: 'stock', access }, 'sql:write').allowed).toBe(true); + + }); + + it('should preserve an explicit restriction verbatim', () => { + + expect(stockAccess({ access: { user: 'viewer', mcp: false } })) + .toEqual({ user: 'viewer', mcp: false }); + + }); + + it('should not let the legacy protected flag re-open the agent channel', () => { + + // `protected: false` said "the author asked for no restriction", + // which is the default case — not an explicit grant of admin. + expect(stockAccess({ protected: false }).mcp).not.toBe('admin'); + + }); + + }); + + describe('state migration', () => { + + it('should not backfill mcp:admin onto a legacy unprotected config', () => { + + const migrated = migrateState({ + configs: { dev: { name: 'dev', protected: false } }, + }); + + const configs = migrated['configs'] as Record; + + expect(configs['dev']!.access.mcp).not.toBe('admin'); + expect(configs['dev']!.access.user).toBe('admin'); + + }); + + it('should leave a config that already stored an explicit access alone', () => { + + const migrated = migrateState({ + configs: { dev: { name: 'dev', access: { user: 'operator', mcp: 'admin' } } }, + }); + + const configs = migrated['configs'] as Record; + + expect(configs['dev']!.access).toEqual({ user: 'operator', mcp: 'admin' }); + + }); + + }); + +}); diff --git a/tests/core/state/access.test.ts b/tests/core/state/access.test.ts index 3a0565c0..ae1375d5 100644 --- a/tests/core/state/access.test.ts +++ b/tests/core/state/access.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect } from 'bun:test'; import { repairConfigAccess } from '../../../src/core/state/access.js'; -import { GUARDED_ACCESS, OPEN_ACCESS } from '../../../src/core/policy/index.js'; +import { GUARDED_ACCESS, DEFAULT_ACCESS } from '../../../src/core/policy/index.js'; describe('state: access repair', () => { @@ -21,13 +21,13 @@ describe('state: access repair', () => { it('should map protected:false to open access', () => { - expect(repairConfigAccess(undefined, false)).toEqual(OPEN_ACCESS); + expect(repairConfigAccess(undefined, false)).toEqual(DEFAULT_ACCESS); }); it('should map an absent protected flag to open access', () => { - expect(repairConfigAccess(undefined, undefined)).toEqual(OPEN_ACCESS); + expect(repairConfigAccess(undefined, undefined)).toEqual(DEFAULT_ACCESS); }); @@ -45,9 +45,9 @@ describe('state: access repair', () => { it('should treat a falsy non-boolean protected as open', () => { - expect(repairConfigAccess(undefined, 0)).toEqual(OPEN_ACCESS); - expect(repairConfigAccess(undefined, '')).toEqual(OPEN_ACCESS); - expect(repairConfigAccess(undefined, null)).toEqual(OPEN_ACCESS); + expect(repairConfigAccess(undefined, 0)).toEqual(DEFAULT_ACCESS); + expect(repairConfigAccess(undefined, '')).toEqual(DEFAULT_ACCESS); + expect(repairConfigAccess(undefined, null)).toEqual(DEFAULT_ACCESS); }); @@ -110,7 +110,7 @@ describe('state: access repair', () => { const repaired = repairConfigAccess(malformed, undefined); expect({ input: malformed, access: repaired }) - .not.toEqual({ input: malformed, access: OPEN_ACCESS }); + .not.toEqual({ input: malformed, access: DEFAULT_ACCESS }); } @@ -118,7 +118,7 @@ describe('state: access repair', () => { it('should restrict when access is present but not an object', () => { - expect(repairConfigAccess('admin', undefined)).toEqual(OPEN_ACCESS); + expect(repairConfigAccess('admin', undefined)).toEqual(DEFAULT_ACCESS); expect(repairConfigAccess(42, true)).toEqual(GUARDED_ACCESS); }); diff --git a/tests/core/state/manager.test.ts b/tests/core/state/manager.test.ts index 32985650..0b2e9379 100644 --- a/tests/core/state/manager.test.ts +++ b/tests/core/state/manager.test.ts @@ -232,7 +232,7 @@ describe('state: manager', () => { }); - it('should migrate a legacy protected:false config to open access', async () => { + it('should migrate a legacy protected:false config to the default access', async () => { const statePath = state.getStatePath(); writeLegacyState(statePath, testPrivateKey, { @@ -247,7 +247,7 @@ describe('state: manager', () => { await state.load(); - expect(state.getConfig('dev')?.access).toEqual({ user: 'admin', mcp: 'admin' }); + expect(state.getConfig('dev')?.access).toEqual({ user: 'admin', mcp: 'viewer' }); const raw = readFileSync(statePath, 'utf8'); const decrypted = JSON.parse( @@ -290,7 +290,7 @@ describe('state: manager', () => { await state.load(); - expect(state.getConfig('corrupt')?.access).toEqual({ user: 'admin', mcp: 'admin' }); + expect(state.getConfig('corrupt')?.access).toEqual({ user: 'admin', mcp: 'viewer' }); const raw = readFileSync(statePath, 'utf8'); const decrypted = JSON.parse( @@ -299,7 +299,7 @@ describe('state: manager', () => { expect(decrypted.configs['corrupt']?.['access']).toEqual({ user: 'admin', - mcp: 'admin', + mcp: 'viewer', }); }); @@ -313,7 +313,7 @@ describe('state: manager', () => { // is a no-op at current version, so this raw shape survives // unchanged into the backfill loop; it must resolve per the // documented fail-closed mapping (protected:true -> operator/viewer), - // not the admin/admin open-access fallback. + // not the unrestricted default. const statePath = state.getStatePath(); const currentVersion = getPackageVersion(); @@ -362,7 +362,7 @@ describe('state: manager', () => { // The backfill only accepted a strict boolean, so `"true"` -- // which a config saved outside the zod path can carry -- took - // the admin/admin fallback and lost its protection entirely. + // the unrestricted default and lost its protection entirely. const statePath = state.getStatePath(); writeCurrentState(statePath, testPrivateKey, { diff --git a/tests/core/version/state.test.ts b/tests/core/version/state.test.ts index b82a955e..acd7e321 100644 --- a/tests/core/version/state.test.ts +++ b/tests/core/version/state.test.ts @@ -168,7 +168,7 @@ describe('version: state', () => { expect(migrated['activeConfig']).toBe('dev'); // v2 backfills access roles onto every config (see the "v2: // per-config access roles" tests below for the mapping itself). - expect(migrated['configs']).toEqual({ dev: { access: { user: 'admin', mcp: 'admin' } } }); + expect(migrated['configs']).toEqual({ dev: { access: { user: 'admin', mcp: 'viewer' } } }); }); @@ -295,7 +295,7 @@ describe('version: state', () => { }); - it('should map protected: false to open access and drop protected', () => { + it('should map protected: false to the default access and drop protected', () => { const state = { schemaVersion: 1, @@ -314,13 +314,13 @@ describe('version: state', () => { dev: { name: 'dev', connection: { dialect: 'sqlite', database: ':memory:' }, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', mcp: 'viewer' }, }, }); }); - it('should map absent protected to open access', () => { + it('should map absent protected to the default access', () => { const state = { schemaVersion: 1, @@ -338,7 +338,7 @@ describe('version: state', () => { dev: { name: 'dev', connection: { dialect: 'sqlite', database: ':memory:' }, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', mcp: 'viewer' }, }, }); @@ -425,7 +425,7 @@ describe('version: state', () => { // A state file written outside the zod path can hold a // string here. Requiring a strict `true` sent every one - // of those to admin/admin — fully open. + // of those to the unrestricted default. expect(migrateConfig({ name: 'prod', protected: 'true' })['access']) .toEqual({ user: 'operator', mcp: 'viewer' }); @@ -437,7 +437,7 @@ describe('version: state', () => { it('should not guard a config whose protected flag is falsy', () => { expect(migrateConfig({ name: 'prod', protected: false })['access']) - .toEqual({ user: 'admin', mcp: 'admin' }); + .toEqual({ user: 'admin', mcp: 'viewer' }); }); From d396226d1e89f04209cefd3cfb3a13247c34dc95 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:23:15 -0400 Subject: [PATCH 086/105] 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. --- src/tui/screens/init/InitScreen.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tui/screens/init/InitScreen.tsx b/src/tui/screens/init/InitScreen.tsx index 93a39faf..2454fc1a 100644 --- a/src/tui/screens/init/InitScreen.tsx +++ b/src/tui/screens/init/InitScreen.tsx @@ -235,7 +235,16 @@ export function InitScreen({ params }: ScreenProps): ReactElement { mkdirSync(noormPath, { recursive: true }); mkdirSync(statePath, { recursive: true }); - writeFileSync(join(noormPath, '.gitignore'), 'state/\n'); + // Only when absent, matching performProjectInit — an unconditional + // write clobbers whatever the user added to this file on every + // re-init. + const noormGitignorePath = join(noormPath, '.gitignore'); + + if (!existsSync(noormGitignorePath)) { + + writeFileSync(noormGitignorePath, 'state/\n'); + + } updateItem(0, { status: 'success' }); From da9f55e259e37660377866edcd52895363ec68c4 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:27:41 -0400 Subject: [PATCH 087/105] 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. --- tests/integration/cli/db.test.ts | 14 +++++++------- tests/integration/sdk/db-reset.test.ts | 6 +++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/integration/cli/db.test.ts b/tests/integration/cli/db.test.ts index e779eca4..478bbb57 100644 --- a/tests/integration/cli/db.test.ts +++ b/tests/integration/cli/db.test.ts @@ -306,7 +306,7 @@ describe('cli: db truncate', () => { it('should return exit code 0 on success', async () => { - const result = await noorm(project, 'db', 'truncate'); + const result = await noorm(project, 'db', 'truncate', '--yes'); expect(result.exitCode).toBe(0); expect(result.ok).toBe(true); @@ -319,7 +319,7 @@ describe('cli: db truncate', () => { await cleanupTestProject(project); project = await setupTestProject(); - const result = await noormJson(project, 'db', 'truncate'); + const result = await noormJson(project, 'db', 'truncate', '--yes'); expect(result.ok).toBe(true); expect(result.data).not.toBeNull(); @@ -333,7 +333,7 @@ describe('cli: db truncate', () => { await cleanupTestProject(project); project = await setupTestProject(); - const result = await noormJson(project, 'db', 'truncate'); + const result = await noormJson(project, 'db', 'truncate', '--yes'); expect(result.ok).toBe(true); expect(result.data!.truncated.length).toBeGreaterThan(0); @@ -365,7 +365,7 @@ describe('cli: db teardown', () => { it('should return exit code 0 on success', async () => { - const result = await noorm(project, 'db', 'teardown'); + const result = await noorm(project, 'db', 'teardown', '--yes'); expect(result.exitCode).toBe(0); expect(result.ok).toBe(true); @@ -379,7 +379,7 @@ describe('cli: db teardown', () => { await cleanupTestProject(project); project = await setupTestProject(); - const result = await noormJson(project, 'db', 'teardown'); + const result = await noormJson(project, 'db', 'teardown', '--yes'); if (!result.ok) { @@ -400,7 +400,7 @@ describe('cli: db teardown', () => { await cleanupTestProject(project); project = await setupTestProject(); - const result = await noormJson(project, 'db', 'teardown'); + const result = await noormJson(project, 'db', 'teardown', '--yes'); expect(result.ok).toBe(true); expect(result.data!.dropped.tables.length).toBeGreaterThan(0); @@ -416,7 +416,7 @@ describe('cli: db teardown', () => { project = await setupTestProject(); // Teardown - await noorm(project, 'db', 'teardown'); + await noorm(project, 'db', 'teardown', '--yes'); // Check that overview shows 0 tables const overview = await noormJson(project, 'db', 'explore'); diff --git a/tests/integration/sdk/db-reset.test.ts b/tests/integration/sdk/db-reset.test.ts index 9ae0cb61..e5a4cd89 100644 --- a/tests/integration/sdk/db-reset.test.ts +++ b/tests/integration/sdk/db-reset.test.ts @@ -53,7 +53,11 @@ describe('integration: sdk DbNamespace reset vs preserveTables', () => { }, settings: { teardown: { preserveTables: ['ev_keep'] } }, identity: { name: 'tester', source: 'system' }, - options: {}, + // `db:teardown` and `db:reset` are confirm cells even for admin, and + // the SDK has no interactive prompt — `yes` is the programmatic + // equivalent of `--yes`. Without it these destructive calls are + // refused, which is the intended behaviour, not a test concern. + options: { yes: true }, projectRoot: '/tmp', changeManager: null, }; From 0832667a76798bae8ab90c758643861e1cbf1f50 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:21:19 -0400 Subject: [PATCH 088/105] fix(ci): gate identity enroll on vault:propagate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/ci/identity/enroll.ts | 63 ++++++++++-- tests/cli/ci/identity-enroll-hijack.test.ts | 108 +++++++++++++++++++- 2 files changed, 162 insertions(+), 9 deletions(-) diff --git a/src/cli/ci/identity/enroll.ts b/src/cli/ci/identity/enroll.ts index cbedacb5..b271732b 100644 --- a/src/cli/ci/identity/enroll.ts +++ b/src/cli/ci/identity/enroll.ts @@ -10,6 +10,10 @@ * identityHash uses `os: 'env'` + `machine: publicKey` so it matches * what `loadIdentityFromEnv` computes when the runner reads the same * private key from env at CI runtime. + * + * The grant is gated on `vault:propagate` against the target config's + * `access`, so a `viewer` config cannot enroll at all and every role that can + * must pass `--yes` — sealing the vault key to a public key is irreversible. */ import { attempt, attemptSync } from '@logosdx/utils'; import { defineCommand } from 'citty'; @@ -17,12 +21,13 @@ import { defineCommand } from 'citty'; import { generateKeyPair } from '../../../core/identity/crypto.js'; import { computeIdentityHash } from '../../../core/identity/hash.js'; import { isValidKeyHex } from '../../../core/identity/storage.js'; -import { propagateVaultKeyTo } from '../../../core/vault/propagate.js'; +import { propagateVaultKeyToChecked, checkVaultPolicy } from '../../../core/vault/index.js'; +import type { VaultPolicyGate } from '../../../core/vault/index.js'; import { decryptVaultKey } from '../../../core/vault/key.js'; import { getNoormTables, noormDb } from '../../../core/shared/tables.js'; import type { NoormDatabase } from '../../../core/shared/tables.js'; import type { EncryptedVaultKey } from '../../../core/vault/types.js'; -import { outputResult, outputError, sharedArgs, withVaultContext } from '../../_utils.js'; +import { outputResult, outputError, sharedArgs, isYesMode, withVaultContext } from '../../_utils.js'; const enrollCommand = defineCommand({ meta: { @@ -45,6 +50,7 @@ const enrollCommand = defineCommand({ type: 'string', description: 'Pre-generated X25519 public key (hex). If omitted, a new keypair is generated.', }, + yes: sharedArgs.yes, json: sharedArgs.json, }, async run({ args }) { @@ -64,6 +70,28 @@ const enrollCommand = defineCommand({ args, fn: async ({ ctx, cryptoIdentity, privateKey }) => { + // Enrollment ends in a vault grant, so it is a config-scoped + // action gated on `vault:propagate` like every other vault + // operation. Checked before any read or write: a role denied + // the vault should not get to probe the identities table. + const enrollConfig = ctx.noorm.config; + const gate: VaultPolicyGate = { + configName: enrollConfig.name, + access: enrollConfig.access, + channel: 'user', + }; + + const policy = checkVaultPolicy(gate, 'vault:propagate'); + + if (!policy.allowed) { + + throw new Error( + policy.blockedReason + ?? `Config "${enrollConfig.name}" cannot grant vault access.`, + ); + + } + let newPublicKey: string; let newPrivateKey: string | null = null; @@ -147,7 +175,7 @@ const enrollCommand = defineCommand({ const [existing, existingErr] = await attempt(() => ndb .selectFrom(tables.identities as keyof NoormDatabase) - .select(['identity_hash', 'public_key']) + .select(['identity_hash', 'public_key', 'encrypted_vault_key']) .where('identity_hash', '=', identityHash) .executeTakeFirst(), ); @@ -209,7 +237,29 @@ const enrollCommand = defineCommand({ } - const propagated = await propagateVaultKeyTo( + // Sealing the vault key to a public key cannot be undone, so + // `vault:propagate` is a `confirm` cell for every role that + // holds it. Asked here rather than up front so the operator is + // shown the identity they are about to grant to — and skipped + // when the target already holds access, since then confirming + // would be a prompt for a no-op. + const alreadyHasVaultAccess = !!existing?.encrypted_vault_key; + + if (policy.requiresConfirmation && !alreadyHasVaultAccess && !isYesMode(args)) { + + throw new Error( + `About to grant irrevocable vault access on config "${enrollConfig.name}" to:\n` + + ` Name: ${name}\n` + + ` Email: ${email}\n` + + ` Public key: ${newPublicKey}\n` + + ` Fingerprint: ${identityHash}\n` + + 'Verify this is the identity you intend, then re-run with --yes to grant.', + ); + + } + + const propagated = await propagateVaultKeyToChecked( + gate, ctx.kysely, vaultKey, identityHash, @@ -308,8 +358,9 @@ const enrollCommand = defineCommand({ (enrollCommand as typeof enrollCommand & { examples: string[] }).examples = [ 'noorm ci identity enroll --config prod --name "CI Bot" --email ci@example.com', - 'noorm ci identity enroll --config prod --name "CI Bot" --email ci@example.com --public-key ', - 'noorm ci identity enroll --config prod --name "CI Bot" --email ci@example.com --json', + 'noorm ci identity enroll --config prod --name "CI Bot" --email ci@example.com --yes', + 'noorm ci identity enroll --config prod --name "CI Bot" --email ci@example.com --public-key --yes', + 'noorm ci identity enroll --config prod --name "CI Bot" --email ci@example.com --json --yes', ]; export default enrollCommand; diff --git a/tests/cli/ci/identity-enroll-hijack.test.ts b/tests/cli/ci/identity-enroll-hijack.test.ts index df1002bd..4d3e5095 100644 --- a/tests/cli/ci/identity-enroll-hijack.test.ts +++ b/tests/cli/ci/identity-enroll-hijack.test.ts @@ -37,6 +37,7 @@ import { import { generateVaultKey, encryptVaultKey, decryptVaultKey } from '../../../src/core/vault/key.js'; import type { EncryptedVaultKey } from '../../../src/core/vault/types.js'; import type { Config } from '../../../src/core/config/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; const CLI = join(process.cwd(), 'dist/cli/index.js'); const TMP_BASE = join(process.cwd(), 'tmp'); @@ -64,7 +65,9 @@ interface EnrollFixture { * an operator identity that already holds vault access (the precondition * `enroll` enforces before it will propagate anything). */ -async function setupEnrollFixture(): Promise { +async function setupEnrollFixture( + access: ConfigAccess = { user: 'admin', mcp: 'admin' }, +): Promise { const testId = randomUUID().slice(0, 8); const dir = join(TMP_BASE, `cli-ci-enroll-${testId}`); @@ -125,7 +128,7 @@ async function setupEnrollFixture(): Promise { name: CONFIG_NAME, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access, connection: { dialect: 'sqlite', database: dbPath }, }; @@ -270,7 +273,9 @@ describe('cli: ci identity enroll — pre-planted key hijack (a08 F1)', () => { fx = await setupEnrollFixture(); await plantRow(fx, fx.botPublicKey); - const result = runEnroll(fx, ['--public-key', fx.botPublicKey]); + // --yes because granting vault access is a `confirm` cell for every + // role that holds `vault:propagate`. + const result = runEnroll(fx, ['--public-key', fx.botPublicKey, '--yes']); expect(result.status).toBe(0); expect(result.stdout).toContain('"alreadyEnrolled":true'); @@ -283,6 +288,103 @@ describe('cli: ci identity enroll — pre-planted key hijack (a08 F1)', () => { }); +/** + * `ci identity enroll` grants irrevocable vault access, so it is a + * config-scoped action and belongs behind the same `access` matrix as every + * other vault operation. It called the ungated `propagateVaultKeyTo`, so the + * config's role was never consulted: a `viewer` config — denied `vault:read` + * outright — could still hand the vault to a new identity, and no role was + * ever asked to confirm the grant. + */ +describe('cli: ci identity enroll — vault:propagate authorization', () => { + + let fx: EnrollFixture | undefined; + + afterEach(async () => { + + if (fx) await rm(fx.dir, { recursive: true, force: true }); + fx = undefined; + + }); + + it('denies a viewer config outright', async () => { + + fx = await setupEnrollFixture({ user: 'viewer', mcp: 'viewer' }); + + const result = runEnroll(fx, ['--public-key', fx.botPublicKey, '--yes']); + + expect(result.status).not.toBe(0); + + }); + + it('grants no vault access from a viewer config', async () => { + + fx = await setupEnrollFixture({ user: 'viewer', mcp: 'viewer' }); + + runEnroll(fx, ['--public-key', fx.botPublicKey, '--yes']); + + expect(await readRow(fx, fx.botIdentityHash)).toBeNull(); + + }); + + it('requires confirmation before granting, even as admin', async () => { + + fx = await setupEnrollFixture(); + + const result = runEnroll(fx, ['--public-key', fx.botPublicKey]); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/--yes/); + + }); + + it('propagates nothing when confirmation is withheld', async () => { + + fx = await setupEnrollFixture(); + + runEnroll(fx, ['--public-key', fx.botPublicKey]); + + const row = await readRow(fx, fx.botIdentityHash); + + // The row may be registered, but the vault key must not be sealed to + // it until the operator confirms. + expect(row?.encrypted_vault_key ?? null).toBeNull(); + + }); + + it('names the identity it is about to grant to, so the operator can check it', async () => { + + fx = await setupEnrollFixture(); + + const result = runEnroll(fx, ['--public-key', fx.botPublicKey]); + + // Must be the REFUSAL that names it — a success message mentioning the + // email would satisfy a bare `toContain` without any gate existing. + expect(result.status).not.toBe(0); + + const output = `${result.stdout}${result.stderr}`; + + expect(output).toContain(BOT_EMAIL); + expect(output).toContain(fx.botIdentityHash); + + }); + + it('grants once confirmed', async () => { + + fx = await setupEnrollFixture(); + + const result = runEnroll(fx, ['--public-key', fx.botPublicKey, '--yes']); + + expect(result.status).toBe(0); + + const row = await readRow(fx, fx.botIdentityHash); + + expect(row!.encrypted_vault_key).toBeTruthy(); + + }); + +}); + describe('cli: ci identity enroll — --public-key validation (a08 F8)', () => { let fx: EnrollFixture | undefined; From cd0ff17c7444c00a93294802fed09c55e7c955f9 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:26:25 -0400 Subject: [PATCH 089/105] fix(sdk): stop a failed or forgotten revert from wedging the connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/sdk/context.ts | 33 +++++++++- src/sdk/impersonate/scope.ts | 31 ++++++++- .../integration/impersonate/postgres.test.ts | 31 +++++++++ tests/sdk/impersonate/scope.test.ts | 66 +++++++++++++++++++ 4 files changed, 158 insertions(+), 3 deletions(-) diff --git a/src/sdk/context.ts b/src/sdk/context.ts index 3e6e1543..071b07d9 100644 --- a/src/sdk/context.ts +++ b/src/sdk/context.ts @@ -59,6 +59,14 @@ export class Context void>(); + constructor( config: Config, settings: Settings, @@ -157,6 +165,14 @@ export class Context resolveHolder(); + + this.#heldConnections.add(release); + // === Business logic block === const connectionDone = this.kysely.connection().execute(async (db) => { @@ -487,6 +510,7 @@ export class Context(db, async () => { await sql.raw(revertSql).execute(db); + this.#heldConnections.delete(release); resolveHolder(); }, this.dialect); @@ -497,7 +521,14 @@ export class Context rejectReady(err)); + connectionDone.catch(err => { + + // The connection never became a usable scope, so there is nothing + // for disconnect() to release. + this.#heldConnections.delete(release); + rejectReady(err); + + }); // === Commit block === return ready; diff --git a/src/sdk/impersonate/scope.ts b/src/sdk/impersonate/scope.ts index 5bda8f79..e8a724e8 100644 --- a/src/sdk/impersonate/scope.ts +++ b/src/sdk/impersonate/scope.ts @@ -5,6 +5,7 @@ * to a dedicated connection-bound Kysely instance. The revert * callback is provided by the caller (Context.impersonate). */ +import { attempt } from '@logosdx/utils'; import { sql } from 'kysely'; import type { Kysely, Transaction } from 'kysely'; @@ -45,6 +46,10 @@ export function buildScope | null = null; + // === Business logic block === return { @@ -89,12 +94,34 @@ export function buildScope reverting!); + + reverting = null; + + if (err) throw err; + + reverted = true; }, diff --git a/tests/integration/impersonate/postgres.test.ts b/tests/integration/impersonate/postgres.test.ts index fd845c49..3127d710 100644 --- a/tests/integration/impersonate/postgres.test.ts +++ b/tests/integration/impersonate/postgres.test.ts @@ -141,6 +141,37 @@ describe('integration: impersonate postgres', () => { }); + /** + * Explicit mode holds a pooled connection inside `.connection().execute()` + * until `revert()` resolves it. Nothing released that holder 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 complete. The context hung forever rather than closing. + */ + it('disconnects even when an explicit scope was never reverted', async () => { + + const leaky = new Context( + makeTestConfig('pg_impersonate_leak', TEST_CONNECTIONS.postgres), + {}, { name: 'tester', source: 'system' }, {}, '/tmp/test', + ); + await leaky.connect(); + + // Explicit mode, deliberately never reverted. + await leaky.impersonate(TEST_ROLE); + + const [, err] = await attempt(() => Promise.race([ + leaky.disconnect(), + new Promise((_, reject) => setTimeout( + () => reject(new Error('disconnect() hung')), + 5000, + ).unref()), + ])); + + expect(err).toBeNull(); + expect(leaky.connected).toBe(false); + + }, 15_000); + it('rejects a username carrying SQL metacharacters before it reaches the server', async () => { const [, err] = await attempt(() => diff --git a/tests/sdk/impersonate/scope.test.ts b/tests/sdk/impersonate/scope.test.ts index d06d182c..1a4049cb 100644 --- a/tests/sdk/impersonate/scope.test.ts +++ b/tests/sdk/impersonate/scope.test.ts @@ -5,6 +5,7 @@ * dedicated connection and that revert is idempotent. */ import { describe, it, expect, vi } from 'bun:test'; +import { attempt } from '@logosdx/utils'; import { Kysely, DummyDriver, @@ -119,4 +120,69 @@ describe('sdk: impersonate buildScope', () => { }); + /** + * The idempotency above only covers a revert that SUCCEEDS. `reverted` was + * set before `revertFn` was awaited, so a revert that threw still marked + * the scope reverted: the caller's retry returned early, the revert SQL + * never ran, and the pooled connection the scope holds was never released + * — permanently, taking `disconnect()` down with it. + */ + it('should stay revertible when revertFn fails, so a retry can succeed', async () => { + + const { db } = createMockKysely(); + const revertFn = vi.fn() + .mockRejectedValueOnce(new Error('connection error, not queryable')) + .mockResolvedValueOnce(undefined); + + const scope = buildScope(db, revertFn, 'postgres'); + + const [, firstErr] = await attempt(() => scope.revert()); + + expect(firstErr).toBeInstanceOf(Error); + + const [, secondErr] = await attempt(() => scope.revert()); + + expect(secondErr).toBeNull(); + expect(revertFn).toHaveBeenCalledTimes(2); + + }); + + it('should surface the revert failure rather than swallowing it', async () => { + + const { db } = createMockKysely(); + const revertFn = vi.fn().mockRejectedValue(new Error('revert blew up')); + + const scope = buildScope(db, revertFn, 'postgres'); + + const [, err] = await attempt(() => scope.revert()); + + expect((err as Error).message).toContain('revert blew up'); + + }); + + it('should run revertFn once when revert() is called concurrently', async () => { + + const { db } = createMockKysely(); + + let release!: () => void; + const gate = new Promise((resolve) => { + + release = resolve; + + }); + + const revertFn = vi.fn().mockImplementation(() => gate); + const scope = buildScope(db, revertFn, 'postgres'); + + const first = scope.revert(); + const second = scope.revert(); + + release(); + + await Promise.all([first, second]); + + expect(revertFn).toHaveBeenCalledTimes(1); + + }); + }); From 4ec666ae86cbcd0d2fc0928fde9fccfa8aeb9d19 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:30:54 -0400 Subject: [PATCH 090/105] fix(tui): unwrap the vault recipients tuple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/tui/screens/vault/VaultScreen.tsx | 10 +++++++--- tests/cli/VaultScreen.test.tsx | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/tui/screens/vault/VaultScreen.tsx b/src/tui/screens/vault/VaultScreen.tsx index 031f765c..bc8af567 100644 --- a/src/tui/screens/vault/VaultScreen.tsx +++ b/src/tui/screens/vault/VaultScreen.tsx @@ -36,10 +36,11 @@ import { getUsersWithoutVaultAccess, type VaultStatus, type VaultSecret, + type PendingVaultUser, } from '../../../core/vault/index.js'; /** Recipients of a propagation, as `getUsersWithoutVaultAccess` returns them. */ -type PropagationRecipient = Awaited>[number]; +type PropagationRecipient = PendingVaultUser; type _Phase = 'connecting' | 'ready' | 'error'; @@ -181,8 +182,11 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { const db = connRef.current.db; const connDialect = connRef.current.dialect; - const [recipients, recipientsErr] = await attempt( - () => getUsersWithoutVaultAccess(db as Kysely, connDialect), + // Returns its own [users, err] tuple — no attempt() wrapper, which + // would nest the tuple inside another one. + const [recipients, recipientsErr] = await getUsersWithoutVaultAccess( + db as Kysely, + connDialect, ); if (recipientsErr) { diff --git a/tests/cli/VaultScreen.test.tsx b/tests/cli/VaultScreen.test.tsx index a95f5e37..1a3c82e5 100644 --- a/tests/cli/VaultScreen.test.tsx +++ b/tests/cli/VaultScreen.test.tsx @@ -72,7 +72,9 @@ mock.module('../../src/core/vault/index.js', () => ({ })), getAllVaultSecrets: vi.fn(async () => []), getVaultKey: vi.fn(async () => 'vault-key'), - getUsersWithoutVaultAccess: vi.fn(async () => RECIPIENTS), + // Mirrors the real [users, err] tuple. Returning a bare array here let the + // screen and the mock agree on a shape the core stopped using. + getUsersWithoutVaultAccess: vi.fn(async () => [RECIPIENTS, null]), propagateVaultKey: vi.fn(async (...args: unknown[]) => { propagateCalls.push(args); From f9662b33f689f53a2f337d02e2aecedc7241c57b Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:27:30 -0400 Subject: [PATCH 091/105] 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. --- src/core/change/history.ts | 210 ++++---------------------------- src/core/runner/tracker.ts | 140 ++------------------- src/core/shared/index.ts | 3 + src/core/shared/operation-id.ts | 172 ++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 313 deletions(-) create mode 100644 src/core/shared/operation-id.ts diff --git a/src/core/change/history.ts b/src/core/change/history.ts index 00fc0b31..ffe66937 100644 --- a/src/core/change/history.ts +++ b/src/core/change/history.ts @@ -24,12 +24,11 @@ * ``` */ import type { Kysely } from 'kysely'; -import { sql } from 'kysely'; import { attempt } from '@logosdx/utils'; import { observer } from '../observer.js'; -import { getNoormTables, noormDb } from '../shared/index.js'; +import { getNoormTables, insertOperationRecord, noormDb } from '../shared/index.js'; import { getCurrentVersion } from '../update/checker.js'; import type { NoormDatabase, @@ -52,31 +51,6 @@ import type { ChangeType } from '../shared/index.js'; // Date Hydration // ───────────────────────────────────────────────────────────── -/** - * Coerce a driver's generated-key value into a usable operation id. - * - * WHY: the three id-retrieval strategies hand back three different - * shapes — postgres a number, mysql a `BigInt` on the insert result, - * mssql whatever the OUTPUT clause yields. Rejecting non-positive and - * unsafe values here stops a `0`, a `null` or a numeric string being - * written into a column that later rows join against. - * - * @example - * toOperationId(42n); // 42 - * toOperationId(null); // undefined - */ -function toOperationId(value: unknown): number | undefined { - - if (value === null || value === undefined) return undefined; - - const asNumber = Number(value); - - if (!Number.isSafeInteger(asNumber) || asNumber <= 0) return undefined; - - return asNumber; - -} - /** * Reserved change name recording a `db teardown`. * @@ -658,9 +632,12 @@ export class ChangeHistory { executedBy: string; }): Promise { - const insertQuery = this.#ndb - .insertInto(this.#tables.change) - .values({ + const [id, insertErr] = await insertOperationRecord({ + db: this.#db, + ndb: this.#ndb, + dialect: this.#dialect, + table: this.#tables.change, + values: { name: data.name, change_type: 'change', direction: data.direction, @@ -668,80 +645,16 @@ export class ChangeHistory { config_name: this.#configName, executed_by: data.executedBy, cli_version: getCurrentVersion(), - }); - - // Three id-retrieval strategies, one per driver capability: - // mssql OUTPUT inserted.id - // mysql no RETURNING clause exists — the driver reports the - // generated key on the insert result itself. Read it from - // there rather than issuing LAST_INSERT_ID() as a second - // query: that function is per-connection, and Kysely - // returns the connection to the pool between statements. - // others RETURNING for an atomic insert+get-id - let id: number | undefined; - - if (this.#dialect === 'mssql') { - - const [result, insertErr] = await attempt(() => - insertQuery - .output('inserted.id as id') - .executeTakeFirstOrThrow(), - ); - - if (insertErr) { - - throw new Error('Failed to create change operation record', { cause: insertErr }); - - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - id = toOperationId((result as any)?.id); - - } - else if (this.#dialect === 'mysql') { - - const [result, err] = await attempt(() => insertQuery.executeTakeFirst()); - - if (err) { - - throw new Error('Failed to create change operation record', { cause: err }); - - } - - id = toOperationId(result?.insertId); - - } - else { - - const [result, err] = await attempt(() => - insertQuery.returning('id').executeTakeFirstOrThrow(), - ); - - if (err) { - - throw new Error('Failed to create change operation record', { cause: err }); - - } - - id = toOperationId(result?.id); - - // SQLite with better-sqlite3 may return null for RETURNING - if (id === null || id === undefined) { - - const lastIdQuery = this.#lastInsertIdQuery(); - - if (lastIdQuery) { + }, + }); - const [lastIdResult] = await attempt(() => lastIdQuery.execute(this.#db)); - id = toOperationId(lastIdResult?.rows?.[0]?.id); + if (insertErr) { - } - - } + throw new Error('Failed to create change operation record', { cause: insertErr }); } - if (typeof id !== 'number' || !Number.isFinite(id) || id <= 0) { + if (id === undefined) { throw new Error(`Invalid operation ID returned: ${id}`); @@ -751,36 +664,6 @@ export class ChangeHistory { } - /** - * Get dialect-specific last-insert-id query. - * - * Only reached as a fallback when `RETURNING id` yielded nothing, so it - * covers just the dialects that take the RETURNING path. - * - * Deliberately has no mysql or mssql case: both retrieve their id from - * the insert itself. A second-statement `LAST_INSERT_ID()` would in fact - * be wrong on mysql — it is per-connection, and Kysely returns the - * connection to the pool between statements. - * - * Returns null if the dialect should always use RETURNING/OUTPUT. - */ - #lastInsertIdQuery(): ReturnType> | null { - - switch (this.#dialect) { - - case 'sqlite': - return sql<{ id: number }>`SELECT last_insert_rowid() as id`; - - case 'postgres': - return sql<{ id: number }>`SELECT lastval() as id`; - - default: - return null; - - } - - } - /** * Create pending file records for all files. * @@ -1010,9 +893,12 @@ export class ChangeHistory { */ async recordReset(executedBy: string, reason?: string): Promise { - const insertQuery = this.#ndb - .insertInto(this.#tables.change) - .values({ + const [id, insertErr] = await insertOperationRecord({ + db: this.#db, + ndb: this.#ndb, + dialect: this.#dialect, + table: this.#tables.change, + values: { name: RESET_MARKER, change_type: 'change', direction: 'change', @@ -1023,62 +909,16 @@ export class ChangeHistory { error_message: reason ?? '', duration_ms: 0, checksum: '', - }); - - // Same three id-retrieval strategies as createOperation — see the - // comment there for why mysql must not use LAST_INSERT_ID(). - if (this.#dialect === 'mssql') { - - const [result, insertErr] = await attempt(() => - insertQuery.output('inserted.id as id').executeTakeFirstOrThrow(), - ); - - if (insertErr) { - - observer.emit('error', { - source: 'change', - error: insertErr, - context: { operation: 'record-reset' }, - }); - - return 0; - - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return toOperationId((result as any)?.id) ?? 0; - - } - - if (this.#dialect === 'mysql') { - - const [result, insertErr] = await attempt(() => insertQuery.executeTakeFirst()); - - if (insertErr) { - - observer.emit('error', { - source: 'change', - error: insertErr, - context: { operation: 'record-reset' }, - }); - - return 0; - - } - - return toOperationId(result?.insertId) ?? 0; - - } - - const [result, err] = await attempt(() => - insertQuery.returning('id').executeTakeFirstOrThrow(), - ); + }, + }); - if (err) { + // A teardown that happened must not be undone by a failure to write + // its audit row, so this degrades to 0 rather than throwing. + if (insertErr) { observer.emit('error', { source: 'change', - error: err, + error: insertErr, context: { operation: 'record-reset' }, }); @@ -1086,7 +926,7 @@ export class ChangeHistory { } - return toOperationId(result?.id) ?? 0; + return id ?? 0; } diff --git a/src/core/runner/tracker.ts b/src/core/runner/tracker.ts index 586dd867..960ae5d7 100644 --- a/src/core/runner/tracker.ts +++ b/src/core/runner/tracker.ts @@ -24,42 +24,15 @@ * ``` */ import type { Kysely } from 'kysely'; -import { sql } from 'kysely'; import { attempt } from '@logosdx/utils'; import { observer } from '../observer.js'; -import { getNoormTables, noormDb } from '../shared/index.js'; +import { getNoormTables, insertOperationRecord, noormDb } from '../shared/index.js'; import type { NoormDatabase, ChangeType, ExecutionStatus, FileType } from '../shared/index.js'; import type { Dialect } from '../connection/types.js'; import type { NeedsRunResult, CreateOperationData, RecordExecutionData, Direction } from './types.js'; -/** - * Coerce a driver-reported generated key into a plain positive integer. - * - * Every dialect reports it differently — mysql2 hands back a `bigint`, - * node-postgres renders `lastval()`'s int8 as a string, mssql and sqlite - * give a number. Returning `undefined` for anything unusable lets the caller - * fall through to its next strategy instead of carrying a `bigint` or a - * numeric string into a column that later rows join against. - * - * @example - * toOperationId(42n); // 42 - * toOperationId('7'); // 7 - * toOperationId(null); // undefined - */ -function toOperationId(value: unknown): number | undefined { - - if (value === null || value === undefined) return undefined; - - const asNumber = Number(value); - - if (!Number.isSafeInteger(asNumber) || asNumber <= 0) return undefined; - - return asNumber; - -} - /** * Execution tracker for change detection and audit logging. * @@ -276,89 +249,28 @@ export class Tracker { // 'commit' is stored as 'change' for historical compatibility const dbDirection = direction === 'commit' ? 'change' : 'revert'; - const insertQuery = this.#ndb - .insertInto(this.#tables.change) - .values({ + const [id, insertErr] = await insertOperationRecord({ + db: this.#db, + ndb: this.#ndb, + dialect: this.#dialect, + table: this.#tables.change, + values: { name: data.name, change_type: data.changeType as ChangeType, direction: dbDirection, status: 'pending', config_name: data.configName, executed_by: data.executedBy, - }); - - // Three id-retrieval strategies, one per driver capability: - // mssql OUTPUT inserted.id - // mysql no RETURNING clause exists — the driver reports the - // generated key on the insert result itself. Read it from - // there rather than issuing LAST_INSERT_ID() as a second - // query: that function is per-connection, and Kysely - // returns the connection to the pool between statements. - // others RETURNING for an atomic insert+get-id - let id: number | undefined; + }, + }); - if (this.#dialect === 'mssql') { + if (insertErr) { - const [result, insertErr] = await attempt(() => - insertQuery - .output('inserted.id as id') - .executeTakeFirstOrThrow(), - ); - - if (insertErr) { - - throw new Error('Failed to create operation record', { cause: insertErr }); - - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - id = toOperationId((result as any)?.id); + throw new Error('Failed to create operation record', { cause: insertErr }); } - else if (this.#dialect === 'mysql') { - - const [result, err] = await attempt(() => insertQuery.executeTakeFirst()); - - if (err) { - - throw new Error('Failed to create operation record', { cause: err }); - - } - - id = toOperationId(result?.insertId); - - } - else { - - const [result, err] = await attempt(() => - insertQuery.returning('id').executeTakeFirstOrThrow(), - ); - if (err) { - - throw new Error('Failed to create operation record', { cause: err }); - - } - - id = toOperationId(result?.id); - - // SQLite with better-sqlite3 may return null for RETURNING - if (id === undefined) { - - const lastIdQuery = this.#lastInsertIdQuery(); - - if (lastIdQuery) { - - const [lastIdResult] = await attempt(() => lastIdQuery.execute(this.#db)); - id = toOperationId(lastIdResult?.rows?.[0]?.id); - - } - - } - - } - - if (typeof id !== 'number' || !Number.isFinite(id) || id <= 0) { + if (id === undefined) { throw new Error(`Invalid operation ID returned: ${id}`); @@ -368,34 +280,6 @@ export class Tracker { } - /** - * Get dialect-specific last-insert-id query. - * - * Returns null if the dialect should always use RETURNING/OUTPUT. - */ - #lastInsertIdQuery(): ReturnType> | null { - - switch (this.#dialect) { - - case 'sqlite': - return sql<{ id: number }>`SELECT last_insert_rowid() as id`; - - case 'mysql': - return sql<{ id: number }>`SELECT LAST_INSERT_ID() as id`; - - case 'mssql': - return sql<{ id: number }>`SELECT SCOPE_IDENTITY() as id`; - - case 'postgres': - return sql<{ id: number }>`SELECT lastval() as id`; - - default: - return null; - - } - - } - /** * Record a file execution. * diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 63892e4c..db1c04f6 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -17,6 +17,9 @@ export { createDialectQuoting, type DialectQuoting } from './dialect-quoting.js' // Tables export { NOORM_TABLES, getNoormTables, noormDb } from './tables.js'; +// Operation records +export { toOperationId, insertOperationRecord } from './operation-id.js'; + export type { NoormTableNames, NoormTableName, diff --git a/src/core/shared/operation-id.ts b/src/core/shared/operation-id.ts new file mode 100644 index 00000000..4965a739 --- /dev/null +++ b/src/core/shared/operation-id.ts @@ -0,0 +1,172 @@ +/** + * Dialect-aware creation of operation rows in the noorm change table. + * + * WHY: a change-table row is the parent record that every file execution, + * revert and teardown hangs off, so its generated id has to come back from + * the insert or nothing downstream can be tracked. No two dialects report + * that id the same way, and the retrieval was previously copy-pasted into + * `runner/tracker.ts` and twice into `change/history.ts`. Every copy emitted + * `RETURNING` on MySQL — which has no such clause — so the runner and the + * change module were both inoperable there, and fixing one copy left the + * others broken. One implementation now serves all three call sites. + * + * @example + * ```typescript + * const [id, err] = await insertOperationRecord({ + * db, ndb, dialect, table: tables.change, values: { name, ... }, + * }); + * + * if (err) throw new Error('Failed to create operation record', { cause: err }); + * ``` + */ +import type { Kysely } from 'kysely'; +import { sql } from 'kysely'; + +import { attempt } from '@logosdx/utils'; + +import type { Dialect } from '../connection/types.js'; +import { getNoormTables } from './tables.js'; +import type { NewNoormChange, NoormDatabase } from './tables.js'; + +/** + * The change table's name in whichever form the dialect uses — bare under + * the `noorm` schema on pg/mssql, `__noorm_change__` on sqlite/mysql. + */ +type ChangeTableName = ReturnType['change']; + +/** + * Coerce a driver-reported generated key into a plain positive integer. + * + * Every dialect reports it differently — mysql2 hands back a `bigint`, + * node-postgres renders `lastval()`'s int8 as a string, mssql and sqlite + * give a number. Returning `undefined` for anything unusable lets the caller + * apply its own failure policy instead of carrying a `bigint`, a `0` or a + * numeric string into a column that later rows join against. + * + * @example + * toOperationId(42n); // 42 + * toOperationId('7'); // 7 + * toOperationId(null); // undefined + */ +export function toOperationId(value: unknown): number | undefined { + + if (value === null || value === undefined) return undefined; + + const asNumber = Number(value); + + if (!Number.isSafeInteger(asNumber) || asNumber <= 0) return undefined; + + return asNumber; + +} + +/** + * Dialect-specific last-insert-id query. + * + * Only reached as a fallback when `RETURNING id` yielded nothing, so it + * covers just the dialects that take the RETURNING path. + * + * Deliberately has no mysql or mssql case: both retrieve their id from the + * insert itself. A second-statement `LAST_INSERT_ID()` would in fact be + * wrong on mysql — it is per-connection, and Kysely returns the connection + * to the pool between statements. + * + * Returns null if the dialect should always use RETURNING/OUTPUT. + */ +function lastInsertIdQuery(dialect: Dialect): ReturnType> | null { + + switch (dialect) { + + case 'sqlite': + return sql<{ id: number }>`SELECT last_insert_rowid() as id`; + + case 'postgres': + return sql<{ id: number }>`SELECT lastval() as id`; + + default: + return null; + + } + +} + +/** + * Insert an operation row and hand back its generated id. + * + * Three id-retrieval strategies, one per driver capability: + * mssql OUTPUT inserted.id + * mysql no RETURNING clause exists — the driver reports the generated + * key on the insert result itself + * others RETURNING for an atomic insert+get-id + * + * Returns an `[id, error]` tuple rather than throwing so each call site keeps + * its own failure policy: the runner and change trackers throw with their own + * wording, while `recordReset` only emits and degrades to `0`, because a + * missing audit row must not fail the teardown that produced it. + * + * A successful insert can still yield an `undefined` id when the driver + * reports nothing usable — that is not an error here, it is the caller's to + * classify. + * + * @example + * const [id, err] = await insertOperationRecord({ db, ndb, dialect, table, values }); + */ +export async function insertOperationRecord(opts: { + db: Kysely; + ndb: Kysely; + dialect: Dialect; + table: ChangeTableName; + values: NewNoormChange; +}): Promise<[number | undefined, Error | null]> { + + const { db, ndb, dialect, table, values } = opts; + + const insertQuery = ndb.insertInto(table).values(values); + + if (dialect === 'mssql') { + + const [result, insertErr] = await attempt(() => + insertQuery + .output('inserted.id as id') + .executeTakeFirstOrThrow(), + ); + + if (insertErr) return [undefined, insertErr]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [toOperationId((result as any)?.id), null]; + + } + + if (dialect === 'mysql') { + + const [result, insertErr] = await attempt(() => insertQuery.executeTakeFirst()); + + if (insertErr) return [undefined, insertErr]; + + return [toOperationId(result?.insertId), null]; + + } + + const [result, insertErr] = await attempt(() => + insertQuery.returning('id').executeTakeFirstOrThrow(), + ); + + if (insertErr) return [undefined, insertErr]; + + const id = toOperationId(result?.id); + + if (id !== undefined) return [id, null]; + + // SQLite with better-sqlite3 may return null for RETURNING. + const fallbackQuery = lastInsertIdQuery(dialect); + + if (!fallbackQuery) return [undefined, null]; + + // A failing fallback is indistinguishable from one that found nothing; + // both leave the caller with no id, which is what it already handles. + const [fallbackResult] = await attempt(() => fallbackQuery.execute(db)); + + return [toOperationId(fallbackResult?.rows?.[0]?.id), null]; + +} From b960d9830ebf042526cf20b10cf6cc6a0428aa86 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:37:46 -0400 Subject: [PATCH 092/105] 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. --- tests/core/explore/recording-db.ts | 11 +- tests/core/shared/operation-id.test.ts | 273 +++++++++++++++++++++++++ 2 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 tests/core/shared/operation-id.test.ts diff --git a/tests/core/explore/recording-db.ts b/tests/core/explore/recording-db.ts index 3ccd0a03..a3612ed0 100644 --- a/tests/core/explore/recording-db.ts +++ b/tests/core/explore/recording-db.ts @@ -66,6 +66,15 @@ export interface ResponseRule { match: RegExp | string; rows?: unknown[]; + /** + * Generated key the driver reports alongside the rows. + * + * MySQL has no RETURNING clause, so its id arrives here rather than in a + * row — replaying it is the only way to exercise that path without a + * live server. + */ + insertId?: bigint; + /** Reject instead of replaying rows, to exercise error paths. */ error?: Error; @@ -182,7 +191,7 @@ export function createRecordingDb( } - return { rows: (rule!.rows ?? []) as R[] }; + return { rows: (rule!.rows ?? []) as R[], insertId: rule!.insertId }; }, diff --git a/tests/core/shared/operation-id.test.ts b/tests/core/shared/operation-id.test.ts new file mode 100644 index 00000000..75245e5b --- /dev/null +++ b/tests/core/shared/operation-id.test.ts @@ -0,0 +1,273 @@ +/** + * Unit tests for the shared operation-record helper. + * + * WHY these assert SQL shape rather than a returned number: the defect this + * helper exists to prevent was `.returning('id')` on MySQL, a dialect with no + * RETURNING clause. Every suite that exercised `createOperation` constructed + * it with `'sqlite'`, so the emitted SQL was never observed and the runner and + * change modules were both inoperable on MySQL under a green suite. Compiling + * the query per dialect and reading the statement back is what makes that + * class of bug visible without a live server. + * + * The second trap encoded here is the follow-up query. `LAST_INSERT_ID()` and + * `SCOPE_IDENTITY()` are per-connection, and Kysely returns the connection to + * the pool between statements — so on mysql and mssql the id must come out of + * the insert itself and no second statement may be issued. The tests assert + * the statement count, not just the value. + */ +import { describe, it, expect } from 'bun:test'; +import { Kysely, SqliteDialect } from 'kysely'; + +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { insertOperationRecord, toOperationId } from '../../../src/core/shared/operation-id.js'; +import { getNoormTables, noormDb } from '../../../src/core/shared/index.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import type { NewNoormChange, NoormDatabase } from '../../../src/core/shared/index.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; + +import { createRecordingDb, type ResponseRule } from '../explore/recording-db.js'; + +const VALUES: NewNoormChange = { + name: 'change:test', + change_type: 'change', + direction: 'change', + status: 'pending', + config_name: 'test', + executed_by: 'test@example.com', +}; + +/** + * Run the helper against a dialect's real query compiler, recording every + * statement the driver would have sent. + */ +async function compileFor(dialect: Dialect, rules: ResponseRule[] = []) { + + const recording = createRecordingDb(dialect, rules); + const db = recording.kysely as unknown as Kysely; + + const [id, err] = await insertOperationRecord({ + db, + ndb: noormDb(db, dialect), + dialect, + table: getNoormTables(dialect).change, + values: VALUES, + }); + + return { id, err, recording }; + +} + +describe('shared: toOperationId', () => { + + it('should accept the shapes each driver reports a generated key in', () => { + + // mysql2 reports a bigint, node-postgres renders int8 as a string, + // mssql and sqlite give a plain number. + expect(toOperationId(42n)).toBe(42); + expect(toOperationId('7')).toBe(7); + expect(toOperationId(3)).toBe(3); + + }); + + it('should reject values that would corrupt the rows joining against it', () => { + + // A 0 or a negative would be written into child rows' change_id as a + // reference to a change record that does not exist. + expect(toOperationId(0)).toBeUndefined(); + expect(toOperationId(-1)).toBeUndefined(); + expect(toOperationId('not-a-number')).toBeUndefined(); + + // Beyond 2^53 the Number conversion is already lossy, so the id would + // no longer identify the row that was inserted. + expect(toOperationId(2n ** 63n)).toBeUndefined(); + + }); + + it('should report an absent key as absent rather than as a zero', () => { + + expect(toOperationId(null)).toBeUndefined(); + expect(toOperationId(undefined)).toBeUndefined(); + + }); + +}); + +describe('shared: insertOperationRecord', () => { + + it('should never emit RETURNING on mysql', async () => { + + const { recording } = await compileFor('mysql', [ + { match: /insert into/i, insertId: 12n }, + ]); + + const insert = recording.queries[0]!; + + // MySQL has no RETURNING clause: emitting one makes every operation + // record fail, which is exactly how the runner and change modules + // shipped inoperable on MySQL. + expect(insert.sql).not.toMatch(/returning/i); + expect(insert.sql).toMatch(/^insert into/i); + + }); + + it('should take the mysql id from the insert result without a second query', async () => { + + const { id, err, recording } = await compileFor('mysql', [ + { match: /insert into/i, insertId: 12n }, + ]); + + expect(err).toBeNull(); + expect(id).toBe(12); + + // LAST_INSERT_ID() is per-connection and Kysely pools connections, so + // a follow-up statement could read another session's insert. + expect(recording.queries).toHaveLength(1); + expect(recording.find(/LAST_INSERT_ID/i)).toBeUndefined(); + + }); + + it('should not fall back to a second query when mysql reports no key', async () => { + + const { id, err, recording } = await compileFor('mysql'); + + expect(err).toBeNull(); + expect(id).toBeUndefined(); + expect(recording.queries).toHaveLength(1); + + }); + + it('should read the mssql id from an OUTPUT clause on the insert', async () => { + + const { id, err, recording } = await compileFor('mssql', [ + { match: /insert into/i, rows: [{ id: 8 }] }, + ]); + + expect(err).toBeNull(); + expect(id).toBe(8); + + const insert = recording.queries[0]!; + + expect(insert.sql).toMatch(/output/i); + expect(insert.sql).toMatch(/inserted/i); + expect(insert.sql).not.toMatch(/returning/i); + + }); + + it('should not fall back to SCOPE_IDENTITY when mssql reports no key', async () => { + + const { id, recording } = await compileFor('mssql', [ + { match: /insert into/i, rows: [{ id: null }] }, + ]); + + // SCOPE_IDENTITY() is per-connection for the same reason + // LAST_INSERT_ID() is. + expect(id).toBeUndefined(); + expect(recording.queries).toHaveLength(1); + expect(recording.find(/SCOPE_IDENTITY/i)).toBeUndefined(); + + }); + + it('should use RETURNING on postgres and sqlite', async () => { + + for (const dialect of ['postgres', 'sqlite'] as const) { + + const { id, err, recording } = await compileFor(dialect, [ + { match: /insert into/i, rows: [{ id: 5 }] }, + ]); + + expect(err).toBeNull(); + expect(id).toBe(5); + expect(recording.queries).toHaveLength(1); + expect(recording.queries[0]!.sql).toMatch(/returning/i); + + } + + }); + + it('should fall back to last_insert_rowid when sqlite RETURNING yields no id', async () => { + + // better-sqlite3 can report a row with no id for RETURNING; without + // the fallback the caller sees a failed insert that in fact succeeded. + const { id, err, recording } = await compileFor('sqlite', [ + { match: /insert into/i, rows: [{ id: null }] }, + { match: /last_insert_rowid/i, rows: [{ id: 3 }] }, + ]); + + expect(err).toBeNull(); + expect(id).toBe(3); + expect(recording.queries).toHaveLength(2); + + }); + + it('should fall back to lastval when postgres RETURNING yields no id', async () => { + + const { id, err, recording } = await compileFor('postgres', [ + { match: /insert into/i, rows: [{ id: null }] }, + { match: /lastval/i, rows: [{ id: 9 }] }, + ]); + + expect(err).toBeNull(); + expect(id).toBe(9); + expect(recording.queries).toHaveLength(2); + + }); + + it('should return a failed insert as an error rather than throwing', async () => { + + // recordReset degrades to 0 on failure instead of failing the teardown + // it is recording, which is only possible if the helper hands the + // error back rather than throwing it. + const { id, err } = await compileFor('postgres', [ + { match: /insert into/i, error: new Error('connection reset') }, + ]); + + expect(err).toBeInstanceOf(Error); + expect(err!.message).toBe('connection reset'); + expect(id).toBeUndefined(); + + }); + + it('should qualify the insert with the noorm schema on postgres', async () => { + + const { recording } = await compileFor('postgres', [ + { match: /insert into/i, rows: [{ id: 1 }] }, + ]); + + expect(recording.queries[0]!.sql).toContain('"noorm"."change"'); + + }); + + it('should return an id a real sqlite database can join child rows to', async () => { + + const db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + const [id, err] = await insertOperationRecord({ + db, + ndb: noormDb(db, 'sqlite'), + dialect: 'sqlite', + table: getNoormTables('sqlite').change, + values: VALUES, + }); + + expect(err).toBeNull(); + expect(id).toBeGreaterThan(0); + + const row = await db + .selectFrom('__noorm_change__') + .select(['id', 'name']) + .where('id', '=', id!) + .executeTakeFirst(); + + expect(row?.name).toBe(VALUES.name); + + await db.destroy(); + + }); + +}); From 6ffb553e6c8b5e15091b8146408d63985dc0242c Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:38:45 -0400 Subject: [PATCH 093/105] 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. --- .changeset/mcp-default-viewer.md | 15 +++++++++++++++ docs/dev/config.md | 2 +- docs/guide/automation/mcp.md | 2 ++ docs/headless.md | 2 +- docs/spec/config-access-roles.md | 10 +++++++--- tests/cli/config/list.test.ts | 17 +++++++++++++++-- 6 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 .changeset/mcp-default-viewer.md diff --git a/.changeset/mcp-default-viewer.md b/.changeset/mcp-default-viewer.md new file mode 100644 index 00000000..91003fcd --- /dev/null +++ b/.changeset/mcp-default-viewer.md @@ -0,0 +1,15 @@ +--- +"@noormdev/cli": major +"@noormdev/sdk": major +--- + +## Access default: agents no longer get admin + +### Changed + +* `fix(policy)!:` a config with no explicit `access` now resolves to `{ user: 'admin', mcp: 'viewer' }` instead of `{ user: 'admin', mcp: 'admin' }`. A stock project writes no `access`, so every config was handing full admin to any connected MCP client — writes, DDL, `change_run`, `run_build`, database drops, and vault/secret reads all went through unchecked. + * **The `user` channel is unchanged.** Nothing about the CLI, TUI, or SDK behaves differently. + * **MCP agents keep read access.** `explore` and read-only SQL (`SELECT`, `EXPLAIN`, `SHOW`, `DESCRIBE`) still work with no setup. + * **MCP agents lose write access by default.** If an agent needs to write, run changes, or run builds, set that config's `access.mcp` to `operator` or `admin` explicitly — via `noorm ui` → Config → Edit, or in the JSON before `config import`. + * **Configs that already store an explicit `access` are untouched**, including ones holding `mcp: 'admin'`. If you created configs on an earlier build, that migration already wrote `admin/admin` to disk and it is preserved — run `noorm config list` and lower any `mcp:admin` you did not intend. +* `noorm config list` now prints the access tag for any config that differs from the default, so an `mcp: admin` escalation is visible. Previously the tag was hidden whenever the user channel was `admin`, which concealed exactly that case. diff --git a/docs/dev/config.md b/docs/dev/config.md index 787f01b3..dc7c3b75 100644 --- a/docs/dev/config.md +++ b/docs/dev/config.md @@ -255,7 +255,7 @@ export NOORM_YES=1 noorm change run # No prompt, even on an operator-role config ``` -**Migration:** a legacy `protected: true` maps to `{ user: 'operator', mcp: 'viewer' }`; `protected: false` or absent maps to `{ user: 'admin', mcp: 'admin' }`. The `protected` field is accepted on input for one version, then dropped — see the state migration in `core/version/state/migrations/`. +**Migration:** a legacy `protected: true` maps to `{ user: 'operator', mcp: 'viewer' }`; `protected: false` or absent maps to the default `{ user: 'admin', mcp: 'viewer' }`. A config that already stores an explicit `access` is left as-is. The `protected` field is accepted on input for one version, then dropped — see the state migration in `core/version/state/migrations/`. ## Stages diff --git a/docs/guide/automation/mcp.md b/docs/guide/automation/mcp.md index 2ce4c87c..99fea168 100644 --- a/docs/guide/automation/mcp.md +++ b/docs/guide/automation/mcp.md @@ -97,6 +97,8 @@ Config resolution and identity attribution work the same as the CLI. Access cont Every config declares a role per channel: `access: { user, mcp }`. The `mcp` role decides what an agent connected over this server can do to that config — independently of what a human gets in the CLI/TUI. +A config that never declared `access` gets `{ user: 'admin', mcp: 'viewer' }`. Agents can explore and read on a fresh project without any setup; anything that writes needs you to raise the `mcp` role on purpose. + | Role | What the agent can do | |------|------------------------| | `viewer` | Explore schema, run read-only SQL (`SELECT`, `EXPLAIN`, `SHOW`, `DESCRIBE`) | diff --git a/docs/headless.md b/docs/headless.md index ff67f2c5..34215059 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -192,7 +192,7 @@ Raw SQL (`noorm sql`) is gated by what the statement actually does, not by a fla `confirm` means: type the phrase `yes-` when prompted, or set `NOORM_YES=1` to skip the prompt in CI. There is no `--force` override for a denied permission — `--force` only skips file checksums (see [Common Flags](#common-flags)). -**Migration note:** the old `Config.protected: boolean` maps automatically on first load — `protected: true` becomes `{ user: 'operator', mcp: 'viewer' }`, `protected: false` (or absent) becomes `{ user: 'admin', mcp: 'admin' }`. The legacy field is still accepted on `config import` for one version, then dropped. +**Migration note:** the old `Config.protected: boolean` maps automatically on first load — `protected: true` becomes `{ user: 'operator', mcp: 'viewer' }`, `protected: false` (or absent) becomes the default `{ user: 'admin', mcp: 'viewer' }`. A config that already stores an explicit `access` keeps it untouched. The legacy field is still accepted on `config import` for one version, then dropped. ## Commands diff --git a/docs/spec/config-access-roles.md b/docs/spec/config-access-roles.md index 33a29f77..733c9779 100644 --- a/docs/spec/config-access-roles.md +++ b/docs/spec/config-access-roles.md @@ -22,7 +22,7 @@ Replace `Config.protected: boolean` with config-scoped, channel-keyed roles enfo mcp: Role | false; } -- `Config` gains `access?: ConfigAccess` — optional in the type until CP6 because CP4/CP5-owned files (`src/cli/ci/init.ts`, TUI config screens, app-context) construct `Config` literals without it; every config materialized through `parseConfig`/state load has it populated. **CP6 makes the field required** once those constructors are updated. Zod default when absent: `{ user: 'admin', mcp: 'admin' }`. +- `Config` gains `access?: ConfigAccess` — optional in the type until CP6 because CP4/CP5-owned files (`src/cli/ci/init.ts`, TUI config screens, app-context) construct `Config` literals without it; every config materialized through `parseConfig`/state load has it populated. **CP6 makes the field required** once those constructors are updated. Zod default when absent: `{ user: 'admin', mcp: 'viewer' }` — the agent channel is never privileged without an explicit opt-in, because a stock project writes no `access` at all. - `ConfigSummary` gains required `access: ConfigAccess`. - Until CP6, `Config.protected` remains present and is **derived at load**: `protected = (access.user !== 'admin')`. It is never persisted (state `persist()` strips it; the zod schema never emits a stored value). No caller may write `protected` after CP2. CP6 deletes the field everywhere except the state migration parser. - Enforcement code must not trust the optionality: on the `mcp` channel, a config with absent `access` is **denied** (fail closed) — in practice unreachable, since configs reach enforcement via parse/migration. @@ -70,7 +70,9 @@ Channel resolution of `confirm`: `mcp: false` is not a role: policy is never consulted for an invisible config — visibility is enforced before policy (see Enforcement). -Helper: `guarded(config): boolean = config.access.user !== 'admin'` — used by TUI styling, `config list` display, and settings rule matching. Display-only; never an enforcement input. +Helper: `guarded(config): boolean = config.access.user !== 'admin'` — used by TUI styling and settings rule matching. Display-only; never an enforcement input. + +The `config list` access tag is keyed to the default instead: `formatAccessTag` renders `user: mcp:` for any config whose access differs from the default, and `null` otherwise. `guarded` cannot drive it, because `mcp: 'admin'` is an escalation the user channel can't see. ## SQL classification @@ -108,7 +110,8 @@ Lives in `src/core/policy/classify.ts` (moved and generalized from `src/rpc/prot New state migration in `src/core/version/state/migrations/` (registered in `src/core/version/state/index.ts`, bump `CURRENT_VERSIONS.state`): - `protected: true` → `access: { user: 'operator', mcp: 'viewer' }` -- `protected: false` or absent → `access: { user: 'admin', mcp: 'admin' }` +- `protected: false` or absent → `access: { user: 'admin', mcp: 'viewer' }` (the default; `protected: false` said "no restriction requested", which is not a grant of agent admin) +- A config that already stores an explicit `access` is left exactly as found, including `mcp: 'admin'`. - Stored `protected` field dropped from the persisted shape after migration. `ConfigSchema` (zod) accepts a legacy `protected` key on input for one version (feeding the same mapping) and never emits it. @@ -153,3 +156,4 @@ Each checkpoint ends green: `bash tmp/run-test-groups.sh` (mirrors CI's four fre - 2026-07-07 — CP4: added `db:reset` permission (viewer deny / operator confirm / admin allow) for data-destructive-but-not-drop operations; the initial CP4 mapping of truncate/teardown/reset/importFile to `db:destroy` violated the migration section's behavior-preservation promise for open configs. - 2026-07-08 — CP8 (post challenge-swarm): classifier now catches CTE-wrapped DML and a destructive-function denylist (viewer write-bypass was confirmed critical). User-channel enforcement moved to the core seam so run/change/transfer/sql-terminal are gated for SDK+TUI+CLI (the earlier "same checkPolicy" claim was only partly delivered). Correctness + hygiene fixes per the swarm. Downgrade guard, state.enc durability/atomicity, and denial observability explicitly deferred (alpha; pre-existing; separate workstreams). - 2026-07-13 — `change:rm` added (viewer deny / operator confirm / admin confirm — admin gets confirm not allow because deleting an applied change also deletes its DB tracking row), refs `docs/spec/v1-44-change-rm-gate.md`. +- 2026-07-29 — Default access is `{ user: 'admin', mcp: 'viewer' }`. **Superseded:** the default was `{ user: 'admin', mcp: 'admin' }`, so a config that never declared `access` — which is every config in a stock project — handed the agent channel admin, and no gate in the matrix constrained an MCP client. `viewer` keeps the agent's real job (explore, `sql:read`) while write, DDL, destructive, and credential permissions need an explicit opt-in; `mcp: false` was rejected because invisibility reports the same error as an unknown config and leaves an operator no way to diagnose it. Configs already storing an explicit `access` are untouched, including ones that stored `admin/admin` under the old default. `formatAccessTag` re-keyed from `guarded()` to "differs from the default" so `mcp: 'admin'` shows up in `config list` instead of reading as unremarkable. diff --git a/tests/cli/config/list.test.ts b/tests/cli/config/list.test.ts index 21ae4549..7769ca65 100644 --- a/tests/cli/config/list.test.ts +++ b/tests/cli/config/list.test.ts @@ -122,9 +122,9 @@ describe('cli: noorm config list — access tag display', () => { }); - it('renders no access tag for an admin/admin config', async () => { + it('renders no access tag for a config on the default access', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', mcp: 'viewer' }); const result = runList(); @@ -133,4 +133,17 @@ describe('cli: noorm config list — access tag display', () => { }); + it('renders the tag for an mcp:admin escalation', async () => { + + // The one config an agent can write to must not read as unremarkable + // just because the human channel is still admin. + await seedConfig({ user: 'admin', mcp: 'admin' }); + + const result = runList(); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('user:admin mcp:admin'); + + }); + }); From f1e214f4654b349b26c474f8410118b19db8b585 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:40:41 -0400 Subject: [PATCH 094/105] 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. --- src/tui/screens/vault/VaultScreen.tsx | 11 ++++++++--- tests/cli/VaultScreen.test.tsx | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/tui/screens/vault/VaultScreen.tsx b/src/tui/screens/vault/VaultScreen.tsx index 031f765c..e00d1bcd 100644 --- a/src/tui/screens/vault/VaultScreen.tsx +++ b/src/tui/screens/vault/VaultScreen.tsx @@ -36,10 +36,11 @@ import { getUsersWithoutVaultAccess, type VaultStatus, type VaultSecret, + type PendingVaultUser, } from '../../../core/vault/index.js'; /** Recipients of a propagation, as `getUsersWithoutVaultAccess` returns them. */ -type PropagationRecipient = Awaited>[number]; +type PropagationRecipient = PendingVaultUser; type _Phase = 'connecting' | 'ready' | 'error'; @@ -181,8 +182,12 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { const db = connRef.current.db; const connDialect = connRef.current.dialect; - const [recipients, recipientsErr] = await attempt( - () => getUsersWithoutVaultAccess(db as Kysely, connDialect), + // Returns its own `[users, error]` tuple rather than throwing, so that a + // failed lookup cannot be mistaken for "nobody is missing access". Calling + // it through `attempt` would nest that tuple inside another one. + const [recipients, recipientsErr] = await getUsersWithoutVaultAccess( + db as Kysely, + connDialect, ); if (recipientsErr) { diff --git a/tests/cli/VaultScreen.test.tsx b/tests/cli/VaultScreen.test.tsx index a95f5e37..e1b2611d 100644 --- a/tests/cli/VaultScreen.test.tsx +++ b/tests/cli/VaultScreen.test.tsx @@ -72,7 +72,9 @@ mock.module('../../src/core/vault/index.js', () => ({ })), getAllVaultSecrets: vi.fn(async () => []), getVaultKey: vi.fn(async () => 'vault-key'), - getUsersWithoutVaultAccess: vi.fn(async () => RECIPIENTS), + // Returns an `[users, error]` tuple, not a bare array — a failed lookup must + // not be indistinguishable from "nobody is missing access". + getUsersWithoutVaultAccess: vi.fn(async () => [RECIPIENTS, null]), propagateVaultKey: vi.fn(async (...args: unknown[]) => { propagateCalls.push(args); From 470403935835a409e188184c56698f3c7e42e7d4 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:40:45 -0400 Subject: [PATCH 095/105] docs(policy): drop stale admin/admin references Comments and wiki signals still named the superseded default and the old `OPEN_ACCESS` constant. --- docs/tui.md | 2 +- docs/wiki/core-policy.md | 2 +- docs/wiki/core-state.md | 2 +- docs/wiki/tui.md | 2 +- src/core/config/resolver.ts | 2 +- src/core/state/access.ts | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index 0e816092..0fd639d4 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -143,7 +143,7 @@ Home > Configurations - `●` indicates active config - `○` indicates inactive config - `>` indicates cursor position -- `[user: mcp:]` tag shows access roles for any config that isn't fully open (`admin`/`admin`) — omitted entirely for wide-open configs +- `[user: mcp:]` tag shows access roles for any config whose access differs from the default (`user: admin`, `mcp: viewer`) — omitted entirely for configs still on the default - `[test]` tag shows test configs - Press `Enter` on a config to activate it diff --git a/docs/wiki/core-policy.md b/docs/wiki/core-policy.md index dbd6c972..5081119e 100644 --- a/docs/wiki/core-policy.md +++ b/docs/wiki/core-policy.md @@ -16,7 +16,7 @@ Also owns raw-SQL statement classification (`read`/`write`/`ddl`, with a destruc - [`src/core/policy/matrix.ts`](../../src/core/policy/matrix.ts) — `MATRIX`; the hard-coded `Permission × Role → PolicyCell` table (not user-extensible), mirroring [`docs/spec/config-access-roles.md`](../spec/config-access-roles.md) - [`src/core/policy/check.ts`](../../src/core/policy/check.ts) — `checkPolicy`, `checkConfigPolicy`, `assertPolicy`, `guarded`, `confirmationPhraseFor`; the enforcement entrypoints every caller reaches for - [`src/core/policy/classify.ts`](../../src/core/policy/classify.ts) — `classifyStatements`; SQL-parser-cst-based statement classifier with a keyword-based fallback, a CTE-DML upgrade rule, and `DESTRUCTIVE_FUNCTIONS` denylist (e.g. `pg_terminate_backend`, `lo_import`, `setval`) -- [`src/core/policy/legacy-access.ts`](../../src/core/policy/legacy-access.ts) — `resolveLegacyAccess`, `OPEN_ACCESS` (`{ user: 'admin', mcp: 'admin' }`), `GUARDED_ACCESS` (`{ user: 'operator', mcp: 'viewer' }`) +- [`src/core/policy/legacy-access.ts`](../../src/core/policy/legacy-access.ts) — `resolveLegacyAccess`, `DEFAULT_ACCESS` (`{ user: 'admin', mcp: 'viewer' }`), `GUARDED_ACCESS` (`{ user: 'operator', mcp: 'viewer' }`) - [`src/core/policy/index.ts`](../../src/core/policy/index.ts) — barrel export for all of the above ## Docs diff --git a/docs/wiki/core-state.md b/docs/wiki/core-state.md index 3a81fa3a..747e0335 100644 --- a/docs/wiki/core-state.md +++ b/docs/wiki/core-state.md @@ -66,5 +66,5 @@ Each config carries `access: ConfigAccess` (per-channel role pair, replacing the - `observer` is a module-scope singleton; `resetConnectionManager`/`resetSettingsManager`/`resetStateManager` are test-only reset points. - Stages in settings allow per-environment config overrides; `evaluateRules` applies them at runtime. - `initProjectContext` is the canonical startup sequence called by CLI entry and SDK `createContext`. -- `Config.access` defaults to `{ user: 'admin', mcp: 'admin' }` (`OPEN_ACCESS`) when absent; a legacy `protected: true` maps to `{ user: 'operator', mcp: 'viewer' }` (`GUARDED_ACCESS`) — both constants live in [`src/core/policy/legacy-access.ts`](../../src/core/policy/legacy-access.ts). +- `Config.access` defaults to `{ user: 'admin', mcp: 'viewer' }` (`DEFAULT_ACCESS`) when absent; a legacy `protected: true` maps to `{ user: 'operator', mcp: 'viewer' }` (`GUARDED_ACCESS`) — both constants live in [`src/core/policy/legacy-access.ts`](../../src/core/policy/legacy-access.ts). - `src/core/config/protection.ts` (hard-block rules for protected configs) was deleted — access enforcement now runs entirely through `core/policy`. diff --git a/docs/wiki/tui.md b/docs/wiki/tui.md index 54c6a859..7a3e1ee3 100644 --- a/docs/wiki/tui.md +++ b/docs/wiki/tui.md @@ -55,7 +55,7 @@ Ink/React-based terminal UI launched by `noorm ui`. Full-screen interactive inte - TUI is launched by [`src/cli/ui.ts`](../../src/cli/ui.ts) — CLI dependency. - `SmartConfirm`/`ProtectedConfirm` ([`src/tui/components/dialogs/`](../../src/tui/components/dialogs)) take `requiresConfirmation`/`confirmationPhrase` from a `PolicyCheck` (`checkConfigPolicy`, `core/policy`) instead of a config's `protected` flag — every destructive-action screen (change run/revert/ff, db create/destroy/teardown/truncate, config rm) calls `checkConfigPolicy` directly to build these props. - [`src/tui/utils/config-validation.ts`](../../src/tui/utils/config-validation.ts) builds `ConfigAccess` from the Add/Edit config forms' `userRole`/`mcpRole` select fields (`buildAccessFromValues`) — `ConfigAddScreen`/`ConfigEditScreen` replaced the old single `protected` checkbox with these two role selects. -- [`src/tui/app-context.tsx`](../../src/tui/app-context.tsx) derives placeholder stage configs' `access` from `stage.defaults.protected` via `GUARDED_ACCESS`/`OPEN_ACCESS` (`core/policy`). +- [`src/tui/app-context.tsx`](../../src/tui/app-context.tsx) derives placeholder stage configs' `access` from `stage.defaults.protected` via `GUARDED_ACCESS`/`DEFAULT_ACCESS` (`core/policy`). ## Conventions worth knowing diff --git a/src/core/config/resolver.ts b/src/core/config/resolver.ts index 9b4c3080..a8d1530e 100644 --- a/src/core/config/resolver.ts +++ b/src/core/config/resolver.ts @@ -70,7 +70,7 @@ export class SettingsProvider { * by the time `parseConfig` runs, short-circuiting the legacy `protected` * fallback in `withResolvedAccess` and silently opening up any config that * only sets legacy `protected: true`. `parseConfig` still defaults absent - * `access` to admin/admin (via `resolveLegacyAccess`) — just after the + * `access` to `DEFAULT_ACCESS` (via `resolveLegacyAccess`) — just after the * merge, from the real inputs, not before it. */ const DEFAULTS: ConfigInput = { diff --git a/src/core/state/access.ts b/src/core/state/access.ts index cede71e4..8967ed49 100644 --- a/src/core/state/access.ts +++ b/src/core/state/access.ts @@ -6,7 +6,7 @@ * accept whatever they found. A truthy-but-invalid `access` such as `{}` * passed through verbatim and bricked the config (every later command * failed zod validation), while a truthy-but-non-boolean legacy - * `protected` such as `"true"` fell through to the admin/admin default and + * `protected` such as `"true"` fell through to the unrestricted default and * silently removed the protection it was asking for. * * The rule here is one-directional: an unrecognised shape may only make a @@ -59,7 +59,7 @@ export function repairConfigAccess(rawAccess: unknown, rawProtected: unknown): C // Any truthy legacy value means the author asked for protection. Only // a strict `true` used to count, so `"true"` and `1` both resolved to - // fully open — the opposite of what they say. + // the unrestricted default — the opposite of what they say. const legacyProtected = Boolean(rawProtected); if (!isRecord(rawAccess)) { From e300ee1c5da83aefef48238f65a27670da24cebf Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 14:50:07 -0400 Subject: [PATCH 096/105] chore(changeset): describe the v1 readiness fixes --- .changeset/v1-authorization-seam.md | 22 ++++++++++++++++++++++ .changeset/v1-crypto-identity.md | 15 +++++++++++++++ .changeset/v1-data-integrity.md | 21 +++++++++++++++++++++ .changeset/v1-explore-correctness.md | 14 ++++++++++++++ .changeset/v1-mysql-dialect.md | 12 ++++++++++++ .changeset/v1-tui-parity.md | 14 ++++++++++++++ .changeset/v1-update-logging.md | 13 +++++++++++++ 7 files changed, 111 insertions(+) create mode 100644 .changeset/v1-authorization-seam.md create mode 100644 .changeset/v1-crypto-identity.md create mode 100644 .changeset/v1-data-integrity.md create mode 100644 .changeset/v1-explore-correctness.md create mode 100644 .changeset/v1-mysql-dialect.md create mode 100644 .changeset/v1-tui-parity.md create mode 100644 .changeset/v1-update-logging.md diff --git a/.changeset/v1-authorization-seam.md b/.changeset/v1-authorization-seam.md new file mode 100644 index 00000000..1afd8e61 --- /dev/null +++ b/.changeset/v1-authorization-seam.md @@ -0,0 +1,22 @@ +--- +"@noormdev/cli": major +"@noormdev/sdk": major +--- + +## Authorization is now enforced at the core seam + +Policy was re-implemented per surface, and several cells were simply empty — the CLI would perform operations the TUI correctly refused, and two whole domains had no permission to check against. + +### Fixed + +* `fix(policy)!:` the SQL classifier no longer routes writes as reads. `EXPLAIN (ANALYZE) DELETE FROM t` classified as `read` and deleted rows under a `viewer` role — through the CLI and over a live MCP session. Two independent causes are fixed: the keyword fallback treated `EXPLAIN` as terminally read-only, and on the parser path `EXPLAIN` inherited nothing from the statement it wraps while the write-signal scan had no DDL node types. An `EXPLAIN` now inherits its wrapped statement's classification. +* `fix(policy)!:` statement splitting is dialect-aware. It tracked only `'`, so an MSSQL bracket-quoted identifier (`SELECT 1 AS [a'b]; DROP TABLE x`) split wrong and the tail ran unclassified. `"`, backticks, `[…]`, `$tag$…$tag$` and all three comment forms are now modelled. +* `fix(policy)!:` a statement with no leading word character used to classify as `read`, so a leading NUL byte carried `DROP TABLE` past the gate. It now fails closed. +* `feat(policy)!:` added the permissions that did not exist — `vault:*`, `secret:*`, `config:write`, `db:truncate`, `db:teardown`, `transfer:plan`, `lock:force`, `debug:*`. Operations in those domains had nothing to gate against and shipped ungated. +* `fix(vault)!:` vault and secret operations are gated at the core seam, so SDK, CLI and TUI inherit one check. A `viewer` config that was denied `run build` could still run `vault set`, `vault rm`, `vault propagate` and `secret set`. +* `fix(db)!:` `db create` is gated. The matrix denied `db:create` to `viewer` and the TUI enforced it; the CLI created the database anyway. +* `fix(db)!:` `db truncate` and `db teardown` require confirmation. `--yes`/`--force` were declared arguments that neither command read, so `noorm db teardown < /dev/null` dropped 63 objects and exited 0 — against documentation promising the CLI refuses. +* `fix(debug)!:` the debug screens' delete paths are gated. They removed rows from `noorm.vault` and `noorm.identities` with no authorization check at all. +* `fix(lock)!:` `lock force` is gated and requires `--yes`, names the holder it evicts, and returns a distinct exit code when there was nothing to release. +* `fix(config)!:` `config import --force` is gated on `config:write`. It rewrote a config's `access` with no check, escalating a `viewer` config to full delete rights in one command. +* `fix(transfer):` `getTransferPlan` is gated, matching the invariant its own comment claimed. `--dry-run` leaked destination table names, row estimates and the FK graph to a denied caller. diff --git a/.changeset/v1-crypto-identity.md b/.changeset/v1-crypto-identity.md new file mode 100644 index 00000000..f24677aa --- /dev/null +++ b/.changeset/v1-crypto-identity.md @@ -0,0 +1,15 @@ +--- +"@noormdev/cli": major +"@noormdev/sdk": major +--- + +## Identity and state key handling fail closed + +### Fixed + +* `fix(identity)!:` malformed key material is rejected instead of deriving a publicly computable key. `Buffer.from(key, 'hex')` never throws, so any non-hex private key collapsed to a zero-length HKDF input and produced a **constant** AES key — an attacker holding no key material could decrypt a real `state.enc`. `isValidKeyHex` existed but was only called on the CI path; it now guards every entry point. +* `fix(ci)!:` `ci identity enroll` verifies the stored public key matches the one presented. It checked only that a row existed, so anyone able to INSERT into `noorm.identities` — no vault access required — could pre-plant their own key and receive the vault key, while the operator's enrollment reported success and echoed back the expected key. +* `fix(identity)!:` `identity init --force` requires `--yes`, backs up the keypair, and names what it destroys. It silently regenerated the keypair and permanently destroyed every project's `state.enc`. +* `fix(ci):` `ci init --force` backs up `state.enc` and refuses on a TTY without confirmation; it destroyed all configs and secrets unprompted. It also now routes through `parseConfig`, so a numeric-looking database name persists as a string. +* `fix(vault)!:` `vault propagate` names every recipient and withholds until confirmed, and reports partial failures instead of returning success with the failed identity silently omitted. +* `fix(sdk):` an explicit identity scope whose revert fails is no longer marked reverted, and `disconnect()` no longer hangs when scopes are still held. diff --git a/.changeset/v1-data-integrity.md b/.changeset/v1-data-integrity.md new file mode 100644 index 00000000..7e0ad0f4 --- /dev/null +++ b/.changeset/v1-data-integrity.md @@ -0,0 +1,21 @@ +--- +"@noormdev/cli": major +"@noormdev/sdk": major +--- + +## Operations no longer report success over lost, duplicated or unwritten data + +### Fixed + +* `fix(transfer)!:` export and db-to-db transfer paginate by primary key instead of bare `LIMIT/OFFSET`. With no `ORDER BY`, a source being written to during a 50,000-row export produced 14,149 missing and 13,387 duplicated rows while reporting `rowsExported: 50000` and exiting 0. Tables with no primary key now take a single-statement snapshot rather than corrupting silently. +* `fix(dt)!:` a malformed row or a dead worker no longer hangs the pipeline forever. The error branch never decremented the in-flight count, `request()` never settled on worker death, and `worker:exit` had no subscribers — a four-byte bad payload wedged `db transfer --import` indefinitely. Three busy-wait loops are replaced with real settlement. +* `fix(transfer)!:` the postgres same-server path no longer copies the destination into itself. It ignored the source database entirely and could only ever emit `INSERT INTO t SELECT ... FROM t`. +* `fix(dt):` decompression is bounded on import. An unbounded `gunzip` inflated 398 KB to 400 MB (1029:1), and because it runs in a compute worker the resulting OOM triggered the hang above. +* `fix(transfer):` reported counts reflect reality — `onConflict: 'skip'` no longer counts no-ops as inserted, and the same-server path reports affected rows instead of a postgres `reltuples` estimate. +* `fix(transfer):` `db transfer --export` with no `--tables` exports every table, as its own flag description promised. It exported nothing, printed `success: true` and exited 0. +* `fix(transfer):` cross-dialect transfer works. The planner probed the destination with the *source* dialect, so every cross-dialect transfer aborted and roughly 400 lines of streaming conversion were unreachable — while the docs claimed the feature worked. +* `fix(teardown)!:` `truncate` qualifies statements with the table's schema. It discarded the schema, emptied 12 tables and then aborted — partial irreversible loss, reported as a failure. +* `fix(state)!:` `state.enc` writes are atomic and reconciled. Ten concurrent `noorm secret set` runs left five secrets, all exiting 0. Writes now stage-and-rename with an exclusive lock, three-way reconcile against a changed file, and keep a `.bak` generation. +* `fix(state):` reads stopped rewriting state. `needsMigration` tested a field the migration never writes, so it was always true and every command — including `config list` — re-encrypted and rewrote `state.enc`, turning reads into writes and amplifying the loss above. +* `fix(runner):` `.sql.tmpl` dedup works. The stored checksum was the raw file hash while the comparison used the rendered hash, so templates re-executed on every build and failed on non-idempotent DDL. +* `fix(runner):` SQLite executes every statement in a multi-statement file instead of silently running the first and reporting success. diff --git a/.changeset/v1-explore-correctness.md b/.changeset/v1-explore-correctness.md new file mode 100644 index 00000000..2bd967d9 --- /dev/null +++ b/.changeset/v1-explore-correctness.md @@ -0,0 +1,14 @@ +--- +"@noormdev/cli": minor +"@noormdev/sdk": minor +--- + +## Schema exploration answers about the object you asked for + +### Fixed + +* `fix(explore):` procedure detail returns parameters. It filtered `information_schema.parameters` on a bare name where the column holds `name_oid`, so the list view reported a parameter count and the detail view always showed none — on every surface. +* `fix(explore):` SQLite quotes identifiers at all raw-SQL sites. A single table named with an embedded quote broke listing, overview and detail for *unrelated* tables. +* `fix(explore):` MySQL table detail reads indexes and foreign keys from the requested schema rather than the connected database, which produced self-contradictory output with the real index missing. +* `fix(explore):` `--schema` is honoured on the list commands, which declared it and ignored it. +* `fix(explore):` the overview counts from the same listings the detail views use, inside one guarded call that surfaces errors, instead of a second implementation that disagreed and hardcoded several counters to zero. diff --git a/.changeset/v1-mysql-dialect.md b/.changeset/v1-mysql-dialect.md new file mode 100644 index 00000000..0edf525d --- /dev/null +++ b/.changeset/v1-mysql-dialect.md @@ -0,0 +1,12 @@ +--- +"@noormdev/cli": patch +"@noormdev/sdk": patch +--- + +## MySQL works + +### Fixed + +* `fix(runner):` `run build` executes on MySQL. `createOperation` emitted a `RETURNING` clause MySQL has no support for, so the operation record was never created and **zero SQL files ran** — the command failed before touching a single file. +* `fix(change):` `recordReset` no longer fails silently on MySQL. It swallowed the same error and returned `0`, so `db teardown` recorded nothing and reported no error at all. +* `refactor(shared):` operation-record insertion is one dialect-aware helper. It existed as three independent copies, which is why fixing the runner left the change module broken and the same defect had to be found twice. diff --git a/.changeset/v1-tui-parity.md b/.changeset/v1-tui-parity.md new file mode 100644 index 00000000..71c886d0 --- /dev/null +++ b/.changeset/v1-tui-parity.md @@ -0,0 +1,14 @@ +--- +"@noormdev/cli": minor +--- + +## TUI matches the CLI on gating and global modes + +### Fixed + +* `fix(tui):` `db transfer` routes through the gated path with a typed confirmation. It called core directly, skipping the confirmation tier the SDK enforces. +* `fix(tui):` the global dry-run toggle is honoured by transfer, truncate and teardown. The indicator showed dry-run as active while all three ran for real. +* `fix(tui):` `truncateFirst` is a working control instead of state the UI could display but never change. +* `fix(tui):` `vault propagate` is no longer a bare `p` keypress — it checks policy and names each recipient before granting access to every enrolled identity. +* `fix(tui):` fixed 15 stale-closure dependency omissions across hooks and screens. `react-hooks/exhaustive-deps` is not enabled in this repo, so callbacks silently captured first-render values. +* `fix(tui):` `InitScreen` no longer overwrites an existing `.noorm/.gitignore` on re-init. diff --git a/.changeset/v1-update-logging.md b/.changeset/v1-update-logging.md new file mode 100644 index 00000000..7572e32f --- /dev/null +++ b/.changeset/v1-update-logging.md @@ -0,0 +1,13 @@ +--- +"@noormdev/cli": minor +"@noormdev/sdk": patch +--- + +## Self-update input validation, log rotation and redaction + +### Fixed + +* `fix(update)!:` the version string from the npm registry is validated as semver before it reaches a URL or a shell. It was interpolated verbatim, and because `fetch` normalises `..`, a poisoned dist-tag could relocate **both the binary and its `checksums.txt`** to an attacker-controlled repository — so checksum verification passed against the attacker's own file. +* `fix(logger):` rotation reopens its write stream. It renamed the file out from under the open descriptor, so every subsequent write landed in the rotated file, `noorm.log` never reappeared, and rotation fired exactly once before growing unbounded. +* `fix(logger):` `settings.logging.enabled`, `.file`, `.maxSize` and `.maxFiles` are honoured. The CLI hardcoded all four, so every logging setting was inert across every command — and the log viewer read a different path than the CLI wrote. +* `fix(logger):` redaction covers this project's own variables. `NOORM_CONNECTION_PASSWORD` and `NOORM_IDENTITY_PRIVATE_KEY` were not masked, credential-bearing URIs passed through verbatim because values were never inspected, and `Error` objects were skipped wholesale. Log files are created `0600` rather than world-readable. From 80829687d32a34d762fc23372eff46101d7d3a42 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 15:34:33 -0400 Subject: [PATCH 097/105] feat(cli)!: separate partial, total and usage exit codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/cli/_exit.ts | 75 +++++++++++++++++++++++++++++ src/cli/change/edit.ts | 3 +- src/cli/change/ff.ts | 3 +- src/cli/change/next.ts | 3 +- src/cli/change/revert.ts | 3 +- src/cli/change/rewind.ts | 3 +- src/cli/change/rm.ts | 3 +- src/cli/change/run.ts | 3 +- src/cli/ci/identity/enroll.ts | 3 +- src/cli/ci/identity/new.ts | 3 +- src/cli/ci/init.ts | 11 +++-- src/cli/ci/secrets.ts | 24 +++++---- src/cli/config/cp.ts | 3 +- src/cli/config/import.ts | 7 +-- src/cli/config/rm.ts | 3 +- src/cli/config/use.ts | 3 +- src/cli/config/validate.ts | 3 +- src/cli/db/create.ts | 5 +- src/cli/db/drop.ts | 5 +- src/cli/db/explore-functions.ts | 5 +- src/cli/db/explore-procedures.ts | 5 +- src/cli/db/explore-tables-detail.ts | 3 +- src/cli/db/explore-triggers.ts | 5 +- src/cli/db/explore-types.ts | 5 +- src/cli/db/explore-views.ts | 5 +- src/cli/db/reset.ts | 12 +++-- src/cli/dev/test-helpers.ts | 21 +++++--- src/cli/dev/test-workers.ts | 7 ++- src/cli/identity/edit.ts | 5 +- src/cli/identity/export.ts | 3 +- src/cli/index.ts | 5 +- src/cli/init.ts | 5 +- src/cli/lock/force.ts | 23 ++++++--- src/cli/run/build.ts | 3 +- src/cli/run/file.ts | 5 +- src/cli/run/files.ts | 3 +- src/cli/secret/rm.ts | 3 +- src/cli/settings/edit.ts | 7 +-- src/cli/settings/secret.ts | 7 +-- src/cli/sql/clear.ts | 5 +- src/cli/sql/history.ts | 5 +- src/cli/sql/query.ts | 5 +- src/cli/sql/repl.ts | 7 +-- src/cli/vault/propagate.ts | 11 +++-- src/cli/vault/rm.ts | 15 ++++-- tests/cli/change-edit.test.ts | 4 +- tests/cli/ci/init.test.ts | 4 +- tests/cli/ci/secrets.test.ts | 8 +-- tests/cli/config/import.test.ts | 2 +- tests/cli/config/rm.test.ts | 4 +- tests/cli/init.test.ts | 8 +-- tests/cli/run/change-rewind.test.ts | 4 +- tests/cli/settings-edit.test.ts | 4 +- tests/cli/settings-secret.test.ts | 4 +- tests/cli/sql-repl.test.ts | 4 +- tests/cli/yes-flag.test.ts | 28 +++++------ 56 files changed, 287 insertions(+), 138 deletions(-) create mode 100644 src/cli/_exit.ts diff --git a/src/cli/_exit.ts b/src/cli/_exit.ts new file mode 100644 index 00000000..b29877fc --- /dev/null +++ b/src/cli/_exit.ts @@ -0,0 +1,75 @@ +/** + * Process exit codes shared by every noorm CLI command. + * + * A CI pipeline has to tell "you named something that isn't there" apart + * from "the work ran and half of it failed" without parsing prose. Before + * this module, `2` meant partial failure on `run build`, total failure on + * `run dir`, and "there was no lock to release" on `lock force` — so the + * only check that held across commands was `!= 0`, which throws away the + * distinction that matters most: is the database now in a mixed state? + * + * `PARTIAL` is 3 rather than 2 because a partial result is the one code a + * pipeline must never conflate with a clean failure — a clean failure can + * be retried, a partial one needs a human. Freeing 2 for `USAGE` follows + * the GNU convention and leaves 1 as the catch-all it already is at 235 + * call sites. + * + * @example + * process.exit(exitCodeForStatus(result.status)); + */ +export const EXIT = { + /** Everything the caller asked for happened. */ + SUCCESS: 0, + + /** The operation ran and wholly failed — no unit of work succeeded. */ + FAILURE: 1, + + /** + * The invocation itself was wrong: a malformed or missing flag, a + * TTY-only command run non-interactively, or a named target (file, + * directory, config, change, glob) that does not exist. Nothing was + * attempted, so nothing was changed. + */ + USAGE: 2, + + /** + * Some units of work succeeded and some failed. The target is in a + * mixed state and re-running is not automatically safe. + */ + PARTIAL: 3, +} as const; + +export type ExitCode = typeof EXIT[keyof typeof EXIT]; + +/** + * Map a core batch/operation status onto an exit code. + * + * Unknown statuses collapse to `FAILURE` rather than `SUCCESS` on purpose: + * a status this layer does not recognise is not evidence that the work + * succeeded, and reporting success on no evidence is the defect this + * whole contract exists to prevent. + * + * @example + * process.exit(exitCodeForStatus(res.status)); // 'partial' -> 3 + */ +export function exitCodeForStatus(status: string | undefined): ExitCode { + + if (status === 'success' || status === 'skipped') return EXIT.SUCCESS; + if (status === 'partial') return EXIT.PARTIAL; + + return EXIT.FAILURE; + +} + +/** + * Whether a core status string represents an unqualified success. + * + * Shared with `outputResult` so the `--json` envelope's `success` flag and + * the process exit code can never disagree — they are derived from the + * same predicate rather than computed independently at each call site. + */ +export function isSuccessStatus(status: string | undefined): boolean { + + return exitCodeForStatus(status) === EXIT.SUCCESS; + +} diff --git a/src/cli/change/edit.ts b/src/cli/change/edit.ts index ab6a81de..a09c676e 100644 --- a/src/cli/change/edit.ts +++ b/src/cli/change/edit.ts @@ -23,6 +23,7 @@ import { defineCommand } from 'citty'; import { getSettingsManager } from '../../core/settings/index.js'; import { outputError } from '../_utils.js'; import { selectChangeFromFs, requireTty } from './_prompt.js'; +import { EXIT } from '../_exit.js'; /** * Spawn an editor against a target path and wait for it to exit. @@ -100,7 +101,7 @@ const editCommand = defineCommand({ if (statErr || !existing?.isDirectory()) { outputError(args, `Change not found: ${changeName}`); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/change/ff.ts b/src/cli/change/ff.ts index 31a5fa54..62cf23a6 100644 --- a/src/cli/change/ff.ts +++ b/src/cli/change/ff.ts @@ -4,6 +4,7 @@ import { defineCommand } from 'citty'; import { withContext, outputResult, sharedArgs } from '../_utils.js'; +import { exitCodeForStatus } from '../_exit.js'; const ffCommand = defineCommand({ meta: { @@ -101,7 +102,7 @@ const ffCommand = defineCommand({ } - process.exit(result.status === 'success' ? 0 : 2); + process.exit(exitCodeForStatus(result.status)); }, }); diff --git a/src/cli/change/next.ts b/src/cli/change/next.ts index 12eb3b00..972818c3 100644 --- a/src/cli/change/next.ts +++ b/src/cli/change/next.ts @@ -7,6 +7,7 @@ import { defineCommand } from 'citty'; import { withContext, outputResult, sharedArgs } from '../_utils.js'; +import { exitCodeForStatus } from '../_exit.js'; const nextCommand = defineCommand({ meta: { @@ -102,7 +103,7 @@ const nextCommand = defineCommand({ } - process.exit(result.status === 'success' ? 0 : 2); + process.exit(exitCodeForStatus(result.status)); }, }); diff --git a/src/cli/change/revert.ts b/src/cli/change/revert.ts index 77e30549..e9835a6b 100644 --- a/src/cli/change/revert.ts +++ b/src/cli/change/revert.ts @@ -9,6 +9,7 @@ import * as p from '@clack/prompts'; import { defineCommand } from 'citty'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { exitCodeForStatus } from '../_exit.js'; import { selectChangeFromStatus, requireTty } from './_prompt.js'; const revertCommand = defineCommand({ @@ -118,7 +119,7 @@ const revertCommand = defineCommand({ } - process.exit(result.status === 'success' ? 0 : 2); + process.exit(exitCodeForStatus(result.status)); }, }); diff --git a/src/cli/change/rewind.ts b/src/cli/change/rewind.ts index fa11e2a7..d4405d19 100644 --- a/src/cli/change/rewind.ts +++ b/src/cli/change/rewind.ts @@ -12,6 +12,7 @@ import * as p from '@clack/prompts'; import { defineCommand } from 'citty'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { exitCodeForStatus } from '../_exit.js'; import { selectChangeFromStatus, requireTty } from './_prompt.js'; const rewindCommand = defineCommand({ @@ -125,7 +126,7 @@ const rewindCommand = defineCommand({ } - process.exit(result.status === 'success' ? 0 : 2); + process.exit(exitCodeForStatus(result.status)); }, }); diff --git a/src/cli/change/rm.ts b/src/cli/change/rm.ts index ff1baa53..12cca476 100644 --- a/src/cli/change/rm.ts +++ b/src/cli/change/rm.ts @@ -29,6 +29,7 @@ import { initState, getStateManager } from '../../core/state/index.js'; import { checkConfigPolicy } from '../../core/policy/index.js'; import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; import { selectChangeFromFs, requireTty } from './_prompt.js'; +import { EXIT } from '../_exit.js'; const rmCommand = defineCommand({ meta: { name: 'rm', description: 'Delete a change directory' }, @@ -105,7 +106,7 @@ const rmCommand = defineCommand({ if (statErr || !existingStats) { outputError(args, `Change not found: ${changeName}`); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/change/run.ts b/src/cli/change/run.ts index c6ab8b87..7027c56b 100644 --- a/src/cli/change/run.ts +++ b/src/cli/change/run.ts @@ -10,6 +10,7 @@ import { defineCommand } from 'citty'; import { isPendingChange } from '../../core/change/index.js'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { exitCodeForStatus } from '../_exit.js'; import { selectChangeFromStatus, requireTty } from './_prompt.js'; const runCommand = defineCommand({ @@ -119,7 +120,7 @@ const runCommand = defineCommand({ } - process.exit(result.status === 'success' ? 0 : 2); + process.exit(exitCodeForStatus(result.status)); }, }); diff --git a/src/cli/ci/identity/enroll.ts b/src/cli/ci/identity/enroll.ts index b271732b..4428476b 100644 --- a/src/cli/ci/identity/enroll.ts +++ b/src/cli/ci/identity/enroll.ts @@ -28,6 +28,7 @@ import { getNoormTables, noormDb } from '../../../core/shared/tables.js'; import type { NoormDatabase } from '../../../core/shared/tables.js'; import type { EncryptedVaultKey } from '../../../core/vault/types.js'; import { outputResult, outputError, sharedArgs, isYesMode, withVaultContext } from '../../_utils.js'; +import { EXIT } from '../../_exit.js'; const enrollCommand = defineCommand({ meta: { @@ -62,7 +63,7 @@ const enrollCommand = defineCommand({ if (!name || !email || !args.config) { outputError(args, 'Missing required: --config, --name, --email'); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/ci/identity/new.ts b/src/cli/ci/identity/new.ts index 61e2eeff..4c802eda 100644 --- a/src/cli/ci/identity/new.ts +++ b/src/cli/ci/identity/new.ts @@ -13,6 +13,7 @@ import { defineCommand } from 'citty'; import { generateKeyPair } from '../../../core/identity/crypto.js'; import { computeIdentityHash } from '../../../core/identity/hash.js'; import { outputResult, outputError, sharedArgs } from '../../_utils.js'; +import { EXIT } from '../../_exit.js'; const newCommand = defineCommand({ meta: { @@ -32,7 +33,7 @@ const newCommand = defineCommand({ if (!name || !email) { outputError(args, 'Both --name and --email are required and must be non-empty.'); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/ci/init.ts b/src/cli/ci/init.ts index e9a8d17d..a230446c 100644 --- a/src/cli/ci/init.ts +++ b/src/cli/ci/init.ts @@ -25,6 +25,7 @@ import type { ConnectionConfig } from '../../core/connection/types.js'; import { DEFAULT_ACCESS } from '../../core/policy/index.js'; import { initState } from '../../core/state/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; /** * Coerce an env-derived value back to a string. @@ -84,7 +85,7 @@ const initCommand = defineCommand({ ? `Missing or invalid environment variables: ${missing.join(', ')}` : `${CI_ENV_VARS.privateKey} is invalid (expected 96 hex characters of a valid X25519 PKCS8 key)`, ); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -94,7 +95,7 @@ const initCommand = defineCommand({ if (envConfigErr) { outputError(args, `Invalid NOORM_CONNECTION_* env vars: ${envConfigErr.message}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -104,14 +105,14 @@ const initCommand = defineCommand({ if (!dialect) { outputError(args, 'NOORM_CONNECTION_DIALECT is required (postgres, mysql, sqlite, or mssql)'); - process.exit(1); + process.exit(EXIT.USAGE); } if (!database) { outputError(args, 'NOORM_CONNECTION_DATABASE is required'); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -216,7 +217,7 @@ const initCommand = defineCommand({ if (parseErr || !config) { outputError(args, `Invalid CI configuration: ${parseErr?.message ?? 'unknown error'}`); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/ci/secrets.ts b/src/cli/ci/secrets.ts index 389fe0db..98187bda 100644 --- a/src/cli/ci/secrets.ts +++ b/src/cli/ci/secrets.ts @@ -6,9 +6,10 @@ * vault. Without --overwrite, existing keys are skipped so a rerun is * safe; with --overwrite, collisions are replaced. * - * Exit codes: 0 all loaded, 1 nothing loaded (precondition or total - * failure), 2 partial (some set, some errored) — lets CI distinguish - * degraded state from clean failure. + * Exit codes follow the shared contract in `../_exit.ts`: 0 all loaded, + * 1 nothing loaded, 2 the invocation named something missing, 3 partial + * (some set, some errored) — lets CI distinguish degraded state from a + * clean failure it can safely retry. */ import { readFile } from 'node:fs/promises'; @@ -17,6 +18,7 @@ import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; interface DotenvLine { key: string; @@ -114,7 +116,7 @@ const secretsCommand = defineCommand({ args, `Failed to load state (did you run "noorm ci init"?): ${initErr.message}`, ); - process.exit(1); + process.exit(EXIT.FAILURE); } @@ -127,14 +129,14 @@ const secretsCommand = defineCommand({ args, 'No config specified and no active config. Run "noorm ci init" or pass --config.', ); - process.exit(1); + process.exit(EXIT.USAGE); } if (!stateManager.getConfig(configName)) { outputError(args, `Config "${configName}" does not exist.`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -143,7 +145,7 @@ const secretsCommand = defineCommand({ if (readErr || content === null) { outputError(args, `Failed to read ${args.file}: ${readErr?.message ?? 'unknown error'}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -152,7 +154,7 @@ const secretsCommand = defineCommand({ if (parseErr || !lines) { outputError(args, `Parse error in ${args.file}: ${parseErr?.message ?? 'unknown error'}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -213,11 +215,13 @@ const secretsCommand = defineCommand({ if (errorCount === 0) { - process.exit(0); + process.exit(EXIT.SUCCESS); } - process.exit(setCount > 0 ? 2 : 1); + // Some keys landed and some did not: the config is now half-loaded, + // which a pipeline must not treat as a retryable clean failure. + process.exit(setCount > 0 ? EXIT.PARTIAL : EXIT.FAILURE); }, }); diff --git a/src/cli/config/cp.ts b/src/cli/config/cp.ts index 38a78ed6..f00bdd71 100644 --- a/src/cli/config/cp.ts +++ b/src/cli/config/cp.ts @@ -9,6 +9,7 @@ import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const cpCommand = defineCommand({ meta: { @@ -39,7 +40,7 @@ const cpCommand = defineCommand({ if (!source) { outputError(args, `Config not found: ${args.src}`); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/config/import.ts b/src/cli/config/import.ts index 46a39a01..5743e935 100644 --- a/src/cli/config/import.ts +++ b/src/cli/config/import.ts @@ -14,6 +14,7 @@ import { ConfigValidationError, parseConfig } from '../../core/config/schema.js' import { checkConfigPolicy } from '../../core/policy/index.js'; import { initState, getStateManager } from '../../core/state/index.js'; import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const importCommand = defineCommand({ meta: { @@ -35,7 +36,7 @@ const importCommand = defineCommand({ if (readErr || !raw) { outputError(args, `Failed to read file: ${readErr?.message ?? 'empty file'}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -44,7 +45,7 @@ const importCommand = defineCommand({ if (parseErr) { outputError(args, `Invalid JSON: ${parseErr.message}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -57,7 +58,7 @@ const importCommand = defineCommand({ : 'Config JSON is missing required fields: name, connection'; outputError(args, message); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/config/rm.ts b/src/cli/config/rm.ts index 7df69b4e..dec0fef8 100644 --- a/src/cli/config/rm.ts +++ b/src/cli/config/rm.ts @@ -14,6 +14,7 @@ import { getSettingsManager } from '../../core/settings/index.js'; import { checkConfigPolicy } from '../../core/policy/index.js'; import { SettingsProvider } from '../../core/config/resolver.js'; import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const rmCommand = defineCommand({ meta: { @@ -48,7 +49,7 @@ const rmCommand = defineCommand({ if (!config) { outputError(args, `Config "${args.name}" not found.`); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/config/use.ts b/src/cli/config/use.ts index 3a0da86e..aff0e843 100644 --- a/src/cli/config/use.ts +++ b/src/cli/config/use.ts @@ -11,6 +11,7 @@ import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; import { syncIdentityWithConfig } from '../../core/identity/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const useCommand = defineCommand({ meta: { @@ -45,7 +46,7 @@ const useCommand = defineCommand({ if (setErr) { outputError(args, setErr.message); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/config/validate.ts b/src/cli/config/validate.ts index 3b4b5fa7..d2e45b9d 100644 --- a/src/cli/config/validate.ts +++ b/src/cli/config/validate.ts @@ -10,6 +10,7 @@ import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; import { validateConfigChecks } from '../../core/config/validate.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const validateCommand = defineCommand({ meta: { @@ -43,7 +44,7 @@ const validateCommand = defineCommand({ if (!config) { outputError(args, `Config "${args.name}" not found.`); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/db/create.ts b/src/cli/db/create.ts index fc9cf0b6..f822d2ad 100644 --- a/src/cli/db/create.ts +++ b/src/cli/db/create.ts @@ -17,6 +17,7 @@ import { resolveConfig, SettingsProvider } from '../../core/config/resolver.js'; import { checkDbStatus, createDb } from '../../core/db/index.js'; import { checkConfigPolicy } from '../../core/policy/index.js'; import { isYesMode, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const createCommand = defineCommand({ meta: { @@ -64,14 +65,14 @@ const createCommand = defineCommand({ if (resolveErr) { outputError(args, resolveErr.message); - process.exit(1); + process.exit(EXIT.USAGE); } if (!config) { outputError(args, 'No active configuration. Use: noorm config use '); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/db/drop.ts b/src/cli/db/drop.ts index 2bad80c6..e7045d27 100644 --- a/src/cli/db/drop.ts +++ b/src/cli/db/drop.ts @@ -15,6 +15,7 @@ import { resolveConfig, SettingsProvider } from '../../core/config/resolver.js'; import { destroyDb } from '../../core/db/index.js'; import { checkConfigPolicy } from '../../core/policy/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const dropCommand = defineCommand({ meta: { @@ -62,14 +63,14 @@ const dropCommand = defineCommand({ if (resolveErr) { outputError(args, resolveErr.message); - process.exit(1); + process.exit(EXIT.USAGE); } if (!config) { outputError(args, 'No active configuration. Use: noorm config use '); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/db/explore-functions.ts b/src/cli/db/explore-functions.ts index 01e40c2e..e2023d2e 100644 --- a/src/cli/db/explore-functions.ts +++ b/src/cli/db/explore-functions.ts @@ -7,6 +7,7 @@ import type { Kysely } from 'kysely'; import { fetchList } from '../../core/explore/index.js'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const functionsCommand = defineCommand({ meta: { @@ -73,7 +74,7 @@ const functionsCommand = defineCommand({ if (!detail) { outputError(args, `Function not found: ${args.name}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -119,7 +120,7 @@ const functionsCommand = defineCommand({ if (args.json) { - outputResult(args, functions, ''); + outputResult(args, { functions }, ''); } diff --git a/src/cli/db/explore-procedures.ts b/src/cli/db/explore-procedures.ts index 57acaeb8..4d4c2159 100644 --- a/src/cli/db/explore-procedures.ts +++ b/src/cli/db/explore-procedures.ts @@ -7,6 +7,7 @@ import type { Kysely } from 'kysely'; import { fetchList } from '../../core/explore/index.js'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const proceduresCommand = defineCommand({ meta: { @@ -66,7 +67,7 @@ const proceduresCommand = defineCommand({ if (!detail) { outputError(args, `Procedure not found: ${args.name}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -112,7 +113,7 @@ const proceduresCommand = defineCommand({ if (args.json) { - outputResult(args, procedures, ''); + outputResult(args, { procedures }, ''); } diff --git a/src/cli/db/explore-tables-detail.ts b/src/cli/db/explore-tables-detail.ts index fdf24c93..902376c0 100644 --- a/src/cli/db/explore-tables-detail.ts +++ b/src/cli/db/explore-tables-detail.ts @@ -4,6 +4,7 @@ import { defineCommand } from 'citty'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const detailCommand = defineCommand({ meta: { @@ -57,7 +58,7 @@ const detailCommand = defineCommand({ if (!detail) { outputError(args, `Table not found: ${args.name}`); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/db/explore-triggers.ts b/src/cli/db/explore-triggers.ts index 3258af8b..d6c8ae3f 100644 --- a/src/cli/db/explore-triggers.ts +++ b/src/cli/db/explore-triggers.ts @@ -11,6 +11,7 @@ import type { Kysely } from 'kysely'; import { fetchList, fetchDetail } from '../../core/explore/index.js'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const triggersCommand = defineCommand({ meta: { @@ -73,7 +74,7 @@ const triggersCommand = defineCommand({ if (!detail) { outputError(args, `Trigger not found: ${args.name}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -117,7 +118,7 @@ const triggersCommand = defineCommand({ if (args.json) { - outputResult(args, triggers, ''); + outputResult(args, { triggers }, ''); } diff --git a/src/cli/db/explore-types.ts b/src/cli/db/explore-types.ts index 3e7790ab..7a99eb0e 100644 --- a/src/cli/db/explore-types.ts +++ b/src/cli/db/explore-types.ts @@ -7,6 +7,7 @@ import type { Kysely } from 'kysely'; import { fetchList } from '../../core/explore/index.js'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const typesCommand = defineCommand({ meta: { @@ -76,7 +77,7 @@ const typesCommand = defineCommand({ if (!detail) { outputError(args, `Type not found: ${args.name}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -123,7 +124,7 @@ const typesCommand = defineCommand({ if (args.json) { - outputResult(args, types, ''); + outputResult(args, { types }, ''); } diff --git a/src/cli/db/explore-views.ts b/src/cli/db/explore-views.ts index a6095d14..154ed189 100644 --- a/src/cli/db/explore-views.ts +++ b/src/cli/db/explore-views.ts @@ -7,6 +7,7 @@ import type { Kysely } from 'kysely'; import { fetchList } from '../../core/explore/index.js'; import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const viewsCommand = defineCommand({ meta: { @@ -68,7 +69,7 @@ const viewsCommand = defineCommand({ if (!detail) { outputError(args, `View not found: ${args.name}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -115,7 +116,7 @@ const viewsCommand = defineCommand({ if (args.json) { - outputResult(args, views, ''); + outputResult(args, { views }, ''); } diff --git a/src/cli/db/reset.ts b/src/cli/db/reset.ts index c045bd62..32feeb3e 100644 --- a/src/cli/db/reset.ts +++ b/src/cli/db/reset.ts @@ -6,7 +6,8 @@ */ import { defineCommand } from 'citty'; -import { withContext, outputResult, isYesMode, sharedArgs } from '../_utils.js'; +import { withContext, outputResult, outputError, isYesMode, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const resetCommand = defineCommand({ meta: { @@ -22,8 +23,13 @@ const resetCommand = defineCommand({ if (!isYesMode(args)) { - process.stderr.write('Error: This is a destructive operation. Pass --yes to confirm.\n'); - process.exit(1); + // outputError, not a bare stderr write: `--json` callers got an + // empty stdout here and so had no envelope to key a failure off. + // Stays FAILURE, not USAGE: every other confirmation/`--force` + // refusal in the CLI exits 1, and splitting this one off would + // make the code mean two things depending on the command. + outputError(args, 'This is a destructive operation. Pass --yes to confirm.'); + process.exit(EXIT.FAILURE); } diff --git a/src/cli/dev/test-helpers.ts b/src/cli/dev/test-helpers.ts index e241334b..c8b9b6a2 100644 --- a/src/cli/dev/test-helpers.ts +++ b/src/cli/dev/test-helpers.ts @@ -19,7 +19,8 @@ import { attempt } from '@logosdx/utils'; import { loadHelpers, findHelperFiles } from '../../core/template/helpers.js'; import { buildContext } from '../../core/template/context.js'; -import { outputResult } from '../_utils.js'; +import { outputResult, outputError } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const testHelpersCommand = defineCommand({ meta: { @@ -53,8 +54,8 @@ const testHelpersCommand = defineCommand({ if (findErr) { - process.stderr.write(` findHelperFiles failed: ${findErr.message}\n`); - process.exit(1); + outputError(args, `findHelperFiles failed: ${findErr.message}`); + process.exit(EXIT.FAILURE); } @@ -73,8 +74,8 @@ const testHelpersCommand = defineCommand({ if (loadErr) { - process.stderr.write(` loadHelpers failed: ${loadErr.message}\n`); - process.exit(1); + outputError(args, `loadHelpers failed: ${loadErr.message}`); + process.exit(EXIT.FAILURE); } @@ -109,8 +110,8 @@ const testHelpersCommand = defineCommand({ if (ctxErr) { - process.stderr.write(` buildContext failed: ${ctxErr.message}\n`); - process.exit(1); + outputError(args, `buildContext failed: ${ctxErr.message}`); + process.exit(EXIT.FAILURE); } @@ -149,7 +150,11 @@ const testHelpersCommand = defineCommand({ } - process.exit(errors.length > 0 ? 1 : 0); + // Some helpers loaded and some did not — a mixed result, not a + // clean failure the caller can retry. + if (errors.length === 0) process.exit(EXIT.SUCCESS); + + process.exit(keys.length > 0 ? EXIT.PARTIAL : EXIT.FAILURE); }, }); diff --git a/src/cli/dev/test-workers.ts b/src/cli/dev/test-workers.ts index c7483a8c..77529452 100644 --- a/src/cli/dev/test-workers.ts +++ b/src/cli/dev/test-workers.ts @@ -20,6 +20,7 @@ import { OrderBuffer } from '../../core/worker-bridge/order-buffer.js'; import { resolveWorker } from '../../core/worker-bridge/paths.js'; import type { ComputeEvents, ConnectionEvents } from '../../core/worker-bridge/types.js'; import { outputResult } from '../_utils.js'; +import { EXIT } from '../_exit.js'; interface TestResult { name: string; @@ -238,7 +239,11 @@ const testWorkersCommand = defineCommand({ } - process.exit(failed > 0 ? 1 : 0); + // A worker suite where some probes passed and some failed is a mixed + // result — the same distinction the run/change commands now report. + if (failed === 0) process.exit(EXIT.SUCCESS); + + process.exit(passed > 0 ? EXIT.PARTIAL : EXIT.FAILURE); }, }); diff --git a/src/cli/identity/edit.ts b/src/cli/identity/edit.ts index 10b1d4f3..668aa41d 100644 --- a/src/cli/identity/edit.ts +++ b/src/cli/identity/edit.ts @@ -18,6 +18,7 @@ import { defineCommand } from 'citty'; import { createIdentityForExistingKeys } from '../../core/identity/factory.js'; import { loadIdentityMetadata } from '../../core/identity/storage.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const editCommand = defineCommand({ meta: { name: 'edit', description: 'Edit identity metadata (name, email)' }, @@ -31,7 +32,7 @@ const editCommand = defineCommand({ if (!args.name && !args.email) { outputError(args, 'At least one of --name or --email must be provided.'); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -47,7 +48,7 @@ const editCommand = defineCommand({ if (!existing) { outputError(args, 'No identity found. Run `noorm identity init` first.'); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/identity/export.ts b/src/cli/identity/export.ts index b9ae9983..45250e25 100644 --- a/src/cli/identity/export.ts +++ b/src/cli/identity/export.ts @@ -9,6 +9,7 @@ import { defineCommand } from 'citty'; import { loadIdentityMetadata, loadPublicKey } from '../../core/identity/storage.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const exportCommand = defineCommand({ meta: { name: 'export', description: 'Display your public key' }, @@ -25,7 +26,7 @@ const exportCommand = defineCommand({ args, 'No identity found. Run `noorm identity init` to create one.', ); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 1e3901ae..2cd0ee24 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,6 +15,7 @@ import { defineCommand, runMain, renderUsage, type CommandDef } from 'citty'; import { initProjectContext, setOriginalCwd } from '../core/project.js'; import { loadIdentityFromEnv } from '../core/identity/env.js'; import { setKeyOverride, setIdentityOverride } from '../core/identity/storage.js'; +import { EXIT } from './_exit.js'; /** * Commands opt into examples by attaching a top-level `examples: string[]` @@ -310,7 +311,7 @@ async function entry(): Promise { if (parsedCwd.error !== null) { process.stderr.write(`Error: ${parsedCwd.error}\n`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -323,7 +324,7 @@ async function entry(): Promise { if (!existsSync(resolvedCwd) || !statSync(resolvedCwd).isDirectory()) { process.stderr.write(`Error: --cwd path is not a directory: ${resolvedCwd}\n`); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/init.ts b/src/cli/init.ts index 5f2dca81..7ebc1365 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -15,6 +15,7 @@ import { hasKeyFiles, loadIdentityMetadata } from '../core/identity/index.js'; import { getOriginalCwd } from '../core/project.js'; import { performProjectInit, type ProjectInitIdentityInfo } from '../core/project-init.js'; import { isYesMode, sharedArgs } from './_utils.js'; +import { EXIT } from './_exit.js'; /** * Exit with cancellation message. @@ -108,7 +109,7 @@ const initCommand = defineCommand({ 'Run: noorm identity init --name "Your Name" --email "you@example.com"\n' + 'Then re-run: noorm init --yes\n', ); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -117,7 +118,7 @@ const initCommand = defineCommand({ if (!process.stdin.isTTY && !yesMode) { process.stderr.write('Error: noorm init requires an interactive terminal.\n'); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/lock/force.ts b/src/cli/lock/force.ts index 427dfdf0..85cce0f9 100644 --- a/src/cli/lock/force.ts +++ b/src/cli/lock/force.ts @@ -4,15 +4,13 @@ * Breaking a lock interrupts whatever migration its holder is running, so the * config's `lock:force` access gates it (enforced at the SDK seam in * `lock.forceRelease`, shared with the TUI and MCP) and `--yes` is the - * confirmation. Exits 2 when there was nothing to release, so a script can - * tell "evicted a holder" apart from "no-op" without parsing text. + * confirmation. Exits `EXIT.USAGE` when there was nothing to release, so a + * script can tell "evicted a holder" apart from "no-op" without parsing text. */ import { defineCommand } from 'citty'; import { withContext, outputResult, sharedArgs } from '../_utils.js'; - -/** Exit code for "command succeeded, but there was no lock to release". */ -const EXIT_NOTHING_TO_RELEASE = 2; +import { EXIT } from '../_exit.js'; const forceCommand = defineCommand({ meta: { @@ -49,15 +47,24 @@ const forceCommand = defineCommand({ }, }); - if (error) process.exit(1); + if (error) process.exit(EXIT.FAILURE); if (args.json) { - outputResult(args, { released: result.released, holder: result.holder, forced: true }, ''); + // `success` tracks whether a lock was actually broken, not whether + // the command ran — "released: false" is a no-op, and the envelope + // must not call a no-op a success. + outputResult( + args, + { success: result.released, released: result.released, holder: result.holder, forced: true }, + '', + ); } - process.exit(result.released ? 0 : EXIT_NOTHING_TO_RELEASE); + // Nothing to release is the "named target does not exist" case: the + // command changed nothing, so it reports USAGE rather than success. + process.exit(result.released ? EXIT.SUCCESS : EXIT.USAGE); }, }); diff --git a/src/cli/run/build.ts b/src/cli/run/build.ts index 409e7adb..1c71fd6a 100644 --- a/src/cli/run/build.ts +++ b/src/cli/run/build.ts @@ -4,6 +4,7 @@ import { defineCommand } from 'citty'; import { withContext, outputResult, sharedArgs } from '../_utils.js'; +import { exitCodeForStatus } from '../_exit.js'; const buildCommand = defineCommand({ meta: { @@ -131,7 +132,7 @@ const buildCommand = defineCommand({ } - process.exit(result.status === 'success' ? 0 : 2); + process.exit(exitCodeForStatus(result.status)); }, }); diff --git a/src/cli/run/file.ts b/src/cli/run/file.ts index 66e44500..046dabae 100644 --- a/src/cli/run/file.ts +++ b/src/cli/run/file.ts @@ -4,6 +4,7 @@ import { defineCommand } from 'citty'; import { withContext, outputResult, sharedArgs } from '../_utils.js'; +import { EXIT, exitCodeForStatus } from '../_exit.js'; const fileCommand = defineCommand({ meta: { @@ -57,7 +58,7 @@ const fileCommand = defineCommand({ }, }); - if (error) process.exit(1); + if (error) process.exit(EXIT.FAILURE); if (args.json) { @@ -65,7 +66,7 @@ const fileCommand = defineCommand({ } - process.exit(result.status === 'success' || result.status === 'skipped' ? 0 : 1); + process.exit(exitCodeForStatus(result.status)); }, }); diff --git a/src/cli/run/files.ts b/src/cli/run/files.ts index e3dba0b7..7bfa7213 100644 --- a/src/cli/run/files.ts +++ b/src/cli/run/files.ts @@ -4,6 +4,7 @@ import { defineCommand } from 'citty'; import { withContext, outputResult, sharedArgs } from '../_utils.js'; +import { exitCodeForStatus } from '../_exit.js'; const filesCommand = defineCommand({ meta: { @@ -96,7 +97,7 @@ const filesCommand = defineCommand({ } - process.exit(result.status === 'success' ? 0 : 2); + process.exit(exitCodeForStatus(result.status)); }, }); diff --git a/src/cli/secret/rm.ts b/src/cli/secret/rm.ts index f3170da8..90f5b65c 100644 --- a/src/cli/secret/rm.ts +++ b/src/cli/secret/rm.ts @@ -9,6 +9,7 @@ import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; import { resolveSecretPolicy } from './_policy.js'; +import { EXIT } from '../_exit.js'; const rmCommand = defineCommand({ meta: { @@ -60,7 +61,7 @@ const rmCommand = defineCommand({ if (!exists) { outputError(args, `Secret "${args.key}" not found in config "${configName}".`); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/settings/edit.ts b/src/cli/settings/edit.ts index 479b0d51..4b64a904 100644 --- a/src/cli/settings/edit.ts +++ b/src/cli/settings/edit.ts @@ -21,6 +21,7 @@ import type { TeardownConfig, } from '../../core/settings/types.js'; import { isYesMode, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; type Manager = ReturnType; @@ -568,14 +569,14 @@ const editCommand = defineCommand({ 'Error: noorm settings edit is interactive only.\n' + "Edit settings.yml directly, or use 'noorm settings build' to validate after changes.\n", ); - process.exit(1); + process.exit(EXIT.USAGE); } if (!process.stdin.isTTY) { process.stderr.write('Error: noorm settings edit requires an interactive terminal.\n'); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -587,7 +588,7 @@ const editCommand = defineCommand({ if (!fileExists) { process.stderr.write('Error: No settings.yml found. Run: noorm settings init\n'); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/settings/secret.ts b/src/cli/settings/secret.ts index 9925ab59..2465719b 100644 --- a/src/cli/settings/secret.ts +++ b/src/cli/settings/secret.ts @@ -14,6 +14,7 @@ import { defineCommand } from 'citty'; import { getSettingsManager } from '../../core/settings/index.js'; import type { SecretType, Stage, StageSecret } from '../../core/settings/types.js'; import { isYesMode, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; type Manager = ReturnType; @@ -314,14 +315,14 @@ const secretCommand = defineCommand({ "Edit the 'secrets' section of settings.yml directly to add/remove requirements.\n" + 'To set actual secret values, use: noorm secret set \n', ); - process.exit(1); + process.exit(EXIT.USAGE); } if (!process.stdin.isTTY) { process.stderr.write('Error: noorm settings secret requires an interactive terminal.\n'); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -333,7 +334,7 @@ const secretCommand = defineCommand({ if (!fileExists) { process.stderr.write('Error: No settings.yml found. Run: noorm settings init\n'); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/sql/clear.ts b/src/cli/sql/clear.ts index 35f1fd56..45f0f0d1 100644 --- a/src/cli/sql/clear.ts +++ b/src/cli/sql/clear.ts @@ -11,6 +11,7 @@ import { defineCommand } from 'citty'; import { SqlHistoryManager } from '../../core/sql-terminal/history.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; import { resolveHistoryConfigName } from './_config.js'; +import { EXIT } from '../_exit.js'; const clearCommand = defineCommand({ meta: { @@ -34,7 +35,7 @@ const clearCommand = defineCommand({ if (!configName) { outputError(args, 'No config specified and no active config set. Use --config or run "noorm config use ".'); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -49,7 +50,7 @@ const clearCommand = defineCommand({ if (isNaN(months) || months < 1) { outputError(args, `Invalid --older-than value: ${args.olderThan}. Must be a positive integer (months).`); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/sql/history.ts b/src/cli/sql/history.ts index f98c5a02..b460fcb3 100644 --- a/src/cli/sql/history.ts +++ b/src/cli/sql/history.ts @@ -16,6 +16,7 @@ import { defineCommand } from 'citty'; import { SqlHistoryManager } from '../../core/sql-terminal/history.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; import { resolveHistoryConfigName } from './_config.js'; +import { EXIT } from '../_exit.js'; /** Maximum characters of query text to display in non-JSON output. */ const QUERY_TRUNCATE = 80; @@ -75,7 +76,7 @@ const historyCommand = defineCommand({ if (isNaN(limit) || limit < 1) { outputError(args, `Invalid limit: ${args.limit}. Must be a positive integer.`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -84,7 +85,7 @@ const historyCommand = defineCommand({ if (!configName) { outputError(args, 'No config specified and no active config set. Use --config or run "noorm config use ".'); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/sql/query.ts b/src/cli/sql/query.ts index ec9cb257..05f6220a 100644 --- a/src/cli/sql/query.ts +++ b/src/cli/sql/query.ts @@ -9,6 +9,7 @@ import type { Kysely } from 'kysely'; import { executeRawSql } from '../../core/sql-terminal/executor.js'; import { withContext, outputError, outputResult, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const sqlCommand = defineCommand({ meta: { @@ -32,7 +33,7 @@ const sqlCommand = defineCommand({ if (readErr) { outputError(args, `Failed to read SQL file: ${args.file}: ${readErr.message}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -43,7 +44,7 @@ const sqlCommand = defineCommand({ if (!query) { outputError(args, 'No query provided. Usage: noorm sql "SELECT ..."'); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/sql/repl.ts b/src/cli/sql/repl.ts index b3ab664b..523f0517 100644 --- a/src/cli/sql/repl.ts +++ b/src/cli/sql/repl.ts @@ -21,6 +21,7 @@ import { observer } from '../../core/observer.js'; import { enableAutoLoggerInit } from '../../core/logger/init.js'; import { getStateManager } from '../../core/state/index.js'; import { isYesMode, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; /** * No-op stream that discards all writes. @@ -49,14 +50,14 @@ const replCommand = defineCommand({ ' noorm sql query "SELECT 1" # one-shot\n' + ' noorm sql --file query.sql # from a file\n', ); - process.exit(1); + process.exit(EXIT.USAGE); } if (!process.stdin.isTTY) { process.stderr.write('Error: noorm sql repl requires an interactive terminal.\n'); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -78,7 +79,7 @@ const replCommand = defineCommand({ if (!configExists) { process.stderr.write(`Error: Config not found: ${args.config}\n`); - process.exit(1); + process.exit(EXIT.USAGE); } diff --git a/src/cli/vault/propagate.ts b/src/cli/vault/propagate.ts index 3d4ce58f..aed81544 100644 --- a/src/cli/vault/propagate.ts +++ b/src/cli/vault/propagate.ts @@ -3,7 +3,8 @@ */ import { defineCommand } from 'citty'; -import { withVaultContext, sharedArgs, isYesMode } from '../_utils.js'; +import { withVaultContext, outputResult, sharedArgs, isYesMode } from '../_utils.js'; +import { EXIT } from '../_exit.js'; import { getVaultKeyChecked, propagateVaultKeyChecked, @@ -188,7 +189,7 @@ const propagateCommand = defineCommand({ if (args.json) { - process.stdout.write(JSON.stringify(result) + '\n'); + outputResult(args, result, ''); } else if (result?.success) { @@ -235,7 +236,11 @@ const propagateCommand = defineCommand({ } - process.exit(result?.success ? 0 : 1); + // Some identities were granted and some were not: the vault is now in + // a mixed state, which a pipeline must not read as a clean failure. + if (result?.success) process.exit(EXIT.SUCCESS); + + process.exit(result?.propagatedTo?.length ? EXIT.PARTIAL : EXIT.FAILURE); }, }); diff --git a/src/cli/vault/rm.ts b/src/cli/vault/rm.ts index 309c98d5..16564934 100644 --- a/src/cli/vault/rm.ts +++ b/src/cli/vault/rm.ts @@ -3,7 +3,8 @@ */ import { defineCommand } from 'citty'; -import { withVaultContext, sharedArgs, isYesMode } from '../_utils.js'; +import { withVaultContext, outputResult, sharedArgs, isYesMode } from '../_utils.js'; +import { EXIT } from '../_exit.js'; import { getVaultKeyChecked, deleteVaultSecretChecked, vaultSecretExists, checkVaultPolicy } from '../../core/vault/index.js'; import type { VaultPolicyGate } from '../../core/vault/index.js'; @@ -71,7 +72,7 @@ const rmCommand = defineCommand({ if (!exists) { - return { success: false, error: `Secret "${args.key}" not found in vault` }; + return { success: false, notFound: true, error: `Secret "${args.key}" not found in vault` }; } @@ -90,13 +91,13 @@ const rmCommand = defineCommand({ if (err) { - process.exit(1); + process.exit(EXIT.FAILURE); } if (args.json) { - process.stdout.write(JSON.stringify(result) + '\n'); + outputResult(args, result, ''); } else if (result?.success) { @@ -110,7 +111,11 @@ const rmCommand = defineCommand({ } - process.exit(result?.success ? 0 : 1); + // Deleting a key that was never in the vault named a target that does + // not exist, which the contract separates from a delete that failed. + if (result?.success) process.exit(EXIT.SUCCESS); + + process.exit(result?.notFound ? EXIT.USAGE : EXIT.FAILURE); }, }); diff --git a/tests/cli/change-edit.test.ts b/tests/cli/change-edit.test.ts index 410a103f..6474568f 100644 --- a/tests/cli/change-edit.test.ts +++ b/tests/cli/change-edit.test.ts @@ -41,14 +41,14 @@ describe('cli: noorm change edit', () => { }); - it('exits 1 when change does not exist', () => { + it('exits 2 when change does not exist', () => { const result = spawnSync('node', [CLI, 'change', 'edit', 'nope'], { cwd: tmpDir, encoding: 'utf-8', }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('Change not found'); }); diff --git a/tests/cli/ci/init.test.ts b/tests/cli/ci/init.test.ts index 9f8de11b..bde98893 100644 --- a/tests/cli/ci/init.test.ts +++ b/tests/cli/ci/init.test.ts @@ -153,7 +153,7 @@ describe('cli: noorm ci init', () => { const result = runInit(tmpDir, validConnectionEnv()); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('NOORM_IDENTITY_PRIVATE_KEY'); }); @@ -164,7 +164,7 @@ describe('cli: noorm ci init', () => { const result = runInit(tmpDir, { ...validIdentityEnv(), ...rest }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('NOORM_CONNECTION_DIALECT'); }); diff --git a/tests/cli/ci/secrets.test.ts b/tests/cli/ci/secrets.test.ts index 488234b5..7e1c5a24 100644 --- a/tests/cli/ci/secrets.test.ts +++ b/tests/cli/ci/secrets.test.ts @@ -182,7 +182,7 @@ describe('cli: noorm ci secrets', () => { }); - it('exits 1 on malformed line', () => { + it('exits 2 on malformed line', () => { const { result: init, identityEnv } = initCi(tmpDir); expect(init.status).toBe(0); @@ -191,12 +191,12 @@ describe('cli: noorm ci secrets', () => { const result = runSecrets(tmpDir, identityEnv, secretsFile, ['--json']); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stdout + result.stderr).toContain('Parse error'); }); - it('exits 1 when state.enc does not exist', () => { + it('exits 2 when state.enc does not exist', () => { writeFileSync(secretsFile, 'FOO=one\n'); @@ -208,7 +208,7 @@ describe('cli: noorm ci secrets', () => { const result = runSecrets(tmpDir, env, secretsFile, ['--json']); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stdout + result.stderr).toContain('ci init'); }); diff --git a/tests/cli/config/import.test.ts b/tests/cli/config/import.test.ts index ba22afc6..1971d979 100644 --- a/tests/cli/config/import.test.ts +++ b/tests/cli/config/import.test.ts @@ -161,7 +161,7 @@ describe('cli: noorm config import — legacy protected mapping', () => { const result = runImport(path); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stdout + result.stderr).toContain('Error'); }); diff --git a/tests/cli/config/rm.test.ts b/tests/cli/config/rm.test.ts index 5f2b8133..a903052b 100644 --- a/tests/cli/config/rm.test.ts +++ b/tests/cli/config/rm.test.ts @@ -159,11 +159,11 @@ describe('cli: noorm config delete command -- access policy gate', () => { }); - it('exits 1 with a clear message for an unknown config name, mutating nothing', async () => { + it('exits 2 with a clear message for an unknown config name, mutating nothing', async () => { const result = runDelete(['does-not-exist', '--yes']); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stdout + result.stderr; expect(out).toContain('"does-not-exist" not found'); diff --git a/tests/cli/init.test.ts b/tests/cli/init.test.ts index 6e58e305..279e9f66 100644 --- a/tests/cli/init.test.ts +++ b/tests/cli/init.test.ts @@ -33,7 +33,7 @@ describe('cli: noorm init', () => { encoding: 'utf-8', }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('interactive terminal'); }); @@ -84,7 +84,7 @@ describe('cli: noorm init', () => { input: '', encoding: 'utf-8', }); - expect(withHere.status).toBe(1); + expect(withHere.status).toBe(2); expect(withHere.stderr + withHere.stdout).toContain('interactive terminal'); expect(withHere.stderr + withHere.stdout).not.toContain('already initialized'); @@ -102,7 +102,7 @@ describe('cli: noorm init', () => { encoding: 'utf-8', }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('interactive terminal'); expect(result.stderr + result.stdout).not.toContain('already initialized'); @@ -116,7 +116,7 @@ describe('cli: noorm init', () => { encoding: 'utf-8', }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('not a directory'); }); diff --git a/tests/cli/run/change-rewind.test.ts b/tests/cli/run/change-rewind.test.ts index 343cf1ed..a7d1c1b4 100644 --- a/tests/cli/run/change-rewind.test.ts +++ b/tests/cli/run/change-rewind.test.ts @@ -59,7 +59,7 @@ describe('cli: noorm change rewind — exit code on partial failure', () => { }); - it('should exit 2 and log the failure when a rewind partially fails', async () => { + it('should exit 3 and log the failure when a rewind partially fails', async () => { // Later-applied change reverts cleanly; earlier-applied change's // revert SQL errors. Rewind reverts most-recent-first, so the good @@ -84,7 +84,7 @@ describe('cli: noorm change rewind — exit code on partial failure', () => { const result = runCli(project, ['change', 'rewind', '2025-01-01-first']); const out = result.stdout + result.stderr; - expect(result.status).toBe(2); + expect(result.status).toBe(3); expect(out.toLowerCase()).toContain('failed'); }); diff --git a/tests/cli/settings-edit.test.ts b/tests/cli/settings-edit.test.ts index 146dfde0..40f9755e 100644 --- a/tests/cli/settings-edit.test.ts +++ b/tests/cli/settings-edit.test.ts @@ -36,7 +36,7 @@ describe('cli: noorm settings edit', () => { encoding: 'utf-8', }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('interactive terminal'); }); @@ -49,7 +49,7 @@ describe('cli: noorm settings edit', () => { encoding: 'utf-8', }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); }); diff --git a/tests/cli/settings-secret.test.ts b/tests/cli/settings-secret.test.ts index bdcdf788..a62fd01c 100644 --- a/tests/cli/settings-secret.test.ts +++ b/tests/cli/settings-secret.test.ts @@ -33,7 +33,7 @@ describe('cli: noorm settings secret', () => { encoding: 'utf-8', }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('interactive terminal'); }); @@ -46,7 +46,7 @@ describe('cli: noorm settings secret', () => { encoding: 'utf-8', }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); }); diff --git a/tests/cli/sql-repl.test.ts b/tests/cli/sql-repl.test.ts index f19c41ce..bf2e4c4b 100644 --- a/tests/cli/sql-repl.test.ts +++ b/tests/cli/sql-repl.test.ts @@ -13,7 +13,7 @@ describe('cli: noorm sql repl', () => { encoding: 'utf-8', }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('interactive terminal'); }); @@ -25,7 +25,7 @@ describe('cli: noorm sql repl', () => { encoding: 'utf-8', }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); }); diff --git a/tests/cli/yes-flag.test.ts b/tests/cli/yes-flag.test.ts index 87119f3c..745c5aa2 100644 --- a/tests/cli/yes-flag.test.ts +++ b/tests/cli/yes-flag.test.ts @@ -143,7 +143,7 @@ describe('cli: noorm sql repl --yes / NOORM_YES', () => { env: { ...process.env, NOORM_YES: '' }, }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stderr + result.stdout; @@ -160,7 +160,7 @@ describe('cli: noorm sql repl --yes / NOORM_YES', () => { env: { ...process.env, NOORM_YES: '1' }, }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stderr + result.stdout; @@ -176,7 +176,7 @@ describe('cli: noorm sql repl --yes / NOORM_YES', () => { env: { ...process.env, NOORM_YES: '' }, }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('interactive terminal'); }); @@ -189,7 +189,7 @@ describe('cli: noorm sql repl --yes / NOORM_YES', () => { env: { ...process.env, NOORM_YES: '0' }, }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stderr + result.stdout; @@ -230,7 +230,7 @@ describe('cli: noorm settings edit --yes / NOORM_YES', () => { env: { ...process.env, NOORM_YES: '' }, }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stderr + result.stdout; @@ -248,7 +248,7 @@ describe('cli: noorm settings edit --yes / NOORM_YES', () => { env: { ...process.env, NOORM_YES: '1' }, }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('interactive only'); }); @@ -262,7 +262,7 @@ describe('cli: noorm settings edit --yes / NOORM_YES', () => { env: { ...process.env, NOORM_YES: '0' }, }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stderr + result.stdout; @@ -303,7 +303,7 @@ describe('cli: noorm settings secret --yes / NOORM_YES', () => { env: { ...process.env, NOORM_YES: '' }, }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stderr + result.stdout; @@ -321,7 +321,7 @@ describe('cli: noorm settings secret --yes / NOORM_YES', () => { env: { ...process.env, NOORM_YES: '1' }, }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stderr + result.stdout; @@ -339,7 +339,7 @@ describe('cli: noorm settings secret --yes / NOORM_YES', () => { env: { ...process.env, NOORM_YES: '0' }, }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stderr + result.stdout; @@ -393,7 +393,7 @@ describe('cli: noorm init --yes / NOORM_YES', () => { const result = runInit(['--yes']); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stderr + result.stdout; @@ -407,7 +407,7 @@ describe('cli: noorm init --yes / NOORM_YES', () => { const result = runInit([], { NOORM_YES: '1' }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stderr + result.stdout).toContain('requires an existing identity'); }); @@ -416,7 +416,7 @@ describe('cli: noorm init --yes / NOORM_YES', () => { const result = runInit([]); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stderr + result.stdout; @@ -429,7 +429,7 @@ describe('cli: noorm init --yes / NOORM_YES', () => { const result = runInit([], { NOORM_YES: '0' }); - expect(result.status).toBe(1); + expect(result.status).toBe(2); const out = result.stderr + result.stdout; From 370fd170e0373634384d231021fd01ba37a11525 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 15:34:47 -0400 Subject: [PATCH 098/105] feat(cli)!: pin one --json success envelope across every command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- src/cli/_utils.ts | 53 +++++++- src/cli/change/history.ts | 2 +- src/cli/change/list.ts | 2 +- src/cli/config/export.ts | 27 +++- src/cli/db/explore-fks.ts | 2 +- src/cli/db/explore-indexes.ts | 2 +- src/cli/db/explore-tables.ts | 2 +- src/cli/update.ts | 41 ++++-- src/cli/vault/cp.ts | 41 +++--- src/cli/vault/init.ts | 4 +- src/cli/vault/list.ts | 4 +- src/cli/vault/set.ts | 4 +- tests/cli/change/history.test.ts | 7 +- tests/cli/change/list.test.ts | 7 +- tests/cli/global-flags.test.ts | 4 +- tests/cli/json-envelope.test.ts | 216 +++++++++++++++++++++++++++++++ tests/cli/lock/status.test.ts | 2 +- tests/integration/cli/db.test.ts | 25 +++- 18 files changed, 372 insertions(+), 73 deletions(-) create mode 100644 tests/cli/json-envelope.test.ts diff --git a/src/cli/_utils.ts b/src/cli/_utils.ts index c9f040d7..9cb71d7a 100644 --- a/src/cli/_utils.ts +++ b/src/cli/_utils.ts @@ -18,6 +18,7 @@ import { loadPrivateKey, loadIdentityMetadata } from '../core/identity/storage.j import { registerIdentity } from '../core/identity/sync.js'; import { isDev, isEnvTruthy } from '../core/environment.js'; import { getConfig } from '../core/config/index.js'; +import { isSuccessStatus } from './_exit.js'; /** * Minimal args shape expected by the helpers. @@ -279,7 +280,10 @@ export async function withVaultContext(opts: { } - const [ctx, ctxError] = await attempt(() => createContext({ config: args.config })); + // `yes` matches withContext deliberately: without it a vault command can + // never satisfy a `confirm`-tier gate, so `--yes` / NOORM_YES would be + // silently inert in CI the moment a vault permission gains that tier. + const [ctx, ctxError] = await attempt(() => createContext({ config: args.config, yes: isYesMode(args) })); if (ctxError) { outputError(args, `Failed to create context: ${ctxError.message}`, logger); @@ -346,6 +350,42 @@ export async function withVaultContext(opts: { } +/** + * Normalize a command's `--json` payload into the shared envelope. + * + * The envelope contract: every `--json` payload is a JSON object carrying a + * top-level boolean `success`, never a bare array, so `jq -e '.success'` + * works against every command. Command-specific fields sit alongside it. + * + * `success` is derived rather than assumed. A payload carrying a core + * `status` string wins — `{ status: 'partial' }` must never be announced as + * `success: true`, which is precisely the class of defect this envelope + * exists to make impossible. A payload that already states `success` + * explicitly is left alone. + * + * The array branch is a safety net, not the intended path: list commands + * name their collection (`configs`, `changes`, `tables`) at the call site. + * It exists so a bare array can never reach stdout unenveloped. + */ +function toJsonEnvelope(payload: unknown): Record { + + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + + return { success: true, data: payload }; + + } + + const obj = payload as Record; + + if (typeof obj['success'] === 'boolean') return obj; + + const status = obj['status']; + const success = typeof status === 'string' ? isSuccessStatus(status) : true; + + return { success, ...obj }; + +} + /** * Output a success result as either JSON or text. * @@ -355,6 +395,9 @@ export async function withVaultContext(opts: { * through `logger.result()` when a logger is available (stdout + log * file); plain text writes directly to stdout, never through * `logger.info`, so it can't be pulled onto the diagnostics stream. + * + * JSON payloads pass through `toJsonEnvelope` so the `success` flag is + * applied in one place instead of at ~70 call sites that would drift. */ export function outputResult( args: CliArgs, @@ -365,14 +408,16 @@ export function outputResult( if (args.json) { + const payload = toJsonEnvelope(json); + if (logger) { - logger.result(json); + logger.result(payload); } else { - process.stdout.write(JSON.stringify(json) + '\n'); + process.stdout.write(JSON.stringify(payload) + '\n'); } @@ -444,7 +489,7 @@ export function handleVaultResult ` ${record.name} - ${record.status} (${new Date(record.executedAt).toLocaleString()})`), ].join('\n'); - outputResult(args, history, text); + outputResult(args, { history }, text); process.exit(0); diff --git a/src/cli/change/list.ts b/src/cli/change/list.ts index f997e00d..c6d0dba4 100644 --- a/src/cli/change/list.ts +++ b/src/cli/change/list.ts @@ -39,7 +39,7 @@ const listCommand = defineCommand({ ...(pending > 0 ? [`${pending} pending change(s)`] : []), ].join('\n'); - outputResult(args, changes, text); + outputResult(args, { changes, pending }, text); process.exit(0); diff --git a/src/cli/config/export.ts b/src/cli/config/export.ts index 9d114b85..2d5e3216 100644 --- a/src/cli/config/export.ts +++ b/src/cli/config/export.ts @@ -12,7 +12,8 @@ import { defineCommand } from 'citty'; import { checkConfigPolicy } from '../../core/policy/index.js'; import { initState, getStateManager } from '../../core/state/index.js'; -import { outputError, sharedArgs } from '../_utils.js'; +import { outputError, outputResult, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const exportCommand = defineCommand({ meta: { @@ -33,7 +34,7 @@ const exportCommand = defineCommand({ if (initErr) { outputError(args, `Failed to load state: ${initErr.message}`); - process.exit(1); + process.exit(EXIT.FAILURE); } @@ -43,7 +44,7 @@ const exportCommand = defineCommand({ if (!config) { outputError(args, `Config not found: ${args.name}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -55,7 +56,7 @@ const exportCommand = defineCommand({ if (!check.allowed) { outputError(args, check.blockedReason ?? `Config "${args.name}" cannot be exported.`); - process.exit(1); + process.exit(EXIT.FAILURE); } @@ -70,14 +71,26 @@ const exportCommand = defineCommand({ if (writeErr) { outputError(args, `Failed to write file: ${writeErr.message}`); - process.exit(1); + process.exit(EXIT.FAILURE); } // Ensure permissions are correct (writeFile mode may not work on all platforms) await attempt(() => chmod(args.output as string, 0o600)); - process.stdout.write(`Config '${args.name}' exported to ${args.output}\n`); + outputResult( + args, + { name: args.name, output: args.output }, + `Config '${args.name}' exported to ${args.output}`, + ); + + } + else if (args.json) { + + // `--json` gets the envelope like every other command. The bare + // artifact — the shape `config import` reads — stays on the + // default path, so `config export dev > dev.json` is unchanged. + outputResult(args, { name: args.name, config }, ''); } else { @@ -86,7 +99,7 @@ const exportCommand = defineCommand({ } - process.exit(0); + process.exit(EXIT.SUCCESS); }, }); diff --git a/src/cli/db/explore-fks.ts b/src/cli/db/explore-fks.ts index 390a48e7..369998ea 100644 --- a/src/cli/db/explore-fks.ts +++ b/src/cli/db/explore-fks.ts @@ -72,7 +72,7 @@ const fksCommand = defineCommand({ if (args.json) { - outputResult(args, fks, ''); + outputResult(args, { foreignKeys: fks }, ''); } diff --git a/src/cli/db/explore-indexes.ts b/src/cli/db/explore-indexes.ts index 73e6fc7b..9ed7d9e0 100644 --- a/src/cli/db/explore-indexes.ts +++ b/src/cli/db/explore-indexes.ts @@ -69,7 +69,7 @@ const indexesCommand = defineCommand({ if (args.json) { - outputResult(args, indexes, ''); + outputResult(args, { indexes }, ''); } diff --git a/src/cli/db/explore-tables.ts b/src/cli/db/explore-tables.ts index 3c307977..128b4059 100644 --- a/src/cli/db/explore-tables.ts +++ b/src/cli/db/explore-tables.ts @@ -58,7 +58,7 @@ const tablesCommand = defineCommand({ if (args.json) { - outputResult(args, tables, ''); + outputResult(args, { tables }, ''); } diff --git a/src/cli/update.ts b/src/cli/update.ts index e2338792..2aa4582d 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -11,6 +11,7 @@ import { observer } from '../core/observer.js'; import { checkForUpdate, getCurrentVersion } from '../core/update/checker.js'; import { installUpdate } from '../core/update/updater.js'; import { isInsecureMode, outputError, outputResult, sharedArgs } from './_utils.js'; +import { EXIT } from './_exit.js'; /** Render a byte count as MB with one decimal (e.g. 41.2). */ function toMb(bytes: number): string { @@ -44,21 +45,29 @@ const updateCommand = defineCommand({ const errorMsg = checkErr?.message ?? 'Failed to check for updates (offline?)'; - outputError(args, errorMsg); - + // One document, not two: `outputError` already writes the + // `{success:false,error}` envelope under `--json`, so the extra + // `outputResult` put a second, success-shaped object on stdout for + // the same failure. if (args.json) { outputResult(args, { + success: false, + error: errorMsg, currentVersion, latestVersion: null, updateAvailable: false, installed: false, - error: errorMsg, }, ''); + } + else { + + outputError(args, errorMsg); + } - process.exit(1); + process.exit(EXIT.FAILURE); } @@ -77,7 +86,7 @@ const updateCommand = defineCommand({ } - process.exit(0); + process.exit(EXIT.SUCCESS); } @@ -147,21 +156,25 @@ const updateCommand = defineCommand({ // is unexpected (e.g. abort wiring) — surface it rather than hang. const errorMsg = installErr?.message ?? 'Update failed'; - process.stderr.write(`Update failed: ${errorMsg}\n`); - if (args.json) { outputResult(args, { + success: false, + error: errorMsg, currentVersion, latestVersion: checkResult.latestVersion, updateAvailable: true, installed: false, - error: errorMsg, }, ''); + } + else { + + outputError(args, `Update failed: ${errorMsg}`); + } - process.exit(1); + process.exit(EXIT.FAILURE); } @@ -170,15 +183,19 @@ const updateCommand = defineCommand({ process.stdout.write(`Updated to ${result.newVersion}. Restart noorm to use the new version.\n`); } - else { + else if (!args.json) { - process.stderr.write(`Update failed: ${result.error}\n`); + outputError(args, `Update failed: ${result.error}`); } if (args.json) { + // `success` is stated rather than inferred: the payload carries no + // `status`, so without it a failed install reported success:true + // while the process exited 1. outputResult(args, { + success: result.success, currentVersion, latestVersion: checkResult.latestVersion, updateAvailable: true, @@ -188,7 +205,7 @@ const updateCommand = defineCommand({ } - process.exit(result.success ? 0 : 1); + process.exit(result.success ? EXIT.SUCCESS : EXIT.FAILURE); }, }); diff --git a/src/cli/vault/cp.ts b/src/cli/vault/cp.ts index 1a8ae83d..12a01121 100644 --- a/src/cli/vault/cp.ts +++ b/src/cli/vault/cp.ts @@ -8,10 +8,11 @@ import { defineCommand } from 'citty'; import { attempt } from '@logosdx/utils'; -import { sharedArgs } from '../_utils.js'; +import { outputResult, outputError, sharedArgs } from '../_utils.js'; import { copyVaultSecrets } from '../../core/vault/index.js'; import { loadPrivateKey, loadIdentityMetadata } from '../../core/identity/storage.js'; import { getStateManager } from '../../core/state/index.js'; +import { EXIT } from '../_exit.js'; const cpCommand = defineCommand({ meta: { @@ -37,8 +38,8 @@ const cpCommand = defineCommand({ if (identityErr || !cryptoIdentity) { - process.stderr.write('Error: Identity not set up. Run: noorm identity init\n'); - process.exit(1); + outputError(args, 'Identity not set up. Run: noorm identity init'); + process.exit(EXIT.FAILURE); } @@ -46,8 +47,8 @@ const cpCommand = defineCommand({ if (keyErr || !privateKey) { - process.stderr.write('Error: Private key not found. Run: noorm identity init\n'); - process.exit(1); + outputError(args, 'Private key not found. Run: noorm identity init'); + process.exit(EXIT.FAILURE); } @@ -56,8 +57,8 @@ const cpCommand = defineCommand({ if (loadErr) { - process.stderr.write(`Error: ${loadErr.message}\n`); - process.exit(1); + outputError(args, loadErr.message); + process.exit(EXIT.FAILURE); } @@ -66,15 +67,15 @@ const cpCommand = defineCommand({ if (!sourceConfig) { - process.stderr.write(`Error: Source config not found: ${sourceConfigName}\n`); - process.exit(1); + outputError(args, `Source config not found: ${sourceConfigName}`); + process.exit(EXIT.USAGE); } if (!destConfig) { - process.stderr.write(`Error: Destination config not found: ${destConfigName}\n`); - process.exit(1); + outputError(args, `Destination config not found: ${destConfigName}`); + process.exit(EXIT.USAGE); } @@ -94,18 +95,8 @@ const cpCommand = defineCommand({ if (copyErr) { - if (args.json) { - - process.stdout.write(JSON.stringify({ success: false, error: copyErr.message }) + '\n'); - - } - else { - - process.stderr.write(`Error: ${copyErr.message}\n`); - - } - - process.exit(1); + outputError(args, copyErr.message); + process.exit(EXIT.FAILURE); } @@ -116,13 +107,13 @@ const cpCommand = defineCommand({ if (args.json) { - process.stdout.write(JSON.stringify({ + outputResult(args, { success: succeeded, dryRun: !!args.dryRun, copied: result?.copied ?? [], skipped: result?.skipped ?? [], errors: result?.errors ?? [], - }) + '\n'); + }, ''); } else { diff --git a/src/cli/vault/init.ts b/src/cli/vault/init.ts index dad43c32..d792d005 100644 --- a/src/cli/vault/init.ts +++ b/src/cli/vault/init.ts @@ -3,7 +3,7 @@ */ import { defineCommand } from 'citty'; -import { withVaultContext, sharedArgs, isYesMode } from '../_utils.js'; +import { withVaultContext, outputResult, sharedArgs, isYesMode } from '../_utils.js'; import { initializeVaultChecked, getVaultStatus, checkVaultPolicy } from '../../core/vault/index.js'; import type { VaultPolicyGate } from '../../core/vault/index.js'; @@ -96,7 +96,7 @@ const initCommand = defineCommand({ if (args.json) { - process.stdout.write(JSON.stringify(result) + '\n'); + outputResult(args, result, ''); } else if (result?.success) { diff --git a/src/cli/vault/list.ts b/src/cli/vault/list.ts index 80e3fd59..a2152110 100644 --- a/src/cli/vault/list.ts +++ b/src/cli/vault/list.ts @@ -3,7 +3,7 @@ */ import { defineCommand } from 'citty'; -import { withVaultContext, sharedArgs } from '../_utils.js'; +import { withVaultContext, outputResult, sharedArgs } from '../_utils.js'; import { getVaultKeyChecked, getAllVaultSecrets, getVaultStatus, checkVaultPolicy } from '../../core/vault/index.js'; import type { VaultPolicyGate } from '../../core/vault/index.js'; @@ -96,7 +96,7 @@ const listCommand = defineCommand({ if (args.json) { - process.stdout.write(JSON.stringify(result) + '\n'); + outputResult(args, result, ''); } else if (result?.success) { diff --git a/src/cli/vault/set.ts b/src/cli/vault/set.ts index a3c91530..feff3e21 100644 --- a/src/cli/vault/set.ts +++ b/src/cli/vault/set.ts @@ -3,7 +3,7 @@ */ import { defineCommand } from 'citty'; -import { withVaultContext, sharedArgs, isYesMode } from '../_utils.js'; +import { withVaultContext, outputResult, sharedArgs, isYesMode } from '../_utils.js'; import { readSecretValue } from './_secret-value.js'; import { getVaultKeyChecked, setVaultSecretChecked, checkVaultPolicy } from '../../core/vault/index.js'; import type { VaultPolicyGate } from '../../core/vault/index.js'; @@ -105,7 +105,7 @@ const setCommand = defineCommand({ if (args.json) { - process.stdout.write(JSON.stringify(result) + '\n'); + outputResult(args, result, ''); } else if (result?.success) { diff --git a/tests/cli/change/history.test.ts b/tests/cli/change/history.test.ts index f93b8c93..0a34598c 100644 --- a/tests/cli/change/history.test.ts +++ b/tests/cli/change/history.test.ts @@ -128,9 +128,12 @@ describe('cli: noorm change history — output streams', () => { expect(result.status).toBe(0); + // The envelope, not a bare array: `--json` is a contract, and a + // top-level array leaves a consumer nowhere to read `success` from. const parsed = JSON.parse(result.stdout); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toEqual([]); + expect(parsed.success).toBe(true); + expect(Array.isArray(parsed.history)).toBe(true); + expect(parsed.history).toEqual([]); }); diff --git a/tests/cli/change/list.test.ts b/tests/cli/change/list.test.ts index b9745f2f..4e3ac596 100644 --- a/tests/cli/change/list.test.ts +++ b/tests/cli/change/list.test.ts @@ -136,9 +136,12 @@ describe('cli: noorm change list — output streams', () => { expect(result.status).toBe(0); + // The envelope, not a bare array: `--json` is a contract, and a + // top-level array leaves a consumer nowhere to read `success` from. const parsed = JSON.parse(result.stdout); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toEqual([]); + expect(parsed.success).toBe(true); + expect(Array.isArray(parsed.changes)).toBe(true); + expect(parsed.changes).toEqual([]); }); diff --git a/tests/cli/global-flags.test.ts b/tests/cli/global-flags.test.ts index bff6b04f..3b26fd4c 100644 --- a/tests/cli/global-flags.test.ts +++ b/tests/cli/global-flags.test.ts @@ -72,7 +72,7 @@ describe('cli: root-level flag placement', () => { const result = runCli(project, ['config', 'list', '--json']); expect(result.status).toBe(0); - expect(JSON.parse(result.stdout.trim())).toEqual({ configs: [] }); + expect(JSON.parse(result.stdout.trim())).toEqual({ success: true, configs: [] }); }); @@ -181,7 +181,7 @@ describe('cli: root-level flag placement', () => { }); expect(result.status).toBe(0); - expect(JSON.parse((result.stdout ?? '').trim())).toEqual({ configs: [] }); + expect(JSON.parse((result.stdout ?? '').trim())).toEqual({ success: true, configs: [] }); }); diff --git a/tests/cli/json-envelope.test.ts b/tests/cli/json-envelope.test.ts new file mode 100644 index 00000000..7bbf1a80 --- /dev/null +++ b/tests/cli/json-envelope.test.ts @@ -0,0 +1,216 @@ +/** + * cli: the `--json` envelope contract. + * + * WHY: the headless audit measured four mutually incompatible success shapes + * across the 72 `--json` commands — `{version,…}`, `{configs}`, a bare array + * from `change list`/`change history`/every `db explore` list, and + * `{success,path}` from `settings build`. Only the *error* payload was + * uniform. A CI consumer therefore had no single success check: `jq -e + * '.success'` was null-or-absent on most successes and errored outright on + * the array-returning commands, leaving the exit code as the only + * discriminator — and that was inconsistent too. + * + * The contract these tests pin: + * 1. every `--json` payload is a JSON *object*, never a bare array; + * 2. it carries a top-level boolean `success`; + * 3. `success` and the exit code agree — exit 0 iff `success === true`. + * + * Rule 3 is the one that matters most: it is what makes "reported success + * while doing nothing" impossible to express in the envelope. + * + * Driven as subprocesses against the compiled CLI because every command + * calls `process.exit`, which would kill an in-process runner. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + CLI, + TMP_BASE, + cleanupProject, + setupProject, + type TestProject, +} from './run/_setup.js'; + +/** + * Every `--json` command reachable from the SQLite env-only harness. + * + * Deliberately mixes successes with failures: the contract is about the + * envelope holding on *both* sides, and a suite that only sampled happy + * paths is how the four incompatible shapes survived to begin with. + */ +const JSON_COMMANDS: string[][] = [ + ['version'], + ['info'], + ['config', 'list'], + ['change', 'list'], + ['change', 'history'], + ['lock', 'status'], + ['lock', 'acquire'], + ['lock', 'release'], + ['identity', 'list'], + ['db', 'explore'], + ['db', 'explore', 'tables'], + ['db', 'explore', 'views'], + ['db', 'explore', 'indexes'], + ['db', 'explore', 'fks'], + ['db', 'explore', 'functions'], + ['db', 'explore', 'procedures'], + ['db', 'explore', 'types'], + ['run', 'build'], + ['settings', 'build'], + // Failure paths — the envelope has to survive these unchanged. + ['secret', 'list'], + ['config', 'validate', 'does-not-exist'], + ['run', 'inspect', 'sql/nope.sql.tmpl'], + ['run', 'preview', 'sql/nope.sql.tmpl'], + ['run', 'dir', 'does-not-exist'], +]; + +interface Envelope { + success?: unknown; + error?: unknown; + [key: string]: unknown; +} + +/** Parse the last complete JSON document on stdout — logger lines may precede it. */ +function parseEnvelope(stdout: string): Envelope | null { + + const start = stdout.indexOf('{'); + + if (start < 0) return null; + + let depth = 0; + let inString = false; + let escaped = false; + + for (let i = start; i < stdout.length; i++) { + + const ch = stdout[i]!; + + if (escaped) { + + escaped = false; + continue; + + } + + if (ch === '\\') { + + escaped = true; + continue; + + } + + if (ch === '"') { + + inString = !inString; + continue; + + } + + if (inString) continue; + + if (ch === '{') depth++; + else if (ch === '}') { + + depth--; + + if (depth === 0) return JSON.parse(stdout.slice(start, i + 1)) as Envelope; + + } + + } + + return null; + +} + +describe('cli: --json envelope contract', () => { + + let project: TestProject; + + beforeAll(async () => { + + await mkdir(TMP_BASE, { recursive: true }); + + }); + + beforeEach(async () => { + + project = await setupProject(); + await writeFile(join(project.dir, 'sql', '01_ok.sql'), 'CREATE TABLE envelope_t (id INTEGER PRIMARY KEY);\n', 'utf-8'); + + }); + + afterEach(async () => { + + await cleanupProject(project); + + }); + + for (const command of JSON_COMMANDS) { + + const label = command.join(' '); + + it(`should emit an object carrying a boolean success for: ${label} --json`, () => { + + const result = spawnSync('node', [CLI, ...command, '--json'], { + cwd: project.dir, + encoding: 'utf-8', + env: { ...process.env, ...project.env }, + }); + + const stdout = typeof result.stdout === 'string' ? result.stdout : ''; + + // A bare array is the shape this contract exists to eliminate: + // it has nowhere to put `success`, so a consumer parsing it can + // only guess whether the command did anything. + expect(stdout.trimStart().startsWith('[')).toBe(false); + + const payload = parseEnvelope(stdout); + + expect(payload).not.toBeNull(); + expect(typeof payload!.success).toBe('boolean'); + + }); + + it(`should agree between success and exit code for: ${label} --json`, () => { + + const result = spawnSync('node', [CLI, ...command, '--json'], { + cwd: project.dir, + encoding: 'utf-8', + env: { ...process.env, ...project.env }, + }); + + const payload = parseEnvelope(typeof result.stdout === 'string' ? result.stdout : ''); + + expect(payload).not.toBeNull(); + + // The invariant that makes the envelope trustworthy: a command + // cannot claim success in JSON while signalling failure to the + // shell, or the reverse. + expect(payload!.success).toBe(result.status === 0); + + }); + + } + + it('should carry an error string on every unsuccessful envelope', () => { + + const result = spawnSync('node', [CLI, 'run', 'inspect', 'sql/nope.sql.tmpl', '--json'], { + cwd: project.dir, + encoding: 'utf-8', + env: { ...process.env, ...project.env }, + }); + + const payload = parseEnvelope(typeof result.stdout === 'string' ? result.stdout : ''); + + expect(payload!.success).toBe(false); + expect(typeof payload!.error).toBe('string'); + + }); + +}); diff --git a/tests/cli/lock/status.test.ts b/tests/cli/lock/status.test.ts index 8d620686..b3a05f6e 100644 --- a/tests/cli/lock/status.test.ts +++ b/tests/cli/lock/status.test.ts @@ -123,7 +123,7 @@ describe('cli: noorm lock status — output streams', () => { expect(result.status).toBe(0); const parsed = JSON.parse(result.stdout); - expect(parsed).toEqual({ isLocked: false, lock: null }); + expect(parsed).toEqual({ success: true, isLocked: false, lock: null }); }); diff --git a/tests/integration/cli/db.test.ts b/tests/integration/cli/db.test.ts index 478bbb57..62c84ab2 100644 --- a/tests/integration/cli/db.test.ts +++ b/tests/integration/cli/db.test.ts @@ -32,6 +32,12 @@ interface TableSummary { schema?: string; } +/** The `db explore tables --json` envelope. */ +interface TablesPayload { + success: boolean; + tables: TableSummary[]; +} + interface TableDetail { name: string; schema?: string; @@ -177,26 +183,29 @@ describe('cli: db explore tables', () => { it('should return valid JSON with --json flag', async () => { - const result = await noormJson(project, 'db', 'explore', 'tables'); + // Enveloped, not a bare array: `--json` carries a top-level `success` + // on every command, and the list lives under a named key. + const result = await noormJson(project, 'db', 'explore', 'tables'); expect(result.ok).toBe(true); - expect(Array.isArray(result.data)).toBe(true); + expect(result.data!.success).toBe(true); + expect(Array.isArray(result.data!.tables)).toBe(true); }); it('should list all tables with column counts', async () => { - const result = await noormJson(project, 'db', 'explore', 'tables'); + const result = await noormJson(project, 'db', 'explore', 'tables'); expect(result.ok).toBe(true); - const tableNames = result.data!.map((t) => t.name); + const tableNames = result.data!.tables.map((t) => t.name); expect(tableNames).toContain('users'); expect(tableNames).toContain('todo_lists'); expect(tableNames).toContain('todo_items'); // Each table should have a column count - for (const table of result.data!) { + for (const table of result.data!.tables) { expect(typeof table.columnCount).toBe('number'); expect(table.columnCount).toBeGreaterThan(0); @@ -261,12 +270,14 @@ describe('cli: db explore tables detail', () => { }); - it('should fail with exit code 1 on missing table', async () => { + it('should fail with the usage exit code on a missing table', async () => { const result = await noorm(project, 'db', 'explore', 'tables', 'detail', 'nonexistent'); + // 2, not 1: the caller named a table that does not exist, so nothing + // was attempted — distinct from a query that ran and failed. expect(result.ok).toBe(false); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); }); From 2d78c126260bea1a352669b7d20d4230c664893a Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 15:34:57 -0400 Subject: [PATCH 099/105] fix(cli): stop reporting success for targets that were never there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/cli/db/transfer.ts | 24 ++-- src/cli/run/dir.ts | 42 +++++- src/cli/run/inspect.ts | 15 ++ src/cli/run/preview.ts | 22 ++- tests/cli/db/transfer.test.ts | 6 +- tests/cli/exit-codes.test.ts | 250 ++++++++++++++++++++++++++++++++++ 6 files changed, 337 insertions(+), 22 deletions(-) create mode 100644 tests/cli/exit-codes.test.ts diff --git a/src/cli/db/transfer.ts b/src/cli/db/transfer.ts index 34270053..00396cb4 100644 --- a/src/cli/db/transfer.ts +++ b/src/cli/db/transfer.ts @@ -12,6 +12,7 @@ import { MIN_PASSPHRASE_LENGTH } from '../../core/dt/crypto.js'; import { getStateManager } from '../../core/state/index.js'; import type { TransferOptions, ConflictStrategy } from '../../core/transfer/index.js'; import { withContext, outputResult, outputError, sharedArgs, type CliArgs } from '../_utils.js'; +import { EXIT, exitCodeForStatus } from '../_exit.js'; // --------------------------------------------------------------------------- // Shared args type for this command @@ -66,7 +67,7 @@ async function resolveExportPassphrase(passphrase: string | undefined, args: Tra if (passphrase.length < MIN_PASSPHRASE_LENGTH) { outputError(args, `Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -77,7 +78,7 @@ async function resolveExportPassphrase(passphrase: string | undefined, args: Tra if (!process.stdin.isTTY || args.json) { outputError(args, '--passphrase required for .dtzx encrypted export (non-interactive session; pass --passphrase or run interactively for a masked prompt)'); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -116,7 +117,7 @@ async function resolveImportPassphrase(passphrase: string | undefined, args: Tra if (!process.stdin.isTTY || args.json) { outputError(args, '--passphrase required for .dtzx encrypted import (non-interactive session; pass --passphrase or run interactively for a masked prompt)'); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -143,7 +144,6 @@ const transferCommand = defineCommand({ }, args: { config: sharedArgs.config, - force: sharedArgs.force, dryRun: sharedArgs.dryRun, json: sharedArgs.json, to: { @@ -209,7 +209,7 @@ const transferCommand = defineCommand({ if (compress && !exportPath) { outputError(args, '--compress is only valid with --export'); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -218,14 +218,14 @@ const transferCommand = defineCommand({ if (modeCount > 1) { outputError(args, 'Flags --to, --export, and --import are mutually exclusive'); - process.exit(1); + process.exit(EXIT.USAGE); } if (modeCount === 0) { outputError(args, 'One of --to, --export, or --import is required. Usage: noorm db transfer --to '); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -247,7 +247,7 @@ const transferCommand = defineCommand({ if (!isConflictStrategy(rawConflict)) { outputError(args, `Invalid --on-conflict value: "${rawConflict}". Must be one of: fail, skip, update, replace`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -301,7 +301,7 @@ const transferCommand = defineCommand({ if (!destConfig) { outputError(args, `Destination config not found: ${destConfigName}`); - process.exit(1); + process.exit(EXIT.USAGE); } @@ -456,11 +456,9 @@ const transferCommand = defineCommand({ } // A partial transfer committed real rows and needs a different - // response than one that moved nothing — exit 2 for both left a + // response than one that moved nothing — one code for both left a // pipeline unable to tell "retry" from "roll back". - if (result?.status === 'success') process.exit(0); - - process.exit(result?.status === 'partial' ? 3 : 2); + process.exit(exitCodeForStatus(result?.status)); }, }); diff --git a/src/cli/run/dir.ts b/src/cli/run/dir.ts index f4afadcc..26296741 100644 --- a/src/cli/run/dir.ts +++ b/src/cli/run/dir.ts @@ -1,9 +1,14 @@ /** * noorm run dir — execute all SQL files in a directory. */ +import { stat } from 'node:fs/promises'; +import { isAbsolute, join } from 'node:path'; + import { defineCommand } from 'citty'; +import { attempt } from '@logosdx/utils'; -import { withContext, outputResult, sharedArgs } from '../_utils.js'; +import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT, exitCodeForStatus } from '../_exit.js'; const dirCommand = defineCommand({ meta: { @@ -23,6 +28,21 @@ const dirCommand = defineCommand({ }, async run({ args }) { + const cwd = process.cwd(); + const dirpath = isAbsolute(args.path) ? args.path : join(cwd, args.path); + + // Core turns a missing directory into a generic failed batch, which + // read as "the SQL failed" rather than "you named a directory that + // isn't there". Checked here so the message and the code both say so. + const [stats] = await attempt(() => stat(dirpath)); + + if (!stats?.isDirectory()) { + + outputError(args, `Directory not found: ${args.path}`); + process.exit(EXIT.USAGE); + + } + const [result, error] = await withContext({ args, fn: (ctx, logger) => { @@ -73,15 +93,29 @@ const dirCommand = defineCommand({ }, }); - if (error) process.exit(1); + if (error) process.exit(EXIT.FAILURE); + + // A directory with no SQL files in it is a no-op, and core reports a + // no-op as `status: 'success'`. Announcing success over zero files is + // how a mistyped path silently passes a pipeline. + const nothingDiscovered = result.files.length === 0; if (args.json) { - outputResult(args, result, ''); + outputResult( + args, + nothingDiscovered ? { ...result, success: false, error: `No SQL files found in: ${args.path}` } : result, + '', + ); + + } + else if (nothingDiscovered) { + + outputError(args, `No SQL files found in: ${args.path}`); } - process.exit(result.status === 'success' ? 0 : 2); + process.exit(nothingDiscovered ? EXIT.USAGE : exitCodeForStatus(result.status)); }, }); diff --git a/src/cli/run/inspect.ts b/src/cli/run/inspect.ts index 780aff8f..88174cc4 100644 --- a/src/cli/run/inspect.ts +++ b/src/cli/run/inspect.ts @@ -10,12 +10,14 @@ * noorm run inspect sql/core/05_Cron/Crons.sql.tmpl --json * ``` */ +import { stat } from 'node:fs/promises'; import { dirname, join, relative } from 'node:path'; import { defineCommand } from 'citty'; import { attempt, attemptSync } from '@logosdx/utils'; import { outputError, outputResult, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; import { buildContext } from '../../core/template/context.js'; import { loadHelpers } from '../../core/template/helpers.js'; import { assertPolicy } from '../../core/policy/index.js'; @@ -78,6 +80,19 @@ const inspectCommand = defineCommand({ const fullPath = join(projectRoot, args.path); const templateDir = dirname(fullPath); + // buildContext only reads the template's *directory* for side-cars, so + // a missing template produced a fully-populated context and exit 0 — + // the command reported on a file that was never there. `run preview` + // fails on the same path because it actually opens the file. + const [stats] = await attempt(() => stat(fullPath)); + + if (!stats?.isFile()) { + + outputError(args, `Template not found: ${args.path}`); + process.exit(EXIT.USAGE); + + } + // Load state for config + secrets const stateManager = getStateManager(projectRoot); const [, loadErr] = await attempt(() => stateManager.load()); diff --git a/src/cli/run/preview.ts b/src/cli/run/preview.ts index ee681e2f..f2a7fd11 100644 --- a/src/cli/run/preview.ts +++ b/src/cli/run/preview.ts @@ -11,12 +11,14 @@ * noorm run preview sql/migrations/002.sql.tmpl > rendered.sql * ``` */ +import { stat } from 'node:fs/promises'; import { join } from 'node:path'; import { defineCommand } from 'citty'; import { attempt, attemptSync } from '@logosdx/utils'; import { outputError, outputResult, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; import { processFile } from '../../core/template/engine.js'; import { assertPolicy } from '../../core/policy/index.js'; import { getStateManager } from '../../core/state/index.js'; @@ -41,6 +43,18 @@ const previewCommand = defineCommand({ const projectRoot = process.cwd(); const fullPath = join(projectRoot, args.path); + // Named up front so a missing template reports "not found" instead of + // an ENOENT surfacing from deep inside the template engine, and lands + // on the same exit code `run inspect` uses for the same mistake. + const [stats] = await attempt(() => stat(fullPath)); + + if (!stats?.isFile()) { + + outputError(args, `Template not found: ${args.path}`); + process.exit(EXIT.USAGE); + + } + // Load state for config + secrets const stateManager = getStateManager(projectRoot); const [, loadErr] = await attempt(() => stateManager.load()); @@ -85,8 +99,12 @@ const previewCommand = defineCommand({ if (err) { - outputError(args, err.stack ?? err.message); - process.exit(1); + // `--json` carries the message only: the stack embeds absolute + // filesystem paths, and the error field is what CI pipelines log + // and surface publicly. The stack still reaches the operator on + // stderr in human mode. + outputError(args, args.json ? err.message : err.stack ?? err.message); + process.exit(EXIT.FAILURE); } diff --git a/tests/cli/db/transfer.test.ts b/tests/cli/db/transfer.test.ts index e8f6ee7a..f145d676 100644 --- a/tests/cli/db/transfer.test.ts +++ b/tests/cli/db/transfer.test.ts @@ -86,7 +86,7 @@ describe('cli: noorm db transfer — passphrase floor', () => { const result = runTransfer(['--export', 'backup.dtzx', '--tables', 'users', '--passphrase', 'x']); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stdout + result.stderr).toContain('12 characters'); }); @@ -95,7 +95,7 @@ describe('cli: noorm db transfer — passphrase floor', () => { const result = runTransfer(['--export', 'backup.dtzx', '--tables', 'users']); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stdout + result.stderr).toContain('--passphrase'); }); @@ -104,7 +104,7 @@ describe('cli: noorm db transfer — passphrase floor', () => { const result = runTransfer(['--export', 'backup.dt', '--tables', 'users', '--on-conflict', 'bogus']); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stdout + result.stderr).toContain('Invalid --on-conflict value: "bogus"'); }); diff --git a/tests/cli/exit-codes.test.ts b/tests/cli/exit-codes.test.ts new file mode 100644 index 00000000..9b87a14c --- /dev/null +++ b/tests/cli/exit-codes.test.ts @@ -0,0 +1,250 @@ +/** + * cli: the exit-code contract, and the no-op-reported-as-success class. + * + * WHY: `2` used to mean partial failure on `run build`, total failure on + * `run dir does-not-exist` (which returned `filesRun: 0` and exit 2), and + * "there was no lock to release" on `lock force`. A pipeline could not tell + * "retry this" from "a human has to look at the half-written database", so + * in practice everyone collapsed to `!= 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. + * + * The second half of this file covers the defect class the audit found nine + * times over: a command handed a nonexistent target, or an empty match set, + * building a success payload without ever checking whether it did anything. + * `run inspect sql/nope.sql.tmpl` returned a fully-populated context object + * and exit 0 for a file that was never on disk. + * + * Driven as subprocesses against the compiled CLI — these commands call + * `process.exit`, and the exit code is the thing under test. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'bun:test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + TMP_BASE, + cleanupProject, + extractJsonObject, + runCli, + setupProject, + type TestProject, +} from './run/_setup.js'; + +/** Exit codes, restated here so a change to `_exit.ts` has to be deliberate. */ +const EXIT_SUCCESS = 0; +const EXIT_FAILURE = 1; +const EXIT_USAGE = 2; +const EXIT_PARTIAL = 3; + +interface Payload { + success?: unknown; + status?: unknown; + error?: unknown; + filesRun?: number; + [key: string]: unknown; +} + +function payloadOf(stdout: string): Payload { + + const json = extractJsonObject(stdout); + + expect(json).not.toBeNull(); + + return JSON.parse(json!) as Payload; + +} + +describe('cli: exit-code contract', () => { + + let project: TestProject; + + beforeAll(async () => { + + await mkdir(TMP_BASE, { recursive: true }); + + }); + + beforeEach(async () => { + + project = await setupProject(); + + }); + + afterEach(async () => { + + await cleanupProject(project); + + }); + + it('should exit PARTIAL when some files ran and some failed', async () => { + + await mkdir(join(project.dir, 'sql', 'mixed'), { recursive: true }); + await writeFile(join(project.dir, 'sql', 'mixed', '01_ok.sql'), 'CREATE TABLE mixed_ok (id INTEGER PRIMARY KEY);\n', 'utf-8'); + await writeFile(join(project.dir, 'sql', 'mixed', '02_bad.sql'), 'SELECT * FROM no_such_table_xyz;\n', 'utf-8'); + + const result = runCli(project, ['run', 'dir', 'sql/mixed', '--json']); + const payload = payloadOf(result.stdout); + + // The distinction the old single code destroyed: one statement was + // committed, so this is not a clean failure a pipeline can retry. + expect(payload.status).toBe('partial'); + expect(result.status).toBe(EXIT_PARTIAL); + expect(payload.success).toBe(false); + + }); + + it('should exit FAILURE, not PARTIAL, when every file failed', async () => { + + await mkdir(join(project.dir, 'sql', 'allbad'), { recursive: true }); + await writeFile(join(project.dir, 'sql', 'allbad', '01_bad.sql'), 'SELECT * FROM no_such_table_xyz;\n', 'utf-8'); + + const result = runCli(project, ['run', 'dir', 'sql/allbad', '--json']); + const payload = payloadOf(result.stdout); + + expect(payload.status).toBe('failed'); + expect(result.status).toBe(EXIT_FAILURE); + + }); + + it('should exit SUCCESS when every file ran', async () => { + + await mkdir(join(project.dir, 'sql', 'allgood'), { recursive: true }); + await writeFile(join(project.dir, 'sql', 'allgood', '01_ok.sql'), 'CREATE TABLE allgood_t (id INTEGER PRIMARY KEY);\n', 'utf-8'); + + const result = runCli(project, ['run', 'dir', 'sql/allgood', '--json']); + const payload = payloadOf(result.stdout); + + expect(result.status).toBe(EXIT_SUCCESS); + expect(payload.success).toBe(true); + + }); + + it('should exit USAGE for a flag combination the command cannot honour', () => { + + const result = runCli(project, ['db', 'transfer', '--to', 'a', '--export', 'b.dt']); + + expect(result.status).toBe(EXIT_USAGE); + expect(result.stdout + result.stderr).toContain('mutually exclusive'); + + }); + +}); + +describe('cli: no-op reported as success', () => { + + let project: TestProject; + + beforeAll(async () => { + + await mkdir(TMP_BASE, { recursive: true }); + + }); + + beforeEach(async () => { + + project = await setupProject(); + + }); + + afterEach(async () => { + + await cleanupProject(project); + + }); + + it('should refuse to inspect a template that does not exist', () => { + + const result = runCli(project, ['run', 'inspect', 'sql/nope.sql.tmpl', '--json']); + const payload = payloadOf(result.stdout); + + // `buildContext` only reads the template's *directory*, so a missing + // file produced a complete, plausible context report and exit 0. + expect(result.status).toBe(EXIT_USAGE); + expect(payload.success).toBe(false); + expect(String(payload.error)).toContain('not found'); + + }); + + it('should refuse to preview a template that does not exist', () => { + + const result = runCli(project, ['run', 'preview', 'sql/nope.sql.tmpl', '--json']); + const payload = payloadOf(result.stdout); + + expect(result.status).toBe(EXIT_USAGE); + expect(payload.success).toBe(false); + + }); + + it('should keep stack traces and absolute paths out of the --json error field', () => { + + const result = runCli(project, ['run', 'preview', 'sql/nope.sql.tmpl', '--json']); + const payload = payloadOf(result.stdout); + + expect(payload.success).toBe(false); + + // This exact invocation used to emit the raw ENOENT stack — + // `at async open (node:internal/fs/promises…)` plus absolute + // node_modules paths — into the machine-readable error field that CI + // logs and surfaces publicly. + expect(String(payload.error)).not.toContain('node_modules'); + expect(String(payload.error)).not.toContain(project.dir); + expect(String(payload.error)).not.toMatch(/\n\s+at /); + + }); + + it('should refuse to run a directory that does not exist', () => { + + const result = runCli(project, ['run', 'dir', 'sql/no-such-dir', '--json']); + const payload = payloadOf(result.stdout); + + expect(result.status).toBe(EXIT_USAGE); + expect(payload.success).toBe(false); + expect(String(payload.error)).toContain('not found'); + + }); + + it('should refuse to report success for a directory containing no SQL files', async () => { + + await mkdir(join(project.dir, 'sql', 'empty'), { recursive: true }); + + const result = runCli(project, ['run', 'dir', 'sql/empty', '--json']); + const payload = payloadOf(result.stdout); + + // Core reports a zero-file batch as `status: 'success'`. Passing that + // straight through is how a mistyped path silently passes a pipeline. + expect(result.status).toBe(EXIT_USAGE); + expect(payload.success).toBe(false); + expect(payload.filesRun).toBe(0); + + }); + + it('should refuse to report success for a glob that matched nothing', () => { + + const result = runCli(project, ['run', 'exec', 'sql/zzz/*.sql', '--json']); + const payload = payloadOf(result.stdout); + + expect(result.status).toBe(EXIT_USAGE); + expect(payload.success).toBe(false); + + }); + + it('should expand a glob and execute the matched files without the Bun global', async () => { + + await mkdir(join(project.dir, 'sql', 'globbed'), { recursive: true }); + await writeFile(join(project.dir, 'sql', 'globbed', '01.sql'), 'CREATE TABLE globbed_t (id INTEGER PRIMARY KEY);\n', 'utf-8'); + + // runCli spawns `node`, not `bun`: before the fallback, `run exec` + // died with "Bun is not defined" before touching the database, which + // is also why this command had no test coverage at all. + const result = runCli(project, ['run', 'exec', 'sql/globbed/*.sql', '--json']); + const payload = payloadOf(result.stdout); + + expect(result.status).toBe(EXIT_SUCCESS); + expect(payload.success).toBe(true); + expect(payload.filesRun).toBe(1); + + }); + +}); From 6e63767cafff79c6bf738c081879b1257931a7ea Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 15:35:04 -0400 Subject: [PATCH 100/105] fix(cli): restore run exec under node, and let CI set lock metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/cli/lock/acquire.ts | 38 +++++++++++++++++++--- src/cli/run/exec.ts | 71 +++++++++++++++++++++++++++++------------ 2 files changed, 84 insertions(+), 25 deletions(-) diff --git a/src/cli/lock/acquire.ts b/src/cli/lock/acquire.ts index f954d450..5e04b725 100644 --- a/src/cli/lock/acquire.ts +++ b/src/cli/lock/acquire.ts @@ -3,7 +3,8 @@ */ import { defineCommand } from 'citty'; -import { withContext, outputResult, sharedArgs } from '../_utils.js'; +import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT } from '../_exit.js'; const acquireCommand = defineCommand({ meta: { @@ -13,14 +14,41 @@ const acquireCommand = defineCommand({ args: { config: sharedArgs.config, json: sharedArgs.json, + timeout: { + type: 'string', + description: 'Lock duration in milliseconds before it expires (default: 300000)', + }, + reason: { + type: 'string', + description: 'Reason shown to anyone this lock blocks', + }, }, async run({ args }) { + // The TUI has collected both since it shipped; without them a + // CI-acquired lock is always default-timeout and reasonless, so + // whoever it blocks has no way to know what they are waiting on. + const timeout = args.timeout === undefined ? undefined : Number(args.timeout); + + if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) { + + outputError(args, `Invalid --timeout value: "${args.timeout}". Must be a positive number of milliseconds.`); + process.exit(EXIT.USAGE); + + } + const [lock, error] = await withContext({ args, fn: (ctx, logger) => { - return ctx.noorm.lock.acquire().then((res) => { + // Keys are omitted rather than passed as `undefined`: + // LockManager merges with `{ ...DEFAULT_LOCK_OPTIONS, ...options }`, + // so an explicit `timeout: undefined` erases the default instead + // of falling back to it. + return ctx.noorm.lock.acquire({ + ...(timeout === undefined ? {} : { timeout }), + ...(args.reason === undefined ? {} : { reason: args.reason }), + }).then((res) => { if (!args.json) { @@ -38,7 +66,7 @@ const acquireCommand = defineCommand({ }, }); - if (error) process.exit(1); + if (error) process.exit(EXIT.FAILURE); if (args.json) { @@ -46,11 +74,12 @@ const acquireCommand = defineCommand({ acquired: true, lockedBy: lock.lockedBy, expiresAt: lock.expiresAt.toISOString(), + ...(args.reason ? { reason: args.reason } : {}), }, ''); } - process.exit(0); + process.exit(EXIT.SUCCESS); }, }); @@ -58,6 +87,7 @@ const acquireCommand = defineCommand({ (acquireCommand as typeof acquireCommand & { examples: string[] }).examples = [ 'noorm lock acquire', 'noorm lock acquire -c prod', + 'noorm lock acquire --timeout 600000 --reason "nightly migration"', 'noorm lock acquire --json', ]; diff --git a/src/cli/run/exec.ts b/src/cli/run/exec.ts index 67387b4f..c6be6c66 100644 --- a/src/cli/run/exec.ts +++ b/src/cli/run/exec.ts @@ -5,14 +5,50 @@ * path, discovers all matching SQL files, and executes them sequentially. */ import path from 'node:path'; -import { stat } from 'node:fs/promises'; +import { glob as nodeGlob, stat } from 'node:fs/promises'; import { defineCommand } from 'citty'; import { attempt } from '@logosdx/utils'; -import { withContext, outputResult, sharedArgs } from '../_utils.js'; +import { withContext, outputResult, outputError, sharedArgs } from '../_utils.js'; +import { EXIT, exitCodeForStatus } from '../_exit.js'; import { discoverFiles } from '../../core/runner/index.js'; +/** + * Expand a glob to absolute paths under `cwd`, on whichever runtime is hosting us. + * + * `run exec` was the only CLI 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`) before doing + * any work. Bun's own Glob stays the primary path so the compiled binary keeps + * its existing matching semantics exactly. + */ +async function expandGlob(pattern: string, cwd: string): Promise { + + const matches: string[] = []; + + if (typeof Bun !== 'undefined') { + + for await (const match of new Bun.Glob(pattern).scan({ cwd, absolute: true, onlyFiles: true })) { + + matches.push(match); + + } + + return matches; + + } + + for await (const match of nodeGlob(pattern, { cwd })) { + + matches.push(path.isAbsolute(match) ? match : path.join(cwd, match)); + + } + + return matches; + +} + /** * Resolve a path argument (directory or glob) to an array of absolute SQL file paths. * @@ -35,21 +71,11 @@ async function resolveInputPaths(input: string, cwd: string): Promise } - // Treat as glob pattern - const glob = new Bun.Glob(input); - const matches: string[] = []; - - for await (const match of glob.scan({ cwd, absolute: true, onlyFiles: true })) { - - if (match.endsWith('.sql') || match.endsWith('.sql.tmpl')) { - - matches.push(match); - - } - - } + const matches = await expandGlob(input, cwd); - return matches.sort(); + return matches + .filter((match) => match.endsWith('.sql') || match.endsWith('.sql.tmpl')) + .sort(); } @@ -75,17 +101,20 @@ const execCommand = defineCommand({ const [filepaths, resolveErr] = await attempt(() => resolveInputPaths(args.path, cwd)); + // outputError, not a bare stderr write: under `--json` these two exits + // used to produce an empty stdout, so a pipeline parsing the envelope + // got nothing to parse and no `success: false` to key off. if (resolveErr || !filepaths) { - process.stderr.write(`Error: Failed to resolve paths: ${resolveErr?.message ?? 'Unknown error'}\n`); - process.exit(1); + outputError(args, `Failed to resolve paths: ${resolveErr?.message ?? 'Unknown error'}`); + process.exit(EXIT.FAILURE); } if (filepaths.length === 0) { - process.stderr.write(`Error: No SQL files found matching: ${args.path}\n`); - process.exit(1); + outputError(args, `No SQL files found matching: ${args.path}`); + process.exit(EXIT.USAGE); } @@ -153,7 +182,7 @@ const execCommand = defineCommand({ } - process.exit(result.status === 'success' ? 0 : 2); + process.exit(exitCodeForStatus(result.status)); }, }); From f63036be8ddb2895e959f0839979d2e0823b33bd Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 15:36:07 -0400 Subject: [PATCH 101/105] 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. --- .../headless-envelope-and-exit-codes.md | 29 +++++++ docs/dev/headless.md | 8 +- docs/guide/automation/ci.md | 2 +- docs/guide/changes/history.md | 2 +- docs/headless.md | 80 ++++++++++++++++--- skills/noorm/references/cli.md | 14 ++-- 6 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 .changeset/headless-envelope-and-exit-codes.md diff --git a/.changeset/headless-envelope-and-exit-codes.md b/.changeset/headless-envelope-and-exit-codes.md new file mode 100644 index 00000000..d06c51ac --- /dev/null +++ b/.changeset/headless-envelope-and-exit-codes.md @@ -0,0 +1,29 @@ +--- +"@noormdev/cli": major +"@noormdev/sdk": major +--- + +## Headless contract: one `--json` envelope, four exit codes + +### Changed + +* `fix(cli)!:` every `--json` payload is now a JSON **object** carrying a top-level boolean `success`, and is never a bare array. `success` is `true` if and only if the process exits `0`, so `jq -e '.success'` is a failure check that works against every command. + * **Commands that returned a top-level array now return a named object.** `change list` → `.changes` (plus `.pending`), `change history` → `.history`, and every `db explore` list — `tables`, `views`, `indexes`, `fks` (→ `.foreignKeys`), `functions`, `procedures`, `types`, `triggers` — under the matching key. A script doing `jq '.[]'` on any of those must now name the key. + * **Every other payload gained `success` and lost nothing.** Existing key reads keep working. + * `config export --json` now emits the envelope (`{success, name, config}`). The bare artifact — the shape `config import` reads — is still what `noorm config export dev > dev.json` produces, unchanged. + * `noorm update --json` no longer prints two JSON documents for one error, and no longer reports `success: true` on a failed install while exiting non-zero. +* `fix(cli)!:` exit codes now distinguish four outcomes: `0` success, `1` total failure, `2` usage error (bad or missing flag, a TTY-only command run non-interactively, or a named target that does not exist), `3` partial failure. + * **`2` no longer means "partial".** It previously meant partial failure on `run build` / `run dir` / `run files` / `run exec` and the five `change` execution commands — but the same `2` was also returned for a *total* failure on those commands, so the two were indistinguishable. Partial now has its own code. **A pipeline testing `[ $? -eq 2 ]` for "partially applied" must now test `-eq 3`.** Checks of the form `if ! noorm …` are unaffected. + * `ci secrets` partial loads move from `2` to `3`. `db transfer`'s total-failure exit moves from `2` to `1` (its partial exit stays `3`). `vault propagate` and `vault rm`, `dev test-workers` and `dev test-helpers` now report partial and not-found instead of collapsing everything to `1`. + * A "you named something that isn't there" failure — missing file, directory, config, change, secret key, table, or a glob matching nothing — moves from `1` to `2` across the CLI. Confirmation and `--force` refusals deliberately stay at `1`. + +### Fixed + +* `noorm run inspect ` reported a fully-populated context and exit `0` for a file that was never on disk — it only ever read the template's *directory*. It now fails with exit `2`. `run preview` gained the same up-front check so both surfaces agree. +* `noorm run dir` reported success over a directory that contained no SQL files, and reported a missing directory as a SQL failure. Both now exit `2` with a message naming the path. +* `noorm run exec` died with `Bun is not defined` under Node — the documented dev entry point — before doing any work. Glob expansion falls back to `node:fs/promises` when the `Bun` global is absent; the compiled binary keeps Bun's own matcher. +* `noorm run preview --json` put a full stack trace, including absolute filesystem paths, into the machine-readable `error` field. `--json` now carries the message only; the stack still reaches an operator on stderr. +* `noorm lock acquire` gained `--timeout` and `--reason`, which the TUI has always collected — CI-acquired locks were permanently default-timeout and reasonless, so whoever they blocked had no way to see what they were waiting on. +* `withVaultContext` never passed `yes` to the SDK context, so `--yes` / `NOORM_YES` was inert for every vault command. +* `db transfer` declared a `--force` flag that nothing read. +* `run exec`, `db reset`, `vault cp`, and `dev test-helpers` wrote some errors straight to stderr, leaving `--json` callers with an empty stdout and no envelope to parse. diff --git a/docs/dev/headless.md b/docs/dev/headless.md index 29ec4a92..d0bff6b6 100644 --- a/docs/dev/headless.md +++ b/docs/dev/headless.md @@ -396,7 +396,9 @@ JSON mode disables colors and outputs structured data. | Code | Meaning | |------|---------| | 0 | Success | -| 1 | Failure | +| 1 | Total failure | +| 2 | Usage error — bad invocation, or a named target that does not exist | +| 3 | Partial failure — some units succeeded, some failed | Always check the exit code in scripts: @@ -479,7 +481,7 @@ set -e CONFIG="${1:-}" # Optional, falls back to active config echo "Checking for pending changes..." -PENDING=$(noorm ${CONFIG:+-c "$CONFIG"} change --json | jq '[.[] | select(.status=="pending")] | length') +PENDING=$(noorm ${CONFIG:+-c "$CONFIG"} change list --json | jq '.pending') if [ "$PENDING" -gt 0 ]; then echo "Applying $PENDING pending changes..." @@ -515,7 +517,7 @@ noorm change ff 2. **Use `--json` for scripting** - Easier to parse than text output ```bash - noorm change --json | jq '.[] | select(.status=="pending")' + noorm change list --json | jq '.changes[] | select(.status=="pending")' ``` 3. **Check exit codes** - Non-zero means failure diff --git a/docs/guide/automation/ci.md b/docs/guide/automation/ci.md index e1d3dc31..1a68a982 100644 --- a/docs/guide/automation/ci.md +++ b/docs/guide/automation/ci.md @@ -178,7 +178,7 @@ noorm ci secrets --file ./ci-secrets.env rm ./ci-secrets.env ``` -Existing keys are skipped by default (so a rerun is safe). Pass `--overwrite` to replace them. Exit codes: `0` clean load, `1` precondition failure, `2` partial (some keys set, some errored). +Existing keys are skipped by default (so a rerun is safe). Pass `--overwrite` to replace them. Exit codes: `0` clean load, `1` total failure, `2` bad invocation (unknown config, unreadable or malformed `--file`), `3` partial (some keys set, some errored). ### GitHub Actions (Prod CI) diff --git a/docs/guide/changes/history.md b/docs/guide/changes/history.md index a4cdd2ce..ed1b6b57 100644 --- a/docs/guide/changes/history.md +++ b/docs/guide/changes/history.md @@ -286,7 +286,7 @@ Now you know the issue---there's duplicate data that needs cleaning before the c Compare with successful runs of the same change on other environments. Did something change between runs? Did the data differ? ```bash -noorm change history --count 100 --json | jq '.[] | select(.name == "2025-01-16-add-payments")' +noorm change history --count 100 --json | jq '.history[] | select(.name == "2025-01-16-add-payments")' ``` diff --git a/docs/headless.md b/docs/headless.md index 34215059..f3d16537 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -569,7 +569,7 @@ noorm ci secrets --file ./ci-secrets.env --config prod --json **File format:** `KEY=value` per line; blank lines and `#` comments ignored; surrounding quotes stripped. -**Exit codes:** `0` all loaded; `1` precondition failure; `2` partial success. +**Exit codes:** `0` all loaded; `1` total failure; `2` bad invocation (unknown config, unreadable or malformed `--file`); `3` partial (some keys set, some errored). **JSON output:** ```json @@ -703,7 +703,7 @@ noorm run exec "seeds/*.sql" --force noorm run exec migrations/ --dry-run ``` -**Exit codes:** `0` success, `2` partial failure, `1` complete failure. +**Exit codes:** `0` success, `1` complete failure, `2` bad invocation, `3` partial failure. #### `run files --paths ` @@ -1439,9 +1439,24 @@ noorm ui | Code | Meaning | |------|---------| -| `0` | Success | -| `1` | Error (check stderr or JSON output) | -| `2` | Partial failure (some operations succeeded, some failed) | +| `0` | Success — everything you asked for happened | +| `1` | Total failure — the operation ran and no unit of work succeeded | +| `2` | Usage error — a bad or missing flag, a TTY-only command run non-interactively, or a named target (file, directory, config, change, secret key, glob) that does not exist. Nothing was attempted, so nothing changed | +| `3` | Partial failure — some units succeeded and some failed. The target is in a mixed state and re-running is not automatically safe | + +The split between `1` and `3` is the one that matters in a pipeline: a total +failure can be retried, a partial one needs a human. `2` says the command never +got as far as touching the database. + +Policy denials currently exit `1`. They do not yet have a dedicated code. + +> **Changed in this release.** `2` previously meant "partial failure" on +> `run build` / `run dir` / `run files` / `run exec` and the five `change` +> execution commands, but the same `2` was also returned for a *total* failure +> on those commands — the two were indistinguishable. Partial now has its own +> code (`3`), total failure reports `1`, and `2` is reserved for a bad +> invocation. A pipeline that tested `[ $? -eq 2 ]` to mean "partially applied" +> must now test `-eq 3`. Checks of the form `if ! noorm …` are unaffected. ## CI/CD Examples @@ -1617,27 +1632,72 @@ That means `--json` output is always exactly one parseable JSON document on stdo log noise ahead of or after it — no `tail -1` needed: ```bash -noorm change history --json | jq '.[0].status' +noorm change history --json | jq '.history[0].status' ``` A command that finds nothing to report still writes a result — never silence. `noorm change list` on a project with no changesets prints `No changes found.` on stdout in text mode and -`[]` on stdout in `--json` mode; either way, a CI step can tell "ran and found nothing" from -"didn't run" by checking the exit code and the (always-present) stdout line, rather than -guessing from empty output. +`{"success":true,"changes":[],"pending":0}` in `--json` mode; either way, a CI step can tell +"ran and found nothing" from "didn't run" by checking the exit code and the (always-present) +stdout line, rather than guessing from empty output. + + +## The `--json` Envelope + +Every `--json` payload is a **JSON object** carrying a top-level boolean `success`. +It is never a bare array. Command-specific fields sit alongside `success`, and +list results live under a key named for the resource: + +```json +{ "success": true, "changes": [ … ], "pending": 0 } +{ "success": true, "status": "success", "filesRun": 3, "filesSkipped": 0, "filesFailed": 0 } +{ "success": false, "status": "partial", "filesRun": 1, "filesSkipped": 0, "filesFailed": 2 } +{ "success": false, "error": "Template not found: sql/nope.sql.tmpl" } +``` + +Three guarantees hold across every command: + +1. `jq -e '.success'` works everywhere — success and failure alike. +2. `success` and the exit code always agree: `success` is `true` if and only if + the process exits `0`. A partial result reports `success: false` alongside + `"status": "partial"` and exit `3`. +3. An unsuccessful payload always carries `error` as a string. That string is a + message, never a stack trace — stack traces contain absolute filesystem + paths and stay out of machine-readable output. + +Commands that report per-unit outcomes also carry a `status` of `"success"`, +`"partial"`, or `"failed"`, which is what `success` and the exit code are derived +from. + +> **Changed in this release.** `--json` success payloads previously had four +> incompatible shapes and no shared discriminator. Two changes are breaking: +> `success` is now present on payloads that had no such key, and the commands +> that returned a **top-level array** now return a named object instead — +> `change list` → `.changes`, `change history` → `.history`, `db explore tables` +> → `.tables`, `views` → `.views`, `indexes` → `.indexes`, `fks` → +> `.foreignKeys`, `functions` → `.functions`, `procedures` → `.procedures`, +> `types` → `.types`, `triggers` → `.triggers`. A script doing `jq '.[]'` on any +> of those must now name the key. Everything else gained a field and lost none. ## Scripting with JSON ```bash # Check for pending changes -pending=$(noorm change list --json | jq '[.[] | select(.status == "pending")] | length') +pending=$(noorm change list --json | jq '.pending') if [ "$pending" -gt 0 ]; then echo "Found $pending pending changes" noorm change ff fi ``` +```bash +# One failure check that works against every command +if ! noorm run build --json | jq -e '.success' > /dev/null; then + echo "build did not succeed" +fi +``` + ```bash # Get table count tables=$(noorm db explore --json | jq '.tables') diff --git a/skills/noorm/references/cli.md b/skills/noorm/references/cli.md index dc215ee1..9eec9c87 100644 --- a/skills/noorm/references/cli.md +++ b/skills/noorm/references/cli.md @@ -244,7 +244,7 @@ noorm ci secrets --file ./ci-secrets.env --config prod --json } ``` -**Exit codes:** `0` all loaded (or all skipped); `1` precondition failure (no `state.enc`, missing config, parse error, total failure); `2` partial success (some set, some errored). +**Exit codes:** `0` all loaded (or all skipped); `1` total failure (no `state.enc`); `2` bad invocation (missing/unknown config, unreadable or malformed `--file`); `3` partial (some set, some errored). --- @@ -955,7 +955,7 @@ noorm db explore --json ### Check for pending changes before deploying ```bash -pending=$(noorm change --json | jq '[.[] | select(.status == "pending")] | length') +pending=$(noorm change list --json | jq '.pending') if [ "$pending" -gt 0 ]; then echo "Found $pending pending changes" noorm change ff @@ -972,7 +972,7 @@ echo "Database has $tables tables" ### Check for failures in change history ```bash -failures=$(noorm change history --json | jq '[.[] | select(.status == "failed")] | length') +failures=$(noorm change history --json | jq '[.history[] | select(.status == "failed")] | length') if [ "$failures" -gt 0 ]; then echo "WARNING: $failures failed changes in history" exit 1 @@ -1005,5 +1005,9 @@ fi | Code | Meaning | |---|---| -| `0` | Success | -| `1` | Error (check stderr or `--json` output) | +| `0` | Success — everything asked for happened | +| `1` | Total failure — the operation ran and nothing succeeded | +| `2` | Usage error — bad/missing flag, TTY-only command run non-interactively, or a named target (file, directory, config, change, secret key, glob) that does not exist. Nothing was attempted | +| `3` | Partial failure — some units succeeded, some failed. Mixed state; re-running is not automatically safe | + +Every `--json` payload is an object carrying a top-level boolean `success`, and `success` is `true` if and only if the exit code is `0`. List results live under a named key (`.changes`, `.history`, `.tables`, …), never a bare top-level array. From 735d5eac6289c612b7d7336b59e7dcb28bf1058b Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Thu, 30 Jul 2026 00:15:36 -0400 Subject: [PATCH 102/105] fix: the access channel names who is driving, not which binary was invoked (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .changeset/agent-channel-not-transport.md | 38 +++ .changeset/agent-provenance-executed-by.md | 11 + .changeset/mssql-connect-by-ip.md | 11 + docs/dev/config-sharing.md | 2 +- docs/dev/config.md | 16 +- docs/dev/datamodel.md | 6 +- docs/dev/sdk.md | 4 +- docs/dev/settings.md | 10 +- docs/dev/state.md | 4 +- docs/getting-started/concepts.md | 2 +- docs/guide/automation/mcp.md | 18 +- docs/guide/environments/configs.md | 27 +- docs/guide/environments/stages.md | 2 +- docs/headless.md | 5 +- docs/reference/sdk.md | 4 +- docs/spec/config-access-roles.md | 43 +-- docs/tui.md | 2 +- docs/wiki/cli.md | 2 +- docs/wiki/core-policy.md | 10 +- docs/wiki/core-state.md | 4 +- docs/wiki/index.md | 2 +- docs/wiki/mcp-rpc.md | 8 +- skills/noorm/references/config.md | 8 +- skills/noorm/references/sdk.md | 2 +- src/cli/_utils.ts | 9 +- src/cli/change/rm.ts | 4 +- src/cli/ci/identity/enroll.ts | 3 +- src/cli/config/export.ts | 4 +- src/cli/config/import.ts | 6 +- src/cli/config/list.ts | 10 +- src/cli/config/rm.ts | 4 +- src/cli/db/create.ts | 4 +- src/cli/db/drop.ts | 4 +- src/cli/info.ts | 33 ++ src/cli/run/_render-secrets.ts | 3 +- src/cli/run/inspect.ts | 4 +- src/cli/run/preview.ts | 4 +- src/cli/secret/_policy.ts | 4 +- src/cli/sql/query.ts | 3 +- src/cli/vault/init.ts | 3 +- src/cli/vault/list.ts | 3 +- src/cli/vault/propagate.ts | 3 +- src/cli/vault/rm.ts | 3 +- src/cli/vault/set.ts | 3 +- src/core/change/types.ts | 4 +- src/core/config/index.ts | 1 + src/core/config/resolver.ts | 8 +- src/core/config/schema.ts | 6 +- src/core/config/types.ts | 2 +- src/core/connection/dialects/mssql.ts | 129 +++++++- src/core/connection/types.ts | 11 + src/core/db/policy.ts | 2 +- src/core/debug/operations.ts | 2 +- src/core/identity/provenance.ts | 74 +++++ src/core/policy/channel.ts | 65 ++++ src/core/policy/check.ts | 36 +-- src/core/policy/harness.ts | 118 ++++++++ src/core/policy/index.ts | 3 + src/core/policy/legacy-access.ts | 16 +- src/core/policy/types.ts | 15 +- src/core/runner/types.ts | 4 +- src/core/settings/manager.ts | 1 + src/core/settings/rules.ts | 2 +- src/core/shared/operation-id.ts | 27 +- src/core/state/access.ts | 14 +- src/core/vault/policy.ts | 2 +- src/core/version/state/index.ts | 3 +- src/core/version/state/migrations/v2.ts | 4 +- src/core/version/state/migrations/v3.ts | 98 ++++++ src/core/version/types.ts | 2 +- src/mcp/index.ts | 2 +- src/rpc/commands/config.ts | 4 +- src/rpc/commands/session.ts | 8 +- src/rpc/session.ts | 15 +- src/rpc/types.ts | 2 +- src/sdk/guards.ts | 2 +- src/sdk/types.ts | 12 +- src/tui/screens/config/ConfigAddScreen.tsx | 10 +- src/tui/screens/config/ConfigEditScreen.tsx | 10 +- src/tui/utils/config-validation.ts | 21 +- src/tui/utils/index.ts | 2 +- tests/cli/VaultScreen.test.tsx | 2 +- tests/cli/agent-channel-escalation.test.ts | 286 ++++++++++++++++++ tests/cli/change/history.test.ts | 2 +- tests/cli/change/list.test.ts | 2 +- tests/cli/change/rewind.test.ts | 2 +- tests/cli/change/rm.test.ts | 8 +- tests/cli/ci/identity-enroll-hijack.test.ts | 6 +- tests/cli/config-validation.test.ts | 20 +- tests/cli/config/export.test.ts | 4 +- tests/cli/config/import.test.ts | 22 +- tests/cli/config/list.test.ts | 70 ++++- tests/cli/config/rm.test.ts | 10 +- tests/cli/db/create.test.ts | 10 +- tests/cli/db/drop.test.ts | 12 +- tests/cli/db/explore.test.ts | 2 +- tests/cli/db/lifecycle-policy.test.ts | 24 +- tests/cli/db/reset.test.ts | 12 +- tests/cli/hooks/useVaultSecretKeys.test.tsx | 2 +- tests/cli/lock/force.test.ts | 4 +- tests/cli/lock/status.test.ts | 2 +- tests/cli/run/preview-inspect-policy.test.ts | 2 +- .../run/preview-inspect-vault-probe.test.ts | 2 +- .../screens/change/change-dry-run.test.tsx | 2 +- .../screens/config/ConfigEditScreen.test.tsx | 2 +- .../config/ConfigRemoveScreen.test.tsx | 2 +- .../cli/screens/db/DbTransferScreen.test.tsx | 2 +- tests/cli/screens/db/db-dry-run.test.tsx | 2 +- tests/cli/sql/history-config.test.ts | 2 +- tests/cli/utils/change-context.test.ts | 2 +- tests/core/change/executor-retry.test.ts | 2 +- tests/core/change/executor.test.ts | 8 +- tests/core/change/manager.test.ts | 2 +- tests/core/change/scaffold.test.ts | 2 +- tests/core/config/env.test.ts | 5 + tests/core/config/resolver.test.ts | 38 +-- tests/core/config/schema.test.ts | 20 +- tests/core/config/validate.test.ts | 2 +- tests/core/connection/dialects/mssql.test.ts | 196 ++++++++++++ tests/core/connection/manager.test.ts | 2 +- tests/core/db/operations.test.ts | 6 +- tests/core/debug/operations.test.ts | 16 +- tests/core/identity/provenance.test.ts | 101 +++++++ tests/core/lock/force-policy.test.ts | 12 +- tests/core/mcp/server.test.ts | 16 +- tests/core/policy/agent-escalation.test.ts | 162 ++++++++++ tests/core/policy/channel.test.ts | 104 +++++++ tests/core/policy/check.test.ts | 45 +-- tests/core/policy/default-access.test.ts | 49 +-- tests/core/policy/visibility.test.ts | 16 +- tests/core/rpc/commands.test.ts | 28 +- tests/core/rpc/list-configs.test.ts | 10 +- tests/core/rpc/session-not-found.test.ts | 12 +- tests/core/rpc/session-status.test.ts | 12 +- tests/core/rpc/session.test.ts | 38 +-- tests/core/runner/dry-run-output.test.ts | 2 +- tests/core/runner/execute-files.test.ts | 2 +- tests/core/runner/mssql-batches.test.ts | 2 +- tests/core/runner/runner.test.ts | 6 +- .../runner/sqlite-multi-statement.test.ts | 2 +- tests/core/runner/template-dedup.test.ts | 2 +- tests/core/secrets/leakage.test.ts | 2 +- tests/core/settings/manager.test.ts | 4 +- tests/core/settings/rules.test.ts | 8 +- tests/core/shared/operation-id.test.ts | 86 +++++- tests/core/sql-terminal/executor.test.ts | 6 +- tests/core/state/access.test.ts | 24 +- tests/core/state/durability.test.ts | 2 +- tests/core/state/manager.test.ts | 34 +-- tests/core/state/merge.test.ts | 2 +- tests/core/teardown/operations.test.ts | 4 +- tests/core/transfer/policy-gate.test.ts | 4 +- tests/core/vault/policy-gate.test.ts | 4 +- tests/core/version/state.test.ts | 144 +++++++-- tests/core/version/types.test.ts | 2 +- .../change/mysql-lifecycle.test.ts | 2 +- .../change/postgres-transaction.test.ts | 2 +- .../integration/connection/mssql-sni.test.ts | 99 ++++++ .../integration/runner/mssql-batches.test.ts | 4 +- tests/integration/sdk/db-reset.test.ts | 2 +- tests/preload.ts | 19 ++ tests/sdk/bundle-smoke.test.ts | 4 +- tests/sdk/context.test.ts | 2 +- tests/sdk/db-namespace.test.ts | 2 +- tests/sdk/destructive-ops.test.ts | 6 +- tests/sdk/guards.test.ts | 36 +-- tests/sdk/impersonate/impersonate.test.ts | 2 +- tests/sdk/noorm-ops.test.ts | 2 +- tests/sdk/render-vault-tier.test.ts | 2 +- tests/sdk/run-build-filtering.test.ts | 2 +- tests/sdk/templates-policy.test.ts | 2 +- tests/sdk/transfer-dt-namespace.test.ts | 2 +- tests/sdk/vault-namespace.test.ts | 2 +- tests/utils/db.ts | 2 +- 174 files changed, 2472 insertions(+), 580 deletions(-) create mode 100644 .changeset/agent-channel-not-transport.md create mode 100644 .changeset/agent-provenance-executed-by.md create mode 100644 .changeset/mssql-connect-by-ip.md create mode 100644 src/core/identity/provenance.ts create mode 100644 src/core/policy/channel.ts create mode 100644 src/core/policy/harness.ts create mode 100644 src/core/version/state/migrations/v3.ts create mode 100644 tests/cli/agent-channel-escalation.test.ts create mode 100644 tests/core/connection/dialects/mssql.test.ts create mode 100644 tests/core/identity/provenance.test.ts create mode 100644 tests/core/policy/agent-escalation.test.ts create mode 100644 tests/core/policy/channel.test.ts create mode 100644 tests/integration/connection/mssql-sni.test.ts diff --git a/.changeset/agent-channel-not-transport.md b/.changeset/agent-channel-not-transport.md new file mode 100644 index 00000000..4e8a6d34 --- /dev/null +++ b/.changeset/agent-channel-not-transport.md @@ -0,0 +1,38 @@ +--- +"@noormdev/cli": major +"@noormdev/sdk": major +--- + +Resolve the access channel from who is driving, not which binary was invoked + +`Channel` used to name the transport: `user` meant the CLI/TUI/SDK and `mcp` +meant the MCP server. Those only coincide when a human is at the keyboard. An +agent refused a write over MCP could see `noorm` on the PATH and shell out, +and because the CLI hardcoded `user` at every policy call site, that second +attempt ran with the human's role. On a stock config that turned deny into +allow for `sql:write`, `sql:ddl`, `db:create`, `run:build` and `vault:read`, +and turned `db:destroy` into a confirm that `--yes` satisfied. + +Two breaking changes: + +**The config fields are renamed.** `Channel` is now `'user' | 'agent'`, and +`ConfigAccess` is `{ user, agent }` instead of `{ user, mcp }`. `agent: false` +hides a config from agents on *both* transports, not just over MCP. Stored +state migrates automatically (state schema v3) and carries every value over +verbatim — `mcp: 'operator'` becomes `agent: 'operator'`, `mcp: false` becomes +`agent: false`. SDK callers passing `channel: 'mcp'` to `createContext` must +pass `'agent'`, and anything reading or writing `config.access.mcp` must use +`config.access.agent`. In the TUI, the "MCP Role" field is now "Agent Role". + +**Agents shelling out to the CLI now get the agent role.** The CLI resolves +its channel from provenance via `resolveChannel()`: an allowlist of variables +the agent harnesses (Claude Code, Codex, Cursor, Gemini CLI) set for their own +child processes. A stock config gives agents `viewer`, so commands that used +to succeed inside an agent session are now refused — that is the fix, not a +regression. `TERM_PROGRAM`, `CI` and TTY state are deliberately not consulted; +they describe the terminal or the pipeline, not the caller. + +Set `NOORM_CHANNEL=user` to opt out when a human is scripting from inside an +agent session, or `NOORM_CHANNEL=agent` to opt in with no harness present. An +agent can set that variable too; this defends against one routing around a +refusal, not one deliberately evading the check. diff --git a/.changeset/agent-provenance-executed-by.md b/.changeset/agent-provenance-executed-by.md new file mode 100644 index 00000000..d78a4e6c --- /dev/null +++ b/.changeset/agent-provenance-executed-by.md @@ -0,0 +1,11 @@ +--- +"@noormdev/cli": minor +"@noormdev/sdk": minor +--- + +## Added + +* `feat(identity):` Record the detected agent harness in operation provenance. When a session runs under a recognised agent harness, `executed_by` is suffixed with `(via )` — so a change applied by an agent is distinguishable from one a human applied. Stamped at the shared insert seam, so change operations, resets and run operations all carry it on every dialect. +* `feat(cli):` `noorm info` reports the detected harness and the environment variables that identified it, so an agent-driven session is visible rather than silent. + +Provenance is folded into the existing identity string rather than a new column: the audit question is binary, and a suffix answers it without a four-dialect schema migration. It is not an attestation — `executed_by` is unauthenticated free text and harness detection reads caller-controlled environment variables, so the suffix records what noorm observed, not a proven claim. diff --git a/.changeset/mssql-connect-by-ip.md b/.changeset/mssql-connect-by-ip.md new file mode 100644 index 00000000..79d6803b --- /dev/null +++ b/.changeset/mssql-connect-by-ip.md @@ -0,0 +1,11 @@ +--- +"@noormdev/cli": patch +"@noormdev/sdk": patch +--- + +## MSSQL connects by IP address + +### Fixed + +* `fix(connection):` connecting to MSSQL by IP address works again. Tedious derived the TLS SNI ServerName from the host, and Node rejects an IP literal there (RFC 6066), so every IP connection failed with `Setting the TLS ServerName to an IP address is not permitted`. Hostname connections are unchanged. Encryption stays on — the connection is never silently downgraded to plaintext to work around it. +* `feat(connection):` new optional `connection.tlsServerName` — the hostname the server's TLS certificate is issued for. Required when connecting to an IP address with certificate validation enabled (`ssl` set), since a certificate cannot be validated against an IP. Without it, that combination now fails with an error naming the field instead of an opaque TLS error. diff --git a/docs/dev/config-sharing.md b/docs/dev/config-sharing.md index 5b854b47..671754aa 100644 --- a/docs/dev/config-sharing.md +++ b/docs/dev/config-sharing.md @@ -74,7 +74,7 @@ The exported data includes everything needed to recreate the config except crede name: 'production', type: 'remote', isTest: false, - access: { user: 'operator', mcp: 'viewer' }, + access: { user: 'operator', agent: 'viewer' }, connection: { dialect: 'postgres', host: 'db.example.com', diff --git a/docs/dev/config.md b/docs/dev/config.md index dc7c3b75..583fa345 100644 --- a/docs/dev/config.md +++ b/docs/dev/config.md @@ -198,14 +198,14 @@ const full = parseConfig(partial) ## Access Roles -`Config.protected: boolean` was replaced by per-channel access roles. Roles live on the config, not the caller — the caller is a **channel**: `user` (CLI/TUI/SDK) or `mcp` (the MCP server). Each config declares a role per channel: +`Config.protected: boolean` was replaced by per-channel access roles. Roles live on the config, not the caller — the caller is a **channel**: `user` (a human) or `agent` (an AI agent, over MCP or the CLI). Each config declares a role per channel: ```typescript const config = { name: 'prod', access: { user: 'operator', // what a human at the CLI/TUI gets - mcp: 'viewer', // what a connected AI agent gets — can differ from user + agent: 'viewer', // what a connected AI agent gets — can differ from user }, // ... } @@ -224,7 +224,7 @@ Three roles, hard-coded, not user-extensible: | `db:destroy` | deny | deny | confirm | | `config:rm` | deny | confirm | confirm | -`mcp: false` is not a role — it makes the config invisible on the MCP channel (absent from `list_configs`, `connect` fails with the byte-identical error an unknown config produces). +`agent: false` is not a role — it makes the config invisible to agents on both MCP and the CLI (absent from `list_configs`, `connect` fails with the byte-identical error an unknown config produces). Check policy before executing: @@ -248,14 +248,14 @@ if (check.requiresConfirmation) { // Proceed with action ``` -`confirm` resolves per channel: on `user` it prompts for `yes-` (skippable with `NOORM_YES=1`); on `mcp` it collapses straight to deny — there's no human on the other end of stdio to type a phrase. +`confirm` resolves per channel: on `user` it prompts for `yes-` (skippable with `NOORM_YES=1`); on `agent` it collapses straight to deny — an agent confirming its own destructive action is theater, and on the CLI it would need only `--yes`. ```bash export NOORM_YES=1 noorm change run # No prompt, even on an operator-role config ``` -**Migration:** a legacy `protected: true` maps to `{ user: 'operator', mcp: 'viewer' }`; `protected: false` or absent maps to the default `{ user: 'admin', mcp: 'viewer' }`. A config that already stores an explicit `access` is left as-is. The `protected` field is accepted on input for one version, then dropped — see the state migration in `core/version/state/migrations/`. +**Migration:** a legacy `protected: true` maps to `{ user: 'operator', agent: 'viewer' }`; `protected: false` or absent maps to the default `{ user: 'admin', agent: 'viewer' }`. A config that already stores an explicit `access` is left as-is. The `protected` field is accepted on input for one version, then dropped — see the state migration in `core/version/state/migrations/`. ## Stages @@ -331,7 +331,7 @@ Stage constraints that can't be violated: | Constraint | Behavior | |------------|----------| -| `protected: true` in defaults | Clamps resolved `access` to at most `{ user: 'operator', mcp: 'viewer' }` — stricter survives, looser is clamped down (see [Access Roles](#access-roles)) | +| `protected: true` in defaults | Clamps resolved `access` to at most `{ user: 'operator', agent: 'viewer' }` — stricter survives, looser is clamped down (see [Access Roles](#access-roles)) | | `isTest: true` in defaults | Cannot set `isTest: false` | | `locked: true` | Config cannot be deleted | @@ -416,8 +416,8 @@ For listings, use `ConfigSummary` which omits sensitive connection details: ```typescript const summaries = state.listConfigs() // [ -// { name: 'dev', type: 'local', isTest: false, access: { user: 'admin', mcp: 'admin' }, isActive: true, dialect: 'postgres', database: 'dev_db' }, -// { name: 'prod', type: 'remote', isTest: false, access: { user: 'operator', mcp: 'viewer' }, isActive: false, dialect: 'postgres', database: 'prod_db' }, +// { name: 'dev', type: 'local', isTest: false, access: { user: 'admin', agent: 'admin' }, isActive: true, dialect: 'postgres', database: 'dev_db' }, +// { name: 'prod', type: 'remote', isTest: false, access: { user: 'operator', agent: 'viewer' }, isActive: false, dialect: 'postgres', database: 'prod_db' }, // ] ``` diff --git a/docs/dev/datamodel.md b/docs/dev/datamodel.md index 02cfdd19..a585506e 100644 --- a/docs/dev/datamodel.md +++ b/docs/dev/datamodel.md @@ -92,7 +92,7 @@ A database connection profile stored in encrypted state. | name | string | Yes | Unique identifier (e.g., `dev`, `staging`, `prod`) | | type | enum | Yes | `local` or `remote` | | isTest | boolean | Yes | Marks database as disposable for testing | -| access | ConfigAccess | Yes | Per-channel access roles (`{ user, mcp }`) — replaces the legacy `protected` boolean | +| access | ConfigAccess | Yes | Per-channel access roles (`{ user, agent }`) — replaces the legacy `protected` boolean | | connection | ConnectionConfig | Yes | Database connection details | | paths | PathConfig | Yes | File system paths for schema and changes | | identity | string | No | Override identity for `executed_by` field | @@ -107,7 +107,7 @@ Per-channel access grant. `Role` is `'viewer' | 'operator' | 'admin'`. | user | Role | Access for the CLI, TUI, and SDK | | mcp | Role \| `false` | Access for the MCP server. `false` hides the config entirely on this channel | -`checkPolicy(channel, config, permission)` resolves a `ConfigAccess` + `Permission` into `allow`/`confirm`/`deny`, channel-aware (`confirm` prompts on `user`, collapses to `deny` on `mcp`). See `docs/spec/config-access-roles.md` for the full permission matrix. +`checkPolicy(channel, config, permission)` resolves a `ConfigAccess` + `Permission` into `allow`/`confirm`/`deny`, channel-aware (`confirm` prompts on `user`, collapses to `deny` on `agent`). See `docs/spec/config-access-roles.md` for the full permission matrix. ### ConnectionConfig @@ -280,7 +280,7 @@ Initial values when creating a config from a stage. | password | string? | Default password | | ssl | boolean? | Default SSL setting | | isTest | boolean? | Default test flag | -| protected | boolean? | `true` becomes an access **ceiling** at resolution: resolved `access` is clamped to at most `{ user: 'operator', mcp: 'viewer' }` — a stricter config-level `access` survives unchanged | +| protected | boolean? | `true` becomes an access **ceiling** at resolution: resolved `access` is clamped to at most `{ user: 'operator', agent: 'viewer' }` — a stricter config-level `access` survives unchanged | ### StageSecret diff --git a/docs/dev/sdk.md b/docs/dev/sdk.md index 046d1f4b..c667d559 100644 --- a/docs/dev/sdk.md +++ b/docs/dev/sdk.md @@ -85,7 +85,7 @@ interface CreateContextOptions { config?: string // Config name (or use NOORM_CONFIG env var) projectRoot?: string // Project root path (see note below) requireTest?: boolean // Refuse if config.isTest !== true - channel?: Channel // 'user' (default) or 'mcp' — which access role applies + channel?: Channel // 'user' (default) or 'agent' — which access role applies stage?: string // Stage name for stage defaults } ``` @@ -107,7 +107,7 @@ const ctx = await createContext({ - `requireTest: true` - Throws `RequireTestError` if the config doesn't have `isTest: true`. Use this in test suites to prevent accidentally running against production. -- `channel: 'mcp'` - Enforces the config's `access.mcp` role instead of `access.user` for destructive operations (`truncate`, `teardown`, `reset`, `changes.revert`). Use this when embedding the SDK behind an MCP-like surface. Defaults to `'user'`. A denied or unconfirmable operation throws `ProtectedConfigError` — the SDK has no prompt, so a `confirm`-tier permission needs `NOORM_YES=1` or the CLI/TUI. See [Access Roles](./config.md#access-roles). +- `channel: 'agent'` - Enforces the config's `access.agent` role instead of `access.user` for destructive operations (`truncate`, `teardown`, `reset`, `changes.revert`). Use this when embedding the SDK behind an MCP-like surface. Defaults to `'user'`. A denied or unconfirmable operation throws `ProtectedConfigError` — the SDK has no prompt, so a `confirm`-tier permission needs `NOORM_YES=1` or the CLI/TUI. See [Access Roles](./config.md#access-roles). ### Environment Variable Support diff --git a/docs/dev/settings.md b/docs/dev/settings.md index 9366ce5d..63ca1913 100644 --- a/docs/dev/settings.md +++ b/docs/dev/settings.md @@ -46,7 +46,7 @@ const paths = settings.getEffectiveBuildPaths({ name: 'dev', type: 'local', isTest: false, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, }) console.log('Effective include:', paths.include) console.log('Effective exclude:', paths.exclude) @@ -211,7 +211,7 @@ All conditions in a rule are AND'd together—every specified condition must be import { ruleMatches } from './core/settings' const match = { isTest: true, type: 'local' } -const config = { name: 'test', type: 'local', isTest: true, access: { user: 'admin', mcp: 'admin' } } +const config = { name: 'test', type: 'local', isTest: true, access: { user: 'admin', agent: 'admin' } } ruleMatches(match, config) // true - both conditions match ``` @@ -259,7 +259,7 @@ const { include, exclude } = settings.getEffectiveBuildPaths({ name: 'dev', type: 'local', isTest: false, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, }) // Build only these paths @@ -315,7 +315,7 @@ Defaults provide initial values when creating a config. Users can override most | Default | Behavior | |---------|----------| -| `protected: true` | Enforced as an access **ceiling** at config resolution (`resolveConfig`), not a value check here: resolved `access` is clamped to at most `{ user: 'operator', mcp: 'viewer' }` no matter what the config or the TUI sets | +| `protected: true` | Enforced as an access **ceiling** at config resolution (`resolveConfig`), not a value check here: resolved `access` is clamped to at most `{ user: 'operator', agent: 'viewer' }` no matter what the config or the TUI sets | | `isTest: true` | Cannot be overridden to false | | `dialect` | Cannot be changed after creation | @@ -593,7 +593,7 @@ const result = settings.evaluateRules({ name: 'dev', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' } + access: { user: 'admin', agent: 'admin' } }) // { matchedRules: [...], include: [...], exclude: [...] } ``` diff --git a/docs/dev/state.md b/docs/dev/state.md index 750b3f2d..c6710318 100644 --- a/docs/dev/state.md +++ b/docs/dev/state.md @@ -77,7 +77,7 @@ await state.setConfig('dev', { name: 'dev', type: 'local', isTest: false, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'postgres', host: 'localhost', @@ -97,7 +97,7 @@ const dev = state.getConfig('dev') // List all configs with summary info const configs = state.listConfigs() -// [{ name: 'dev', type: 'local', isTest: false, access: { user: 'admin', mcp: 'admin' }, isActive: true }] +// [{ name: 'dev', type: 'local', isTest: false, access: { user: 'admin', agent: 'admin' }, isActive: true }] // Delete a config (also removes its secrets) await state.deleteConfig('dev') diff --git a/docs/getting-started/concepts.md b/docs/getting-started/concepts.md index 36b4b028..e00c5bee 100644 --- a/docs/getting-started/concepts.md +++ b/docs/getting-started/concepts.md @@ -217,7 +217,7 @@ noorm -c prod run build ## Access Roles -Every config declares an access role — `viewer`, `operator`, or `admin` — separately for two **channels**: `user` (you, at the CLI or TUI) and `mcp` (an AI agent connected over MCP). This is how you prevent accidental destructive operations: +Every config declares an access role — `viewer`, `operator`, or `admin` — separately for two **channels**: `user` (you, at the CLI or TUI) and `agent` (an AI agent, whether it reaches noorm over MCP or by running the CLI). This is how you prevent accidental destructive operations: - `viewer` - Read-only. `db teardown`, writes, and changes are all blocked. - `operator` - Reads and writes are fine; `db teardown` and applying changes require typing a confirmation phrase. diff --git a/docs/guide/automation/mcp.md b/docs/guide/automation/mcp.md index 99fea168..81ee8ec4 100644 --- a/docs/guide/automation/mcp.md +++ b/docs/guide/automation/mcp.md @@ -90,14 +90,16 @@ Once connected, your agent can: - **Build schemas** — execute SQL files - **Inspect templates** — see available context before rendering -Config resolution and identity attribution work the same as the CLI. Access control does not: every command is gated by the config's **`mcp` role** before its handler ever runs, and there is no confirmation flow — the agent gets an answer, not a prompt. +Config resolution and identity attribution work the same as the CLI. Access control does not: every command is gated by the config's **`agent` role** before its handler ever runs, and there is no confirmation flow — the agent gets an answer, not a prompt. ## Access Roles -Every config declares a role per channel: `access: { user, mcp }`. The `mcp` role decides what an agent connected over this server can do to that config — independently of what a human gets in the CLI/TUI. +Every config declares a role per channel: `access: { user, agent }`. The `agent` role decides what an AI agent can do to that config — independently of what a human gets in the CLI/TUI. -A config that never declared `access` gets `{ user: 'admin', mcp: 'viewer' }`. Agents can explore and read on a fresh project without any setup; anything that writes needs you to raise the `mcp` role on purpose. +The channel names *who is driving*, not which binary was invoked. An agent that gets refused here and shells out to `noorm` on the command line is still an agent, and gets the same `agent` role: the CLI detects the harness it was spawned from. Raising or lowering `agent` therefore governs both routes at once. See [Access Roles](/guide/environments/configs#access-roles) for the detection rules and the `NOORM_CHANNEL` override. + +A config that never declared `access` gets `{ user: 'admin', agent: 'viewer' }`. Agents can explore and read on a fresh project without any setup; anything that writes needs you to raise the `agent` role on purpose. | Role | What the agent can do | |------|------------------------| @@ -106,24 +108,24 @@ A config that never declared `access` gets `{ user: 'admin', mcp: 'viewer' }`. A | `admin` | Full access: writes, DDL, changes, builds — frictionless, no confirmation | | `false` | Invisible — the config does not exist on this channel | -Raw SQL is classified by what the statement actually does (`sql:read` / `sql:write` / `sql:ddl`), not by which command the agent called — an agent with `mcp: viewer` gets a `SELECT` through but a same-shaped `INSERT` denied. +Raw SQL is classified by what the statement actually does (`sql:read` / `sql:write` / `sql:ddl`), not by which command the agent called — an agent with `agent: viewer` gets a `SELECT` through but a same-shaped `INSERT` denied. -There is no human on the other end of stdio, so the matrix's `confirm` cells never prompt on this channel — they resolve straight to **deny**, with a message pointing at the CLI. An agent typing its own confirmation phrase would be theater, not a safeguard. If an agent legitimately needs to run changes on a database, give that config `mcp: 'admin'` — reserve it for configs where that's an acceptable risk (a disposable dev database, say), not production. +The matrix's `confirm` cells never prompt on this channel — they resolve straight to **deny**. An agent typing its own confirmation phrase would be theater, not a safeguard, and on the CLI it would need only `--yes`. If an agent legitimately needs to run changes on a database, give that config `agent: 'admin'` — reserve it for configs where that's an acceptable risk (a disposable dev database, say), not production. ```yaml # Shape of the config's `access` field — set via `noorm ui` → Config → Edit, # or by editing the JSON before `config import` (see Config Sharing) access: user: admin # what you get in the CLI/TUI - mcp: viewer # what any connected agent gets + agent: viewer # what any connected agent gets ``` ## Invisible Configs -Set `access.mcp: false` to hide a config from MCP entirely — it never appears in `list_configs`, and `connect`/`getContext` fail with the same error an unknown config name would produce. An agent enumerating configs cannot tell the difference between "doesn't exist" and "exists but is off-limits." +Set `access.agent: false` to hide a config from agents entirely — over MCP it never appears in `list_configs`, and `connect`/`getContext` fail with the same error an unknown config name would produce. `noorm config list` filters it the same way when an agent runs it. An agent enumerating configs cannot tell the difference between "doesn't exist" and "exists but is off-limits." ## Security -The MCP server uses the identity and config of the shell session that spawned it. It has no way to escalate privileges beyond what `noorm` itself can do, and it cannot escalate past the `mcp` role a config was given — there is no `--force` override on this channel. +The MCP server uses the identity and config of the shell session that spawned it. It has no way to escalate privileges beyond what `noorm` itself can do, and it cannot escalate past the `agent` role a config was given — there is no `--force` override on this channel, and shelling out to the CLI reaches the same role rather than the human's. diff --git a/docs/guide/environments/configs.md b/docs/guide/environments/configs.md index ad19266e..ffb64acd 100644 --- a/docs/guide/environments/configs.md +++ b/docs/guide/environments/configs.md @@ -111,7 +111,7 @@ Every config has these fields: | `user` | No | Authentication username | | `password` | No | Authentication password (stored encrypted) | | `access.user` | No | CLI/TUI/SDK role: `viewer`, `operator`, or `admin` (default: `admin`) | -| `access.mcp` | No | MCP role: `viewer`, `operator`, `admin`, or `false` to hide from MCP (default: `admin`) | +| `access.agent` | No | AI agent role, over MCP and the CLI alike: `viewer`, `operator`, `admin`, or `false` to hide the config from agents (default: `viewer`) | | `isTest` | No | Marks as test database (default: false) | | `ssl` | No | SSL/TLS configuration | | `pool` | No | Connection pool settings | @@ -129,7 +129,9 @@ Every config has these fields: ## Access Roles -Production databases need safeguards. Every config carries an access role per **channel** — who's asking. `noorm ui` → Config → Edit sets `access.user` (the CLI/TUI/SDK) and `access.mcp` (an AI agent connected via MCP) independently, so a config can be wide open to you at the terminal while showing an agent only read access. +Production databases need safeguards. Every config carries an access role per **channel** — who's *driving*. `noorm ui` → Config → Edit sets `access.user` (a human) and `access.agent` (an AI agent) independently, so a config can be wide open to you at the terminal while showing an agent only read access. + +The channel is the caller, not the transport. An agent reaches noorm over MCP or by running `noorm` on the command line, and both get the `agent` role — the CLI recognizes the agent harness it was spawned from, so an agent that is refused over MCP cannot route around it by shelling out. | Role | Behavior | |------|----------| @@ -156,10 +158,26 @@ export NOORM_YES=1 noorm -c prod change run ``` -There is no equivalent skip on the MCP channel — an agent hitting a `confirm` cell always gets denied, redirected to the CLI. See [MCP](/guide/automation/mcp#access-roles) for the agent-facing side of this. +There is no equivalent skip on the `agent` channel — an agent hitting a `confirm` cell is always denied, and `NOORM_YES` / `--yes` do not change that. Otherwise the confirmation would be one flag away from meaningless. See [MCP](/guide/automation/mcp#access-roles) for the agent-facing side of this. + +**Which channel am I on?** + +noorm resolves the channel from the environment it was started in, in this order: + +1. `NOORM_CHANNEL=user` or `NOORM_CHANNEL=agent`, if set. +2. The variables agent harnesses export for their child processes — Claude Code, OpenAI Codex, Cursor, Gemini CLI, or the generic `AI_AGENT` / `NOORM_AGENT`. Any of those means `agent`. +3. Otherwise `user`. + +`TERM_PROGRAM`, `CI`, and whether stdout is a TTY are deliberately ignored: they describe the terminal or the pipeline, not the caller, and keying on them would lock a human out of their own CLI. + +Set `NOORM_CHANNEL=user` when *you* are scripting from inside an agent session and want your own role: + +```bash +NOORM_CHANNEL=user noorm -c prod change run +``` ::: warning Access Roles Are Not Security -Roles prevent accidents, not attacks. They won't stop a determined user or malicious script. Use proper database permissions for real security. +Roles prevent accidents, not attacks. They won't stop a determined user or malicious script — an agent can set `NOORM_CHANNEL` too. The channel defends against an agent routing around a refusal, which is the realistic case, not one setting out to evade the check. Use proper database permissions for real security. ::: @@ -192,6 +210,7 @@ Environment variables override stored config values. This is how you inject secr |----------|---------| | `NOORM_CONFIG` | Which config to use | | `NOORM_YES` | Skip confirmations (set to `1`) | +| `NOORM_CHANNEL` | Force the policy channel: `user` or `agent` (default: detected from the environment) | **Example: Override host for CI runner** diff --git a/docs/guide/environments/stages.md b/docs/guide/environments/stages.md index 05bdf48b..7b86a0f7 100644 --- a/docs/guide/environments/stages.md +++ b/docs/guide/environments/stages.md @@ -96,7 +96,7 @@ Some defaults are **enforced** and cannot be overridden in the TUI: | Default | Behavior | |---------|----------| -| `protected: true` | Acts as an access ceiling: whatever `access` the config or the developer sets, the *resolved* access is clamped to at most `{ user: 'operator', mcp: 'viewer' }` | +| `protected: true` | Acts as an access ceiling: whatever `access` the config or the developer sets, the *resolved* access is clamped to at most `{ user: 'operator', agent: 'viewer' }` | | `isTest: true` | Cannot be set to false in the TUI | | `dialect` | Cannot be changed after config creation | diff --git a/docs/headless.md b/docs/headless.md index f3d16537..2d82eaa6 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -149,6 +149,7 @@ All `NOORM_*` environment variables are processed through a nesting convention t |----------|---------| | `NOORM_CONFIG` | Select which stored config to use | | `NOORM_YES` | Skip confirmations (`1` or `true`) | +| `NOORM_CHANNEL` | Force the policy channel: `user` or `agent` (default: detected from the environment) | These are excluded from config nesting and consumed directly by the CLI. @@ -175,7 +176,7 @@ NOORM_PATHS_CHANGES → paths.changes ## Access Roles -Each config carries a per-channel access grant: `access: { user, mcp }`. The `user` role governs the CLI, TUI, and SDK; `mcp` governs the MCP server (see [MCP](./guide/automation/mcp.md)). Roles are fixed — `viewer`, `operator`, `admin` — and hard-coded to this matrix (cells: allow / confirm / deny): +Each config carries a per-channel access grant: `access: { user, agent }`. The `user` role governs a human at the CLI, TUI, or SDK; `agent` governs an AI agent, over MCP or the CLI (see [MCP](./guide/automation/mcp.md)). Roles are fixed — `viewer`, `operator`, `admin` — and hard-coded to this matrix (cells: allow / confirm / deny): | Command class | viewer | operator | admin | |---|---|---|---| @@ -192,7 +193,7 @@ Raw SQL (`noorm sql`) is gated by what the statement actually does, not by a fla `confirm` means: type the phrase `yes-` when prompted, or set `NOORM_YES=1` to skip the prompt in CI. There is no `--force` override for a denied permission — `--force` only skips file checksums (see [Common Flags](#common-flags)). -**Migration note:** the old `Config.protected: boolean` maps automatically on first load — `protected: true` becomes `{ user: 'operator', mcp: 'viewer' }`, `protected: false` (or absent) becomes the default `{ user: 'admin', mcp: 'viewer' }`. A config that already stores an explicit `access` keeps it untouched. The legacy field is still accepted on `config import` for one version, then dropped. +**Migration note:** the old `Config.protected: boolean` maps automatically on first load — `protected: true` becomes `{ user: 'operator', agent: 'viewer' }`, `protected: false` (or absent) becomes the default `{ user: 'admin', agent: 'viewer' }`. A config that already stores an explicit `access` keeps it untouched. The legacy field is still accepted on `config import` for one version, then dropped. ## Commands diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index c36b7ef7..fdc31166 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -50,7 +50,7 @@ interface CreateContextOptions { config?: string; // Config name (or use NOORM_CONFIG env var) projectRoot?: string; // Defaults to process.cwd() requireTest?: boolean; // Refuse if config.isTest !== true - channel?: Channel; // 'user' (default) or 'mcp' — which access role applies + channel?: Channel; // 'user' (default) or 'agent' — which access role applies stage?: string; // Stage name for stage defaults } @@ -70,7 +70,7 @@ const ctx = await createContext({ | `channel` | `Channel` | Which caller channel this context represents for access-policy checks. Defaults to `'user'`. A destructive operation the config's role denies — or that resolves to "requires confirmation," which the SDK can't prompt for — throws `ProtectedConfigError`. | | `stage` | `string` | Stage name for inheriting stage defaults. | -> Access is per-config, not per-context: each config declares `access: { user, mcp }` (roles `viewer`/`operator`/`admin`, or `mcp: false` to hide the config from the `mcp` channel entirely). `channel` tells `createContext` which half of that grant to enforce — see [Access Roles](/guide/environments/configs#access-roles). +> Access is per-config, not per-context: each config declares `access: { user, agent }` (roles `viewer`/`operator`/`admin`, or `agent: false` to hide the config from agents entirely, over MCP and the CLI alike). `channel` tells `createContext` which half of that grant to enforce — see [Access Roles](/guide/environments/configs#access-roles). ## Top-Level Context Properties diff --git a/docs/spec/config-access-roles.md b/docs/spec/config-access-roles.md index 733c9779..7be95952 100644 --- a/docs/spec/config-access-roles.md +++ b/docs/spec/config-access-roles.md @@ -8,24 +8,26 @@ The body of this spec is current truth. Superseded decisions live only in the ch ## Objective -Replace `Config.protected: boolean` with config-scoped, channel-keyed roles enforced by one central policy check. Add MCP invisibility (`mcp: false`). Gate raw SQL by statement classification. Close the existing gap where MCP `change_run`/`change_ff`/`change_revert`/`run_file`/`run_build` bypass protection entirely. +Replace `Config.protected: boolean` with config-scoped, channel-keyed roles enforced by one central policy check. Add MCP invisibility (`agent: false`). Gate raw SQL by statement classification. Close the existing gap where MCP `change_run`/`change_ff`/`change_revert`/`run_file`/`run_build` bypass protection entirely. ## Data model type Role = 'viewer' | 'operator' | 'admin' - type Channel = 'user' | 'mcp' + type Channel = 'user' | 'agent' interface ConfigAccess { user: Role; - mcp: Role | false; + agent: Role | false; } -- `Config` gains `access?: ConfigAccess` — optional in the type until CP6 because CP4/CP5-owned files (`src/cli/ci/init.ts`, TUI config screens, app-context) construct `Config` literals without it; every config materialized through `parseConfig`/state load has it populated. **CP6 makes the field required** once those constructors are updated. Zod default when absent: `{ user: 'admin', mcp: 'viewer' }` — the agent channel is never privileged without an explicit opt-in, because a stock project writes no `access` at all. +- `Config` gains `access?: ConfigAccess` — optional in the type until CP6 because CP4/CP5-owned files (`src/cli/ci/init.ts`, TUI config screens, app-context) construct `Config` literals without it; every config materialized through `parseConfig`/state load has it populated. **CP6 makes the field required** once those constructors are updated. Zod default when absent: `{ user: 'admin', agent: 'viewer' }` — the agent channel is never privileged without an explicit opt-in, because a stock project writes no `access` at all. - `ConfigSummary` gains required `access: ConfigAccess`. - Until CP6, `Config.protected` remains present and is **derived at load**: `protected = (access.user !== 'admin')`. It is never persisted (state `persist()` strips it; the zod schema never emits a stored value). No caller may write `protected` after CP2. CP6 deletes the field everywhere except the state migration parser. -- Enforcement code must not trust the optionality: on the `mcp` channel, a config with absent `access` is **denied** (fail closed) — in practice unreachable, since configs reach enforcement via parse/migration. +- Enforcement code must not trust the optionality: on the `agent` channel, a config with absent `access` is **denied** (fail closed) — in practice unreachable, since configs reach enforcement via parse/migration. +- `Channel` names *who is driving*, not which binary was invoked. Callers never hardcode it outside the MCP server: `resolveChannel(env?)` (`src/core/policy/channel.ts`) resolves it as (1) `NOORM_CHANNEL` when exactly `user` or `agent`, (2) `isAgentSession()` (`src/core/policy/harness.ts`, an allowlist of harness-set variables) → `agent`, (3) `user`. `mcp serve` constructs `new SessionManager('agent')` literally and never calls `resolveChannel`, so stdio traffic is `agent` even under `NOORM_CHANNEL=user`. The TUI keeps a literal `'user'` — it needs a TTY and an interactive human. +- Stored state migrates `access.mcp` → `access.agent` verbatim at state schema **v3** (`src/core/version/state/migrations/v3.ts`), preserving `false`. v2 already emits the new key (it shares `repairConfigAccess`), so v3 is a no-op on anything coming up through it. ## Permissions and matrix @@ -66,13 +68,13 @@ Matrix (cells: `allow` / `confirm` / `deny`), hard-coded in `src/core/policy/`: Channel resolution of `confirm`: - `user`: `allowed: true, requiresConfirmation: true, confirmationPhrase: 'yes-'`. `NOORM_YES=1` (`shouldSkipConfirmations()`) resolves it to plain allow. -- `mcp`: `allowed: false`, blockedReason directs to the CLI. `NOORM_YES` has no effect on the mcp channel. +- `agent`: `allowed: false`, blockedReason directs to the CLI. `NOORM_YES` has no effect on the agent channel. -`mcp: false` is not a role: policy is never consulted for an invisible config — visibility is enforced before policy (see Enforcement). +`agent: false` is not a role: policy is never consulted for an invisible config — visibility is enforced before policy (see Enforcement). Helper: `guarded(config): boolean = config.access.user !== 'admin'` — used by TUI styling and settings rule matching. Display-only; never an enforcement input. -The `config list` access tag is keyed to the default instead: `formatAccessTag` renders `user: mcp:` for any config whose access differs from the default, and `null` otherwise. `guarded` cannot drive it, because `mcp: 'admin'` is an escalation the user channel can't see. +The `config list` access tag is keyed to the default instead: `formatAccessTag` renders `user: agent:` for any config whose access differs from the default, and `null` otherwise. `guarded` cannot drive it, because `agent: 'admin'` is an escalation the user channel can't see. ## SQL classification @@ -95,13 +97,13 @@ Lives in `src/core/policy/classify.ts` (moved and generalized from `src/rpc/prot 1. **RPC**: `RpcCommand` gains required `permission: Permission | 'open'`. `'open'` = no config-scoped gate (`list_configs`, `connect`, `disconnect`, `noorm_help` internals); every other command declares its permission. Assignments: explore/overview/list/detail → `explore`; sql → `sql:read` (handler escalates: run `classifyStatements` and re-check with the actual class before executing); change_history → `explore`; change_run → `change:run`; change_ff → `change:ff`; change_revert → `change:revert`; run_build → `run:build`; run_file → `run:file`. -2. **Gate location**: `run_noorm_cmd` dispatch in `src/mcp/server.ts`. For non-`'open'` commands: resolve target config (explicit `config` arg, else session active config), then `checkPolicy('mcp', config, cmd.permission)`; deny → `isError` result with `blockedReason`, handler never runs. -3. **Channel ownership**: `SessionManager` is constructed with a `Channel` (`'mcp'` in `mcp serve`). It exposes the channel to the gate. -4. **Invisibility** (`mcp: false`, mcp channel only): `list_configs` omits the config; `SessionManager.connect` and `getContext` throw the **byte-identical** error an unknown config name produces (assert equality in tests). Session info returned by `connect` reports the channel's effective role instead of `protected`. +2. **Gate location**: `run_noorm_cmd` dispatch in `src/mcp/server.ts`. For non-`'open'` commands: resolve target config (explicit `config` arg, else session active config), then `checkPolicy('agent', config, cmd.permission)`; deny → `isError` result with `blockedReason`, handler never runs. +3. **Channel ownership**: `SessionManager` is constructed with a `Channel` (`'agent'` in `mcp serve`). It exposes the channel to the gate. +4. **Invisibility** (`agent: false`, agent channel only): `list_configs` omits the config; `SessionManager.connect` and `getContext` throw the **byte-identical** error an unknown config name produces (assert equality in tests). Session info returned by `connect` reports the channel's effective role instead of `protected`. 5. **SDK**: `CreateContextOptions` gains `channel?: Channel` (default `'user'`). `src/sdk/guards.ts` destructive-op checks call `checkPolicy` instead of reading `protected`. Guard permission mapping: `db.truncate`/`db.teardown`/`db.reset`/`dt.importFile` → `db:reset` (data-destructive, admin frictionless — preserves pre-migration behavior for open configs, which is the migration section's promise); `changes.revert`/`changes.rewind` → `change:revert`; `changes.run`/`changes.ff` → `change:run`/`change:ff`; `run.file`/`run.dir`/`run.build` → `run:file`/`run:dir`/`run:build`; data transfer into a target → `db:reset`. In the SDK (no prompt available), `requiresConfirmation` blocks with a message naming `NOORM_YES=1` and the CLI/TUI; `checkPolicy` already resolves `NOORM_YES=1` to allow on the user channel. On deny, the thrown error carries the policy's `blockedReason` (role + permission), and `ProtectedConfigError.operation` names the public method the caller invoked. 5a. **Core-seam enforcement** (the "enforced above the driver" promise): the user-channel gate lives at the core boundary each surface funnels through — `runFile`/`runFiles` (`core/runner`), data transfer (`core/transfer`), the SQL terminal executor (ad-hoc SQL → `classifyStatements` then gate), and `changes.run`/`ff`/`revert` (`core/change`). The `Context` carries its `Channel`, so SDK, TUI, and CLI callers all inherit one enforcement path rather than each re-implementing checks per screen/namespace. Run/change files gate on their command permission (not content-classified); only the ad-hoc SQL terminal is classified. 6. **CLI/TUI**: inherit enforcement from the core seam (5a). `SmartConfirm` takes `requiresConfirmation` + `confirmationPhrase` instead of `protected: boolean`; `ProtectedConfirm` keeps the `yes-` phrase flow, driven by `confirmationPhrase` — sourced from one `confirmationPhraseFor(name)` helper in `src/core/policy/`, never re-templated per screen. The matrix is the enforcement **floor** — screens may keep stricter confirm UX than the cell requires (TUI teardown/truncate retain their unconditional phrase-confirm for all roles, and additionally deny when `checkConfigPolicy('user', config, 'db:reset')` says deny). -7. **Settings stages**: stage `protected: true` becomes an access **ceiling** at resolution (`src/core/config/resolver.ts`): resolved access is clamped to at most `{ user: 'operator', mcp: 'viewer' }`; stricter survives, looser is clamped. Settings rule `match.protected` matches `guarded(config)`. Stage/rule YAML vocabulary is unchanged in this issue. +7. **Settings stages**: stage `protected: true` becomes an access **ceiling** at resolution (`src/core/config/resolver.ts`): resolved access is clamped to at most `{ user: 'operator', agent: 'viewer' }`; stricter survives, looser is clamped. Settings rule `match.protected` matches `guarded(config)`. Stage/rule YAML vocabulary is unchanged in this issue. ## Migration @@ -109,9 +111,9 @@ Lives in `src/core/policy/classify.ts` (moved and generalized from `src/rpc/prot New state migration in `src/core/version/state/migrations/` (registered in `src/core/version/state/index.ts`, bump `CURRENT_VERSIONS.state`): -- `protected: true` → `access: { user: 'operator', mcp: 'viewer' }` -- `protected: false` or absent → `access: { user: 'admin', mcp: 'viewer' }` (the default; `protected: false` said "no restriction requested", which is not a grant of agent admin) -- A config that already stores an explicit `access` is left exactly as found, including `mcp: 'admin'`. +- `protected: true` → `access: { user: 'operator', agent: 'viewer' }` +- `protected: false` or absent → `access: { user: 'admin', agent: 'viewer' }` (the default; `protected: false` said "no restriction requested", which is not a grant of agent admin) +- A config that already stores an explicit `access` is left exactly as found, including `agent: 'admin'`. - Stored `protected` field dropped from the persisted shape after migration. `ConfigSchema` (zod) accepts a legacy `protected` key on input for one version (feeding the same mapping) and never emits it. @@ -126,11 +128,11 @@ Each checkpoint ends green: `bash tmp/run-test-groups.sh` (mirrors CI's four fre |---|---|---|---| | 1 | Policy core, additive, unwired: types, matrix, `checkPolicy`, `guarded`, `classifyStatements` | new `src/core/policy/{types,matrix,check,classify,index}.ts`; new tests `tests/core/policy/` | Table-driven tests cover every matrix cell × both channels; classifier tests cover read/write/ddl, multi-statement max, CTE, EXEC/CALL→ddl, unparseable→ddl, empty→read. Existing suite untouched. | | 2 | Config carries `access`: types, zod schema + default, state migration, resolver stage-ceiling clamp, `ConfigSummary.access`, `protected` becomes derived-at-load | `src/core/config/{types,schema,resolver}.ts`, `src/core/state/manager.ts`, `src/core/version/state/*`, `src/core/settings/rules.ts` (match via `guarded`) | Migration test: v-prev state with protected true/false loads with mapped access and no stored `protected`; resolver clamp tests (stricter survives, looser clamped); all existing tests green with derived `protected`. | -| 3 | MCP enforcement: `RpcCommand.permission`, dispatch gate, sql escalation via classifier, invisibility, session channel + role in session info | `src/rpc/{types,registry,session}.ts`, `src/rpc/commands/*.ts`, `src/mcp/server.ts`, `src/mcp/init.ts`; delete `src/rpc/protection.ts` after rewiring `query.ts` | Gate tests: viewer denies change_run/sql-write/ddl; operator denies change_run (confirm→deny) but allows sql write; admin allows; `mcp: false` absent from list_configs and connect error byte-identical to unknown config (string-equality assertion); sql on viewer allows SELECT, denies INSERT. | +| 3 | MCP enforcement: `RpcCommand.permission`, dispatch gate, sql escalation via classifier, invisibility, session channel + role in session info | `src/rpc/{types,registry,session}.ts`, `src/rpc/commands/*.ts`, `src/mcp/server.ts`, `src/mcp/init.ts`; delete `src/rpc/protection.ts` after rewiring `query.ts` | Gate tests: viewer denies change_run/sql-write/ddl; operator denies change_run (confirm→deny) but allows sql write; admin allows; `agent: false` absent from list_configs and connect error byte-identical to unknown config (string-equality assertion); sql on viewer allows SELECT, denies INSERT. | | 4 | User-channel core: SDK guards via `checkPolicy`, `CreateContextOptions.channel`, CLI consumers (`ci/init` access defaults, `config/list` access display, `db/drop` policy check) | `src/sdk/{types,index,guards}.ts`, `src/cli/ci/init.ts`, `src/cli/config/list.ts`, `src/cli/db/drop.ts` | `tests/sdk/guards.test.ts` + `destructive-ops.test.ts` rewritten against access; CLI behavior covered by existing tests updated to fixtures with `access`. | -| 5 | TUI: `SmartConfirm`/`ProtectedConfirm` consume `PolicyCheck`; 12 screens swap `activeConfig.protected` for `checkPolicy`/`guarded`; ConfigAdd/Edit edit `access.user` + `access.mcp` (incl. `false`); Export/Import carry `access`; app-context stage defaults | `src/tui/components/dialogs/*.tsx`, `src/tui/screens/{change,config,db,run,lock,settings}/*.tsx`, `src/tui/app-context.tsx` | Typecheck green with zero `protected` reads in `src/tui/` except settings-stage vocabulary; existing TUI tests (if any) green. | +| 5 | TUI: `SmartConfirm`/`ProtectedConfirm` consume `PolicyCheck`; 12 screens swap `activeConfig.protected` for `checkPolicy`/`guarded`; ConfigAdd/Edit edit `access.user` + `access.agent` (incl. `false`); Export/Import carry `access`; app-context stage defaults | `src/tui/components/dialogs/*.tsx`, `src/tui/screens/{change,config,db,run,lock,settings}/*.tsx`, `src/tui/app-context.tsx` | Typecheck green with zero `protected` reads in `src/tui/` except settings-stage vocabulary; existing TUI tests (if any) green. | | 6 | Kill `protected`: delete field from `Config`/`ConfigSummary`/session info, delete `src/core/config/protection.ts` + its exports, sweep remaining reads, update all test fixtures | `src/core/config/{types,schema,index}.ts`, remaining consumers, `tests/**` fixtures | `rg -n '\.protected' src/ tests/` returns only settings stage/rule vocabulary and the state-migration parser; full suite + both typechecks green. | -| 7 | Docs + changeset: MCP guide access section, headless docs, config reference; minor changeset | `docs/guide/automation/mcp.md`, `docs/headless.md`, `docs/dev/headless.md`, config reference page, `.changeset/.md` | Docs describe access model (matrix, `mcp: false`, migration note); changeset present with `minor`. | +| 7 | Docs + changeset: MCP guide access section, headless docs, config reference; minor changeset | `docs/guide/automation/mcp.md`, `docs/headless.md`, `docs/dev/headless.md`, config reference page, `.changeset/.md` | Docs describe access model (matrix, `agent: false`, migration note); changeset present with `minor`. | | 8 | **Post-swarm hardening** (challenge-swarm findings). Classifier: CTE-DML + destructive-function denylist. Core-seam user-channel enforcement (run/change/transfer/sql-terminal). Correctness: SDK deny carries `blockedReason`, `ProtectedConfigError.operation` labels, single-source `confirmationPhraseFor`, fix TUI `guarded` fail-open vs `checkConfigPolicy` fail-closed mismatch. Hygiene: permission-value test, delete dead `stageEnforcesProtected`, fix two false-confidence tests, honest changeset prose. | `src/core/policy/{classify,check}.ts`, `src/core/{runner,transfer,change,sql-terminal}/*`, `src/sdk/**`, `src/tui/**`, `tests/**`, `.changeset/*` | viewer denied on CTE-DML + denylisted funcs (probe + tests); run/transfer/sql-terminal gated on user channel; permission-value table test pins every command; dead code gone; false-confidence tests each have one failing behavior; all groups + both typechecks green. | **Deferred to follow-ups (out of scope for this PR — recorded in `.claude/project/followups/`):** downgrade version-guard + `state.enc.bak` (alpha, per product owner); `state.enc` atomic write + inter-process lock (pre-existing durability, broader than access-roles); policy-denial observability + MCP logger init (separate observability workstream); legacy `protected` input-path removal trigger keyed to a version; invisibility timing side-channel; single `ROLE_ORDER` constant. @@ -152,8 +154,9 @@ Each checkpoint ends green: `bash tmp/run-test-groups.sh` (mirrors CI's four fre - 2026-07-07 — Initial spec from design doc + scoping report (issue #40). - 2026-07-07 — CP1: `checkPolicy` takes a structural `PolicyTarget` (Config lacks `access` until CP2). Verification method corrected to CI-mirrored four-group runs. -- 2026-07-07 — CP2: `Config.access` optional until CP6 (CP4/CP5-owned constructors); fail-closed rule added for absent access on the mcp channel. Stage `protected: true` override-block in `checkConfigCompleteness` replaced by the ceiling clamp (the old "stored wins" behavior was the bug the clamp fixes). +- 2026-07-07 — CP2: `Config.access` optional until CP6 (CP4/CP5-owned constructors); fail-closed rule added for absent access on the agent channel. Stage `protected: true` override-block in `checkConfigCompleteness` replaced by the ceiling clamp (the old "stored wins" behavior was the bug the clamp fixes). - 2026-07-07 — CP4: added `db:reset` permission (viewer deny / operator confirm / admin allow) for data-destructive-but-not-drop operations; the initial CP4 mapping of truncate/teardown/reset/importFile to `db:destroy` violated the migration section's behavior-preservation promise for open configs. - 2026-07-08 — CP8 (post challenge-swarm): classifier now catches CTE-wrapped DML and a destructive-function denylist (viewer write-bypass was confirmed critical). User-channel enforcement moved to the core seam so run/change/transfer/sql-terminal are gated for SDK+TUI+CLI (the earlier "same checkPolicy" claim was only partly delivered). Correctness + hygiene fixes per the swarm. Downgrade guard, state.enc durability/atomicity, and denial observability explicitly deferred (alpha; pre-existing; separate workstreams). - 2026-07-13 — `change:rm` added (viewer deny / operator confirm / admin confirm — admin gets confirm not allow because deleting an applied change also deletes its DB tracking row), refs `docs/spec/v1-44-change-rm-gate.md`. -- 2026-07-29 — Default access is `{ user: 'admin', mcp: 'viewer' }`. **Superseded:** the default was `{ user: 'admin', mcp: 'admin' }`, so a config that never declared `access` — which is every config in a stock project — handed the agent channel admin, and no gate in the matrix constrained an MCP client. `viewer` keeps the agent's real job (explore, `sql:read`) while write, DDL, destructive, and credential permissions need an explicit opt-in; `mcp: false` was rejected because invisibility reports the same error as an unknown config and leaves an operator no way to diagnose it. Configs already storing an explicit `access` are untouched, including ones that stored `admin/admin` under the old default. `formatAccessTag` re-keyed from `guarded()` to "differs from the default" so `mcp: 'admin'` shows up in `config list` instead of reading as unremarkable. +- 2026-07-29 — Default access is `{ user: 'admin', agent: 'viewer' }`. **Superseded:** the default was `{ user: 'admin', agent: 'admin' }`, so a config that never declared `access` — which is every config in a stock project — handed the agent channel admin, and no gate in the matrix constrained an MCP client. `viewer` keeps the agent's real job (explore, `sql:read`) while write, DDL, destructive, and credential permissions need an explicit opt-in; `agent: false` was rejected because invisibility reports the same error as an unknown config and leaves an operator no way to diagnose it. Configs already storing an explicit `access` are untouched, including ones that stored `admin/admin` under the old default. `formatAccessTag` re-keyed from `guarded()` to "differs from the default" so `agent: 'admin'` shows up in `config list` instead of reading as unremarkable. +- 2026-07-29 — `Channel` names the caller, not the transport: `'user' | 'agent'`, and `ConfigAccess` is `{ user, agent }`. **Superseded:** `Channel` was `'user' | 'mcp'` and the CLI hardcoded `'user'` at every policy call site, so an agent refused a write over MCP could shell out to `noorm` and run it with the human's role — on a stock config that turned deny into allow for `sql:write`, `sql:ddl`, `db:create`, `run:build` and `vault:read`, and turned `db:destroy` into a confirm that `--yes` satisfied. The CLI now resolves the channel from provenance (`resolveChannel` in `src/core/policy/channel.ts`): `NOORM_CHANNEL` if explicitly `user`/`agent`, else an allowlist of agent-harness variables (`isAgentSession`, `src/core/policy/harness.ts`), else `user`; `mcp serve` still passes `'agent'` literally, above the override. `TERM_PROGRAM`, `CI` and TTY state are excluded — they describe the terminal or the pipeline, not the caller, and a false positive locks a human out of their own CLI. `agent: false` now hides a config on both transports (`noorm config list` filters it the way `list_configs` already did). Stored `access.mcp` migrates to `access.agent` verbatim at state schema v3. Detection is evadable by design; it defends against an agent routing around a refusal, not one deliberately evading. diff --git a/docs/tui.md b/docs/tui.md index 0fd639d4..5995f8bc 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -143,7 +143,7 @@ Home > Configurations - `●` indicates active config - `○` indicates inactive config - `>` indicates cursor position -- `[user: mcp:]` tag shows access roles for any config whose access differs from the default (`user: admin`, `mcp: viewer`) — omitted entirely for configs still on the default +- `[user: agent:]` tag shows access roles for any config whose access differs from the default (`user: admin`, `agent: viewer`) — omitted entirely for configs still on the default - `[test]` tag shows test configs - Press `Enter` on a config to activate it diff --git a/docs/wiki/cli.md b/docs/wiki/cli.md index e4b9196b..427757e3 100644 --- a/docs/wiki/cli.md +++ b/docs/wiki/cli.md @@ -71,6 +71,6 @@ Published as `@noormdev/cli` from [`packages/cli/`](../../packages/cli). - `--yes` / `-y` flag suppresses all confirmation prompts (headless mode). - `--json` flag formats output as machine-readable JSON. - Build produces a standalone binary via `bun build --compile` — worker paths must use `resolveWorker()`. -- `config list` ([`src/cli/config/list.ts`](../../src/cli/config/list.ts)) prints an access tag (`user: mcp:`) instead of a `protected` flag, shown only when `guarded(config)` is true. +- `config list` ([`src/cli/config/list.ts`](../../src/cli/config/list.ts)) prints an access tag (`user: agent:`) instead of a `protected` flag, shown only when `guarded(config)` is true. - `db drop` ([`src/cli/db/drop.ts`](../../src/cli/db/drop.ts)) no longer requires `--yes` unconditionally — it's only required when the resolved `db:destroy` policy check returns `requiresConfirmation`. - Workspace package `@noormdev/cli` publishes the pre-built binary; `postinstall.js` extracts it. diff --git a/docs/wiki/core-policy.md b/docs/wiki/core-policy.md index 5081119e..99308ebf 100644 --- a/docs/wiki/core-policy.md +++ b/docs/wiki/core-policy.md @@ -6,17 +6,17 @@ type: Domain ## What it does -Single access-control layer for every config-scoped action across every caller channel (CLI, TUI, SDK, MCP). Roles live on the config (`ConfigAccess`), not the actor — the actor is a `Channel` (`user` or `mcp`) — and a hard-coded permission × role matrix (`MATRIX`) resolves `allow`/`confirm`/`deny` per action. Replaces the removed `Config.protected: boolean` and the deleted `src/core/config/protection.ts` / `src/rpc/protection.ts` rule checkers. +Single access-control layer for every config-scoped action across every caller channel (CLI, TUI, SDK, MCP). Roles live on the config (`ConfigAccess`), not the actor — the actor is a `Channel` (`user` or `agent`) — and a hard-coded permission × role matrix (`MATRIX`) resolves `allow`/`confirm`/`deny` per action. Replaces the removed `Config.protected: boolean` and the deleted `src/core/config/protection.ts` / `src/rpc/protection.ts` rule checkers. Also owns raw-SQL statement classification (`read`/`write`/`ddl`, with a destructive-function denylist) used to gate ad-hoc SQL, and the one-version `protected` boolean → `access` migration path (`resolveLegacyAccess`). ## CLI code -- [`src/core/policy/types.ts`](../../src/core/policy/types.ts) — `Role` (`viewer`/`operator`/`admin`), `Channel` (`user`/`mcp`), `ConfigAccess` (`{ user: Role; mcp: Role | false }`), `Permission`, `PolicyTarget`, `PolicyCell` (`allow`/`confirm`/`deny`), `PolicyCheck` +- [`src/core/policy/types.ts`](../../src/core/policy/types.ts) — `Role` (`viewer`/`operator`/`admin`), `Channel` (`user`/`agent`), `ConfigAccess` (`{ user: Role; agent: Role | false }`), `Permission`, `PolicyTarget`, `PolicyCell` (`allow`/`confirm`/`deny`), `PolicyCheck` - [`src/core/policy/matrix.ts`](../../src/core/policy/matrix.ts) — `MATRIX`; the hard-coded `Permission × Role → PolicyCell` table (not user-extensible), mirroring [`docs/spec/config-access-roles.md`](../spec/config-access-roles.md) - [`src/core/policy/check.ts`](../../src/core/policy/check.ts) — `checkPolicy`, `checkConfigPolicy`, `assertPolicy`, `guarded`, `confirmationPhraseFor`; the enforcement entrypoints every caller reaches for - [`src/core/policy/classify.ts`](../../src/core/policy/classify.ts) — `classifyStatements`; SQL-parser-cst-based statement classifier with a keyword-based fallback, a CTE-DML upgrade rule, and `DESTRUCTIVE_FUNCTIONS` denylist (e.g. `pg_terminate_backend`, `lo_import`, `setval`) -- [`src/core/policy/legacy-access.ts`](../../src/core/policy/legacy-access.ts) — `resolveLegacyAccess`, `DEFAULT_ACCESS` (`{ user: 'admin', mcp: 'viewer' }`), `GUARDED_ACCESS` (`{ user: 'operator', mcp: 'viewer' }`) +- [`src/core/policy/legacy-access.ts`](../../src/core/policy/legacy-access.ts) — `resolveLegacyAccess`, `DEFAULT_ACCESS` (`{ user: 'admin', agent: 'viewer' }`), `GUARDED_ACCESS` (`{ user: 'operator', agent: 'viewer' }`) - [`src/core/policy/index.ts`](../../src/core/policy/index.ts) — barrel export for all of the above ## Docs @@ -38,8 +38,8 @@ Also owns raw-SQL statement classification (`read`/`write`/`ddl`, with a destruc ## Conventions worth knowing -- `checkPolicy`'s `confirm` cell resolves differently per channel: `user` prompts for `yes-` (`confirmationPhraseFor`, skippable via `NOORM_YES`), `mcp` collapses `confirm` to `deny` — there's no human on the other end of MCP stdio to type a phrase. -- `mcp: false` (invisible config) is never a role and is not expected to reach `checkPolicy` — visibility is enforced upstream (`SessionManager.connect`, `list_configs`). +- `checkPolicy`'s `confirm` cell resolves differently per channel: `user` prompts for `yes-` (`confirmationPhraseFor`, skippable via `NOORM_YES`), `agent` collapses `confirm` to `deny` — an agent confirming its own destructive action is theater. +- `agent: false` (invisible config) is never a role and is not expected to reach `checkPolicy` — visibility is enforced upstream (`SessionManager.connect`, `list_configs`). - `checkConfigPolicy` fails closed: a config with no `access` at all is denied on every channel. - `classifyStatements` fails closed to `ddl` for anything it can't positively classify as `read` or `write` — an unrecognized statement could do anything. - `DESTRUCTIVE_FUNCTIONS` is a denylist, not an allowlist, by design: `SELECT f()` is statically undecidable, so only known-dangerous builtins upgrade a `SELECT` to `write`. diff --git a/docs/wiki/core-state.md b/docs/wiki/core-state.md index 747e0335..265c9fe1 100644 --- a/docs/wiki/core-state.md +++ b/docs/wiki/core-state.md @@ -23,7 +23,7 @@ Each config carries `access: ConfigAccess` (per-channel role pair, replacing the - [`src/core/settings/defaults.ts`](../../src/core/settings/defaults.ts) — `DEFAULT_SETTINGS` - [`src/core/settings/events.ts`](../../src/core/settings/events.ts) — settings-related observer event types - [`src/core/config/index.ts`](../../src/core/config/index.ts) — `makeNestedConfig`; builds config object from env at module scope (known contamination source — see CLAUDE.md) -- [`src/core/config/resolver.ts`](../../src/core/config/resolver.ts) — `resolveConfig`, `SettingsProvider`; picks active config from state + settings. `applyStageCeiling` clamps a resolved config's `access` down to `{ user: 'operator', mcp: 'viewer' }` when the linked stage sets `protected: true` — replaces the old hard-violation check in `checkConfigCompleteness` +- [`src/core/config/resolver.ts`](../../src/core/config/resolver.ts) — `resolveConfig`, `SettingsProvider`; picks active config from state + settings. `applyStageCeiling` clamps a resolved config's `access` down to `{ user: 'operator', agent: 'viewer' }` when the linked stage sets `protected: true` — replaces the old hard-violation check in `checkConfigCompleteness` - [`src/core/config/schema.ts`](../../src/core/config/schema.ts) — config schema validation; `withResolvedAccess` maps a legacy `protected: boolean` input to `access: ConfigAccess` via `resolveLegacyAccess` (`core/policy`) - [`src/core/lifecycle/manager.ts`](../../src/core/lifecycle/manager.ts) — `LifecycleManager`; shutdown phase orchestration, signal handlers - [`src/core/lifecycle/handlers.ts`](../../src/core/lifecycle/handlers.ts) — signal/exception handler registration @@ -66,5 +66,5 @@ Each config carries `access: ConfigAccess` (per-channel role pair, replacing the - `observer` is a module-scope singleton; `resetConnectionManager`/`resetSettingsManager`/`resetStateManager` are test-only reset points. - Stages in settings allow per-environment config overrides; `evaluateRules` applies them at runtime. - `initProjectContext` is the canonical startup sequence called by CLI entry and SDK `createContext`. -- `Config.access` defaults to `{ user: 'admin', mcp: 'viewer' }` (`DEFAULT_ACCESS`) when absent; a legacy `protected: true` maps to `{ user: 'operator', mcp: 'viewer' }` (`GUARDED_ACCESS`) — both constants live in [`src/core/policy/legacy-access.ts`](../../src/core/policy/legacy-access.ts). +- `Config.access` defaults to `{ user: 'admin', agent: 'viewer' }` (`DEFAULT_ACCESS`) when absent; a legacy `protected: true` maps to `{ user: 'operator', agent: 'viewer' }` (`GUARDED_ACCESS`) — both constants live in [`src/core/policy/legacy-access.ts`](../../src/core/policy/legacy-access.ts). - `src/core/config/protection.ts` (hard-block rules for protected configs) was deleted — access enforcement now runs entirely through `core/policy`. diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 2d85e4a5..cc328616 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -86,6 +86,6 @@ CI gate: lint → typecheck → build → 4 test groups → 3 example jobs. Inte **Domain partitioning basis:** Domains are functional vertical slices. `core-state` groups the startup/persistence concerns (state, settings, config, lifecycle, version) because they all initialize together in `project-init.ts`. `core-identity` groups crypto identity, vault, logger, and SQL terminal because they share the "user-facing sensitive data" concern. `core-db` groups connection, explore, transfer, and teardown because they all operate against a live database connection. `core-change` and `core-runner` are separate because changes are versioned operations while runner handles idempotent file execution — they share `runFile` but have distinct lifecycles. `core-policy` is a new cross-cutting domain ([`src/core/policy/`](../../src/core/policy)): domains that enforce a config-scoped action via `assertPolicy`/`checkConfigPolicy` (`core-change`, `core-runner`, `core-db`, `core-identity`, `sdk`, `cli`, `tui`, `mcp-rpc`) import from it directly, and `core-state` imports it too but only for `resolveLegacyAccess`/`guarded` — data resolution and display styling, not enforcement. The role×permission matrix and SQL classifier live nowhere else, so `core-policy` gets its own vertical slice rather than being folded into `core-state`. -**Access-control policy (2026-07, config-access-roles feature):** `Config.protected: boolean` was replaced by `Config.access: ConfigAccess` (per-channel `user`/`mcp` roles), enforced through the new `core-policy` domain. `src/core/config/protection.ts` and `src/rpc/protection.ts` were both deleted — their rule-checking is absorbed into `core/policy`. The runner/change/transfer/sql-terminal executors gate at their core seam via `assertPolicy`, so SDK/CLI/TUI/MCP callers all inherit one enforcement path. `StateManager.load()` now also runs the schemaVersion-keyed migration (`core/version/state/`, v2 maps `protected`→`access`) ahead of the pre-existing package-semver migration — previously only the semver path ran here. +**Access-control policy (2026-07, config-access-roles feature):** `Config.protected: boolean` was replaced by `Config.access: ConfigAccess` (per-channel `user`/`agent` roles), enforced through the new `core-policy` domain. `src/core/config/protection.ts` and `src/rpc/protection.ts` were both deleted — their rule-checking is absorbed into `core/policy`. The runner/change/transfer/sql-terminal executors gate at their core seam via `assertPolicy`, so SDK/CLI/TUI/MCP callers all inherit one enforcement path. `StateManager.load()` now also runs the schemaVersion-keyed migration (`core/version/state/`, v2 maps `protected`→`access`) ahead of the pre-existing package-semver migration — previously only the semver path ran here. **Deterministic substrate:** `.claude/project/deterministic-signals.md` (generated 2026-06-01T02:30:22Z, atomic 3.0.0) diff --git a/docs/wiki/mcp-rpc.md b/docs/wiki/mcp-rpc.md index 4f428d7a..cbeb479f 100644 --- a/docs/wiki/mcp-rpc.md +++ b/docs/wiki/mcp-rpc.md @@ -16,9 +16,9 @@ Every `RpcCommand` declares a `permission: Permission | 'open'` (`core/policy` p - [`src/mcp/init.ts`](../../src/mcp/init.ts) — `initMcpServer`; initializes RPC registry, registers all commands, wires session - [`src/mcp/index.ts`](../../src/mcp/index.ts) — barrel export - [`src/rpc/registry.ts`](../../src/rpc/registry.ts) — `RpcRegistry`; flat `Map` with register/get/list -- [`src/rpc/session.ts`](../../src/rpc/session.ts) — `SessionManager`; tracks active Kysely connections per config name. Carries the session's `channel` (`user`/`mcp`, default `'user'`) and enforces mcp-channel invisibility in `connect()` — a config with `access.mcp === false` (or no `access`) throws the same not-found error as an unknown config name +- [`src/rpc/session.ts`](../../src/rpc/session.ts) — `SessionManager`; tracks active Kysely connections per config name. Carries the session's `channel` (`user`/`agent`, default `'user'`) and enforces agent-channel invisibility in `connect()` — a config with `access.agent === false` (or no `access`) throws the same not-found error as an unknown config name - [`src/rpc/commands/changes.ts`](../../src/rpc/commands/changes.ts) — RPC commands: `list_changes`, `run_change`, `revert_change`, `ff_changes` -- [`src/rpc/commands/config.ts`](../../src/rpc/commands/config.ts) — RPC commands: `list_configs` (`permission: 'open'`; filters out `access.mcp === false` configs for the mcp channel), `get_active_config` +- [`src/rpc/commands/config.ts`](../../src/rpc/commands/config.ts) — RPC commands: `list_configs` (`permission: 'open'`; filters out `access.agent === false` configs for the agent channel), `get_active_config` - [`src/rpc/commands/explore.ts`](../../src/rpc/commands/explore.ts) — RPC commands: `list_tables`, `describe_table`, `list_views`, `list_functions` - [`src/rpc/commands/query.ts`](../../src/rpc/commands/query.ts) — RPC commands: `sql` (dispatch-gates on `'sql:read'`; `executeRawSql` itself checks the classified statement class against the config's role), `run_sql` - [`src/rpc/commands/run.ts`](../../src/rpc/commands/run.ts) — RPC commands: `run_file`, `run_build` @@ -50,5 +50,5 @@ Every `RpcCommand` declares a `permission: Permission | 'open'` (`core/policy` p - `mcp init` writes `.mcp.json` with the `noorm mcp serve` invocation for Claude Desktop / IDE integration. - Zod schemas on each RPC command define the `payload` shape validated at dispatch time. - Tests in [`tests/core/mcp/`](../../tests/core/mcp) cover server init and command dispatch; [`tests/core/rpc/`](../../tests/core/rpc) covers registry, permissions, session. -- `connect()` on the mcp channel throws the identical `configNotFoundMessage` error (`core/config/resolver.ts`) for an unknown config and an invisible one (`access.mcp === false`) — an mcp caller cannot distinguish "doesn't exist" from "not permitted". -- `SessionInfo.protected: boolean` was replaced by `SessionInfo.role: Role` — the resolved role for the session's channel (`mcp` resolves `access.mcp`, `user` resolves `access.user`). +- `connect()` on the agent channel throws the identical `configNotFoundMessage` error (`core/config/resolver.ts`) for an unknown config and an invisible one (`access.agent === false`) — an agent cannot distinguish "doesn't exist" from "not permitted". +- `SessionInfo.protected: boolean` was replaced by `SessionInfo.role: Role` — the resolved role for the session's channel (`agent` resolves `access.agent`, `user` resolves `access.user`). diff --git a/skills/noorm/references/config.md b/skills/noorm/references/config.md index 1dcde981..54d37343 100644 --- a/skills/noorm/references/config.md +++ b/skills/noorm/references/config.md @@ -186,7 +186,7 @@ All conditions within a rule are AND'd — every specified field must match. | `defaults.password` | `string?` | Password | | `defaults.ssl` | `boolean?` | Enable SSL | | `defaults.isTest` | `boolean?` | Test flag (if `true`, cannot be overridden to `false`) | -| `defaults.protected` | `boolean?` | If `true`, acts as an access ceiling: resolved config `access` is clamped to at most `{ user: 'operator', mcp: 'viewer' }` | +| `defaults.protected` | `boolean?` | If `true`, acts as an access ceiling: resolved config `access` is clamped to at most `{ user: 'operator', agent: 'viewer' }` | | `secrets[]` | array | Secrets required for this stage | ### `secrets[]` (stage or universal) @@ -238,7 +238,7 @@ interface Config { isTest: boolean; // Test database flag (default: false) access: { // Per-channel access roles (replaces the legacy `protected` boolean) user: 'viewer' | 'operator' | 'admin'; // CLI/TUI/SDK role (default: 'admin') - mcp: 'viewer' | 'operator' | 'admin' | false; // MCP role; `false` hides the config from MCP (default: 'admin') + agent: 'viewer' | 'operator' | 'admin' | false; // AI agent role (MCP and CLI); `false` hides the config from agents (default: 'viewer') }; connection: { dialect: 'postgres' | 'mysql' | 'sqlite' | 'mssql'; @@ -265,9 +265,9 @@ interface Config { ## Stage Constraints -`defaults.isTest: true` cannot be overridden to `false` for configs created from that stage. `defaults.protected: true` works differently: it is an access **ceiling** applied at config resolution — a config linked to that stage gets its resolved `access` clamped to at most `{ user: 'operator', mcp: 'viewer' }`, no matter what `access` the config itself declares. This enforces safety invariants — a production stage stays guarded, a test stage stays flagged as test. +`defaults.isTest: true` cannot be overridden to `false` for configs created from that stage. `defaults.protected: true` works differently: it is an access **ceiling** applied at config resolution — a config linked to that stage gets its resolved `access` clamped to at most `{ user: 'operator', agent: 'viewer' }`, no matter what `access` the config itself declares. This enforces safety invariants — a production stage stays guarded, a test stage stays flagged as test. -See `docs/spec/config-access-roles.md` for the full per-channel access model (`viewer`/`operator`/`admin` roles, the permission matrix, and MCP's `access.mcp: false` invisibility). +See `docs/spec/config-access-roles.md` for the full per-channel access model (`viewer`/`operator`/`admin` roles, the permission matrix, and MCP's `access.agent: false` invisibility). ## Minimal Settings Example diff --git a/skills/noorm/references/sdk.md b/skills/noorm/references/sdk.md index 2b6e8b14..faf416c9 100644 --- a/skills/noorm/references/sdk.md +++ b/skills/noorm/references/sdk.md @@ -45,7 +45,7 @@ const ctx = await createContext(options); | `config` | `string` | — | Config name from stored state. Falls back to `NOORM_CONFIG` env, then env-only mode | | `projectRoot` | `string` | `process.cwd()` | Project root directory | | `requireTest` | `boolean` | `false` | Refuse if `config.isTest` is not `true`. Throws `RequireTestError` | -| `channel` | `Channel` | `'user'` | Which caller channel this context represents for access-policy checks (`'user'` or `'mcp'`). Destructive ops throw `ProtectedConfigError` when the config's role for this channel denies or can't confirm the action | +| `channel` | `Channel` | `'user'` | Which caller channel this context represents for access-policy checks (`'user'` or `'agent'`). Destructive ops throw `ProtectedConfigError` when the config's role for this channel denies or can't confirm the action | | `stage` | `string` | — | Stage name for stage defaults from `settings.yml` | ### Env-Only Mode (CI/CD) diff --git a/src/cli/_utils.ts b/src/cli/_utils.ts index 9cb71d7a..25699c37 100644 --- a/src/cli/_utils.ts +++ b/src/cli/_utils.ts @@ -18,6 +18,7 @@ import { loadPrivateKey, loadIdentityMetadata } from '../core/identity/storage.j import { registerIdentity } from '../core/identity/sync.js'; import { isDev, isEnvTruthy } from '../core/environment.js'; import { getConfig } from '../core/config/index.js'; +import { resolveChannel } from '../core/policy/index.js'; import { isSuccessStatus } from './_exit.js'; /** @@ -189,7 +190,9 @@ export async function withContext(opts: { const logger = await createCliLogger(projectRoot, !!args.json); await logger.start(); - const [ctx, ctxError] = await attempt(() => createContext({ config: args.config, yes: isYesMode(args) })); + const [ctx, ctxError] = await attempt( + () => createContext({ config: args.config, yes: isYesMode(args), channel: resolveChannel() }), + ); if (ctxError) { outputError(args, `Failed to create context: ${ctxError.message}`, logger); @@ -283,7 +286,9 @@ export async function withVaultContext(opts: { // `yes` matches withContext deliberately: without it a vault command can // never satisfy a `confirm`-tier gate, so `--yes` / NOORM_YES would be // silently inert in CI the moment a vault permission gains that tier. - const [ctx, ctxError] = await attempt(() => createContext({ config: args.config, yes: isYesMode(args) })); + const [ctx, ctxError] = await attempt( + () => createContext({ config: args.config, yes: isYesMode(args), channel: resolveChannel() }), + ); if (ctxError) { outputError(args, `Failed to create context: ${ctxError.message}`, logger); diff --git a/src/cli/change/rm.ts b/src/cli/change/rm.ts index 12cca476..fbf17cb9 100644 --- a/src/cli/change/rm.ts +++ b/src/cli/change/rm.ts @@ -26,7 +26,7 @@ import { defineCommand } from 'citty'; import { deleteChange } from '../../core/change/index.js'; import { getSettingsManager } from '../../core/settings/index.js'; import { initState, getStateManager } from '../../core/state/index.js'; -import { checkConfigPolicy } from '../../core/policy/index.js'; +import { checkConfigPolicy, resolveChannel } from '../../core/policy/index.js'; import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; import { selectChangeFromFs, requireTty } from './_prompt.js'; import { EXIT } from '../_exit.js'; @@ -60,7 +60,7 @@ const rmCommand = defineCommand({ const configName = args.config ?? stateManager.getActiveConfigName(); const config = configName ? stateManager.getConfig(configName) : null; - const check = config ? checkConfigPolicy('user', config, 'change:rm') : null; + const check = config ? checkConfigPolicy(resolveChannel(), config, 'change:rm') : null; if (check && !check.allowed) { diff --git a/src/cli/ci/identity/enroll.ts b/src/cli/ci/identity/enroll.ts index 4428476b..387c51b7 100644 --- a/src/cli/ci/identity/enroll.ts +++ b/src/cli/ci/identity/enroll.ts @@ -21,6 +21,7 @@ import { defineCommand } from 'citty'; import { generateKeyPair } from '../../../core/identity/crypto.js'; import { computeIdentityHash } from '../../../core/identity/hash.js'; import { isValidKeyHex } from '../../../core/identity/storage.js'; +import { resolveChannel } from '../../../core/policy/index.js'; import { propagateVaultKeyToChecked, checkVaultPolicy } from '../../../core/vault/index.js'; import type { VaultPolicyGate } from '../../../core/vault/index.js'; import { decryptVaultKey } from '../../../core/vault/key.js'; @@ -79,7 +80,7 @@ const enrollCommand = defineCommand({ const gate: VaultPolicyGate = { configName: enrollConfig.name, access: enrollConfig.access, - channel: 'user', + channel: resolveChannel(), }; const policy = checkVaultPolicy(gate, 'vault:propagate'); diff --git a/src/cli/config/export.ts b/src/cli/config/export.ts index 2d5e3216..34d5f148 100644 --- a/src/cli/config/export.ts +++ b/src/cli/config/export.ts @@ -10,7 +10,7 @@ import { chmod, writeFile } from 'node:fs/promises'; import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; -import { checkConfigPolicy } from '../../core/policy/index.js'; +import { checkConfigPolicy, resolveChannel } from '../../core/policy/index.js'; import { initState, getStateManager } from '../../core/state/index.js'; import { outputError, outputResult, sharedArgs } from '../_utils.js'; import { EXIT } from '../_exit.js'; @@ -51,7 +51,7 @@ const exportCommand = defineCommand({ // The export carries the connection password in plaintext, so it is // a secret read — a viewer role that is denied `config:rm` must not // be able to walk away with the credential instead. - const check = checkConfigPolicy('user', config, 'secret:read'); + const check = checkConfigPolicy(resolveChannel(), config, 'secret:read'); if (!check.allowed) { diff --git a/src/cli/config/import.ts b/src/cli/config/import.ts index 5743e935..d16fe3ab 100644 --- a/src/cli/config/import.ts +++ b/src/cli/config/import.ts @@ -11,7 +11,7 @@ import { attempt, attemptSync } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { ConfigValidationError, parseConfig } from '../../core/config/schema.js'; -import { checkConfigPolicy } from '../../core/policy/index.js'; +import { checkConfigPolicy, resolveChannel } from '../../core/policy/index.js'; import { initState, getStateManager } from '../../core/state/index.js'; import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; import { EXIT } from '../_exit.js'; @@ -86,8 +86,8 @@ const importCommand = defineCommand({ // An overwrite rewrites `access` wholesale, so the config being // replaced decides — not the incoming file. Without this, one // --force promotes a viewer config to admin, or flips the - // `mcp: false` invisibility an operator set deliberately. - const check = checkConfigPolicy('user', existing, 'config:write'); + // `agent: false` invisibility an operator set deliberately. + const check = checkConfigPolicy(resolveChannel(), existing, 'config:write'); if (!check.allowed) { diff --git a/src/cli/config/list.ts b/src/cli/config/list.ts index 46dfe8cf..10daa2cf 100644 --- a/src/cli/config/list.ts +++ b/src/cli/config/list.ts @@ -8,7 +8,7 @@ import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; -import { formatAccessTag } from '../../core/policy/index.js'; +import { formatAccessTag, isVisibleToChannel, resolveChannel } from '../../core/policy/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; const listCommand = defineCommand({ @@ -33,7 +33,13 @@ const listCommand = defineCommand({ } const stateManager = getStateManager(projectRoot); - const configs = stateManager.listConfigs(); + + // Mirrors the `list_configs` RPC command: `access.agent === false` + // means the config does not exist as far as an agent is concerned. + // Filtering only over MCP would have made the setting worthless the + // moment the agent ran `noorm config list` instead. + const channel = resolveChannel(); + const configs = stateManager.listConfigs().filter((c) => isVisibleToChannel(c.access, channel)); if (configs.length === 0) { diff --git a/src/cli/config/rm.ts b/src/cli/config/rm.ts index dec0fef8..3af33feb 100644 --- a/src/cli/config/rm.ts +++ b/src/cli/config/rm.ts @@ -11,7 +11,7 @@ import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; import { getSettingsManager } from '../../core/settings/index.js'; -import { checkConfigPolicy } from '../../core/policy/index.js'; +import { checkConfigPolicy, resolveChannel } from '../../core/policy/index.js'; import { SettingsProvider } from '../../core/config/resolver.js'; import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; import { EXIT } from '../_exit.js'; @@ -63,7 +63,7 @@ const rmCommand = defineCommand({ } - const check = checkConfigPolicy('user', config, 'config:rm'); + const check = checkConfigPolicy(resolveChannel(), config, 'config:rm'); if (!check.allowed) { diff --git a/src/cli/db/create.ts b/src/cli/db/create.ts index f822d2ad..3d4c0359 100644 --- a/src/cli/db/create.ts +++ b/src/cli/db/create.ts @@ -15,7 +15,7 @@ import { initState, getStateManager } from '../../core/state/index.js'; import { getSettingsManager } from '../../core/settings/index.js'; import { resolveConfig, SettingsProvider } from '../../core/config/resolver.js'; import { checkDbStatus, createDb } from '../../core/db/index.js'; -import { checkConfigPolicy } from '../../core/policy/index.js'; +import { checkConfigPolicy, resolveChannel } from '../../core/policy/index.js'; import { isYesMode, outputResult, outputError, sharedArgs } from '../_utils.js'; import { EXIT } from '../_exit.js'; @@ -81,7 +81,7 @@ const createCommand = defineCommand({ // Gate before any probe touches the server: for SQLite, checkDbStatus // opens the target and so creates the file, which would hand a denied // role a database anyway. - const check = checkConfigPolicy('user', config, 'db:create'); + const check = checkConfigPolicy(resolveChannel(), config, 'db:create'); if (!check.allowed) { diff --git a/src/cli/db/drop.ts b/src/cli/db/drop.ts index e7045d27..6439a3ff 100644 --- a/src/cli/db/drop.ts +++ b/src/cli/db/drop.ts @@ -13,7 +13,7 @@ import { initState, getStateManager } from '../../core/state/index.js'; import { getSettingsManager } from '../../core/settings/index.js'; import { resolveConfig, SettingsProvider } from '../../core/config/resolver.js'; import { destroyDb } from '../../core/db/index.js'; -import { checkConfigPolicy } from '../../core/policy/index.js'; +import { checkConfigPolicy, resolveChannel } from '../../core/policy/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; import { EXIT } from '../_exit.js'; @@ -95,7 +95,7 @@ const dropCommand = defineCommand({ } - const check = checkConfigPolicy('user', config, 'db:destroy'); + const check = checkConfigPolicy(resolveChannel(), config, 'db:destroy'); if (!check.allowed) { diff --git a/src/cli/info.ts b/src/cli/info.ts index 4779b4c5..7fb07abd 100644 --- a/src/cli/info.ts +++ b/src/cli/info.ts @@ -17,6 +17,7 @@ import type { FullVersionRecord } from '../core/version/schema/index.js'; import { fetchOverview } from '../core/explore/index.js'; import type { ExploreOverview } from '../core/explore/index.js'; import { loadIdentityMetadata } from '../core/identity/storage.js'; +import { detectAgentHarness } from '../core/policy/harness.js'; import { getStateManager } from '../core/state/index.js'; import { findProjectRoot } from '../core/project.js'; import { createConnection } from '../core/connection/index.js'; @@ -50,6 +51,20 @@ interface InfoResult { registered_at: string | null; last_seen_at: string | null; } | null; + + /** + * The agent harness noorm believes is driving this session, if any. + * + * Reported because detection silently changes the channel an operation is + * authorised on, and a permission denial with no visible cause is a bad + * thing to debug. `markers` names the variables actually set, which is what + * an operator would unset to be treated as human. + */ + agent: { + id: string; + name: string; + markers: string[]; + } | null; objects: { tables: number; views: number; @@ -117,6 +132,8 @@ async function gatherInfo(): Promise { // === Validation block === const [identityMeta] = await attempt(() => loadIdentityMetadata()); + const harness = detectAgentHarness(); + const projectResult = findProjectRoot(); // === Business logic block === @@ -232,6 +249,11 @@ async function gatherInfo(): Promise { registered_at: identityDbInfo?.registeredAt ?? null, last_seen_at: identityDbInfo?.lastSeenAt ?? null, } : null, + agent: harness ? { + id: harness.id, + name: harness.name, + markers: harness.markers.filter((marker) => !!process.env[marker]), + } : null, objects: overview ? { tables: overview.tables, views: overview.views, @@ -295,6 +317,17 @@ function formatInfoOutput(info: InfoResult): string { } + if (info.agent) { + + lines.push(`Agent: ${info.agent.name} (${info.agent.markers.join(', ')})`); + + } + else { + + lines.push('Agent: none detected'); + + } + lines.push(''); lines.push(`Objects: ${formatObjectStats(info.objects)}`); diff --git a/src/cli/run/_render-secrets.ts b/src/cli/run/_render-secrets.ts index df1b9fc8..35deb1e4 100644 --- a/src/cli/run/_render-secrets.ts +++ b/src/cli/run/_render-secrets.ts @@ -21,6 +21,7 @@ import { attempt } from '@logosdx/utils'; import { createContext } from '../../sdk/index.js'; import { resolveVaultKey, buildSecretsContext } from '../../core/vault/index.js'; +import { resolveChannel } from '../../core/policy/index.js'; import type { StateManager } from '../../core/state/index.js'; import type { NoormDatabase } from '../../core/shared/index.js'; @@ -60,7 +61,7 @@ export async function resolveRenderSecrets( if (!configName) return { secrets: {}, vaultProbeFailed: false }; - const [vault, connErr] = await attempt(() => createContext({ config: configName })); + const [vault, connErr] = await attempt(() => createContext({ config: configName, channel: resolveChannel() })); if (connErr) { diff --git a/src/cli/run/inspect.ts b/src/cli/run/inspect.ts index 88174cc4..e7d755ed 100644 --- a/src/cli/run/inspect.ts +++ b/src/cli/run/inspect.ts @@ -20,7 +20,7 @@ import { outputError, outputResult, sharedArgs } from '../_utils.js'; import { EXIT } from '../_exit.js'; import { buildContext } from '../../core/template/context.js'; import { loadHelpers } from '../../core/template/helpers.js'; -import { assertPolicy } from '../../core/policy/index.js'; +import { assertPolicy, resolveChannel } from '../../core/policy/index.js'; import { getStateManager } from '../../core/state/index.js'; import { resolveRenderSecrets, RENDER_SECRETS_NOTICE } from './_render-secrets.js'; @@ -112,7 +112,7 @@ const inspectCommand = defineCommand({ // so it needs the same gate as preview. if (activeConfig) { - const [, policyErr] = attemptSync(() => assertPolicy('user', activeConfig, 'run:file')); + const [, policyErr] = attemptSync(() => assertPolicy(resolveChannel(), activeConfig, 'run:file')); if (policyErr) { diff --git a/src/cli/run/preview.ts b/src/cli/run/preview.ts index f2a7fd11..24bf425f 100644 --- a/src/cli/run/preview.ts +++ b/src/cli/run/preview.ts @@ -20,7 +20,7 @@ import { attempt, attemptSync } from '@logosdx/utils'; import { outputError, outputResult, sharedArgs } from '../_utils.js'; import { EXIT } from '../_exit.js'; import { processFile } from '../../core/template/engine.js'; -import { assertPolicy } from '../../core/policy/index.js'; +import { assertPolicy, resolveChannel } from '../../core/policy/index.js'; import { getStateManager } from '../../core/state/index.js'; import { resolveRenderSecrets, RENDER_SECRETS_NOTICE } from './_render-secrets.js'; @@ -76,7 +76,7 @@ const previewCommand = defineCommand({ // returns an empty set for that case. if (activeConfig) { - const [, policyErr] = attemptSync(() => assertPolicy('user', activeConfig, 'run:file')); + const [, policyErr] = attemptSync(() => assertPolicy(resolveChannel(), activeConfig, 'run:file')); if (policyErr) { diff --git a/src/cli/secret/_policy.ts b/src/cli/secret/_policy.ts index f7508970..8bfed633 100644 --- a/src/cli/secret/_policy.ts +++ b/src/cli/secret/_policy.ts @@ -7,7 +7,7 @@ * which is why the check lives in one helper rather than being re-typed in * three command files. */ -import { checkConfigPolicy } from '../../core/policy/index.js'; +import { checkConfigPolicy, resolveChannel } from '../../core/policy/index.js'; import type { Permission, PolicyCheck } from '../../core/policy/index.js'; import type { StateManager } from '../../core/state/index.js'; @@ -54,7 +54,7 @@ export function resolveSecretPolicy( } - const check = checkConfigPolicy('user', config, permission); + const check = checkConfigPolicy(resolveChannel(), config, permission); if (!check.allowed) { diff --git a/src/cli/sql/query.ts b/src/cli/sql/query.ts index 05f6220a..92e479ac 100644 --- a/src/cli/sql/query.ts +++ b/src/cli/sql/query.ts @@ -7,6 +7,7 @@ import { defineCommand } from 'citty'; import { attempt } from '@logosdx/utils'; import type { Kysely } from 'kysely'; +import { resolveChannel } from '../../core/policy/index.js'; import { executeRawSql } from '../../core/sql-terminal/executor.js'; import { withContext, outputError, outputResult, sharedArgs } from '../_utils.js'; import { EXIT } from '../_exit.js'; @@ -52,7 +53,7 @@ const sqlCommand = defineCommand({ args, fn: async (ctx) => executeRawSql(ctx.kysely as unknown as Kysely, query!, ctx.noorm.config.name, { access: ctx.noorm.config.access, - channel: 'user', + channel: resolveChannel(), dialect: ctx.dialect, }), }); diff --git a/src/cli/vault/init.ts b/src/cli/vault/init.ts index d792d005..754f6142 100644 --- a/src/cli/vault/init.ts +++ b/src/cli/vault/init.ts @@ -6,6 +6,7 @@ import { defineCommand } from 'citty'; import { withVaultContext, outputResult, sharedArgs, isYesMode } from '../_utils.js'; import { initializeVaultChecked, getVaultStatus, checkVaultPolicy } from '../../core/vault/index.js'; import type { VaultPolicyGate } from '../../core/vault/index.js'; +import { resolveChannel } from '../../core/policy/index.js'; const initCommand = defineCommand({ meta: { @@ -28,7 +29,7 @@ const initCommand = defineCommand({ const gate: VaultPolicyGate = { configName: config.name, access: config.access, - channel: 'user', + channel: resolveChannel(), }; const check = checkVaultPolicy(gate, 'vault:write'); diff --git a/src/cli/vault/list.ts b/src/cli/vault/list.ts index a2152110..b1d419e9 100644 --- a/src/cli/vault/list.ts +++ b/src/cli/vault/list.ts @@ -6,6 +6,7 @@ import { defineCommand } from 'citty'; import { withVaultContext, outputResult, sharedArgs } from '../_utils.js'; import { getVaultKeyChecked, getAllVaultSecrets, getVaultStatus, checkVaultPolicy } from '../../core/vault/index.js'; import type { VaultPolicyGate } from '../../core/vault/index.js'; +import { resolveChannel } from '../../core/policy/index.js'; const listCommand = defineCommand({ meta: { @@ -27,7 +28,7 @@ const listCommand = defineCommand({ const gate: VaultPolicyGate = { configName: config.name, access: config.access, - channel: 'user', + channel: resolveChannel(), }; const check = checkVaultPolicy(gate, 'vault:read'); diff --git a/src/cli/vault/propagate.ts b/src/cli/vault/propagate.ts index aed81544..db55febf 100644 --- a/src/cli/vault/propagate.ts +++ b/src/cli/vault/propagate.ts @@ -12,6 +12,7 @@ import { checkVaultPolicy, } from '../../core/vault/index.js'; import type { PendingVaultUser, VaultPolicyGate } from '../../core/vault/index.js'; +import { resolveChannel } from '../../core/policy/index.js'; /** * Shape of a pending identity as reported to the operator. @@ -57,7 +58,7 @@ const propagateCommand = defineCommand({ const gate: VaultPolicyGate = { configName: config.name, access: config.access, - channel: 'user', + channel: resolveChannel(), }; const check = checkVaultPolicy(gate, 'vault:propagate'); diff --git a/src/cli/vault/rm.ts b/src/cli/vault/rm.ts index 16564934..861483bc 100644 --- a/src/cli/vault/rm.ts +++ b/src/cli/vault/rm.ts @@ -7,6 +7,7 @@ import { withVaultContext, outputResult, sharedArgs, isYesMode } from '../_utils import { EXIT } from '../_exit.js'; import { getVaultKeyChecked, deleteVaultSecretChecked, vaultSecretExists, checkVaultPolicy } from '../../core/vault/index.js'; import type { VaultPolicyGate } from '../../core/vault/index.js'; +import { resolveChannel } from '../../core/policy/index.js'; const rmCommand = defineCommand({ meta: { @@ -30,7 +31,7 @@ const rmCommand = defineCommand({ const gate: VaultPolicyGate = { configName: config.name, access: config.access, - channel: 'user', + channel: resolveChannel(), }; const check = checkVaultPolicy(gate, 'vault:write'); diff --git a/src/cli/vault/set.ts b/src/cli/vault/set.ts index feff3e21..0905a748 100644 --- a/src/cli/vault/set.ts +++ b/src/cli/vault/set.ts @@ -7,6 +7,7 @@ import { withVaultContext, outputResult, sharedArgs, isYesMode } from '../_utils import { readSecretValue } from './_secret-value.js'; import { getVaultKeyChecked, setVaultSecretChecked, checkVaultPolicy } from '../../core/vault/index.js'; import type { VaultPolicyGate } from '../../core/vault/index.js'; +import { resolveChannel } from '../../core/policy/index.js'; const setCommand = defineCommand({ meta: { @@ -41,7 +42,7 @@ const setCommand = defineCommand({ const gate: VaultPolicyGate = { configName: config.name, access: config.access, - channel: 'user', + channel: resolveChannel(), }; const check = checkVaultPolicy(gate, 'vault:write'); diff --git a/src/core/change/types.ts b/src/core/change/types.ts index 35f3d4a1..0d3897c7 100644 --- a/src/core/change/types.ts +++ b/src/core/change/types.ts @@ -342,7 +342,7 @@ export const DEFAULT_BATCH_OPTIONS: Required> * projectRoot: '/project', * changesDir: '/project/changes', * sqlDir: '/project/sql', - * access: { user: 'admin', mcp: 'admin' }, + * access: { user: 'admin', agent: 'admin' }, * channel: 'user', * } * ``` @@ -373,7 +373,7 @@ export interface ChangeContext { */ access: ConfigAccess; - /** Caller channel for the policy gate — `user` for CLI/TUI/SDK, `mcp` for MCP. */ + /** Caller channel for the policy gate — `user` for CLI/TUI/SDK, `agent` for an AI agent (MCP or CLI). */ channel: Channel; /** Config object for template context */ diff --git a/src/core/config/index.ts b/src/core/config/index.ts index eb645ac7..e4fa0d51 100644 --- a/src/core/config/index.ts +++ b/src/core/config/index.ts @@ -16,6 +16,7 @@ const META_ENV_VARS = new Set([ 'NOORM_DEV', // Dev mode detection 'NOORM_CI_CONFIG_NAME', // ci init config name override 'NOORM_LOGGER_DEBUG', // Logger-internal debug + 'NOORM_CHANNEL', // Policy channel override (resolveChannel) ]); /** diff --git a/src/core/config/resolver.ts b/src/core/config/resolver.ts index a8d1530e..161388a5 100644 --- a/src/core/config/resolver.ts +++ b/src/core/config/resolver.ts @@ -87,9 +87,9 @@ const DEFAULTS: ConfigInput = { /** * Ceiling a `protected: true` stage caps a config's access to. A config can - * still be stricter than this (e.g. `mcp: false`); it just can't be looser. + * still be stricter than this (e.g. `agent: false`); it just can't be looser. */ -const PROTECTED_STAGE_CEILING: ConfigAccess = { user: 'operator', mcp: 'viewer' }; +const PROTECTED_STAGE_CEILING: ConfigAccess = { user: 'operator', agent: 'viewer' }; /** * Strictness rank for comparing roles: `false` (invisible) is stricter than @@ -114,7 +114,7 @@ function clampToCeiling(access: ConfigAccess, ceiling: ConfigAccess): ConfigAcce return { user: roleRank(access.user) <= roleRank(ceiling.user) ? access.user : ceiling.user, - mcp: roleRank(access.mcp) <= roleRank(ceiling.mcp) ? access.mcp : ceiling.mcp, + agent: roleRank(access.agent) <= roleRank(ceiling.agent) ? access.agent : ceiling.agent, }; } @@ -159,7 +159,7 @@ export interface ResolveOptions { /** * Message for a config name that has no stored config. * - * Single source of truth: `SessionManager`'s mcp-channel invisibility deny + * Single source of truth: `SessionManager`'s agent-channel invisibility deny * (`src/rpc/session.ts`) reuses this so a hidden config's connect error is * byte-identical to an unknown config's, rather than hand-copying the string. * diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index 7c6740f9..5febe43a 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -22,7 +22,7 @@ const RoleSchema = z.enum(['viewer', 'operator', 'admin']); const AccessSchema = z.object({ user: RoleSchema, - mcp: z.union([RoleSchema, z.literal(false)]), + agent: z.union([RoleSchema, z.literal(false)]), }); /** @@ -132,6 +132,7 @@ export const ConnectionSchema = z password: z.string().optional(), ssl: SSLSchema.optional(), pool: PoolSchema.optional(), + tlsServerName: z.string().optional(), }) .refine((conn) => conn.dialect === 'sqlite' || conn.host, { message: 'Host is required for non-SQLite databases', @@ -179,6 +180,7 @@ const PartialConnectionSchema = z.object({ password: z.string().optional(), ssl: SSLSchema.optional(), pool: PoolSchema.optional(), + tlsServerName: z.string().optional(), }); /** @@ -320,7 +322,7 @@ export function validateConfigInput(input: unknown): asserts input is ConfigInpu * const config = parseConfig(minimal) * // config.type === 'local' (default) * // config.isTest === false (default) - * // config.access === { user: 'admin', mcp: 'admin' } (default) + * // config.access === { user: 'admin', agent: 'viewer' } (default) * ``` */ export function parseConfig(config: unknown): ConfigSchemaType { diff --git a/src/core/config/types.ts b/src/core/config/types.ts index 0de684ff..13199f54 100644 --- a/src/core/config/types.ts +++ b/src/core/config/types.ts @@ -19,7 +19,7 @@ import type { ConfigAccess } from '../policy/index.js'; * name: 'dev', * type: 'local', * isTest: false, - * access: { user: 'admin', mcp: 'admin' }, + * access: { user: 'admin', agent: 'admin' }, * connection: { * dialect: 'postgres', * host: 'localhost', diff --git a/src/core/connection/dialects/mssql.ts b/src/core/connection/dialects/mssql.ts index 7d518c90..dc55edfe 100644 --- a/src/core/connection/dialects/mssql.ts +++ b/src/core/connection/dialects/mssql.ts @@ -8,24 +8,129 @@ * to the target database, avoiding cryptic ECONNRESET errors when * the database doesn't exist. */ +import { isIP } from 'node:net'; + import { Kysely, MssqlDialect, sql } from 'kysely'; +import type { ConnectionConfiguration } from 'tedious'; + import type { ConnectionConfig, ConnectionResult } from '../types.js'; import { DEFAULT_PORTS } from '../defaults.js'; import { MssqlLimitPlugin } from './mssql-limit-plugin.js'; +/** + * SNI ServerName presented when connecting to an MSSQL host by IP address + * without certificate validation. + * + * TLS carries the requested hostname in the SNI extension, which RFC 6066 + * forbids from being an IP literal — Node's `tls.connect` enforces that. On + * the PRELOGIN handshake that `encrypt: true` uses, tedious 19.2.1 passes + * `config.server` straight through to `tls.connect({ servername })` with no + * guard (`lib/connection.js:2248` -> `lib/message-io.js:53`), so an IP host + * fails the connection outright. Its TDS 8.0 path is guarded + * (`lib/connection.js:1200`) but is not a general substitute. + * + * Substituting a name is safe *only* on the `trustServerCertificate: true` + * branch: nothing compares the presented name against the certificate, so the + * value is inert and encryption stays on. Under certificate validation the + * same substitution would quietly defeat hostname verification, which is why + * `resolveTlsServerName` throws there instead of falling back to this. + * + * `.invalid` is reserved by RFC 2606 and can never resolve to a real host, so + * the placeholder cannot collide with a certificate anyone could obtain. + */ +export const UNVERIFIED_TLS_SERVER_NAME = 'noorm-unverified.invalid'; + +/** + * Raised when no usable TLS ServerName can be derived for an MSSQL connection. + * + * Carries the offending host so callers can name it back to the user; the + * message names `tlsServerName` because supplying it is the only fix that + * keeps certificate validation on. + * + * @example + * throw new MssqlTlsServerNameError('10.0.0.5', 'Cannot validate ...'); + */ +export class MssqlTlsServerNameError extends Error { + + override readonly name = 'MssqlTlsServerNameError' as const; + + constructor(public readonly host: string, message: string) { + + super(message); + + } + +} + +/** + * Resolve the TLS ServerName (SNI) tedious should present, if any. + * + * Returns `undefined` for hostname connections so tedious keeps deriving the + * name from `server` itself — that path already works and is the common case. + * + * @example + * resolveTlsServerName({ dialect: 'mssql', host: '10.0.0.5', database: 'app' }) + * // => 'noorm-unverified.invalid' + */ +export function resolveTlsServerName(config: ConnectionConfig): string | undefined { + + const host = config.host ?? 'localhost'; + const supplied = config.tlsServerName; + const validatingCertificate = !!config.ssl; + + if (supplied && isIP(supplied) !== 0) { + + throw new MssqlTlsServerNameError( + host, + `Invalid tlsServerName '${supplied}': a TLS ServerName must be a hostname, not an IP address. ` + + 'Set it to the hostname the server\'s certificate is issued for.', + ); + + } + + if (supplied) { + + return supplied; + + } + + if (isIP(host) === 0) { + + return undefined; + + } + + if (validatingCertificate) { + + throw new MssqlTlsServerNameError( + host, + `Cannot verify the TLS certificate of MSSQL host '${host}': a certificate cannot be validated ` + + 'against an IP address, because TLS forbids sending one as the ServerName. ' + + 'Set connection.tlsServerName to the hostname the server\'s certificate is issued for, ' + + 'or connect using that hostname instead.', + ); + + } + + return UNVERIFIED_TLS_SERVER_NAME; + +} + /** * Build tedious connection options from noorm config. * * Centralizes the tedious config so both the preflight check * and the real pool use the same settings. + * + * @example + * const options = buildTediousOptions(config, 'master'); */ -function buildTediousConfig( - Tedious: typeof import('tedious'), +export function buildTediousOptions( config: ConnectionConfig, database?: string, -) { +): ConnectionConfiguration { - return new Tedious.Connection({ + return { server: config.host ?? 'localhost', authentication: { type: 'default', @@ -39,8 +144,22 @@ function buildTediousConfig( database: database ?? config.database, trustServerCertificate: !config.ssl, encrypt: true, + serverName: resolveTlsServerName(config), }, - }); + }; + +} + +/** + * Instantiate a tedious Connection for the given noorm config. + */ +function buildTediousConfig( + Tedious: typeof import('tedious'), + config: ConnectionConfig, + database?: string, +) { + + return new Tedious.Connection(buildTediousOptions(config, database)); } diff --git a/src/core/connection/types.ts b/src/core/connection/types.ts index d436a42b..bb1fba96 100644 --- a/src/core/connection/types.ts +++ b/src/core/connection/types.ts @@ -63,6 +63,17 @@ export interface ConnectionConfig { cert?: string; key?: string; }; + + /** + * Hostname the server's TLS certificate is issued for (its CN or a DNS + * entry in the Subject Alternative Name). + * + * Only needed when `host` is an IP address: TLS carries the requested + * hostname in the SNI extension, which RFC 6066 forbids from being an IP + * literal, so the certificate has no name to be checked against. MSSQL is + * the only dialect that reads this today. + */ + tlsServerName?: string; } /** diff --git a/src/core/db/policy.ts b/src/core/db/policy.ts index fbc334a4..b66b2891 100644 --- a/src/core/db/policy.ts +++ b/src/core/db/policy.ts @@ -30,7 +30,7 @@ export interface DbPolicyContext { /** Per-channel roles from the config. Absent access denies (fail closed). */ access?: ConfigAccess; - /** Caller channel — `user` for CLI/TUI/SDK, `mcp` for the MCP server. */ + /** Caller channel — `user` for CLI/TUI/SDK, `agent` for an AI agent (MCP or CLI). */ channel?: Channel; /** Caller pre-confirmed the operation (CLI `--yes`, SDK `options.yes`). */ diff --git a/src/core/debug/operations.ts b/src/core/debug/operations.ts index 3cf29318..c83ff921 100644 --- a/src/core/debug/operations.ts +++ b/src/core/debug/operations.ts @@ -88,7 +88,7 @@ export interface GetRowsOptions { * otherwise inherit the tables without inheriting the check. */ export interface DebugPolicyContext { - /** CLI/TUI/SDK callers are `user`; the MCP server is `mcp` */ + /** CLI/TUI/SDK callers are `user`; an AI agent is `agent` (MCP or CLI) */ channel: Channel; /** Config being inspected; missing `access` denies every operation */ diff --git a/src/core/identity/provenance.ts b/src/core/identity/provenance.ts new file mode 100644 index 00000000..6f3581ca --- /dev/null +++ b/src/core/identity/provenance.ts @@ -0,0 +1,74 @@ +/** + * Agent provenance in the recorded audit identity. + * + * `executed_by` records who ran an operation and nothing about what was driving + * the session, so a change applied by an agent has been indistinguishable from + * one a human typed — which is the first question anyone asks when a migration + * goes wrong. + * + * The provenance is folded into the identity string rather than given its own + * column. The audit question is binary — "was this an agent?" — and a suffix + * answers it against every dialect immediately, on databases whose schema + * migration has not run. A dedicated column would be cleaner to query, but it + * costs a four-dialect schema migration plus a window where the writing CLI and + * the target database disagree about whether the column exists. + * + * This is provenance, not attestation. `executed_by` is unauthenticated free + * text, and harness detection reads environment variables the caller owns, so + * the suffix can be both forged and suppressed. It records what noorm observed, + * which is the only honest claim available without signing the identity. + * + * @example + * withAgentProvenance('Ann ', detectAgentHarness()); + * // 'Ann (via Claude Code)' + */ +import type { AgentHarness } from '../policy/harness.js'; + +/** + * Width of the `executed_by` column, from the v1 schema migration. + * + * Appending to a value that already nearly fills the column would turn an + * insert that succeeds today into a hard error on postgres, mysql and mssql. + * The identity is trimmed to make room instead, because a change that records + * a shortened name is recoverable and one that refuses to run is not. + */ +const EXECUTED_BY_MAX_LENGTH = 255; + +/** + * Append the detected harness to an audit identity. + * + * Returns the identity untouched when no harness was detected, so a human's + * record is byte-for-byte what it was before this existed. + * + * The identity is never parsed or escaped — it is carried verbatim ahead of the + * suffix. An identity containing its own parentheses or angle brackets is + * therefore preserved exactly, at the cost of being able to spell a suffix that + * mimics this one. That trade is deliberate: mangling the operator's name to + * defend an unauthenticated field buys nothing. + * + * @example + * withAgentProvenance('Ann ', null); + * // 'Ann ' + * + * withAgentProvenance('Ann (Platform) ', { id: 'codex', name: 'OpenAI Codex', markers: [] }); + * // 'Ann (Platform) (via OpenAI Codex)' + */ +export function withAgentProvenance(executedBy: string, harness: AgentHarness | null): string { + + if (!harness) return executedBy; + + const suffix = `(via ${harness.name})`; + const identity = executedBy.trim(); + + if (!identity) return suffix; + + // Reserve the separating space alongside the suffix. + const room = EXECUTED_BY_MAX_LENGTH - suffix.length - 1; + + // A harness named long enough to fill the column on its own would leave the + // identity nowhere to go; keeping the identity is the better half to save. + if (room <= 0) return identity; + + return `${identity.slice(0, room).trimEnd()} ${suffix}`; + +} diff --git a/src/core/policy/channel.ts b/src/core/policy/channel.ts new file mode 100644 index 00000000..12df509e --- /dev/null +++ b/src/core/policy/channel.ts @@ -0,0 +1,65 @@ +/** + * Channel resolution. + * + * `Channel` names *who is driving*, not which binary was invoked. Those + * coincide only when a human is at the keyboard, and the CLI used to assume + * they always did: it hardcoded `user` at every policy call site. An agent + * denied `sql:write` over MCP could see `noorm` on the PATH, shell out, and + * get the human's role — `sql:write`, `sql:ddl`, `db:create`, `run:build` + * and `vault:read` all flipped from deny to allow on a stock config, and + * `db:destroy` dropped to a confirm that `--yes` walks straight through. + * + * Resolving the channel from provenance is what closes that. + */ +import { isAgentSession } from './harness.js'; +import type { Channel } from './types.js'; + +/** + * Escape hatch for a human scripting from inside an agent session. + * + * A `NOORM_CHANNEL=agent` is equally available to an agent, and that is + * accepted: this defends against an agent routing around a refusal, not one + * that sets out to evade the check. An agent willing to unset the harness + * variables was never going to be stopped by reading them. + */ +const CHANNEL_ENV_VAR = 'NOORM_CHANNEL'; + +function isChannel(value: unknown): value is Channel { + + return value === 'user' || value === 'agent'; + +} + +/** + * Resolve the channel this process is acting on behalf of. + * + * Precedence: + * + * 1. `NOORM_CHANNEL`, when set to exactly `user` or `agent`. Any other value + * is ignored rather than treated as an error — a typo must not silently + * grant the looser channel, and it must not break the CLI either. + * 2. Harness provenance via `isAgentSession()` — an allowlist of variables + * the agent harnesses set for their own child processes. + * 3. `user`. + * + * The MCP server sits *above* this: it constructs its session with `agent` + * literally (`src/mcp/index.ts`) and never calls this function, so stdio + * traffic is `agent` even under `NOORM_CHANNEL=user`. + * + * Pure over its input so tests need not mutate `process.env`, which Bun + * caches for the life of the process and leaks across test files. + * + * @example + * resolveChannel({}); // 'user' + * resolveChannel({ CLAUDECODE: '1' }); // 'agent' + * resolveChannel({ CLAUDECODE: '1', NOORM_CHANNEL: 'user' }); // 'user' + */ +export function resolveChannel(env: Record = process.env): Channel { + + const override = env[CHANNEL_ENV_VAR]; + + if (isChannel(override)) return override; + + return isAgentSession(env) ? 'agent' : 'user'; + +} diff --git a/src/core/policy/check.ts b/src/core/policy/check.ts index f726c51b..97a19d5e 100644 --- a/src/core/policy/check.ts +++ b/src/core/policy/check.ts @@ -22,11 +22,11 @@ export function confirmationPhraseFor(name: string): string { * * The matrix cell is channel-agnostic (`allow`/`confirm`/`deny`); only * `confirm` resolves differently per channel: the `user` channel prompts for - * `yes-` (skippable via `NOORM_YES`), while `mcp` collapses confirm - * to deny — there's no human on the other end of stdio to type a phrase, and - * an agent confirming its own destructive action is theater. + * `yes-` (skippable via `NOORM_YES`), while `agent` collapses confirm + * to deny — an agent confirming its own destructive action is theater, and on + * the CLI it would need only `--yes` to do it. * - * `mcp: false` (invisible config) is never a role and is not expected to + * `agent: false` (invisible config) is never a role and is not expected to * reach this function — visibility is enforced upstream. If it does, this * fails closed rather than crashing. * @@ -67,12 +67,12 @@ export function checkPolicy(channel: Channel, target: PolicyTarget, permission: } - if (channel === 'mcp') { + if (channel === 'agent') { return { allowed: false, requiresConfirmation: false, - blockedReason: `"${permission}" on config "${target.name}" requires confirmation — use the CLI.`, + blockedReason: `"${permission}" on config "${target.name}" requires confirmation from a human.`, }; } @@ -103,7 +103,7 @@ export function checkPolicy(channel: Channel, target: PolicyTarget, permission: * populates `access`. * * @example - * const check = checkConfigPolicy('mcp', ctx.noorm.config, 'sql:write'); + * const check = checkConfigPolicy('agent', ctx.noorm.config, 'sql:write'); * if (!check.allowed) throw new RpcError(check.blockedReason ?? 'denied'); */ export function checkConfigPolicy( @@ -156,19 +156,19 @@ export function assertPolicy( } /** - * Whether a config is visible on a channel — the mcp-channel invisibility + * Whether a config is visible on a channel — the agent-channel invisibility * rule, extracted so `list_configs` and `session.ts`'s `connect()` share one * fail-closed implementation instead of two independently drifting copies. * - * Fails closed: missing `access` is treated the same as `access.mcp === - * false`. The `user` channel is always visible; only `mcp` can be hidden. + * Fails closed: missing `access` is treated the same as `access.agent === + * false`. The `user` channel is always visible; only `agent` can be hidden. * * @example - * isVisibleToChannel(config.access, 'mcp'); // false when access.mcp === false or access is missing + * isVisibleToChannel(config.access, 'agent'); // false when access.agent === false or access is missing */ export function isVisibleToChannel(access: ConfigAccess | undefined, channel: Channel): boolean { - return !(channel === 'mcp' && (!access || access.mcp === false)); + return !(channel === 'agent' && (!access || access.agent === false)); } @@ -178,7 +178,7 @@ export function isVisibleToChannel(access: ConfigAccess | undefined, channel: Ch * input; `checkPolicy` is the only gate. * * @example - * guarded({ name: 'prod', access: { user: 'operator', mcp: 'viewer' } }); // true + * guarded({ name: 'prod', access: { user: 'operator', agent: 'viewer' } }); // true */ export function guarded(target: PolicyTarget): boolean { @@ -187,26 +187,26 @@ export function guarded(target: PolicyTarget): boolean { } /** - * Formats access as `user: mcp:` — the shared display + * Formats access as `user: agent:` — the shared display * string for `noorm config list` and the TUI config list screen, so the * format can't drift between the two. Omitted entirely (`null`) only for * configs sitting on `DEFAULT_ACCESS`, so the tag means "someone changed * this" in either direction. * * Keyed to the default rather than `guarded()` because the default is no - * longer the loosest setting: `mcp: 'admin'` is now an opt-in escalation, + * longer the loosest setting: `agent: 'admin'` is now an opt-in escalation, * and `guarded()` (which only reads the user channel) would render the one * config an agent can write to as unremarkable. * * @example - * formatAccessTag({ name: 'prod', access: { user: 'operator', mcp: false } }); // 'user:operator mcp:off' + * formatAccessTag({ name: 'prod', access: { user: 'operator', agent: false } }); // 'user:operator agent:off' */ export function formatAccessTag(config: { name: string; access: ConfigAccess }): string | null { const { access } = config; - if (access.user === DEFAULT_ACCESS.user && access.mcp === DEFAULT_ACCESS.mcp) return null; + if (access.user === DEFAULT_ACCESS.user && access.agent === DEFAULT_ACCESS.agent) return null; - return `user:${access.user} mcp:${access.mcp === false ? 'off' : access.mcp}`; + return `user:${access.user} agent:${access.agent === false ? 'off' : access.agent}`; } diff --git a/src/core/policy/harness.ts b/src/core/policy/harness.ts new file mode 100644 index 00000000..03433bcf --- /dev/null +++ b/src/core/policy/harness.ts @@ -0,0 +1,118 @@ +/** + * Agent-harness detection. + * + * An agent that is denied a write over MCP can see that `noorm` is on the + * PATH and simply shell out to it. Before this existed the CLI hardcoded the + * `user` channel, so that second attempt ran with the human's role — a config + * whose agent role was `viewer` still let an agent drop the database through + * the CLI. Resolving the channel from the environment is what closes that. + * + * This is an allowlist of markers the harnesses set themselves, not a + * heuristic. Deliberately excluded: `TERM_PROGRAM`, `CI`, and TTY state. Those + * describe the terminal or the pipeline, not the caller — `TERM_PROGRAM` is + * set by iTerm, VS Code and Warp alike, so keying on it would classify a human + * as an agent and lock them out of their own CLI. A false positive here is + * worse than a false negative: it breaks the operator's tooling, while a miss + * only leaves us where we already were. + * + * Detection is evadable — anything reading env is. It is aimed at an agent + * that routes around a refusal, which is 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. + * + * @example + * const harness = detectAgentHarness(); + * if (harness) console.log(`running under ${harness.name}`); + */ + +/** + * A harness noorm can recognise from the environment it exports. + */ +export interface AgentHarness { + + /** Stable id recorded in provenance. Never renamed once shipped. */ + id: string; + + /** Display name for messages and `noorm info`. */ + name: string; + + /** Environment variables whose presence identifies this harness. */ + markers: readonly string[]; + +} + +/** + * Harnesses we recognise, most specific first. + * + * Each entry is a variable the harness sets for its own child processes. + * Adding one is a single line — but verify the variable is set by the harness + * itself and is not something a user might plausibly export by hand, because + * a wrong entry silently downgrades a human's permissions. + */ +export const AGENT_HARNESSES: readonly AgentHarness[] = [ + { id: 'claude-code', name: 'Claude Code', markers: ['CLAUDECODE', 'CLAUDE_CODE', 'CLAUDE_CODE_ENTRYPOINT'] }, + { id: 'codex', name: 'OpenAI Codex', markers: ['CODEX_SANDBOX'] }, + { id: 'cursor', name: 'Cursor', markers: ['CURSOR_AGENT'] }, + { id: 'gemini-cli', name: 'Gemini CLI', markers: ['GEMINI_CLI'] }, + + // Generic convention, and the self-declaration hatch for a harness that + // is not listed above. Last so a specific match always wins the id. + { id: 'generic', name: 'AI agent', markers: ['AI_AGENT', 'NOORM_AGENT'] }, +]; + +/** + * Whether an environment variable is meaningfully set. + * + * An exported-but-empty variable reads as absent: `CLAUDECODE=` is how a + * caller disables the marker, and treating it as present would make that + * impossible. + */ +function isSet(env: Record, key: string): boolean { + + const value = env[key]; + + return typeof value === 'string' && value.length > 0; + +} + +/** + * Identify the agent harness driving this process, if any. + * + * Pure over its input so it can be tested without mutating `process.env` — + * which matters here, because Bun caches some environment reads for the life + * of the process and a test that mutates the real environment leaks into + * every file that runs after it. + * + * @example + * detectAgentHarness({ CLAUDECODE: '1' }); // { id: 'claude-code', ... } + * detectAgentHarness({ TERM_PROGRAM: 'vscode' }); // null — a terminal, not an agent + */ +export function detectAgentHarness( + env: Record = process.env, +): AgentHarness | null { + + for (const harness of AGENT_HARNESSES) { + + if (harness.markers.some((marker) => isSet(env, marker))) { + + return harness; + + } + + } + + return null; + +} + +/** + * Whether this process is being driven by an agent. + * + * @example + * if (isAgentSession()) { /* resolve the agent channel *\/ } + */ +export function isAgentSession(env: Record = process.env): boolean { + + return detectAgentHarness(env) !== null; + +} diff --git a/src/core/policy/index.ts b/src/core/policy/index.ts index d1d0b8c6..7bc0135b 100644 --- a/src/core/policy/index.ts +++ b/src/core/policy/index.ts @@ -5,7 +5,10 @@ * config-scoped action. */ export { assertPolicy, checkConfigPolicy, checkPolicy, confirmationPhraseFor, formatAccessTag, guarded, isVisibleToChannel } from './check.js'; +export { resolveChannel } from './channel.js'; export { classifyStatements } from './classify.js'; +export { AGENT_HARNESSES, detectAgentHarness, isAgentSession } from './harness.js'; +export type { AgentHarness } from './harness.js'; export type { SqlClass } from './classify.js'; export { DEFAULT_ACCESS, GUARDED_ACCESS, resolveLegacyAccess } from './legacy-access.js'; export { MATRIX } from './matrix.js'; diff --git a/src/core/policy/legacy-access.ts b/src/core/policy/legacy-access.ts index 63dc27c3..1873579a 100644 --- a/src/core/policy/legacy-access.ts +++ b/src/core/policy/legacy-access.ts @@ -13,20 +13,24 @@ import type { ConfigAccess } from './types.js'; * Access for a config with no explicit role and no legacy `protected` flag. * * The agent channel is deliberately *not* admin here. A stock project never - * writes `access`, so this is what an MCP client holds against essentially + * writes `access`, so this is what every agent holds against essentially * every config in the wild — granting it admin made the rest of the matrix * decorative on that channel. `viewer` keeps the agent's real job (schema * exploration and read queries) working while write, DDL, destructive, and * credential permissions all require an explicit opt-in. * - * Not `mcp: false`: invisibility reports the same error as an unknown + * `viewer` rather than `operator` also means the destructive cells resolve to + * `deny`, not `confirm`. That distinction is load-bearing on the CLI, where + * `--yes` satisfies a confirm. + * + * Not `agent: false`: invisibility reports the same error as an unknown * config, so an operator whose agent stopped working would have no way to * tell a restricted default from a typo. */ -export const DEFAULT_ACCESS: ConfigAccess = { user: 'admin', mcp: 'viewer' }; +export const DEFAULT_ACCESS: ConfigAccess = { user: 'admin', agent: 'viewer' }; /** Access a legacy `protected: true` boolean maps to. */ -export const GUARDED_ACCESS: ConfigAccess = { user: 'operator', mcp: 'viewer' }; +export const GUARDED_ACCESS: ConfigAccess = { user: 'operator', agent: 'viewer' }; /** * Resolves the access a config should have from its raw inputs. @@ -36,8 +40,8 @@ export const GUARDED_ACCESS: ConfigAccess = { user: 'operator', mcp: 'viewer' }; * for no restriction, which is the default — not a grant of agent admin. * * @example - * resolveLegacyAccess(undefined, true); // { user: 'operator', mcp: 'viewer' } - * resolveLegacyAccess(undefined, undefined); // { user: 'admin', mcp: 'viewer' } + * resolveLegacyAccess(undefined, true); // { user: 'operator', agent: 'viewer' } + * resolveLegacyAccess(undefined, undefined); // { user: 'admin', agent: 'viewer' } */ export function resolveLegacyAccess( access: ConfigAccess | undefined, diff --git a/src/core/policy/types.ts b/src/core/policy/types.ts index 7a513bcc..0647faf9 100644 --- a/src/core/policy/types.ts +++ b/src/core/policy/types.ts @@ -13,19 +13,22 @@ export type Role = 'viewer' | 'operator' | 'admin'; /** - * Who is asking — CLI/TUI/SDK callers are `user`, the MCP server is `mcp`. + * Who is *driving* — a human at the keyboard is `user`, an AI agent is + * `agent`, whichever binary it reached for. Deliberately not "which + * transport was used": an agent that shells out to the CLI after an MCP + * refusal is still an agent. Resolved from provenance by `resolveChannel`. * Not an identity system; there are no user accounts. */ -export type Channel = 'user' | 'mcp'; +export type Channel = 'user' | 'agent'; /** - * Per-channel access for a config. `mcp: false` means the config is - * invisible on the MCP channel — not a role, and never consulted by - * `checkPolicy` (visibility is enforced upstream of policy). + * Per-channel access for a config. `agent: false` means the config is + * invisible to agents on *both* transports — not a role, and never consulted + * by `checkPolicy` (visibility is enforced upstream of policy). */ export interface ConfigAccess { user: Role; - mcp: Role | false; + agent: Role | false; } /** diff --git a/src/core/runner/types.ts b/src/core/runner/types.ts index 2e96aa51..a5938d4d 100644 --- a/src/core/runner/types.ts +++ b/src/core/runner/types.ts @@ -77,7 +77,7 @@ export const DEFAULT_RUN_OPTIONS: Required> & { outpu * configName: 'dev', * identity: { name: 'Alice', email: 'alice@example.com' }, * projectRoot: '/project', - * access: { user: 'admin', mcp: 'admin' }, + * access: { user: 'admin', agent: 'admin' }, * channel: 'user', * } * ``` @@ -103,7 +103,7 @@ export interface RunContext { */ access: ConfigAccess; - /** Caller channel for the policy gate — `user` for CLI/TUI/SDK, `mcp` for MCP. */ + /** Caller channel for the policy gate — `user` for CLI/TUI/SDK, `agent` for an AI agent (MCP or CLI). */ channel: Channel; /** Database dialect for schema-aware operations. Default: 'postgres' */ diff --git a/src/core/settings/manager.ts b/src/core/settings/manager.ts index 8adcb325..153fe5e0 100644 --- a/src/core/settings/manager.ts +++ b/src/core/settings/manager.ts @@ -42,6 +42,7 @@ const META_SETTINGS_ENV_VARS = new Set([ 'NOORM_CI_CONFIG_NAME', 'NOORM_LOGGER_DEBUG', 'NOORM_IDENTITY', + 'NOORM_CHANNEL', ]); /** diff --git a/src/core/settings/rules.ts b/src/core/settings/rules.ts index fae66e36..3578735f 100644 --- a/src/core/settings/rules.ts +++ b/src/core/settings/rules.ts @@ -59,7 +59,7 @@ function isConfigGuarded(config: ConfigForRuleMatch): boolean { * @example * ```typescript * const rule = { match: { isTest: true, type: 'local' } } - * const config = { name: 'dev', isTest: true, type: 'local', access: { user: 'admin', mcp: 'admin' } } + * const config = { name: 'dev', isTest: true, type: 'local', access: { user: 'admin', agent: 'admin' } } * * ruleMatches(rule.match, config) // true * ``` diff --git a/src/core/shared/operation-id.ts b/src/core/shared/operation-id.ts index 4965a739..b9c6b5b5 100644 --- a/src/core/shared/operation-id.ts +++ b/src/core/shared/operation-id.ts @@ -25,6 +25,8 @@ import { sql } from 'kysely'; import { attempt } from '@logosdx/utils'; import type { Dialect } from '../connection/types.js'; +import { withAgentProvenance } from '../identity/provenance.js'; +import { detectAgentHarness } from '../policy/harness.js'; import { getNoormTables } from './tables.js'; import type { NewNoormChange, NoormDatabase } from './tables.js'; @@ -108,6 +110,11 @@ function lastInsertIdQuery(dialect: Dialect): ReturnType; }): Promise<[number | undefined, Error | null]> { - const { db, ndb, dialect, table, values } = opts; + const { db, ndb, dialect, table } = opts; + + const harness = detectAgentHarness(opts.env); + + // Left strictly untouched when no harness is detected, so a human-driven + // record is byte-for-byte what it was before provenance existed. + const values: NewNoormChange = harness === null + ? opts.values + : { + ...opts.values, + executed_by: withAgentProvenance(opts.values.executed_by ?? '', harness), + }; const insertQuery = ndb.insertInto(table).values(values); diff --git a/src/core/state/access.ts b/src/core/state/access.ts index 8967ed49..4a6360cb 100644 --- a/src/core/state/access.ts +++ b/src/core/state/access.ts @@ -33,11 +33,11 @@ function isRole(value: unknown): value is Role { } /** - * `mcp: false` is not a role — it hides the config from the MCP channel - * entirely, which is strictly more restrictive than any role. It must - * survive repair untouched. + * `agent: false` is not a role — it hides the config from agents entirely, + * which is strictly more restrictive than any role. It must survive repair + * untouched. */ -function isMcpAccess(value: unknown): value is Role | false { +function isAgentAccess(value: unknown): value is Role | false { return value === false || isRole(value); @@ -51,8 +51,8 @@ function isMcpAccess(value: unknown): value is Role | false { * * @example * ```typescript - * repairConfigAccess({ user: 'admin' }, undefined); // { user: 'admin', mcp: 'viewer' } - * repairConfigAccess(undefined, 'true'); // { user: 'operator', mcp: 'viewer' } + * repairConfigAccess({ user: 'admin' }, undefined); // { user: 'admin', agent: 'viewer' } + * repairConfigAccess(undefined, 'true'); // { user: 'operator', agent: 'viewer' } * ``` */ export function repairConfigAccess(rawAccess: unknown, rawProtected: unknown): ConfigAccess { @@ -70,7 +70,7 @@ export function repairConfigAccess(rawAccess: unknown, rawProtected: unknown): C return { user: isRole(rawAccess['user']) ? rawAccess['user'] : MOST_RESTRICTIVE_ROLE, - mcp: isMcpAccess(rawAccess['mcp']) ? rawAccess['mcp'] : MOST_RESTRICTIVE_ROLE, + agent: isAgentAccess(rawAccess['agent']) ? rawAccess['agent'] : MOST_RESTRICTIVE_ROLE, }; } diff --git a/src/core/vault/policy.ts b/src/core/vault/policy.ts index 755e27ab..85e0ae6e 100644 --- a/src/core/vault/policy.ts +++ b/src/core/vault/policy.ts @@ -25,7 +25,7 @@ export interface VaultPolicyGate { /** The config's access declaration. Absent denies. */ access?: ConfigAccess; - /** Who is asking — CLI/TUI/SDK are `user`, the MCP server is `mcp`. */ + /** Who is asking — CLI/TUI/SDK are `user`, an AI agent is `agent` (MCP or CLI). */ channel: Channel; } diff --git a/src/core/version/state/index.ts b/src/core/version/state/index.ts index 784f5575..e18e187c 100644 --- a/src/core/version/state/index.ts +++ b/src/core/version/state/index.ts @@ -23,12 +23,13 @@ import { // Import migrations import { v1 } from './migrations/v1.js'; import { v2 } from './migrations/v2.js'; +import { v3 } from './migrations/v3.js'; /** * All state migrations in order. * Add new migrations here as they're created. */ -const MIGRATIONS: StateMigration[] = [v1, v2]; +const MIGRATIONS: StateMigration[] = [v1, v2, v3]; /** * Get state version from state object. diff --git a/src/core/version/state/migrations/v2.ts b/src/core/version/state/migrations/v2.ts index 2b95b1ea..d157dd5a 100644 --- a/src/core/version/state/migrations/v2.ts +++ b/src/core/version/state/migrations/v2.ts @@ -17,8 +17,8 @@ function isRecord(value: unknown): value is Record { /** * Migration v2: per-config access roles. * - * - `protected: true` -> `{ user: 'operator', mcp: 'viewer' }` - * - `protected: false` or absent -> `{ user: 'admin', mcp: 'admin' }` + * - `protected: true` -> `{ user: 'operator', agent: 'viewer' }` + * - `protected: false` or absent -> `{ user: 'admin', agent: 'viewer' }` * * The stored `protected` field is dropped — `access` becomes the sole * source of truth for a config's roles. Because `protected` does not diff --git a/src/core/version/state/migrations/v3.ts b/src/core/version/state/migrations/v3.ts new file mode 100644 index 00000000..30bd962f --- /dev/null +++ b/src/core/version/state/migrations/v3.ts @@ -0,0 +1,98 @@ +/** + * State Migration v3 - `access.mcp` renamed to `access.agent`. + * + * The channel stopped describing which binary was invoked and started + * describing who is driving, so the stored field follows. Values carry over + * verbatim: an explicit `mcp: 'operator'` becomes `agent: 'operator'`, and + * `mcp: false` becomes `agent: false` — the rename does not change what any + * config grants, only who the grant now also covers (an agent shelling out + * to the CLI, which previously ran as the human). + */ +import { repairConfigAccess } from '../../../state/access.js'; +import type { StateMigration } from '../../types.js'; + +function isRecord(value: unknown): value is Record { + + return typeof value === 'object' && value !== null; + +} + +/** + * Migration v3: rename the agent channel's access key. + * + * Only the key moves — `repairConfigAccess` then decides whether the value + * is a shape it recognises, and downgrades to `viewer` if not. That keeps + * the one-directional rule v2 established: an unreadable stored access may + * only make a config more restrictive, never less. + * + * v2 already emits the post-rename shape (it shares `repairConfigAccess`), + * so on a v1-or-older state this migration finds no `mcp` key and is a + * no-op. It matters for state already sitting at v2. + */ +export const v3: StateMigration = { + version: 3, + description: 'Rename per-config access.mcp to access.agent', + + up(state: Record): Record { + + return mapConfigAccess(state, (access) => { + + if (!isRecord(access)) return repairConfigAccess(access, undefined); + + const { mcp, ...rest } = access; + + return repairConfigAccess({ ...rest, agent: rest['agent'] ?? mcp }, undefined); + + }); + + }, + + down(state: Record): Record { + + return mapConfigAccess(state, (access) => { + + if (!isRecord(access)) return access; + + const { agent, ...rest } = access; + + return { ...rest, mcp: agent }; + + }); + + }, +}; + +/** + * Applies `fn` to every config's `access`, leaving non-object configs and a + * missing `configs` map alone. Extracted because `up` and `down` differ only + * in the per-config transform. + */ +function mapConfigAccess( + state: Record, + fn: (access: unknown) => unknown, +): Record { + + const rawConfigs = state['configs']; + const configs = isRecord(rawConfigs) ? rawConfigs : {}; + + const mapped: Record = {}; + + for (const [name, rawConfig] of Object.entries(configs)) { + + if (!isRecord(rawConfig)) { + + mapped[name] = rawConfig; + continue; + + } + + mapped[name] = { ...rawConfig, access: fn(rawConfig['access']) }; + + } + + return { + ...state, + configs: mapped, + }; + +} diff --git a/src/core/version/types.ts b/src/core/version/types.ts index 76ef2efe..9015acda 100644 --- a/src/core/version/types.ts +++ b/src/core/version/types.ts @@ -27,7 +27,7 @@ export const CURRENT_VERSIONS = Object.freeze({ schema: 2, /** State file (state.enc) schema version */ - state: 2, + state: 3, /** Settings file (settings.yml) schema version */ settings: 1, diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 7769280d..58d07b4c 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -12,7 +12,7 @@ import { createMcpServer } from './server.js'; export async function startServer(): Promise { const registry = createRegistry(); - const session = new SessionManager('mcp'); + const session = new SessionManager('agent'); const server = createMcpServer(registry, session); const transport = new StdioServerTransport(); diff --git a/src/rpc/commands/config.ts b/src/rpc/commands/config.ts index 98ba8464..30c3fb62 100644 --- a/src/rpc/commands/config.ts +++ b/src/rpc/commands/config.ts @@ -27,8 +27,8 @@ const listConfigsCommand: RpcCommand, ConfigSummary[]> = { const summaries = manager.listConfigs(); - // Invisibility: a config with access.mcp === false (or missing - // access) does not exist as far as the mcp channel is concerned. + // Invisibility: a config with access.agent === false (or missing + // access) does not exist as far as the agent channel is concerned. return summaries.filter((summary) => isVisibleToChannel(summary.access, session.channel)); }, diff --git a/src/rpc/commands/session.ts b/src/rpc/commands/session.ts index 31b91cb3..ecb24e87 100644 --- a/src/rpc/commands/session.ts +++ b/src/rpc/commands/session.ts @@ -69,8 +69,8 @@ export interface SessionStatus { * * Active-config resolution mirrors `resolveConfig`'s no-name path * (`src/core/config/resolver.ts:214`) so `status` reports exactly what a - * bare `connect` would target. On the mcp channel, a config hidden via - * `access.mcp === false` (or unknown to state) is reported as no active + * bare `connect` would target. On the agent channel, a config hidden via + * `access.agent === false` (or unknown to state) is reported as no active * config, same invisibility `list_configs` applies — `status` must not leak * a hidden config's name through the active-config field. * @@ -98,11 +98,11 @@ const statusCommand: RpcCommand, SessionStatus> = { const connections = session.listConnections(); let activeConfig = getEnvConfigName() ?? manager.getActiveConfigName() ?? null; - if (activeConfig && session.channel === 'mcp') { + if (activeConfig && session.channel === 'agent') { const config = manager.getConfig(activeConfig); - if (!config || config.access.mcp === false) { + if (!config || config.access.agent === false) { activeConfig = null; diff --git a/src/rpc/session.ts b/src/rpc/session.ts index 360b3e97..c82f1357 100644 --- a/src/rpc/session.ts +++ b/src/rpc/session.ts @@ -27,10 +27,11 @@ export class SessionManager implements RpcSession { #contexts = new Map(); /** - * The channel this session was opened on. Drives policy checks - * (`checkConfigPolicy`) and mcp-channel invisibility in `connect`/ - * `getContext`. Defaults to `'user'` so pre-existing `new SessionManager()` - * call sites keep working; `mcp serve` passes `'mcp'` explicitly. + * Who this session acts for. Drives policy checks (`checkConfigPolicy`) + * and agent-channel invisibility in `connect`/`getContext`. Defaults to + * `'user'` so pre-existing `new SessionManager()` call sites keep + * working; `mcp serve` passes `'agent'` explicitly, because everything + * reaching it over stdio is an agent by construction. */ readonly channel: Channel; @@ -46,7 +47,7 @@ export class SessionManager implements RpcSession { * Creates a Context, connects, and stores it. * If config is omitted, resolves the active config from state. * - * On the `mcp` channel, a config with `access.mcp === false` (or no + * On the `agent` channel, a config with `access.agent === false` (or no * `access` at all — fail closed per docs/spec/config-access-roles.md) * is invisible: this throws the byte-identical error an unknown config * name produces, rather than surfacing that the config exists but is @@ -85,9 +86,9 @@ export class SessionManager implements RpcSession { name: resolvedName, dialect: ctx.dialect, database: ctx.noorm.config.connection.database, - // access.mcp === false is unreachable here — the invisibility + // access.agent === false is unreachable here — the invisibility // guard above already denies before a context is ever stored. - role: this.channel === 'mcp' ? (rawAccess.mcp === false ? 'viewer' : rawAccess.mcp) : rawAccess.user, + role: this.channel === 'agent' ? (rawAccess.agent === false ? 'viewer' : rawAccess.agent) : rawAccess.user, }; } diff --git a/src/rpc/types.ts b/src/rpc/types.ts index 77a4f79b..79600fe1 100644 --- a/src/rpc/types.ts +++ b/src/rpc/types.ts @@ -25,7 +25,7 @@ export interface RpcCommand { /** * Session interface for RPC command handlers. * - * Provides access to database connections and the channel (`user`/`mcp`) + * Provides access to database connections and the channel (`user`/`agent`) * the session was opened on, so handlers can run channel-aware policy * checks (e.g. the `sql` command's statement-class escalation). * Implemented by SessionManager in session.ts. diff --git a/src/sdk/guards.ts b/src/sdk/guards.ts index 95766bd3..9e108405 100644 --- a/src/sdk/guards.ts +++ b/src/sdk/guards.ts @@ -121,7 +121,7 @@ export function checkRequireTest( * `options.yes` (the programmatic equivalent of the CLI's `--yes`) — * mirrors `db drop`'s CLI gate (`check.requiresConfirmation && !args.yes`). * `options.yes` is only consulted once `checkConfigPolicy` has already - * resolved the channel: on `mcp`, `confirm` collapses to deny before this + * resolved the channel: on `agent`, `confirm` collapses to deny before this * function ever sees a `requiresConfirmation` result, so `yes: true` never * unblocks an MCP-channel context. * diff --git a/src/sdk/types.ts b/src/sdk/types.ts index f494962d..2eaf022c 100644 --- a/src/sdk/types.ts +++ b/src/sdk/types.ts @@ -56,17 +56,19 @@ export interface CreateContextOptions { stage?: string; /** - * Which caller channel this context represents for access-policy checks - * (`checkPolicy` in `src/sdk/guards.ts`). SDK/CLI/TUI callers are `user`; - * pass `'mcp'` when the context backs an MCP session. Default: `'user'`. + * Who this context acts for, for access-policy checks (`checkPolicy` in + * `src/sdk/guards.ts`). A human driving the SDK/CLI/TUI is `user`; pass + * `'agent'` when an AI agent is driving, whichever transport it reached + * for. Default: `'user'`. */ channel?: Channel; /** * Pre-confirm operations that a policy `confirm` cell would otherwise * block — the programmatic equivalent of the CLI's --yes. Only - * meaningful on the user channel; mcp collapses confirm to deny - * before this is consulted. Default: false. + * meaningful on the user channel; `agent` collapses confirm to deny + * before this is consulted, so an agent cannot use it to walk through + * a gate a human was meant to answer. Default: false. */ yes?: boolean; diff --git a/src/tui/screens/config/ConfigAddScreen.tsx b/src/tui/screens/config/ConfigAddScreen.tsx index 5f50cb83..51ff8e7f 100644 --- a/src/tui/screens/config/ConfigAddScreen.tsx +++ b/src/tui/screens/config/ConfigAddScreen.tsx @@ -35,7 +35,7 @@ import { buildConnectionConfig, buildAccessFromValues, USER_ROLE_OPTIONS, - MCP_ROLE_OPTIONS, + AGENT_ROLE_OPTIONS, } from '../../utils/index.js'; /** @@ -126,11 +126,11 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { defaultValue: defaultAccess.user, }, { - key: 'mcpRole', - label: 'MCP Role (agent access)', + key: 'agentRole', + label: 'Agent Role (MCP/CLI access)', type: 'select', - options: MCP_ROLE_OPTIONS, - defaultValue: defaultAccess.mcp === false ? 'off' : defaultAccess.mcp, + options: AGENT_ROLE_OPTIONS, + defaultValue: defaultAccess.agent === false ? 'off' : defaultAccess.agent, }, { key: 'isTest', diff --git a/src/tui/screens/config/ConfigEditScreen.tsx b/src/tui/screens/config/ConfigEditScreen.tsx index 87d5441b..6d784b57 100644 --- a/src/tui/screens/config/ConfigEditScreen.tsx +++ b/src/tui/screens/config/ConfigEditScreen.tsx @@ -31,7 +31,7 @@ import { buildConnectionConfig, buildAccessFromValues, USER_ROLE_OPTIONS, - MCP_ROLE_OPTIONS, + AGENT_ROLE_OPTIONS, } from '../../utils/index.js'; /** @@ -133,11 +133,11 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { defaultValue: access.user, }, { - key: 'mcpRole', - label: 'MCP Role (agent access)', + key: 'agentRole', + label: 'Agent Role (MCP/CLI access)', type: 'select', - options: MCP_ROLE_OPTIONS, - defaultValue: access.mcp === false ? 'off' : access.mcp, + options: AGENT_ROLE_OPTIONS, + defaultValue: access.agent === false ? 'off' : access.agent, }, { key: 'isTest', diff --git a/src/tui/utils/config-validation.ts b/src/tui/utils/config-validation.ts index 0fde82b7..f9acc8aa 100644 --- a/src/tui/utils/config-validation.ts +++ b/src/tui/utils/config-validation.ts @@ -151,7 +151,7 @@ export function buildConnectionConfig( /** * Select options for the `userRole` field — the `user` channel has no - * `false`/off state, unlike `mcp`. + * `false`/off state, unlike `agent`. */ export const USER_ROLE_OPTIONS: SelectOption[] = [ { label: 'Viewer', value: 'viewer' }, @@ -160,11 +160,12 @@ export const USER_ROLE_OPTIONS: SelectOption[] = [ ]; /** - * Select options for the `mcpRole` field — `off` maps to `access.mcp: false` - * (invisible to the MCP channel), the one state the `user` channel lacks. + * Select options for the `agentRole` field — `off` maps to `access.agent: false` + * (invisible to agents over MCP *and* the CLI), the one state the `user` + * channel lacks. */ -export const MCP_ROLE_OPTIONS: SelectOption[] = [ - { label: 'Off (hidden from MCP)', value: 'off' }, +export const AGENT_ROLE_OPTIONS: SelectOption[] = [ + { label: 'Off (hidden from agents)', value: 'off' }, { label: 'Viewer', value: 'viewer' }, { label: 'Operator', value: 'operator' }, { label: 'Admin', value: 'admin' }, @@ -177,7 +178,7 @@ function isRole(value: string): value is Role { } /** - * Builds a `ConfigAccess` from the `userRole`/`mcpRole` select fields shared + * Builds a `ConfigAccess` from the `userRole`/`agentRole` select fields shared * by ConfigAdd/ConfigEdit. The select only ever offers valid options, so an * unrecognized/missing value should never happen in practice — the fallback * is defense-in-depth and fails closed (`viewer`/`false`) rather than @@ -185,18 +186,18 @@ function isRole(value: string): value is Role { * * @example * ```typescript - * buildAccessFromValues({ userRole: 'operator', mcpRole: 'off' }); - * // { user: 'operator', mcp: false } + * buildAccessFromValues({ userRole: 'operator', agentRole: 'off' }); + * // { user: 'operator', agent: false } * ``` */ export function buildAccessFromValues(values: FormValues): ConfigAccess { const userRoleValue = String(values['userRole'] ?? 'viewer'); - const mcpRoleValue = String(values['mcpRole'] ?? 'off'); + const agentRoleValue = String(values['agentRole'] ?? 'off'); return { user: isRole(userRoleValue) ? userRoleValue : 'viewer', - mcp: mcpRoleValue === 'off' ? false : (isRole(mcpRoleValue) ? mcpRoleValue : false), + agent: agentRoleValue === 'off' ? false : (isRole(agentRoleValue) ? agentRoleValue : false), }; } diff --git a/src/tui/utils/index.ts b/src/tui/utils/index.ts index 58086792..4cc8cae6 100644 --- a/src/tui/utils/index.ts +++ b/src/tui/utils/index.ts @@ -23,7 +23,7 @@ export { isConfigGuarded, DEFAULT_PORTS, USER_ROLE_OPTIONS, - MCP_ROLE_OPTIONS, + AGENT_ROLE_OPTIONS, type ConnectionDefaults, } from './config-validation.js'; export { getErrorMessage } from './error.js'; diff --git a/tests/cli/VaultScreen.test.tsx b/tests/cli/VaultScreen.test.tsx index 5d31ff8f..00bd1313 100644 --- a/tests/cli/VaultScreen.test.tsx +++ b/tests/cli/VaultScreen.test.tsx @@ -102,7 +102,7 @@ function makeConfig() { name: 'test', type: 'local' as const, isTest: true, - access: { user: 'admin' as const, mcp: 'admin' as const }, + access: { user: 'admin' as const, agent: 'admin' as const }, connection: { dialect: 'sqlite' as const, database: ':memory:', diff --git a/tests/cli/agent-channel-escalation.test.ts b/tests/cli/agent-channel-escalation.test.ts new file mode 100644 index 00000000..77486f4d --- /dev/null +++ b/tests/cli/agent-channel-escalation.test.ts @@ -0,0 +1,286 @@ +/** + * cli: an agent that shells out gets the agent role, not the human's. + * + * The escalation this pins was measured through the shipped binary, so this + * reproduces it there. An agent refused a write over MCP could run the same + * operation through `noorm` and succeed, because the CLI hardcoded the `user` + * channel at every policy call site. On a stock config that turned deny into + * allow for sql:write, sql:ddl, db:create, run:build and vault:read, and + * turned db:destroy's deny into a confirm that `--yes` walks straight + * through. + * + * Each case runs the *same command twice against the same config*, differing + * only in whether the spawned environment carries an agent-harness marker. + * The human run has to succeed and the agent run has to be refused — a fix + * that simply broke the command would pass half of that and fail the other. + * + * The matrix-level property (nothing denied over MCP is reachable via the + * CLI) lives in tests/core/policy/agent-escalation.test.ts; this file proves + * the binary actually resolves the channel it is handed. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { Database } from 'bun:sqlite'; + +import { generateKeyPair } from '../../src/core/identity/crypto.js'; +import { StateManager } from '../../src/core/state/index.js'; +import type { Config } from '../../src/core/config/types.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'stock'; + +/** + * The variable Claude Code exports for its own child processes — the exact + * provenance signal `resolveChannel` keys on. Any entry from + * `AGENT_HARNESSES` would do; one real marker keeps the test concrete. + */ +const AGENT_MARKER = 'CLAUDECODE'; + +describe('cli: agent channel escalation', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let baseEnv: Record; + + beforeEach(async () => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-agent-channel-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-agent-channel-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + dbPath = join(tmpDir, 'target.db'); + privateKey = generateKeyPair().privateKey; + + baseEnv = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) baseEnv[key] = value; + + } + + Object.assign(baseEnv, { + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + await seedStockConfig(); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** + * A config with no `access` written — the shape a stock project actually + * has on disk, and the one the escalation was measured against. Seeded + * through StateManager because there is no headless `config add`. + */ + async function seedStockConfig(): Promise { + + const config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + connection: { dialect: 'sqlite', database: dbPath }, + } as unknown as Config; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + function seedTable(): void { + + const db = new Database(dbPath); + db.run('CREATE TABLE IF NOT EXISTS widget (id INTEGER PRIMARY KEY, name TEXT)'); + db.run("INSERT INTO widget (name) VALUES ('a')"); + db.close(); + + } + + function rowCount(): number { + + const db = new Database(dbPath); + const row = db.query('SELECT count(*) AS n FROM widget').get() as { n: number }; + db.close(); + + return row.n; + + } + + /** Runs the CLI as a human operator. */ + function asHuman(args: string[]) { + + return spawnSync('node', [CLI, ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: baseEnv, + }); + + } + + /** Runs the identical command from inside an agent harness. */ + function asAgent(args: string[]) { + + return spawnSync('node', [CLI, ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...baseEnv, [AGENT_MARKER]: '1' }, + }); + + } + + describe('sql:write', () => { + + it('refuses an INSERT for an agent while allowing it for the human', () => { + + seedTable(); + + const agent = asAgent(['sql', "INSERT INTO widget (name) VALUES ('agent')"]); + + expect(agent.status).not.toBe(0); + expect(rowCount()).toBe(1); + + const human = asHuman(['sql', "INSERT INTO widget (name) VALUES ('human')"]); + + expect(human.status).toBe(0); + expect(rowCount()).toBe(2); + + }); + + }); + + describe('sql:ddl', () => { + + it('refuses a CREATE TABLE for an agent while allowing it for the human', () => { + + seedTable(); + + const agent = asAgent(['sql', 'CREATE TABLE agent_made (id INTEGER)']); + + expect(agent.status).not.toBe(0); + + const human = asHuman(['sql', 'CREATE TABLE human_made (id INTEGER)']); + + expect(human.status).toBe(0); + + }); + + }); + + describe('sql:read', () => { + + it('still lets an agent read — the gate restricts the role, it does not lock the agent out', () => { + + seedTable(); + + const agent = asAgent(['sql', 'SELECT count(*) AS n FROM widget']); + + expect(agent.status).toBe(0); + + }); + + }); + + describe('db:create', () => { + + it('refuses for an agent while allowing the human', () => { + + const agent = asAgent(['db', 'create']); + + expect(agent.status).not.toBe(0); + expect(existsSync(dbPath)).toBe(false); + + const human = asHuman(['db', 'create']); + + expect(human.status).toBe(0); + expect(existsSync(dbPath)).toBe(true); + + }); + + }); + + describe('db:destroy', () => { + + it('is not satisfiable by --yes on the agent channel', () => { + + seedTable(); + + // This is the shape of the original leak: db:destroy resolved to + // `confirm` for the human's admin role, and `--yes` answers a + // confirm. On the agent channel confirm collapses to deny before + // --yes is ever consulted. + const agent = asAgent(['db', 'drop', '--yes']); + + expect(agent.status).not.toBe(0); + expect(existsSync(dbPath)).toBe(true); + + }); + + }); + + describe('vault:read', () => { + + it('refuses to list vault secrets for an agent', () => { + + seedTable(); + + const agent = asAgent(['vault', 'list']); + + expect(agent.status).not.toBe(0); + expect(agent.stdout + agent.stderr).toContain('vault:read'); + + }); + + }); + + describe('NOORM_CHANNEL escape hatch', () => { + + it('lets a human scripting inside an agent session opt back in', () => { + + seedTable(); + + const result = spawnSync('node', [CLI, 'sql', "INSERT INTO widget (name) VALUES ('scripted')"], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...baseEnv, [AGENT_MARKER]: '1', NOORM_CHANNEL: 'user' }, + }); + + expect(result.status).toBe(0); + expect(rowCount()).toBe(2); + + }); + + it('lets a caller declare the agent channel with no harness present', () => { + + seedTable(); + + const result = spawnSync('node', [CLI, 'sql', "INSERT INTO widget (name) VALUES ('declared')"], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...baseEnv, NOORM_CHANNEL: 'agent' }, + }); + + expect(result.status).not.toBe(0); + expect(rowCount()).toBe(1); + + }); + + }); + +}); diff --git a/tests/cli/change/history.test.ts b/tests/cli/change/history.test.ts index 0a34598c..16fa2d36 100644 --- a/tests/cli/change/history.test.ts +++ b/tests/cli/change/history.test.ts @@ -87,7 +87,7 @@ describe('cli: noorm change history — output streams', () => { name: CONFIG_NAME, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: dbPath }, }; diff --git a/tests/cli/change/list.test.ts b/tests/cli/change/list.test.ts index 4e3ac596..6fe73d2c 100644 --- a/tests/cli/change/list.test.ts +++ b/tests/cli/change/list.test.ts @@ -95,7 +95,7 @@ describe('cli: noorm change list — output streams', () => { name: CONFIG_NAME, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: dbPath }, }; diff --git a/tests/cli/change/rewind.test.ts b/tests/cli/change/rewind.test.ts index 23ddd316..1da08a7c 100644 --- a/tests/cli/change/rewind.test.ts +++ b/tests/cli/change/rewind.test.ts @@ -89,7 +89,7 @@ describe('cli: noorm change rewind', () => { name: CONFIG_NAME, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: dbPath }, }; diff --git a/tests/cli/change/rm.test.ts b/tests/cli/change/rm.test.ts index 43944c3c..314a35aa 100644 --- a/tests/cli/change/rm.test.ts +++ b/tests/cli/change/rm.test.ts @@ -120,7 +120,7 @@ describe('cli: noorm change rm — role gate + isYesMode confirm', () => { it('viewer-role active config denies deletion, disk untouched, even with --yes passed', async () => { - await seedConfig({ user: 'viewer', mcp: 'admin' }); + await seedConfig({ user: 'viewer', agent: 'admin' }); const result = runRm(['--yes']); @@ -132,7 +132,7 @@ describe('cli: noorm change rm — role gate + isYesMode confirm', () => { it('operator-role plus --yes succeeds, change directory deleted', async () => { - await seedConfig({ user: 'operator', mcp: 'admin' }); + await seedConfig({ user: 'operator', agent: 'admin' }); const result = runRm(['--yes']); @@ -143,7 +143,7 @@ describe('cli: noorm change rm — role gate + isYesMode confirm', () => { it('operator-role without --yes and without NOORM_YES is blocked, disk untouched', async () => { - await seedConfig({ user: 'operator', mcp: 'admin' }); + await seedConfig({ user: 'operator', agent: 'admin' }); const result = runRm(); @@ -155,7 +155,7 @@ describe('cli: noorm change rm — role gate + isYesMode confirm', () => { it('admin-role plus NOORM_YES=1, no --yes flag, succeeds — proves the isYesMode fix', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); const result = runRm([], { NOORM_YES: '1' }); diff --git a/tests/cli/ci/identity-enroll-hijack.test.ts b/tests/cli/ci/identity-enroll-hijack.test.ts index 4d3e5095..8fb593f6 100644 --- a/tests/cli/ci/identity-enroll-hijack.test.ts +++ b/tests/cli/ci/identity-enroll-hijack.test.ts @@ -66,7 +66,7 @@ interface EnrollFixture { * `enroll` enforces before it will propagate anything). */ async function setupEnrollFixture( - access: ConfigAccess = { user: 'admin', mcp: 'admin' }, + access: ConfigAccess = { user: 'admin', agent: 'admin' }, ): Promise { const testId = randomUUID().slice(0, 8); @@ -309,7 +309,7 @@ describe('cli: ci identity enroll — vault:propagate authorization', () => { it('denies a viewer config outright', async () => { - fx = await setupEnrollFixture({ user: 'viewer', mcp: 'viewer' }); + fx = await setupEnrollFixture({ user: 'viewer', agent: 'viewer' }); const result = runEnroll(fx, ['--public-key', fx.botPublicKey, '--yes']); @@ -319,7 +319,7 @@ describe('cli: ci identity enroll — vault:propagate authorization', () => { it('grants no vault access from a viewer config', async () => { - fx = await setupEnrollFixture({ user: 'viewer', mcp: 'viewer' }); + fx = await setupEnrollFixture({ user: 'viewer', agent: 'viewer' }); runEnroll(fx, ['--public-key', fx.botPublicKey, '--yes']); diff --git a/tests/cli/config-validation.test.ts b/tests/cli/config-validation.test.ts index 0a32f947..7626716e 100644 --- a/tests/cli/config-validation.test.ts +++ b/tests/cli/config-validation.test.ts @@ -8,29 +8,29 @@ import { describe('config-validation: buildAccessFromValues', () => { - it('maps valid userRole/mcpRole values to the matching ConfigAccess', () => { + it('maps valid userRole/agentRole values to the matching ConfigAccess', () => { - expect(buildAccessFromValues({ userRole: 'operator', mcpRole: 'off' })) - .toEqual({ user: 'operator', mcp: false }); + expect(buildAccessFromValues({ userRole: 'operator', agentRole: 'off' })) + .toEqual({ user: 'operator', agent: false }); - expect(buildAccessFromValues({ userRole: 'admin', mcpRole: 'admin' })) - .toEqual({ user: 'admin', mcp: 'admin' }); + expect(buildAccessFromValues({ userRole: 'admin', agentRole: 'admin' })) + .toEqual({ user: 'admin', agent: 'admin' }); - expect(buildAccessFromValues({ userRole: 'viewer', mcpRole: 'viewer' })) - .toEqual({ user: 'viewer', mcp: 'viewer' }); + expect(buildAccessFromValues({ userRole: 'viewer', agentRole: 'viewer' })) + .toEqual({ user: 'viewer', agent: 'viewer' }); }); it('fails closed to viewer/false when fields are missing', () => { - expect(buildAccessFromValues({})).toEqual({ user: 'viewer', mcp: false }); + expect(buildAccessFromValues({})).toEqual({ user: 'viewer', agent: false }); }); it('fails closed to viewer/false on unrecognized/garbage values', () => { - expect(buildAccessFromValues({ userRole: 'superuser', mcpRole: 'root' })) - .toEqual({ user: 'viewer', mcp: false }); + expect(buildAccessFromValues({ userRole: 'superuser', agentRole: 'root' })) + .toEqual({ user: 'viewer', agent: false }); }); diff --git a/tests/cli/config/export.test.ts b/tests/cli/config/export.test.ts index f52879af..3d021a63 100644 --- a/tests/cli/config/export.test.ts +++ b/tests/cli/config/export.test.ts @@ -77,7 +77,7 @@ describe('cli: noorm config export — output file mode', () => { name: CONFIG_NAME, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: join(tmpDir, 'target.db') }, }; @@ -142,7 +142,7 @@ describe('cli: noorm config export — output file mode', () => { name: CONFIG_NAME, type: 'local', isTest: true, - access: { user: 'viewer', mcp: false }, + access: { user: 'viewer', agent: false }, connection: { dialect: 'sqlite', database: join(tmpDir, 'target.db'), diff --git a/tests/cli/config/import.test.ts b/tests/cli/config/import.test.ts index 1971d979..4edcb573 100644 --- a/tests/cli/config/import.test.ts +++ b/tests/cli/config/import.test.ts @@ -134,7 +134,7 @@ describe('cli: noorm config import — legacy protected mapping', () => { expect(result.status).toBe(0); const access = readPersistedAccess('legacy-guarded'); - expect(access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(access).toEqual({ user: 'operator', agent: 'viewer' }); }); @@ -142,7 +142,7 @@ describe('cli: noorm config import — legacy protected mapping', () => { const path = writeConfigFile('modern.json', { name: 'modern-viewer', - access: { user: 'viewer', mcp: false }, + access: { user: 'viewer', agent: false }, connection: { dialect: 'sqlite', database: ':memory:' }, }); @@ -151,7 +151,7 @@ describe('cli: noorm config import — legacy protected mapping', () => { expect(result.status).toBe(0); const access = readPersistedAccess('modern-viewer'); - expect(access).toEqual({ user: 'viewer', mcp: false }); + expect(access).toEqual({ user: 'viewer', agent: false }); }); @@ -169,7 +169,7 @@ describe('cli: noorm config import — legacy protected mapping', () => { /** * Overwriting a config rewrites its `access` block, so import is an * escalation vector: without a gate, one `--force` turns a viewer config - * (or one hidden from MCP with `mcp: false`) into admin/admin. The + * (or one hidden from agents with `agent: false`) into admin/admin. The * existing config's role decides, not the incoming file's. */ describe('access escalation via --force', () => { @@ -186,7 +186,7 @@ describe('cli: noorm config import — legacy protected mapping', () => { const escalated = writeConfigFile('escalated.json', { name: 'locked', - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:' }, }); @@ -196,30 +196,30 @@ describe('cli: noorm config import — legacy protected mapping', () => { it('denies overwriting a viewer config, leaving its access intact', () => { - const result = importThenEscalate({ user: 'viewer', mcp: false }, ['--force']); + const result = importThenEscalate({ user: 'viewer', agent: false }, ['--force']); expect(result.status).toBe(1); expect(result.stdout + result.stderr).toContain('config:write'); - expect(readPersistedAccess('locked')).toEqual({ user: 'viewer', mcp: false }); + expect(readPersistedAccess('locked')).toEqual({ user: 'viewer', agent: false }); }); it('requires confirmation before overwriting an admin config', () => { - const result = importThenEscalate({ user: 'admin', mcp: 'viewer' }, ['--force']); + const result = importThenEscalate({ user: 'admin', agent: 'viewer' }, ['--force']); expect(result.status).toBe(1); expect(result.stdout + result.stderr).toContain('confirmation'); - expect(readPersistedAccess('locked')).toEqual({ user: 'admin', mcp: 'viewer' }); + expect(readPersistedAccess('locked')).toEqual({ user: 'admin', agent: 'viewer' }); }); it('overwrites an admin config once confirmation is given', () => { - const result = importThenEscalate({ user: 'admin', mcp: 'viewer' }, ['--force', '--yes']); + const result = importThenEscalate({ user: 'admin', agent: 'viewer' }, ['--force', '--yes']); expect(result.status).toBe(0); - expect(readPersistedAccess('locked')).toEqual({ user: 'admin', mcp: 'admin' }); + expect(readPersistedAccess('locked')).toEqual({ user: 'admin', agent: 'admin' }); }); diff --git a/tests/cli/config/list.test.ts b/tests/cli/config/list.test.ts index 7769ca65..a9fb7bfb 100644 --- a/tests/cli/config/list.test.ts +++ b/tests/cli/config/list.test.ts @@ -100,31 +100,31 @@ describe('cli: noorm config list — access tag display', () => { } - it('renders "user: mcp:" for a guarded config', async () => { + it('renders "user: agent:" for a guarded config', async () => { - await seedConfig({ user: 'operator', mcp: 'viewer' }); + await seedConfig({ user: 'operator', agent: 'viewer' }); const result = runList(); expect(result.status).toBe(0); - expect(result.stdout).toContain('user:operator mcp:viewer'); + expect(result.stdout).toContain('user:operator agent:viewer'); }); - it('renders "mcp:off" when access.mcp is false', async () => { + it('renders "agent:off" when access.agent is false', async () => { - await seedConfig({ user: 'viewer', mcp: false }); + await seedConfig({ user: 'viewer', agent: false }); const result = runList(); expect(result.status).toBe(0); - expect(result.stdout).toContain('user:viewer mcp:off'); + expect(result.stdout).toContain('user:viewer agent:off'); }); it('renders no access tag for a config on the default access', async () => { - await seedConfig({ user: 'admin', mcp: 'viewer' }); + await seedConfig({ user: 'admin', agent: 'viewer' }); const result = runList(); @@ -133,16 +133,66 @@ describe('cli: noorm config list — access tag display', () => { }); - it('renders the tag for an mcp:admin escalation', async () => { + it('renders the tag for an agent:admin escalation', async () => { // The one config an agent can write to must not read as unremarkable // just because the human channel is still admin. - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); const result = runList(); expect(result.status).toBe(0); - expect(result.stdout).toContain('user:admin mcp:admin'); + expect(result.stdout).toContain('user:admin agent:admin'); + + }); + + describe('agent: false invisibility', () => { + + /** Runs `config list` from inside an agent harness. */ + function runListAsAgent(): ReturnType { + + return spawnSync('node', [CLI, 'config', 'list'], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...identityEnv, CLAUDECODE: '1' }, + }); + + } + + it('hides the config from an agent on the CLI, as it already does over MCP', async () => { + + // `list_configs` filtered on the mcp channel from the start. + // Filtering only there made the setting worthless the moment the + // agent ran `noorm config list` instead of the RPC command. + await seedConfig({ user: 'admin', agent: false }); + + const result = runListAsAgent(); + + expect(result.stdout).not.toContain(CONFIG_NAME); + expect(result.stdout).toContain('No configurations found'); + + }); + + it('still shows it to the human', async () => { + + await seedConfig({ user: 'admin', agent: false }); + + const result = runList(); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(CONFIG_NAME); + + }); + + it('does not hide a config that merely restricts the agent role', async () => { + + await seedConfig({ user: 'admin', agent: 'viewer' }); + + const result = runListAsAgent(); + + expect(result.stdout).toContain(CONFIG_NAME); + + }); }); diff --git a/tests/cli/config/rm.test.ts b/tests/cli/config/rm.test.ts index a903052b..0a848f3e 100644 --- a/tests/cli/config/rm.test.ts +++ b/tests/cli/config/rm.test.ts @@ -125,7 +125,7 @@ describe('cli: noorm config delete command -- access policy gate', () => { it('deletes an admin config when --yes is passed', async () => { - await seedConfig(CONFIG_NAME, { user: 'admin', mcp: 'admin' }); + await seedConfig(CONFIG_NAME, { user: 'admin', agent: 'admin' }); const result = runDelete([CONFIG_NAME, '--yes']); @@ -136,7 +136,7 @@ describe('cli: noorm config delete command -- access policy gate', () => { it('refuses an admin config without --yes or NOORM_YES, naming the confirmation phrase', async () => { - await seedConfig(CONFIG_NAME, { user: 'admin', mcp: 'admin' }); + await seedConfig(CONFIG_NAME, { user: 'admin', agent: 'admin' }); const result = runDelete([CONFIG_NAME]); @@ -150,7 +150,7 @@ describe('cli: noorm config delete command -- access policy gate', () => { it('deletes when NOORM_YES=1 is set without --yes', async () => { - await seedConfig(CONFIG_NAME, { user: 'admin', mcp: 'admin' }); + await seedConfig(CONFIG_NAME, { user: 'admin', agent: 'admin' }); const result = runDelete([CONFIG_NAME], { NOORM_YES: '1' }); @@ -171,7 +171,7 @@ describe('cli: noorm config delete command -- access policy gate', () => { it('refuses to delete a config linked to a locked stage even with --yes, and leaves it intact', async () => { - await seedConfig(LOCKED_CONFIG_NAME, { user: 'admin', mcp: 'admin' }); + await seedConfig(LOCKED_CONFIG_NAME, { user: 'admin', agent: 'admin' }); writeLockedStage(LOCKED_CONFIG_NAME); const result = runDelete([LOCKED_CONFIG_NAME, '--yes']); @@ -186,7 +186,7 @@ describe('cli: noorm config delete command -- access policy gate', () => { it('denies a viewer with the policy blockedReason and leaves the config intact', async () => { - await seedConfig(CONFIG_NAME, { user: 'viewer', mcp: 'admin' }); + await seedConfig(CONFIG_NAME, { user: 'viewer', agent: 'admin' }); const result = runDelete([CONFIG_NAME, '--yes']); diff --git a/tests/cli/db/create.test.ts b/tests/cli/db/create.test.ts index ca165728..a29f4996 100644 --- a/tests/cli/db/create.test.ts +++ b/tests/cli/db/create.test.ts @@ -126,7 +126,7 @@ describe('cli: noorm db create — fresh vs already-exists', () => { it('creates the database and initializes tracking when the target does not exist yet', async () => { - const config = await seedConfig({ user: 'admin', mcp: 'admin' }); + const config = await seedConfig({ user: 'admin', agent: 'admin' }); expect(existsSync(dbPath)).toBe(false); @@ -145,7 +145,7 @@ describe('cli: noorm db create — fresh vs already-exists', () => { it('created is true when the JSON output reports a genuinely fresh create', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); expect(existsSync(dbPath)).toBe(false); @@ -160,7 +160,7 @@ describe('cli: noorm db create — fresh vs already-exists', () => { it('created is false when the SQLite target file already existed before create ran', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); writeFileSync(dbPath, ''); @@ -175,7 +175,7 @@ describe('cli: noorm db create — fresh vs already-exists', () => { it('short-circuits without re-running createDb when the target already exists and is initialized', async () => { - const config = await seedConfig({ user: 'admin', mcp: 'admin' }); + const config = await seedConfig({ user: 'admin', agent: 'admin' }); const first = runCreate(['--json']); expect(first.status).toBe(0); @@ -200,7 +200,7 @@ describe('cli: noorm db create — fresh vs already-exists', () => { it('targets the NOORM_CONNECTION_* database instead of the persisted config when both are set (#51)', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); const envDbPath = join(tmpDir, 'env-target.db'); diff --git a/tests/cli/db/drop.test.ts b/tests/cli/db/drop.test.ts index 9d2c0a72..24a95803 100644 --- a/tests/cli/db/drop.test.ts +++ b/tests/cli/db/drop.test.ts @@ -113,7 +113,7 @@ describe('cli: noorm db drop — access policy gate', () => { it('denies a viewer with the policy blockedReason and leaves the database intact', async () => { - await seedConfig({ user: 'viewer', mcp: 'admin' }); + await seedConfig({ user: 'viewer', agent: 'admin' }); const result = runDrop(); @@ -127,7 +127,7 @@ describe('cli: noorm db drop — access policy gate', () => { it('denies an operator with the policy blockedReason and leaves the database intact', async () => { - await seedConfig({ user: 'operator', mcp: 'admin' }); + await seedConfig({ user: 'operator', agent: 'admin' }); const result = runDrop(); @@ -141,7 +141,7 @@ describe('cli: noorm db drop — access policy gate', () => { it('blocks an admin without --yes or NOORM_YES, naming the confirmation phrase', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); const result = runDrop(); @@ -155,7 +155,7 @@ describe('cli: noorm db drop — access policy gate', () => { it('proceeds to the real drop when an admin passes --yes', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); const result = runDrop(['--yes']); @@ -166,7 +166,7 @@ describe('cli: noorm db drop — access policy gate', () => { it('proceeds to the real drop when NOORM_YES=1 is set without --yes', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); const result = runDrop([], { NOORM_YES: '1' }); @@ -177,7 +177,7 @@ describe('cli: noorm db drop — access policy gate', () => { it('targets the NOORM_CONNECTION_* database instead of the persisted config when both are set (#51)', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); const envDbPath = join(tmpDir, 'env-target.db'); writeFileSync(envDbPath, ''); diff --git a/tests/cli/db/explore.test.ts b/tests/cli/db/explore.test.ts index 22b89abc..3a0a5962 100644 --- a/tests/cli/db/explore.test.ts +++ b/tests/cli/db/explore.test.ts @@ -81,7 +81,7 @@ describe('cli: noorm db explore — output streams', () => { name: CONFIG_NAME, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: dbPath }, }; diff --git a/tests/cli/db/lifecycle-policy.test.ts b/tests/cli/db/lifecycle-policy.test.ts index 48ca98a4..28f61146 100644 --- a/tests/cli/db/lifecycle-policy.test.ts +++ b/tests/cli/db/lifecycle-policy.test.ts @@ -139,7 +139,7 @@ describe('cli: db lifecycle access policy', () => { it('denies a viewer and does not create the database', async () => { - await seedConfig({ user: 'viewer', mcp: 'admin' }); + await seedConfig({ user: 'viewer', agent: 'admin' }); const result = runDb('create'); @@ -153,7 +153,7 @@ describe('cli: db lifecycle access policy', () => { it('blocks an operator without --yes, naming the confirmation phrase, and does not create the database', async () => { - await seedConfig({ user: 'operator', mcp: 'admin' }); + await seedConfig({ user: 'operator', agent: 'admin' }); const result = runDb('create'); @@ -165,7 +165,7 @@ describe('cli: db lifecycle access policy', () => { it('creates for an operator that passes --yes', async () => { - await seedConfig({ user: 'operator', mcp: 'admin' }); + await seedConfig({ user: 'operator', agent: 'admin' }); const result = runDb('create', ['--json', '--yes']); @@ -176,7 +176,7 @@ describe('cli: db lifecycle access policy', () => { it('creates for an admin without --yes, because db:create is allow for admin', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); const result = runDb('create', ['--json']); @@ -195,7 +195,7 @@ describe('cli: db lifecycle access policy', () => { it('refuses to wipe data for an admin that passed no --yes, and leaves every row in place', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); seedData(); const result = runDb('truncate'); @@ -208,7 +208,7 @@ describe('cli: db lifecycle access policy', () => { it('wipes data for an admin that passed --yes', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); seedData(); const result = runDb('truncate', ['--yes']); @@ -220,7 +220,7 @@ describe('cli: db lifecycle access policy', () => { it('wipes data when NOORM_YES=1 is set without --yes', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); seedData(); const result = runDb('truncate', [], { NOORM_YES: '1' }); @@ -232,7 +232,7 @@ describe('cli: db lifecycle access policy', () => { it('denies a viewer outright and leaves every row in place', async () => { - await seedConfig({ user: 'viewer', mcp: 'admin' }); + await seedConfig({ user: 'viewer', agent: 'admin' }); seedData(); const result = runDb('truncate', ['--yes']); @@ -253,7 +253,7 @@ describe('cli: db lifecycle access policy', () => { it('refuses to drop objects for an admin that passed no --yes, and leaves the table standing', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); seedData(); const result = runDb('teardown'); @@ -266,7 +266,7 @@ describe('cli: db lifecycle access policy', () => { it('drops objects for an admin that passed --yes', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); seedData(); const result = runDb('teardown', ['--yes']); @@ -283,7 +283,7 @@ describe('cli: db lifecycle access policy', () => { it('denies an operator even with --yes, because db:teardown is deny below admin', async () => { - await seedConfig({ user: 'operator', mcp: 'admin' }); + await seedConfig({ user: 'operator', agent: 'admin' }); seedData(); const result = runDb('teardown', ['--yes']); @@ -296,7 +296,7 @@ describe('cli: db lifecycle access policy', () => { it('allows a dry run to preview without the confirmation an execution would need', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); seedData(); const result = runDb('teardown', ['--dry-run', '--json']); diff --git a/tests/cli/db/reset.test.ts b/tests/cli/db/reset.test.ts index fb8ace7e..c8a17dcc 100644 --- a/tests/cli/db/reset.test.ts +++ b/tests/cli/db/reset.test.ts @@ -116,7 +116,7 @@ describe('cli: noorm db reset — pre-gate + yes threading', () => { it('blocks with the destructive-operation pre-gate when neither --yes nor NOORM_YES is set', async () => { - await seedConfig({ user: 'admin', mcp: 'admin' }); + await seedConfig({ user: 'admin', agent: 'admin' }); const result = runReset(); @@ -127,7 +127,7 @@ describe('cli: noorm db reset — pre-gate + yes threading', () => { it('passes the pre-gate and completes headlessly for an operator-role config with --yes', async () => { - await seedConfig({ user: 'operator', mcp: 'admin' }); + await seedConfig({ user: 'operator', agent: 'admin' }); const result = runReset(['--yes']); @@ -143,7 +143,7 @@ describe('cli: noorm db reset — pre-gate + yes threading', () => { // used to fail at this exact pre-gate. Reverting reset.ts's // `isYesMode(args)` back to `args.yes` makes this fail (status 1, // "Pass --yes to confirm"). - await seedConfig({ user: 'operator', mcp: 'admin' }); + await seedConfig({ user: 'operator', agent: 'admin' }); const result = runReset([], { NOORM_YES: '1' }); @@ -229,7 +229,7 @@ describe('cli: noorm db truncate/teardown/reset — operator-role --yes headless it('noorm db truncate --yes succeeds headlessly for an operator-role config (no NOORM_YES)', async () => { - await seedConfig({ user: 'operator', mcp: 'admin' }); + await seedConfig({ user: 'operator', agent: 'admin' }); const result = runDb('truncate', ['--yes']); @@ -239,7 +239,7 @@ describe('cli: noorm db truncate/teardown/reset — operator-role --yes headless it('noorm db teardown --yes is denied for an operator-role config, because db:teardown stops below admin', async () => { - await seedConfig({ user: 'operator', mcp: 'admin' }); + await seedConfig({ user: 'operator', agent: 'admin' }); const result = runDb('teardown', ['--yes']); @@ -250,7 +250,7 @@ describe('cli: noorm db truncate/teardown/reset — operator-role --yes headless it('noorm db reset --yes succeeds headlessly for an operator-role config (no NOORM_YES)', async () => { - await seedConfig({ user: 'operator', mcp: 'admin' }); + await seedConfig({ user: 'operator', agent: 'admin' }); const result = runDb('reset', ['--yes']); diff --git a/tests/cli/hooks/useVaultSecretKeys.test.tsx b/tests/cli/hooks/useVaultSecretKeys.test.tsx index b1307ecc..0283359c 100644 --- a/tests/cli/hooks/useVaultSecretKeys.test.tsx +++ b/tests/cli/hooks/useVaultSecretKeys.test.tsx @@ -28,7 +28,7 @@ const testConfig = { name: 'dev', type: 'local' as const, isTest: false, - access: { user: 'admin' as const, mcp: 'admin' as const }, + access: { user: 'admin' as const, agent: 'admin' as const }, connection: { dialect: 'postgres' as const, host: 'localhost', diff --git a/tests/cli/lock/force.test.ts b/tests/cli/lock/force.test.ts index a5422f9b..9c00e122 100644 --- a/tests/cli/lock/force.test.ts +++ b/tests/cli/lock/force.test.ts @@ -81,7 +81,7 @@ describe('cli: noorm lock force', () => { }); - async function seedConfig(access: ConfigAccess = { user: 'admin', mcp: 'admin' }): Promise { + async function seedConfig(access: ConfigAccess = { user: 'admin', agent: 'admin' }): Promise { const config: Config = { name: CONFIG_NAME, @@ -169,7 +169,7 @@ describe('cli: noorm lock force', () => { it('denies a viewer config even with --yes', async () => { - await seedConfig({ user: 'viewer', mcp: false }); + await seedConfig({ user: 'viewer', agent: false }); const result = run(['lock', 'force'], ['--yes', '--json']); diff --git a/tests/cli/lock/status.test.ts b/tests/cli/lock/status.test.ts index b3a05f6e..2c942758 100644 --- a/tests/cli/lock/status.test.ts +++ b/tests/cli/lock/status.test.ts @@ -82,7 +82,7 @@ describe('cli: noorm lock status — output streams', () => { name: CONFIG_NAME, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: dbPath }, }; diff --git a/tests/cli/run/preview-inspect-policy.test.ts b/tests/cli/run/preview-inspect-policy.test.ts index 7d2cc0b9..55e901df 100644 --- a/tests/cli/run/preview-inspect-policy.test.ts +++ b/tests/cli/run/preview-inspect-policy.test.ts @@ -47,7 +47,7 @@ async function setupRoleProject(role: Role): Promise { name: 'guarded', type: 'local', isTest: true, - access: { user: role, mcp: role }, + access: { user: role, agent: role }, connection: { dialect: 'sqlite', database: join(dir, '.noorm', 'test.db'), diff --git a/tests/cli/run/preview-inspect-vault-probe.test.ts b/tests/cli/run/preview-inspect-vault-probe.test.ts index 9a2526ba..c7909d5d 100644 --- a/tests/cli/run/preview-inspect-vault-probe.test.ts +++ b/tests/cli/run/preview-inspect-vault-probe.test.ts @@ -38,7 +38,7 @@ async function setupUnreachableProject(): Promise { name: 'unreachable', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'postgres', host: '127.0.0.1', diff --git a/tests/cli/screens/change/change-dry-run.test.tsx b/tests/cli/screens/change/change-dry-run.test.tsx index eb0443f1..e03f0fe6 100644 --- a/tests/cli/screens/change/change-dry-run.test.tsx +++ b/tests/cli/screens/change/change-dry-run.test.tsx @@ -83,7 +83,7 @@ function makeConfig() { name: 'test', type: 'local' as const, isTest: true, - access: { user: 'admin' as const, mcp: 'admin' as const }, + access: { user: 'admin' as const, agent: 'admin' as const }, connection: { dialect: 'sqlite' as const, database: ':memory:', diff --git a/tests/cli/screens/config/ConfigEditScreen.test.tsx b/tests/cli/screens/config/ConfigEditScreen.test.tsx index 5e20777d..a75d4d37 100644 --- a/tests/cli/screens/config/ConfigEditScreen.test.tsx +++ b/tests/cli/screens/config/ConfigEditScreen.test.tsx @@ -36,7 +36,7 @@ function makeConfig(name: string) { name, type: 'local' as const, isTest: false, - access: { user: 'admin' as const, mcp: 'admin' as const }, + access: { user: 'admin' as const, agent: 'admin' as const }, connection: { dialect: 'postgres' as const, host: 'localhost', diff --git a/tests/cli/screens/config/ConfigRemoveScreen.test.tsx b/tests/cli/screens/config/ConfigRemoveScreen.test.tsx index 42003a72..8964be31 100644 --- a/tests/cli/screens/config/ConfigRemoveScreen.test.tsx +++ b/tests/cli/screens/config/ConfigRemoveScreen.test.tsx @@ -30,7 +30,7 @@ function makeConfig(name: string) { name, type: 'local' as const, isTest: false, - access: { user: 'admin' as const, mcp: 'admin' as const }, + access: { user: 'admin' as const, agent: 'admin' as const }, connection: { dialect: 'postgres' as const, host: 'localhost', diff --git a/tests/cli/screens/db/DbTransferScreen.test.tsx b/tests/cli/screens/db/DbTransferScreen.test.tsx index b26d9d13..8f00d1f8 100644 --- a/tests/cli/screens/db/DbTransferScreen.test.tsx +++ b/tests/cli/screens/db/DbTransferScreen.test.tsx @@ -114,7 +114,7 @@ function makeConfig(name: string, role: Role): Config { name, type: 'local', isTest: true, - access: { user: role, mcp: role }, + access: { user: role, agent: role }, connection: { dialect: 'sqlite', database: ':memory:', diff --git a/tests/cli/screens/db/db-dry-run.test.tsx b/tests/cli/screens/db/db-dry-run.test.tsx index e8f77bc9..d6d02839 100644 --- a/tests/cli/screens/db/db-dry-run.test.tsx +++ b/tests/cli/screens/db/db-dry-run.test.tsx @@ -72,7 +72,7 @@ function makeConfig() { name: 'test', type: 'local' as const, isTest: true, - access: { user: 'admin' as const, mcp: 'admin' as const }, + access: { user: 'admin' as const, agent: 'admin' as const }, connection: { dialect: 'sqlite' as const, database: ':memory:', diff --git a/tests/cli/sql/history-config.test.ts b/tests/cli/sql/history-config.test.ts index 14fc9217..0ff87272 100644 --- a/tests/cli/sql/history-config.test.ts +++ b/tests/cli/sql/history-config.test.ts @@ -84,7 +84,7 @@ describe('cli: noorm sql history/clear — config resolution', () => { name, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: join(tmpDir, `${name}.db`) }, }; diff --git a/tests/cli/utils/change-context.test.ts b/tests/cli/utils/change-context.test.ts index dcfdf49b..c1f8926e 100644 --- a/tests/cli/utils/change-context.test.ts +++ b/tests/cli/utils/change-context.test.ts @@ -80,7 +80,7 @@ describe('tui: createChangeManager', () => { name: 'test', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', diff --git a/tests/core/change/executor-retry.test.ts b/tests/core/change/executor-retry.test.ts index 918fe550..e1c9f268 100644 --- a/tests/core/change/executor-retry.test.ts +++ b/tests/core/change/executor-retry.test.ts @@ -80,7 +80,7 @@ describe('change: executor retry', () => { projectRoot: tempDir, changesDir, sqlDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'sqlite', }; diff --git a/tests/core/change/executor.test.ts b/tests/core/change/executor.test.ts index f17a3ccd..201b46eb 100644 --- a/tests/core/change/executor.test.ts +++ b/tests/core/change/executor.test.ts @@ -78,7 +78,7 @@ describe('change: executor', () => { projectRoot: tempDir, changesDir, sqlDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'sqlite', }; @@ -353,7 +353,7 @@ describe('change: executor', () => { { name: '001.sql', content: 'CREATE TABLE gate_test (id INTEGER)' }, ]); - const context: ChangeContext = { ...buildContext(), access: { user: 'viewer', mcp: false } }; + const context: ChangeContext = { ...buildContext(), access: { user: 'viewer', agent: false } }; await expect(executeChange(context, change)).rejects.toThrow(/change:run/); @@ -365,7 +365,7 @@ describe('change: executor', () => { { name: '001.sql', content: 'CREATE TABLE gate_test2 (id INTEGER)' }, ]); - const context: ChangeContext = { ...buildContext(), access: { user: 'viewer', mcp: false } }; + const context: ChangeContext = { ...buildContext(), access: { user: 'viewer', agent: false } }; await expect(revertChange(context, change)).rejects.toThrow(/change:revert/); @@ -377,7 +377,7 @@ describe('change: executor', () => { { name: '001.sql', content: 'CREATE TABLE gate_test3 (id INTEGER)' }, ]); - const context: ChangeContext = { ...buildContext(), access: { user: 'viewer', mcp: false } }; + const context: ChangeContext = { ...buildContext(), access: { user: 'viewer', agent: false } }; await expect(executeChange(context, change)).rejects.toThrow(/"test"/); diff --git a/tests/core/change/manager.test.ts b/tests/core/change/manager.test.ts index c833b166..5fe234af 100644 --- a/tests/core/change/manager.test.ts +++ b/tests/core/change/manager.test.ts @@ -76,7 +76,7 @@ describe('change: manager', () => { projectRoot: tempDir, changesDir, sqlDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'sqlite', ...extra, diff --git a/tests/core/change/scaffold.test.ts b/tests/core/change/scaffold.test.ts index 58e6f12b..4bf7a645 100644 --- a/tests/core/change/scaffold.test.ts +++ b/tests/core/change/scaffold.test.ts @@ -191,7 +191,7 @@ describe('change: scaffold', () => { projectRoot: runDir, changesDir: runDir, sqlDir: runDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'sqlite', }; diff --git a/tests/core/config/env.test.ts b/tests/core/config/env.test.ts index b3ff41cc..7d9fbaac 100644 --- a/tests/core/config/env.test.ts +++ b/tests/core/config/env.test.ts @@ -42,6 +42,7 @@ describe('config: env', () => { 'NOORM_CONFIG', 'NOORM_YES', 'NOORM_JSON', + 'NOORM_CHANNEL', 'CI', ]; @@ -248,6 +249,9 @@ describe('config: env', () => { process.env['NOORM_CONFIG'] = 'staging'; process.env['NOORM_YES'] = 'true'; process.env['NOORM_JSON'] = 'true'; + // Selects the policy channel; it is not a field on any config, + // and forwarding it would invent a `channel` key on every one. + process.env['NOORM_CHANNEL'] = 'agent'; const config = getEnvConfig() as unknown as Record; @@ -256,6 +260,7 @@ describe('config: env', () => { expect(config['config']).toBeUndefined(); expect(config['yes']).toBeUndefined(); expect(config['json']).toBeUndefined(); + expect(config['channel']).toBeUndefined(); }); diff --git a/tests/core/config/resolver.test.ts b/tests/core/config/resolver.test.ts index 49d7d525..5b37f789 100644 --- a/tests/core/config/resolver.test.ts +++ b/tests/core/config/resolver.test.ts @@ -79,7 +79,7 @@ function createConfig(overrides: Partial = {}): Config { name: 'test', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', @@ -374,7 +374,7 @@ describe('config: resolver', () => { const config = resolveConfig(state); expect(config!.type).toBe('local'); - expect(config!.access).toEqual({ user: 'admin', mcp: 'viewer' }); + expect(config!.access).toEqual({ user: 'admin', agent: 'viewer' }); }); @@ -446,7 +446,7 @@ describe('config: resolver', () => { // explicit access (defaults to admin/admin), which is looser // than the operator/viewer ceiling, so it gets clamped down. expect(guarded(config!)).toBe(true); - expect(config!.access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(config!.access).toEqual({ user: 'operator', agent: 'viewer' }); expect(config!.connection.host).toBe('localhost'); // stored overrides stage }); @@ -508,7 +508,7 @@ describe('config: resolver', () => { // Stage `protected: true` clamps the stored config's (looser, // default admin/admin) access down to the ceiling. expect(guarded(config!)).toBe(true); - expect(config!.access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(config!.access).toEqual({ user: 'operator', agent: 'viewer' }); }); @@ -554,7 +554,7 @@ describe('config: resolver', () => { describe('stage access ceiling', () => { function resolveWithStageProtected( - access: { user: 'viewer' | 'operator' | 'admin'; mcp: 'viewer' | 'operator' | 'admin' | false }, + access: { user: 'viewer' | 'operator' | 'admin'; agent: 'viewer' | 'operator' | 'admin' | false }, stageProtected: boolean, ) { @@ -578,44 +578,44 @@ describe('config: resolver', () => { it('should not clamp when the stage is not protected', () => { - const config = resolveWithStageProtected({ user: 'admin', mcp: 'admin' }, false); + const config = resolveWithStageProtected({ user: 'admin', agent: 'admin' }, false); - expect(config!.access).toEqual({ user: 'admin', mcp: 'admin' }); + expect(config!.access).toEqual({ user: 'admin', agent: 'admin' }); }); - it('should clamp a looser user+mcp access down to the ceiling', () => { + it('should clamp a looser user+agent access down to the ceiling', () => { - const config = resolveWithStageProtected({ user: 'admin', mcp: 'admin' }, true); + const config = resolveWithStageProtected({ user: 'admin', agent: 'admin' }, true); - expect(config!.access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(config!.access).toEqual({ user: 'operator', agent: 'viewer' }); expect(guarded(config!)).toBe(true); }); it('should leave access exactly at the ceiling unchanged', () => { - const config = resolveWithStageProtected({ user: 'operator', mcp: 'viewer' }, true); + const config = resolveWithStageProtected({ user: 'operator', agent: 'viewer' }, true); - expect(config!.access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(config!.access).toEqual({ user: 'operator', agent: 'viewer' }); }); it('should let a stricter access survive the ceiling untouched', () => { - const config = resolveWithStageProtected({ user: 'viewer', mcp: false }, true); + const config = resolveWithStageProtected({ user: 'viewer', agent: false }, true); - expect(config!.access).toEqual({ user: 'viewer', mcp: false }); + expect(config!.access).toEqual({ user: 'viewer', agent: false }); expect(guarded(config!)).toBe(true); }); it('should clamp each channel independently', () => { - // user is looser than the ceiling, mcp is already stricter - const config = resolveWithStageProtected({ user: 'admin', mcp: false }, true); + // user is looser than the ceiling, agent is already stricter + const config = resolveWithStageProtected({ user: 'admin', agent: false }, true); - expect(config!.access).toEqual({ user: 'operator', mcp: false }); + expect(config!.access).toEqual({ user: 'operator', agent: false }); }); @@ -690,7 +690,7 @@ describe('config: resolver', () => { // longer duplicates that enforcement as a violation. const config = createConfig({ name: 'prod', - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, }); const state = createMockState(); @@ -739,7 +739,7 @@ describe('config: resolver', () => { const config = createConfig({ name: 'prod', - access: { user: 'operator', mcp: 'viewer' }, + access: { user: 'operator', agent: 'viewer' }, }); const state = createMockState({ secrets: { prod: ['DB_PASSWORD', 'API_KEY'] }, diff --git a/tests/core/config/schema.test.ts b/tests/core/config/schema.test.ts index 96c9eaca..f8f08e99 100644 --- a/tests/core/config/schema.test.ts +++ b/tests/core/config/schema.test.ts @@ -21,7 +21,7 @@ function createValidConfig(overrides: Partial = {}): Config { name: 'test', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', @@ -294,7 +294,7 @@ describe('config: schema validation', () => { expect(result.type).toBe('local'); expect(result.isTest).toBe(false); - expect(result.access).toEqual({ user: 'admin', mcp: 'viewer' }); + expect(result.access).toEqual({ user: 'admin', agent: 'viewer' }); }); @@ -303,14 +303,14 @@ describe('config: schema validation', () => { const config = createValidConfig({ type: 'remote', isTest: true, - access: { user: 'operator', mcp: 'viewer' }, + access: { user: 'operator', agent: 'viewer' }, }); const result = parseConfig(config); expect(result.type).toBe('remote'); expect(result.isTest).toBe(true); - expect(result.access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(result.access).toEqual({ user: 'operator', agent: 'viewer' }); }); @@ -337,7 +337,7 @@ describe('config: schema validation', () => { const result = parseConfig(withoutAccess(createValidConfig())); - expect(result.access).toEqual({ user: 'admin', mcp: 'viewer' }); + expect(result.access).toEqual({ user: 'admin', agent: 'viewer' }); expect(guarded(result)).toBe(false); }); @@ -348,7 +348,7 @@ describe('config: schema validation', () => { const result = parseConfig(config); - expect(result.access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(result.access).toEqual({ user: 'operator', agent: 'viewer' }); expect(guarded(result)).toBe(true); }); @@ -359,7 +359,7 @@ describe('config: schema validation', () => { const result = parseConfig(config); - expect(result.access).toEqual({ user: 'admin', mcp: 'viewer' }); + expect(result.access).toEqual({ user: 'admin', agent: 'viewer' }); expect(guarded(result)).toBe(false); }); @@ -369,12 +369,12 @@ describe('config: schema validation', () => { const config = { ...createValidConfig(), protected: true, - access: { user: 'viewer' as const, mcp: false as const }, + access: { user: 'viewer' as const, agent: false as const }, }; const result = parseConfig(config); - expect(result.access).toEqual({ user: 'viewer', mcp: false }); + expect(result.access).toEqual({ user: 'viewer', agent: false }); }); @@ -386,7 +386,7 @@ describe('config: schema validation', () => { const config = { ...createValidConfig(), protected: true, - access: { user: 'admin' as const, mcp: 'admin' as const }, + access: { user: 'admin' as const, agent: 'admin' as const }, }; const result = parseConfig(config); diff --git a/tests/core/config/validate.test.ts b/tests/core/config/validate.test.ts index 78ba2a5a..5b7e01aa 100644 --- a/tests/core/config/validate.test.ts +++ b/tests/core/config/validate.test.ts @@ -20,7 +20,7 @@ function createConfig(overrides: Partial = {}): Config { name: 'test', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', diff --git a/tests/core/connection/dialects/mssql.test.ts b/tests/core/connection/dialects/mssql.test.ts new file mode 100644 index 00000000..d045e7dc --- /dev/null +++ b/tests/core/connection/dialects/mssql.test.ts @@ -0,0 +1,196 @@ +/** + * Unit tests for the MSSQL tedious option builder. + * + * These exist because connecting to MSSQL by IP address was broken outright: + * tedious derives the TLS SNI ServerName from `server`, and Node's TLS layer + * rejects an IP literal there (RFC 6066). The assertions below encode the + * intended security posture per case, not merely the shape of the object — + * an IP host must keep encryption on when certificates are not being + * validated, and must refuse to connect (never silently downgrade) when they + * are. + */ +import { describe, it, expect } from 'bun:test'; +import { isIP } from 'node:net'; + +import { attemptSync } from '@logosdx/utils'; + +import { ConnectionSchema } from '../../../../src/core/config/schema.js'; +import { + MssqlTlsServerNameError, + UNVERIFIED_TLS_SERVER_NAME, + buildTediousOptions, + resolveTlsServerName, +} from '../../../../src/core/connection/dialects/mssql.js'; +import type { ConnectionConfig } from '../../../../src/core/connection/types.js'; + + +/** + * Build an mssql ConnectionConfig with only the fields under test varying. + */ +function mssqlConfig(overrides: Partial = {}): ConnectionConfig { + + return { + dialect: 'mssql', + host: 'db.example.com', + port: 11433, + user: 'sa', + password: 'secret', + database: 'app', + ...overrides, + }; + +} + + +describe('connection/dialects/mssql: resolveTlsServerName', () => { + + it('should leave hostname connections untouched', () => { + + expect(resolveTlsServerName(mssqlConfig())).toBeUndefined(); + + }); + + it('should leave hostname connections untouched when validating certificates', () => { + + expect(resolveTlsServerName(mssqlConfig({ ssl: true }))).toBeUndefined(); + + }); + + it('should leave an unset host untouched', () => { + + expect(resolveTlsServerName(mssqlConfig({ host: undefined }))).toBeUndefined(); + + }); + + it('should substitute a placeholder for an IPv4 host when not validating certificates', () => { + + expect(resolveTlsServerName(mssqlConfig({ host: '46.101.71.200' }))) + .toBe(UNVERIFIED_TLS_SERVER_NAME); + + }); + + it('should substitute a placeholder for an IPv6 host when not validating certificates', () => { + + expect(resolveTlsServerName(mssqlConfig({ host: '2001:db8::1' }))) + .toBe(UNVERIFIED_TLS_SERVER_NAME); + + }); + + it('should never substitute a placeholder that is itself an IP literal', () => { + + // The placeholder only works because Node accepts it as a DNS name; + // an IP literal would reintroduce the exact bug this guards against. + expect(isIP(UNVERIFIED_TLS_SERVER_NAME)).toBe(0); + + }); + + it('should prefer a supplied certificate hostname over the placeholder', () => { + + expect(resolveTlsServerName(mssqlConfig({ host: '46.101.71.200', tlsServerName: 'sql.example.com' }))) + .toBe('sql.example.com'); + + }); + + it('should use the supplied certificate hostname when validating certificates', () => { + + expect(resolveTlsServerName(mssqlConfig({ host: '46.101.71.200', ssl: true, tlsServerName: 'sql.example.com' }))) + .toBe('sql.example.com'); + + }); + + it('should honour a supplied certificate hostname for a hostname connection', () => { + + expect(resolveTlsServerName(mssqlConfig({ tlsServerName: 'sql.internal' }))) + .toBe('sql.internal'); + + }); + + it('should refuse to connect to an IP while validating certificates without a hostname', () => { + + const [result, err] = attemptSync(() => + resolveTlsServerName(mssqlConfig({ host: '46.101.71.200', ssl: true })), + ); + + expect(result).toBeNull(); + expect(err).toBeInstanceOf(MssqlTlsServerNameError); + expect(err?.message).toContain('tlsServerName'); + expect(err?.message).toContain('46.101.71.200'); + + }); + + it('should reject an IP address supplied as the certificate hostname', () => { + + const [, err] = attemptSync(() => + resolveTlsServerName(mssqlConfig({ host: '46.101.71.200', tlsServerName: '46.101.71.200' })), + ); + + expect(err).toBeInstanceOf(MssqlTlsServerNameError); + expect(err?.message).toContain('tlsServerName'); + + }); + +}); + + +describe('connection/dialects/mssql: buildTediousOptions', () => { + + it('should keep encryption on and omit serverName for a hostname', () => { + + const options = buildTediousOptions(mssqlConfig()); + + expect(options.server).toBe('db.example.com'); + expect(options.options?.encrypt).toBe(true); + expect(options.options?.serverName).toBeUndefined(); + + }); + + it('should keep encryption on for an IP host rather than downgrading', () => { + + const options = buildTediousOptions(mssqlConfig({ host: '46.101.71.200' })); + + expect(options.server).toBe('46.101.71.200'); + expect(options.options?.encrypt).toBe(true); + expect(options.options?.trustServerCertificate).toBe(true); + expect(options.options?.serverName).toBe(UNVERIFIED_TLS_SERVER_NAME); + + }); + + it('should validate certificates against the supplied hostname for an IP host', () => { + + const options = buildTediousOptions(mssqlConfig({ host: '46.101.71.200', ssl: true, tlsServerName: 'sql.example.com' })); + + expect(options.options?.encrypt).toBe(true); + expect(options.options?.trustServerCertificate).toBe(false); + expect(options.options?.serverName).toBe('sql.example.com'); + + }); + + it('should carry the database override used by the master preflight', () => { + + const options = buildTediousOptions(mssqlConfig({ host: '10.0.0.5' }), 'master'); + + expect(options.options?.database).toBe('master'); + expect(options.options?.serverName).toBe(UNVERIFIED_TLS_SERVER_NAME); + + }); + + it('should default the host to localhost', () => { + + const options = buildTediousOptions(mssqlConfig({ host: undefined })); + + expect(options.server).toBe('localhost'); + expect(options.options?.serverName).toBeUndefined(); + + }); + + it('should survive config validation rather than being stripped', () => { + + // Zod drops unrecognized keys silently, so a field missing from + // ConnectionSchema would never reach this builder at all. + const parsed = ConnectionSchema.parse(mssqlConfig({ host: '10.0.0.5', tlsServerName: 'sql.example.com' })); + + expect(buildTediousOptions(parsed).options?.serverName).toBe('sql.example.com'); + + }); + +}); diff --git a/tests/core/connection/manager.test.ts b/tests/core/connection/manager.test.ts index 9007602e..fee4e472 100644 --- a/tests/core/connection/manager.test.ts +++ b/tests/core/connection/manager.test.ts @@ -29,7 +29,7 @@ function createTestConfig(name: string): Config { name, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', diff --git a/tests/core/db/operations.test.ts b/tests/core/db/operations.test.ts index 163fddfe..18cea510 100644 --- a/tests/core/db/operations.test.ts +++ b/tests/core/db/operations.test.ts @@ -16,9 +16,9 @@ import { createDb, destroyDb } from '../../../src/core/db/index.js'; import type { ConnectionConfig } from '../../../src/core/connection/index.js'; import type { ConfigAccess } from '../../../src/core/policy/index.js'; -const VIEWER: ConfigAccess = { user: 'viewer', mcp: 'viewer' }; -const OPERATOR: ConfigAccess = { user: 'operator', mcp: 'operator' }; -const ADMIN: ConfigAccess = { user: 'admin', mcp: 'admin' }; +const VIEWER: ConfigAccess = { user: 'viewer', agent: 'viewer' }; +const OPERATOR: ConfigAccess = { user: 'operator', agent: 'operator' }; +const ADMIN: ConfigAccess = { user: 'admin', agent: 'admin' }; describe('db: lifecycle operations', () => { diff --git a/tests/core/debug/operations.test.ts b/tests/core/debug/operations.test.ts index 39142301..aab26a47 100644 --- a/tests/core/debug/operations.test.ts +++ b/tests/core/debug/operations.test.ts @@ -40,17 +40,17 @@ import { observer } from '../../../src/core/observer.js'; const ADMIN: DebugPolicyContext = { channel: 'user', - config: { name: 'test', access: { user: 'admin', mcp: 'admin' } }, + config: { name: 'test', access: { user: 'admin', agent: 'admin' } }, }; const OPERATOR: DebugPolicyContext = { channel: 'user', - config: { name: 'test', access: { user: 'operator', mcp: 'operator' } }, + config: { name: 'test', access: { user: 'operator', agent: 'operator' } }, }; const VIEWER: DebugPolicyContext = { channel: 'user', - config: { name: 'test', access: { user: 'viewer', mcp: 'viewer' } }, + config: { name: 'test', access: { user: 'viewer', agent: 'viewer' } }, }; const NO_ACCESS: DebugPolicyContext = { @@ -58,9 +58,9 @@ const NO_ACCESS: DebugPolicyContext = { config: { name: 'test' }, }; -const MCP_ADMIN: DebugPolicyContext = { - channel: 'mcp', - config: { name: 'test', access: { user: 'admin', mcp: 'admin' } }, +const AGENT_ADMIN: DebugPolicyContext = { + channel: 'agent', + config: { name: 'test', access: { user: 'admin', agent: 'admin' } }, }; /** @@ -244,10 +244,10 @@ describe('debug: createDebugOperations', () => { }); - it('should deny writes on the mcp channel even for an admin role', async () => { + it('should deny writes on the agent channel even for an admin role', async () => { const ids = await seedVault(db, 1); - const ops = createDebugOperations(db, 'sqlite', MCP_ADMIN); + const ops = createDebugOperations(db, 'sqlite', AGENT_ADMIN); const [, err] = await attempt(() => ops.deleteRowById(NOORM_TABLES.vault, ids[0]!)); diff --git a/tests/core/identity/provenance.test.ts b/tests/core/identity/provenance.test.ts new file mode 100644 index 00000000..03145e05 --- /dev/null +++ b/tests/core/identity/provenance.test.ts @@ -0,0 +1,101 @@ +/** + * Unit tests for agent provenance in the audit identity. + * + * WHY the format is asserted literally rather than via a helper: the suffix is + * the entire audit signal, and anyone asking "did an agent do this?" months + * from now will do it with a `LIKE '%(via %'` against rows already written. + * Changing the shape silently orphans every record that came before, so the + * spelling is pinned here on purpose and a failure means a migration decision, + * not a test to update. + * + * Harnesses come from `detectAgentHarness` over a literal env rather than a + * hand-built object, so the names that reach the database are the shipped ones + * — a rename in `AGENT_HARNESSES` has to surface here. + */ +import { describe, it, expect } from 'bun:test'; + +import { withAgentProvenance } from '../../../src/core/identity/provenance.js'; +import { detectAgentHarness } from '../../../src/core/policy/harness.js'; + +const CLAUDE = detectAgentHarness({ CLAUDECODE: '1' })!; +const CODEX = detectAgentHarness({ CODEX_SANDBOX: 'seatbelt' })!; + +describe('identity: withAgentProvenance', () => { + + it('should leave the identity untouched when no harness was detected', () => { + + // A human-driven record has to stay byte-for-byte what it was before + // provenance existed, or every pre-existing row reads as a change. + expect(withAgentProvenance('Ann ', null)).toBe('Ann '); + expect(withAgentProvenance('', null)).toBe(''); + + }); + + it('should append the harness name in the documented shape', () => { + + expect(withAgentProvenance('Ann ', CLAUDE)).toBe('Ann (via Claude Code)'); + expect(withAgentProvenance('Ann ', CODEX)).toBe('Ann (via OpenAI Codex)'); + + }); + + it('should name the specific harness rather than a generic agent flag', () => { + + // The audit answer "an agent" is far less useful than "which one", and + // the allowlist is ordered so a specific marker wins over AI_AGENT. + expect(withAgentProvenance('Ann', CLAUDE)).not.toContain('AI agent'); + expect(withAgentProvenance('Ann', CLAUDE)).toContain('Claude Code'); + + }); + + it('should carry an identity containing brackets or parens through verbatim', () => { + + // `executed_by` is free text and a real NOORM_IDENTITY can hold both. + // Escaping or stripping them would corrupt the human attribution, which + // is the field's primary job. + const identity = 'Ann (Platform) '; + + expect(withAgentProvenance(identity, CLAUDE)).toBe('Ann (Platform) (via Claude Code)'); + + const adversarial = 'CSO (via Claude Code)'; + + // Already spelling the suffix does not corrupt the value or collapse + // the two — it is unauthenticated free text, and the honest record is + // what was actually submitted plus what noorm actually observed. + expect(withAgentProvenance(adversarial, CLAUDE)).toBe('CSO (via Claude Code) (via Claude Code)'); + + }); + + it('should record the harness alone when there is no identity to qualify', () => { + + // `executed_by` defaults to '' in the schema, so an empty identity is a + // real case and must not produce a leading space. + expect(withAgentProvenance('', CLAUDE)).toBe('(via Claude Code)'); + expect(withAgentProvenance(' ', CLAUDE)).toBe('(via Claude Code)'); + + }); + + it('should keep the result inside the executed_by column width', () => { + + // The column is varchar(255). Appending blindly would turn an insert + // that succeeds today into a hard error on postgres, mysql and mssql — + // a provenance nicety must never be able to fail a migration. + const long = 'N'.repeat(250); + const result = withAgentProvenance(long, CLAUDE); + + expect(result.length).toBeLessThanOrEqual(255); + expect(result).toEndWith('(via Claude Code)'); + + }); + + it('should preserve as much of an over-long identity as the suffix allows', () => { + + const long = `${'N'.repeat(300)} `; + const result = withAgentProvenance(long, CLAUDE); + + expect(result.length).toBe(255); + expect(result).toStartWith('NNNN'); + expect(result).toEndWith(' (via Claude Code)'); + + }); + +}); diff --git a/tests/core/lock/force-policy.test.ts b/tests/core/lock/force-policy.test.ts index ed6c4cef..9e9485a4 100644 --- a/tests/core/lock/force-policy.test.ts +++ b/tests/core/lock/force-policy.test.ts @@ -25,9 +25,9 @@ import { LockNamespace } from '../../../src/sdk/namespaces/lock.js'; import { ProtectedConfigError } from '../../../src/sdk/guards.js'; import type { ContextState } from '../../../src/sdk/state.js'; -const VIEWER: ConfigAccess = { user: 'viewer', mcp: false }; -const OPERATOR: ConfigAccess = { user: 'operator', mcp: 'viewer' }; -const ADMIN: ConfigAccess = { user: 'admin', mcp: 'admin' }; +const VIEWER: ConfigAccess = { user: 'viewer', agent: false }; +const OPERATOR: ConfigAccess = { user: 'operator', agent: 'viewer' }; +const ADMIN: ConfigAccess = { user: 'admin', agent: 'admin' }; const CONFIG_NAME = 'dev'; @@ -160,13 +160,13 @@ describe('lock: force authorization', () => { }); - it('should deny the mcp channel even when pre-confirmed', async () => { + it('should deny the agent channel even when pre-confirmed', async () => { - const lock = makeNamespace(ADMIN, { yes: true, channel: 'mcp' }); + const lock = makeNamespace(ADMIN, { yes: true, channel: 'agent' }); const [, err] = await attempt(() => lock.forceRelease()); - // `confirm` collapses to deny on mcp, so `yes` can never unblock it. + // `confirm` collapses to deny on agent, so `yes` can never unblock it. expect(err).toBeInstanceOf(ProtectedConfigError); }); diff --git a/tests/core/mcp/server.test.ts b/tests/core/mcp/server.test.ts index 852469ba..774475f0 100644 --- a/tests/core/mcp/server.test.ts +++ b/tests/core/mcp/server.test.ts @@ -116,7 +116,7 @@ function buildMockSession(overrides: Partial = {}): RpcSession & { const getContextCalls: string[] = []; return { - channel: 'mcp', + channel: 'agent', getContextCalls, getContext(config?: string) { @@ -414,7 +414,7 @@ describe('mcp: server dispatch — policy gate (CP3)', () => { it('should deny and never invoke the handler when the resolved role denies the permission (viewer)', async () => { - const { client, cleanup, called } = await setup('change:run', { user: 'admin', mcp: 'viewer' }); + const { client, cleanup, called } = await setup('change:run', { user: 'admin', agent: 'viewer' }); const { isError, parsed } = await callJson(client, 'run_noorm_cmd', { command: 'gated_cmd', payload: {} }); @@ -428,9 +428,9 @@ describe('mcp: server dispatch — policy gate (CP3)', () => { }); - it('should collapse an operator confirm cell to deny on the mcp channel', async () => { + it('should collapse an operator confirm cell to deny on the agent channel', async () => { - const { client, cleanup, called } = await setup('change:run', { user: 'admin', mcp: 'operator' }); + const { client, cleanup, called } = await setup('change:run', { user: 'admin', agent: 'operator' }); const { isError, parsed } = await callJson(client, 'run_noorm_cmd', { command: 'gated_cmd', payload: {} }); @@ -439,14 +439,18 @@ describe('mcp: server dispatch — policy gate (CP3)', () => { const body = parsed as { error: string }; expect(isError).toBe(true); - expect(body.error.toLowerCase()).toContain('cli'); + + // Not "use the CLI" — the CLI now resolves the same agent channel, + // so pointing there would be both wrong and an escalation hint. + expect(body.error.toLowerCase()).not.toContain('use the cli'); + expect(body.error).toContain('change:run'); expect(called.value).toBe(false); }); it('should allow and invoke the handler when the resolved role allows the permission (admin)', async () => { - const { client, cleanup, called } = await setup('change:run', { user: 'admin', mcp: 'admin' }); + const { client, cleanup, called } = await setup('change:run', { user: 'admin', agent: 'admin' }); const { isError } = await callJson(client, 'run_noorm_cmd', { command: 'gated_cmd', payload: {} }); diff --git a/tests/core/policy/agent-escalation.test.ts b/tests/core/policy/agent-escalation.test.ts new file mode 100644 index 00000000..b9ce5574 --- /dev/null +++ b/tests/core/policy/agent-escalation.test.ts @@ -0,0 +1,162 @@ +/** + * The escalation this channel rename exists to close. + * + * `channel` used to name the transport, not the caller, and the CLI + * hardcoded `user` at every policy call site. So an agent that was refused a + * write over MCP could see `noorm` on the PATH, shell out, and run the same + * operation with the human's role. Measured against a stock config: + * + * 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 + * + * That table is this file's spec. It asserts the property, not the current + * numbers: for a stock config, *nothing* an agent is denied on one route may + * be reachable on the other. A future matrix edit that reopens any cell for + * agents fails here even if it never touches this file. + * + * The end-to-end half — that the shipped binary actually resolves `agent` + * from harness provenance — lives in tests/cli/agent-channel-escalation.test.ts. + */ +import { describe, it, expect } from 'bun:test'; + +import { parseConfig } from '../../../src/core/config/index.js'; +import { checkPolicy, resolveChannel } from '../../../src/core/policy/index.js'; +import { MATRIX } from '../../../src/core/policy/matrix.js'; +import type { ConfigAccess, Permission } from '../../../src/core/policy/index.js'; + +/** Access of a config exactly as it lands on disk: no `access` key written. */ +function stockAccess(): ConfigAccess { + + return parseConfig({ + name: 'stock', + type: 'local', + isTest: true, + connection: { dialect: 'sqlite', database: ':memory:' }, + }).access; + +} + +const ALL_PERMISSIONS = Object.keys(MATRIX) as Permission[]; + +/** The environment an agent harness exports for its child processes. */ +const AGENT_ENV = { CLAUDECODE: '1' }; + +/** The permissions measured as escalatable before the fix. */ +const MEASURED: Permission[] = [ + 'sql:write', + 'sql:ddl', + 'db:create', + 'db:destroy', + 'run:build', + 'vault:read', +]; + +describe('policy: agent escalation via the CLI', () => { + + it('should resolve the agent channel from harness provenance', () => { + + // Everything below rests on this: the CLI reaches checkPolicy with + // whatever resolveChannel() returns, so if this drifts back to + // 'user' the whole gate is decorative again. + expect(resolveChannel(AGENT_ENV)).toBe('agent'); + + }); + + for (const permission of MEASURED) { + + it(`should deny "${permission}" on the channel a harnessed CLI resolves`, () => { + + const channel = resolveChannel(AGENT_ENV); + const check = checkPolicy(channel, { name: 'stock', access: stockAccess() }, permission); + + expect(check.allowed).toBe(false); + + // Not merely "needs confirmation": on the CLI a confirm cell is + // one --yes away from proceeding, which is exactly how + // db:destroy leaked before. + expect(check.requiresConfirmation).toBe(false); + + }); + + } + + for (const permission of ALL_PERMISSIONS) { + + it(`should give "${permission}" the same answer over MCP and over the CLI`, () => { + + const target = { name: 'stock', access: stockAccess() }; + + // The MCP server constructs its session with 'agent' literally. + const viaMcp = checkPolicy('agent', target, permission); + + // The CLI resolves the channel from the environment it was + // spawned into. + const viaCli = checkPolicy(resolveChannel(AGENT_ENV), target, permission); + + expect(viaCli.allowed).toBe(viaMcp.allowed); + expect(viaCli.requiresConfirmation).toBe(viaMcp.requiresConfirmation); + + }); + + } + + it('should keep the human route open, or the fix is a regression not a gate', () => { + + // The point is to stop an agent inheriting the human's role, not to + // take it away from the human. + const target = { name: 'stock', access: stockAccess() }; + + for (const permission of MEASURED) { + + expect(checkPolicy('user', target, permission).allowed).toBe(true); + + } + + }); + + it('should let a human scripting inside an agent session opt back in', () => { + + const target = { name: 'stock', access: stockAccess() }; + const channel = resolveChannel({ ...AGENT_ENV, NOORM_CHANNEL: 'user' }); + + expect(channel).toBe('user'); + expect(checkPolicy(channel, target, 'sql:write').allowed).toBe(true); + + }); + + describe('agent: false hides the config from both routes', () => { + + const hidden = parseConfig({ + name: 'hidden', + type: 'local', + isTest: true, + access: { user: 'admin', agent: false }, + connection: { dialect: 'sqlite', database: ':memory:' }, + }).access; + + it('should deny every permission to a harnessed CLI caller', () => { + + const channel = resolveChannel(AGENT_ENV); + + for (const permission of ALL_PERMISSIONS) { + + expect(checkPolicy(channel, { name: 'hidden', access: hidden }, permission).allowed).toBe(false); + + } + + }); + + it('should still serve the human', () => { + + expect(checkPolicy('user', { name: 'hidden', access: hidden }, 'sql:read').allowed).toBe(true); + + }); + + }); + +}); diff --git a/tests/core/policy/channel.test.ts b/tests/core/policy/channel.test.ts new file mode 100644 index 00000000..9837e46a --- /dev/null +++ b/tests/core/policy/channel.test.ts @@ -0,0 +1,104 @@ +/** + * Channel resolution from provenance. + * + * `Channel` names who is driving, not which binary was invoked. The CLI used + * to assume those were the same thing and hardcoded `user`, so an agent + * denied a write over MCP could shell out to `noorm` and get the human's + * role. These tests fix the precedence that closes that, including the + * `NOORM_CHANNEL` escape hatch a human needs when scripting from inside an + * agent session. + * + * `resolveChannel` is pure over its env argument, so nothing here mutates + * `process.env` — Bun caches some environment reads for the life of the + * process and a mutation would leak into every file that runs after this one. + */ +import { describe, it, expect } from 'bun:test'; + +import { AGENT_HARNESSES, resolveChannel } from '../../../src/core/policy/index.js'; + +describe('policy: resolveChannel', () => { + + it('should resolve user for an empty environment', () => { + + expect(resolveChannel({})).toBe('user'); + + }); + + describe('harness provenance', () => { + + for (const harness of AGENT_HARNESSES) { + + for (const marker of harness.markers) { + + it(`should resolve agent when ${marker} is set (${harness.name})`, () => { + + expect(resolveChannel({ [marker]: '1' })).toBe('agent'); + + }); + + } + + } + + it('should treat an exported-but-empty marker as absent', () => { + + // `CLAUDECODE=` is how a caller disables the marker; reading it as + // present would make opting out impossible. + expect(resolveChannel({ CLAUDECODE: '' })).toBe('user'); + + }); + + it('should not resolve agent from a terminal or a CI pipeline', () => { + + // TERM_PROGRAM is set by iTerm, VS Code and Warp alike, and CI + // describes the pipeline, not the caller. Keying on either would + // lock a human out of their own CLI. + expect(resolveChannel({ TERM_PROGRAM: 'vscode' })).toBe('user'); + expect(resolveChannel({ CI: 'true', GITHUB_ACTIONS: 'true' })).toBe('user'); + + }); + + }); + + describe('NOORM_CHANNEL override', () => { + + it('should let a human scripting inside an agent session opt out', () => { + + expect(resolveChannel({ CLAUDECODE: '1', NOORM_CHANNEL: 'user' })).toBe('user'); + + }); + + it('should let a caller declare the agent channel with no harness present', () => { + + expect(resolveChannel({ NOORM_CHANNEL: 'agent' })).toBe('agent'); + + }); + + it('should ignore an unrecognised value rather than failing or guessing', () => { + + // A typo must not silently grant the looser channel, and it must + // not break the CLI either — fall through to provenance. + expect(resolveChannel({ NOORM_CHANNEL: 'mcp' })).toBe('user'); + expect(resolveChannel({ NOORM_CHANNEL: 'mcp', CLAUDECODE: '1' })).toBe('agent'); + expect(resolveChannel({ NOORM_CHANNEL: '' })).toBe('user'); + + }); + + it('should outrank harness detection in both directions', () => { + + expect(resolveChannel({ CURSOR_AGENT: '1', NOORM_CHANNEL: 'user' })).toBe('user'); + expect(resolveChannel({ TERM_PROGRAM: 'iTerm.app', NOORM_CHANNEL: 'agent' })).toBe('agent'); + + }); + + }); + + it('should read the real process environment when called with no argument', () => { + + // The suite pins NOORM_CHANNEL=user in tests/preload.ts precisely so + // this is deterministic regardless of who ran `bun test`. + expect(resolveChannel()).toBe('user'); + + }); + +}); diff --git a/tests/core/policy/check.test.ts b/tests/core/policy/check.test.ts index 39003a16..3c9b06ff 100644 --- a/tests/core/policy/check.test.ts +++ b/tests/core/policy/check.test.ts @@ -42,7 +42,7 @@ const PERMISSIONS: Permission[] = [ 'config:rm', ]; const ROLES: Role[] = ['viewer', 'operator', 'admin']; -const CHANNELS: Channel[] = ['user', 'mcp']; +const CHANNELS: Channel[] = ['user', 'agent']; /** * Build a target where both channels carry the given role, so the channel @@ -50,7 +50,7 @@ const CHANNELS: Channel[] = ['user', 'mcp']; */ function targetFor(role: Role, name = 'acme'): PolicyTarget { - return { name, access: { user: role, mcp: role } }; + return { name, access: { user: role, agent: role } }; } @@ -125,7 +125,12 @@ describe('policy: checkPolicy', () => { expect(check.allowed).toBe(false); expect(check.requiresConfirmation).toBe(false); expect(check.blockedReason).toBeDefined(); - expect(check.blockedReason?.toLowerCase()).toContain('cli'); + + // The message must not offer the agent another route. + // It used to say "use the CLI", which was accurate + // when the CLI ran as the human and is now both wrong + // and an invitation to escalate. + expect(check.blockedReason?.toLowerCase()).not.toContain('use the cli'); } @@ -149,11 +154,11 @@ describe('policy: checkPolicy', () => { }); - it('should not let NOORM_YES affect the mcp channel', () => { + it('should not let NOORM_YES affect the agent channel', () => { process.env['NOORM_YES'] = '1'; - const check = checkPolicy('mcp', targetFor('operator', 'prod'), 'change:run'); + const check = checkPolicy('agent', targetFor('operator', 'prod'), 'change:run'); expect(check.allowed).toBe(false); expect(check.blockedReason).toBeDefined(); @@ -184,11 +189,11 @@ describe('policy: checkPolicy', () => { }); - it('should deny with a blockedReason when access.mcp is false', () => { + it('should deny with a blockedReason when access.agent is false', () => { - const target: PolicyTarget = { name: 'invisible', access: { user: 'admin', mcp: false } }; + const target: PolicyTarget = { name: 'invisible', access: { user: 'admin', agent: false } }; - const check = checkPolicy('mcp', target, 'explore'); + const check = checkPolicy('agent', target, 'explore'); expect(check.allowed).toBe(false); expect(check.requiresConfirmation).toBe(false); @@ -210,9 +215,9 @@ describe('policy: checkConfigPolicy', () => { }); - it('should deny on the mcp channel with the same shared message when access is absent', () => { + it('should deny on the agent channel with the same shared message when access is absent', () => { - const check = checkConfigPolicy('mcp', { name: 'legacy' }, 'explore'); + const check = checkConfigPolicy('agent', { name: 'legacy' }, 'explore'); expect(check.allowed).toBe(false); expect(check.requiresConfirmation).toBe(false); @@ -296,38 +301,38 @@ describe('policy: guarded', () => { describe('policy: formatAccessTag', () => { - it('should render "user: mcp:" for a guarded config', () => { + it('should render "user: agent:" for a guarded config', () => { - const config: PolicyTarget = { name: 'prod', access: { user: 'operator', mcp: 'viewer' } }; + const config: PolicyTarget = { name: 'prod', access: { user: 'operator', agent: 'viewer' } }; - expect(formatAccessTag(config)).toBe('user:operator mcp:viewer'); + expect(formatAccessTag(config)).toBe('user:operator agent:viewer'); }); - it('should render "mcp:off" when access.mcp is false', () => { + it('should render "agent:off" when access.agent is false', () => { - const config: PolicyTarget = { name: 'prod', access: { user: 'viewer', mcp: false } }; + const config: PolicyTarget = { name: 'prod', access: { user: 'viewer', agent: false } }; - expect(formatAccessTag(config)).toBe('user:viewer mcp:off'); + expect(formatAccessTag(config)).toBe('user:viewer agent:off'); }); it('should return null for a config sitting on the default access', () => { - const config: PolicyTarget = { name: 'prod', access: { user: 'admin', mcp: 'viewer' } }; + const config: PolicyTarget = { name: 'prod', access: { user: 'admin', agent: 'viewer' } }; expect(formatAccessTag(config)).toBeNull(); }); - it('should render the tag for an mcp:admin escalation rather than hiding it', () => { + it('should render the tag for an agent:admin escalation rather than hiding it', () => { // The user channel is admin either way, so `guarded()` cannot tell // this apart from the default — yet this is the one config an agent // can write to. It has to be visible in `noorm config list`. - const config: PolicyTarget = { name: 'prod', access: { user: 'admin', mcp: 'admin' } }; + const config: PolicyTarget = { name: 'prod', access: { user: 'admin', agent: 'admin' } }; - expect(formatAccessTag(config)).toBe('user:admin mcp:admin'); + expect(formatAccessTag(config)).toBe('user:admin agent:admin'); }); diff --git a/tests/core/policy/default-access.test.ts b/tests/core/policy/default-access.test.ts index 5ea40519..d143fb4a 100644 --- a/tests/core/policy/default-access.test.ts +++ b/tests/core/policy/default-access.test.ts @@ -1,12 +1,13 @@ /** * The access a config gets when it never declared one. * - * A stock noorm project writes no `access`, so this default is what an MCP - * agent actually holds against every config in the wild. It used to be - * admin on both channels, which left every other gate in the matrix - * decorative on the agent channel. These tests drive the real parse path and - * the real matrix rather than comparing constants, so a regression surfaces - * as "an agent can drop the database" instead of "a literal changed". + * A stock noorm project writes no `access`, so this default is what any + * agent actually holds against every config in the wild — over MCP and, now, + * when it shells out to the CLI. It used to be admin on both channels, which + * left every other gate in the matrix decorative on the agent channel. These + * tests drive the real parse path and the real matrix rather than comparing + * constants, so a regression surfaces as "an agent can drop the database" + * instead of "a literal changed". */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; @@ -75,13 +76,13 @@ describe('policy: default access', () => { }); - describe('mcp channel', () => { + describe('agent channel', () => { for (const permission of MUTATING) { it(`should deny "${permission}" on a config that never declared access`, () => { - const check = checkPolicy('mcp', { name: 'stock', access: stockAccess() }, permission); + const check = checkPolicy('agent', { name: 'stock', access: stockAccess() }, permission); expect(check.allowed).toBe(false); @@ -93,7 +94,7 @@ describe('policy: default access', () => { it(`should still allow "${permission}" on a config that never declared access`, () => { - const check = checkPolicy('mcp', { name: 'stock', access: stockAccess() }, permission); + const check = checkPolicy('agent', { name: 'stock', access: stockAccess() }, permission); expect(check.allowed).toBe(true); @@ -105,18 +106,18 @@ describe('policy: default access', () => { const target = { name: 'stock', access: stockAccess() }; - expect(checkPolicy('mcp', target, 'vault:read').allowed).toBe(false); - expect(checkPolicy('mcp', target, 'secret:read').allowed).toBe(false); + expect(checkPolicy('agent', target, 'vault:read').allowed).toBe(false); + expect(checkPolicy('agent', target, 'secret:read').allowed).toBe(false); }); it('should leave the config visible — restricted, not hidden', () => { - // `mcp: false` would make the config unreachable and report the + // `agent: false` would make the config unreachable and report the // same error as an unknown config, giving an operator no way to // tell a downgraded default from a typo. The default restricts // the role instead. - expect(stockAccess().mcp).not.toBe(false); + expect(stockAccess().agent).not.toBe(false); }); @@ -146,19 +147,19 @@ describe('policy: default access', () => { describe('explicit access', () => { - it('should preserve an explicit mcp:admin opt-in verbatim', () => { + it('should preserve an explicit agent:admin opt-in verbatim', () => { - const access = stockAccess({ access: { user: 'operator', mcp: 'admin' } }); + const access = stockAccess({ access: { user: 'operator', agent: 'admin' } }); - expect(access).toEqual({ user: 'operator', mcp: 'admin' }); - expect(checkPolicy('mcp', { name: 'stock', access }, 'sql:write').allowed).toBe(true); + expect(access).toEqual({ user: 'operator', agent: 'admin' }); + expect(checkPolicy('agent', { name: 'stock', access }, 'sql:write').allowed).toBe(true); }); it('should preserve an explicit restriction verbatim', () => { - expect(stockAccess({ access: { user: 'viewer', mcp: false } })) - .toEqual({ user: 'viewer', mcp: false }); + expect(stockAccess({ access: { user: 'viewer', agent: false } })) + .toEqual({ user: 'viewer', agent: false }); }); @@ -166,7 +167,7 @@ describe('policy: default access', () => { // `protected: false` said "the author asked for no restriction", // which is the default case — not an explicit grant of admin. - expect(stockAccess({ protected: false }).mcp).not.toBe('admin'); + expect(stockAccess({ protected: false }).agent).not.toBe('admin'); }); @@ -174,7 +175,7 @@ describe('policy: default access', () => { describe('state migration', () => { - it('should not backfill mcp:admin onto a legacy unprotected config', () => { + it('should not backfill agent:admin onto a legacy unprotected config', () => { const migrated = migrateState({ configs: { dev: { name: 'dev', protected: false } }, @@ -182,7 +183,7 @@ describe('policy: default access', () => { const configs = migrated['configs'] as Record; - expect(configs['dev']!.access.mcp).not.toBe('admin'); + expect(configs['dev']!.access.agent).not.toBe('admin'); expect(configs['dev']!.access.user).toBe('admin'); }); @@ -190,12 +191,12 @@ describe('policy: default access', () => { it('should leave a config that already stored an explicit access alone', () => { const migrated = migrateState({ - configs: { dev: { name: 'dev', access: { user: 'operator', mcp: 'admin' } } }, + configs: { dev: { name: 'dev', access: { user: 'operator', agent: 'admin' } } }, }); const configs = migrated['configs'] as Record; - expect(configs['dev']!.access).toEqual({ user: 'operator', mcp: 'admin' }); + expect(configs['dev']!.access).toEqual({ user: 'operator', agent: 'admin' }); }); diff --git a/tests/core/policy/visibility.test.ts b/tests/core/policy/visibility.test.ts index 03fd3fdb..8cd81ea0 100644 --- a/tests/core/policy/visibility.test.ts +++ b/tests/core/policy/visibility.test.ts @@ -7,9 +7,9 @@ import type { ConfigAccess } from '../../../src/core/policy/index.js'; describe('policy: isVisibleToChannel', () => { - it('should deny the mcp channel when access is undefined', () => { + it('should deny the agent channel when access is undefined', () => { - expect(isVisibleToChannel(undefined, 'mcp')).toBe(false); + expect(isVisibleToChannel(undefined, 'agent')).toBe(false); }); @@ -19,19 +19,19 @@ describe('policy: isVisibleToChannel', () => { }); - it('should deny the mcp channel when access.mcp is false', () => { + it('should deny the agent channel when access.agent is false', () => { - const access: ConfigAccess = { user: 'admin', mcp: false }; + const access: ConfigAccess = { user: 'admin', agent: false }; - expect(isVisibleToChannel(access, 'mcp')).toBe(false); + expect(isVisibleToChannel(access, 'agent')).toBe(false); }); - it('should allow the mcp channel when access.mcp is a real role', () => { + it('should allow the agent channel when access.agent is a real role', () => { - const access: ConfigAccess = { user: 'admin', mcp: 'viewer' }; + const access: ConfigAccess = { user: 'admin', agent: 'viewer' }; - expect(isVisibleToChannel(access, 'mcp')).toBe(true); + expect(isVisibleToChannel(access, 'agent')).toBe(true); }); diff --git a/tests/core/rpc/commands.test.ts b/tests/core/rpc/commands.test.ts index 639a7a95..b457661b 100644 --- a/tests/core/rpc/commands.test.ts +++ b/tests/core/rpc/commands.test.ts @@ -26,7 +26,7 @@ interface MockContextOptions { * * Provides full mock noorm operations so handlers can reach the policy * check and session delegation logic without needing a real database. - * Defaults to the `mcp` channel since that's what CP3 gates; individual + * Defaults to the `agent` channel since that's what CP3 gates; individual * tests override `channel` to prove the same handler logic is channel-generic. */ function createMockSession(options: MockContextOptions = {}): RpcSession { @@ -37,7 +37,7 @@ function createMockSession(options: MockContextOptions = {}): RpcSession { noorm: { config: { name: options.configName ?? 'test', - access: options.access ?? { user: 'admin', mcp: 'admin' }, + access: options.access ?? { user: 'admin', agent: 'admin' }, connection: { database: options.database ?? 'testdb' }, }, changes: { @@ -54,7 +54,7 @@ function createMockSession(options: MockContextOptions = {}): RpcSession { } as unknown as Context; return { - channel: options.channel ?? 'mcp', + channel: options.channel ?? 'agent', getContext: () => mockContext, connect: async (config?: string) => ({ name: config ?? 'test', @@ -80,7 +80,7 @@ function createMockSession(options: MockContextOptions = {}): RpcSession { function createDisconnectedSession(): RpcSession { return { - channel: 'mcp', + channel: 'agent', getContext: () => { throw new RpcError('Not connected — call connect first'); @@ -103,7 +103,7 @@ describe('rpc commands: sql', () => { it('should deny INSERT (sql:write) for a viewer role', async () => { - const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' } }); + const session = createMockSession({ access: { user: 'admin', agent: 'viewer' } }); const input = cmd.inputSchema.parse({ query: 'INSERT INTO t VALUES (1)' }); await expect(cmd.handler(input, session)).rejects.toThrow(/sql:write/i); @@ -112,7 +112,7 @@ describe('rpc commands: sql', () => { it('should deny DROP (sql:ddl) for a viewer role', async () => { - const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' } }); + const session = createMockSession({ access: { user: 'admin', agent: 'viewer' } }); const input = cmd.inputSchema.parse({ query: 'DROP TABLE users' }); await expect(cmd.handler(input, session)).rejects.toThrow(/sql:ddl/i); @@ -121,7 +121,7 @@ describe('rpc commands: sql', () => { it('should deny UPDATE (sql:write) for a viewer role', async () => { - const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' } }); + const session = createMockSession({ access: { user: 'admin', agent: 'viewer' } }); const input = cmd.inputSchema.parse({ query: 'UPDATE users SET name = \'bob\'' }); await expect(cmd.handler(input, session)).rejects.toThrow(/sql:write/i); @@ -130,7 +130,7 @@ describe('rpc commands: sql', () => { it('should deny DELETE (sql:write) for a viewer role', async () => { - const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' } }); + const session = createMockSession({ access: { user: 'admin', agent: 'viewer' } }); const input = cmd.inputSchema.parse({ query: 'DELETE FROM users' }); await expect(cmd.handler(input, session)).rejects.toThrow(/sql:write/i); @@ -139,7 +139,7 @@ describe('rpc commands: sql', () => { it('should allow SELECT for a viewer role (policy passes, execution reached)', async () => { - const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' } }); + const session = createMockSession({ access: { user: 'admin', agent: 'viewer' } }); const input = cmd.inputSchema.parse({ query: 'SELECT * FROM users LIMIT 10' }); // The policy gate is the only thing in this call chain that rejects @@ -153,7 +153,7 @@ describe('rpc commands: sql', () => { it('should allow INSERT (sql:write) for an operator role (policy passes, execution reached)', async () => { - const session = createMockSession({ access: { user: 'admin', mcp: 'operator' } }); + const session = createMockSession({ access: { user: 'admin', agent: 'operator' } }); const input = cmd.inputSchema.parse({ query: 'INSERT INTO t VALUES (1)' }); await expect(cmd.handler(input, session)).resolves.toBeDefined(); @@ -162,7 +162,7 @@ describe('rpc commands: sql', () => { it('should deny DROP (sql:ddl) for an operator role', async () => { - const session = createMockSession({ access: { user: 'admin', mcp: 'operator' } }); + const session = createMockSession({ access: { user: 'admin', agent: 'operator' } }); const input = cmd.inputSchema.parse({ query: 'DROP TABLE users' }); await expect(cmd.handler(input, session)).rejects.toThrow(/sql:ddl/i); @@ -171,7 +171,7 @@ describe('rpc commands: sql', () => { it('should allow DROP (sql:ddl) for an admin role (policy passes, execution reached)', async () => { - const session = createMockSession({ access: { user: 'admin', mcp: 'admin' } }); + const session = createMockSession({ access: { user: 'admin', agent: 'admin' } }); const input = cmd.inputSchema.parse({ query: 'DROP TABLE users' }); await expect(cmd.handler(input, session)).resolves.toBeDefined(); @@ -180,7 +180,7 @@ describe('rpc commands: sql', () => { it('should include config name in the denial reason', async () => { - const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' }, configName: 'prod' }); + const session = createMockSession({ access: { user: 'admin', agent: 'viewer' }, configName: 'prod' }); const input = cmd.inputSchema.parse({ query: 'DROP TABLE secrets' }); const [, err] = await attempt(() => cmd.handler(input, session)); @@ -201,7 +201,7 @@ describe('rpc commands: sql', () => { it('should apply the same escalation on the user channel', async () => { - const session = createMockSession({ access: { user: 'viewer', mcp: 'admin' }, channel: 'user' }); + const session = createMockSession({ access: { user: 'viewer', agent: 'admin' }, channel: 'user' }); const input = cmd.inputSchema.parse({ query: 'INSERT INTO t VALUES (1)' }); await expect(cmd.handler(input, session)).rejects.toThrow(/sql:write/i); diff --git a/tests/core/rpc/list-configs.test.ts b/tests/core/rpc/list-configs.test.ts index 221f0af3..53cf1153 100644 --- a/tests/core/rpc/list-configs.test.ts +++ b/tests/core/rpc/list-configs.test.ts @@ -1,5 +1,5 @@ /** - * rpc commands: list_configs mcp-channel invisibility. + * rpc commands: list_configs agent-channel invisibility. * * Uses a real StateManager (via the module singleton `list_configs` reads * through `initState()`) rather than mocking state, so the test proves the @@ -32,7 +32,7 @@ function testConfig(name: string, overrides: Partial = {}): Config { name, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:' }, ...overrides, }; @@ -74,7 +74,7 @@ describe('rpc commands: list_configs', () => { const manager = await initState(tempDir); await manager.setConfig('visible', testConfig('visible')); - await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', mcp: false } })); + await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', agent: false } })); }); @@ -86,9 +86,9 @@ describe('rpc commands: list_configs', () => { }); - it('should omit configs with access.mcp === false on the mcp channel', async () => { + it('should omit configs with access.agent === false on the agent channel', async () => { - const result = await cmd.handler({}, sessionFor('mcp')); + const result = await cmd.handler({}, sessionFor('agent')); if (!isConfigSummaryArray(result)) throw new Error('expected an array of ConfigSummary'); diff --git a/tests/core/rpc/session-not-found.test.ts b/tests/core/rpc/session-not-found.test.ts index 68084c24..7669019f 100644 --- a/tests/core/rpc/session-not-found.test.ts +++ b/tests/core/rpc/session-not-found.test.ts @@ -1,5 +1,5 @@ /** - * rpc session: real unknown-config vs mcp-invisibility error parity. + * rpc session: real unknown-config vs agent-invisibility error parity. * * Runs `SessionManager.connect()` against a real `createContext` / * `StateManager` (no sdk mock — `session.test.ts` mocks `createContext` to @@ -26,7 +26,7 @@ function testConfig(name: string, overrides: Partial = {}): Config { name, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:' }, ...overrides, }; @@ -47,7 +47,7 @@ describe('rpc: session manager (real createContext)', () => { setKeyOverride(privateKey); const manager = await initState(tempDir); - await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', mcp: false } })); + await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', agent: false } })); }); @@ -62,10 +62,10 @@ describe('rpc: session manager (real createContext)', () => { it('should throw the byte-identical error for a real hidden config and a real unknown config', async () => { - const mcpSession = new SessionManager('mcp'); + const agentSession = new SessionManager('agent'); - const [, unknownErr] = await attempt(() => mcpSession.connect('ghost')); - const [, hiddenErr] = await attempt(() => mcpSession.connect('hidden')); + const [, unknownErr] = await attempt(() => agentSession.connect('ghost')); + const [, hiddenErr] = await attempt(() => agentSession.connect('hidden')); expect(unknownErr).toBeDefined(); expect(hiddenErr).toBeDefined(); diff --git a/tests/core/rpc/session-status.test.ts b/tests/core/rpc/session-status.test.ts index 58429290..feaf6f0f 100644 --- a/tests/core/rpc/session-status.test.ts +++ b/tests/core/rpc/session-status.test.ts @@ -3,7 +3,7 @@ * * Uses a real StateManager (via the module singleton `status` reads through * `initState()`) rather than mocking state, so active-config resolution is - * proven end-to-end the same way `list-configs.test.ts` proves mcp-channel + * proven end-to-end the same way `list-configs.test.ts` proves agent-channel * filtering. `setKeyOverride` supplies the encryption key in-memory so * persistence never touches the real `~/.noorm/identity.key`. Only * `RpcSession` (`channel`, `listConnections`, `hasConnection`) is mocked. @@ -34,7 +34,7 @@ function testConfig(name: string, overrides: Partial = {}): Config { name, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:' }, ...overrides, }; @@ -190,13 +190,13 @@ describe('rpc commands: status', () => { }); - it('should hide the active config name on the mcp channel when access.mcp === false', async () => { + it('should hide the active config name on the agent channel when access.agent === false', async () => { const manager = await initState(tempDir); - await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', mcp: false } })); + await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', agent: false } })); await manager.setActiveConfig('hidden'); - const result = await cmd.handler({}, sessionFor({ channel: 'mcp' })); + const result = await cmd.handler({}, sessionFor({ channel: 'agent' })); if (!isSessionStatus(result)) throw new Error('expected a SessionStatus'); @@ -208,7 +208,7 @@ describe('rpc commands: status', () => { it('should report the active config name unfiltered on the user channel for the same hidden config', async () => { const manager = await initState(tempDir); - await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', mcp: false } })); + await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', agent: false } })); await manager.setActiveConfig('hidden'); const result = await cmd.handler({}, sessionFor({ channel: 'user' })); diff --git a/tests/core/rpc/session.test.ts b/tests/core/rpc/session.test.ts index 9bbc2d0b..ed134545 100644 --- a/tests/core/rpc/session.test.ts +++ b/tests/core/rpc/session.test.ts @@ -7,7 +7,7 @@ import type { Config } from '../../../src/core/config/types.js'; // The real `createContext` resolves configs through on-disk state, which // session.connect() would otherwise need a live project + identity key for. -// Mocking it lets these tests drive the mcp-channel invisibility and +// Mocking it lets these tests drive the agent-channel invisibility and // fail-closed rules directly, with a plain in-memory config registry. const actualSdk = await import('../../../src/sdk/index.js'); @@ -43,7 +43,7 @@ function testConfig(name: string, overrides: Partial = {}): Config { name, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:' }, ...overrides, }; @@ -119,23 +119,23 @@ describe('rpc: session manager', () => { it('should expose the channel passed to the constructor', () => { - expect(new SessionManager('mcp').channel).toBe('mcp'); + expect(new SessionManager('agent').channel).toBe('agent'); }); }); - describe('connect: mcp invisibility', () => { + describe('connect: agent invisibility', () => { - it('should throw the byte-identical error for a hidden config (access.mcp: false) and an unknown config', async () => { + it('should throw the byte-identical error for a hidden config (access.agent: false) and an unknown config', async () => { - const mcpSession = new SessionManager('mcp'); + const agentSession = new SessionManager('agent'); - const [, unknownErr] = await attempt(() => mcpSession.connect('does-not-exist')); + const [, unknownErr] = await attempt(() => agentSession.connect('does-not-exist')); - configs.set('secret', testConfig('secret', { access: { user: 'admin', mcp: false } })); + configs.set('secret', testConfig('secret', { access: { user: 'admin', agent: false } })); - const [, hiddenErr] = await attempt(() => mcpSession.connect('secret')); + const [, hiddenErr] = await attempt(() => agentSession.connect('secret')); expect(unknownErr).toBeDefined(); expect(hiddenErr).toBeDefined(); @@ -144,21 +144,21 @@ describe('rpc: session manager', () => { }); - it('should allow a visible config through on the mcp channel and report its mcp role', async () => { + it('should allow a visible config through on the agent channel and report its agent role', async () => { - const mcpSession = new SessionManager('mcp'); + const agentSession = new SessionManager('agent'); - configs.set('reporting', testConfig('reporting', { access: { user: 'admin', mcp: 'viewer' } })); + configs.set('reporting', testConfig('reporting', { access: { user: 'admin', agent: 'viewer' } })); - const info = await mcpSession.connect('reporting'); + const info = await agentSession.connect('reporting'); expect(info.role).toBe('viewer'); }); - it('should not apply mcp invisibility on the user channel', async () => { + it('should not apply agent invisibility on the user channel', async () => { - configs.set('secret', testConfig('secret', { access: { user: 'operator', mcp: false } })); + configs.set('secret', testConfig('secret', { access: { user: 'operator', agent: false } })); const info = await session.connect('secret'); @@ -166,11 +166,11 @@ describe('rpc: session manager', () => { }); - it('should deny an mcp-channel config that reaches connect with no `access` at all, identically to an unknown config', async () => { + it('should deny an agent-channel config that reaches connect with no `access` at all, identically to an unknown config', async () => { - const mcpSession = new SessionManager('mcp'); + const agentSession = new SessionManager('agent'); - const [, unknownErr] = await attempt(() => mcpSession.connect('does-not-exist')); + const [, unknownErr] = await attempt(() => agentSession.connect('does-not-exist')); // `Config.access` is required at compile time; this hand-built // double simulates a runtime boundary that bypasses it (a @@ -181,7 +181,7 @@ describe('rpc: session manager', () => { Reflect.deleteProperty(noAccessConfig, 'access'); configs.set('no-access', noAccessConfig); - const [, noAccessErr] = await attempt(() => mcpSession.connect('no-access')); + const [, noAccessErr] = await attempt(() => agentSession.connect('no-access')); expect(unknownErr).toBeDefined(); expect(noAccessErr).toBeDefined(); diff --git a/tests/core/runner/dry-run-output.test.ts b/tests/core/runner/dry-run-output.test.ts index c81aeeca..26274bbf 100644 --- a/tests/core/runner/dry-run-output.test.ts +++ b/tests/core/runner/dry-run-output.test.ts @@ -34,7 +34,7 @@ describe('runner: dry-run output', () => { configName: 'test', identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, projectRoot: tempDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'sqlite', secrets: { API_TOKEN: 'sk-live-not-a-real-token' }, diff --git a/tests/core/runner/execute-files.test.ts b/tests/core/runner/execute-files.test.ts index 49281fba..fb15751c 100644 --- a/tests/core/runner/execute-files.test.ts +++ b/tests/core/runner/execute-files.test.ts @@ -33,7 +33,7 @@ describe('runner: executeFiles — duplicate and exactly-once guards', () => { configName: 'test', identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, projectRoot: tempDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'sqlite', }; diff --git a/tests/core/runner/mssql-batches.test.ts b/tests/core/runner/mssql-batches.test.ts index 049b5e83..cd93ca16 100644 --- a/tests/core/runner/mssql-batches.test.ts +++ b/tests/core/runner/mssql-batches.test.ts @@ -130,7 +130,7 @@ function makeContext(dialect: RunContext['dialect']): TestSetup { configName: 'test', identity: { name: 'Test', email: 't@x.com', source: 'config' }, projectRoot: '/tmp', - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect, }; diff --git a/tests/core/runner/runner.test.ts b/tests/core/runner/runner.test.ts index 60c61320..6e016f6b 100644 --- a/tests/core/runner/runner.test.ts +++ b/tests/core/runner/runner.test.ts @@ -20,7 +20,7 @@ const mockContext: RunContext = { configName: 'test', identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, projectRoot: FIXTURES_DIR, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', config: { table: 'users' }, secrets: { API_KEY: 'secret123' }, @@ -145,7 +145,7 @@ describe('runner: policy gate', () => { // proves it fires before any file I/O or DB access is attempted. const viewerContext: RunContext = { ...mockContext, - access: { user: 'viewer', mcp: false }, + access: { user: 'viewer', agent: false }, }; it('should deny runBuild for a viewer role', async () => { @@ -222,7 +222,7 @@ describe('runner: policy gate', () => { const filepath = path.join(FIXTURES_DIR, 'template.sql.tmpl'); const results = await preview( - { ...mockContext, access: { user: 'operator', mcp: 'operator' } }, + { ...mockContext, access: { user: 'operator', agent: 'operator' } }, [filepath], ); diff --git a/tests/core/runner/sqlite-multi-statement.test.ts b/tests/core/runner/sqlite-multi-statement.test.ts index a0c8c31e..1afc47af 100644 --- a/tests/core/runner/sqlite-multi-statement.test.ts +++ b/tests/core/runner/sqlite-multi-statement.test.ts @@ -36,7 +36,7 @@ describe('runner: multi-statement files on sqlite', () => { configName: 'test', identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, projectRoot: tempDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'sqlite', }; diff --git a/tests/core/runner/template-dedup.test.ts b/tests/core/runner/template-dedup.test.ts index 485c4948..829e8a5d 100644 --- a/tests/core/runner/template-dedup.test.ts +++ b/tests/core/runner/template-dedup.test.ts @@ -39,7 +39,7 @@ describe('runner: template checksum dedup', () => { configName: 'test', identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, projectRoot: tempDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'sqlite', }; diff --git a/tests/core/secrets/leakage.test.ts b/tests/core/secrets/leakage.test.ts index e3e3373d..82e2902b 100644 --- a/tests/core/secrets/leakage.test.ts +++ b/tests/core/secrets/leakage.test.ts @@ -34,7 +34,7 @@ function createTestConfig(name: string): Config { name, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', diff --git a/tests/core/settings/manager.test.ts b/tests/core/settings/manager.test.ts index 206bd3a5..5eb1d3ce 100644 --- a/tests/core/settings/manager.test.ts +++ b/tests/core/settings/manager.test.ts @@ -1022,7 +1022,7 @@ stages: name: 'test', type: 'local' as const, isTest: true, - access: { user: 'admin' as const, mcp: 'admin' as const }, + access: { user: 'admin' as const, agent: 'admin' as const }, }; const result = manager.evaluateRules(testConfig); @@ -1060,7 +1060,7 @@ stages: name: 'test', type: 'local' as const, isTest: true, - access: { user: 'admin' as const, mcp: 'admin' as const }, + access: { user: 'admin' as const, agent: 'admin' as const }, }; const result = manager.getEffectiveBuildPaths(testConfig); diff --git a/tests/core/settings/rules.test.ts b/tests/core/settings/rules.test.ts index 70cc2e9d..70bdbb8a 100644 --- a/tests/core/settings/rules.test.ts +++ b/tests/core/settings/rules.test.ts @@ -17,28 +17,28 @@ describe('settings: rule evaluation', () => { name: 'dev', type: 'local', isTest: false, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, }; const testConfig: ConfigForRuleMatch = { name: 'test', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, }; const prodConfig: ConfigForRuleMatch = { name: 'prod', type: 'remote', isTest: false, - access: { user: 'operator', mcp: 'viewer' }, + access: { user: 'operator', agent: 'viewer' }, }; const stagingConfig: ConfigForRuleMatch = { name: 'staging', type: 'remote', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, }; describe('ruleMatches', () => { diff --git a/tests/core/shared/operation-id.test.ts b/tests/core/shared/operation-id.test.ts index 75245e5b..9f229d3b 100644 --- a/tests/core/shared/operation-id.test.ts +++ b/tests/core/shared/operation-id.test.ts @@ -40,7 +40,11 @@ const VALUES: NewNoormChange = { * Run the helper against a dialect's real query compiler, recording every * statement the driver would have sent. */ -async function compileFor(dialect: Dialect, rules: ResponseRule[] = []) { +async function compileFor( + dialect: Dialect, + rules: ResponseRule[] = [], + env: Record = {}, +) { const recording = createRecordingDb(dialect, rules); const db = recording.kysely as unknown as Kysely; @@ -51,6 +55,7 @@ async function compileFor(dialect: Dialect, rules: ResponseRule[] = []) { dialect, table: getNoormTables(dialect).change, values: VALUES, + env, }); return { id, err, recording }; @@ -237,6 +242,85 @@ describe('shared: insertOperationRecord', () => { }); + it('should stamp the driving harness onto executed_by', async () => { + + // Every operation record funnels through this helper, so stamping here + // is what makes it impossible for a call site to write a row that hides + // the agent behind it. + const { recording } = await compileFor( + 'postgres', + [{ match: /insert into/i, rows: [{ id: 1 }] }], + { CLAUDECODE: '1' }, + ); + + expect(recording.queries[0]!.parameters).toContain('test@example.com (via Claude Code)'); + + }); + + it('should leave executed_by alone when no harness is driving', async () => { + + // The absence of a suffix is what makes its presence meaningful; a + // human-driven row must stay exactly what it was. + const { recording } = await compileFor( + 'postgres', + [{ match: /insert into/i, rows: [{ id: 1 }] }], + { TERM_PROGRAM: 'iTerm.app', CI: 'true' }, + ); + + expect(recording.queries[0]!.parameters).toContain('test@example.com'); + expect(recording.queries[0]!.parameters.join(' ')).not.toContain('(via'); + + }); + + it('should stamp provenance on every dialect', async () => { + + // The suffix rides in the identity string precisely so it needs no + // per-dialect support; this asserts that claim rather than assuming it. + for (const dialect of ['postgres', 'sqlite', 'mysql', 'mssql'] as const) { + + const { recording } = await compileFor( + dialect, + [{ match: /insert into/i, rows: [{ id: 1 }], insertId: 1n }], + { CURSOR_AGENT: '1' }, + ); + + expect(recording.queries[0]!.parameters).toContain('test@example.com (via Cursor)'); + + } + + }); + + it('should persist provenance to a real database, not just the bound parameters', async () => { + + const db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + const [id] = await insertOperationRecord({ + db, + ndb: noormDb(db, 'sqlite'), + dialect: 'sqlite', + table: getNoormTables('sqlite').change, + values: VALUES, + env: { CLAUDECODE: '1' }, + }); + + const row = await db + .selectFrom('__noorm_change__') + .select(['executed_by']) + .where('id', '=', id!) + .executeTakeFirst(); + + expect(row?.executed_by).toBe('test@example.com (via Claude Code)'); + + await db.destroy(); + + }); + it('should return an id a real sqlite database can join child rows to', async () => { const db = new Kysely({ diff --git a/tests/core/sql-terminal/executor.test.ts b/tests/core/sql-terminal/executor.test.ts index ff9b93a7..87267ec8 100644 --- a/tests/core/sql-terminal/executor.test.ts +++ b/tests/core/sql-terminal/executor.test.ts @@ -401,12 +401,12 @@ describe('sql-terminal: executor', () => { describe('policy gate', () => { const VIEWER_GATE = { - access: { user: 'admin' as const, mcp: 'viewer' as const }, - channel: 'mcp' as const, + access: { user: 'admin' as const, agent: 'viewer' as const }, + channel: 'agent' as const, dialect: 'postgres' as const, }; const ADMIN_GATE = { - access: { user: 'admin' as const, mcp: 'admin' as const }, + access: { user: 'admin' as const, agent: 'admin' as const }, channel: 'user' as const, dialect: 'postgres' as const, }; diff --git a/tests/core/state/access.test.ts b/tests/core/state/access.test.ts index ae1375d5..16db4898 100644 --- a/tests/core/state/access.test.ts +++ b/tests/core/state/access.test.ts @@ -57,22 +57,22 @@ describe('state: access repair', () => { it('should keep a fully valid access untouched', () => { - expect(repairConfigAccess({ user: 'operator', mcp: 'viewer' }, true)) - .toEqual({ user: 'operator', mcp: 'viewer' }); + expect(repairConfigAccess({ user: 'operator', agent: 'viewer' }, true)) + .toEqual({ user: 'operator', agent: 'viewer' }); }); - it('should keep mcp:false, which hides the config rather than widening it', () => { + it('should keep agent:false, which hides the config rather than widening it', () => { - expect(repairConfigAccess({ user: 'viewer', mcp: false }, undefined)) - .toEqual({ user: 'viewer', mcp: false }); + expect(repairConfigAccess({ user: 'viewer', agent: false }, undefined)) + .toEqual({ user: 'viewer', agent: false }); }); it('should let a valid access win over the legacy flag', () => { - expect(repairConfigAccess({ user: 'admin', mcp: 'admin' }, true)) - .toEqual({ user: 'admin', mcp: 'admin' }); + expect(repairConfigAccess({ user: 'admin', agent: 'admin' }, true)) + .toEqual({ user: 'admin', agent: 'admin' }); }); @@ -85,27 +85,27 @@ describe('state: access repair', () => { // `{}` is truthy, so it used to pass straight through and brick // the config: every later command failed zod validation with // `expected one of "viewer"|"operator"|"admin"`. - expect(repairConfigAccess({}, true)).toEqual({ user: 'viewer', mcp: 'viewer' }); + expect(repairConfigAccess({}, true)).toEqual({ user: 'viewer', agent: 'viewer' }); }); it('should fill a missing channel with the most restrictive role', () => { expect(repairConfigAccess({ user: 'admin' }, undefined)) - .toEqual({ user: 'admin', mcp: 'viewer' }); + .toEqual({ user: 'admin', agent: 'viewer' }); }); it('should replace an unrecognised role with the most restrictive one', () => { - expect(repairConfigAccess({ user: 'superuser', mcp: 'admin' }, undefined)) - .toEqual({ user: 'viewer', mcp: 'admin' }); + expect(repairConfigAccess({ user: 'superuser', agent: 'admin' }, undefined)) + .toEqual({ user: 'viewer', agent: 'admin' }); }); it('should never widen a config whose access is malformed', () => { - for (const malformed of [{}, { user: 'nope' }, { mcp: 'nope' }, { user: null }]) { + for (const malformed of [{}, { user: 'nope' }, { agent: 'nope' }, { user: null }]) { const repaired = repairConfigAccess(malformed, undefined); diff --git a/tests/core/state/durability.test.ts b/tests/core/state/durability.test.ts index e3b9dfa9..cfa6a3ff 100644 --- a/tests/core/state/durability.test.ts +++ b/tests/core/state/durability.test.ts @@ -23,7 +23,7 @@ function createTestConfig(name: string): Config { name, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:' }, }; diff --git a/tests/core/state/manager.test.ts b/tests/core/state/manager.test.ts index 0b2e9379..c6989194 100644 --- a/tests/core/state/manager.test.ts +++ b/tests/core/state/manager.test.ts @@ -28,7 +28,7 @@ function createTestConfig(name: string, overrides: Partial = {}): Config name, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', @@ -220,7 +220,7 @@ describe('state: manager', () => { await state.load(); - expect(state.getConfig('prod')?.access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(state.getConfig('prod')?.access).toEqual({ user: 'operator', agent: 'viewer' }); const raw = readFileSync(statePath, 'utf8'); const decrypted = JSON.parse( @@ -247,7 +247,7 @@ describe('state: manager', () => { await state.load(); - expect(state.getConfig('dev')?.access).toEqual({ user: 'admin', mcp: 'viewer' }); + expect(state.getConfig('dev')?.access).toEqual({ user: 'admin', agent: 'viewer' }); const raw = readFileSync(statePath, 'utf8'); const decrypted = JSON.parse( @@ -290,7 +290,7 @@ describe('state: manager', () => { await state.load(); - expect(state.getConfig('corrupt')?.access).toEqual({ user: 'admin', mcp: 'viewer' }); + expect(state.getConfig('corrupt')?.access).toEqual({ user: 'admin', agent: 'viewer' }); const raw = readFileSync(statePath, 'utf8'); const decrypted = JSON.parse( @@ -299,7 +299,7 @@ describe('state: manager', () => { expect(decrypted.configs['corrupt']?.['access']).toEqual({ user: 'admin', - mcp: 'viewer', + agent: 'viewer', }); }); @@ -344,7 +344,7 @@ describe('state: manager', () => { await state.load(); - expect(state.getConfig('guarded')?.access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(state.getConfig('guarded')?.access).toEqual({ user: 'operator', agent: 'viewer' }); const raw = readFileSync(statePath, 'utf8'); const decrypted = JSON.parse( @@ -353,7 +353,7 @@ describe('state: manager', () => { expect(decrypted.configs['guarded']?.['access']).toEqual({ user: 'operator', - mcp: 'viewer', + agent: 'viewer', }); }); @@ -377,7 +377,7 @@ describe('state: manager', () => { await state.load(); - expect(state.getConfig('prod')?.access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(state.getConfig('prod')?.access).toEqual({ user: 'operator', agent: 'viewer' }); }); @@ -399,14 +399,14 @@ describe('state: manager', () => { await state.load(); - expect(state.getConfig('broken')?.access).toEqual({ user: 'viewer', mcp: 'viewer' }); + expect(state.getConfig('broken')?.access).toEqual({ user: 'viewer', agent: 'viewer' }); const raw = readFileSync(statePath, 'utf8'); const decrypted = JSON.parse( decrypt(JSON.parse(raw) as EncryptedPayload, testPrivateKey), ) as { configs: Record> }; - expect(decrypted.configs['broken']?.['access']).toEqual({ user: 'viewer', mcp: 'viewer' }); + expect(decrypted.configs['broken']?.['access']).toEqual({ user: 'viewer', agent: 'viewer' }); }); @@ -488,7 +488,7 @@ describe('state: manager', () => { it('should update existing config', async () => { await state.setConfig('dev', createTestConfig('dev')); - await state.setConfig('dev', createTestConfig('dev', { access: { user: 'operator', mcp: 'viewer' } })); + await state.setConfig('dev', createTestConfig('dev', { access: { user: 'operator', agent: 'viewer' } })); const config = state.getConfig('dev'); expect(guarded(config!)).toBe(true); @@ -510,7 +510,7 @@ describe('state: manager', () => { const initialCount = state.listConfigs().length; await state.setConfig('dev', createTestConfig('dev')); - await state.setConfig('prod', createTestConfig('prod', { access: { user: 'operator', mcp: 'viewer' } })); + await state.setConfig('prod', createTestConfig('prod', { access: { user: 'operator', agent: 'viewer' } })); const list = state.listConfigs(); expect(list).toHaveLength(initialCount + 2); @@ -522,24 +522,24 @@ describe('state: manager', () => { it('should include access in config summaries', async () => { await state.setConfig('dev', createTestConfig('dev')); - await state.setConfig('prod', createTestConfig('prod', { access: { user: 'operator', mcp: 'viewer' } })); + await state.setConfig('prod', createTestConfig('prod', { access: { user: 'operator', agent: 'viewer' } })); const list = state.listConfigs(); expect(list.find((c) => c.name === 'dev')?.access).toEqual({ user: 'admin', - mcp: 'admin', + agent: 'admin', }); expect(list.find((c) => c.name === 'prod')?.access).toEqual({ user: 'operator', - mcp: 'viewer', + agent: 'viewer', }); }); it('should not persist a stored protected field on disk', async () => { - await state.setConfig('dev', createTestConfig('dev', { access: { user: 'operator', mcp: 'viewer' } })); + await state.setConfig('dev', createTestConfig('dev', { access: { user: 'operator', agent: 'viewer' } })); const raw = readFileSync(state.getStatePath(), 'utf8'); const payload = JSON.parse(raw) as EncryptedPayload; @@ -550,7 +550,7 @@ describe('state: manager', () => { expect(decrypted.configs['dev']).not.toHaveProperty('protected'); expect(decrypted.configs['dev']?.['access']).toEqual({ user: 'operator', - mcp: 'viewer', + agent: 'viewer', }); }); diff --git a/tests/core/state/merge.test.ts b/tests/core/state/merge.test.ts index c043858d..1632edca 100644 --- a/tests/core/state/merge.test.ts +++ b/tests/core/state/merge.test.ts @@ -16,7 +16,7 @@ function config(name: string): Config { name, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:' }, }; diff --git a/tests/core/teardown/operations.test.ts b/tests/core/teardown/operations.test.ts index c6f783c0..ad15f1fd 100644 --- a/tests/core/teardown/operations.test.ts +++ b/tests/core/teardown/operations.test.ts @@ -798,8 +798,8 @@ describe('teardown: teardownSchema foreign schemas', () => { describe('teardown: core-seam policy gate', () => { - const viewer = { configName: 'prod', access: { user: 'viewer', mcp: 'viewer' } } as const; - const admin = { configName: 'prod', access: { user: 'admin', mcp: 'admin' } } as const; + const viewer = { configName: 'prod', access: { user: 'viewer', agent: 'viewer' } } as const; + const admin = { configName: 'prod', access: { user: 'admin', agent: 'admin' } } as const; it('refuses a truncate the role denies', async () => { diff --git a/tests/core/transfer/policy-gate.test.ts b/tests/core/transfer/policy-gate.test.ts index 1a97db3f..11e62fd6 100644 --- a/tests/core/transfer/policy-gate.test.ts +++ b/tests/core/transfer/policy-gate.test.ts @@ -11,8 +11,8 @@ import { transferData, getTransferPlan } from '../../../src/core/transfer/index. import { makeTestConfig } from '../../utils/db.js'; import type { ConfigAccess } from '../../../src/core/policy/index.js'; -const VIEWER: ConfigAccess = { user: 'viewer', mcp: false }; -const ADMIN: ConfigAccess = { user: 'admin', mcp: 'admin' }; +const VIEWER: ConfigAccess = { user: 'viewer', agent: false }; +const ADMIN: ConfigAccess = { user: 'admin', agent: 'admin' }; describe('transfer: policy gate', () => { diff --git a/tests/core/vault/policy-gate.test.ts b/tests/core/vault/policy-gate.test.ts index 7888f2d6..71da17c0 100644 --- a/tests/core/vault/policy-gate.test.ts +++ b/tests/core/vault/policy-gate.test.ts @@ -49,8 +49,8 @@ const gateFor = (access: ConfigAccess): VaultPolicyGate => ({ channel: 'user', }); -const VIEWER = gateFor({ user: 'viewer', mcp: 'viewer' }); -const ADMIN = gateFor({ user: 'admin', mcp: 'admin' }); +const VIEWER = gateFor({ user: 'viewer', agent: 'viewer' }); +const ADMIN = gateFor({ user: 'admin', agent: 'admin' }); async function createTestDb(): Promise> { diff --git a/tests/core/version/state.test.ts b/tests/core/version/state.test.ts index acd7e321..2bc35327 100644 --- a/tests/core/version/state.test.ts +++ b/tests/core/version/state.test.ts @@ -13,6 +13,7 @@ import { createEmptyVersionedState, ensureStateVersion, } from '../../../src/core/version/state/index.js'; +import { v3 } from '../../../src/core/version/state/migrations/v3.js'; describe('version: state', () => { @@ -168,7 +169,7 @@ describe('version: state', () => { expect(migrated['activeConfig']).toBe('dev'); // v2 backfills access roles onto every config (see the "v2: // per-config access roles" tests below for the mapping itself). - expect(migrated['configs']).toEqual({ dev: { access: { user: 'admin', mcp: 'viewer' } } }); + expect(migrated['configs']).toEqual({ dev: { access: { user: 'admin', agent: 'viewer' } } }); }); @@ -289,7 +290,7 @@ describe('version: state', () => { prod: { name: 'prod', connection: { dialect: 'sqlite', database: ':memory:' }, - access: { user: 'operator', mcp: 'viewer' }, + access: { user: 'operator', agent: 'viewer' }, }, }); @@ -314,7 +315,7 @@ describe('version: state', () => { dev: { name: 'dev', connection: { dialect: 'sqlite', database: ':memory:' }, - access: { user: 'admin', mcp: 'viewer' }, + access: { user: 'admin', agent: 'viewer' }, }, }); @@ -338,7 +339,7 @@ describe('version: state', () => { dev: { name: 'dev', connection: { dialect: 'sqlite', database: ':memory:' }, - access: { user: 'admin', mcp: 'viewer' }, + access: { user: 'admin', agent: 'viewer' }, }, }); @@ -351,7 +352,7 @@ describe('version: state', () => { configs: { staging: { name: 'staging', - access: { user: 'viewer', mcp: false }, + access: { user: 'viewer', agent: false }, connection: { dialect: 'sqlite', database: ':memory:' }, }, }, @@ -363,7 +364,7 @@ describe('version: state', () => { staging: { name: 'staging', connection: { dialect: 'sqlite', database: ':memory:' }, - access: { user: 'viewer', mcp: false }, + access: { user: 'viewer', agent: false }, }, }); @@ -376,7 +377,7 @@ describe('version: state', () => { configs: { prod: { name: 'prod', - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, protected: true, connection: { dialect: 'sqlite', database: ':memory:' }, }, @@ -389,7 +390,7 @@ describe('version: state', () => { prod: { name: 'prod', connection: { dialect: 'sqlite', database: ':memory:' }, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, }, }); @@ -427,17 +428,17 @@ describe('version: state', () => { // string here. Requiring a strict `true` sent every one // of those to the unrestricted default. expect(migrateConfig({ name: 'prod', protected: 'true' })['access']) - .toEqual({ user: 'operator', mcp: 'viewer' }); + .toEqual({ user: 'operator', agent: 'viewer' }); expect(migrateConfig({ name: 'prod', protected: 1 })['access']) - .toEqual({ user: 'operator', mcp: 'viewer' }); + .toEqual({ user: 'operator', agent: 'viewer' }); }); it('should not guard a config whose protected flag is falsy', () => { expect(migrateConfig({ name: 'prod', protected: false })['access']) - .toEqual({ user: 'admin', mcp: 'viewer' }); + .toEqual({ user: 'admin', agent: 'viewer' }); }); @@ -447,14 +448,14 @@ describe('version: state', () => { // while `protected` was discarded — leaving a config no // command could ever use again and no way back. expect(migrateConfig({ name: 'prod', protected: true, access: {} })['access']) - .toEqual({ user: 'viewer', mcp: 'viewer' }); + .toEqual({ user: 'viewer', agent: 'viewer' }); }); it('should fill a half-populated access rather than persisting it', () => { expect(migrateConfig({ name: 'prod', protected: true, access: { user: 'admin' } })['access']) - .toEqual({ user: 'admin', mcp: 'viewer' }); + .toEqual({ user: 'admin', agent: 'viewer' }); }); @@ -463,8 +464,8 @@ describe('version: state', () => { const malformed = [ { name: 'prod', protected: true, access: {} }, { name: 'prod', access: { user: 'admin' } }, - { name: 'prod', access: { mcp: 'admin' } }, - { name: 'prod', access: { user: 'superuser', mcp: 'admin' } }, + { name: 'prod', access: { agent: 'admin' } }, + { name: 'prod', access: { user: 'superuser', agent: 'admin' } }, { name: 'prod', protected: 'yes' }, ]; @@ -475,8 +476,8 @@ describe('version: state', () => { expect({ input: rawConfig, userValid: ['viewer', 'operator', 'admin'].includes(access['user'] as string) }) .toEqual({ input: rawConfig, userValid: true }); - expect({ input: rawConfig, mcpValid: access['mcp'] === false || ['viewer', 'operator', 'admin'].includes(access['mcp'] as string) }) - .toEqual({ input: rawConfig, mcpValid: true }); + expect({ input: rawConfig, agentValid: access['agent'] === false || ['viewer', 'operator', 'admin'].includes(access['agent'] as string) }) + .toEqual({ input: rawConfig, agentValid: true }); } @@ -486,6 +487,115 @@ describe('version: state', () => { }); + /** + * v3 renames `access.mcp` to `access.agent`. The channel stopped + * naming the transport and started naming the caller, so a config + * that granted an MCP client `operator` now grants any agent + * `operator` — including one shelling out to the CLI. The stored + * value must survive verbatim: silently downgrading a deliberate + * grant would look like the fix breaking someone's setup, and + * silently upgrading one would re-open the hole. + */ + describe('v3: access.mcp renamed to access.agent', () => { + + function migrateFromV2(access: unknown): Record { + + const migrated = migrateState({ + schemaVersion: 2, + configs: { prod: { name: 'prod', access } }, + }); + + const configs = migrated['configs'] as Record>; + + return configs['prod']!['access'] as Record; + + } + + it('should carry an explicit role over verbatim', () => { + + expect(migrateFromV2({ user: 'operator', mcp: 'operator' })) + .toEqual({ user: 'operator', agent: 'operator' }); + + }); + + it('should carry mcp:false over as agent:false', () => { + + // Invisibility is stricter than any role — losing it would + // expose a config its owner deliberately hid. + expect(migrateFromV2({ user: 'admin', mcp: false })) + .toEqual({ user: 'admin', agent: false }); + + }); + + it('should not leave the old key behind', () => { + + expect(migrateFromV2({ user: 'admin', mcp: 'viewer' })).not.toHaveProperty('mcp'); + + }); + + it('should leave a config already on the new key alone', () => { + + expect(migrateFromV2({ user: 'operator', agent: 'admin' })) + .toEqual({ user: 'operator', agent: 'admin' }); + + }); + + it('should keep failing closed on a shape it cannot read', () => { + + // Same one-directional rule v2 established: an unrecognised + // stored access may only make a config more restrictive. + expect(migrateFromV2({ user: 'admin', mcp: 'superuser' })) + .toEqual({ user: 'admin', agent: 'viewer' }); + + expect(migrateFromV2({})).toEqual({ user: 'viewer', agent: 'viewer' }); + + }); + + it('should be a no-op for state coming up through v2, which already emits the new key', () => { + + const migrated = migrateState({ + schemaVersion: 1, + configs: { prod: { name: 'prod', protected: true } }, + }); + + const configs = migrated['configs'] as Record>; + + expect(configs['prod']!['access']).toEqual({ user: 'operator', agent: 'viewer' }); + + }); + + it('should reverse the rename on the way down', () => { + + const reverted = v3.down!({ + schemaVersion: 3, + configs: { + prod: { name: 'prod', access: { user: 'operator', agent: 'admin' } }, + hidden: { name: 'hidden', access: { user: 'admin', agent: false } }, + }, + }); + + expect(reverted['configs']).toEqual({ + prod: { name: 'prod', access: { user: 'operator', mcp: 'admin' } }, + hidden: { name: 'hidden', access: { user: 'admin', mcp: false } }, + }); + + }); + + it('should round-trip a config through down and back up', () => { + + const original = { user: 'operator', agent: false }; + + const down = v3.down!({ configs: { prod: { name: 'prod', access: original } } }); + const up = v3.up(down); + + const configs = up['configs'] as Record>; + + expect(configs['prod']!['access']).toEqual(original); + + }); + + }); + }); describe('createEmptyVersionedState', () => { diff --git a/tests/core/version/types.test.ts b/tests/core/version/types.test.ts index 3fa65031..353d854a 100644 --- a/tests/core/version/types.test.ts +++ b/tests/core/version/types.test.ts @@ -21,7 +21,7 @@ describe('version: types', () => { it('should have state version', () => { - expect(CURRENT_VERSIONS.state).toBe(2); + expect(CURRENT_VERSIONS.state).toBe(3); }); diff --git a/tests/integration/change/mysql-lifecycle.test.ts b/tests/integration/change/mysql-lifecycle.test.ts index 16fb67f6..f3c9beff 100644 --- a/tests/integration/change/mysql-lifecycle.test.ts +++ b/tests/integration/change/mysql-lifecycle.test.ts @@ -123,7 +123,7 @@ describe('change: lifecycle on live mysql', () => { projectRoot: tempDir, changesDir, sqlDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'mysql', }; diff --git a/tests/integration/change/postgres-transaction.test.ts b/tests/integration/change/postgres-transaction.test.ts index 61fe347e..0dcd8c14 100644 --- a/tests/integration/change/postgres-transaction.test.ts +++ b/tests/integration/change/postgres-transaction.test.ts @@ -120,7 +120,7 @@ describe('integration: postgres change transaction', () => { projectRoot: tempDir, changesDir, sqlDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'postgres', }; diff --git a/tests/integration/connection/mssql-sni.test.ts b/tests/integration/connection/mssql-sni.test.ts new file mode 100644 index 00000000..76bcb66c --- /dev/null +++ b/tests/integration/connection/mssql-sni.test.ts @@ -0,0 +1,99 @@ +/** + * Integration test: connecting to MSSQL by IP address. + * + * The unit tests assert the option shape; this asserts the shape actually + * works on the wire. Connecting by IP with `encrypt: true` used to fail + * outright — tedious forwarded the IP as the TLS SNI ServerName and Node + * refused it (RFC 6066). The container is reachable at both `localhost` and + * `127.0.0.1`, so the hostname case is a control for the IP case. + * + * Requires the docker-compose.test.yml MSSQL container on port 11433. + * Skips with a clear message when the container is unreachable. + */ +import { describe, it, expect, beforeAll } from 'bun:test'; +import { sql } from 'kysely'; + +import { attempt } from '@logosdx/utils'; + +import { createMssqlConnection, MssqlTlsServerNameError } from '../../../src/core/connection/dialects/mssql.js'; +import type { ConnectionConfig } from '../../../src/core/connection/types.js'; + +import { TEST_CONNECTIONS, skipIfNoContainer } from '../../utils/db.js'; + + +/** + * The container's own connection settings with `master` as the target, so the + * test depends on nothing but a reachable server. + */ +function mssqlAt(host: string, overrides: Partial = {}): ConnectionConfig { + + return { + ...TEST_CONNECTIONS.mssql, + host, + database: 'master', + ...overrides, + }; + +} + +/** + * Open a connection, run a trivial query, and always close the pool. + */ +async function querySelectOne(config: ConnectionConfig): Promise { + + const conn = await createMssqlConnection(config); + + const [rows, err] = await attempt(async () => { + + const result = await sql<{ v: number }>`SELECT 1 AS v`.execute(conn.db); + + return result.rows; + + }); + + await conn.destroy(); + + if (err) { + + throw err; + + } + + return rows![0]!.v; + +} + + +describe('connection/dialects/mssql: IP connections', () => { + + beforeAll(async () => { + + await skipIfNoContainer('mssql'); + + }); + + it('should connect over an encrypted channel by hostname', async () => { + + expect(await querySelectOne(mssqlAt('localhost'))).toBe(1); + + }); + + it('should connect over an encrypted channel by IP address', async () => { + + expect(await querySelectOne(mssqlAt('127.0.0.1'))).toBe(1); + + }); + + it('should refuse an IP connection that asks for certificate validation', async () => { + + // Refusing is the point: silently dropping to `encrypt: false` would + // trade a failed connection for an unencrypted one. + const [result, err] = await attempt(() => createMssqlConnection(mssqlAt('127.0.0.1', { ssl: true }))); + + expect(result).toBeNull(); + expect(err).toBeInstanceOf(MssqlTlsServerNameError); + expect(err?.message).toContain('tlsServerName'); + + }); + +}); diff --git a/tests/integration/runner/mssql-batches.test.ts b/tests/integration/runner/mssql-batches.test.ts index 0ac7b83c..c30c14cc 100644 --- a/tests/integration/runner/mssql-batches.test.ts +++ b/tests/integration/runner/mssql-batches.test.ts @@ -188,7 +188,7 @@ describe('integration: mssql runner GO batch splitter', () => { configName: 'integration-test', identity: { name: 'test', email: 'test@example.com', source: 'config' }, projectRoot: tempDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'mssql', }; @@ -241,7 +241,7 @@ describe('integration: mssql runner GO batch splitter', () => { configName: 'integration-test', identity: { name: 'test', email: 'test@example.com', source: 'config' }, projectRoot: tempDir, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, channel: 'user', dialect: 'mssql', }; diff --git a/tests/integration/sdk/db-reset.test.ts b/tests/integration/sdk/db-reset.test.ts index e5a4cd89..808d9314 100644 --- a/tests/integration/sdk/db-reset.test.ts +++ b/tests/integration/sdk/db-reset.test.ts @@ -48,7 +48,7 @@ describe('integration: sdk DbNamespace reset vs preserveTables', () => { name: 'test', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'postgres', database: 'noorm_test' }, }, settings: { teardown: { preserveTables: ['ev_keep'] } }, diff --git a/tests/preload.ts b/tests/preload.ts index 808a7b9e..bc91c1cd 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -10,6 +10,7 @@ import { join } from 'node:path'; import { sql } from 'kysely'; import { createConnection, testConnection } from '../src/core/connection/factory.js'; +import { AGENT_HARNESSES } from '../src/core/policy/index.js'; import { TEST_CONNECTIONS } from './utils/db.js'; /** @@ -59,6 +60,24 @@ async function ensureMssqlDatabase(): Promise { // === Setup === +// Pin the policy channel for the whole suite, including the CLI subprocesses +// tests/cli spawns (they inherit this env). Without it, `resolveChannel()` +// sniffs the agent-harness markers of whoever ran `bun test` — so the same +// suite would deny vault:propagate for a developer inside Claude Code and +// allow it on CI, which is a coin flip, not a test. +// +// Both halves are needed: several CLI fixtures build a subprocess env that +// strips every NOORM_* var, so pinning NOORM_CHANNEL alone would not survive +// into them — the inherited harness markers have to go too. Tests that want +// the agent channel opt in explicitly, in the env they hand to the spawn. +process.env['NOORM_CHANNEL'] ??= 'user'; + +for (const harness of AGENT_HARNESSES) { + + for (const marker of harness.markers) delete process.env[marker]; + +} + const tmpDir = join(process.cwd(), 'tmp'); if (!existsSync(tmpDir)) { diff --git a/tests/sdk/bundle-smoke.test.ts b/tests/sdk/bundle-smoke.test.ts index f8bf83ab..bd3272b2 100644 --- a/tests/sdk/bundle-smoke.test.ts +++ b/tests/sdk/bundle-smoke.test.ts @@ -188,7 +188,7 @@ describe.skipIf(!bundleExists)('sdk bundle: Context instantiation', () => { name: 'smoke-test', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'postgres', database: 'smokedb' }, }, {}, @@ -325,7 +325,7 @@ describe.skipIf(!bundleExists)('sdk bundle: dialect chunks', () => { name: `${dialect}-test`, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect, database: `${dialect}db` }, }, {}, diff --git a/tests/sdk/context.test.ts b/tests/sdk/context.test.ts index 872e127e..259c1509 100644 --- a/tests/sdk/context.test.ts +++ b/tests/sdk/context.test.ts @@ -31,7 +31,7 @@ function createMockConfig(dialect: Config['connection']['dialect'] = 'postgres') name: 'test', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect, database: 'testdb' }, }; diff --git a/tests/sdk/db-namespace.test.ts b/tests/sdk/db-namespace.test.ts index 47bd5505..5e22586e 100644 --- a/tests/sdk/db-namespace.test.ts +++ b/tests/sdk/db-namespace.test.ts @@ -84,7 +84,7 @@ function createMockConfig(): Config { name: 'test', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'postgres', database: 'testdb' }, }; diff --git a/tests/sdk/destructive-ops.test.ts b/tests/sdk/destructive-ops.test.ts index ca3f3160..2c1ac38f 100644 --- a/tests/sdk/destructive-ops.test.ts +++ b/tests/sdk/destructive-ops.test.ts @@ -35,13 +35,13 @@ import type { Change } from '../../src/core/change/index.js'; // ───────────────────────────────────────────────────────────── /** Mirrors the legacy `protected: true` migration mapping. */ -const OPERATOR_ACCESS: ConfigAccess = { user: 'operator', mcp: 'viewer' }; +const OPERATOR_ACCESS: ConfigAccess = { user: 'operator', agent: 'viewer' }; /** Denies everything but explore/sql:read on the user channel. */ -const VIEWER_ACCESS: ConfigAccess = { user: 'viewer', mcp: false }; +const VIEWER_ACCESS: ConfigAccess = { user: 'viewer', agent: false }; /** Mirrors the legacy `protected: false` migration mapping. */ -const ADMIN_ACCESS: ConfigAccess = { user: 'admin', mcp: 'admin' }; +const ADMIN_ACCESS: ConfigAccess = { user: 'admin', agent: 'admin' }; function makeConfig(access: ConfigAccess): Config { diff --git a/tests/sdk/guards.test.ts b/tests/sdk/guards.test.ts index 0920fbd6..85920120 100644 --- a/tests/sdk/guards.test.ts +++ b/tests/sdk/guards.test.ts @@ -10,9 +10,9 @@ import type { Config } from '../../src/core/config/types.js'; import type { ConfigAccess } from '../../src/core/policy/index.js'; import type { CreateContextOptions } from '../../src/sdk/types.js'; -const ADMIN_ACCESS: ConfigAccess = { user: 'admin', mcp: 'admin' }; -const OPERATOR_ACCESS: ConfigAccess = { user: 'operator', mcp: 'viewer' }; -const VIEWER_ACCESS: ConfigAccess = { user: 'viewer', mcp: false }; +const ADMIN_ACCESS: ConfigAccess = { user: 'admin', agent: 'admin' }; +const OPERATOR_ACCESS: ConfigAccess = { user: 'operator', agent: 'viewer' }; +const VIEWER_ACCESS: ConfigAccess = { user: 'viewer', agent: false }; function makeConfig(access: ConfigAccess, overrides: Partial = {}): Config { @@ -283,11 +283,11 @@ describe('checkProtectedConfig', () => { it('defaults the channel to user when options.channel is omitted', () => { // change:revert is a confirm cell for operator — allowed on `user` - // once NOORM_YES skips the prompt, but OPERATOR_ACCESS.mcp is - // 'viewer', a deny cell, so the `mcp` channel is always blocked. - // (VIEWER_ACCESS.mcp === false would deny on both channels + // once NOORM_YES skips the prompt, but OPERATOR_ACCESS.agent is + // 'viewer', a deny cell, so the `agent` channel is always blocked. + // (VIEWER_ACCESS.agent === false would deny on both channels // regardless of the default, proving nothing about which one ran.) - // If the default silently fell through to `mcp` this would throw, + // If the default silently fell through to `agent` this would throw, // so this pins the CreateContextOptions.channel default to `user`. process.env['NOORM_YES'] = '1'; @@ -301,14 +301,14 @@ describe('checkProtectedConfig', () => { }); - it('respects an explicit mcp channel, where NOORM_YES has no effect on confirm cells', () => { + it('respects an explicit agent channel, where NOORM_YES has no effect on confirm cells', () => { process.env['NOORM_YES'] = '1'; const config = makeConfig(ADMIN_ACCESS); expect( - () => checkProtectedConfig(config, { channel: 'mcp' }, 'db:destroy', 'drop'), + () => checkProtectedConfig(config, { channel: 'agent' }, 'db:destroy', 'drop'), ).toThrow(ProtectedConfigError); delete process.env['NOORM_YES']; @@ -333,27 +333,27 @@ describe('checkProtectedConfig', () => { }); - it('denies db:reset on the mcp channel even when options.yes is true (operator config, mcp resolves to viewer -> deny)', () => { + it('denies db:reset on the agent channel even when options.yes is true (operator config, agent resolves to viewer -> deny)', () => { const config = makeConfig(OPERATOR_ACCESS, { name: 'prod' }); - expect(() => checkProtectedConfig(config, { channel: 'mcp', yes: true }, 'db:reset', 'truncate')).toThrow(ProtectedConfigError); + expect(() => checkProtectedConfig(config, { channel: 'agent', yes: true }, 'db:reset', 'truncate')).toThrow(ProtectedConfigError); }); - it('denies db:reset on the mcp channel via the confirm-to-deny collapse, even when options.yes is true (mcp:operator hits a confirm cell, not a plain deny)', () => { + it('denies db:reset on the agent channel via the confirm-to-deny collapse, even when options.yes is true (agent:operator hits a confirm cell, not a plain deny)', () => { - // mcp:'viewer' above hits db:reset's deny cell directly and never - // reaches checkPolicy's mcp-collapse branch (check.ts ~68-75). Here - // mcp:'operator' resolves db:reset to the same 'confirm' cell as the - // user channel, so this only denies if the mcp channel collapses + // agent:'viewer' above hits db:reset's deny cell directly and never + // reaches checkPolicy's agent-collapse branch (check.ts ~68-75). Here + // agent:'operator' resolves db:reset to the same 'confirm' cell as the + // user channel, so this only denies if the agent channel collapses // confirm-to-deny before options.yes is ever consulted -- the // invariant spec C2 pins. If that collapse branch were removed, // options.yes: true would satisfy requiresConfirmation and this // would NOT throw. - const config = makeConfig({ user: 'operator', mcp: 'operator' }, { name: 'prod' }); + const config = makeConfig({ user: 'operator', agent: 'operator' }, { name: 'prod' }); - expect(() => checkProtectedConfig(config, { channel: 'mcp', yes: true }, 'db:reset', 'truncate')).toThrow(ProtectedConfigError); + expect(() => checkProtectedConfig(config, { channel: 'agent', yes: true }, 'db:reset', 'truncate')).toThrow(ProtectedConfigError); }); diff --git a/tests/sdk/impersonate/impersonate.test.ts b/tests/sdk/impersonate/impersonate.test.ts index b1f7fe6b..4c9c8695 100644 --- a/tests/sdk/impersonate/impersonate.test.ts +++ b/tests/sdk/impersonate/impersonate.test.ts @@ -30,7 +30,7 @@ function createMockConfig(dialect: Config['connection']['dialect'] = 'postgres') name: 'test', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect, database: 'testdb' }, }; diff --git a/tests/sdk/noorm-ops.test.ts b/tests/sdk/noorm-ops.test.ts index e0634dca..e4af3b08 100644 --- a/tests/sdk/noorm-ops.test.ts +++ b/tests/sdk/noorm-ops.test.ts @@ -35,7 +35,7 @@ function createMockConfig(dialect: Config['connection']['dialect'] = 'postgres') name: 'test', type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect, database: 'testdb' }, }; diff --git a/tests/sdk/render-vault-tier.test.ts b/tests/sdk/render-vault-tier.test.ts index f58219b4..82a876ff 100644 --- a/tests/sdk/render-vault-tier.test.ts +++ b/tests/sdk/render-vault-tier.test.ts @@ -30,7 +30,7 @@ function makeConfig(overrides: Partial = {}): Config { name: 'dev', type: 'local', isTest: false, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'postgres', database: 'testdb' }, ...overrides, }; diff --git a/tests/sdk/run-build-filtering.test.ts b/tests/sdk/run-build-filtering.test.ts index a45115f1..3afe02f3 100644 --- a/tests/sdk/run-build-filtering.test.ts +++ b/tests/sdk/run-build-filtering.test.ts @@ -134,7 +134,7 @@ function makeConfig(overrides: Partial = {}): Config { name: 'dev', type: 'local', isTest: false, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'postgres', database: 'testdb' }, ...overrides, }; diff --git a/tests/sdk/templates-policy.test.ts b/tests/sdk/templates-policy.test.ts index b0bc7264..10ac0434 100644 --- a/tests/sdk/templates-policy.test.ts +++ b/tests/sdk/templates-policy.test.ts @@ -31,7 +31,7 @@ function makeState(projectRoot: string, userRole: Role): ContextState { name: 'dev', type: 'local', isTest: false, - access: { user: userRole, mcp: userRole }, + access: { user: userRole, agent: userRole }, connection: { dialect: 'postgres', database: 'testdb' }, }; diff --git a/tests/sdk/transfer-dt-namespace.test.ts b/tests/sdk/transfer-dt-namespace.test.ts index dbd9a9cc..59c05207 100644 --- a/tests/sdk/transfer-dt-namespace.test.ts +++ b/tests/sdk/transfer-dt-namespace.test.ts @@ -30,7 +30,7 @@ function makeConfig(dialect: Config['connection']['dialect']): Config { name: 'dev', type: 'local', isTest: false, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect, database: 'testdb' }, }; diff --git a/tests/sdk/vault-namespace.test.ts b/tests/sdk/vault-namespace.test.ts index fc959cde..c449156c 100644 --- a/tests/sdk/vault-namespace.test.ts +++ b/tests/sdk/vault-namespace.test.ts @@ -95,7 +95,7 @@ function makeConfig(name: string, connectionOverrides: Record = name, type: 'local', isTest: false, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', ...connectionOverrides }, }; diff --git a/tests/utils/db.ts b/tests/utils/db.ts index d01190e6..c258fffb 100644 --- a/tests/utils/db.ts +++ b/tests/utils/db.ts @@ -919,7 +919,7 @@ export function makeTestConfig(name: string, connection: ConnectionConfig): Conf name, type: 'local', isTest: true, - access: { user: 'admin', mcp: 'admin' }, + access: { user: 'admin', agent: 'admin' }, connection, }; From 0cd96e52157d1a4ed9008bcdff3c7d3874c2c789 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Thu, 30 Jul 2026 00:33:03 -0400 Subject: [PATCH 103/105] test(cli): report what the settings layer resolved when the logger assertions fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/cli/cli-logger-settings.test.ts | 40 +++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/tests/cli/cli-logger-settings.test.ts b/tests/cli/cli-logger-settings.test.ts index 93242ccd..8a83f727 100644 --- a/tests/cli/cli-logger-settings.test.ts +++ b/tests/cli/cli-logger-settings.test.ts @@ -3,8 +3,10 @@ import { mkdir, rm, writeFile, stat, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { attempt } from '@logosdx/utils'; + import { createCliLogger } from '../../src/cli/_utils.js'; -import { resetSettingsManager } from '../../src/core/settings/index.js'; +import { getSettingsManager, resetSettingsManager } from '../../src/core/settings/index.js'; import { resetLogger } from '../../src/core/logger/index.js'; /** @@ -27,6 +29,34 @@ describe('cli: createCliLogger settings', () => { const exists = async (path: string) => stat(path).then(() => true, () => false); + /** + * Describe what the settings layer actually resolved. + * + * These assertions all reduce to "did `createCliLogger` read settings.yml", + * and a bare `expected true, received false` cannot distinguish a missing + * file from a stale singleton from a load that threw. Every one of those + * has looked identical while chasing a failure that reproduces only on CI. + */ + const diagnose = async () => { + + const file = join(projectRoot, '.noorm', 'settings.yml'); + const onDisk = await exists(file); + const raw = onDisk ? await readFile(file, 'utf-8') : ''; + + const manager = getSettingsManager(projectRoot); + const [, loadErr] = await attempt(() => manager.load()); + + return [ + `projectRoot=${projectRoot}`, + `settings.yml on disk=${onDisk}`, + `raw=${JSON.stringify(raw)}`, + `manager.settingsFilePath=${manager.settingsFilePath}`, + `load error=${loadErr ? loadErr.message : 'none'}`, + `resolved logging=${JSON.stringify(manager.settings?.logging ?? null)}`, + ].join(' | '); + + }; + beforeEach(async () => { // The settings manager is a process-wide singleton keyed on the first @@ -63,7 +93,7 @@ describe('cli: createCliLogger settings', () => { await logger.flush(); await logger.stop(); - expect(await exists(join(projectRoot, '.noorm', 'state', 'noorm.log'))).toBe(false); + expect(await exists(join(projectRoot, '.noorm', 'state', 'noorm.log')), await diagnose()).toBe(false); }); @@ -96,7 +126,7 @@ describe('cli: createCliLogger settings', () => { const custom = join(projectRoot, '.noorm', 'state', 'custom.log'); - expect(await exists(custom)).toBe(true); + expect(await exists(custom), await diagnose()).toBe(true); expect(await readFile(custom, 'utf-8')).toContain('custom path entry'); expect(await exists(join(projectRoot, '.noorm', 'state', 'noorm.log'))).toBe(false); @@ -109,7 +139,7 @@ describe('cli: createCliLogger settings', () => { const logger = await createCliLogger(projectRoot, false); - expect(logger.level).toBe('error'); + expect(logger.level, await diagnose()).toBe('error'); }); @@ -129,7 +159,7 @@ describe('cli: createCliLogger settings', () => { const rotated = (await readdir(join(projectRoot, '.noorm', 'state'))) .filter((f) => f !== 'noorm.log'); - expect(rotated.length).toBe(1); + expect(rotated.length, await diagnose()).toBe(1); }); From 0f2e7c8136380ca982b3441cb6f29df5257cf475 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Thu, 30 Jul 2026 01:51:28 -0400 Subject: [PATCH 104/105] ci: isolate the CLI logger settings tests in their own process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 18 +++++++++++++++++- CLAUDE.md | 16 +++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33a41133..50f58e3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,23 @@ jobs: env: CI: 'false' FORCE_COLOR: 'true' - run: bun test --serial tests/cli + run: | + bun test --serial $(find tests/cli \( -name '*.test.ts' -o -name '*.test.tsx' \) \ + ! -name 'cli-logger-settings.test.ts' | sort | tr '\n' ' ') + # Isolated for the same reason tests/core/transfer is: bun's + # `mock.module` registry is process-global and re-registering does + # not restore, so an `afterAll` cleanup is a no-op. Two init screen + # tests replace the `SettingsManager` class; `getSettingsManager` + # then constructs a mock instance, and `createCliLogger` reads + # `settings: {}` instead of settings.yml. Load order decides who + # wins — root files first on macOS, subdirectories first on Linux — + # so this passed locally and failed only here. A separate process + # is the only reliable isolation. + - name: Run CLI logger settings tests (isolated) + env: + CI: 'false' + FORCE_COLOR: 'true' + run: bun test --serial tests/cli/cli-logger-settings.test.ts - name: Run integration tests env: CI: 'false' diff --git a/CLAUDE.md b/CLAUDE.md index fa093989..86c63f57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,23 +23,29 @@ bun run typecheck # Type check ## Running Tests -A single `bun test` (whole suite, one process) does **not** reflect CI. CI deliberately splits the suite into four independent `bun test --serial` invocations, each in a fresh process — see `.github/workflows/ci.yml:122-142`: +A single `bun test` (whole suite, one process) does **not** reflect CI. CI deliberately splits the suite into five independent `bun test --serial` invocations, each in a fresh process — see `.github/workflows/ci.yml`: 1. `tests/utils` + `tests/core` (excluding `tests/core/transfer`) + `tests/sdk` 2. `tests/core/transfer` (isolated — comment cites a runner-image regression) -3. `tests/cli` -4. `tests/integration` +3. `tests/cli` (excluding `cli-logger-settings.test.ts`) +4. `tests/cli/cli-logger-settings.test.ts` (isolated — see below) +5. `tests/integration` The split exists because cross-file pollution (module-scope `process.env` snapshots, shared DB state, singletons left dirty between files) produces hundreds of false failures in the unified run while CI is green. When triaging a "broken" test, **run the file in isolation first**; if it passes alone, the failure is contamination, not a regression. -Known contamination source: `src/core/config/index.ts:34` calls `makeNestedConfig(process.env, …)` at module scope, snapshotting env at first import. The same bug was fixed for `SettingsManager` in commit `ec9ccc2` (`fix(settings): move makeNestedConfig to call-time`); the config module hasn't been moved yet. +**`mock.module` never restores.** Bun's mock registry is process-global, and re-registering the real module does *not* undo a mock — measured directly. Every `afterAll(() => mock.module(..., () => actualX))` in this repo is therefore a no-op, despite the "restore mocked modules to prevent pollution" comments next to them. A file that mocks a module poisons every file loaded after it for the life of the process. + +That is what isolates group 4. Two init-screen tests replace the `SettingsManager` *class*; `getSettingsManager` then constructs a mock instance, so `createCliLogger` reads `settings: {}` instead of `settings.yml`. Which file wins depends on load order — root files before subdirectories on macOS, the reverse on Linux — so it passed locally and failed only on CI. When mocking a module other code also depends on, assume the mock is permanent. + +The previously documented contamination source — `src/core/config/index.ts:34` calling `makeNestedConfig(process.env, …)` at module scope — **does not reproduce**: the call passes `memoizeOpts: false`, so lookups re-read `process.env` rather than snapshotting at import. To reproduce CI locally, run the four invocations separately: ```bash bun test --serial $(find tests/utils tests/core tests/sdk -name '*.test.ts' | grep -v tests/core/transfer | sort | tr '\n' ' ') bun test --serial tests/core/transfer -bun test --serial tests/cli +bun test --serial $(find tests/cli \( -name '*.test.ts' -o -name '*.test.tsx' \) ! -name 'cli-logger-settings.test.ts' | sort | tr '\n' ' ') +bun test --serial tests/cli/cli-logger-settings.test.ts bun test --serial tests/integration ``` From b207ba6b254a550cc4c21701507ee26e01d1bee6 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Thu, 30 Jul 2026 02:06:37 -0400 Subject: [PATCH 105/105] 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. --- .github/workflows/ci.yml | 12 ++++++--- tests/fixtures/ci/failure/sql/002_views.sql | 30 ++++++++++++--------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50f58e3f..7f6713e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -440,7 +440,11 @@ jobs: node dist/cli/index.js run build -H echo "Success: Valid SQL built successfully" - # Test: Invalid SQL should fail (exit 2) + # Test: invalid SQL must not report success (exit 3 = partial). + # The fixture has two files; the first applies and the second is a + # parse error, so this is a partial build, not a total one. Exit 2 + # is now reserved for usage errors — see docs/headless.md for the + # full scheme (0 success, 1 total failure, 2 usage, 3 partial). - name: Test invalid SQL build (expect failure) env: NOORM_CONNECTION_DIALECT: sqlite @@ -453,9 +457,9 @@ jobs: node dist/cli/index.js run build -H EXIT_CODE=$? set -e - if [ $EXIT_CODE -eq 2 ]; then - echo "Success: CLI correctly returned exit code 2 for failed build" + if [ $EXIT_CODE -eq 3 ]; then + echo "Success: CLI correctly returned exit code 3 (partial) for a build with a failing file" else - echo "Failure: Expected exit code 2, got $EXIT_CODE" + echo "Failure: Expected exit code 3, got $EXIT_CODE" exit 1 fi diff --git a/tests/fixtures/ci/failure/sql/002_views.sql b/tests/fixtures/ci/failure/sql/002_views.sql index abfca6ff..f0a10218 100644 --- a/tests/fixtures/ci/failure/sql/002_views.sql +++ b/tests/fixtures/ci/failure/sql/002_views.sql @@ -1,12 +1,18 @@ --- SQLite Views with INTENTIONAL SYNTAX ERROR for CI failure testing --- This file is designed to cause a parse error when executed - -DROP VIEW IF EXISTS v_active_users; -CREATE VIEW v_active_users AS -SELECT - id, - email, - username - display_name -- INTENTIONAL ERROR: missing comma above -FROM users -WHERE deleted_at IS NULL; +-- SQLite view with an INTENTIONAL SYNTAX ERROR, for the CI failure-path check. +-- +-- `SELECT FROM` has no column list, so SQLite rejects it at prepare time: +-- near "FROM": syntax error +-- +-- The previous version claimed its error was the missing comma in +-- `username display_name`. That is valid SQL — an implicit column alias — and +-- SQLite accepts it. It also selected a `deleted_at` column that does not +-- exist, which SQLite accepts too, since it does not resolve a view's columns +-- until the view is queried. Both were verified against bun:sqlite. +-- +-- So the file never contained invalid SQL. It failed only because it held two +-- statements and SQLite's prepare rejects the trailing one. Once the runner +-- learned to split statements properly, both ran cleanly, the build passed, +-- and the CI step that asserts a failure began failing instead. +-- +-- Keep this one statement, and keep the error a real parse error. +CREATE VIEW v_active_users AS SELECT FROM users