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
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
writeFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { resolve, join } from "node:path";
|
|
10
|
+
import { getAssessmentsDir, getConfig, getRepoAllowedTestCommands, PatchWardenConfig } from "../config.js";
|
|
11
|
+
import { guardPath } from "../security/pathGuard.js";
|
|
12
|
+
import { PatchWardenError } from "../errors.js";
|
|
13
|
+
import { TOOL_SCHEMA_EPOCH } from "../version.js";
|
|
14
|
+
import { getLastToolCatalogSnapshot } from "../tools/toolCatalog.js";
|
|
15
|
+
import { captureRepoSnapshot, type RepoSnapshot } from "../runner/changeCapture.js";
|
|
16
|
+
import type { RiskAssessmentResult } from "../security/riskEngine.js";
|
|
17
|
+
import type { TaskTemplateName, ChangePolicy } from "../tools/taskTemplates.js";
|
|
18
|
+
|
|
19
|
+
const SENSITIVE_PATH_RULES_SIGNATURE = "v1";
|
|
20
|
+
const ARTIFACT_RULES_SIGNATURE = "v1";
|
|
21
|
+
|
|
22
|
+
export interface AgentAssessmentSummary {
|
|
23
|
+
attempted: boolean;
|
|
24
|
+
status: "completed" | "timed_out" | "non_zero_exit" | "parse_failed" | "read_only_violation" | "spawn_failed" | "not_run";
|
|
25
|
+
output: AgentAssessmentOutput | null;
|
|
26
|
+
merged_risk: "low" | "medium" | "high";
|
|
27
|
+
merged_decision: "allow" | "needs_confirm" | "blocked";
|
|
28
|
+
merged_reason_codes: string[];
|
|
29
|
+
timed_out: boolean;
|
|
30
|
+
exit_code: number | null;
|
|
31
|
+
read_only_violation: boolean;
|
|
32
|
+
violation_files: string[];
|
|
33
|
+
stdout_truncated: boolean;
|
|
34
|
+
stderr_truncated: boolean;
|
|
35
|
+
log_paths: {
|
|
36
|
+
stdout: string;
|
|
37
|
+
stderr: string;
|
|
38
|
+
assessment: string;
|
|
39
|
+
prompt?: string;
|
|
40
|
+
violation?: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AgentAssessmentOutput {
|
|
45
|
+
risk_level: "low" | "medium" | "high";
|
|
46
|
+
reason_codes: string[];
|
|
47
|
+
affected_paths: string[];
|
|
48
|
+
destructive_actions: string[];
|
|
49
|
+
requires_user_confirm: boolean;
|
|
50
|
+
confidence: number;
|
|
51
|
+
notes: string;
|
|
52
|
+
assessed_at: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AssessmentRecord {
|
|
56
|
+
assessment_id: string;
|
|
57
|
+
assessment_short_id: string;
|
|
58
|
+
decision: "allow" | "needs_confirm" | "blocked";
|
|
59
|
+
risk_level: "low" | "medium" | "high";
|
|
60
|
+
risk_hints: string[];
|
|
61
|
+
hard_rule_hits: string[];
|
|
62
|
+
reason_codes: string[];
|
|
63
|
+
plan_hash: string | null;
|
|
64
|
+
plan_id: string | null;
|
|
65
|
+
policy_hash: string;
|
|
66
|
+
tool_manifest_sha256: string;
|
|
67
|
+
workspace_fingerprint: string;
|
|
68
|
+
workspace_snapshot_summary: {
|
|
69
|
+
head: string | null;
|
|
70
|
+
file_count: number;
|
|
71
|
+
workspace_dirty: boolean;
|
|
72
|
+
snapshot_truncated: boolean;
|
|
73
|
+
};
|
|
74
|
+
expires_at: string;
|
|
75
|
+
created_at: string;
|
|
76
|
+
repo_path: string;
|
|
77
|
+
resolved_repo_path: string;
|
|
78
|
+
template?: TaskTemplateName | null;
|
|
79
|
+
goal?: string | null;
|
|
80
|
+
test_command?: string | null;
|
|
81
|
+
verify_commands?: string[];
|
|
82
|
+
agent: string;
|
|
83
|
+
timeout_seconds?: number;
|
|
84
|
+
change_policy?: ChangePolicy;
|
|
85
|
+
requires_confirm: boolean;
|
|
86
|
+
confirmed: boolean;
|
|
87
|
+
confirmed_at: string | null;
|
|
88
|
+
confirm_code: string | null;
|
|
89
|
+
agent_assessment_summary?: AgentAssessmentSummary | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AssessmentCreateInput {
|
|
93
|
+
decision: RiskAssessmentResult["decision"];
|
|
94
|
+
risk_level: RiskAssessmentResult["risk_level"];
|
|
95
|
+
risk_hints: string[];
|
|
96
|
+
hard_rule_hits: string[];
|
|
97
|
+
reason_codes: string[];
|
|
98
|
+
repo_path: string;
|
|
99
|
+
resolved_repo_path: string;
|
|
100
|
+
plan_id: string | null;
|
|
101
|
+
plan_content: string | null;
|
|
102
|
+
template?: TaskTemplateName | null;
|
|
103
|
+
goal?: string | null;
|
|
104
|
+
test_command?: string | null;
|
|
105
|
+
verify_commands?: string[];
|
|
106
|
+
agent: string;
|
|
107
|
+
timeout_seconds?: number;
|
|
108
|
+
change_policy?: ChangePolicy;
|
|
109
|
+
snapshot: RepoSnapshot;
|
|
110
|
+
assessment_id?: string;
|
|
111
|
+
assessment_dir?: string;
|
|
112
|
+
agent_assessment_summary?: AgentAssessmentSummary | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface AssessmentValidationResult {
|
|
116
|
+
valid: boolean;
|
|
117
|
+
failure_reason: string | null;
|
|
118
|
+
assessment: AssessmentRecord | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface AssessmentConfirmationResult {
|
|
122
|
+
assessment_id: string;
|
|
123
|
+
decision: "needs_confirm";
|
|
124
|
+
confirmed: true;
|
|
125
|
+
confirmed_at: string;
|
|
126
|
+
expires_at: string;
|
|
127
|
+
next_action: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface AssessmentValidationOptions {
|
|
131
|
+
allow_unconfirmed?: boolean;
|
|
132
|
+
skip_tool_manifest?: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function generateAssessmentId(): string {
|
|
136
|
+
const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "_").slice(0, 15);
|
|
137
|
+
const randomHex = randomBytes(16).toString("hex");
|
|
138
|
+
return `assessment_${timestamp}_${randomHex}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createAssessmentDir(assessmentId: string): string {
|
|
142
|
+
const config = getConfig();
|
|
143
|
+
const assessmentsDir = getAssessmentsDir(config);
|
|
144
|
+
guardPath(assessmentsDir, config.workspaceRoot, config.assessmentsDir);
|
|
145
|
+
mkdirSync(assessmentsDir, { recursive: true });
|
|
146
|
+
const dir = resolve(assessmentsDir, assessmentId);
|
|
147
|
+
guardPath(dir, config.workspaceRoot, config.assessmentsDir);
|
|
148
|
+
mkdirSync(dir, { recursive: true });
|
|
149
|
+
return dir;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function createAssessment(input: AssessmentCreateInput): AssessmentRecord {
|
|
153
|
+
const config = getConfig();
|
|
154
|
+
|
|
155
|
+
const assessmentId = input.assessment_id || generateAssessmentId();
|
|
156
|
+
const randomHex = assessmentId.split("_").pop() || "";
|
|
157
|
+
const dir = input.assessment_dir || createAssessmentDir(assessmentId);
|
|
158
|
+
|
|
159
|
+
const planHash = input.plan_content
|
|
160
|
+
? createHash("sha256").update(input.plan_content).digest("hex")
|
|
161
|
+
: null;
|
|
162
|
+
|
|
163
|
+
const toolManifest = getLastToolCatalogSnapshot()?.tool_manifest_sha256 || computeFallbackManifestHash(config);
|
|
164
|
+
|
|
165
|
+
const workspaceFingerprint = computeWorkspaceFingerprint(input.snapshot);
|
|
166
|
+
const snapshotTruncated = input.snapshot.warnings.some((w) => w.includes("snapshot limited"));
|
|
167
|
+
|
|
168
|
+
const policyHash = computePolicyHash({
|
|
169
|
+
change_policy: input.change_policy || "repo_scoped_changes",
|
|
170
|
+
template: input.template || null,
|
|
171
|
+
verify_commands: input.verify_commands || [],
|
|
172
|
+
allowed_test_commands: config.allowedTestCommands,
|
|
173
|
+
repo_allowed_test_commands: getRepoAllowedCommands(config, input.resolved_repo_path),
|
|
174
|
+
sensitive_path_rules: SENSITIVE_PATH_RULES_SIGNATURE,
|
|
175
|
+
artifact_rules: ARTIFACT_RULES_SIGNATURE,
|
|
176
|
+
schema_epoch: TOOL_SCHEMA_EPOCH,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const now = new Date();
|
|
180
|
+
const expiresAt = new Date(now.getTime() + config.assessmentTtlSeconds * 1000).toISOString();
|
|
181
|
+
|
|
182
|
+
const record: AssessmentRecord = {
|
|
183
|
+
assessment_id: assessmentId,
|
|
184
|
+
assessment_short_id: randomHex.slice(0, 12),
|
|
185
|
+
decision: input.decision,
|
|
186
|
+
risk_level: input.risk_level,
|
|
187
|
+
risk_hints: input.risk_hints,
|
|
188
|
+
hard_rule_hits: input.hard_rule_hits,
|
|
189
|
+
reason_codes: input.reason_codes,
|
|
190
|
+
plan_hash: planHash,
|
|
191
|
+
plan_id: input.plan_id,
|
|
192
|
+
policy_hash: policyHash,
|
|
193
|
+
tool_manifest_sha256: toolManifest,
|
|
194
|
+
workspace_fingerprint: workspaceFingerprint,
|
|
195
|
+
workspace_snapshot_summary: {
|
|
196
|
+
head: input.snapshot.head,
|
|
197
|
+
file_count: Object.keys(input.snapshot.files).length,
|
|
198
|
+
workspace_dirty: input.snapshot.workspace_dirty,
|
|
199
|
+
snapshot_truncated: snapshotTruncated,
|
|
200
|
+
},
|
|
201
|
+
expires_at: expiresAt,
|
|
202
|
+
created_at: now.toISOString(),
|
|
203
|
+
repo_path: input.repo_path,
|
|
204
|
+
resolved_repo_path: input.resolved_repo_path,
|
|
205
|
+
template: input.template || null,
|
|
206
|
+
goal: input.goal || null,
|
|
207
|
+
test_command: input.test_command || null,
|
|
208
|
+
verify_commands: input.verify_commands || [],
|
|
209
|
+
agent: input.agent,
|
|
210
|
+
timeout_seconds: input.timeout_seconds,
|
|
211
|
+
change_policy: input.change_policy || "repo_scoped_changes",
|
|
212
|
+
requires_confirm: input.decision === "needs_confirm",
|
|
213
|
+
confirmed: false,
|
|
214
|
+
confirmed_at: null,
|
|
215
|
+
confirm_code: null,
|
|
216
|
+
agent_assessment_summary: input.agent_assessment_summary || null,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
writeFileSync(join(dir, "assessment.json"), JSON.stringify(record, null, 2), "utf-8");
|
|
220
|
+
|
|
221
|
+
return record;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function readAssessment(assessmentId: string): AssessmentRecord {
|
|
225
|
+
const config = getConfig();
|
|
226
|
+
const assessmentsDir = getAssessmentsDir(config);
|
|
227
|
+
const dir = resolve(assessmentsDir, assessmentId);
|
|
228
|
+
const file = join(dir, "assessment.json");
|
|
229
|
+
guardPath(file, config.workspaceRoot, config.assessmentsDir);
|
|
230
|
+
if (!existsSync(file)) {
|
|
231
|
+
throw new PatchWardenError(
|
|
232
|
+
"assessment_not_found",
|
|
233
|
+
`Assessment "${assessmentId}" not found.`,
|
|
234
|
+
"Call create_task with execution_mode=assess_only first to get a valid assessment_id.",
|
|
235
|
+
true,
|
|
236
|
+
{ assessment_id: assessmentId }
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return JSON.parse(readFileSync(file, "utf-8")) as AssessmentRecord;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function validateAssessmentFreshness(
|
|
243
|
+
assessmentId: string,
|
|
244
|
+
currentSnapshot: RepoSnapshot,
|
|
245
|
+
options: AssessmentValidationOptions = {}
|
|
246
|
+
): AssessmentValidationResult {
|
|
247
|
+
let assessment: AssessmentRecord;
|
|
248
|
+
try {
|
|
249
|
+
assessment = readAssessment(assessmentId);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
return { valid: false, failure_reason: extractReason(e), assessment: null };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (new Date(assessment.expires_at) < new Date()) {
|
|
255
|
+
return { valid: false, failure_reason: "assessment_expired", assessment };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (assessment.plan_id && assessment.plan_hash) {
|
|
259
|
+
const config = getConfig();
|
|
260
|
+
const planFile = resolve(
|
|
261
|
+
config.workspaceRoot,
|
|
262
|
+
config.plansDir,
|
|
263
|
+
assessment.plan_id,
|
|
264
|
+
"plan.md"
|
|
265
|
+
);
|
|
266
|
+
if (!existsSync(planFile)) {
|
|
267
|
+
return { valid: false, failure_reason: "assessment_plan_file_missing", assessment };
|
|
268
|
+
}
|
|
269
|
+
const currentPlanHash = createHash("sha256")
|
|
270
|
+
.update(readFileSync(planFile, "utf-8"))
|
|
271
|
+
.digest("hex");
|
|
272
|
+
if (currentPlanHash !== assessment.plan_hash) {
|
|
273
|
+
return { valid: false, failure_reason: "assessment_stale_plan", assessment };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!options.skip_tool_manifest) {
|
|
278
|
+
const currentManifest = getLastToolCatalogSnapshot()?.tool_manifest_sha256 ||
|
|
279
|
+
computeFallbackManifestHash(getConfig());
|
|
280
|
+
if (currentManifest !== assessment.tool_manifest_sha256) {
|
|
281
|
+
return { valid: false, failure_reason: "assessment_stale_config", assessment };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const currentFingerprint = computeWorkspaceFingerprint(currentSnapshot);
|
|
286
|
+
if (currentFingerprint !== assessment.workspace_fingerprint) {
|
|
287
|
+
return { valid: false, failure_reason: "assessment_workspace_changed", assessment };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const config = getConfig();
|
|
291
|
+
const currentPolicyHash = computePolicyHash({
|
|
292
|
+
change_policy: assessment.change_policy || "repo_scoped_changes",
|
|
293
|
+
template: assessment.template || null,
|
|
294
|
+
verify_commands: assessment.verify_commands || [],
|
|
295
|
+
allowed_test_commands: config.allowedTestCommands,
|
|
296
|
+
repo_allowed_test_commands: getRepoAllowedCommands(config, assessment.resolved_repo_path),
|
|
297
|
+
sensitive_path_rules: SENSITIVE_PATH_RULES_SIGNATURE,
|
|
298
|
+
artifact_rules: ARTIFACT_RULES_SIGNATURE,
|
|
299
|
+
schema_epoch: TOOL_SCHEMA_EPOCH,
|
|
300
|
+
});
|
|
301
|
+
if (currentPolicyHash !== assessment.policy_hash) {
|
|
302
|
+
return { valid: false, failure_reason: "assessment_stale_policy", assessment };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (assessment.requires_confirm && !assessment.confirmed && !options.allow_unconfirmed) {
|
|
306
|
+
return { valid: false, failure_reason: "assessment_needs_confirm", assessment };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { valid: true, failure_reason: null, assessment };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Records an explicit confirmation performed from the local PatchWarden CLI.
|
|
314
|
+
* This is intentionally not registered as an MCP tool: a remote client may
|
|
315
|
+
* request confirmation, but it cannot grant confirmation to itself.
|
|
316
|
+
*/
|
|
317
|
+
export function confirmAssessment(assessmentId: string): AssessmentConfirmationResult {
|
|
318
|
+
if (!/^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(assessmentId)) {
|
|
319
|
+
throw new PatchWardenError(
|
|
320
|
+
"assessment_id_invalid",
|
|
321
|
+
"Local confirmation requires the full assessment_id (32 hexadecimal random characters).",
|
|
322
|
+
"Copy the complete assessment_id from the assess_only response; assessment_short_id is display-only."
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const assessment = readAssessment(assessmentId);
|
|
327
|
+
if (assessment.decision !== "needs_confirm" || !assessment.requires_confirm) {
|
|
328
|
+
throw new PatchWardenError(
|
|
329
|
+
"assessment_confirmation_not_allowed",
|
|
330
|
+
`Assessment "${assessmentId}" has decision "${assessment.decision}" and cannot be locally confirmed.`,
|
|
331
|
+
assessment.decision === "blocked"
|
|
332
|
+
? "Fix the hard-rule finding and run assess_only again."
|
|
333
|
+
: "This assessment does not require confirmation; execute it with the minimal assessment_id call."
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const snapshot = captureRepoSnapshot(assessment.resolved_repo_path);
|
|
338
|
+
const validation = validateAssessmentFreshness(assessmentId, snapshot, {
|
|
339
|
+
allow_unconfirmed: true,
|
|
340
|
+
// A standalone CLI has no active MCP profile snapshot. The execute path
|
|
341
|
+
// still performs the full tool-manifest check before creating the task.
|
|
342
|
+
skip_tool_manifest: true,
|
|
343
|
+
});
|
|
344
|
+
if (!validation.valid || !validation.assessment) {
|
|
345
|
+
throw new PatchWardenError(
|
|
346
|
+
validation.failure_reason || "assessment_validation_failed",
|
|
347
|
+
`Assessment "${assessmentId}" cannot be confirmed: ${validation.failure_reason || "validation_failed"}.`,
|
|
348
|
+
"Run create_task with execution_mode=assess_only again, then confirm the new assessment locally."
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const confirmedAt = validation.assessment.confirmed_at || new Date().toISOString();
|
|
353
|
+
const confirmedRecord: AssessmentRecord = {
|
|
354
|
+
...validation.assessment,
|
|
355
|
+
confirmed: true,
|
|
356
|
+
confirmed_at: confirmedAt,
|
|
357
|
+
confirm_code: validation.assessment.confirm_code || randomBytes(8).toString("hex"),
|
|
358
|
+
};
|
|
359
|
+
const config = getConfig();
|
|
360
|
+
const assessmentFile = resolve(getAssessmentsDir(config), assessmentId, "assessment.json");
|
|
361
|
+
guardPath(assessmentFile, config.workspaceRoot, config.assessmentsDir);
|
|
362
|
+
const temporaryFile = `${assessmentFile}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
363
|
+
guardPath(temporaryFile, config.workspaceRoot, config.assessmentsDir);
|
|
364
|
+
writeFileSync(temporaryFile, JSON.stringify(confirmedRecord, null, 2), "utf-8");
|
|
365
|
+
renameSync(temporaryFile, assessmentFile);
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
assessment_id: assessmentId,
|
|
369
|
+
decision: "needs_confirm",
|
|
370
|
+
confirmed: true,
|
|
371
|
+
confirmed_at: confirmedAt,
|
|
372
|
+
expires_at: confirmedRecord.expires_at,
|
|
373
|
+
next_action: "Call create_task with only execution_mode=execute and this full assessment_id.",
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function computeWorkspaceFingerprint(snapshot: RepoSnapshot): string {
|
|
378
|
+
const fileHashes = Object.entries(snapshot.files)
|
|
379
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
380
|
+
.map(([path, fp]) => `${path}:${fp.sha256}`)
|
|
381
|
+
.join("\n");
|
|
382
|
+
return createHash("sha256")
|
|
383
|
+
.update(`${snapshot.head || "null"}\n${snapshot.status}\n${fileHashes}`)
|
|
384
|
+
.digest("hex");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
interface PolicyHashInput {
|
|
388
|
+
change_policy: ChangePolicy;
|
|
389
|
+
template: TaskTemplateName | null;
|
|
390
|
+
verify_commands: string[];
|
|
391
|
+
allowed_test_commands: string[];
|
|
392
|
+
repo_allowed_test_commands: string[];
|
|
393
|
+
sensitive_path_rules: string;
|
|
394
|
+
artifact_rules: string;
|
|
395
|
+
schema_epoch: string;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function computePolicyHash(input: PolicyHashInput): string {
|
|
399
|
+
const payload = JSON.stringify({
|
|
400
|
+
change_policy: input.change_policy,
|
|
401
|
+
template: input.template,
|
|
402
|
+
verify_commands: [...input.verify_commands].sort(),
|
|
403
|
+
allowed_test_commands: [...input.allowed_test_commands].sort(),
|
|
404
|
+
repo_allowed_test_commands: [...input.repo_allowed_test_commands].sort(),
|
|
405
|
+
sensitive_path_rules: input.sensitive_path_rules,
|
|
406
|
+
artifact_rules: input.artifact_rules,
|
|
407
|
+
schema_epoch: input.schema_epoch,
|
|
408
|
+
});
|
|
409
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function getRepoAllowedCommands(config: PatchWardenConfig, repoPath: string): string[] {
|
|
413
|
+
return getRepoAllowedTestCommands(config, repoPath);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function computeFallbackManifestHash(config: PatchWardenConfig): string {
|
|
417
|
+
return createHash("sha256")
|
|
418
|
+
.update(`${TOOL_SCHEMA_EPOCH}:${config.toolProfile || "full"}`)
|
|
419
|
+
.digest("hex");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function extractReason(error: unknown): string {
|
|
423
|
+
if (error instanceof PatchWardenError) return error.reason;
|
|
424
|
+
if (error instanceof Error) return error.message.slice(0, 80);
|
|
425
|
+
return String(error).slice(0, 80);
|
|
426
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Local-only confirmation for medium-risk assessment tickets.
|
|
4
|
+
*
|
|
5
|
+
* This command is deliberately not exposed through the MCP registry. It must
|
|
6
|
+
* be invoked by a person (or another explicitly trusted local workflow) in a
|
|
7
|
+
* terminal that uses the same PatchWarden configuration as the server.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { loadConfig } from "../config.js";
|
|
11
|
+
import { confirmAssessment } from "./assessmentStore.js";
|
|
12
|
+
import { errorPayload } from "../errors.js";
|
|
13
|
+
|
|
14
|
+
loadConfig();
|
|
15
|
+
|
|
16
|
+
const assessmentId = process.argv[2];
|
|
17
|
+
if (!assessmentId || process.argv.length !== 3) {
|
|
18
|
+
console.error("Usage: patchwarden-confirm <full_assessment_id>");
|
|
19
|
+
console.error("The display-only assessment_short_id is not accepted.");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const result = confirmAssessment(assessmentId);
|
|
25
|
+
console.log(JSON.stringify(result, null, 2));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(JSON.stringify(errorPayload(error), null, 2));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|