fix(harness): state-integrity pass — reopen order, atomic decision batch, bash anti-bypass
Audit findings 5.1-5.3.
5.1 `phase reopen` now appends `phase_reopened` BEFORE the artifact
teardown: a crash between the two used to leave later-phase artifacts
deleted with the ledger still at the old phase (resume entered a phase
missing its artifacts). The inverse half-state — reopened with stale later
artifacts — is benign. Order locked by tests/test_phase_reopen_order.py.
5.2 New `tht decision add-batch --doc -`: N substantive decisions in ONE
atomic ledger write (meta types and cte_approved stay on `decision add`;
strictest min-phase enforced). reviewer_schema_linking now builds the
complete curation set and persists it with a single add-batch call — a
mid-loop failure can no longer leave the audit ledger half-written, and a
retry cannot duplicate the first K decisions.
5.3 The anti-bypass hook now also blocks BASH mutations of protected
state (`echo >> review_decisions.jsonl`, `sed -i` on the manifest,
`cat > tht-gate.js`, python open('w'), mv/rm/tee/…): FORBIDDEN only
covered tht subcommands and the write/edit hook only covered pi's own
tools. Read-only access (cat/grep/tail/ls) stays allowed.
Also: knownDecisionTypes is defensive — a workflow meta declaring NO
emits at all (older tht, minimal stubs) skips pre-validation instead of
rejecting every substantive type; with emits present, unknown types are
still rejected before the widget (new L1 test).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,17 @@ const PROTECTED_FILES =
|
||||
// itself. pi's write/edit tools are otherwise unrestricted, so a confused model can
|
||||
// (and did) patch tht-gate.js mid-loop. Block any write under the extensions dir.
|
||||
export const GATE_CODE_FILES = /\.pi[\\/]extensions[\\/]/;
|
||||
// Bash can mutate protected state around the write/edit hook (`echo >> ledger`,
|
||||
// `sed -i` on the manifest, `cat > tht-gate.js`). Block any bash command that names
|
||||
// a protected path in a mutating context; read-only mentions (cat/grep/ls) stay
|
||||
// allowed. Defense against a CONFUSED model, not a sandbox.
|
||||
const PROTECTED_PATH_TOKEN =
|
||||
/(review_decisions\.jsonl|session_manifest\.yaml|cte_plan\.json|\.pi[\\/]extensions)/;
|
||||
const BASH_MUTATION =
|
||||
/(>>?|\btee\b|\bsed\b[^|;&]*\s-i\b|\bmv\b|\bcp\b|\brm\b|\btruncate\b|\bdd\b|open\([^)]*['"][wa]\+?b?['"]|\bchmod\b|\bln\b)/;
|
||||
export function isProtectedBashMutation(cmd) {
|
||||
return PROTECTED_PATH_TOKEN.test(cmd) && BASH_MUTATION.test(cmd);
|
||||
}
|
||||
|
||||
// --- kickoff payloads (verbatim from source L184-212, load-bearing model prose) -
|
||||
const NUOVA_DOMANDA_KICKOFF =
|
||||
@@ -287,15 +298,20 @@ let _knownTypes = null;
|
||||
function knownDecisionTypes(ctx) {
|
||||
if (_knownTypes) return _knownTypes;
|
||||
const meta = phaseMeta(ctx);
|
||||
const types = new Set([
|
||||
const emitted = new Set();
|
||||
for (const p of meta.phases) {
|
||||
for (const t of (p.emits || [])) emitted.add(t);
|
||||
}
|
||||
// A workflow meta with NO emits (older tht, minimal test stubs) gives the gate no
|
||||
// vocabulary to validate against: skip validation instead of rejecting every
|
||||
// substantive type and bricking the gates.
|
||||
if (emitted.size === 0) return null;
|
||||
for (const t of [
|
||||
"phase_approved", "phase_auto_approved", "phase_reopened",
|
||||
"phase_skipped", "decision_retracted",
|
||||
]);
|
||||
for (const p of meta.phases) {
|
||||
for (const t of (p.emits || [])) types.add(t);
|
||||
}
|
||||
_knownTypes = types;
|
||||
return types;
|
||||
]) emitted.add(t);
|
||||
_knownTypes = emitted;
|
||||
return emitted;
|
||||
}
|
||||
function decisionMinPhaseMap(ctx) {
|
||||
const meta = phaseMeta(ctx);
|
||||
@@ -309,6 +325,7 @@ function decisionMinPhaseMap(ctx) {
|
||||
}
|
||||
function validateDecisionTypes(ctx, options, session) {
|
||||
const known = knownDecisionTypes(ctx);
|
||||
if (!known) return null;
|
||||
for (const o of options) {
|
||||
if (o.decision && !known.has(o.decision.type)) {
|
||||
return `Tipo di decisione '${o.decision.type}' non valido. Tipi ammessi: ${[...known].join(", ")}. Correggi e riprova.`;
|
||||
@@ -565,6 +582,15 @@ export default function (pi) {
|
||||
"reviewer: usa i tool reviewer_confirm / reviewer_select.",
|
||||
};
|
||||
}
|
||||
if (isProtectedBashMutation(cmd)) {
|
||||
return {
|
||||
block: true,
|
||||
reason:
|
||||
"I file di stato della sessione e il codice del gate non si modificano " +
|
||||
"da shell: lo stato passa SOLO dai tool del gate (reviewer_*/write_*). " +
|
||||
"La lettura (cat/grep) resta permessa.",
|
||||
};
|
||||
}
|
||||
}
|
||||
if (event.toolName === "write" || event.toolName === "edit") {
|
||||
const path = event.input?.path ?? event.input?.file_path ?? "";
|
||||
@@ -984,16 +1010,20 @@ export default function (pi) {
|
||||
if (resp.control === "freetext")
|
||||
return textResult(`Altro (reviewer): ${resp.text}. Riformula tenendone conto.`);
|
||||
|
||||
// Build the COMPLETE decision set first, persist it with ONE atomic ledger
|
||||
// write (decision add-batch): a per-item loop could fail halfway and leave
|
||||
// the audit ledger half-written, with duplicates on retry.
|
||||
const byId = new Map(enriched.map((t) => [t.id, t]));
|
||||
const toPersist = [];
|
||||
let n = 0;
|
||||
for (const rt of resp.tables ?? []) {
|
||||
const t = byId.get(rt.id);
|
||||
if (!t || !rt.enacted) continue;
|
||||
if (t.kind === "promote") {
|
||||
const e1 = relayIfThtFails(ctx, decisionAddArgs(session, {
|
||||
type: "table_promoted", subject: t.name, detail: t.description, rationale: t.rationale,
|
||||
}), "");
|
||||
if (e1) return e1;
|
||||
toPersist.push({
|
||||
type: "table_promoted", subject: t.name,
|
||||
detail: t.description, rationale: t.rationale,
|
||||
});
|
||||
n++;
|
||||
const sel = new Set(rt.columns ?? []);
|
||||
for (const c of t.columns) {
|
||||
@@ -1001,19 +1031,27 @@ export default function (pi) {
|
||||
? "column_promoted"
|
||||
: (c.suggested ? "column_excluded" : null);
|
||||
if (!type) continue;
|
||||
const e2 = relayIfThtFails(ctx, decisionAddArgs(session, {
|
||||
toPersist.push({
|
||||
type, subject: `${t.name}.${c.name}`, detail: c.description ?? "",
|
||||
}), "");
|
||||
if (e2) return e2;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const e3 = relayIfThtFails(ctx, decisionAddArgs(session, {
|
||||
type: "table_excluded", subject: t.name, detail: t.description, rationale: t.rationale,
|
||||
}), "");
|
||||
if (e3) return e3;
|
||||
toPersist.push({
|
||||
type: "table_excluded", subject: t.name,
|
||||
detail: t.description, rationale: t.rationale,
|
||||
});
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (toPersist.length) {
|
||||
const eBatch = relayIfThtFails(
|
||||
ctx,
|
||||
["decision", "add-batch", "--session", session, "--doc", "-"],
|
||||
"Nessuna decisione registrata (batch atomico fallito): correggi e ripresenta il gate.",
|
||||
JSON.stringify(toPersist),
|
||||
);
|
||||
if (eBatch) return eBatch;
|
||||
}
|
||||
|
||||
// Deterministic projection of the ledger into schema_linking.json.
|
||||
const eSync = relayIfThtFails(ctx, ["session", "sync-schema-linking", session], "");
|
||||
|
||||
Reference in New Issue
Block a user