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