@yagni-app/code-staging 1.0.8-staging.1273.1 → 1.0.8-staging.1280.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.
|
@@ -24,7 +24,11 @@ import { logAskQuestion } from "./diagnostics.js";
|
|
|
24
24
|
const TOOL_NAME = "ask_user_question";
|
|
25
25
|
/** Claude mirrors this as ASK_USER_QUESTION_TOOL_CHIP_WIDTH. */
|
|
26
26
|
const CHIP_WIDTH = 12;
|
|
27
|
-
/**
|
|
27
|
+
/** Cancel an unanswered question after 2 minutes. This is the structured
|
|
28
|
+
* QUESTION tool's own UX cap — NOT the permission-ask contract (gate asks
|
|
29
|
+
* wait indefinitely per Claude Code parity; see agent-safety.md). A stale
|
|
30
|
+
* question auto-cancels so the agent's turn cannot hang on a chip nobody
|
|
31
|
+
* is looking at; the user can always re-ask. */
|
|
28
32
|
const ASK_TIMEOUT_MS = 120_000;
|
|
29
33
|
/** Sentinel value representing the "Other" row in the multi-select toggle set. */
|
|
30
34
|
const OTHER_KEY = "__other__";
|
|
@@ -523,7 +527,8 @@ async function askOne(ctx, q, qIndex) {
|
|
|
523
527
|
return;
|
|
524
528
|
}
|
|
525
529
|
ctx.signal?.addEventListener("abort", onAbort, { once: true });
|
|
526
|
-
// Best-effort timeout
|
|
530
|
+
// Best-effort timeout: this surface's own 2-minute question cap (the
|
|
531
|
+
// permission-gate ask deliberately has none — see ASK_TIMEOUT_MS above).
|
|
527
532
|
const timer = setTimeout(() => settle({ status: "cancelled" }), ASK_TIMEOUT_MS);
|
|
528
533
|
void ctx.ui
|
|
529
534
|
.custom((_tui, theme, keybindings, done) => {
|
|
@@ -513,45 +513,134 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
513
513
|
const flat = command.replace(/\s+/g, " ").trim();
|
|
514
514
|
return flat.length <= 240 ? flat : `${flat.slice(0, 237)}…`;
|
|
515
515
|
};
|
|
516
|
-
const ASK_TIMEOUT_MS = 120_000;
|
|
517
516
|
const ASK_YES = "Yes, run it";
|
|
518
517
|
const ASK_NO = "No";
|
|
518
|
+
/**
|
|
519
|
+
* The ask dialog now waits indefinitely, so an unanswered ask is a silent
|
|
520
|
+
* unbounded pause: a lost RPC client or an abandoned dialog hangs the
|
|
521
|
+
* session with nothing in the trail to explain why. Emit one sanitized
|
|
522
|
+
* event when the dialog OPENS — kind + tool ONLY. The sink's default-on
|
|
523
|
+
* contract is content-free (raw content is gated behind YAGNI_DEBUG), and
|
|
524
|
+
* even the rule string stays out: it is user-authored free text that can
|
|
525
|
+
* embed secret-bearing fragments (env assignments, URL credentials), and
|
|
526
|
+
* a rule ask is already fully identified by kind "rule" + the tool. Fail-
|
|
527
|
+
* soft; the sink never throws into the gate.
|
|
528
|
+
*/
|
|
529
|
+
const logAskOpened = (kind, toolName) => {
|
|
530
|
+
try {
|
|
531
|
+
logEvent({
|
|
532
|
+
source: "guardian",
|
|
533
|
+
level: "info",
|
|
534
|
+
event: "guardian_ask_opened",
|
|
535
|
+
fields: { kind, tool: toolName },
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
/* telemetry must never affect the gate */
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
/**
|
|
543
|
+
* The paired resolution event: with no dialog timeout, the pause DURATION
|
|
544
|
+
* and its OUTCOME are the story (answered vs ESC-dismissed vs turn-aborted),
|
|
545
|
+
* and a dismissed dialog would otherwise leave no trail line of its own.
|
|
546
|
+
* kind + tool + outcome only — all enum values, nothing content-bearing.
|
|
547
|
+
* durationMs makes the pause directly readable instead of timestamp-diffing
|
|
548
|
+
* two lines. Same fail-soft contract as the open event.
|
|
549
|
+
*/
|
|
550
|
+
const logAskResolved = (kind, toolName, outcome, openedAt) => {
|
|
551
|
+
try {
|
|
552
|
+
logEvent({
|
|
553
|
+
source: "guardian",
|
|
554
|
+
level: "info",
|
|
555
|
+
event: "guardian_ask_resolved",
|
|
556
|
+
fields: { kind, tool: toolName, outcome, durationMs: Date.now() - openedAt },
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
/* telemetry must never affect the gate */
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
/**
|
|
564
|
+
* The single choice-to-resolution mapping BOTH ask dialogs share — one
|
|
565
|
+
* place, so the two surfaces can never drift. The option labels the dialog
|
|
566
|
+
* actually offered are passed in; the rule rung is null when the plain
|
|
567
|
+
* dialog didn't offer it. An aborted turn wins over any choice value; a
|
|
568
|
+
* real user ESC maps to "dismissed" (the no-abort, no-answer bucket —
|
|
569
|
+
* ESC resolves undefined in pi). A THROWN select never reaches this
|
|
570
|
+
* function: the dialogs route it to thrownChoiceResolution, which is the
|
|
571
|
+
* ONLY source of outcome "error". Both fail closed identically
|
|
572
|
+
* downstream — only the telemetry distinguishes them.
|
|
573
|
+
*/
|
|
574
|
+
const resolveChoice = (choice, opts) => {
|
|
575
|
+
if (opts.aborted)
|
|
576
|
+
return "aborted";
|
|
577
|
+
if (choice === ASK_YES)
|
|
578
|
+
return "yes";
|
|
579
|
+
if (opts.rememberLabel !== null && choice === opts.rememberLabel)
|
|
580
|
+
return "remember";
|
|
581
|
+
if (opts.ruleLabel != null && choice === opts.ruleLabel)
|
|
582
|
+
return "rule";
|
|
583
|
+
if (choice === ASK_NO)
|
|
584
|
+
return "no";
|
|
585
|
+
// No abort and no recognizable answer — a real ESC (pi resolves a
|
|
586
|
+
// dismissed dialog as undefined) or any unmatched value. NEVER the
|
|
587
|
+
// thrown-select path: that maps to "error" in thrownChoiceResolution.
|
|
588
|
+
return "dismissed";
|
|
589
|
+
};
|
|
590
|
+
/**
|
|
591
|
+
* Map a THROWN select to its resolution: the UI/RPC layer failed (dead
|
|
592
|
+
* client, disposed dialog) — never a human answer. Distinct from ESC in
|
|
593
|
+
* the trail so a lost client is attributable; blocks exactly like a
|
|
594
|
+
* dismissal downstream (fail closed).
|
|
595
|
+
*/
|
|
596
|
+
const thrownChoiceResolution = (aborted) => aborted ? "aborted" : "error";
|
|
519
597
|
/**
|
|
520
598
|
* The single human-in-the-loop ask surface (YAG-510): used for ask
|
|
521
599
|
* verdicts, Guardian-unavailable/disabled fallbacks, and the breaker
|
|
522
600
|
* escalation — one UI, one cache, one event stream. Always passes the
|
|
523
|
-
* turn's abort signal (without it a turn-abort leaves the dialog hanging)
|
|
524
|
-
*
|
|
601
|
+
* turn's abort signal (without it a turn-abort leaves the dialog hanging).
|
|
602
|
+
*
|
|
603
|
+
* No dialog timeout — Claude Code parity: a permission ask waits
|
|
604
|
+
* indefinitely for the human. The only dismissal paths are the user
|
|
605
|
+
* answering, dismissing the dialog (ESC), the turn aborting (Ctrl-C /
|
|
606
|
+
* interrupt), or the UI layer failing (outcome "error", fail closed).
|
|
607
|
+
* The old 120s auto-fail-closed cap is gone; an unattended dialog pauses
|
|
608
|
+
* the session rather than denying the command.
|
|
525
609
|
*/
|
|
526
|
-
const askUser = async (ctx, title, rememberLabel) => {
|
|
610
|
+
const askUser = async (ctx, title, rememberLabel, kind = "guardian_verdict", toolName = "bash") => {
|
|
527
611
|
if (ctx.signal?.aborted)
|
|
528
612
|
return "aborted";
|
|
613
|
+
logAskOpened(kind, toolName);
|
|
614
|
+
const openedAt = Date.now();
|
|
529
615
|
const options = rememberLabel ? [ASK_YES, rememberLabel, ASK_NO] : [ASK_YES, ASK_NO];
|
|
530
616
|
let choice;
|
|
617
|
+
let selectThrew = false;
|
|
531
618
|
try {
|
|
532
619
|
choice = await ctx.ui.select(title, options, {
|
|
533
620
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
534
|
-
timeout: ASK_TIMEOUT_MS,
|
|
535
621
|
});
|
|
536
622
|
}
|
|
537
623
|
catch {
|
|
538
|
-
|
|
624
|
+
selectThrew = true;
|
|
539
625
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
return "no";
|
|
546
|
-
return ctx.signal?.aborted ? "aborted" : "dismissed";
|
|
626
|
+
const resolution = selectThrew
|
|
627
|
+
? thrownChoiceResolution(ctx.signal?.aborted ?? false)
|
|
628
|
+
: resolveChoice(choice, { rememberLabel, aborted: ctx.signal?.aborted ?? false });
|
|
629
|
+
logAskResolved(kind, toolName, resolution, openedAt);
|
|
630
|
+
return resolution;
|
|
547
631
|
};
|
|
548
632
|
/**
|
|
549
633
|
* variant: the Guardian ask dialog with an optional third option
|
|
550
|
-
* (persist a user-level permission rule). Same semantics as askUser
|
|
634
|
+
* (persist a user-level permission rule). Same semantics as askUser
|
|
635
|
+
* (indefinite wait, turn-abort signal only). kind/toolName are threaded
|
|
636
|
+
* through — never hardcoded here — so a future non-bash caller cannot
|
|
637
|
+
* silently misattribute the dialog in the trail.
|
|
551
638
|
*/
|
|
552
|
-
const askUserWithOptions = async (ctx, title, rememberLabel, ruleLabel) => {
|
|
639
|
+
const askUserWithOptions = async (ctx, title, rememberLabel, ruleLabel, kind = "guardian_verdict", toolName = "bash") => {
|
|
553
640
|
if (ctx.signal?.aborted)
|
|
554
641
|
return "aborted";
|
|
642
|
+
logAskOpened(kind, toolName);
|
|
643
|
+
const openedAt = Date.now();
|
|
555
644
|
const options = [
|
|
556
645
|
ASK_YES,
|
|
557
646
|
...(rememberLabel ? [rememberLabel] : []),
|
|
@@ -559,24 +648,20 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
559
648
|
ASK_NO,
|
|
560
649
|
];
|
|
561
650
|
let choice;
|
|
651
|
+
let selectThrew = false;
|
|
562
652
|
try {
|
|
563
653
|
choice = await ctx.ui.select(title, options, {
|
|
564
654
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
565
|
-
timeout: ASK_TIMEOUT_MS,
|
|
566
655
|
});
|
|
567
656
|
}
|
|
568
657
|
catch {
|
|
569
|
-
|
|
658
|
+
selectThrew = true;
|
|
570
659
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
return "rule";
|
|
577
|
-
if (choice === ASK_NO)
|
|
578
|
-
return "no";
|
|
579
|
-
return ctx.signal?.aborted ? "aborted" : "dismissed";
|
|
660
|
+
const resolution = selectThrew
|
|
661
|
+
? thrownChoiceResolution(ctx.signal?.aborted ?? false)
|
|
662
|
+
: resolveChoice(choice, { rememberLabel, ruleLabel, aborted: ctx.signal?.aborted ?? false });
|
|
663
|
+
logAskResolved(kind, toolName, resolution, openedAt);
|
|
664
|
+
return resolution;
|
|
580
665
|
};
|
|
581
666
|
const buildAskTitle = (command, rationale, riskLevel) => {
|
|
582
667
|
const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
|
|
@@ -666,7 +751,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
666
751
|
if (ruleAskApprovals.has(askKey))
|
|
667
752
|
return {};
|
|
668
753
|
const origin = ruleVerdict.rule.source === "user" ? "your user settings" : ruleVerdict.rule.source === "local" ? "the project's local settings" : "the project's settings";
|
|
669
|
-
const choice = await askUser(ctx, `Permission rule (ask) in ${origin}:\n${ruleVerdict.rule.raw}\nAllow ${event.toolName}?`, null);
|
|
754
|
+
const choice = await askUser(ctx, `Permission rule (ask) in ${origin}:\n${ruleVerdict.rule.raw}\nAllow ${event.toolName}?`, null, "rule", event.toolName);
|
|
670
755
|
if (choice === "yes") {
|
|
671
756
|
if (ruleAskApprovals.size > APPROVED_CACHE_MAX)
|
|
672
757
|
ruleAskApprovals.clear();
|
|
@@ -816,7 +901,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
816
901
|
if (ctx?.hasUI && !breakerEscalationOffered && !ctx.signal?.aborted) {
|
|
817
902
|
breakerEscalationOffered = true;
|
|
818
903
|
const title = `Guardian denied ${guardianState.read().consecutiveDenials} commands in a row.\nAllow the latest command anyway?\n$ ${boundedCommand(command)}`;
|
|
819
|
-
const resolution = await askUser(ctx, title, null);
|
|
904
|
+
const resolution = await askUser(ctx, title, null, "breaker");
|
|
820
905
|
if (resolution === "yes") {
|
|
821
906
|
guardianState.resetTurn();
|
|
822
907
|
rememberApproved(cwd, command);
|
|
@@ -984,7 +1069,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
984
1069
|
const ruleLabel = grantCandidate && deps.persistUserRule
|
|
985
1070
|
? `Yes, and always allow \`${grantCandidate.pattern.join(" ")}\` in this project's local settings`
|
|
986
1071
|
: null;
|
|
987
|
-
const resolution = await askUserWithOptions(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel, ruleLabel);
|
|
1072
|
+
const resolution = await askUserWithOptions(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel, ruleLabel, "guardian_verdict", "bash");
|
|
988
1073
|
if (resolution === "yes") {
|
|
989
1074
|
rememberApproved(cwd, command);
|
|
990
1075
|
emitGateEvent(slot, {
|
|
@@ -1084,7 +1169,10 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1084
1169
|
reason: "The user declined this command. Ask what they would like to do differently, or take a different approach.",
|
|
1085
1170
|
};
|
|
1086
1171
|
}
|
|
1087
|
-
// dismissed
|
|
1172
|
+
// dismissed or UI-layer error — neutral reason, no "denied"
|
|
1173
|
+
// spin. (No timeout can land here anymore: the dialog waits
|
|
1174
|
+
// indefinitely, so this is only a real ESC/dismiss — or the
|
|
1175
|
+
// select itself failed, which fails closed the same way.)
|
|
1088
1176
|
return {
|
|
1089
1177
|
block: true,
|
|
1090
1178
|
reason: "The permission dialog was dismissed; the command was not run. Ask the user how to proceed.",
|
|
@@ -1112,7 +1200,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1112
1200
|
// bounded per prompt so an outage can't become an ask storm.
|
|
1113
1201
|
errorFallbackAsks += 1;
|
|
1114
1202
|
const errorMsg = guardianErrorMessage(error);
|
|
1115
|
-
const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null);
|
|
1203
|
+
const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null, "error_fallback");
|
|
1116
1204
|
if (resolution === "yes") {
|
|
1117
1205
|
rememberApproved(cwd, command);
|
|
1118
1206
|
emitGateEvent(slot, { ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
|
package/dist/upgrade.js
CHANGED
|
@@ -249,13 +249,22 @@ export function parseUpgradeArgs(args) {
|
|
|
249
249
|
}
|
|
250
250
|
return { ok: true, target, method };
|
|
251
251
|
}
|
|
252
|
+
// npm's informational channels (deprecation warnings, the funding notice,
|
|
253
|
+
// and npm ≥11.13's `allowScripts` notice) are noise under an upgrade the user
|
|
254
|
+
// explicitly asked for; errors and the added-packages summary stay visible.
|
|
255
|
+
// Note the allowScripts notice is advisory only: with the default (non-strict)
|
|
256
|
+
// policy npm still runs the listed scripts, so quieting it changes nothing
|
|
257
|
+
// functionally. Audit findings go to stdout, unaffected by --loglevel.
|
|
258
|
+
const NPM_QUIET_FLAGS = ["--loglevel=error", "--no-fund", "--no-audit"];
|
|
252
259
|
function installArgv(method, target) {
|
|
253
260
|
// brew upgrades to whatever the tap formula publishes; it cannot pin a
|
|
254
261
|
// version, so `target` only tells us an upgrade is worthwhile.
|
|
255
262
|
if (method === "brew")
|
|
256
263
|
return ["upgrade", BREW_FORMULA];
|
|
257
264
|
const spec = `${PACKAGE_NAME}@${target}`;
|
|
258
|
-
return method === "npm"
|
|
265
|
+
return method === "npm"
|
|
266
|
+
? ["install", "-g", ...NPM_QUIET_FLAGS, spec]
|
|
267
|
+
: ["add", "-g", spec];
|
|
259
268
|
}
|
|
260
269
|
function failureHint(method) {
|
|
261
270
|
if (method === "brew") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.0.8-staging.
|
|
3
|
+
"version": "1.0.8-staging.1280.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": "9b213ea65690a4544d0cdf093b6903aab8a1456b"
|
|
62
62
|
}
|