diff --git a/README.md b/README.md index 1bd519f..6d82371 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,11 @@ 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: +The extension generates three 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 +- `confirm.wav` — soft ascending alert for human confirmation +- `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: @@ -89,6 +90,7 @@ You can also test them manually inside pi: - `/policy-gate-sound` - `/policy-gate-sound block` +- `/policy-gate-sound refine` ## Config files @@ -118,6 +120,7 @@ Example: "soundEnabled": true, "soundConfirmEnabled": true, "soundBlockEnabled": true, + "soundRefineEnabled": true, "soundPlayer": "auto", "overrides": [ { @@ -142,6 +145,7 @@ Example: "soundEnabled": true, "soundConfirmEnabled": true, "soundBlockEnabled": true, + "soundRefineEnabled": true, "soundPlayer": "auto", "soundDirectory": "~/.pi/agent/policy-gate-sounds", "sensitivePathGlobs": ["~/.ssh/**", "**/.env"], @@ -163,6 +167,7 @@ Example: - `soundPlayer: "auto"` picks the first available local player. - 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. @@ -202,7 +207,8 @@ Inside pi: - `/policy-gate` — show current policy summary - `/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 diff --git a/index.ts b/index.ts index 3e29705..2ca7e1f 100644 --- a/index.ts +++ b/index.ts @@ -7,6 +7,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:pat import { minimatch } from "minimatch"; type PolicyAction = "allow" | "confirm" | "deny" | "refine"; +type AlertSoundKind = "confirm" | "block" | "refine"; type ToolName = "bash" | "read" | "write" | "edit"; @@ -31,6 +32,7 @@ interface PolicyGateConfig { soundEnabled?: boolean; soundConfirmEnabled?: boolean; soundBlockEnabled?: boolean; + soundRefineEnabled?: boolean; soundPlayer?: string; soundDirectory?: string; overrides?: RuleOverride[]; @@ -47,6 +49,7 @@ interface ResolvedConfig { soundEnabled: boolean; soundConfirmEnabled: boolean; soundBlockEnabled: boolean; + soundRefineEnabled: boolean; soundPlayer: string; soundDirectory: string; overrides: RuleOverride[]; @@ -132,6 +135,7 @@ const DEFAULT_POLICY_FEATURES = [ "refine recursive deletes and find -delete into explicit absolute paths", "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", + "play distinct generated sounds for confirmation, deny, and refine outcomes", ]; const DEFAULT_CONFIG: ResolvedConfig = { @@ -145,6 +149,7 @@ const DEFAULT_CONFIG: ResolvedConfig = { soundEnabled: true, soundConfirmEnabled: true, soundBlockEnabled: true, + soundRefineEnabled: true, soundPlayer: "auto", soundDirectory: DEFAULT_SOUND_DIR, overrides: [], @@ -403,27 +408,37 @@ function resolveAudioPlayer(preferred: string): string | null { return null; } -function soundPath(kind: "confirm" | "block", config: ResolvedConfig): string { +function soundPath(kind: AlertSoundKind, config: ResolvedConfig): string { return join(config.soundDirectory, `${kind}.wav`); } -function createAlertWav(kind: "confirm" | "block"): Buffer { +function createAlertWav(kind: AlertSoundKind): Buffer { const sampleRate = 44100; - const amplitude = 0.35; + const amplitude = kind === "block" ? 0.42 : kind === "refine" ? 0.3 : 0.28; const segments = kind === "confirm" ? [ - { durationMs: 90, freqs: [740, 1110] }, - { durationMs: 40, freqs: [] }, - { durationMs: 120, freqs: [988, 1480] }, + { durationMs: 80, freqs: [660, 990] }, + { durationMs: 35, freqs: [] }, + { durationMs: 95, freqs: [880, 1320] }, + { durationMs: 35, freqs: [] }, + { durationMs: 130, freqs: [1174, 1568] }, ] - : [ - { durationMs: 120, freqs: [440, 660] }, - { durationMs: 40, freqs: [] }, - { durationMs: 130, freqs: [330, 494] }, - { durationMs: 40, freqs: [] }, - { durationMs: 180, freqs: [220, 330] }, - ]; + : kind === "refine" + ? [ + { durationMs: 95, freqs: [587, 784] }, + { durationMs: 32, freqs: [] }, + { durationMs: 95, freqs: [659, 880] }, + { 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 pcm = Buffer.alloc(sampleCount * 2); @@ -469,18 +484,17 @@ function createAlertWav(kind: "confirm" | "block"): Buffer { function ensureSoundAssets(config: ResolvedConfig) { if (!config.soundEnabled) return; 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); - 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 (kind === "confirm" && !config.soundConfirmEnabled) return; if (kind === "block" && !config.soundBlockEnabled) return; + if (kind === "refine" && !config.soundRefineEnabled) return; const player = resolveAudioPlayer(config.soundPlayer); if (!player) return; try { @@ -518,6 +532,7 @@ function mergeConfig(base: ResolvedConfig, patch: PolicyGateConfig | null, cwd: soundEnabled: patch.soundEnabled ?? base.soundEnabled, soundConfirmEnabled: patch.soundConfirmEnabled ?? base.soundConfirmEnabled, soundBlockEnabled: patch.soundBlockEnabled ?? base.soundBlockEnabled, + soundRefineEnabled: patch.soundRefineEnabled ?? base.soundRefineEnabled, soundPlayer: patch.soundPlayer ?? base.soundPlayer, soundDirectory: patch.soundDirectory ? resolveConfigPath(patch.soundDirectory, cwd) : base.soundDirectory, overrides: patch.overrides ? [...base.overrides, ...patch.overrides] : base.overrides, @@ -964,6 +979,7 @@ export default function policyGate(pi: ExtensionAPI) { `Sound alerts enabled: ${activeConfig.soundEnabled}`, `Confirm sound enabled: ${activeConfig.soundConfirmEnabled}`, `Block sound enabled: ${activeConfig.soundBlockEnabled}`, + `Refine sound enabled: ${activeConfig.soundRefineEnabled}`, `Sound player: ${resolveAudioPlayer(activeConfig.soundPlayer) ?? "none"}`, `Sound directory: ${activeConfig.soundDirectory}`, "Built-in default policy:", @@ -975,9 +991,10 @@ 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])", + description: "Play the confirmation, block, or refine alert sound (usage: /policy-gate-sound [confirm|block|refine])", 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); const message = `Played ${kind} alert with ${resolveAudioPlayer(activeConfig.soundPlayer) ?? "no player"}.`; if (ctx.hasUI) ctx.ui.notify(message, "info"); @@ -1024,10 +1041,14 @@ export default function policyGate(pi: ExtensionAPI) { if (override) decision = override; if (decision.action === "allow") return undefined; - if (decision.action === "deny" || decision.action === "refine") { + if (decision.action === "deny") { playAlert("block", activeConfig); return { block: true, reason: formatDecision(decision) }; } + if (decision.action === "refine") { + playAlert("refine", activeConfig); + return { block: true, reason: formatDecision(decision) }; + } playAlert("confirm", activeConfig); if (!ctx.hasUI) { diff --git a/package-lock.json b/package-lock.json index 5a60509..4e1c2e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@enne2/pi-policy-gate", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@enne2/pi-policy-gate", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "minimatch": "^10.1.1" diff --git a/package.json b/package.json index 94d9f63..56461bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "type": "module", "keywords": [ diff --git a/policy-gate.example.json b/policy-gate.example.json index 9c162b0..589a382 100644 --- a/policy-gate.example.json +++ b/policy-gate.example.json @@ -10,6 +10,7 @@ "soundEnabled": true, "soundConfirmEnabled": true, "soundBlockEnabled": true, + "soundRefineEnabled": true, "soundPlayer": "auto", "sensitivePathGlobs": [ "~/.ssh/**",