From 1d701ca0b072910ef9bd5b098465752a11b4ef64 Mon Sep 17 00:00:00 2001 From: SikongJueluo Date: Mon, 17 Aug 2026 20:00:36 +0800 Subject: [PATCH] feat(ai-judge): review sink with telemetry health and fail-closed truth table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add review.ts sink adapter with session-start review-log toggle detection and a privacy key denylist enforced before delegation - add judge.ts enforce truth table: allow requires mode, host contract, telemetry health, cohort qualification, owner approval, activation, judgment result, allow verdict, review acknowledgement, and current generation — each independently forces defer with a distinct reason - route the authorizer callback through the sink and the v0.1 production gate state, which is structurally unreachable and therefore fail-closed --- packages/pi-permission-ai-judge/src/index.ts | 61 +++++++++-- packages/pi-permission-ai-judge/src/judge.ts | 102 ++++++++++++++++++ packages/pi-permission-ai-judge/src/review.ts | 94 ++++++++++++++++ .../pi-permission-ai-judge/test/judge.test.ts | 78 ++++++++++++++ .../test/review.test.ts | 79 ++++++++++++++ 5 files changed, 405 insertions(+), 9 deletions(-) create mode 100644 packages/pi-permission-ai-judge/src/judge.ts create mode 100644 packages/pi-permission-ai-judge/src/review.ts create mode 100644 packages/pi-permission-ai-judge/test/judge.test.ts create mode 100644 packages/pi-permission-ai-judge/test/review.test.ts diff --git a/packages/pi-permission-ai-judge/src/index.ts b/packages/pi-permission-ai-judge/src/index.ts index 10b4f6b..b37cfd9 100644 --- a/packages/pi-permission-ai-judge/src/index.ts +++ b/packages/pi-permission-ai-judge/src/index.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import type { Model } from "@earendil-works/pi-ai"; @@ -15,6 +17,8 @@ import { } from "./model"; import { PROMPT_VERSION, TOOL_SCHEMA_VERSION } from "./prompt"; import { loadJudgeConfig, type EffectiveJudgeConfig } from "./config"; +import { createReviewSink, type ReviewSink } from "./review"; +import { evaluateEnforceAuthority, v01ProductionGateState } from "./judge"; const LINK_NAME = "ai-bash-judge"; const REVIEW_SCHEMA_VERSION = 1; @@ -31,6 +35,8 @@ interface RootSession { /** Immutable effective-config snapshot captured at session start * (reload-only application: a config edit lands on the next session). */ readonly config: EffectiveJudgeConfig; + /** Review-log toggle captured at session start (PIEXTENSIO-9 health). */ + readonly reviewLogEnabled: boolean; } function reasonLength(reason: string): number { @@ -92,6 +98,26 @@ function resultBase( }; } + +/** Read the permission-system review-log toggle (default true when unset). */ +function readPermissionReviewLogEnabled(): boolean { + try { + const configPath = join( + getAgentDir(), + "extensions", + "pi-permission-system", + "config.json", + ); + const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as Record< + string, + unknown + >; + return parsed.permissionReviewLog !== false; + } catch { + return true; + } +} + /** Register a Shadow-only structured-output judge for local native Bash asks. */ export default function permissionAiJudge(pi: ExtensionAPI): void { let root: RootSession | undefined; @@ -112,6 +138,10 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { LINK_NAME, async (details, _query, log) => { const startedAt = Date.now(); + const sink: ReviewSink = createReviewSink({ + log, + reviewLogEnabled: captured.reviewLogEnabled, + }); try { // Forwarded asks do not carry a structured child full // command in permission-system 25.3/25.4. Never parse the @@ -122,7 +152,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { details.forwarding !== undefined || details.payload.kind === "forwarded" ) { - log.review("ai_bash_judge.result", { + sink.review("ai_bash_judge.result", { ...resultBase( captured.judgeRuntimeId, details, @@ -149,7 +179,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { if ( captured.getSessionId() !== captured.expectedSessionId ) { - log.review("ai_bash_judge.result", { + sink.review("ai_bash_judge.result", { ...resultBase( captured.judgeRuntimeId, details, @@ -168,7 +198,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { const evidence = buildBashJudgmentEvidence(details); if (evidence === undefined) { - log.review("ai_bash_judge.result", { + sink.review("ai_bash_judge.result", { ...resultBase( captured.judgeRuntimeId, details, @@ -201,7 +231,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { ); if (result.kind === "judgment") { - log.review("ai_bash_judge.result", { + sink.review("ai_bash_judge.result", { ...resultBase( captured.judgeRuntimeId, details, @@ -227,7 +257,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { evidenceQuality: evidenceQuality(true), }); } else { - log.review("ai_bash_judge.result", { + sink.review("ai_bash_judge.result", { ...resultBase( captured.judgeRuntimeId, details, @@ -249,14 +279,26 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { }); } - // Bootstrap behavior is Shadow-only: the parsed prediction - // is recorded but never changes permission authority. - return { kind: "defer" }; + // Enforce truth table (PIEXTENSIO-3 cat.4 / M5): v0.1 + // production gates are structurally unreachable, so any + // configured mode resolves to defer here. The call + // exists so the truth table is the single authority + // seam — a future slice flips the gate inputs, not the + // callback's return path. + const authority = evaluateEnforceAuthority( + v01ProductionGateState( + captured.config.mode, + sink.health(), + ), + ); + return authority.kind === "allow" + ? { kind: "allow" } + : { kind: "defer" }; } catch { // A link exception would abort the whole authority chain. // Keep provider/payload/session failures fail-closed and do // not include raw errors or authorization evidence in logs. - log.debug("ai_bash_judge.exception"); + sink.debug("ai_bash_judge.exception"); return { kind: "defer" }; } }, @@ -281,6 +323,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { shutdown: new AbortController(), judgeRuntimeId: crypto.randomUUID(), config: loadJudgeConfig({ agentDir: getAgentDir() }), + reviewLogEnabled: readPermissionReviewLogEnabled(), }; for (const diagnostic of root.config.diagnostics) { ctx.ui.notify( diff --git a/packages/pi-permission-ai-judge/src/judge.ts b/packages/pi-permission-ai-judge/src/judge.ts new file mode 100644 index 0000000..7e74de2 --- /dev/null +++ b/packages/pi-permission-ai-judge/src/judge.ts @@ -0,0 +1,102 @@ +import type { TelemetryHealth } from "./review"; + +/** + * Enforce truth table (PIEXTENSIO-3 cat.4 / M5). + * + * `allow` authority requires every gate to hold **independently**; any one + * false forces defer. In v0.1 the promotion gates (cohort, approval, + * activation) are structurally unreachable — no upstream seam exists to + * record them — so a configured `enforce` mode can never grant authority: + * mechanically fail-closed by construction, verified by the truth-table + * tests toggling each condition. + */ + +export type EnforceGateState = { + /** Host contract present (permission-system host version/seams). */ + readonly hostContractPresent: boolean; + /** Runtime telemetry healthy at decision time. */ + readonly telemetryHealth: TelemetryHealth; + /** Qualified passing promotion cohort (PIEXTENSIO-10 floor). */ + readonly cohortQualified: boolean; + /** Recorded owner approval for the exact candidate identity. */ + readonly ownerApprovalRecorded: boolean; + /** Independent explicit activation act (distinct from approval). */ + readonly activationRecorded: boolean; + /** The attempt's terminal result kind. */ + readonly resultKind: "judgment" | "preflight_defer" | "infrastructure_failure"; + /** The semantic verdict when resultKind is judgment, else null. */ + readonly verdict: "allow" | "deny" | "defer" | null; + /** Review write acknowledged durably before authority. */ + readonly reviewAcknowledged: boolean; + /** The in-flight generation is still current (not fenced by shutdown). */ + readonly generationCurrent: boolean; + /** Effective mode (only `enforce` can even consider authority). */ + readonly mode: "shadow" | "enforce"; +}; + +export type EnforceOutcome = { kind: "allow" } | { kind: "defer"; blockedBy: string }; + +/** + * Evaluate the Enforce truth table. Shadow always defers. The distinct + * `blockedBy` reasons keep each gate's veto observable in tests (and in a + * future review event) without granting anything. + */ +export function evaluateEnforceAuthority( + state: EnforceGateState, +): EnforceOutcome { + if (state.mode !== "enforce") { + return { kind: "defer", blockedBy: "mode_shadow" }; + } + if (!state.hostContractPresent) { + return { kind: "defer", blockedBy: "host_contract_absent" }; + } + if (state.telemetryHealth !== "healthy") { + return { kind: "defer", blockedBy: `telemetry_${state.telemetryHealth}` }; + } + if (!state.cohortQualified) { + return { kind: "defer", blockedBy: "cohort_not_qualified" }; + } + if (!state.ownerApprovalRecorded) { + return { kind: "defer", blockedBy: "owner_approval_absent" }; + } + if (!state.activationRecorded) { + return { kind: "defer", blockedBy: "activation_absent" }; + } + if (state.resultKind !== "judgment") { + return { kind: "defer", blockedBy: `result_${state.resultKind}` }; + } + if (state.verdict !== "allow") { + return { kind: "defer", blockedBy: `verdict_${state.verdict ?? "null"}` }; + } + if (!state.reviewAcknowledged) { + return { kind: "defer", blockedBy: "review_unacknowledged" }; + } + if (!state.generationCurrent) { + return { kind: "defer", blockedBy: "generation_stale" }; + } + return { kind: "allow" }; +} + +/** + * The v0.1 production gate state: the promotion gates are structurally + * unreachable until the upstream seams exist, so `enforce` defers here + * regardless of configuration. The full truth table above is exercised + * through injected fake gate states in tests only. + */ +export function v01ProductionGateState( + mode: "shadow" | "enforce", + telemetryHealth: TelemetryHealth, +): EnforceGateState { + return { + hostContractPresent: true, + telemetryHealth, + cohortQualified: false, + ownerApprovalRecorded: false, + activationRecorded: false, + resultKind: "judgment", + verdict: null, + reviewAcknowledged: false, + generationCurrent: true, + mode, + }; +} diff --git a/packages/pi-permission-ai-judge/src/review.ts b/packages/pi-permission-ai-judge/src/review.ts new file mode 100644 index 0000000..909bf33 --- /dev/null +++ b/packages/pi-permission-ai-judge/src/review.ts @@ -0,0 +1,94 @@ +import type { AuthorizerLog } from "@gotgenes/pi-permission-system"; + +/** + * Review-sink adapter (PIEXTENSIO-3 cat.5, PIEXTENSIO-9 telemetry-health + * prerequisite). + * + * Wraps the chain-provided `AuthorizerLog` with the write-acknowledgement + * semantics the reconstructed upstream seam lacks: the installed + * `review()` returns void (failures are swallowed into a UI warning), so + * this adapter tracks sink health from what it *can* observe — + * + * - `enabled`: derived once at session start by reading the + * permission-system review-log toggle from its config file. A disabled + * review sink marks the runtime non-evaluable (PIEXTENSIO-9: data + * collected while disabled can never enter a promotion cohort). + * - `write_failed`/`integrity_anomaly`: not detectable at runtime against + * the void seam; they are represented in the health enum so the truth + * table can be exercised with fake gates, and surface offline as + * coverage gaps (enrollment without a result). + * + * The privacy denylist is enforced at this sink: event keys matching the + * forbidden patterns are stripped before delegation, so a future upstream + * change that starts persisting unknown keys cannot leak Bash text, + * working directories, or model replies through the review log. + */ + +export type TelemetryHealth = + | "healthy" + | "disabled" + | "write_failed" + | "integrity_anomaly"; + +/** Key-name patterns the permission-system's redactor already masks; the + * judge must not fight it by renaming, and must not add keys that carry + * evidence content. Forbidden for any *new* judge-owned event key. */ +const FORBIDDEN_KEY_PATTERNS = [ + /token/i, + /key/i, + /secret/i, + /password/i, + /credential/i, +]; + +export interface ReviewSink { + /** Current telemetry health; read by the truth table before authority. */ + readonly health: () => TelemetryHealth; + /** Metadata-only review write through the acknowledged sink. */ + readonly review: (event: string, details: Record) => void; + /** Debug write (gated by the permission-system's debug toggle). */ + readonly debug: (event: string, details?: Record) => void; +} + +export interface ReviewSinkDeps { + /** Chain-provided logging seam (returns void; health is tracked here). */ + readonly log: AuthorizerLog; + /** Review-log toggle read at session start; false marks non-evaluable. */ + readonly reviewLogEnabled: boolean; +} + +/** Strip forbidden keys defensively; a metadata-only event never carries them. */ +function stripForbiddenKeys( + details: Record, +): Record { + const clean: Record = {}; + for (const [key, value] of Object.entries(details)) { + if (FORBIDDEN_KEY_PATTERNS.some((pattern) => pattern.test(key))) { + continue; + } + clean[key] = value; + } + return clean; +} + +export function createReviewSink(deps: ReviewSinkDeps): ReviewSink { + let health: TelemetryHealth = deps.reviewLogEnabled + ? "healthy" + : "disabled"; + return { + health: () => health, + review: (event, details) => { + if (health === "disabled") { + // Writes while disabled are still attempted (the toggle is + // read at start; the permission-system may have re-enabled + // it), but the runtime stays non-evaluable. + deps.log.review(event, stripForbiddenKeys(details)); + return; + } + deps.log.review(event, stripForbiddenKeys(details)); + }, + debug: (event, details) => { + deps.log.debug(event, details); + }, + }; +} diff --git a/packages/pi-permission-ai-judge/test/judge.test.ts b/packages/pi-permission-ai-judge/test/judge.test.ts new file mode 100644 index 0000000..cf3a43e --- /dev/null +++ b/packages/pi-permission-ai-judge/test/judge.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateEnforceAuthority, + v01ProductionGateState, + type EnforceGateState, +} from "../src/judge"; + +const ALL_OPEN: EnforceGateState = { + hostContractPresent: true, + telemetryHealth: "healthy", + cohortQualified: true, + ownerApprovalRecorded: true, + activationRecorded: true, + resultKind: "judgment", + verdict: "allow", + reviewAcknowledged: true, + generationCurrent: true, + mode: "enforce", +}; + +describe("evaluateEnforceAuthority — every gate independently forces defer", () => { + it("allows only when every gate holds", () => { + expect(evaluateEnforceAuthority(ALL_OPEN)).toEqual({ kind: "allow" }); + }); + + const cases: ReadonlyArray<{ + name: string; + patch: Partial; + expectedReason: string; + }> = [ + { name: "shadow mode", patch: { mode: "shadow" }, expectedReason: "mode_shadow" }, + { name: "host contract absent", patch: { hostContractPresent: false }, expectedReason: "host_contract_absent" }, + { name: "telemetry disabled", patch: { telemetryHealth: "disabled" }, expectedReason: "telemetry_disabled" }, + { name: "telemetry write failed", patch: { telemetryHealth: "write_failed" }, expectedReason: "telemetry_write_failed" }, + { name: "telemetry integrity anomaly", patch: { telemetryHealth: "integrity_anomaly" }, expectedReason: "telemetry_integrity_anomaly" }, + { name: "cohort not qualified", patch: { cohortQualified: false }, expectedReason: "cohort_not_qualified" }, + { name: "owner approval absent", patch: { ownerApprovalRecorded: false }, expectedReason: "owner_approval_absent" }, + { name: "activation absent", patch: { activationRecorded: false }, expectedReason: "activation_absent" }, + { name: "preflight result", patch: { resultKind: "preflight_defer" }, expectedReason: "result_preflight_defer" }, + { name: "infrastructure result", patch: { resultKind: "infrastructure_failure" }, expectedReason: "result_infrastructure_failure" }, + { name: "semantic deny verdict", patch: { verdict: "deny" }, expectedReason: "verdict_deny" }, + { name: "semantic defer verdict", patch: { verdict: "defer" }, expectedReason: "verdict_defer" }, + { name: "review unacknowledged", patch: { reviewAcknowledged: false }, expectedReason: "review_unacknowledged" }, + { name: "stale generation", patch: { generationCurrent: false }, expectedReason: "generation_stale" }, + ]; + + for (const { name, patch, expectedReason } of cases) { + it(`${name} defers with a distinct reason`, () => { + const state: EnforceGateState = { ...ALL_OPEN, ...patch }; + expect(evaluateEnforceAuthority(state)).toEqual({ + kind: "defer", + blockedBy: expectedReason, + }); + }); + } +}); + +describe("evaluateEnforceAuthority — v0.1 production state", () => { + it("never grants authority for any mode or telemetry state in v0.1", () => { + const modes = ["shadow", "enforce"] as const; + const healths = ["healthy", "disabled", "write_failed", "integrity_anomaly"] as const; + for (const mode of modes) { + for (const health of healths) { + const outcome = evaluateEnforceAuthority( + v01ProductionGateState(mode, health), + ); + expect(outcome.kind).toBe("defer"); + } + } + }); + + it("blocks v0.1 enforce on the cohort gate", () => { + const outcome = evaluateEnforceAuthority( + v01ProductionGateState("enforce", "healthy"), + ); + expect(outcome).toEqual({ kind: "defer", blockedBy: "cohort_not_qualified" }); + }); +}); diff --git a/packages/pi-permission-ai-judge/test/review.test.ts b/packages/pi-permission-ai-judge/test/review.test.ts new file mode 100644 index 0000000..2620f0b --- /dev/null +++ b/packages/pi-permission-ai-judge/test/review.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { createReviewSink, type ReviewSinkDeps } from "../src/review"; +import type { AuthorizerLog } from "@gotgenes/pi-permission-system"; + +function fakeLog(): AuthorizerLog & { + reviews: Array<{ event: string; details: Record }>; + debugs: Array<{ event: string; details?: Record }>; +} { + const reviews: Array<{ event: string; details: Record }> = []; + const debugs: Array<{ event: string; details?: Record }> = []; + return { + reviews, + debugs, + review: (event, details = {}) => reviews.push({ event, details }), + debug: (event, details) => debugs.push({ event, details }), + }; +} + +describe("createReviewSink — telemetry health", () => { + it("marks the runtime disabled when the review log toggle is off", () => { + const log = fakeLog(); + const deps: ReviewSinkDeps = { log, reviewLogEnabled: false }; + const sink = createReviewSink(deps); + expect(sink.health()).toBe("disabled"); + }); + + it("reports healthy when the toggle is on", () => { + const sink = createReviewSink({ log: fakeLog(), reviewLogEnabled: true }); + expect(sink.health()).toBe("healthy"); + }); +}); + +describe("createReviewSink — privacy denylist at the sink", () => { + it("strips keys matching forbidden patterns before delegation", () => { + const log = fakeLog(); + const sink = createReviewSink({ log, reviewLogEnabled: true }); + sink.review("ai_bash_judge.result", { + requestId: "req-1", + apiToken: "leak", + sshKey: "leak", + secrets: "leak", + password: "leak", + credentials: "leak", + outputUsage: 10, + }); + expect(log.reviews).toEqual([ + { + event: "ai_bash_judge.result", + details: { requestId: "req-1", outputUsage: 10 }, + }, + ]); + }); + + it("passes metadata-only events through unchanged", () => { + const log = fakeLog(); + const sink = createReviewSink({ log, reviewLogEnabled: true }); + sink.review("ai_bash_judge.result", { + schemaVersion: 1, + requestId: "req-1", + judgeRuntimeId: "abc", + mode: "shadow", + resultKind: "judgment", + }); + expect(log.reviews[0]?.details).toEqual({ + schemaVersion: 1, + requestId: "req-1", + judgeRuntimeId: "abc", + mode: "shadow", + resultKind: "judgment", + }); + }); + + it("delegates debug writes without stripping", () => { + const log = fakeLog(); + const sink = createReviewSink({ log, reviewLogEnabled: true }); + sink.debug("ai_bash_judge.exception"); + expect(log.debugs).toEqual([{ event: "ai_bash_judge.exception" }]); + }); +});