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

63
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) {

4
package-lock.json generated

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

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

1
policy-gate.example.json

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

Loading…
Cancel
Save