diff --git a/packages/context/src/ingest/isolated-diff/git-patch.test.ts b/packages/context/src/ingest/isolated-diff/git-patch.test.ts index fb92016f..2a48ce9b 100644 --- a/packages/context/src/ingest/isolated-diff/git-patch.test.ts +++ b/packages/context/src/ingest/isolated-diff/git-patch.test.ts @@ -50,6 +50,22 @@ describe('isolated diff patch contract', () => { ).toThrow(/slDisallowed WorkUnit lookml-mismatch touched semantic-layer\/c1\/orders.yaml/); }); + it('rejects semantic-layer paths outside allowed target connections', () => { + const patch = + 'diff --git a/semantic-layer/finance/orders.yaml b/semantic-layer/finance/orders.yaml\nindex 1..2 100644\n'; + + expect(() => + assertPatchAllowedForWorkUnit({ + unitKey: 'wu-finance', + patch, + slDisallowed: false, + allowedTargetConnectionIds: new Set(['warehouse']), + }), + ).toThrow( + /semantic-layer target connection not allowed: semantic-layer\/finance\/orders.yaml \(finance\); allowed: warehouse/, + ); + }); + it('rejects executable and binary changes under known text artifact roots', () => { expect(textArtifactRoots).toEqual(['wiki/', 'semantic-layer/']); diff --git a/packages/context/src/ingest/isolated-diff/git-patch.ts b/packages/context/src/ingest/isolated-diff/git-patch.ts index 451d448a..ee7f0020 100644 --- a/packages/context/src/ingest/isolated-diff/git-patch.ts +++ b/packages/context/src/ingest/isolated-diff/git-patch.ts @@ -1,3 +1,5 @@ +import { assertSemanticLayerTargetPathsAllowed } from '../semantic-layer-target-policy.js'; + export const textArtifactRoots = ['wiki/', 'semantic-layer/'] as const; export interface PatchTouchedPath { @@ -12,6 +14,7 @@ export interface PatchPolicyInput { unitKey: string; patch: string; slDisallowed: boolean; + allowedTargetConnectionIds?: ReadonlySet; } function stripPrefix(path: string): string { @@ -74,6 +77,12 @@ export function parsePatchTouchedPaths(patch: string): PatchTouchedPath[] { export function assertPatchAllowedForWorkUnit(input: PatchPolicyInput): PatchTouchedPath[] { const touched = parsePatchTouchedPaths(input.patch); + if (input.allowedTargetConnectionIds) { + assertSemanticLayerTargetPathsAllowed({ + paths: touched.map((entry) => entry.path), + allowedConnectionIds: input.allowedTargetConnectionIds, + }); + } for (const entry of touched) { if (input.slDisallowed && entry.path.startsWith('semantic-layer/')) { throw new Error(`slDisallowed WorkUnit ${input.unitKey} touched ${entry.path}`); diff --git a/packages/context/src/ingest/semantic-layer-target-policy.test.ts b/packages/context/src/ingest/semantic-layer-target-policy.test.ts new file mode 100644 index 00000000..73d09dc0 --- /dev/null +++ b/packages/context/src/ingest/semantic-layer-target-policy.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + assertSemanticLayerTargetPathsAllowed, + findDisallowedSemanticLayerTargetPaths, + semanticLayerConnectionIdFromPath, +} from './semantic-layer-target-policy.js'; + +describe('semantic-layer target policy', () => { + it('extracts connection ids from semantic-layer paths', () => { + expect(semanticLayerConnectionIdFromPath('semantic-layer/warehouse/orders.yaml')).toBe('warehouse'); + expect(semanticLayerConnectionIdFromPath('a/semantic-layer/finance/orders.yaml')).toBe('finance'); + expect(semanticLayerConnectionIdFromPath('wiki/global/orders.md')).toBeNull(); + }); + + it('finds semantic-layer paths outside the allowed target connections', () => { + expect( + findDisallowedSemanticLayerTargetPaths({ + paths: [ + 'semantic-layer/warehouse/orders.yaml', + 'semantic-layer/finance/orders.yaml', + 'wiki/global/orders.md', + ], + allowedConnectionIds: new Set(['warehouse']), + }), + ).toEqual([{ path: 'semantic-layer/finance/orders.yaml', connectionId: 'finance' }]); + }); + + it('throws a deterministic error for unauthorized semantic-layer targets', () => { + expect(() => + assertSemanticLayerTargetPathsAllowed({ + paths: ['semantic-layer/finance/orders.yaml', 'semantic-layer/marketing/accounts.yaml'], + allowedConnectionIds: new Set(['warehouse']), + }), + ).toThrow( + /semantic-layer target connection not allowed: semantic-layer\/finance\/orders\.yaml \(finance\), semantic-layer\/marketing\/accounts\.yaml \(marketing\); allowed: warehouse/, + ); + }); +}); diff --git a/packages/context/src/ingest/semantic-layer-target-policy.ts b/packages/context/src/ingest/semantic-layer-target-policy.ts new file mode 100644 index 00000000..adf63b3b --- /dev/null +++ b/packages/context/src/ingest/semantic-layer-target-policy.ts @@ -0,0 +1,42 @@ +export interface SemanticLayerTargetPolicyInput { + paths: readonly string[]; + allowedConnectionIds: ReadonlySet; +} + +export interface SemanticLayerTargetPolicyViolation { + path: string; + connectionId: string; +} + +export function semanticLayerConnectionIdFromPath(path: string): string | null { + const normalized = path.replace(/^[ab]\//, ''); + const match = /^semantic-layer\/([^/]+)\//.exec(normalized); + return match?.[1] ?? null; +} + +export function findDisallowedSemanticLayerTargetPaths( + input: SemanticLayerTargetPolicyInput, +): SemanticLayerTargetPolicyViolation[] { + return input.paths + .map((path) => ({ path, connectionId: semanticLayerConnectionIdFromPath(path) })) + .filter((entry): entry is SemanticLayerTargetPolicyViolation => { + return entry.connectionId !== null && !input.allowedConnectionIds.has(entry.connectionId); + }) + .sort((left, right) => { + const byConnection = left.connectionId.localeCompare(right.connectionId); + return byConnection === 0 ? left.path.localeCompare(right.path) : byConnection; + }); +} + +export function assertSemanticLayerTargetPathsAllowed(input: SemanticLayerTargetPolicyInput): void { + const violations = findDisallowedSemanticLayerTargetPaths(input); + if (violations.length === 0) { + return; + } + const allowed = [...input.allowedConnectionIds].sort(); + throw new Error( + `semantic-layer target connection not allowed: ${violations + .map((violation) => `${violation.path} (${violation.connectionId})`) + .join(', ')}; allowed: ${allowed.length > 0 ? allowed.join(', ') : '(none)'}`, + ); +} diff --git a/packages/context/src/sl/tools/sl-edit-source.tool.test.ts b/packages/context/src/sl/tools/sl-edit-source.tool.test.ts index 49a8f757..75c753ef 100644 --- a/packages/context/src/sl/tools/sl-edit-source.tool.test.ts +++ b/packages/context/src/sl/tools/sl-edit-source.tool.test.ts @@ -99,6 +99,27 @@ describe('SlEditSourceTool — session gating', () => { ); }); + it('rejects session-scoped edits outside allowed target connections', async () => { + const { tool } = makeTool(); + const session = makeSession({ + allowedConnectionNames: new Set(['warehouse']), + }); + const context: ToolContext = { ...baseContext, session }; + + const result = await tool.call( + { + connectionId: 'finance', + sourceName: 'orders', + yaml_edits: [{ oldText: 'measures: []', newText: 'measures: []' }], + } as any, + context, + ); + + expect(result.structured.success).toBe(false); + expect(result.markdown).toContain('connectionId "finance" is outside this ingest session'); + expect(session.actions).toEqual([]); + }); + it('indexes normally when no session is present', async () => { const { tool, slSearchService } = makeTool(); const result = await tool.call( diff --git a/packages/context/src/sl/tools/sl-edit-source.tool.ts b/packages/context/src/sl/tools/sl-edit-source.tool.ts index 813072c0..f6669120 100644 --- a/packages/context/src/sl/tools/sl-edit-source.tool.ts +++ b/packages/context/src/sl/tools/sl-edit-source.tool.ts @@ -1,6 +1,12 @@ import YAML from 'yaml'; import { z } from 'zod'; -import { addTouchedSlSource, type ToolContext, type ToolOutput, validateActionRawPaths } from '../../tools/index.js'; +import { + addTouchedSlSource, + type ToolContext, + type ToolOutput, + validateActionRawPaths, + validateActionTargetConnection, +} from '../../tools/index.js'; import { applySqlEdits } from '../../tools/sql-edit-replacer.js'; import { normalizeSemanticLayerDescriptions } from '../description-normalization.js'; import type { SemanticLayerSource } from '../types.js'; @@ -79,6 +85,10 @@ If no source exists yet, use sl_write_source instead — this tool will reject t const semanticLayerService = context.session?.semanticLayerService ?? this.semanticLayerService; const skipIndex = context.session?.isWorktreeScoped === true; + const targetConnectionValidation = validateActionTargetConnection(context.session, connectionId); + if (!targetConnectionValidation.ok) { + return this.buildOutput(false, [targetConnectionValidation.error], sourceName); + } const rawPathValidation = validateActionRawPaths(context.session, input.rawPaths); if (!rawPathValidation.ok) { return this.buildOutput(false, [rawPathValidation.error], sourceName); diff --git a/packages/context/src/sl/tools/sl-write-source.tool.test.ts b/packages/context/src/sl/tools/sl-write-source.tool.test.ts index 6f9cdbc0..186028b8 100644 --- a/packages/context/src/sl/tools/sl-write-source.tool.test.ts +++ b/packages/context/src/sl/tools/sl-write-source.tool.test.ts @@ -133,6 +133,34 @@ describe('SlWriteSourceTool — session gating', () => { ); }); + it('rejects session-scoped writes outside allowed target connections', async () => { + const { tool } = makeTool(); + const session = makeSession({ + allowedConnectionNames: new Set(['warehouse']), + }); + const context: ToolContext = { ...baseContext, session }; + + const result = await tool.call( + { + connectionId: 'finance', + sourceName: 'finance_orders', + source: { + name: 'finance_orders', + table: 'public.orders', + grain: ['id'], + columns: [{ name: 'id', type: 'string' }], + measures: [], + joins: [], + } as any, + } as any, + context, + ); + + expect(result.structured.success).toBe(false); + expect(result.markdown).toContain('connectionId "finance" is outside this ingest session'); + expect(session.actions).toEqual([]); + }); + it('indexes normally when no session is present', async () => { const { tool, slSearchService } = makeTool(); const result = await tool.call( diff --git a/packages/context/src/sl/tools/sl-write-source.tool.ts b/packages/context/src/sl/tools/sl-write-source.tool.ts index 357e7ca0..b9a79e6b 100644 --- a/packages/context/src/sl/tools/sl-write-source.tool.ts +++ b/packages/context/src/sl/tools/sl-write-source.tool.ts @@ -1,6 +1,12 @@ import YAML from 'yaml'; import { z } from 'zod'; -import { addTouchedSlSource, type ToolContext, type ToolOutput, validateActionRawPaths } from '../../tools/index.js'; +import { + addTouchedSlSource, + type ToolContext, + type ToolOutput, + validateActionRawPaths, + validateActionTargetConnection, +} from '../../tools/index.js'; import { sourceOverlaySchema } from '../schemas.js'; import type { SemanticLayerService } from '../semantic-layer.service.js'; import type { SemanticLayerSource } from '../types.js'; @@ -106,6 +112,10 @@ Do NOT join back to a table that the SQL already aggregates from if the grain co const semanticLayerService = context.session?.semanticLayerService ?? this.semanticLayerService; const skipIndex = context.session?.isWorktreeScoped === true; + const targetConnectionValidation = validateActionTargetConnection(context.session, connectionId); + if (!targetConnectionValidation.ok) { + return this.buildOutput(false, [targetConnectionValidation.error], sourceName); + } const rawPathValidation = validateActionRawPaths(context.session, input.rawPaths); if (!rawPathValidation.ok) { return this.buildOutput(false, [rawPathValidation.error], sourceName); diff --git a/packages/context/src/tools/action-target-connection.ts b/packages/context/src/tools/action-target-connection.ts new file mode 100644 index 00000000..4ba3f651 --- /dev/null +++ b/packages/context/src/tools/action-target-connection.ts @@ -0,0 +1,23 @@ +import type { ToolSession } from './tool-session.js'; + +type ActionTargetConnectionValidation = { ok: true } | { ok: false; error: string }; + +export function validateActionTargetConnection( + session: ToolSession | undefined, + connectionId: string, +): ActionTargetConnectionValidation { + const allowed = session?.allowedConnectionNames; + if (!allowed) { + return { ok: true }; + } + if (allowed.has(connectionId)) { + return { ok: true }; + } + const allowedList = [...allowed].sort(); + return { + ok: false, + error: `connectionId "${connectionId}" is outside this ingest session's allowed target connections: ${ + allowedList.length > 0 ? allowedList.join(', ') : '(none)' + }`, + }; +} diff --git a/packages/context/src/tools/index.ts b/packages/context/src/tools/index.ts index a3fc5e7b..c6a334d5 100644 --- a/packages/context/src/tools/index.ts +++ b/packages/context/src/tools/index.ts @@ -32,6 +32,7 @@ export type { SqlEdit } from './sql-edit-replacer.js'; export { applySqlEdits } from './sql-edit-replacer.js'; export type { IngestToolMetadata, MemoryAction, ToolSession } from './tool-session.js'; export { validateActionRawPaths } from './action-raw-paths.js'; +export { validateActionTargetConnection } from './action-target-connection.js'; export type { TouchedSlSource, TouchedSlSourceSet } from './touched-sl-sources.js'; export { addTouchedSlSource,