Stand up .pi/ as a real project workspace for the pi-coding-agent CLI:
- settings.json: OpenAI defaults (gpt-4o), model cycling list, declared resource paths
- mifune-banner extension: flush-left MIFUNE wordmark + pi harness · ryaneggz/mifune subtitle
- path-guard extension: tool_call interceptor with case-insensitive risky-bash regex,
hasUI headless guard, /dev/sd*|nvme*|hd*|disk* bare-redirection match, defensive
toolName.toLowerCase() (verified lowercase in pi source), canonical input key `path`
- APPEND_SYSTEM.md: generic, consumer-agnostic
- prompts/review.md: /review template
- skills + rules: directory symlinks to ../.claude/{skills,rules} (single source of
truth, mirrors .codex/ pattern)
- new canonical .claude/skills/harness-context/SKILL.md reachable from pi via symlink
Tests at .pi/extensions/__tests__/{path-guard,mifune-banner}.test.ts (32 + 7 cases,
hand-rolled mock ExtensionAPI, no pi devDep). vitest.config.ts include glob extended
to .pi/**/__tests__/**/*.test.ts. ci-harness.yml paths filter extended and a new
"Root tests" step runs pnpm test:scripts (also lifts cron-runtime.test.ts into CI).
86 tests passing across 6 files.
Closes #255
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
93 lines
3.3 KiB
TypeScript
93 lines
3.3 KiB
TypeScript
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
|
const SENSITIVE_PATHS: RegExp[] = [
|
|
/(^|\/)\.env(\..+)?$/,
|
|
/(^|\/)secrets?\//,
|
|
/(^|\/)credentials?\.(json|ya?ml)$/,
|
|
/\.pem$/,
|
|
/\.key$/,
|
|
/(^|\/)id_(rsa|ed25519|ecdsa)$/,
|
|
];
|
|
|
|
// Fix #2: All regexes now carry the `i` flag for case-insensitive matching.
|
|
// Fix #3: Block-device redirection regex updated to match bare `> /dev/...`
|
|
// without requiring a leading `:` (shell colon-truncate idiom).
|
|
// nvme devices use numeric suffixes (nvme0n1), disk\d+ likewise.
|
|
const RISKY_BASH = [
|
|
/\brm\s+-rf\b/i,
|
|
/\bsudo\b/i,
|
|
/\bgit\s+push\s+.*--force\b/i,
|
|
/\bgit\s+reset\s+--hard\b/i,
|
|
/(^|\s)>\s*\/dev\/(sd[a-z]|nvme\d*|hd[a-z]|disk\d+)\b/i,
|
|
];
|
|
|
|
// Tool names confirmed lowercase from pi source:
|
|
// packages/coding-agent/src/core/tools/index.ts:
|
|
// export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls";
|
|
const MUTATING_TOOLS = new Set(["write", "edit"]);
|
|
|
|
// Canonical input key for write and edit is `path` (verified from pi source):
|
|
// write.ts: const writeSchema = Type.Object({ path: Type.String(...), content: ... });
|
|
// edit.ts: const editSchema = Type.Object({ path: Type.String(...), edits: ... });
|
|
// Fallbacks retained for forward-compatibility with any future schema changes.
|
|
function pickPath(input: Record<string, unknown> | undefined): string | undefined {
|
|
if (!input) return undefined;
|
|
for (const key of ["path", "file_path", "target", "filename"]) {
|
|
const v = input[key];
|
|
if (typeof v === "string" && v.length > 0) return v;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
pi.on("tool_call", async (event, ctx) => {
|
|
// Fix #1: Guard against headless/RPC mode where ctx.ui is unavailable.
|
|
// Mirrors the pattern in mifune-banner.ts.
|
|
if (!ctx.hasUI) return;
|
|
|
|
// Fix #4: Defensive toLowerCase normalization on toolName. Pi uses
|
|
// lowercase names (confirmed from index.ts ToolName union), but this
|
|
// costs nothing and protects against future renames.
|
|
const toolName = event.toolName?.toLowerCase() ?? "";
|
|
|
|
if (MUTATING_TOOLS.has(toolName)) {
|
|
const path = pickPath(event.input as Record<string, unknown>);
|
|
if (path && SENSITIVE_PATHS.some((re) => re.test(path))) {
|
|
const ok = await ctx.ui.confirm(
|
|
"Sensitive path",
|
|
`Allow ${toolName} on ${path}?`,
|
|
);
|
|
if (!ok) return { block: true, reason: `Path ${path} is protected` };
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (toolName === "bash") {
|
|
const cmd = (event.input as { command?: string } | undefined)?.command ?? "";
|
|
if (RISKY_BASH.some((re) => re.test(cmd))) {
|
|
const ok = await ctx.ui.confirm(
|
|
"Risky command",
|
|
`Allow:\n${cmd.length > 200 ? cmd.slice(0, 200) + "..." : cmd}`,
|
|
);
|
|
if (!ok) return { block: true, reason: "User declined risky command" };
|
|
}
|
|
}
|
|
});
|
|
|
|
pi.registerCommand("guard", {
|
|
description: "Show what path-guard is protecting",
|
|
handler: async (_args, ctx) => {
|
|
const lines = [
|
|
"path-guard is active.",
|
|
"",
|
|
"Sensitive paths (write/edit prompt):",
|
|
...SENSITIVE_PATHS.map((re) => ` ${re.source}`),
|
|
"",
|
|
"Risky bash patterns (prompt before run):",
|
|
...RISKY_BASH.map((re) => ` ${re.source}`),
|
|
];
|
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
},
|
|
});
|
|
}
|