patchwarden 1.6.1 → 1.6.2
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/README.en.md +17 -12
- package/README.md +14 -11
- package/dist/assessments/assessmentDiagnostics.d.ts +29 -0
- package/dist/assessments/assessmentDiagnostics.js +132 -0
- package/dist/assessments/assessmentStore.d.ts +39 -0
- package/dist/assessments/assessmentStore.js +166 -12
- package/dist/assessments/securitySnapshot.d.ts +50 -0
- package/dist/assessments/securitySnapshot.js +158 -0
- package/dist/control/routes/status.d.ts +14 -0
- package/dist/control/routes/status.js +22 -0
- package/dist/control/runtime.js +2 -10
- package/dist/control/server.js +1 -0
- package/dist/diagnostics/allowedTestCommandSafety.d.ts +1 -0
- package/dist/diagnostics/allowedTestCommandSafety.js +11 -0
- package/dist/direct/directSessionStore.js +5 -4
- package/dist/doctor.js +3 -5
- package/dist/runner/runTask.d.ts +1 -0
- package/dist/runner/runTask.js +47 -6
- package/dist/runner/taskRuntime.d.ts +2 -0
- package/dist/runner/taskStatusStore.js +12 -1
- package/dist/runner/watch.js +64 -1
- package/dist/smoke-test.js +3 -3
- package/dist/tools/definitions/toolDefs.js +3 -3
- package/dist/tools/diagnostics/auditTask.d.ts +1 -0
- package/dist/tools/diagnostics/auditTask.js +35 -7
- package/dist/tools/diagnostics/healthCheck.d.ts +12 -0
- package/dist/tools/diagnostics/healthCheck.js +29 -1
- package/dist/tools/tasks/cancelTask.js +2 -1
- package/dist/tools/tasks/createTask.d.ts +2 -2
- package/dist/tools/tasks/createTask.js +70 -9
- package/dist/tools/tasks/diagnoseTask.js +4 -8
- package/dist/tools/tasks/getTaskStatus.js +6 -1
- package/dist/tools/tasks/getTaskSummary.js +2 -10
- package/dist/tools/tasks/listTasks.js +2 -1
- package/dist/tools/tasks/reconcileTasks.d.ts +2 -1
- package/dist/tools/tasks/reconcileTasks.js +185 -39
- package/dist/tools/tasks/runTaskLoop.js +5 -11
- package/dist/tools/tasks/taskOutputs.d.ts +2 -2
- package/dist/tools/tasks/taskStates.d.ts +6 -0
- package/dist/tools/tasks/taskStates.js +52 -0
- package/dist/tools/tasks/waitForTask.js +3 -11
- package/dist/tools/workspace/listAgents.d.ts +6 -0
- package/dist/tools/workspace/listAgents.js +12 -2
- package/dist/utils/atomicFile.js +20 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/CODE_WIKI.md +4 -4
- package/docs/open-source-application.md +11 -12
- package/docs/release-evidence.md +33 -47
- package/package.json +1 -1
- package/scripts/checks/control-center-smoke.js +11 -4
- package/scripts/checks/lifecycle-smoke.js +5 -2
- package/scripts/checks/mcp-smoke.js +31 -1
- package/scripts/checks/watcher-supervisor-smoke.js +29 -1
- package/scripts/control/start-patchwarden-tunnel.ps1 +65 -1
- package/scripts/e2e/demo-runtime.mjs +153 -0
- package/scripts/release/desktop-preflight.js +1 -1
- package/src/assessments/assessmentDiagnostics.ts +172 -0
- package/src/assessments/assessmentStore.ts +237 -12
- package/src/assessments/securitySnapshot.ts +231 -0
- package/src/control/routes/status.ts +37 -0
- package/src/control/runtime.ts +2 -10
- package/src/control/server.ts +1 -0
- package/src/diagnostics/allowedTestCommandSafety.ts +12 -0
- package/src/direct/directSessionStore.ts +6 -4
- package/src/doctor.ts +3 -5
- package/src/runner/runTask.ts +59 -6
- package/src/runner/taskRuntime.ts +2 -0
- package/src/runner/taskStatusStore.ts +16 -1
- package/src/runner/watch.ts +67 -1
- package/src/smoke-test.ts +2 -2
- package/src/tools/definitions/toolDefs.ts +3 -3
- package/src/tools/diagnostics/auditTask.ts +37 -7
- package/src/tools/diagnostics/healthCheck.ts +29 -1
- package/src/tools/tasks/cancelTask.ts +2 -1
- package/src/tools/tasks/createTask.ts +113 -8
- package/src/tools/tasks/diagnoseTask.ts +4 -8
- package/src/tools/tasks/getTaskStatus.ts +6 -1
- package/src/tools/tasks/getTaskSummary.ts +2 -11
- package/src/tools/tasks/listTasks.ts +2 -1
- package/src/tools/tasks/reconcileTasks.ts +162 -13
- package/src/tools/tasks/runTaskLoop.ts +4 -12
- package/src/tools/tasks/taskStates.ts +54 -0
- package/src/tools/tasks/waitForTask.ts +3 -12
- package/src/tools/workspace/listAgents.ts +17 -3
- package/src/utils/atomicFile.ts +17 -1
- package/src/version.ts +1 -1
- package/ui/pages/dashboard.html +4 -0
- package/ui/pages/logs.html +29 -0
- package/ui/pages/settings.html +3 -3
- package/ui/settings.js +26 -17
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { getAssessmentsDir, getConfig } from "../config.js";
|
|
4
|
+
import { guardPath } from "../security/pathGuard.js";
|
|
5
|
+
import { atomicWriteJsonFileSync } from "../utils/atomicFile.js";
|
|
6
|
+
import { readJsonObjectFileSync } from "../utils/lockedJsonFile.js";
|
|
7
|
+
import type { AssessmentValidationResult } from "./assessmentStore.js";
|
|
8
|
+
import { ASSESSMENT_SECURITY_SNAPSHOT_VERSION } from "./securitySnapshot.js";
|
|
9
|
+
import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
|
|
10
|
+
|
|
11
|
+
const DIAGNOSTIC_VERSION = "assessment-validation-diagnostic-v2";
|
|
12
|
+
const DIAGNOSTIC_FILE = "last-validation-failure.json";
|
|
13
|
+
const VALIDATOR_MODULE_PATH = "dist/assessments/assessmentStore.js";
|
|
14
|
+
const VALIDATOR_BUILD_ID = `patchwarden-${PATCHWARDEN_VERSION}:${TOOL_SCHEMA_EPOCH}:${ASSESSMENT_SECURITY_SNAPSHOT_VERSION}`;
|
|
15
|
+
|
|
16
|
+
export interface AssessmentValidationFailureDiagnostic {
|
|
17
|
+
diagnostic_version: string;
|
|
18
|
+
error_code: string;
|
|
19
|
+
reason_code: string;
|
|
20
|
+
assessment_id: string | null;
|
|
21
|
+
assessment_config_sha256: string | null;
|
|
22
|
+
current_config_sha256: string | null;
|
|
23
|
+
assessment_config_hash: string | null;
|
|
24
|
+
current_config_hash: string | null;
|
|
25
|
+
snapshot_version: string | null;
|
|
26
|
+
assessment_fingerprint_version: string | null;
|
|
27
|
+
current_fingerprint_version: string;
|
|
28
|
+
assessment_schema_epoch: string | null;
|
|
29
|
+
current_schema_epoch: string;
|
|
30
|
+
assessment_created_at: string | null;
|
|
31
|
+
assessment_expires_at: string | null;
|
|
32
|
+
config_changed: boolean;
|
|
33
|
+
changed_field_names: string[];
|
|
34
|
+
changed_config_sections: string[];
|
|
35
|
+
validator_module_path: string;
|
|
36
|
+
validator_build_id: string;
|
|
37
|
+
watcher_instance_id: string | null;
|
|
38
|
+
observed_at: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildAssessmentValidationFailureDiagnostic(
|
|
42
|
+
result: AssessmentValidationResult,
|
|
43
|
+
): AssessmentValidationFailureDiagnostic | null {
|
|
44
|
+
if (result.valid || !result.failure_reason) return null;
|
|
45
|
+
const reasonCode = safeCode(result.failure_reason);
|
|
46
|
+
const assessmentHash = safeHash(result.expected_hash);
|
|
47
|
+
const currentHash = safeHash(result.actual_hash);
|
|
48
|
+
const changedSections = [...new Set((result.config_change_categories || [])
|
|
49
|
+
.map((name) => safeCode(name))
|
|
50
|
+
.filter(Boolean))]
|
|
51
|
+
.sort()
|
|
52
|
+
.slice(0, 32);
|
|
53
|
+
const assessmentVersion = safeText(result.assessment?.assessment_security_snapshot_version, 80);
|
|
54
|
+
return {
|
|
55
|
+
diagnostic_version: DIAGNOSTIC_VERSION,
|
|
56
|
+
error_code: reasonCode,
|
|
57
|
+
reason_code: reasonCode,
|
|
58
|
+
assessment_id: safeAssessmentId(result.assessment?.assessment_id),
|
|
59
|
+
assessment_config_sha256: assessmentHash,
|
|
60
|
+
current_config_sha256: currentHash,
|
|
61
|
+
assessment_config_hash: assessmentHash,
|
|
62
|
+
current_config_hash: currentHash,
|
|
63
|
+
snapshot_version: assessmentVersion,
|
|
64
|
+
assessment_fingerprint_version: assessmentVersion,
|
|
65
|
+
current_fingerprint_version: ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
|
|
66
|
+
assessment_schema_epoch: safeText(result.assessment?.assessment_schema_epoch, 80),
|
|
67
|
+
current_schema_epoch: TOOL_SCHEMA_EPOCH,
|
|
68
|
+
assessment_created_at: safeTimestamp(result.assessment?.created_at),
|
|
69
|
+
assessment_expires_at: safeTimestamp(result.assessment?.expires_at),
|
|
70
|
+
config_changed: result.failure_reason === "assessment_stale_config",
|
|
71
|
+
changed_field_names: changedSections,
|
|
72
|
+
changed_config_sections: changedSections,
|
|
73
|
+
validator_module_path: VALIDATOR_MODULE_PATH,
|
|
74
|
+
validator_build_id: VALIDATOR_BUILD_ID,
|
|
75
|
+
watcher_instance_id: safeInstanceId(process.env.PATCHWARDEN_WATCHER_INSTANCE_ID),
|
|
76
|
+
observed_at: new Date().toISOString(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Persist a bounded value-free failure summary without changing validation behavior. */
|
|
81
|
+
export function recordAssessmentValidationFailure(result: AssessmentValidationResult): boolean {
|
|
82
|
+
const diagnostic = buildAssessmentValidationFailureDiagnostic(result);
|
|
83
|
+
if (!diagnostic) return false;
|
|
84
|
+
try {
|
|
85
|
+
const config = getConfig();
|
|
86
|
+
const assessmentsDir = getAssessmentsDir(config);
|
|
87
|
+
const file = guardPath(
|
|
88
|
+
join(assessmentsDir, DIAGNOSTIC_FILE),
|
|
89
|
+
config.workspaceRoot,
|
|
90
|
+
config.assessmentsDir,
|
|
91
|
+
);
|
|
92
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
93
|
+
atomicWriteJsonFileSync(file, diagnostic);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
// Diagnostics must never replace the original fail-closed validation error.
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function readLastAssessmentValidationFailure(): AssessmentValidationFailureDiagnostic | null {
|
|
102
|
+
try {
|
|
103
|
+
const config = getConfig();
|
|
104
|
+
const file = guardPath(
|
|
105
|
+
join(getAssessmentsDir(config), DIAGNOSTIC_FILE),
|
|
106
|
+
config.workspaceRoot,
|
|
107
|
+
config.assessmentsDir,
|
|
108
|
+
);
|
|
109
|
+
if (!existsSync(file)) return null;
|
|
110
|
+
const raw = readJsonObjectFileSync<Record<string, unknown>>(file);
|
|
111
|
+
if (raw.diagnostic_version !== DIAGNOSTIC_VERSION) return null;
|
|
112
|
+
const changedSections = Array.isArray(raw.changed_config_sections)
|
|
113
|
+
? [...new Set(raw.changed_config_sections.map(safeCode).filter(Boolean))].sort().slice(0, 32)
|
|
114
|
+
: [];
|
|
115
|
+
return {
|
|
116
|
+
diagnostic_version: DIAGNOSTIC_VERSION,
|
|
117
|
+
error_code: safeCode(raw.error_code),
|
|
118
|
+
reason_code: safeCode(raw.reason_code),
|
|
119
|
+
assessment_id: safeAssessmentId(raw.assessment_id),
|
|
120
|
+
assessment_config_sha256: safeHash(raw.assessment_config_sha256),
|
|
121
|
+
current_config_sha256: safeHash(raw.current_config_sha256),
|
|
122
|
+
assessment_config_hash: safeHash(raw.assessment_config_hash),
|
|
123
|
+
current_config_hash: safeHash(raw.current_config_hash),
|
|
124
|
+
snapshot_version: safeText(raw.snapshot_version, 80),
|
|
125
|
+
assessment_fingerprint_version: safeText(raw.assessment_fingerprint_version, 80),
|
|
126
|
+
current_fingerprint_version: safeText(raw.current_fingerprint_version, 80) || ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
|
|
127
|
+
assessment_schema_epoch: safeText(raw.assessment_schema_epoch, 80),
|
|
128
|
+
current_schema_epoch: safeText(raw.current_schema_epoch, 80) || TOOL_SCHEMA_EPOCH,
|
|
129
|
+
assessment_created_at: safeTimestamp(raw.assessment_created_at),
|
|
130
|
+
assessment_expires_at: safeTimestamp(raw.assessment_expires_at),
|
|
131
|
+
config_changed: raw.config_changed === true,
|
|
132
|
+
changed_field_names: Array.isArray(raw.changed_field_names)
|
|
133
|
+
? [...new Set(raw.changed_field_names.map(safeCode).filter(Boolean))].sort().slice(0, 32)
|
|
134
|
+
: [],
|
|
135
|
+
changed_config_sections: changedSections,
|
|
136
|
+
validator_module_path: safeText(raw.validator_module_path, 120) || VALIDATOR_MODULE_PATH,
|
|
137
|
+
validator_build_id: safeText(raw.validator_build_id, 160) || VALIDATOR_BUILD_ID,
|
|
138
|
+
watcher_instance_id: safeInstanceId(raw.watcher_instance_id),
|
|
139
|
+
observed_at: safeTimestamp(raw.observed_at) || new Date(0).toISOString(),
|
|
140
|
+
};
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function safeAssessmentId(value: unknown): string | null {
|
|
147
|
+
return typeof value === "string" && /^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(value) ? value : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function safeInstanceId(value: unknown): string | null {
|
|
151
|
+
return typeof value === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(value) ? value : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function safeCode(value: unknown): string {
|
|
155
|
+
return typeof value === "string" && /^[a-z0-9_]{1,80}$/.test(value) ? value : "";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function safeHash(value: unknown): string | null {
|
|
159
|
+
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value) ? value : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function safeText(value: unknown, maxLength: number): string | null {
|
|
163
|
+
return typeof value === "string" && value.length > 0
|
|
164
|
+
? value.replace(/[\r\n]+/g, " ").slice(0, maxLength)
|
|
165
|
+
: null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function safeTimestamp(value: unknown): string | null {
|
|
169
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value))
|
|
170
|
+
? new Date(value).toISOString()
|
|
171
|
+
: null;
|
|
172
|
+
}
|
|
@@ -12,10 +12,22 @@ import { redactSensitiveContent } from "../security/contentRedaction.js";
|
|
|
12
12
|
import { atomicWriteJsonFileSync } from "../utils/atomicFile.js";
|
|
13
13
|
import { withFileLockSync } from "../utils/lockedJsonFile.js";
|
|
14
14
|
import { TOOL_SCHEMA_EPOCH } from "../version.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
buildToolCatalogSnapshot,
|
|
17
|
+
resolveToolProfile,
|
|
18
|
+
} from "../tools/catalog/toolCatalog.js";
|
|
19
|
+
import { getToolDefs } from "../tools/definitions/toolDefs.js";
|
|
16
20
|
import { captureRepoSnapshot, type RepoSnapshot } from "../runner/changeCapture.js";
|
|
17
21
|
import type { RiskAssessmentResult } from "../security/riskEngine.js";
|
|
18
22
|
import type { TaskTemplateName, ChangePolicy } from "../tools/taskTemplates.js";
|
|
23
|
+
import { getProjectPolicySummary, type ProjectPolicy } from "../policy/projectPolicy.js";
|
|
24
|
+
import {
|
|
25
|
+
ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
|
|
26
|
+
buildAssessmentSecuritySnapshot,
|
|
27
|
+
getAssessmentSecuritySnapshotComponentHashes,
|
|
28
|
+
hashAssessmentSecuritySnapshot,
|
|
29
|
+
type SecuritySnapshotCategory,
|
|
30
|
+
} from "./securitySnapshot.js";
|
|
19
31
|
|
|
20
32
|
const SENSITIVE_PATH_RULES_SIGNATURE = "v1";
|
|
21
33
|
const ARTIFACT_RULES_SIGNATURE = "v1";
|
|
@@ -65,6 +77,12 @@ export interface AssessmentRecord {
|
|
|
65
77
|
plan_id: string | null;
|
|
66
78
|
policy_hash: string;
|
|
67
79
|
tool_manifest_sha256: string;
|
|
80
|
+
execution_config_hash?: string;
|
|
81
|
+
execution_config_component_hashes?: Partial<Record<ExecutionConfigChangeCategory, string>>;
|
|
82
|
+
assessment_security_snapshot_version?: string;
|
|
83
|
+
assessment_security_snapshot_sha256?: string;
|
|
84
|
+
assessment_security_snapshot_component_hashes?: Partial<Record<ExecutionConfigChangeCategory, string>>;
|
|
85
|
+
assessment_schema_epoch?: string;
|
|
68
86
|
workspace_fingerprint: string;
|
|
69
87
|
workspace_snapshot_summary: {
|
|
70
88
|
head: string | null;
|
|
@@ -83,10 +101,15 @@ export interface AssessmentRecord {
|
|
|
83
101
|
agent: string;
|
|
84
102
|
timeout_seconds?: number;
|
|
85
103
|
change_policy?: ChangePolicy;
|
|
104
|
+
scope?: string[];
|
|
105
|
+
forbidden?: string[];
|
|
106
|
+
verification?: string[];
|
|
107
|
+
done_evidence?: string[];
|
|
86
108
|
requires_confirm: boolean;
|
|
87
109
|
confirmed: boolean;
|
|
88
110
|
confirmed_at: string | null;
|
|
89
111
|
confirm_code: string | null;
|
|
112
|
+
used_at?: string | null;
|
|
90
113
|
agent_assessment_summary?: AgentAssessmentSummary | null;
|
|
91
114
|
}
|
|
92
115
|
|
|
@@ -107,6 +130,10 @@ export interface AssessmentCreateInput {
|
|
|
107
130
|
agent: string;
|
|
108
131
|
timeout_seconds?: number;
|
|
109
132
|
change_policy?: ChangePolicy;
|
|
133
|
+
scope?: string[];
|
|
134
|
+
forbidden?: string[];
|
|
135
|
+
verification?: string[];
|
|
136
|
+
done_evidence?: string[];
|
|
110
137
|
snapshot: RepoSnapshot;
|
|
111
138
|
assessment_id?: string;
|
|
112
139
|
assessment_dir?: string;
|
|
@@ -117,8 +144,13 @@ export interface AssessmentValidationResult {
|
|
|
117
144
|
valid: boolean;
|
|
118
145
|
failure_reason: string | null;
|
|
119
146
|
assessment: AssessmentRecord | null;
|
|
147
|
+
expected_hash?: string;
|
|
148
|
+
actual_hash?: string;
|
|
149
|
+
config_change_categories?: ExecutionConfigChangeCategory[];
|
|
120
150
|
}
|
|
121
151
|
|
|
152
|
+
export type ExecutionConfigChangeCategory = SecuritySnapshotCategory | "allowed_test_commands" | "execution_config";
|
|
153
|
+
|
|
122
154
|
export interface AssessmentConfirmationResult {
|
|
123
155
|
assessment_id: string;
|
|
124
156
|
decision: "needs_confirm";
|
|
@@ -161,7 +193,7 @@ export function createAssessment(input: AssessmentCreateInput): AssessmentRecord
|
|
|
161
193
|
? createHash("sha256").update(input.plan_content).digest("hex")
|
|
162
194
|
: null;
|
|
163
195
|
|
|
164
|
-
const toolManifest =
|
|
196
|
+
const toolManifest = getCurrentToolManifest(config);
|
|
165
197
|
|
|
166
198
|
const workspaceFingerprint = computeWorkspaceFingerprint(input.snapshot);
|
|
167
199
|
const snapshotTruncated = input.snapshot.warnings.some((w) => w.includes("snapshot limited"));
|
|
@@ -176,6 +208,21 @@ export function createAssessment(input: AssessmentCreateInput): AssessmentRecord
|
|
|
176
208
|
artifact_rules: ARTIFACT_RULES_SIGNATURE,
|
|
177
209
|
schema_epoch: TOOL_SCHEMA_EPOCH,
|
|
178
210
|
});
|
|
211
|
+
const executionConfig = computeExecutionConfigFingerprint(config, {
|
|
212
|
+
agent: input.agent,
|
|
213
|
+
tool_manifest_sha256: toolManifest,
|
|
214
|
+
tool_profile: resolveToolProfile(config.toolProfile),
|
|
215
|
+
repo_path: input.resolved_repo_path,
|
|
216
|
+
change_policy: input.change_policy || "repo_scoped_changes",
|
|
217
|
+
template: input.template || null,
|
|
218
|
+
verify_commands: input.verify_commands || [],
|
|
219
|
+
test_command: input.test_command || null,
|
|
220
|
+
task_timeout_seconds: input.timeout_seconds,
|
|
221
|
+
scope: input.scope,
|
|
222
|
+
forbidden: input.forbidden,
|
|
223
|
+
verification: input.verification,
|
|
224
|
+
done_evidence: input.done_evidence,
|
|
225
|
+
});
|
|
179
226
|
|
|
180
227
|
const now = new Date();
|
|
181
228
|
const expiresAt = new Date(now.getTime() + config.assessmentTtlSeconds * 1000).toISOString();
|
|
@@ -192,6 +239,12 @@ export function createAssessment(input: AssessmentCreateInput): AssessmentRecord
|
|
|
192
239
|
plan_id: input.plan_id,
|
|
193
240
|
policy_hash: policyHash,
|
|
194
241
|
tool_manifest_sha256: toolManifest,
|
|
242
|
+
execution_config_hash: executionConfig.hash,
|
|
243
|
+
execution_config_component_hashes: executionConfig.components,
|
|
244
|
+
assessment_security_snapshot_version: ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
|
|
245
|
+
assessment_security_snapshot_sha256: executionConfig.hash,
|
|
246
|
+
assessment_security_snapshot_component_hashes: executionConfig.components,
|
|
247
|
+
assessment_schema_epoch: TOOL_SCHEMA_EPOCH,
|
|
195
248
|
workspace_fingerprint: workspaceFingerprint,
|
|
196
249
|
workspace_snapshot_summary: {
|
|
197
250
|
head: input.snapshot.head,
|
|
@@ -210,10 +263,15 @@ export function createAssessment(input: AssessmentCreateInput): AssessmentRecord
|
|
|
210
263
|
agent: input.agent,
|
|
211
264
|
timeout_seconds: input.timeout_seconds,
|
|
212
265
|
change_policy: input.change_policy || "repo_scoped_changes",
|
|
266
|
+
scope: input.scope || [],
|
|
267
|
+
forbidden: input.forbidden || [],
|
|
268
|
+
verification: input.verification || [],
|
|
269
|
+
done_evidence: input.done_evidence || [],
|
|
213
270
|
requires_confirm: input.decision === "needs_confirm",
|
|
214
271
|
confirmed: false,
|
|
215
272
|
confirmed_at: null,
|
|
216
273
|
confirm_code: null,
|
|
274
|
+
used_at: null,
|
|
217
275
|
agent_assessment_summary: input.agent_assessment_summary || null,
|
|
218
276
|
};
|
|
219
277
|
|
|
@@ -223,6 +281,13 @@ export function createAssessment(input: AssessmentCreateInput): AssessmentRecord
|
|
|
223
281
|
}
|
|
224
282
|
|
|
225
283
|
export function readAssessment(assessmentId: string): AssessmentRecord {
|
|
284
|
+
if (!/^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(assessmentId)) {
|
|
285
|
+
throw new PatchWardenError(
|
|
286
|
+
"assessment_id_invalid",
|
|
287
|
+
"A full assessment_id with 32 hexadecimal random characters is required.",
|
|
288
|
+
"Copy the complete assessment_id from the assess_only response; assessment_short_id is display-only.",
|
|
289
|
+
);
|
|
290
|
+
}
|
|
226
291
|
const config = getConfig();
|
|
227
292
|
const assessmentsDir = getAssessmentsDir(config);
|
|
228
293
|
const dir = resolve(assessmentsDir, assessmentId);
|
|
@@ -237,7 +302,43 @@ export function readAssessment(assessmentId: string): AssessmentRecord {
|
|
|
237
302
|
{ assessment_id: assessmentId }
|
|
238
303
|
);
|
|
239
304
|
}
|
|
240
|
-
|
|
305
|
+
try {
|
|
306
|
+
const parsed = JSON.parse(readFileSync(file, "utf-8")) as AssessmentRecord;
|
|
307
|
+
if (!parsed || typeof parsed !== "object" || parsed.assessment_id !== assessmentId) {
|
|
308
|
+
throw new Error("record identity mismatch");
|
|
309
|
+
}
|
|
310
|
+
return parsed;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
throw new PatchWardenError(
|
|
313
|
+
"assessment_corrupted",
|
|
314
|
+
`Assessment "${assessmentId}" cannot be read as a valid record.`,
|
|
315
|
+
"Run create_task with execution_mode=assess_only again.",
|
|
316
|
+
true,
|
|
317
|
+
{ assessment_id: assessmentId, failure_category: "invalid_assessment_record" },
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Atomically consume an Assessment before creating its one allowed task. */
|
|
323
|
+
export function markAssessmentUsed(assessmentId: string): AssessmentRecord {
|
|
324
|
+
const config = getConfig();
|
|
325
|
+
const assessmentFile = resolve(getAssessmentsDir(config), assessmentId, "assessment.json");
|
|
326
|
+
guardPath(assessmentFile, config.workspaceRoot, config.assessmentsDir);
|
|
327
|
+
return withFileLockSync(assessmentFile, () => {
|
|
328
|
+
const assessment = readAssessment(assessmentId);
|
|
329
|
+
if (assessment.used_at) {
|
|
330
|
+
throw new PatchWardenError(
|
|
331
|
+
"assessment_used",
|
|
332
|
+
`Assessment "${assessmentId}" has already been used.`,
|
|
333
|
+
"Run create_task with execution_mode=assess_only again to obtain a new assessment_id.",
|
|
334
|
+
true,
|
|
335
|
+
{ assessment_id: assessmentId, used_at: assessment.used_at },
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const consumed: AssessmentRecord = { ...assessment, used_at: new Date().toISOString() };
|
|
339
|
+
atomicWriteJsonFileSync(assessmentFile, consumed);
|
|
340
|
+
return consumed;
|
|
341
|
+
});
|
|
241
342
|
}
|
|
242
343
|
|
|
243
344
|
export function validateAssessmentFreshness(
|
|
@@ -245,6 +346,9 @@ export function validateAssessmentFreshness(
|
|
|
245
346
|
currentSnapshot: RepoSnapshot,
|
|
246
347
|
options: AssessmentValidationOptions = {}
|
|
247
348
|
): AssessmentValidationResult {
|
|
349
|
+
if (!/^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(assessmentId)) {
|
|
350
|
+
return { valid: false, failure_reason: "assessment_id_invalid", assessment: null };
|
|
351
|
+
}
|
|
248
352
|
let assessment: AssessmentRecord;
|
|
249
353
|
try {
|
|
250
354
|
assessment = readAssessment(assessmentId);
|
|
@@ -275,11 +379,21 @@ export function validateAssessmentFreshness(
|
|
|
275
379
|
}
|
|
276
380
|
}
|
|
277
381
|
|
|
382
|
+
const config = getConfig();
|
|
383
|
+
const currentManifest = getCurrentToolManifest(config);
|
|
278
384
|
if (!options.skip_tool_manifest) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
385
|
+
// Legacy records need the standalone manifest check. Versioned security
|
|
386
|
+
// snapshots compare the manifest together with the source config, so the
|
|
387
|
+
// diagnostic can report both "tool_manifest" and e.g. "allowed_commands".
|
|
388
|
+
if (currentManifest !== assessment.tool_manifest_sha256 && !assessment.assessment_security_snapshot_sha256) {
|
|
389
|
+
return {
|
|
390
|
+
valid: false,
|
|
391
|
+
failure_reason: "assessment_stale_config",
|
|
392
|
+
assessment,
|
|
393
|
+
expected_hash: assessment.tool_manifest_sha256,
|
|
394
|
+
actual_hash: currentManifest,
|
|
395
|
+
config_change_categories: ["tool_manifest"],
|
|
396
|
+
};
|
|
283
397
|
}
|
|
284
398
|
}
|
|
285
399
|
|
|
@@ -288,7 +402,52 @@ export function validateAssessmentFreshness(
|
|
|
288
402
|
return { valid: false, failure_reason: "assessment_workspace_changed", assessment };
|
|
289
403
|
}
|
|
290
404
|
|
|
291
|
-
|
|
405
|
+
if (assessment.execution_config_hash) {
|
|
406
|
+
const currentExecutionConfig = computeExecutionConfigFingerprint(config, {
|
|
407
|
+
agent: assessment.agent,
|
|
408
|
+
tool_manifest_sha256: currentManifest,
|
|
409
|
+
tool_profile: resolveToolProfile(config.toolProfile),
|
|
410
|
+
repo_path: assessment.resolved_repo_path,
|
|
411
|
+
change_policy: assessment.change_policy || "repo_scoped_changes",
|
|
412
|
+
template: assessment.template || null,
|
|
413
|
+
verify_commands: assessment.verify_commands || [],
|
|
414
|
+
test_command: assessment.test_command || null,
|
|
415
|
+
task_timeout_seconds: assessment.timeout_seconds,
|
|
416
|
+
scope: assessment.scope,
|
|
417
|
+
forbidden: assessment.forbidden,
|
|
418
|
+
verification: assessment.verification,
|
|
419
|
+
done_evidence: assessment.done_evidence,
|
|
420
|
+
});
|
|
421
|
+
if (
|
|
422
|
+
assessment.assessment_security_snapshot_version
|
|
423
|
+
&& assessment.assessment_security_snapshot_version !== ASSESSMENT_SECURITY_SNAPSHOT_VERSION
|
|
424
|
+
) {
|
|
425
|
+
return {
|
|
426
|
+
valid: false,
|
|
427
|
+
failure_reason: "assessment_snapshot_version_incompatible",
|
|
428
|
+
assessment,
|
|
429
|
+
expected_hash: assessment.execution_config_hash,
|
|
430
|
+
actual_hash: currentExecutionConfig.hash,
|
|
431
|
+
config_change_categories: ["schema_epoch"],
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (currentExecutionConfig.hash !== assessment.execution_config_hash) {
|
|
435
|
+
const previousComponents = assessment.execution_config_component_hashes;
|
|
436
|
+
const changedCategories = previousComponents
|
|
437
|
+
? (Object.keys(currentExecutionConfig.components) as ExecutionConfigChangeCategory[])
|
|
438
|
+
.filter((category) => previousComponents[category] !== currentExecutionConfig.components[category])
|
|
439
|
+
: ["execution_config" as const];
|
|
440
|
+
return {
|
|
441
|
+
valid: false,
|
|
442
|
+
failure_reason: "assessment_stale_config",
|
|
443
|
+
assessment,
|
|
444
|
+
expected_hash: assessment.execution_config_hash,
|
|
445
|
+
actual_hash: currentExecutionConfig.hash,
|
|
446
|
+
config_change_categories: changedCategories.length > 0 ? changedCategories : ["execution_config"],
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
292
451
|
const currentPolicyHash = computePolicyHash({
|
|
293
452
|
change_policy: assessment.change_policy || "repo_scoped_changes",
|
|
294
453
|
template: assessment.template || null,
|
|
@@ -425,10 +584,76 @@ function getRepoAllowedCommands(config: PatchWardenConfig, repoPath: string): st
|
|
|
425
584
|
return getRepoAllowedTestCommands(config, repoPath);
|
|
426
585
|
}
|
|
427
586
|
|
|
428
|
-
function
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
587
|
+
function getCurrentToolManifest(config: PatchWardenConfig): string {
|
|
588
|
+
// The catalog cache is process-local. Rebuild the canonical catalog here so
|
|
589
|
+
// MCP and the independent Watcher derive the same manifest across processes.
|
|
590
|
+
const profile = resolveToolProfile(config.toolProfile);
|
|
591
|
+
return buildToolCatalogSnapshot(getToolDefs(), profile).tool_manifest_sha256;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
interface ExecutionConfigHashInput {
|
|
595
|
+
agent: string;
|
|
596
|
+
tool_manifest_sha256: string;
|
|
597
|
+
tool_profile: string;
|
|
598
|
+
repo_path: string;
|
|
599
|
+
change_policy?: string | null;
|
|
600
|
+
template?: string | null;
|
|
601
|
+
verify_commands?: string[];
|
|
602
|
+
test_command?: string | null;
|
|
603
|
+
task_timeout_seconds?: number | null;
|
|
604
|
+
scope?: string[];
|
|
605
|
+
forbidden?: string[];
|
|
606
|
+
verification?: string[];
|
|
607
|
+
done_evidence?: string[];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export function computeExecutionConfigHash(
|
|
611
|
+
config: PatchWardenConfig,
|
|
612
|
+
input: ExecutionConfigHashInput,
|
|
613
|
+
): string {
|
|
614
|
+
return computeExecutionConfigFingerprint(config, input).hash;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function computeExecutionConfigFingerprint(
|
|
618
|
+
config: PatchWardenConfig,
|
|
619
|
+
input: ExecutionConfigHashInput,
|
|
620
|
+
): { hash: string; components: Partial<Record<ExecutionConfigChangeCategory, string>> } {
|
|
621
|
+
let projectPolicy: ProjectPolicy | null = null;
|
|
622
|
+
let projectPolicyValid: boolean | null = null;
|
|
623
|
+
let projectPolicyIssues: Array<{ code: string; severity: string; field: string }> = [];
|
|
624
|
+
try {
|
|
625
|
+
const summary = getProjectPolicySummary(input.repo_path);
|
|
626
|
+
projectPolicy = summary.effective_policy;
|
|
627
|
+
projectPolicyValid = summary.valid;
|
|
628
|
+
projectPolicyIssues = summary.issues.map(({ code, severity, field }) => ({ code, severity, field }));
|
|
629
|
+
} catch {
|
|
630
|
+
// A workspace-root change can make the old repository inaccessible. The
|
|
631
|
+
// workspace_root component still fails closed without exposing the path.
|
|
632
|
+
}
|
|
633
|
+
const snapshot = buildAssessmentSecuritySnapshot({
|
|
634
|
+
config,
|
|
635
|
+
schemaEpoch: TOOL_SCHEMA_EPOCH,
|
|
636
|
+
toolProfile: input.tool_profile,
|
|
637
|
+
toolManifestSha256: input.tool_manifest_sha256,
|
|
638
|
+
agent: input.agent,
|
|
639
|
+
repoPath: input.repo_path,
|
|
640
|
+
changePolicy: input.change_policy,
|
|
641
|
+
template: input.template,
|
|
642
|
+
verifyCommands: input.verify_commands,
|
|
643
|
+
testCommand: input.test_command,
|
|
644
|
+
taskTimeoutSeconds: input.task_timeout_seconds,
|
|
645
|
+
scope: input.scope,
|
|
646
|
+
forbidden: input.forbidden,
|
|
647
|
+
verification: input.verification,
|
|
648
|
+
doneEvidence: input.done_evidence,
|
|
649
|
+
projectPolicy,
|
|
650
|
+
projectPolicyValid,
|
|
651
|
+
projectPolicyIssues,
|
|
652
|
+
});
|
|
653
|
+
return {
|
|
654
|
+
hash: hashAssessmentSecuritySnapshot(snapshot),
|
|
655
|
+
components: getAssessmentSecuritySnapshotComponentHashes(snapshot),
|
|
656
|
+
};
|
|
432
657
|
}
|
|
433
658
|
|
|
434
659
|
function extractReason(error: unknown): string {
|