feat: harden workflow gates and expose token usage

This commit is contained in:
2026-07-21 12:14:26 +02:00
parent 8aa1676811
commit 2ff63d371f
20 changed files with 604 additions and 53 deletions
@@ -0,0 +1,33 @@
const test = require("node:test");
const assert = require("node:assert");
const { hasUngatedAssistantProse } = require("../../tht-gate.js");
test("assistant prose plus bash still requires a gate steer", () => {
assert.equal(
hasUngatedAssistantProse([
{
role: "assistant",
content: [
{ type: "toolCall", name: "bash" },
{ type: "text", text: "Continuo ad analizzare..." },
],
},
]),
true,
);
});
test("a real reviewer tool call suppresses the prose safety steer", () => {
assert.equal(
hasUngatedAssistantProse([
{
role: "assistant",
content: [
{ type: "text", text: "Domanda al reviewer" },
{ type: "toolCall", name: "reviewer_select" },
],
},
]),
false,
);
});
@@ -26,6 +26,32 @@ test("tht schema introspect senza --refresh passa (cache hit innocuo)", async ()
assert.equal(res, undefined);
});
for (const cmd of [
'find / -name "schema_linking.json"',
'find /Users/mp/projects/ThothII -name "schema_linking.json"',
'find . -name "schema_linking.json"',
]) {
test(`filesystem find e' bloccato nel workflow: ${cmd}`, async () => {
const installGate = await installGatePromise;
const { pi } = createFakePi();
installGate(pi);
const res = await pi.emit("tool_call", { toolName: "bash", input: { command: cmd } });
assert.equal(res?.block, true);
assert.match(res?.reason ?? "", /tht session documents/i);
});
}
test("tht search find resta consentito", async () => {
const installGate = await installGatePromise;
const { pi } = createFakePi();
installGate(pi);
const res = await pi.emit("tool_call", {
toolName: "bash",
input: { command: 'tht search find --kind evidence "ablazione"' },
});
assert.equal(res, undefined);
});
// Bash mutations of protected state bypass the write/edit hook: block them.
const BLOCKED_BASH = [
'echo \'{"type":"phase_approved","subject":"phase:4"}\' >> sessions/s1/review_decisions.jsonl',
@@ -30,13 +30,18 @@ const META = JSON.stringify({
});
// `cte next` walks the queue one entry per `decision add cte_approved`.
function useShell({ phase, cteQueue = [] }) {
function useShell({ phase, cteQueue = [], sqlContent = "SELECT 42" }) {
const calls = [];
const queue = [...cteQueue];
shell.current = (file, args) => {
calls.push(args.join(" "));
if (args[0] === "phase" && args[1] === "meta") return META;
if (args[0] === "phase" && args[1] === "show") return `Fase corrente: ${phase}\n`;
if (args[0] === "session" && args[1] === "documents") {
return JSON.stringify([
{ phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: sqlContent },
]);
}
if (args[0] === "cte" && args[1] === "next") return queue.length ? `${queue[0]}\n` : "";
if (args[0] === "decision" && args[1] === "add" && args.includes("cte_approved")) {
queue.shift();
@@ -47,7 +52,7 @@ function useShell({ phase, cteQueue = [] }) {
return calls;
}
async function runConfirm(kind, artifactKind) {
async function runConfirm(kind, artifactKind, { artifactData = "# md", onDescriptor } = {}) {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
@@ -57,11 +62,12 @@ async function runConfirm(kind, artifactKind) {
await pi.emit("session_start", {});
ctx.ui.input = async (title) => {
const d = JSON.parse(title);
onDescriptor?.(d);
return JSON.stringify({ id: d.id, kind: "artifact-gate", choices: ["approve"] });
};
return tools.get("reviewer_confirm").def.execute(
"call-1",
{ session: "s1", kind, title: "t", artifact: { kind: artifactKind, data: "# md" } },
{ session: "s1", kind, title: "t", artifact: { kind: artifactKind, data: artifactData } },
null,
null,
ctx,
@@ -104,3 +110,30 @@ test("approving the SQL closes F7 automatically (no separate phase gate)", async
);
assert.ok(!calls.some((c) => c.startsWith("session finalize")), "F7 must not finalize");
});
test("SQL approval hydrates the gate from persisted sql_final instead of trusting empty model data", async () => {
useShell({ phase: 7, sqlContent: "SELECT persisted_sql" });
let descriptor;
await runConfirm("sql", "sql", {
artifactData: {},
onDescriptor: (value) => { descriptor = value; },
});
assert.equal(descriptor.artifact.kind, "sql");
assert.equal(descriptor.artifact.data, "SELECT persisted_sql");
});
test("SQL approval refuses to show a gate when persisted sql_final is empty", async () => {
const calls = useShell({ phase: 7, sqlContent: "" });
let shown = false;
const result = await runConfirm("sql", "sql", {
artifactData: {},
onDescriptor: () => { shown = true; },
});
assert.equal(shown, false);
assert.match(result.content[0].text, /sql_final.*vuoto/i);
assert.ok(!calls.some((c) => c.includes("decision add")));
});
@@ -21,6 +21,8 @@ test("the resume kickoff injects the bootstrap steps and forces in-turn action",
assert.match(text, /<tht-sessione-skill>/);
assert.match(text, /# Thoth session workflow \(phases 1-8\)/);
assert.match(text, /non esplorare il repository/i);
assert.match(text, /tht session documents <id> --json/);
assert.match(text, /non cercare i file fisici con `find`/i);
assert.doesNotMatch(text, /Carica la skill leggendo/);
});
@@ -1,6 +1,10 @@
const test = require("node:test");
const assert = require("node:assert");
const { resolveSelectOutcome, decisionAddArgs } = require("../../tht-gate.js");
const {
resolveSelectOutcome,
decisionAddArgs,
decisionRecordedResultText,
} = require("../../tht-gate.js");
// Workstream F — single-select answers auto-confirm. A concrete reviewer_select choice
// that carries a `decision` payload IS the confirmation: the gate persists it directly
@@ -46,3 +50,15 @@ test("decisionAddArgs omits optional detail/rationale when absent", () => {
["decision", "add", "--session", "s1", "--type", "t", "--subject", "sub"],
);
});
test("a persisted select decision immediately directs the model to the next gate tool", () => {
const text = decisionRecordedResultText(
{ type: "concept_clarified" },
{ label: "Procedure invasive" },
);
assert.match(text, /Decisione registrata \(concept_clarified\): Procedure invasive\./);
assert.match(text, /tht session show/);
assert.match(text, /prossimo tool reviewer_/);
assert.match(text, /Non scrivere analisi o spiegazioni visibili/);
});