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/tools/createTask.ts
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, existsSync, statSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, writeFileSync, existsSync, statSync, readFileSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
import { resolve, join, dirname } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { getTasksDir, getPlansDir, getConfig } from "../config.js";
|
|
5
6
|
import { guardPath, guardWorkspacePath, guardReadPath } from "../security/pathGuard.js";
|
|
6
7
|
import { guardTestCommand } from "../security/commandGuard.js";
|
|
8
|
+
import { guardRuntimeSelfModification } from "../security/runtimeGuard.js";
|
|
9
|
+
import { guardPlanContent } from "../security/planGuard.js";
|
|
10
|
+
import { assessRisk } from "../security/riskEngine.js";
|
|
11
|
+
import {
|
|
12
|
+
createAssessment,
|
|
13
|
+
readAssessment,
|
|
14
|
+
validateAssessmentFreshness,
|
|
15
|
+
generateAssessmentId,
|
|
16
|
+
createAssessmentDir,
|
|
17
|
+
type AssessmentRecord,
|
|
18
|
+
type AgentAssessmentSummary,
|
|
19
|
+
} from "../assessments/assessmentStore.js";
|
|
20
|
+
import { runAgentAssessment } from "../assessments/agentAssessor.js";
|
|
21
|
+
import { captureRepoSnapshot } from "../runner/changeCapture.js";
|
|
7
22
|
import { writeTaskProgress } from "../taskProgress.js";
|
|
8
23
|
import { PatchWardenError } from "../errors.js";
|
|
9
24
|
import { savePlan } from "./savePlan.js";
|
|
@@ -53,13 +68,59 @@ export interface CreateTaskInput {
|
|
|
53
68
|
template?: TaskTemplateName;
|
|
54
69
|
goal?: string;
|
|
55
70
|
source_task_id?: string;
|
|
56
|
-
agent
|
|
71
|
+
agent?: string;
|
|
57
72
|
repo_path?: string;
|
|
58
73
|
test_command?: string;
|
|
59
74
|
verify_commands?: string[];
|
|
60
75
|
timeout_seconds?: number;
|
|
76
|
+
execution_mode?: "assess_only" | "execute";
|
|
77
|
+
assessment_id?: string;
|
|
61
78
|
}
|
|
62
79
|
|
|
80
|
+
export interface AssessOnlyOutput {
|
|
81
|
+
assessment_id: string;
|
|
82
|
+
assessment_short_id: string;
|
|
83
|
+
decision: "allow" | "needs_confirm" | "blocked";
|
|
84
|
+
risk_level: "low" | "medium" | "high";
|
|
85
|
+
risk_hints: string[];
|
|
86
|
+
hard_rule_hits: string[];
|
|
87
|
+
reason_codes: string[];
|
|
88
|
+
expires_at: string;
|
|
89
|
+
requires_confirm: boolean;
|
|
90
|
+
workspace_snapshot_summary: {
|
|
91
|
+
head: string | null;
|
|
92
|
+
file_count: number;
|
|
93
|
+
workspace_dirty: boolean;
|
|
94
|
+
snapshot_truncated: boolean;
|
|
95
|
+
};
|
|
96
|
+
next_action: string;
|
|
97
|
+
next_tool_call?: {
|
|
98
|
+
name: "create_task";
|
|
99
|
+
arguments: {
|
|
100
|
+
execution_mode: "execute";
|
|
101
|
+
assessment_id: string;
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
local_confirmation: {
|
|
105
|
+
required: boolean;
|
|
106
|
+
command: "patchwarden-confirm" | null;
|
|
107
|
+
arguments: string[];
|
|
108
|
+
};
|
|
109
|
+
suggestion?: string;
|
|
110
|
+
agent_assessment?: {
|
|
111
|
+
status: string;
|
|
112
|
+
merged_risk: string;
|
|
113
|
+
merged_decision: string;
|
|
114
|
+
confidence: number | null;
|
|
115
|
+
notes: string | null;
|
|
116
|
+
read_only_violation: boolean;
|
|
117
|
+
stdout_truncated: boolean;
|
|
118
|
+
stderr_truncated: boolean;
|
|
119
|
+
} | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type CreateTaskResult = CreateTaskOutput | AssessOnlyOutput;
|
|
123
|
+
|
|
63
124
|
export interface CreateTaskOutput {
|
|
64
125
|
task_id: string;
|
|
65
126
|
plan_id: string;
|
|
@@ -85,15 +146,39 @@ export interface CreateTaskOutput {
|
|
|
85
146
|
};
|
|
86
147
|
}
|
|
87
148
|
|
|
88
|
-
export function createTask(input: CreateTaskInput):
|
|
149
|
+
export function createTask(input: CreateTaskInput & { execution_mode: "assess_only" }): AssessOnlyOutput;
|
|
150
|
+
export function createTask(input: CreateTaskInput): CreateTaskOutput;
|
|
151
|
+
export function createTask(input: CreateTaskInput): CreateTaskResult {
|
|
89
152
|
const config = getConfig();
|
|
90
153
|
const tasksDir = getTasksDir(config);
|
|
91
154
|
const plansDir = getPlansDir(config);
|
|
92
155
|
|
|
156
|
+
const executionMode = input.execution_mode || "execute";
|
|
157
|
+
|
|
158
|
+
// ── assessment_id execute mode: load record and override params ──
|
|
159
|
+
let assessmentRecord: AssessmentRecord | null = null;
|
|
160
|
+
let effectiveInput = input;
|
|
161
|
+
if (executionMode === "execute" && input.assessment_id) {
|
|
162
|
+
assessmentRecord = readAssessment(input.assessment_id);
|
|
163
|
+
// Parameter mismatch check: if ChatGPT passes params that differ from the assessment, reject
|
|
164
|
+
effectiveInput = mergeAssessmentIntoInput(input, assessmentRecord);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Validate required fields ──
|
|
168
|
+
// agent and repo_path are required unless assessment_id fills them.
|
|
169
|
+
// assess_only always requires them because the risk engine needs to check them.
|
|
170
|
+
if (!effectiveInput.agent || effectiveInput.agent.trim() === "") {
|
|
171
|
+
throw new PatchWardenError(
|
|
172
|
+
"agent_required",
|
|
173
|
+
"create_task requires an agent name.",
|
|
174
|
+
"Pass a configured agent name, or use assessment_id from a prior assess_only call."
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
93
178
|
const planSources = [
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
179
|
+
effectiveInput.plan_id?.trim() ? "plan_id" : "",
|
|
180
|
+
effectiveInput.inline_plan?.trim() ? "inline_plan" : "",
|
|
181
|
+
effectiveInput.template ? "template" : "",
|
|
97
182
|
].filter(Boolean);
|
|
98
183
|
if (planSources.length !== 1) {
|
|
99
184
|
throw new PatchWardenError(
|
|
@@ -102,16 +187,16 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
|
|
|
102
187
|
"Use an existing plan_id, pass inline_plan text, or choose one built-in template."
|
|
103
188
|
);
|
|
104
189
|
}
|
|
105
|
-
if (
|
|
190
|
+
if (effectiveInput.template && !TASK_TEMPLATE_NAMES.includes(effectiveInput.template)) {
|
|
106
191
|
throw new PatchWardenError(
|
|
107
192
|
"invalid_task_template",
|
|
108
|
-
`Unknown task template "${
|
|
193
|
+
`Unknown task template "${effectiveInput.template}".`,
|
|
109
194
|
`Use one of: ${TASK_TEMPLATE_NAMES.join(", ")}.`
|
|
110
195
|
);
|
|
111
196
|
}
|
|
112
197
|
|
|
113
198
|
// Resolve repo alias if configured
|
|
114
|
-
let resolvedRepoPath =
|
|
199
|
+
let resolvedRepoPath = effectiveInput.repo_path?.trim() || "";
|
|
115
200
|
const aliases = (config as any).repoAliases as Record<string, string> | undefined;
|
|
116
201
|
if (aliases && resolvedRepoPath && aliases[resolvedRepoPath]) {
|
|
117
202
|
resolvedRepoPath = aliases[resolvedRepoPath];
|
|
@@ -128,10 +213,10 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
|
|
|
128
213
|
}
|
|
129
214
|
|
|
130
215
|
// Validate agent
|
|
131
|
-
if (!config.agents[
|
|
216
|
+
if (!config.agents[effectiveInput.agent]) {
|
|
132
217
|
throw new PatchWardenError(
|
|
133
218
|
"agent_not_configured",
|
|
134
|
-
`Unknown agent "${
|
|
219
|
+
`Unknown agent "${effectiveInput.agent}". Available: ${Object.keys(config.agents).join(", ")}`,
|
|
135
220
|
"Call list_agents and use an available configured agent."
|
|
136
221
|
);
|
|
137
222
|
}
|
|
@@ -162,45 +247,23 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
|
|
|
162
247
|
|
|
163
248
|
// Runtime self-modification protection: refuse to modify the active
|
|
164
249
|
// PatchWarden runtime directory or its critical subdirectories.
|
|
165
|
-
|
|
166
|
-
const criticalDirs = ["dist", "src", "scripts", "release"];
|
|
167
|
-
if (safeRepoPath === runtimeRoot || safeRepoPath.startsWith(runtimeRoot + resolve("/")[0])) {
|
|
168
|
-
const isCritical = criticalDirs.some((dir) =>
|
|
169
|
-
safeRepoPath === join(runtimeRoot, dir) ||
|
|
170
|
-
safeRepoPath.startsWith(join(runtimeRoot, dir) + resolve("/")[0])
|
|
171
|
-
);
|
|
172
|
-
if (safeRepoPath === runtimeRoot || isCritical) {
|
|
173
|
-
throw new PatchWardenError(
|
|
174
|
-
"runtime_self_modification_blocked",
|
|
175
|
-
`repo_path "${resolvedRepoPath}" points to the active PatchWarden runtime or its critical subdirectories.`,
|
|
176
|
-
"Use a dev copy or git worktree for PatchWarden development. The running MCP server must not be modified by a task.",
|
|
177
|
-
true,
|
|
178
|
-
{
|
|
179
|
-
operation: "create_task",
|
|
180
|
-
path: resolvedRepoPath,
|
|
181
|
-
resolved_repo_path: safeRepoPath,
|
|
182
|
-
runtime_root: runtimeRoot,
|
|
183
|
-
safe_alternative: "Clone or copy PatchWarden to a separate directory for development tasks.",
|
|
184
|
-
}
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
250
|
+
guardRuntimeSelfModification(safeRepoPath);
|
|
188
251
|
|
|
189
252
|
// Validate test command — must be in allowlist, no swallowing
|
|
190
253
|
let testCmd = "";
|
|
191
|
-
if (
|
|
192
|
-
testCmd = guardTestCommand(
|
|
254
|
+
if (effectiveInput.test_command && effectiveInput.test_command.trim() !== "") {
|
|
255
|
+
testCmd = guardTestCommand(effectiveInput.test_command, config, safeRepoPath);
|
|
193
256
|
// guardTestCommand throws if not in allowedTestCommands
|
|
194
257
|
}
|
|
195
258
|
|
|
196
|
-
if (
|
|
259
|
+
if (effectiveInput.verify_commands !== undefined && !Array.isArray(effectiveInput.verify_commands)) {
|
|
197
260
|
throw new PatchWardenError(
|
|
198
261
|
"invalid_verify_commands",
|
|
199
262
|
"verify_commands must be an array of allow-listed command strings.",
|
|
200
263
|
"Pass an array such as [\"npm test\", \"npm run build\"]."
|
|
201
264
|
);
|
|
202
265
|
}
|
|
203
|
-
if ((
|
|
266
|
+
if ((effectiveInput.verify_commands?.length || 0) > 20) {
|
|
204
267
|
throw new PatchWardenError(
|
|
205
268
|
"invalid_verify_commands",
|
|
206
269
|
"verify_commands cannot contain more than 20 commands.",
|
|
@@ -208,11 +271,11 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
|
|
|
208
271
|
);
|
|
209
272
|
}
|
|
210
273
|
const verifyCommands = [...new Set([
|
|
211
|
-
...(
|
|
274
|
+
...(effectiveInput.verify_commands || []).map((command) => guardTestCommand(command, config, safeRepoPath)),
|
|
212
275
|
...(testCmd ? [testCmd] : []),
|
|
213
276
|
])];
|
|
214
277
|
|
|
215
|
-
const timeoutSeconds =
|
|
278
|
+
const timeoutSeconds = effectiveInput.timeout_seconds ?? config.defaultTaskTimeoutSeconds;
|
|
216
279
|
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) {
|
|
217
280
|
throw new PatchWardenError(
|
|
218
281
|
"invalid_timeout",
|
|
@@ -228,9 +291,206 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
|
|
|
228
291
|
);
|
|
229
292
|
}
|
|
230
293
|
|
|
231
|
-
let planId =
|
|
294
|
+
let planId = effectiveInput.plan_id?.trim() || "";
|
|
232
295
|
let planSource: CreateTaskOutput["plan_source"] = "saved";
|
|
233
296
|
let changePolicy: ChangePolicy = "repo_scoped_changes";
|
|
297
|
+
let planContentForHash: string | null = null;
|
|
298
|
+
|
|
299
|
+
// ── assess_only: check plan content WITHOUT saving first ──
|
|
300
|
+
// This catches guardPlanContent violations as hard rule hits instead of throwing.
|
|
301
|
+
if (executionMode === "assess_only") {
|
|
302
|
+
let assessPlanContent = "";
|
|
303
|
+
let assessPlanTitle = effectiveInput.plan_title?.trim() || "Assessment plan";
|
|
304
|
+
if (planId) {
|
|
305
|
+
const planFile = join(resolve(plansDir, planId), "plan.md");
|
|
306
|
+
guardReadPath(planFile, config.workspaceRoot, config.plansDir);
|
|
307
|
+
if (!existsSync(planFile)) {
|
|
308
|
+
throw new PatchWardenError(
|
|
309
|
+
"plan_not_found",
|
|
310
|
+
`Plan "${planId}" not found.`,
|
|
311
|
+
"Call save_plan first, or pass inline_plan/template directly to create_task."
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
assessPlanContent = readFileSync(planFile, "utf-8");
|
|
315
|
+
} else if (effectiveInput.inline_plan?.trim()) {
|
|
316
|
+
assessPlanContent = effectiveInput.inline_plan.trim();
|
|
317
|
+
} else if (effectiveInput.template) {
|
|
318
|
+
const expanded = expandTaskTemplate({
|
|
319
|
+
template: effectiveInput.template,
|
|
320
|
+
goal: effectiveInput.goal || "",
|
|
321
|
+
source_task_id: effectiveInput.source_task_id,
|
|
322
|
+
verify_commands: verifyCommands,
|
|
323
|
+
});
|
|
324
|
+
assessPlanContent = expanded.content;
|
|
325
|
+
assessPlanTitle = expanded.title;
|
|
326
|
+
changePolicy = expanded.change_policy;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Run risk engine WITHOUT saving the plan
|
|
330
|
+
let planBlocked = false;
|
|
331
|
+
let planBlockReason = "";
|
|
332
|
+
try {
|
|
333
|
+
guardPlanContent(assessPlanTitle, assessPlanContent);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
planBlocked = true;
|
|
336
|
+
planBlockReason = e instanceof PatchWardenError ? e.reason : "plan_content_blocked";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const snapshot = captureRepoSnapshot(safeRepoPath);
|
|
340
|
+
const snapshotTruncated = snapshot.warnings.some((w) => w.includes("snapshot limited"));
|
|
341
|
+
|
|
342
|
+
let riskResult;
|
|
343
|
+
if (planBlocked) {
|
|
344
|
+
riskResult = {
|
|
345
|
+
risk_level: "high" as const,
|
|
346
|
+
decision: "blocked" as const,
|
|
347
|
+
reason_codes: [],
|
|
348
|
+
risk_hints: [],
|
|
349
|
+
hard_rule_hits: [planBlockReason],
|
|
350
|
+
};
|
|
351
|
+
} else {
|
|
352
|
+
riskResult = assessRisk({
|
|
353
|
+
repoPath: resolvedRepoPath,
|
|
354
|
+
resolvedRepoPath: safeRepoPath,
|
|
355
|
+
planContent: assessPlanContent,
|
|
356
|
+
planTitle: assessPlanTitle,
|
|
357
|
+
testCommand: testCmd,
|
|
358
|
+
verifyCommands: verifyCommands,
|
|
359
|
+
template: effectiveInput.template,
|
|
360
|
+
goal: effectiveInput.goal,
|
|
361
|
+
agent: effectiveInput.agent,
|
|
362
|
+
config,
|
|
363
|
+
snapshotTruncated,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Save the plan now that risk assessment is done (for allow/needs_confirm)
|
|
368
|
+
if (!planBlocked) {
|
|
369
|
+
if (effectiveInput.inline_plan?.trim()) {
|
|
370
|
+
const saved = savePlan({
|
|
371
|
+
title: effectiveInput.plan_title?.trim() || "Inline task plan",
|
|
372
|
+
content: effectiveInput.inline_plan.trim(),
|
|
373
|
+
});
|
|
374
|
+
planId = saved.plan_id;
|
|
375
|
+
planSource = "inline";
|
|
376
|
+
planContentForHash = readFileSync(saved.path, "utf-8");
|
|
377
|
+
} else if (effectiveInput.template) {
|
|
378
|
+
const saved = savePlan({ title: assessPlanTitle, content: assessPlanContent });
|
|
379
|
+
planId = saved.plan_id;
|
|
380
|
+
planSource = "template";
|
|
381
|
+
planContentForHash = readFileSync(saved.path, "utf-8");
|
|
382
|
+
} else {
|
|
383
|
+
planContentForHash = assessPlanContent;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Agent assessment: only when enabled AND deterministic decision is "allow" ──
|
|
388
|
+
let finalRiskResult = riskResult;
|
|
389
|
+
let agentAssessmentSummary: AgentAssessmentSummary | null = null;
|
|
390
|
+
let preGeneratedAssessmentId: string | undefined;
|
|
391
|
+
let preGeneratedAssessmentDir: string | undefined;
|
|
392
|
+
|
|
393
|
+
if (config.enableAgentAssessment === true && riskResult.decision === "allow" && !planBlocked) {
|
|
394
|
+
preGeneratedAssessmentId = generateAssessmentId();
|
|
395
|
+
preGeneratedAssessmentDir = createAssessmentDir(preGeneratedAssessmentId);
|
|
396
|
+
|
|
397
|
+
const assessorAgentName = config.agentAssessmentAgentName || effectiveInput.agent;
|
|
398
|
+
agentAssessmentSummary = runAgentAssessment({
|
|
399
|
+
assessmentId: preGeneratedAssessmentId,
|
|
400
|
+
assessmentDir: preGeneratedAssessmentDir,
|
|
401
|
+
agentName: assessorAgentName,
|
|
402
|
+
repoPath: safeRepoPath,
|
|
403
|
+
workspaceRoot: config.workspaceRoot,
|
|
404
|
+
goal: effectiveInput.goal || "",
|
|
405
|
+
planContent: assessPlanContent,
|
|
406
|
+
timeoutSeconds: config.agentAssessmentTimeoutSeconds || 120,
|
|
407
|
+
maxOutputBytes: config.agentAssessmentMaxOutputBytes || 524288,
|
|
408
|
+
config,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
finalRiskResult = {
|
|
412
|
+
risk_level: agentAssessmentSummary.merged_risk,
|
|
413
|
+
decision: agentAssessmentSummary.merged_decision,
|
|
414
|
+
reason_codes: [...riskResult.reason_codes, ...agentAssessmentSummary.merged_reason_codes],
|
|
415
|
+
risk_hints: riskResult.risk_hints,
|
|
416
|
+
hard_rule_hits: riskResult.hard_rule_hits,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const record = createAssessment({
|
|
421
|
+
decision: finalRiskResult.decision,
|
|
422
|
+
risk_level: finalRiskResult.risk_level,
|
|
423
|
+
risk_hints: finalRiskResult.risk_hints,
|
|
424
|
+
hard_rule_hits: finalRiskResult.hard_rule_hits,
|
|
425
|
+
reason_codes: finalRiskResult.reason_codes,
|
|
426
|
+
repo_path: resolvedRepoPath,
|
|
427
|
+
resolved_repo_path: safeRepoPath,
|
|
428
|
+
plan_id: planId || null,
|
|
429
|
+
plan_content: planContentForHash,
|
|
430
|
+
template: effectiveInput.template || null,
|
|
431
|
+
goal: effectiveInput.goal || null,
|
|
432
|
+
test_command: testCmd || null,
|
|
433
|
+
verify_commands: verifyCommands,
|
|
434
|
+
agent: effectiveInput.agent,
|
|
435
|
+
timeout_seconds: timeoutSeconds,
|
|
436
|
+
change_policy: changePolicy,
|
|
437
|
+
snapshot,
|
|
438
|
+
...(preGeneratedAssessmentId ? { assessment_id: preGeneratedAssessmentId } : {}),
|
|
439
|
+
...(preGeneratedAssessmentDir ? { assessment_dir: preGeneratedAssessmentDir } : {}),
|
|
440
|
+
agent_assessment_summary: agentAssessmentSummary,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const nextAction = record.decision === "allow"
|
|
444
|
+
? "Call the returned next_tool_call exactly as provided; do not resend goal, plan, repository, agent, or verification arguments."
|
|
445
|
+
: record.decision === "needs_confirm"
|
|
446
|
+
? `Assessment requires local confirmation. Run patchwarden-confirm ${record.assessment_id} locally, then call the returned next_tool_call exactly as provided.`
|
|
447
|
+
: `Assessment blocked. Fix the reported hard rule hit and re-run assess_only.`;
|
|
448
|
+
|
|
449
|
+
const nextToolCall = record.decision === "blocked" ? undefined : {
|
|
450
|
+
name: "create_task" as const,
|
|
451
|
+
arguments: {
|
|
452
|
+
execution_mode: "execute" as const,
|
|
453
|
+
assessment_id: record.assessment_id,
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
assessment_id: record.assessment_id,
|
|
459
|
+
assessment_short_id: record.assessment_short_id,
|
|
460
|
+
decision: record.decision,
|
|
461
|
+
risk_level: record.risk_level,
|
|
462
|
+
risk_hints: record.risk_hints,
|
|
463
|
+
hard_rule_hits: record.hard_rule_hits,
|
|
464
|
+
reason_codes: record.reason_codes,
|
|
465
|
+
expires_at: record.expires_at,
|
|
466
|
+
requires_confirm: record.requires_confirm,
|
|
467
|
+
workspace_snapshot_summary: record.workspace_snapshot_summary,
|
|
468
|
+
next_action: nextAction,
|
|
469
|
+
...(nextToolCall ? { next_tool_call: nextToolCall } : {}),
|
|
470
|
+
local_confirmation: {
|
|
471
|
+
required: record.requires_confirm,
|
|
472
|
+
command: record.requires_confirm ? "patchwarden-confirm" : null,
|
|
473
|
+
arguments: record.requires_confirm ? [record.assessment_id] : [],
|
|
474
|
+
},
|
|
475
|
+
...(finalRiskResult.hard_rule_hits.length > 0
|
|
476
|
+
? { suggestion: `Hard rule hit: ${finalRiskResult.hard_rule_hits.join(", ")}` }
|
|
477
|
+
: {}),
|
|
478
|
+
...(agentAssessmentSummary ? {
|
|
479
|
+
agent_assessment: {
|
|
480
|
+
status: agentAssessmentSummary.status,
|
|
481
|
+
merged_risk: agentAssessmentSummary.merged_risk,
|
|
482
|
+
merged_decision: agentAssessmentSummary.merged_decision,
|
|
483
|
+
confidence: agentAssessmentSummary.output?.confidence ?? null,
|
|
484
|
+
notes: agentAssessmentSummary.output?.notes ?? null,
|
|
485
|
+
read_only_violation: agentAssessmentSummary.read_only_violation,
|
|
486
|
+
stdout_truncated: agentAssessmentSummary.stdout_truncated,
|
|
487
|
+
stderr_truncated: agentAssessmentSummary.stderr_truncated,
|
|
488
|
+
}
|
|
489
|
+
} : {}),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── execute mode: resolve plan normally ──
|
|
234
494
|
if (planId) {
|
|
235
495
|
const planFile = join(resolve(plansDir, planId), "plan.md");
|
|
236
496
|
guardReadPath(planFile, config.workspaceRoot, config.plansDir);
|
|
@@ -241,31 +501,45 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
|
|
|
241
501
|
"Call save_plan first, or pass inline_plan/template directly to create_task."
|
|
242
502
|
);
|
|
243
503
|
}
|
|
244
|
-
|
|
504
|
+
planContentForHash = readFileSync(planFile, "utf-8");
|
|
505
|
+
} else if (effectiveInput.inline_plan?.trim()) {
|
|
245
506
|
const saved = savePlan({
|
|
246
|
-
title:
|
|
247
|
-
content:
|
|
507
|
+
title: effectiveInput.plan_title?.trim() || "Inline task plan",
|
|
508
|
+
content: effectiveInput.inline_plan.trim(),
|
|
248
509
|
});
|
|
249
510
|
planId = saved.plan_id;
|
|
250
511
|
planSource = "inline";
|
|
512
|
+
planContentForHash = readFileSync(saved.path, "utf-8");
|
|
251
513
|
} else {
|
|
252
514
|
const expanded = expandTaskTemplate({
|
|
253
|
-
template:
|
|
254
|
-
goal:
|
|
255
|
-
source_task_id:
|
|
515
|
+
template: effectiveInput.template!,
|
|
516
|
+
goal: effectiveInput.goal || "",
|
|
517
|
+
source_task_id: effectiveInput.source_task_id,
|
|
256
518
|
verify_commands: verifyCommands,
|
|
257
519
|
});
|
|
258
520
|
const saved = savePlan({ title: expanded.title, content: expanded.content });
|
|
259
521
|
planId = saved.plan_id;
|
|
260
522
|
planSource = "template";
|
|
261
523
|
changePolicy = expanded.change_policy;
|
|
524
|
+
planContentForHash = readFileSync(saved.path, "utf-8");
|
|
262
525
|
}
|
|
263
526
|
|
|
264
|
-
|
|
265
|
-
|
|
527
|
+
// ── execute mode with assessment_id: validate freshness ──
|
|
528
|
+
if (assessmentRecord) {
|
|
529
|
+
const snapshot = captureRepoSnapshot(safeRepoPath);
|
|
530
|
+
const validation = validateAssessmentFreshness(input.assessment_id!, snapshot);
|
|
531
|
+
if (!validation.valid) {
|
|
532
|
+
throw new PatchWardenError(
|
|
533
|
+
validation.failure_reason || "assessment_validation_failed",
|
|
534
|
+
`Assessment "${input.assessment_id}" is no longer valid: ${validation.failure_reason}`,
|
|
535
|
+
"Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.",
|
|
536
|
+
true,
|
|
537
|
+
{ assessment_id: input.assessment_id, failure_reason: validation.failure_reason }
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
266
541
|
|
|
267
|
-
|
|
268
|
-
mkdirSync(taskDir, { recursive: true });
|
|
542
|
+
const { taskId, taskDir } = createTaskDirectory(tasksDir, config.workspaceRoot, config.tasksDir);
|
|
269
543
|
|
|
270
544
|
const status: TaskStatus = "pending";
|
|
271
545
|
const statusFile = join(taskDir, "status.json");
|
|
@@ -273,15 +547,16 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
|
|
|
273
547
|
task_id: taskId,
|
|
274
548
|
plan_id: planId,
|
|
275
549
|
plan_source: planSource,
|
|
276
|
-
template:
|
|
550
|
+
template: effectiveInput.template || null,
|
|
277
551
|
change_policy: changePolicy,
|
|
278
|
-
agent:
|
|
552
|
+
agent: effectiveInput.agent,
|
|
279
553
|
workspace_root: resolve(config.workspaceRoot),
|
|
280
554
|
repo_path: resolvedRepoPath,
|
|
281
555
|
resolved_repo_path: safeRepoPath,
|
|
282
556
|
test_command: testCmd,
|
|
283
557
|
verify_commands: verifyCommands,
|
|
284
558
|
timeout_seconds: timeoutSeconds,
|
|
559
|
+
assessment_id: assessmentRecord?.assessment_id || input.assessment_id || null,
|
|
285
560
|
status,
|
|
286
561
|
phase: "queued" as TaskPhase,
|
|
287
562
|
created_at: new Date().toISOString(),
|
|
@@ -312,14 +587,14 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
|
|
|
312
587
|
return {
|
|
313
588
|
task_id: taskId,
|
|
314
589
|
plan_id: planId,
|
|
315
|
-
agent:
|
|
590
|
+
agent: effectiveInput.agent,
|
|
316
591
|
status,
|
|
317
592
|
timeout_seconds: timeoutSeconds,
|
|
318
593
|
continuation_required: watcher.available && hasWaitForTask,
|
|
319
594
|
next_action: !watcher.available ? nextActionBlocked : hasWaitForTask ? nextActionWait : nextActionPoll,
|
|
320
595
|
path: taskDir,
|
|
321
596
|
plan_source: planSource,
|
|
322
|
-
...(
|
|
597
|
+
...(effectiveInput.template ? { template: effectiveInput.template } : {}),
|
|
323
598
|
change_policy: changePolicy,
|
|
324
599
|
server_version: PATCHWARDEN_VERSION,
|
|
325
600
|
tool_profile: catalog?.tool_profile || resolveToolProfile(config.toolProfile),
|
|
@@ -333,5 +608,80 @@ export function createTask(input: CreateTaskInput): CreateTaskOutput {
|
|
|
333
608
|
: hasWaitForTask
|
|
334
609
|
? { name: "wait_for_task", arguments: { task_id: taskId, timeout_seconds: 25 } }
|
|
335
610
|
: { name: "get_task_status", arguments: { task_id: taskId } },
|
|
611
|
+
} as CreateTaskOutput;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function mergeAssessmentIntoInput(
|
|
615
|
+
input: CreateTaskInput,
|
|
616
|
+
record: AssessmentRecord
|
|
617
|
+
): CreateTaskInput {
|
|
618
|
+
// Use plan_id from the assessment record (the plan was already saved during assess_only).
|
|
619
|
+
// Do NOT set template/inline_plan alongside plan_id — createTask requires exactly one plan source.
|
|
620
|
+
const merged: CreateTaskInput = {
|
|
621
|
+
...input,
|
|
622
|
+
plan_id: record.plan_id || input.plan_id,
|
|
623
|
+
template: undefined,
|
|
624
|
+
inline_plan: undefined,
|
|
625
|
+
goal: record.goal || input.goal,
|
|
626
|
+
agent: record.agent,
|
|
627
|
+
repo_path: record.repo_path,
|
|
628
|
+
test_command: record.test_command || undefined,
|
|
629
|
+
verify_commands: record.verify_commands || input.verify_commands,
|
|
630
|
+
timeout_seconds: record.timeout_seconds || input.timeout_seconds,
|
|
336
631
|
};
|
|
632
|
+
// Parameter mismatch check: if caller passed explicit params that differ from assessment
|
|
633
|
+
if (input.template && record.template && input.template !== record.template) {
|
|
634
|
+
throw new PatchWardenError(
|
|
635
|
+
"assessment_parameter_mismatch",
|
|
636
|
+
`template mismatch: caller passed "${input.template}" but assessment has "${record.template}".`,
|
|
637
|
+
"Do not override assessment-locked parameters. Use the same assessment_id as-is.",
|
|
638
|
+
true,
|
|
639
|
+
{ field: "template", assessment_value: record.template, caller_value: input.template }
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
if (input.goal && record.goal && input.goal !== record.goal) {
|
|
643
|
+
throw new PatchWardenError(
|
|
644
|
+
"assessment_parameter_mismatch",
|
|
645
|
+
`goal mismatch: caller passed a different goal than the assessment.`,
|
|
646
|
+
"Do not override assessment-locked parameters. Use the same assessment_id as-is.",
|
|
647
|
+
true,
|
|
648
|
+
{ field: "goal" }
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (input.repo_path && record.repo_path && input.repo_path !== record.repo_path) {
|
|
652
|
+
throw new PatchWardenError(
|
|
653
|
+
"assessment_parameter_mismatch",
|
|
654
|
+
`repo_path mismatch: caller passed "${input.repo_path}" but assessment has "${record.repo_path}".`,
|
|
655
|
+
"Do not override assessment-locked parameters. Use the same assessment_id as-is.",
|
|
656
|
+
true,
|
|
657
|
+
{ field: "repo_path", assessment_value: record.repo_path, caller_value: input.repo_path }
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
return merged;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function createTaskDirectory(tasksDir: string, workspaceRoot: string, configuredTasksDir: string): { taskId: string; taskDir: string } {
|
|
664
|
+
guardPath(tasksDir, workspaceRoot, configuredTasksDir);
|
|
665
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
666
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
667
|
+
const timestamp = new Date().toISOString()
|
|
668
|
+
.replace(/[-:]/g, "")
|
|
669
|
+
.replace("T", "_")
|
|
670
|
+
.slice(0, 15);
|
|
671
|
+
const taskId = `task_${timestamp}_${randomBytes(3).toString("hex")}`;
|
|
672
|
+
const taskDir = resolve(tasksDir, taskId);
|
|
673
|
+
guardPath(taskDir, workspaceRoot, configuredTasksDir);
|
|
674
|
+
try {
|
|
675
|
+
mkdirSync(taskDir, { recursive: false });
|
|
676
|
+
return { taskId, taskDir };
|
|
677
|
+
} catch (error) {
|
|
678
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
|
|
679
|
+
throw error;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
throw new PatchWardenError(
|
|
683
|
+
"task_id_generation_failed",
|
|
684
|
+
"Could not allocate a unique short task ID.",
|
|
685
|
+
"Retry create_task; no task directory was created."
|
|
686
|
+
);
|
|
337
687
|
}
|