patchwarden 0.4.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.
Files changed (153) hide show
  1. package/Check-PatchWarden-Health.cmd +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +526 -0
  4. package/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  5. package/Restart-PatchWarden.cmd +19 -0
  6. package/Start-PatchWarden-Tunnel.cmd +7 -0
  7. package/dist/config.d.ts +24 -0
  8. package/dist/config.js +131 -0
  9. package/dist/doctor.d.ts +10 -0
  10. package/dist/doctor.js +388 -0
  11. package/dist/errors.d.ts +16 -0
  12. package/dist/errors.js +27 -0
  13. package/dist/httpServer.d.ts +17 -0
  14. package/dist/httpServer.js +255 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +24 -0
  17. package/dist/runner/changeCapture.d.ts +43 -0
  18. package/dist/runner/changeCapture.js +245 -0
  19. package/dist/runner/cli.d.ts +9 -0
  20. package/dist/runner/cli.js +26 -0
  21. package/dist/runner/runTask.d.ts +8 -0
  22. package/dist/runner/runTask.js +821 -0
  23. package/dist/runner/watch.d.ts +19 -0
  24. package/dist/runner/watch.js +136 -0
  25. package/dist/security/commandGuard.d.ts +22 -0
  26. package/dist/security/commandGuard.js +56 -0
  27. package/dist/security/contentRedaction.d.ts +12 -0
  28. package/dist/security/contentRedaction.js +65 -0
  29. package/dist/security/pathGuard.d.ts +3 -0
  30. package/dist/security/pathGuard.js +98 -0
  31. package/dist/security/planGuard.d.ts +1 -0
  32. package/dist/security/planGuard.js +65 -0
  33. package/dist/security/sensitiveGuard.d.ts +2 -0
  34. package/dist/security/sensitiveGuard.js +60 -0
  35. package/dist/smoke-test.d.ts +15 -0
  36. package/dist/smoke-test.js +989 -0
  37. package/dist/taskProgress.d.ts +6 -0
  38. package/dist/taskProgress.js +36 -0
  39. package/dist/taskRuntime.d.ts +10 -0
  40. package/dist/taskRuntime.js +25 -0
  41. package/dist/tools/auditTask.d.ts +19 -0
  42. package/dist/tools/auditTask.js +307 -0
  43. package/dist/tools/cancelTask.d.ts +30 -0
  44. package/dist/tools/cancelTask.js +64 -0
  45. package/dist/tools/createTask.d.ts +42 -0
  46. package/dist/tools/createTask.js +188 -0
  47. package/dist/tools/getPlan.d.ts +12 -0
  48. package/dist/tools/getPlan.js +29 -0
  49. package/dist/tools/getTaskFile.d.ts +24 -0
  50. package/dist/tools/getTaskFile.js +84 -0
  51. package/dist/tools/getTaskProgress.d.ts +1 -0
  52. package/dist/tools/getTaskProgress.js +4 -0
  53. package/dist/tools/getTaskStatus.d.ts +46 -0
  54. package/dist/tools/getTaskStatus.js +36 -0
  55. package/dist/tools/getTaskStdoutTail.d.ts +9 -0
  56. package/dist/tools/getTaskStdoutTail.js +56 -0
  57. package/dist/tools/getTaskSummary.d.ts +66 -0
  58. package/dist/tools/getTaskSummary.js +237 -0
  59. package/dist/tools/healthCheck.d.ts +79 -0
  60. package/dist/tools/healthCheck.js +230 -0
  61. package/dist/tools/killTask.d.ts +15 -0
  62. package/dist/tools/killTask.js +4 -0
  63. package/dist/tools/listAgents.d.ts +12 -0
  64. package/dist/tools/listAgents.js +42 -0
  65. package/dist/tools/listTasks.d.ts +36 -0
  66. package/dist/tools/listTasks.js +94 -0
  67. package/dist/tools/listWorkspace.d.ts +10 -0
  68. package/dist/tools/listWorkspace.js +54 -0
  69. package/dist/tools/readWorkspaceFile.d.ts +8 -0
  70. package/dist/tools/readWorkspaceFile.js +26 -0
  71. package/dist/tools/registry.d.ts +24 -0
  72. package/dist/tools/registry.js +566 -0
  73. package/dist/tools/retryTask.d.ts +8 -0
  74. package/dist/tools/retryTask.js +45 -0
  75. package/dist/tools/savePlan.d.ts +10 -0
  76. package/dist/tools/savePlan.js +28 -0
  77. package/dist/tools/taskOutputs.d.ts +49 -0
  78. package/dist/tools/taskOutputs.js +136 -0
  79. package/dist/tools/taskTemplates.d.ts +15 -0
  80. package/dist/tools/taskTemplates.js +116 -0
  81. package/dist/tools/toolCatalog.d.ts +19 -0
  82. package/dist/tools/toolCatalog.js +71 -0
  83. package/dist/tools/waitForTask.d.ts +25 -0
  84. package/dist/tools/waitForTask.js +59 -0
  85. package/dist/version.d.ts +2 -0
  86. package/dist/version.js +2 -0
  87. package/dist/watcherStatus.d.ts +20 -0
  88. package/dist/watcherStatus.js +77 -0
  89. package/docs/assets/patchwarden-chatgpt-demo.svg +30 -0
  90. package/docs/demo.md +58 -0
  91. package/docs/migration-from-safe-bifrost.md +31 -0
  92. package/docs/release-v0.3.0.md +43 -0
  93. package/docs/release-v0.4.0.md +74 -0
  94. package/examples/config.example.json +40 -0
  95. package/examples/openai-tunnel/README.md +48 -0
  96. package/examples/openai-tunnel/chatgpt-test-prompt.md +53 -0
  97. package/examples/openai-tunnel/tunnel-client.example.yaml +39 -0
  98. package/package.json +73 -0
  99. package/scripts/brand-check.js +44 -0
  100. package/scripts/doctor-smoke.js +52 -0
  101. package/scripts/get-patchwarden-health.ps1 +114 -0
  102. package/scripts/http-mcp-smoke.js +351 -0
  103. package/scripts/lifecycle-smoke.js +453 -0
  104. package/scripts/mcp-manifest-check.js +120 -0
  105. package/scripts/mcp-smoke.js +336 -0
  106. package/scripts/pack-clean.js +142 -0
  107. package/scripts/patchwarden-mcp-stdio.cmd +7 -0
  108. package/scripts/restart-patchwarden.ps1 +242 -0
  109. package/scripts/start-patchwarden-tunnel.ps1 +503 -0
  110. package/scripts/tunnel-supervisor-smoke.js +77 -0
  111. package/scripts/watcher-supervisor-smoke.js +195 -0
  112. package/src/config.ts +165 -0
  113. package/src/doctor.ts +438 -0
  114. package/src/errors.ts +26 -0
  115. package/src/httpServer.ts +277 -0
  116. package/src/index.ts +34 -0
  117. package/src/runner/changeCapture.ts +304 -0
  118. package/src/runner/cli.ts +34 -0
  119. package/src/runner/runTask.ts +935 -0
  120. package/src/runner/watch.ts +152 -0
  121. package/src/security/commandGuard.ts +97 -0
  122. package/src/security/contentRedaction.ts +85 -0
  123. package/src/security/pathGuard.ts +175 -0
  124. package/src/security/planGuard.ts +93 -0
  125. package/src/security/sensitiveGuard.ts +72 -0
  126. package/src/smoke-test.ts +1078 -0
  127. package/src/taskProgress.ts +41 -0
  128. package/src/taskRuntime.ts +37 -0
  129. package/src/tools/auditTask.ts +340 -0
  130. package/src/tools/cancelTask.ts +70 -0
  131. package/src/tools/createTask.ts +337 -0
  132. package/src/tools/getPlan.ts +48 -0
  133. package/src/tools/getTaskFile.ts +111 -0
  134. package/src/tools/getTaskProgress.ts +5 -0
  135. package/src/tools/getTaskStatus.ts +85 -0
  136. package/src/tools/getTaskStdoutTail.ts +61 -0
  137. package/src/tools/getTaskSummary.ts +307 -0
  138. package/src/tools/healthCheck.ts +234 -0
  139. package/src/tools/killTask.ts +5 -0
  140. package/src/tools/listAgents.ts +54 -0
  141. package/src/tools/listTasks.ts +139 -0
  142. package/src/tools/listWorkspace.ts +71 -0
  143. package/src/tools/readWorkspaceFile.ts +41 -0
  144. package/src/tools/registry.ts +666 -0
  145. package/src/tools/retryTask.ts +53 -0
  146. package/src/tools/savePlan.ts +46 -0
  147. package/src/tools/taskOutputs.ts +155 -0
  148. package/src/tools/taskTemplates.ts +162 -0
  149. package/src/tools/toolCatalog.ts +92 -0
  150. package/src/tools/waitForTask.ts +90 -0
  151. package/src/version.ts +2 -0
  152. package/src/watcherStatus.ts +102 -0
  153. package/tsconfig.json +17 -0
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PatchWarden Watcher
4
+ *
5
+ * Polls .patchwarden/tasks/ for pending tasks and executes them automatically.
6
+ * This is the recommended way to run tasks — ChatGPT creates tasks,
7
+ * the watcher picks them up and runs them locally.
8
+ *
9
+ * Safety invariants (enforced every tick):
10
+ * - repo_path must be inside workspace
11
+ * - agent must be in allowlist
12
+ * - test_command must be in allowlist (or empty)
13
+ * - Each task runs at most once (no retry loop)
14
+ * - No auto commit, no auto push, no file deletion
15
+ *
16
+ * Run: node dist/runner/watch.js
17
+ * or: npm run watch
18
+ */
19
+ export {};
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PatchWarden Watcher
4
+ *
5
+ * Polls .patchwarden/tasks/ for pending tasks and executes them automatically.
6
+ * This is the recommended way to run tasks — ChatGPT creates tasks,
7
+ * the watcher picks them up and runs them locally.
8
+ *
9
+ * Safety invariants (enforced every tick):
10
+ * - repo_path must be inside workspace
11
+ * - agent must be in allowlist
12
+ * - test_command must be in allowlist (or empty)
13
+ * - Each task runs at most once (no retry loop)
14
+ * - No auto commit, no auto push, no file deletion
15
+ *
16
+ * Run: node dist/runner/watch.js
17
+ * or: npm run watch
18
+ */
19
+ import { readdirSync, readFileSync, existsSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
20
+ import { resolve, join, dirname } from "node:path";
21
+ import { loadConfig, getConfig, getTasksDir, resolveWorkspaceRoot } from "../config.js";
22
+ import { guardWorkspacePath } from "../security/pathGuard.js";
23
+ import { guardAgentCommand, guardTestCommand } from "../security/commandGuard.js";
24
+ import { runTask } from "./runTask.js";
25
+ // ── Bootstrap ─────────────────────────────────────────────────────
26
+ loadConfig();
27
+ const config = getConfig();
28
+ const tasksDir = getTasksDir(config);
29
+ const wsRoot = resolveWorkspaceRoot(config);
30
+ const POLL_INTERVAL_MS = 4000;
31
+ const WATCHER_HEARTBEAT_FILE = join(dirname(tasksDir), "watcher-heartbeat.json");
32
+ const WATCHER_STARTED_AT = new Date().toISOString();
33
+ const WATCHER_INSTANCE_ID = process.env.PATCHWARDEN_WATCHER_INSTANCE_ID || `standalone-${process.pid}-${Date.now()}`;
34
+ const WATCHER_LAUNCHER_PID = Number.isInteger(Number(process.env.PATCHWARDEN_WATCHER_LAUNCHER_PID))
35
+ ? Number(process.env.PATCHWARDEN_WATCHER_LAUNCHER_PID)
36
+ : null;
37
+ mkdirSync(dirname(WATCHER_HEARTBEAT_FILE), { recursive: true });
38
+ console.error(`[watcher] Workspace: ${wsRoot}`);
39
+ console.error(`[watcher] Tasks: ${tasksDir}`);
40
+ console.error(`[watcher] Polling every ${POLL_INTERVAL_MS / 1000}s`);
41
+ console.error(`[watcher] Press Ctrl+C to stop`);
42
+ // Track executed tasks to prevent re-execution
43
+ const executedTasks = new Set();
44
+ // ── Main loop ─────────────────────────────────────────────────────
45
+ async function tick() {
46
+ try {
47
+ const heartbeatTemporary = `${WATCHER_HEARTBEAT_FILE}.${WATCHER_INSTANCE_ID}.tmp`;
48
+ writeFileSync(heartbeatTemporary, JSON.stringify({
49
+ status: "running",
50
+ pid: process.pid,
51
+ started_at: WATCHER_STARTED_AT,
52
+ last_heartbeat_at: new Date().toISOString(),
53
+ instance_id: WATCHER_INSTANCE_ID,
54
+ launcher_pid: WATCHER_LAUNCHER_PID,
55
+ }, null, 2), "utf-8");
56
+ renameSync(heartbeatTemporary, WATCHER_HEARTBEAT_FILE);
57
+ // Ensure tasks directory exists
58
+ if (!existsSync(tasksDir))
59
+ return;
60
+ const entries = readdirSync(tasksDir, { withFileTypes: true });
61
+ const taskDirs = entries.filter((e) => e.isDirectory());
62
+ for (const entry of taskDirs) {
63
+ const taskId = entry.name;
64
+ // Skip already-executed tasks
65
+ if (executedTasks.has(taskId))
66
+ continue;
67
+ const taskDir = resolve(tasksDir, taskId);
68
+ const statusFile = join(taskDir, "status.json");
69
+ if (!existsSync(statusFile))
70
+ continue;
71
+ let statusData;
72
+ try {
73
+ statusData = JSON.parse(readFileSync(statusFile, "utf-8"));
74
+ }
75
+ catch {
76
+ continue; // corrupted status, skip
77
+ }
78
+ if (statusData.status !== "pending")
79
+ continue;
80
+ // ── Pre-flight safety checks ──
81
+ try {
82
+ // Check repo_path
83
+ guardWorkspacePath(statusData.repo_path || wsRoot, wsRoot);
84
+ // Check agent
85
+ guardAgentCommand(statusData.agent, config);
86
+ // Check test_command
87
+ if (statusData.test_command) {
88
+ guardTestCommand(statusData.test_command, config);
89
+ }
90
+ }
91
+ catch (err) {
92
+ const errMsg = `[watcher] Safety check failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`;
93
+ console.error(errMsg);
94
+ // Write error and mark as failed so it doesn't get re-picked
95
+ try {
96
+ writeFileSync(join(taskDir, "error.log"), errMsg, "utf-8");
97
+ const data = JSON.parse(readFileSync(statusFile, "utf-8"));
98
+ data.status = "failed";
99
+ data.error = errMsg;
100
+ data.updated_at = new Date().toISOString();
101
+ writeFileSync(statusFile, JSON.stringify(data, null, 2), "utf-8");
102
+ }
103
+ catch { }
104
+ executedTasks.add(taskId);
105
+ continue;
106
+ }
107
+ // ── Execute ──
108
+ console.error(`[watcher] Executing: ${taskId}`);
109
+ executedTasks.add(taskId);
110
+ try {
111
+ const result = await runTask(taskId);
112
+ console.error(`[watcher] ${taskId} → ${result.status}`);
113
+ }
114
+ catch (err) {
115
+ console.error(`[watcher] ${taskId} → error: ${err instanceof Error ? err.message : String(err)}`);
116
+ }
117
+ }
118
+ }
119
+ catch (err) {
120
+ console.error(`[watcher] Tick error: ${err instanceof Error ? err.message : String(err)}`);
121
+ }
122
+ }
123
+ // ── Start ─────────────────────────────────────────────────────────
124
+ console.error("[watcher] Started");
125
+ setInterval(tick, POLL_INTERVAL_MS);
126
+ // Run first tick immediately
127
+ tick();
128
+ // Graceful shutdown
129
+ process.on("SIGINT", () => {
130
+ console.error("[watcher] Stopped");
131
+ process.exit(0);
132
+ });
133
+ process.on("SIGTERM", () => {
134
+ console.error("[watcher] Stopped");
135
+ process.exit(0);
136
+ });
@@ -0,0 +1,22 @@
1
+ import { PatchWardenConfig } from "../config.js";
2
+ /**
3
+ * Command guard: ensure only allow-listed commands can execute.
4
+ *
5
+ * Rules:
6
+ * - Agent commands must be registered in config.agents
7
+ * - Test commands must be in config.allowedTestCommands (exact match)
8
+ * - No arbitrary shell commands are allowed
9
+ * - Placeholders {repo} and {prompt} are replaced safely
10
+ */
11
+ export interface AllowedCommand {
12
+ command: string;
13
+ args: string[];
14
+ }
15
+ export declare function guardAgentCommand(agent: string, config: PatchWardenConfig): AllowedCommand;
16
+ export declare function guardTestCommand(testCommand: string, config: PatchWardenConfig): string;
17
+ /**
18
+ * Escape a user-provided string for safe use in shell arguments.
19
+ * We prevent injection by refusing to pass arbitrary strings to shell.
20
+ * Instead, the prompt is passed as a command argument via spawn, not shell.
21
+ */
22
+ export declare function sanitizePromptArg(prompt: string): string;
@@ -0,0 +1,56 @@
1
+ import { PatchWardenError } from "../errors.js";
2
+ export function guardAgentCommand(agent, config) {
3
+ const agentCfg = config.agents[agent];
4
+ if (!agentCfg) {
5
+ throw new PatchWardenError("agent_not_configured", `Agent "${agent}" is not configured. Allowed agents: ${Object.keys(config.agents).join(", ")}`, "Call list_agents and use one of the configured agent names.");
6
+ }
7
+ // Validate args don't contain shell metacharacters
8
+ const resolvedArgs = agentCfg.args.map((arg) => {
9
+ // {repo} and {prompt} are safe placeholders
10
+ if (arg === "{repo}" || arg === "{prompt}")
11
+ return arg;
12
+ // Other literal args ok
13
+ return arg;
14
+ });
15
+ // Validate command or configured executable path.
16
+ // Absolute paths are allowed only because they come from the local config,
17
+ // never from the MCP caller. We still reject traversal and shell syntax.
18
+ if (!isSafeConfiguredCommand(agentCfg.command)) {
19
+ throw new PatchWardenError("agent_command_invalid", `Invalid agent command name: "${agentCfg.command}"`, "Fix the locally configured executable path; MCP callers cannot override agent commands.");
20
+ }
21
+ return { command: agentCfg.command, args: resolvedArgs };
22
+ }
23
+ function isSafeConfiguredCommand(command) {
24
+ if (!command || typeof command !== "string")
25
+ return false;
26
+ if (/[\x00-\x1F"'`|&;<>()$]/.test(command))
27
+ return false;
28
+ const normalized = command.replace(/\\/g, "/");
29
+ const parts = normalized.split("/");
30
+ if (parts.some((part) => part === ".."))
31
+ return false;
32
+ const basename = parts[parts.length - 1] || "";
33
+ return /^[a-zA-Z0-9._-]+$/.test(basename);
34
+ }
35
+ export function guardTestCommand(testCommand, config) {
36
+ if (!testCommand || typeof testCommand !== "string") {
37
+ // If no test command specified, that's ok — skip tests
38
+ return "";
39
+ }
40
+ const trimmed = testCommand.trim();
41
+ if (trimmed === "")
42
+ return "";
43
+ if (!config.allowedTestCommands.includes(trimmed)) {
44
+ throw new PatchWardenError("test_command_not_allowlisted", `Test command "${trimmed}" is not in the allowed list. Allowed: ${config.allowedTestCommands.join(", ")}`, "Use an exact allowed command shown by create_task, or omit test_command.");
45
+ }
46
+ return trimmed;
47
+ }
48
+ /**
49
+ * Escape a user-provided string for safe use in shell arguments.
50
+ * We prevent injection by refusing to pass arbitrary strings to shell.
51
+ * Instead, the prompt is passed as a command argument via spawn, not shell.
52
+ */
53
+ export function sanitizePromptArg(prompt) {
54
+ // Remove null bytes and control characters
55
+ return prompt.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
56
+ }
@@ -0,0 +1,12 @@
1
+ export interface RedactionResult {
2
+ content: string;
3
+ redacted: boolean;
4
+ redaction_categories: string[];
5
+ }
6
+ export interface StructuredRedactionResult<T> {
7
+ value: T;
8
+ redacted: boolean;
9
+ redaction_categories: string[];
10
+ }
11
+ export declare function redactSensitiveContent(input: string): RedactionResult;
12
+ export declare function redactSensitiveValue<T>(input: T): StructuredRedactionResult<T>;
@@ -0,0 +1,65 @@
1
+ const RULES = [
2
+ {
3
+ category: "private_key",
4
+ pattern: /-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/gi,
5
+ replace: "[REDACTED PRIVATE KEY]",
6
+ },
7
+ {
8
+ category: "bearer_token",
9
+ pattern: /\bBearer\s+[A-Za-z0-9._~+\/-]{12,}={0,2}/gi,
10
+ replace: "Bearer [REDACTED]",
11
+ },
12
+ {
13
+ category: "npm_token",
14
+ pattern: /(\b_?authToken\s*=\s*)[^\s\r\n]+/gi,
15
+ replace: "$1[REDACTED]",
16
+ },
17
+ {
18
+ category: "credential_assignment",
19
+ pattern: /\b((?:access[_ -]?token|api[_ -]?key|secret|password|credential|token)\s*[:=]\s*)(["']?)([^\s,"'\]}]{8,})(["']?)/gi,
20
+ replace: "$1$2[REDACTED]$4",
21
+ },
22
+ {
23
+ category: "known_token_format",
24
+ pattern: /\b(?:sk|ghp|github_pat)_[A-Za-z0-9_-]{16,}\b/g,
25
+ replace: "[REDACTED TOKEN]",
26
+ },
27
+ ];
28
+ export function redactSensitiveContent(input) {
29
+ let content = input;
30
+ const categories = [];
31
+ for (const rule of RULES) {
32
+ rule.pattern.lastIndex = 0;
33
+ if (!rule.pattern.test(content))
34
+ continue;
35
+ rule.pattern.lastIndex = 0;
36
+ content = content.replace(rule.pattern, rule.replace);
37
+ categories.push(rule.category);
38
+ }
39
+ return {
40
+ content,
41
+ redacted: categories.length > 0,
42
+ redaction_categories: [...new Set(categories)],
43
+ };
44
+ }
45
+ export function redactSensitiveValue(input) {
46
+ const categories = [];
47
+ const visit = (value) => {
48
+ if (typeof value === "string") {
49
+ const result = redactSensitiveContent(value);
50
+ categories.push(...result.redaction_categories);
51
+ return result.content;
52
+ }
53
+ if (Array.isArray(value))
54
+ return value.map(visit);
55
+ if (value && typeof value === "object") {
56
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, visit(entry)]));
57
+ }
58
+ return value;
59
+ };
60
+ return {
61
+ value: visit(input),
62
+ redacted: categories.length > 0,
63
+ redaction_categories: [...new Set(categories)],
64
+ };
65
+ }
@@ -0,0 +1,3 @@
1
+ export declare function guardPath(requestedPath: string, workspaceRoot: string, allowedPrefix?: string): string;
2
+ export declare function guardReadPath(requestedPath: string, workspaceRoot: string, allowedPrefix?: string): string;
3
+ export declare function guardWorkspacePath(inputPath: string, workspaceRoot: string): string;
@@ -0,0 +1,98 @@
1
+ import { basename, dirname, isAbsolute, normalize, parse, resolve, sep, } from "node:path";
2
+ import { realpathSync } from "node:fs";
3
+ import { PatchWardenError } from "../errors.js";
4
+ export function guardPath(requestedPath, workspaceRoot, allowedPrefix) {
5
+ const ws = normalize(resolve(workspaceRoot));
6
+ const candidate = requestedPath === "" || requestedPath === "."
7
+ ? ws
8
+ : normalize(isAbsolute(requestedPath)
9
+ ? resolve(requestedPath)
10
+ : resolve(ws, requestedPath));
11
+ const realCandidate = realPathOrExistingPrefix(candidate);
12
+ const realWs = realPathOrSelf(ws);
13
+ assertInside(realCandidate, realWs, requestedPath, "Path escapes workspace");
14
+ if (allowedPrefix) {
15
+ const prefixPath = normalize(resolve(ws, allowedPrefix));
16
+ assertInside(realCandidate, realPathOrExistingPrefix(prefixPath), requestedPath, `Path outside allowed prefix: "${requestedPath}" is not under "${allowedPrefix}"`);
17
+ }
18
+ return normalize(realCandidate);
19
+ }
20
+ export function guardReadPath(requestedPath, workspaceRoot, allowedPrefix) {
21
+ const guarded = guardPath(requestedPath, workspaceRoot, allowedPrefix);
22
+ try {
23
+ return realpathSync(guarded);
24
+ }
25
+ catch {
26
+ throw new Error(`File not found: "${requestedPath}"`);
27
+ }
28
+ }
29
+ export function guardWorkspacePath(inputPath, workspaceRoot) {
30
+ const ws = normalize(resolve(workspaceRoot));
31
+ const input = inputPath || ".";
32
+ const winDriveMatch = input.match(/^([A-Za-z]):[/\\]/);
33
+ const wsDriveMatch = ws.match(/^([A-Za-z]):[/\\]/);
34
+ if (winDriveMatch && wsDriveMatch) {
35
+ if (winDriveMatch[1].toLowerCase() !== wsDriveMatch[1].toLowerCase()) {
36
+ throw new PatchWardenError("workspace_path_escape", `repo_path "${input}" is on drive ${winDriveMatch[1].toUpperCase()}: ` +
37
+ `but workspace is on drive ${wsDriveMatch[1].toUpperCase()}:. All paths must be under the configured workspace.`, "Pass a repo_path located under the configured workspaceRoot.", true, { path: input, operation: "resolve_repo_path", safe_alternative: "Use a repository path inside workspaceRoot." });
38
+ }
39
+ }
40
+ else if (winDriveMatch && !wsDriveMatch) {
41
+ throw new PatchWardenError("workspace_path_escape", `repo_path "${input}" appears to be a Windows path but workspace ` +
42
+ `"${workspaceRoot}" is a Unix path. All paths must be under the configured workspace.`, "Use the same path style as workspaceRoot and keep repo_path inside it.", true, { path: input, operation: "resolve_repo_path", safe_alternative: "Use the workspaceRoot path style and an internal repository path." });
43
+ }
44
+ const resolved = normalize(isAbsolute(input) ? resolve(input) : resolve(ws, input));
45
+ const realResolved = realPathOrExistingPrefix(resolved);
46
+ const realWs = realPathOrSelf(ws);
47
+ assertInside(realResolved, realWs, input, `repo_path "${input}" is outside workspace "${workspaceRoot}". All paths must be under the configured workspace.`);
48
+ return normalize(realResolved);
49
+ }
50
+ function realPathOrSelf(value) {
51
+ try {
52
+ return realpathSync(value);
53
+ }
54
+ catch {
55
+ return normalize(value);
56
+ }
57
+ }
58
+ function realPathOrExistingPrefix(candidate) {
59
+ const normalized = normalize(candidate);
60
+ const root = parse(normalized).root;
61
+ const suffix = [];
62
+ let current = normalized;
63
+ while (current && current !== root) {
64
+ try {
65
+ return resolve(realpathSync(current), ...suffix);
66
+ }
67
+ catch {
68
+ suffix.unshift(basename(current));
69
+ const parent = dirname(current);
70
+ if (parent === current)
71
+ break;
72
+ current = parent;
73
+ }
74
+ }
75
+ try {
76
+ return resolve(realpathSync(root || current), ...suffix);
77
+ }
78
+ catch {
79
+ return normalized;
80
+ }
81
+ }
82
+ function assertInside(candidate, root, requestedPath, message) {
83
+ const normalizedRoot = normalize(root);
84
+ const normalizedCandidate = normalize(candidate);
85
+ const checkRoot = process.platform === "win32"
86
+ ? normalizedRoot.toLowerCase()
87
+ : normalizedRoot;
88
+ const checkCandidate = process.platform === "win32"
89
+ ? normalizedCandidate.toLowerCase()
90
+ : normalizedCandidate;
91
+ if (checkCandidate !== checkRoot &&
92
+ !checkCandidate.startsWith(checkRoot + sep)) {
93
+ if (message.startsWith("Path escapes workspace")) {
94
+ throw new PatchWardenError("workspace_path_escape", `Path escapes workspace: "${requestedPath}" resolves to "${normalizedCandidate}" which is outside "${normalizedRoot}"`, "Use a path inside the configured workspace and allowed prefix.", true, { path: requestedPath, operation: "path_access", safe_alternative: "Read a path inside the configured workspace and allowed prefix." });
95
+ }
96
+ throw new PatchWardenError("workspace_path_escape", message, "Use a path inside the configured workspace and allowed prefix.", true, { path: requestedPath, operation: "path_access", safe_alternative: "Read a path inside the configured workspace and allowed prefix." });
97
+ }
98
+ }
@@ -0,0 +1 @@
1
+ export declare function guardPlanContent(title: string, content: string): void;
@@ -0,0 +1,65 @@
1
+ import { PatchWardenError } from "../errors.js";
2
+ const PLAN_RULES = [
3
+ {
4
+ id: "plan_secret_access",
5
+ category: "credential_access",
6
+ pattern: /(?:\b(?:read|open|cat|dump|extract|steal|exfiltrat\w*|leak)\b|读取|查看|打开|导出|窃取|泄露)[^.!?;。!?;\r\n]{0,60}?(?:\.env\b|\.npmrc\b|access[ _-]?token|api[ _-]?key|token\b|credentials?\b|id_rsa\b|id_ed25519\b|ssh[^.!?;。!?;\r\n]{0,12}private[^.!?;。!?;\r\n]{0,8}key|private[^.!?;。!?;\r\n]{0,8}key|密钥|令牌|凭据|私钥)/is,
7
+ suggestion: "Rewrite the plan to avoid reading, exporting, or exposing credentials and secret files.",
8
+ },
9
+ {
10
+ id: "plan_secret_access_reversed",
11
+ category: "credential_access",
12
+ pattern: /(?:\.env\b|\.npmrc\b|access[ _-]?token|api[ _-]?key|credentials?\b|id_rsa\b|id_ed25519\b|ssh[^.!?;。!?;\r\n]{0,12}private[^.!?;。!?;\r\n]{0,8}key|private[^.!?;。!?;\r\n]{0,8}key|密钥|令牌|凭据|私钥)[^.!?;。!?;\r\n]{0,60}?(?:\b(?:read|open|cat|dump|extract|steal|exfiltrat\w*|leak)\b|读取|查看|打开|导出|窃取|泄露)/is,
13
+ suggestion: "Rewrite the plan to avoid reading, exporting, or exposing credentials and secret files.",
14
+ },
15
+ {
16
+ id: "plan_destructive_delete",
17
+ category: "destructive_disk_operation",
18
+ pattern: /(?:rm\s+-rf\s+(?:\/|~|[a-z]:[\\/])|remove-item.{0,80}(?:c:\\users|recurse.{0,20}force)|删除.{0,30}(?:用户目录|主目录|整个磁盘|全盘|系统盘)|清空.{0,20}(?:磁盘|用户目录|主目录))/is,
19
+ suggestion: "Limit deletion to explicit project-local temporary files and require a preview or backup first.",
20
+ },
21
+ {
22
+ id: "plan_malicious_persistence",
23
+ category: "malicious_persistence_or_exfiltration",
24
+ pattern: /(?:\b(?:install|create|deploy)\b|植入|安装|创建).{0,50}(?:backdoor|keylogger|credential stealer|data stealer|后门|键盘记录|窃密)|(?:\b(?:persist|persistence)\b|持久化).{0,40}(?:malware|payload|backdoor|恶意|后门)/is,
25
+ suggestion: "Remove persistence, backdoor, credential theft, or data-exfiltration instructions.",
26
+ },
27
+ ];
28
+ export function guardPlanContent(title, content) {
29
+ const text = `${title}\n${content}`;
30
+ for (const rule of PLAN_RULES) {
31
+ for (const match of text.matchAll(globalPattern(rule.pattern))) {
32
+ const index = match.index ?? 0;
33
+ if (isDirectlyNegated(text, index, match[0]))
34
+ continue;
35
+ throw new PatchWardenError(rule.id, `save_plan blocked content in category "${rule.category}".`, rule.suggestion, true, {
36
+ operation: "save_plan",
37
+ matched_category: rule.category,
38
+ matched_text: match[0].slice(0, 160),
39
+ });
40
+ }
41
+ }
42
+ }
43
+ function globalPattern(pattern) {
44
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
45
+ return new RegExp(pattern.source, flags);
46
+ }
47
+ function isDirectlyNegated(text, matchIndex, matchedText) {
48
+ const prefix = currentClause(text.slice(Math.max(0, matchIndex - 160), matchIndex));
49
+ const englishDirect = /\b(?:do not|don't|never|must not|should not|shall not|cannot|can't|won't|shouldn't|mustn't|avoid|refrain from|forbids?|blocks?|prevents?)\s+(?:(?:any|all|the|a|an|users?|agents?|tools?|process(?:es)?|attempts?)\s+){0,5}(?:(?:from|to)\s+)?$/i;
50
+ const chineseDirect = /(?:禁止|不要|不得|严禁|避免|防止|阻止|勿|别|不可|不应)(?:(?:任何|所有|用户|代理|工具|程序|进程|尝试|执行|进行)\s*){0,4}$/;
51
+ if (englishDirect.test(prefix) || chineseDirect.test(prefix))
52
+ return true;
53
+ const clauseAndMatch = `${prefix}${matchedText}`;
54
+ const englishNegatedAction = /\b(?:do not|don't|never|must not|should not|shall not|cannot|can't|won't|shouldn't|mustn't|avoid|refrain from|forbids?|blocks?|prevents?)\s+(?:(?:any|all|the|a|an|users?|agents?|tools?|process(?:es)?|attempts?)\s+){0,5}(?:(?:from|to)\s+)?(?:read|open|cat|dump|extract|steal|exfiltrat\w*|leak|delete|remove|install|create|deploy)\b/i;
55
+ const chineseNegatedAction = /(?:禁止|不要|不得|严禁|避免|防止|阻止|勿|别|不可|不应)(?:(?:任何|所有|用户|代理|工具|程序|进程|尝试|执行|进行)\s*){0,4}(?:读取|查看|打开|导出|窃取|泄露|删除|安装|创建|植入)/s;
56
+ if (englishNegatedAction.test(clauseAndMatch) || chineseNegatedAction.test(clauseAndMatch))
57
+ return true;
58
+ const englishReversed = /\b(?:must not|should not|shall not|cannot|can't|may not|never)\s+be\s+(?:read|opened|dumped|extracted|exported|deleted|removed|installed|created|deployed)\b/i;
59
+ const chineseReversed = /(?:禁止|不得|严禁|不可|不应).{0,12}(?:读取|查看|打开|导出|窃取|泄露|删除|安装|创建|植入)/s;
60
+ return englishReversed.test(matchedText) || chineseReversed.test(matchedText);
61
+ }
62
+ function currentClause(prefix) {
63
+ const boundary = Math.max(prefix.lastIndexOf("\n"), prefix.lastIndexOf("."), prefix.lastIndexOf("!"), prefix.lastIndexOf("?"), prefix.lastIndexOf(";"), prefix.lastIndexOf("。"), prefix.lastIndexOf("!"), prefix.lastIndexOf("?"), prefix.lastIndexOf(";"));
64
+ return prefix.slice(boundary + 1);
65
+ }
@@ -0,0 +1,2 @@
1
+ export declare function isSensitivePath(filePath: string): boolean;
2
+ export declare function guardSensitivePath(filePath: string): void;
@@ -0,0 +1,60 @@
1
+ import { PatchWardenError } from "../errors.js";
2
+ /**
3
+ * Sensitive file guard: block reads of files likely to contain secrets.
4
+ * Returns true if the file is ALLOWED (not sensitive).
5
+ */
6
+ const SENSITIVE_PATTERNS = [
7
+ // Exact filenames
8
+ /(?:^|[\\/])\.env$/i,
9
+ /(?:^|[\\/])\.env\..+$/i,
10
+ /(?:^|[\\/])id_rsa$/i,
11
+ /(?:^|[\\/])id_ed25519$/i,
12
+ /(?:^|[\\/])id_ecdsa$/i,
13
+ /(?:^|[\\/])\.ssh[\\/]/i,
14
+ // Credentials / tokens
15
+ /(?:^|[\\/])credentials/i,
16
+ /(?:^|[\\/])\.?aws[\\/]credentials/i,
17
+ /(?:^|[\\/])\.netrc$/i,
18
+ /(?:^|[\\/])\.npmrc$/i,
19
+ /(?:^|[\\/])\.pypirc$/i,
20
+ /(?:^|[\\/])token/i,
21
+ // Private keys
22
+ /(?:^|[\\/])[^.]+\.pem$/i,
23
+ /(?:^|[\\/])[^.]+\.key$/i,
24
+ /(?:^|[\\/])[^.]+\.pfx$/i,
25
+ /(?:^|[\\/])[^.]+\.p12$/i,
26
+ // Browser data
27
+ /(?:^|[\\/])cookies/i,
28
+ /(?:^|[\\/])web data$/i,
29
+ /(?:^|[\\/])login data$/i,
30
+ /(?:^|[\\/])local state$/i,
31
+ // Other sensitive files
32
+ /(?:^|[\\/])\.git-credentials$/i,
33
+ /(?:^|[\\/])\.docker[\\/]config\.json$/i,
34
+ /(?:^|[\\/])\.kube[\\/]config$/i,
35
+ /(?:^|[\\/])config\.json$/i, // generic config files often contain local tokens or service credentials
36
+ ];
37
+ // Files specifically inside .patchwarden are always allowed
38
+ const SAFE_PREFIX = ".patchwarden";
39
+ export function isSensitivePath(filePath) {
40
+ // Files inside .patchwarden are always safe
41
+ const normalized = filePath.replace(/\\/g, "/");
42
+ if (normalized.includes(`${SAFE_PREFIX}/`) || normalized.endsWith(SAFE_PREFIX)) {
43
+ return false;
44
+ }
45
+ for (const pattern of SENSITIVE_PATTERNS) {
46
+ if (pattern.test(filePath)) {
47
+ return true;
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+ export function guardSensitivePath(filePath) {
53
+ if (isSensitivePath(filePath)) {
54
+ throw new PatchWardenError("sensitive_path_blocked", `Access denied: "${filePath}" matches a sensitive file pattern. Reading this file is not permitted.`, "Read only non-sensitive task artifacts or workspace files.", true, {
55
+ path: filePath,
56
+ operation: "read",
57
+ safe_alternative: "Read a non-sensitive task artifact, or remove secret material and retry.",
58
+ });
59
+ }
60
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * PatchWarden Security Smoke Tests
3
+ *
4
+ * Covers all security requirements:
5
+ * 1. Workspace containment (path escape, readWorkspaceFile uses safePath)
6
+ * 2. Sensitive file rejection
7
+ * 3. test_command allowlist enforcement
8
+ * 4. repo_path workspace enforcement
9
+ * 5. plan_id existence validation
10
+ * 6. Runner CLI real execution
11
+ * 7. Task output file read restrictions
12
+ *
13
+ * Run: node dist/smoke-test.js
14
+ */
15
+ export {};