@yagni-app/code 0.2.1 → 0.3.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/cli.d.ts +30 -0
- package/dist/cli.js +147 -3
- package/dist/connectClaudeCode.d.ts +77 -0
- package/dist/connectClaudeCode.js +228 -0
- package/dist/connectCodex.d.ts +75 -0
- package/dist/connectCodex.js +201 -0
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +1 -1
- package/dist/extension/advisor.d.ts +4 -4
- package/dist/extension/advisor.js +6 -7
- package/dist/extension/approvedPrefixes.d.ts +103 -0
- package/dist/extension/approvedPrefixes.js +282 -0
- package/dist/extension/askAdvisorTool.d.ts +20 -5
- package/dist/extension/askAdvisorTool.js +126 -20
- package/dist/extension/askYagniTool.d.ts +23 -0
- package/dist/extension/askYagniTool.js +91 -2
- package/dist/extension/branding.d.ts +34 -3
- package/dist/extension/branding.js +114 -13
- package/dist/extension/chipEditor.d.ts +30 -9
- package/dist/extension/chipEditor.js +173 -59
- package/dist/extension/claudeRules.d.ts +0 -2
- package/dist/extension/claudeRules.js +0 -8
- package/dist/extension/cmux/dispatcher.d.ts +25 -0
- package/dist/extension/cmux/dispatcher.js +266 -0
- package/dist/extension/cmux/hooks.d.ts +12 -0
- package/dist/extension/cmux/hooks.js +192 -0
- package/dist/extension/cmux/index.d.ts +3 -0
- package/dist/extension/cmux/index.js +155 -0
- package/dist/extension/cmux/naming.d.ts +5 -0
- package/dist/extension/cmux/naming.js +23 -0
- package/dist/extension/cmux/state.d.ts +33 -0
- package/dist/extension/cmux/state.js +142 -0
- package/dist/extension/config.d.ts +44 -1
- package/dist/extension/config.js +37 -4
- package/dist/extension/costHud.d.ts +16 -22
- package/dist/extension/costHud.js +8 -47
- package/dist/extension/crashReport.js +1 -3
- package/dist/extension/execPolicy.d.ts +135 -0
- package/dist/extension/execPolicy.js +936 -0
- package/dist/extension/flywheel.d.ts +44 -0
- package/dist/extension/flywheel.js +53 -0
- package/dist/extension/footer.d.ts +118 -0
- package/dist/extension/footer.js +308 -0
- package/dist/extension/guardian.d.ts +139 -0
- package/dist/extension/guardian.js +237 -0
- package/dist/extension/index.d.ts +35 -7
- package/dist/extension/index.js +338 -33
- package/dist/extension/mineBeat.d.ts +95 -0
- package/dist/extension/mineBeat.js +193 -0
- package/dist/extension/permission.d.ts +124 -10
- package/dist/extension/permission.js +603 -52
- package/dist/extension/pipeline/childRegistry.d.ts +41 -0
- package/dist/extension/pipeline/childRegistry.js +118 -0
- package/dist/extension/pipeline/finish.js +5 -1
- package/dist/extension/pipeline/goCommand.d.ts +1 -1
- package/dist/extension/pipeline/goCommand.js +40 -9
- package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
- package/dist/extension/pipeline/goStatusCommands.js +61 -1
- package/dist/extension/pipeline/personas.js +26 -1
- package/dist/extension/pipeline/resilience.d.ts +2 -1
- package/dist/extension/pipeline/resilience.js +21 -2
- package/dist/extension/pipeline/runRegistry.d.ts +23 -1
- package/dist/extension/pipeline/runRegistry.js +57 -1
- package/dist/extension/pipeline/runner.js +4 -0
- package/dist/extension/pipeline/verify.d.ts +4 -0
- package/dist/extension/pipeline/verify.js +48 -26
- package/dist/extension/recordDecisionTool.d.ts +8 -0
- package/dist/extension/recordDecisionTool.js +24 -0
- package/dist/extension/redact.d.ts +20 -0
- package/dist/extension/redact.js +64 -0
- package/dist/extension/rerouteNotice.d.ts +3 -4
- package/dist/extension/rerouteNotice.js +20 -11
- package/dist/extension/subagentRender.d.ts +129 -0
- package/dist/extension/subagentRender.js +441 -0
- package/dist/extension/subagents.d.ts +11 -8
- package/dist/extension/subagents.js +162 -37
- package/dist/extension/ticketTools.d.ts +37 -0
- package/dist/extension/ticketTools.js +117 -0
- package/dist/extension/todos.d.ts +28 -1
- package/dist/extension/todos.js +76 -1
- package/dist/extension/tokenProvider.js +46 -5
- package/dist/extension/ultra.d.ts +27 -0
- package/dist/extension/ultra.js +76 -0
- package/dist/launch.d.ts +7 -0
- package/dist/launch.js +24 -12
- package/dist/login.d.ts +4 -2
- package/dist/login.js +19 -4
- package/dist/padding.d.ts +22 -0
- package/dist/padding.js +25 -0
- package/dist/promptEnrichment.d.ts +40 -0
- package/dist/promptEnrichment.js +85 -0
- package/dist/signalForward.d.ts +60 -0
- package/dist/signalForward.js +130 -0
- package/dist/token.d.ts +25 -0
- package/dist/token.js +45 -0
- package/package.json +6 -5
- package/dist/extension/boostCommand.d.ts +0 -144
- package/dist/extension/boostCommand.js +0 -263
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* YAGNI Code registers no tool_call handler today, so the interactive session has
|
|
5
5
|
* no plan/approval surface. P3 adds one on pi's documented `tool_call` block seam
|
|
6
6
|
* plus a `/mode` command:
|
|
7
|
-
* - auto (default):
|
|
7
|
+
* - auto (default): ordinary coding tools run directly; external tracker
|
|
8
|
+
* changes still require fresh human confirmation.
|
|
8
9
|
* - plan : blocks write/edit/bash so the agent can explore + propose without
|
|
9
10
|
* touching the tree.
|
|
10
11
|
* - review : surfaces a three-way ctx.ui.select before a write/edit/bash; a
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
* adds a session-scoped bless rule AND drafts a decision capture.
|
|
15
16
|
*
|
|
16
17
|
* `decideGate` is PURE; the live wiring holds the mode in a small closure (no
|
|
17
|
-
* module-global state). The default auto mode remains
|
|
18
|
+
* module-global state). The default auto mode remains direct for coding tools, but stricter
|
|
18
19
|
* modes fail closed for side-effect tools if the gate itself errors. Bless rules
|
|
19
20
|
* are session-scoped, path-prefix-bound, never persisted, and never consulted in
|
|
20
21
|
* plan mode (plan blocks outright before isBlessed is reached).
|
|
@@ -25,19 +26,35 @@
|
|
|
25
26
|
* When the mode leaves plan, stale plan-context messages are filtered out of
|
|
26
27
|
* the context so the model doesn't keep believing it is restricted.
|
|
27
28
|
*/
|
|
29
|
+
import { describePrefix, matchesGrant, validateGrant, } from "./approvedPrefixes.js";
|
|
28
30
|
import { makeBlessStore as defaultMakeBlessStore } from "./bless.js";
|
|
31
|
+
import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
|
|
32
|
+
import { isDebug } from "./diagnostics.js";
|
|
33
|
+
import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
|
|
34
|
+
export function createModeHolder(initial = "auto") {
|
|
35
|
+
let current = initial;
|
|
36
|
+
const listeners = new Set();
|
|
37
|
+
return {
|
|
38
|
+
get: () => current,
|
|
39
|
+
set: (m) => {
|
|
40
|
+
current = m;
|
|
41
|
+
for (const fn of listeners)
|
|
42
|
+
fn(m);
|
|
43
|
+
},
|
|
44
|
+
onSet: (fn) => { listeners.add(fn); },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
29
47
|
export const DEFAULT_PERMISSION_POLICY = {
|
|
30
|
-
planBlockTools: ["write", "edit", "bash"],
|
|
31
|
-
reviewConfirmTools: ["write", "edit", "bash"],
|
|
48
|
+
planBlockTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
|
|
49
|
+
reviewConfirmTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
|
|
50
|
+
alwaysConfirmTools: ["file_ticket", "update_ticket_status"],
|
|
32
51
|
};
|
|
33
52
|
/**
|
|
34
|
-
* Pure permission decision for one tool call under a mode + policy.
|
|
35
|
-
*
|
|
53
|
+
* Pure permission decision for one tool call under a mode + policy. Auto allows
|
|
54
|
+
* ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
|
|
36
55
|
* unless a recorded decision blesses them.
|
|
37
56
|
*/
|
|
38
57
|
export function decideGate(toolName, params, mode, policy) {
|
|
39
|
-
if (mode === "auto")
|
|
40
|
-
return { block: false };
|
|
41
58
|
if (mode === "plan") {
|
|
42
59
|
if (policy.planBlockTools.includes(toolName)) {
|
|
43
60
|
return {
|
|
@@ -47,6 +64,47 @@ export function decideGate(toolName, params, mode, policy) {
|
|
|
47
64
|
}
|
|
48
65
|
return { block: false };
|
|
49
66
|
}
|
|
67
|
+
if (policy.alwaysConfirmTools?.includes(toolName)) {
|
|
68
|
+
return { block: false, confirm: true };
|
|
69
|
+
}
|
|
70
|
+
// Exec policy: classify bash commands before the tool-granular logic.
|
|
71
|
+
if (toolName === "bash") {
|
|
72
|
+
const command = typeof params.command === "string" ? params.command.trim() : "";
|
|
73
|
+
if (command) {
|
|
74
|
+
try {
|
|
75
|
+
const execPolicy = policy.execPolicy ?? DEFAULT_EXEC_POLICY;
|
|
76
|
+
const classification = classifyCommand(command, execPolicy);
|
|
77
|
+
if (classification.decision === "allow")
|
|
78
|
+
return { block: false };
|
|
79
|
+
if (classification.decision === "forbidden") {
|
|
80
|
+
return {
|
|
81
|
+
block: true,
|
|
82
|
+
reason: `${classification.justification}. Do not attempt the same outcome via a workaround or indirect execution — use a materially safer alternative, or ask the user.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// prompt — signal to the handler so it can run the Guardian.
|
|
86
|
+
// In auto mode the handler runs the Guardian; in review mode the
|
|
87
|
+
// handler runs the Guardian first, then falls back to user confirm.
|
|
88
|
+
if (mode === "auto") {
|
|
89
|
+
return { block: false, classify: "prompt", classifyJustification: classification.justification };
|
|
90
|
+
}
|
|
91
|
+
// review mode
|
|
92
|
+
if (policy.isBlessed?.(toolName, params))
|
|
93
|
+
return { block: false };
|
|
94
|
+
return {
|
|
95
|
+
block: false,
|
|
96
|
+
confirm: true,
|
|
97
|
+
classify: "prompt",
|
|
98
|
+
classifyJustification: classification.justification,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// classifyCommand threw — fall through to tool-granular logic (graceful degradation).
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (mode === "auto")
|
|
107
|
+
return { block: false };
|
|
50
108
|
// review
|
|
51
109
|
if (policy.reviewConfirmTools.includes(toolName)) {
|
|
52
110
|
if (policy.isBlessed?.(toolName, params))
|
|
@@ -55,9 +113,13 @@ export function decideGate(toolName, params, mode, policy) {
|
|
|
55
113
|
}
|
|
56
114
|
return { block: false };
|
|
57
115
|
}
|
|
58
|
-
/** The customType tag on injected
|
|
59
|
-
export const
|
|
116
|
+
/** The customType tag on injected mode-context messages (filterable later). */
|
|
117
|
+
export const MODE_CONTEXT_TYPE = "yagni-mode-context";
|
|
118
|
+
/** Legacy alias — the original plan-mode tag, kept for backward compat. */
|
|
119
|
+
export const PLAN_CONTEXT_TYPE = MODE_CONTEXT_TYPE;
|
|
60
120
|
const PLAN_MARKER = "[PLAN MODE ACTIVE]";
|
|
121
|
+
const AUTO_MARKER = "[AUTO MODE]";
|
|
122
|
+
const REVIEW_MARKER = "[REVIEW MODE]";
|
|
61
123
|
export const PLAN_CONTEXT_MESSAGE = `${PLAN_MARKER}
|
|
62
124
|
You are in plan mode: explore and design, change nothing.
|
|
63
125
|
- write, edit, and bash are held by the permission gate; do not attempt them.
|
|
@@ -65,38 +127,86 @@ You are in plan mode: explore and design, change nothing.
|
|
|
65
127
|
- Produce a concrete numbered plan of the steps you would take, with the files involved.
|
|
66
128
|
- End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.
|
|
67
129
|
- Once executing, track the plan's steps with todo_write.`;
|
|
68
|
-
|
|
130
|
+
const AUTO_CONTEXT_MESSAGE = `${AUTO_MARKER}
|
|
131
|
+
You are in auto mode. Coding commands run directly.
|
|
132
|
+
- Proactively verify your work: run tests, lint, and typecheck after changes.
|
|
133
|
+
- Destructive commands (rm -rf, git reset --hard, git push --force) are blocked by the exec policy.
|
|
134
|
+
- Ambiguous commands are reviewed by the Guardian before running.`;
|
|
135
|
+
const REVIEW_CONTEXT_MESSAGE = `${REVIEW_MARKER}
|
|
136
|
+
You are in review mode. Each bash, write, and edit may be confirmed before running.
|
|
137
|
+
- Safe commands (ls, cat, git status) run without prompting.
|
|
138
|
+
- Ambiguous commands are reviewed by the Guardian first; if the Guardian allows, they run without prompting.
|
|
139
|
+
- If the Guardian denies or is unavailable, you will be asked to confirm.
|
|
140
|
+
- Propose verification steps but wait for approval before running them.`;
|
|
141
|
+
/** Build the mode-awareness context message for the current permission mode. */
|
|
142
|
+
export function buildModeContextMessage(mode) {
|
|
143
|
+
switch (mode) {
|
|
144
|
+
case "plan":
|
|
145
|
+
return PLAN_CONTEXT_MESSAGE;
|
|
146
|
+
case "review":
|
|
147
|
+
return REVIEW_CONTEXT_MESSAGE;
|
|
148
|
+
case "auto":
|
|
149
|
+
return AUTO_CONTEXT_MESSAGE;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const MODE_MARKERS = {
|
|
153
|
+
plan: PLAN_MARKER,
|
|
154
|
+
auto: AUTO_MARKER,
|
|
155
|
+
review: REVIEW_MARKER,
|
|
156
|
+
};
|
|
157
|
+
function mentionsModeMarker(content) {
|
|
69
158
|
if (typeof content === "string")
|
|
70
|
-
return content.includes(
|
|
159
|
+
return Object.values(MODE_MARKERS).some((m) => content.includes(m));
|
|
71
160
|
if (Array.isArray(content)) {
|
|
72
161
|
return content.some((c) => typeof c?.text === "string" &&
|
|
73
|
-
(c.text.includes(
|
|
162
|
+
Object.values(MODE_MARKERS).some((m) => c.text.includes(m)));
|
|
74
163
|
}
|
|
75
164
|
return false;
|
|
76
165
|
}
|
|
166
|
+
/** Which mode marker does this message carry, if any? */
|
|
167
|
+
function modeMarkerFor(content) {
|
|
168
|
+
const text = typeof content === "string"
|
|
169
|
+
? content
|
|
170
|
+
: Array.isArray(content)
|
|
171
|
+
? content.find((c) => typeof c?.text === "string")?.text
|
|
172
|
+
: undefined;
|
|
173
|
+
if (!text)
|
|
174
|
+
return null;
|
|
175
|
+
for (const [mode, marker] of Object.entries(MODE_MARKERS)) {
|
|
176
|
+
if (text.includes(marker))
|
|
177
|
+
return marker;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
77
181
|
/**
|
|
78
|
-
* Drop previously injected
|
|
79
|
-
* model
|
|
80
|
-
*
|
|
182
|
+
* Drop previously injected mode-context messages from a DIFFERENT mode so the
|
|
183
|
+
* model does not keep believing it is in a prior mode. Messages matching the
|
|
184
|
+
* current mode are kept (the fresh injection from before_agent_start should
|
|
185
|
+
* survive). Pure; returns the SAME array when nothing needs filtering so callers
|
|
186
|
+
* can cheaply detect a no-op.
|
|
81
187
|
*/
|
|
82
|
-
export function
|
|
188
|
+
export function filterStaleModeContext(messages, currentMode) {
|
|
189
|
+
const currentMarker = currentMode ? MODE_MARKERS[currentMode] : undefined;
|
|
83
190
|
const keep = messages.filter((m) => {
|
|
84
191
|
const msg = m;
|
|
85
|
-
if (msg?.customType ===
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
192
|
+
if (msg?.customType === MODE_CONTEXT_TYPE) {
|
|
193
|
+
// Keep if it matches the current mode; strip if from a different mode
|
|
194
|
+
// (or if we don't know the current mode — strip all to be safe).
|
|
195
|
+
const marker = modeMarkerFor(msg.content);
|
|
196
|
+
return currentMarker !== undefined && marker === currentMarker;
|
|
197
|
+
}
|
|
198
|
+
if (msg?.role === "user" && mentionsModeMarker(msg.content)) {
|
|
199
|
+
const marker = modeMarkerFor(msg.content);
|
|
200
|
+
return currentMarker !== undefined && marker === currentMarker;
|
|
201
|
+
}
|
|
89
202
|
return true;
|
|
90
203
|
});
|
|
91
204
|
return keep.length === messages.length ? messages : keep;
|
|
92
205
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
plan: "⏸ plan",
|
|
96
|
-
review: "✓ review",
|
|
97
|
-
};
|
|
206
|
+
/** Legacy alias — the original plan-mode filter name. */
|
|
207
|
+
export const filterStalePlanContext = filterStaleModeContext;
|
|
98
208
|
const MODE_COPY = {
|
|
99
|
-
auto: "auto: changes apply
|
|
209
|
+
auto: "auto: coding changes apply directly; external tracker changes ask first (default).",
|
|
100
210
|
plan: "plan: write, edit, and bash are held so the agent can explore and propose only.",
|
|
101
211
|
review: "review: you confirm each write, edit, or bash command before it applies.",
|
|
102
212
|
};
|
|
@@ -104,7 +214,41 @@ function isMode(value) {
|
|
|
104
214
|
return value === "auto" || value === "plan" || value === "review";
|
|
105
215
|
}
|
|
106
216
|
function sideEffectTools(policy) {
|
|
107
|
-
return new Set([
|
|
217
|
+
return new Set([
|
|
218
|
+
...policy.planBlockTools,
|
|
219
|
+
...policy.reviewConfirmTools,
|
|
220
|
+
...(policy.alwaysConfirmTools ?? []),
|
|
221
|
+
]);
|
|
222
|
+
}
|
|
223
|
+
function boundedPromptValue(value, fallback) {
|
|
224
|
+
if (typeof value !== "string")
|
|
225
|
+
return fallback;
|
|
226
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
227
|
+
if (normalized.length === 0)
|
|
228
|
+
return fallback;
|
|
229
|
+
return normalized.length <= 80 ? normalized : `${normalized.slice(0, 77)}…`;
|
|
230
|
+
}
|
|
231
|
+
function externalTrackerPrompt(toolName, input) {
|
|
232
|
+
if (toolName === "file_ticket") {
|
|
233
|
+
const title = boundedPromptValue(input.title, "Untitled ticket");
|
|
234
|
+
const target = boundedPromptValue(input.target_key, "default project/team");
|
|
235
|
+
return `File “${title}” in ${target}?`;
|
|
236
|
+
}
|
|
237
|
+
if (toolName === "update_ticket_status") {
|
|
238
|
+
const ref = boundedPromptValue(input.ref, "ticket");
|
|
239
|
+
const status = boundedPromptValue(input.status, "requested status");
|
|
240
|
+
return `Move ${ref} to ${status}?`;
|
|
241
|
+
}
|
|
242
|
+
return "Confirm external tracker change";
|
|
243
|
+
}
|
|
244
|
+
function guardianErrorMessage(error) {
|
|
245
|
+
switch (error) {
|
|
246
|
+
case "timeout": return "review timed out";
|
|
247
|
+
case "malformed": return "unclear verdict";
|
|
248
|
+
case "empty": return "no response";
|
|
249
|
+
case "network": return "service unavailable";
|
|
250
|
+
default: return "unknown error";
|
|
251
|
+
}
|
|
108
252
|
}
|
|
109
253
|
/**
|
|
110
254
|
* Wire the tool_call gate + the /mode command onto a shared mode holder. Default
|
|
@@ -114,6 +258,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
114
258
|
const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
|
|
115
259
|
let mode = deps.mode ?? "auto";
|
|
116
260
|
const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
|
|
261
|
+
deps.modeHolder?.onSet((m) => {
|
|
262
|
+
if (m !== mode)
|
|
263
|
+
approvedCommands.clear();
|
|
264
|
+
mode = m;
|
|
265
|
+
});
|
|
117
266
|
// The session bless store is created lazily on the first tool_call (it needs
|
|
118
267
|
// the cwd). Its isBlessed backs the review-mode auto-approve, UNLESS the caller
|
|
119
268
|
// injected its own isBlessed (e.g. a test policy) — that always wins.
|
|
@@ -121,22 +270,420 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
121
270
|
const effectivePolicy = {
|
|
122
271
|
planBlockTools: basePolicy.planBlockTools,
|
|
123
272
|
reviewConfirmTools: basePolicy.reviewConfirmTools,
|
|
273
|
+
alwaysConfirmTools: basePolicy.alwaysConfirmTools,
|
|
274
|
+
// execPolicy MUST be carried through: decideGate reads policy.execPolicy
|
|
275
|
+
// and dropping it here silently reverts every custom policy to the
|
|
276
|
+
// default (round-2 review blocker).
|
|
277
|
+
execPolicy: basePolicy.execPolicy,
|
|
124
278
|
isBlessed: basePolicy.isBlessed ?? ((tool, params) => blessStore?.isBlessed(tool, params) ?? false),
|
|
125
279
|
};
|
|
126
280
|
const sideEffects = sideEffectTools(effectivePolicy);
|
|
281
|
+
const guardianState = deps.guardianState;
|
|
282
|
+
const guardianLimits = deps.guardianLimits;
|
|
283
|
+
const guardianDisabled = deps.guardianDisabled ?? false;
|
|
284
|
+
const guardianReview = deps.guardianReview;
|
|
285
|
+
const guardianTier = deps.guardianTier;
|
|
286
|
+
// --- YAG-510 gate state ---
|
|
287
|
+
// Grants: in-memory list seeded from deps, appended on "don't ask again".
|
|
288
|
+
// Deliberately NOT live-reloaded from disk: auto mode can write files, so a
|
|
289
|
+
// mid-session re-read of rules.json would let the agent (or a prompt
|
|
290
|
+
// injection) author its own grants and self-authorize within the same
|
|
291
|
+
// session. New grants from concurrent sessions apply at next launch — the
|
|
292
|
+
// startup load is the trust boundary (PR #1698 review).
|
|
293
|
+
const grants = [...(deps.grants ?? [])];
|
|
294
|
+
// Keyed by cwd: a session can change working directory (cd, /go worktrees),
|
|
295
|
+
// and a repoKey memoized from the first cwd would let repo-A grants match
|
|
296
|
+
// commands running in repo B (PR #1694 review).
|
|
297
|
+
const repoKeys = new Map();
|
|
298
|
+
const resolveRepoKeyFor = (cwd) => {
|
|
299
|
+
let key = repoKeys.get(cwd);
|
|
300
|
+
if (key === undefined) {
|
|
301
|
+
key = deps.resolveRepoKey ? deps.resolveRepoKey(cwd) : cwd;
|
|
302
|
+
repoKeys.set(cwd, key);
|
|
303
|
+
}
|
|
304
|
+
return key;
|
|
305
|
+
};
|
|
306
|
+
// Session exact-command approval cache (ticket 4.5): a user-approved ask
|
|
307
|
+
// covers an identical later command. Keyed by cwd + trimmed command,
|
|
308
|
+
// LRU-capped, cleared on every /mode transition.
|
|
309
|
+
const APPROVED_CACHE_MAX = 50;
|
|
310
|
+
const approvedCommands = new Map();
|
|
311
|
+
const cacheKey = (cwd, command) => `${cwd}\u0000${command}`;
|
|
312
|
+
const rememberApproved = (cwd, command) => {
|
|
313
|
+
const key = cacheKey(cwd, command);
|
|
314
|
+
approvedCommands.delete(key);
|
|
315
|
+
approvedCommands.set(key, true);
|
|
316
|
+
if (approvedCommands.size > APPROVED_CACHE_MAX) {
|
|
317
|
+
const oldest = approvedCommands.keys().next().value;
|
|
318
|
+
if (oldest !== undefined)
|
|
319
|
+
approvedCommands.delete(oldest);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
// Per-USER-PROMPT bounds (reset in before_agent_start, which fires once per
|
|
323
|
+
// user prompt — NOT per LLM turn): genuine ask verdicts are uncapped (the
|
|
324
|
+
// user's patience is the bound); error-fallback asks are capped so
|
|
325
|
+
// a provider outage can't become an ask storm; the breaker escalation is
|
|
326
|
+
// offered once, and a decline latches back to hard blocks.
|
|
327
|
+
const ERROR_ASK_CAP = 3;
|
|
328
|
+
let errorFallbackAsks = 0;
|
|
329
|
+
let breakerEscalationOffered = false;
|
|
330
|
+
const emitGateEvent = (event) => {
|
|
331
|
+
if (!deps.onGuardianEvent)
|
|
332
|
+
return;
|
|
333
|
+
try {
|
|
334
|
+
void Promise.resolve(deps.onGuardianEvent(event)).catch(() => { });
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// Fail-soft: storage must never affect the gate.
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
/** Bounded single-line command rendering for dialog titles. */
|
|
341
|
+
const boundedCommand = (command) => {
|
|
342
|
+
const flat = command.replace(/\s+/g, " ").trim();
|
|
343
|
+
return flat.length <= 240 ? flat : `${flat.slice(0, 237)}…`;
|
|
344
|
+
};
|
|
345
|
+
const ASK_TIMEOUT_MS = 120_000;
|
|
346
|
+
const ASK_YES = "Yes, run it";
|
|
347
|
+
const ASK_NO = "No";
|
|
348
|
+
/**
|
|
349
|
+
* The single human-in-the-loop ask surface (YAG-510): used for ask
|
|
350
|
+
* verdicts, Guardian-unavailable/disabled fallbacks, and the breaker
|
|
351
|
+
* escalation — one UI, one cache, one event stream. Always passes the
|
|
352
|
+
* turn's abort signal (without it a turn-abort leaves the dialog hanging)
|
|
353
|
+
* and a timeout (pi renders a countdown; expiry fails closed).
|
|
354
|
+
*/
|
|
355
|
+
const askUser = async (ctx, title, rememberLabel) => {
|
|
356
|
+
if (ctx.signal?.aborted)
|
|
357
|
+
return "aborted";
|
|
358
|
+
const options = rememberLabel ? [ASK_YES, rememberLabel, ASK_NO] : [ASK_YES, ASK_NO];
|
|
359
|
+
let choice;
|
|
360
|
+
try {
|
|
361
|
+
choice = await ctx.ui.select(title, options, {
|
|
362
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
363
|
+
timeout: ASK_TIMEOUT_MS,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
choice = undefined;
|
|
368
|
+
}
|
|
369
|
+
if (choice === ASK_YES)
|
|
370
|
+
return "yes";
|
|
371
|
+
if (rememberLabel !== null && choice === rememberLabel)
|
|
372
|
+
return "remember";
|
|
373
|
+
if (choice === ASK_NO)
|
|
374
|
+
return "no";
|
|
375
|
+
return ctx.signal?.aborted ? "aborted" : "dismissed";
|
|
376
|
+
};
|
|
377
|
+
const buildAskTitle = (command, rationale, riskLevel) => {
|
|
378
|
+
const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
|
|
379
|
+
return `Guardian asks${risk}\n${rationale}\n$ ${boundedCommand(command)}`;
|
|
380
|
+
};
|
|
127
381
|
pi.on("tool_call", async (event, ctx) => {
|
|
382
|
+
// Snapshot the mode ONCE: /mode can flip mid-await, and post-await reads
|
|
383
|
+
// of the closure variable would disagree with the decision already made.
|
|
384
|
+
const modeAtEntry = mode;
|
|
128
385
|
try {
|
|
129
386
|
const input = event.input ?? {};
|
|
130
|
-
const decision = decideGate(event.toolName, input,
|
|
387
|
+
const decision = decideGate(event.toolName, input, modeAtEntry, effectivePolicy);
|
|
131
388
|
if (decision.block)
|
|
132
389
|
return { block: true, reason: decision.reason };
|
|
390
|
+
// Prompt band (YAG-510 order): grants → exact-command cache → cap/
|
|
391
|
+
// breaker → Guardian consult → allow/ask/deny. Grants and the cache are
|
|
392
|
+
// checked BEFORE the cap and breaker: a user-approved command must
|
|
393
|
+
// never be blocked by "review cap reached".
|
|
394
|
+
if (decision.classify === "prompt") {
|
|
395
|
+
const command = typeof input.command === "string"
|
|
396
|
+
? input.command.trim()
|
|
397
|
+
: "";
|
|
398
|
+
const cwd = ctx?.cwd ?? ".";
|
|
399
|
+
const execJustification = decision.classifyJustification;
|
|
400
|
+
const eventBase = {
|
|
401
|
+
command,
|
|
402
|
+
...(execJustification ? { execJustification } : {}),
|
|
403
|
+
mode: modeAtEntry,
|
|
404
|
+
...(guardianTier ? { tier: guardianTier } : {}),
|
|
405
|
+
};
|
|
406
|
+
// 1. Persisted grants — auto mode only (review's contract is
|
|
407
|
+
// confirm-each-command). A grant can never cover forbidden commands:
|
|
408
|
+
// decideGate already returned block for those.
|
|
409
|
+
if (modeAtEntry === "auto" && command) {
|
|
410
|
+
const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
|
|
411
|
+
if (grant) {
|
|
412
|
+
emitGateEvent({ ...eventBase, outcome: "prefix_allow", consulted: false });
|
|
413
|
+
return {};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// 2. Session exact-command approval cache (ticket 4.5).
|
|
417
|
+
if (command && approvedCommands.has(cacheKey(cwd, command))) {
|
|
418
|
+
emitGateEvent({ ...eventBase, outcome: "cached_allow", consulted: false });
|
|
419
|
+
return {};
|
|
420
|
+
}
|
|
421
|
+
const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
|
|
422
|
+
const limits = guardianLimits ?? DEFAULT_GUARDIAN_LIMITS;
|
|
423
|
+
if (guardianAvailable && guardianState.read().reviews >= limits.maxReviews) {
|
|
424
|
+
// Sliding-window consult cap (capacity recovers as old reviews age
|
|
425
|
+
// out — a long-lived session is never bricked). Review mode falls
|
|
426
|
+
// through to its ordinary confirm (no LLM cost); auto blocks.
|
|
427
|
+
if (modeAtEntry === "auto") {
|
|
428
|
+
if (ctx?.hasUI)
|
|
429
|
+
ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} in the last hour).`, "warning");
|
|
430
|
+
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.` };
|
|
431
|
+
}
|
|
432
|
+
// fall through to decision.confirm below
|
|
433
|
+
}
|
|
434
|
+
else if (guardianAvailable) {
|
|
435
|
+
// Circuit breaker (pre-consult). With a UI, escalate to ONE ask per
|
|
436
|
+
// user prompt — asking beats stopping; a decline latches back to
|
|
437
|
+
// hard blocks for the rest of the prompt.
|
|
438
|
+
const breaker = checkCircuitBreaker(guardianState.read(), limits);
|
|
439
|
+
if (breaker.tripped) {
|
|
440
|
+
if (ctx?.hasUI && !breakerEscalationOffered && !ctx.signal?.aborted) {
|
|
441
|
+
breakerEscalationOffered = true;
|
|
442
|
+
const title = `Guardian denied ${guardianState.read().consecutiveDenials} commands in a row.\nAllow the latest command anyway?\n$ ${boundedCommand(command)}`;
|
|
443
|
+
const resolution = await askUser(ctx, title, null);
|
|
444
|
+
if (resolution === "yes") {
|
|
445
|
+
guardianState.resetTurn();
|
|
446
|
+
rememberApproved(cwd, command);
|
|
447
|
+
emitGateEvent({ ...eventBase, outcome: "breaker_ask_approved", consulted: false });
|
|
448
|
+
return {};
|
|
449
|
+
}
|
|
450
|
+
if (resolution === "aborted") {
|
|
451
|
+
emitGateEvent({ ...eventBase, outcome: "aborted", consulted: false });
|
|
452
|
+
return { block: true };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
456
|
+
if (ctx?.hasUI)
|
|
457
|
+
ctx.ui.notify(breaker.reason ?? "Guardian circuit breaker tripped.", "warning");
|
|
458
|
+
return { block: true, reason: breaker.reason };
|
|
459
|
+
}
|
|
460
|
+
// Show the reviewing chip.
|
|
461
|
+
if (ctx?.hasUI)
|
|
462
|
+
ctx.ui.setStatus?.("yagni-guardian", "Guardian Reviewing");
|
|
463
|
+
const startMs = Date.now();
|
|
464
|
+
let reviewResult;
|
|
465
|
+
try {
|
|
466
|
+
reviewResult = await guardianReview(command, {
|
|
467
|
+
cwd,
|
|
468
|
+
...(ctx?.signal ? { signal: ctx.signal } : {}),
|
|
469
|
+
...(guardianTier ? { modelTier: guardianTier } : {}),
|
|
470
|
+
timeoutMs: limits.timeoutMs,
|
|
471
|
+
...(execJustification ? { execJustification } : {}),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
reviewResult = { verdict: null, error: "network", cost: 0 };
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
if (ctx?.hasUI)
|
|
479
|
+
ctx.ui.setStatus?.("yagni-guardian", undefined);
|
|
480
|
+
}
|
|
481
|
+
const durationMs = Date.now() - startMs;
|
|
482
|
+
const emitDiag = (outcome, rationale) => {
|
|
483
|
+
if (!deps.onGuardianReview)
|
|
484
|
+
return;
|
|
485
|
+
void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
|
|
486
|
+
durationMs,
|
|
487
|
+
tier: guardianTier,
|
|
488
|
+
...(rationale ? { rationale } : {}),
|
|
489
|
+
debug: isDebug(),
|
|
490
|
+
}))).catch(() => { });
|
|
491
|
+
};
|
|
492
|
+
const verdict = reviewResult.verdict;
|
|
493
|
+
if (verdict?.outcome === "allow") {
|
|
494
|
+
guardianState.recordReview("allow");
|
|
495
|
+
emitDiag("allow", verdict.rationale);
|
|
496
|
+
emitGateEvent({
|
|
497
|
+
...eventBase,
|
|
498
|
+
outcome: "allow",
|
|
499
|
+
riskLevel: verdict.riskLevel,
|
|
500
|
+
rationale: verdict.rationale,
|
|
501
|
+
durationMs,
|
|
502
|
+
consulted: true,
|
|
503
|
+
});
|
|
504
|
+
return {};
|
|
505
|
+
}
|
|
506
|
+
if (verdict?.outcome === "deny") {
|
|
507
|
+
guardianState.recordReview("deny");
|
|
508
|
+
const rationale = verdict.rationale;
|
|
509
|
+
emitDiag("deny", rationale);
|
|
510
|
+
emitGateEvent({
|
|
511
|
+
...eventBase,
|
|
512
|
+
outcome: "deny",
|
|
513
|
+
riskLevel: verdict.riskLevel,
|
|
514
|
+
rationale,
|
|
515
|
+
durationMs,
|
|
516
|
+
consulted: true,
|
|
517
|
+
});
|
|
518
|
+
// Check circuit breaker after recording.
|
|
519
|
+
const breaker2 = checkCircuitBreaker(guardianState.read(), limits);
|
|
520
|
+
if (breaker2.tripped) {
|
|
521
|
+
if (ctx?.hasUI)
|
|
522
|
+
ctx.ui.notify(breaker2.reason ?? "Guardian circuit breaker tripped.", "warning");
|
|
523
|
+
}
|
|
524
|
+
else if (ctx?.hasUI) {
|
|
525
|
+
ctx.ui.notify(`Guardian denied: ${rationale}`, "warning");
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
block: true,
|
|
529
|
+
reason: `Guardian denied: ${rationale} Do not attempt the same outcome via a workaround or indirect execution — find a materially safer alternative, or ask the user to proceed.`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
if (verdict?.outcome === "ask") {
|
|
533
|
+
guardianState.recordReview("ask");
|
|
534
|
+
emitDiag("ask", verdict.rationale);
|
|
535
|
+
if (!ctx?.hasUI) {
|
|
536
|
+
// Headless (includes every /go child stage): fail closed.
|
|
537
|
+
emitGateEvent({
|
|
538
|
+
...eventBase,
|
|
539
|
+
outcome: "ask_headless_blocked",
|
|
540
|
+
riskLevel: verdict.riskLevel,
|
|
541
|
+
rationale: verdict.rationale,
|
|
542
|
+
durationMs,
|
|
543
|
+
consulted: true,
|
|
544
|
+
});
|
|
545
|
+
return {
|
|
546
|
+
block: true,
|
|
547
|
+
reason: `Guardian needs user approval: ${verdict.rationale} No UI available — the command was held. Find a safer alternative or leave this step for the user.`,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
// Offer "don't ask again" only when the grant would actually
|
|
551
|
+
// cover this command (grant-time validation).
|
|
552
|
+
const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
|
|
553
|
+
const rememberLabel = grantCandidate
|
|
554
|
+
? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
|
|
555
|
+
: null;
|
|
556
|
+
const resolution = await askUser(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel);
|
|
557
|
+
if (resolution === "yes") {
|
|
558
|
+
rememberApproved(cwd, command);
|
|
559
|
+
emitGateEvent({
|
|
560
|
+
...eventBase,
|
|
561
|
+
outcome: "ask_approved",
|
|
562
|
+
riskLevel: verdict.riskLevel,
|
|
563
|
+
rationale: verdict.rationale,
|
|
564
|
+
durationMs,
|
|
565
|
+
consulted: true,
|
|
566
|
+
});
|
|
567
|
+
return {};
|
|
568
|
+
}
|
|
569
|
+
if (resolution === "remember" && grantCandidate) {
|
|
570
|
+
const grantRecord = {
|
|
571
|
+
...grantCandidate,
|
|
572
|
+
cwd,
|
|
573
|
+
addedAt: new Date().toISOString(),
|
|
574
|
+
};
|
|
575
|
+
grants.push(grantRecord);
|
|
576
|
+
try {
|
|
577
|
+
deps.persistGrant?.(grantRecord);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// Fail-soft: the in-memory grant still applies this session.
|
|
581
|
+
}
|
|
582
|
+
emitGateEvent({
|
|
583
|
+
...eventBase,
|
|
584
|
+
outcome: "ask_approved_remembered",
|
|
585
|
+
riskLevel: verdict.riskLevel,
|
|
586
|
+
rationale: verdict.rationale,
|
|
587
|
+
durationMs,
|
|
588
|
+
consulted: true,
|
|
589
|
+
});
|
|
590
|
+
return {};
|
|
591
|
+
}
|
|
592
|
+
if (resolution === "aborted") {
|
|
593
|
+
// The user is abandoning the turn — no steering text (do not
|
|
594
|
+
// tell an aborting model it was "denied").
|
|
595
|
+
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: true });
|
|
596
|
+
return { block: true };
|
|
597
|
+
}
|
|
598
|
+
emitGateEvent({
|
|
599
|
+
...eventBase,
|
|
600
|
+
outcome: "ask_denied",
|
|
601
|
+
riskLevel: verdict.riskLevel,
|
|
602
|
+
rationale: verdict.rationale,
|
|
603
|
+
durationMs,
|
|
604
|
+
consulted: true,
|
|
605
|
+
});
|
|
606
|
+
if (resolution === "no") {
|
|
607
|
+
return {
|
|
608
|
+
block: true,
|
|
609
|
+
reason: "The user declined this command. Ask what they would like to do differently, or take a different approach.",
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
// dismissed / dialog timeout — neutral reason, no "denied" spin.
|
|
613
|
+
return {
|
|
614
|
+
block: true,
|
|
615
|
+
reason: "The permission dialog was dismissed; the command was not run. Ask the user how to proceed.",
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
// Guardian failed (timeout/malformed/network/empty/aborted).
|
|
619
|
+
const error = reviewResult.error ?? "network";
|
|
620
|
+
emitDiag(error);
|
|
621
|
+
if (error === "aborted" || ctx?.signal?.aborted) {
|
|
622
|
+
// The user aborted mid-consult — silent block: no dialog, no
|
|
623
|
+
// "Guardian unavailable" warning on a turn they deliberately
|
|
624
|
+
// killed. (Belt and braces with reviewCommand's own aborted
|
|
625
|
+
// detection — an aborted child can die in shapes that look like
|
|
626
|
+
// other errors.)
|
|
627
|
+
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
628
|
+
return { block: true };
|
|
629
|
+
}
|
|
630
|
+
if (modeAtEntry === "review" && decision.confirm) {
|
|
631
|
+
// Review mode: fall through to the ordinary confirm below —
|
|
632
|
+
// uncapped; the mode's contract is manual approval and an outage
|
|
633
|
+
// must not lock the user out of their own confirm flow.
|
|
634
|
+
}
|
|
635
|
+
else if (ctx?.hasUI && !ctx.signal?.aborted && errorFallbackAsks < ERROR_ASK_CAP) {
|
|
636
|
+
// Auto mode with a user present: ask instead of stopping —
|
|
637
|
+
// bounded per prompt so an outage can't become an ask storm.
|
|
638
|
+
errorFallbackAsks += 1;
|
|
639
|
+
const errorMsg = guardianErrorMessage(error);
|
|
640
|
+
const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null);
|
|
641
|
+
if (resolution === "yes") {
|
|
642
|
+
rememberApproved(cwd, command);
|
|
643
|
+
emitGateEvent({ ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
|
|
644
|
+
return {};
|
|
645
|
+
}
|
|
646
|
+
if (resolution === "aborted") {
|
|
647
|
+
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
648
|
+
return { block: true };
|
|
649
|
+
}
|
|
650
|
+
emitGateEvent({ ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
|
|
651
|
+
const timeoutNote = error === "timeout" ? " The timeout is not evidence the command is unsafe." : "";
|
|
652
|
+
return {
|
|
653
|
+
block: true,
|
|
654
|
+
reason: `The user declined while the Guardian was unavailable (${errorMsg}).${timeoutNote} Find a safer alternative or ask the user.`,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
const errorMsg = guardianErrorMessage(error);
|
|
659
|
+
emitGateEvent({ ...eventBase, outcome: error, durationMs, consulted: false });
|
|
660
|
+
if (ctx?.hasUI)
|
|
661
|
+
ctx.ui.notify(`Guardian unavailable: ${errorMsg}`, "warning");
|
|
662
|
+
const timeoutNote = error === "timeout" ? " Do not assume the command is unsafe from the timeout alone; you may retry once or ask the user." : "";
|
|
663
|
+
return {
|
|
664
|
+
block: true,
|
|
665
|
+
reason: `Guardian unavailable (${errorMsg}).${timeoutNote} Switch to /mode review to approve manually.`,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
else if (modeAtEntry === "auto") {
|
|
670
|
+
// Guardian disabled (kill switch / env var) or not wired: pre-Guardian
|
|
671
|
+
// behavior (#1692). Auto mode allows prompt-band commands — the kill
|
|
672
|
+
// switch must never leave sessions stricter than before Guardian
|
|
673
|
+
// existed, and it must not replace Guardian with dialogs either. The
|
|
674
|
+
// forbidden band still blocks above (decideGate); grants and the
|
|
675
|
+
// approval cache were already consulted above.
|
|
676
|
+
return {};
|
|
677
|
+
}
|
|
678
|
+
// review mode with Guardian disabled/capped: fall through to confirm.
|
|
679
|
+
}
|
|
133
680
|
if (decision.confirm) {
|
|
134
681
|
// Review mode needs a confirmation. With no dialog-capable UI (headless),
|
|
135
682
|
// fail CLOSED: the user explicitly chose a stricter mode, so a write we
|
|
136
683
|
// cannot get consent for is held rather than silently auto-applied (this
|
|
137
684
|
// mirrors plan mode, which blocks regardless of UI).
|
|
138
685
|
if (!ctx?.hasUI) {
|
|
139
|
-
return { block: true, reason:
|
|
686
|
+
return { block: true, reason: `${event.toolName} held (no UI to confirm this action).` };
|
|
140
687
|
}
|
|
141
688
|
// Lazily bind the bless store to this session's cwd.
|
|
142
689
|
if (!blessStore)
|
|
@@ -144,14 +691,16 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
144
691
|
// Three-way prompt (pi's confirm is boolean-only, so use select): Yes,
|
|
145
692
|
// Yes-and-remember (only when a path-prefix bless is meaningful), or No.
|
|
146
693
|
const dir = blessStore.describeDir(input);
|
|
147
|
-
const blessable = dir !== null;
|
|
694
|
+
const blessable = dir !== null && !effectivePolicy.alwaysConfirmTools?.includes(event.toolName);
|
|
148
695
|
const yes = "Yes";
|
|
149
696
|
const no = "No";
|
|
150
697
|
const remember = blessable
|
|
151
698
|
? `Yes, and don't ask again for ${event.toolName} in ${dir}`
|
|
152
699
|
: undefined;
|
|
153
700
|
const options = blessable ? [yes, remember, no] : [yes, no];
|
|
154
|
-
const choice = await ctx.ui.select(
|
|
701
|
+
const choice = await ctx.ui.select(effectivePolicy.alwaysConfirmTools?.includes(event.toolName)
|
|
702
|
+
? externalTrackerPrompt(event.toolName, input)
|
|
703
|
+
: "YAGNI Code review mode", options);
|
|
155
704
|
if (choice === yes)
|
|
156
705
|
return {};
|
|
157
706
|
if (blessable && choice === remember) {
|
|
@@ -169,43 +718,40 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
169
718
|
return {};
|
|
170
719
|
}
|
|
171
720
|
catch {
|
|
172
|
-
if (
|
|
721
|
+
if (modeAtEntry !== "auto" && sideEffects.has(event.toolName)) {
|
|
173
722
|
return {
|
|
174
723
|
block: true,
|
|
175
|
-
reason: `permission gate failed while ${
|
|
724
|
+
reason: `permission gate failed while ${modeAtEntry} mode was active; held ${event.toolName}. Switch to /mode auto to apply.`,
|
|
176
725
|
};
|
|
177
726
|
}
|
|
178
727
|
return {};
|
|
179
728
|
}
|
|
180
729
|
});
|
|
181
|
-
// Model awareness:
|
|
182
|
-
//
|
|
183
|
-
// writes.
|
|
730
|
+
// Model awareness: every agent turn carries a hidden mode-context message so
|
|
731
|
+
// the model knows what it can do (auto: verify proactively, review: wait for
|
|
732
|
+
// approval, plan: hold writes). The context hook strips stale mode context
|
|
733
|
+
// from prior turns so old mode messages don't accumulate.
|
|
184
734
|
pi.on("before_agent_start", async () => {
|
|
185
|
-
|
|
186
|
-
|
|
735
|
+
// before_agent_start fires once per USER PROMPT (not per LLM turn).
|
|
736
|
+
// Reset the per-prompt bounds here: the Guardian denial streak, the
|
|
737
|
+
// error-fallback ask budget, and the breaker-escalation latch.
|
|
738
|
+
deps.guardianState?.resetTurn();
|
|
739
|
+
errorFallbackAsks = 0;
|
|
740
|
+
breakerEscalationOffered = false;
|
|
187
741
|
return {
|
|
188
|
-
message: { customType:
|
|
742
|
+
message: { customType: MODE_CONTEXT_TYPE, content: buildModeContextMessage(mode), display: false },
|
|
189
743
|
};
|
|
190
744
|
});
|
|
191
|
-
//
|
|
192
|
-
//
|
|
745
|
+
// Strip stale mode-context messages from prior turns so the model does not
|
|
746
|
+
// see contradictory mode instructions.
|
|
193
747
|
pi.on("context", async (event) => {
|
|
194
|
-
|
|
195
|
-
return;
|
|
196
|
-
const filtered = filterStalePlanContext(event.messages);
|
|
748
|
+
const filtered = filterStaleModeContext(event.messages, mode);
|
|
197
749
|
if (filtered === event.messages)
|
|
198
750
|
return;
|
|
199
751
|
return { messages: filtered };
|
|
200
752
|
});
|
|
201
753
|
const paintMode = (ctx) => {
|
|
202
|
-
|
|
203
|
-
if (ctx.hasUI)
|
|
204
|
-
ctx.ui.setStatus?.("yagni-mode", MODE_STATUS[mode]);
|
|
205
|
-
}
|
|
206
|
-
catch {
|
|
207
|
-
// The chip is chrome; never let it break /mode.
|
|
208
|
-
}
|
|
754
|
+
deps.modeHolder?.set(mode);
|
|
209
755
|
};
|
|
210
756
|
pi.registerCommand("mode", {
|
|
211
757
|
description: "Set the permission tier: /mode auto | plan | review. No argument shows the current mode.",
|
|
@@ -224,6 +770,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
224
770
|
return;
|
|
225
771
|
}
|
|
226
772
|
const leavingPlan = mode === "plan" && arg !== "plan";
|
|
773
|
+
if (arg !== mode) {
|
|
774
|
+
// A mode change is a trust-posture change: session ask-approvals do
|
|
775
|
+
// not carry across it (grants persist but are suppressed in review).
|
|
776
|
+
approvedCommands.clear();
|
|
777
|
+
}
|
|
227
778
|
mode = arg;
|
|
228
779
|
paintMode(ctx);
|
|
229
780
|
notify(`Permission mode set to ${mode}. ${MODE_COPY[mode]}`, "info");
|