Skip to content
72 changes: 71 additions & 1 deletion extensions/gentle-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1773,6 +1773,40 @@ 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,
autoGate?: () => Promise<ToolCallEventResult | undefined>,
) => Promise<ToolCallEventResult | undefined>;
}

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, autoGate) =>
confirmCommand(command, ctx, events, herdrLifecycle, yoloActive, autoGate),
},
];

const PATH_GUARDED_TOOL_NAMES = new Set(["read", "write", "edit"]);
const PATH_INPUT_KEYS = new Set([
"path",
Expand Down Expand Up @@ -2173,6 +2207,32 @@ 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, and `autoGate`
* threads the Auto permission-mode gate the same way. 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,
autoGate?: () => Promise<ToolCallEventResult | undefined>,
): Promise<ToolCallEventResult | undefined> {
for (const policy of policies) {
const verdict = await policy.evaluate(command, ctx, events, herdrLifecycle, yoloActive, autoGate);
if (verdict !== undefined) return verdict;
}
return undefined;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down Expand Up @@ -9631,6 +9691,8 @@ export const __testing = {
loadRuntimeGuardrailsConfig,
isReversibleYoloCommand,
yoloConfiguredRestriction,
BASH_COMMAND_POLICIES,
evaluateBashPolicies,
buildGentlePrompt,
nativeStatusUnsupported,
nativeStartRejection,
Expand Down Expand Up @@ -10507,7 +10569,15 @@ function createGentleAiExtensionForTesting(
return { block: true, reason: "Permission mode changed during tool admission. This action was not run; retry under the current mode." };
}
const autoGate = permissionMode === "auto" ? gate : undefined;
return await confirmCommand(command, ctx, pi.events, herdrLifecycle, yoloActive, autoGate);
return await evaluateBashPolicies(
command,
ctx,
pi.events,
herdrLifecycle,
yoloActive,
BASH_COMMAND_POLICIES,
autoGate,
);
});

for (const owner of ["delegation", "review"] as const) {
Expand Down
212 changes: 212 additions & 0 deletions tests/autonomous-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,3 +910,215 @@ 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<typeof __testing.evaluateBashPolicies>;

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<BashPolicySeamArgs[5]>[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: forwards the Auto permission gate to every policy", async () => {
const autoGate = async () => undefined;
const seen: unknown[] = [];
const seam = makeBashPolicySeamHarness("/stub-cwd");
const result = await __testing.evaluateBashPolicies(
"echo hello",
seam.ctx,
seam.events,
seam.herdrLifecycle,
false,
[
{
name: "auto-gate-recorder",
async evaluate(_command, _ctx, _events, _herdrLifecycle, _yoloActive, gate) {
seen.push(gate);
return undefined;
},
},
],
autoGate,
);
assert.equal(result, undefined);
assert.deepEqual(seen, [autoGate], "the seam must thread the Auto gate into policies");
});

test("evaluateBashPolicies: real runtime-guardrails policy defers a non-guarded command to the Auto gate", async () => {
const cwd = mkdtempSync(join(tmpdir(), "gentle-ai-bash-policy-auto-"));
try {
const seam = makeBashPolicySeamHarness(cwd);
let gateCalls = 0;
const denied = { block: true as const, reason: "auto-mode classifier denied" };
const result = await __testing.evaluateBashPolicies(
"echo hello",
seam.ctx,
seam.events,
seam.herdrLifecycle,
false,
undefined,
async () => {
gateCalls += 1;
return denied;
},
);
assert.deepEqual(result, denied, "the Auto gate verdict must reach the caller through the seam");
assert.equal(gateCalls, 1);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});

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 });
}
});
Loading