diff --git a/apps/worker/src/ai/pi/source-jail.ts b/apps/worker/src/ai/pi/source-jail.ts deleted file mode 100644 index 9b27219d..00000000 --- a/apps/worker/src/ai/pi/source-jail.ts +++ /dev/null @@ -1,295 +0,0 @@ -// Copyright (C) 2026 Keygraph, Inc. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License version 3 -// as published by the Free Software Foundation. - -/** Attempt-local working-tree copy used by the task-formation model boundary. */ - -import type { Dirent, Stats } from 'node:fs'; -import { cp, lstat, mkdir, mkdtemp, readdir, realpath, rm } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { ArtifactIntegrityError, ReconciliationIoError } from '../reconciliation/artifact-store.js'; - -const JAIL_PREFIX = 'shannon-task-formation-'; -// Never copied into the model-readable jail: `.git` carries deliverables history, `.shannon` holds -// scan internals, and `.pi` holds provider credentials. Any of these reaching the jail would expose -// them to the tools the model drives. The post-copy verification re-checks their absence by name. -const ALWAYS_EXCLUDED_NAMES = Object.freeze(['.git', '.shannon', '.pi'] as const); - -export interface SourceJailOptions { - readonly sourceRoot: string; - readonly deliverablesPath: string; - readonly reconciliationWorkspacePath: string; - readonly signal?: AbortSignal; - /** Test-only filesystem selector. Production uses `os.tmpdir()`. */ - readonly tempRoot?: string; -} - -/** One source-only jail plus the immutable deny rules used by its live tool gate. */ -export interface SourceJail { - readonly dir: string; - readonly deniedPaths: readonly string[]; - cleanup(): Promise; -} - -function isErrno(error: unknown, code: string): boolean { - return error instanceof Error && (error as NodeJS.ErrnoException).code === code; -} - -function cancellationError(signal: AbortSignal): Error { - if (signal.reason instanceof Error) return signal.reason; - return new DOMException('Task formation was cancelled.', 'AbortError'); -} - -function checkCancellation(signal: AbortSignal | undefined): void { - if (signal?.aborted === true) throw cancellationError(signal); -} - -// Path-confinement predicate: true only when `candidate` is `root` itself or lies beneath it. -// A relative path that escapes upward (`..`) or is absolute means the candidate is outside the root. -function isWithin(root: string, candidate: string): boolean { - const relativePath = path.relative(root, candidate); - return ( - relativePath === '' || - (!relativePath.startsWith(`..${path.sep}`) && relativePath !== '..' && !path.isAbsolute(relativePath)) - ); -} - -async function relativeExclusion( - sourceRoot: string, - lexicalSourceRoot: string, - candidate: string, -): Promise { - const resolved = path.resolve(candidate); - let relativePath: string | undefined; - if (isWithin(sourceRoot, resolved)) { - relativePath = path.relative(sourceRoot, resolved); - } else if (isWithin(lexicalSourceRoot, resolved)) { - relativePath = path.relative(lexicalSourceRoot, resolved); - } else { - try { - const canonicalCandidate = await realpath(resolved); - if (isWithin(sourceRoot, canonicalCandidate)) { - relativePath = path.relative(sourceRoot, canonicalCandidate); - } - } catch { - return undefined; - } - } - if (relativePath === undefined) return undefined; - - if (relativePath === '') { - // An exclusion that resolves to the whole root would empty the jail. Fail closed rather than - // copy nothing and hand the model an empty tree. - throw new ArtifactIntegrityError('A task-formation exclusion resolves to the complete source root'); - } - return relativePath; -} - -async function buildDynamicExclusions( - options: SourceJailOptions, - sourceRoot: string, - lexicalSourceRoot: string, -): Promise { - const exclusions = ( - await Promise.all([ - relativeExclusion(sourceRoot, lexicalSourceRoot, options.deliverablesPath), - relativeExclusion(sourceRoot, lexicalSourceRoot, options.reconciliationWorkspacePath), - ]) - ).filter((value): value is string => value !== undefined); - return Object.freeze([...new Set(exclusions)]); -} - -function pathHasAlwaysExcludedName(relativePath: string): boolean { - const segments = relativePath.split(path.sep); - return segments.some((segment) => (ALWAYS_EXCLUDED_NAMES as readonly string[]).includes(segment)); -} - -function pathIsDynamicallyExcluded(relativePath: string, exclusions: readonly string[]): boolean { - return exclusions.some((excluded) => relativePath === excluded || relativePath.startsWith(`${excluded}${path.sep}`)); -} - -async function copySourceTree( - sourceRoot: string, - destination: string, - dynamicExclusions: readonly string[], - signal: AbortSignal | undefined, -): Promise { - let entries: Dirent[]; - try { - entries = (await readdir(sourceRoot, { withFileTypes: true })).sort((left, right) => - left.name.localeCompare(right.name), - ); - } catch { - throw new ReconciliationIoError('Unable to enumerate the task-formation source tree'); - } - - // Cancellation is checked before every top-level entry and inside the copy filter so an aborted - // scan stops promptly instead of copying a whole large tree first. - for (const entry of entries) { - checkCancellation(signal); - const source = path.join(sourceRoot, entry.name); - const destinationEntry = path.join(destination, entry.name); - try { - // verbatimSymlinks copies links as links rather than following them, so a link pointing - // outside the tree cannot pull external content in; the filter then drops any path that - // resolves outside the root, plus the always- and dynamically-excluded paths. - await cp(source, destinationEntry, { - recursive: true, - verbatimSymlinks: true, - errorOnExist: true, - force: false, - async filter(candidate) { - checkCancellation(signal); - const relativePath = path.relative(sourceRoot, candidate); - if (relativePath === '' || !isWithin(sourceRoot, path.resolve(candidate))) return false; - if (pathHasAlwaysExcludedName(relativePath)) return false; - return !pathIsDynamicallyExcluded(relativePath, dynamicExclusions); - }, - }); - } catch (error) { - if (signal?.aborted === true) throw cancellationError(signal); - if (error instanceof ArtifactIntegrityError) throw error; - throw new ReconciliationIoError('Unable to copy the task-formation source tree'); - } - } - checkCancellation(signal); -} - -async function assertAlwaysExcludedNamesAbsent(directory: string, signal: AbortSignal | undefined): Promise { - checkCancellation(signal); - let entries: Dirent[]; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - throw new ReconciliationIoError('Unable to verify the task-formation source jail'); - } - - for (const entry of entries) { - checkCancellation(signal); - if ((ALWAYS_EXCLUDED_NAMES as readonly string[]).includes(entry.name)) { - throw new ArtifactIntegrityError('The task-formation source jail contains an excluded entry'); - } - if (entry.isDirectory() && !entry.isSymbolicLink()) { - await assertAlwaysExcludedNamesAbsent(path.join(directory, entry.name), signal); - } - } -} - -async function assertDynamicExclusionsAbsent( - directory: string, - exclusions: readonly string[], - signal: AbortSignal | undefined, -): Promise { - for (const excluded of exclusions) { - checkCancellation(signal); - try { - await lstat(path.join(directory, excluded)); - } catch (error) { - if (isErrno(error, 'ENOENT')) continue; - throw new ReconciliationIoError('Unable to verify a task-formation jail exclusion'); - } - throw new ArtifactIntegrityError('The task-formation source jail contains a protected workspace entry'); - } -} - -// Re-verify the copied tree independently of the copy filter: the jail root must be a real -// directory (not a symlink), and no excluded name or protected workspace path may survive. This -// catches a filter gap or a race during the copy before the model is allowed to read the tree. -async function verifyJail( - directory: string, - dynamicExclusions: readonly string[], - signal: AbortSignal | undefined, -): Promise { - checkCancellation(signal); - let stats: Stats; - try { - stats = await lstat(directory); - } catch { - throw new ReconciliationIoError('Unable to inspect the task-formation source jail'); - } - if (stats.isSymbolicLink() || !stats.isDirectory()) { - throw new ArtifactIntegrityError('The task-formation source jail is not a real directory'); - } - await assertAlwaysExcludedNamesAbsent(directory, signal); - await assertDynamicExclusionsAbsent(directory, dynamicExclusions, signal); - checkCancellation(signal); -} - -async function removeJail(directory: string): Promise { - try { - await rm(directory, { recursive: true, force: true }); - } catch { - throw new ReconciliationIoError('Unable to remove the task-formation source jail'); - } - - try { - await lstat(directory); - } catch (error) { - if (isErrno(error, 'ENOENT')) return; - throw new ReconciliationIoError('Unable to verify task-formation source-jail cleanup'); - } - throw new ReconciliationIoError('Task-formation source-jail cleanup left the jail on disk'); -} - -/** - * Copy the scanned working tree into an isolated temporary directory without following symlinks. - * Every failure removes the attempt-local directory before it propagates. - */ -export async function materializeSourceJail(options: SourceJailOptions): Promise { - checkCancellation(options.signal); - - const lexicalSourceRoot = path.resolve(options.sourceRoot); - let sourceRoot: string; - try { - sourceRoot = await realpath(options.sourceRoot); - const sourceStats = await lstat(sourceRoot); - if (sourceStats.isSymbolicLink() || !sourceStats.isDirectory()) { - throw new ArtifactIntegrityError('The task-formation source root is not a real directory'); - } - } catch (error) { - if (error instanceof ArtifactIntegrityError) throw error; - throw new ReconciliationIoError('Unable to resolve the task-formation source root'); - } - - let tempRoot: string; - try { - const configuredTempRoot = options.tempRoot ?? os.tmpdir(); - await mkdir(configuredTempRoot, { recursive: true }); - tempRoot = await realpath(configuredTempRoot); - } catch { - throw new ReconciliationIoError('Unable to resolve the task-formation temporary root'); - } - // A temp root inside the source tree would make the copy try to copy the jail into itself. - if (isWithin(sourceRoot, tempRoot)) { - throw new ArtifactIntegrityError('The task-formation temporary root cannot be inside the source tree'); - } - - const dynamicExclusions = await buildDynamicExclusions(options, sourceRoot, lexicalSourceRoot); - let directory: string; - try { - directory = await mkdtemp(path.join(tempRoot, JAIL_PREFIX)); - } catch { - throw new ReconciliationIoError('Unable to create the task-formation source jail'); - } - - let cleaned = false; - const cleanup = async (): Promise => { - if (cleaned) return; - await removeJail(directory); - cleaned = true; - }; - - try { - await copySourceTree(sourceRoot, directory, dynamicExclusions, options.signal); - await verifyJail(directory, dynamicExclusions, options.signal); - } catch (error) { - await cleanup().catch(() => undefined); - throw error; - } - - const deniedPaths = Object.freeze([...ALWAYS_EXCLUDED_NAMES, ...dynamicExclusions]); - return Object.freeze({ dir: directory, deniedPaths, cleanup }); -} diff --git a/apps/worker/src/ai/pi/task-formation-executor.ts b/apps/worker/src/ai/pi/task-formation-executor.ts index 24b30e45..8af39cd8 100644 --- a/apps/worker/src/ai/pi/task-formation-executor.ts +++ b/apps/worker/src/ai/pi/task-formation-executor.ts @@ -38,10 +38,9 @@ const MAX_TURNS = 128; const MAX_LIST_RESULTS = 500; const DEFAULT_LIST_RESULTS = 200; const MAX_OUTPUT_BYTES = 64 * 1024; -// The live-tool-side counterpart of the source jail's copy-time exclusion (source-jail.ts): even if -// one of these somehow existed in the jailed tree, the read/grep/find/ls/glob tools built below must -// still refuse to serve it. `.git` is deliverables history, `.shannon` is scan internals, `.pi` is -// provider credentials. +// Task formation reads the live repository, so these read-only tools are the sole barrier keeping the +// model out of `.git` (source history), `.shannon` (scan internals, incl. the deliverables Git repo), +// and `.pi` (credentials). Always denied, whatever extra denies a caller passes. const ALWAYS_DENIED_PATHS = Object.freeze(['.git', '.shannon', '.pi'] as const); const TRANSIENT_IO_CODES = new Set([ 'EAGAIN', @@ -288,9 +287,9 @@ function createGlobTool(confinement: RepositoryConfinement): ToolDefinition { return defineTool({ name: 'glob', label: 'Glob source files', - description: 'Match bounded file globs from the source-jail root without following symlinks.', - promptSnippet: 'glob: match source files from the jail root', - promptGuidelines: ['Patterns are always rooted in the source jail.'], + description: 'Match bounded file globs from the repository root without following symlinks.', + promptSnippet: 'glob: match source files from the repository root', + promptGuidelines: ['Patterns are always rooted in the repository.'], parameters: Type.Object( { pattern: Type.String({ minLength: 1, maxLength: 256 }), @@ -322,7 +321,7 @@ function createGlobTool(confinement: RepositoryConfinement): ToolDefinition { }); } -/** Create the five code-owned source tools that share one canonical jail policy. */ +/** Create the five code-owned source tools that share one canonical deny policy. */ export async function createTaskFormationSourceTools(options: ToolFactoryOptions): Promise { const deniedPaths = uniqueDeniedPaths(options.deniedPaths); const capellaTools = await createCapellaRepositoryTools({ diff --git a/apps/worker/src/ai/reconciliation/form.ts b/apps/worker/src/ai/reconciliation/form.ts index 6fb48d66..46e361e1 100644 --- a/apps/worker/src/ai/reconciliation/form.ts +++ b/apps/worker/src/ai/reconciliation/form.ts @@ -6,12 +6,10 @@ /** Pass 1 task formation over current observations only. */ -import path from 'node:path'; -import { DEFAULT_DELIVERABLES_SUBDIR, WORKSPACES_DIR } from '../../paths.js'; +import { WORKSPACES_DIR } from '../../paths.js'; import { loadPrompt } from '../../services/prompt-manager.js'; import type { ActivityLogger } from '../../types/activity-logger.js'; import type { ReconciliationClass } from '../../types/reconciliation.js'; -import { materializeSourceJail } from '../pi/source-jail.js'; import { isTaskFormationFallbackReason, type TaskFormationExecutionContext, @@ -61,7 +59,6 @@ export interface FormClassExploitTasksInput { readonly repositoryPath: string; readonly producerRef: ArtifactRef<'producer-observations'>; readonly supplementalRef: ArtifactRef<'supplemental-observations'>; - readonly deliverablesSubdir?: string; readonly webUrl?: string; } @@ -181,14 +178,14 @@ function taskFormationPromptName(vulnerabilityClass: ReconciliationClass): strin // prompt itself is missing or unreadable content, which Temporal should not spend retries on. async function loadClassPolicy( vulnerabilityClass: ReconciliationClass, - jailPath: string, + repoPath: string, webUrl: string, logger: ActivityLogger, ): Promise { try { return await loadPrompt( taskFormationPromptName(vulnerabilityClass), - { webUrl, repoPath: jailPath, AUTH_STATE_FILE: '' }, + { webUrl, repoPath, AUTH_STATE_FILE: '' }, null, false, logger, @@ -332,109 +329,69 @@ export function createFormClassExploitTasks( const submitTool = createValidatingSubmitTool(buildTaskFormationSchema([...labelSet]), (parameters) => findTaskFormationProblems(parameters, labelSet), ); - const deliverablesPath = path.resolve( + const classPolicy = await loadClassPolicy( + input.vulnerabilityClass, input.repositoryPath, - input.deliverablesSubdir ?? DEFAULT_DELIVERABLES_SUBDIR, + input.webUrl ?? 'https://not-applicable.invalid', + logger, ); - const reconciliationWorkspacePath = path.resolve(workspacesDir, input.sessionId, '.shannon', 'reconciliation'); - // Task formation runs against a disposable copy of the source tree rather than the live - // repository or the deliverables directory, so the model's tool calls during this stage cannot - // read or modify anything outside what it was actually given to reason about. - const jail = await materializeSourceJail({ - sourceRoot: input.repositoryPath, - deliverablesPath, - reconciliationWorkspacePath, - ...(signal !== undefined && { signal }), - }); + checkCancellation(signal); - let formation: FormClassExploitTasksResult; + let modelResult: TaskFormationExecutorResult; try { - const classPolicy = await loadClassPolicy( - input.vulnerabilityClass, - jail.dir, - input.webUrl ?? 'https://not-applicable.invalid', - logger, - ); - checkCancellation(signal); - - let modelResult: TaskFormationExecutorResult; - try { - const executorTimeoutMs = deps.executorTimeoutMsFor?.(); - modelResult = await executor.run({ - cwd: jail.dir, - systemPrompt: classPolicy, - modelContext: modelInput.serialized, - deniedPaths: jail.deniedPaths, - submitTool, - signal: signal ?? new AbortController().signal, - ...(executorTimeoutMs !== undefined && { timeoutMs: executorTimeoutMs }), - correlation: { - ...deps.executionContextFor?.(), - stage: 'task-formation', - vulnerabilityClass: input.vulnerabilityClass, - }, - }); - } catch (error) { - if (!(error instanceof TaskFormationExecutorError)) throw error; - if (error.failureKind === 'infrastructure') { - throw new ReconciliationIoError( - 'Task-formation executor setup encountered a retryable infrastructure failure', - ); - } - if (error.failureKind !== 'model') throw error; - const metrics = metricsFromUsage(error.usage, error.modelCalls); - deps.onMetrics?.(metrics); - throw new TaskFormationModelError({ - message: error.message, - retryable: error.retryable, - ...(error.fallbackReason !== undefined && { fallbackReason: error.fallbackReason }), - metrics, - }); - } - - const metrics = metricsFromUsage(modelResult.usage, modelResult.modelCalls); - deps.onMetrics?.(metrics); - checkCancellation(signal); - const accepted = acceptTaskGroups(modelResult.output, labelSet); - const groups = accepted.groups.map((group) => ({ - producer_ids: group.queue_labels.map((label) => { - const producerId = modelInput.labelToProducerId.get(label); - if (producerId === undefined) { - throw new ArtifactIntegrityError('An accepted task-formation label has no observation mapping'); - } - return producerId; - }), - reasoning: group.reasoning, - })); - const body: TaskFormationBody = { - model_ran: true, - groups, - rejected_group_count: accepted.rejectedGroupCount, - dropped_unknown_label_count: accepted.droppedUnknownLabelCount, - }; - const ref = await writeFormationArtifact(input, workspacesDir, body); - formation = { ref, metrics, model: `${modelResult.providerId}:${modelResult.modelId}` }; + const executorTimeoutMs = deps.executorTimeoutMsFor?.(); + modelResult = await executor.run({ + cwd: input.repositoryPath, + systemPrompt: classPolicy, + modelContext: modelInput.serialized, + deniedPaths: [], + submitTool, + signal: signal ?? new AbortController().signal, + ...(executorTimeoutMs !== undefined && { timeoutMs: executorTimeoutMs }), + correlation: { + ...deps.executionContextFor?.(), + stage: 'task-formation', + vulnerabilityClass: input.vulnerabilityClass, + }, + }); } catch (error) { - // A primary error — including cancellation — already owns the outcome, so a cleanup failure - // is logged and swallowed rather than replacing that error's type or cause chain. - try { - await jail.cleanup(); - } catch { - logger.error( - 'A temporary copy of your source code could not be removed after analysis. It is inside the scan workspace and is safe to delete.', - { - stage: 'task-formation', - vulnerabilityClass: input.vulnerabilityClass, - }, - ); + if (!(error instanceof TaskFormationExecutorError)) throw error; + if (error.failureKind === 'infrastructure') { + throw new ReconciliationIoError('Task-formation executor setup encountered a retryable infrastructure failure'); } - throw error; + if (error.failureKind !== 'model') throw error; + const metrics = metricsFromUsage(error.usage, error.modelCalls); + deps.onMetrics?.(metrics); + throw new TaskFormationModelError({ + message: error.message, + retryable: error.retryable, + ...(error.fallbackReason !== undefined && { fallbackReason: error.fallbackReason }), + metrics, + }); } - // Nothing else is in flight after a successful formation, so an unremoved or unverifiable jail - // is the stage's outcome: it leaves a full copy of the scanned tree on disk and fails here. - await jail.cleanup(); - return formation; + const metrics = metricsFromUsage(modelResult.usage, modelResult.modelCalls); + deps.onMetrics?.(metrics); + checkCancellation(signal); + const accepted = acceptTaskGroups(modelResult.output, labelSet); + const groups = accepted.groups.map((group) => ({ + producer_ids: group.queue_labels.map((label) => { + const producerId = modelInput.labelToProducerId.get(label); + if (producerId === undefined) { + throw new ArtifactIntegrityError('An accepted task-formation label has no observation mapping'); + } + return producerId; + }), + reasoning: group.reasoning, + })); + const body: TaskFormationBody = { + model_ran: true, + groups, + rejected_group_count: accepted.rejectedGroupCount, + dropped_unknown_label_count: accepted.droppedUnknownLabelCount, + }; + const ref = await writeFormationArtifact(input, workspacesDir, body); + return { ref, metrics, model: `${modelResult.providerId}:${modelResult.modelId}` }; }; }