@yagni-app/code-staging 1.2.3-staging.1686.1 → 1.2.3-staging.1701.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.
@@ -918,7 +918,7 @@ export async function registerYagni(pi, deps = {}) {
918
918
  });
919
919
  if (sandboxHandle)
920
920
  sandboxHandle.setRules(loadedRules.rules);
921
- registerPermissionGate(pi, {
921
+ const permissionGate = registerPermissionGate(pi, {
922
922
  ...(sandboxHandle
923
923
  ? {
924
924
  sandboxAutoAllow: (toolName, params) => sandboxAutoAllowDecision(toolName, params, sandboxHandle.manager, sandboxHandle.settings()),
@@ -1955,6 +1955,13 @@ export async function registerYagni(pi, deps = {}) {
1955
1955
  if (GIT_MUTATING_PATTERN.test(command)) {
1956
1956
  footerInvalidateHandle.invalidateGit();
1957
1957
  }
1958
+ // The gate's repeat-failure streaks: every bash outcome, sandboxed or
1959
+ // not (an allowed command failing the same way ten times is denied).
1960
+ if (command) {
1961
+ permissionGate.recordBashOutcome(command, event.isError === true, (event.content ?? [])
1962
+ .map((c) => (c && typeof c === "object" && "text" in c ? String(c.text) : ""))
1963
+ .join("\n"));
1964
+ }
1958
1965
  // The auto-sandbox rung's prior-failure evidence: a SANDBOXED bash
1959
1966
  // result carrying a sandbox-denial signature marks this command — its
1960
1967
  // later escape attempts re-arm the consent dialog instead of
@@ -428,6 +428,22 @@ export declare const filterStalePlanContext: typeof filterStaleModeContext;
428
428
  * pre-widening read-only shape without a release.
429
429
  */
430
430
  export declare function escapeClassifyGateKnob(env?: NodeJS.ProcessEnv): boolean;
431
+ /**
432
+ * The repeat-failure denial (spec 2026-10-08 build environments, §6): an
433
+ * allowed bash command that has failed identically this many times in a row
434
+ * is denied on the next attempt. In a build sandbox a missing tool otherwise
435
+ * turns into dozens of identical retries until the Guardian's cap stops the
436
+ * run with nothing to show for it.
437
+ */
438
+ export declare const REPEAT_FAILURE_LIMIT = 10;
439
+ export declare const REPEAT_FAILURE_REASON = "This command has failed the same way ten times; the sandbox cannot do what it asks.";
440
+ interface RepeatFailureRing {
441
+ /** Feed one bash outcome (the tool_result's error flag and text). */
442
+ record(command: string, isError: boolean, text: string): void;
443
+ /** Has this command failed identically REPEAT_FAILURE_LIMIT times in a row? */
444
+ exhausted(command: string): boolean;
445
+ }
446
+ export declare function createRepeatFailureRing(): RepeatFailureRing;
431
447
  /** The decision source of ONE tool_call (telemetry), carried per invocation
432
448
  * because tool calls run concurrently: refined by the user dialog, hooks,
433
449
  * and Guardian outcomes as the call moves through the gate. */
@@ -445,6 +461,15 @@ export declare function wireToolCallGate(pi: Pick<ExtensionAPI, "on">, run: (eve
445
461
  * Wire the tool_call gate + the /mode command onto a shared mode holder. Default
446
462
  * auto, so absent any /mode this is a no-op over today's behavior.
447
463
  */
448
- export declare function registerPermissionGate(pi: ExtensionAPI, deps?: RegisterPermissionDeps): void;
464
+ /** What the wiring layer feeds back into the gate from its tool_result listener. */
465
+ export interface PermissionGateHandle {
466
+ /**
467
+ * One bash outcome, from index.ts's existing tool_result listener (the
468
+ * gate keeps no observer of its own): the command, the result's error flag
469
+ * and its text. Feeds the repeat-failure streaks. Fail-soft; never throws.
470
+ */
471
+ recordBashOutcome(command: string, isError: boolean, text: string): void;
472
+ }
473
+ export declare function registerPermissionGate(pi: ExtensionAPI, deps?: RegisterPermissionDeps): PermissionGateHandle;
449
474
  export {};
450
475
  //# sourceMappingURL=gate.d.ts.map
@@ -29,6 +29,7 @@
29
29
  import { derivePrefix, describePrefix, describeSeedPrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, sandboxFailureKey, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
30
30
  import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, segmentCannotYieldCandidate, } from "./prefixExtract.js";
31
31
  import { logEvent } from "../errorSink.js";
32
+ import { splitExitStatusError } from "../bashSemantics.js";
32
33
  import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
33
34
  import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
34
35
  import { evaluateRules } from "../permissionRules/engine.js";
@@ -374,6 +375,64 @@ function guardianErrorMessage(error) {
374
375
  default: return "unknown error";
375
376
  }
376
377
  }
378
+ /**
379
+ * The repeat-failure denial (spec 2026-10-08 build environments, §6): an
380
+ * allowed bash command that has failed identically this many times in a row
381
+ * is denied on the next attempt. In a build sandbox a missing tool otherwise
382
+ * turns into dozens of identical retries until the Guardian's cap stops the
383
+ * run with nothing to show for it.
384
+ */
385
+ export const REPEAT_FAILURE_LIMIT = 10;
386
+ export const REPEAT_FAILURE_REASON = "This command has failed the same way ten times; the sandbox cannot do what it asks.";
387
+ /** Commands tracked per session; the least recently failed drops out first. */
388
+ const REPEAT_FAILURE_RING_MAX = 50;
389
+ /** Whitespace-insensitive, on top of the grant canonicalization. */
390
+ function repeatFailureKey(command) {
391
+ return sandboxFailureKey(command).replace(/\s+/g, " ");
392
+ }
393
+ /**
394
+ * A failure's identity: exit code + the first non-empty line of its output.
395
+ * pi's bash tool merges stderr into the output and throws non-zero exits as
396
+ * `<output>\n\nCommand exited with code N`, so the first output line is the
397
+ * error's first line. Anything else (abort, timeout, a gate block) has no
398
+ * exit code and returns null: it neither extends nor breaks a streak.
399
+ */
400
+ function failureIdentity(text) {
401
+ const parsed = splitExitStatusError(text);
402
+ if (!parsed)
403
+ return null;
404
+ const firstLine = parsed.output.split("\n").map((l) => l.trim()).find((l) => l !== "") ?? "";
405
+ return `${parsed.exitCode}\u0000${firstLine}`;
406
+ }
407
+ export function createRepeatFailureRing() {
408
+ const streaks = new Map();
409
+ return {
410
+ record(command, isError, text) {
411
+ const key = repeatFailureKey(command);
412
+ if (!key)
413
+ return;
414
+ if (!isError) {
415
+ streaks.delete(key);
416
+ return;
417
+ }
418
+ const identity = failureIdentity(text);
419
+ if (identity === null)
420
+ return;
421
+ const prior = streaks.get(key);
422
+ const count = prior && prior.identity === identity ? prior.count + 1 : 1;
423
+ streaks.delete(key);
424
+ streaks.set(key, { identity, count });
425
+ if (streaks.size > REPEAT_FAILURE_RING_MAX) {
426
+ const oldest = streaks.keys().next().value;
427
+ if (oldest !== undefined)
428
+ streaks.delete(oldest);
429
+ }
430
+ },
431
+ exhausted(command) {
432
+ return (streaks.get(repeatFailureKey(command))?.count ?? 0) >= REPEAT_FAILURE_LIMIT;
433
+ },
434
+ };
435
+ }
377
436
  /**
378
437
  * The pi tool_call handler wiring (crash-honest attribution). Extracted so
379
438
  * the telemetry contract is testable against the production path: a thrown
@@ -424,10 +483,6 @@ export function wireToolCallGate(pi, run, onToolDecision) {
424
483
  }
425
484
  });
426
485
  }
427
- /**
428
- * Wire the tool_call gate + the /mode command onto a shared mode holder. Default
429
- * auto, so absent any /mode this is a no-op over today's behavior.
430
- */
431
486
  export function registerPermissionGate(pi, deps = {}) {
432
487
  const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
433
488
  let mode = deps.mode ?? "auto";
@@ -589,6 +644,10 @@ export function registerPermissionGate(pi, deps = {}) {
589
644
  deps.modeHolder?.onSet(() => {
590
645
  sandboxedFailures.clear();
591
646
  });
647
+ // Repeat-failure streaks (spec 2026-10-08 §6): per normalised command, the
648
+ // identity of its last failure and how many times in a row it failed that
649
+ // way. Session state, bounded LRU; a success of the command clears it.
650
+ const repeatFailures = createRepeatFailureRing();
592
651
  /** Shared consult-call builder: routes through the injectable seam with
593
652
  * the sink line + the onPrefixConsult hook — ONE place so the escape and
594
653
  * Guardian-ask fan-outs cannot drift. */
@@ -2196,7 +2255,28 @@ export function registerPermissionGate(pi, deps = {}) {
2196
2255
  reason: "The permission dialog was dismissed; the command was not run. Ask the user how to proceed.",
2197
2256
  };
2198
2257
  };
2199
- wireToolCallGate(pi, gateToolCall, deps.onToolDecision);
2258
+ // The repeat-failure denial rides the result side of an ALLOWED call: the
2259
+ // gate (Guardian, rules, grants, hooks) decides first exactly as before,
2260
+ // and only a call it would let through is checked against the streak. One
2261
+ // missing tool must not turn into eighty identical pulls.
2262
+ const gateWithRepeatFailure = async (event, ctx, slot) => {
2263
+ const result = await gateToolCall(event, ctx, slot);
2264
+ if (result?.block || event.toolName !== "bash")
2265
+ return result;
2266
+ const command = event.input?.command;
2267
+ if (typeof command !== "string" || !repeatFailures.exhausted(command))
2268
+ return result;
2269
+ slot.source = "config";
2270
+ logEvent({
2271
+ source: "permission-rules",
2272
+ level: "info",
2273
+ event: "repeat_failure_denied",
2274
+ // No command content on the always-on tier; the limit is the evidence.
2275
+ fields: { limit: REPEAT_FAILURE_LIMIT },
2276
+ });
2277
+ return { block: true, reason: REPEAT_FAILURE_REASON };
2278
+ };
2279
+ wireToolCallGate(pi, gateWithRepeatFailure, deps.onToolDecision);
2200
2280
  // Model awareness: every agent turn carries a hidden mode-context message so
2201
2281
  // the model knows what it can do (auto: verify proactively, review: wait for
2202
2282
  // approval, plan: hold writes). The context hook strips stale mode context
@@ -2254,5 +2334,23 @@ export function registerPermissionGate(pi, deps = {}) {
2254
2334
  }
2255
2335
  },
2256
2336
  });
2337
+ return {
2338
+ recordBashOutcome: (command, isError, text) => {
2339
+ try {
2340
+ repeatFailures.record(command, isError, text);
2341
+ }
2342
+ catch (err) {
2343
+ try {
2344
+ logEvent({
2345
+ source: "permission-rules",
2346
+ level: "warn",
2347
+ event: "repeat_failure_record_failed",
2348
+ fields: { error: err instanceof Error ? err.constructor.name : typeof err },
2349
+ });
2350
+ }
2351
+ catch { /* telemetry must never affect the gate */ }
2352
+ }
2353
+ },
2354
+ };
2257
2355
  }
2258
2356
  //# sourceMappingURL=gate.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.3-staging.1686.1",
3
+ "version": "1.2.3-staging.1701.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "44055f7635d957d2f2f19a547cb945a6e942de62"
61
+ "yagniSourceSha": "17afa06cf2895ba0df94906ec0300b989ecc4d20"
62
62
  }