mirror of
https://github.com/SikongJueluo/pi-extensions.git
synced 2026-10-05 11:52:55 +08:00
refactor(ai-judge): extract stage functions from top health hotspots
- split loadJudgeConfig into parseConfigVersion, parseMode, parseJudgeModelField, and parseTimeout with an explicit orchestration layer - split analyzeShadowReviewLog into collectReviewEvents, joinTerminalRequest, and buildJoinedRow - extract the authorizer callback from index.ts into module-level judgeAuthorize with shared preflight/infra result emitters - extract the enforce-mode session notice into notifyEnforceActive
This commit is contained in:
@@ -187,17 +187,16 @@ function humanFromResolution(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconstruct the PIEXTENSIO-9 join over a review event stream.
|
* Phase 1: collect per-requestId first-seen records in append order.
|
||||||
*
|
|
||||||
* Input is the raw parsed JSONL of one permission review log. File order is
|
|
||||||
* authoritative: a human decision must appear after the judge result in
|
|
||||||
* append order. Enrollments with no result remain visible in
|
|
||||||
* `metrics.completionCoverage` and their dispositions are omitted from the
|
|
||||||
* joined set without a quarantine entry (missing outcomes are counted, not
|
|
||||||
* invented).
|
|
||||||
*/
|
*/
|
||||||
export function analyzeShadowReviewLog(events: readonly ReviewEvent[]): AnalyzeResult {
|
interface CollectedEvents {
|
||||||
// Phase 1: collect per-requestId first-seen records in append order.
|
readonly enrolled: ReadonlySet<string>;
|
||||||
|
readonly results: ReadonlyMap<string, ReviewEvent[]>;
|
||||||
|
readonly terminal: ReadonlyMap<string, ReviewEvent[]>;
|
||||||
|
readonly linkMarkers: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectReviewEvents(events: readonly ReviewEvent[]): CollectedEvents {
|
||||||
const enrolled = new Set<string>();
|
const enrolled = new Set<string>();
|
||||||
const results = new Map<string, ReviewEvent[]>();
|
const results = new Map<string, ReviewEvent[]>();
|
||||||
const terminal = new Map<string, ReviewEvent[]>();
|
const terminal = new Map<string, ReviewEvent[]>();
|
||||||
@@ -238,32 +237,36 @@ export function analyzeShadowReviewLog(events: readonly ReviewEvent[]): AnalyzeR
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { enrolled, results, terminal, linkMarkers };
|
||||||
|
}
|
||||||
|
|
||||||
const dispositions: Disposition[] = [];
|
/** Outcome of joining one terminal request against its judge result. */
|
||||||
const joined: JoinedRow[] = [];
|
type JoinOutcome =
|
||||||
const quarantined: Record<string, number> = {};
|
| { readonly kind: "joined"; readonly row: JoinedRow }
|
||||||
const quarantine = (category: QuarantineCategory): void => {
|
| { readonly kind: "quarantined"; readonly category: QuarantineCategory }
|
||||||
quarantined[category] = (quarantined[category] ?? 0) + 1;
|
| { readonly kind: "skipped" };
|
||||||
dispositions.push({ kind: "quarantined", category });
|
|
||||||
};
|
|
||||||
|
|
||||||
const terminalIds = [...terminal.keys()];
|
/**
|
||||||
for (const requestId of terminalIds) {
|
* Phase 2: join one enrolled request's terminal permission event against
|
||||||
if (!enrolled.has(requestId)) {
|
* its judge result. Guards run in append-order integrity order; the first
|
||||||
continue;
|
* failure quarantines with an explicit category.
|
||||||
}
|
*/
|
||||||
const resultList = results.get(requestId) ?? [];
|
function joinTerminalRequest(
|
||||||
const terminalList = terminal.get(requestId) ?? [];
|
requestId: string,
|
||||||
|
events: readonly ReviewEvent[],
|
||||||
|
collected: CollectedEvents,
|
||||||
|
): JoinOutcome {
|
||||||
|
const resultList = collected.results.get(requestId) ?? [];
|
||||||
|
const terminalList = collected.terminal.get(requestId) ?? [];
|
||||||
|
|
||||||
if (resultList.length > 1) {
|
if (resultList.length > 1) {
|
||||||
quarantine("duplicate_result");
|
return { kind: "quarantined", category: "duplicate_result" };
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
const result = resultList[0];
|
const result = resultList[0];
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
// No result yet (or a lost write): counted as a coverage gap in
|
// No result yet (or a lost write): counted as a coverage gap in
|
||||||
// the metrics, not quarantined as an integrity fault.
|
// the metrics, not quarantined as an integrity fault.
|
||||||
continue;
|
return { kind: "skipped" };
|
||||||
}
|
}
|
||||||
// The terminal permission_request entry must appear after the judge
|
// The terminal permission_request entry must appear after the judge
|
||||||
// result in append order. The terminal list is collected from the
|
// result in append order. The terminal list is collected from the
|
||||||
@@ -273,8 +276,7 @@ export function analyzeShadowReviewLog(events: readonly ReviewEvent[]): AnalyzeR
|
|||||||
(t) => events.indexOf(t) > resultIndex,
|
(t) => events.indexOf(t) > resultIndex,
|
||||||
);
|
);
|
||||||
if (terminalEvents.length === 0) {
|
if (terminalEvents.length === 0) {
|
||||||
quarantine("human_before_result");
|
return { kind: "quarantined", category: "human_before_result" };
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (terminalEvents.length > 1) {
|
if (terminalEvents.length > 1) {
|
||||||
// Upstream's forwarded decision path double-writes the terminal
|
// Upstream's forwarded decision path double-writes the terminal
|
||||||
@@ -289,8 +291,7 @@ export function analyzeShadowReviewLog(events: readonly ReviewEvent[]): AnalyzeR
|
|||||||
terminalEvents.map((t) => asString(t.resolution) ?? ""),
|
terminalEvents.map((t) => asString(t.resolution) ?? ""),
|
||||||
);
|
);
|
||||||
if (distinct.size > 1) {
|
if (distinct.size > 1) {
|
||||||
quarantine("multiple_human_decisions");
|
return { kind: "quarantined", category: "multiple_human_decisions" };
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const terminalEvent = terminalEvents[0] as ReviewEvent;
|
const terminalEvent = terminalEvents[0] as ReviewEvent;
|
||||||
@@ -299,10 +300,21 @@ export function analyzeShadowReviewLog(events: readonly ReviewEvent[]): AnalyzeR
|
|||||||
terminalEvent.denialReason,
|
terminalEvent.denialReason,
|
||||||
);
|
);
|
||||||
if ("error" in human) {
|
if ("error" in human) {
|
||||||
quarantine("terminal_event_unreadable");
|
return { kind: "quarantined", category: "terminal_event_unreadable" };
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "joined",
|
||||||
|
row: buildJoinedRow(requestId, result, human, collected.linkMarkers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJoinedRow(
|
||||||
|
requestId: string,
|
||||||
|
result: ReviewEvent,
|
||||||
|
human: HumanDecision,
|
||||||
|
linkMarkers: ReadonlySet<string>,
|
||||||
|
): JoinedRow {
|
||||||
const state = human.state;
|
const state = human.state;
|
||||||
let attribution: JoinedRow["humanAttribution"];
|
let attribution: JoinedRow["humanAttribution"];
|
||||||
if (
|
if (
|
||||||
@@ -319,7 +331,7 @@ export function analyzeShadowReviewLog(events: readonly ReviewEvent[]): AnalyzeR
|
|||||||
// allow) cannot be attributed to the human under the reconstructed
|
// allow) cannot be attributed to the human under the reconstructed
|
||||||
// rule; they stay joined but never enter the comparison matrix.
|
// rule; they stay joined but never enter the comparison matrix.
|
||||||
|
|
||||||
const row: JoinedRow = {
|
return {
|
||||||
requestId,
|
requestId,
|
||||||
judgeRuntimeId: asString(result.judgeRuntimeId),
|
judgeRuntimeId: asString(result.judgeRuntimeId),
|
||||||
resultKind:
|
resultKind:
|
||||||
@@ -347,22 +359,55 @@ export function analyzeShadowReviewLog(events: readonly ReviewEvent[]): AnalyzeR
|
|||||||
human,
|
human,
|
||||||
humanAttribution: attribution,
|
humanAttribution: attribution,
|
||||||
};
|
};
|
||||||
joined.push(row);
|
}
|
||||||
dispositions.push({ kind: "joined", row });
|
|
||||||
|
/**
|
||||||
|
* Reconstruct the PIEXTENSIO-9 join over a review event stream.
|
||||||
|
*
|
||||||
|
* Input is the raw parsed JSONL of one permission review log. File order is
|
||||||
|
* authoritative: a human decision must appear after the judge result in
|
||||||
|
* append order. Enrollments with no result remain visible in
|
||||||
|
* `metrics.completionCoverage` and their dispositions are omitted from the
|
||||||
|
* joined set without a quarantine entry (missing outcomes are counted, not
|
||||||
|
* invented).
|
||||||
|
*/
|
||||||
|
export function analyzeShadowReviewLog(events: readonly ReviewEvent[]): AnalyzeResult {
|
||||||
|
const collected = collectReviewEvents(events);
|
||||||
|
|
||||||
|
const dispositions: Disposition[] = [];
|
||||||
|
const joined: JoinedRow[] = [];
|
||||||
|
const quarantined: Record<string, number> = {};
|
||||||
|
const quarantine = (category: QuarantineCategory): void => {
|
||||||
|
quarantined[category] = (quarantined[category] ?? 0) + 1;
|
||||||
|
dispositions.push({ kind: "quarantined", category });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const requestId of collected.terminal.keys()) {
|
||||||
|
if (!collected.enrolled.has(requestId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const outcome = joinTerminalRequest(requestId, events, collected);
|
||||||
|
if (outcome.kind === "quarantined") {
|
||||||
|
quarantine(outcome.category);
|
||||||
|
} else if (outcome.kind === "joined") {
|
||||||
|
joined.push(outcome.row);
|
||||||
|
dispositions.push({ kind: "joined", row: outcome.row });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Results without enrollment are integrity faults: the denominator must
|
// Results without enrollment are integrity faults: the denominator must
|
||||||
// be permission-owned.
|
// be permission-owned.
|
||||||
for (const requestId of results.keys()) {
|
const terminalIds = [...collected.terminal.keys()];
|
||||||
if (!enrolled.has(requestId) && !terminalIds.includes(requestId)) {
|
for (const requestId of collected.results.keys()) {
|
||||||
|
if (!collected.enrolled.has(requestId) && !terminalIds.includes(requestId)) {
|
||||||
quarantine("result_without_enrollment");
|
quarantine("result_without_enrollment");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enrollments: enrolled.size,
|
enrollments: collected.enrolled.size,
|
||||||
dispositions,
|
dispositions,
|
||||||
metrics: computeMetrics(enrolled.size, joined, quarantined),
|
metrics: computeMetrics(collected.enrolled.size, joined, quarantined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,132 @@ function parseJudgeModel(value: unknown): JudgeModelSelection | undefined {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse the `version` field; unknown versions are a hard failure. */
|
||||||
|
function parseConfigVersion(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
): { version: 1 | 2 } | { problem: string } {
|
||||||
|
// Version: unversioned files are legacy v1; anything but 1 or 2 fails
|
||||||
|
// closed to all defaults (the consent expression is uninterpretable).
|
||||||
|
if (record.version === undefined) {
|
||||||
|
return { version: 1 };
|
||||||
|
}
|
||||||
|
if (record.version === 1 || record.version === 2) {
|
||||||
|
return { version: record.version };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
problem: `unknown version ${JSON.stringify(record.version)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the `mode` field: unknown or missing resolves to shadow (fail-closed). */
|
||||||
|
function parseMode(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
): { mode: JudgeMode; diagnostics: ConfigDiagnostic[] } {
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
let mode: JudgeMode = "shadow";
|
||||||
|
if (record.mode !== undefined) {
|
||||||
|
if (record.mode === "shadow" || record.mode === "enforce") {
|
||||||
|
mode = record.mode;
|
||||||
|
} else {
|
||||||
|
diagnostics.push({
|
||||||
|
key: "mode",
|
||||||
|
problem: `unknown mode ${JSON.stringify(record.mode)}`,
|
||||||
|
fallback: "shadow",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { mode, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the v2-only `model` field. `demoteToShadow` marks that a malformed
|
||||||
|
* model poisons the consent file — the caller fails the whole config closed
|
||||||
|
* to shadow rather than silently substituting the session model.
|
||||||
|
*/
|
||||||
|
function parseJudgeModelField(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
configVersion: 1 | 2,
|
||||||
|
): {
|
||||||
|
judgeModel: JudgeModelSelection | undefined;
|
||||||
|
demoteToShadow: boolean;
|
||||||
|
diagnostics: ConfigDiagnostic[];
|
||||||
|
} {
|
||||||
|
if (record.model === undefined) {
|
||||||
|
return { judgeModel: undefined, demoteToShadow: false, diagnostics: [] };
|
||||||
|
}
|
||||||
|
if (configVersion === 2) {
|
||||||
|
const judgeModel = parseJudgeModel(record.model);
|
||||||
|
if (judgeModel === undefined) {
|
||||||
|
return {
|
||||||
|
judgeModel: undefined,
|
||||||
|
demoteToShadow: true,
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
key: "model",
|
||||||
|
problem: `invalid model ${JSON.stringify(record.model)} (expected {provider, id} with non-empty strings)`,
|
||||||
|
fallback: "shadow (no judge model)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { judgeModel, demoteToShadow: false, diagnostics: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
judgeModel: undefined,
|
||||||
|
demoteToShadow: false,
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
key: "model",
|
||||||
|
problem: "model selection requires \"version\": 2",
|
||||||
|
fallback: "session model",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `timeoutMs`: integers in [5_000, 30_000]; anything else falls back
|
||||||
|
* to the documented 15,000 ms default. Boundary semantics: 4,999 and 30,001
|
||||||
|
* are invalid, 5,000 and 30,000 are valid (PIEXTENSIO-3 boundaries).
|
||||||
|
*/
|
||||||
|
function parseTimeout(record: Record<string, unknown>): {
|
||||||
|
timeoutMs: number;
|
||||||
|
timeoutCohort: EffectiveJudgeConfig["timeoutCohort"];
|
||||||
|
diagnostics: ConfigDiagnostic[];
|
||||||
|
} {
|
||||||
|
if (record.timeoutMs === undefined) {
|
||||||
|
return {
|
||||||
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||||
|
timeoutCohort: "default",
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const value = record.timeoutMs;
|
||||||
|
if (
|
||||||
|
typeof value === "number" &&
|
||||||
|
Number.isInteger(value) &&
|
||||||
|
value >= MIN_TIMEOUT_MS &&
|
||||||
|
value <= MAX_TIMEOUT_MS
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
timeoutMs: value,
|
||||||
|
timeoutCohort: value === DEFAULT_TIMEOUT_MS ? "default" : value,
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||||
|
timeoutCohort: "default",
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
key: "timeoutMs",
|
||||||
|
problem: `invalid timeoutMs ${JSON.stringify(value)}`,
|
||||||
|
fallback: `${DEFAULT_TIMEOUT_MS} (default)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and validate the global config. Missing file, malformed JSON, or
|
* Load and validate the global config. Missing file, malformed JSON, or
|
||||||
* an unknown version resolve to the documented defaults with one
|
* an unknown version resolve to the documented defaults with one
|
||||||
@@ -128,40 +254,22 @@ export function loadJudgeConfig(
|
|||||||
const record = parsed as Record<string, unknown>;
|
const record = parsed as Record<string, unknown>;
|
||||||
const diagnostics: ConfigDiagnostic[] = [];
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
|
||||||
// Version: unversioned files are legacy v1; anything but 1 or 2 fails
|
const versionResult = parseConfigVersion(record);
|
||||||
// closed to all defaults (the consent expression is uninterpretable).
|
if ("problem" in versionResult) {
|
||||||
let configVersion: 1 | 2 = 1;
|
|
||||||
if (record.version !== undefined) {
|
|
||||||
if (record.version === 1 || record.version === 2) {
|
|
||||||
configVersion = record.version;
|
|
||||||
} else {
|
|
||||||
return {
|
return {
|
||||||
...DEFAULT_CONFIG,
|
...DEFAULT_CONFIG,
|
||||||
diagnostics: [
|
diagnostics: [
|
||||||
{
|
{ key: "version", problem: versionResult.problem, fallback: "all defaults" },
|
||||||
key: "version",
|
|
||||||
problem: `unknown version ${JSON.stringify(record.version)}`,
|
|
||||||
fallback: "all defaults",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
const configVersion = versionResult.version;
|
||||||
|
|
||||||
// Mode: unknown or missing resolves to shadow (fail-closed). A v1
|
const modeResult = parseMode(record);
|
||||||
// enforce cannot silently inherit the v2 risk contract (ADR 0008).
|
diagnostics.push(...modeResult.diagnostics);
|
||||||
let mode: JudgeMode = "shadow";
|
let mode = modeResult.mode;
|
||||||
if (record.mode !== undefined) {
|
|
||||||
if (record.mode === "shadow" || record.mode === "enforce") {
|
// A v1 enforce cannot silently inherit the v2 risk contract (ADR 0008).
|
||||||
mode = record.mode;
|
|
||||||
} else {
|
|
||||||
diagnostics.push({
|
|
||||||
key: "mode",
|
|
||||||
problem: `unknown mode ${JSON.stringify(record.mode)}`,
|
|
||||||
fallback: "shadow",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (configVersion === 1 && mode === "enforce") {
|
if (configVersion === 1 && mode === "enforce") {
|
||||||
mode = "shadow";
|
mode = "shadow";
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
@@ -172,60 +280,21 @@ export function loadJudgeConfig(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Judge model: v2-only. A malformed model poisons the consent file —
|
const modelResult = parseJudgeModelField(record, configVersion);
|
||||||
// fail the whole config closed to shadow rather than silently
|
diagnostics.push(...modelResult.diagnostics);
|
||||||
// substituting the session model.
|
if (modelResult.demoteToShadow) {
|
||||||
let judgeModel: JudgeModelSelection | undefined;
|
|
||||||
if (record.model !== undefined) {
|
|
||||||
if (configVersion === 2) {
|
|
||||||
judgeModel = parseJudgeModel(record.model);
|
|
||||||
if (judgeModel === undefined) {
|
|
||||||
mode = "shadow";
|
mode = "shadow";
|
||||||
diagnostics.push({
|
|
||||||
key: "model",
|
|
||||||
problem: `invalid model ${JSON.stringify(record.model)} (expected {provider, id} with non-empty strings)`,
|
|
||||||
fallback: "shadow (no judge model)",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
diagnostics.push({
|
|
||||||
key: "model",
|
|
||||||
problem: "model selection requires \"version\": 2",
|
|
||||||
fallback: "session model",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout: integers in [5_000, 30_000]; anything else falls back to the
|
const timeoutResult = parseTimeout(record);
|
||||||
// documented 15,000 ms default. Boundary semantics: 4,999 and 30,001
|
diagnostics.push(...timeoutResult.diagnostics);
|
||||||
// are invalid, 5,000 and 30,000 are valid (PIEXTENSIO-3 boundaries).
|
|
||||||
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
||||||
let timeoutCohort: EffectiveJudgeConfig["timeoutCohort"] = "default";
|
|
||||||
if (record.timeoutMs !== undefined) {
|
|
||||||
const value = record.timeoutMs;
|
|
||||||
if (
|
|
||||||
typeof value === "number" &&
|
|
||||||
Number.isInteger(value) &&
|
|
||||||
value >= MIN_TIMEOUT_MS &&
|
|
||||||
value <= MAX_TIMEOUT_MS
|
|
||||||
) {
|
|
||||||
timeoutMs = value;
|
|
||||||
timeoutCohort = value === DEFAULT_TIMEOUT_MS ? "default" : value;
|
|
||||||
} else {
|
|
||||||
diagnostics.push({
|
|
||||||
key: "timeoutMs",
|
|
||||||
problem: `invalid timeoutMs ${JSON.stringify(value)}`,
|
|
||||||
fallback: `${DEFAULT_TIMEOUT_MS} (default)`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
configVersion,
|
configVersion,
|
||||||
mode,
|
mode,
|
||||||
timeoutMs,
|
timeoutMs: timeoutResult.timeoutMs,
|
||||||
timeoutCohort,
|
timeoutCohort: timeoutResult.timeoutCohort,
|
||||||
judgeModel,
|
judgeModel: modelResult.judgeModel,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ import {
|
|||||||
getPermissionsService,
|
getPermissionsService,
|
||||||
PERMISSIONS_READY_CHANNEL,
|
PERMISSIONS_READY_CHANNEL,
|
||||||
type PromptPermissionDetails,
|
type PromptPermissionDetails,
|
||||||
|
type AuthorizerLog,
|
||||||
|
type AuthorizerVerdict,
|
||||||
} from "@gotgenes/pi-permission-system";
|
} from "@gotgenes/pi-permission-system";
|
||||||
import { buildBashJudgmentEvidence } from "./evidence";
|
import { buildBashJudgmentEvidence } from "./evidence";
|
||||||
import {
|
import {
|
||||||
createModelAvailability,
|
createModelAvailability,
|
||||||
requestStructuredVerdict,
|
requestStructuredVerdict,
|
||||||
type ModelAvailability,
|
type ModelAvailability,
|
||||||
|
type ModelAttempt,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
import { PROMPT_VERSION, TOOL_SCHEMA_VERSION } from "./prompt";
|
import { PROMPT_VERSION, TOOL_SCHEMA_VERSION } from "./prompt";
|
||||||
import { loadJudgeConfig, type EffectiveJudgeConfig } from "./config";
|
import { loadJudgeConfig, type EffectiveJudgeConfig } from "./config";
|
||||||
@@ -31,6 +34,7 @@ import {
|
|||||||
loadModelCatalog,
|
loadModelCatalog,
|
||||||
DEFAULT_CATALOG_PATH,
|
DEFAULT_CATALOG_PATH,
|
||||||
type ModelCatalogClassification,
|
type ModelCatalogClassification,
|
||||||
|
type LoadedModelCatalog,
|
||||||
} from "./catalog";
|
} from "./catalog";
|
||||||
|
|
||||||
const LINK_NAME = "ai-bash-judge";
|
const LINK_NAME = "ai-bash-judge";
|
||||||
@@ -166,31 +170,161 @@ function resolveJudgeModel(
|
|||||||
return { kind: "model", model: found, source: "configured" };
|
return { kind: "model", model: found, source: "configured" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register a Shadow-only structured-output judge for local native Bash asks. */
|
/** Per-call emission context shared by the result emitters. */
|
||||||
export default function permissionAiJudge(pi: ExtensionAPI): void {
|
interface EmitContext {
|
||||||
let root: RootSession | undefined;
|
readonly captured: RootSession;
|
||||||
let disposeAuthorizer: (() => void) | undefined;
|
readonly details: PromptPermissionDetails;
|
||||||
|
readonly startedAt: number;
|
||||||
|
readonly emitResult: (record: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
function tryRegister(): void {
|
/** Shared identity/cohort fields; latency is measured at emit time. */
|
||||||
if (disposeAuthorizer !== undefined || root === undefined) {
|
function emitBase(ctx: EmitContext): Record<string, unknown> {
|
||||||
return;
|
return resultBase(
|
||||||
}
|
ctx.captured.judgeRuntimeId,
|
||||||
|
ctx.details,
|
||||||
|
ctx.startedAt,
|
||||||
|
ctx.captured.config,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const service = getPermissionsService();
|
/**
|
||||||
if (service === undefined) {
|
* Emit a `preflight_defer` result (fail-closed before the model runs) and
|
||||||
return;
|
* return the matching defer verdict.
|
||||||
}
|
*/
|
||||||
|
function preflightDefer(
|
||||||
|
ctx: EmitContext,
|
||||||
|
code: string,
|
||||||
|
eq: Record<string, unknown>,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
): AuthorizerVerdict {
|
||||||
|
ctx.emitResult({
|
||||||
|
...emitBase(ctx),
|
||||||
|
resultKind: "preflight_defer",
|
||||||
|
verdict: null,
|
||||||
|
effectiveVerdict: "defer",
|
||||||
|
modelCalled: false,
|
||||||
|
code,
|
||||||
|
...extra,
|
||||||
|
evidenceQuality: eq,
|
||||||
|
});
|
||||||
|
return { kind: "defer" };
|
||||||
|
}
|
||||||
|
|
||||||
const captured = root;
|
/**
|
||||||
disposeAuthorizer = service.registerAuthorizer(
|
* Emit an `infrastructure_failure` result with a defer verdict (e.g. the
|
||||||
LINK_NAME,
|
* judge model cannot be resolved).
|
||||||
async (details, _query, log) => {
|
*/
|
||||||
|
function infrastructureDefer(
|
||||||
|
ctx: EmitContext,
|
||||||
|
code: string,
|
||||||
|
eq: Record<string, unknown>,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
): AuthorizerVerdict {
|
||||||
|
ctx.emitResult({
|
||||||
|
...emitBase(ctx),
|
||||||
|
resultKind: "infrastructure_failure",
|
||||||
|
verdict: null,
|
||||||
|
effectiveVerdict: "defer",
|
||||||
|
modelCalled: false,
|
||||||
|
code,
|
||||||
|
...extra,
|
||||||
|
evidenceQuality: eq,
|
||||||
|
});
|
||||||
|
return { kind: "defer" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit the `judgment` result for a model call that returned a verdict. */
|
||||||
|
function emitJudgmentResult(
|
||||||
|
ctx: EmitContext,
|
||||||
|
result: Extract<ModelAttempt, { readonly kind: "judgment" }>,
|
||||||
|
conversation: ConversationEvidence,
|
||||||
|
effectiveVerdict: "allow" | "defer",
|
||||||
|
authorityBlockedBy: string | null,
|
||||||
|
modelSource: "configured" | "session",
|
||||||
|
risk: HighRiskMatch | undefined,
|
||||||
|
): void {
|
||||||
|
ctx.emitResult({
|
||||||
|
...emitBase(ctx),
|
||||||
|
resultKind: "judgment",
|
||||||
|
verdict: result.verdict,
|
||||||
|
effectiveVerdict,
|
||||||
|
authorityBlockedBy,
|
||||||
|
modelCalled: true,
|
||||||
|
code: null,
|
||||||
|
modelSource,
|
||||||
|
provider: result.metadata.provider,
|
||||||
|
model: result.metadata.model,
|
||||||
|
api: result.metadata.api,
|
||||||
|
riskOverride: risk ?? null,
|
||||||
|
// Log keys deliberately avoid the substring
|
||||||
|
// "token": permission-system masks any key matching
|
||||||
|
// /token/i (structural key-name redaction), which
|
||||||
|
// would erase usage telemetry from the review log.
|
||||||
|
inputUsage: result.inputTokens,
|
||||||
|
outputUsage: result.outputTokens,
|
||||||
|
modelLatencyMs: result.modelLatencyMs,
|
||||||
|
reasonLength: reasonLength(result.reason),
|
||||||
|
evidenceQuality: evidenceQuality(true, conversation, ctx.captured.getCwd()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit the post-model `infrastructure_failure` result under authority. */
|
||||||
|
function emitInfrastructureResult(
|
||||||
|
ctx: EmitContext,
|
||||||
|
result: Extract<ModelAttempt, { readonly kind: "infrastructure_failure" }>,
|
||||||
|
conversation: ConversationEvidence,
|
||||||
|
effectiveVerdict: "allow" | "defer",
|
||||||
|
authorityBlockedBy: string | null,
|
||||||
|
modelSource: "configured" | "session",
|
||||||
|
risk: HighRiskMatch | undefined,
|
||||||
|
): void {
|
||||||
|
ctx.emitResult({
|
||||||
|
...emitBase(ctx),
|
||||||
|
resultKind: "infrastructure_failure",
|
||||||
|
verdict: null,
|
||||||
|
effectiveVerdict,
|
||||||
|
authorityBlockedBy,
|
||||||
|
modelCalled: result.modelCalled,
|
||||||
|
code: result.code,
|
||||||
|
modelSource,
|
||||||
|
provider: result.metadata?.provider ?? null,
|
||||||
|
model: result.metadata?.model ?? null,
|
||||||
|
api: result.metadata?.api ?? null,
|
||||||
|
riskOverride: risk ?? null,
|
||||||
|
inputUsage: result.inputTokens ?? null,
|
||||||
|
outputUsage: result.outputTokens ?? null,
|
||||||
|
modelLatencyMs: result.modelLatencyMs,
|
||||||
|
evidenceQuality: evidenceQuality(true, conversation, ctx.captured.getCwd()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One authorize call: enroll, preflight-gate, judge, and enforce the truth
|
||||||
|
* table. Extracted from the registerAuthorizer callback so each stage reads
|
||||||
|
* linearly; any exception fails closed (defer) without logging raw errors.
|
||||||
|
*/
|
||||||
|
async function judgeAuthorize(
|
||||||
|
captured: RootSession,
|
||||||
|
details: PromptPermissionDetails,
|
||||||
|
log: AuthorizerLog,
|
||||||
|
): Promise<AuthorizerVerdict> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const sink: ReviewSink = createReviewSink({
|
const sink: ReviewSink = createReviewSink({
|
||||||
log,
|
log,
|
||||||
reviewLogEnabled: captured.reviewLogEnabled,
|
reviewLogEnabled: captured.reviewLogEnabled,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
const ctx: EmitContext = {
|
||||||
|
captured,
|
||||||
|
details,
|
||||||
|
startedAt,
|
||||||
|
emitResult: (record) => {
|
||||||
|
sink.review("ai_bash_judge.result", record);
|
||||||
|
captured.auditLog.audit("ai_bash_judge.result", record);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Judge-owned enrollment record (ADR 0006 denominator:
|
// Judge-owned enrollment record (ADR 0006 denominator:
|
||||||
// asks the Judge received, per its own audit log).
|
// asks the Judge received, per its own audit log).
|
||||||
// Non-bash surfaces are ignored below without a shadow
|
// Non-bash surfaces are ignored below without a shadow
|
||||||
@@ -215,10 +349,6 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
: (details.command ?? null),
|
: (details.command ?? null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const emitResult = (record: Record<string, unknown>): void => {
|
|
||||||
sink.review("ai_bash_judge.result", record);
|
|
||||||
captured.auditLog.audit("ai_bash_judge.result", record);
|
|
||||||
};
|
|
||||||
// Forwarded asks do not carry a structured child full
|
// Forwarded asks do not carry a structured child full
|
||||||
// command in permission-system 25.3/25.4. Never parse the
|
// command in permission-system 25.3/25.4. Never parse the
|
||||||
// legacy prose. The deferral is recorded so the request
|
// legacy prose. The deferral is recorded so the request
|
||||||
@@ -228,21 +358,11 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
details.forwarding !== undefined ||
|
details.forwarding !== undefined ||
|
||||||
details.payload.kind === "forwarded"
|
details.payload.kind === "forwarded"
|
||||||
) {
|
) {
|
||||||
emitResult({
|
return preflightDefer(
|
||||||
...resultBase(
|
ctx,
|
||||||
captured.judgeRuntimeId,
|
"missing_structured_input",
|
||||||
details,
|
evidenceQuality(false, EMPTY_CONVERSATION, "", false),
|
||||||
startedAt,
|
);
|
||||||
captured.config,
|
|
||||||
),
|
|
||||||
resultKind: "preflight_defer",
|
|
||||||
verdict: null,
|
|
||||||
effectiveVerdict: "defer",
|
|
||||||
modelCalled: false,
|
|
||||||
code: "missing_structured_input",
|
|
||||||
evidenceQuality: evidenceQuality(false, EMPTY_CONVERSATION, "", false),
|
|
||||||
});
|
|
||||||
return { kind: "defer" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore unrelated permission surfaces without producing a
|
// Ignore unrelated permission surfaces without producing a
|
||||||
@@ -252,43 +372,21 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
return { kind: "defer" };
|
return { kind: "defer" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (captured.getSessionId() !== captured.expectedSessionId) {
|
||||||
captured.getSessionId() !== captured.expectedSessionId
|
return preflightDefer(
|
||||||
) {
|
ctx,
|
||||||
emitResult({
|
"session_ownership_unproven",
|
||||||
...resultBase(
|
evidenceQuality(false, EMPTY_CONVERSATION, ""),
|
||||||
captured.judgeRuntimeId,
|
);
|
||||||
details,
|
|
||||||
startedAt,
|
|
||||||
captured.config,
|
|
||||||
),
|
|
||||||
resultKind: "preflight_defer",
|
|
||||||
verdict: null,
|
|
||||||
effectiveVerdict: "defer",
|
|
||||||
modelCalled: false,
|
|
||||||
code: "session_ownership_unproven",
|
|
||||||
evidenceQuality: evidenceQuality(false, EMPTY_CONVERSATION, ""),
|
|
||||||
});
|
|
||||||
return { kind: "defer" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const evidence = buildBashJudgmentEvidence(details);
|
const evidence = buildBashJudgmentEvidence(details);
|
||||||
if (evidence === undefined) {
|
if (evidence === undefined) {
|
||||||
emitResult({
|
return preflightDefer(
|
||||||
...resultBase(
|
ctx,
|
||||||
captured.judgeRuntimeId,
|
"invalid_evidence",
|
||||||
details,
|
evidenceQuality(false, EMPTY_CONVERSATION, ""),
|
||||||
startedAt,
|
);
|
||||||
captured.config,
|
|
||||||
),
|
|
||||||
resultKind: "preflight_defer",
|
|
||||||
verdict: null,
|
|
||||||
effectiveVerdict: "defer",
|
|
||||||
modelCalled: false,
|
|
||||||
code: "invalid_evidence",
|
|
||||||
evidenceQuality: evidenceQuality(false, EMPTY_CONVERSATION, ""),
|
|
||||||
});
|
|
||||||
return { kind: "defer" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Built-in high-risk override (ADR 0008): clear-cut
|
// Built-in high-risk override (ADR 0008): clear-cut
|
||||||
@@ -299,23 +397,12 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
evidence.fullCommand,
|
evidence.fullCommand,
|
||||||
);
|
);
|
||||||
if (risk !== undefined && captured.config.mode === "enforce") {
|
if (risk !== undefined && captured.config.mode === "enforce") {
|
||||||
emitResult({
|
return preflightDefer(
|
||||||
...resultBase(
|
ctx,
|
||||||
captured.judgeRuntimeId,
|
"high_risk_override",
|
||||||
details,
|
evidenceQuality(true, EMPTY_CONVERSATION, captured.getCwd()),
|
||||||
startedAt,
|
{ riskCategory: risk.category, riskRule: risk.rule },
|
||||||
captured.config,
|
);
|
||||||
),
|
|
||||||
resultKind: "preflight_defer",
|
|
||||||
verdict: null,
|
|
||||||
effectiveVerdict: "defer",
|
|
||||||
modelCalled: false,
|
|
||||||
code: "high_risk_override",
|
|
||||||
riskCategory: risk.category,
|
|
||||||
riskRule: risk.rule,
|
|
||||||
evidenceQuality: evidenceQuality(true, EMPTY_CONVERSATION, captured.getCwd()),
|
|
||||||
});
|
|
||||||
return { kind: "defer" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-request judge-model resolution (PIEXTENSIO-3 cat.3
|
// Per-request judge-model resolution (PIEXTENSIO-3 cat.3
|
||||||
@@ -329,25 +416,17 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
captured.modelRegistry,
|
captured.modelRegistry,
|
||||||
);
|
);
|
||||||
if (resolved.kind === "unavailable") {
|
if (resolved.kind === "unavailable") {
|
||||||
emitResult({
|
return infrastructureDefer(
|
||||||
...resultBase(
|
ctx,
|
||||||
captured.judgeRuntimeId,
|
"judge_model_unavailable",
|
||||||
details,
|
evidenceQuality(true, EMPTY_CONVERSATION, captured.getCwd()),
|
||||||
startedAt,
|
{
|
||||||
captured.config,
|
|
||||||
),
|
|
||||||
resultKind: "infrastructure_failure",
|
|
||||||
verdict: null,
|
|
||||||
effectiveVerdict: "defer",
|
|
||||||
modelCalled: false,
|
|
||||||
code: "judge_model_unavailable",
|
|
||||||
provider: captured.config.judgeModel?.provider ?? null,
|
provider: captured.config.judgeModel?.provider ?? null,
|
||||||
model: captured.config.judgeModel?.id ?? null,
|
model: captured.config.judgeModel?.id ?? null,
|
||||||
api: null,
|
api: null,
|
||||||
riskOverride: risk ?? null,
|
riskOverride: risk ?? null,
|
||||||
evidenceQuality: evidenceQuality(true, EMPTY_CONVERSATION, captured.getCwd()),
|
},
|
||||||
});
|
);
|
||||||
return { kind: "defer" };
|
|
||||||
}
|
}
|
||||||
const modelSource = resolved.source;
|
const modelSource = resolved.source;
|
||||||
const availability: ModelAvailability = createModelAvailability(
|
const availability: ModelAvailability = createModelAvailability(
|
||||||
@@ -397,58 +476,15 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
authority.kind === "allow" ? null : authority.blockedBy;
|
authority.kind === "allow" ? null : authority.blockedBy;
|
||||||
|
|
||||||
if (result.kind === "judgment") {
|
if (result.kind === "judgment") {
|
||||||
emitResult({
|
emitJudgmentResult(
|
||||||
...resultBase(
|
ctx, result, conversation,
|
||||||
captured.judgeRuntimeId,
|
effectiveVerdict, authorityBlockedBy, modelSource, risk,
|
||||||
details,
|
);
|
||||||
startedAt,
|
|
||||||
captured.config,
|
|
||||||
),
|
|
||||||
resultKind: "judgment",
|
|
||||||
verdict: result.verdict,
|
|
||||||
effectiveVerdict,
|
|
||||||
authorityBlockedBy,
|
|
||||||
modelCalled: true,
|
|
||||||
code: null,
|
|
||||||
modelSource,
|
|
||||||
provider: result.metadata.provider,
|
|
||||||
model: result.metadata.model,
|
|
||||||
api: result.metadata.api,
|
|
||||||
riskOverride: risk ?? null,
|
|
||||||
// Log keys deliberately avoid the substring
|
|
||||||
// "token": permission-system masks any key matching
|
|
||||||
// /token/i (structural key-name redaction), which
|
|
||||||
// would erase usage telemetry from the review log.
|
|
||||||
inputUsage: result.inputTokens,
|
|
||||||
outputUsage: result.outputTokens,
|
|
||||||
modelLatencyMs: result.modelLatencyMs,
|
|
||||||
reasonLength: reasonLength(result.reason),
|
|
||||||
evidenceQuality: evidenceQuality(true, conversation, captured.getCwd()),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
emitResult({
|
emitInfrastructureResult(
|
||||||
...resultBase(
|
ctx, result, conversation,
|
||||||
captured.judgeRuntimeId,
|
effectiveVerdict, authorityBlockedBy, modelSource, risk,
|
||||||
details,
|
);
|
||||||
startedAt,
|
|
||||||
captured.config,
|
|
||||||
),
|
|
||||||
resultKind: "infrastructure_failure",
|
|
||||||
verdict: null,
|
|
||||||
effectiveVerdict,
|
|
||||||
authorityBlockedBy,
|
|
||||||
modelCalled: result.modelCalled,
|
|
||||||
code: result.code,
|
|
||||||
modelSource,
|
|
||||||
provider: result.metadata?.provider ?? null,
|
|
||||||
model: result.metadata?.model ?? null,
|
|
||||||
api: result.metadata?.api ?? null,
|
|
||||||
riskOverride: risk ?? null,
|
|
||||||
inputUsage: result.inputTokens ?? null,
|
|
||||||
outputUsage: result.outputTokens ?? null,
|
|
||||||
modelLatencyMs: result.modelLatencyMs,
|
|
||||||
evidenceQuality: evidenceQuality(true, conversation, captured.getCwd()),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return authority.kind === "allow"
|
return authority.kind === "allow"
|
||||||
@@ -461,7 +497,77 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
sink.debug("ai_bash_judge.exception");
|
sink.debug("ai_bash_judge.exception");
|
||||||
return { kind: "defer" };
|
return { kind: "defer" };
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One non-blocking session notice in Enforce mode: the risk contract and
|
||||||
|
* the effective judge model (ADR 0008). Not repeated per ask. The advisory
|
||||||
|
* model catalog (PIEXTENSIO-24) only annotates this notice — it never gates
|
||||||
|
* authority.
|
||||||
|
*/
|
||||||
|
function notifyEnforceActive(
|
||||||
|
notify: (message: string, kind: "info" | "warning") => void,
|
||||||
|
config: EffectiveJudgeConfig,
|
||||||
|
sessionModel: Model<any> | undefined,
|
||||||
|
catalog: LoadedModelCatalog,
|
||||||
|
): void {
|
||||||
|
const configured = config.judgeModel;
|
||||||
|
let judgeModelDescription: string;
|
||||||
|
let classification: ModelCatalogClassification | null;
|
||||||
|
if (configured !== undefined) {
|
||||||
|
judgeModelDescription = `${configured.provider}/${configured.id} (configured)`;
|
||||||
|
classification = classifyModel(
|
||||||
|
catalog,
|
||||||
|
configured.provider,
|
||||||
|
configured.id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (sessionModel === undefined) {
|
||||||
|
judgeModelDescription =
|
||||||
|
"the current session model (none resolved yet)";
|
||||||
|
classification = null;
|
||||||
|
} else {
|
||||||
|
judgeModelDescription = `${sessionModel.provider}/${sessionModel.id} (current session model)`;
|
||||||
|
classification = classifyModel(
|
||||||
|
catalog,
|
||||||
|
sessionModel.provider,
|
||||||
|
sessionModel.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const catalogNote =
|
||||||
|
classification === "unlisted"
|
||||||
|
? " This model is untested in the advisory catalog — used at your own risk."
|
||||||
|
: classification === "deprecated" || classification === "revoked"
|
||||||
|
? ` Advisory catalog status: ${classification}.`
|
||||||
|
: "";
|
||||||
|
notify(
|
||||||
|
`ai-bash-judge Enforce active: ${judgeModelDescription} judges Bash asks; allow skips the dialog — you accept the risk of model misjudgment (ADR 0008). High-risk shapes (irreversible, publish, system, credentials) always ask.${catalogNote}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Register a Shadow-only structured-output judge for local native Bash asks. */
|
||||||
|
export default function permissionAiJudge(pi: ExtensionAPI): void {
|
||||||
|
let root: RootSession | undefined;
|
||||||
|
let disposeAuthorizer: (() => void) | undefined;
|
||||||
|
|
||||||
|
function tryRegister(): void {
|
||||||
|
if (disposeAuthorizer !== undefined || root === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = getPermissionsService();
|
||||||
|
if (service === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const captured = root;
|
||||||
|
disposeAuthorizer = service.registerAuthorizer(
|
||||||
|
LINK_NAME,
|
||||||
|
async (details, _query, log) =>
|
||||||
|
judgeAuthorize(captured, details, log),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,40 +619,11 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (root.config.mode === "enforce") {
|
if (root.config.mode === "enforce") {
|
||||||
const configured = root.config.judgeModel;
|
notifyEnforceActive(
|
||||||
let judgeModelDescription: string;
|
(message, kind) => ctx.ui.notify(message, kind),
|
||||||
let classification: ModelCatalogClassification | null;
|
root.config,
|
||||||
if (configured !== undefined) {
|
ctx.model,
|
||||||
judgeModelDescription = `${configured.provider}/${configured.id} (configured)`;
|
|
||||||
classification = classifyModel(
|
|
||||||
catalogResult.catalog,
|
catalogResult.catalog,
|
||||||
configured.provider,
|
|
||||||
configured.id,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const sessionModel = ctx.model;
|
|
||||||
if (sessionModel === undefined) {
|
|
||||||
judgeModelDescription =
|
|
||||||
"the current session model (none resolved yet)";
|
|
||||||
classification = null;
|
|
||||||
} else {
|
|
||||||
judgeModelDescription = `${sessionModel.provider}/${sessionModel.id} (current session model)`;
|
|
||||||
classification = classifyModel(
|
|
||||||
catalogResult.catalog,
|
|
||||||
sessionModel.provider,
|
|
||||||
sessionModel.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const catalogNote =
|
|
||||||
classification === "unlisted"
|
|
||||||
? " This model is untested in the advisory catalog — used at your own risk."
|
|
||||||
: classification === "deprecated" || classification === "revoked"
|
|
||||||
? ` Advisory catalog status: ${classification}.`
|
|
||||||
: "";
|
|
||||||
ctx.ui.notify(
|
|
||||||
`ai-bash-judge Enforce active: ${judgeModelDescription} judges Bash asks; allow skips the dialog — you accept the risk of model misjudgment (ADR 0008). High-risk shapes (irreversible, publish, system, credentials) always ask.${catalogNote}`,
|
|
||||||
"info",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
tryRegister();
|
tryRegister();
|
||||||
|
|||||||
Reference in New Issue
Block a user