mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 11:02:17 +00:00
143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
/**
|
|
* 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 "@mariozechner/pi-ai";
|
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
import { Type } from "@sinclair/typebox";
|
|
import { appendFileSync, constants, readFileSync } from "fs";
|
|
import { access, readFile } from "fs/promises";
|
|
import { homedir } from "os";
|
|
import { join, resolve } from "path";
|
|
|
|
const LOG_FILE = join(homedir(), ".pi", "agent", "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));
|
|
}
|
|
|
|
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 {
|
|
appendFileSync(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, _onUpdate, ctx) {
|
|
const { path, offset, limit } = params;
|
|
const absolutePath = resolve(ctx.cwd, path);
|
|
|
|
// Check if path is blocked
|
|
if (isBlockedPath(absolutePath)) {
|
|
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
|
|
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");
|
|
}
|
|
},
|
|
});
|
|
}
|