@yagni-app/code-staging 1.2.3-staging.1661.1 → 1.2.3-staging.1700.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.
- package/dist/extension/index.js +8 -1
- package/dist/extension/permission/gate.d.ts +26 -1
- package/dist/extension/permission/gate.js +103 -5
- package/dist/extension/pipeline/budget.d.ts +8 -3
- package/dist/extension/pipeline/budget.js +9 -4
- package/dist/extension/pipeline/types.d.ts +4 -3
- package/dist/extension/pipeline/types.js +5 -4
- package/dist/extension/pipeline/verify.d.ts +6 -2
- package/dist/extension/pipeline/verify.js +8 -4
- package/package.json +2 -2
package/dist/extension/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -94,12 +94,17 @@ export declare function stageWontFit(budget: RunBudget, kind: TimedStageKind, ex
|
|
|
94
94
|
* than the time actually left, since the host stops the run at the deadline.
|
|
95
95
|
*/
|
|
96
96
|
export declare const STAGE_WALL_FLOOR_MS: number;
|
|
97
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Time the verify gate is allowed after the last write, on top of the stage
|
|
99
|
+
* estimates: the verify run's own ceiling (`VERIFY_TIMEOUT_MS`, twenty
|
|
100
|
+
* minutes). Five until 2026-10-09, when verify could run ten, so a long stage
|
|
101
|
+
* could leave the gate less time than it may take.
|
|
102
|
+
*/
|
|
98
103
|
export declare const VERIFY_ALLOWANCE_MS: number;
|
|
99
104
|
/**
|
|
100
105
|
* What must still run after a stage stops: a review round, one fix, and the
|
|
101
|
-
* verify gate. Held back from the stage's wall clock
|
|
102
|
-
* leaves the run time to review what it wrote.
|
|
106
|
+
* verify gate (4 + 6 + 20 = 30 minutes). Held back from the stage's wall clock
|
|
107
|
+
* so a long stage still leaves the run time to review and verify what it wrote.
|
|
103
108
|
*/
|
|
104
109
|
export declare const STAGE_WALL_RESERVE_MS: number;
|
|
105
110
|
/**
|
|
@@ -126,12 +126,17 @@ export function stageWontFit(budget, kind, expectedMs, nowMs = Date.now()) {
|
|
|
126
126
|
* than the time actually left, since the host stops the run at the deadline.
|
|
127
127
|
*/
|
|
128
128
|
export const STAGE_WALL_FLOOR_MS = 20 * 60_000;
|
|
129
|
-
/**
|
|
130
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Time the verify gate is allowed after the last write, on top of the stage
|
|
131
|
+
* estimates: the verify run's own ceiling (`VERIFY_TIMEOUT_MS`, twenty
|
|
132
|
+
* minutes). Five until 2026-10-09, when verify could run ten, so a long stage
|
|
133
|
+
* could leave the gate less time than it may take.
|
|
134
|
+
*/
|
|
135
|
+
export const VERIFY_ALLOWANCE_MS = 20 * 60_000;
|
|
131
136
|
/**
|
|
132
137
|
* What must still run after a stage stops: a review round, one fix, and the
|
|
133
|
-
* verify gate. Held back from the stage's wall clock
|
|
134
|
-
* leaves the run time to review what it wrote.
|
|
138
|
+
* verify gate (4 + 6 + 20 = 30 minutes). Held back from the stage's wall clock
|
|
139
|
+
* so a long stage still leaves the run time to review and verify what it wrote.
|
|
135
140
|
*/
|
|
136
141
|
export const STAGE_WALL_RESERVE_MS = DEFAULT_STAGE_ESTIMATE_MS.review + DEFAULT_STAGE_ESTIMATE_MS.fix + VERIFY_ALLOWANCE_MS;
|
|
137
142
|
/**
|
|
@@ -544,9 +544,10 @@ export interface ResiliencePolicy {
|
|
|
544
544
|
}
|
|
545
545
|
/**
|
|
546
546
|
* Default resilience policy. Generous timeouts so a legitimately long but live
|
|
547
|
-
* stage is never killed: the idle window
|
|
548
|
-
*
|
|
549
|
-
*
|
|
547
|
+
* stage is never killed: the idle window is the check for a stuck model (four
|
|
548
|
+
* minutes until 2026-10-09; fifteen now, since a long test run or a slow
|
|
549
|
+
* model turn emits nothing for minutes and is not stuck), and the wall window
|
|
550
|
+
* only bounds a runaway that keeps emitting events. The wall is 60 minutes for an interactive /go, where a
|
|
550
551
|
* person can abort; a run with a deadline replaces it per stage with the time
|
|
551
552
|
* left (budget.ts#stageWall). Three attempts with short, jittered
|
|
552
553
|
* backoff so a single transient blip across the up-to-8 child spawns no longer
|
|
@@ -21,16 +21,17 @@ export const MAX_REVIEW_ROUNDS = 2;
|
|
|
21
21
|
export const MAX_FIX_TURNS = 3;
|
|
22
22
|
/**
|
|
23
23
|
* Default resilience policy. Generous timeouts so a legitimately long but live
|
|
24
|
-
* stage is never killed: the idle window
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* stage is never killed: the idle window is the check for a stuck model (four
|
|
25
|
+
* minutes until 2026-10-09; fifteen now, since a long test run or a slow
|
|
26
|
+
* model turn emits nothing for minutes and is not stuck), and the wall window
|
|
27
|
+
* only bounds a runaway that keeps emitting events. The wall is 60 minutes for an interactive /go, where a
|
|
27
28
|
* person can abort; a run with a deadline replaces it per stage with the time
|
|
28
29
|
* left (budget.ts#stageWall). Three attempts with short, jittered
|
|
29
30
|
* backoff so a single transient blip across the up-to-8 child spawns no longer
|
|
30
31
|
* terminates the whole run.
|
|
31
32
|
*/
|
|
32
33
|
export const DEFAULT_RESILIENCE_POLICY = {
|
|
33
|
-
idleTimeoutMs:
|
|
34
|
+
idleTimeoutMs: 15 * 60_000,
|
|
34
35
|
wallTimeoutMs: 60 * 60_000,
|
|
35
36
|
maxAttempts: 3,
|
|
36
37
|
backoffBaseMs: 1_000,
|
|
@@ -47,9 +47,13 @@
|
|
|
47
47
|
* findings, handoffs, or run records (§0.9).
|
|
48
48
|
*/
|
|
49
49
|
import type { Finding } from "./types.js";
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Total wall-clock budget for a single verify run before it is abandoned
|
|
52
|
+
* (fail-open). Ten minutes until 2026-10-09; twenty now, matching the run
|
|
53
|
+
* budget's `VERIFY_ALLOWANCE_MS`.
|
|
54
|
+
*/
|
|
51
55
|
export declare const VERIFY_TIMEOUT_MS: number;
|
|
52
|
-
/** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. */
|
|
56
|
+
/** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. Ten minutes until 2026-10-09. */
|
|
53
57
|
export declare const VERIFY_TEST_TIMEOUT_MS: number;
|
|
54
58
|
/** A resolved verify command plus how it was found. */
|
|
55
59
|
export interface VerifyCommand {
|
|
@@ -59,10 +59,14 @@ import { claimCovers } from "./fanout.js";
|
|
|
59
59
|
import { composeAbortSignal } from "./resilience.js";
|
|
60
60
|
import { scrubSecrets } from "./scrubSecrets.js";
|
|
61
61
|
import { snapshotWorkspace } from "./workspace.js";
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Total wall-clock budget for a single verify run before it is abandoned
|
|
64
|
+
* (fail-open). Ten minutes until 2026-10-09; twenty now, matching the run
|
|
65
|
+
* budget's `VERIFY_ALLOWANCE_MS`.
|
|
66
|
+
*/
|
|
67
|
+
export const VERIFY_TIMEOUT_MS = 20 * 60_000;
|
|
68
|
+
/** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. Ten minutes until 2026-10-09. */
|
|
69
|
+
export const VERIFY_TEST_TIMEOUT_MS = 20 * 60_000;
|
|
66
70
|
/** Cap on located findings parsed from one failing run, and on the opaque-tail message. */
|
|
67
71
|
const MAX_LOCATED_FINDINGS = 25;
|
|
68
72
|
const MAX_TAIL_CHARS = 1000;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.2.3-staging.
|
|
3
|
+
"version": "1.2.3-staging.1700.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": "
|
|
61
|
+
"yagniSourceSha": "ca2b75c6072f31b7a6c2ee58fa166ce63b0cefca"
|
|
62
62
|
}
|