patchwarden 0.4.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PatchWarden.cmd +51 -0
- package/README.en.md +1022 -0
- package/README.md +897 -358
- package/dist/assessments/agentAssessor.d.ts +15 -0
- package/dist/assessments/agentAssessor.js +293 -0
- package/dist/assessments/assessmentStore.d.ts +133 -0
- package/dist/assessments/assessmentStore.js +238 -0
- package/dist/assessments/confirmCli.d.ts +9 -0
- package/dist/assessments/confirmCli.js +26 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.js +156 -3
- package/dist/direct/directAudit.d.ts +23 -0
- package/dist/direct/directAudit.js +309 -0
- package/dist/direct/directGuards.d.ts +20 -0
- package/dist/direct/directGuards.js +137 -0
- package/dist/direct/directPatch.d.ts +17 -0
- package/dist/direct/directPatch.js +113 -0
- package/dist/direct/directSessionStore.d.ts +63 -0
- package/dist/direct/directSessionStore.js +192 -0
- package/dist/direct/directVerification.d.ts +12 -0
- package/dist/direct/directVerification.js +96 -0
- package/dist/doctor.js +73 -5
- package/dist/logging.d.ts +52 -0
- package/dist/logging.js +123 -0
- package/dist/runner/agentInvocation.d.ts +23 -0
- package/dist/runner/agentInvocation.js +106 -0
- package/dist/runner/changeCapture.d.ts +70 -0
- package/dist/runner/changeCapture.js +278 -6
- package/dist/runner/runTask.js +271 -65
- package/dist/runner/simpleProcess.d.ts +21 -0
- package/dist/runner/simpleProcess.js +206 -0
- package/dist/runner/watch.js +17 -2
- package/dist/security/commandGuard.d.ts +2 -1
- package/dist/security/commandGuard.js +25 -3
- package/dist/security/riskEngine.d.ts +27 -0
- package/dist/security/riskEngine.js +118 -0
- package/dist/security/runtimeGuard.d.ts +6 -0
- package/dist/security/runtimeGuard.js +28 -0
- package/dist/smoke-test.js +1353 -7
- package/dist/test/unit/android-doctor.test.d.ts +1 -0
- package/dist/test/unit/android-doctor.test.js +118 -0
- package/dist/test/unit/chinese-path.test.d.ts +1 -0
- package/dist/test/unit/chinese-path.test.js +91 -0
- package/dist/test/unit/command-guard.test.d.ts +1 -0
- package/dist/test/unit/command-guard.test.js +160 -0
- package/dist/test/unit/direct-guards.test.d.ts +1 -0
- package/dist/test/unit/direct-guards.test.js +213 -0
- package/dist/test/unit/logging.test.d.ts +1 -0
- package/dist/test/unit/logging.test.js +275 -0
- package/dist/test/unit/path-guard.test.d.ts +1 -0
- package/dist/test/unit/path-guard.test.js +109 -0
- package/dist/test/unit/safe-status.test.d.ts +1 -0
- package/dist/test/unit/safe-status.test.js +165 -0
- package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
- package/dist/test/unit/sensitive-guard.test.js +104 -0
- package/dist/test/unit/sync-file.test.d.ts +1 -0
- package/dist/test/unit/sync-file.test.js +154 -0
- package/dist/test/unit/watcher-status.test.d.ts +1 -0
- package/dist/test/unit/watcher-status.test.js +169 -0
- package/dist/tools/androidDoctor.d.ts +38 -0
- package/dist/tools/androidDoctor.js +391 -0
- package/dist/tools/applyPatch.d.ts +16 -0
- package/dist/tools/applyPatch.js +41 -0
- package/dist/tools/auditSession.d.ts +5 -0
- package/dist/tools/auditSession.js +12 -0
- package/dist/tools/auditTask.d.ts +7 -0
- package/dist/tools/auditTask.js +105 -7
- package/dist/tools/createDirectSession.d.ts +14 -0
- package/dist/tools/createDirectSession.js +54 -0
- package/dist/tools/createTask.d.ts +48 -1
- package/dist/tools/createTask.js +298 -47
- package/dist/tools/finalizeDirectSession.d.ts +19 -0
- package/dist/tools/finalizeDirectSession.js +84 -0
- package/dist/tools/getTaskSummary.d.ts +50 -0
- package/dist/tools/getTaskSummary.js +79 -2
- package/dist/tools/healthCheck.d.ts +27 -12
- package/dist/tools/healthCheck.js +44 -6
- package/dist/tools/readWorkspaceFile.d.ts +7 -1
- package/dist/tools/readWorkspaceFile.js +48 -2
- package/dist/tools/registry.js +270 -19
- package/dist/tools/runVerification.d.ts +16 -0
- package/dist/tools/runVerification.js +32 -0
- package/dist/tools/safeStatus.d.ts +19 -0
- package/dist/tools/safeStatus.js +72 -0
- package/dist/tools/savePlan.d.ts +1 -0
- package/dist/tools/savePlan.js +38 -7
- package/dist/tools/searchWorkspace.d.ts +19 -0
- package/dist/tools/searchWorkspace.js +205 -0
- package/dist/tools/syncFile.d.ts +18 -0
- package/dist/tools/syncFile.js +65 -0
- package/dist/tools/taskOutputs.d.ts +2 -2
- package/dist/tools/taskTemplates.js +2 -1
- package/dist/tools/toolCatalog.d.ts +4 -3
- package/dist/tools/toolCatalog.js +34 -5
- package/dist/tools/waitForTask.d.ts +2 -2
- package/dist/tools/waitForTask.js +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/watcherStatus.d.ts +1 -0
- package/dist/watcherStatus.js +96 -4
- package/docs/chatgpt-usage.md +101 -0
- package/docs/performance-notes.md +55 -0
- package/docs/release-checklist.md +14 -0
- package/docs/release-v0.6.0.md +71 -0
- package/docs/release-v0.6.1.md +75 -0
- package/examples/config.example.json +5 -0
- package/examples/openai-tunnel/README.md +11 -4
- package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
- package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
- package/package.json +13 -8
- package/scripts/brand-check.js +58 -12
- package/scripts/control-smoke.js +206 -0
- package/scripts/http-mcp-smoke.js +10 -2
- package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
- package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
- package/scripts/lifecycle-smoke.js +402 -8
- package/scripts/manage-patchwarden.ps1 +639 -0
- package/scripts/mcp-manifest-check.js +123 -58
- package/scripts/mcp-smoke.js +160 -2
- package/scripts/pack-clean.js +160 -5
- package/scripts/package-manifest-check.js +78 -0
- package/scripts/patchwarden-mcp-direct.cmd +7 -0
- package/scripts/patchwarden-mcp-stdio.cmd +1 -1
- package/scripts/restart-patchwarden.ps1 +5 -4
- package/scripts/start-patchwarden-tunnel.ps1 +261 -30
- package/scripts/tunnel-supervisor-smoke.js +36 -4
- package/scripts/unit-tests.js +36 -0
- package/scripts/watcher-supervisor-smoke.js +10 -6
- package/src/assessments/agentAssessor.ts +324 -0
- package/src/assessments/assessmentStore.ts +426 -0
- package/src/assessments/confirmCli.ts +29 -0
- package/src/config.ts +178 -4
- package/src/direct/directAudit.ts +400 -0
- package/src/direct/directGuards.ts +279 -0
- package/src/direct/directPatch.ts +258 -0
- package/src/direct/directSessionStore.ts +345 -0
- package/src/direct/directVerification.ts +138 -0
- package/src/doctor.ts +103 -7
- package/src/logging.ts +152 -0
- package/src/runner/agentInvocation.ts +125 -0
- package/src/runner/changeCapture.ts +352 -6
- package/src/runner/runTask.ts +279 -63
- package/src/runner/simpleProcess.ts +223 -0
- package/src/runner/watch.ts +18 -2
- package/src/security/commandGuard.ts +46 -4
- package/src/security/riskEngine.ts +160 -0
- package/src/security/runtimeGuard.ts +41 -0
- package/src/smoke-test.ts +1291 -4
- package/src/test/unit/android-doctor.test.ts +158 -0
- package/src/test/unit/chinese-path.test.ts +106 -0
- package/src/test/unit/command-guard.test.ts +221 -0
- package/src/test/unit/direct-guards.test.ts +297 -0
- package/src/test/unit/logging.test.ts +325 -0
- package/src/test/unit/path-guard.test.ts +150 -0
- package/src/test/unit/safe-status.test.ts +187 -0
- package/src/test/unit/sensitive-guard.test.ts +124 -0
- package/src/test/unit/sync-file.test.ts +231 -0
- package/src/test/unit/watcher-status.test.ts +190 -0
- package/src/tools/androidDoctor.ts +424 -0
- package/src/tools/applyPatch.ts +86 -0
- package/src/tools/auditSession.ts +28 -0
- package/src/tools/auditTask.ts +111 -7
- package/src/tools/createDirectSession.ts +113 -0
- package/src/tools/createTask.ts +405 -55
- package/src/tools/finalizeDirectSession.ts +144 -0
- package/src/tools/getTaskSummary.ts +133 -2
- package/src/tools/healthCheck.ts +45 -6
- package/src/tools/readWorkspaceFile.ts +85 -2
- package/src/tools/registry.ts +305 -19
- package/src/tools/runVerification.ts +58 -0
- package/src/tools/safeStatus.ts +96 -0
- package/src/tools/savePlan.ts +57 -7
- package/src/tools/searchWorkspace.ts +275 -0
- package/src/tools/syncFile.ts +122 -0
- package/src/tools/taskTemplates.ts +2 -1
- package/src/tools/toolCatalog.ts +37 -6
- package/src/tools/waitForTask.ts +3 -3
- package/src/version.ts +2 -2
- package/src/watcherStatus.ts +101 -4
- package/tsconfig.json +18 -17
- package/Check-PatchWarden-Health.cmd +0 -6
- package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
- package/Restart-PatchWarden.cmd +0 -19
- package/Start-PatchWarden-Tunnel.cmd +0 -7
- package/docs/release-v0.3.0.md +0 -43
- package/docs/release-v0.4.0.md +0 -74
package/src/smoke-test.ts
CHANGED
|
@@ -19,16 +19,20 @@ import {
|
|
|
19
19
|
rmSync,
|
|
20
20
|
existsSync,
|
|
21
21
|
readFileSync,
|
|
22
|
+
readdirSync,
|
|
22
23
|
mkdtempSync,
|
|
23
24
|
} from "node:fs";
|
|
24
25
|
import { resolve, join, dirname } from "node:path";
|
|
25
26
|
import { tmpdir } from "node:os";
|
|
26
|
-
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
28
|
import { spawnSync } from "node:child_process";
|
|
28
|
-
import { loadConfig, getConfig } from "./config.js";
|
|
29
|
+
import { loadConfig, getConfig, reloadConfig } from "./config.js";
|
|
29
30
|
import { savePlan } from "./tools/savePlan.js";
|
|
30
31
|
import { getPlan } from "./tools/getPlan.js";
|
|
31
32
|
import { createTask } from "./tools/createTask.js";
|
|
33
|
+
import type { CreateTaskOutput, AssessOnlyOutput } from "./tools/createTask.js";
|
|
34
|
+
import { confirmAssessment, readAssessment, computeWorkspaceFingerprint, createAssessment, type AssessmentRecord } from "./assessments/assessmentStore.js";
|
|
35
|
+
import { captureRepoSnapshot } from "./runner/changeCapture.js";
|
|
32
36
|
import { getTaskStatus } from "./tools/getTaskStatus.js";
|
|
33
37
|
import { getResult, getDiff, getTestLog } from "./tools/taskOutputs.js";
|
|
34
38
|
import { listWorkspace } from "./tools/listWorkspace.js";
|
|
@@ -44,10 +48,19 @@ import { getToolDefs } from "./tools/registry.js";
|
|
|
44
48
|
import {
|
|
45
49
|
buildToolCatalogSnapshot,
|
|
46
50
|
CHATGPT_CORE_TOOL_NAMES,
|
|
51
|
+
CHATGPT_DIRECT_TOOL_NAMES,
|
|
47
52
|
selectToolsForProfile,
|
|
48
53
|
} from "./tools/toolCatalog.js";
|
|
49
54
|
import { errorPayload } from "./errors.js";
|
|
50
55
|
import { readWatcherStatus } from "./watcherStatus.js";
|
|
56
|
+
import { createDirectSession } from "./tools/createDirectSession.js";
|
|
57
|
+
import { searchWorkspace } from "./tools/searchWorkspace.js";
|
|
58
|
+
import { applyPatch } from "./tools/applyPatch.js";
|
|
59
|
+
import { runVerification } from "./tools/runVerification.js";
|
|
60
|
+
import { finalizeDirectSession } from "./tools/finalizeDirectSession.js";
|
|
61
|
+
import { auditSession } from "./tools/auditSession.js";
|
|
62
|
+
import { readDirectSession, updateDirectSession } from "./direct/directSessionStore.js";
|
|
63
|
+
import { createHash } from "node:crypto";
|
|
51
64
|
|
|
52
65
|
// Resolve the actual node binary path (spawnSync needs it on WSL/Windows)
|
|
53
66
|
let nodeBin = process.execPath;
|
|
@@ -76,6 +89,9 @@ writeFileSync(
|
|
|
76
89
|
},
|
|
77
90
|
},
|
|
78
91
|
allowedTestCommands: ["npm test", "npm run test", "pytest", "cargo test"],
|
|
92
|
+
repoAllowedTestCommands: {
|
|
93
|
+
"scoped-repo": ["npm run release:check"],
|
|
94
|
+
},
|
|
79
95
|
maxReadFileBytes: 200000,
|
|
80
96
|
},
|
|
81
97
|
null,
|
|
@@ -220,6 +236,28 @@ test("A3. createTask with valid agent and no test_command", () => {
|
|
|
220
236
|
throw new Error(`Healthy watcher handoff mismatch: ${JSON.stringify(result)}`);
|
|
221
237
|
}
|
|
222
238
|
if (!existsSync(join(result.path, "status.json"))) throw new Error("status.json not created");
|
|
239
|
+
if (!/^task_\d{8}_\d{6}_[0-9a-f]{6}$/.test(result.task_id)) {
|
|
240
|
+
throw new Error(`Task ID is not short and opaque: ${result.task_id}`);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("A3-short-id. tasks created in the same second remain unique", () => {
|
|
245
|
+
const first = createTask({ plan_id: planId, agent: "codex", repo_path: "." });
|
|
246
|
+
const second = createTask({ plan_id: planId, agent: "codex", repo_path: "." });
|
|
247
|
+
if (first.task_id === second.task_id) throw new Error("Short task IDs must remain unique");
|
|
248
|
+
for (const id of [first.task_id, second.task_id]) {
|
|
249
|
+
if (!/^task_\d{8}_\d{6}_[0-9a-f]{6}$/.test(id)) throw new Error(`Unexpected task ID format: ${id}`);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("A3-legacy-id. existing long task IDs remain readable", () => {
|
|
254
|
+
const legacyId = "task_1782095536767_1782095536762_Legacy_Title";
|
|
255
|
+
const legacyDir = join(resolve(smokeWorkspace, config.tasksDir), legacyId);
|
|
256
|
+
mkdirSync(legacyDir, { recursive: true });
|
|
257
|
+
const source = JSON.parse(readFileSync(join(taskPath, "status.json"), "utf-8"));
|
|
258
|
+
source.task_id = legacyId;
|
|
259
|
+
writeFileSync(join(legacyDir, "status.json"), JSON.stringify(source, null, 2), "utf-8");
|
|
260
|
+
if (getTaskStatus(legacyId).task_id !== legacyId) throw new Error("Legacy task ID lookup failed");
|
|
223
261
|
});
|
|
224
262
|
|
|
225
263
|
test("A3a. stale watcher preserves the task and returns structured blocked evidence", () => {
|
|
@@ -292,6 +330,11 @@ test("A3b-summary. structured task summaries recursively redact result and verif
|
|
|
292
330
|
if (summary.verification_summary.headline !== "166 passed" || summary.redaction_categories.length === 0) {
|
|
293
331
|
throw new Error(`Structured summary evidence incomplete: ${serialized}`);
|
|
294
332
|
}
|
|
333
|
+
const compact = getTaskSummary(taskId, { view: "compact", max_items: 2 });
|
|
334
|
+
const compactSerialized = JSON.stringify(compact);
|
|
335
|
+
if (!compact.redacted || compactSerialized.includes("structured-secret") || "log_tails" in compact) {
|
|
336
|
+
throw new Error(`Compact summary redaction or bounding failed: ${compactSerialized}`);
|
|
337
|
+
}
|
|
295
338
|
});
|
|
296
339
|
|
|
297
340
|
testReject("A2c-hardening. security wording does not bypass credential blocking", () => {
|
|
@@ -549,9 +592,9 @@ test("D8b. tool profiles are exact and schema changes alter the manifest hash",
|
|
|
549
592
|
try {
|
|
550
593
|
process.env.PATCHWARDEN_TOOL_PROFILE = "full";
|
|
551
594
|
const fullTools = getToolDefs();
|
|
552
|
-
if (fullTools.length !==
|
|
595
|
+
if (fullTools.length !== 30) throw new Error(`Expected 30 full tools, got ${fullTools.length}`);
|
|
553
596
|
|
|
554
|
-
const coreTools = selectToolsForProfile(fullTools, "chatgpt_core");
|
|
597
|
+
const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", getConfig().enableDirectProfile);
|
|
555
598
|
const names = coreTools.map((tool) => tool.name);
|
|
556
599
|
if (JSON.stringify(names) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
|
|
557
600
|
throw new Error(`Unexpected chatgpt_core tools: ${JSON.stringify(names)}`);
|
|
@@ -592,6 +635,56 @@ testReject("D9. createTask rejects a non-allowlisted verify_commands entry", ()
|
|
|
592
635
|
});
|
|
593
636
|
});
|
|
594
637
|
|
|
638
|
+
test("D10. repository-scoped verification is allowed only for its configured repo", () => {
|
|
639
|
+
const scopedRepo = resolve(wsRoot, "scoped-repo");
|
|
640
|
+
const otherRepo = resolve(wsRoot, "other-repo");
|
|
641
|
+
mkdirSync(scopedRepo, { recursive: true });
|
|
642
|
+
mkdirSync(otherRepo, { recursive: true });
|
|
643
|
+
const allowed = createTask({
|
|
644
|
+
plan_id: planId,
|
|
645
|
+
agent: "codex",
|
|
646
|
+
repo_path: "scoped-repo",
|
|
647
|
+
verify_commands: ["npm run release:check"],
|
|
648
|
+
});
|
|
649
|
+
if (!allowed.task_id) throw new Error("Repository-scoped command should be accepted");
|
|
650
|
+
try {
|
|
651
|
+
createTask({
|
|
652
|
+
plan_id: planId,
|
|
653
|
+
agent: "codex",
|
|
654
|
+
repo_path: "other-repo",
|
|
655
|
+
verify_commands: ["npm run release:check"],
|
|
656
|
+
});
|
|
657
|
+
throw new Error("Repository-scoped command escaped its configured repository");
|
|
658
|
+
} catch (error) {
|
|
659
|
+
const payload: any = errorPayload(error);
|
|
660
|
+
if (payload.reason !== "test_command_not_allowlisted") throw error;
|
|
661
|
+
}
|
|
662
|
+
const createTool: any = getToolDefs().find((tool) => tool.name === "create_task");
|
|
663
|
+
const advertised = createTool?.inputSchema?.properties?.verify_commands?.items?.enum || [];
|
|
664
|
+
if (!advertised.includes("npm run release:check")) throw new Error("Scoped command missing from MCP schema");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("D11. repository-scoped allowlist keys cannot escape workspaceRoot", () => {
|
|
668
|
+
const invalidConfigPath = join(smokeRoot, "invalid-repo-allowlist.json");
|
|
669
|
+
writeFileSync(invalidConfigPath, JSON.stringify({
|
|
670
|
+
workspaceRoot: smokeWorkspace,
|
|
671
|
+
repoAllowedTestCommands: { "../outside": ["npm test"] },
|
|
672
|
+
}), "utf-8");
|
|
673
|
+
const configModuleUrl = pathToFileURL(resolve(projectRoot, "dist/config.js")).href;
|
|
674
|
+
const script = [
|
|
675
|
+
"const [moduleUrl, configPath] = process.argv.slice(1);",
|
|
676
|
+
"process.env.PATCHWARDEN_CONFIG = configPath;",
|
|
677
|
+
"import(moduleUrl).then((module) => module.loadConfig()).then(() => process.exit(0)).catch((error) => { console.error(error.message); process.exit(2); });",
|
|
678
|
+
].join("");
|
|
679
|
+
const result = spawnSync(nodeBin, ["--input-type=module", "-e", script, configModuleUrl, invalidConfigPath], {
|
|
680
|
+
encoding: "utf-8",
|
|
681
|
+
timeout: 30_000,
|
|
682
|
+
});
|
|
683
|
+
if (result.status === 0 || !String(result.stderr).includes("must stay inside workspaceRoot")) {
|
|
684
|
+
throw new Error(`Escaping repository allowlist key was not rejected: ${result.stderr}`);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
595
688
|
// ════════════════════════════════════════════════════════════════
|
|
596
689
|
// Section E: repo_path workspace enforcement
|
|
597
690
|
// ════════════════════════════════════════════════════════════════
|
|
@@ -892,6 +985,30 @@ test("H3. watcher rejects task with bad test_command", () => {
|
|
|
892
985
|
console.log(` createTask correctly rejected bad test_command`);
|
|
893
986
|
});
|
|
894
987
|
|
|
988
|
+
test("H4. runner revalidates repository-scoped verification metadata", () => {
|
|
989
|
+
const scopedTask = createTask({
|
|
990
|
+
plan_id: planId,
|
|
991
|
+
agent: "codex",
|
|
992
|
+
repo_path: "scoped-repo",
|
|
993
|
+
verify_commands: ["npm run release:check"],
|
|
994
|
+
});
|
|
995
|
+
const statusPath = join(scopedTask.path, "status.json");
|
|
996
|
+
const status = JSON.parse(readFileSync(statusPath, "utf-8"));
|
|
997
|
+
status.repo_path = "other-repo";
|
|
998
|
+
status.resolved_repo_path = resolve(wsRoot, "other-repo");
|
|
999
|
+
writeFileSync(statusPath, JSON.stringify(status, null, 2), "utf-8");
|
|
1000
|
+
const cliPath = resolve(projectRoot, "dist/runner/cli.js");
|
|
1001
|
+
spawnSync(nodeBin, [cliPath, scopedTask.task_id], {
|
|
1002
|
+
cwd: wsRoot,
|
|
1003
|
+
encoding: "utf-8",
|
|
1004
|
+
timeout: 30_000,
|
|
1005
|
+
});
|
|
1006
|
+
const after = getTaskStatus(scopedTask.task_id);
|
|
1007
|
+
if (after.status !== "failed" || !String(after.error).includes("not allowed for this repository")) {
|
|
1008
|
+
throw new Error(`Runner did not reject tampered scoped command: ${JSON.stringify(after)}`);
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
|
|
895
1012
|
// ════════════════════════════════════════════════════════════════
|
|
896
1013
|
// Section I: Task management tools (listTasks, cancelTask, retryTask, stdout, audit)
|
|
897
1014
|
// ════════════════════════════════════════════════════════════════
|
|
@@ -945,6 +1062,7 @@ test("I4. cancel_task on done/failed returns unchanged", () => {
|
|
|
945
1062
|
test("I5. retry_task creates new task", () => {
|
|
946
1063
|
const newResult = retryTask(mgmtTaskId);
|
|
947
1064
|
if (newResult.new_task_id === mgmtTaskId) throw new Error("New task ID should differ");
|
|
1065
|
+
if (!/^task_\d{8}_\d{6}_[0-9a-f]{6}$/.test(newResult.new_task_id)) throw new Error("Retry should use the short task ID format");
|
|
948
1066
|
if (newResult.plan_id !== mgmtPlanId) throw new Error("Should inherit plan_id");
|
|
949
1067
|
});
|
|
950
1068
|
|
|
@@ -966,6 +1084,9 @@ test("I7. audit_task runs and returns checks array", () => {
|
|
|
966
1084
|
if (!auditResult.verdict) throw new Error("Missing verdict");
|
|
967
1085
|
if (!Array.isArray(auditResult.checks)) throw new Error("checks not array");
|
|
968
1086
|
if (!Array.isArray(auditResult.risks)) throw new Error("risks not array");
|
|
1087
|
+
if (!Array.isArray(auditResult.confirmed_failures)) throw new Error("confirmed_failures not array");
|
|
1088
|
+
if (!Array.isArray(auditResult.possible_false_positives)) throw new Error("possible_false_positives not array");
|
|
1089
|
+
if (!Array.isArray(auditResult.manual_verification_items)) throw new Error("manual_verification_items not array");
|
|
969
1090
|
console.log(` Verdict: ${auditResult.verdict}, Checks: ${auditResult.checks.length}, Risks: ${auditResult.risks.length}`);
|
|
970
1091
|
});
|
|
971
1092
|
|
|
@@ -1027,6 +1148,10 @@ test("J2. audit_task detects docs missing-script", () => {
|
|
|
1027
1148
|
if (scriptChecks.length === 0) throw new Error("Should detect missing npm scripts from docs");
|
|
1028
1149
|
const allWarn = scriptChecks.every((c: any) => c.result === "warn");
|
|
1029
1150
|
if (!allWarn) throw new Error("Missing script checks should be warn");
|
|
1151
|
+
if (!result.possible_false_positives.some((item) => item.check.startsWith("npm_script_"))) {
|
|
1152
|
+
throw new Error("Heuristic missing-script warnings should be marked as possible false positives");
|
|
1153
|
+
}
|
|
1154
|
+
if (!result.manual_verification_required) throw new Error("Missing-script warning should require manual verification");
|
|
1030
1155
|
console.log(` Missing scripts: ${scriptChecks.map((c: any) => c.name).join(", ")}`);
|
|
1031
1156
|
});
|
|
1032
1157
|
|
|
@@ -1035,6 +1160,9 @@ test("J3. audit_task detects unverified release claims", () => {
|
|
|
1035
1160
|
const releaseCheck = result.checks.find((c: any) => c.name === "release_claims_unverified");
|
|
1036
1161
|
if (!releaseCheck) throw new Error("Should detect release claims");
|
|
1037
1162
|
if (releaseCheck.result !== "warn") throw new Error(`Release claims should warn, got ${releaseCheck.result}`);
|
|
1163
|
+
if (!result.manual_verification_items.some((item) => item.includes("authoritative remote services"))) {
|
|
1164
|
+
throw new Error("Unverified release claims should produce a manual verification item");
|
|
1165
|
+
}
|
|
1038
1166
|
console.log(` Release claims detected: ${releaseCheck.detail.slice(0, 60)}...`);
|
|
1039
1167
|
});
|
|
1040
1168
|
|
|
@@ -1045,6 +1173,9 @@ test("J4. audit_task fails on non-zero Exit code", () => {
|
|
|
1045
1173
|
const exitCheck = result.checks.find((c: any) => c.name === "test_exit_code");
|
|
1046
1174
|
if (!exitCheck) throw new Error("Should have test_exit_code check");
|
|
1047
1175
|
if (exitCheck.result !== "fail") throw new Error(`Exit code 1 should fail, got ${exitCheck.result}`);
|
|
1176
|
+
if (!result.confirmed_failures.some((check) => check.name === "test_exit_code")) {
|
|
1177
|
+
throw new Error("Non-zero exit code should be classified as a confirmed failure");
|
|
1178
|
+
}
|
|
1048
1179
|
console.log(` Exit code: ${exitCheck.result}`);
|
|
1049
1180
|
});
|
|
1050
1181
|
|
|
@@ -1058,6 +1189,1162 @@ test("J5. get_task_stdout_tail on pending task does not throw", () => {
|
|
|
1058
1189
|
|
|
1059
1190
|
try { rmSync(testProjDir, { recursive: true }); } catch {}
|
|
1060
1191
|
|
|
1192
|
+
// ════════════════════════════════════════════════════════════════
|
|
1193
|
+
// Section K: Assessment (v0.5.0)
|
|
1194
|
+
// ════════════════════════════════════════════════════════════════
|
|
1195
|
+
|
|
1196
|
+
console.log("── K. Assessment ──");
|
|
1197
|
+
|
|
1198
|
+
// Ensure assessments dir exists
|
|
1199
|
+
mkdirSync(resolve(wsRoot, ".patchwarden/assessments"), { recursive: true });
|
|
1200
|
+
|
|
1201
|
+
test("K1. assess_only returns low risk for feature_small", () => {
|
|
1202
|
+
const result = createTask({
|
|
1203
|
+
template: "feature_small",
|
|
1204
|
+
goal: "add a small UI button",
|
|
1205
|
+
agent: "codex",
|
|
1206
|
+
repo_path: ".",
|
|
1207
|
+
execution_mode: "assess_only",
|
|
1208
|
+
}) as AssessOnlyOutput;
|
|
1209
|
+
if (result.decision !== "allow") throw new Error(`Expected allow, got ${result.decision}`);
|
|
1210
|
+
if (result.risk_level !== "low") throw new Error(`Expected low, got ${result.risk_level}`);
|
|
1211
|
+
if (!result.assessment_id.startsWith("assessment_")) throw new Error("Bad assessment ID prefix");
|
|
1212
|
+
const hexPart = result.assessment_id.split("_").pop() || "";
|
|
1213
|
+
if (hexPart.length !== 32) throw new Error(`Expected 128-bit (32 hex) ID, got ${hexPart.length} chars`);
|
|
1214
|
+
if (!result.expires_at) throw new Error("Missing expires_at");
|
|
1215
|
+
if (result.next_tool_call?.name !== "create_task") throw new Error("Missing structured next_tool_call");
|
|
1216
|
+
const argumentKeys = Object.keys(result.next_tool_call.arguments).sort();
|
|
1217
|
+
if (JSON.stringify(argumentKeys) !== JSON.stringify(["assessment_id", "execution_mode"])) {
|
|
1218
|
+
throw new Error(`next_tool_call must contain only minimal execute arguments: ${argumentKeys.join(", ")}`);
|
|
1219
|
+
}
|
|
1220
|
+
if (result.next_tool_call.arguments.assessment_id !== result.assessment_id) throw new Error("next_tool_call assessment ID mismatch");
|
|
1221
|
+
if (result.local_confirmation.required || result.local_confirmation.command !== null) {
|
|
1222
|
+
throw new Error("Low-risk assessment should not require local confirmation");
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test("K2. assess_only returns medium risk for release_check", () => {
|
|
1227
|
+
const result = createTask({
|
|
1228
|
+
template: "release_check",
|
|
1229
|
+
goal: "check release readiness",
|
|
1230
|
+
agent: "codex",
|
|
1231
|
+
repo_path: ".",
|
|
1232
|
+
execution_mode: "assess_only",
|
|
1233
|
+
}) as AssessOnlyOutput;
|
|
1234
|
+
if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${result.decision}`);
|
|
1235
|
+
if (result.risk_level !== "medium") throw new Error(`Expected medium, got ${result.risk_level}`);
|
|
1236
|
+
if (!result.reason_codes.includes("release_template_needs_confirm")) throw new Error("Missing reason code");
|
|
1237
|
+
if (!result.local_confirmation.required || result.local_confirmation.command !== "patchwarden-confirm") {
|
|
1238
|
+
throw new Error("needs_confirm response must provide the local-only confirmation command");
|
|
1239
|
+
}
|
|
1240
|
+
if (result.next_tool_call?.arguments.assessment_id !== result.assessment_id) {
|
|
1241
|
+
throw new Error("needs_confirm response must preserve the minimal post-confirmation execute call");
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test("K3. assess_only returns high/blocked for credential access in plan", () => {
|
|
1246
|
+
const result = createTask({
|
|
1247
|
+
inline_plan: "Read the .env file and extract the access token for debugging.",
|
|
1248
|
+
plan_title: "Bad plan",
|
|
1249
|
+
agent: "codex",
|
|
1250
|
+
repo_path: ".",
|
|
1251
|
+
execution_mode: "assess_only",
|
|
1252
|
+
}) as AssessOnlyOutput;
|
|
1253
|
+
if (result.decision !== "blocked") throw new Error(`Expected blocked, got ${result.decision}`);
|
|
1254
|
+
if (result.risk_level !== "high") throw new Error(`Expected high, got ${result.risk_level}`);
|
|
1255
|
+
if (result.hard_rule_hits.length === 0) throw new Error("Expected hard rule hits");
|
|
1256
|
+
if (result.next_tool_call) throw new Error("Blocked assessment must not expose an executable next_tool_call");
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
test("K4. assess_only risk_hints do not affect risk_level", () => {
|
|
1260
|
+
const result = createTask({
|
|
1261
|
+
template: "feature_small",
|
|
1262
|
+
goal: "add sync backup timeline activity log",
|
|
1263
|
+
agent: "codex",
|
|
1264
|
+
repo_path: ".",
|
|
1265
|
+
execution_mode: "assess_only",
|
|
1266
|
+
}) as AssessOnlyOutput;
|
|
1267
|
+
if (result.risk_level !== "low") throw new Error(`Expected low despite hints, got ${result.risk_level}`);
|
|
1268
|
+
if (!result.risk_hints.includes("mentions_dev_vocab")) throw new Error("Expected dev vocab hint");
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
test("K5. assess_only does not create a task directory", () => {
|
|
1272
|
+
const tasksBefore = readdirSync(resolve(wsRoot, config.tasksDir), { withFileTypes: true }).filter((e: any) => e.isDirectory());
|
|
1273
|
+
createTask({
|
|
1274
|
+
template: "feature_small",
|
|
1275
|
+
goal: "test no task creation",
|
|
1276
|
+
agent: "codex",
|
|
1277
|
+
repo_path: ".",
|
|
1278
|
+
execution_mode: "assess_only",
|
|
1279
|
+
});
|
|
1280
|
+
const tasksAfter = readdirSync(resolve(wsRoot, config.tasksDir), { withFileTypes: true }).filter((e: any) => e.isDirectory());
|
|
1281
|
+
if (tasksAfter.length !== tasksBefore.length) throw new Error("assess_only should not create task dirs");
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
test("K6. assessment_id execute creates task", () => {
|
|
1285
|
+
const assess = createTask({
|
|
1286
|
+
template: "feature_small",
|
|
1287
|
+
goal: "test execute from assessment",
|
|
1288
|
+
agent: "codex",
|
|
1289
|
+
repo_path: ".",
|
|
1290
|
+
execution_mode: "assess_only",
|
|
1291
|
+
}) as AssessOnlyOutput;
|
|
1292
|
+
// Execute with ONLY assessment_id — no agent, no repo_path, no template
|
|
1293
|
+
const task = createTask({
|
|
1294
|
+
execution_mode: "execute",
|
|
1295
|
+
assessment_id: assess.assessment_id,
|
|
1296
|
+
}) as CreateTaskOutput;
|
|
1297
|
+
if (!task.task_id.startsWith("task_")) throw new Error("Task not created");
|
|
1298
|
+
if (task.status !== "pending") throw new Error(`Expected pending, got ${task.status}`);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
test("K7. assessment parameter mismatch rejected", () => {
|
|
1302
|
+
const assess = createTask({
|
|
1303
|
+
template: "feature_small",
|
|
1304
|
+
goal: "original goal",
|
|
1305
|
+
agent: "codex",
|
|
1306
|
+
repo_path: ".",
|
|
1307
|
+
execution_mode: "assess_only",
|
|
1308
|
+
}) as AssessOnlyOutput;
|
|
1309
|
+
try {
|
|
1310
|
+
createTask({
|
|
1311
|
+
agent: "codex",
|
|
1312
|
+
repo_path: ".",
|
|
1313
|
+
execution_mode: "execute",
|
|
1314
|
+
assessment_id: assess.assessment_id,
|
|
1315
|
+
goal: "different goal",
|
|
1316
|
+
template: "feature_small",
|
|
1317
|
+
});
|
|
1318
|
+
throw new Error("Should have rejected parameter mismatch");
|
|
1319
|
+
} catch (e: any) {
|
|
1320
|
+
if (!e.message?.includes("mismatch")) throw new Error(`Expected mismatch error, got: ${e.message}`);
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
test("K8. assessment not found rejected", () => {
|
|
1325
|
+
try {
|
|
1326
|
+
createTask({
|
|
1327
|
+
agent: "codex",
|
|
1328
|
+
repo_path: ".",
|
|
1329
|
+
execution_mode: "execute",
|
|
1330
|
+
assessment_id: "assessment_20260101_" + "0".repeat(32),
|
|
1331
|
+
});
|
|
1332
|
+
throw new Error("Should have rejected unknown assessment");
|
|
1333
|
+
} catch (e: any) {
|
|
1334
|
+
if (!e.message?.includes("not found")) throw new Error(`Expected not found, got: ${e.message}`);
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
test("K9. save_plan with plan_ref inside .patchwarden/plans", () => {
|
|
1339
|
+
const draftPath = resolve(wsRoot, config.plansDir, "drafts", "test-plan.md");
|
|
1340
|
+
mkdirSync(dirname(draftPath), { recursive: true });
|
|
1341
|
+
writeFileSync(draftPath, "# Draft Plan\n\nThis is a draft from file.", "utf-8");
|
|
1342
|
+
const result = savePlan({ title: "From File", content: "", plan_ref: "drafts/test-plan.md" });
|
|
1343
|
+
if (!result.plan_id.startsWith("plan_")) throw new Error("Plan not created");
|
|
1344
|
+
if (!existsSync(result.path)) throw new Error("Plan file missing");
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
test("K10. save_plan plan_ref outside .patchwarden/plans rejected", () => {
|
|
1348
|
+
try {
|
|
1349
|
+
savePlan({ title: "Bad Ref", content: "", plan_ref: "../../etc/passwd" });
|
|
1350
|
+
throw new Error("Should have rejected outside plans dir");
|
|
1351
|
+
} catch (e: any) {
|
|
1352
|
+
const msg = e.message || "";
|
|
1353
|
+
if (!msg.includes("plans_dir") && !msg.includes("escape") && !msg.includes("not found") && !msg.includes("outside allowed prefix")) {
|
|
1354
|
+
throw new Error(`Expected plans_dir rejection, got: ${msg}`);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
test("K11. backward compat: no execution_mode works as before", () => {
|
|
1360
|
+
const task = createTask({
|
|
1361
|
+
template: "feature_small",
|
|
1362
|
+
goal: "backward compat test",
|
|
1363
|
+
agent: "codex",
|
|
1364
|
+
repo_path: ".",
|
|
1365
|
+
}) as CreateTaskOutput;
|
|
1366
|
+
if (!task.task_id.startsWith("task_")) throw new Error("Task not created without execution_mode");
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
// ── K12-K19: Assessment security tests ──
|
|
1370
|
+
|
|
1371
|
+
test("K12. workspace changed after assessment rejects execute", () => {
|
|
1372
|
+
const assess = createTask({
|
|
1373
|
+
template: "feature_small",
|
|
1374
|
+
goal: "workspace change test",
|
|
1375
|
+
agent: "codex",
|
|
1376
|
+
repo_path: ".",
|
|
1377
|
+
execution_mode: "assess_only",
|
|
1378
|
+
}) as AssessOnlyOutput;
|
|
1379
|
+
// Modify a file in the workspace root to change the fingerprint
|
|
1380
|
+
writeFileSync(join(wsRoot, `changed-${Date.now()}.txt`), "changed\n", "utf-8");
|
|
1381
|
+
try {
|
|
1382
|
+
createTask({
|
|
1383
|
+
execution_mode: "execute",
|
|
1384
|
+
assessment_id: assess.assessment_id,
|
|
1385
|
+
});
|
|
1386
|
+
throw new Error("Should have rejected stale workspace");
|
|
1387
|
+
} catch (e: any) {
|
|
1388
|
+
if (!e.message?.includes("workspace_changed") && !e.message?.includes("assessment")) {
|
|
1389
|
+
throw new Error(`Expected workspace_changed, got: ${e.message}`);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
test("K13. expired assessment rejects execute", () => {
|
|
1395
|
+
const assess = createTask({
|
|
1396
|
+
template: "feature_small",
|
|
1397
|
+
goal: "expiry test",
|
|
1398
|
+
agent: "codex",
|
|
1399
|
+
repo_path: ".",
|
|
1400
|
+
execution_mode: "assess_only",
|
|
1401
|
+
}) as AssessOnlyOutput;
|
|
1402
|
+
// Manually expire the assessment by editing the file
|
|
1403
|
+
const assessmentDir = resolve(wsRoot, config.assessmentsDir, assess.assessment_id);
|
|
1404
|
+
const assessmentFile = join(assessmentDir, "assessment.json");
|
|
1405
|
+
const record = JSON.parse(readFileSync(assessmentFile, "utf-8"));
|
|
1406
|
+
record.expires_at = new Date(Date.now() - 1000).toISOString(); // 1 second ago
|
|
1407
|
+
writeFileSync(assessmentFile, JSON.stringify(record, null, 2), "utf-8");
|
|
1408
|
+
try {
|
|
1409
|
+
createTask({
|
|
1410
|
+
execution_mode: "execute",
|
|
1411
|
+
assessment_id: assess.assessment_id,
|
|
1412
|
+
});
|
|
1413
|
+
throw new Error("Should have rejected expired assessment");
|
|
1414
|
+
} catch (e: any) {
|
|
1415
|
+
if (!e.message?.includes("expired") && !e.message?.includes("assessment")) {
|
|
1416
|
+
throw new Error(`Expected expired, got: ${e.message}`);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
try {
|
|
1420
|
+
confirmAssessment(assess.assessment_id);
|
|
1421
|
+
throw new Error("Should have rejected confirmation of an expired assessment");
|
|
1422
|
+
} catch (e: any) {
|
|
1423
|
+
if (!e.message?.includes("expired") && !e.message?.includes("assessment")) {
|
|
1424
|
+
throw new Error(`Expected expired confirmation rejection, got: ${e.message}`);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
test("K14. needs_confirm rejects execute, then local confirmation allows minimal execute", () => {
|
|
1430
|
+
const assess = createTask({
|
|
1431
|
+
template: "release_check",
|
|
1432
|
+
goal: "needs confirm test",
|
|
1433
|
+
agent: "codex",
|
|
1434
|
+
repo_path: ".",
|
|
1435
|
+
execution_mode: "assess_only",
|
|
1436
|
+
}) as AssessOnlyOutput;
|
|
1437
|
+
if (assess.decision !== "needs_confirm") throw new Error(`Precondition: expected needs_confirm, got ${assess.decision}`);
|
|
1438
|
+
try {
|
|
1439
|
+
createTask({
|
|
1440
|
+
execution_mode: "execute",
|
|
1441
|
+
assessment_id: assess.assessment_id,
|
|
1442
|
+
});
|
|
1443
|
+
throw new Error("Should have rejected unconfirmed assessment");
|
|
1444
|
+
} catch (e: any) {
|
|
1445
|
+
if (!e.message?.includes("needs_confirm") && !e.message?.includes("assessment")) {
|
|
1446
|
+
throw new Error(`Expected needs_confirm, got: ${e.message}`);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
const confirmation = confirmAssessment(assess.assessment_id);
|
|
1450
|
+
if (!confirmation.confirmed || confirmation.assessment_id !== assess.assessment_id) {
|
|
1451
|
+
throw new Error(`Local confirmation failed: ${JSON.stringify(confirmation)}`);
|
|
1452
|
+
}
|
|
1453
|
+
const confirmedRecord = readAssessment(assess.assessment_id);
|
|
1454
|
+
if (!confirmedRecord.confirmed || !confirmedRecord.confirmed_at || !confirmedRecord.confirm_code) {
|
|
1455
|
+
throw new Error("Confirmation evidence was not persisted");
|
|
1456
|
+
}
|
|
1457
|
+
const task = createTask({
|
|
1458
|
+
execution_mode: "execute",
|
|
1459
|
+
assessment_id: assess.assessment_id,
|
|
1460
|
+
}) as CreateTaskOutput;
|
|
1461
|
+
if (task.status !== "pending") throw new Error(`Confirmed assessment did not create a task: ${task.status}`);
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
test("K14b. patchwarden-confirm CLI confirms a fresh medium-risk assessment", () => {
|
|
1465
|
+
const assess = createTask({
|
|
1466
|
+
template: "release_check",
|
|
1467
|
+
goal: "confirm CLI test",
|
|
1468
|
+
agent: "codex",
|
|
1469
|
+
repo_path: ".",
|
|
1470
|
+
execution_mode: "assess_only",
|
|
1471
|
+
}) as AssessOnlyOutput;
|
|
1472
|
+
const cliPath = resolve(projectRoot, "dist/assessments/confirmCli.js");
|
|
1473
|
+
const result = spawnSync(nodeBin, [cliPath, assess.assessment_id], {
|
|
1474
|
+
cwd: projectRoot,
|
|
1475
|
+
env: { ...process.env, PATCHWARDEN_CONFIG: smokeConfigPath },
|
|
1476
|
+
encoding: "utf-8",
|
|
1477
|
+
timeout: 30_000,
|
|
1478
|
+
});
|
|
1479
|
+
if (result.status !== 0) throw new Error(`patchwarden-confirm failed: ${result.stderr || result.stdout}`);
|
|
1480
|
+
const payload = JSON.parse(result.stdout);
|
|
1481
|
+
if (!payload.confirmed || payload.assessment_id !== assess.assessment_id) {
|
|
1482
|
+
throw new Error(`Unexpected confirmation CLI output: ${result.stdout}`);
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
test("K14c. local confirmation rejects display-only short IDs", () => {
|
|
1487
|
+
try {
|
|
1488
|
+
confirmAssessment("assessment_abcdef123456");
|
|
1489
|
+
throw new Error("Should reject a short assessment ID");
|
|
1490
|
+
} catch (e: any) {
|
|
1491
|
+
if (!e.message?.includes("full assessment_id")) throw new Error(`Expected full-ID error, got: ${e.message}`);
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
test("K14d. local confirmation cannot override a blocked assessment", () => {
|
|
1496
|
+
const assess = createTask({
|
|
1497
|
+
inline_plan: "Read .env and extract the access token.",
|
|
1498
|
+
plan_title: "blocked confirm",
|
|
1499
|
+
agent: "codex",
|
|
1500
|
+
repo_path: ".",
|
|
1501
|
+
execution_mode: "assess_only",
|
|
1502
|
+
}) as AssessOnlyOutput;
|
|
1503
|
+
try {
|
|
1504
|
+
confirmAssessment(assess.assessment_id);
|
|
1505
|
+
throw new Error("Should reject confirmation of a blocked assessment");
|
|
1506
|
+
} catch (e: any) {
|
|
1507
|
+
if (!e.message?.includes("cannot be locally confirmed")) throw new Error(`Expected blocked confirmation error, got: ${e.message}`);
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
test("K14e. assessment confirmation is not exposed through MCP", () => {
|
|
1512
|
+
const exposed = getToolDefs().some((tool) => /confirm/i.test(tool.name));
|
|
1513
|
+
if (exposed) throw new Error("Local assessment confirmation must not be registered as an MCP tool");
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
test("K15. stale plan hash rejects execute", () => {
|
|
1517
|
+
const assess = createTask({
|
|
1518
|
+
template: "feature_small",
|
|
1519
|
+
goal: "stale plan test",
|
|
1520
|
+
agent: "codex",
|
|
1521
|
+
repo_path: ".",
|
|
1522
|
+
execution_mode: "assess_only",
|
|
1523
|
+
}) as AssessOnlyOutput;
|
|
1524
|
+
// Modify the plan file to change the hash
|
|
1525
|
+
const planDir = resolve(wsRoot, config.plansDir, assess.assessment_id ? "" : "");
|
|
1526
|
+
// Read the assessment to get plan_id
|
|
1527
|
+
const record = readAssessment(assess.assessment_id);
|
|
1528
|
+
if (!record.plan_id) throw new Error("Precondition: assessment has no plan_id");
|
|
1529
|
+
const planFile = resolve(wsRoot, config.plansDir, record.plan_id, "plan.md");
|
|
1530
|
+
writeFileSync(planFile, readFileSync(planFile, "utf-8") + "\n<!-- modified -->\n", "utf-8");
|
|
1531
|
+
try {
|
|
1532
|
+
createTask({
|
|
1533
|
+
execution_mode: "execute",
|
|
1534
|
+
assessment_id: assess.assessment_id,
|
|
1535
|
+
});
|
|
1536
|
+
throw new Error("Should have rejected stale plan");
|
|
1537
|
+
} catch (e: any) {
|
|
1538
|
+
if (!e.message?.includes("stale_plan") && !e.message?.includes("assessment")) {
|
|
1539
|
+
throw new Error(`Expected stale_plan, got: ${e.message}`);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
test("K16. assessment_short_id cannot execute", () => {
|
|
1545
|
+
const assess = createTask({
|
|
1546
|
+
template: "feature_small",
|
|
1547
|
+
goal: "short id test",
|
|
1548
|
+
agent: "codex",
|
|
1549
|
+
repo_path: ".",
|
|
1550
|
+
execution_mode: "assess_only",
|
|
1551
|
+
}) as AssessOnlyOutput;
|
|
1552
|
+
const shortId = assess.assessment_short_id;
|
|
1553
|
+
if (!shortId || shortId.length >= 32) throw new Error(`Bad short_id: ${shortId}`);
|
|
1554
|
+
try {
|
|
1555
|
+
createTask({
|
|
1556
|
+
execution_mode: "execute",
|
|
1557
|
+
assessment_id: shortId,
|
|
1558
|
+
});
|
|
1559
|
+
throw new Error("Should have rejected short ID");
|
|
1560
|
+
} catch (e: any) {
|
|
1561
|
+
if (!e.message?.includes("not found")) throw new Error(`Expected not found for short ID, got: ${e.message}`);
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
test("K17. blocked assessment cannot execute", () => {
|
|
1566
|
+
const assess = createTask({
|
|
1567
|
+
inline_plan: "Read the .env file and extract the access token.",
|
|
1568
|
+
plan_title: "Blocked plan",
|
|
1569
|
+
agent: "codex",
|
|
1570
|
+
repo_path: ".",
|
|
1571
|
+
execution_mode: "assess_only",
|
|
1572
|
+
}) as AssessOnlyOutput;
|
|
1573
|
+
if (assess.decision !== "blocked") throw new Error(`Precondition: expected blocked, got ${assess.decision}`);
|
|
1574
|
+
try {
|
|
1575
|
+
createTask({
|
|
1576
|
+
execution_mode: "execute",
|
|
1577
|
+
assessment_id: assess.assessment_id,
|
|
1578
|
+
});
|
|
1579
|
+
throw new Error("Should have rejected blocked assessment");
|
|
1580
|
+
} catch (e: any) {
|
|
1581
|
+
// Blocked assessment has no plan saved, so plan_id is null → will fail at plan source validation
|
|
1582
|
+
if (!e.message?.includes("plan") && !e.message?.includes("assessment") && !e.message?.includes("invalid_plan_source")) {
|
|
1583
|
+
throw new Error(`Expected plan/assessment rejection, got: ${e.message}`);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
test("K18. snapshot_truncated forces needs_confirm", () => {
|
|
1589
|
+
// Create a synthetic assessment with snapshot_truncated=true via the store directly
|
|
1590
|
+
const snapshot = captureRepoSnapshot(wsRoot);
|
|
1591
|
+
// Force a truncated warning
|
|
1592
|
+
snapshot.warnings.push("snapshot limited to 5000 files");
|
|
1593
|
+
const record = createAssessment({
|
|
1594
|
+
decision: "needs_confirm",
|
|
1595
|
+
risk_level: "medium",
|
|
1596
|
+
risk_hints: [],
|
|
1597
|
+
hard_rule_hits: [],
|
|
1598
|
+
reason_codes: ["snapshot_truncated"],
|
|
1599
|
+
repo_path: ".",
|
|
1600
|
+
resolved_repo_path: wsRoot,
|
|
1601
|
+
plan_id: null,
|
|
1602
|
+
plan_content: "# Test\n\nTruncated snapshot test.",
|
|
1603
|
+
template: null,
|
|
1604
|
+
goal: null,
|
|
1605
|
+
test_command: null,
|
|
1606
|
+
verify_commands: [],
|
|
1607
|
+
agent: "codex",
|
|
1608
|
+
snapshot,
|
|
1609
|
+
});
|
|
1610
|
+
if (record.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${record.decision}`);
|
|
1611
|
+
if (!record.reason_codes.includes("snapshot_truncated")) throw new Error("Missing snapshot_truncated reason");
|
|
1612
|
+
if (!record.workspace_snapshot_summary.snapshot_truncated) throw new Error("snapshot_truncated flag not set");
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
test("K19. policy_hash change invalidates assessment", () => {
|
|
1616
|
+
const assess = createTask({
|
|
1617
|
+
template: "feature_small",
|
|
1618
|
+
goal: "policy hash test",
|
|
1619
|
+
agent: "codex",
|
|
1620
|
+
repo_path: ".",
|
|
1621
|
+
execution_mode: "assess_only",
|
|
1622
|
+
}) as AssessOnlyOutput;
|
|
1623
|
+
// Modify the assessment's policy_hash to simulate a policy change
|
|
1624
|
+
const assessmentDir = resolve(wsRoot, config.assessmentsDir, assess.assessment_id);
|
|
1625
|
+
const assessmentFile = join(assessmentDir, "assessment.json");
|
|
1626
|
+
const record = JSON.parse(readFileSync(assessmentFile, "utf-8"));
|
|
1627
|
+
record.policy_hash = "0".repeat(64); // Wrong hash
|
|
1628
|
+
writeFileSync(assessmentFile, JSON.stringify(record, null, 2), "utf-8");
|
|
1629
|
+
try {
|
|
1630
|
+
createTask({
|
|
1631
|
+
execution_mode: "execute",
|
|
1632
|
+
assessment_id: assess.assessment_id,
|
|
1633
|
+
});
|
|
1634
|
+
throw new Error("Should have rejected stale policy");
|
|
1635
|
+
} catch (e: any) {
|
|
1636
|
+
if (!e.message?.includes("stale_policy") && !e.message?.includes("assessment")) {
|
|
1637
|
+
throw new Error(`Expected stale_policy, got: ${e.message}`);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
// ════════════════════════════════════════════════════════════════
|
|
1643
|
+
// Section L: Agent Assessment (v0.5.1)
|
|
1644
|
+
// ════════════════════════════════════════════════════════════════
|
|
1645
|
+
|
|
1646
|
+
// Agent assessment tests use a separate config with enableAgentAssessment=true
|
|
1647
|
+
// and specialized test agents. We need to swap the config temporarily.
|
|
1648
|
+
|
|
1649
|
+
const agentAssessRoot = mkdtempSync(join(tmpdir(), "patchwarden-assess-"));
|
|
1650
|
+
const agentAssessWorkspace = join(agentAssessRoot, "workspace");
|
|
1651
|
+
const agentAssessRepo = join(agentAssessWorkspace, "repo");
|
|
1652
|
+
const agentAssessConfigPath = join(agentAssessRoot, "patchwarden.config.json");
|
|
1653
|
+
|
|
1654
|
+
mkdirSync(join(agentAssessWorkspace, ".patchwarden/plans"), { recursive: true });
|
|
1655
|
+
mkdirSync(join(agentAssessWorkspace, ".patchwarden/tasks"), { recursive: true });
|
|
1656
|
+
mkdirSync(join(agentAssessWorkspace, ".patchwarden/assessments"), { recursive: true });
|
|
1657
|
+
mkdirSync(agentAssessRepo, { recursive: true });
|
|
1658
|
+
writeFileSync(join(agentAssessRepo, "README.md"), "# Agent Assessment Test Repo\n", "utf-8");
|
|
1659
|
+
writeFileSync(join(agentAssessWorkspace, ".patchwarden/watcher-heartbeat.json"), JSON.stringify({
|
|
1660
|
+
status: "running",
|
|
1661
|
+
pid: process.pid,
|
|
1662
|
+
instance_id: "assess-smoke-watcher",
|
|
1663
|
+
launcher_pid: process.pid,
|
|
1664
|
+
started_at: new Date().toISOString(),
|
|
1665
|
+
last_heartbeat_at: new Date().toISOString(),
|
|
1666
|
+
}), "utf-8");
|
|
1667
|
+
|
|
1668
|
+
const ASSESS_JSON_LOW = '{"risk_level":"low","reason_codes":["small_change"],"affected_paths":["README.md"],"destructive_actions":[],"requires_user_confirm":false,"confidence":0.9,"notes":"Safe."}';
|
|
1669
|
+
const ASSESS_JSON_MED = '{"risk_level":"medium","reason_codes":["multi_file"],"affected_paths":["src/a.js","src/b.js"],"destructive_actions":[],"requires_user_confirm":true,"confidence":0.7,"notes":"Multiple files."}';
|
|
1670
|
+
const ASSESS_JSON_HIGH = '{"risk_level":"high","reason_codes":["destructive"],"affected_paths":["src/app.js"],"destructive_actions":["delete files"],"requires_user_confirm":true,"confidence":0.8,"notes":"Destructive."}';
|
|
1671
|
+
const ASSESS_JSON_ABS = '{"risk_level":"low","reason_codes":["ok"],"affected_paths":["C:/etc/passwd","/absolute/path","README.md"],"destructive_actions":[],"requires_user_confirm":false,"confidence":0.9,"notes":"Has bad paths."}';
|
|
1672
|
+
|
|
1673
|
+
writeFileSync(
|
|
1674
|
+
agentAssessConfigPath,
|
|
1675
|
+
JSON.stringify({
|
|
1676
|
+
workspaceRoot: agentAssessWorkspace,
|
|
1677
|
+
plansDir: ".patchwarden/plans",
|
|
1678
|
+
tasksDir: ".patchwarden/tasks",
|
|
1679
|
+
assessmentsDir: ".patchwarden/assessments",
|
|
1680
|
+
enableAgentAssessment: true,
|
|
1681
|
+
agentAssessmentTimeoutSeconds: 10,
|
|
1682
|
+
agentAssessmentMaxOutputBytes: 524288,
|
|
1683
|
+
agents: {
|
|
1684
|
+
codex: { command: "node", args: ["-e", "console.log('agent placeholder')"] },
|
|
1685
|
+
assessor_low: { command: "node", args: ["-e", `console.log('Analysis done.\\n===ASSESSMENT_JSON===\\n${ASSESS_JSON_LOW}')`] },
|
|
1686
|
+
assessor_medium: { command: "node", args: ["-e", `console.log('===ASSESSMENT_JSON===\\n${ASSESS_JSON_MED}')`] },
|
|
1687
|
+
assessor_high: { command: "node", args: ["-e", `console.log('===ASSESSMENT_JSON===\\n${ASSESS_JSON_HIGH}')`] },
|
|
1688
|
+
assessor_bad_json: { command: "node", args: ["-e", "console.log('This is not valid JSON.')"] },
|
|
1689
|
+
assessor_timeout: { command: "node", args: ["-e", "setTimeout(()=>console.log('slow'),30000)"] },
|
|
1690
|
+
assessor_nonzero: { command: "node", args: ["-e", "process.exit(1)"] },
|
|
1691
|
+
assessor_writer: { command: "node", args: ["-e", "require('fs').writeFileSync('assessment-vandalized.txt','changed\\n')"] },
|
|
1692
|
+
assessor_abs_path: { command: "node", args: ["-e", `console.log('===ASSESSMENT_JSON===\\n${ASSESS_JSON_ABS}')`] },
|
|
1693
|
+
assessor_large_output: { command: "node", args: ["-e", "console.log('x'.repeat(600000))"] },
|
|
1694
|
+
},
|
|
1695
|
+
allowedTestCommands: ["npm test"],
|
|
1696
|
+
maxReadFileBytes: 200000,
|
|
1697
|
+
}, null, 2),
|
|
1698
|
+
"utf-8"
|
|
1699
|
+
);
|
|
1700
|
+
|
|
1701
|
+
const originalConfigEnv = process.env.PATCHWARDEN_CONFIG;
|
|
1702
|
+
process.env.PATCHWARDEN_CONFIG = agentAssessConfigPath;
|
|
1703
|
+
reloadConfig();
|
|
1704
|
+
|
|
1705
|
+
// Reload config for agent assessment tests
|
|
1706
|
+
loadConfig();
|
|
1707
|
+
const assessConfig = getConfig();
|
|
1708
|
+
const assessWsRoot = assessConfig.workspaceRoot;
|
|
1709
|
+
|
|
1710
|
+
console.log("── L. Agent Assessment ──");
|
|
1711
|
+
|
|
1712
|
+
test("L20 (K20). agentAssessor disabled by default — no agent_assessment field", () => {
|
|
1713
|
+
// Restore original config (enableAgentAssessment not set)
|
|
1714
|
+
process.env.PATCHWARDEN_CONFIG = originalConfigEnv;
|
|
1715
|
+
reloadConfig();
|
|
1716
|
+
const result = createTask({
|
|
1717
|
+
template: "feature_small",
|
|
1718
|
+
goal: "test disabled",
|
|
1719
|
+
agent: "codex",
|
|
1720
|
+
repo_path: ".",
|
|
1721
|
+
execution_mode: "assess_only",
|
|
1722
|
+
}) as AssessOnlyOutput;
|
|
1723
|
+
if (result.agent_assessment !== undefined && result.agent_assessment !== null) {
|
|
1724
|
+
throw new Error(`Expected no agent_assessment, got: ${JSON.stringify(result.agent_assessment)}`);
|
|
1725
|
+
}
|
|
1726
|
+
// Switch back to assess config
|
|
1727
|
+
process.env.PATCHWARDEN_CONFIG = agentAssessConfigPath;
|
|
1728
|
+
reloadConfig();
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
test("L21 (K21). agentAssessor low risk stays low", () => {
|
|
1732
|
+
const result = createTask({
|
|
1733
|
+
template: "feature_small",
|
|
1734
|
+
goal: "test low risk",
|
|
1735
|
+
agent: "assessor_low",
|
|
1736
|
+
repo_path: "repo",
|
|
1737
|
+
execution_mode: "assess_only",
|
|
1738
|
+
}) as AssessOnlyOutput;
|
|
1739
|
+
if (result.decision !== "allow") throw new Error(`Expected allow, got ${result.decision}`);
|
|
1740
|
+
if (result.risk_level !== "low") throw new Error(`Expected low, got ${result.risk_level}`);
|
|
1741
|
+
if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
|
|
1742
|
+
if (result.agent_assessment.status !== "completed") throw new Error(`Expected completed, got ${result.agent_assessment.status}`);
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
test("L22 (K22). agentAssessor medium risk → needs_confirm", () => {
|
|
1746
|
+
const result = createTask({
|
|
1747
|
+
template: "feature_small",
|
|
1748
|
+
goal: "test medium risk",
|
|
1749
|
+
agent: "assessor_medium",
|
|
1750
|
+
repo_path: "repo",
|
|
1751
|
+
execution_mode: "assess_only",
|
|
1752
|
+
}) as AssessOnlyOutput;
|
|
1753
|
+
if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${result.decision}`);
|
|
1754
|
+
if (result.risk_level !== "medium") throw new Error(`Expected medium, got ${result.risk_level}`);
|
|
1755
|
+
if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
test("L23 (K23). agentAssessor high risk → blocked", () => {
|
|
1759
|
+
const result = createTask({
|
|
1760
|
+
template: "feature_small",
|
|
1761
|
+
goal: "test high risk",
|
|
1762
|
+
agent: "assessor_high",
|
|
1763
|
+
repo_path: "repo",
|
|
1764
|
+
execution_mode: "assess_only",
|
|
1765
|
+
}) as AssessOnlyOutput;
|
|
1766
|
+
if (result.decision !== "blocked") throw new Error(`Expected blocked, got ${result.decision}`);
|
|
1767
|
+
if (result.risk_level !== "high") throw new Error(`Expected high, got ${result.risk_level}`);
|
|
1768
|
+
if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
test("L24 (K24). agentAssessor timeout → needs_confirm", () => {
|
|
1772
|
+
const result = createTask({
|
|
1773
|
+
template: "feature_small",
|
|
1774
|
+
goal: "test timeout",
|
|
1775
|
+
agent: "assessor_timeout",
|
|
1776
|
+
repo_path: "repo",
|
|
1777
|
+
execution_mode: "assess_only",
|
|
1778
|
+
}) as AssessOnlyOutput;
|
|
1779
|
+
if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${result.decision}`);
|
|
1780
|
+
if (result.risk_level !== "medium") throw new Error(`Expected medium, got ${result.risk_level}`);
|
|
1781
|
+
if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
|
|
1782
|
+
if (result.agent_assessment.status !== "timed_out") throw new Error(`Expected timed_out, got ${result.agent_assessment.status}`);
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
test("L25 (K25). agentAssessor non-zero exit → needs_confirm", () => {
|
|
1786
|
+
const result = createTask({
|
|
1787
|
+
template: "feature_small",
|
|
1788
|
+
goal: "test non-zero exit",
|
|
1789
|
+
agent: "assessor_nonzero",
|
|
1790
|
+
repo_path: "repo",
|
|
1791
|
+
execution_mode: "assess_only",
|
|
1792
|
+
}) as AssessOnlyOutput;
|
|
1793
|
+
if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${result.decision}`);
|
|
1794
|
+
if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
|
|
1795
|
+
if (result.agent_assessment.status !== "non_zero_exit") throw new Error(`Expected non_zero_exit, got ${result.agent_assessment.status}`);
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
test("L26 (K26). agentAssessor read-only violation → blocked", () => {
|
|
1799
|
+
const result = createTask({
|
|
1800
|
+
template: "feature_small",
|
|
1801
|
+
goal: "test read-only violation",
|
|
1802
|
+
agent: "assessor_writer",
|
|
1803
|
+
repo_path: "repo",
|
|
1804
|
+
execution_mode: "assess_only",
|
|
1805
|
+
}) as AssessOnlyOutput;
|
|
1806
|
+
if (result.decision !== "blocked") throw new Error(`Expected blocked, got ${result.decision}`);
|
|
1807
|
+
if (result.risk_level !== "high") throw new Error(`Expected high, got ${result.risk_level}`);
|
|
1808
|
+
if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
|
|
1809
|
+
if (!result.agent_assessment.read_only_violation) throw new Error("Expected read_only_violation to be true");
|
|
1810
|
+
// Clean up vandalized file
|
|
1811
|
+
try { rmSync(join(agentAssessRepo, "assessment-vandalized.txt"), { force: true }); } catch {}
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
test("L27 (K27). agentAssessor absolute/outside paths sanitized", () => {
|
|
1815
|
+
const result = createTask({
|
|
1816
|
+
template: "feature_small",
|
|
1817
|
+
goal: "test path sanitize",
|
|
1818
|
+
agent: "assessor_abs_path",
|
|
1819
|
+
repo_path: "repo",
|
|
1820
|
+
execution_mode: "assess_only",
|
|
1821
|
+
}) as AssessOnlyOutput;
|
|
1822
|
+
// Should still be allow/low since risk_level is low and paths are sanitized
|
|
1823
|
+
if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
|
|
1824
|
+
if (result.agent_assessment.status !== "completed") throw new Error(`Expected completed, got ${result.agent_assessment.status}`);
|
|
1825
|
+
// Check that reason_codes includes paths_sanitized
|
|
1826
|
+
if (!result.reason_codes.includes("paths_sanitized")) {
|
|
1827
|
+
throw new Error(`Expected paths_sanitized in reason_codes, got: ${result.reason_codes.join(", ")}`);
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
test("L28 (K28). agentAssessor large stdout truncated safely", () => {
|
|
1832
|
+
const result = createTask({
|
|
1833
|
+
template: "feature_small",
|
|
1834
|
+
goal: "test large output",
|
|
1835
|
+
agent: "assessor_large_output",
|
|
1836
|
+
repo_path: "repo",
|
|
1837
|
+
execution_mode: "assess_only",
|
|
1838
|
+
}) as AssessOnlyOutput;
|
|
1839
|
+
// Large output with no JSON marker → parse_failed → needs_confirm
|
|
1840
|
+
if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${result.decision}`);
|
|
1841
|
+
if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
|
|
1842
|
+
if (!result.agent_assessment.stdout_truncated) throw new Error("Expected stdout_truncated to be true");
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
test("L29 (K29). deterministic medium/high skips agent", () => {
|
|
1846
|
+
// release_check template → deterministic medium → should NOT run agent assessment
|
|
1847
|
+
const result = createTask({
|
|
1848
|
+
template: "release_check",
|
|
1849
|
+
goal: "test skip agent on medium",
|
|
1850
|
+
agent: "assessor_low", // Would produce low if run, but shouldn't run
|
|
1851
|
+
repo_path: "repo",
|
|
1852
|
+
execution_mode: "assess_only",
|
|
1853
|
+
}) as AssessOnlyOutput;
|
|
1854
|
+
if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm (deterministic), got ${result.decision}`);
|
|
1855
|
+
if (result.risk_level !== "medium") throw new Error(`Expected medium (deterministic), got ${result.risk_level}`);
|
|
1856
|
+
// agent_assessment should be null/undefined since agent was not run
|
|
1857
|
+
if (result.agent_assessment !== null && result.agent_assessment !== undefined) {
|
|
1858
|
+
throw new Error(`Expected no agent_assessment for medium risk, got: ${JSON.stringify(result.agent_assessment)}`);
|
|
1859
|
+
}
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
// Restore original config
|
|
1863
|
+
process.env.PATCHWARDEN_CONFIG = originalConfigEnv;
|
|
1864
|
+
reloadConfig();
|
|
1865
|
+
|
|
1866
|
+
try { rmSync(agentAssessRoot, { recursive: true, force: true }); } catch {}
|
|
1867
|
+
|
|
1868
|
+
// ════════════════════════════════════════════════════════════════
|
|
1869
|
+
// Section M: chatgpt_direct profile and session tests
|
|
1870
|
+
// ════════════════════════════════════════════════════════════════
|
|
1871
|
+
|
|
1872
|
+
console.log("\n--- Section M: chatgpt_direct profile and session tests ---\n");
|
|
1873
|
+
|
|
1874
|
+
const directRoot = mkdtempSync(join(tmpdir(), "patchwarden-direct-"));
|
|
1875
|
+
const directWorkspace = join(directRoot, "workspace");
|
|
1876
|
+
const directConfigPath = join(directRoot, "patchwarden.config.json");
|
|
1877
|
+
const directRepo = join(directWorkspace, "test-repo");
|
|
1878
|
+
|
|
1879
|
+
// Create fixture repo
|
|
1880
|
+
mkdirSync(join(directRepo, "src"), { recursive: true });
|
|
1881
|
+
writeFileSync(join(directRepo, "src", "index.ts"), "export function hello() {\n return 'hello';\n}\n\nexport function world() {\n return 'world';\n}\n", "utf-8");
|
|
1882
|
+
writeFileSync(join(directRepo, "package.json"), JSON.stringify({
|
|
1883
|
+
name: "test-repo",
|
|
1884
|
+
version: "1.0.0",
|
|
1885
|
+
scripts: {
|
|
1886
|
+
test: 'node -e "console.log(\'test ok\')"',
|
|
1887
|
+
build: 'node -e "console.log(\'build ok\')"',
|
|
1888
|
+
lint: 'node -e "console.log(\'lint ok\')"',
|
|
1889
|
+
},
|
|
1890
|
+
}, null, 2), "utf-8");
|
|
1891
|
+
writeFileSync(join(directRepo, ".env"), "SECRET=blocked\n", "utf-8");
|
|
1892
|
+
|
|
1893
|
+
// Create watcher heartbeat for Direct workspace
|
|
1894
|
+
mkdirSync(join(directWorkspace, ".patchwarden"), { recursive: true });
|
|
1895
|
+
writeFileSync(join(directWorkspace, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
|
|
1896
|
+
status: "running",
|
|
1897
|
+
pid: process.pid,
|
|
1898
|
+
instance_id: "direct-smoke-watcher",
|
|
1899
|
+
launcher_pid: process.pid,
|
|
1900
|
+
started_at: new Date().toISOString(),
|
|
1901
|
+
last_heartbeat_at: new Date().toISOString(),
|
|
1902
|
+
}), "utf-8");
|
|
1903
|
+
|
|
1904
|
+
// Create Direct-enabled config
|
|
1905
|
+
writeFileSync(directConfigPath, JSON.stringify({
|
|
1906
|
+
workspaceRoot: directWorkspace,
|
|
1907
|
+
plansDir: ".patchwarden/plans",
|
|
1908
|
+
tasksDir: ".patchwarden/tasks",
|
|
1909
|
+
assessmentsDir: ".patchwarden/assessments",
|
|
1910
|
+
assessmentTtlSeconds: 3600,
|
|
1911
|
+
agents: {
|
|
1912
|
+
codex: { command: "node", args: ["-e", "console.log('agent placeholder')"] },
|
|
1913
|
+
},
|
|
1914
|
+
allowedTestCommands: ["npm test"],
|
|
1915
|
+
maxReadFileBytes: 200000,
|
|
1916
|
+
enableDirectProfile: true,
|
|
1917
|
+
directAllowedCommands: ["npm test", "npm run build", "npm run lint"],
|
|
1918
|
+
directSessionsDir: ".patchwarden/direct-sessions",
|
|
1919
|
+
directSessionTtlSeconds: 3600,
|
|
1920
|
+
directMaxPatchBytes: 200000,
|
|
1921
|
+
directMaxFileBytes: 500000,
|
|
1922
|
+
}, null, 2), "utf-8");
|
|
1923
|
+
|
|
1924
|
+
process.env.PATCHWARDEN_CONFIG = directConfigPath;
|
|
1925
|
+
reloadConfig();
|
|
1926
|
+
|
|
1927
|
+
let directSessionId = "";
|
|
1928
|
+
|
|
1929
|
+
test("M1. chatgpt_core still has 17 tools", () => {
|
|
1930
|
+
const tools = getToolDefs();
|
|
1931
|
+
const coreTools = selectToolsForProfile(tools, "chatgpt_core", true);
|
|
1932
|
+
if (coreTools.length !== 17) throw new Error(`Expected 17, got ${coreTools.length}`);
|
|
1933
|
+
if (JSON.stringify(coreTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
|
|
1934
|
+
throw new Error("Tool names mismatch");
|
|
1935
|
+
}
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
test("M2. chatgpt_direct disabled exposes only health_check", () => {
|
|
1939
|
+
const tools = getToolDefs();
|
|
1940
|
+
const disabledTools = selectToolsForProfile(tools, "chatgpt_direct", false);
|
|
1941
|
+
if (disabledTools.length !== 1) throw new Error(`Expected 1, got ${disabledTools.length}`);
|
|
1942
|
+
if (disabledTools[0].name !== "health_check") throw new Error(`Expected health_check, got ${disabledTools[0].name}`);
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
test("M3. chatgpt_direct enabled has 10 tools", () => {
|
|
1946
|
+
const tools = getToolDefs();
|
|
1947
|
+
const directTools = selectToolsForProfile(tools, "chatgpt_direct", true);
|
|
1948
|
+
if (directTools.length !== 10) throw new Error(`Expected 10, got ${directTools.length}`);
|
|
1949
|
+
if (JSON.stringify(directTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES)) {
|
|
1950
|
+
throw new Error("Tool names mismatch");
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
test("M4. create_direct_session creates a session", () => {
|
|
1955
|
+
const result = createDirectSession({ repo_path: "test-repo", title: "test session" });
|
|
1956
|
+
if (!result.session_id.startsWith("direct_")) throw new Error(`Invalid session_id: ${result.session_id}`);
|
|
1957
|
+
if (!result.resolved_repo_path) throw new Error("Missing resolved_repo_path");
|
|
1958
|
+
if (!result.expires_at) throw new Error("Missing expires_at");
|
|
1959
|
+
if (result.allowed_commands.length === 0) throw new Error("No allowed commands");
|
|
1960
|
+
if (!result.workspace_clean) throw new Error("Workspace should be clean");
|
|
1961
|
+
directSessionId = result.session_id;
|
|
1962
|
+
});
|
|
1963
|
+
|
|
1964
|
+
test("M5. read_workspace_file in direct profile without session_id is rejected", () => {
|
|
1965
|
+
// Temporarily set profile to chatgpt_direct
|
|
1966
|
+
const prevProfile = process.env.PATCHWARDEN_TOOL_PROFILE;
|
|
1967
|
+
process.env.PATCHWARDEN_TOOL_PROFILE = "chatgpt_direct";
|
|
1968
|
+
try {
|
|
1969
|
+
readWorkspaceFile({ path: "src/index.ts" });
|
|
1970
|
+
throw new Error("Should have rejected read without session_id in direct profile");
|
|
1971
|
+
} catch (err: any) {
|
|
1972
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
1973
|
+
// Expected rejection
|
|
1974
|
+
} finally {
|
|
1975
|
+
if (prevProfile === undefined) delete process.env.PATCHWARDEN_TOOL_PROFILE;
|
|
1976
|
+
else process.env.PATCHWARDEN_TOOL_PROFILE = prevProfile;
|
|
1977
|
+
}
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
test("M6. read_workspace_file with session_id returns sha256", () => {
|
|
1981
|
+
const result = readWorkspaceFile({ path: "src/index.ts", session_id: directSessionId });
|
|
1982
|
+
if (!result.sha256) throw new Error("Missing sha256");
|
|
1983
|
+
if (!result.relative_path) throw new Error("Missing relative_path");
|
|
1984
|
+
if (result.size === undefined) throw new Error("Missing size");
|
|
1985
|
+
if (!result.content) throw new Error("Missing content");
|
|
1986
|
+
// Verify sha256 is correct
|
|
1987
|
+
const expectedHash = createHash("sha256").update(result.content, "utf-8").digest("hex");
|
|
1988
|
+
if (result.sha256 !== expectedHash) throw new Error("sha256 mismatch");
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
test("M7. search_workspace finds text and skips sensitive files", () => {
|
|
1992
|
+
const result = searchWorkspace({ session_id: directSessionId, query: "hello" });
|
|
1993
|
+
if (result.total_matches === 0) throw new Error("Expected matches for 'hello'");
|
|
1994
|
+
const paths = result.results.map((r) => r.path);
|
|
1995
|
+
if (!paths.some((p) => p.includes("index.ts"))) throw new Error("Should find match in index.ts");
|
|
1996
|
+
// .env should not appear in results
|
|
1997
|
+
if (paths.some((p) => p.includes(".env"))) throw new Error(".env should be skipped");
|
|
1998
|
+
});
|
|
1999
|
+
|
|
2000
|
+
test("M8. apply_patch with matching hash succeeds", () => {
|
|
2001
|
+
const fileResult = readWorkspaceFile({ path: "src/index.ts", session_id: directSessionId });
|
|
2002
|
+
const expectedSha = fileResult.sha256!;
|
|
2003
|
+
const result = applyPatch({
|
|
2004
|
+
session_id: directSessionId,
|
|
2005
|
+
path: "src/index.ts",
|
|
2006
|
+
expected_sha256: expectedSha,
|
|
2007
|
+
operations: [
|
|
2008
|
+
{ type: "replace_exact", old_text: "return 'hello';", new_text: "return 'hello updated';", occurrence: "first" },
|
|
2009
|
+
],
|
|
2010
|
+
});
|
|
2011
|
+
if (result.operations_applied !== 1) throw new Error(`Expected 1 op applied, got ${result.operations_applied}`);
|
|
2012
|
+
if (result.before_sha256 !== expectedSha) throw new Error("before_sha256 mismatch");
|
|
2013
|
+
if (result.after_sha256 === expectedSha) throw new Error("after_sha256 should differ from before");
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
test("M9. apply_patch with mismatched hash is rejected", () => {
|
|
2017
|
+
try {
|
|
2018
|
+
applyPatch({
|
|
2019
|
+
session_id: directSessionId,
|
|
2020
|
+
path: "src/index.ts",
|
|
2021
|
+
expected_sha256: "0000000000000000000000000000000000000000000000000000000000000000",
|
|
2022
|
+
operations: [
|
|
2023
|
+
{ type: "replace_exact", old_text: "hello", new_text: "goodbye" },
|
|
2024
|
+
],
|
|
2025
|
+
});
|
|
2026
|
+
throw new Error("Should have rejected hash mismatch");
|
|
2027
|
+
} catch (err: any) {
|
|
2028
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2029
|
+
// Expected rejection
|
|
2030
|
+
}
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
test("M10. apply_patch on sensitive file is rejected", () => {
|
|
2034
|
+
try {
|
|
2035
|
+
applyPatch({
|
|
2036
|
+
session_id: directSessionId,
|
|
2037
|
+
path: ".env",
|
|
2038
|
+
expected_sha256: "dummy",
|
|
2039
|
+
operations: [
|
|
2040
|
+
{ type: "replace_whole_file", new_text: "HACKED=true\n" },
|
|
2041
|
+
],
|
|
2042
|
+
});
|
|
2043
|
+
throw new Error("Should have rejected sensitive file");
|
|
2044
|
+
} catch (err: any) {
|
|
2045
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2046
|
+
// Expected rejection
|
|
2047
|
+
}
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
test("M11. apply_patch on node_modules is rejected", () => {
|
|
2051
|
+
try {
|
|
2052
|
+
applyPatch({
|
|
2053
|
+
session_id: directSessionId,
|
|
2054
|
+
path: "node_modules/test/index.js",
|
|
2055
|
+
expected_sha256: "dummy",
|
|
2056
|
+
operations: [
|
|
2057
|
+
{ type: "replace_whole_file", new_text: "module.exports = {};" },
|
|
2058
|
+
],
|
|
2059
|
+
});
|
|
2060
|
+
throw new Error("Should have rejected node_modules path");
|
|
2061
|
+
} catch (err: any) {
|
|
2062
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2063
|
+
// Expected rejection
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
|
|
2067
|
+
test("M12. apply_patch on dist/release is rejected", () => {
|
|
2068
|
+
try {
|
|
2069
|
+
applyPatch({
|
|
2070
|
+
session_id: directSessionId,
|
|
2071
|
+
path: "dist/index.js",
|
|
2072
|
+
expected_sha256: "dummy",
|
|
2073
|
+
operations: [
|
|
2074
|
+
{ type: "replace_whole_file", new_text: "console.log('hacked');" },
|
|
2075
|
+
],
|
|
2076
|
+
});
|
|
2077
|
+
throw new Error("Should have rejected dist path");
|
|
2078
|
+
} catch (err: any) {
|
|
2079
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2080
|
+
// Expected rejection
|
|
2081
|
+
}
|
|
2082
|
+
try {
|
|
2083
|
+
applyPatch({
|
|
2084
|
+
session_id: directSessionId,
|
|
2085
|
+
path: "release/index.js",
|
|
2086
|
+
expected_sha256: "dummy",
|
|
2087
|
+
operations: [
|
|
2088
|
+
{ type: "replace_whole_file", new_text: "console.log('hacked');" },
|
|
2089
|
+
],
|
|
2090
|
+
});
|
|
2091
|
+
throw new Error("Should have rejected release path");
|
|
2092
|
+
} catch (err: any) {
|
|
2093
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2094
|
+
// Expected rejection
|
|
2095
|
+
}
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
// M13 and M14 are async tests — use IIFE with top-level await
|
|
2099
|
+
await (async () => {
|
|
2100
|
+
try {
|
|
2101
|
+
const result = await runVerification({
|
|
2102
|
+
session_id: directSessionId,
|
|
2103
|
+
command: "npm test",
|
|
2104
|
+
timeout_seconds: 30,
|
|
2105
|
+
});
|
|
2106
|
+
if (!result.passed) throw new Error(`npm test should pass, got exit_code ${result.exit_code}`);
|
|
2107
|
+
if (result.command !== "npm test") throw new Error("Command mismatch");
|
|
2108
|
+
console.log(" ✅ M13. run_verification allows whitelisted command");
|
|
2109
|
+
passed++;
|
|
2110
|
+
} catch (err) {
|
|
2111
|
+
console.log(` ❌ M13. run_verification allows whitelisted command: ${err instanceof Error ? err.message : String(err)}`);
|
|
2112
|
+
failed++;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
try {
|
|
2116
|
+
await runVerification({
|
|
2117
|
+
session_id: directSessionId,
|
|
2118
|
+
command: "rm -rf /",
|
|
2119
|
+
timeout_seconds: 5,
|
|
2120
|
+
});
|
|
2121
|
+
console.log(" ❌ M14. run_verification rejects non-whitelisted command: Should have rejected");
|
|
2122
|
+
failed++;
|
|
2123
|
+
} catch (err) {
|
|
2124
|
+
console.log(" ✅ M14. run_verification rejects non-whitelisted command");
|
|
2125
|
+
passed++;
|
|
2126
|
+
}
|
|
2127
|
+
})();
|
|
2128
|
+
|
|
2129
|
+
test("M15. finalize_direct_session generates summary/diff/changed-files", () => {
|
|
2130
|
+
const result = finalizeDirectSession({ session_id: directSessionId });
|
|
2131
|
+
if (!result.finalized) throw new Error("Should be finalized");
|
|
2132
|
+
if (result.changed_files_total === 0) throw new Error("Expected changed files");
|
|
2133
|
+
if (!result.diff_path) throw new Error("Missing diff_path");
|
|
2134
|
+
if (!result.summary_path) throw new Error("Missing summary_path");
|
|
2135
|
+
if (!existsSync(result.diff_path)) throw new Error("diff.patch not created");
|
|
2136
|
+
if (!existsSync(result.summary_path)) throw new Error("summary.md not created");
|
|
2137
|
+
const changedFilesPath = join(dirname(result.diff_path), "changed-files.json");
|
|
2138
|
+
if (!existsSync(changedFilesPath)) throw new Error("changed-files.json not created");
|
|
2139
|
+
if (result.source_changes.length === 0) throw new Error("Expected source changes");
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
test("M16. apply_patch after finalize is rejected", () => {
|
|
2143
|
+
try {
|
|
2144
|
+
applyPatch({
|
|
2145
|
+
session_id: directSessionId,
|
|
2146
|
+
path: "src/index.ts",
|
|
2147
|
+
expected_sha256: "dummy",
|
|
2148
|
+
operations: [
|
|
2149
|
+
{ type: "replace_whole_file", new_text: "should fail" },
|
|
2150
|
+
],
|
|
2151
|
+
});
|
|
2152
|
+
throw new Error("Should have rejected patch after finalize");
|
|
2153
|
+
} catch (err: any) {
|
|
2154
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2155
|
+
// Expected rejection (session_finalized)
|
|
2156
|
+
}
|
|
2157
|
+
});
|
|
2158
|
+
|
|
2159
|
+
test("M17. audit_session passes for normal small change", () => {
|
|
2160
|
+
const result = auditSession({ session_id: directSessionId });
|
|
2161
|
+
if (result.decision === "fail") throw new Error(`Expected pass or warn, got fail: ${result.blocking_findings.join("; ")}`);
|
|
2162
|
+
if (!result.evidence.diff_path) throw new Error("Missing diff_path in evidence");
|
|
2163
|
+
if (!result.evidence.summary_path) throw new Error("Missing summary_path in evidence");
|
|
2164
|
+
// Should have run verification
|
|
2165
|
+
const session = readDirectSession(directSessionId);
|
|
2166
|
+
if (session.verification_runs.length === 0) throw new Error("Expected verification runs");
|
|
2167
|
+
});
|
|
2168
|
+
|
|
2169
|
+
// Create a second session for M18 (no verification)
|
|
2170
|
+
let noVerifySessionId = "";
|
|
2171
|
+
test("M18. audit_session warns or fails for unverified source changes", () => {
|
|
2172
|
+
const sess = createDirectSession({ repo_path: "test-repo", title: "no verify session" });
|
|
2173
|
+
noVerifySessionId = sess.session_id;
|
|
2174
|
+
|
|
2175
|
+
// Apply a patch but don't run verification
|
|
2176
|
+
const fileResult = readWorkspaceFile({ path: "src/index.ts", session_id: noVerifySessionId });
|
|
2177
|
+
applyPatch({
|
|
2178
|
+
session_id: noVerifySessionId,
|
|
2179
|
+
path: "src/index.ts",
|
|
2180
|
+
expected_sha256: fileResult.sha256!,
|
|
2181
|
+
operations: [
|
|
2182
|
+
{ type: "replace_exact", old_text: "return 'hello updated';", new_text: "return 'hello no verify';" },
|
|
2183
|
+
],
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
finalizeDirectSession({ session_id: noVerifySessionId });
|
|
2187
|
+
const auditResult = auditSession({ session_id: noVerifySessionId });
|
|
2188
|
+
if (auditResult.decision === "pass") throw new Error("Expected warn or fail for unverified source changes");
|
|
2189
|
+
if (!auditResult.reason_codes.includes("source_changes_without_verification")) {
|
|
2190
|
+
throw new Error(`Expected source_changes_without_verification, got: ${auditResult.reason_codes.join(", ")}`);
|
|
2191
|
+
}
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
// M19: Delete file test (real deletion)
|
|
2195
|
+
test("M19. audit_session fails for deleted file", () => {
|
|
2196
|
+
// Create a temp file in the repo before session creation
|
|
2197
|
+
writeFileSync(join(directRepo, "src", "temp-delete.ts"), "export const temp = 'delete me';\n", "utf-8");
|
|
2198
|
+
|
|
2199
|
+
const sess = createDirectSession({ repo_path: "test-repo", title: "delete test session" });
|
|
2200
|
+
const deleteSessionId = sess.session_id;
|
|
2201
|
+
|
|
2202
|
+
// Apply a patch to an existing file (so there's a real change)
|
|
2203
|
+
const fileResult = readWorkspaceFile({ path: "src/index.ts", session_id: deleteSessionId });
|
|
2204
|
+
applyPatch({
|
|
2205
|
+
session_id: deleteSessionId,
|
|
2206
|
+
path: "src/index.ts",
|
|
2207
|
+
expected_sha256: fileResult.sha256!,
|
|
2208
|
+
operations: [
|
|
2209
|
+
{ type: "replace_exact", old_text: "return 'hello no verify';", new_text: "return 'hello delete test';" },
|
|
2210
|
+
],
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
// Run verification so the only issue is the deleted file
|
|
2214
|
+
// Need to update package.json test script to work with current state
|
|
2215
|
+
// Actually, npm test should still work since it just prints a message
|
|
2216
|
+
|
|
2217
|
+
// Real deletion of the temp file
|
|
2218
|
+
rmSync(join(directRepo, "src", "temp-delete.ts"));
|
|
2219
|
+
|
|
2220
|
+
finalizeDirectSession({ session_id: deleteSessionId });
|
|
2221
|
+
const auditResult = auditSession({ session_id: deleteSessionId });
|
|
2222
|
+
if (auditResult.decision !== "fail") {
|
|
2223
|
+
throw new Error(`Expected fail for deleted file, got ${auditResult.decision}: ${auditResult.reason_codes.join(", ")}`);
|
|
2224
|
+
}
|
|
2225
|
+
if (!auditResult.reason_codes.includes("file_deleted")) {
|
|
2226
|
+
throw new Error(`Expected file_deleted in reason_codes, got: ${auditResult.reason_codes.join(", ")}`);
|
|
2227
|
+
}
|
|
2228
|
+
});
|
|
2229
|
+
|
|
2230
|
+
test("M20. session expiry rejects all operations", () => {
|
|
2231
|
+
const sess = createDirectSession({ repo_path: "test-repo", title: "expiry test" });
|
|
2232
|
+
const expirySessionId = sess.session_id;
|
|
2233
|
+
|
|
2234
|
+
// Manually set expires_at to the past
|
|
2235
|
+
const session = readDirectSession(expirySessionId);
|
|
2236
|
+
updateDirectSession(expirySessionId, {
|
|
2237
|
+
...session,
|
|
2238
|
+
expires_at: new Date(Date.now() - 60000).toISOString(),
|
|
2239
|
+
});
|
|
2240
|
+
|
|
2241
|
+
// read should reject
|
|
2242
|
+
try {
|
|
2243
|
+
readWorkspaceFile({ path: "src/index.ts", session_id: expirySessionId });
|
|
2244
|
+
throw new Error("Should have rejected expired session (read)");
|
|
2245
|
+
} catch (err: any) {
|
|
2246
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
// apply_patch should reject
|
|
2250
|
+
try {
|
|
2251
|
+
applyPatch({
|
|
2252
|
+
session_id: expirySessionId,
|
|
2253
|
+
path: "src/index.ts",
|
|
2254
|
+
expected_sha256: "dummy",
|
|
2255
|
+
operations: [{ type: "replace_whole_file", new_text: "test" }],
|
|
2256
|
+
});
|
|
2257
|
+
throw new Error("Should have rejected expired session (patch)");
|
|
2258
|
+
} catch (err: any) {
|
|
2259
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// search should reject
|
|
2263
|
+
try {
|
|
2264
|
+
searchWorkspace({ session_id: expirySessionId, query: "hello" });
|
|
2265
|
+
throw new Error("Should have rejected expired session (search)");
|
|
2266
|
+
} catch (err: any) {
|
|
2267
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2268
|
+
}
|
|
2269
|
+
});
|
|
2270
|
+
|
|
2271
|
+
// M21. Direct read blocks .patchwarden internal paths
|
|
2272
|
+
test("M21. read_workspace_file blocks .patchwarden internal paths", () => {
|
|
2273
|
+
const sess = createDirectSession({ repo_path: "test-repo", title: "internal path test" });
|
|
2274
|
+
const internalSessionId = sess.session_id;
|
|
2275
|
+
|
|
2276
|
+
// Try to read the session file itself
|
|
2277
|
+
try {
|
|
2278
|
+
readWorkspaceFile({
|
|
2279
|
+
path: `.patchwarden/direct-sessions/${internalSessionId}/session.json`,
|
|
2280
|
+
session_id: internalSessionId,
|
|
2281
|
+
});
|
|
2282
|
+
throw new Error("Should have rejected .patchwarden internal path read");
|
|
2283
|
+
} catch (err: any) {
|
|
2284
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2285
|
+
if (!err.reason || err.reason !== "internal_patchwarden_path_blocked") {
|
|
2286
|
+
throw new Error(`Expected internal_patchwarden_path_blocked, got: ${err.reason || err.message}`);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// Try to read any .patchwarden file
|
|
2291
|
+
try {
|
|
2292
|
+
readWorkspaceFile({
|
|
2293
|
+
path: ".patchwarden/watcher-heartbeat.json",
|
|
2294
|
+
session_id: internalSessionId,
|
|
2295
|
+
});
|
|
2296
|
+
throw new Error("Should have rejected .patchwarden internal path read (2)");
|
|
2297
|
+
} catch (err: any) {
|
|
2298
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2299
|
+
if (!err.reason || err.reason !== "internal_patchwarden_path_blocked") {
|
|
2300
|
+
throw new Error(`Expected internal_patchwarden_path_blocked, got: ${err.reason || err.message}`);
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
// M22. Binary detection with null byte works for extensionless files
|
|
2306
|
+
test("M22. binary detection blocks extensionless files with null bytes", () => {
|
|
2307
|
+
// Create a blob file with null bytes in the fixture repo
|
|
2308
|
+
const blobPath = join(directRepo, "blob");
|
|
2309
|
+
writeFileSync(blobPath, Buffer.from("abc\x00def", "binary"));
|
|
2310
|
+
|
|
2311
|
+
const sess = createDirectSession({ repo_path: "test-repo", title: "binary test" });
|
|
2312
|
+
const binarySessionId = sess.session_id;
|
|
2313
|
+
|
|
2314
|
+
// read should reject
|
|
2315
|
+
try {
|
|
2316
|
+
readWorkspaceFile({ path: "blob", session_id: binarySessionId });
|
|
2317
|
+
throw new Error("Should have rejected binary file read");
|
|
2318
|
+
} catch (err: any) {
|
|
2319
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2320
|
+
if (!err.reason || err.reason !== "binary_file_blocked") {
|
|
2321
|
+
throw new Error(`Expected binary_file_blocked, got: ${err.reason || err.message}`);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// apply_patch should reject
|
|
2326
|
+
try {
|
|
2327
|
+
applyPatch({
|
|
2328
|
+
session_id: binarySessionId,
|
|
2329
|
+
path: "blob",
|
|
2330
|
+
expected_sha256: "dummy",
|
|
2331
|
+
operations: [{ type: "replace_whole_file", new_text: "safe" }],
|
|
2332
|
+
});
|
|
2333
|
+
throw new Error("Should have rejected binary file patch");
|
|
2334
|
+
} catch (err: any) {
|
|
2335
|
+
if (err.message && err.message.includes("Should have rejected")) throw err;
|
|
2336
|
+
if (!err.reason || err.reason !== "binary_file_blocked") {
|
|
2337
|
+
throw new Error(`Expected binary_file_blocked, got: ${err.reason || err.message}`);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
|
|
2342
|
+
// Restore original config
|
|
2343
|
+
process.env.PATCHWARDEN_CONFIG = originalConfigEnv;
|
|
2344
|
+
reloadConfig();
|
|
2345
|
+
|
|
2346
|
+
try { rmSync(directRoot, { recursive: true, force: true }); } catch {}
|
|
2347
|
+
|
|
1061
2348
|
// ════════════════════════════════════════════════════════════════
|
|
1062
2349
|
// Summary
|
|
1063
2350
|
// ════════════════════════════════════════════════════════════════
|