fix(gate): coerce stringified object params; block model edits to gate code

Some models (GLM 5.2) send object params (reviewer_confirm's artifact,
write_schema_linking's schema_linking) as JSON-encoded strings. These failed
TypeBox validation before execute(), making the model retry in an unbounded loop
(observed ~2100s hang). jsonObjectOrSelf() coerces them back to objects in
prepareReviewerArguments and write_schema_linking.

Also close an anti-bypass gap: pi's write/edit tools were unrestricted on the gate
code, so a looping model actually patched tht-gate.js. GATE_CODE_FILES now blocks
any write under .pi/extensions. Requires a pi restart to take effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 18:28:45 +02:00
co-authored by Claude Opus 4.8
parent f1b071d165
commit a0a503b721
2 changed files with 82 additions and 2 deletions
+34 -2
View File
@@ -34,7 +34,20 @@ import { isReserved } from "./reserved-labels.mjs";
// --- prepareArguments: parse stringified arrays (workaround for models that send
// arrays as JSON strings -- same pattern as pi-core's edit tool prepareEditArguments)
function prepareReviewerArguments(input) {
// Coerce a value that may arrive as a JSON-encoded string back into an object/array.
// Models sometimes stringify object params (Type.Object / Type.Any); the parsed value
// is returned only when it is genuinely an object, so plain strings pass through intact.
export function jsonObjectOrSelf(value) {
if (typeof value !== "string") return value;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : value;
} catch {
return value;
}
}
export function prepareReviewerArguments(input) {
if (!input || typeof input !== "object") return input;
const args = { ...input };
// Parse stringified JSON arrays (workaround for models that send arrays as strings)
@@ -54,6 +67,10 @@ function prepareReviewerArguments(input) {
/* not JSON */
}
}
// reviewer_confirm's `artifact` is a Type.Object; some models send it as a
// stringified JSON object, which fails validation BEFORE execute() and makes the
// model loop. Coerce it back to an object so the gate proceeds.
if (args.artifact !== undefined) args.artifact = jsonObjectOrSelf(args.artifact);
return args;
}
@@ -65,6 +82,10 @@ const FORBIDDEN = [
];
const PROTECTED_FILES =
/(review_decisions\.jsonl|session_manifest\.yaml|cte_plan\.json)/;
// The model orchestrates the workflow; it must never rewrite the gate/extension code
// 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[\\/]/;
// --- kickoff payloads (verbatim from source L184-212, load-bearing model prose) -
const NUOVA_DOMANDA_KICKOFF =
@@ -332,6 +353,14 @@ export default function (pi) {
}
if (event.toolName === "write" || event.toolName === "edit") {
const path = event.input?.path ?? event.input?.file_path ?? "";
if (GATE_CODE_FILES.test(path)) {
return {
block: true,
reason:
"Il codice del gate (.pi/extensions) non va modificato: sei l'orchestratore " +
"del workflow, non uno sviluppatore del gate. Usa i tool del gate.",
};
}
if (PROTECTED_FILES.test(path)) {
return {
block: true,
@@ -758,7 +787,10 @@ export default function (pi) {
}),
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
const { session, schema_linking } = params;
const { session } = params;
// schema_linking is Type.Any(): a stringified JSON object passes validation
// but would be double-encoded here and rejected by the CLI. Normalize first.
const schema_linking = jsonObjectOrSelf(params.schema_linking);
try {
tht(
ctx,