Browse Source

Differentiate confirm, deny, and refine sounds

main
Matteo Benedetto 4 weeks ago
parent
commit
9e11014693
  1. 14
      README.md
  2. 63
      index.ts
  3. 4
      package-lock.json
  4. 2
      package.json
  5. 1
      policy-gate.example.json

14
README.md

@ -66,10 +66,11 @@ Then load it from local `node_modules` or publish it to your npm registry later.
## Sound alerts ## Sound alerts
The extension generates two small WAV files programmatically on first use: The extension generates three small WAV files programmatically on first use:
- `confirm.wav` — played when human confirmation is required - `confirm.wav` — soft ascending alert for human confirmation
- `block.wav` — played when a command is denied or must be refined - `block.wav` — lower, harsher alert for denied actions
- `refine.wav` — distinct mid-tone alert for “blocked, but reformulate safely” cases
By default it writes them under: By default it writes them under:
@ -89,6 +90,7 @@ You can also test them manually inside pi:
- `/policy-gate-sound` - `/policy-gate-sound`
- `/policy-gate-sound block` - `/policy-gate-sound block`
- `/policy-gate-sound refine`
## Config files ## Config files
@ -118,6 +120,7 @@ Example:
"soundEnabled": true, "soundEnabled": true,
"soundConfirmEnabled": true, "soundConfirmEnabled": true,
"soundBlockEnabled": true, "soundBlockEnabled": true,
"soundRefineEnabled": true,
"soundPlayer": "auto", "soundPlayer": "auto",
"overrides": [ "overrides": [
{ {
@ -142,6 +145,7 @@ Example:
"soundEnabled": true, "soundEnabled": true,
"soundConfirmEnabled": true, "soundConfirmEnabled": true,
"soundBlockEnabled": true, "soundBlockEnabled": true,
"soundRefineEnabled": true,
"soundPlayer": "auto", "soundPlayer": "auto",
"soundDirectory": "~/.pi/agent/policy-gate-sounds", "soundDirectory": "~/.pi/agent/policy-gate-sounds",
"sensitivePathGlobs": ["~/.ssh/**", "**/.env"], "sensitivePathGlobs": ["~/.ssh/**", "**/.env"],
@ -163,6 +167,7 @@ Example:
- `soundPlayer: "auto"` picks the first available local player. - `soundPlayer: "auto"` picks the first available local player.
- Set `soundEnabled: false` to disable all alert sounds. - Set `soundEnabled: false` to disable all alert sounds.
- `soundConfirmEnabled`, `soundBlockEnabled`, and `soundRefineEnabled` let you mute just one class of event.
- `soundDirectory` can be absolute, `~`-based, or relative to the project cwd. - `soundDirectory` can be absolute, `~`-based, or relative to the project cwd.
@ -202,7 +207,8 @@ Inside pi:
- `/policy-gate` — show current policy summary - `/policy-gate` — show current policy summary
- `/policy-gate-sound` — play the confirmation sound - `/policy-gate-sound` — play the confirmation sound
- `/policy-gate-sound block` — play the block/refine sound - `/policy-gate-sound block` — play the deny sound
- `/policy-gate-sound refine` — play the refine sound
## Development ## Development

63
index.ts

@ -7,6 +7,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:pat
import { minimatch } from "minimatch"; import { minimatch } from "minimatch";
type PolicyAction = "allow" | "confirm" | "deny" | "refine"; type PolicyAction = "allow" | "confirm" | "deny" | "refine";
type AlertSoundKind = "confirm" | "block" | "refine";
type ToolName = "bash" | "read" | "write" | "edit"; type ToolName = "bash" | "read" | "write" | "edit";
@ -31,6 +32,7 @@ interface PolicyGateConfig {
soundEnabled?: boolean; soundEnabled?: boolean;
soundConfirmEnabled?: boolean; soundConfirmEnabled?: boolean;
soundBlockEnabled?: boolean; soundBlockEnabled?: boolean;
soundRefineEnabled?: boolean;
soundPlayer?: string; soundPlayer?: string;
soundDirectory?: string; soundDirectory?: string;
overrides?: RuleOverride[]; overrides?: RuleOverride[];
@ -47,6 +49,7 @@ interface ResolvedConfig {
soundEnabled: boolean; soundEnabled: boolean;
soundConfirmEnabled: boolean; soundConfirmEnabled: boolean;
soundBlockEnabled: boolean; soundBlockEnabled: boolean;
soundRefineEnabled: boolean;
soundPlayer: string; soundPlayer: string;
soundDirectory: string; soundDirectory: string;
overrides: RuleOverride[]; overrides: RuleOverride[];
@ -132,6 +135,7 @@ const DEFAULT_POLICY_FEATURES = [
"refine recursive deletes and find -delete into explicit absolute paths", "refine recursive deletes and find -delete into explicit absolute paths",
"confirm reads/writes against sensitive files and writes outside the workspace", "confirm reads/writes against sensitive files and writes outside the workspace",
"confirm bash-based file mutations targeting sensitive paths such as AGENTS.md, .env, shell dotfiles, SSH keys, and local policy files", "confirm bash-based file mutations targeting sensitive paths such as AGENTS.md, .env, shell dotfiles, SSH keys, and local policy files",
"play distinct generated sounds for confirmation, deny, and refine outcomes",
]; ];
const DEFAULT_CONFIG: ResolvedConfig = { const DEFAULT_CONFIG: ResolvedConfig = {
@ -145,6 +149,7 @@ const DEFAULT_CONFIG: ResolvedConfig = {
soundEnabled: true, soundEnabled: true,
soundConfirmEnabled: true, soundConfirmEnabled: true,
soundBlockEnabled: true, soundBlockEnabled: true,
soundRefineEnabled: true,
soundPlayer: "auto", soundPlayer: "auto",
soundDirectory: DEFAULT_SOUND_DIR, soundDirectory: DEFAULT_SOUND_DIR,
overrides: [], overrides: [],
@ -403,27 +408,37 @@ function resolveAudioPlayer(preferred: string): string | null {
return null; return null;
} }
function soundPath(kind: "confirm" | "block", config: ResolvedConfig): string { function soundPath(kind: AlertSoundKind, config: ResolvedConfig): string {
return join(config.soundDirectory, `${kind}.wav`); return join(config.soundDirectory, `${kind}.wav`);
} }
function createAlertWav(kind: "confirm" | "block"): Buffer { function createAlertWav(kind: AlertSoundKind): Buffer {
const sampleRate = 44100; const sampleRate = 44100;
const amplitude = 0.35; const amplitude = kind === "block" ? 0.42 : kind === "refine" ? 0.3 : 0.28;
const segments = const segments =
kind === "confirm" kind === "confirm"
? [ ? [
{ durationMs: 90, freqs: [740, 1110] }, { durationMs: 80, freqs: [660, 990] },
{ durationMs: 40, freqs: [] }, { durationMs: 35, freqs: [] },
{ durationMs: 120, freqs: [988, 1480] }, { durationMs: 95, freqs: [880, 1320] },
{ durationMs: 35, freqs: [] },
{ durationMs: 130, freqs: [1174, 1568] },
] ]
: [ : kind === "refine"
{ durationMs: 120, freqs: [440, 660] }, ? [
{ durationMs: 40, freqs: [] }, { durationMs: 95, freqs: [587, 784] },
{ durationMs: 130, freqs: [330, 494] }, { durationMs: 32, freqs: [] },
{ durationMs: 40, freqs: [] }, { durationMs: 95, freqs: [659, 880] },
{ durationMs: 180, freqs: [220, 330] }, { durationMs: 32, freqs: [] },
]; { durationMs: 95, freqs: [587, 784] },
]
: [
{ durationMs: 120, freqs: [392, 587] },
{ durationMs: 36, freqs: [] },
{ durationMs: 130, freqs: [294, 440] },
{ durationMs: 36, freqs: [] },
{ durationMs: 190, freqs: [196, 294] },
];
const sampleCount = segments.reduce((sum, segment) => sum + Math.round((segment.durationMs / 1000) * sampleRate), 0); const sampleCount = segments.reduce((sum, segment) => sum + Math.round((segment.durationMs / 1000) * sampleRate), 0);
const pcm = Buffer.alloc(sampleCount * 2); const pcm = Buffer.alloc(sampleCount * 2);
@ -469,18 +484,17 @@ function createAlertWav(kind: "confirm" | "block"): Buffer {
function ensureSoundAssets(config: ResolvedConfig) { function ensureSoundAssets(config: ResolvedConfig) {
if (!config.soundEnabled) return; if (!config.soundEnabled) return;
mkdirSync(config.soundDirectory, { recursive: true }); mkdirSync(config.soundDirectory, { recursive: true });
for (const kind of ["confirm", "block"] as const) { for (const kind of ["confirm", "block", "refine"] as const) {
const target = soundPath(kind, config); const target = soundPath(kind, config);
if (!existsSync(target)) { writeFileSync(target, createAlertWav(kind));
writeFileSync(target, createAlertWav(kind));
}
} }
} }
function playAlert(kind: "confirm" | "block", config: ResolvedConfig) { function playAlert(kind: AlertSoundKind, config: ResolvedConfig) {
if (!config.soundEnabled) return; if (!config.soundEnabled) return;
if (kind === "confirm" && !config.soundConfirmEnabled) return; if (kind === "confirm" && !config.soundConfirmEnabled) return;
if (kind === "block" && !config.soundBlockEnabled) return; if (kind === "block" && !config.soundBlockEnabled) return;
if (kind === "refine" && !config.soundRefineEnabled) return;
const player = resolveAudioPlayer(config.soundPlayer); const player = resolveAudioPlayer(config.soundPlayer);
if (!player) return; if (!player) return;
try { try {
@ -518,6 +532,7 @@ function mergeConfig(base: ResolvedConfig, patch: PolicyGateConfig | null, cwd:
soundEnabled: patch.soundEnabled ?? base.soundEnabled, soundEnabled: patch.soundEnabled ?? base.soundEnabled,
soundConfirmEnabled: patch.soundConfirmEnabled ?? base.soundConfirmEnabled, soundConfirmEnabled: patch.soundConfirmEnabled ?? base.soundConfirmEnabled,
soundBlockEnabled: patch.soundBlockEnabled ?? base.soundBlockEnabled, soundBlockEnabled: patch.soundBlockEnabled ?? base.soundBlockEnabled,
soundRefineEnabled: patch.soundRefineEnabled ?? base.soundRefineEnabled,
soundPlayer: patch.soundPlayer ?? base.soundPlayer, soundPlayer: patch.soundPlayer ?? base.soundPlayer,
soundDirectory: patch.soundDirectory ? resolveConfigPath(patch.soundDirectory, cwd) : base.soundDirectory, soundDirectory: patch.soundDirectory ? resolveConfigPath(patch.soundDirectory, cwd) : base.soundDirectory,
overrides: patch.overrides ? [...base.overrides, ...patch.overrides] : base.overrides, overrides: patch.overrides ? [...base.overrides, ...patch.overrides] : base.overrides,
@ -964,6 +979,7 @@ export default function policyGate(pi: ExtensionAPI) {
`Sound alerts enabled: ${activeConfig.soundEnabled}`, `Sound alerts enabled: ${activeConfig.soundEnabled}`,
`Confirm sound enabled: ${activeConfig.soundConfirmEnabled}`, `Confirm sound enabled: ${activeConfig.soundConfirmEnabled}`,
`Block sound enabled: ${activeConfig.soundBlockEnabled}`, `Block sound enabled: ${activeConfig.soundBlockEnabled}`,
`Refine sound enabled: ${activeConfig.soundRefineEnabled}`,
`Sound player: ${resolveAudioPlayer(activeConfig.soundPlayer) ?? "none"}`, `Sound player: ${resolveAudioPlayer(activeConfig.soundPlayer) ?? "none"}`,
`Sound directory: ${activeConfig.soundDirectory}`, `Sound directory: ${activeConfig.soundDirectory}`,
"Built-in default policy:", "Built-in default policy:",
@ -975,9 +991,10 @@ export default function policyGate(pi: ExtensionAPI) {
}); });
pi.registerCommand("policy-gate-sound", { pi.registerCommand("policy-gate-sound", {
description: "Play the confirmation or block alert sound (usage: /policy-gate-sound [confirm|block])", description: "Play the confirmation, block, or refine alert sound (usage: /policy-gate-sound [confirm|block|refine])",
handler: async (args, ctx) => { handler: async (args, ctx) => {
const kind = args?.trim() === "block" ? "block" : "confirm"; const normalized = args?.trim();
const kind: AlertSoundKind = normalized === "block" || normalized === "refine" ? normalized : "confirm";
playAlert(kind, activeConfig); playAlert(kind, activeConfig);
const message = `Played ${kind} alert with ${resolveAudioPlayer(activeConfig.soundPlayer) ?? "no player"}.`; const message = `Played ${kind} alert with ${resolveAudioPlayer(activeConfig.soundPlayer) ?? "no player"}.`;
if (ctx.hasUI) ctx.ui.notify(message, "info"); if (ctx.hasUI) ctx.ui.notify(message, "info");
@ -1024,10 +1041,14 @@ export default function policyGate(pi: ExtensionAPI) {
if (override) decision = override; if (override) decision = override;
if (decision.action === "allow") return undefined; if (decision.action === "allow") return undefined;
if (decision.action === "deny" || decision.action === "refine") { if (decision.action === "deny") {
playAlert("block", activeConfig); playAlert("block", activeConfig);
return { block: true, reason: formatDecision(decision) }; return { block: true, reason: formatDecision(decision) };
} }
if (decision.action === "refine") {
playAlert("refine", activeConfig);
return { block: true, reason: formatDecision(decision) };
}
playAlert("confirm", activeConfig); playAlert("confirm", activeConfig);
if (!ctx.hasUI) { if (!ctx.hasUI) {

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "@enne2/pi-policy-gate", "name": "@enne2/pi-policy-gate",
"version": "0.3.0", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@enne2/pi-policy-gate", "name": "@enne2/pi-policy-gate",
"version": "0.3.0", "version": "0.4.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"minimatch": "^10.1.1" "minimatch": "^10.1.1"

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "@enne2/pi-policy-gate", "name": "@enne2/pi-policy-gate",
"version": "0.3.0", "version": "0.4.0",
"description": "Policy gate extension for pi that classifies risky tool calls into allow, confirm, deny, or require-refinement.", "description": "Policy gate extension for pi that classifies risky tool calls into allow, confirm, deny, or require-refinement.",
"type": "module", "type": "module",
"keywords": [ "keywords": [

1
policy-gate.example.json

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

Loading…
Cancel
Save