@phren/agent 0.0.1
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/dist/agent-loop.js +328 -0
- package/dist/bin.js +3 -0
- package/dist/checkpoint.js +103 -0
- package/dist/commands.js +292 -0
- package/dist/config.js +139 -0
- package/dist/context/pruner.js +62 -0
- package/dist/context/token-counter.js +28 -0
- package/dist/cost.js +71 -0
- package/dist/index.js +284 -0
- package/dist/mcp-client.js +168 -0
- package/dist/memory/anti-patterns.js +69 -0
- package/dist/memory/auto-capture.js +72 -0
- package/dist/memory/context-flush.js +24 -0
- package/dist/memory/context.js +170 -0
- package/dist/memory/error-recovery.js +58 -0
- package/dist/memory/project-context.js +77 -0
- package/dist/memory/session.js +100 -0
- package/dist/multi/agent-colors.js +41 -0
- package/dist/multi/child-entry.js +173 -0
- package/dist/multi/coordinator.js +263 -0
- package/dist/multi/diff-renderer.js +175 -0
- package/dist/multi/markdown.js +96 -0
- package/dist/multi/presets.js +107 -0
- package/dist/multi/progress.js +32 -0
- package/dist/multi/spawner.js +219 -0
- package/dist/multi/tui-multi.js +626 -0
- package/dist/multi/types.js +7 -0
- package/dist/permissions/allowlist.js +61 -0
- package/dist/permissions/checker.js +111 -0
- package/dist/permissions/prompt.js +190 -0
- package/dist/permissions/sandbox.js +95 -0
- package/dist/permissions/shell-safety.js +74 -0
- package/dist/permissions/types.js +2 -0
- package/dist/plan.js +38 -0
- package/dist/providers/anthropic.js +170 -0
- package/dist/providers/codex-auth.js +197 -0
- package/dist/providers/codex.js +265 -0
- package/dist/providers/ollama.js +142 -0
- package/dist/providers/openai-compat.js +163 -0
- package/dist/providers/openrouter.js +116 -0
- package/dist/providers/resolve.js +39 -0
- package/dist/providers/retry.js +55 -0
- package/dist/providers/types.js +2 -0
- package/dist/repl.js +180 -0
- package/dist/spinner.js +46 -0
- package/dist/system-prompt.js +31 -0
- package/dist/tools/edit-file.js +31 -0
- package/dist/tools/git.js +98 -0
- package/dist/tools/glob.js +65 -0
- package/dist/tools/grep.js +108 -0
- package/dist/tools/lint-test.js +76 -0
- package/dist/tools/phren-finding.js +35 -0
- package/dist/tools/phren-search.js +44 -0
- package/dist/tools/phren-tasks.js +71 -0
- package/dist/tools/read-file.js +44 -0
- package/dist/tools/registry.js +46 -0
- package/dist/tools/shell.js +48 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/write-file.js +27 -0
- package/dist/tui.js +451 -0
- package/package.json +39 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { checkShellSafety } from "./shell-safety.js";
|
|
2
|
+
import { validatePath, checkSensitivePath } from "./sandbox.js";
|
|
3
|
+
import { isAllowed } from "./allowlist.js";
|
|
4
|
+
/** Tools that are safe in all modes — read-only, no side effects. */
|
|
5
|
+
const ALWAYS_SAFE_TOOLS = new Set([
|
|
6
|
+
"phren_search",
|
|
7
|
+
"phren_get_tasks",
|
|
8
|
+
]);
|
|
9
|
+
/** Tools that access file paths and need sensitive-path checks. */
|
|
10
|
+
const FILE_TOOLS = new Set([
|
|
11
|
+
"read_file",
|
|
12
|
+
"write_file",
|
|
13
|
+
"edit_file",
|
|
14
|
+
"glob",
|
|
15
|
+
"grep",
|
|
16
|
+
]);
|
|
17
|
+
/** Tools that auto-confirm mode allows without prompting. */
|
|
18
|
+
const AUTO_CONFIRM_TOOLS = new Set([
|
|
19
|
+
"edit_file",
|
|
20
|
+
"phren_add_finding",
|
|
21
|
+
"phren_complete_task",
|
|
22
|
+
]);
|
|
23
|
+
/** Tools that are always denied regardless of mode. */
|
|
24
|
+
const DENY_LIST_TOOLS = new Set([
|
|
25
|
+
// Reserved for future use — e.g. "delete_project"
|
|
26
|
+
]);
|
|
27
|
+
/**
|
|
28
|
+
* Check whether a tool call should be allowed, asked about, or denied.
|
|
29
|
+
*/
|
|
30
|
+
export function checkPermission(config, toolName, input) {
|
|
31
|
+
// Deny-list always wins
|
|
32
|
+
if (DENY_LIST_TOOLS.has(toolName)) {
|
|
33
|
+
return { verdict: "deny", reason: `Tool "${toolName}" is on the deny list.` };
|
|
34
|
+
}
|
|
35
|
+
// Shell commands get extra scrutiny
|
|
36
|
+
if (toolName === "shell") {
|
|
37
|
+
const cmd = input.command || "";
|
|
38
|
+
const safety = checkShellSafety(cmd);
|
|
39
|
+
if (!safety.safe && safety.severity === "block") {
|
|
40
|
+
return { verdict: "deny", reason: safety.reason };
|
|
41
|
+
}
|
|
42
|
+
if (!safety.safe && safety.severity === "warn") {
|
|
43
|
+
// In full-auto, warn becomes ask. In other modes, it's already going to ask.
|
|
44
|
+
if (config.mode === "full-auto") {
|
|
45
|
+
return { verdict: "ask", reason: safety.reason };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Check cwd for shell
|
|
49
|
+
const cwd = input.cwd || config.projectRoot;
|
|
50
|
+
const cwdResult = validatePath(cwd, config.projectRoot, config.allowedPaths);
|
|
51
|
+
if (!cwdResult.ok) {
|
|
52
|
+
if (config.mode === "full-auto") {
|
|
53
|
+
return { verdict: "ask", reason: `Shell cwd outside sandbox: ${cwdResult.error}` };
|
|
54
|
+
}
|
|
55
|
+
// suggest and auto-confirm will ask below anyway
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Path-based tools: validate sandbox + sensitive path
|
|
59
|
+
if (FILE_TOOLS.has(toolName)) {
|
|
60
|
+
const filePath = input.path || "";
|
|
61
|
+
if (filePath) {
|
|
62
|
+
// Sensitive path check applies in ALL modes
|
|
63
|
+
const sensitive = checkSensitivePath(filePath);
|
|
64
|
+
if (sensitive.sensitive) {
|
|
65
|
+
return { verdict: "deny", reason: `Sensitive path: ${sensitive.reason}` };
|
|
66
|
+
}
|
|
67
|
+
// Sandbox check: ask for out-of-sandbox paths in ALL modes (not just full-auto)
|
|
68
|
+
const pathResult = validatePath(filePath, config.projectRoot, config.allowedPaths);
|
|
69
|
+
if (!pathResult.ok) {
|
|
70
|
+
return { verdict: "ask", reason: `Path outside sandbox: ${pathResult.error}` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Always-safe tools pass in all modes
|
|
75
|
+
if (ALWAYS_SAFE_TOOLS.has(toolName)) {
|
|
76
|
+
return { verdict: "allow", reason: "Read-only tool, always allowed." };
|
|
77
|
+
}
|
|
78
|
+
// Session allowlist — user previously approved this tool+pattern via (a)llow-tool or (s)ession-allow.
|
|
79
|
+
// Placed after deny-list, shell-safety blocks, and sensitive-path denials so those are never bypassed.
|
|
80
|
+
if (isAllowed(toolName, input)) {
|
|
81
|
+
return { verdict: "allow", reason: "Session allowlist." };
|
|
82
|
+
}
|
|
83
|
+
// Mode-specific logic
|
|
84
|
+
switch (config.mode) {
|
|
85
|
+
case "suggest":
|
|
86
|
+
// Suggest mode: ask for everything except safe tools
|
|
87
|
+
return { verdict: "ask", reason: `Suggest mode requires confirmation for "${toolName}".` };
|
|
88
|
+
case "auto-confirm":
|
|
89
|
+
if (AUTO_CONFIRM_TOOLS.has(toolName)) {
|
|
90
|
+
// Auto-confirm tools are allowed if path is in sandbox
|
|
91
|
+
return { verdict: "allow", reason: `Auto-confirm mode allows "${toolName}".` };
|
|
92
|
+
}
|
|
93
|
+
if (toolName === "shell") {
|
|
94
|
+
const cwd = input.cwd || config.projectRoot;
|
|
95
|
+
const cwdResult = validatePath(cwd, config.projectRoot, config.allowedPaths);
|
|
96
|
+
if (cwdResult.ok) {
|
|
97
|
+
const cmd = input.command || "";
|
|
98
|
+
const safety = checkShellSafety(cmd);
|
|
99
|
+
if (safety.safe) {
|
|
100
|
+
return { verdict: "allow", reason: "Safe shell command within sandbox." };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { verdict: "ask", reason: `Auto-confirm mode requires confirmation for "${toolName}".` };
|
|
105
|
+
case "full-auto":
|
|
106
|
+
// Full-auto: allow everything not denied or warned
|
|
107
|
+
return { verdict: "allow", reason: "Full-auto mode." };
|
|
108
|
+
default:
|
|
109
|
+
return { verdict: "ask", reason: "Unknown permission mode." };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart permission prompt — color-coded, full context, keyboard shortcuts.
|
|
3
|
+
*
|
|
4
|
+
* Responses:
|
|
5
|
+
* y = allow once
|
|
6
|
+
* n = deny
|
|
7
|
+
* a = allow this tool for the rest of the session (any input)
|
|
8
|
+
* s = allow this exact tool+pattern for the rest of the session
|
|
9
|
+
*/
|
|
10
|
+
import * as readline from "node:readline";
|
|
11
|
+
import { addAllow } from "./allowlist.js";
|
|
12
|
+
// ── ANSI colors ─────────────────────────────────────────────────────────
|
|
13
|
+
const RESET = "\x1b[0m";
|
|
14
|
+
const BOLD = "\x1b[1m";
|
|
15
|
+
const DIM = "\x1b[2m";
|
|
16
|
+
const GREEN = "\x1b[32m";
|
|
17
|
+
const YELLOW = "\x1b[33m";
|
|
18
|
+
const RED = "\x1b[31m";
|
|
19
|
+
const CYAN = "\x1b[36m";
|
|
20
|
+
const READ_TOOLS = new Set(["read_file", "glob", "grep", "git_status", "git_diff", "phren_search", "phren_get_tasks"]);
|
|
21
|
+
const DANGEROUS_TOOLS = new Set(["shell"]);
|
|
22
|
+
function classifyRisk(toolName) {
|
|
23
|
+
if (READ_TOOLS.has(toolName))
|
|
24
|
+
return "read";
|
|
25
|
+
if (DANGEROUS_TOOLS.has(toolName))
|
|
26
|
+
return "dangerous";
|
|
27
|
+
return "write";
|
|
28
|
+
}
|
|
29
|
+
function riskColor(risk) {
|
|
30
|
+
switch (risk) {
|
|
31
|
+
case "read": return GREEN;
|
|
32
|
+
case "write": return YELLOW;
|
|
33
|
+
case "dangerous": return RED;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function riskLabel(risk) {
|
|
37
|
+
switch (risk) {
|
|
38
|
+
case "read": return "READ";
|
|
39
|
+
case "write": return "WRITE";
|
|
40
|
+
case "dangerous": return "SHELL";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// ── Summary generation ──────────────────────────────────────────────────
|
|
44
|
+
function summarizeCall(toolName, input) {
|
|
45
|
+
switch (toolName) {
|
|
46
|
+
case "read_file": {
|
|
47
|
+
const p = input.path || "?";
|
|
48
|
+
const offset = input.offset ? ` from line ${input.offset}` : "";
|
|
49
|
+
const limit = input.limit ? ` (${input.limit} lines)` : "";
|
|
50
|
+
return `Read ${p}${offset}${limit}`;
|
|
51
|
+
}
|
|
52
|
+
case "write_file": {
|
|
53
|
+
const p = input.path || "?";
|
|
54
|
+
const content = input.content || "";
|
|
55
|
+
const lines = content.split("\n").length;
|
|
56
|
+
return `Write ${lines} lines to ${p}`;
|
|
57
|
+
}
|
|
58
|
+
case "edit_file": {
|
|
59
|
+
const p = input.path || "?";
|
|
60
|
+
return `Edit ${p}`;
|
|
61
|
+
}
|
|
62
|
+
case "glob": {
|
|
63
|
+
const pattern = input.pattern || "?";
|
|
64
|
+
const dir = input.path || ".";
|
|
65
|
+
return `Glob "${pattern}" in ${dir}`;
|
|
66
|
+
}
|
|
67
|
+
case "grep": {
|
|
68
|
+
const pattern = input.pattern || "?";
|
|
69
|
+
const dir = input.path || ".";
|
|
70
|
+
return `Grep "${pattern}" in ${dir}`;
|
|
71
|
+
}
|
|
72
|
+
case "shell": {
|
|
73
|
+
const cmd = input.command || "?";
|
|
74
|
+
return cmd.length > 120 ? cmd.slice(0, 117) + "..." : cmd;
|
|
75
|
+
}
|
|
76
|
+
case "git_commit": {
|
|
77
|
+
const msg = input.message || "";
|
|
78
|
+
return `Commit: ${msg.slice(0, 80)}`;
|
|
79
|
+
}
|
|
80
|
+
case "phren_add_finding":
|
|
81
|
+
return `Save finding to phren`;
|
|
82
|
+
case "phren_complete_task":
|
|
83
|
+
return `Complete phren task`;
|
|
84
|
+
default: {
|
|
85
|
+
const keys = Object.keys(input);
|
|
86
|
+
return keys.length > 0 ? `${toolName}(${keys.join(", ")})` : toolName;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Ask the user on stderr whether to allow a tool call.
|
|
92
|
+
* Returns true if user approves (y, a, or s), false if denied (n).
|
|
93
|
+
*
|
|
94
|
+
* Side effect: "a" and "s" responses add to the session allowlist.
|
|
95
|
+
*/
|
|
96
|
+
export async function askUser(toolName, input, reason) {
|
|
97
|
+
const risk = classifyRisk(toolName);
|
|
98
|
+
const color = riskColor(risk);
|
|
99
|
+
const label = riskLabel(risk);
|
|
100
|
+
const summary = summarizeCall(toolName, input);
|
|
101
|
+
// Header
|
|
102
|
+
process.stderr.write(`\n${color}${BOLD}[${label}]${RESET} ${BOLD}${toolName}${RESET}\n`);
|
|
103
|
+
process.stderr.write(`${DIM} ${reason}${RESET}\n`);
|
|
104
|
+
process.stderr.write(`${CYAN} ${summary}${RESET}\n`);
|
|
105
|
+
// Show full input for shell commands or when details matter
|
|
106
|
+
if (toolName === "shell") {
|
|
107
|
+
const cmd = input.command || "";
|
|
108
|
+
if (cmd.length > 120) {
|
|
109
|
+
process.stderr.write(`${DIM} Full command:${RESET}\n`);
|
|
110
|
+
process.stderr.write(`${DIM} ${cmd}${RESET}\n`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const result = await promptKey();
|
|
114
|
+
// Persist allowlist entries for session/tool scopes
|
|
115
|
+
if (result === "allow-session") {
|
|
116
|
+
addAllow(toolName, input, "session");
|
|
117
|
+
}
|
|
118
|
+
else if (result === "allow-tool") {
|
|
119
|
+
addAllow(toolName, input, "tool");
|
|
120
|
+
}
|
|
121
|
+
return result !== "deny";
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Read a single keypress from stdin.
|
|
125
|
+
* Temporarily exits raw mode if the TUI has it enabled, restores after.
|
|
126
|
+
*/
|
|
127
|
+
async function promptKey() {
|
|
128
|
+
const hint = `${DIM} [y]es [n]o [a]llow-tool [s]ession-allow${RESET} `;
|
|
129
|
+
process.stderr.write(hint);
|
|
130
|
+
const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
if (process.stdin.isTTY) {
|
|
133
|
+
// Single-keypress mode
|
|
134
|
+
if (!wasRaw) {
|
|
135
|
+
process.stdin.setRawMode(true);
|
|
136
|
+
}
|
|
137
|
+
process.stdin.resume();
|
|
138
|
+
const onData = (data) => {
|
|
139
|
+
process.stdin.removeListener("data", onData);
|
|
140
|
+
if (!wasRaw && process.stdin.isTTY) {
|
|
141
|
+
process.stdin.setRawMode(false);
|
|
142
|
+
}
|
|
143
|
+
process.stdin.pause();
|
|
144
|
+
const key = data.toString().trim().toLowerCase();
|
|
145
|
+
process.stderr.write(key + "\n");
|
|
146
|
+
switch (key) {
|
|
147
|
+
case "y":
|
|
148
|
+
resolve("allow");
|
|
149
|
+
break;
|
|
150
|
+
case "a":
|
|
151
|
+
resolve("allow-tool");
|
|
152
|
+
break;
|
|
153
|
+
case "s":
|
|
154
|
+
resolve("allow-session");
|
|
155
|
+
break;
|
|
156
|
+
case "n":
|
|
157
|
+
resolve("deny");
|
|
158
|
+
break;
|
|
159
|
+
case "\x03": // Ctrl+C
|
|
160
|
+
process.stderr.write("\n");
|
|
161
|
+
resolve("deny");
|
|
162
|
+
break;
|
|
163
|
+
default:
|
|
164
|
+
resolve("deny"); // Unknown key = deny (safe default)
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
process.stdin.on("data", onData);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// Non-TTY fallback: readline
|
|
171
|
+
const iface = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
172
|
+
iface.question("", (answer) => {
|
|
173
|
+
iface.close();
|
|
174
|
+
const key = answer.trim().toLowerCase();
|
|
175
|
+
switch (key) {
|
|
176
|
+
case "y":
|
|
177
|
+
resolve("allow");
|
|
178
|
+
break;
|
|
179
|
+
case "a":
|
|
180
|
+
resolve("allow-tool");
|
|
181
|
+
break;
|
|
182
|
+
case "s":
|
|
183
|
+
resolve("allow-session");
|
|
184
|
+
break;
|
|
185
|
+
default: resolve("deny");
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
/** Patterns that match sensitive files/directories. */
|
|
5
|
+
const SENSITIVE_PATTERNS = [
|
|
6
|
+
"/.ssh/",
|
|
7
|
+
"/.aws/",
|
|
8
|
+
".env",
|
|
9
|
+
"codex-token.json",
|
|
10
|
+
"id_rsa",
|
|
11
|
+
"id_ed25519",
|
|
12
|
+
"/etc/shadow",
|
|
13
|
+
"/etc/passwd",
|
|
14
|
+
"credentials.json",
|
|
15
|
+
"secrets.json",
|
|
16
|
+
"secrets.yaml",
|
|
17
|
+
".npmrc",
|
|
18
|
+
".netrc",
|
|
19
|
+
".docker/config.json",
|
|
20
|
+
".kube/config",
|
|
21
|
+
"/.gnupg/",
|
|
22
|
+
".pypirc",
|
|
23
|
+
];
|
|
24
|
+
/** File extensions that are always sensitive. */
|
|
25
|
+
const SENSITIVE_EXTENSIONS = [".pem", ".p12", ".pfx", ".key", ".keystore", ".jks"];
|
|
26
|
+
/**
|
|
27
|
+
* Resolve and validate a file path against the sandbox boundary.
|
|
28
|
+
*/
|
|
29
|
+
export function validatePath(filePath, projectRoot, allowedPaths) {
|
|
30
|
+
// Resolve ~ to home directory
|
|
31
|
+
let resolved = filePath;
|
|
32
|
+
if (resolved.startsWith("~/") || resolved === "~") {
|
|
33
|
+
resolved = path.join(os.homedir(), resolved.slice(1));
|
|
34
|
+
}
|
|
35
|
+
// Resolve to absolute
|
|
36
|
+
if (!path.isAbsolute(resolved)) {
|
|
37
|
+
resolved = path.resolve(projectRoot, resolved);
|
|
38
|
+
}
|
|
39
|
+
// Normalize (remove .., trailing slashes, etc.)
|
|
40
|
+
resolved = path.normalize(resolved);
|
|
41
|
+
// Resolve symlinks if the path exists
|
|
42
|
+
try {
|
|
43
|
+
if (fs.existsSync(resolved)) {
|
|
44
|
+
resolved = fs.realpathSync(resolved);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// If we can't resolve, proceed with the normalized path
|
|
49
|
+
}
|
|
50
|
+
// Check sandbox boundaries
|
|
51
|
+
if (!isPathInSandbox(resolved, projectRoot, allowedPaths)) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
error: `Path "${resolved}" is outside project root "${projectRoot}" and not in allowed paths.`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, resolved };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check if a resolved path is within the project root or any allowed path.
|
|
61
|
+
*/
|
|
62
|
+
export function isPathInSandbox(resolved, projectRoot, allowedPaths) {
|
|
63
|
+
const normalizedResolved = path.normalize(resolved) + path.sep;
|
|
64
|
+
const normalizedRoot = path.normalize(projectRoot) + path.sep;
|
|
65
|
+
if (normalizedResolved.startsWith(normalizedRoot) || resolved === projectRoot) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
for (const allowed of allowedPaths) {
|
|
69
|
+
let normalizedAllowed = allowed;
|
|
70
|
+
if (normalizedAllowed.startsWith("~/") || normalizedAllowed === "~") {
|
|
71
|
+
normalizedAllowed = path.join(os.homedir(), normalizedAllowed.slice(1));
|
|
72
|
+
}
|
|
73
|
+
normalizedAllowed = path.normalize(normalizedAllowed) + path.sep;
|
|
74
|
+
if (normalizedResolved.startsWith(normalizedAllowed) || resolved === path.normalize(allowed)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Check if a resolved path matches any known sensitive pattern.
|
|
82
|
+
*/
|
|
83
|
+
export function checkSensitivePath(resolved) {
|
|
84
|
+
const normalizedLower = resolved.toLowerCase();
|
|
85
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
86
|
+
if (SENSITIVE_EXTENSIONS.includes(ext)) {
|
|
87
|
+
return { sensitive: true, reason: `Sensitive file extension: ${ext}` };
|
|
88
|
+
}
|
|
89
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
90
|
+
if (normalizedLower.includes(pattern.toLowerCase())) {
|
|
91
|
+
return { sensitive: true, reason: `Matches sensitive pattern: ${pattern}` };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { sensitive: false };
|
|
95
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const DANGEROUS_PATTERNS = [
|
|
2
|
+
// Block: destructive/irreversible (no $ anchors — catch chained commands like `rm -rf /; echo done`)
|
|
3
|
+
{ pattern: /rm\s+-[a-z]*r[a-z]*f?\s+\/\s*/i, reason: "Recursive delete of root filesystem", severity: "block" },
|
|
4
|
+
{ pattern: /rm\s+-[a-z]*r[a-z]*f?\s+\/[^\/\s]*/i, reason: "Recursive delete of top-level directory", severity: "block" },
|
|
5
|
+
{ pattern: /curl\s+.*\|\s*(?:ba)?sh/i, reason: "Piping remote script to shell", severity: "block" },
|
|
6
|
+
{ pattern: /wget\s+.*\|\s*(?:ba)?sh/i, reason: "Piping remote script to shell", severity: "block" },
|
|
7
|
+
{ pattern: /\bmkfs\b/i, reason: "Filesystem format command", severity: "block" },
|
|
8
|
+
{ pattern: /\bdd\b.*\bof=\/dev\//i, reason: "Direct device write with dd", severity: "block" },
|
|
9
|
+
{ pattern: />\s*\/dev\/[sh]d[a-z]/i, reason: "Direct write to block device", severity: "block" },
|
|
10
|
+
{ pattern: /:(){ :\|:& };:/i, reason: "Fork bomb", severity: "block" },
|
|
11
|
+
{ pattern: /\bnohup\b/i, reason: "Detached process may outlive session", severity: "block" },
|
|
12
|
+
{ pattern: /\bdisown\b/i, reason: "Detached process may outlive session", severity: "block" },
|
|
13
|
+
{ pattern: /\bsetsid\b/i, reason: "Detached process may outlive session", severity: "block" },
|
|
14
|
+
// Warn: potentially dangerous
|
|
15
|
+
{ pattern: /\beval\b/i, reason: "Dynamic code execution via eval", severity: "warn" },
|
|
16
|
+
{ pattern: /\$\(.*\)/, reason: "Command substitution", severity: "warn" },
|
|
17
|
+
{ pattern: /`[^`]+`/, reason: "Command substitution via backticks", severity: "warn" },
|
|
18
|
+
{ pattern: /\benv\b/i, reason: "May expose environment variables", severity: "warn" },
|
|
19
|
+
{ pattern: /\bprintenv\b/i, reason: "May expose environment variables", severity: "warn" },
|
|
20
|
+
{ pattern: /\bsudo\b/i, reason: "Elevated privileges requested", severity: "warn" },
|
|
21
|
+
{ pattern: /\bgit\s+push\s+--force\b/i, reason: "Force push can rewrite remote history", severity: "warn" },
|
|
22
|
+
{ pattern: /\bgit\s+push\s+-f\b/i, reason: "Force push can rewrite remote history", severity: "warn" },
|
|
23
|
+
{ pattern: /\bgit\s+reset\s+--hard\b/i, reason: "Hard reset discards uncommitted changes", severity: "warn" },
|
|
24
|
+
{ pattern: /\bchmod\s+777\b/, reason: "World-writable permissions", severity: "warn" },
|
|
25
|
+
{ pattern: /\bchown\b.*\broot\b/i, reason: "Changing ownership to root", severity: "warn" },
|
|
26
|
+
];
|
|
27
|
+
/** API key env var patterns to scrub. */
|
|
28
|
+
const KEY_PATTERNS = [
|
|
29
|
+
"ANTHROPIC_API_KEY",
|
|
30
|
+
"OPENAI_API_KEY",
|
|
31
|
+
"OPENROUTER_API_KEY",
|
|
32
|
+
"AWS_ACCESS_KEY_ID",
|
|
33
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
34
|
+
"DATABASE_URL",
|
|
35
|
+
"KUBECONFIG",
|
|
36
|
+
"DOCKER_AUTH_CONFIG",
|
|
37
|
+
];
|
|
38
|
+
/** Suffix patterns that also match connection strings and auth configs. */
|
|
39
|
+
const SECRET_SUFFIX_PATTERNS = ["_URI", "_DSN"];
|
|
40
|
+
const SECRET_SUFFIXES = ["_SECRET", "_TOKEN", "_PASSWORD", "_KEY"];
|
|
41
|
+
/**
|
|
42
|
+
* Check a shell command for dangerous patterns.
|
|
43
|
+
*/
|
|
44
|
+
export function checkShellSafety(command) {
|
|
45
|
+
for (const dp of DANGEROUS_PATTERNS) {
|
|
46
|
+
if (dp.pattern.test(command)) {
|
|
47
|
+
return { safe: false, reason: dp.reason, severity: dp.severity };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { safe: true, reason: "", severity: "ok" };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Return a sanitized copy of process.env with API keys and secrets removed.
|
|
54
|
+
*/
|
|
55
|
+
export function scrubEnv() {
|
|
56
|
+
const env = { ...process.env };
|
|
57
|
+
for (const key of Object.keys(env)) {
|
|
58
|
+
// Known API key vars
|
|
59
|
+
if (KEY_PATTERNS.includes(key)) {
|
|
60
|
+
delete env[key];
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// Anything ending with _SECRET, _TOKEN, _PASSWORD, _KEY, _URI, _DSN
|
|
64
|
+
const upper = key.toUpperCase();
|
|
65
|
+
if (SECRET_SUFFIXES.some((suffix) => upper.endsWith(suffix))) {
|
|
66
|
+
delete env[key];
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (SECRET_SUFFIX_PATTERNS.some((suffix) => upper.endsWith(suffix))) {
|
|
70
|
+
delete env[key];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return env;
|
|
74
|
+
}
|
package/dist/plan.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Plan mode — ask the LLM for a plan before allowing tool use. */
|
|
2
|
+
import * as readline from "readline";
|
|
3
|
+
const PLAN_SUFFIX = `
|
|
4
|
+
|
|
5
|
+
## Plan mode
|
|
6
|
+
|
|
7
|
+
Before executing any tools, first describe your plan:
|
|
8
|
+
1. What you understand about the task
|
|
9
|
+
2. What files you'll need to read or modify
|
|
10
|
+
3. What approach you'll take
|
|
11
|
+
4. Any risks or uncertainties
|
|
12
|
+
|
|
13
|
+
Do NOT call any tools yet. Just describe your plan.`;
|
|
14
|
+
/** Append plan instruction to the system prompt. */
|
|
15
|
+
export function injectPlanPrompt(systemPrompt) {
|
|
16
|
+
return systemPrompt + PLAN_SUFFIX;
|
|
17
|
+
}
|
|
18
|
+
/** Ask the user to approve the plan. Returns true if approved. */
|
|
19
|
+
export async function requestPlanApproval() {
|
|
20
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
rl.question("\n\x1b[1mApprove this plan? [Y/n/edit] \x1b[0m", (answer) => {
|
|
23
|
+
rl.close();
|
|
24
|
+
const trimmed = answer.trim().toLowerCase();
|
|
25
|
+
if (trimmed === "n" || trimmed === "no") {
|
|
26
|
+
resolve({ approved: false });
|
|
27
|
+
}
|
|
28
|
+
else if (trimmed.startsWith("edit") || trimmed.length > 3) {
|
|
29
|
+
// Anything longer than "yes" is treated as feedback
|
|
30
|
+
const feedback = trimmed.startsWith("edit") ? trimmed.slice(4).trim() || "Please revise the plan." : trimmed;
|
|
31
|
+
resolve({ approved: false, feedback });
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
resolve({ approved: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export class AnthropicProvider {
|
|
2
|
+
name = "anthropic";
|
|
3
|
+
contextWindow = 200_000;
|
|
4
|
+
apiKey;
|
|
5
|
+
model;
|
|
6
|
+
constructor(apiKey, model) {
|
|
7
|
+
this.apiKey = apiKey;
|
|
8
|
+
this.model = model ?? "claude-sonnet-4-20250514";
|
|
9
|
+
}
|
|
10
|
+
async chat(system, messages, tools) {
|
|
11
|
+
const body = {
|
|
12
|
+
model: this.model,
|
|
13
|
+
system,
|
|
14
|
+
messages: messages.map((m) => ({
|
|
15
|
+
role: m.role,
|
|
16
|
+
content: m.content,
|
|
17
|
+
})),
|
|
18
|
+
max_tokens: 8192,
|
|
19
|
+
};
|
|
20
|
+
if (tools.length > 0) {
|
|
21
|
+
body.tools = tools.map((t) => ({
|
|
22
|
+
name: t.name,
|
|
23
|
+
description: t.description,
|
|
24
|
+
input_schema: t.input_schema,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
"x-api-key": this.apiKey,
|
|
32
|
+
"anthropic-version": "2023-06-01",
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const text = await res.text();
|
|
38
|
+
throw new Error(`Anthropic API error ${res.status}: ${text}`);
|
|
39
|
+
}
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
const content = data.content ?? [];
|
|
42
|
+
const stop_reason = data.stop_reason === "tool_use" ? "tool_use"
|
|
43
|
+
: data.stop_reason === "max_tokens" ? "max_tokens"
|
|
44
|
+
: "end_turn";
|
|
45
|
+
const usage = data.usage;
|
|
46
|
+
return {
|
|
47
|
+
content,
|
|
48
|
+
stop_reason: stop_reason,
|
|
49
|
+
usage: usage ? { input_tokens: usage.input_tokens ?? 0, output_tokens: usage.output_tokens ?? 0 } : undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async *chatStream(system, messages, tools) {
|
|
53
|
+
const body = {
|
|
54
|
+
model: this.model,
|
|
55
|
+
system,
|
|
56
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
57
|
+
max_tokens: 8192,
|
|
58
|
+
stream: true,
|
|
59
|
+
};
|
|
60
|
+
if (tools.length > 0) {
|
|
61
|
+
body.tools = tools.map((t) => ({
|
|
62
|
+
name: t.name,
|
|
63
|
+
description: t.description,
|
|
64
|
+
input_schema: t.input_schema,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
"x-api-key": this.apiKey,
|
|
72
|
+
"anthropic-version": "2023-06-01",
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
throw new Error(`Anthropic API error ${res.status}: ${text}`);
|
|
79
|
+
}
|
|
80
|
+
let stopReason = "end_turn";
|
|
81
|
+
let usage;
|
|
82
|
+
// Map block index to tool ID for consistent ID across start/delta/end
|
|
83
|
+
const indexToToolId = new Map();
|
|
84
|
+
for await (const event of parseSSE(res)) {
|
|
85
|
+
const type = event.event;
|
|
86
|
+
const data = event.data;
|
|
87
|
+
if (type === "content_block_start") {
|
|
88
|
+
const block = data.content_block;
|
|
89
|
+
if (block.type === "tool_use") {
|
|
90
|
+
const index = data.index;
|
|
91
|
+
const id = block.id;
|
|
92
|
+
indexToToolId.set(index, id);
|
|
93
|
+
yield { type: "tool_use_start", id, name: block.name };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (type === "content_block_delta") {
|
|
97
|
+
const delta = data.delta;
|
|
98
|
+
if (delta.type === "text_delta") {
|
|
99
|
+
yield { type: "text_delta", text: delta.text };
|
|
100
|
+
}
|
|
101
|
+
else if (delta.type === "input_json_delta") {
|
|
102
|
+
const index = data.index;
|
|
103
|
+
const id = indexToToolId.get(index) ?? String(index);
|
|
104
|
+
yield { type: "tool_use_delta", id, json: delta.partial_json };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (type === "content_block_stop") {
|
|
108
|
+
const index = data.index;
|
|
109
|
+
if (indexToToolId.has(index)) {
|
|
110
|
+
yield { type: "tool_use_end", id: indexToToolId.get(index) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (type === "message_delta") {
|
|
114
|
+
const delta = data.delta;
|
|
115
|
+
if (delta.stop_reason === "tool_use")
|
|
116
|
+
stopReason = "tool_use";
|
|
117
|
+
else if (delta.stop_reason === "max_tokens")
|
|
118
|
+
stopReason = "max_tokens";
|
|
119
|
+
const u = data.usage;
|
|
120
|
+
if (u) {
|
|
121
|
+
usage = {
|
|
122
|
+
input_tokens: u.input_tokens ?? 0,
|
|
123
|
+
output_tokens: u.output_tokens ?? 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else if (type === "message_start") {
|
|
128
|
+
const u = data.message?.usage;
|
|
129
|
+
if (u) {
|
|
130
|
+
usage = {
|
|
131
|
+
input_tokens: u.input_tokens ?? 0,
|
|
132
|
+
output_tokens: u.output_tokens ?? 0,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
yield { type: "done", stop_reason: stopReason, usage };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Parse SSE stream from a fetch Response. */
|
|
141
|
+
async function* parseSSE(res) {
|
|
142
|
+
if (!res.body)
|
|
143
|
+
throw new Error("Provider returned empty response body");
|
|
144
|
+
const reader = res.body.getReader();
|
|
145
|
+
const decoder = new TextDecoder();
|
|
146
|
+
let buf = "";
|
|
147
|
+
let currentEvent = "";
|
|
148
|
+
for (;;) {
|
|
149
|
+
const { done, value } = await reader.read();
|
|
150
|
+
if (done)
|
|
151
|
+
break;
|
|
152
|
+
buf += decoder.decode(value, { stream: true });
|
|
153
|
+
const lines = buf.split("\n");
|
|
154
|
+
buf = lines.pop();
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
if (line.startsWith("event: ")) {
|
|
157
|
+
currentEvent = line.slice(7).trim();
|
|
158
|
+
}
|
|
159
|
+
else if (line.startsWith("data: ")) {
|
|
160
|
+
const raw = line.slice(6);
|
|
161
|
+
if (raw === "[DONE]")
|
|
162
|
+
return;
|
|
163
|
+
try {
|
|
164
|
+
yield { event: currentEvent, data: JSON.parse(raw) };
|
|
165
|
+
}
|
|
166
|
+
catch { /* skip malformed JSON */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|