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.
Files changed (91) hide show
  1. package/README.en.md +17 -12
  2. package/README.md +14 -11
  3. package/dist/assessments/assessmentDiagnostics.d.ts +29 -0
  4. package/dist/assessments/assessmentDiagnostics.js +132 -0
  5. package/dist/assessments/assessmentStore.d.ts +39 -0
  6. package/dist/assessments/assessmentStore.js +166 -12
  7. package/dist/assessments/securitySnapshot.d.ts +50 -0
  8. package/dist/assessments/securitySnapshot.js +158 -0
  9. package/dist/control/routes/status.d.ts +14 -0
  10. package/dist/control/routes/status.js +22 -0
  11. package/dist/control/runtime.js +2 -10
  12. package/dist/control/server.js +1 -0
  13. package/dist/diagnostics/allowedTestCommandSafety.d.ts +1 -0
  14. package/dist/diagnostics/allowedTestCommandSafety.js +11 -0
  15. package/dist/direct/directSessionStore.js +5 -4
  16. package/dist/doctor.js +3 -5
  17. package/dist/runner/runTask.d.ts +1 -0
  18. package/dist/runner/runTask.js +47 -6
  19. package/dist/runner/taskRuntime.d.ts +2 -0
  20. package/dist/runner/taskStatusStore.js +12 -1
  21. package/dist/runner/watch.js +64 -1
  22. package/dist/smoke-test.js +3 -3
  23. package/dist/tools/definitions/toolDefs.js +3 -3
  24. package/dist/tools/diagnostics/auditTask.d.ts +1 -0
  25. package/dist/tools/diagnostics/auditTask.js +35 -7
  26. package/dist/tools/diagnostics/healthCheck.d.ts +12 -0
  27. package/dist/tools/diagnostics/healthCheck.js +29 -1
  28. package/dist/tools/tasks/cancelTask.js +2 -1
  29. package/dist/tools/tasks/createTask.d.ts +2 -2
  30. package/dist/tools/tasks/createTask.js +70 -9
  31. package/dist/tools/tasks/diagnoseTask.js +4 -8
  32. package/dist/tools/tasks/getTaskStatus.js +6 -1
  33. package/dist/tools/tasks/getTaskSummary.js +2 -10
  34. package/dist/tools/tasks/listTasks.js +2 -1
  35. package/dist/tools/tasks/reconcileTasks.d.ts +2 -1
  36. package/dist/tools/tasks/reconcileTasks.js +185 -39
  37. package/dist/tools/tasks/runTaskLoop.js +5 -11
  38. package/dist/tools/tasks/taskOutputs.d.ts +2 -2
  39. package/dist/tools/tasks/taskStates.d.ts +6 -0
  40. package/dist/tools/tasks/taskStates.js +52 -0
  41. package/dist/tools/tasks/waitForTask.js +3 -11
  42. package/dist/tools/workspace/listAgents.d.ts +6 -0
  43. package/dist/tools/workspace/listAgents.js +12 -2
  44. package/dist/utils/atomicFile.js +20 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/docs/CODE_WIKI.md +4 -4
  48. package/docs/open-source-application.md +11 -12
  49. package/docs/release-evidence.md +33 -47
  50. package/package.json +1 -1
  51. package/scripts/checks/control-center-smoke.js +11 -4
  52. package/scripts/checks/lifecycle-smoke.js +5 -2
  53. package/scripts/checks/mcp-smoke.js +31 -1
  54. package/scripts/checks/watcher-supervisor-smoke.js +29 -1
  55. package/scripts/control/start-patchwarden-tunnel.ps1 +65 -1
  56. package/scripts/e2e/demo-runtime.mjs +153 -0
  57. package/scripts/release/desktop-preflight.js +1 -1
  58. package/src/assessments/assessmentDiagnostics.ts +172 -0
  59. package/src/assessments/assessmentStore.ts +237 -12
  60. package/src/assessments/securitySnapshot.ts +231 -0
  61. package/src/control/routes/status.ts +37 -0
  62. package/src/control/runtime.ts +2 -10
  63. package/src/control/server.ts +1 -0
  64. package/src/diagnostics/allowedTestCommandSafety.ts +12 -0
  65. package/src/direct/directSessionStore.ts +6 -4
  66. package/src/doctor.ts +3 -5
  67. package/src/runner/runTask.ts +59 -6
  68. package/src/runner/taskRuntime.ts +2 -0
  69. package/src/runner/taskStatusStore.ts +16 -1
  70. package/src/runner/watch.ts +67 -1
  71. package/src/smoke-test.ts +2 -2
  72. package/src/tools/definitions/toolDefs.ts +3 -3
  73. package/src/tools/diagnostics/auditTask.ts +37 -7
  74. package/src/tools/diagnostics/healthCheck.ts +29 -1
  75. package/src/tools/tasks/cancelTask.ts +2 -1
  76. package/src/tools/tasks/createTask.ts +113 -8
  77. package/src/tools/tasks/diagnoseTask.ts +4 -8
  78. package/src/tools/tasks/getTaskStatus.ts +6 -1
  79. package/src/tools/tasks/getTaskSummary.ts +2 -11
  80. package/src/tools/tasks/listTasks.ts +2 -1
  81. package/src/tools/tasks/reconcileTasks.ts +162 -13
  82. package/src/tools/tasks/runTaskLoop.ts +4 -12
  83. package/src/tools/tasks/taskStates.ts +54 -0
  84. package/src/tools/tasks/waitForTask.ts +3 -12
  85. package/src/tools/workspace/listAgents.ts +17 -3
  86. package/src/utils/atomicFile.ts +17 -1
  87. package/src/version.ts +1 -1
  88. package/ui/pages/dashboard.html +4 -0
  89. package/ui/pages/logs.html +29 -0
  90. package/ui/pages/settings.html +3 -3
  91. package/ui/settings.js +26 -17
package/README.en.md CHANGED
@@ -19,9 +19,9 @@ PatchWarden stores that plan as a workspace-scoped task, lets a preconfigured
19
19
  local agent execute it, and returns results, diffs, artifact manifests, and
20
20
  independent verification evidence.
21
21
 
22
- [Download Windows installer v1.6.0](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.0/PatchWarden-Setup-1.6.0-x64.exe)
23
- · [Portable ZIP](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.0/PatchWarden-Portable-1.6.0-x64.zip)
24
- · [Checksums](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.0/PatchWarden-Desktop-SHA256SUMS.txt)
22
+ [Download Windows installer v1.6.2](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.2/PatchWarden-Setup-1.6.2-x64.exe)
23
+ · [Portable ZIP](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.2/PatchWarden-Portable-1.6.2-x64.zip)
24
+ · [Checksums](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.2/PatchWarden-Desktop-SHA256SUMS.txt)
25
25
  · [Three-minute quick start](#three-minute-quick-start)
26
26
  · [Discussions](https://github.com/jiezeng2004-design/PatchWarden/discussions)
27
27
 
@@ -29,12 +29,18 @@ independent verification evidence.
29
29
 
30
30
  <sub>Real PatchWarden Desktop first-run UI from a privacy-safe smoke run; no real workspace, account, or credential data is shown.</sub>
31
31
 
32
- Current source version: **v1.6.1**; current Windows Release: **v1.6.0**; npm
33
- `latest`: **v1.5.1**. Use the installer above for the first Windows experience;
34
- pin an actually published version for npm/CLI use. See the
32
+ Current source version and Windows Release: **v1.6.2**. npm `latest` is
33
+ published separately by the maintainer; use the npm badge above or query the
34
+ registry for its current value. Use the installer above for the first Windows experience. See the
35
35
  [CHANGELOG](CHANGELOG.md), [migration guide](docs/migration-from-safe-bifrost.md), and
36
36
  [release checklist](docs/release-checklist.md).
37
37
 
38
+ > [!NOTE]
39
+ > The desktop installer is currently available for Windows x64 only. macOS,
40
+ > Linux, and users who are not installing Desktop yet can continue using the
41
+ > existing stable npm package and its built-in local Dashboard. This change
42
+ > does not run `npm publish` automatically.
43
+
38
44
  > [!IMPORTANT]
39
45
  > PatchWarden is not a general-purpose remote shell. MCP clients cannot run
40
46
  > arbitrary commands: files must remain inside the configured workspace,
@@ -239,16 +245,15 @@ three minutes. A ChatGPT Tunnel is not required for this first run.
239
245
 
240
246
  ### Option A: Windows installer (recommended for a first run)
241
247
 
242
- 1. Download the [Windows installer v1.6.0](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.0/PatchWarden-Setup-1.6.0-x64.exe)
243
- and its [SHA256 checksum file](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.0/PatchWarden-Desktop-SHA256SUMS.txt).
248
+ 1. Download the [Windows installer v1.6.2](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.2/PatchWarden-Setup-1.6.2-x64.exe)
249
+ and its [SHA256 checksum file](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.2/PatchWarden-Desktop-SHA256SUMS.txt).
244
250
  2. Verify the installer in PowerShell:
245
251
 
246
252
  ```powershell
247
- Get-FileHash .\PatchWarden-Setup-1.6.0-x64.exe -Algorithm SHA256
253
+ Get-FileHash .\PatchWarden-Setup-1.6.2-x64.exe -Algorithm SHA256
248
254
  ```
249
255
 
250
- The current published value is `77d655545925ae283a0caed0fff12554debac15b54c5decf1ae8f3867703058e`;
251
- always prefer the checksum file from the same Release. The installer is not
256
+ Always use the checksum file from the same Release. The installer is not
252
257
  code-signed yet, so Windows SmartScreen may show an unknown-publisher warning.
253
258
 
254
259
  3. Install and open PatchWarden Desktop, then choose a dedicated workspace that
@@ -683,7 +688,7 @@ country list.
683
688
  Use this sequence:
684
689
 
685
690
  1. `health_check` — verify version, workspace, Watcher, and tool catalog.
686
- 2. `list_agents` — confirm the local execution agent is available.
691
+ 2. `list_agents` — confirm the local agent executable is available. This read-only check does not verify provider balance, login state, or model permissions; `provider_status` is `not_checked` by default.
687
692
  3. `list_workspace` — identify the correct `repo_path`.
688
693
  4. `save_plan`, or provide an `inline_plan` when creating the task.
689
694
  5. `create_task` — specify the agent, repository, and verification commands.
package/README.md CHANGED
@@ -15,9 +15,9 @@ ChatGPT、Codex、OpenCode 或其他 MCP 客户端负责规划与验收,
15
15
  PatchWarden 负责把计划保存成工作区内任务,再由预先配置的本地 Agent
16
16
  执行,并返回结果、代码差异和独立测试记录。
17
17
 
18
- [下载 Windows 安装版 v1.6.0](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.0/PatchWarden-Setup-1.6.0-x64.exe)
19
- · [免安装 ZIP](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.0/PatchWarden-Portable-1.6.0-x64.zip)
20
- · [校验文件](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.0/PatchWarden-Desktop-SHA256SUMS.txt)
18
+ [下载 Windows 安装版 v1.6.2](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.2/PatchWarden-Setup-1.6.2-x64.exe)
19
+ · [免安装 ZIP](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.2/PatchWarden-Portable-1.6.2-x64.zip)
20
+ · [校验文件](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.2/PatchWarden-Desktop-SHA256SUMS.txt)
21
21
  · [三分钟快速开始](#三分钟快速开始)
22
22
  · [Discussions](https://github.com/jiezeng2004-design/PatchWarden/discussions)
23
23
 
@@ -25,11 +25,15 @@ PatchWarden 负责把计划保存成工作区内任务,再由预先配置的
25
25
 
26
26
  <sub>PatchWarden Desktop 真实首启界面,来自隐私安全的桌面 smoke 验收;截图未使用真实工作区、账号或凭据。</sub>
27
27
 
28
- 当前源码版本:**v1.6.1**;当前 Windows Release:**v1.6.0**;npm `latest`:**v1.5.1**。
29
- Windows 首次体验推荐上面的安装版;npm/CLI 用户请固定已发布版本。查看
28
+ 当前源码版本和 Windows Release:**v1.6.2**。npm `latest` 由维护者独立手动发布,
29
+ 请以页面顶部的 npm 徽章和注册表查询结果为准。Windows 首次体验推荐上面的安装版;查看
30
30
  [CHANGELOG](CHANGELOG.md)、[迁移指南](docs/migration-from-safe-bifrost.md)和
31
31
  [发布检查清单](docs/release-checklist.md)。
32
32
 
33
+ > [!NOTE]
34
+ > 当前桌面安装包暂时只提供 Windows x64。macOS、Linux 或暂时不安装桌面版的用户,
35
+ > 可以继续使用 npm 上已有的稳定版本及其内置本地 Dashboard;本次 GitHub Release 不自动执行 `npm publish`。
36
+
33
37
  > [!IMPORTANT]
34
38
  > PatchWarden 不是通用远程 Shell。MCP 客户端不能随意执行命令:
35
39
  > 文件必须位于配置的工作区内,Agent 必须预先登记,验证命令必须与白名单
@@ -148,16 +152,15 @@ CLI,或者先把 OpenCode 配置为执行 Agent。
148
152
 
149
153
  ### 方案 A:Windows 安装版(首次体验推荐)
150
154
 
151
- 1. 下载 [Windows 安装版 v1.6.0](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.0/PatchWarden-Setup-1.6.0-x64.exe)
152
- 和 [SHA256 校验文件](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.0/PatchWarden-Desktop-SHA256SUMS.txt)。
155
+ 1. 下载 [Windows 安装版 v1.6.2](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.2/PatchWarden-Setup-1.6.2-x64.exe)
156
+ 和 [SHA256 校验文件](https://github.com/jiezeng2004-design/PatchWarden/releases/download/v1.6.2/PatchWarden-Desktop-SHA256SUMS.txt)。
153
157
  2. 在 PowerShell 中校验安装包:
154
158
 
155
159
  ```powershell
156
- Get-FileHash .\PatchWarden-Setup-1.6.0-x64.exe -Algorithm SHA256
160
+ Get-FileHash .\PatchWarden-Setup-1.6.2-x64.exe -Algorithm SHA256
157
161
  ```
158
162
 
159
- 当前发布值应为 `77d655545925ae283a0caed0fff12554debac15b54c5decf1ae8f3867703058e`;
160
- 仍应以同一 Release 的校验文件为准。安装包尚未代码签名,Windows SmartScreen
163
+ 请以同一 Release 的校验文件为准。安装包尚未代码签名,Windows SmartScreen
161
164
  可能显示“未知发布者”。
162
165
 
163
166
  3. 安装并打开 PatchWarden Desktop,选择一个只包含项目的专用工作区。
@@ -565,7 +568,7 @@ Tunnel。支持范围可能变化,因此不要在项目里写死国家列表
565
568
  推荐顺序:
566
569
 
567
570
  1. `health_check`:确认版本、工作区、Watcher 和工具目录。
568
- 2. `list_agents`:确认本地 Agent 命令可用。
571
+ 2. `list_agents`:确认本地 Agent 可执行文件可用;该检查不会验证模型提供商余额、登录态或调用权限,结果中的 `provider_status` 默认为 `not_checked`。
569
572
  3. `list_workspace`:确定 `repo_path`。
570
573
  4. `save_plan`,或在创建任务时提供 `inline_plan`。
571
574
  5. `create_task`:明确 Agent、仓库和验证命令。
@@ -0,0 +1,29 @@
1
+ import type { AssessmentValidationResult } from "./assessmentStore.js";
2
+ export interface AssessmentValidationFailureDiagnostic {
3
+ diagnostic_version: string;
4
+ error_code: string;
5
+ reason_code: string;
6
+ assessment_id: string | null;
7
+ assessment_config_sha256: string | null;
8
+ current_config_sha256: string | null;
9
+ assessment_config_hash: string | null;
10
+ current_config_hash: string | null;
11
+ snapshot_version: string | null;
12
+ assessment_fingerprint_version: string | null;
13
+ current_fingerprint_version: string;
14
+ assessment_schema_epoch: string | null;
15
+ current_schema_epoch: string;
16
+ assessment_created_at: string | null;
17
+ assessment_expires_at: string | null;
18
+ config_changed: boolean;
19
+ changed_field_names: string[];
20
+ changed_config_sections: string[];
21
+ validator_module_path: string;
22
+ validator_build_id: string;
23
+ watcher_instance_id: string | null;
24
+ observed_at: string;
25
+ }
26
+ export declare function buildAssessmentValidationFailureDiagnostic(result: AssessmentValidationResult): AssessmentValidationFailureDiagnostic | null;
27
+ /** Persist a bounded value-free failure summary without changing validation behavior. */
28
+ export declare function recordAssessmentValidationFailure(result: AssessmentValidationResult): boolean;
29
+ export declare function readLastAssessmentValidationFailure(): AssessmentValidationFailureDiagnostic | null;
@@ -0,0 +1,132 @@
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 { ASSESSMENT_SECURITY_SNAPSHOT_VERSION } from "./securitySnapshot.js";
8
+ import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
9
+ const DIAGNOSTIC_VERSION = "assessment-validation-diagnostic-v2";
10
+ const DIAGNOSTIC_FILE = "last-validation-failure.json";
11
+ const VALIDATOR_MODULE_PATH = "dist/assessments/assessmentStore.js";
12
+ const VALIDATOR_BUILD_ID = `patchwarden-${PATCHWARDEN_VERSION}:${TOOL_SCHEMA_EPOCH}:${ASSESSMENT_SECURITY_SNAPSHOT_VERSION}`;
13
+ export function buildAssessmentValidationFailureDiagnostic(result) {
14
+ if (result.valid || !result.failure_reason)
15
+ return null;
16
+ const reasonCode = safeCode(result.failure_reason);
17
+ const assessmentHash = safeHash(result.expected_hash);
18
+ const currentHash = safeHash(result.actual_hash);
19
+ const changedSections = [...new Set((result.config_change_categories || [])
20
+ .map((name) => safeCode(name))
21
+ .filter(Boolean))]
22
+ .sort()
23
+ .slice(0, 32);
24
+ const assessmentVersion = safeText(result.assessment?.assessment_security_snapshot_version, 80);
25
+ return {
26
+ diagnostic_version: DIAGNOSTIC_VERSION,
27
+ error_code: reasonCode,
28
+ reason_code: reasonCode,
29
+ assessment_id: safeAssessmentId(result.assessment?.assessment_id),
30
+ assessment_config_sha256: assessmentHash,
31
+ current_config_sha256: currentHash,
32
+ assessment_config_hash: assessmentHash,
33
+ current_config_hash: currentHash,
34
+ snapshot_version: assessmentVersion,
35
+ assessment_fingerprint_version: assessmentVersion,
36
+ current_fingerprint_version: ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
37
+ assessment_schema_epoch: safeText(result.assessment?.assessment_schema_epoch, 80),
38
+ current_schema_epoch: TOOL_SCHEMA_EPOCH,
39
+ assessment_created_at: safeTimestamp(result.assessment?.created_at),
40
+ assessment_expires_at: safeTimestamp(result.assessment?.expires_at),
41
+ config_changed: result.failure_reason === "assessment_stale_config",
42
+ changed_field_names: changedSections,
43
+ changed_config_sections: changedSections,
44
+ validator_module_path: VALIDATOR_MODULE_PATH,
45
+ validator_build_id: VALIDATOR_BUILD_ID,
46
+ watcher_instance_id: safeInstanceId(process.env.PATCHWARDEN_WATCHER_INSTANCE_ID),
47
+ observed_at: new Date().toISOString(),
48
+ };
49
+ }
50
+ /** Persist a bounded value-free failure summary without changing validation behavior. */
51
+ export function recordAssessmentValidationFailure(result) {
52
+ const diagnostic = buildAssessmentValidationFailureDiagnostic(result);
53
+ if (!diagnostic)
54
+ return false;
55
+ try {
56
+ const config = getConfig();
57
+ const assessmentsDir = getAssessmentsDir(config);
58
+ const file = guardPath(join(assessmentsDir, DIAGNOSTIC_FILE), config.workspaceRoot, config.assessmentsDir);
59
+ mkdirSync(dirname(file), { recursive: true });
60
+ atomicWriteJsonFileSync(file, diagnostic);
61
+ return true;
62
+ }
63
+ catch {
64
+ // Diagnostics must never replace the original fail-closed validation error.
65
+ return false;
66
+ }
67
+ }
68
+ export function readLastAssessmentValidationFailure() {
69
+ try {
70
+ const config = getConfig();
71
+ const file = guardPath(join(getAssessmentsDir(config), DIAGNOSTIC_FILE), config.workspaceRoot, config.assessmentsDir);
72
+ if (!existsSync(file))
73
+ return null;
74
+ const raw = readJsonObjectFileSync(file);
75
+ if (raw.diagnostic_version !== DIAGNOSTIC_VERSION)
76
+ return null;
77
+ const changedSections = Array.isArray(raw.changed_config_sections)
78
+ ? [...new Set(raw.changed_config_sections.map(safeCode).filter(Boolean))].sort().slice(0, 32)
79
+ : [];
80
+ return {
81
+ diagnostic_version: DIAGNOSTIC_VERSION,
82
+ error_code: safeCode(raw.error_code),
83
+ reason_code: safeCode(raw.reason_code),
84
+ assessment_id: safeAssessmentId(raw.assessment_id),
85
+ assessment_config_sha256: safeHash(raw.assessment_config_sha256),
86
+ current_config_sha256: safeHash(raw.current_config_sha256),
87
+ assessment_config_hash: safeHash(raw.assessment_config_hash),
88
+ current_config_hash: safeHash(raw.current_config_hash),
89
+ snapshot_version: safeText(raw.snapshot_version, 80),
90
+ assessment_fingerprint_version: safeText(raw.assessment_fingerprint_version, 80),
91
+ current_fingerprint_version: safeText(raw.current_fingerprint_version, 80) || ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
92
+ assessment_schema_epoch: safeText(raw.assessment_schema_epoch, 80),
93
+ current_schema_epoch: safeText(raw.current_schema_epoch, 80) || TOOL_SCHEMA_EPOCH,
94
+ assessment_created_at: safeTimestamp(raw.assessment_created_at),
95
+ assessment_expires_at: safeTimestamp(raw.assessment_expires_at),
96
+ config_changed: raw.config_changed === true,
97
+ changed_field_names: Array.isArray(raw.changed_field_names)
98
+ ? [...new Set(raw.changed_field_names.map(safeCode).filter(Boolean))].sort().slice(0, 32)
99
+ : [],
100
+ changed_config_sections: changedSections,
101
+ validator_module_path: safeText(raw.validator_module_path, 120) || VALIDATOR_MODULE_PATH,
102
+ validator_build_id: safeText(raw.validator_build_id, 160) || VALIDATOR_BUILD_ID,
103
+ watcher_instance_id: safeInstanceId(raw.watcher_instance_id),
104
+ observed_at: safeTimestamp(raw.observed_at) || new Date(0).toISOString(),
105
+ };
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ function safeAssessmentId(value) {
112
+ return typeof value === "string" && /^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(value) ? value : null;
113
+ }
114
+ function safeInstanceId(value) {
115
+ return typeof value === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(value) ? value : null;
116
+ }
117
+ function safeCode(value) {
118
+ return typeof value === "string" && /^[a-z0-9_]{1,80}$/.test(value) ? value : "";
119
+ }
120
+ function safeHash(value) {
121
+ return typeof value === "string" && /^[0-9a-f]{64}$/.test(value) ? value : null;
122
+ }
123
+ function safeText(value, maxLength) {
124
+ return typeof value === "string" && value.length > 0
125
+ ? value.replace(/[\r\n]+/g, " ").slice(0, maxLength)
126
+ : null;
127
+ }
128
+ function safeTimestamp(value) {
129
+ return typeof value === "string" && Number.isFinite(Date.parse(value))
130
+ ? new Date(value).toISOString()
131
+ : null;
132
+ }
@@ -1,6 +1,8 @@
1
+ import { PatchWardenConfig } from "../config.js";
1
2
  import { type RepoSnapshot } from "../runner/changeCapture.js";
2
3
  import type { RiskAssessmentResult } from "../security/riskEngine.js";
3
4
  import type { TaskTemplateName, ChangePolicy } from "../tools/taskTemplates.js";
5
+ import { type SecuritySnapshotCategory } from "./securitySnapshot.js";
4
6
  export interface AgentAssessmentSummary {
5
7
  attempted: boolean;
6
8
  status: "completed" | "timed_out" | "non_zero_exit" | "parse_failed" | "read_only_violation" | "spawn_failed" | "not_run";
@@ -44,6 +46,12 @@ export interface AssessmentRecord {
44
46
  plan_id: string | null;
45
47
  policy_hash: string;
46
48
  tool_manifest_sha256: string;
49
+ execution_config_hash?: string;
50
+ execution_config_component_hashes?: Partial<Record<ExecutionConfigChangeCategory, string>>;
51
+ assessment_security_snapshot_version?: string;
52
+ assessment_security_snapshot_sha256?: string;
53
+ assessment_security_snapshot_component_hashes?: Partial<Record<ExecutionConfigChangeCategory, string>>;
54
+ assessment_schema_epoch?: string;
47
55
  workspace_fingerprint: string;
48
56
  workspace_snapshot_summary: {
49
57
  head: string | null;
@@ -62,10 +70,15 @@ export interface AssessmentRecord {
62
70
  agent: string;
63
71
  timeout_seconds?: number;
64
72
  change_policy?: ChangePolicy;
73
+ scope?: string[];
74
+ forbidden?: string[];
75
+ verification?: string[];
76
+ done_evidence?: string[];
65
77
  requires_confirm: boolean;
66
78
  confirmed: boolean;
67
79
  confirmed_at: string | null;
68
80
  confirm_code: string | null;
81
+ used_at?: string | null;
69
82
  agent_assessment_summary?: AgentAssessmentSummary | null;
70
83
  }
71
84
  export interface AssessmentCreateInput {
@@ -85,6 +98,10 @@ export interface AssessmentCreateInput {
85
98
  agent: string;
86
99
  timeout_seconds?: number;
87
100
  change_policy?: ChangePolicy;
101
+ scope?: string[];
102
+ forbidden?: string[];
103
+ verification?: string[];
104
+ done_evidence?: string[];
88
105
  snapshot: RepoSnapshot;
89
106
  assessment_id?: string;
90
107
  assessment_dir?: string;
@@ -94,7 +111,11 @@ export interface AssessmentValidationResult {
94
111
  valid: boolean;
95
112
  failure_reason: string | null;
96
113
  assessment: AssessmentRecord | null;
114
+ expected_hash?: string;
115
+ actual_hash?: string;
116
+ config_change_categories?: ExecutionConfigChangeCategory[];
97
117
  }
118
+ export type ExecutionConfigChangeCategory = SecuritySnapshotCategory | "allowed_test_commands" | "execution_config";
98
119
  export interface AssessmentConfirmationResult {
99
120
  assessment_id: string;
100
121
  decision: "needs_confirm";
@@ -111,6 +132,8 @@ export declare function generateAssessmentId(): string;
111
132
  export declare function createAssessmentDir(assessmentId: string): string;
112
133
  export declare function createAssessment(input: AssessmentCreateInput): AssessmentRecord;
113
134
  export declare function readAssessment(assessmentId: string): AssessmentRecord;
135
+ /** Atomically consume an Assessment before creating its one allowed task. */
136
+ export declare function markAssessmentUsed(assessmentId: string): AssessmentRecord;
114
137
  export declare function validateAssessmentFreshness(assessmentId: string, currentSnapshot: RepoSnapshot, options?: AssessmentValidationOptions): AssessmentValidationResult;
115
138
  /**
116
139
  * Records an explicit confirmation performed from the local PatchWarden CLI.
@@ -130,4 +153,20 @@ interface PolicyHashInput {
130
153
  schema_epoch: string;
131
154
  }
132
155
  export declare function computePolicyHash(input: PolicyHashInput): string;
156
+ interface ExecutionConfigHashInput {
157
+ agent: string;
158
+ tool_manifest_sha256: string;
159
+ tool_profile: string;
160
+ repo_path: string;
161
+ change_policy?: string | null;
162
+ template?: string | null;
163
+ verify_commands?: string[];
164
+ test_command?: string | null;
165
+ task_timeout_seconds?: number | null;
166
+ scope?: string[];
167
+ forbidden?: string[];
168
+ verification?: string[];
169
+ done_evidence?: string[];
170
+ }
171
+ export declare function computeExecutionConfigHash(config: PatchWardenConfig, input: ExecutionConfigHashInput): string;
133
172
  export {};
@@ -8,8 +8,11 @@ import { redactSensitiveContent } from "../security/contentRedaction.js";
8
8
  import { atomicWriteJsonFileSync } from "../utils/atomicFile.js";
9
9
  import { withFileLockSync } from "../utils/lockedJsonFile.js";
10
10
  import { TOOL_SCHEMA_EPOCH } from "../version.js";
11
- import { getLastToolCatalogSnapshot } from "../tools/catalog/toolCatalog.js";
11
+ import { buildToolCatalogSnapshot, resolveToolProfile, } from "../tools/catalog/toolCatalog.js";
12
+ import { getToolDefs } from "../tools/definitions/toolDefs.js";
12
13
  import { captureRepoSnapshot } from "../runner/changeCapture.js";
14
+ import { getProjectPolicySummary } from "../policy/projectPolicy.js";
15
+ import { ASSESSMENT_SECURITY_SNAPSHOT_VERSION, buildAssessmentSecuritySnapshot, getAssessmentSecuritySnapshotComponentHashes, hashAssessmentSecuritySnapshot, } from "./securitySnapshot.js";
13
16
  const SENSITIVE_PATH_RULES_SIGNATURE = "v1";
14
17
  const ARTIFACT_RULES_SIGNATURE = "v1";
15
18
  export function generateAssessmentId() {
@@ -35,7 +38,7 @@ export function createAssessment(input) {
35
38
  const planHash = input.plan_content
36
39
  ? createHash("sha256").update(input.plan_content).digest("hex")
37
40
  : null;
38
- const toolManifest = getLastToolCatalogSnapshot()?.tool_manifest_sha256 || computeFallbackManifestHash(config);
41
+ const toolManifest = getCurrentToolManifest(config);
39
42
  const workspaceFingerprint = computeWorkspaceFingerprint(input.snapshot);
40
43
  const snapshotTruncated = input.snapshot.warnings.some((w) => w.includes("snapshot limited"));
41
44
  const policyHash = computePolicyHash({
@@ -48,6 +51,21 @@ export function createAssessment(input) {
48
51
  artifact_rules: ARTIFACT_RULES_SIGNATURE,
49
52
  schema_epoch: TOOL_SCHEMA_EPOCH,
50
53
  });
54
+ const executionConfig = computeExecutionConfigFingerprint(config, {
55
+ agent: input.agent,
56
+ tool_manifest_sha256: toolManifest,
57
+ tool_profile: resolveToolProfile(config.toolProfile),
58
+ repo_path: input.resolved_repo_path,
59
+ change_policy: input.change_policy || "repo_scoped_changes",
60
+ template: input.template || null,
61
+ verify_commands: input.verify_commands || [],
62
+ test_command: input.test_command || null,
63
+ task_timeout_seconds: input.timeout_seconds,
64
+ scope: input.scope,
65
+ forbidden: input.forbidden,
66
+ verification: input.verification,
67
+ done_evidence: input.done_evidence,
68
+ });
51
69
  const now = new Date();
52
70
  const expiresAt = new Date(now.getTime() + config.assessmentTtlSeconds * 1000).toISOString();
53
71
  const record = {
@@ -62,6 +80,12 @@ export function createAssessment(input) {
62
80
  plan_id: input.plan_id,
63
81
  policy_hash: policyHash,
64
82
  tool_manifest_sha256: toolManifest,
83
+ execution_config_hash: executionConfig.hash,
84
+ execution_config_component_hashes: executionConfig.components,
85
+ assessment_security_snapshot_version: ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
86
+ assessment_security_snapshot_sha256: executionConfig.hash,
87
+ assessment_security_snapshot_component_hashes: executionConfig.components,
88
+ assessment_schema_epoch: TOOL_SCHEMA_EPOCH,
65
89
  workspace_fingerprint: workspaceFingerprint,
66
90
  workspace_snapshot_summary: {
67
91
  head: input.snapshot.head,
@@ -80,16 +104,24 @@ export function createAssessment(input) {
80
104
  agent: input.agent,
81
105
  timeout_seconds: input.timeout_seconds,
82
106
  change_policy: input.change_policy || "repo_scoped_changes",
107
+ scope: input.scope || [],
108
+ forbidden: input.forbidden || [],
109
+ verification: input.verification || [],
110
+ done_evidence: input.done_evidence || [],
83
111
  requires_confirm: input.decision === "needs_confirm",
84
112
  confirmed: false,
85
113
  confirmed_at: null,
86
114
  confirm_code: null,
115
+ used_at: null,
87
116
  agent_assessment_summary: input.agent_assessment_summary || null,
88
117
  };
89
118
  atomicWriteJsonFileSync(join(dir, "assessment.json"), record);
90
119
  return record;
91
120
  }
92
121
  export function readAssessment(assessmentId) {
122
+ if (!/^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(assessmentId)) {
123
+ throw new PatchWardenError("assessment_id_invalid", "A full assessment_id with 32 hexadecimal random characters is required.", "Copy the complete assessment_id from the assess_only response; assessment_short_id is display-only.");
124
+ }
93
125
  const config = getConfig();
94
126
  const assessmentsDir = getAssessmentsDir(config);
95
127
  const dir = resolve(assessmentsDir, assessmentId);
@@ -98,9 +130,36 @@ export function readAssessment(assessmentId) {
98
130
  if (!existsSync(file)) {
99
131
  throw new PatchWardenError("assessment_not_found", `Assessment "${assessmentId}" not found.`, "Call create_task with execution_mode=assess_only first to get a valid assessment_id.", true, { assessment_id: assessmentId });
100
132
  }
101
- return JSON.parse(readFileSync(file, "utf-8"));
133
+ try {
134
+ const parsed = JSON.parse(readFileSync(file, "utf-8"));
135
+ if (!parsed || typeof parsed !== "object" || parsed.assessment_id !== assessmentId) {
136
+ throw new Error("record identity mismatch");
137
+ }
138
+ return parsed;
139
+ }
140
+ catch (error) {
141
+ throw new PatchWardenError("assessment_corrupted", `Assessment "${assessmentId}" cannot be read as a valid record.`, "Run create_task with execution_mode=assess_only again.", true, { assessment_id: assessmentId, failure_category: "invalid_assessment_record" });
142
+ }
143
+ }
144
+ /** Atomically consume an Assessment before creating its one allowed task. */
145
+ export function markAssessmentUsed(assessmentId) {
146
+ const config = getConfig();
147
+ const assessmentFile = resolve(getAssessmentsDir(config), assessmentId, "assessment.json");
148
+ guardPath(assessmentFile, config.workspaceRoot, config.assessmentsDir);
149
+ return withFileLockSync(assessmentFile, () => {
150
+ const assessment = readAssessment(assessmentId);
151
+ if (assessment.used_at) {
152
+ throw new PatchWardenError("assessment_used", `Assessment "${assessmentId}" has already been used.`, "Run create_task with execution_mode=assess_only again to obtain a new assessment_id.", true, { assessment_id: assessmentId, used_at: assessment.used_at });
153
+ }
154
+ const consumed = { ...assessment, used_at: new Date().toISOString() };
155
+ atomicWriteJsonFileSync(assessmentFile, consumed);
156
+ return consumed;
157
+ });
102
158
  }
103
159
  export function validateAssessmentFreshness(assessmentId, currentSnapshot, options = {}) {
160
+ if (!/^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(assessmentId)) {
161
+ return { valid: false, failure_reason: "assessment_id_invalid", assessment: null };
162
+ }
104
163
  let assessment;
105
164
  try {
106
165
  assessment = readAssessment(assessmentId);
@@ -124,18 +183,70 @@ export function validateAssessmentFreshness(assessmentId, currentSnapshot, optio
124
183
  return { valid: false, failure_reason: "assessment_stale_plan", assessment };
125
184
  }
126
185
  }
186
+ const config = getConfig();
187
+ const currentManifest = getCurrentToolManifest(config);
127
188
  if (!options.skip_tool_manifest) {
128
- const currentManifest = getLastToolCatalogSnapshot()?.tool_manifest_sha256 ||
129
- computeFallbackManifestHash(getConfig());
130
- if (currentManifest !== assessment.tool_manifest_sha256) {
131
- return { valid: false, failure_reason: "assessment_stale_config", assessment };
189
+ // Legacy records need the standalone manifest check. Versioned security
190
+ // snapshots compare the manifest together with the source config, so the
191
+ // diagnostic can report both "tool_manifest" and e.g. "allowed_commands".
192
+ if (currentManifest !== assessment.tool_manifest_sha256 && !assessment.assessment_security_snapshot_sha256) {
193
+ return {
194
+ valid: false,
195
+ failure_reason: "assessment_stale_config",
196
+ assessment,
197
+ expected_hash: assessment.tool_manifest_sha256,
198
+ actual_hash: currentManifest,
199
+ config_change_categories: ["tool_manifest"],
200
+ };
132
201
  }
133
202
  }
134
203
  const currentFingerprint = computeWorkspaceFingerprint(currentSnapshot);
135
204
  if (currentFingerprint !== assessment.workspace_fingerprint) {
136
205
  return { valid: false, failure_reason: "assessment_workspace_changed", assessment };
137
206
  }
138
- const config = getConfig();
207
+ if (assessment.execution_config_hash) {
208
+ const currentExecutionConfig = computeExecutionConfigFingerprint(config, {
209
+ agent: assessment.agent,
210
+ tool_manifest_sha256: currentManifest,
211
+ tool_profile: resolveToolProfile(config.toolProfile),
212
+ repo_path: assessment.resolved_repo_path,
213
+ change_policy: assessment.change_policy || "repo_scoped_changes",
214
+ template: assessment.template || null,
215
+ verify_commands: assessment.verify_commands || [],
216
+ test_command: assessment.test_command || null,
217
+ task_timeout_seconds: assessment.timeout_seconds,
218
+ scope: assessment.scope,
219
+ forbidden: assessment.forbidden,
220
+ verification: assessment.verification,
221
+ done_evidence: assessment.done_evidence,
222
+ });
223
+ if (assessment.assessment_security_snapshot_version
224
+ && assessment.assessment_security_snapshot_version !== ASSESSMENT_SECURITY_SNAPSHOT_VERSION) {
225
+ return {
226
+ valid: false,
227
+ failure_reason: "assessment_snapshot_version_incompatible",
228
+ assessment,
229
+ expected_hash: assessment.execution_config_hash,
230
+ actual_hash: currentExecutionConfig.hash,
231
+ config_change_categories: ["schema_epoch"],
232
+ };
233
+ }
234
+ if (currentExecutionConfig.hash !== assessment.execution_config_hash) {
235
+ const previousComponents = assessment.execution_config_component_hashes;
236
+ const changedCategories = previousComponents
237
+ ? Object.keys(currentExecutionConfig.components)
238
+ .filter((category) => previousComponents[category] !== currentExecutionConfig.components[category])
239
+ : ["execution_config"];
240
+ return {
241
+ valid: false,
242
+ failure_reason: "assessment_stale_config",
243
+ assessment,
244
+ expected_hash: assessment.execution_config_hash,
245
+ actual_hash: currentExecutionConfig.hash,
246
+ config_change_categories: changedCategories.length > 0 ? changedCategories : ["execution_config"],
247
+ };
248
+ }
249
+ }
139
250
  const currentPolicyHash = computePolicyHash({
140
251
  change_policy: assessment.change_policy || "repo_scoped_changes",
141
252
  template: assessment.template || null,
@@ -232,10 +343,53 @@ export function computePolicyHash(input) {
232
343
  function getRepoAllowedCommands(config, repoPath) {
233
344
  return getRepoAllowedTestCommands(config, repoPath);
234
345
  }
235
- function computeFallbackManifestHash(config) {
236
- return createHash("sha256")
237
- .update(`${TOOL_SCHEMA_EPOCH}:${config.toolProfile || "full"}`)
238
- .digest("hex");
346
+ function getCurrentToolManifest(config) {
347
+ // The catalog cache is process-local. Rebuild the canonical catalog here so
348
+ // MCP and the independent Watcher derive the same manifest across processes.
349
+ const profile = resolveToolProfile(config.toolProfile);
350
+ return buildToolCatalogSnapshot(getToolDefs(), profile).tool_manifest_sha256;
351
+ }
352
+ export function computeExecutionConfigHash(config, input) {
353
+ return computeExecutionConfigFingerprint(config, input).hash;
354
+ }
355
+ function computeExecutionConfigFingerprint(config, input) {
356
+ let projectPolicy = null;
357
+ let projectPolicyValid = null;
358
+ let projectPolicyIssues = [];
359
+ try {
360
+ const summary = getProjectPolicySummary(input.repo_path);
361
+ projectPolicy = summary.effective_policy;
362
+ projectPolicyValid = summary.valid;
363
+ projectPolicyIssues = summary.issues.map(({ code, severity, field }) => ({ code, severity, field }));
364
+ }
365
+ catch {
366
+ // A workspace-root change can make the old repository inaccessible. The
367
+ // workspace_root component still fails closed without exposing the path.
368
+ }
369
+ const snapshot = buildAssessmentSecuritySnapshot({
370
+ config,
371
+ schemaEpoch: TOOL_SCHEMA_EPOCH,
372
+ toolProfile: input.tool_profile,
373
+ toolManifestSha256: input.tool_manifest_sha256,
374
+ agent: input.agent,
375
+ repoPath: input.repo_path,
376
+ changePolicy: input.change_policy,
377
+ template: input.template,
378
+ verifyCommands: input.verify_commands,
379
+ testCommand: input.test_command,
380
+ taskTimeoutSeconds: input.task_timeout_seconds,
381
+ scope: input.scope,
382
+ forbidden: input.forbidden,
383
+ verification: input.verification,
384
+ doneEvidence: input.done_evidence,
385
+ projectPolicy,
386
+ projectPolicyValid,
387
+ projectPolicyIssues,
388
+ });
389
+ return {
390
+ hash: hashAssessmentSecuritySnapshot(snapshot),
391
+ components: getAssessmentSecuritySnapshotComponentHashes(snapshot),
392
+ };
239
393
  }
240
394
  function extractReason(error) {
241
395
  if (error instanceof PatchWardenError)