Add audio alerts for block and confirm events
This commit is contained in:
@@ -20,6 +20,7 @@ Built-in defaults:
|
||||
- `curl` / `wget` / `nc` / `socat` / `telnet` are **confirmed**
|
||||
- `env` / `printenv` are **confirmed**
|
||||
- bash-based writes to sensitive files such as `AGENTS.md`, `.env`, shell dotfiles, SSH config, and local policy files are **confirmed**
|
||||
- programmatically generated local **alert sounds** play on confirmation requests and blocks/refinements by default
|
||||
|
||||
## What it protects against
|
||||
|
||||
@@ -63,6 +64,32 @@ npm install git+ssh://git@git.enne2.net:222/enne2/pi-policy-gate.git
|
||||
|
||||
Then load it from local `node_modules` or publish it to your npm registry later.
|
||||
|
||||
## Sound alerts
|
||||
|
||||
The extension generates two small WAV files programmatically on first use:
|
||||
|
||||
- `confirm.wav` — played when human confirmation is required
|
||||
- `block.wav` — played when a command is denied or must be refined
|
||||
|
||||
By default it writes them under:
|
||||
|
||||
```text
|
||||
~/.pi/agent/policy-gate-sounds/
|
||||
```
|
||||
|
||||
It automatically uses the first available local player among:
|
||||
|
||||
- `paplay`
|
||||
- `pw-play`
|
||||
- `aplay`
|
||||
- `ffplay`
|
||||
- `play`
|
||||
|
||||
You can also test them manually inside pi:
|
||||
|
||||
- `/policy-gate-sound`
|
||||
- `/policy-gate-sound block`
|
||||
|
||||
## Config files
|
||||
|
||||
The extension looks for config files in this order:
|
||||
@@ -88,6 +115,10 @@ Example:
|
||||
{
|
||||
"workspaceRoots": ["."],
|
||||
"requireAbsolutePathForRecursiveDelete": true,
|
||||
"soundEnabled": true,
|
||||
"soundConfirmEnabled": true,
|
||||
"soundBlockEnabled": true,
|
||||
"soundPlayer": "auto",
|
||||
"overrides": [
|
||||
{
|
||||
"tool": "bash",
|
||||
@@ -108,6 +139,11 @@ Example:
|
||||
"confirmSensitiveReads": true,
|
||||
"confirmSensitiveWrites": true,
|
||||
"confirmWritesOutsideWorkspace": true,
|
||||
"soundEnabled": true,
|
||||
"soundConfirmEnabled": true,
|
||||
"soundBlockEnabled": true,
|
||||
"soundPlayer": "auto",
|
||||
"soundDirectory": "~/.pi/agent/policy-gate-sounds",
|
||||
"sensitivePathGlobs": ["~/.ssh/**", "**/.env"],
|
||||
"overrides": [
|
||||
{
|
||||
@@ -125,6 +161,11 @@ Example:
|
||||
|
||||
### Notes
|
||||
|
||||
- `soundPlayer: "auto"` picks the first available local player.
|
||||
- Set `soundEnabled: false` to disable all alert sounds.
|
||||
- `soundDirectory` can be absolute, `~`-based, or relative to the project cwd.
|
||||
|
||||
|
||||
- `workspaceRoots` can be absolute paths, `~` paths, or paths relative to the current project cwd.
|
||||
- `commandRegex` is evaluated against the raw bash command string.
|
||||
- `pathGlob` is matched against both the absolute path and the path relative to the current cwd.
|
||||
@@ -160,6 +201,8 @@ The extension tells the model to switch to a safer form such as using an explici
|
||||
Inside pi:
|
||||
|
||||
- `/policy-gate` — show current policy summary
|
||||
- `/policy-gate-sound` — play the confirmation sound
|
||||
- `/policy-gate-sound block` — play the block/refine sound
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@enne2/pi-policy-gate",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@enne2/pi-policy-gate",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.1.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@enne2/pi-policy-gate",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Policy gate extension for pi that classifies risky tool calls into allow, confirm, deny, or require-refinement.",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"confirmSensitiveReads": true,
|
||||
"confirmSensitiveWrites": true,
|
||||
"confirmWritesOutsideWorkspace": true,
|
||||
"soundEnabled": true,
|
||||
"soundConfirmEnabled": true,
|
||||
"soundBlockEnabled": true,
|
||||
"soundPlayer": "auto",
|
||||
"sensitivePathGlobs": [
|
||||
"~/.ssh/**",
|
||||
"~/.aws/**",
|
||||
|
||||
Reference in New Issue
Block a user