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.
- package/README.md +3 -12
- package/extensions/gentle-ai.ts +313 -119
- package/lib/gentle-ai-binary.ts +64 -17
- package/lib/native-review-cli.ts +158 -12
- package/lib/review-candidate-view.ts +9 -5
- package/lib/review-integration-v2.ts +5 -1
- package/package.json +1 -1
- package/runtime/gentle-ai-binary.mjs +64 -17
- package/runtime/native-review-cli.mjs +158 -12
- package/runtime/review-integration-v2.mjs +5 -1
- package/scripts/gentle-ai-installer.mjs +454 -71
- package/scripts/test-packed-runner.mjs +1 -1
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/tests/gentle-ai-binary.test.ts +70 -3
- package/tests/gentle-ai-installer.test.ts +543 -7
- package/tests/gentle-ai.test.ts +2 -2
- package/tests/native-review-consent.test.ts +128 -0
- package/tests/native-review-parity-runtime.test.ts +15 -6
- package/tests/native-review-parity.test.ts +155 -46
- package/tests/package-manifest.test.ts +10 -3
- package/tests/review-candidate-view.test.ts +43 -0
- package/tests/review-controller-native-routing.test.ts +247 -6
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-integration-v2.test.ts +10 -1
- package/tests/runtime-harness.mjs +24 -12
|
@@ -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"
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
}
|