@yagni-app/code-staging 1.0.5-staging.1238.1 → 1.0.5-staging.1240.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.js +5 -0
- package/dist/extension/index.js +20 -9
- package/dist/extension/mcp/auth.js +7 -3
- package/dist/extension/mcp/callbackPage.d.ts +31 -0
- package/dist/extension/mcp/callbackPage.js +222 -0
- package/dist/extension/permission/gate.js +8 -3
- package/dist/extension/permission/guardian.d.ts +24 -5
- package/dist/extension/permission/guardian.js +162 -24
- package/dist/extension/pipeline/personas.js +5 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -516,6 +516,10 @@ export const HELP_TEXT = [
|
|
|
516
516
|
" yagni connect claude-code Route Claude Code through the YAGNI model proxy",
|
|
517
517
|
" (--project scopes to this repo; --off disconnects).",
|
|
518
518
|
" yagni connect codex Route Codex CLI through the YAGNI model proxy.",
|
|
519
|
+
" yagni mcp <subcommand> Manage MCP servers: add, remove, list, get,",
|
|
520
|
+
" add-from-claude, and more — run `yagni mcp` for",
|
|
521
|
+
" details. OAuth servers authenticate via the",
|
|
522
|
+
" /mcp panel in a session.",
|
|
519
523
|
" yagni token Output the active environment's API token (for helpers).",
|
|
520
524
|
" yagni use <name> Switch the active environment (sticky).",
|
|
521
525
|
" Presets: prod, local. Others need --base-url <url>.",
|
|
@@ -537,6 +541,7 @@ export const HELP_TEXT = [
|
|
|
537
541
|
" /mode plan|review|auto Hold writes for planning, or confirm each change.",
|
|
538
542
|
" /todos Show the agent's live task list.",
|
|
539
543
|
" /cost Session usage and credit headroom.",
|
|
544
|
+
" /mcp Manage MCP servers; authenticate OAuth servers.",
|
|
540
545
|
"",
|
|
541
546
|
"The active environment is sticky; `use` switches it (prod is the default).",
|
|
542
547
|
"Set YAGNI_BASE_URL to override the base URL for a single run.",
|
package/dist/extension/index.js
CHANGED
|
@@ -386,12 +386,18 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
386
386
|
const guardianLogSink = (payload) => {
|
|
387
387
|
const event = typeof payload.event === "string" ? payload.event : "guardian_event";
|
|
388
388
|
const { event: _ignored, ...fields } = payload;
|
|
389
|
+
// `malformed` (repair failed — still really failing) and a failed
|
|
390
|
+
// telemetry POST are errors. `repaired: true` (the ladder salvaged the
|
|
391
|
+
// verdict) is a warn — it worked, but we keep counting it. Everything
|
|
392
|
+
// else is routine info.
|
|
393
|
+
const level = event === "guardian_event_post_failed" || payload.outcome === "malformed"
|
|
394
|
+
? "error"
|
|
395
|
+
: payload.repaired === true && payload.outcome !== undefined
|
|
396
|
+
? "warn"
|
|
397
|
+
: "info";
|
|
389
398
|
logEvent({
|
|
390
399
|
source: "guardian",
|
|
391
|
-
|
|
392
|
-
// whole capture exists to diagnose) — an error, not routine info. The
|
|
393
|
-
// failed-telemetry event is already an error.
|
|
394
|
-
level: event === "guardian_event_post_failed" || payload.outcome === "malformed" ? "error" : "info",
|
|
400
|
+
level,
|
|
395
401
|
event,
|
|
396
402
|
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
397
403
|
fields,
|
|
@@ -419,15 +425,20 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
419
425
|
guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
|
|
420
426
|
onGuardianReview: (ev) => {
|
|
421
427
|
guardianLogSink(ev);
|
|
422
|
-
// A malformed verdict
|
|
423
|
-
//
|
|
424
|
-
//
|
|
428
|
+
// A malformed verdict (repair FAILED — still really failing) is a real
|
|
429
|
+
// failure on our side that must reach Sentry for EVERY workspace
|
|
430
|
+
// (independent of the opt-in storage tier). A REPAIRED verdict (repair
|
|
431
|
+
// salvaged it) posts too, with outcome "malformed_repaired" so the
|
|
432
|
+
// backend can count salvage hits on their own Sentry issue. Both
|
|
433
|
+
// fire-and-forget to the dedicated telemetry endpoint; fail-soft, and
|
|
425
434
|
// suppressed under test/eval so a CI run never phones prod.
|
|
426
|
-
|
|
435
|
+
const isMalformed = ev.outcome === "malformed";
|
|
436
|
+
const isRepaired = ev.outcome !== "malformed" && ev.repaired === true;
|
|
437
|
+
if ((isMalformed || isRepaired) && !evalMode && !runningUnderTest(env)) {
|
|
427
438
|
void (async () => {
|
|
428
439
|
try {
|
|
429
440
|
const body = {
|
|
430
|
-
outcome: ev.outcome,
|
|
441
|
+
outcome: isRepaired ? "malformed_repaired" : ev.outcome,
|
|
431
442
|
...(ev.rawOutput ? { rawOutput: ev.rawOutput } : {}),
|
|
432
443
|
...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
|
|
433
444
|
...(ev.tier ? { tier: ev.tier } : {}),
|
|
@@ -25,6 +25,7 @@ import { runningUnderTest } from "../crashReport.js";
|
|
|
25
25
|
import { getStoredOAuthEntry, updateStoredOAuthEntry } from "./authStore.js";
|
|
26
26
|
import { deleteStoredOAuthEntry as clearStoredOAuthEntry } from "./authStore.js";
|
|
27
27
|
import { logMcpEvent, redactSensitiveUrlParams } from "./log.js";
|
|
28
|
+
import { renderAuthErrorPage, renderAuthStateMismatchPage, renderAuthSuccessPage, } from "./callbackPage.js";
|
|
28
29
|
/** Cancellation via an AbortSignal (Ctrl-C / Esc in the /mcp panel). */
|
|
29
30
|
export class AuthenticationCancelledError extends Error {
|
|
30
31
|
constructor(message = "OAuth cancelled") {
|
|
@@ -424,20 +425,23 @@ function waitForCode(port, expectedState, opts = {}) {
|
|
|
424
425
|
// The provider-controlled values are HTML-escaped: any local process
|
|
425
426
|
// can hit the loopback with a crafted ?error=<script> payload, and the
|
|
426
427
|
// page must never reflect it (same spot Claude Code sanitizes).
|
|
427
|
-
res.end(
|
|
428
|
+
res.end(renderAuthErrorPage({
|
|
429
|
+
error: escapeHtml(error),
|
|
430
|
+
errorDescription: errorDescription ? escapeHtml(errorDescription) : undefined,
|
|
431
|
+
}));
|
|
428
432
|
finish(undefined);
|
|
429
433
|
return;
|
|
430
434
|
}
|
|
431
435
|
if (state !== expectedState) {
|
|
432
436
|
onEvent?.("oauth_callback_state_mismatch", {});
|
|
433
437
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
434
|
-
res.end(
|
|
438
|
+
res.end(renderAuthStateMismatchPage());
|
|
435
439
|
fail(new Error("OAuth state mismatch - possible CSRF attack"));
|
|
436
440
|
return;
|
|
437
441
|
}
|
|
438
442
|
onEvent?.("oauth_callback_code", {});
|
|
439
443
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
440
|
-
res.end(
|
|
444
|
+
res.end(renderAuthSuccessPage());
|
|
441
445
|
finish(code);
|
|
442
446
|
});
|
|
443
447
|
server.on("error", () => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loopback OAuth callback page — the only surface a user ever sees served
|
|
3
|
+
* from the local `127.0.0.1:<port>/callback` listener. Rendered with the same
|
|
4
|
+
* Glass & Print language as the app: tinted blue field, floating glass card,
|
|
5
|
+
* one blue accent, three type voices (Instrument Sans interface / Newsreader
|
|
6
|
+
* authored / IBM Plex Mono fact).
|
|
7
|
+
*
|
|
8
|
+
* Self-contained: no external assets, fonts, or network calls — the page must
|
|
9
|
+
* render offline and never phone home. Tokens are inlined from
|
|
10
|
+
* packages/frontend/src/styles/tokens.css so this module can't drift a runtime
|
|
11
|
+
* dependency on the frontend build; token names are kept in comments so a
|
|
12
|
+
* re-derivation stays mechanical.
|
|
13
|
+
*
|
|
14
|
+
* `escapeHtml` is applied by the caller to every provider-controlled value
|
|
15
|
+
* before interpolation (a local process can hit the loopback with a crafted
|
|
16
|
+
* ?error=<script> payload — the page never reflects raw input).
|
|
17
|
+
*/
|
|
18
|
+
/** The success page shown after the provider redirects back with a code. */
|
|
19
|
+
export declare function renderAuthSuccessPage(): string;
|
|
20
|
+
/**
|
|
21
|
+
* The error page shown when the provider bounces the flow. `error` and
|
|
22
|
+
* `errorDescription` are provider-controlled and already HTML-escaped by the
|
|
23
|
+
* caller.
|
|
24
|
+
*/
|
|
25
|
+
export declare function renderAuthErrorPage(opts: {
|
|
26
|
+
error?: string;
|
|
27
|
+
errorDescription?: string;
|
|
28
|
+
}): string;
|
|
29
|
+
/** The state-mismatch page — a hard failure, shown before the flow aborts. */
|
|
30
|
+
export declare function renderAuthStateMismatchPage(): string;
|
|
31
|
+
//# sourceMappingURL=callbackPage.d.ts.map
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loopback OAuth callback page — the only surface a user ever sees served
|
|
3
|
+
* from the local `127.0.0.1:<port>/callback` listener. Rendered with the same
|
|
4
|
+
* Glass & Print language as the app: tinted blue field, floating glass card,
|
|
5
|
+
* one blue accent, three type voices (Instrument Sans interface / Newsreader
|
|
6
|
+
* authored / IBM Plex Mono fact).
|
|
7
|
+
*
|
|
8
|
+
* Self-contained: no external assets, fonts, or network calls — the page must
|
|
9
|
+
* render offline and never phone home. Tokens are inlined from
|
|
10
|
+
* packages/frontend/src/styles/tokens.css so this module can't drift a runtime
|
|
11
|
+
* dependency on the frontend build; token names are kept in comments so a
|
|
12
|
+
* re-derivation stays mechanical.
|
|
13
|
+
*
|
|
14
|
+
* `escapeHtml` is applied by the caller to every provider-controlled value
|
|
15
|
+
* before interpolation (a local process can hit the loopback with a crafted
|
|
16
|
+
* ?error=<script> payload — the page never reflects raw input).
|
|
17
|
+
*/
|
|
18
|
+
const CSS = `
|
|
19
|
+
:root {
|
|
20
|
+
/* tokens.css — field, ink, accent, materials */
|
|
21
|
+
--field-base: #f4f6fc;
|
|
22
|
+
--field-tint-blue: #dfe8ff;
|
|
23
|
+
--field-tint-violet: #e8e4ff;
|
|
24
|
+
--field-tint-cyan: #e3f0f6;
|
|
25
|
+
--ink: #23252e;
|
|
26
|
+
--ink-strong: #181a22;
|
|
27
|
+
--text-secondary: #565b72;
|
|
28
|
+
--text-quiet: #8b90a5;
|
|
29
|
+
--text-label: #7b8098;
|
|
30
|
+
--accent: #2f56d3;
|
|
31
|
+
--accent-muted: rgba(47, 86, 211, 0.12);
|
|
32
|
+
--accent-border: rgba(47, 86, 211, 0.3);
|
|
33
|
+
--error: #b4452f;
|
|
34
|
+
--error-muted: rgba(180, 69, 47, 0.07);
|
|
35
|
+
--glass-bg: rgba(255, 255, 255, 0.55);
|
|
36
|
+
--glass-border: rgba(255, 255, 255, 0.85);
|
|
37
|
+
--glass-blur: 18px;
|
|
38
|
+
--radius-pane: 16px;
|
|
39
|
+
--shadow-staged: 0 1px 2px rgba(24, 34, 64, 0.07), 0 18px 40px -30px rgba(38, 52, 110, 0.35);
|
|
40
|
+
--shadow-staged-high: 0 1px 2px rgba(24, 34, 64, 0.08), 0 28px 60px -28px rgba(38, 52, 110, 0.42);
|
|
41
|
+
--body: "Instrument Sans Variable", "Instrument Sans", system-ui, sans-serif;
|
|
42
|
+
--prose: "Newsreader", ui-serif, Georgia, serif;
|
|
43
|
+
--mono: "IBM Plex Mono", ui-monospace, monospace;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
* { box-sizing: border-box; }
|
|
47
|
+
|
|
48
|
+
html, body { height: 100%; }
|
|
49
|
+
|
|
50
|
+
body {
|
|
51
|
+
margin: 0;
|
|
52
|
+
min-height: 100%;
|
|
53
|
+
display: grid;
|
|
54
|
+
place-items: center;
|
|
55
|
+
padding: 24px;
|
|
56
|
+
font-family: var(--body);
|
|
57
|
+
color: var(--ink);
|
|
58
|
+
background:
|
|
59
|
+
radial-gradient(1200px 700px at 8% -5%, var(--field-tint-blue) 0, rgba(223, 232, 255, 0) 60%),
|
|
60
|
+
radial-gradient(900px 600px at 95% 0%, var(--field-tint-violet) 0, rgba(232, 228, 255, 0) 55%),
|
|
61
|
+
radial-gradient(900px 700px at 60% 100%, var(--field-tint-cyan) 0, rgba(227, 240, 246, 0) 60%),
|
|
62
|
+
var(--field-base);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.card {
|
|
66
|
+
width: 100%;
|
|
67
|
+
max-width: 400px;
|
|
68
|
+
text-align: center;
|
|
69
|
+
padding: 40px 32px 32px;
|
|
70
|
+
background: var(--glass-bg);
|
|
71
|
+
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.15);
|
|
72
|
+
backdrop-filter: blur(var(--glass-blur)) saturate(1.15);
|
|
73
|
+
border: 1px solid var(--glass-border);
|
|
74
|
+
border-radius: var(--radius-pane);
|
|
75
|
+
box-shadow: var(--shadow-staged-high);
|
|
76
|
+
animation: rise 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@keyframes rise {
|
|
80
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
81
|
+
to { opacity: 1; transform: none; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.wordmark {
|
|
85
|
+
font-family: var(--mono);
|
|
86
|
+
font-size: 10px;
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
letter-spacing: 0.22em;
|
|
89
|
+
text-transform: uppercase;
|
|
90
|
+
color: var(--text-label);
|
|
91
|
+
margin: 0 0 28px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.badge {
|
|
95
|
+
width: 52px;
|
|
96
|
+
height: 52px;
|
|
97
|
+
margin: 0 auto 20px;
|
|
98
|
+
display: grid;
|
|
99
|
+
place-items: center;
|
|
100
|
+
border-radius: 14px;
|
|
101
|
+
background: var(--accent-muted);
|
|
102
|
+
border: 1px solid var(--accent-border);
|
|
103
|
+
color: var(--accent);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.card--error .badge {
|
|
107
|
+
background: var(--error-muted);
|
|
108
|
+
border-color: rgba(180, 69, 47, 0.28);
|
|
109
|
+
color: var(--error);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
h1 {
|
|
113
|
+
font-family: var(--prose);
|
|
114
|
+
font-size: 30px;
|
|
115
|
+
font-weight: 600;
|
|
116
|
+
line-height: 1.15;
|
|
117
|
+
letter-spacing: -0.01em;
|
|
118
|
+
color: var(--ink-strong);
|
|
119
|
+
margin: 0 0 10px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.lede {
|
|
123
|
+
font-size: 14px;
|
|
124
|
+
line-height: 1.55;
|
|
125
|
+
color: var(--text-secondary);
|
|
126
|
+
margin: 0 0 24px;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.detail {
|
|
130
|
+
font-family: var(--mono);
|
|
131
|
+
font-size: 12px;
|
|
132
|
+
line-height: 1.5;
|
|
133
|
+
color: var(--error);
|
|
134
|
+
background: var(--error-muted);
|
|
135
|
+
border: 1px solid rgba(180, 69, 47, 0.2);
|
|
136
|
+
border-radius: 8px;
|
|
137
|
+
padding: 8px 10px;
|
|
138
|
+
margin: 0 0 24px;
|
|
139
|
+
word-break: break-word;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.divider {
|
|
143
|
+
height: 1px;
|
|
144
|
+
margin: 0 0 20px;
|
|
145
|
+
background: linear-gradient(90deg, transparent, var(--accent-border), transparent);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.hint {
|
|
149
|
+
font-size: 12.5px;
|
|
150
|
+
color: var(--text-quiet);
|
|
151
|
+
margin: 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@media (prefers-reduced-motion: reduce) {
|
|
155
|
+
.card { animation: none; }
|
|
156
|
+
}
|
|
157
|
+
`;
|
|
158
|
+
const CHECK_ICON = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>`;
|
|
159
|
+
const ERROR_ICON = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="13"/><line x1="12" y1="16.5" x2="12.01" y2="16.5"/></svg>`;
|
|
160
|
+
function shell(opts) {
|
|
161
|
+
return `<!DOCTYPE html>
|
|
162
|
+
<html lang="en">
|
|
163
|
+
<head>
|
|
164
|
+
<meta charset="utf-8">
|
|
165
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
166
|
+
<meta name="color-scheme" content="light">
|
|
167
|
+
<title>${opts.title} — YAGNI Code</title>
|
|
168
|
+
<style>${CSS}</style>
|
|
169
|
+
</head>
|
|
170
|
+
<body>
|
|
171
|
+
<main class="card${opts.error ? " card--error" : ""}">
|
|
172
|
+
<p class="wordmark">YAGNI Code</p>
|
|
173
|
+
<div class="badge">${opts.error ? ERROR_ICON : CHECK_ICON}</div>
|
|
174
|
+
<h1>${opts.heading}</h1>
|
|
175
|
+
<p class="lede">${opts.lede}</p>
|
|
176
|
+
${opts.detail ? `<p class="detail">${opts.detail}</p>` : ""}
|
|
177
|
+
<div class="divider"></div>
|
|
178
|
+
<p class="hint">${opts.hint}</p>
|
|
179
|
+
</main>
|
|
180
|
+
</body>
|
|
181
|
+
</html>`;
|
|
182
|
+
}
|
|
183
|
+
/** The success page shown after the provider redirects back with a code. */
|
|
184
|
+
export function renderAuthSuccessPage() {
|
|
185
|
+
return shell({
|
|
186
|
+
title: "Connected",
|
|
187
|
+
heading: "Connected to YAGNI Code",
|
|
188
|
+
lede: "Your MCP server is authorized and ready to use.",
|
|
189
|
+
hint: "You can close this window and return to your session.",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The error page shown when the provider bounces the flow. `error` and
|
|
194
|
+
* `errorDescription` are provider-controlled and already HTML-escaped by the
|
|
195
|
+
* caller.
|
|
196
|
+
*/
|
|
197
|
+
export function renderAuthErrorPage(opts) {
|
|
198
|
+
const detail = opts.error
|
|
199
|
+
? opts.errorDescription
|
|
200
|
+
? `${opts.error}: ${opts.errorDescription}`
|
|
201
|
+
: opts.error
|
|
202
|
+
: undefined;
|
|
203
|
+
return shell({
|
|
204
|
+
title: "Connection failed",
|
|
205
|
+
error: true,
|
|
206
|
+
heading: "Authentication didn't complete",
|
|
207
|
+
lede: "The provider didn't finish the sign-in flow.",
|
|
208
|
+
detail,
|
|
209
|
+
hint: "You can close this window and try again from YAGNI Code.",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/** The state-mismatch page — a hard failure, shown before the flow aborts. */
|
|
213
|
+
export function renderAuthStateMismatchPage() {
|
|
214
|
+
return shell({
|
|
215
|
+
title: "Connection failed",
|
|
216
|
+
error: true,
|
|
217
|
+
heading: "Authentication didn't complete",
|
|
218
|
+
lede: "The sign-in response didn't match the request, so it was rejected.",
|
|
219
|
+
hint: "You can close this window and try again from YAGNI Code.",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=callbackPage.js.map
|
|
@@ -577,6 +577,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
577
577
|
void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
|
|
578
578
|
durationMs,
|
|
579
579
|
tier: guardianTier,
|
|
580
|
+
...(reviewResult.repaired ? { repaired: true } : {}),
|
|
580
581
|
...(rationale ? { rationale } : {}),
|
|
581
582
|
...(rawOutput ? { rawOutput } : {}),
|
|
582
583
|
debug: isDebug(),
|
|
@@ -585,7 +586,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
585
586
|
const verdict = reviewResult.verdict;
|
|
586
587
|
if (verdict?.outcome === "allow") {
|
|
587
588
|
guardianState.recordReview("allow");
|
|
588
|
-
|
|
589
|
+
// A repaired verdict is a REAL allow: it flows through the normal
|
|
590
|
+
// path; the diag event carries `repaired` + the pre-repair shape so
|
|
591
|
+
// telemetry can count salvage hits (the rawOutput is already
|
|
592
|
+
// scrubbed + capped at the source).
|
|
593
|
+
emitDiag("allow", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
589
594
|
emitGateEvent({
|
|
590
595
|
...eventBase,
|
|
591
596
|
outcome: "allow",
|
|
@@ -599,7 +604,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
599
604
|
if (verdict?.outcome === "deny") {
|
|
600
605
|
guardianState.recordReview("deny");
|
|
601
606
|
const rationale = verdict.rationale;
|
|
602
|
-
emitDiag("deny", rationale);
|
|
607
|
+
emitDiag("deny", rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
603
608
|
emitGateEvent({
|
|
604
609
|
...eventBase,
|
|
605
610
|
outcome: "deny",
|
|
@@ -624,7 +629,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
624
629
|
}
|
|
625
630
|
if (verdict?.outcome === "ask") {
|
|
626
631
|
guardianState.recordReview("ask");
|
|
627
|
-
emitDiag("ask", verdict.rationale);
|
|
632
|
+
emitDiag("ask", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
628
633
|
if (!ctx?.hasUI) {
|
|
629
634
|
// Headless (includes every /go child stage): fail closed.
|
|
630
635
|
emitGateEvent({
|
|
@@ -99,6 +99,14 @@ export interface CircuitBreakerResult {
|
|
|
99
99
|
reason?: string;
|
|
100
100
|
}
|
|
101
101
|
export declare function checkCircuitBreaker(state: GuardianState, limits: GuardianLimits): CircuitBreakerResult;
|
|
102
|
+
export interface ParsedVerdict {
|
|
103
|
+
verdict: GuardianVerdict;
|
|
104
|
+
/** True when the strict parse failed and the lenient repair ladder salvaged it. */
|
|
105
|
+
repaired: boolean;
|
|
106
|
+
}
|
|
107
|
+
export declare function parseVerdictDetailed(raw: string): ParsedVerdict | null;
|
|
108
|
+
/** Strict-shaped convenience wrapper: the verdict, or null. Callers that need
|
|
109
|
+
* the repaired signal use {@link parseVerdictDetailed}. */
|
|
102
110
|
export declare function parseVerdict(raw: string): GuardianVerdict | null;
|
|
103
111
|
export declare function formatGuardianSubtotal(state: GuardianState, limits: GuardianLimits): string;
|
|
104
112
|
export type GuardianError = "timeout" | "malformed" | "network" | "empty" | "aborted";
|
|
@@ -106,11 +114,17 @@ export interface ReviewResult {
|
|
|
106
114
|
verdict: GuardianVerdict | null;
|
|
107
115
|
error?: GuardianError;
|
|
108
116
|
cost: number;
|
|
117
|
+
/** True when the verdict came from the lenient repair ladder (strict parse
|
|
118
|
+
* failed first). The verdict is real and flows through the normal
|
|
119
|
+
* allow/ask/deny handling; this flag only marks it for telemetry. */
|
|
120
|
+
repaired?: boolean;
|
|
109
121
|
/**
|
|
110
122
|
* Scrubbed + capped copy of the model output when the verdict failed to
|
|
111
|
-
* parse (`error: "malformed"`)
|
|
112
|
-
*
|
|
113
|
-
*
|
|
123
|
+
* parse (`error: "malformed"`), or of the pre-repair extracted block when
|
|
124
|
+
* the repair ladder salvaged it (`repaired: true`). Present so the sink can
|
|
125
|
+
* capture the exact failure shape either way. Never contains the raw
|
|
126
|
+
* command unredacted: `scrubSecrets` removes secret-shaped values before
|
|
127
|
+
* this is stored.
|
|
114
128
|
*/
|
|
115
129
|
rawOutput?: string;
|
|
116
130
|
}
|
|
@@ -152,14 +166,18 @@ export interface GuardianDiagnosticEvent {
|
|
|
152
166
|
outcome: GuardianOutcome | GuardianError;
|
|
153
167
|
durationMs?: number;
|
|
154
168
|
tier?: string;
|
|
169
|
+
/** True when the lenient repair ladder salvaged a broken verdict — the
|
|
170
|
+
* outcome is still the REAL verdict (allow/ask/deny); this flag marks it
|
|
171
|
+
* for telemetry so repair hit-rate is measurable. */
|
|
172
|
+
repaired?: true;
|
|
155
173
|
/** Debug-only: command hash for correlation (never the raw command). */
|
|
156
174
|
commandHash?: string;
|
|
157
175
|
/** Debug-only: the Guardian's rationale. */
|
|
158
176
|
rationale?: string;
|
|
159
177
|
/**
|
|
160
178
|
* Scrubbed + capped copy of the unparseable model output, present only for
|
|
161
|
-
* `outcome: "malformed"`. Always-on (NOT debug-gated):
|
|
162
|
-
* `scrubSecrets`-redacted and size-capped at the source.
|
|
179
|
+
* `outcome: "malformed"` or `repaired: true`. Always-on (NOT debug-gated):
|
|
180
|
+
* it is already `scrubSecrets`-redacted and size-capped at the source.
|
|
163
181
|
*/
|
|
164
182
|
rawOutput?: string;
|
|
165
183
|
}
|
|
@@ -170,6 +188,7 @@ export interface GuardianDiagnosticEvent {
|
|
|
170
188
|
export declare function buildDiagnosticEvent(outcome: GuardianOutcome | GuardianError, opts: {
|
|
171
189
|
durationMs?: number;
|
|
172
190
|
tier?: string;
|
|
191
|
+
repaired?: boolean;
|
|
173
192
|
rationale?: string;
|
|
174
193
|
commandHash?: string;
|
|
175
194
|
rawOutput?: string;
|
|
@@ -128,33 +128,158 @@ export function checkCircuitBreaker(state, limits) {
|
|
|
128
128
|
}
|
|
129
129
|
return { tripped: false };
|
|
130
130
|
}
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
const VALID_RISK_LEVELS = ["low", "medium", "high", "critical"];
|
|
132
|
+
/** Validate an already-parsed object into a verdict. The SINGLE validation
|
|
133
|
+
* gate: strict and repaired parses both end here, so the enum checks stay
|
|
134
|
+
* exactly as strict after repair as before. */
|
|
135
|
+
function validateVerdictObject(parsed) {
|
|
136
|
+
if (!parsed)
|
|
137
|
+
return null;
|
|
138
|
+
const outcome = parsed.outcome;
|
|
139
|
+
if (outcome !== "allow" && outcome !== "ask" && outcome !== "deny")
|
|
140
|
+
return null;
|
|
141
|
+
const riskLevel = parsed.riskLevel;
|
|
142
|
+
return {
|
|
143
|
+
outcome,
|
|
144
|
+
riskLevel: typeof riskLevel === "string" && VALID_RISK_LEVELS.includes(riskLevel)
|
|
145
|
+
? riskLevel
|
|
146
|
+
: "medium",
|
|
147
|
+
rationale: typeof parsed.rationale === "string" && parsed.rationale.trim().length > 0
|
|
148
|
+
? parsed.rationale.trim()
|
|
149
|
+
: "No rationale provided.",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Extract the first {...} block (efficient-tier models may wrap JSON in
|
|
153
|
+
* markdown fences despite instructions to output raw JSON). */
|
|
154
|
+
function extractJsonBlock(raw) {
|
|
155
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
156
|
+
return jsonMatch ? jsonMatch[0] : raw;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The lenient repair ladder — applied ONLY after a strict `JSON.parse` of the
|
|
160
|
+
* extracted block has already thrown. Each rung fixes one observed model
|
|
161
|
+
* failure shape and immediately retries the strict parse; the ladder runs in
|
|
162
|
+
* cheapest-first order and the content-synthesizing re-quote is always LAST.
|
|
163
|
+
* Every rung is end-anchored or last-occurrence-anchored and linear-time.
|
|
164
|
+
* Returns the repaired JSON string (parsable) or null if no rung fits.
|
|
165
|
+
*
|
|
166
|
+
* Observed shapes (captured live on deepseek-v4-flash, all previously "unclear
|
|
167
|
+
* verdict"): illegal backslash escapes (\d written literally), a doubled
|
|
168
|
+
* closing quote, and unescaped quotes inside the rationale. The trailing
|
|
169
|
+
* comma is the most common LLM JSON slip in the wild, not (yet) observed here.
|
|
170
|
+
*
|
|
171
|
+
* What the ladder deliberately does NOT fix (all fail closed):
|
|
172
|
+
* single quotes as structure (ambiguous with English apostrophes), glued
|
|
173
|
+
* multi-object output (never pick one verdict of two), raw newlines inside
|
|
174
|
+
* strings (indistinguishable from legal pretty-printed whitespace without
|
|
175
|
+
* parsing), a broken prefix before the rationale anchor, and truncations.
|
|
176
|
+
*/
|
|
177
|
+
function repairVerdictJson(s) {
|
|
178
|
+
// The ladder is SEQUENTIAL: each rung transforms the previous result and
|
|
179
|
+
// immediately retries the strict parse. Composing matters — a blob can
|
|
180
|
+
// carry more than one failure shape (an illegal backslash escape AND a
|
|
181
|
+
// trailing comma, say), and each rung alone would leave the other broken.
|
|
182
|
+
let cur = s;
|
|
183
|
+
// Rung 1 — illegal backslash escapes (\d, \w, \( … become \\d). Legal JSON
|
|
184
|
+
// escapes (" \\ \/ b f n r t and \uXXXX) are left untouched by the lookahead.
|
|
185
|
+
const escapedBackslashes = cur.replace(/\\(?!["\\\/bfnrtu])/g, "\\\\");
|
|
186
|
+
if (escapedBackslashes !== cur) {
|
|
187
|
+
try {
|
|
188
|
+
JSON.parse(escapedBackslashes);
|
|
189
|
+
return escapedBackslashes;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
cur = escapedBackslashes;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Rung 2 — doubled closing quote before }: anchored to END so a doubled
|
|
196
|
+
// quote mid-rationale is untouched (the re-quote rung handles that shape).
|
|
197
|
+
const collapsedQuote = cur.replace(/""(\s*\})$/, '"$1');
|
|
198
|
+
if (collapsedQuote !== cur) {
|
|
199
|
+
try {
|
|
200
|
+
JSON.parse(collapsedQuote);
|
|
201
|
+
return collapsedQuote;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
cur = collapsedQuote;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Rung 3 — trailing comma before }: anchored to END so a comma inside a
|
|
208
|
+
// rationale value ("a, b") can never be stripped.
|
|
209
|
+
const strippedComma = cur.replace(/,(\s*\})$/, '$1');
|
|
210
|
+
if (strippedComma !== cur) {
|
|
211
|
+
try {
|
|
212
|
+
JSON.parse(strippedComma);
|
|
213
|
+
return strippedComma;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
cur = strippedComma;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Rung 4 (last resort, the only rung that synthesizes content) — re-quote
|
|
220
|
+
// the trailing rationale value. Applies only when the shape is
|
|
221
|
+
// {…"rationale":"<rest-to-end>}: the prefix before the anchor must carry no
|
|
222
|
+
// closing brace (rules out prose and glued multi-object output — an earlier
|
|
223
|
+
// sibling object always leaves a } behind), and the extracted rationale text
|
|
224
|
+
// must carry no braces either (rules out a glued TAIL). Every " and \ in the
|
|
225
|
+
// tail is escaped in ONE pass so whatever the model wrote inside the
|
|
226
|
+
// rationale becomes literal text. Anchor ambiguity resolves to the LAST
|
|
227
|
+
// "rationale":" occurrence; single pass, no loops.
|
|
228
|
+
const anchor = cur.lastIndexOf('"rationale":"');
|
|
229
|
+
if (anchor !== -1) {
|
|
230
|
+
const prefix = cur.slice(0, anchor);
|
|
231
|
+
const tail = cur.slice(anchor + '"rationale":"'.length);
|
|
232
|
+
// The tail must be the trailing value closing the object: non-empty
|
|
233
|
+
// content, an optional-whitespace + } at the very end.
|
|
234
|
+
const tailMatch = tail.match(/^(.+?)(\s*\})$/);
|
|
235
|
+
if (tailMatch && !prefix.includes("}")) {
|
|
236
|
+
const rawRationale = tailMatch[1].replace(/"+$/, "");
|
|
237
|
+
if (rawRationale.length > 0 &&
|
|
238
|
+
!rawRationale.includes("{") &&
|
|
239
|
+
!rawRationale.includes("}")) {
|
|
240
|
+
const reQuoted = prefix +
|
|
241
|
+
'"rationale":"' +
|
|
242
|
+
rawRationale.replace(/["\\]/g, "\\$&") +
|
|
243
|
+
'"}';
|
|
244
|
+
try {
|
|
245
|
+
JSON.parse(reQuoted);
|
|
246
|
+
return reQuoted;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
/* fail closed */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
export function parseVerdictDetailed(raw) {
|
|
257
|
+
const jsonStr = extractJsonBlock(raw);
|
|
133
258
|
try {
|
|
134
|
-
// Efficient-tier models may wrap JSON in markdown fences despite
|
|
135
|
-
// instructions to output raw JSON. Extract the first {...} block.
|
|
136
|
-
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
137
|
-
const jsonStr = jsonMatch ? jsonMatch[0] : raw;
|
|
138
259
|
const parsed = JSON.parse(jsonStr);
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
return null;
|
|
142
|
-
const riskLevel = parsed.riskLevel;
|
|
143
|
-
const validLevels = ["low", "medium", "high", "critical"];
|
|
144
|
-
return {
|
|
145
|
-
outcome,
|
|
146
|
-
riskLevel: typeof riskLevel === "string" && validLevels.includes(riskLevel)
|
|
147
|
-
? riskLevel
|
|
148
|
-
: "medium",
|
|
149
|
-
rationale: typeof parsed.rationale === "string" && parsed.rationale.trim().length > 0
|
|
150
|
-
? parsed.rationale.trim()
|
|
151
|
-
: "No rationale provided.",
|
|
152
|
-
};
|
|
260
|
+
const verdict = validateVerdictObject(parsed);
|
|
261
|
+
return verdict ? { verdict, repaired: false } : null;
|
|
153
262
|
}
|
|
154
263
|
catch {
|
|
155
|
-
|
|
264
|
+
// Strict parse failed — try the repair ladder on the same extracted block.
|
|
265
|
+
const repairedJson = repairVerdictJson(jsonStr);
|
|
266
|
+
if (repairedJson === null)
|
|
267
|
+
return null;
|
|
268
|
+
try {
|
|
269
|
+
const parsed = JSON.parse(repairedJson);
|
|
270
|
+
const verdict = validateVerdictObject(parsed);
|
|
271
|
+
return verdict ? { verdict, repaired: true } : null;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
156
276
|
}
|
|
157
277
|
}
|
|
278
|
+
/** Strict-shaped convenience wrapper: the verdict, or null. Callers that need
|
|
279
|
+
* the repaired signal use {@link parseVerdictDetailed}. */
|
|
280
|
+
export function parseVerdict(raw) {
|
|
281
|
+
return parseVerdictDetailed(raw)?.verdict ?? null;
|
|
282
|
+
}
|
|
158
283
|
// --- /cost subtotal ---
|
|
159
284
|
export function formatGuardianSubtotal(state, limits) {
|
|
160
285
|
if (state.reviews === 0)
|
|
@@ -236,8 +361,8 @@ export async function reviewCommand(command, deps) {
|
|
|
236
361
|
}
|
|
237
362
|
return { verdict: null, error: "empty", cost };
|
|
238
363
|
}
|
|
239
|
-
const
|
|
240
|
-
if (!
|
|
364
|
+
const parsed = parseVerdictDetailed(output);
|
|
365
|
+
if (!parsed) {
|
|
241
366
|
// Scrubbed + capped so the local sink and Sentry can see the exact
|
|
242
367
|
// failure shape without carrying a raw command or a secret it echoed.
|
|
243
368
|
return {
|
|
@@ -247,7 +372,19 @@ export async function reviewCommand(command, deps) {
|
|
|
247
372
|
rawOutput: scrubSecrets(output).slice(0, GUARDIAN_RAW_OUTPUT_CAP),
|
|
248
373
|
};
|
|
249
374
|
}
|
|
250
|
-
|
|
375
|
+
if (parsed.repaired) {
|
|
376
|
+
// The repair ladder salvaged a broken-but-salvageable verdict. The
|
|
377
|
+
// verdict is real (validation is exactly as strict as the happy path);
|
|
378
|
+
// capture the pre-repair shape so telemetry can keep watching what the
|
|
379
|
+
// model is still emitting wrong.
|
|
380
|
+
return {
|
|
381
|
+
verdict: parsed.verdict,
|
|
382
|
+
cost,
|
|
383
|
+
repaired: true,
|
|
384
|
+
rawOutput: scrubSecrets(output).slice(0, GUARDIAN_RAW_OUTPUT_CAP),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { verdict: parsed.verdict, cost };
|
|
251
388
|
}
|
|
252
389
|
catch (err) {
|
|
253
390
|
// Distinguish the caller aborting (user hit ESC — must NOT be treated as
|
|
@@ -275,6 +412,7 @@ export function buildDiagnosticEvent(outcome, opts) {
|
|
|
275
412
|
outcome,
|
|
276
413
|
...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
|
|
277
414
|
...(opts.tier !== undefined ? { tier: opts.tier } : {}),
|
|
415
|
+
...(opts.repaired ? { repaired: true } : {}),
|
|
278
416
|
...(opts.rawOutput !== undefined ? { rawOutput: opts.rawOutput } : {}),
|
|
279
417
|
};
|
|
280
418
|
if (opts.debug) {
|
|
@@ -183,6 +183,11 @@ Rationale rules:
|
|
|
183
183
|
Output ONLY a JSON object with this exact shape:
|
|
184
184
|
{"outcome":"allow"|"ask"|"deny","riskLevel":"low"|"medium"|"high"|"critical","rationale":"see rationale rules"}
|
|
185
185
|
|
|
186
|
+
Your output must parse as strict JSON on the first try:
|
|
187
|
+
- Inside the rationale string, never write a bare double quote. Quote commands, flags, and paths with backticks instead: \`cat ~/.zshrc\`, not "cat ~/.zshrc".
|
|
188
|
+
- A backslash inside a JSON string is legal only as the two-character JSON escapes \\, \\n, \\t, or \\uXXXX, and a quote inside the rationale as \\". Never write regex-style escapes like \\d or \\w literally — write "digit" or "word character" in words instead.
|
|
189
|
+
- Output exactly one JSON object: no trailing comma, no doubled closing quote, no second object, nothing after the closing brace.
|
|
190
|
+
|
|
186
191
|
Do not output anything else after the JSON. No markdown fences, only the JSON object.`;
|
|
187
192
|
const TITLE_BODY = `You produce a session title from a user's prompt. Output ONLY a concise, sentence-case title of 3-7 words that captures the main topic or goal. Capitalize only the first word and proper nouns. Do not include a ticket code in the title text itself (the caller prepends it). No markdown, no prose, no quotes — just the title on one line.
|
|
188
193
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.0.5-staging.
|
|
3
|
+
"version": "1.0.5-staging.1240.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)",
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
"turndown": "^7.2.4",
|
|
43
43
|
"typebox": "^1.3.15"
|
|
44
44
|
},
|
|
45
|
-
"yagniSourceSha": "
|
|
45
|
+
"yagniSourceSha": "24a0ebcde7f7df67fd0fcd230caa85c0c897742b"
|
|
46
46
|
}
|