diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 99c1015c5..260886498 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -1549,6 +1549,39 @@ function loadRuntimeGuardrailsConfig( } } +/** + * Ordered policy seam for the model-initiated `bash` tool_call path + * (gentle-pi#405 work unit S1). + * + * `evaluateBashPolicies` runs these policies in list order and the first + * verdict (a non-undefined ToolCallEventResult) wins: a policy returning + * `undefined` allows the command to pass to the next policy, and an overall + * `undefined` means no policy objected. Future policies (package-manager, + * SQL) append entries here so evaluation order stays explicit and greppable + * through the stable `name` strings. + */ +interface BashCommandPolicy { + /** Stable, greppable identifier; tests pin the ordered list by name. */ + name: string; + evaluate: ( + command: string, + ctx: ExtensionContext, + events: ExtensionAPI["events"], + herdrLifecycle: HerdrConfirmationLifecycle, + yoloActive: boolean, + ) => Promise; +} + +const BASH_COMMAND_POLICIES: readonly BashCommandPolicy[] = [ + { + name: "runtime-guardrails", + // Thin adapter preserving confirmCommand's async signature; the guard + // logic itself stays byte-identical inside confirmCommand. + evaluate: async (command, ctx, events, herdrLifecycle, yoloActive) => + confirmCommand(command, ctx, events, herdrLifecycle, yoloActive), + }, +]; + const PATH_GUARDED_TOOL_NAMES = new Set(["read", "write", "edit"]); const PATH_INPUT_KEYS = new Set([ "path", @@ -1865,6 +1898,30 @@ async function confirmCommand( }; } +/** + * Evaluate the ordered bash command policies for a model-initiated `bash` + * tool call. Policies run in `BASH_COMMAND_POLICIES` order; the first policy + * to return a verdict terminates evaluation with that verdict, while an + * `undefined` from a policy defers to the remaining policies. `yoloActive` + * threads the session YOLO state through to every policy. The optional + * `policies` argument exists for tests and future composition; production + * callers use the default ordered list. + */ +async function evaluateBashPolicies( + command: string, + ctx: ExtensionContext, + events: ExtensionAPI["events"], + herdrLifecycle: HerdrConfirmationLifecycle, + yoloActive: boolean, + policies: readonly BashCommandPolicy[] = BASH_COMMAND_POLICIES, +): Promise { + for (const policy of policies) { + const verdict = await policy.evaluate(command, ctx, events, herdrLifecycle, yoloActive); + if (verdict !== undefined) return verdict; + } + return undefined; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -8828,6 +8885,8 @@ export const __testing = { guardedCommandPreview, guardedCommandTitle, loadRuntimeGuardrailsConfig, + BASH_COMMAND_POLICIES, + evaluateBashPolicies, isOrdinaryYoloPush, yoloPushConfiguredRestriction, buildGentlePrompt, @@ -9561,7 +9620,13 @@ function createGentleAiExtensionForTesting( const childDenied = blockChildDestructiveCommand(event.input.command); if (childDenied) return childDenied; } - return await confirmCommand(event.input.command, ctx, pi.events, herdrLifecycle, yoloActive); + return await evaluateBashPolicies( + event.input.command, + ctx, + pi.events, + herdrLifecycle, + yoloActive, + ); }); for (const owner of ["delegation", "review"] as const) { diff --git a/tests/autonomous-guard.test.ts b/tests/autonomous-guard.test.ts index 3061e3e05..1c9b37ce7 100644 --- a/tests/autonomous-guard.test.ts +++ b/tests/autonomous-guard.test.ts @@ -718,3 +718,165 @@ test("loadRuntimeGuardrailsConfig: autonomousMode:{} (object) in JSON does NOT a rmSync(dir, { recursive: true, force: true }); } }); +// evaluateBashPolicies — ordered bash command policy seam (gentle-pi#405 S1) +// --------------------------------------------------------------------------- + +type BashPolicySeamArgs = Parameters; + +function makeBashPolicySeamHarness(cwd: string) { + return { + ctx: { cwd, hasUI: false, ui: {} } as BashPolicySeamArgs[1], + events: { + emit: (_channel: string, _data: unknown) => {}, + on: (_channel: string, _handler: (data: unknown) => void) => () => {}, + }, + herdrLifecycle: { + begin: () => {}, + settle: () => {}, + } as BashPolicySeamArgs[3], + }; +} + +function makeStubBashPolicy( + name: string, + verdict: { block: true; reason: string } | undefined, + calls: string[], +): NonNullable[number] { + return { + name, + async evaluate(_command: string) { + calls.push(name); + return verdict; + }, + }; +} + +test("evaluateBashPolicies: forwards yoloActive to every policy", async () => { + const seen: boolean[] = []; + const seam = makeBashPolicySeamHarness("/stub-cwd"); + const result = await __testing.evaluateBashPolicies( + "git push", + seam.ctx, + seam.events, + seam.herdrLifecycle, + true, + [ + { + name: "yolo-recorder", + async evaluate(_command, _ctx, _events, _herdrLifecycle, yoloActive) { + seen.push(yoloActive); + return undefined; + }, + }, + ], + ); + assert.equal(result, undefined); + assert.deepEqual(seen, [true], "the seam must thread yoloActive into policies"); +}); + +test('evaluateBashPolicies: ordered policy list is exactly ["runtime-guardrails"]', () => { + assert.deepEqual( + __testing.BASH_COMMAND_POLICIES.map((policy) => policy.name), + ["runtime-guardrails"], + ); + assert.ok( + __testing.BASH_COMMAND_POLICIES.every( + (policy) => typeof policy.evaluate === "function", + ), + "every policy must expose an evaluate function", + ); +}); + +test("evaluateBashPolicies: short-circuits on the first verdict (later policies are not evaluated)", async () => { + const calls: string[] = []; + const firstVerdict = { block: true, reason: "first policy verdict" } as const; + const seam = makeBashPolicySeamHarness("/stub-cwd"); + const result = await __testing.evaluateBashPolicies( + "ignored by stubs", + seam.ctx, + seam.events, + seam.herdrLifecycle, + false, + [ + makeStubBashPolicy("first", firstVerdict, calls), + makeStubBashPolicy( + "second", + { block: true, reason: "second policy verdict" }, + calls, + ), + ], + ); + assert.deepEqual(result, firstVerdict); + assert.deepEqual( + calls, + ["first"], + "the second policy must not run after a verdict", + ); +}); + +test("evaluateBashPolicies: allow verdict from the last policy returns undefined overall", async () => { + const calls: string[] = []; + const seam = makeBashPolicySeamHarness("/stub-cwd"); + const result = await __testing.evaluateBashPolicies( + "ignored by stubs", + seam.ctx, + seam.events, + seam.herdrLifecycle, + false, + [ + makeStubBashPolicy("first-allow", undefined, calls), + makeStubBashPolicy("last-allow", undefined, calls), + ], + ); + assert.equal(result, undefined); + assert.deepEqual( + calls, + ["first-allow", "last-allow"], + "an allow must not short-circuit remaining policies", + ); +}); + +test("evaluateBashPolicies: real runtime-guardrails policy blocks hard-deny commands through the seam", async () => { + const dir = makeTmpDir(); + const originalConfigHome = process.env.GENTLE_PI_CONFIG_HOME; + process.env.GENTLE_PI_CONFIG_HOME = join(dir, "config-home"); + try { + const seam = makeBashPolicySeamHarness(dir); + const result = await __testing.evaluateBashPolicies( + "rm -rf /", + seam.ctx, + seam.events, + seam.herdrLifecycle, + false, + ); + assert.equal(result?.block, true); + assert.match(result?.reason ?? "", /destructive/); + } finally { + if (originalConfigHome === undefined) + delete process.env.GENTLE_PI_CONFIG_HOME; + else process.env.GENTLE_PI_CONFIG_HOME = originalConfigHome; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("evaluateBashPolicies: real runtime-guardrails policy allows a non-guarded command through the seam", async () => { + const dir = makeTmpDir(); + const originalConfigHome = process.env.GENTLE_PI_CONFIG_HOME; + process.env.GENTLE_PI_CONFIG_HOME = join(dir, "config-home"); + try { + const seam = makeBashPolicySeamHarness(dir); + const result = await __testing.evaluateBashPolicies( + "echo hello", + seam.ctx, + seam.events, + seam.herdrLifecycle, + false, + ); + assert.equal(result, undefined); + } finally { + if (originalConfigHome === undefined) + delete process.env.GENTLE_PI_CONFIG_HOME; + else process.env.GENTLE_PI_CONFIG_HOME = originalConfigHome; + rmSync(dir, { recursive: true, force: true }); + } +});