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