patchwarden 1.1.0 → 1.5.0
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 +41 -7
- package/README.md +36 -9
- package/dist/controlCenter.js +197 -1
- package/dist/direct/directSessionStore.d.ts +2 -0
- package/dist/direct/directVerification.js +7 -0
- package/dist/doctor.js +1 -1
- package/dist/policy/projectPolicy.d.ts +55 -0
- package/dist/policy/projectPolicy.js +286 -0
- package/dist/smoke-test.js +8 -8
- package/dist/test/unit/evidence-pack.test.d.ts +1 -0
- package/dist/test/unit/evidence-pack.test.js +130 -0
- package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
- package/dist/test/unit/project-policy-release-mode.test.js +125 -0
- package/dist/test/unit/run-task-loop.test.d.ts +1 -0
- package/dist/test/unit/run-task-loop.test.js +380 -0
- package/dist/test/unit/schema-drift-check.test.js +10 -9
- package/dist/tools/evidencePack.d.ts +39 -0
- package/dist/tools/evidencePack.js +168 -0
- package/dist/tools/recommendAgentForTask.d.ts +19 -0
- package/dist/tools/recommendAgentForTask.js +56 -0
- package/dist/tools/registry.js +376 -2
- package/dist/tools/releaseMode.d.ts +50 -0
- package/dist/tools/releaseMode.js +370 -0
- package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
- package/dist/tools/runDirectVerificationBundle.js +64 -0
- package/dist/tools/runTaskLoop.d.ts +57 -0
- package/dist/tools/runTaskLoop.js +417 -0
- package/dist/tools/runVerification.d.ts +4 -0
- package/dist/tools/runVerification.js +4 -0
- package/dist/tools/safeViews.d.ts +6 -0
- package/dist/tools/safeViews.js +2 -0
- package/dist/tools/taskLineage.d.ts +91 -0
- package/dist/tools/taskLineage.js +175 -0
- package/dist/tools/toolCatalog.d.ts +2 -2
- package/dist/tools/toolCatalog.js +6 -0
- package/dist/tools/toolRegistry.js +110 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/docs/chatgpt-usage.md +31 -0
- package/docs/control-center/README.md +9 -0
- package/package.json +2 -2
- package/scripts/checks/control-center-smoke.js +87 -0
- package/scripts/checks/control-smoke.js +2 -2
- package/scripts/checks/mcp-manifest-check.js +12 -0
- package/scripts/checks/mcp-smoke.js +31 -7
- package/scripts/checks/watcher-supervisor-smoke.js +1 -1
- package/src/controlCenter.ts +198 -1
- package/src/direct/directSessionStore.ts +2 -0
- package/src/direct/directVerification.ts +7 -0
- package/src/doctor.ts +1 -1
- package/src/policy/projectPolicy.ts +344 -0
- package/src/smoke-test.ts +5 -5
- package/src/test/unit/evidence-pack.test.ts +142 -0
- package/src/test/unit/project-policy-release-mode.test.ts +156 -0
- package/src/test/unit/run-task-loop.test.ts +425 -0
- package/src/test/unit/schema-drift-check.test.ts +11 -9
- package/src/tools/evidencePack.ts +205 -0
- package/src/tools/listWorkspace.ts +71 -71
- package/src/tools/recommendAgentForTask.ts +79 -0
- package/src/tools/registry.ts +405 -2
- package/src/tools/releaseMode.ts +450 -0
- package/src/tools/runDirectVerificationBundle.ts +98 -0
- package/src/tools/runTaskLoop.ts +526 -0
- package/src/tools/runVerification.ts +8 -0
- package/src/tools/safeViews.ts +2 -0
- package/src/tools/taskLineage.ts +300 -0
- package/src/tools/toolCatalog.ts +6 -0
- package/src/tools/toolRegistry.ts +110 -0
- package/src/version.ts +2 -2
- package/ui/pages/dashboard.html +143 -2
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
getAllConfiguredDirectCommands,
|
|
5
|
+
getAllConfiguredTestCommands,
|
|
6
|
+
getConfig,
|
|
7
|
+
type PatchWardenConfig,
|
|
8
|
+
} from "../config.js";
|
|
9
|
+
import { guardTestCommand } from "../security/commandGuard.js";
|
|
10
|
+
import { guardWorkspacePath } from "../security/pathGuard.js";
|
|
11
|
+
import { isSensitivePath } from "../security/sensitiveGuard.js";
|
|
12
|
+
|
|
13
|
+
export interface ProjectPolicy {
|
|
14
|
+
allowed_commands: string[];
|
|
15
|
+
auto_cleanup: {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
patterns: string[];
|
|
18
|
+
exclude: string[];
|
|
19
|
+
};
|
|
20
|
+
high_risk_commands: string[];
|
|
21
|
+
protected_paths: string[];
|
|
22
|
+
release_mode: {
|
|
23
|
+
version_source: string;
|
|
24
|
+
required_commands: string[];
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ProjectPolicyIssue {
|
|
29
|
+
code: string;
|
|
30
|
+
severity: "error" | "warn";
|
|
31
|
+
field: string;
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReleaseReadinessSummary {
|
|
36
|
+
version_source: string;
|
|
37
|
+
version: string | null;
|
|
38
|
+
package_json_version: string | null;
|
|
39
|
+
package_name: string | null;
|
|
40
|
+
version_consistent: boolean | null;
|
|
41
|
+
required_commands: Array<{
|
|
42
|
+
command: string;
|
|
43
|
+
allowed: boolean;
|
|
44
|
+
reason: string | null;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ProjectPolicySummary {
|
|
49
|
+
repo_path: string;
|
|
50
|
+
resolved_repo_path: string;
|
|
51
|
+
policy_path: string;
|
|
52
|
+
exists: boolean;
|
|
53
|
+
valid: boolean;
|
|
54
|
+
effective_policy: ProjectPolicy;
|
|
55
|
+
issues: ProjectPolicyIssue[];
|
|
56
|
+
release_readiness: ReleaseReadinessSummary;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULT_POLICY: ProjectPolicy = {
|
|
60
|
+
allowed_commands: [],
|
|
61
|
+
auto_cleanup: {
|
|
62
|
+
enabled: true,
|
|
63
|
+
patterns: ["release-artifact-manifest.json", "frontend/dist", "release_packages"],
|
|
64
|
+
exclude: [".git", ".patchwarden", "node_modules", "docs", "samples"],
|
|
65
|
+
},
|
|
66
|
+
high_risk_commands: ["npm publish", "git push", "git tag", "gh release create"],
|
|
67
|
+
protected_paths: [".env", ".env.*", ".ssh", ".npmrc", ".pypirc", "patchwarden.config.json"],
|
|
68
|
+
release_mode: {
|
|
69
|
+
version_source: "package.json",
|
|
70
|
+
required_commands: ["npm run build", "npm test"],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const DANGEROUS_COMMAND_RE = /\b(?:publish|push|tag|release\s+create|deploy)\b/i;
|
|
75
|
+
const DANGEROUS_PATTERN_RE = /(^|[\\/])(?:\.git|node_modules)([\\/]|$)|^\*\*$|^\/|^[A-Za-z]:[\\/]/i;
|
|
76
|
+
|
|
77
|
+
export function getProjectPolicySummary(repoPathInput: string): ProjectPolicySummary {
|
|
78
|
+
const config = getConfig();
|
|
79
|
+
const repoPath = guardWorkspacePath(repoPathInput, config.workspaceRoot);
|
|
80
|
+
const policyPath = join(repoPath, ".patchwarden", "project-policy.json");
|
|
81
|
+
const issues: ProjectPolicyIssue[] = [];
|
|
82
|
+
let rawPolicy: Partial<ProjectPolicy> = {};
|
|
83
|
+
let exists = false;
|
|
84
|
+
|
|
85
|
+
if (existsSync(policyPath)) {
|
|
86
|
+
exists = true;
|
|
87
|
+
try {
|
|
88
|
+
const raw = readFileSync(policyPath, "utf-8").replace(/^\uFEFF/, "");
|
|
89
|
+
rawPolicy = JSON.parse(raw) as Partial<ProjectPolicy>;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
issues.push({
|
|
92
|
+
code: "policy_json_invalid",
|
|
93
|
+
severity: "error",
|
|
94
|
+
field: ".patchwarden/project-policy.json",
|
|
95
|
+
message: `Project policy is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const effective = normalizePolicy(rawPolicy, issues);
|
|
101
|
+
validateProjectPolicy(repoPath, effective, issues, config);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
repo_path: repoPathInput,
|
|
105
|
+
resolved_repo_path: repoPath,
|
|
106
|
+
policy_path: ".patchwarden/project-policy.json",
|
|
107
|
+
exists,
|
|
108
|
+
valid: !issues.some((issue) => issue.severity === "error"),
|
|
109
|
+
effective_policy: effective,
|
|
110
|
+
issues,
|
|
111
|
+
release_readiness: buildReleaseReadiness(repoPath, effective, config),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function getDefaultProjectPolicy(): ProjectPolicy {
|
|
116
|
+
return clonePolicy(DEFAULT_POLICY);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function commandAllowedByProjectPolicy(command: string, summary: ProjectPolicySummary): { allowed: boolean; reason: string | null } {
|
|
120
|
+
const trimmed = command.trim();
|
|
121
|
+
if (!trimmed) return { allowed: false, reason: "empty_command" };
|
|
122
|
+
if (summary.effective_policy.high_risk_commands.includes(trimmed) || DANGEROUS_COMMAND_RE.test(trimmed)) {
|
|
123
|
+
return { allowed: false, reason: "high_risk_command" };
|
|
124
|
+
}
|
|
125
|
+
if (
|
|
126
|
+
summary.effective_policy.allowed_commands.length > 0 &&
|
|
127
|
+
!summary.effective_policy.allowed_commands.includes(trimmed)
|
|
128
|
+
) {
|
|
129
|
+
return { allowed: false, reason: "not_in_project_policy_allowed_commands" };
|
|
130
|
+
}
|
|
131
|
+
const readiness = summary.release_readiness.required_commands.find((entry) => entry.command === trimmed);
|
|
132
|
+
if (readiness && !readiness.allowed) {
|
|
133
|
+
return { allowed: false, reason: readiness.reason || "not_allowlisted_by_patchwarden_config" };
|
|
134
|
+
}
|
|
135
|
+
return { allowed: true, reason: null };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function isProtectedByProjectPolicy(relPath: string, policy: ProjectPolicy): boolean {
|
|
139
|
+
const normalized = normalizeRelPath(relPath);
|
|
140
|
+
if (!normalized) return true;
|
|
141
|
+
if (isSensitivePath(normalized)) return true;
|
|
142
|
+
return policy.protected_paths.some((pattern) => pathMatchesPattern(normalized, pattern));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function resolveVersionFromPolicy(repoPath: string, policy: ProjectPolicy): string | null {
|
|
146
|
+
const source = policy.release_mode.version_source || "package.json";
|
|
147
|
+
if (source === "package.json") return readPackageJson(repoPath).version;
|
|
148
|
+
if (source === "src/version.ts") {
|
|
149
|
+
const versionFile = safeRepoFile(repoPath, "src/version.ts");
|
|
150
|
+
if (!versionFile) return null;
|
|
151
|
+
const text = readFileSync(versionFile, "utf-8");
|
|
152
|
+
const match = text.match(/PATCHWARDEN_VERSION\s*=\s*["']([^"']+)["']/);
|
|
153
|
+
return match ? match[1] : null;
|
|
154
|
+
}
|
|
155
|
+
const file = safeRepoFile(repoPath, source);
|
|
156
|
+
if (!file) return null;
|
|
157
|
+
const text = readFileSync(file, "utf-8").replace(/^\uFEFF/, "").trim();
|
|
158
|
+
return text.split(/\r?\n/)[0]?.trim() || null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function readPackageJson(repoPath: string): { name: string | null; version: string | null; githubRepo: string | null } {
|
|
162
|
+
const packagePath = join(repoPath, "package.json");
|
|
163
|
+
if (!existsSync(packagePath)) return { name: null, version: null, githubRepo: null };
|
|
164
|
+
try {
|
|
165
|
+
const data = JSON.parse(readFileSync(packagePath, "utf-8").replace(/^\uFEFF/, ""));
|
|
166
|
+
return {
|
|
167
|
+
name: typeof data.name === "string" ? data.name : null,
|
|
168
|
+
version: typeof data.version === "string" ? data.version : null,
|
|
169
|
+
githubRepo: parseGithubRepo(data.repository),
|
|
170
|
+
};
|
|
171
|
+
} catch {
|
|
172
|
+
return { name: null, version: null, githubRepo: null };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizePolicy(raw: Partial<ProjectPolicy>, issues: ProjectPolicyIssue[]): ProjectPolicy {
|
|
177
|
+
const policy = clonePolicy(DEFAULT_POLICY);
|
|
178
|
+
const object = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
179
|
+
policy.allowed_commands = stringArrayOrDefault(object.allowed_commands, policy.allowed_commands, "allowed_commands", issues);
|
|
180
|
+
policy.high_risk_commands = stringArrayOrDefault(object.high_risk_commands, policy.high_risk_commands, "high_risk_commands", issues);
|
|
181
|
+
policy.protected_paths = stringArrayOrDefault(object.protected_paths, policy.protected_paths, "protected_paths", issues);
|
|
182
|
+
|
|
183
|
+
if (object.auto_cleanup && typeof object.auto_cleanup === "object" && !Array.isArray(object.auto_cleanup)) {
|
|
184
|
+
const cleanup = object.auto_cleanup as Partial<ProjectPolicy["auto_cleanup"]>;
|
|
185
|
+
if (cleanup.enabled !== undefined && typeof cleanup.enabled !== "boolean") {
|
|
186
|
+
issues.push({ code: "invalid_type", severity: "error", field: "auto_cleanup.enabled", message: "auto_cleanup.enabled must be a boolean." });
|
|
187
|
+
} else if (cleanup.enabled !== undefined) {
|
|
188
|
+
policy.auto_cleanup.enabled = cleanup.enabled;
|
|
189
|
+
}
|
|
190
|
+
policy.auto_cleanup.patterns = stringArrayOrDefault(cleanup.patterns, policy.auto_cleanup.patterns, "auto_cleanup.patterns", issues);
|
|
191
|
+
policy.auto_cleanup.exclude = stringArrayOrDefault(cleanup.exclude, policy.auto_cleanup.exclude, "auto_cleanup.exclude", issues);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (object.release_mode && typeof object.release_mode === "object" && !Array.isArray(object.release_mode)) {
|
|
195
|
+
const releaseMode = object.release_mode as Partial<ProjectPolicy["release_mode"]>;
|
|
196
|
+
if (releaseMode.version_source !== undefined && typeof releaseMode.version_source !== "string") {
|
|
197
|
+
issues.push({ code: "invalid_type", severity: "error", field: "release_mode.version_source", message: "release_mode.version_source must be a string." });
|
|
198
|
+
} else if (releaseMode.version_source) {
|
|
199
|
+
policy.release_mode.version_source = releaseMode.version_source.trim();
|
|
200
|
+
}
|
|
201
|
+
policy.release_mode.required_commands = stringArrayOrDefault(releaseMode.required_commands, policy.release_mode.required_commands, "release_mode.required_commands", issues);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return policy;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function validateProjectPolicy(repoPath: string, policy: ProjectPolicy, issues: ProjectPolicyIssue[], config: PatchWardenConfig): void {
|
|
208
|
+
for (const [field, commands] of [
|
|
209
|
+
["allowed_commands", policy.allowed_commands],
|
|
210
|
+
["high_risk_commands", policy.high_risk_commands],
|
|
211
|
+
["release_mode.required_commands", policy.release_mode.required_commands],
|
|
212
|
+
] as const) {
|
|
213
|
+
for (const command of commands) {
|
|
214
|
+
if (field !== "high_risk_commands" && DANGEROUS_COMMAND_RE.test(command)) {
|
|
215
|
+
issues.push({ code: "high_risk_command", severity: "error", field, message: `Command is release/destructive risk and cannot be auto-executed: ${command}` });
|
|
216
|
+
}
|
|
217
|
+
if (field !== "high_risk_commands") {
|
|
218
|
+
try {
|
|
219
|
+
guardTestCommand(command, config, repoPath);
|
|
220
|
+
} catch {
|
|
221
|
+
issues.push({ code: "command_not_allowlisted", severity: "warn", field, message: `Command is not allowed by existing PatchWarden config: ${command}` });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const field of ["auto_cleanup.patterns", "auto_cleanup.exclude", "protected_paths"] as const) {
|
|
228
|
+
const values = field === "auto_cleanup.patterns"
|
|
229
|
+
? policy.auto_cleanup.patterns
|
|
230
|
+
: field === "auto_cleanup.exclude"
|
|
231
|
+
? policy.auto_cleanup.exclude
|
|
232
|
+
: policy.protected_paths;
|
|
233
|
+
for (const value of values) {
|
|
234
|
+
if (!value.trim()) {
|
|
235
|
+
issues.push({ code: "empty_path_pattern", severity: "error", field, message: "Path patterns must be non-empty." });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const blocksCriticalDirectory = field === "auto_cleanup.patterns" && DANGEROUS_PATTERN_RE.test(value);
|
|
239
|
+
if (value.includes("\0") || value.includes("..") || isAbsolute(value) || blocksCriticalDirectory) {
|
|
240
|
+
issues.push({ code: "unsafe_path_pattern", severity: "error", field, message: `Unsafe path pattern rejected: ${value}` });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const versionSource = policy.release_mode.version_source;
|
|
246
|
+
if (versionSource && versionSource !== "package.json" && versionSource !== "src/version.ts") {
|
|
247
|
+
const resolved = safeRepoFile(repoPath, versionSource);
|
|
248
|
+
if (!resolved) {
|
|
249
|
+
issues.push({ code: "version_source_invalid", severity: "error", field: "release_mode.version_source", message: `Version source is outside repo, sensitive, or missing: ${versionSource}` });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildReleaseReadiness(repoPath: string, policy: ProjectPolicy, config: PatchWardenConfig): ReleaseReadinessSummary {
|
|
255
|
+
const packageJson = readPackageJson(repoPath);
|
|
256
|
+
let version: string | null = null;
|
|
257
|
+
try {
|
|
258
|
+
version = resolveVersionFromPolicy(repoPath, policy);
|
|
259
|
+
} catch {
|
|
260
|
+
version = null;
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
version_source: policy.release_mode.version_source,
|
|
264
|
+
version,
|
|
265
|
+
package_json_version: packageJson.version,
|
|
266
|
+
package_name: packageJson.name,
|
|
267
|
+
version_consistent: version && packageJson.version ? version === packageJson.version : null,
|
|
268
|
+
required_commands: policy.release_mode.required_commands.map((command) => {
|
|
269
|
+
try {
|
|
270
|
+
guardTestCommand(command, config, repoPath);
|
|
271
|
+
return { command, allowed: true, reason: null };
|
|
272
|
+
} catch (err) {
|
|
273
|
+
return { command, allowed: false, reason: err instanceof Error ? err.message : String(err) };
|
|
274
|
+
}
|
|
275
|
+
}),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function safeRepoFile(repoPath: string, relPath: string): string | null {
|
|
280
|
+
const normalized = normalizeRelPath(relPath);
|
|
281
|
+
if (!normalized || isSensitivePath(normalized)) return null;
|
|
282
|
+
const candidate = resolve(repoPath, normalized);
|
|
283
|
+
const rel = relative(repoPath, candidate);
|
|
284
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) return null;
|
|
285
|
+
if (!existsSync(candidate) || !statSync(candidate).isFile()) return null;
|
|
286
|
+
const parent = dirname(candidate);
|
|
287
|
+
const parentRel = relative(repoPath, parent);
|
|
288
|
+
if (parentRel === ".." || parentRel.startsWith(`..${sep}`)) return null;
|
|
289
|
+
return candidate;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function stringArrayOrDefault(value: unknown, fallback: string[], field: string, issues: ProjectPolicyIssue[]): string[] {
|
|
293
|
+
if (value === undefined) return [...fallback];
|
|
294
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
295
|
+
issues.push({ code: "invalid_type", severity: "error", field, message: `${field} must be an array of strings.` });
|
|
296
|
+
return [...fallback];
|
|
297
|
+
}
|
|
298
|
+
return [...new Set(value.map((entry) => entry.trim()).filter(Boolean))];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function clonePolicy(policy: ProjectPolicy): ProjectPolicy {
|
|
302
|
+
return {
|
|
303
|
+
allowed_commands: [...policy.allowed_commands],
|
|
304
|
+
auto_cleanup: {
|
|
305
|
+
enabled: policy.auto_cleanup.enabled,
|
|
306
|
+
patterns: [...policy.auto_cleanup.patterns],
|
|
307
|
+
exclude: [...policy.auto_cleanup.exclude],
|
|
308
|
+
},
|
|
309
|
+
high_risk_commands: [...policy.high_risk_commands],
|
|
310
|
+
protected_paths: [...policy.protected_paths],
|
|
311
|
+
release_mode: {
|
|
312
|
+
version_source: policy.release_mode.version_source,
|
|
313
|
+
required_commands: [...policy.release_mode.required_commands],
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function normalizeRelPath(value: string): string {
|
|
319
|
+
return value.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function pathMatchesPattern(relPath: string, pattern: string): boolean {
|
|
323
|
+
const normalizedPattern = normalizeRelPath(pattern);
|
|
324
|
+
if (!normalizedPattern) return false;
|
|
325
|
+
if (normalizedPattern.includes("*")) {
|
|
326
|
+
const escaped = normalizedPattern
|
|
327
|
+
.split("*")
|
|
328
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
329
|
+
.join("[^/]*");
|
|
330
|
+
return new RegExp(`^${escaped}$`, "i").test(relPath);
|
|
331
|
+
}
|
|
332
|
+
return relPath.toLowerCase() === normalizedPattern.toLowerCase() ||
|
|
333
|
+
relPath.toLowerCase().startsWith(`${normalizedPattern.toLowerCase()}/`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseGithubRepo(repository: unknown): string | null {
|
|
337
|
+
const raw = typeof repository === "string"
|
|
338
|
+
? repository
|
|
339
|
+
: repository && typeof repository === "object" && typeof (repository as any).url === "string"
|
|
340
|
+
? (repository as any).url
|
|
341
|
+
: "";
|
|
342
|
+
const match = raw.match(/github\.com[:/](.+?\/.+?)(?:\.git)?(?:[#?].*)?$/i);
|
|
343
|
+
return match ? match[1] : null;
|
|
344
|
+
}
|
package/src/smoke-test.ts
CHANGED
|
@@ -592,7 +592,7 @@ test("D8b. tool profiles are exact and schema changes alter the manifest hash",
|
|
|
592
592
|
try {
|
|
593
593
|
process.env.PATCHWARDEN_TOOL_PROFILE = "full";
|
|
594
594
|
const fullTools = getToolDefs();
|
|
595
|
-
if (fullTools.length !==
|
|
595
|
+
if (fullTools.length !== 64) throw new Error(`Expected 64 full tools, got ${fullTools.length}`);
|
|
596
596
|
|
|
597
597
|
const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", getConfig().enableDirectProfile);
|
|
598
598
|
const names = coreTools.map((tool) => tool.name);
|
|
@@ -1934,10 +1934,10 @@ reloadConfig();
|
|
|
1934
1934
|
|
|
1935
1935
|
let directSessionId = "";
|
|
1936
1936
|
|
|
1937
|
-
test("M1. chatgpt_core still has
|
|
1937
|
+
test("M1. chatgpt_core still has the expected tool manifest", () => {
|
|
1938
1938
|
const tools = getToolDefs();
|
|
1939
1939
|
const coreTools = selectToolsForProfile(tools, "chatgpt_core", true);
|
|
1940
|
-
if (coreTools.length !==
|
|
1940
|
+
if (coreTools.length !== CHATGPT_CORE_TOOL_NAMES.length) throw new Error(`Expected ${CHATGPT_CORE_TOOL_NAMES.length}, got ${coreTools.length}`);
|
|
1941
1941
|
if (JSON.stringify(coreTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
|
|
1942
1942
|
throw new Error("Tool names mismatch");
|
|
1943
1943
|
}
|
|
@@ -1950,10 +1950,10 @@ test("M2. chatgpt_direct disabled exposes only health_check", () => {
|
|
|
1950
1950
|
if (disabledTools[0].name !== "health_check") throw new Error(`Expected health_check, got ${disabledTools[0].name}`);
|
|
1951
1951
|
});
|
|
1952
1952
|
|
|
1953
|
-
test("M3. chatgpt_direct enabled has
|
|
1953
|
+
test("M3. chatgpt_direct enabled has 14 tools", () => {
|
|
1954
1954
|
const tools = getToolDefs();
|
|
1955
1955
|
const directTools = selectToolsForProfile(tools, "chatgpt_direct", true);
|
|
1956
|
-
if (directTools.length !==
|
|
1956
|
+
if (directTools.length !== CHATGPT_DIRECT_TOOL_NAMES.length) throw new Error(`Expected ${CHATGPT_DIRECT_TOOL_NAMES.length}, got ${directTools.length}`);
|
|
1957
1957
|
if (JSON.stringify(directTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES)) {
|
|
1958
1958
|
throw new Error("Tool names mismatch");
|
|
1959
1959
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { reloadConfig } from "../../config.js";
|
|
7
|
+
import { recommendAgentForTask } from "../../tools/recommendAgentForTask.js";
|
|
8
|
+
import { exportTaskEvidencePack, listEvidencePacks, readEvidencePack } from "../../tools/evidencePack.js";
|
|
9
|
+
import { writeTaskLineage } from "../../tools/taskLineage.js";
|
|
10
|
+
|
|
11
|
+
let tempDir: string;
|
|
12
|
+
let prevConfigEnv: string | undefined;
|
|
13
|
+
|
|
14
|
+
function writeConfig() {
|
|
15
|
+
const configPath = join(tempDir, "patchwarden.config.json");
|
|
16
|
+
writeFileSync(
|
|
17
|
+
configPath,
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
workspaceRoot: tempDir,
|
|
20
|
+
tasksDir: ".patchwarden/tasks",
|
|
21
|
+
plansDir: ".patchwarden/plans",
|
|
22
|
+
assessmentsDir: ".patchwarden/assessments",
|
|
23
|
+
agents: {
|
|
24
|
+
codex: { command: "codex", args: [] },
|
|
25
|
+
opencode: { command: "opencode", args: [] },
|
|
26
|
+
},
|
|
27
|
+
allowedTestCommands: ["npm test", "npm run build"],
|
|
28
|
+
defaultTaskTimeoutSeconds: 30,
|
|
29
|
+
maxTaskTimeoutSeconds: 120,
|
|
30
|
+
}),
|
|
31
|
+
"utf-8"
|
|
32
|
+
);
|
|
33
|
+
prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
|
|
34
|
+
process.env.PATCHWARDEN_CONFIG = configPath;
|
|
35
|
+
reloadConfig();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("v1.5 evidence packs and agent recommendations", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-evidence-"));
|
|
41
|
+
writeConfig();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
if (prevConfigEnv === undefined) delete process.env.PATCHWARDEN_CONFIG;
|
|
46
|
+
else process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
|
|
47
|
+
reloadConfig();
|
|
48
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("recommends an agent without creating task evidence", () => {
|
|
52
|
+
const recommendation = recommendAgentForTask({
|
|
53
|
+
repo_path: ".",
|
|
54
|
+
goal: "Refactor two modules safely",
|
|
55
|
+
scope_files: ["src/a.ts", "src/b.ts"],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.equal(recommendation.bounded, true);
|
|
59
|
+
assert.ok(["codex", "opencode"].includes(recommendation.recommended_agent));
|
|
60
|
+
assert.ok(Array.isArray(recommendation.suggested_verify_commands));
|
|
61
|
+
const payload = JSON.stringify(recommendation);
|
|
62
|
+
assert.ok(!payload.includes("stdout"));
|
|
63
|
+
assert.ok(!payload.includes("stderr"));
|
|
64
|
+
assert.ok(!payload.includes("diff"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("exports bounded BOM-free evidence pack from lineage", () => {
|
|
68
|
+
writeTaskLineage({
|
|
69
|
+
lineage_id: "lineage_v15_pack",
|
|
70
|
+
goal: "Export evidence",
|
|
71
|
+
repo_path: ".",
|
|
72
|
+
created_at: "2026-07-05T12:00:00.000Z",
|
|
73
|
+
updated_at: "2026-07-05T12:01:00.000Z",
|
|
74
|
+
final_status: "accepted",
|
|
75
|
+
stop_reason: "success",
|
|
76
|
+
next_action: "accept",
|
|
77
|
+
main_task: "task-main",
|
|
78
|
+
fix_tasks: [],
|
|
79
|
+
cleanup_tasks: [],
|
|
80
|
+
direct_sessions: [{
|
|
81
|
+
session_id: "direct-one",
|
|
82
|
+
status: "passed",
|
|
83
|
+
command_count: 1,
|
|
84
|
+
passed_commands: 1,
|
|
85
|
+
failed_commands: 0,
|
|
86
|
+
timed_out_commands: 0,
|
|
87
|
+
audit_decision: "pass",
|
|
88
|
+
changed_files_total: 0,
|
|
89
|
+
next_action: "accept",
|
|
90
|
+
}],
|
|
91
|
+
rounds: [{
|
|
92
|
+
iteration: 1,
|
|
93
|
+
task_id: "task-main",
|
|
94
|
+
role: "main",
|
|
95
|
+
status: "done_by_agent",
|
|
96
|
+
terminal: true,
|
|
97
|
+
verification_status: "passed",
|
|
98
|
+
audit_verdict: "pass",
|
|
99
|
+
fail_checks: [],
|
|
100
|
+
warn_checks: [],
|
|
101
|
+
next_action: "accept",
|
|
102
|
+
}],
|
|
103
|
+
warnings: [],
|
|
104
|
+
errors: [],
|
|
105
|
+
worktree: {
|
|
106
|
+
isolation_mode: "worktree",
|
|
107
|
+
worktree_id: "wt-one",
|
|
108
|
+
worktree_path: join(tempDir, "_workspacetrees", "wt-one"),
|
|
109
|
+
branch: "pw-test",
|
|
110
|
+
cleanup: "keep",
|
|
111
|
+
status: "active",
|
|
112
|
+
next_action: "merge or discard explicitly",
|
|
113
|
+
},
|
|
114
|
+
agent_routing: {
|
|
115
|
+
requested_agent: "auto",
|
|
116
|
+
selected_agent: "codex",
|
|
117
|
+
reason: "test route",
|
|
118
|
+
fallback: false,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const pack = exportTaskEvidencePack({ lineage_id: "lineage_v15_pack" });
|
|
123
|
+
assert.equal(pack.bounded, true);
|
|
124
|
+
assert.equal(pack.lineage.worktree.worktree_id, "wt-one");
|
|
125
|
+
assert.equal(pack.lineage.agent_routing?.selected_agent, "codex");
|
|
126
|
+
const raw = readFileSync(pack.files.json);
|
|
127
|
+
assert.notEqual(raw[0], 0xef);
|
|
128
|
+
JSON.parse(raw.toString("utf-8"));
|
|
129
|
+
|
|
130
|
+
const payload = JSON.stringify(pack);
|
|
131
|
+
assert.ok(!payload.includes("stdout_tail"));
|
|
132
|
+
assert.ok(!payload.includes("stderr_tail"));
|
|
133
|
+
assert.ok(!payload.includes("diff.patch"));
|
|
134
|
+
assert.ok(!payload.includes("verification.log"));
|
|
135
|
+
|
|
136
|
+
const readBack = readEvidencePack("lineage_v15_pack");
|
|
137
|
+
assert.equal(readBack?.lineage_id, "lineage_v15_pack");
|
|
138
|
+
const listed = listEvidencePacks();
|
|
139
|
+
assert.equal(listed.total, 1);
|
|
140
|
+
assert.equal(listed.evidence_packs[0].lineage_id, "lineage_v15_pack");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { reloadConfig } from "../../config.js";
|
|
7
|
+
import { getProjectPolicySummary } from "../../policy/projectPolicy.js";
|
|
8
|
+
import { releaseCleanup, releasePrepare, releaseVerify } from "../../tools/releaseMode.js";
|
|
9
|
+
|
|
10
|
+
let tempDir: string;
|
|
11
|
+
let repoPath: string;
|
|
12
|
+
let prevConfigEnv: string | undefined;
|
|
13
|
+
|
|
14
|
+
function writeConfig(allowedTestCommands = ["npm test", "npm run build"]): void {
|
|
15
|
+
const configPath = join(tempDir, "patchwarden.config.json");
|
|
16
|
+
writeFileSync(
|
|
17
|
+
configPath,
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
workspaceRoot: tempDir,
|
|
20
|
+
plansDir: ".patchwarden/plans",
|
|
21
|
+
tasksDir: ".patchwarden/tasks",
|
|
22
|
+
assessmentsDir: ".patchwarden/assessments",
|
|
23
|
+
allowedTestCommands,
|
|
24
|
+
agents: {},
|
|
25
|
+
defaultTaskTimeoutSeconds: 30,
|
|
26
|
+
maxTaskTimeoutSeconds: 120,
|
|
27
|
+
}),
|
|
28
|
+
"utf-8",
|
|
29
|
+
);
|
|
30
|
+
prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
|
|
31
|
+
process.env.PATCHWARDEN_CONFIG = configPath;
|
|
32
|
+
reloadConfig(configPath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writePackage(version = "1.3.0"): void {
|
|
36
|
+
writeFileSync(
|
|
37
|
+
join(repoPath, "package.json"),
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
name: "patchwarden-test",
|
|
40
|
+
version,
|
|
41
|
+
repository: { type: "git", url: "git+https://github.com/example/patchwarden-test.git" },
|
|
42
|
+
}),
|
|
43
|
+
"utf-8",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("project policy and release mode", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-policy-"));
|
|
50
|
+
repoPath = join(tempDir, "repo");
|
|
51
|
+
mkdirSync(repoPath, { recursive: true });
|
|
52
|
+
writePackage();
|
|
53
|
+
writeConfig();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
if (prevConfigEnv === undefined) {
|
|
58
|
+
delete process.env.PATCHWARDEN_CONFIG;
|
|
59
|
+
} else {
|
|
60
|
+
process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
|
|
61
|
+
}
|
|
62
|
+
reloadConfig();
|
|
63
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns safe defaults when project-policy.json is missing", () => {
|
|
67
|
+
const summary = getProjectPolicySummary(repoPath);
|
|
68
|
+
assert.equal(summary.exists, false);
|
|
69
|
+
assert.equal(summary.valid, true);
|
|
70
|
+
assert.equal(summary.effective_policy.release_mode.version_source, "package.json");
|
|
71
|
+
assert.equal(summary.release_readiness.version, "1.3.0");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("parses BOM-prefixed policy JSON and reports allowlist issues without granting permission", () => {
|
|
75
|
+
mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
|
|
76
|
+
writeFileSync(
|
|
77
|
+
join(repoPath, ".patchwarden", "project-policy.json"),
|
|
78
|
+
"\uFEFF" + JSON.stringify({
|
|
79
|
+
allowed_commands: ["npm run build", "npm publish"],
|
|
80
|
+
release_mode: {
|
|
81
|
+
version_source: "package.json",
|
|
82
|
+
required_commands: ["npm run build", "not-allowed"],
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
"utf-8",
|
|
86
|
+
);
|
|
87
|
+
const summary = getProjectPolicySummary(repoPath);
|
|
88
|
+
assert.equal(summary.exists, true);
|
|
89
|
+
assert.equal(summary.valid, false);
|
|
90
|
+
assert.ok(summary.issues.some((issue) => issue.code === "high_risk_command"));
|
|
91
|
+
assert.ok(summary.issues.some((issue) => issue.code === "command_not_allowlisted"));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("rejects unsafe path patterns and sensitive protected paths block cleanup", () => {
|
|
95
|
+
mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
|
|
96
|
+
writeFileSync(
|
|
97
|
+
join(repoPath, ".patchwarden", "project-policy.json"),
|
|
98
|
+
JSON.stringify({
|
|
99
|
+
auto_cleanup: { enabled: true, patterns: ["../escape", ".git", "release_packages"], exclude: [] },
|
|
100
|
+
protected_paths: [".env", "release_packages"],
|
|
101
|
+
}),
|
|
102
|
+
"utf-8",
|
|
103
|
+
);
|
|
104
|
+
mkdirSync(join(repoPath, "release_packages"), { recursive: true });
|
|
105
|
+
const summary = getProjectPolicySummary(repoPath);
|
|
106
|
+
assert.equal(summary.valid, false);
|
|
107
|
+
assert.ok(summary.issues.some((issue) => issue.code === "unsafe_path_pattern"));
|
|
108
|
+
const cleanup = releaseCleanup({ repo_path: repoPath, dry_run: true });
|
|
109
|
+
const skipped = cleanup.summary.skipped as Array<{ path: string; reason: string }>;
|
|
110
|
+
assert.ok(skipped.some((entry) => entry.path === "release_packages" && entry.reason === "protected_or_sensitive"));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("release_cleanup defaults to dry run and writes BOM-free JSON summary", () => {
|
|
114
|
+
mkdirSync(join(repoPath, "release_packages"), { recursive: true });
|
|
115
|
+
const result = releaseCleanup({ repo_path: repoPath });
|
|
116
|
+
assert.equal(result.mode, "release_cleanup");
|
|
117
|
+
assert.equal((result.summary as any).dry_run, true);
|
|
118
|
+
assert.equal(existsSync(join(repoPath, "release_packages")), true);
|
|
119
|
+
const reportPath = join(repoPath, String((result.summary as any).report_path));
|
|
120
|
+
const raw = readFileSync(reportPath, "utf-8");
|
|
121
|
+
assert.notEqual(raw.charCodeAt(0), 0xfeff);
|
|
122
|
+
assert.doesNotThrow(() => JSON.parse(raw));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("release_cleanup honors auto_cleanup.enabled=false before deleting", () => {
|
|
126
|
+
mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
|
|
127
|
+
writeFileSync(
|
|
128
|
+
join(repoPath, ".patchwarden", "project-policy.json"),
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
auto_cleanup: { enabled: false, patterns: ["release_packages"], exclude: [] },
|
|
131
|
+
}),
|
|
132
|
+
"utf-8",
|
|
133
|
+
);
|
|
134
|
+
mkdirSync(join(repoPath, "release_packages"), { recursive: true });
|
|
135
|
+
const result = releaseCleanup({ repo_path: repoPath, dry_run: false });
|
|
136
|
+
assert.equal((result.summary as any).cleanup_disabled_by_policy, true);
|
|
137
|
+
assert.deepEqual((result.summary as any).removed, []);
|
|
138
|
+
assert.equal(existsSync(join(repoPath, "release_packages")), true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("release_verify fails when required metadata cannot be inferred", async () => {
|
|
142
|
+
writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "", version: "" }), "utf-8");
|
|
143
|
+
const result = await releaseVerify({ repo_path: repoPath });
|
|
144
|
+
assert.equal(result.ok, false);
|
|
145
|
+
const stages = result.summary.stages as Record<string, string>;
|
|
146
|
+
assert.equal(stages.published_verified, "failed");
|
|
147
|
+
assert.equal(stages.github_release_verified, "failed");
|
|
148
|
+
assert.equal(stages.ci_verified, "failed");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("release_prepare blocks commands not accepted by the existing command guard", () => {
|
|
152
|
+
const result = releasePrepare({ repo_path: repoPath, required_commands: ["npm run build", "npm run secret"] });
|
|
153
|
+
const commands = result.summary.commands as Array<{ command: string; status: string }>;
|
|
154
|
+
assert.equal(commands.find((entry) => entry.command === "npm run secret")?.status, "blocked");
|
|
155
|
+
});
|
|
156
|
+
});
|