import * as path from "node:path"; import { freezeSpecSnapshot, loadSpecRecord } from "../state/stores/spec-store.ts"; import type { SpecRecord, TaskPacket, TaskScope, TeamRunManifest, VerificationContract } from "../state/types.ts"; import type { WorkflowStep } from "../workflows/workflow-config.ts"; import { generateTaskHashId } from "./task-id.ts"; // ═══════════════════════════════════════════════════════════════════════════ // SEC-007 Fix: Workflow Step Task Sanitization // Context provided by workers comes from workflow definitions that could // be user-controlled. Sanitize task text to prevent injection. // See: SECURITY-ISSUES.md SEC-007 // ═══════════════════════════════════════════════════════════════════════════ /** * Sanitize workflow step task text to reduce injection risk. * * The task text is used as a prompt for worker agents. In a multi-tenant * or shared workflow scenario, malicious workflow definitions could * embed injection instructions. * * Sanitization: * - Strip zero-width Unicode characters * - Strip known prompt injection directive patterns * - Strip base64/hex encoded payloads * - Collapse excessive whitespace */ export function sanitizeTaskText(task: string): string { let sanitized = task; // 1. Strip zero-width and invisible Unicode characters sanitized = sanitized.replace(/[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF]/g, ""); // 1b. Strip HTML/JS comments and script tags (instruction hiding). // SEC-4: bounded quantifier {0,8192} prevents polynomial O(n²) backtracking // DoS on pathological inputs (e.g. an unclosed ``). sanitized = sanitized.replace(/|<\/?script[^>]*>/gi, ""); // 2. Strip known prompt injection directive patterns sanitized = sanitized.replace( /^\s*(?:SYSTEM|INSTRUCTION|IGNORE(?:\s+ALL)?\s+(?:PREVIOUS|INSTRUCTIONS)?|OVERRIDE|YOUR\s+ROLE\s+IS|MALICIOUS)\s*:.*$/gim, "", ); // 3. Strip base64/hex encoded command payloads sanitized = sanitized.replace(/\b(?:base64|base32|hex)\s*['":]\s*([A-Za-z0-9+\/=]{16,})/gi, "[encoded-redacted]"); // 4. Strip embedded instruction patterns in brackets (incl. MALICIOUS to match agent sanitizer) sanitized = sanitized.replace(/\[(?:SYSTEM|INSTRUCTION|OVERRIDE|MALICIOUS)\s*:[^\]]*\]/gi, ""); // 5. Strip eval/exec patterns with encoded content sanitized = sanitized.replace(/\b(?:eval|exec|spawn|subprocess)\s*\(\s*(?:base64|Buffer\.from)\s*\(/gi, "[suspicious-call-redacted]"); // 6. Strip markdown codeblocks that attempt to hide instructions. // SEC-4: bounded quantifier {0,8192} prevents polynomial backtracking DoS. sanitized = sanitized.replace(/```\s*(?:system|instruction|prompt)\n[\s\S]{0,8192}?```/gi, ""); // 7. Strip YAML-like assignment patterns that could override behavior. // Applied unconditionally — task text is always untrusted. sanitized = sanitized.replace(/^\s*(?:role|persona|behavior|directive)\s*[=:].*$/gim, ""); // 8. Strip potential exfiltration patterns sanitized = sanitized.replace(/\b(?:write|append)\s+.*(?:secrets?|keys?|token|credential)/gi, "[suspicious-write-redacted]"); // 9. Strip network exfiltration patterns sanitized = sanitized.replace(/\b(?:fetch|curl|wget|axios)\s+.*(?:exfil|steal|leak|send)/gi, "[suspicious-network-redacted]"); // 10. Collapse multiple blank lines (cleanup after removals) sanitized = sanitized.replace(/\n{3,}/g, "\n\n"); return sanitized.trim(); } export interface BuildTaskPacketInput { /** T4/R6 (ADR-6 §2): workspace spec ids — loaded + FROZEN into the packet. */ specRefs?: string[]; /** T4/R6 (ADR-6 §7): strict mode — coverage AND machine-check (§4). */ specStrict?: boolean; manifest: TeamRunManifest; step: WorkflowStep; taskId: string; cwd: string; worktreePath?: string; } export interface TaskPacketValidationResult { valid: boolean; errors: string[]; } export function inferTaskScope(step: WorkflowStep): TaskScope { const reads = step.reads === false ? [] : (step.reads ?? []); if (reads.length === 1) return "single_file"; if (reads.length > 1) return "module"; return "workspace"; } export function defaultVerificationContract(step: WorkflowStep): VerificationContract { return { requiredGreenLevel: step.verify ? "targeted" : "none", commands: [], allowManualEvidence: true, }; } export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket { // T4/R6 (ADR-6 §1/§2): the ONLY packet-creation path is also the freeze // point — specRefs load from the workspace store and freeze into immutable // snapshots embedded in the packet (later spec edits never rewrite them). // Round-1 review (fail-closed freeze): declared-but-unresolvable ids are // kept on the packet as unresolvedSpecRefs instead of being dropped — the // gate surfaces them (badge non-strict / failure strict), never a silent // no-op. Snapshots freeze trust via the store at THIS moment. const declaredSpecRefs = input.specRefs ?? []; const resolvedRecords = declaredSpecRefs .map((id) => loadSpecRecord(input.manifest.cwd, id)) .filter((r): r is SpecRecord => r !== undefined); const specSnapshots = resolvedRecords.map((r) => freezeSpecSnapshot(r, input.manifest.cwd)); const unresolvedSpecRefs = declaredSpecRefs.filter((id) => !specSnapshots.some((s) => s.specId === id)); const scope = inferTaskScope(input.step); const reads = input.step.reads === false ? [] : (input.step.reads ?? []); const scopePath = reads.length === 1 ? reads[0] : reads.length > 1 ? reads.join(", ") : undefined; // SEC-007: Sanitize task text before inserting into task packet const sanitizedTask = sanitizeTaskText(input.step.task); const sanitizedGoal = sanitizeTaskText(input.manifest.goal); // Generate a deterministic hash-based task ID for traceability and logging. // Uses goal + step ID + run ID as content parts. // TODO(hashId): tracked — add hashId to TaskPacket when the schema supports it. const _taskHashId = generateTaskHashId([input.manifest.goal, input.step.id, input.manifest.runId]); return { objective: sanitizedTask.replaceAll("{goal}", sanitizedGoal), ...(declaredSpecRefs.length > 0 ? { specRefs: declaredSpecRefs, specSnapshots } : {}), ...(unresolvedSpecRefs.length > 0 ? { unresolvedSpecRefs } : {}), ...(input.specStrict === true ? { specStrict: true } : {}), scope, scopePath, repo: path.basename(input.manifest.cwd) || input.manifest.cwd, worktree: input.worktreePath, branchPolicy: input.manifest.workspaceMode === "worktree" ? "Use the assigned task worktree and avoid modifying the leader checkout." : "Use the current checkout; do not create branches unless explicitly requested.", acceptanceTests: [], commitPolicy: "Do not commit unless explicitly requested by the user or workflow.", reportingContract: "Report intended/changed files, verification evidence, blockers, conflict risks, and next recommended action.", escalationPolicy: "Stop and report if scope is ambiguous, destructive action is needed, permissions are missing, verification cannot be completed, or edits may overlap with another worker/task.", constraints: [ "Stay within the assigned task scope.", "Do not claim completion without verification evidence.", "Use mailbox/API state for coordination when available.", "Do not make overlapping edits to the same file/symbol without explicit leader sequencing or ownership guidance.", ], expectedArtifacts: ["prompt", "result", "verification"], verification: defaultVerificationContract(input.step), }; } export function validateTaskPacket(packet: TaskPacket): TaskPacketValidationResult { const errors: string[] = []; if (!packet.objective.trim()) errors.push("objective must not be empty"); if (!packet.repo.trim()) errors.push("repo must not be empty"); if (!packet.branchPolicy.trim()) errors.push("branchPolicy must not be empty"); if (!packet.commitPolicy.trim()) errors.push("commitPolicy must not be empty"); if (!packet.reportingContract.trim()) errors.push("reportingContract must not be empty"); if (!packet.escalationPolicy.trim()) errors.push("escalationPolicy must not be empty"); if ((packet.scope === "module" || packet.scope === "single_file" || packet.scope === "custom") && !packet.scopePath?.trim()) { errors.push(`scopePath is required for scope '${packet.scope}'`); } if (packet.constraints.length === 0) errors.push("constraints must contain at least one entry"); for (const [index, constraint] of packet.constraints.entries()) { if (!constraint.trim()) errors.push(`constraints contains an empty value at index ${index}`); } if (packet.expectedArtifacts.length === 0) errors.push("expectedArtifacts must contain at least one entry"); for (const [index, artifact] of packet.expectedArtifacts.entries()) { if (!artifact.trim()) errors.push(`expectedArtifacts contains an empty value at index ${index}`); } for (const [index, test] of packet.acceptanceTests.entries()) { if (!test.trim()) errors.push(`acceptanceTests contains an empty value at index ${index}`); } return { valid: errors.length === 0, errors }; } /** * Structured handoff template for task completion reports. * Distilled from ECC dmux-workflows pattern — workers use this format * so verifiers and downstream consumers can parse output predictably. */ export const HANDOFF_TEMPLATE = [ "## Handoff", "", "### Summary", "", "", "### Files Changed", "", "", "", "### Tests / Verification", "", "", "### Follow-ups", "", "", "### Provenance", "", ].join("\n"); export interface ParsedHandoff { summary: string[]; filesChanged: string[]; tests: string[]; followups: string[]; } /** * Extract text between a ### heading and the next ### heading or end of text. */ function extractSection(content: string, heading: string): string { const lines = content.split("\n"); const headingMarker = `### ${heading}`; const startIndex = lines.findIndex((line) => line.trim() === headingMarker); if (startIndex === -1) return ""; const collected: string[] = []; for (let i = startIndex + 1; i < lines.length; i++) { const trimmed = lines[i].trim(); if (trimmed.startsWith("### ") || trimmed.startsWith("## ")) break; // Stop at paragraph text (non-bullet, non-comment, non-empty) that follows // a blank line — signals end of subsection content. if ( trimmed.length > 0 && !trimmed.startsWith("- ") && !trimmed.startsWith("