feat: harden workflow gates and expose token usage
This commit is contained in:
@@ -165,6 +165,14 @@ export function isProtectedBashMutation(cmd) {
|
||||
return PROTECTED_PATH_TOKEN.test(cmd) && BASH_MUTATION.test(cmd);
|
||||
}
|
||||
|
||||
// Session artifacts are exposed by deterministic `tht session documents`; shell
|
||||
// discovery is both unnecessary and dangerous (a model once escalated to `find /`
|
||||
// and wedged the whole RPC turn). Match shell command boundaries so the supported
|
||||
// `tht search find` subcommand remains available.
|
||||
export function isFilesystemFind(cmd) {
|
||||
return /(?:^|[;&|\n])\s*(?:(?:sudo|command)\s+)?find(?:\s|$)/.test(cmd);
|
||||
}
|
||||
|
||||
// --- kickoff payloads (verbatim from source L184-212, load-bearing model prose) -
|
||||
const NUOVA_DOMANDA_KICKOFF =
|
||||
"AZIONE IMMEDIATA OBBLIGATORIA: la skill canonica è già inclusa integralmente nel " +
|
||||
@@ -480,6 +488,35 @@ export function decisionAddArgs(session, d) {
|
||||
return args;
|
||||
}
|
||||
|
||||
// Keep the model inside the gate workflow immediately after a reviewer selection.
|
||||
// The agent_end prose safety net is too late for providers that keep streaming a
|
||||
// single, very long assistant turn: put the continuation contract in the tool result
|
||||
// that unlocks the model after the reviewer response.
|
||||
export function decisionRecordedResultText(decision, option) {
|
||||
return (
|
||||
`Decisione registrata (${decision.type}): ${option.label}. ` +
|
||||
"Ora rileggi lo stato persistito con `tht session show` e invoca immediatamente " +
|
||||
"il prossimo tool reviewer_ richiesto dal workflow. " +
|
||||
"Non scrivere analisi o spiegazioni visibili."
|
||||
);
|
||||
}
|
||||
|
||||
export function hasUngatedAssistantProse(messages) {
|
||||
const last = messages?.[messages.length - 1];
|
||||
if (!last || last.role !== "assistant" || !Array.isArray(last.content))
|
||||
return false;
|
||||
const hasText = last.content.some(
|
||||
(block) => block.type === "text" && (block.text ?? "").trim().length > 0,
|
||||
);
|
||||
const hasReviewerTool = last.content.some(
|
||||
(block) =>
|
||||
block.type === "toolCall" &&
|
||||
typeof block.name === "string" &&
|
||||
block.name.startsWith("reviewer_"),
|
||||
);
|
||||
return hasText && !hasReviewerTool;
|
||||
}
|
||||
|
||||
// Workstream F: classifies a reviewer_select response into the action the gate takes.
|
||||
// A concrete choice that carries a `decision` payload auto-confirms (persist directly,
|
||||
// no second gate); a bare choice stays ask-only; back/exit/Other never persist.
|
||||
@@ -618,6 +655,15 @@ export default function (pi) {
|
||||
lastSteered = false;
|
||||
return;
|
||||
}
|
||||
if (isFilesystemFind(cmd)) {
|
||||
return {
|
||||
block: true,
|
||||
reason:
|
||||
"Non cercare file con `find`: gli artefatti persistiti della sessione " +
|
||||
"si leggono con `tht session documents <id> --json`; per catalogo ed " +
|
||||
"evidence usa `tht schema render` / `tht search find`.",
|
||||
};
|
||||
}
|
||||
if (FORBIDDEN.some((re) => re.test(cmd))) {
|
||||
return {
|
||||
block: true,
|
||||
@@ -739,17 +785,7 @@ export default function (pi) {
|
||||
// tool call (and the lock is active), nudge it back to the gate tools.
|
||||
pi.on("agent_end", async (event) => {
|
||||
if (!lockActive) return;
|
||||
const msgs = event.messages ?? [];
|
||||
const last = msgs[msgs.length - 1];
|
||||
const isProse =
|
||||
last &&
|
||||
last.role === "assistant" &&
|
||||
Array.isArray(last.content) &&
|
||||
last.content.some(
|
||||
(b) => b.type === "text" && (b.text ?? "").trim().length > 0,
|
||||
) &&
|
||||
!last.content.some((b) => b.type === "toolCall");
|
||||
if (isProse && !lastSteered) {
|
||||
if (hasUngatedAssistantProse(event.messages ?? []) && !lastSteered) {
|
||||
lastSteered = true;
|
||||
await pi.sendUserMessage(
|
||||
"Le risposte del reviewer arrivano solo dai widget del gate. Riproponi la " +
|
||||
@@ -832,7 +868,7 @@ export default function (pi) {
|
||||
if (err) return err;
|
||||
if (advance) advanceIfReady(ctx, session);
|
||||
return textResult(
|
||||
`Decisione registrata (${outcome.decision.type}): ${outcome.option.label}.`,
|
||||
decisionRecordedResultText(outcome.decision, outcome.option),
|
||||
);
|
||||
}
|
||||
return textResult(
|
||||
@@ -1159,6 +1195,36 @@ export default function (pi) {
|
||||
const curNum = currentPhase(ctx, session);
|
||||
const phase = phaseId(ctx, curNum);
|
||||
|
||||
// F7's review target is the persisted artifact, never the model-authored
|
||||
// descriptor payload. A model can (and did) call reviewer_confirm with
|
||||
// `artifact.data: {}` even though write_final_sql had already persisted a
|
||||
// non-empty sql_final.sql; trusting that payload rendered a blank approval
|
||||
// dialog. Hydrate from the repository boundary and fail closed before any
|
||||
// widget is shown if the artifact is unavailable or empty.
|
||||
if (kind === "sql") {
|
||||
let docs;
|
||||
try {
|
||||
docs = JSON.parse(tht(ctx, ["session", "documents", session, "--json"]));
|
||||
} catch (e) {
|
||||
const msg = (e.stderr || e.message || String(e)).toString().trim();
|
||||
return textResult(
|
||||
`Impossibile leggere sql_final.sql dalla sessione ${session}: ${msg}. ` +
|
||||
"Verifica l'artefatto persistito e riprova.",
|
||||
);
|
||||
}
|
||||
const sqlDoc = Array.isArray(docs)
|
||||
? docs.find((doc) => doc && doc.key === "sql" && doc.format === "sql")
|
||||
: null;
|
||||
const sql = typeof sqlDoc?.content === "string" ? sqlDoc.content : "";
|
||||
if (!sql.trim()) {
|
||||
return textResult(
|
||||
`sql_final.sql assente o vuoto per la sessione ${session}: ` +
|
||||
"scrivi un SQL finale valido prima di ripresentare il gate F7.",
|
||||
);
|
||||
}
|
||||
artifact = { ...artifact, kind: "sql", data: sql };
|
||||
}
|
||||
|
||||
// Sessione gia' finalizzata: non c'e' piu' nulla da approvare — mai
|
||||
// ripresentare il phase-gate (la form "approve") a workflow chiuso.
|
||||
if (kind === "phase" && curNum >= phaseMeta(ctx).max_phase) {
|
||||
|
||||
Reference in New Issue
Block a user