@yagni-app/code 1.0.7 → 1.0.9
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/README.md +12 -1
- package/dist/extension/askUserQuestionTool.js +7 -2
- package/dist/extension/config.d.ts +6 -0
- package/dist/extension/hooks.d.ts +3 -3
- package/dist/extension/hooks.js +30 -5
- package/dist/extension/index.d.ts +6 -0
- package/dist/extension/index.js +56 -38
- package/dist/extension/permission/gate.d.ts +5 -1
- package/dist/extension/permission/gate.js +138 -44
- package/dist/extension/permissionRules/loadConfig.d.ts +23 -12
- package/dist/extension/permissionRules/loadConfig.js +29 -14
- package/dist/extension/permissionRules/pathRules.d.ts +9 -7
- package/dist/extension/permissionRules/pathRules.js +10 -8
- package/dist/extension/sandbox/config.d.ts +15 -14
- package/dist/extension/sandbox/config.js +62 -40
- package/dist/extension/sandbox/manager.d.ts +10 -0
- package/dist/extension/sandbox/manager.js +28 -1
- package/dist/extension/sandbox/session.js +150 -96
- package/dist/extension/settingsFiles.d.ts +50 -0
- package/dist/extension/settingsFiles.js +206 -0
- package/dist/extension/telemetry/attrs.d.ts +7 -0
- package/dist/extension/telemetry/attrs.js +7 -0
- package/dist/extension/telemetry/config.d.ts +5 -1
- package/dist/extension/telemetry/register.d.ts +7 -0
- package/dist/extension/telemetry/register.js +15 -0
- package/dist/extension/telemetry/tracker.js +8 -1
- package/dist/upgrade.js +10 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -203,7 +203,18 @@ prefix instead of `claude_code`:
|
|
|
203
203
|
- **Log events:** `user_prompt`, `assistant_response`, `api_request`,
|
|
204
204
|
`api_error`, `tool_result`, `tool_decision`, `permission_mode_changed`
|
|
205
205
|
(as `event.name`, body `yagni_code.<name>`), with `session.id`,
|
|
206
|
-
`organization.id`, `terminal.type`, and a per-prompt
|
|
206
|
+
`organization.id`, `user.email`, `terminal.type`, and a per-prompt
|
|
207
|
+
`prompt.id`.
|
|
208
|
+
|
|
209
|
+
Every signal carries `organization.id` (the workspace) and `user.email` (the
|
|
210
|
+
signed-in developer's account email, learned from the backend at session
|
|
211
|
+
start) so a collector can attribute sessions and spend per person. The LLM
|
|
212
|
+
span additionally declares `user.email` as a Datadog cost tag (the
|
|
213
|
+
`_dd.ml_obs.metadata` attribute, `{"_dd":{"cost_tags":["user.email"]}}`), so
|
|
214
|
+
Datadog's LLM Observability Cost page offers it under Custom Tags → Group by
|
|
215
|
+
and breaks spend down per developer. `OTEL_METRICS_INCLUDE_ACCOUNT_UUID=false`
|
|
216
|
+
drops the email, and that declaration, from all three signals. Prompt,
|
|
217
|
+
response, and tool content never export.
|
|
207
218
|
|
|
208
219
|
Nothing is exported unless you configure an endpoint. Enable it one of three
|
|
209
220
|
ways (first match wins):
|
|
@@ -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) => {
|
|
@@ -127,6 +127,12 @@ export interface ContextBrief {
|
|
|
127
127
|
* The opt-in mining beat fires ONLY on an explicit 0.
|
|
128
128
|
*/
|
|
129
129
|
repoDecisionCount?: number;
|
|
130
|
+
/**
|
|
131
|
+
* The signed-in developer's account email (additive; absent on older
|
|
132
|
+
* backends). Feeds the OTel export's `user.email` attribute when the
|
|
133
|
+
* launcher did not already forward one.
|
|
134
|
+
*/
|
|
135
|
+
userEmail?: string;
|
|
130
136
|
}
|
|
131
137
|
/**
|
|
132
138
|
* Attribution headers for the model proxy (YAG-471). On the SERVER side these
|
|
@@ -26,8 +26,8 @@ export interface HookEntry {
|
|
|
26
26
|
export interface HookGroup {
|
|
27
27
|
matcher?: string;
|
|
28
28
|
hooks: HookEntry[];
|
|
29
|
-
/** Where this group was loaded from. Project
|
|
30
|
-
_source?: "user" | "project";
|
|
29
|
+
/** Where this group was loaded from. Project AND local groups are gated on workspace trust. */
|
|
30
|
+
_source?: "user" | "project" | "local";
|
|
31
31
|
}
|
|
32
32
|
/** The hooks section of config.json. */
|
|
33
33
|
export type HooksConfig = Record<string, HookGroup[]>;
|
|
@@ -55,7 +55,7 @@ export interface HookRunner {
|
|
|
55
55
|
preToolUse(toolName: string, input: Record<string, unknown>, cwd: string, trusted?: boolean): Promise<PreToolUseHookResult>;
|
|
56
56
|
permissionRequest(toolName: string, input: Record<string, unknown>, cwd: string, trusted?: boolean): Promise<PermissionRequestHookResult>;
|
|
57
57
|
}
|
|
58
|
-
/** Read and merge hooks config from user and
|
|
58
|
+
/** Read and merge hooks config from user, project, and local files. Pure I/O, fail-soft. */
|
|
59
59
|
export declare function loadHooksConfig(userHome?: string, cwd?: string, env?: NodeJS.ProcessEnv): HooksConfig;
|
|
60
60
|
/**
|
|
61
61
|
* Check if a matcher matches a tool name. Matches Claude Code / Codex:
|
package/dist/extension/hooks.js
CHANGED
|
@@ -36,7 +36,7 @@ const SUPPORTED_EVENTS = [
|
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
37
37
|
// Config loading
|
|
38
38
|
// ---------------------------------------------------------------------------
|
|
39
|
-
/** Read and merge hooks config from user and
|
|
39
|
+
/** Read and merge hooks config from user, project, and local files. Pure I/O, fail-soft. */
|
|
40
40
|
export function loadHooksConfig(userHome = homedir(), cwd = process.cwd(), env = process.env) {
|
|
41
41
|
if (env.YAGNI_CODE_EVAL_MODE === "1")
|
|
42
42
|
return {};
|
|
@@ -47,6 +47,13 @@ export function loadHooksConfig(userHome = homedir(), cwd = process.cwd(), env =
|
|
|
47
47
|
// Project-level: .yagni-code/config.json — tagged for trust gating at execution time
|
|
48
48
|
const projectPath = join(cwd, ".yagni-code", "config.json");
|
|
49
49
|
mergeHooksFromFile(merged, projectPath, "project");
|
|
50
|
+
// Local-level: .yagni-code/config.local.json — the personal per-project
|
|
51
|
+
// tier. Trust-gated like project (NOT always-trusted): a hostile repo can
|
|
52
|
+
// COMMIT a local file — the gitignore convention only covers untracked
|
|
53
|
+
// files. Deliberately stricter than Claude Code, which does not
|
|
54
|
+
// trust-gate its localSettings hooks.
|
|
55
|
+
const localPath = join(cwd, ".yagni-code", "config.local.json");
|
|
56
|
+
mergeHooksFromFile(merged, localPath, "local");
|
|
50
57
|
return merged;
|
|
51
58
|
}
|
|
52
59
|
function mergeHooksFromFile(merged, path, source) {
|
|
@@ -68,8 +75,20 @@ function mergeHooksFromFile(merged, path, source) {
|
|
|
68
75
|
}
|
|
69
76
|
}
|
|
70
77
|
}
|
|
71
|
-
catch {
|
|
72
|
-
// Fail-soft: missing or malformed config is
|
|
78
|
+
catch (err) {
|
|
79
|
+
// Fail-soft: missing or malformed config is skipped — and LOGGED: a
|
|
80
|
+
// corrupt local/project config silently dropping its hooks (including
|
|
81
|
+
// deny-enforcing ones) with zero signal is undiagnosable. Path + error
|
|
82
|
+
// class only, matching loadConfig.ts's warning behavior.
|
|
83
|
+
logEvent({
|
|
84
|
+
source: "hooks",
|
|
85
|
+
level: "warn",
|
|
86
|
+
event: "config_warning",
|
|
87
|
+
fields: {
|
|
88
|
+
path,
|
|
89
|
+
error: err instanceof Error ? err.constructor.name : typeof err,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
73
92
|
}
|
|
74
93
|
}
|
|
75
94
|
function isValidHookGroup(value) {
|
|
@@ -297,11 +316,17 @@ function logHookEvent(env, payload) {
|
|
|
297
316
|
fields,
|
|
298
317
|
});
|
|
299
318
|
}
|
|
300
|
-
/**
|
|
319
|
+
/**
|
|
320
|
+
* Filter hook groups by workspace trust: project AND local groups are
|
|
321
|
+
* skipped when untrusted. Local is gated exactly like project (not like the
|
|
322
|
+
* always-trusted user tier) because a hostile repo can COMMIT a
|
|
323
|
+
* config.local.json — the global-gitignore convention only covers
|
|
324
|
+
* untracked files. Deliberately stricter than Claude Code's localSettings.
|
|
325
|
+
*/
|
|
301
326
|
function filterByTrust(groups, isTrusted) {
|
|
302
327
|
if (isTrusted)
|
|
303
328
|
return groups;
|
|
304
|
-
return groups.filter((g) => g._source !== "project");
|
|
329
|
+
return groups.filter((g) => g._source !== "project" && g._source !== "local");
|
|
305
330
|
}
|
|
306
331
|
/**
|
|
307
332
|
* Create a HookRunner for injection into the permission gate. The `isTrusted`
|
|
@@ -3,6 +3,7 @@ import { type SpendResponse } from "./costHud.js";
|
|
|
3
3
|
import { runInitPass as defaultRunInitPass } from "./initPass.js";
|
|
4
4
|
import { startMcp as defaultStartMcp } from "./mcp/startup.js";
|
|
5
5
|
import { type GuardianGateEvent } from "./permission/gate.js";
|
|
6
|
+
import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
|
|
6
7
|
import { type PluginCliRunner } from "./plugins/panel.js";
|
|
7
8
|
import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
|
|
8
9
|
import { type TokenProvider } from "./tokenProvider.js";
|
|
@@ -67,6 +68,11 @@ export interface RegisterYagniDeps {
|
|
|
67
68
|
* fresh-workspace first-run and is skipped otherwise, without a network or disk.
|
|
68
69
|
*/
|
|
69
70
|
runInitPass?: typeof defaultRunInitPass;
|
|
71
|
+
/**
|
|
72
|
+
* OTel export registration. Injectable so tests assert the boot handoff of
|
|
73
|
+
* the /context `userEmail` into the telemetry identity without an SDK.
|
|
74
|
+
*/
|
|
75
|
+
registerTelemetry?: typeof defaultRegisterTelemetry;
|
|
70
76
|
/** The workspace id the token is bound to (keys the one-time init marker). */
|
|
71
77
|
getWorkspaceId?: () => string | undefined;
|
|
72
78
|
/** Has the init pass already run once for this workspace? Injectable (no disk in tests). */
|
package/dist/extension/index.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
5
3
|
import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
|
|
6
4
|
import { makeChildUsageState } from "./childUsage.js";
|
|
@@ -26,6 +24,7 @@ import { logEvent } from "./errorSink.js";
|
|
|
26
24
|
import { registerFeedbackCommands } from "./feedbackCommand.js";
|
|
27
25
|
import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
|
|
28
26
|
import { codeStateHome } from "./stateHome.js";
|
|
27
|
+
import { mutateLocalConfig } from "./settingsFiles.js";
|
|
29
28
|
import { logTurnLifecycle } from "./turnLog.js";
|
|
30
29
|
import { createYagniFooterFactory, cyclePermissionMode, formatCwd, GIT_MUTATING_PATTERN, isShiftTab } from "./footer.js";
|
|
31
30
|
import { RerouteNotifier } from "./rerouteNotice.js";
|
|
@@ -38,7 +37,7 @@ import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
|
|
|
38
37
|
import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
|
|
39
38
|
import { registerSandbox } from "./sandbox/session.js";
|
|
40
39
|
import { sandboxAutoAllowDecision } from "./sandbox/bash.js";
|
|
41
|
-
import { registerTelemetry } from "./telemetry/register.js";
|
|
40
|
+
import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
|
|
42
41
|
import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
|
|
43
42
|
import { loadPermissionRules } from "./permissionRules/loadConfig.js";
|
|
44
43
|
import { registerPluginPanel } from "./plugins/panel.js";
|
|
@@ -422,7 +421,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
422
421
|
// OTel export (traces, metrics, log events; Claude Code parity). A no-op
|
|
423
422
|
// unless the launcher's gate set YAGNI_OTEL_EXPORT=1 — see the CLI's
|
|
424
423
|
// otel.ts for the sources and src/telemetry for the exporter.
|
|
425
|
-
const telemetry = registerTelemetry(pi, { env });
|
|
424
|
+
const telemetry = (deps.registerTelemetry ?? defaultRegisterTelemetry)(pi, { env });
|
|
426
425
|
{
|
|
427
426
|
let previousMode = modeHolder.get();
|
|
428
427
|
modeHolder.onSet((m) => {
|
|
@@ -607,45 +606,41 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
607
606
|
if (!evalMode)
|
|
608
607
|
appendGrant(grant);
|
|
609
608
|
},
|
|
610
|
-
// persist a
|
|
611
|
-
// option
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
609
|
+
// persist a project-local allow rule (Guardian ask dialog's third
|
|
610
|
+
// option — Claude Code's own default save destination for personal
|
|
611
|
+
// rules is localSettings; ours matches). Atomic write, never overwrites
|
|
612
|
+
// other keys. THROWS on failure — the gate's catch owns the fail-soft
|
|
613
|
+
// behavior (notify the user, record ask_approved not remembered); a
|
|
614
|
+
// catch HERE would swallow the throw and make that path unreachable,
|
|
615
|
+
// leaving the user believing a rule saved that will not survive restart.
|
|
616
|
+
// Local writes fire-and-forget the global gitignore helper so the file
|
|
617
|
+
// is never committed by accident.
|
|
618
|
+
//
|
|
619
|
+
// The cwd is the GATE's per-call session cwd (a session can switch
|
|
620
|
+
// directories; process.cwd() would silently land the rule in another
|
|
621
|
+
// project's local settings while the dialog claims this one). Not
|
|
622
|
+
// registered in eval mode: the gate hides the save option when the dep
|
|
623
|
+
// is absent, so a no-op implementation that would still emit
|
|
624
|
+
// ask_approved_remembered never exists.
|
|
625
|
+
...(evalMode ? {} : {
|
|
626
|
+
persistUserRule: (ruleString, cwd) => {
|
|
627
|
+
mutateLocalConfig(cwd, (config) => {
|
|
628
|
+
const perms = (config.permissions ?? {});
|
|
629
|
+
const allow = Array.isArray(perms.allow) ? [...perms.allow] : [];
|
|
630
|
+
if (!allow.includes(ruleString))
|
|
631
|
+
allow.push(ruleString);
|
|
632
|
+
perms.allow = allow;
|
|
633
|
+
config.permissions = perms;
|
|
634
|
+
});
|
|
632
635
|
logEvent({
|
|
633
636
|
source: "permission-rules",
|
|
634
637
|
level: "info",
|
|
635
|
-
event: "
|
|
638
|
+
event: "rule_saved",
|
|
636
639
|
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
637
|
-
fields: { rule: ruleString },
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
catch (err) {
|
|
641
|
-
logEvent({
|
|
642
|
-
source: "permission-rules",
|
|
643
|
-
level: "warn",
|
|
644
|
-
event: "user_rule_save_failed",
|
|
645
|
-
fields: { message: err instanceof Error ? err.message : "unknown" },
|
|
640
|
+
fields: { rule: ruleString, destination: "local" },
|
|
646
641
|
});
|
|
647
|
-
}
|
|
648
|
-
},
|
|
642
|
+
},
|
|
643
|
+
}),
|
|
649
644
|
// Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
|
|
650
645
|
// "off" → nothing (not even sent); "hash" → sha256 + family prefix +
|
|
651
646
|
// metadata, no command content; "raw" → adds client-REDACTED command and
|
|
@@ -845,6 +840,29 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
845
840
|
contextBrief = undefined;
|
|
846
841
|
}
|
|
847
842
|
}
|
|
843
|
+
// The developer's account email for the OTel export (`user.email`), so a
|
|
844
|
+
// collector can attribute sessions per person. Set before session_start
|
|
845
|
+
// fires, so even the session.count metric carries it. No-op when export is
|
|
846
|
+
// off or the launcher already forwarded YAGNI_USER_EMAIL.
|
|
847
|
+
const bootUserEmail = typeof briefResult?.userEmail === "string" ? briefResult.userEmail : undefined;
|
|
848
|
+
telemetry.setUserEmail(bootUserEmail);
|
|
849
|
+
if (telemetry.enabled && !bootUserEmail && !telemetry.config.identity.userEmail) {
|
|
850
|
+
// Attribution would otherwise degrade silently: leave a trail in the local
|
|
851
|
+
// error sink so "why is user.email missing in the collector" is answerable.
|
|
852
|
+
// Info, not warn: one line per session start, and an account with no
|
|
853
|
+
// email hits it by design. The fields name each way the email can be
|
|
854
|
+
// absent — the boot fetch failed or lacked the field, and whether the
|
|
855
|
+
// launcher's YAGNI_USER_EMAIL was unset or forwarded blank.
|
|
856
|
+
logEvent({
|
|
857
|
+
source: "telemetry",
|
|
858
|
+
level: "info",
|
|
859
|
+
event: "user_email_missing",
|
|
860
|
+
fields: {
|
|
861
|
+
contextFetch: briefResult === null ? "failed_or_skipped" : "no_email_field",
|
|
862
|
+
launcherEmail: env.YAGNI_USER_EMAIL === undefined ? "unset" : "blank",
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
}
|
|
848
866
|
// M1 ambient judgment recall: after a `read`, append the recorded judgment for
|
|
849
867
|
// that path to the tool result (result modification is supported on pi's
|
|
850
868
|
// `tool_result` event — verified against 0.80.2). No-op when the corpus is thin
|
|
@@ -231,7 +231,11 @@ export interface RegisterPermissionDeps {
|
|
|
231
231
|
* Wired by index.ts; offered as a third option on Guardian ask dialogs.
|
|
232
232
|
* Fail-soft: the in-session approval applies even if the write fails.
|
|
233
233
|
*/
|
|
234
|
-
|
|
234
|
+
/** Persist an allow rule for the caller; the cwd is the SESSION cwd of
|
|
235
|
+
* the gate call (may differ from process.cwd() when the session switches
|
|
236
|
+
* directories) — the rule must land in the project the user is working
|
|
237
|
+
* in, the same cwd the gate uses for grants and approvals. */
|
|
238
|
+
persistUserRule?: (ruleString: string, cwd: string) => void;
|
|
235
239
|
/**
|
|
236
240
|
* Called (fire-and-forget) at every terminal prompt-band outcome with the
|
|
237
241
|
* rich storage event (raw command — the wiring layer redacts/hashes).
|
|
@@ -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})` : "";
|
|
@@ -642,7 +727,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
642
727
|
}
|
|
643
728
|
catch { /* logging must never affect the gate */ }
|
|
644
729
|
if (ruleVerdict.verdict === "deny") {
|
|
645
|
-
const origin = ruleVerdict.rule.source === "
|
|
730
|
+
const origin = ruleVerdict.rule.source === "user" ? "your user settings" : ruleVerdict.rule.source === "local" ? "the project's local settings" : "the project's settings";
|
|
646
731
|
return {
|
|
647
732
|
block: true,
|
|
648
733
|
reason: `${event.toolName} was denied by a permission rule in ${origin} (${ruleVerdict.rule.raw}). Do not attempt the same outcome via a workaround or indirect execution — ask the user to change the rule if this action is genuinely needed.`,
|
|
@@ -665,8 +750,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
665
750
|
const askKey = ruleAskKey(event.toolName, ruleVerdict.rule.raw, input);
|
|
666
751
|
if (ruleAskApprovals.has(askKey))
|
|
667
752
|
return {};
|
|
668
|
-
const origin = ruleVerdict.rule.source === "
|
|
669
|
-
const choice = await askUser(ctx, `Permission rule (ask) in ${origin}:\n${ruleVerdict.rule.raw}\nAllow ${event.toolName}?`, null);
|
|
753
|
+
const origin = ruleVerdict.rule.source === "user" ? "your user settings" : ruleVerdict.rule.source === "local" ? "the project's local settings" : "the project's settings";
|
|
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);
|
|
@@ -982,9 +1067,9 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
982
1067
|
? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
|
|
983
1068
|
: null;
|
|
984
1069
|
const ruleLabel = grantCandidate && deps.persistUserRule
|
|
985
|
-
? `Yes, and always allow \`${grantCandidate.pattern.join(" ")}\` in
|
|
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, {
|
|
@@ -1000,29 +1085,35 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1000
1085
|
if (resolution === "rule" && ruleCandidate && deps.persistUserRule) {
|
|
1001
1086
|
let persisted = false;
|
|
1002
1087
|
try {
|
|
1003
|
-
deps.persistUserRule(ruleCandidate);
|
|
1088
|
+
deps.persistUserRule(ruleCandidate, cwd);
|
|
1004
1089
|
persisted = true;
|
|
1005
1090
|
// Also covers this session like a grant would:
|
|
1006
1091
|
rememberApproved(cwd, command);
|
|
1007
1092
|
}
|
|
1008
1093
|
catch (err) {
|
|
1009
|
-
// Fail-soft: the in-memory approval
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1094
|
+
// Fail-soft: the in-memory approval covers ONLY this call —
|
|
1095
|
+
// rememberApproved sits after the throwing call, so a failed
|
|
1096
|
+
// persist does NOT remember the command for the session (a
|
|
1097
|
+
// narrower fallback than a grant, deliberately: the user's
|
|
1098
|
+
// durable choice did not land). But the user just made an
|
|
1099
|
+
// explicit durable choice in the dialog — a silent failure
|
|
1100
|
+
// would leave them believing a rule exists that will not
|
|
1101
|
+
// survive restart. Log the failure (rule string + the thrown
|
|
1102
|
+
// message — it is mutateConfigJson's own path/reason text,
|
|
1103
|
+
// never the raw parse error or file content) and tell the
|
|
1104
|
+
// user.
|
|
1015
1105
|
logEvent({
|
|
1016
1106
|
source: "permission-rules",
|
|
1017
1107
|
level: "warn",
|
|
1018
|
-
event: "
|
|
1108
|
+
event: "rule_save_failed",
|
|
1019
1109
|
fields: {
|
|
1020
1110
|
rule: ruleCandidate,
|
|
1021
|
-
|
|
1111
|
+
destination: "local",
|
|
1112
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1022
1113
|
},
|
|
1023
1114
|
});
|
|
1024
1115
|
if (ctx?.hasUI) {
|
|
1025
|
-
ctx.ui.notify(`Could not save the permission rule to your settings — it
|
|
1116
|
+
ctx.ui.notify(`Could not save the permission rule to your settings — it did not persist; you'll be asked again on the next identical command.`, "warning");
|
|
1026
1117
|
}
|
|
1027
1118
|
}
|
|
1028
1119
|
emitGateEvent(slot, {
|
|
@@ -1078,7 +1169,10 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1078
1169
|
reason: "The user declined this command. Ask what they would like to do differently, or take a different approach.",
|
|
1079
1170
|
};
|
|
1080
1171
|
}
|
|
1081
|
-
// 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.)
|
|
1082
1176
|
return {
|
|
1083
1177
|
block: true,
|
|
1084
1178
|
reason: "The permission dialog was dismissed; the command was not run. Ask the user how to proceed.",
|
|
@@ -1106,7 +1200,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1106
1200
|
// bounded per prompt so an outage can't become an ask storm.
|
|
1107
1201
|
errorFallbackAsks += 1;
|
|
1108
1202
|
const errorMsg = guardianErrorMessage(error);
|
|
1109
|
-
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");
|
|
1110
1204
|
if (resolution === "yes") {
|
|
1111
1205
|
rememberApproved(cwd, command);
|
|
1112
1206
|
emitGateEvent(slot, { ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
|