gentle-pi 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,9 +40,14 @@ type ToolCallHandler = (
40
40
  ctx: ExtensionContext,
41
41
  ) => Promise<unknown>;
42
42
 
43
+ interface RegisteredCommandFixture {
44
+ handler: (args: string, ctx: ExtensionContext) => Promise<void>;
45
+ }
46
+
43
47
  interface Runtime {
44
48
  controller: RegisteredTool;
45
49
  toolCall: ToolCallHandler;
50
+ commands: Map<string, RegisteredCommandFixture>;
46
51
  }
47
52
 
48
53
  interface PublicationProbeRequestFixture {
@@ -74,6 +79,7 @@ function runtime(
74
79
  candidateViews: CandidateViewRegistry | null = null,
75
80
  ): Runtime {
76
81
  const tools = new Map<string, RegisteredTool>();
82
+ const commands = new Map<string, RegisteredCommandFixture>();
77
83
  let toolCall: ToolCallHandler | undefined;
78
84
  const dependencies = { nativeReviewCli, publicationProbe, publicationProbeTimeoutMs, bashTimeRevalidationTimeoutMs, candidateViews } as unknown as Parameters<typeof createGentleAiExtension>[0];
79
85
  createGentleAiExtension(dependencies)({
@@ -81,12 +87,12 @@ function runtime(
81
87
  if (name === "tool_call") toolCall = handler;
82
88
  },
83
89
  registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
84
- registerCommand() {},
90
+ registerCommand(name: string, definition: RegisteredCommandFixture) { commands.set(name, definition); },
85
91
  } as unknown as ExtensionAPI);
86
92
  const controller = tools.get("gentle_review");
87
93
  assert.ok(controller);
88
94
  assert.ok(toolCall);
89
- return { controller, toolCall };
95
+ return { controller, toolCall, commands };
90
96
  }
91
97
 
92
98
  function context(cwd: string, signal?: AbortSignal): ExtensionContext {
@@ -287,6 +293,7 @@ function targetStatusFixture(options: {
287
293
  baseTree?: string;
288
294
  currentCandidateTree?: string;
289
295
  paths?: readonly string[];
296
+ projection?: "workspace" | "staged";
290
297
  } = {}): ReviewStatusV3 {
291
298
  const applicability = options.applicability ?? "current_target";
292
299
  const action = options.action ?? (applicability === "current_target" ? "finalize" : applicability === "unrelated" ? "start" : applicability === "ambiguous" ? "select_lineage" : "repair_authority");
@@ -302,7 +309,7 @@ function targetStatusFixture(options: {
302
309
  const projection = {
303
310
  schema: "gentle-ai.review-integration.projection/v1" as const,
304
311
  kind: "current-changes" as const,
305
- projection: "workspace" as const,
312
+ projection: options.projection ?? "workspace",
306
313
  baseTree,
307
314
  initialReviewTree: tree,
308
315
  currentCandidateTree: tree,
@@ -1896,7 +1903,181 @@ test("native allow registers one authorization and bash-time revalidation consum
1896
1903
  assert.equal(await toolCall({ toolName: "bash", input: { command } }, interactiveContext(cwd)), undefined);
1897
1904
  const replay = await toolCall({ toolName: "bash", input: { command } }, context(cwd)) as { block: boolean };
1898
1905
  assert.equal(replay.block, true);
1899
- assert.equal(validates, 2);
1906
+ assert.equal(validates, 3, "the replay discovers but cannot remint the consumed binding");
1907
+ });
1908
+
1909
+ test("fresh pre-commit consumes an exact approved native receipt without START or projection restoration", async (t) => {
1910
+ const cwd = repository(t);
1911
+ writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
1912
+ git(cwd, "add", "--", "app.ts");
1913
+ const tree = git(cwd, "write-tree");
1914
+ let starts = 0;
1915
+ const requests: Parameters<NativeReviewCli["validate"]>[0][] = [];
1916
+ const { toolCall } = runtime(fakeNative({
1917
+ start: async () => {
1918
+ starts += 1;
1919
+ throw new Error("an approved exact candidate must not start another review");
1920
+ },
1921
+ validate: async (request) => {
1922
+ requests.push(request);
1923
+ return { allowed: true, result: "allow", action: "continue", reason: "approved receipt binds the index", gateContext: nativeGateContext("approved-lineage", "r1", tree) };
1924
+ },
1925
+ targetStatus: async () => { throw new Error("approved receipt consumption must not reconstruct an unrelated projection"); },
1926
+ }), undefined, undefined, undefined, new CandidateViewRegistry());
1927
+ const command = "git commit -m approved";
1928
+ const input = { command };
1929
+
1930
+ assert.equal(await toolCall({ toolName: "bash", input }, context(cwd)), undefined);
1931
+ assert.equal(starts, 0);
1932
+ assert.equal(requests.length, 2, "authorization and bash-time TOCTOU validation must both run");
1933
+ assert.equal(requests[0]?.lineageId, undefined, "fresh receipt discovery is candidate-bound, not caller-selected");
1934
+ assert.equal(requests[1]?.lineageId, "approved-lineage");
1935
+ assert.notEqual(input.command, command, "the approved pre-commit must use the durable commit transaction");
1936
+ });
1937
+
1938
+ test("a consumed native receipt binding cannot remint authorization for the same exact command", async (t) => {
1939
+ const cwd = repository(t);
1940
+ writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
1941
+ git(cwd, "add", "--", "app.ts");
1942
+ const tree = git(cwd, "write-tree");
1943
+ let validations = 0;
1944
+ const { toolCall } = runtime(fakeNative({
1945
+ validate: async () => {
1946
+ validations += 1;
1947
+ return { allowed: true, result: "allow", action: "continue", reason: "same approved receipt", gateContext: nativeGateContext("same-lineage", "same-revision", tree) };
1948
+ },
1949
+ }));
1950
+ const command = "git commit -m same-binding";
1951
+
1952
+ assert.equal(await toolCall({ toolName: "bash", input: { command } }, context(cwd)), undefined);
1953
+ const replayInput = { command };
1954
+ const replay = await toolCall({ toolName: "bash", input: replayInput }, context(cwd)) as { block: boolean };
1955
+ assert.equal(replay.block, true);
1956
+ assert.equal(replayInput.command, command);
1957
+ assert.equal(validations, 3, "the replay may discover the binding once but must not revalidate or authorize it");
1958
+ });
1959
+
1960
+ test("a new approved candidate binding may authorize the same command and cwd later in the session", async (t) => {
1961
+ const cwd = repository(t);
1962
+ const command = "git commit -m reusable-command";
1963
+ const requests: Parameters<NativeReviewCli["validate"]>[0][] = [];
1964
+ const { toolCall } = runtime(fakeNative({
1965
+ validate: async (request) => {
1966
+ requests.push(request);
1967
+ const tree = git(cwd, "write-tree");
1968
+ const suffix = tree.slice(0, 8);
1969
+ return { allowed: true, result: "allow", action: "continue", reason: "candidate-bound receipt", gateContext: nativeGateContext(`lineage-${suffix}`, `revision-${suffix}`, tree) };
1970
+ },
1971
+ }));
1972
+ writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
1973
+ git(cwd, "add", "--", "app.ts");
1974
+ const firstInput = { command };
1975
+ assert.equal(await toolCall({ toolName: "bash", input: firstInput }, context(cwd)), undefined);
1976
+ assert.notEqual(firstInput.command, command);
1977
+
1978
+ writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
1979
+ git(cwd, "add", "--", "app.ts");
1980
+ const secondInput = { command };
1981
+ assert.equal(await toolCall({ toolName: "bash", input: secondInput }, context(cwd)), undefined);
1982
+ assert.notEqual(secondInput.command, command);
1983
+ assert.equal(requests.length, 4);
1984
+ assert.equal(requests[0]?.lineageId, undefined);
1985
+ assert.equal(requests[1]?.lineageId?.startsWith("lineage-"), true);
1986
+ assert.equal(requests[2]?.lineageId, undefined);
1987
+ assert.equal(requests[3]?.lineageId?.startsWith("lineage-"), true);
1988
+ });
1989
+
1990
+ test("a denied native discovery does not prevent a later valid receipt for the same command", async (t) => {
1991
+ const cwd = repository(t);
1992
+ writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
1993
+ git(cwd, "add", "--", "app.ts");
1994
+ const tree = git(cwd, "write-tree");
1995
+ let validations = 0;
1996
+ const { toolCall } = runtime(fakeNative({
1997
+ validate: async () => {
1998
+ validations += 1;
1999
+ return validations === 1
2000
+ ? { allowed: false, result: "scope-changed", action: "create-new-lineage", reason: "not approved yet", gateContext: nativeGateContext("", "denied", tree) }
2001
+ : { allowed: true, result: "allow", action: "continue", reason: "now approved", gateContext: nativeGateContext("later-lineage", "later-revision", tree) };
2002
+ },
2003
+ }));
2004
+ const command = "git commit -m later-approved";
2005
+
2006
+ assert.equal((await toolCall({ toolName: "bash", input: { command } }, context(cwd)) as { block: boolean }).block, true);
2007
+ const approvedInput = { command };
2008
+ assert.equal(await toolCall({ toolName: "bash", input: approvedInput }, context(cwd)), undefined);
2009
+ assert.notEqual(approvedInput.command, command);
2010
+ assert.equal(validations, 3);
2011
+ });
2012
+
2013
+ test("a blocked bash-time native gate does not consume its receipt binding", async (t) => {
2014
+ const cwd = repository(t);
2015
+ writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
2016
+ git(cwd, "add", "--", "app.ts");
2017
+ const tree = git(cwd, "write-tree");
2018
+ let validations = 0;
2019
+ const { toolCall } = runtime(fakeNative({
2020
+ validate: async () => {
2021
+ validations += 1;
2022
+ if (validations === 2) return { allowed: false, result: "invalidated", action: "explicit-maintainer-action", reason: "transient authority block", gateContext: nativeGateContext("retry-lineage", "retry-revision", tree) };
2023
+ return { allowed: true, result: "allow", action: "continue", reason: "approved", gateContext: nativeGateContext("retry-lineage", "retry-revision", tree) };
2024
+ },
2025
+ }));
2026
+ const command = "git commit -m retry-binding";
2027
+
2028
+ assert.equal((await toolCall({ toolName: "bash", input: { command } }, context(cwd)) as { block: boolean }).block, true);
2029
+ const retryInput = { command };
2030
+ assert.equal(await toolCall({ toolName: "bash", input: retryInput }, context(cwd)), undefined);
2031
+ assert.notEqual(retryInput.command, command);
2032
+ assert.equal(validations, 4);
2033
+ });
2034
+
2035
+ test("fresh pre-commit rejects an approved receipt for a different index tree", async (t) => {
2036
+ const cwd = repository(t);
2037
+ writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
2038
+ git(cwd, "add", "--", "app.ts");
2039
+ let validations = 0;
2040
+ const { toolCall } = runtime(fakeNative({
2041
+ validate: async () => {
2042
+ validations += 1;
2043
+ return { allowed: true, result: "allow", action: "continue", reason: "stale receipt", gateContext: nativeGateContext("stale-lineage", "r1", "f".repeat(40)) };
2044
+ },
2045
+ }));
2046
+ const command = "git commit -m stale";
2047
+ const input = { command };
2048
+
2049
+ const result = await toolCall({ toolName: "bash", input }, context(cwd)) as { block: boolean; reason: string };
2050
+ assert.equal(result.block, true);
2051
+ assert.match(result.reason, /does not bind the exact current pre-commit tree/);
2052
+ assert.equal(validations, 1);
2053
+ assert.equal(input.command, command);
2054
+ });
2055
+
2056
+ test("fresh pre-commit honors native disabled/unmanaged delivery without restoring stale authority", async (t) => {
2057
+ const cwd = repository(t);
2058
+ writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
2059
+ git(cwd, "add", "--", "app.ts");
2060
+ let validations = 0;
2061
+ const { toolCall } = runtime(fakeNative({
2062
+ validate: async () => {
2063
+ validations += 1;
2064
+ return {
2065
+ allowed: false,
2066
+ result: "invalidated",
2067
+ action: "repository-policy",
2068
+ reason: "receipt-driven development is disabled",
2069
+ gateContext: nativeGateContext("", "r1", git(cwd, "write-tree")),
2070
+ delivery: "disabled/unmanaged",
2071
+ };
2072
+ },
2073
+ targetStatus: async () => { throw new Error("disabled delivery must not restore stale authority"); },
2074
+ }));
2075
+ const command = "git commit -m unmanaged";
2076
+ const input = { command };
2077
+
2078
+ assert.equal(await toolCall({ toolName: "bash", input }, context(cwd)), undefined);
2079
+ assert.equal(validations, 1);
2080
+ assert.equal(input.command, command, "unmanaged delivery must remain an ordinary repository command");
1900
2081
  });
1901
2082
 
1902
2083
  test("native VALIDATE delivery disabled/unmanaged renders as a successful skipped envelope before the maintainer-exception check, minting no authorization", async (t) => {
@@ -1965,7 +2146,7 @@ test("native pre-commit after reload delegates exact-tree validation when no loc
1965
2146
  validations += 1;
1966
2147
  return { allowed: true, result: "allow", action: "continue", reason: "native receipt matches", gateContext: nativeGateContext("reloaded-lineage", "r1", git(cwd, "write-tree")) };
1967
2148
  },
1968
- targetStatus: async () => targetStatusFixture({ lineageId: "reloaded-lineage", baseTree: git(cwd, "rev-parse", "HEAD^{tree}"), currentCandidateTree: git(cwd, "write-tree"), paths: ["app.ts"] }),
2149
+ targetStatus: async () => targetStatusFixture({ lineageId: "reloaded-lineage", baseTree: git(cwd, "rev-parse", "HEAD^{tree}"), currentCandidateTree: git(cwd, "write-tree"), paths: ["app.ts"], projection: "staged" }),
1969
2150
  }), undefined, undefined, undefined, new CandidateViewRegistry());
1970
2151
  const validated = await controller.execute("reload-pre-commit", { operation: "validate", lineageId: "reloaded-lineage", idempotencyKey: "reload", command: "git commit -m reload", input: "{}" }, undefined, undefined, context(cwd));
1971
2152
  assert.equal(validations, 1);
@@ -3236,7 +3417,7 @@ test("native deny, target drift, and bash-time errors never restore an authoriza
3236
3417
  await drifting.controller.execute("allow", { operation: "validate", lineageId: "native-lineage", idempotencyKey: "key", command, input: "{}" }, undefined, undefined, context(cwd));
3237
3418
  assert.equal((await drifting.toolCall({ toolName: "bash", input: { command } }, context(cwd)) as { block: boolean }).block, true);
3238
3419
  assert.equal((await drifting.toolCall({ toolName: "bash", input: { command } }, context(cwd)) as { block: boolean }).block, true);
3239
- assert.equal(calls, 2);
3420
+ assert.equal(calls, 3, "a blocked gate is not consumed, so the retry performs one fresh fail-closed discovery");
3240
3421
 
3241
3422
  const failing = runtime(fakeNative({
3242
3423
  validate: async () => { throw new Error("native connection lost"); },
@@ -3877,3 +4058,63 @@ test("RECOVER rechecks a committed range against its frozen base instead of the
3877
4058
  assert.equal(recovers.length, 1);
3878
4059
  candidateViews.cleanupTerminal("native-lineage", "approved");
3879
4060
  });
4061
+
4062
+ test("dangerous push confirmation precedes mode reconsult outside a repository", async (t) => {
4063
+ const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-non-repository-push-"));
4064
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
4065
+ let modeCalls = 0;
4066
+ const { toolCall } = runtime(fakeNative({
4067
+ reviewMode: async () => {
4068
+ modeCalls += 1;
4069
+ throw new Error("mode lookup must not precede dangerous-command confirmation");
4070
+ },
4071
+ }));
4072
+ const interactive = interactiveContext(cwd);
4073
+ const deniedContext = { ...interactive, ui: { ...interactive.ui, confirm: async () => false } };
4074
+ const result = await toolCall(
4075
+ { toolName: "bash", input: { command: "git push" } },
4076
+ deniedContext,
4077
+ ) as { block: boolean; reason: string };
4078
+ assert.equal(result.block, true);
4079
+ assert.match(result.reason, /not confirmed/);
4080
+ assert.equal(modeCalls, 0);
4081
+ });
4082
+
4083
+ test("out-of-band review-mode disable discards stale lifecycle authorization and proceeds organically", async (t) => {
4084
+ const cwd = repository(t);
4085
+ let effective: "on" | "off" = "on";
4086
+ let validations = 0;
4087
+ const command = "git commit -m native";
4088
+ const { controller, toolCall } = runtime(fakeNative({
4089
+ reviewMode: async () => ({ operation: "status", scope: "both", status: { global: effective, cloneLocal: "", effective, source: effective === "off" ? "global" : "default" } }),
4090
+ validate: async () => { validations += 1; return { allowed: true, result: "allow", action: "continue", reason: "ok", gateContext: nativeGateContext() }; },
4091
+ targetStatus: async () => targetStatusFixture({ lineageId: "native-lineage", baseTree: git(cwd, "rev-parse", "HEAD^{tree}"), currentCandidateTree: git(cwd, "write-tree"), paths: [] }),
4092
+ }));
4093
+ const authorized = await controller.execute("authorize-before-disable", { operation: "validate", lineageId: "native-lineage", idempotencyKey: "disable-race", command, input: "{}" }, undefined, undefined, context(cwd));
4094
+ assert.notEqual((authorized.details as Record<string, unknown>).authorization, undefined);
4095
+ effective = "off";
4096
+ assert.equal(await toolCall({ toolName: "bash", input: { command } }, context(cwd)), undefined);
4097
+ assert.equal(validations, 1, "disabled organic delivery must not reuse or revalidate stale review authority");
4098
+ });
4099
+
4100
+ test("successful /gentle:review-mode disable clears pending authorizations even after mode is re-enabled", async (t) => {
4101
+ const cwd = repository(t);
4102
+ let effective: "on" | "off" = "on";
4103
+ const command = "git commit -m native";
4104
+ const { controller, toolCall, commands } = runtime(fakeNative({
4105
+ reviewMode: async (request) => {
4106
+ if (request.operation === "disable") effective = "off";
4107
+ if (request.operation === "enable") effective = "on";
4108
+ return { operation: request.operation, scope: "clone", status: { global: "", cloneLocal: effective === "off" ? "off" : "", effective, source: effective === "off" ? "clone_local" : "default" } };
4109
+ },
4110
+ validate: async () => ({ allowed: true, result: "allow", action: "continue", reason: "ok", gateContext: nativeGateContext() }),
4111
+ targetStatus: async () => targetStatusFixture({ lineageId: "native-lineage", baseTree: git(cwd, "rev-parse", "HEAD^{tree}"), currentCandidateTree: git(cwd, "write-tree"), paths: [] }),
4112
+ }));
4113
+ await controller.execute("authorize-before-command-disable", { operation: "validate", lineageId: "native-lineage", idempotencyKey: "command-disable", command, input: "{}" }, undefined, undefined, context(cwd));
4114
+ const commandContext = { ...interactiveContext(cwd), ui: { confirm: async () => true, notify: () => {} } } as unknown as ExtensionContext;
4115
+ await commands.get("gentle:review-mode")!.handler("disable", commandContext);
4116
+ await commands.get("gentle:review-mode")!.handler("enable", commandContext);
4117
+ const result = await toolCall({ toolName: "bash", input: { command } }, context(cwd)) as { block: boolean; reason: string };
4118
+ assert.equal(result.block, true);
4119
+ assert.match(result.reason, /receipt consumption failed closed/, "re-enabled delivery must perform fresh receipt discovery rather than reuse the pre-disable authorization");
4120
+ });
@@ -482,7 +482,7 @@ test("shipped controller and orchestrator contracts specify inspect-first compac
482
482
  assert.match(toolContract, /RESET.*reclaim.*RECOVER.*recover/s);
483
483
  assert.match(toolContract, /native-input-required.*never.*invent/is);
484
484
  assert.match(toolContract, /output.*lost|response.*lost|ambiguous.*START/is);
485
- assert.match(toolContract, /ambiguous START or FINALIZE.*target-scoped native status.*declared action/is);
485
+ assert.match(toolContract, /ambiguous START, answer-consent, or FINALIZE.*target-scoped native status.*declared action/is);
486
486
  assert.doesNotMatch(toolContract, /START throws.*lineage does not exist/is);
487
487
 
488
488
  for (const path of ["assets/orchestrator-delegation.md", "skills/gentle-ai/SKILL.md"]) {
@@ -387,15 +387,24 @@ test("failure context accepts scope_change or binding_revision but rejects both
387
387
  assert.throws(() => decodeReviewFailureV2(both), /exactly one|not allowed/);
388
388
  });
389
389
 
390
- test("consent rejects a swapped choice order and an invocation missing the exact answer suffix", () => {
390
+ test("consent rejects a swapped choice order, invalid answer domain, or invocation outside the frozen target", () => {
391
391
  const source = fixture<JsonObject>("consent.fixture.json");
392
392
  const swapped = clone(source);
393
393
  swapped.choices = [(source.choices as JsonObject[])[1], (source.choices as JsonObject[])[0]];
394
394
  assert.throws(() => decodeReviewConsentV2(swapped), /answer/);
395
395
 
396
+ const badAnswer = clone(source);
397
+ (badAnswer.choices as JsonObject[])[0].answer = "yes";
398
+ assert.throws(() => decodeReviewConsentV2(badAnswer), /answer/);
399
+
396
400
  const badInvocation = clone(source);
397
401
  (badInvocation.choices as JsonObject[])[0].invocation = "gentle-ai review start --contract gentle-ai.review-integration/v1 --consent granted";
398
402
  assert.throws(() => decodeReviewConsentV2(badInvocation), /invocation/);
403
+
404
+ const differentTarget = clone(source);
405
+ (differentTarget.choices as JsonObject[])[0].invocation = String((differentTarget.choices as JsonObject[])[0].invocation)
406
+ .replace(String(differentTarget.target_identity), `sha256:${"b".repeat(64)}`);
407
+ assert.throws(() => decodeReviewConsentV2(differentTarget), /target|invocation/);
399
408
  });
400
409
 
401
410
  test("next_transition decodes an execute variant and rejects a stop that carries a transition", () => {
@@ -364,13 +364,19 @@ async function run() {
364
364
  const toolCwd = await tempWorkspace();
365
365
  try {
366
366
  const toolHook = hooks.get("tool_call")[0];
367
+ const ghPrCwd = await tempWorkspace();
368
+ try {
369
+ execFileSync("git", ["init"], { cwd: ghPrCwd, stdio: "ignore" });
370
+ const receiptGate = await toolHook(
371
+ { toolName: "bash", input: { command: "gh pr create --draft" } },
372
+ createCtx(ghPrCwd, true, "receipt-gate-session"),
373
+ );
374
+ assert.equal(receiptGate.block, true);
375
+ assert.match(receiptGate.reason, /exactly derive.*--base/i);
376
+ } finally {
377
+ await rm(ghPrCwd, { recursive: true, force: true });
378
+ }
367
379
  assert.equal(await toolHook({ toolName: "bash", input: { command: "git status" } }, createCtx(toolCwd)), undefined);
368
- const receiptGate = await toolHook(
369
- { toolName: "bash", input: { command: "gh pr create --draft" } },
370
- createCtx(toolCwd, true, "receipt-gate-session"),
371
- );
372
- assert.equal(receiptGate.block, true);
373
- assert.match(receiptGate.reason, /exactly derive.*--base/i);
374
380
  const denied = await toolHook({ toolName: "bash", input: { command: "rm -rf /" } }, createCtx(toolCwd));
375
381
  assert.equal(denied.block, true);
376
382
  assert.match(denied.reason, /destructive/);
@@ -399,12 +405,18 @@ async function run() {
399
405
  0,
400
406
  "receipt validation must not launch or announce review actors",
401
407
  );
402
- const commitGate = await toolHook(
403
- { toolName: "bash", input: { command: "git commit -m bounded" } },
404
- createCtx(toolCwd),
405
- );
406
- assert.equal(commitGate.block, true);
407
- assert.match(commitGate.reason, /exactly derive/i);
408
+ const commitCwd = await tempWorkspace();
409
+ try {
410
+ execFileSync("git", ["init"], { cwd: commitCwd, stdio: "ignore" });
411
+ const commitGate = await toolHook(
412
+ { toolName: "bash", input: { command: "git commit -m bounded tracked.txt" } },
413
+ createCtx(commitCwd),
414
+ );
415
+ assert.equal(commitGate.block, true);
416
+ assert.match(commitGate.reason, /exactly derive/i);
417
+ } finally {
418
+ await rm(commitCwd, { recursive: true, force: true });
419
+ }
408
420
  } finally {
409
421
  await rm(toolCwd, { recursive: true, force: true });
410
422
  }