@yagni-app/code-staging 0.3.0-staging.1067.1 → 0.3.0-staging.1071.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The Guardian — LLM auto-review of prompt-band bash commands (YAG-504).
3
+ *
4
+ * Pure half: verdict types, state tracking, circuit breaker, JSON parsing.
5
+ * I/O half: reviewCommand spawns a locked-down child pi (same runStage seam
6
+ * the advisor uses) on the efficient tier with read-only tools and a risk
7
+ * policy persona. The child returns a JSON verdict; the gate acts on it.
8
+ *
9
+ * Same pure/IO split as advisor.ts (decideConsult pure, askAdvisorTool I/O)
10
+ * and permission.ts (decideGate pure, registerPermissionGate I/O), for the
11
+ * same reason: the rules are what need exhaustive tests, and they must not
12
+ * require a child process to exercise.
13
+ *
14
+ * Trigger: the exec policy classifies a bash command as "prompt" (not clearly
15
+ * safe, not clearly forbidden). The Guardian reviews it instead of interrupting
16
+ * the user. On allow, the command runs. On deny, the agent sees the rationale
17
+ * and is told to find a safer alternative or ask the user. On timeout/error,
18
+ * auto mode fails closed (block); review mode falls back to the user prompt.
19
+ *
20
+ * Circuit breaker: 3 consecutive denials in one turn → turn interrupted.
21
+ */
22
+ import { runStage as defaultRunStage } from "./pipeline/runner.js";
23
+ export const DEFAULT_GUARDIAN_LIMITS = {
24
+ maxReviews: 30,
25
+ maxConsecutiveDenials: 3,
26
+ timeoutMs: 15_000,
27
+ };
28
+ /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
29
+ export const GUARDIAN_MODEL_TIER = "efficient";
30
+ /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
31
+ export const GUARDIAN_TOOLS = ["read"];
32
+ export function makeGuardianState() {
33
+ const state = { reviews: 0, consecutiveDenials: 0 };
34
+ return {
35
+ read: () => ({ ...state }),
36
+ recordReview(outcome) {
37
+ state.reviews += 1;
38
+ if (outcome === "deny") {
39
+ state.consecutiveDenials += 1;
40
+ }
41
+ else {
42
+ state.consecutiveDenials = 0;
43
+ }
44
+ return { ...state };
45
+ },
46
+ resetTurn() {
47
+ state.consecutiveDenials = 0;
48
+ },
49
+ };
50
+ }
51
+ export function checkCircuitBreaker(state, limits) {
52
+ if (state.consecutiveDenials >= limits.maxConsecutiveDenials) {
53
+ return {
54
+ tripped: true,
55
+ reason: `Guardian denied ${state.consecutiveDenials} consecutive actions this turn. Pausing for human review.`,
56
+ };
57
+ }
58
+ return { tripped: false };
59
+ }
60
+ // --- Verdict parsing (fail closed on malformed) ---
61
+ export function parseVerdict(raw) {
62
+ try {
63
+ // Efficient-tier models may wrap JSON in markdown fences despite
64
+ // instructions to output raw JSON. Extract the first {...} block.
65
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
66
+ const jsonStr = jsonMatch ? jsonMatch[0] : raw;
67
+ const parsed = JSON.parse(jsonStr);
68
+ const outcome = parsed?.outcome;
69
+ if (outcome !== "allow" && outcome !== "deny")
70
+ return null;
71
+ const riskLevel = parsed.riskLevel;
72
+ const validLevels = ["low", "medium", "high", "critical"];
73
+ return {
74
+ outcome,
75
+ riskLevel: typeof riskLevel === "string" && validLevels.includes(riskLevel)
76
+ ? riskLevel
77
+ : "medium",
78
+ rationale: typeof parsed.rationale === "string" && parsed.rationale.trim().length > 0
79
+ ? parsed.rationale.trim()
80
+ : "No rationale provided.",
81
+ };
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ // --- /cost subtotal ---
88
+ export function formatGuardianSubtotal(state, limits) {
89
+ if (state.reviews === 0)
90
+ return "";
91
+ const plural = state.reviews === 1 ? "review" : "reviews";
92
+ return `Guardian: ${state.reviews} ${plural}.`;
93
+ }
94
+ /**
95
+ * The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
96
+ * (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
97
+ * agent name selects the guardian persona from PERSONA_BODIES.
98
+ */
99
+ export function guardianStage(modelTier = GUARDIAN_MODEL_TIER) {
100
+ return {
101
+ id: "plan",
102
+ agent: "guardian",
103
+ model: modelTier,
104
+ tools: GUARDIAN_TOOLS,
105
+ taskTemplate: "{ticket}",
106
+ };
107
+ }
108
+ /**
109
+ * Run a Guardian consult: spawn a locked-down child pi with the risk policy
110
+ * persona and the command as the task. Parse the JSON verdict from the output.
111
+ * Returns { verdict, cost } on success, { verdict: null, error, cost } on failure.
112
+ */
113
+ export async function reviewCommand(command, deps) {
114
+ const runStage = deps.runStage ?? defaultRunStage;
115
+ const stage = guardianStage(deps.modelTier);
116
+ // Build the abort signal with the Guardian timeout.
117
+ const timeoutMs = DEFAULT_GUARDIAN_LIMITS.timeoutMs;
118
+ const controller = new AbortController();
119
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
120
+ timer.unref?.();
121
+ // Chain with the caller's signal if present.
122
+ if (deps.signal) {
123
+ if (deps.signal.aborted)
124
+ controller.abort();
125
+ else
126
+ deps.signal.addEventListener("abort", () => controller.abort(), { once: true });
127
+ }
128
+ try {
129
+ const result = await runStage(stage, { ticket: `Review this shell command for safety. Output only the JSON verdict.\n\nCommand:\n${command}` }, {
130
+ cwd: deps.cwd,
131
+ signal: controller.signal,
132
+ callerLabel: "guardian",
133
+ });
134
+ const cost = result.usage?.cost ?? 0;
135
+ const output = result.finalOutput.trim();
136
+ if (!output) {
137
+ return { verdict: null, error: "empty", cost };
138
+ }
139
+ const verdict = parseVerdict(output);
140
+ if (!verdict) {
141
+ return { verdict: null, error: "malformed", cost };
142
+ }
143
+ return { verdict, cost };
144
+ }
145
+ catch (err) {
146
+ // Distinguish timeout from network/process errors.
147
+ if (controller.signal.aborted && !deps.signal?.aborted) {
148
+ return { verdict: null, error: "timeout", cost: 0 };
149
+ }
150
+ return { verdict: null, error: "network", cost: 0 };
151
+ }
152
+ finally {
153
+ clearTimeout(timer);
154
+ }
155
+ }
156
+ /**
157
+ * Create a sanitized diagnostic event. Never includes the raw command text
158
+ * (could contain secrets). YAGNI_DEBUG=1 adds rationale and a command hash.
159
+ */
160
+ export function buildDiagnosticEvent(outcome, opts) {
161
+ const ev = {
162
+ event: "guardian_review",
163
+ outcome,
164
+ ...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
165
+ ...(opts.tier !== undefined ? { tier: opts.tier } : {}),
166
+ };
167
+ if (opts.debug) {
168
+ if (opts.rationale)
169
+ ev.rationale = opts.rationale;
170
+ if (opts.commandHash)
171
+ ev.commandHash = opts.commandHash;
172
+ }
173
+ return ev;
174
+ }
175
+ //# sourceMappingURL=guardian.js.map
@@ -106,6 +106,8 @@ export { makeAskYagniTool } from "./askYagniTool.js";
106
106
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
107
107
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
108
108
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
109
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, reviewCommand, } from "./guardian.js";
110
+ export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./guardian.js";
109
111
  export type { Citation, MakeAskYagniToolOptions } from "./askYagniTool.js";
110
112
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
111
113
  export type { MakeReviewToolOptions } from "./reviewTool.js";
@@ -141,7 +143,9 @@ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkd
141
143
  export type { SubagentDef, SubagentSource } from "./subagents.js";
142
144
  export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
143
145
  export type { TodoItem, TodoStatus, TodoTheme } from "./todos.js";
144
- export { decideGate, registerPermissionGate, filterStalePlanContext, DEFAULT_PERMISSION_POLICY, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
146
+ export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
147
+ export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
148
+ export type { ExecDecision, ExecPolicy, PrefixRule, ExecClassification, } from "./execPolicy.js";
145
149
  export type { PermissionMode, PermissionPolicy, GateDecision, RegisterPermissionDeps, BlessRememberInfo, } from "./permission.js";
146
150
  export { makeBlessStore, blessPath } from "./bless.js";
147
151
  export type { BlessStore, BlessRule } from "./bless.js";
@@ -2,6 +2,7 @@ import { appendFileSync, mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
5
+ import { DEFAULT_GUARDIAN_LIMITS, formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, reviewCommand } from "./guardian.js";
5
6
  import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
6
7
  import { makeAskYagniTool } from "./askYagniTool.js";
7
8
  import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
@@ -234,8 +235,26 @@ export async function registerYagni(pi, deps = {}) {
234
235
  // Mutating MCP tools join write/edit/bash in the gate policy: plan mode
235
236
  // holds them, review mode confirms them.
236
237
  const modeHolder = createModeHolder();
238
+ const guardianState = makeGuardianState();
239
+ const guardianDisabled = env.YAGNI_DISABLE_GUARDIAN === "1" || env.YAGNI_DISABLE_GUARDIAN === "true";
240
+ const guardianTier = env.YAGNI_GUARDIAN_TIER ?? GUARDIAN_MODEL_TIER;
237
241
  registerPermissionGate(pi, {
238
242
  modeHolder,
243
+ guardianState,
244
+ guardianLimits: DEFAULT_GUARDIAN_LIMITS,
245
+ guardianTier,
246
+ guardianDisabled,
247
+ guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
248
+ onGuardianReview: (ev) => {
249
+ try {
250
+ if (process.env.NODE_TEST_CONTEXT)
251
+ return;
252
+ const logPath = join(codeStateHome(null), "logs", "guardian.log");
253
+ mkdirSync(dirname(logPath), { recursive: true });
254
+ appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...ev }) + "\n", "utf8");
255
+ }
256
+ catch { /* logging must never break the session */ }
257
+ },
239
258
  ...(mcpMutatingTools.length > 0
240
259
  ? {
241
260
  policy: {
@@ -271,7 +290,11 @@ export async function registerYagni(pi, deps = {}) {
271
290
  // turn_end accumulator. Thread the subtotal in explicitly for the local
272
291
  // fallback line; the server-authoritative line already counts advisor
273
292
  // spend as an ordinary caller row.
274
- advisorSubtotal: () => formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS),
293
+ advisorSubtotal: () => {
294
+ const advisor = formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS);
295
+ const guardian = formatGuardianSubtotal(guardianState.read(), DEFAULT_GUARDIAN_LIMITS);
296
+ return [advisor, guardian].filter(Boolean).join(" ");
297
+ },
275
298
  fetchHeadroom: async (signal) => {
276
299
  try {
277
300
  const res = await resilientFetch(`${baseUrl}/api/yagni-code/credits`, { method: "GET", headers: { authorization: `Bearer ${getTokenFn() ?? ""}` } }, { fetchImpl: authedFetch, signal, policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: COST_FETCH_TIMEOUT_MS, jitterRatio: 0 } });
@@ -651,6 +674,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
651
674
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
652
675
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
653
676
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
677
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, reviewCommand, } from "./guardian.js";
654
678
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
655
679
  export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
656
680
  export { makeRecordDecisionTool } from "./recordDecisionTool.js";
@@ -681,7 +705,8 @@ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkd
681
705
  export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
682
706
  // P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
683
707
  // the session bless-with-remember capture hook.
684
- export { decideGate, registerPermissionGate, filterStalePlanContext, DEFAULT_PERMISSION_POLICY, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
708
+ export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
709
+ export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
685
710
  // W4 judgment loop: the session bless store (tool + path-prefix, session-only).
686
711
  export { makeBlessStore, blessPath } from "./bless.js";
687
712
  // W4 judgment loop: the decisions surface (/decide + /decisions) + shared bank.
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
30
30
  import { type BlessStore } from "./bless.js";
31
+ import { type ExecPolicy } from "./execPolicy.js";
31
32
  export type PermissionMode = "auto" | "plan" | "review";
32
33
  /**
33
34
  * A shared, mutable holder for the current permission mode. Both
@@ -54,6 +55,12 @@ export interface PermissionPolicy {
54
55
  * judgment; default undefined (nothing pre-blessed).
55
56
  */
56
57
  isBlessed?: (toolName: string, params: Record<string, unknown>) => boolean;
58
+ /**
59
+ * Exec policy for bash command classification. When present, bash commands are
60
+ * classified by the exec policy engine before the tool-granular logic runs.
61
+ * Defaults to DEFAULT_EXEC_POLICY when absent.
62
+ */
63
+ execPolicy?: ExecPolicy;
57
64
  }
58
65
  export declare const DEFAULT_PERMISSION_POLICY: PermissionPolicy;
59
66
  /** A pure gate verdict: block outright, ask to confirm, or allow. */
@@ -62,6 +69,13 @@ export interface GateDecision {
62
69
  reason?: string;
63
70
  /** review mode only: the caller should ctx.ui.confirm before allowing. */
64
71
  confirm?: boolean;
72
+ /**
73
+ * Exec policy classification result for bash commands. When present, the
74
+ * tool_call handler should run the Guardian for "prompt" before falling
75
+ * through to the user confirm. Absent for non-bash tools or when no
76
+ * command string is available.
77
+ */
78
+ classify?: "allow" | "prompt" | "forbidden";
65
79
  }
66
80
  /**
67
81
  * Pure permission decision for one tool call under a mode + policy. Auto allows
@@ -89,16 +103,44 @@ export interface RegisterPermissionDeps {
89
103
  onBlessRemember?: (ctx: ExtensionContext, info: BlessRememberInfo) => void | Promise<void>;
90
104
  /** Shared holder so the footer can read the live mode on every render. */
91
105
  modeHolder?: ModeHolder;
106
+ /**
107
+ * Guardian state handle for the session. When present, prompt-band bash
108
+ * commands are auto-reviewed by the Guardian LLM instead of interrupting
109
+ * the user. When absent, prompt-band commands fall through to the existing
110
+ * tool-granular behavior (review: user prompt, auto: allow).
111
+ */
112
+ guardianState?: import("./guardian.js").GuardianStateHandle;
113
+ /** Guardian limits (timeouts, circuit breaker). Defaults to DEFAULT_GUARDIAN_LIMITS. */
114
+ guardianLimits?: import("./guardian.js").GuardianLimits;
115
+ /** Override the Guardian model tier (default: efficient). */
116
+ guardianTier?: string;
117
+ /** Injectable Guardian review function (tests pass a stub). */
118
+ guardianReview?: (command: string, deps: import("./guardian.js").ReviewCommandDeps) => Promise<import("./guardian.js").ReviewResult>;
119
+ /**
120
+ * Called (fire-and-forget) after each Guardian review with a sanitized
121
+ * diagnostic event. Fail-soft; never blocks.
122
+ */
123
+ onGuardianReview?: (event: import("./guardian.js").GuardianDiagnosticEvent) => void;
124
+ /** Disable the Guardian entirely (env var YAGNI_DISABLE_GUARDIAN). */
125
+ guardianDisabled?: boolean;
92
126
  }
93
- /** The customType tag on injected plan-mode context (filterable later). */
94
- export declare const PLAN_CONTEXT_TYPE = "yagni-plan-context";
127
+ /** The customType tag on injected mode-context messages (filterable later). */
128
+ export declare const MODE_CONTEXT_TYPE = "yagni-mode-context";
129
+ /** Legacy alias — the original plan-mode tag, kept for backward compat. */
130
+ export declare const PLAN_CONTEXT_TYPE = "yagni-mode-context";
95
131
  export declare const PLAN_CONTEXT_MESSAGE = "[PLAN MODE ACTIVE]\nYou are in plan mode: explore and design, change nothing.\n- write, edit, and bash are held by the permission gate; do not attempt them.\n- Read, search, and ask_yagni freely to ground the plan in how this company works.\n- Produce a concrete numbered plan of the steps you would take, with the files involved.\n- End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.\n- Once executing, track the plan's steps with todo_write.";
132
+ /** Build the mode-awareness context message for the current permission mode. */
133
+ export declare function buildModeContextMessage(mode: PermissionMode): string;
96
134
  /**
97
- * Drop previously injected plan-mode context once plan mode is off, so the
98
- * model stops believing writes are held. Pure; returns the SAME array when
99
- * nothing needs filtering so callers can cheaply detect a no-op.
135
+ * Drop previously injected mode-context messages from a DIFFERENT mode so the
136
+ * model does not keep believing it is in a prior mode. Messages matching the
137
+ * current mode are kept (the fresh injection from before_agent_start should
138
+ * survive). Pure; returns the SAME array when nothing needs filtering so callers
139
+ * can cheaply detect a no-op.
100
140
  */
101
- export declare function filterStalePlanContext<T>(messages: T[]): T[];
141
+ export declare function filterStaleModeContext<T>(messages: T[], currentMode?: PermissionMode): T[];
142
+ /** Legacy alias — the original plan-mode filter name. */
143
+ export declare const filterStalePlanContext: typeof filterStaleModeContext;
102
144
  /**
103
145
  * Wire the tool_call gate + the /mode command onto a shared mode holder. Default
104
146
  * auto, so absent any /mode this is a no-op over today's behavior.