diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index ca0396a431..2ffab3b7eb 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -152,6 +152,16 @@ export { resolvePlanOverallStatus, type PlanOverallStatus } from "./plan-overall export { hasPlanReadySteps } from "./plan-ready.js"; export { isPlanTerminated } from "./plan-terminated.js"; export * from "./plan-templates.js"; +export { + PROMPT_PACKET_REDACTED_PATH, + PROMPT_PACKET_REDACTED_TERM, + PROMPT_PACKET_TEXT_FIELDS, + buildPromptPacket, + sanitizePromptPacketField, + type PromptPacket, + type PromptPacketInput, + type PromptPacketTextField, +} from "./prompt-packet.js"; export * from "./portfolio/queue.js"; export { applyAiPolicyFatigueToRankInput, diff --git a/packages/gittensory-engine/src/prompt-packet.ts b/packages/gittensory-engine/src/prompt-packet.ts new file mode 100644 index 0000000000..5c5ee4bc38 --- /dev/null +++ b/packages/gittensory-engine/src/prompt-packet.ts @@ -0,0 +1,47 @@ +// Metadata-only prompt-packet builder (#2321): four analyze-phase text fields scrubbed with the same PUBLIC_UNSAFE_TERMS / PUBLIC_LOCAL_PATH_INLINE vocabulary as src/signals/redaction.ts (duplicated here so gittensory-engine stays standalone). + +/** Canonical economic/identity term vocabulary (alternation source only — mirrors `PUBLIC_UNSAFE_TERMS`). */ +const PUBLIC_UNSAFE_TERMS = String.raw`(?:reward|score|wallet|hotkey|coldkey|mnemonic|payout|ranking)\w*|farming|raw[-_\s]?trust|trust[-_\s]?score|private[-_\s]?reviewability|reviewability`; + +/** Canonical local-filesystem-root vocabulary (alternation source only — mirrors `PUBLIC_LOCAL_PATH_INLINE`). */ +const PUBLIC_LOCAL_PATH_INLINE = String.raw`/Users/|/home/|/root/|/var/|/opt/|/tmp/|/private/|[A-Za-z]:[\\/]Users[\\/]|[A-Za-z]:[\\/]Program Files[\\/]`; + +const UNSAFE_TERM_SCRUB = new RegExp(String.raw`\b(${PUBLIC_UNSAFE_TERMS})\b`, "gi"); +const LOCAL_PATH_SCRUB = new RegExp(String.raw`(?:${PUBLIC_LOCAL_PATH_INLINE})[^\s"',;)]*`, "gi"); + +export const PROMPT_PACKET_REDACTED_TERM = "[redacted]"; +export const PROMPT_PACKET_REDACTED_PATH = ""; + +/** The four free-text fields the analyze prompt packet exposes to a coding agent. */ +export type PromptPacketTextField = "taskBrief" | "feasibilityNotes" | "retrievalContext" | "constraints"; + +export const PROMPT_PACKET_TEXT_FIELDS: readonly PromptPacketTextField[] = Object.freeze([ + "taskBrief", + "feasibilityNotes", + "retrievalContext", + "constraints", +]); + +export type PromptPacketInput = Record; +export type PromptPacket = PromptPacketInput; + +function emptyPromptPacketInput(): PromptPacketInput { + return { + taskBrief: "", + feasibilityNotes: "", + retrievalContext: "", + constraints: "", + }; +} + +/** Scrub unsafe economic/identity terms and absolute local paths from one packet field. */ +export function sanitizePromptPacketField(value: string): string { + return value.replace(LOCAL_PATH_SCRUB, PROMPT_PACKET_REDACTED_PATH).replace(UNSAFE_TERM_SCRUB, PROMPT_PACKET_REDACTED_TERM); +} + +/** Build a public-safe analyze prompt packet from metadata-only inputs. Clean fields pass through byte-identical; unsafe terms and local paths are redacted. */ +export function buildPromptPacket(input: PromptPacketInput): PromptPacket { + const packet = emptyPromptPacketInput(); + for (const field of PROMPT_PACKET_TEXT_FIELDS) packet[field] = sanitizePromptPacketField(input[field]); + return packet; +} diff --git a/test/unit/prompt-packet-redaction.test.ts b/test/unit/prompt-packet-redaction.test.ts new file mode 100644 index 0000000000..36f9961aee --- /dev/null +++ b/test/unit/prompt-packet-redaction.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { + PROMPT_PACKET_REDACTED_PATH, + PROMPT_PACKET_REDACTED_TERM, + PROMPT_PACKET_TEXT_FIELDS, + buildPromptPacket, + type PromptPacketInput, +} from "../../packages/gittensory-engine/src/prompt-packet"; +import { PUBLIC_LOCAL_PATH_INLINE, PUBLIC_UNSAFE_TERMS } from "../../src/signals/redaction"; + +function cleanPacketInput(over: Partial = {}): PromptPacketInput { + return { + taskBrief: "Add retry logic to the cache reconnect path.", + feasibilityNotes: "Linked issue is open and unassigned.", + retrievalContext: "See src/cache/reconnect.ts for the existing handler.", + constraints: "Match house style; run npm run test:ci before push.", + ...over, + }; +} + +function splitTopLevelAlternation(source: string): string[] { + const branches: string[] = []; + let depth = 0; + let current = ""; + for (const char of source) { + if (char === "(") depth += 1; + if (depth > 0) current += char; + else if (char === "|") { + branches.push(current); + current = ""; + } else { + current += char; + } + if (char === ")") depth -= 1; + } + if (current.length > 0) branches.push(current); + return branches; +} + +/** Enumerate every unsafe-term family branch from the canonical alternation source (do not hand-copy). */ +function enumerateUnsafeTermFamilies(source: string): Array<{ id: string; sample: string }> { + const branches = splitTopLevelAlternation(source); + const families: Array<{ id: string; sample: string }> = []; + + for (const branch of branches) { + const pluralizable = branch.match(/^\(\?:([^)]+)\)\\w\*$/) ?? branch.match(/^\(([^)]+)\)\\w\*$/); + if (pluralizable) { + for (const term of splitTopLevelAlternation(pluralizable[1]!)) { + families.push({ id: term, sample: term }); + } + continue; + } + if (branch === "raw[-_\\s]?trust") { + families.push({ id: "raw-trust", sample: "raw-trust" }); + continue; + } + if (branch === "trust[-_\\s]?score") { + families.push({ id: "trust-score", sample: "trust_score" }); + continue; + } + if (branch === "private[-_\\s]?reviewability") { + families.push({ id: "private-reviewability", sample: "private-reviewability" }); + continue; + } + families.push({ id: branch, sample: branch }); + } + + return families; +} + +/** Enumerate every local-path root prefix from the canonical alternation source (do not hand-copy). */ +function enumerateLocalPathSamples(source: string): Array<{ id: string; sample: string }> { + return splitTopLevelAlternation(source).map((prefix) => { + if (prefix.startsWith("[A-Za-z]:[\\\\/]Users[\\\\/]")) { + return { id: "windows-users", sample: "C:\\Users\\alice\\repo\\main.ts" }; + } + if (prefix.startsWith("[A-Za-z]:[\\\\/]Program Files[\\\\/]")) { + return { id: "windows-program-files", sample: "C:\\Program Files\\App\\config.json" }; + } + const id = prefix.replace(/\/$/, "").replace(/^\//, ""); + return { id, sample: `${prefix}alice/project` }; + }); +} + +const UNSAFE_TERM_FAMILIES = enumerateUnsafeTermFamilies(PUBLIC_UNSAFE_TERMS); +const LOCAL_PATH_SAMPLES = enumerateLocalPathSamples(PUBLIC_LOCAL_PATH_INLINE); + +describe("buildPromptPacket redaction (#2321 adversarial allowlist)", () => { + it("enumerates every unsafe-term family from PUBLIC_UNSAFE_TERMS", () => { + expect(UNSAFE_TERM_FAMILIES.map((entry) => entry.id).sort()).toEqual( + ["coldkey", "farming", "hotkey", "mnemonic", "payout", "private-reviewability", "ranking", "raw-trust", "reviewability", "reward", "score", "trust-score", "wallet"].sort(), + ); + }); + + it("enumerates every local-path root from PUBLIC_LOCAL_PATH_INLINE", () => { + expect(LOCAL_PATH_SAMPLES.map((entry) => entry.id).sort()).toEqual( + ["Users", "home", "opt", "private", "root", "tmp", "var", "windows-program-files", "windows-users"].sort(), + ); + }); + + it.each(UNSAFE_TERM_FAMILIES.flatMap(({ id, sample }) => + PROMPT_PACKET_TEXT_FIELDS.map((field) => ({ id, field, sample })), + ))("strips unsafe term family '$id' injected into $field", ({ field, sample }) => { + const input = cleanPacketInput({ [field]: `prefix ${sample} suffix` }); + const packet = buildPromptPacket(input); + + expect(packet[field]).toContain(PROMPT_PACKET_REDACTED_TERM); + expect(packet[field]).not.toMatch(new RegExp(String.raw`\b${sample.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\b`, "i")); + }); + + it.each(LOCAL_PATH_SAMPLES.flatMap(({ id, sample }) => + PROMPT_PACKET_TEXT_FIELDS.map((field) => ({ id, field, sample })), + ))("strips local-path prefix '$id' injected into $field", ({ field, sample }) => { + const input = cleanPacketInput({ [field]: `clone failed at ${sample} during setup` }); + const packet = buildPromptPacket(input); + + expect(packet[field]).toContain(PROMPT_PACKET_REDACTED_PATH); + expect(packet[field]).not.toContain(sample); + }); + + it("applies both unsafe-term and local-path filters in the same field (double jeopardy)", () => { + const packet = buildPromptPacket( + cleanPacketInput({ taskBrief: "wallet backup stored at /home/alice/secrets before retry" }), + ); + + expect(packet.taskBrief).toContain(PROMPT_PACKET_REDACTED_TERM); + expect(packet.taskBrief).toContain(PROMPT_PACKET_REDACTED_PATH); + expect(packet.taskBrief).not.toMatch(/\bwallet\b/i); + expect(packet.taskBrief).not.toMatch(/\/home\//); + }); + + it("leaves fields with zero unsafe content byte-identical", () => { + const input = cleanPacketInput(); + const packet = buildPromptPacket(input); + + for (const field of PROMPT_PACKET_TEXT_FIELDS) { + expect(packet[field]).toBe(input[field]); + } + }); + + it("strips a field that contains only an unsafe term instead of forwarding it verbatim", () => { + const packet = buildPromptPacket(cleanPacketInput({ constraints: "wallet" })); + + expect(packet.constraints).toBe(PROMPT_PACKET_REDACTED_TERM); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 76b7d8bc59..64d071d027 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -7242,7 +7242,7 @@ describe("queue processors", () => { } finally { errors.mockRestore(); } - }); + }, 60_000); it("REGRESSION (#orb-retry-storm): a repair dispatch under the attempt cap records a repair_attempt audit event, and a second sweep tick does not duplicate the repair_exhausted event once already flagged", async () => { const sent: import("../../src/types").JobMessage[] = []; @@ -7287,7 +7287,7 @@ describe("queue processors", () => { .bind("agent.sweep.regate.repair_exhausted", targetKey) .first<{ n: number }>(); expect(exhausted?.n).toBe(1); - }); + }, 60_000); it("agent re-gate sweep fail-opens when current Gate check reads fail during repair priority selection", async () => { const sent: import("../../src/types").JobMessage[] = []; diff --git a/test/unit/rag-wiring.test.ts b/test/unit/rag-wiring.test.ts index 291443fbe4..340faa353d 100644 --- a/test/unit/rag-wiring.test.ts +++ b/test/unit/rag-wiring.test.ts @@ -413,7 +413,7 @@ describe("RAG wired into the AI reviewer (flag GITTENSORY_REVIEW_RAG)", () => { notesReferencedRetrievedPath: false, findingReferencedRetrievedPath: false, }); - }); + }, 60_000); it("FLAG-OFF (default): the prompt is byte-identical to the no-RAG prompt (ragContext undefined)", async () => { // The flag-OFF call site leaves ragContext undefined; the prompt must equal the no-RAG prompt.