@yagni-app/code-staging 0.3.0-staging.1088.1 → 0.3.0-staging.1090.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/approvedPrefixes.d.ts +11 -0
- package/dist/extension/approvedPrefixes.js +30 -0
- package/dist/extension/guardian.d.ts +14 -4
- package/dist/extension/guardian.js +34 -10
- package/dist/extension/index.js +3 -1
- package/dist/extension/permission.js +10 -4
- package/dist/extension/pipeline/goCommand.js +6 -4
- package/dist/extension/pipeline/runRegistry.d.ts +9 -1
- package/dist/extension/pipeline/runRegistry.js +22 -1
- package/package.json +2 -2
|
@@ -73,6 +73,17 @@ export declare function matchesGrant(command: string, grants: readonly ApprovedP
|
|
|
73
73
|
export declare function validateGrant(command: string, policy: ExecPolicy, repoKey: string): ApprovedPrefixGrant | null;
|
|
74
74
|
/** Human label for the remember option: "git push …". */
|
|
75
75
|
export declare function describePrefix(pattern: string[]): string;
|
|
76
|
+
/**
|
|
77
|
+
* Could {@link derivePrefix} ever have produced this pattern? The persisted
|
|
78
|
+
* file is plain JSON on disk, so a row that derivation could not have written
|
|
79
|
+
* (a banned interpreter/destruction/egress prefix, a bare multi-subcommand
|
|
80
|
+
* tool, a path-prefixed word, or an over-long pattern) is treated as
|
|
81
|
+
* tampered/corrupt and dropped at load time rather than honored (PR #1698
|
|
82
|
+
* review). This is defense in depth, not the trust boundary itself — the
|
|
83
|
+
* boundary is that grants only enter the live gate at startup or through the
|
|
84
|
+
* gate's own ask flow.
|
|
85
|
+
*/
|
|
86
|
+
export declare function isDerivablePattern(pattern: readonly string[]): boolean;
|
|
76
87
|
export declare function rulesFilePath(homeOverride?: string | null): string;
|
|
77
88
|
/**
|
|
78
89
|
* Resolve the grant scope key for a session cwd: the git remote origin URL,
|
|
@@ -179,6 +179,35 @@ export function validateGrant(command, policy, repoKey) {
|
|
|
179
179
|
export function describePrefix(pattern) {
|
|
180
180
|
return `${pattern.join(" ")} …`;
|
|
181
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Could {@link derivePrefix} ever have produced this pattern? The persisted
|
|
184
|
+
* file is plain JSON on disk, so a row that derivation could not have written
|
|
185
|
+
* (a banned interpreter/destruction/egress prefix, a bare multi-subcommand
|
|
186
|
+
* tool, a path-prefixed word, or an over-long pattern) is treated as
|
|
187
|
+
* tampered/corrupt and dropped at load time rather than honored (PR #1698
|
|
188
|
+
* review). This is defense in depth, not the trust boundary itself — the
|
|
189
|
+
* boundary is that grants only enter the live gate at startup or through the
|
|
190
|
+
* gate's own ask flow.
|
|
191
|
+
*/
|
|
192
|
+
export function isDerivablePattern(pattern) {
|
|
193
|
+
if (pattern.length < 1 || pattern.length > 2)
|
|
194
|
+
return false;
|
|
195
|
+
const first = pattern[0];
|
|
196
|
+
if (first.includes("/") || first.startsWith("\\"))
|
|
197
|
+
return false;
|
|
198
|
+
if (BANNED_PREFIXES.has(first))
|
|
199
|
+
return false;
|
|
200
|
+
if (pattern.length === 2) {
|
|
201
|
+
const second = pattern[1];
|
|
202
|
+
if (!MULTI_SUBCOMMAND_TOOLS.has(first))
|
|
203
|
+
return false;
|
|
204
|
+
if (second.startsWith("-") || !SAFE_SUBCOMMAND_RE.test(second))
|
|
205
|
+
return false;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
// Derivation never emits a bare multi-subcommand tool ("git" alone).
|
|
209
|
+
return !MULTI_SUBCOMMAND_TOOLS.has(first);
|
|
210
|
+
}
|
|
182
211
|
// --- I/O half ---
|
|
183
212
|
export function rulesFilePath(homeOverride = null) {
|
|
184
213
|
return join(codeStateHome(homeOverride), "rules.json");
|
|
@@ -220,6 +249,7 @@ export function loadGrants(homeOverride = null) {
|
|
|
220
249
|
return parsed.grants.filter((g) => Array.isArray(g?.pattern) &&
|
|
221
250
|
g.pattern.length > 0 &&
|
|
222
251
|
g.pattern.every((t) => typeof t === "string") &&
|
|
252
|
+
isDerivablePattern(g.pattern) &&
|
|
223
253
|
typeof g.repoKey === "string" &&
|
|
224
254
|
typeof g.addedAt === "string" &&
|
|
225
255
|
typeof g.cwd === "string");
|
|
@@ -37,7 +37,7 @@ export interface GuardianVerdict {
|
|
|
37
37
|
rationale: string;
|
|
38
38
|
}
|
|
39
39
|
export interface GuardianLimits {
|
|
40
|
-
/**
|
|
40
|
+
/** Cap on Guardian reviews within the sliding window ({@link GUARDIAN_REVIEW_WINDOW_MS}). */
|
|
41
41
|
maxReviews: number;
|
|
42
42
|
/** Consecutive denials per turn before the circuit breaker trips. */
|
|
43
43
|
maxConsecutiveDenials: number;
|
|
@@ -47,15 +47,25 @@ export interface GuardianLimits {
|
|
|
47
47
|
export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
|
|
48
48
|
/**
|
|
49
49
|
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
50
|
-
* overrides the
|
|
51
|
-
* the default (a bad value must never zero out the cap and lock the
|
|
50
|
+
* overrides the sliding-window review cap; anything non-numeric or < 1 falls
|
|
51
|
+
* back to the default (a bad value must never zero out the cap and lock the
|
|
52
|
+
* session).
|
|
52
53
|
*/
|
|
53
54
|
export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
|
|
54
55
|
/** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
|
|
55
56
|
export declare const GUARDIAN_MODEL_TIER = "efficient";
|
|
56
57
|
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
57
58
|
export declare const GUARDIAN_TOOLS: string[];
|
|
59
|
+
/**
|
|
60
|
+
* The review-cap window. `reviews` counts consults inside a SLIDING window
|
|
61
|
+
* rather than for the session's lifetime: a 24/7 session (a fleet operator's
|
|
62
|
+
* always-on terminal) must regain review capacity as old consults age out,
|
|
63
|
+
* not hard-block forever after the first N. The cap is a cost/runaway bound,
|
|
64
|
+
* not a safety bound — safety is the verdicts themselves.
|
|
65
|
+
*/
|
|
66
|
+
export declare const GUARDIAN_REVIEW_WINDOW_MS: number;
|
|
58
67
|
export interface GuardianState {
|
|
68
|
+
/** Guardian consults within the last {@link GUARDIAN_REVIEW_WINDOW_MS}. */
|
|
59
69
|
reviews: number;
|
|
60
70
|
consecutiveDenials: number;
|
|
61
71
|
}
|
|
@@ -64,7 +74,7 @@ export interface GuardianStateHandle {
|
|
|
64
74
|
recordReview(outcome: GuardianOutcome): GuardianState;
|
|
65
75
|
resetTurn(): void;
|
|
66
76
|
}
|
|
67
|
-
export declare function makeGuardianState(): GuardianStateHandle;
|
|
77
|
+
export declare function makeGuardianState(now?: () => number): GuardianStateHandle;
|
|
68
78
|
export interface CircuitBreakerResult {
|
|
69
79
|
tripped: boolean;
|
|
70
80
|
reason?: string;
|
|
@@ -35,8 +35,9 @@ export const DEFAULT_GUARDIAN_LIMITS = {
|
|
|
35
35
|
};
|
|
36
36
|
/**
|
|
37
37
|
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
38
|
-
* overrides the
|
|
39
|
-
* the default (a bad value must never zero out the cap and lock the
|
|
38
|
+
* overrides the sliding-window review cap; anything non-numeric or < 1 falls
|
|
39
|
+
* back to the default (a bad value must never zero out the cap and lock the
|
|
40
|
+
* session).
|
|
40
41
|
*/
|
|
41
42
|
export function resolveGuardianLimits(env = process.env) {
|
|
42
43
|
const raw = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
|
|
@@ -48,25 +49,48 @@ export function resolveGuardianLimits(env = process.env) {
|
|
|
48
49
|
export const GUARDIAN_MODEL_TIER = "efficient";
|
|
49
50
|
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
50
51
|
export const GUARDIAN_TOOLS = ["read"];
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
// --- State ---
|
|
53
|
+
/**
|
|
54
|
+
* The review-cap window. `reviews` counts consults inside a SLIDING window
|
|
55
|
+
* rather than for the session's lifetime: a 24/7 session (a fleet operator's
|
|
56
|
+
* always-on terminal) must regain review capacity as old consults age out,
|
|
57
|
+
* not hard-block forever after the first N. The cap is a cost/runaway bound,
|
|
58
|
+
* not a safety bound — safety is the verdicts themselves.
|
|
59
|
+
*/
|
|
60
|
+
export const GUARDIAN_REVIEW_WINDOW_MS = 60 * 60_000;
|
|
61
|
+
export function makeGuardianState(now = Date.now) {
|
|
62
|
+
const reviewTimes = [];
|
|
63
|
+
let consecutiveDenials = 0;
|
|
64
|
+
const prune = () => {
|
|
65
|
+
const cutoff = now() - GUARDIAN_REVIEW_WINDOW_MS;
|
|
66
|
+
while (reviewTimes.length > 0 && reviewTimes[0] <= cutoff)
|
|
67
|
+
reviewTimes.shift();
|
|
68
|
+
};
|
|
69
|
+
const snapshot = () => ({
|
|
70
|
+
reviews: reviewTimes.length,
|
|
71
|
+
consecutiveDenials,
|
|
72
|
+
});
|
|
53
73
|
return {
|
|
54
|
-
read: () =>
|
|
74
|
+
read: () => {
|
|
75
|
+
prune();
|
|
76
|
+
return snapshot();
|
|
77
|
+
},
|
|
55
78
|
recordReview(outcome) {
|
|
56
|
-
|
|
79
|
+
prune();
|
|
80
|
+
reviewTimes.push(now());
|
|
57
81
|
if (outcome === "deny") {
|
|
58
|
-
|
|
82
|
+
consecutiveDenials += 1;
|
|
59
83
|
}
|
|
60
84
|
else if (outcome === "allow") {
|
|
61
|
-
|
|
85
|
+
consecutiveDenials = 0;
|
|
62
86
|
}
|
|
63
87
|
// "ask" leaves the denial streak UNCHANGED: it is neither a denial nor
|
|
64
88
|
// an exoneration. If it reset the streak, deny/ask/deny/ask would never
|
|
65
89
|
// trip the breaker (round-2 review blocker).
|
|
66
|
-
return
|
|
90
|
+
return snapshot();
|
|
67
91
|
},
|
|
68
92
|
resetTurn() {
|
|
69
|
-
|
|
93
|
+
consecutiveDenials = 0;
|
|
70
94
|
},
|
|
71
95
|
};
|
|
72
96
|
}
|
package/dist/extension/index.js
CHANGED
|
@@ -268,7 +268,9 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
268
268
|
catch { /* logging must never break the session */ }
|
|
269
269
|
};
|
|
270
270
|
// YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded once
|
|
271
|
-
// at startup (grants added by other concurrent sessions appear next launch
|
|
271
|
+
// at startup (grants added by other concurrent sessions appear next launch —
|
|
272
|
+
// the startup load is the trust boundary; live reload was reviewed and
|
|
273
|
+
// rejected as a same-session self-authorization path, PR #1698).
|
|
272
274
|
const sessionGrants = evalMode ? [] : loadGrants();
|
|
273
275
|
const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
|
|
274
276
|
registerPermissionGate(pi, {
|
|
@@ -279,6 +279,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
279
279
|
const guardianTier = deps.guardianTier;
|
|
280
280
|
// --- YAG-510 gate state ---
|
|
281
281
|
// Grants: in-memory list seeded from deps, appended on "don't ask again".
|
|
282
|
+
// Deliberately NOT live-reloaded from disk: auto mode can write files, so a
|
|
283
|
+
// mid-session re-read of rules.json would let the agent (or a prompt
|
|
284
|
+
// injection) author its own grants and self-authorize within the same
|
|
285
|
+
// session. New grants from concurrent sessions apply at next launch — the
|
|
286
|
+
// startup load is the trust boundary (PR #1698 review).
|
|
282
287
|
const grants = [...(deps.grants ?? [])];
|
|
283
288
|
// Keyed by cwd: a session can change working directory (cd, /go worktrees),
|
|
284
289
|
// and a repoKey memoized from the first cwd would let repo-A grants match
|
|
@@ -410,12 +415,13 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
410
415
|
const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
|
|
411
416
|
const limits = guardianLimits ?? DEFAULT_GUARDIAN_LIMITS;
|
|
412
417
|
if (guardianAvailable && guardianState.read().reviews >= limits.maxReviews) {
|
|
413
|
-
//
|
|
414
|
-
//
|
|
418
|
+
// Sliding-window consult cap (capacity recovers as old reviews age
|
|
419
|
+
// out — a long-lived session is never bricked). Review mode falls
|
|
420
|
+
// through to its ordinary confirm (no LLM cost); auto blocks.
|
|
415
421
|
if (modeAtEntry === "auto") {
|
|
416
422
|
if (ctx?.hasUI)
|
|
417
|
-
ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews}
|
|
418
|
-
return { block: true, reason: `Guardian review cap reached (${limits.maxReviews}
|
|
423
|
+
ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} in the last hour).`, "warning");
|
|
424
|
+
return { block: true, reason: `Guardian review cap reached (${limits.maxReviews} in the last hour). Capacity recovers as older reviews age out; switch to /mode review to approve manually, or retry this step later.` };
|
|
419
425
|
}
|
|
420
426
|
// fall through to decision.confirm below
|
|
421
427
|
}
|
|
@@ -77,7 +77,7 @@ import { registerGoStatusCommands } from "./goStatusCommands.js";
|
|
|
77
77
|
import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
|
|
78
78
|
import { composeAbortSignal } from "./resilience.js";
|
|
79
79
|
import { planResume } from "./resume.js";
|
|
80
|
-
import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows,
|
|
80
|
+
import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, resolveMaxConcurrentRuns, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
|
|
81
81
|
import { makeRunSession as defaultMakeRunSession } from "./runSession.js";
|
|
82
82
|
import { recordSessionRun } from "../sessionRuns.js";
|
|
83
83
|
import { resolveTicketBrief as defaultResolveTicketBrief } from "./ticketResolution.js";
|
|
@@ -460,13 +460,15 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
460
460
|
return;
|
|
461
461
|
}
|
|
462
462
|
// In-flight guards: the same ticket never runs twice at once in this
|
|
463
|
-
// process, and at most
|
|
463
|
+
// process, and at most resolveMaxConcurrentRuns() runs are in flight
|
|
464
|
+
// (default 3; fleet operators raise it via YAGNI_MAX_CONCURRENT_RUNS).
|
|
464
465
|
if (findActiveRunByTicket(ticket)) {
|
|
465
466
|
notify(`/go ${ticket} is already running - see /go-status.`, "warning");
|
|
466
467
|
return;
|
|
467
468
|
}
|
|
468
|
-
|
|
469
|
-
|
|
469
|
+
const maxConcurrentRuns = resolveMaxConcurrentRuns();
|
|
470
|
+
if (activeRunCount() >= maxConcurrentRuns) {
|
|
471
|
+
notify(`${maxConcurrentRuns} /go runs are already in flight; wait for one to finish (see /go-status) or raise YAGNI_MAX_CONCURRENT_RUNS.`, "warning");
|
|
470
472
|
return;
|
|
471
473
|
}
|
|
472
474
|
// --- Run tree resolution: worktree by default; --here = legacy in-place.
|
|
@@ -22,8 +22,16 @@
|
|
|
22
22
|
* candidate).
|
|
23
23
|
*/
|
|
24
24
|
import type { CheckpointRecord, StopReason } from "./types.js";
|
|
25
|
-
/**
|
|
25
|
+
/** Default bound on simultaneously in-flight /go runs in one process (spec §3b). */
|
|
26
26
|
export declare const MAX_CONCURRENT_RUNS = 3;
|
|
27
|
+
/** Hard ceiling for the env override — a typo must not launch hundreds of runs. */
|
|
28
|
+
export declare const MAX_CONCURRENT_RUNS_CEILING = 32;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the in-flight /go cap from the environment. `YAGNI_MAX_CONCURRENT_RUNS`
|
|
31
|
+
* raises (or lowers) the default for fleet-scale operators; non-numeric or < 1
|
|
32
|
+
* falls back to the default, and anything above the ceiling clamps to it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveMaxConcurrentRuns(env?: Record<string, string | undefined>): number;
|
|
27
35
|
/**
|
|
28
36
|
* A non-terminal row whose journal has been quiet this long is treated as
|
|
29
37
|
* INTERRUPTED (its process died) rather than still running elsewhere. Sits
|
|
@@ -24,8 +24,22 @@
|
|
|
24
24
|
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { codeStateHome } from "../stateHome.js";
|
|
27
|
-
/**
|
|
27
|
+
/** Default bound on simultaneously in-flight /go runs in one process (spec §3b). */
|
|
28
28
|
export const MAX_CONCURRENT_RUNS = 3;
|
|
29
|
+
/** Hard ceiling for the env override — a typo must not launch hundreds of runs. */
|
|
30
|
+
export const MAX_CONCURRENT_RUNS_CEILING = 32;
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the in-flight /go cap from the environment. `YAGNI_MAX_CONCURRENT_RUNS`
|
|
33
|
+
* raises (or lowers) the default for fleet-scale operators; non-numeric or < 1
|
|
34
|
+
* falls back to the default, and anything above the ceiling clamps to it.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveMaxConcurrentRuns(env = process.env) {
|
|
37
|
+
const raw = env.YAGNI_MAX_CONCURRENT_RUNS?.trim();
|
|
38
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
39
|
+
if (!Number.isFinite(parsed) || parsed < 1)
|
|
40
|
+
return MAX_CONCURRENT_RUNS;
|
|
41
|
+
return Math.min(parsed, MAX_CONCURRENT_RUNS_CEILING);
|
|
42
|
+
}
|
|
29
43
|
/**
|
|
30
44
|
* A non-terminal row whose journal has been quiet this long is treated as
|
|
31
45
|
* INTERRUPTED (its process died) rather than still running elsewhere. Sits
|
|
@@ -106,6 +120,13 @@ const active = new Map();
|
|
|
106
120
|
export function _resetRunRegistryForTest() {
|
|
107
121
|
active.clear();
|
|
108
122
|
}
|
|
123
|
+
// NOTE on growth: the mirror is append-only and grows without bound on a
|
|
124
|
+
// long-lived install. In-place compaction was reviewed and REMOVED (PR #1698):
|
|
125
|
+
// a fold+rewrite without cross-process exclusion can permanently erase another
|
|
126
|
+
// process's terminal settle (nothing ever re-appends a final row), which would
|
|
127
|
+
// resurrect a finished run as "interrupted" and invite duplicate worktree
|
|
128
|
+
// adoption. Compaction needs an inter-process lock + unique temp files —
|
|
129
|
+
// tracked separately; until then, growth is the safe failure mode.
|
|
109
130
|
/** Fail-soft append of one full row to the mirror (self-heals a torn previous write). */
|
|
110
131
|
function appendRow(row) {
|
|
111
132
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "0.3.0-staging.
|
|
3
|
+
"version": "0.3.0-staging.1090.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)",
|
|
@@ -38,5 +38,5 @@
|
|
|
38
38
|
"@earendil-works/pi-tui": "0.84.1",
|
|
39
39
|
"typebox": "^1.3.11"
|
|
40
40
|
},
|
|
41
|
-
"yagniSourceSha": "
|
|
41
|
+
"yagniSourceSha": "79e1523affbd694ad96a236da6aefe33000497d8"
|
|
42
42
|
}
|