@yagni-app/code-staging 0.3.0-staging.1079.1 → 0.3.0-staging.1081.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.
@@ -13,15 +13,23 @@
13
13
  *
14
14
  * Trigger: the exec policy classifies a bash command as "prompt" (not clearly
15
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.
16
+ * the user. On allow, the command runs. On ask (YAG-510), the user arbitrates:
17
+ * the gate shows the Guardian's question-rationale and the user approves or
18
+ * declines. On deny, the agent sees the rationale and is told to find a safer
19
+ * alternative or ask the user. On timeout/error, auto mode falls back to an
20
+ * ask when a UI exists, else fails closed; review mode falls back to the
21
+ * ordinary user confirm.
19
22
  *
20
- * Circuit breaker: 3 consecutive denials in one turn → turn interrupted.
23
+ * Circuit breaker: 3 consecutive denials within one user prompt → escalation
24
+ * (ask the user once) or, headless, interruption. Denial streaks reset on
25
+ * `before_agent_start`, which fires once per USER PROMPT (not per LLM turn).
26
+ * An `ask` outcome leaves the streak untouched — neither a denial nor an
27
+ * exoneration — so an ask-preferring model cannot disarm the breaker by
28
+ * alternating deny/ask.
21
29
  */
22
30
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
23
31
  import type { PipelineStage } from "./pipeline/types.js";
24
- export type GuardianOutcome = "allow" | "deny";
32
+ export type GuardianOutcome = "allow" | "ask" | "deny";
25
33
  export type GuardianRiskLevel = "low" | "medium" | "high" | "critical";
26
34
  export interface GuardianVerdict {
27
35
  outcome: GuardianOutcome;
@@ -64,7 +72,7 @@ export interface CircuitBreakerResult {
64
72
  export declare function checkCircuitBreaker(state: GuardianState, limits: GuardianLimits): CircuitBreakerResult;
65
73
  export declare function parseVerdict(raw: string): GuardianVerdict | null;
66
74
  export declare function formatGuardianSubtotal(state: GuardianState, limits: GuardianLimits): string;
67
- export type GuardianError = "timeout" | "malformed" | "network" | "empty";
75
+ export type GuardianError = "timeout" | "malformed" | "network" | "empty" | "aborted";
68
76
  export interface ReviewResult {
69
77
  verdict: GuardianVerdict | null;
70
78
  error?: GuardianError;
@@ -76,6 +84,14 @@ export interface ReviewCommandDeps {
76
84
  signal?: AbortSignal;
77
85
  /** Override the model tier (default: efficient). */
78
86
  modelTier?: string;
87
+ /** Consult timeout in ms (default: DEFAULT_GUARDIAN_LIMITS.timeoutMs). */
88
+ timeoutMs?: number;
89
+ /**
90
+ * The exec policy's justification for routing this command to the Guardian
91
+ * ("pushes to remote — confirm intent"). Included in the consult prompt so
92
+ * an `ask` rationale can add information beyond the static rule text.
93
+ */
94
+ execJustification?: string;
79
95
  }
80
96
  /**
81
97
  * The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
@@ -13,11 +13,19 @@
13
13
  *
14
14
  * Trigger: the exec policy classifies a bash command as "prompt" (not clearly
15
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.
16
+ * the user. On allow, the command runs. On ask (YAG-510), the user arbitrates:
17
+ * the gate shows the Guardian's question-rationale and the user approves or
18
+ * declines. On deny, the agent sees the rationale and is told to find a safer
19
+ * alternative or ask the user. On timeout/error, auto mode falls back to an
20
+ * ask when a UI exists, else fails closed; review mode falls back to the
21
+ * ordinary user confirm.
19
22
  *
20
- * Circuit breaker: 3 consecutive denials in one turn → turn interrupted.
23
+ * Circuit breaker: 3 consecutive denials within one user prompt → escalation
24
+ * (ask the user once) or, headless, interruption. Denial streaks reset on
25
+ * `before_agent_start`, which fires once per USER PROMPT (not per LLM turn).
26
+ * An `ask` outcome leaves the streak untouched — neither a denial nor an
27
+ * exoneration — so an ask-preferring model cannot disarm the breaker by
28
+ * alternating deny/ask.
21
29
  */
22
30
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
23
31
  export const DEFAULT_GUARDIAN_LIMITS = {
@@ -49,9 +57,12 @@ export function makeGuardianState() {
49
57
  if (outcome === "deny") {
50
58
  state.consecutiveDenials += 1;
51
59
  }
52
- else {
60
+ else if (outcome === "allow") {
53
61
  state.consecutiveDenials = 0;
54
62
  }
63
+ // "ask" leaves the denial streak UNCHANGED: it is neither a denial nor
64
+ // an exoneration. If it reset the streak, deny/ask/deny/ask would never
65
+ // trip the breaker (round-2 review blocker).
55
66
  return { ...state };
56
67
  },
57
68
  resetTurn() {
@@ -77,7 +88,7 @@ export function parseVerdict(raw) {
77
88
  const jsonStr = jsonMatch ? jsonMatch[0] : raw;
78
89
  const parsed = JSON.parse(jsonStr);
79
90
  const outcome = parsed?.outcome;
80
- if (outcome !== "allow" && outcome !== "deny")
91
+ if (outcome !== "allow" && outcome !== "ask" && outcome !== "deny")
81
92
  return null;
82
93
  const riskLevel = parsed.riskLevel;
83
94
  const validLevels = ["low", "medium", "high", "critical"];
@@ -124,8 +135,9 @@ export function guardianStage(modelTier = GUARDIAN_MODEL_TIER) {
124
135
  export async function reviewCommand(command, deps) {
125
136
  const runStage = deps.runStage ?? defaultRunStage;
126
137
  const stage = guardianStage(deps.modelTier);
127
- // Build the abort signal with the Guardian timeout.
128
- const timeoutMs = DEFAULT_GUARDIAN_LIMITS.timeoutMs;
138
+ // Build the abort signal with the Guardian timeout (injectable — the gate
139
+ // threads guardianLimits.timeoutMs through).
140
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_GUARDIAN_LIMITS.timeoutMs;
129
141
  const controller = new AbortController();
130
142
  const timer = setTimeout(() => controller.abort(), timeoutMs);
131
143
  timer.unref?.();
@@ -137,13 +149,23 @@ export async function reviewCommand(command, deps) {
137
149
  deps.signal.addEventListener("abort", () => controller.abort(), { once: true });
138
150
  }
139
151
  try {
140
- const result = await runStage(stage, { ticket: `Review this shell command for safety. Output only the JSON verdict.\n\nCommand:\n${command}` }, {
152
+ const justificationLine = deps.execJustification
153
+ ? `\n\nWhy this command was routed to you (static policy): ${deps.execJustification}`
154
+ : "";
155
+ const result = await runStage(stage, { ticket: `Review this shell command for safety. Output only the JSON verdict.${justificationLine}\n\nCommand:\n${command}` }, {
141
156
  cwd: deps.cwd,
142
157
  signal: controller.signal,
143
158
  callerLabel: "guardian",
144
159
  });
145
160
  const cost = result.usage?.cost ?? 0;
146
161
  const output = result.finalOutput.trim();
162
+ // An aborted child often RESOLVES with empty/partial output instead of
163
+ // throwing (observed live: ESC → 433ms "empty"). Any result produced
164
+ // while the caller's signal is aborted is an abort, not an outage — the
165
+ // gate must not warn or fall back to an ask on it.
166
+ if (deps.signal?.aborted) {
167
+ return { verdict: null, error: "aborted", cost };
168
+ }
147
169
  if (!output) {
148
170
  return { verdict: null, error: "empty", cost };
149
171
  }
@@ -154,8 +176,13 @@ export async function reviewCommand(command, deps) {
154
176
  return { verdict, cost };
155
177
  }
156
178
  catch (err) {
157
- // Distinguish timeout from network/process errors.
158
- if (controller.signal.aborted && !deps.signal?.aborted) {
179
+ // Distinguish the caller aborting (user hit ESC — must NOT be treated as
180
+ // an outage, or the fallback ask would pop a dialog on an aborted turn),
181
+ // our own timeout, and real network/process errors.
182
+ if (deps.signal?.aborted) {
183
+ return { verdict: null, error: "aborted", cost: 0 };
184
+ }
185
+ if (controller.signal.aborted) {
159
186
  return { verdict: null, error: "timeout", cost: 0 };
160
187
  }
161
188
  return { verdict: null, error: "network", cost: 0 };
@@ -1,7 +1,10 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { appendFileSync, mkdirSync } from "node:fs";
2
3
  import { dirname, join } from "node:path";
3
4
  import { Text } from "@earendil-works/pi-tui";
4
5
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
6
+ import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./approvedPrefixes.js";
7
+ import { redactCommand } from "./redact.js";
5
8
  import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand } from "./guardian.js";
6
9
  import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
7
10
  import { makeAskYagniTool } from "./askYagniTool.js";
@@ -127,7 +130,7 @@ export async function registerYagni(pi, deps = {}) {
127
130
  // after_provider_response event does NOT fire on a 401 (the OpenAI SDK throws
128
131
  // before onResponse is reached), so message_end is the only seam.
129
132
  let lastAuthRecovery = null;
130
- const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
133
+ const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled, guardianStorage: guardianStorageTier, } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
131
134
  // Lock the interactive session to the `advanced` tier only. The backend
132
135
  // catalog returns all tiers, but only `advanced` is registered with the
133
136
  // `yagni` provider, so /model and Ctrl+P show a single entry. Child
@@ -245,6 +248,23 @@ export async function registerYagni(pi, deps = {}) {
245
248
  !workspaceGuardianEnabled;
246
249
  const guardianTier = env.YAGNI_GUARDIAN_TIER ?? GUARDIAN_MODEL_TIER;
247
250
  const guardianLimits = resolveGuardianLimits(env);
251
+ // YAG-510: guardian.log stays the sanitized local debug sink (hash-only,
252
+ // never the command). The remote guardian-events stream below is the
253
+ // separate, opt-in, per-workspace analytics sink; the two are independent.
254
+ const guardianLogSink = (payload) => {
255
+ try {
256
+ if (process.env.NODE_TEST_CONTEXT)
257
+ return;
258
+ const logPath = join(codeStateHome(null), "logs", "guardian.log");
259
+ mkdirSync(dirname(logPath), { recursive: true });
260
+ appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...payload }) + "\n", "utf8");
261
+ }
262
+ catch { /* logging must never break the session */ }
263
+ };
264
+ // YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded once
265
+ // at startup (grants added by other concurrent sessions appear next launch).
266
+ const sessionGrants = evalMode ? [] : loadGrants();
267
+ const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
248
268
  registerPermissionGate(pi, {
249
269
  modeHolder,
250
270
  guardianState,
@@ -252,16 +272,63 @@ export async function registerYagni(pi, deps = {}) {
252
272
  guardianTier,
253
273
  guardianDisabled,
254
274
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
255
- onGuardianReview: (ev) => {
256
- try {
257
- if (process.env.NODE_TEST_CONTEXT)
258
- return;
259
- const logPath = join(codeStateHome(null), "logs", "guardian.log");
260
- mkdirSync(dirname(logPath), { recursive: true });
261
- appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...ev }) + "\n", "utf8");
262
- }
263
- catch { /* logging must never break the session */ }
275
+ onGuardianReview: (ev) => guardianLogSink(ev),
276
+ grants: sessionGrants,
277
+ resolveRepoKey,
278
+ persistGrant: (grant) => {
279
+ if (!evalMode)
280
+ appendGrant(grant);
264
281
  },
282
+ // Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
283
+ // "off" → nothing (not even sent); "hash" → sha256 + family prefix +
284
+ // metadata, no command content; "raw" → adds client-REDACTED command and
285
+ // rationale. Fire-and-forget: one attempt, short timeout, failures logged
286
+ // fail-soft to guardian.log — a storage outage never touches the session.
287
+ onGuardianEvent: guardianStorageTier === "off" || evalMode
288
+ ? undefined
289
+ : (ev) => {
290
+ void (async () => {
291
+ try {
292
+ const body = {
293
+ sessionId: env.YAGNI_SESSION_ID ?? null,
294
+ commandHash: createHash("sha256").update(ev.command).digest("hex"),
295
+ commandPrefix: storagePrefix(ev.command),
296
+ outcome: ev.outcome,
297
+ mode: ev.mode,
298
+ ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
299
+ ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
300
+ ...(ev.tier ? { tier: ev.tier } : {}),
301
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
302
+ };
303
+ if (guardianStorageTier === "raw") {
304
+ body.command = redactCommand(ev.command);
305
+ if (ev.rationale)
306
+ body.rationale = redactCommand(ev.rationale);
307
+ }
308
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
309
+ method: "POST",
310
+ headers: {
311
+ "content-type": "application/json",
312
+ authorization: `Bearer ${getTokenFn() ?? ""}`,
313
+ ...attributionHeaders(deps.env),
314
+ },
315
+ body: JSON.stringify(body),
316
+ }, {
317
+ fetchImpl: authedFetch,
318
+ policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
319
+ });
320
+ if (!res.ok) {
321
+ guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
322
+ }
323
+ }
324
+ catch (err) {
325
+ guardianLogSink({
326
+ event: "guardian_event_post_failed",
327
+ error: err instanceof Error ? err.message : "unknown",
328
+ });
329
+ }
330
+ })();
331
+ },
265
332
  ...(mcpMutatingTools.length > 0
266
333
  ? {
267
334
  policy: {
@@ -27,8 +27,10 @@
27
27
  * the context so the model doesn't keep believing it is restricted.
28
28
  */
29
29
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
30
+ import { type ApprovedPrefixGrant } from "./approvedPrefixes.js";
30
31
  import { type BlessStore } from "./bless.js";
31
32
  import { type ExecPolicy } from "./execPolicy.js";
33
+ import { type GuardianError, type GuardianRiskLevel } from "./guardian.js";
32
34
  export type PermissionMode = "auto" | "plan" | "review";
33
35
  /**
34
36
  * A shared, mutable holder for the current permission mode. Both
@@ -76,6 +78,12 @@ export interface GateDecision {
76
78
  * command string is available.
77
79
  */
78
80
  classify?: "allow" | "prompt" | "forbidden";
81
+ /**
82
+ * The exec policy's justification for a "prompt" classification — why the
83
+ * command was routed to the Guardian. Threaded into the consult prompt and
84
+ * the storage event (it is the layer-tuning signal, YAG-510).
85
+ */
86
+ classifyJustification?: string;
79
87
  }
80
88
  /**
81
89
  * Pure permission decision for one tool call under a mode + policy. Auto allows
@@ -83,6 +91,35 @@ export interface GateDecision {
83
91
  * unless a recorded decision blesses them.
84
92
  */
85
93
  export declare function decideGate(toolName: string, params: Record<string, unknown>, mode: PermissionMode, policy: PermissionPolicy): GateDecision;
94
+ /**
95
+ * Terminal outcome of one prompt-band decision (YAG-510). One storage event
96
+ * is emitted per terminal outcome; `consulted` says whether a Guardian LLM
97
+ * call actually happened (grants/cache hits skip it).
98
+ */
99
+ export type GuardianGateOutcome = "prefix_allow" | "cached_allow" | "allow" | "deny" | "ask_approved" | "ask_approved_remembered" | "ask_denied" | "ask_headless_blocked" | "breaker_ask_approved" | "breaker_blocked" | GuardianError;
100
+ /**
101
+ * Rich per-decision event for opt-in storage (YAG-510). Carries the RAW
102
+ * command — the wiring layer (index.ts) hashes/redacts per the workspace's
103
+ * storage tier before anything leaves the machine. Distinct from the
104
+ * sanitized GuardianDiagnosticEvent (local debug log), which never carries
105
+ * the command.
106
+ */
107
+ export interface GuardianGateEvent {
108
+ command: string;
109
+ outcome: GuardianGateOutcome;
110
+ /** Why the exec policy routed this command to the Guardian. */
111
+ execJustification?: string;
112
+ riskLevel?: GuardianRiskLevel;
113
+ rationale?: string;
114
+ mode: PermissionMode;
115
+ tier?: string;
116
+ durationMs?: number;
117
+ /** Whether a Guardian LLM consult actually ran for this decision. */
118
+ consulted: boolean;
119
+ /** Present when the terminal outcome followed a Guardian error (e.g. an
120
+ * error-fallback ask that the user then approved). */
121
+ guardianError?: GuardianError;
122
+ }
86
123
  /** What was blessed with "don't ask again", handed to the capture hook. */
87
124
  export interface BlessRememberInfo {
88
125
  tool: string;
@@ -123,6 +160,24 @@ export interface RegisterPermissionDeps {
123
160
  onGuardianReview?: (event: import("./guardian.js").GuardianDiagnosticEvent) => void;
124
161
  /** Disable the Guardian entirely (env var YAGNI_DISABLE_GUARDIAN). */
125
162
  guardianDisabled?: boolean;
163
+ /**
164
+ * Persisted "don't ask again" grants for this session's repo (YAG-510).
165
+ * Consulted ONLY after the exec policy classified a command as "prompt" —
166
+ * a grant can never override the forbidden band. Suppressed in review mode.
167
+ */
168
+ grants?: readonly ApprovedPrefixGrant[];
169
+ /** Resolve the grant scope key for a cwd (git remote origin URL fallback
170
+ * realpath). Lazily invoked on the first prompt-band bash call. */
171
+ resolveRepoKey?: (cwd: string) => string;
172
+ /** Persist a new grant (fire-and-forget; the in-memory list is updated
173
+ * either way). index.ts wires approvedPrefixes.appendGrant. */
174
+ persistGrant?: (grant: ApprovedPrefixGrant) => void;
175
+ /**
176
+ * Called (fire-and-forget) at every terminal prompt-band outcome with the
177
+ * rich storage event (raw command — the wiring layer redacts/hashes).
178
+ * Fail-soft; never blocks.
179
+ */
180
+ onGuardianEvent?: (event: GuardianGateEvent) => void;
126
181
  }
127
182
  /** The customType tag on injected mode-context messages (filterable later). */
128
183
  export declare const MODE_CONTEXT_TYPE = "yagni-mode-context";