Browse Source

Add audio alerts for block and confirm events

main
Matteo Benedetto 4 weeks ago
parent
commit
26dc88c40b
  1. 43
      README.md
  2. 163
      index.ts
  3. 4
      package-lock.json
  4. 2
      package.json
  5. 4
      policy-gate.example.json

43
README.md

@ -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

163
index.ts

@ -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,

4
package-lock.json generated

@ -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"

2
package.json

@ -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": [

4
policy-gate.example.json

@ -7,6 +7,10 @@
"confirmSensitiveReads": true,
"confirmSensitiveWrites": true,
"confirmWritesOutsideWorkspace": true,
"soundEnabled": true,
"soundConfirmEnabled": true,
"soundBlockEnabled": true,
"soundPlayer": "auto",
"sensitivePathGlobs": [
"~/.ssh/**",
"~/.aws/**",

Loading…
Cancel
Save