From 4d259c5f1628e1fa8b363f7c1a39553a42236aee Mon Sep 17 00:00:00 2001 From: danielgap Date: Wed, 9 Sep 2026 02:04:46 +0200 Subject: [PATCH 1/3] feat(safety): ordered bash command policy seam (#405 S1) Restructure the model-initiated bash tool_call tail into an ordered, named policy list (BASH_COMMAND_POLICIES) evaluated by evaluateBashPolicies: first non-undefined verdict wins, undefined defers to the next policy, overall undefined allows. The only entry is the runtime-guardrails adapter over the byte-identical confirmCommand, so every verdict, block reason, emitted event sequence, headless fail-safe, and config precedence is unchanged. Exposed via __testing with order/short-circuit/allow tests. Work unit S1 of #405: no new policy modules, no config schema change, no second tool_call handler. --- extensions/gentle-ai.ts | 67 ++++++++++++++++- tests/autonomous-guard.test.ts | 132 +++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 1 deletion(-) diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 22d3353f6..061aa3217 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -1588,6 +1588,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", @@ -1904,6 +1937,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); } @@ -9363,6 +9420,8 @@ export const __testing = { loadRuntimeGuardrailsConfig, isOrdinaryYoloPush, yoloPushConfiguredRestriction, + BASH_COMMAND_POLICIES, + evaluateBashPolicies, buildGentlePrompt, nativeStatusUnsupported, nativeStartRejection, @@ -10128,7 +10187,13 @@ function createGentleAiExtensionForTesting( const childDenied = blockChildDestructiveCommand(command); if (childDenied) return childDenied; } - return await confirmCommand(command, ctx, pi.events, herdrLifecycle, yoloActive); + return await evaluateBashPolicies( + 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..319c9e97e 100644 --- a/tests/autonomous-guard.test.ts +++ b/tests/autonomous-guard.test.ts @@ -718,3 +718,135 @@ 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: () => {}, on: () => {} } as BashPolicySeamArgs[2], + 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: 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, + [ + 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, + [ + 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, + ); + 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, + ); + 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 }); + } +}); From c99d453abde4bad6925dd9cf80cace3b6de439d4 Mon Sep 17 00:00:00 2001 From: danielgap Date: Fri, 11 Sep 2026 23:48:33 +0200 Subject: [PATCH 2/3] test(safety): make the bash policy seam harness satisfy the EventBus interface The seam harness mocked ExtensionAPI["events"] as { emit: () => {}, on: () => {} } with an `as` cast, which trips TS2352: EventBus.on must return an unsubscribe function, and a void-returning stub does not overlap. Give the mock the real emit/on signatures so it matches EventBus structurally and the cast disappears entirely, keeping the typecheck ratchet baseline at 205. --- tests/autonomous-guard.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/autonomous-guard.test.ts b/tests/autonomous-guard.test.ts index 319c9e97e..6185e5bf6 100644 --- a/tests/autonomous-guard.test.ts +++ b/tests/autonomous-guard.test.ts @@ -726,7 +726,10 @@ type BashPolicySeamArgs = Parameters; function makeBashPolicySeamHarness(cwd: string) { return { ctx: { cwd, hasUI: false, ui: {} } as BashPolicySeamArgs[1], - events: { emit: () => {}, on: () => {} } as BashPolicySeamArgs[2], + events: { + emit: (_channel: string, _data: unknown) => {}, + on: (_channel: string, _handler: (data: unknown) => void) => () => {}, + }, herdrLifecycle: { begin: () => {}, settle: () => {}, From 495b1818c250bfd431a96ace1d95e8c08f2be99b Mon Sep 17 00:00:00 2001 From: danielgap Date: Fri, 2 Oct 2026 12:12:10 +0200 Subject: [PATCH 3/3] test(safety): prove the bash policy seam threads yoloActive (#405) --- tests/autonomous-guard.test.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/autonomous-guard.test.ts b/tests/autonomous-guard.test.ts index 6185e5bf6..1c9b37ce7 100644 --- a/tests/autonomous-guard.test.ts +++ b/tests/autonomous-guard.test.ts @@ -741,7 +741,7 @@ function makeStubBashPolicy( name: string, verdict: { block: true; reason: string } | undefined, calls: string[], -): NonNullable[number] { +): NonNullable[number] { return { name, async evaluate(_command: string) { @@ -751,6 +751,29 @@ function makeStubBashPolicy( }; } +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), @@ -773,6 +796,7 @@ test("evaluateBashPolicies: short-circuits on the first verdict (later policies seam.ctx, seam.events, seam.herdrLifecycle, + false, [ makeStubBashPolicy("first", firstVerdict, calls), makeStubBashPolicy( @@ -798,6 +822,7 @@ test("evaluateBashPolicies: allow verdict from the last policy returns undefined seam.ctx, seam.events, seam.herdrLifecycle, + false, [ makeStubBashPolicy("first-allow", undefined, calls), makeStubBashPolicy("last-allow", undefined, calls), @@ -822,6 +847,7 @@ test("evaluateBashPolicies: real runtime-guardrails policy blocks hard-deny comm seam.ctx, seam.events, seam.herdrLifecycle, + false, ); assert.equal(result?.block, true); assert.match(result?.reason ?? "", /destructive/); @@ -844,6 +870,7 @@ test("evaluateBashPolicies: real runtime-guardrails policy allows a non-guarded seam.ctx, seam.events, seam.herdrLifecycle, + false, ); assert.equal(result, undefined); } finally {