forked from earendil-works/pi
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathtool-override.ts
More file actions
144 lines (127 loc) · 4.71 KB
/
Copy pathtool-override.ts
File metadata and controls
144 lines (127 loc) · 4.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
/**
* Tool Override Example - Demonstrates overriding built-in tools
*
* Extensions can register tools with the same name as built-in tools to replace them.
* This is useful for:
* - Adding logging or auditing to tool calls
* - Implementing access control or sandboxing
* - Routing tool calls to remote systems (e.g., pi-ssh-remote)
* - Modifying tool behavior for specific workflows
*
* This example overrides the `read` tool to:
* 1. Log all file access to a log file
* 2. Block access to sensitive paths (e.g., .env files)
* 3. Delegate to the original read implementation for allowed files
*
* Since no custom renderCall/renderResult are provided, the built-in renderer
* is used automatically (syntax highlighting, line numbers, truncation warnings).
*
* Usage:
* pi -e ./tool-override.ts
*/
import type { TextContent } from "@earendil-works/pi-ai";
import { type ExtensionAPI, getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import { constants, readFileSync } from "fs";
import { access, appendFile, readFile } from "fs/promises";
import { join, resolve } from "path";
import { Type } from "typebox";
const LOG_FILE = join(getAgentDir(), "read-access.log");
// Paths that are blocked from reading
const BLOCKED_PATTERNS = [
/\.env$/,
/\.env\..+$/,
/secrets?\.(json|yaml|yml|toml)$/i,
/credentials?\.(json|yaml|yml|toml)$/i,
/\/\.ssh\//,
/\/\.aws\//,
/\/\.gnupg\//,
];
function isBlockedPath(path: string): boolean {
return BLOCKED_PATTERNS.some((pattern) => pattern.test(path));
}
async function logAccess(path: string, allowed: boolean, reason?: string) {
const timestamp = new Date().toISOString();
const status = allowed ? "ALLOWED" : "BLOCKED";
const msg = reason ? ` (${reason})` : "";
const line = `[${timestamp}] ${status}: ${path}${msg}\n`;
try {
await withFileMutationQueue(LOG_FILE, async () => {
await appendFile(LOG_FILE, line);
});
} catch {
// Ignore logging errors
}
}
const readSchema = Type.Object({
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
});
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "read", // Same name as built-in - this will override it
label: "read (audited)",
description:
"Read the contents of a file with access logging. Some sensitive paths (.env, secrets, credentials) are blocked.",
parameters: readSchema,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { path, offset, limit } = params;
const absolutePath = resolve(ctx.cwd, path);
// Check if path is blocked
if (isBlockedPath(absolutePath)) {
await logAccess(absolutePath, false, "matches blocked pattern");
return {
content: [
{
type: "text",
text: `Access denied: "${path}" matches a blocked pattern (sensitive file). This tool blocks access to .env files, secrets, credentials, and SSH/AWS/GPG directories.`,
},
],
details: { blocked: true },
};
}
// Log allowed access
await logAccess(absolutePath, true);
// Perform the actual read (simplified implementation)
try {
await access(absolutePath, constants.R_OK);
const content = await readFile(absolutePath, "utf-8");
const lines = content.split("\n");
// Apply offset and limit
const startLine = offset ? Math.max(0, offset - 1) : 0;
const endLine = limit ? startLine + limit : lines.length;
const selectedLines = lines.slice(startLine, endLine);
// Basic truncation (50KB limit)
let text = selectedLines.join("\n");
const maxBytes = 50 * 1024;
if (Buffer.byteLength(text, "utf-8") > maxBytes) {
text = `${text.slice(0, maxBytes)}\n\n[Output truncated at 50KB]`;
}
return {
content: [{ type: "text", text }] as TextContent[],
details: { lines: lines.length },
};
} catch (error: any) {
return {
content: [{ type: "text", text: `Error reading file: ${error.message}` }] as TextContent[],
details: { error: true },
};
}
},
// No renderCall/renderResult - uses built-in renderer automatically
// (syntax highlighting, line numbers, truncation warnings, etc.)
});
// Also register a command to view the access log
pi.registerCommand("read-log", {
description: "View the file access log",
handler: async (_args, ctx) => {
try {
const log = readFileSync(LOG_FILE, "utf-8");
const lines = log.trim().split("\n").slice(-20); // Last 20 entries
ctx.ui.notify(`Recent file access:\n${lines.join("\n")}`, "info");
} catch {
ctx.ui.notify("No access log found", "info");
}
},
});
}