@yagni-app/code-staging 0.3.0-staging.1067.1 → 0.3.0-staging.1073.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/config.d.ts +11 -1
- package/dist/extension/config.js +1 -1
- package/dist/extension/execPolicy.d.ts +81 -0
- package/dist/extension/execPolicy.js +453 -0
- package/dist/extension/guardian.d.ts +113 -0
- package/dist/extension/guardian.js +186 -0
- package/dist/extension/index.d.ts +8 -4
- package/dist/extension/index.js +35 -3
- package/dist/extension/permission.d.ts +48 -6
- package/dist/extension/permission.js +233 -24
- package/dist/extension/pipeline/personas.js +22 -0
- package/dist/promptEnrichment.d.ts +1 -1
- package/dist/promptEnrichment.js +17 -0
- package/package.json +2 -2
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
import type { PipelineStage } from "./pipeline/types.js";
|
|
24
|
+
export type GuardianOutcome = "allow" | "deny";
|
|
25
|
+
export type GuardianRiskLevel = "low" | "medium" | "high" | "critical";
|
|
26
|
+
export interface GuardianVerdict {
|
|
27
|
+
outcome: GuardianOutcome;
|
|
28
|
+
riskLevel: GuardianRiskLevel;
|
|
29
|
+
rationale: string;
|
|
30
|
+
}
|
|
31
|
+
export interface GuardianLimits {
|
|
32
|
+
/** Session cap on total Guardian reviews. */
|
|
33
|
+
maxReviews: number;
|
|
34
|
+
/** Consecutive denials per turn before the circuit breaker trips. */
|
|
35
|
+
maxConsecutiveDenials: number;
|
|
36
|
+
/** Hard timeout for the Guardian consult in ms. */
|
|
37
|
+
timeoutMs: number;
|
|
38
|
+
}
|
|
39
|
+
export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
42
|
+
* overrides the session review cap; anything non-numeric or < 1 falls back to
|
|
43
|
+
* the default (a bad value must never zero out the cap and lock the session).
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
|
|
46
|
+
/** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
|
|
47
|
+
export declare const GUARDIAN_MODEL_TIER = "efficient";
|
|
48
|
+
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
49
|
+
export declare const GUARDIAN_TOOLS: string[];
|
|
50
|
+
export interface GuardianState {
|
|
51
|
+
reviews: number;
|
|
52
|
+
consecutiveDenials: number;
|
|
53
|
+
}
|
|
54
|
+
export interface GuardianStateHandle {
|
|
55
|
+
read(): GuardianState;
|
|
56
|
+
recordReview(outcome: GuardianOutcome): GuardianState;
|
|
57
|
+
resetTurn(): void;
|
|
58
|
+
}
|
|
59
|
+
export declare function makeGuardianState(): GuardianStateHandle;
|
|
60
|
+
export interface CircuitBreakerResult {
|
|
61
|
+
tripped: boolean;
|
|
62
|
+
reason?: string;
|
|
63
|
+
}
|
|
64
|
+
export declare function checkCircuitBreaker(state: GuardianState, limits: GuardianLimits): CircuitBreakerResult;
|
|
65
|
+
export declare function parseVerdict(raw: string): GuardianVerdict | null;
|
|
66
|
+
export declare function formatGuardianSubtotal(state: GuardianState, limits: GuardianLimits): string;
|
|
67
|
+
export type GuardianError = "timeout" | "malformed" | "network" | "empty";
|
|
68
|
+
export interface ReviewResult {
|
|
69
|
+
verdict: GuardianVerdict | null;
|
|
70
|
+
error?: GuardianError;
|
|
71
|
+
cost: number;
|
|
72
|
+
}
|
|
73
|
+
export interface ReviewCommandDeps {
|
|
74
|
+
runStage?: typeof defaultRunStage;
|
|
75
|
+
cwd: string;
|
|
76
|
+
signal?: AbortSignal;
|
|
77
|
+
/** Override the model tier (default: efficient). */
|
|
78
|
+
modelTier?: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
|
|
82
|
+
* (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
|
|
83
|
+
* agent name selects the guardian persona from PERSONA_BODIES.
|
|
84
|
+
*/
|
|
85
|
+
export declare function guardianStage(modelTier?: string): PipelineStage;
|
|
86
|
+
/**
|
|
87
|
+
* Run a Guardian consult: spawn a locked-down child pi with the risk policy
|
|
88
|
+
* persona and the command as the task. Parse the JSON verdict from the output.
|
|
89
|
+
* Returns { verdict, cost } on success, { verdict: null, error, cost } on failure.
|
|
90
|
+
*/
|
|
91
|
+
export declare function reviewCommand(command: string, deps: ReviewCommandDeps): Promise<ReviewResult>;
|
|
92
|
+
export interface GuardianDiagnosticEvent {
|
|
93
|
+
event: "guardian_review";
|
|
94
|
+
outcome: GuardianOutcome | GuardianError;
|
|
95
|
+
durationMs?: number;
|
|
96
|
+
tier?: string;
|
|
97
|
+
/** Debug-only: command hash for correlation (never the raw command). */
|
|
98
|
+
commandHash?: string;
|
|
99
|
+
/** Debug-only: the Guardian's rationale. */
|
|
100
|
+
rationale?: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Create a sanitized diagnostic event. Never includes the raw command text
|
|
104
|
+
* (could contain secrets). YAGNI_DEBUG=1 adds rationale and a command hash.
|
|
105
|
+
*/
|
|
106
|
+
export declare function buildDiagnosticEvent(outcome: GuardianOutcome | GuardianError, opts: {
|
|
107
|
+
durationMs?: number;
|
|
108
|
+
tier?: string;
|
|
109
|
+
rationale?: string;
|
|
110
|
+
commandHash?: string;
|
|
111
|
+
debug?: boolean;
|
|
112
|
+
}): GuardianDiagnosticEvent;
|
|
113
|
+
//# sourceMappingURL=guardian.d.ts.map
|
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
/**
|
|
29
|
+
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
30
|
+
* overrides the session review cap; anything non-numeric or < 1 falls back to
|
|
31
|
+
* the default (a bad value must never zero out the cap and lock the session).
|
|
32
|
+
*/
|
|
33
|
+
export function resolveGuardianLimits(env = process.env) {
|
|
34
|
+
const raw = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
|
|
35
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
36
|
+
const maxReviews = Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_GUARDIAN_LIMITS.maxReviews;
|
|
37
|
+
return { ...DEFAULT_GUARDIAN_LIMITS, maxReviews };
|
|
38
|
+
}
|
|
39
|
+
/** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
|
|
40
|
+
export const GUARDIAN_MODEL_TIER = "efficient";
|
|
41
|
+
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
42
|
+
export const GUARDIAN_TOOLS = ["read"];
|
|
43
|
+
export function makeGuardianState() {
|
|
44
|
+
const state = { reviews: 0, consecutiveDenials: 0 };
|
|
45
|
+
return {
|
|
46
|
+
read: () => ({ ...state }),
|
|
47
|
+
recordReview(outcome) {
|
|
48
|
+
state.reviews += 1;
|
|
49
|
+
if (outcome === "deny") {
|
|
50
|
+
state.consecutiveDenials += 1;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
state.consecutiveDenials = 0;
|
|
54
|
+
}
|
|
55
|
+
return { ...state };
|
|
56
|
+
},
|
|
57
|
+
resetTurn() {
|
|
58
|
+
state.consecutiveDenials = 0;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function checkCircuitBreaker(state, limits) {
|
|
63
|
+
if (state.consecutiveDenials >= limits.maxConsecutiveDenials) {
|
|
64
|
+
return {
|
|
65
|
+
tripped: true,
|
|
66
|
+
reason: `Guardian denied ${state.consecutiveDenials} consecutive actions this turn. Pausing for human review.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { tripped: false };
|
|
70
|
+
}
|
|
71
|
+
// --- Verdict parsing (fail closed on malformed) ---
|
|
72
|
+
export function parseVerdict(raw) {
|
|
73
|
+
try {
|
|
74
|
+
// Efficient-tier models may wrap JSON in markdown fences despite
|
|
75
|
+
// instructions to output raw JSON. Extract the first {...} block.
|
|
76
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
77
|
+
const jsonStr = jsonMatch ? jsonMatch[0] : raw;
|
|
78
|
+
const parsed = JSON.parse(jsonStr);
|
|
79
|
+
const outcome = parsed?.outcome;
|
|
80
|
+
if (outcome !== "allow" && outcome !== "deny")
|
|
81
|
+
return null;
|
|
82
|
+
const riskLevel = parsed.riskLevel;
|
|
83
|
+
const validLevels = ["low", "medium", "high", "critical"];
|
|
84
|
+
return {
|
|
85
|
+
outcome,
|
|
86
|
+
riskLevel: typeof riskLevel === "string" && validLevels.includes(riskLevel)
|
|
87
|
+
? riskLevel
|
|
88
|
+
: "medium",
|
|
89
|
+
rationale: typeof parsed.rationale === "string" && parsed.rationale.trim().length > 0
|
|
90
|
+
? parsed.rationale.trim()
|
|
91
|
+
: "No rationale provided.",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// --- /cost subtotal ---
|
|
99
|
+
export function formatGuardianSubtotal(state, limits) {
|
|
100
|
+
if (state.reviews === 0)
|
|
101
|
+
return "";
|
|
102
|
+
const plural = state.reviews === 1 ? "review" : "reviews";
|
|
103
|
+
return `Guardian: ${state.reviews} ${plural}.`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
|
|
107
|
+
* (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
|
|
108
|
+
* agent name selects the guardian persona from PERSONA_BODIES.
|
|
109
|
+
*/
|
|
110
|
+
export function guardianStage(modelTier = GUARDIAN_MODEL_TIER) {
|
|
111
|
+
return {
|
|
112
|
+
id: "plan",
|
|
113
|
+
agent: "guardian",
|
|
114
|
+
model: modelTier,
|
|
115
|
+
tools: GUARDIAN_TOOLS,
|
|
116
|
+
taskTemplate: "{ticket}",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Run a Guardian consult: spawn a locked-down child pi with the risk policy
|
|
121
|
+
* persona and the command as the task. Parse the JSON verdict from the output.
|
|
122
|
+
* Returns { verdict, cost } on success, { verdict: null, error, cost } on failure.
|
|
123
|
+
*/
|
|
124
|
+
export async function reviewCommand(command, deps) {
|
|
125
|
+
const runStage = deps.runStage ?? defaultRunStage;
|
|
126
|
+
const stage = guardianStage(deps.modelTier);
|
|
127
|
+
// Build the abort signal with the Guardian timeout.
|
|
128
|
+
const timeoutMs = DEFAULT_GUARDIAN_LIMITS.timeoutMs;
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
131
|
+
timer.unref?.();
|
|
132
|
+
// Chain with the caller's signal if present.
|
|
133
|
+
if (deps.signal) {
|
|
134
|
+
if (deps.signal.aborted)
|
|
135
|
+
controller.abort();
|
|
136
|
+
else
|
|
137
|
+
deps.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const result = await runStage(stage, { ticket: `Review this shell command for safety. Output only the JSON verdict.\n\nCommand:\n${command}` }, {
|
|
141
|
+
cwd: deps.cwd,
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
callerLabel: "guardian",
|
|
144
|
+
});
|
|
145
|
+
const cost = result.usage?.cost ?? 0;
|
|
146
|
+
const output = result.finalOutput.trim();
|
|
147
|
+
if (!output) {
|
|
148
|
+
return { verdict: null, error: "empty", cost };
|
|
149
|
+
}
|
|
150
|
+
const verdict = parseVerdict(output);
|
|
151
|
+
if (!verdict) {
|
|
152
|
+
return { verdict: null, error: "malformed", cost };
|
|
153
|
+
}
|
|
154
|
+
return { verdict, cost };
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
// Distinguish timeout from network/process errors.
|
|
158
|
+
if (controller.signal.aborted && !deps.signal?.aborted) {
|
|
159
|
+
return { verdict: null, error: "timeout", cost: 0 };
|
|
160
|
+
}
|
|
161
|
+
return { verdict: null, error: "network", cost: 0 };
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Create a sanitized diagnostic event. Never includes the raw command text
|
|
169
|
+
* (could contain secrets). YAGNI_DEBUG=1 adds rationale and a command hash.
|
|
170
|
+
*/
|
|
171
|
+
export function buildDiagnosticEvent(outcome, opts) {
|
|
172
|
+
const ev = {
|
|
173
|
+
event: "guardian_review",
|
|
174
|
+
outcome,
|
|
175
|
+
...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
|
|
176
|
+
...(opts.tier !== undefined ? { tier: opts.tier } : {}),
|
|
177
|
+
};
|
|
178
|
+
if (opts.debug) {
|
|
179
|
+
if (opts.rationale)
|
|
180
|
+
ev.rationale = opts.rationale;
|
|
181
|
+
if (opts.commandHash)
|
|
182
|
+
ev.commandHash = opts.commandHash;
|
|
183
|
+
}
|
|
184
|
+
return ev;
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=guardian.js.map
|
|
@@ -4,7 +4,7 @@ import { runInitPass as defaultRunInitPass } from "./initPass.js";
|
|
|
4
4
|
import { fetchMcpServers as defaultFetchMcpServers } from "./mcpTools.js";
|
|
5
5
|
import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
|
|
6
6
|
import { type TokenProvider } from "./tokenProvider.js";
|
|
7
|
-
import { type
|
|
7
|
+
import { type CatalogResult, type ContextBrief } from "./config.js";
|
|
8
8
|
/**
|
|
9
9
|
* YAGNI Code extension entry point.
|
|
10
10
|
*
|
|
@@ -31,7 +31,7 @@ export interface RegisterYagniDeps {
|
|
|
31
31
|
baseUrl: string;
|
|
32
32
|
getToken: () => string | undefined;
|
|
33
33
|
fetchImpl?: typeof fetch;
|
|
34
|
-
}) => Promise<
|
|
34
|
+
}) => Promise<CatalogResult>;
|
|
35
35
|
fetchContextBrief?: (opts: {
|
|
36
36
|
baseUrl: string;
|
|
37
37
|
getToken: () => string | undefined;
|
|
@@ -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, resolveGuardianLimits, 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";
|
|
@@ -122,7 +124,7 @@ export type { RunInitPassDeps, InitPassOutcome, RunTeamSetupDeps, TeamSetupOutco
|
|
|
122
124
|
export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
|
|
123
125
|
export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, BRAND_NAME } from "./branding.js";
|
|
124
126
|
export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
|
|
125
|
-
export type { FetchCatalogOptions, ModelEntry } from "./config.js";
|
|
127
|
+
export type { CatalogResult, FetchCatalogOptions, ModelEntry } from "./config.js";
|
|
126
128
|
export { buildYagniProvider } from "./provider.js";
|
|
127
129
|
export { registerGoCommand } from "./pipeline/goCommand.js";
|
|
128
130
|
export type { RegisterGoDeps } from "./pipeline/goCommand.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";
|
package/dist/extension/index.js
CHANGED
|
@@ -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 { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, 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";
|
|
@@ -126,7 +127,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
126
127
|
// after_provider_response event does NOT fire on a 401 (the OpenAI SDK throws
|
|
127
128
|
// before onResponse is reached), so message_end is the only seam.
|
|
128
129
|
let lastAuthRecovery = null;
|
|
129
|
-
const fullCatalog = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
|
|
130
|
+
const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
|
|
130
131
|
// Lock the interactive session to the `advanced` tier only. The backend
|
|
131
132
|
// catalog returns all tiers, but only `advanced` is registered with the
|
|
132
133
|
// `yagni` provider, so /model and Ctrl+P show a single entry. Child
|
|
@@ -234,8 +235,33 @@ 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
|
+
// Disabled by the local env override OR the workspace kill switch
|
|
240
|
+
// (yagni_code.guardian, read from the catalog response at launch). The env
|
|
241
|
+
// var wins for a single developer's debugging; the flag turns it off for
|
|
242
|
+
// every session in the workspace.
|
|
243
|
+
const guardianDisabled = env.YAGNI_DISABLE_GUARDIAN === "1" ||
|
|
244
|
+
env.YAGNI_DISABLE_GUARDIAN === "true" ||
|
|
245
|
+
!workspaceGuardianEnabled;
|
|
246
|
+
const guardianTier = env.YAGNI_GUARDIAN_TIER ?? GUARDIAN_MODEL_TIER;
|
|
247
|
+
const guardianLimits = resolveGuardianLimits(env);
|
|
237
248
|
registerPermissionGate(pi, {
|
|
238
249
|
modeHolder,
|
|
250
|
+
guardianState,
|
|
251
|
+
guardianLimits,
|
|
252
|
+
guardianTier,
|
|
253
|
+
guardianDisabled,
|
|
254
|
+
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 */ }
|
|
264
|
+
},
|
|
239
265
|
...(mcpMutatingTools.length > 0
|
|
240
266
|
? {
|
|
241
267
|
policy: {
|
|
@@ -271,7 +297,11 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
271
297
|
// turn_end accumulator. Thread the subtotal in explicitly for the local
|
|
272
298
|
// fallback line; the server-authoritative line already counts advisor
|
|
273
299
|
// spend as an ordinary caller row.
|
|
274
|
-
advisorSubtotal: () =>
|
|
300
|
+
advisorSubtotal: () => {
|
|
301
|
+
const advisor = formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS);
|
|
302
|
+
const guardian = formatGuardianSubtotal(guardianState.read(), guardianLimits);
|
|
303
|
+
return [advisor, guardian].filter(Boolean).join(" ");
|
|
304
|
+
},
|
|
275
305
|
fetchHeadroom: async (signal) => {
|
|
276
306
|
try {
|
|
277
307
|
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 +681,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
|
|
|
651
681
|
export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
652
682
|
export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
653
683
|
export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
|
|
684
|
+
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
|
|
654
685
|
export { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
|
655
686
|
export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
|
|
656
687
|
export { makeRecordDecisionTool } from "./recordDecisionTool.js";
|
|
@@ -681,7 +712,8 @@ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkd
|
|
|
681
712
|
export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
|
|
682
713
|
// P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
|
|
683
714
|
// the session bless-with-remember capture hook.
|
|
684
|
-
export { decideGate, registerPermissionGate, filterStalePlanContext, DEFAULT_PERMISSION_POLICY, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
|
|
715
|
+
export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
|
|
716
|
+
export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
|
|
685
717
|
// W4 judgment loop: the session bless store (tool + path-prefix, session-only).
|
|
686
718
|
export { makeBlessStore, blessPath } from "./bless.js";
|
|
687
719
|
// 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
|
|
94
|
-
export declare const
|
|
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
|
|
98
|
-
* model
|
|
99
|
-
*
|
|
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
|
|
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.
|