From 440e503acd28fdcfe535c0099b9fffc947dca1be Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 23:01:36 -0400 Subject: [PATCH 1/6] 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. --- src/core/policy/harness.ts | 118 +++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 src/core/policy/harness.ts 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; + +} From 1611eb4963e1ea3881cee3028702bcb05a3a7d64 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 23:12:34 -0400 Subject: [PATCH 2/6] feat(identity): stamp agent provenance on audit rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/identity/provenance.ts | 74 ++++++++++++++++++ src/core/shared/operation-id.ts | 27 ++++++- tests/core/identity/provenance.test.ts | 101 +++++++++++++++++++++++++ tests/core/shared/operation-id.test.ts | 86 ++++++++++++++++++++- 4 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 src/core/identity/provenance.ts create mode 100644 tests/core/identity/provenance.test.ts 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/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/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/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({ From 8dda999e2002b2ec434383943280be5662063018 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 23:12:41 -0400 Subject: [PATCH 3/6] 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. --- src/cli/info.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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)}`); From 0a3cf2005433d3faec5ca3299d2c9775b50f022f Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 23:12:41 -0400 Subject: [PATCH 4/6] chore(changeset): describe agent provenance in audit rows --- .changeset/agent-provenance-executed-by.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/agent-provenance-executed-by.md 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. From 071bac9f1a7a5cc809c0202bf4cabb98de0438fd Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 23:32:14 -0400 Subject: [PATCH 5/6] 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. --- .changeset/mssql-connect-by-ip.md | 11 + src/core/config/schema.ts | 2 + src/core/connection/dialects/mssql.ts | 129 +++++++++++- src/core/connection/types.ts | 11 + tests/core/connection/dialects/mssql.test.ts | 196 ++++++++++++++++++ .../integration/connection/mssql-sni.test.ts | 99 +++++++++ 6 files changed, 443 insertions(+), 5 deletions(-) create mode 100644 .changeset/mssql-connect-by-ip.md create mode 100644 tests/core/connection/dialects/mssql.test.ts create mode 100644 tests/integration/connection/mssql-sni.test.ts 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/src/core/config/schema.ts b/src/core/config/schema.ts index 7c6740f9..14370f8f 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -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(), }); /** 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/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/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'); + + }); + +}); From 4c615f2c790554019674f57be67ab6e8b1cb71eb Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 29 Jul 2026 23:35:44 -0400 Subject: [PATCH 6/6] feat(policy)!: resolve the access channel from who is driving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 +++ 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/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 | 4 +- src/core/config/types.ts | 2 +- src/core/db/policy.ts | 2 +- src/core/debug/operations.ts | 2 +- src/core/policy/channel.ts | 65 ++++ src/core/policy/check.ts | 36 +-- 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/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/manager.test.ts | 2 +- tests/core/db/operations.test.ts | 6 +- tests/core/debug/operations.test.ts | 16 +- 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/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/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 +- 162 files changed, 1581 insertions(+), 573 deletions(-) create mode 100644 .changeset/agent-channel-not-transport.md create mode 100644 src/core/policy/channel.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/policy/agent-escalation.test.ts create mode 100644 tests/core/policy/channel.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/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/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..88fdfddb 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)]), }); /** @@ -320,7 +320,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/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/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/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/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/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/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/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/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, };