@yagni-app/code-staging 1.1.6-staging.1445.1 → 1.1.6-staging.1449.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extension/index.js +105 -21
- package/dist/extension/mcp/auth.d.ts +6 -0
- package/dist/extension/mcp/auth.js +40 -33
- package/dist/extension/mcp/authStore.d.ts +18 -2
- package/dist/extension/mcp/openUrl.d.ts +43 -0
- package/dist/extension/mcp/openUrl.js +80 -0
- package/dist/extension/mcp/panel.js +7 -1
- package/dist/extension/permission/guardian.d.ts +47 -11
- package/dist/extension/permission/guardian.js +229 -49
- package/dist/extension/pipeline/directConsult.js +6 -4
- package/dist/extension/sandbox/bash.d.ts +16 -2
- package/dist/extension/sandbox/bash.js +60 -0
- package/dist/extension/sandbox/session.js +63 -7
- package/dist/mcpCommand.d.ts +3 -0
- package/dist/mcpCommand.js +5 -1
- package/package.json +2 -2
package/dist/extension/index.js
CHANGED
|
@@ -773,31 +773,70 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
773
773
|
// the command — its later escape attempts go to the consent dialog (the
|
|
774
774
|
// sandbox genuinely cannot run it). Content stays local: only the trimmed
|
|
775
775
|
// command string is held in memory, never logged.
|
|
776
|
-
const sandboxedFailures = new Map();
|
|
777
776
|
const SANDBOXED_FAILURES_MAX = 50;
|
|
777
|
+
/** Bounded LRU map keyed by the CANONICAL command (sandboxFailureKey): a
|
|
778
|
+
* retry differing by quote rendering or safe decoration must hit the same
|
|
779
|
+
* entry — raw-string keys would miss exactly the variable shapes the
|
|
780
|
+
* grants layer canonicalizes for. Shared by both brake maps (the boolean
|
|
781
|
+
* seed set and the escalation counter) so the refresh + eviction policy
|
|
782
|
+
* lives in ONE place — the two copies drifted once already (the counter
|
|
783
|
+
* missed the delete-then-set refresh and evicted a hot key at the cap).
|
|
784
|
+
* upsert performs the delete-then-set refresh (a re-touched key moves to
|
|
785
|
+
* the END of insertion order, so the evicted key is always the
|
|
786
|
+
* least-recently-touched one) and evicts at the cap. A null key (an
|
|
787
|
+
* uncanonicalizable command) is a no-op returning undefined. */
|
|
788
|
+
const makeSandboxFailureMap = (nextValue) => {
|
|
789
|
+
const map = new Map();
|
|
790
|
+
return {
|
|
791
|
+
has: (key) => map.has(key),
|
|
792
|
+
get: (key) => map.get(key),
|
|
793
|
+
/** Canonical-keyed upsert: refresh LRU order, compute the value from
|
|
794
|
+
* the existing one, evict at the cap. */
|
|
795
|
+
upsert: (command) => {
|
|
796
|
+
const key = sandboxFailureKey(command);
|
|
797
|
+
if (!key)
|
|
798
|
+
return undefined;
|
|
799
|
+
const value = nextValue(map.get(key), key);
|
|
800
|
+
map.delete(key);
|
|
801
|
+
map.set(key, value);
|
|
802
|
+
if (map.size > SANDBOXED_FAILURES_MAX) {
|
|
803
|
+
const oldest = map.keys().next().value;
|
|
804
|
+
if (oldest !== undefined)
|
|
805
|
+
map.delete(oldest);
|
|
806
|
+
}
|
|
807
|
+
return { key, value };
|
|
808
|
+
},
|
|
809
|
+
clear: () => { map.clear(); },
|
|
810
|
+
};
|
|
811
|
+
};
|
|
812
|
+
const sandboxedFailures = makeSandboxFailureMap(() => true);
|
|
778
813
|
const recordSandboxedFailure = (command) => {
|
|
779
|
-
|
|
780
|
-
// rendering or safe decoration must hit the same entry — raw-string
|
|
781
|
-
// keys would miss exactly the variable shapes the grants layer
|
|
782
|
-
// canonicalizes for.
|
|
783
|
-
const key = sandboxFailureKey(command);
|
|
784
|
-
if (!key)
|
|
785
|
-
return;
|
|
786
|
-
sandboxedFailures.delete(key);
|
|
787
|
-
sandboxedFailures.set(key, true);
|
|
788
|
-
if (sandboxedFailures.size > SANDBOXED_FAILURES_MAX) {
|
|
789
|
-
const oldest = sandboxedFailures.keys().next().value;
|
|
790
|
-
if (oldest !== undefined)
|
|
791
|
-
sandboxedFailures.delete(oldest);
|
|
792
|
-
}
|
|
814
|
+
sandboxedFailures.upsert(command);
|
|
793
815
|
};
|
|
794
816
|
const hasSandboxedFailure = (command) => sandboxedFailures.has(sandboxFailureKey(command));
|
|
817
|
+
// Unrecognized-failure escalation (the generic backstop behind the
|
|
818
|
+
// signature vocabulary): a sandboxed failure whose output matches NO
|
|
819
|
+
// known signature counts per canonical key; the SECOND occurrence seeds
|
|
820
|
+
// the brake. Arithmetic lands on the rung's documented contract — first
|
|
821
|
+
// failure (count 1, no seed), the escape's one silent sandboxed retry
|
|
822
|
+
// fails (count 2, seed), every further escape dialogs — for every
|
|
823
|
+
// failure shape, not just the enumerated ones. Worst case is a command
|
|
824
|
+
// failing twice for non-sandbox reasons: one dialog the user declines
|
|
825
|
+
// (the code's documented fail-safe direction). Recognized signatures
|
|
826
|
+
// still seed at 1 — the escalation never weakens the existing valve.
|
|
827
|
+
const unrecognizedSandboxFailures = makeSandboxFailureMap((existing) => (existing ?? 0) + 1);
|
|
828
|
+
const countSandboxedFailure = (command) => {
|
|
829
|
+
// The shared map's upsert refreshes LRU order, increments the count,
|
|
830
|
+
// and evicts at the cap — the same policy the seed set uses.
|
|
831
|
+
return unrecognizedSandboxFailures.upsert(command)?.value ?? 0;
|
|
832
|
+
};
|
|
795
833
|
// A mode transition is a trust-posture change — the auto-sandbox rung's
|
|
796
834
|
// prior-failure evidence clears with the gate's other session caches
|
|
797
835
|
// (the gate's own fallback map clears via modeHolder.onSet; this map is
|
|
798
836
|
// the production one, so it must clear here too).
|
|
799
837
|
modeHolder.onSet(() => {
|
|
800
838
|
sandboxedFailures.clear();
|
|
839
|
+
unrecognizedSandboxFailures.clear();
|
|
801
840
|
});
|
|
802
841
|
// settings-based permission rules, loaded once at startup (same
|
|
803
842
|
// trust-boundary posture as grants: no mid-session reload). User config
|
|
@@ -957,7 +996,29 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
957
996
|
}),
|
|
958
997
|
prefixConsultTransportAttribution: () => attributionHeaders(env),
|
|
959
998
|
onPrefixConsult: makePrefixErrorSink(),
|
|
960
|
-
|
|
999
|
+
// The Guardian consult rides the same in-process direct transport as
|
|
1000
|
+
// the prefix consult: one /v1/chat/completions POST per review,
|
|
1001
|
+
// no child spawn, no grounded context. Kill switches compose: the
|
|
1002
|
+
// workspace/env kill switch still disables the consult entirely (checked
|
|
1003
|
+
// in the gate), and YAGNI_GUARDIAN_LEGACY_TRANSPORT forces the proven
|
|
1004
|
+
// child-spawn path as a targeted transport rollback (reviewCommand
|
|
1005
|
+
// itself reads the knob — the wiring here never needs to know). Transport
|
|
1006
|
+
// absence otherwise falls back to the legacy child path, which never
|
|
1007
|
+
// happens in production (baseUrl/getTokenFn are session-lifetime
|
|
1008
|
+
// values) — the fallback is the injectable test seam.
|
|
1009
|
+
guardianReview: (command, deps) => reviewCommand(command, {
|
|
1010
|
+
...deps,
|
|
1011
|
+
modelTier: guardianTier,
|
|
1012
|
+
...(guardianDisabled
|
|
1013
|
+
? {}
|
|
1014
|
+
: {
|
|
1015
|
+
transport: {
|
|
1016
|
+
baseUrl,
|
|
1017
|
+
getToken: getTokenFn,
|
|
1018
|
+
attribution: () => attributionHeaders(env),
|
|
1019
|
+
},
|
|
1020
|
+
}),
|
|
1021
|
+
}),
|
|
961
1022
|
onGuardianReview: (ev) => {
|
|
962
1023
|
guardianLogSink(ev);
|
|
963
1024
|
// A malformed verdict (repair FAILED — still really failing) is a real
|
|
@@ -1887,18 +1948,41 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
1887
1948
|
.join("\n");
|
|
1888
1949
|
if (isSandboxDenialOutput(text)) {
|
|
1889
1950
|
recordSandboxedFailure(command);
|
|
1890
|
-
// The seed must be observable (
|
|
1891
|
-
//
|
|
1892
|
-
//
|
|
1893
|
-
//
|
|
1951
|
+
// The seed must be observable (info tier — readSessionTrail filters
|
|
1952
|
+
// debug, and an invisible seed made the incident session
|
|
1953
|
+
// undebuggable; no command content, the closed signature class
|
|
1954
|
+
// rides the field): a false-positive seed session-long disables
|
|
1955
|
+
// auto-sandbox for a command, and without this line the only
|
|
1956
|
+
// symptom would be unexplained dialogs.
|
|
1894
1957
|
const signature = sandboxDenialSignature(text);
|
|
1895
1958
|
logEvent({
|
|
1896
1959
|
source: "sandbox",
|
|
1897
|
-
level: "
|
|
1960
|
+
level: "info",
|
|
1898
1961
|
event: "sandbox_failure_seeded",
|
|
1899
1962
|
fields: { signature },
|
|
1900
1963
|
});
|
|
1901
1964
|
}
|
|
1965
|
+
else {
|
|
1966
|
+
// Unrecognized-failure escalation: no known signature — count it;
|
|
1967
|
+
// the second unrecognized failure of the same canonical command
|
|
1968
|
+
// seeds the brake (the rung then dialogs instead of auto-sandboxing
|
|
1969
|
+
// into the same failure forever). Seed fires only on the 1→2
|
|
1970
|
+
// transition — a later third failure never re-seeds.
|
|
1971
|
+
const count = countSandboxedFailure(command);
|
|
1972
|
+
if (count === 2) {
|
|
1973
|
+
recordSandboxedFailure(command);
|
|
1974
|
+
logEvent({
|
|
1975
|
+
source: "sandbox",
|
|
1976
|
+
level: "info",
|
|
1977
|
+
event: "sandbox_failure_seeded",
|
|
1978
|
+
// count rides the field so the trail distinguishes the
|
|
1979
|
+
// escalation's seed (always 2) from a recognized seed (1) —
|
|
1980
|
+
// closed vocabulary only; no failure text (content stays out of
|
|
1981
|
+
// the always-on trail, the recognized branch's contract).
|
|
1982
|
+
fields: { signature: "unrecognized", count },
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1902
1986
|
}
|
|
1903
1987
|
});
|
|
1904
1988
|
// Seed the unified error trail from tool-exec failures. A tool's SUCCESS
|
|
@@ -26,6 +26,12 @@ import { type StoredOAuthEntry } from "./authStore.js";
|
|
|
26
26
|
export interface AuthDeps {
|
|
27
27
|
/** Open a URL in the browser; injected for tests, default is a best-effort opener. */
|
|
28
28
|
openUrl?: (url: string) => Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Called with the authorization URL just before the browser is opened, so
|
|
31
|
+
* the caller can show it: the manual fallback when the opener fails or
|
|
32
|
+
* mangles the link.
|
|
33
|
+
*/
|
|
34
|
+
onAuthorizationUrl?: (url: string) => void;
|
|
29
35
|
/** Traditional fetch for revocation (SDK auth uses its own fetch). */
|
|
30
36
|
fetch?: typeof fetch;
|
|
31
37
|
}
|
|
@@ -22,10 +22,10 @@ import { createServer, get as httpGet } from "node:http";
|
|
|
22
22
|
import { randomBytes } from "node:crypto";
|
|
23
23
|
import { auth as sdkAuth, discoverOAuthServerInfo } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
24
24
|
import { expandServerEnv } from "./config.js";
|
|
25
|
-
import { runningUnderTest } from "../crashReport.js";
|
|
26
25
|
import { getStoredOAuthEntry, updateStoredOAuthEntry } from "./authStore.js";
|
|
27
26
|
import { deleteStoredOAuthEntry as clearStoredOAuthEntry } from "./authStore.js";
|
|
28
27
|
import { logMcpEvent, redactSensitiveUrlParams } from "./log.js";
|
|
28
|
+
import { defaultOpenUrl } from "./openUrl.js";
|
|
29
29
|
import { renderAuthErrorPage, renderAuthStateMismatchPage, renderAuthSuccessPage, } from "./callbackPage.js";
|
|
30
30
|
/** Cancellation via an AbortSignal (Ctrl-C / Esc in the /mcp panel). */
|
|
31
31
|
export class AuthenticationCancelledError extends Error {
|
|
@@ -207,8 +207,15 @@ export class YagniAuthProvider {
|
|
|
207
207
|
async saveClientInformation(info) {
|
|
208
208
|
updateStoredOAuthEntry(this.serverName, this.config, (entry) => {
|
|
209
209
|
entry.clientId = info.client_id;
|
|
210
|
-
if (info.client_secret != null)
|
|
210
|
+
if (info.client_secret != null) {
|
|
211
211
|
entry.clientSecret = info.client_secret;
|
|
212
|
+
entry.clientSecretSource = "dcr";
|
|
213
|
+
}
|
|
214
|
+
else if (entry.clientSecretSource === "dcr") {
|
|
215
|
+
// A previous registration's secret must not outlive its client.
|
|
216
|
+
delete entry.clientSecret;
|
|
217
|
+
delete entry.clientSecretSource;
|
|
218
|
+
}
|
|
212
219
|
// The SDK only calls this from dynamic client registration, so the
|
|
213
220
|
// redirect this provider advertised is exactly what the server pinned.
|
|
214
221
|
entry.redirectUri = this.redirectUri;
|
|
@@ -325,7 +332,15 @@ export async function authenticate(serverName, config, deps = {}, opts = {}) {
|
|
|
325
332
|
// that errors before redirecting cannot wedge the command loop.
|
|
326
333
|
const state = await provider.state();
|
|
327
334
|
const codePromise = waitForCode(listener, state, { ...bounds, onEvent: (evt, fields) => logMcpEvent(serverName, evt, { ...fields, redirectUri }) });
|
|
328
|
-
|
|
335
|
+
deps.onAuthorizationUrl?.(authorizationUrl);
|
|
336
|
+
try {
|
|
337
|
+
await openUrl(authorizationUrl);
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
// Any opener, injected or default: the flow keeps waiting, because the
|
|
341
|
+
// user has the printed URL. The log records why the window is missing.
|
|
342
|
+
logMcpEvent(serverName, "oauth_browser_open_failed", { detail: errorText(err) });
|
|
343
|
+
}
|
|
329
344
|
const code = await loggedPhase(serverName, "callback", { redirectUri }, () => codePromise);
|
|
330
345
|
if (!code) {
|
|
331
346
|
logMcpEvent(serverName, "oauth_result", { phase: "callback", outcome: "provider_denied", errorDescription: "no authorization code received", redirectUri });
|
|
@@ -397,26 +412,39 @@ async function trySilentRefresh(serverName, config, stored, serverUrl, bounds) {
|
|
|
397
412
|
* when the listener we hold is on a different port (the pinned one was
|
|
398
413
|
* unavailable, or `oauth.callbackPort` changed) the client is dropped so the
|
|
399
414
|
* SDK re-registers for the port we will actually answer on. Ports, not
|
|
400
|
-
* strings, are what the registration pins. A
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
415
|
+
* strings, are what the registration pins. A stored client with NO redirect
|
|
416
|
+
* on record was issued by a CLI from before pinning: it is bound to a port we
|
|
417
|
+
* cannot know, so it is dropped too (`legacy_unpinned`). The auth store only
|
|
418
|
+
* ever holds DCR-issued ids; a pre-registered `oauth.clientId` lives in the
|
|
419
|
+
* config and is never touched. A drop that cannot be persisted fails the
|
|
420
|
+
* flow: the alternative is sending a redirect the stored client is not
|
|
421
|
+
* registered for, which strict servers refuse.
|
|
404
422
|
*/
|
|
405
423
|
function dropPinnedClientIfPortChanged(serverName, config, stored, listener, redirectUri) {
|
|
406
|
-
if (!stored?.clientId
|
|
424
|
+
if (!stored?.clientId)
|
|
407
425
|
return;
|
|
408
|
-
|
|
426
|
+
const legacy = !stored.redirectUri;
|
|
427
|
+
if (!legacy && portOfRedirectUri(stored.redirectUri) === listener.port)
|
|
409
428
|
return;
|
|
410
|
-
const reason = config.oauth?.callbackPort !== undefined ? "callback_port_changed" : "pinned_port_unavailable";
|
|
429
|
+
const reason = legacy ? "legacy_unpinned" : config.oauth?.callbackPort !== undefined ? "callback_port_changed" : "pinned_port_unavailable";
|
|
411
430
|
const { errors } = updateStoredOAuthEntry(serverName, config, (entry) => {
|
|
412
431
|
delete entry.clientId;
|
|
413
|
-
delete entry.clientSecret;
|
|
414
432
|
delete entry.redirectUri;
|
|
433
|
+
// The secret goes with the DCR client unless it is the operator's
|
|
434
|
+
// (`--client-secret`), which belongs to the config's pre-registered
|
|
435
|
+
// client and lives nowhere else. Entries from before provenance was
|
|
436
|
+
// recorded are read the way the old CLI wrote them: an operator's secret
|
|
437
|
+
// only ever appeared next to a config `oauth.clientId`.
|
|
438
|
+
const operators = entry.clientSecretSource === "operator" || (entry.clientSecretSource === undefined && Boolean(config.oauth?.clientId));
|
|
439
|
+
if (!operators) {
|
|
440
|
+
delete entry.clientSecret;
|
|
441
|
+
delete entry.clientSecretSource;
|
|
442
|
+
}
|
|
415
443
|
});
|
|
416
444
|
if (errors.length > 0) {
|
|
417
445
|
closeQuietly(listener.server);
|
|
418
446
|
logMcpEvent(serverName, "oauth_client_reregister_failed", { reason, previousRedirectUri: stored.redirectUri, redirectUri, error: errors[0] });
|
|
419
|
-
throw new Error(`could not reset the OAuth client registration for ${serverName} (${errors[0]}); its client is pinned to ${stored.redirectUri}`);
|
|
447
|
+
throw new Error(`could not reset the OAuth client registration for ${serverName} (${errors[0]}); its client is pinned to ${stored.redirectUri ?? "a redirect this CLI has no record of"}`);
|
|
420
448
|
}
|
|
421
449
|
logMcpEvent(serverName, "oauth_client_reregister", {
|
|
422
450
|
reason,
|
|
@@ -684,27 +712,6 @@ export async function revokeServerTokens(serverName, config, metadata, clientInf
|
|
|
684
712
|
await revokeOne(entry.refreshToken, "refresh_token");
|
|
685
713
|
await revokeOne(entry.accessToken, "access_token");
|
|
686
714
|
}
|
|
687
|
-
/** Best-effort browser open without a shell (injected in tests).
|
|
688
|
-
*
|
|
689
|
-
* A no-op under the test runner (crashReport.ts's runningUnderTest): a test
|
|
690
|
-
* that forgets to inject `openUrl` must not pop a real browser window on the
|
|
691
|
-
* developer's machine. Same philosophy as crash-report suppression — make the
|
|
692
|
-
* whole class of leak impossible rather than fixing one call site at a time.
|
|
693
|
-
*/
|
|
694
|
-
async function defaultOpenUrl(url) {
|
|
695
|
-
if (runningUnderTest())
|
|
696
|
-
return;
|
|
697
|
-
const { spawn } = await import("node:child_process");
|
|
698
|
-
const platform = process.platform;
|
|
699
|
-
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
700
|
-
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
701
|
-
try {
|
|
702
|
-
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
703
|
-
}
|
|
704
|
-
catch {
|
|
705
|
-
// best-effort — the URL is surfaced to the user by the panel caller
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
715
|
/**
|
|
709
716
|
* Revoke tokens + clear stored credentials for a server being removed. Runs
|
|
710
717
|
* RFC 9728/8414 discovery to find the revocation endpoint, revokes (refresh →
|
|
@@ -15,10 +15,26 @@ import type { McpHttpServerConfig } from "./config.js";
|
|
|
15
15
|
export interface StoredOAuthEntry {
|
|
16
16
|
serverName: string;
|
|
17
17
|
serverUrl: string;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Client id issued by dynamic client registration. A pre-registered id
|
|
20
|
+
* lives in the config (`oauth.clientId`) and is never written here, which
|
|
21
|
+
* is what lets the interactive flow drop a stored id whose redirect is
|
|
22
|
+
* unknown (auth.ts `legacy_unpinned`) without discarding an operator's.
|
|
23
|
+
*/
|
|
19
24
|
clientId?: string;
|
|
20
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Client secret: the one DCR issued alongside `clientId` when the server
|
|
27
|
+
* returns one (Atlassian's current authorization server does), or a
|
|
28
|
+
* pre-registered one from `yagni mcp add --client-secret`.
|
|
29
|
+
*/
|
|
21
30
|
clientSecret?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Who issued `clientSecret`: `dcr` (it belongs to `clientId` and is
|
|
33
|
+
* dropped with it) or `operator` (`--client-secret`; it belongs to the
|
|
34
|
+
* config's `oauth.clientId` and survives a DCR client drop). Absent on
|
|
35
|
+
* entries written before this field existed.
|
|
36
|
+
*/
|
|
37
|
+
clientSecretSource?: "dcr" | "operator";
|
|
22
38
|
/**
|
|
23
39
|
* The redirect_uri registered with the authorization server when `clientId`
|
|
24
40
|
* came from dynamic client registration. Strict servers (Atlassian, most
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a URL in the user's default browser without going through a shell.
|
|
3
|
+
*
|
|
4
|
+
* The OAuth authorize URL is long and full of `&`. `cmd /c start <url>` on
|
|
5
|
+
* Windows re-parses its arguments as a shell line, so cmd.exe split the URL
|
|
6
|
+
* at the first `&` and the browser opened `…/authorize?response_type=code`;
|
|
7
|
+
* Atlassian ID then showed "The authorize request was incomplete or invalid"
|
|
8
|
+
* (2026-09-21 customer report). The same re-parse would let a hostile
|
|
9
|
+
* authorization endpoint execute commands. This mirrors the CLI login's
|
|
10
|
+
* opener (`yagni-code-cli/src/login.ts`; the extension cannot import that
|
|
11
|
+
* package, and the CLI's login tests pin the two resolvers together): the
|
|
12
|
+
* URL is always one literal argv element, and on Windows rundll32's
|
|
13
|
+
* FileProtocolHandler opens the default browser with no cmd.exe in between.
|
|
14
|
+
*
|
|
15
|
+
* A failure to open is thrown, never swallowed here: `authenticate()` logs
|
|
16
|
+
* it and keeps waiting, because the user still has the printed URL.
|
|
17
|
+
*/
|
|
18
|
+
export type OpenRunner = (cmd: string, args: string[]) => Promise<void>;
|
|
19
|
+
export declare function resolveOpenCommand(url: string, platform?: NodeJS.Platform): {
|
|
20
|
+
cmd: string;
|
|
21
|
+
args: string[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Hand an http(s) URL to the OS opener. Anything else (file:, javascript:,
|
|
25
|
+
* a custom scheme, garbage) is refused without invoking the runner; the
|
|
26
|
+
* refused URL is named in the error, since it carries no OAuth state. A
|
|
27
|
+
* runner failure rejects with the command named, so the log can tell a
|
|
28
|
+
* missing opener from a user who ignored the window.
|
|
29
|
+
*/
|
|
30
|
+
export declare function openUrlWith(url: string, runner: OpenRunner, platform?: NodeJS.Platform): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* The real runner: a detached child with no stdio. `spawn` reports a missing
|
|
33
|
+
* binary (ENOENT) asynchronously; waiting for 'spawn' or 'error' turns that
|
|
34
|
+
* into a rejection instead of an unhandled 'error' event that would take
|
|
35
|
+
* the session down.
|
|
36
|
+
*/
|
|
37
|
+
export declare const spawnRunner: OpenRunner;
|
|
38
|
+
/**
|
|
39
|
+
* The default browser opener. A no-op under the test runner: a test that
|
|
40
|
+
* forgets to inject `openUrl` must not pop a real browser window.
|
|
41
|
+
*/
|
|
42
|
+
export declare function defaultOpenUrl(url: string): Promise<void>;
|
|
43
|
+
//# sourceMappingURL=openUrl.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a URL in the user's default browser without going through a shell.
|
|
3
|
+
*
|
|
4
|
+
* The OAuth authorize URL is long and full of `&`. `cmd /c start <url>` on
|
|
5
|
+
* Windows re-parses its arguments as a shell line, so cmd.exe split the URL
|
|
6
|
+
* at the first `&` and the browser opened `…/authorize?response_type=code`;
|
|
7
|
+
* Atlassian ID then showed "The authorize request was incomplete or invalid"
|
|
8
|
+
* (2026-09-21 customer report). The same re-parse would let a hostile
|
|
9
|
+
* authorization endpoint execute commands. This mirrors the CLI login's
|
|
10
|
+
* opener (`yagni-code-cli/src/login.ts`; the extension cannot import that
|
|
11
|
+
* package, and the CLI's login tests pin the two resolvers together): the
|
|
12
|
+
* URL is always one literal argv element, and on Windows rundll32's
|
|
13
|
+
* FileProtocolHandler opens the default browser with no cmd.exe in between.
|
|
14
|
+
*
|
|
15
|
+
* A failure to open is thrown, never swallowed here: `authenticate()` logs
|
|
16
|
+
* it and keeps waiting, because the user still has the printed URL.
|
|
17
|
+
*/
|
|
18
|
+
import { runningUnderTest } from "../crashReport.js";
|
|
19
|
+
/** Keeps a failure's detail to one mcp.log-sized line (auth.ts caps at the same length). */
|
|
20
|
+
const DETAIL_MAX = 300;
|
|
21
|
+
export function resolveOpenCommand(url, platform = process.platform) {
|
|
22
|
+
if (platform === "darwin")
|
|
23
|
+
return { cmd: "open", args: [url] };
|
|
24
|
+
if (platform === "win32")
|
|
25
|
+
return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
|
|
26
|
+
return { cmd: "xdg-open", args: [url] };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Hand an http(s) URL to the OS opener. Anything else (file:, javascript:,
|
|
30
|
+
* a custom scheme, garbage) is refused without invoking the runner; the
|
|
31
|
+
* refused URL is named in the error, since it carries no OAuth state. A
|
|
32
|
+
* runner failure rejects with the command named, so the log can tell a
|
|
33
|
+
* missing opener from a user who ignored the window.
|
|
34
|
+
*/
|
|
35
|
+
export async function openUrlWith(url, runner, platform = process.platform) {
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = new URL(url);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* refused below */
|
|
42
|
+
}
|
|
43
|
+
if (!parsed || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
|
|
44
|
+
throw new Error(`refused: not an http(s) URL (${url})`.slice(0, DETAIL_MAX));
|
|
45
|
+
}
|
|
46
|
+
const { cmd, args } = resolveOpenCommand(url, platform);
|
|
47
|
+
try {
|
|
48
|
+
await runner(cmd, args);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw new Error(`${cmd}: ${err instanceof Error ? err.message : String(err)}`.slice(0, DETAIL_MAX));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The real runner: a detached child with no stdio. `spawn` reports a missing
|
|
56
|
+
* binary (ENOENT) asynchronously; waiting for 'spawn' or 'error' turns that
|
|
57
|
+
* into a rejection instead of an unhandled 'error' event that would take
|
|
58
|
+
* the session down.
|
|
59
|
+
*/
|
|
60
|
+
export const spawnRunner = async (cmd, args) => {
|
|
61
|
+
const { spawn } = await import("node:child_process");
|
|
62
|
+
await new Promise((resolve, reject) => {
|
|
63
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
64
|
+
child.once("error", reject);
|
|
65
|
+
child.once("spawn", () => {
|
|
66
|
+
child.unref();
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The default browser opener. A no-op under the test runner: a test that
|
|
73
|
+
* forgets to inject `openUrl` must not pop a real browser window.
|
|
74
|
+
*/
|
|
75
|
+
export async function defaultOpenUrl(url) {
|
|
76
|
+
if (runningUnderTest())
|
|
77
|
+
return;
|
|
78
|
+
await openUrlWith(url, spawnRunner);
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=openUrl.js.map
|
|
@@ -106,6 +106,10 @@ export function registerMcpPanel(pi, manager, configErrors) {
|
|
|
106
106
|
await doReconnect(sub[1], ctx.ui);
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
|
+
if ((sub[0] === "connect" || sub[0] === "authenticate") && sub[1]) {
|
|
110
|
+
await doAuthenticate(sub[1], ctx.ui, ctx.signal);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
109
113
|
if (sub[0] === "enable" || sub[0] === "disable") {
|
|
110
114
|
await doToggle(sub[0], sub.slice(1), ctx.ui);
|
|
111
115
|
return;
|
|
@@ -124,7 +128,9 @@ export function registerMcpPanel(pi, manager, configErrors) {
|
|
|
124
128
|
return;
|
|
125
129
|
}
|
|
126
130
|
ui.notify(`Starting OAuth for ${name} — your browser will open…`, "info");
|
|
127
|
-
const result = await manager.authenticateServer(name, signal
|
|
131
|
+
const result = await manager.authenticateServer(name, signal, {
|
|
132
|
+
onAuthorizationUrl: (url) => ui.notify(`Browser didn't open? Open this URL to continue:\n${url}`, "info"),
|
|
133
|
+
});
|
|
128
134
|
if (result?.status === "connected") {
|
|
129
135
|
await refreshForServer(name);
|
|
130
136
|
ui.notify(`✓ ${name} authenticated and connected`, "info");
|
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
* The Guardian — LLM auto-review of prompt-band bash commands (YAG-504).
|
|
3
3
|
*
|
|
4
4
|
* Pure half: verdict types, state tracking, circuit breaker, JSON parsing.
|
|
5
|
-
* I/O half: reviewCommand
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* I/O half: reviewCommand consults the guardian persona over the in-process
|
|
6
|
+
* direct transport (one /v1/chat/completions POST, same seam the prefix
|
|
7
|
+
* consult uses) on the efficient tier. The legacy child-spawn path
|
|
8
|
+
* (runStage, the advisor's seam) remains as the injectable test fallback.
|
|
9
|
+
* The consult returns a JSON verdict; the gate acts on it.
|
|
10
|
+
*
|
|
11
|
+
* The one deliberate verdict-semantics edge of the direct consult: it has no
|
|
12
|
+
* tools, so a command referencing an opaque script/config is judged from
|
|
13
|
+
* command text alone where the child could read the file first (Claude
|
|
14
|
+
* Code's equivalent check is also tool-less). Verdict parsing, the repair
|
|
15
|
+
* ladder, and the gate's handling are unchanged.
|
|
8
16
|
*
|
|
9
17
|
* Same pure/IO split as advisor.ts (decideConsult pure, askAdvisorTool I/O)
|
|
10
18
|
* and permission.ts (decideGate pure, registerPermissionGate I/O), for the
|
|
@@ -46,14 +54,15 @@ export interface GuardianLimits {
|
|
|
46
54
|
}
|
|
47
55
|
export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
|
|
48
56
|
/**
|
|
49
|
-
* Client-side safety margin on top of the backend's advertised wall ceiling
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
57
|
+
* Client-side safety margin on top of the backend's advertised wall ceiling.
|
|
58
|
+
* The backend owns the real timeout and retries, so the client must
|
|
59
|
+
* NOT pre-empt it; it derives its own deadline from the catalog's
|
|
60
|
+
* `guardianTimeoutMs * attempts` plus this margin, and treats that bound as a
|
|
61
|
+
* last-resort floor, not the normal path. Sized for the direct HTTP transport
|
|
62
|
+
* (one request, no child boot or read-tool round-trips); the legacy child
|
|
63
|
+
* path's spawn+teardown cost sat inside the backend's own advertised ceiling.
|
|
55
64
|
*/
|
|
56
|
-
export declare const GUARDIAN_CLIENT_OVERHEAD_MS =
|
|
65
|
+
export declare const GUARDIAN_CLIENT_OVERHEAD_MS = 2000;
|
|
57
66
|
/**
|
|
58
67
|
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
59
68
|
* overrides the sliding-window review cap; anything non-numeric or < 1 falls
|
|
@@ -64,7 +73,7 @@ export declare const GUARDIAN_CLIENT_OVERHEAD_MS = 10000;
|
|
|
64
73
|
export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
|
|
65
74
|
/**
|
|
66
75
|
* Derive the client's whole-consult deadline from the backend's advertised
|
|
67
|
-
* wall ceiling and attempt count
|
|
76
|
+
* wall ceiling and attempt count. Falls back to the resolved limit
|
|
68
77
|
* when the catalog omitted the fields (older backend).
|
|
69
78
|
*/
|
|
70
79
|
export declare function deriveGuardianTimeoutMs(base: GuardianLimits, advertised: {
|
|
@@ -75,6 +84,7 @@ export declare function deriveGuardianTimeoutMs(base: GuardianLimits, advertised
|
|
|
75
84
|
export declare const GUARDIAN_MODEL_TIER = "efficient";
|
|
76
85
|
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
77
86
|
export declare const GUARDIAN_TOOLS: string[];
|
|
87
|
+
export declare function _setGuardianPersonaBodyResolverForTest(resolver: (() => string) | null): void;
|
|
78
88
|
/**
|
|
79
89
|
* The review-cap window. `reviews` counts consults inside a SLIDING window
|
|
80
90
|
* rather than for the session's lifetime: a 24/7 session (a fleet operator's
|
|
@@ -148,7 +158,33 @@ export interface ReviewCommandDeps {
|
|
|
148
158
|
* an `ask` rationale can add information beyond the static rule text.
|
|
149
159
|
*/
|
|
150
160
|
execJustification?: string;
|
|
161
|
+
/**
|
|
162
|
+
* The direct transport (the production path): one in-process
|
|
163
|
+
* /v1/chat/completions POST with the guardian persona as the system prompt —
|
|
164
|
+
* no child spawn, no grounded context. Absent → the legacy child-spawn path
|
|
165
|
+
* (the injectable test seam, same retention as the prefix consult).
|
|
166
|
+
*
|
|
167
|
+
* Accepted regression, pinned in tests: the OpenAI-shaped response carries
|
|
168
|
+
* token usage but no per-consult dollar cost, so `ReviewResult.cost` is 0
|
|
169
|
+
* on this path. The backend owns billing via the caller header either way.
|
|
170
|
+
*/
|
|
171
|
+
transport?: {
|
|
172
|
+
baseUrl: string;
|
|
173
|
+
getToken: () => string | undefined;
|
|
174
|
+
/** Attribution headers (x-yagni-session-id / x-yagni-run-id). */
|
|
175
|
+
attribution?: () => Record<string, string>;
|
|
176
|
+
};
|
|
151
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* The transport revert knob (read per consult — `reviewCommand` itself calls
|
|
180
|
+
* it, so a flipped env var takes effect on the next gated command without a
|
|
181
|
+
* session restart): truthy forces the legacy child-spawn transport even when
|
|
182
|
+
* the direct transport is wired, so a transport-specific regression has a
|
|
183
|
+
* targeted rollback that does not disable the consult entirely. The gate's
|
|
184
|
+
* independent kill switch stays orthogonal.
|
|
185
|
+
*/
|
|
186
|
+
export declare function guardianLegacyTransportKnob(env?: NodeJS.ProcessEnv): boolean;
|
|
187
|
+
export declare function _resetGuardianLegacyTransportWarnForTest(): void;
|
|
152
188
|
/**
|
|
153
189
|
* The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
|
|
154
190
|
* (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
|
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
* The Guardian — LLM auto-review of prompt-band bash commands (YAG-504).
|
|
3
3
|
*
|
|
4
4
|
* Pure half: verdict types, state tracking, circuit breaker, JSON parsing.
|
|
5
|
-
* I/O half: reviewCommand
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* I/O half: reviewCommand consults the guardian persona over the in-process
|
|
6
|
+
* direct transport (one /v1/chat/completions POST, same seam the prefix
|
|
7
|
+
* consult uses) on the efficient tier. The legacy child-spawn path
|
|
8
|
+
* (runStage, the advisor's seam) remains as the injectable test fallback.
|
|
9
|
+
* The consult returns a JSON verdict; the gate acts on it.
|
|
10
|
+
*
|
|
11
|
+
* The one deliberate verdict-semantics edge of the direct consult: it has no
|
|
12
|
+
* tools, so a command referencing an opaque script/config is judged from
|
|
13
|
+
* command text alone where the child could read the file first (Claude
|
|
14
|
+
* Code's equivalent check is also tool-less). Verdict parsing, the repair
|
|
15
|
+
* ladder, and the gate's handling are unchanged.
|
|
8
16
|
*
|
|
9
17
|
* Same pure/IO split as advisor.ts (decideConsult pure, askAdvisorTool I/O)
|
|
10
18
|
* and permission.ts (decideGate pure, registerPermissionGate I/O), for the
|
|
@@ -28,21 +36,25 @@
|
|
|
28
36
|
* alternating deny/ask.
|
|
29
37
|
*/
|
|
30
38
|
import { runStage as defaultRunStage } from "../pipeline/runner.js";
|
|
39
|
+
import { directConsult } from "../pipeline/directConsult.js";
|
|
40
|
+
import { PERSONA_BODIES } from "../pipeline/personas.js";
|
|
31
41
|
import { scrubSecrets } from "../pipeline/scrubSecrets.js";
|
|
42
|
+
import { logEvent } from "../errorSink.js";
|
|
32
43
|
export const DEFAULT_GUARDIAN_LIMITS = {
|
|
33
44
|
maxReviews: 120,
|
|
34
45
|
maxConsecutiveDenials: 3,
|
|
35
46
|
timeoutMs: 15_000,
|
|
36
47
|
};
|
|
37
48
|
/**
|
|
38
|
-
* Client-side safety margin on top of the backend's advertised wall ceiling
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
49
|
+
* Client-side safety margin on top of the backend's advertised wall ceiling.
|
|
50
|
+
* The backend owns the real timeout and retries, so the client must
|
|
51
|
+
* NOT pre-empt it; it derives its own deadline from the catalog's
|
|
52
|
+
* `guardianTimeoutMs * attempts` plus this margin, and treats that bound as a
|
|
53
|
+
* last-resort floor, not the normal path. Sized for the direct HTTP transport
|
|
54
|
+
* (one request, no child boot or read-tool round-trips); the legacy child
|
|
55
|
+
* path's spawn+teardown cost sat inside the backend's own advertised ceiling.
|
|
44
56
|
*/
|
|
45
|
-
export const GUARDIAN_CLIENT_OVERHEAD_MS =
|
|
57
|
+
export const GUARDIAN_CLIENT_OVERHEAD_MS = 2_000;
|
|
46
58
|
/**
|
|
47
59
|
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
48
60
|
* overrides the sliding-window review cap; anything non-numeric or < 1 falls
|
|
@@ -61,7 +73,7 @@ export function resolveGuardianLimits(env = process.env) {
|
|
|
61
73
|
}
|
|
62
74
|
/**
|
|
63
75
|
* Derive the client's whole-consult deadline from the backend's advertised
|
|
64
|
-
* wall ceiling and attempt count
|
|
76
|
+
* wall ceiling and attempt count. Falls back to the resolved limit
|
|
65
77
|
* when the catalog omitted the fields (older backend).
|
|
66
78
|
*/
|
|
67
79
|
export function deriveGuardianTimeoutMs(base, advertised) {
|
|
@@ -74,6 +86,31 @@ export function deriveGuardianTimeoutMs(base, advertised) {
|
|
|
74
86
|
export const GUARDIAN_MODEL_TIER = "efficient";
|
|
75
87
|
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
76
88
|
export const GUARDIAN_TOOLS = ["read"];
|
|
89
|
+
/**
|
|
90
|
+
* The guardian persona body — the system prompt on the direct transport. A
|
|
91
|
+
* missing/renamed persona must never silently degrade to an empty system
|
|
92
|
+
* prompt (every consult would run unprompted and the verdicts would drift
|
|
93
|
+
* with no telemetry signature), so a warn fires ONCE when it resolves empty.
|
|
94
|
+
* Same reset-export pattern as the prefix persona resolver.
|
|
95
|
+
*/
|
|
96
|
+
let guardianPersonaBodyWarned = false;
|
|
97
|
+
let resolveGuardianPersonaBody = () => PERSONA_BODIES.guardian ?? "";
|
|
98
|
+
export function _setGuardianPersonaBodyResolverForTest(resolver) {
|
|
99
|
+
resolveGuardianPersonaBody = resolver ?? (() => PERSONA_BODIES.guardian ?? "");
|
|
100
|
+
guardianPersonaBodyWarned = false;
|
|
101
|
+
}
|
|
102
|
+
function guardianPersonaBody() {
|
|
103
|
+
const body = resolveGuardianPersonaBody();
|
|
104
|
+
if (body.length === 0 && !guardianPersonaBodyWarned) {
|
|
105
|
+
guardianPersonaBodyWarned = true;
|
|
106
|
+
logEvent({
|
|
107
|
+
source: "guardian",
|
|
108
|
+
level: "warn",
|
|
109
|
+
event: "guardian_persona_body_missing",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return body;
|
|
113
|
+
}
|
|
77
114
|
// --- State ---
|
|
78
115
|
/**
|
|
79
116
|
* The review-cap window. `reviews` counts consults inside a SLIDING window
|
|
@@ -293,6 +330,28 @@ export function formatGuardianSubtotal(state, limits) {
|
|
|
293
330
|
* it cannot balloon a log line or a Sentry payload.
|
|
294
331
|
*/
|
|
295
332
|
export const GUARDIAN_RAW_OUTPUT_CAP = 2048;
|
|
333
|
+
/**
|
|
334
|
+
* The transport revert knob (read per consult — `reviewCommand` itself calls
|
|
335
|
+
* it, so a flipped env var takes effect on the next gated command without a
|
|
336
|
+
* session restart): truthy forces the legacy child-spawn transport even when
|
|
337
|
+
* the direct transport is wired, so a transport-specific regression has a
|
|
338
|
+
* targeted rollback that does not disable the consult entirely. The gate's
|
|
339
|
+
* independent kill switch stays orthogonal.
|
|
340
|
+
*/
|
|
341
|
+
export function guardianLegacyTransportKnob(env = process.env) {
|
|
342
|
+
const v = env.YAGNI_GUARDIAN_LEGACY_TRANSPORT;
|
|
343
|
+
return v === "1" || v === "true";
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* One-time sink warn when the LEGACY child transport runs in a session whose
|
|
347
|
+
* gate wired the direct transport — the two paths are indistinguishable in
|
|
348
|
+
* outcome telemetry, so this line is the only place a transport regression
|
|
349
|
+
* (forcing the knob for a workspace) becomes visible.
|
|
350
|
+
*/
|
|
351
|
+
let guardianLegacyTransportWarned = false;
|
|
352
|
+
export function _resetGuardianLegacyTransportWarnForTest() {
|
|
353
|
+
guardianLegacyTransportWarned = false;
|
|
354
|
+
}
|
|
296
355
|
/**
|
|
297
356
|
* The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
|
|
298
357
|
* (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
|
|
@@ -328,39 +387,17 @@ export async function reviewCommand(command, deps) {
|
|
|
328
387
|
else
|
|
329
388
|
deps.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
330
389
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
// An aborted child often RESOLVES with empty/partial output instead of
|
|
343
|
-
// throwing (observed live: ESC → 433ms "empty"). Any result produced
|
|
344
|
-
// while the caller's signal is aborted is an abort, not an outage — the
|
|
345
|
-
// gate must not warn or fall back to an ask on it.
|
|
346
|
-
if (deps.signal?.aborted) {
|
|
347
|
-
return { verdict: null, error: "aborted", cost };
|
|
348
|
-
}
|
|
349
|
-
// The backend now owns the timeout (YAG-562) and streams a terminal error
|
|
350
|
-
// frame when the consult fails; a result with a `stopReason: "error"` or an
|
|
351
|
-
// `errorMessage` is a real backend failure, NOT the silent-empty shape. Map
|
|
352
|
-
// it to a concrete error so the gate shows "review timed out" / "service
|
|
353
|
-
// unavailable" instead of the misleading "no response". A timeout-shaped
|
|
354
|
-
// message (stream_idle_timeout / "timed out") is distinguished so the gate
|
|
355
|
-
// can add its timeout-specific note.
|
|
356
|
-
if (!output) {
|
|
357
|
-
if (result.stopReason === "error" || result.errorMessage) {
|
|
358
|
-
const msg = (result.errorMessage ?? "").toLowerCase();
|
|
359
|
-
const isTimeout = /stream_idle_timeout|timed out|idle|wall|timeout/i.test(msg);
|
|
360
|
-
return { verdict: null, error: isTimeout ? "timeout" : "network", cost };
|
|
361
|
-
}
|
|
362
|
-
return { verdict: null, error: "empty", cost };
|
|
363
|
-
}
|
|
390
|
+
const justificationLine = deps.execJustification
|
|
391
|
+
? `\n\nWhy this command was routed to you (static policy): ${deps.execJustification}`
|
|
392
|
+
: "";
|
|
393
|
+
const consultPrompt = `Review this shell command for safety. Output only the JSON verdict.${justificationLine}\n\nCommand:\n${command}`;
|
|
394
|
+
/**
|
|
395
|
+
* Classify a non-empty model output into a ReviewResult: verdict,
|
|
396
|
+
* repaired-verdict, or malformed with the scrubbed/capped raw capture.
|
|
397
|
+
* Shared by both transports so verdict semantics cannot drift between
|
|
398
|
+
* them (the acceptance criterion: only the transport changes).
|
|
399
|
+
*/
|
|
400
|
+
const classifyOutput = (output, cost) => {
|
|
364
401
|
const parsed = parseVerdictDetailed(output);
|
|
365
402
|
if (!parsed) {
|
|
366
403
|
// Scrubbed + capped so the local sink and Sentry can see the exact
|
|
@@ -385,18 +422,161 @@ export async function reviewCommand(command, deps) {
|
|
|
385
422
|
};
|
|
386
423
|
}
|
|
387
424
|
return { verdict: parsed.verdict, cost };
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
425
|
+
};
|
|
426
|
+
/**
|
|
427
|
+
* Classify a thrown consult error: the caller aborting (user hit ESC — must
|
|
428
|
+
* NOT be treated as an outage, or the fallback ask would pop a dialog on an
|
|
429
|
+
* aborted turn), our own deadline, and real network/transport errors. The
|
|
430
|
+
* thrown message itself never reaches telemetry (it can carry provider
|
|
431
|
+
* payloads — the prefix consult's scrub doctrine); the ERROR CLASS rides
|
|
432
|
+
* the local sink line — emitted only for the NETWORK classification, with
|
|
433
|
+
* the resolved outcome in fields, so routine aborts and deadline timeouts
|
|
434
|
+
* (the two shapes that must NOT read as outages) never emit a warn line.
|
|
435
|
+
*/
|
|
436
|
+
const classifyThrow = (err) => {
|
|
437
|
+
const errorClass = err instanceof Error ? err.constructor.name : typeof err;
|
|
393
438
|
if (deps.signal?.aborted) {
|
|
394
439
|
return { verdict: null, error: "aborted", cost: 0 };
|
|
395
440
|
}
|
|
396
441
|
if (controller.signal.aborted) {
|
|
397
442
|
return { verdict: null, error: "timeout", cost: 0 };
|
|
398
443
|
}
|
|
444
|
+
logEvent({
|
|
445
|
+
source: "guardian",
|
|
446
|
+
level: "warn",
|
|
447
|
+
event: "guardian_consult_error",
|
|
448
|
+
fields: { outcome: "network", errorClass },
|
|
449
|
+
});
|
|
399
450
|
return { verdict: null, error: "network", cost: 0 };
|
|
451
|
+
};
|
|
452
|
+
/**
|
|
453
|
+
* The direct transport: one /v1/chat/completions POST with the guardian
|
|
454
|
+
* persona as the system prompt and the consult prompt as user content —
|
|
455
|
+
* no child spawn, no grounded context, ~1k input tokens instead of ~31k.
|
|
456
|
+
*/
|
|
457
|
+
const reviewViaDirect = async () => {
|
|
458
|
+
const t = deps.transport;
|
|
459
|
+
// A missing/renamed guardian persona fails CLOSED: an empty system prompt
|
|
460
|
+
// would consult with no risk taxonomy and no JSON contract — a
|
|
461
|
+
// permissive bias on a safety gate. Returning an error class lets the
|
|
462
|
+
// gate's bounded fallback (ask the user / fail closed) handle it. The
|
|
463
|
+
// outcome stays "network" (the gate treats it identically), but a
|
|
464
|
+
// PER-OCCURRENCE sink line marks the true shape — a config/packaging bug
|
|
465
|
+
// must read as "persona missing" at 2am in any session, not as an
|
|
466
|
+
// outage (the once-per-process body warn may predate a fresh session).
|
|
467
|
+
const systemPrompt = guardianPersonaBody();
|
|
468
|
+
if (systemPrompt.length === 0) {
|
|
469
|
+
logEvent({
|
|
470
|
+
source: "guardian",
|
|
471
|
+
level: "error",
|
|
472
|
+
event: "guardian_persona_missing",
|
|
473
|
+
fields: { outcome: "network" },
|
|
474
|
+
});
|
|
475
|
+
return { verdict: null, error: "network", cost: 0 };
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
const res = await directConsult({ systemPrompt, userContent: consultPrompt, modelTier: deps.modelTier ?? GUARDIAN_MODEL_TIER }, {
|
|
479
|
+
baseUrl: t.baseUrl,
|
|
480
|
+
getToken: t.getToken,
|
|
481
|
+
attribution: t.attribution ?? (() => ({})),
|
|
482
|
+
callerLabel: "guardian",
|
|
483
|
+
signal: controller.signal,
|
|
484
|
+
});
|
|
485
|
+
const output = res.text.trim();
|
|
486
|
+
// An aborted request can resolve with empty/partial output instead of
|
|
487
|
+
// throwing — any result produced while the caller's signal is aborted
|
|
488
|
+
// is an abort, not an outage.
|
|
489
|
+
if (deps.signal?.aborted) {
|
|
490
|
+
return { verdict: null, error: "aborted", cost: res.cost };
|
|
491
|
+
}
|
|
492
|
+
if (!output) {
|
|
493
|
+
// A 200 with no/blank content is an EMPTY result: the backend's
|
|
494
|
+
// non-stream pipeline has already done its own empty-result retry
|
|
495
|
+
// and failover by this point, so the client does not retry again —
|
|
496
|
+
// the gate's bounded error fallback handles it. Aligned with the
|
|
497
|
+
// child path: a result resolving after the client's own deadline
|
|
498
|
+
// fired reads as a timeout, never as a silent empty.
|
|
499
|
+
if (controller.signal.aborted) {
|
|
500
|
+
return { verdict: null, error: "timeout", cost: res.cost };
|
|
501
|
+
}
|
|
502
|
+
return { verdict: null, error: "empty", cost: res.cost };
|
|
503
|
+
}
|
|
504
|
+
return classifyOutput(output, res.cost);
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
return classifyThrow(err);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
/**
|
|
511
|
+
* The legacy child transport (the injectable test seam; also the targeted
|
|
512
|
+
* rollback when the revert knob forces it). Spawns a locked-down child pi
|
|
513
|
+
* via runStage and reduces its NDJSON stream.
|
|
514
|
+
*/
|
|
515
|
+
const reviewViaChild = async () => {
|
|
516
|
+
try {
|
|
517
|
+
return await runChild();
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
return classifyThrow(err);
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
const runChild = async () => {
|
|
524
|
+
const result = await runStage(stage, { ticket: consultPrompt }, {
|
|
525
|
+
cwd: deps.cwd,
|
|
526
|
+
signal: controller.signal,
|
|
527
|
+
callerLabel: "guardian",
|
|
528
|
+
});
|
|
529
|
+
const cost = result.usage?.cost ?? 0;
|
|
530
|
+
const output = result.finalOutput.trim();
|
|
531
|
+
// An aborted child often RESOLVES with empty/partial output instead of
|
|
532
|
+
// throwing (observed live: ESC → 433ms "empty"). Any result produced
|
|
533
|
+
// while the caller's signal is aborted is an abort, not an outage — the
|
|
534
|
+
// gate must not warn or fall back to an ask on it.
|
|
535
|
+
if (deps.signal?.aborted) {
|
|
536
|
+
return { verdict: null, error: "aborted", cost };
|
|
537
|
+
}
|
|
538
|
+
// The backend now owns the timeout (YAG-562) and streams a terminal error
|
|
539
|
+
// frame when the consult fails; a result with a `stopReason: "error"` or an
|
|
540
|
+
// `errorMessage` is a real backend failure, NOT the silent-empty shape. Map
|
|
541
|
+
// it to a concrete error so the gate shows "review timed out" / "service
|
|
542
|
+
// unavailable" instead of the misleading "no response". A timeout-shaped
|
|
543
|
+
// message (stream_idle_timeout / "timed out") is distinguished so the gate
|
|
544
|
+
// can add its timeout-specific note.
|
|
545
|
+
if (!output) {
|
|
546
|
+
if (result.stopReason === "error" || result.errorMessage) {
|
|
547
|
+
const msg = (result.errorMessage ?? "").toLowerCase();
|
|
548
|
+
const isTimeout = /stream_idle_timeout|timed out|idle|wall|timeout/i.test(msg);
|
|
549
|
+
return { verdict: null, error: isTimeout ? "timeout" : "network", cost };
|
|
550
|
+
}
|
|
551
|
+
// The client's own deadline firing resolves the child with empty output
|
|
552
|
+
// (runStage SIGTERMs and resolves, it does not throw) — the previous
|
|
553
|
+
// classification mislabeled this last-resort abort as "no response";
|
|
554
|
+
// it is a timeout (observed live: every "empty" event at the derived
|
|
555
|
+
// 100s deadline).
|
|
556
|
+
if (controller.signal.aborted) {
|
|
557
|
+
return { verdict: null, error: "timeout", cost };
|
|
558
|
+
}
|
|
559
|
+
return { verdict: null, error: "empty", cost };
|
|
560
|
+
}
|
|
561
|
+
return classifyOutput(output, cost);
|
|
562
|
+
};
|
|
563
|
+
const useLegacy = !deps.transport || guardianLegacyTransportKnob();
|
|
564
|
+
if (useLegacy && deps.transport) {
|
|
565
|
+
// The knob forced the child path in a session that wired the direct
|
|
566
|
+
// transport — the one line that makes a forced rollback visible in the
|
|
567
|
+
// local trail (the two paths are otherwise indistinguishable in outcome
|
|
568
|
+
// telemetry). One-time, never per-call.
|
|
569
|
+
if (!guardianLegacyTransportWarned) {
|
|
570
|
+
guardianLegacyTransportWarned = true;
|
|
571
|
+
logEvent({
|
|
572
|
+
source: "guardian",
|
|
573
|
+
level: "warn",
|
|
574
|
+
event: "guardian_legacy_transport_used",
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
return useLegacy ? await reviewViaChild() : await reviewViaDirect();
|
|
400
580
|
}
|
|
401
581
|
finally {
|
|
402
582
|
clearTimeout(timer);
|
|
@@ -42,10 +42,12 @@ export async function directConsult(req, opts) {
|
|
|
42
42
|
}
|
|
43
43
|
const data = (await res.json());
|
|
44
44
|
// A 200 with no/blank content is an EMPTY result, not a transport error:
|
|
45
|
-
//
|
|
46
|
-
// backend's own 502-shape body
|
|
47
|
-
//
|
|
48
|
-
//
|
|
45
|
+
// each consult's OWN classifier decides the retry policy for the shape
|
|
46
|
+
// (the prefix consult retries once — the backend's own 502-shape body
|
|
47
|
+
// invites it; the guardian consult does not — its non-stream pipeline
|
|
48
|
+
// already retried and failed over server-side). A thrown error here
|
|
49
|
+
// would misclassify the shape as `network` for every consult regardless
|
|
50
|
+
// of its policy. A genuinely malformed (non-JSON) body still throws below.
|
|
49
51
|
const text = data.choices?.[0]?.message?.content ?? "";
|
|
50
52
|
return { text, cost: typeof data.usage?.cost === "number" ? data.usage.cost : 0 };
|
|
51
53
|
}
|
|
@@ -148,9 +148,9 @@ export declare function isSandboxDenialOutput(output: string): boolean;
|
|
|
148
148
|
* offending path when found (pi-sandbox's regex, credited MIT).
|
|
149
149
|
*/
|
|
150
150
|
export declare function extractBlockedWritePath(output: string): string | null;
|
|
151
|
-
/** The
|
|
151
|
+
/** The network-posture denial classes this classifier knows (closed
|
|
152
152
|
* set — rides the sink line as a low-cardinality field). */
|
|
153
|
-
export type NetworkDenialClass = "ipc-listen" | "tls-trustd" | "loopback";
|
|
153
|
+
export type NetworkDenialClass = "ipc-listen" | "tls-trustd" | "loopback" | "dns" | "ssh-proxy";
|
|
154
154
|
/**
|
|
155
155
|
* Classify a sandbox network-posture denial from command output. Pure;
|
|
156
156
|
* advisory-only (never widens anything — the fix is a knob the USER flips,
|
|
@@ -164,6 +164,20 @@ export type NetworkDenialClass = "ipc-listen" | "tls-trustd" | "loopback";
|
|
|
164
164
|
* - loopback: EPERM/"not permitted" on a connect to 127.0.0.1/
|
|
165
165
|
* localhost/::1 (loopback bypasses the proxy via
|
|
166
166
|
* no_proxy, so the allowlist can never admit it)
|
|
167
|
+
* - dns: raw name-resolution failure (getaddrinfo/nslookup/ping
|
|
168
|
+
* spellings). The profile denies DNS by design — proxy-
|
|
169
|
+
* aware tools ride the mux where the PARENT resolves;
|
|
170
|
+
* raw resolvers fail. This class exists so the failure
|
|
171
|
+
* reads as the fence working (not the machine losing
|
|
172
|
+
* connectivity) and seeds the rung's brake — a
|
|
173
|
+
* DNS-shaped sandboxed failure re-arms the consent
|
|
174
|
+
* dialog, never loops silently.
|
|
175
|
+
* - ssh-proxy: git-over-SSH through the sandbox's SSH bridge (nc
|
|
176
|
+
* ProxyCommand): the bridge carries no credential, so
|
|
177
|
+
* the mux policy-refuses it even for granted domains;
|
|
178
|
+
* ssh renders the refusal as a dispatch fatal on
|
|
179
|
+
* "UNKNOWN port 65535" (nc's rewrite of the proxied
|
|
180
|
+
* endpoint — a real server renders its own address).
|
|
167
181
|
* Returns null when the output carries no network-posture signature — a
|
|
168
182
|
* plain file-write EPERM must NOT get a network hint.
|
|
169
183
|
*/
|
|
@@ -437,6 +437,20 @@ export function extractBlockedWritePath(output) {
|
|
|
437
437
|
* - loopback: EPERM/"not permitted" on a connect to 127.0.0.1/
|
|
438
438
|
* localhost/::1 (loopback bypasses the proxy via
|
|
439
439
|
* no_proxy, so the allowlist can never admit it)
|
|
440
|
+
* - dns: raw name-resolution failure (getaddrinfo/nslookup/ping
|
|
441
|
+
* spellings). The profile denies DNS by design — proxy-
|
|
442
|
+
* aware tools ride the mux where the PARENT resolves;
|
|
443
|
+
* raw resolvers fail. This class exists so the failure
|
|
444
|
+
* reads as the fence working (not the machine losing
|
|
445
|
+
* connectivity) and seeds the rung's brake — a
|
|
446
|
+
* DNS-shaped sandboxed failure re-arms the consent
|
|
447
|
+
* dialog, never loops silently.
|
|
448
|
+
* - ssh-proxy: git-over-SSH through the sandbox's SSH bridge (nc
|
|
449
|
+
* ProxyCommand): the bridge carries no credential, so
|
|
450
|
+
* the mux policy-refuses it even for granted domains;
|
|
451
|
+
* ssh renders the refusal as a dispatch fatal on
|
|
452
|
+
* "UNKNOWN port 65535" (nc's rewrite of the proxied
|
|
453
|
+
* endpoint — a real server renders its own address).
|
|
440
454
|
* Returns null when the output carries no network-posture signature — a
|
|
441
455
|
* plain file-write EPERM must NOT get a network hint.
|
|
442
456
|
*/
|
|
@@ -513,6 +527,52 @@ export function networkDenialHint(output) {
|
|
|
513
527
|
"The local-binding knob is ON by default — this denial means it was turned off; the user can re-enable it in the /sandbox panel (Network tab). Retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
|
|
514
528
|
};
|
|
515
529
|
}
|
|
530
|
+
// ssh-proxy: the SSH-bridge policy refusal — matched BEFORE dns (the
|
|
531
|
+
// ordering pin): a DNS-shaped failure from an ssh prefix classifies as
|
|
532
|
+
// dns (the more honest diagnosis — alias resolution failed), while the
|
|
533
|
+
// bridge refusal is the UNKNOWN-port dispatch fatal or the mux's own
|
|
534
|
+
// refusal sentence. "UNKNOWN port 65535" is nc's rewrite of the
|
|
535
|
+
// proxied destination; a refusing-but-real server renders its own
|
|
536
|
+
// address there, so the UNKNOWN shape is proxy-specific by construction.
|
|
537
|
+
if (/ssh_dispatch_run_fatal: Connection to UNKNOWN port \d+: (Broken pipe|Connection reset)/i.test(output) ||
|
|
538
|
+
/kex_exchange_identification: Connection (?:closed|reset) by UNKNOWN port \d+/i.test(output) ||
|
|
539
|
+
/This proxy requires authentication[^.]*\bdid not offer an authentication method/i.test(output) ||
|
|
540
|
+
/SSH-2\.0-policy_refusal/.test(output)) {
|
|
541
|
+
return {
|
|
542
|
+
cls: "ssh-proxy",
|
|
543
|
+
hint: "This looks like a sandbox git-over-SSH denial (the SSH bridge routes ssh through the sandbox proxy, which carries no credential — git over SSH cannot ride it even for granted domains). " +
|
|
544
|
+
"Use the HTTPS remote for this operation, or retry outside the sandbox (the user will be asked). Do not treat this as a network outage.",
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
// dns: raw name-resolution failure — the fence denies DNS by design;
|
|
548
|
+
// proxy-aware tools ride the mux (parent-side resolution), raw
|
|
549
|
+
// resolvers do not. Per-line discipline matches the EPERM branch
|
|
550
|
+
// (numbered grep hits, quoted text, and prose-initial lines are data):
|
|
551
|
+
// "Could not resolve host" is prose-likely enough that a bare
|
|
552
|
+
// substring match would fire on quoted log lines.
|
|
553
|
+
{
|
|
554
|
+
const dnsPhrases = /Could not resolve host(?:name)?|nodename nor servname provided|Name or service not known|Temporary failure in name resolution|getaddrinfo (?:ENOTFOUND|EAI_AGAIN|EAI_NONAME)|cannot resolve \S+: Unknown host|no servers could be reached|no DNS configuration/i;
|
|
555
|
+
for (const line of output.split("\n")) {
|
|
556
|
+
const t = line.trim();
|
|
557
|
+
if (!dnsPhrases.test(t))
|
|
558
|
+
continue;
|
|
559
|
+
if (/^\d+:/.test(t))
|
|
560
|
+
continue; // numbered grep/log hit — data
|
|
561
|
+
if (/^["']/.test(t))
|
|
562
|
+
continue; // quoted text — data
|
|
563
|
+
// Prose-initial: the phrase behind an article/conjunction opener is
|
|
564
|
+
// data ("if the host could not resolve…"). A `tool:` prefix or the
|
|
565
|
+
// bare phrase at line head is the diagnostic shape (same split as
|
|
566
|
+
// the EPERM branch's prose exclusion).
|
|
567
|
+
if (/^(the|a|an|this|that|it|when|if|note|tip|for|some)\b/i.test(t))
|
|
568
|
+
continue;
|
|
569
|
+
return {
|
|
570
|
+
cls: "dns",
|
|
571
|
+
hint: "This looks like a sandbox DNS-resolution denial (raw name resolution is unavailable inside the sandbox by design — proxy-aware tools ride the network proxy, where the parent resolves). " +
|
|
572
|
+
"This is NOT evidence the machine lost connectivity; do not diagnose network health from inside the sandbox. If this command genuinely needs direct network, retry with dangerouslyDisableSandbox (the user will be asked).",
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
516
576
|
return null;
|
|
517
577
|
}
|
|
518
578
|
/**
|
|
@@ -128,9 +128,15 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
|
|
|
128
128
|
// auto-allow, and the escape rung at once. Stated here so the model
|
|
129
129
|
// never mints one.
|
|
130
130
|
const envPrefixNote = "Do not prepend environment variables you have not verified exist (grep the tool's binary/docs/help first) — an unverified prefix defeats grants, auto-allow, and the sandbox retry rung; write the plain command instead.";
|
|
131
|
+
// The git-over-SSH caveat: the SSH bridge carries no credential, so a
|
|
132
|
+
// granted domain does NOT make git-over-SSH work — the model that
|
|
133
|
+
// expects the grant to cover an SSH remote reads the bridge refusal as
|
|
134
|
+
// a network outage and probes (the incident session burned 10 minutes
|
|
135
|
+
// on exactly that). The HTTPS remote is the in-sandbox cure.
|
|
136
|
+
const gitSshNote = "Git commands that talk to a remote over SSH (git@… URLs) cannot ride the sandbox's network proxy on macOS — expect an SSH-bridge or DNS failure and either use the HTTPS remote, or retry outside the sandbox (the user will be asked). A granted domain does not make git-over-SSH work.";
|
|
131
137
|
const wrapped = {
|
|
132
138
|
...def,
|
|
133
|
-
description: `${def.description}\n\n## Command sandbox\nCommands run inside an OS sandbox: ${restrictions.join("; ")}. ${strictNote}\n${heredocNote}\n${tmpdirNote}\n${envPrefixNote}`,
|
|
139
|
+
description: `${def.description}\n\n## Command sandbox\nCommands run inside an OS sandbox: ${restrictions.join("; ")}. ${strictNote}\n${heredocNote}\n${tmpdirNote}\n${envPrefixNote}\n${gitSshNote}`,
|
|
134
140
|
parameters: schema,
|
|
135
141
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
136
142
|
const input = params;
|
|
@@ -358,10 +364,14 @@ export function registerSandbox(pi, opts) {
|
|
|
358
364
|
if (sessionDomainGrants.has(host))
|
|
359
365
|
return true;
|
|
360
366
|
const ui = uiBridge;
|
|
361
|
-
if (!ui?.hasUI)
|
|
367
|
+
if (!ui?.hasUI) {
|
|
368
|
+
logNetworkAsk(host, "headless", null);
|
|
362
369
|
return false; // headless: fail closed (never grant silently)
|
|
363
|
-
|
|
370
|
+
}
|
|
371
|
+
if (ui.signal?.aborted) {
|
|
372
|
+
logNetworkAsk(host, "aborted", null);
|
|
364
373
|
return false;
|
|
374
|
+
}
|
|
365
375
|
// Claude-parity grant options. Session grant is memory-only and
|
|
366
376
|
// agent-invisible; project/user write the sandbox config file.
|
|
367
377
|
const dialogOpts = {
|
|
@@ -378,13 +388,36 @@ export function registerSandbox(pi, opts) {
|
|
|
378
388
|
"Block",
|
|
379
389
|
], dialogOpts);
|
|
380
390
|
}
|
|
381
|
-
catch {
|
|
391
|
+
catch (err) {
|
|
392
|
+
// A real 600s timeout vs an immediate UI-bridge error are different
|
|
393
|
+
// failures; the deny behavior is identical, but the trail says which:
|
|
394
|
+
// the dialog's own timeout throws a name-matched abort/timeout error
|
|
395
|
+
// (pi's select timeout shape), anything else is a bridge failure.
|
|
396
|
+
const isTimeout = err instanceof Error && /timeout|abort/i.test(err.name);
|
|
397
|
+
logNetworkAsk(host, isTimeout ? "timeout" : "error", null);
|
|
382
398
|
return false; // dialog error / timeout: never grant
|
|
383
399
|
}
|
|
384
|
-
|
|
400
|
+
// A signal abort while the dialog was open resolves select to undefined
|
|
401
|
+
// — that is the session tearing down, not the user choosing Block.
|
|
402
|
+
if (ui.signal?.aborted) {
|
|
403
|
+
logNetworkAsk(host, "aborted", null);
|
|
385
404
|
return false;
|
|
386
|
-
|
|
387
|
-
if (choice
|
|
405
|
+
}
|
|
406
|
+
if (choice === undefined || choice === "Block") {
|
|
407
|
+
logNetworkAsk(host, "block", null);
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
// FAIL-CLOSED choice dispatch: every branch matches its option label
|
|
411
|
+
// explicitly — an unrecognized choice (a renamed/added dialog option in
|
|
412
|
+
// a future version) never silently grants; it denies with its own
|
|
413
|
+
// outcome in the trail.
|
|
414
|
+
if (choice.startsWith("Allow for this session")) {
|
|
415
|
+
sessionDomainGrants.add(host);
|
|
416
|
+
logNetworkAsk(host, "session", null);
|
|
417
|
+
}
|
|
418
|
+
else if (choice.startsWith("Allow for this project")) {
|
|
419
|
+
sessionDomainGrants.add(host);
|
|
420
|
+
logNetworkAsk(host, "allow", "project");
|
|
388
421
|
try {
|
|
389
422
|
persistDomainGrant(opts.projectRoot ?? opts.cwd, host, resolvedStateHome);
|
|
390
423
|
}
|
|
@@ -397,6 +430,8 @@ export function registerSandbox(pi, opts) {
|
|
|
397
430
|
}
|
|
398
431
|
}
|
|
399
432
|
else if (choice.startsWith("Allow for all projects")) {
|
|
433
|
+
sessionDomainGrants.add(host);
|
|
434
|
+
logNetworkAsk(host, "allow", "user");
|
|
400
435
|
try {
|
|
401
436
|
persistDomainGrant(null, host, resolvedStateHome);
|
|
402
437
|
}
|
|
@@ -405,6 +440,10 @@ export function registerSandbox(pi, opts) {
|
|
|
405
440
|
ui.ui.notify?.("Could not persist the domain grant to the config file — it applies to this session only.", "warning");
|
|
406
441
|
}
|
|
407
442
|
}
|
|
443
|
+
else {
|
|
444
|
+
logNetworkAsk(host, "unknown", null);
|
|
445
|
+
return false; // an unrecognized choice never grants
|
|
446
|
+
}
|
|
408
447
|
// The grant must reach the live srt config before the retry.
|
|
409
448
|
manager.refreshConfig([...effectiveRulesForSandbox()]);
|
|
410
449
|
return true;
|
|
@@ -945,6 +984,23 @@ function logFsDenialHint(fs, surface) {
|
|
|
945
984
|
return;
|
|
946
985
|
logEvent({ source: "sandbox", level: "info", event: "fs_denial_hint", fields: { class: fs.cls, surface } });
|
|
947
986
|
}
|
|
987
|
+
/** Sink line for every network-ask resolution. The ask was invisible in the
|
|
988
|
+
* incident session's trail — whether the dialog fired, which option was
|
|
989
|
+
* picked, and where the grant persisted had to be reconstructed from the
|
|
990
|
+
* config file by hand. outcome: session | allow | block | timeout |
|
|
991
|
+
* aborted | headless | unknown | error (the closed resolution vocabulary);
|
|
992
|
+
* tier: where a persistent grant landed (project | user) or null. The
|
|
993
|
+
* host rides the field (same exposure class as logGrantPersistFailure's
|
|
994
|
+
* `grant`) — the sink scrubs each line, and the transcript carries hosts
|
|
995
|
+
* anyway. */
|
|
996
|
+
function logNetworkAsk(host, outcome, tier) {
|
|
997
|
+
logEvent({
|
|
998
|
+
source: "sandbox",
|
|
999
|
+
level: "info",
|
|
1000
|
+
event: "network_ask",
|
|
1001
|
+
fields: { host, outcome, tier },
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
948
1004
|
/** Sink line for a failed domain-grant persist — the thrown message is
|
|
949
1005
|
* mutateConfigJson's own path/reason text (diagnosable, no file content),
|
|
950
1006
|
* same posture as sandbox_persist_failed / rule_save_failed. */
|
package/dist/mcpCommand.d.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface McpCliModule {
|
|
|
42
42
|
type: "http";
|
|
43
43
|
url: string;
|
|
44
44
|
tools: string[];
|
|
45
|
+
}, deps?: {
|
|
46
|
+
onAuthorizationUrl?: (url: string) => void;
|
|
45
47
|
}): Promise<{
|
|
46
48
|
result: "AUTHORIZED";
|
|
47
49
|
}>;
|
|
@@ -64,6 +66,7 @@ export interface McpCliModule {
|
|
|
64
66
|
};
|
|
65
67
|
updateStoredOAuthEntry(serverName: string, config: unknown, mutate: (entry: {
|
|
66
68
|
clientSecret?: string;
|
|
69
|
+
clientSecretSource?: "dcr" | "operator";
|
|
67
70
|
}) => void): {
|
|
68
71
|
errors: unknown[];
|
|
69
72
|
};
|
package/dist/mcpCommand.js
CHANGED
|
@@ -412,7 +412,9 @@ async function connectWorkers(mod, parsed, io, env) {
|
|
|
412
412
|
if (shadow)
|
|
413
413
|
throw new Error("A project or local yagni-workers entry would override this connection. Remove that entry before connecting");
|
|
414
414
|
io.stdout("Opening YAGNI in your browser. Choose your workspace and permissions; paid Worker requests use that workspace's balance.\n");
|
|
415
|
-
await mod.authenticate("yagni-workers", config
|
|
415
|
+
await mod.authenticate("yagni-workers", config, {
|
|
416
|
+
onAuthorizationUrl: (url) => io.stdout(`If your browser did not open, visit:\n${url}\n`),
|
|
417
|
+
});
|
|
416
418
|
writeServerToScope(mod, "yagni-workers", config, "user", io.cwd);
|
|
417
419
|
io.stdout("Workers connected. Start a new YAGNI Code session to use them; /mcp shows connection status.\n");
|
|
418
420
|
return 0;
|
|
@@ -491,6 +493,8 @@ function persistAdd(mod, name, serverConfig, parsed, io) {
|
|
|
491
493
|
if (secret) {
|
|
492
494
|
mod.updateStoredOAuthEntry(name, serverConfig, (entry) => {
|
|
493
495
|
entry.clientSecret = secret;
|
|
496
|
+
// The operator's secret survives a DCR client drop; a DCR-issued one does not.
|
|
497
|
+
entry.clientSecretSource = "operator";
|
|
494
498
|
});
|
|
495
499
|
}
|
|
496
500
|
else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.6-staging.
|
|
3
|
+
"version": "1.1.6-staging.1449.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "49d55bc47e1e42094c17731b568fd3410e9c4781"
|
|
62
62
|
}
|