|
|
|
|
@ -1,8 +1,9 @@
|
|
|
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; |
|
|
|
|
import { getAgentDir, isToolCallEventType } from "@earendil-works/pi-coding-agent"; |
|
|
|
|
import { existsSync, readFileSync, realpathSync } from "node:fs"; |
|
|
|
|
import { spawn, spawnSync } from "node:child_process"; |
|
|
|
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; |
|
|
|
|
import os from "node:os"; |
|
|
|
|
import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; |
|
|
|
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; |
|
|
|
|
import { minimatch } from "minimatch"; |
|
|
|
|
|
|
|
|
|
type PolicyAction = "allow" | "confirm" | "deny" | "refine"; |
|
|
|
|
@ -27,6 +28,11 @@ interface PolicyGateConfig {
|
|
|
|
|
confirmSensitiveWrites?: boolean; |
|
|
|
|
confirmWritesOutsideWorkspace?: boolean; |
|
|
|
|
sensitivePathGlobs?: string[]; |
|
|
|
|
soundEnabled?: boolean; |
|
|
|
|
soundConfirmEnabled?: boolean; |
|
|
|
|
soundBlockEnabled?: boolean; |
|
|
|
|
soundPlayer?: string; |
|
|
|
|
soundDirectory?: string; |
|
|
|
|
overrides?: RuleOverride[]; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -38,6 +44,11 @@ interface ResolvedConfig {
|
|
|
|
|
confirmSensitiveWrites: boolean; |
|
|
|
|
confirmWritesOutsideWorkspace: boolean; |
|
|
|
|
sensitivePathGlobs: string[]; |
|
|
|
|
soundEnabled: boolean; |
|
|
|
|
soundConfirmEnabled: boolean; |
|
|
|
|
soundBlockEnabled: boolean; |
|
|
|
|
soundPlayer: string; |
|
|
|
|
soundDirectory: string; |
|
|
|
|
overrides: RuleOverride[]; |
|
|
|
|
configSources: string[]; |
|
|
|
|
} |
|
|
|
|
@ -52,8 +63,11 @@ interface Decision {
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const HOME = os.homedir(); |
|
|
|
|
const GLOBAL_CONFIG = resolve(getAgentDir(), "policy-gate.json"); |
|
|
|
|
const AGENT_DIR = getAgentDir(); |
|
|
|
|
const GLOBAL_CONFIG = resolve(AGENT_DIR, "policy-gate.json"); |
|
|
|
|
const PROJECT_CONFIG_NAME = ".pi/policy-gate.json"; |
|
|
|
|
const DEFAULT_SOUND_DIR = resolve(AGENT_DIR, "policy-gate-sounds"); |
|
|
|
|
const AUDIO_PLAYER_CANDIDATES = ["paplay", "pw-play", "aplay", "ffplay", "play"]; |
|
|
|
|
|
|
|
|
|
const DEFAULT_SENSITIVE_PATH_GLOBS = [ |
|
|
|
|
"~/.ssh/**", |
|
|
|
|
@ -128,6 +142,11 @@ const DEFAULT_CONFIG: ResolvedConfig = {
|
|
|
|
|
confirmSensitiveWrites: true, |
|
|
|
|
confirmWritesOutsideWorkspace: true, |
|
|
|
|
sensitivePathGlobs: DEFAULT_SENSITIVE_PATH_GLOBS, |
|
|
|
|
soundEnabled: true, |
|
|
|
|
soundConfirmEnabled: true, |
|
|
|
|
soundBlockEnabled: true, |
|
|
|
|
soundPlayer: "auto", |
|
|
|
|
soundDirectory: DEFAULT_SOUND_DIR, |
|
|
|
|
overrides: [], |
|
|
|
|
configSources: [], |
|
|
|
|
}; |
|
|
|
|
@ -366,6 +385,120 @@ function readJsonFile<T>(path: string): T | null {
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let detectedAudioPlayer: string | null | undefined; |
|
|
|
|
|
|
|
|
|
function resolveAudioPlayer(preferred: string): string | null { |
|
|
|
|
if (preferred && preferred !== "auto") { |
|
|
|
|
return spawnSync(preferred, ["--help"], { stdio: "ignore" }).error ? null : preferred; |
|
|
|
|
} |
|
|
|
|
if (detectedAudioPlayer !== undefined) return detectedAudioPlayer; |
|
|
|
|
for (const candidate of AUDIO_PLAYER_CANDIDATES) { |
|
|
|
|
const result = spawnSync(candidate, ["--help"], { stdio: "ignore" }); |
|
|
|
|
if (!result.error) { |
|
|
|
|
detectedAudioPlayer = candidate; |
|
|
|
|
return candidate; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
detectedAudioPlayer = null; |
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function soundPath(kind: "confirm" | "block", config: ResolvedConfig): string { |
|
|
|
|
return join(config.soundDirectory, `${kind}.wav`); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function createAlertWav(kind: "confirm" | "block"): Buffer { |
|
|
|
|
const sampleRate = 44100; |
|
|
|
|
const amplitude = 0.35; |
|
|
|
|
const segments = |
|
|
|
|
kind === "confirm" |
|
|
|
|
? [ |
|
|
|
|
{ durationMs: 90, freqs: [740, 1110] }, |
|
|
|
|
{ durationMs: 40, freqs: [] }, |
|
|
|
|
{ durationMs: 120, freqs: [988, 1480] }, |
|
|
|
|
] |
|
|
|
|
: [ |
|
|
|
|
{ durationMs: 120, freqs: [440, 660] }, |
|
|
|
|
{ durationMs: 40, freqs: [] }, |
|
|
|
|
{ durationMs: 130, freqs: [330, 494] }, |
|
|
|
|
{ durationMs: 40, freqs: [] }, |
|
|
|
|
{ durationMs: 180, freqs: [220, 330] }, |
|
|
|
|
]; |
|
|
|
|
|
|
|
|
|
const sampleCount = segments.reduce((sum, segment) => sum + Math.round((segment.durationMs / 1000) * sampleRate), 0); |
|
|
|
|
const pcm = Buffer.alloc(sampleCount * 2); |
|
|
|
|
let sampleOffset = 0; |
|
|
|
|
|
|
|
|
|
for (const segment of segments) { |
|
|
|
|
const segmentSamples = Math.round((segment.durationMs / 1000) * sampleRate); |
|
|
|
|
for (let i = 0; i < segmentSamples; i += 1) { |
|
|
|
|
const globalIndex = sampleOffset + i; |
|
|
|
|
let sample = 0; |
|
|
|
|
if (segment.freqs.length > 0) { |
|
|
|
|
const t = i / sampleRate; |
|
|
|
|
const attack = Math.min(1, i / Math.max(1, segmentSamples * 0.1)); |
|
|
|
|
const release = Math.min(1, (segmentSamples - i) / Math.max(1, segmentSamples * 0.12)); |
|
|
|
|
const envelope = Math.min(attack, release); |
|
|
|
|
for (const freq of segment.freqs) { |
|
|
|
|
sample += Math.sin(2 * Math.PI * freq * t); |
|
|
|
|
} |
|
|
|
|
sample = (sample / segment.freqs.length) * amplitude * envelope; |
|
|
|
|
} |
|
|
|
|
pcm.writeInt16LE(Math.max(-1, Math.min(1, sample)) * 32767, globalIndex * 2); |
|
|
|
|
} |
|
|
|
|
sampleOffset += segmentSamples; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const header = Buffer.alloc(44); |
|
|
|
|
header.write("RIFF", 0); |
|
|
|
|
header.writeUInt32LE(36 + pcm.length, 4); |
|
|
|
|
header.write("WAVE", 8); |
|
|
|
|
header.write("fmt ", 12); |
|
|
|
|
header.writeUInt32LE(16, 16); |
|
|
|
|
header.writeUInt16LE(1, 20); |
|
|
|
|
header.writeUInt16LE(1, 22); |
|
|
|
|
header.writeUInt32LE(sampleRate, 24); |
|
|
|
|
header.writeUInt32LE(sampleRate * 2, 28); |
|
|
|
|
header.writeUInt16LE(2, 32); |
|
|
|
|
header.writeUInt16LE(16, 34); |
|
|
|
|
header.write("data", 36); |
|
|
|
|
header.writeUInt32LE(pcm.length, 40); |
|
|
|
|
return Buffer.concat([header, pcm]); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function ensureSoundAssets(config: ResolvedConfig) { |
|
|
|
|
if (!config.soundEnabled) return; |
|
|
|
|
mkdirSync(config.soundDirectory, { recursive: true }); |
|
|
|
|
for (const kind of ["confirm", "block"] as const) { |
|
|
|
|
const target = soundPath(kind, config); |
|
|
|
|
if (!existsSync(target)) { |
|
|
|
|
writeFileSync(target, createAlertWav(kind)); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function playAlert(kind: "confirm" | "block", config: ResolvedConfig) { |
|
|
|
|
if (!config.soundEnabled) return; |
|
|
|
|
if (kind === "confirm" && !config.soundConfirmEnabled) return; |
|
|
|
|
if (kind === "block" && !config.soundBlockEnabled) return; |
|
|
|
|
const player = resolveAudioPlayer(config.soundPlayer); |
|
|
|
|
if (!player) return; |
|
|
|
|
try { |
|
|
|
|
ensureSoundAssets(config); |
|
|
|
|
const file = soundPath(kind, config); |
|
|
|
|
const args = |
|
|
|
|
player === "ffplay" |
|
|
|
|
? ["-nodisp", "-autoexit", "-loglevel", "quiet", file] |
|
|
|
|
: player === "play" |
|
|
|
|
? ["-q", file] |
|
|
|
|
: [file]; |
|
|
|
|
const child = spawn(player, args, { stdio: "ignore", detached: true }); |
|
|
|
|
child.unref(); |
|
|
|
|
} catch { |
|
|
|
|
// best-effort notification only
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function mergeConfig(base: ResolvedConfig, patch: PolicyGateConfig | null, cwd: string, source?: string): ResolvedConfig { |
|
|
|
|
if (!patch) return base; |
|
|
|
|
|
|
|
|
|
@ -382,6 +515,11 @@ function mergeConfig(base: ResolvedConfig, patch: PolicyGateConfig | null, cwd:
|
|
|
|
|
confirmWritesOutsideWorkspace: |
|
|
|
|
patch.confirmWritesOutsideWorkspace ?? base.confirmWritesOutsideWorkspace, |
|
|
|
|
sensitivePathGlobs: patch.sensitivePathGlobs ?? base.sensitivePathGlobs, |
|
|
|
|
soundEnabled: patch.soundEnabled ?? base.soundEnabled, |
|
|
|
|
soundConfirmEnabled: patch.soundConfirmEnabled ?? base.soundConfirmEnabled, |
|
|
|
|
soundBlockEnabled: patch.soundBlockEnabled ?? base.soundBlockEnabled, |
|
|
|
|
soundPlayer: patch.soundPlayer ?? base.soundPlayer, |
|
|
|
|
soundDirectory: patch.soundDirectory ? resolveConfigPath(patch.soundDirectory, cwd) : base.soundDirectory, |
|
|
|
|
overrides: patch.overrides ? [...base.overrides, ...patch.overrides] : base.overrides, |
|
|
|
|
configSources: source ? [...base.configSources, source] : base.configSources, |
|
|
|
|
}; |
|
|
|
|
@ -791,6 +929,7 @@ export default function policyGate(pi: ExtensionAPI) {
|
|
|
|
|
pi.on("session_start", async (_event, ctx) => { |
|
|
|
|
const extraConfig = pi.getFlag("policy-gate-config") as string | undefined; |
|
|
|
|
activeConfig = loadConfig(ctx.cwd, extraConfig ? resolveConfigPath(extraConfig, ctx.cwd) : undefined); |
|
|
|
|
ensureSoundAssets(activeConfig); |
|
|
|
|
if (ctx.hasUI) { |
|
|
|
|
ctx.ui.setStatus("policy-gate", ctx.ui.theme.fg("accent", "policy-gate active")); |
|
|
|
|
} |
|
|
|
|
@ -822,6 +961,11 @@ export default function policyGate(pi: ExtensionAPI) {
|
|
|
|
|
`Sensitive writes require confirm: ${activeConfig.confirmSensitiveWrites}`, |
|
|
|
|
`Writes outside workspace require confirm: ${activeConfig.confirmWritesOutsideWorkspace}`, |
|
|
|
|
`Recursive delete requires absolute path: ${activeConfig.requireAbsolutePathForRecursiveDelete}`, |
|
|
|
|
`Sound alerts enabled: ${activeConfig.soundEnabled}`, |
|
|
|
|
`Confirm sound enabled: ${activeConfig.soundConfirmEnabled}`, |
|
|
|
|
`Block sound enabled: ${activeConfig.soundBlockEnabled}`, |
|
|
|
|
`Sound player: ${resolveAudioPlayer(activeConfig.soundPlayer) ?? "none"}`, |
|
|
|
|
`Sound directory: ${activeConfig.soundDirectory}`, |
|
|
|
|
"Built-in default policy:", |
|
|
|
|
...DEFAULT_POLICY_FEATURES.map((feature) => `- ${feature}`), |
|
|
|
|
].join("\n"); |
|
|
|
|
@ -830,6 +974,17 @@ export default function policyGate(pi: ExtensionAPI) {
|
|
|
|
|
}, |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
pi.registerCommand("policy-gate-sound", { |
|
|
|
|
description: "Play the confirmation or block alert sound (usage: /policy-gate-sound [confirm|block])", |
|
|
|
|
handler: async (args, ctx) => { |
|
|
|
|
const kind = args?.trim() === "block" ? "block" : "confirm"; |
|
|
|
|
playAlert(kind, activeConfig); |
|
|
|
|
const message = `Played ${kind} alert with ${resolveAudioPlayer(activeConfig.soundPlayer) ?? "no player"}.`; |
|
|
|
|
if (ctx.hasUI) ctx.ui.notify(message, "info"); |
|
|
|
|
else console.log(message); |
|
|
|
|
}, |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
pi.on("tool_call", async (event, ctx) => { |
|
|
|
|
const cwd = ctx.cwd; |
|
|
|
|
let decision: Decision | null = null; |
|
|
|
|
@ -870,9 +1025,11 @@ export default function policyGate(pi: ExtensionAPI) {
|
|
|
|
|
|
|
|
|
|
if (decision.action === "allow") return undefined; |
|
|
|
|
if (decision.action === "deny" || decision.action === "refine") { |
|
|
|
|
playAlert("block", activeConfig); |
|
|
|
|
return { block: true, reason: formatDecision(decision) }; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
playAlert("confirm", activeConfig); |
|
|
|
|
if (!ctx.hasUI) { |
|
|
|
|
return { |
|
|
|
|
block: true, |
|
|
|
|
|