Differentiate confirm, deny, and refine sounds
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
+1
-1
@@ -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": [
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"soundEnabled": true,
|
||||
"soundConfirmEnabled": true,
|
||||
"soundBlockEnabled": true,
|
||||
"soundRefineEnabled": true,
|
||||
"soundPlayer": "auto",
|
||||
"sensitivePathGlobs": [
|
||||
"~/.ssh/**",
|
||||
|
||||
Reference in New Issue
Block a user