@yagni-app/code 1.0.5 → 1.0.7

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.
Files changed (111) hide show
  1. package/README.md +89 -27
  2. package/dist/claudeCompat.d.ts +59 -0
  3. package/dist/claudeCompat.js +109 -2
  4. package/dist/claudePlugins.d.ts +45 -3
  5. package/dist/claudePlugins.js +129 -19
  6. package/dist/cli.js +28 -4
  7. package/dist/doctor.d.ts +42 -4
  8. package/dist/doctor.js +150 -15
  9. package/dist/extension/condensedTools.d.ts +12 -1
  10. package/dist/extension/condensedTools.js +17 -9
  11. package/dist/extension/index.d.ts +18 -5
  12. package/dist/extension/index.js +319 -72
  13. package/dist/extension/mcp/approval.d.ts +45 -0
  14. package/dist/extension/mcp/approval.js +164 -0
  15. package/dist/extension/mcp/auth.d.ts +124 -0
  16. package/dist/extension/mcp/auth.js +560 -0
  17. package/dist/extension/mcp/authStore.d.ts +61 -0
  18. package/dist/extension/mcp/authStore.js +105 -0
  19. package/dist/extension/mcp/callbackPage.d.ts +31 -0
  20. package/dist/extension/mcp/callbackPage.js +222 -0
  21. package/dist/extension/mcp/cliConfig.d.ts +12 -0
  22. package/dist/extension/mcp/cliConfig.js +12 -0
  23. package/dist/extension/mcp/config.d.ts +153 -0
  24. package/dist/extension/mcp/config.js +381 -0
  25. package/dist/extension/mcp/log.d.ts +28 -0
  26. package/dist/extension/mcp/log.js +82 -0
  27. package/dist/extension/mcp/manager.d.ts +100 -0
  28. package/dist/extension/mcp/manager.js +273 -0
  29. package/dist/extension/mcp/names.d.ts +25 -0
  30. package/dist/extension/mcp/names.js +40 -0
  31. package/dist/extension/mcp/panel.d.ts +33 -0
  32. package/dist/extension/mcp/panel.js +268 -0
  33. package/dist/extension/mcp/prompts.d.ts +23 -0
  34. package/dist/extension/mcp/prompts.js +93 -0
  35. package/dist/extension/mcp/startup.d.ts +55 -0
  36. package/dist/extension/mcp/startup.js +152 -0
  37. package/dist/extension/mcp/tools.d.ts +31 -0
  38. package/dist/extension/mcp/tools.js +117 -0
  39. package/dist/extension/mcp/transports.d.ts +17 -0
  40. package/dist/extension/mcp/transports.js +44 -0
  41. package/dist/extension/permission/gate.d.ts +104 -2
  42. package/dist/extension/permission/gate.js +387 -31
  43. package/dist/extension/permission/guardian.d.ts +24 -5
  44. package/dist/extension/permission/guardian.js +162 -24
  45. package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
  46. package/dist/extension/permissionRules/bashFileArgs.js +236 -0
  47. package/dist/extension/permissionRules/engine.d.ts +50 -0
  48. package/dist/extension/permissionRules/engine.js +238 -0
  49. package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
  50. package/dist/extension/permissionRules/loadConfig.js +90 -0
  51. package/dist/extension/permissionRules/parser.d.ts +38 -0
  52. package/dist/extension/permissionRules/parser.js +136 -0
  53. package/dist/extension/permissionRules/pathRules.d.ts +58 -0
  54. package/dist/extension/permissionRules/pathRules.js +120 -0
  55. package/dist/extension/permissionRules/shellRules.d.ts +52 -0
  56. package/dist/extension/permissionRules/shellRules.js +221 -0
  57. package/dist/extension/pipeline/invocation.d.ts +3 -6
  58. package/dist/extension/pipeline/invocation.js +3 -6
  59. package/dist/extension/pipeline/personas.js +5 -0
  60. package/dist/extension/pipeline/runner.d.ts +0 -1
  61. package/dist/extension/pipeline/runner.js +6 -14
  62. package/dist/extension/plugins/inventory.d.ts +88 -0
  63. package/dist/extension/plugins/inventory.js +144 -0
  64. package/dist/extension/plugins/panel.d.ts +45 -0
  65. package/dist/extension/plugins/panel.js +293 -0
  66. package/dist/extension/sandbox/bash.d.ts +99 -0
  67. package/dist/extension/sandbox/bash.js +190 -0
  68. package/dist/extension/sandbox/config.d.ts +114 -0
  69. package/dist/extension/sandbox/config.js +366 -0
  70. package/dist/extension/sandbox/manager.d.ts +98 -0
  71. package/dist/extension/sandbox/manager.js +216 -0
  72. package/dist/extension/sandbox/panel.d.ts +111 -0
  73. package/dist/extension/sandbox/panel.js +342 -0
  74. package/dist/extension/sandbox/session.d.ts +85 -0
  75. package/dist/extension/sandbox/session.js +775 -0
  76. package/dist/extension/telemetry/attrs.d.ts +96 -0
  77. package/dist/extension/telemetry/attrs.js +149 -0
  78. package/dist/extension/telemetry/config.d.ts +99 -0
  79. package/dist/extension/telemetry/config.js +193 -0
  80. package/dist/extension/telemetry/index.d.ts +7 -0
  81. package/dist/extension/telemetry/index.js +7 -0
  82. package/dist/extension/telemetry/probe.d.ts +29 -0
  83. package/dist/extension/telemetry/probe.js +122 -0
  84. package/dist/extension/telemetry/register.d.ts +40 -0
  85. package/dist/extension/telemetry/register.js +192 -0
  86. package/dist/extension/telemetry/sdk.d.ts +63 -0
  87. package/dist/extension/telemetry/sdk.js +207 -0
  88. package/dist/extension/telemetry/tracker.d.ts +131 -0
  89. package/dist/extension/telemetry/tracker.js +551 -0
  90. package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
  91. package/dist/extension/vendor/ignore.d.ts +86 -0
  92. package/dist/extension/vendor/ignore.js +788 -0
  93. package/dist/goHeadless.d.ts +1 -1
  94. package/dist/goHeadless.js +2 -2
  95. package/dist/launch.d.ts +4 -3
  96. package/dist/launch.js +7 -4
  97. package/dist/mcpCommand.d.ts +122 -0
  98. package/dist/mcpCommand.js +787 -0
  99. package/dist/otel.d.ts +69 -63
  100. package/dist/otel.js +154 -119
  101. package/dist/paths.d.ts +13 -0
  102. package/dist/paths.js +18 -0
  103. package/dist/pluginCommand.d.ts +43 -0
  104. package/dist/pluginCommand.js +499 -0
  105. package/dist/pluginStore.d.ts +170 -0
  106. package/dist/pluginStore.js +554 -0
  107. package/dist/upgrade.d.ts +11 -2
  108. package/dist/upgrade.js +48 -8
  109. package/package.json +20 -3
  110. package/dist/extension/mcpTools.d.ts +0 -57
  111. package/dist/extension/mcpTools.js +0 -132
@@ -1,7 +1,9 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { type SpendResponse } from "./costHud.js";
3
3
  import { runInitPass as defaultRunInitPass } from "./initPass.js";
4
- import { fetchMcpServers as defaultFetchMcpServers } from "./mcpTools.js";
4
+ import { startMcp as defaultStartMcp } from "./mcp/startup.js";
5
+ import { type GuardianGateEvent } from "./permission/gate.js";
6
+ import { type PluginCliRunner } from "./plugins/panel.js";
5
7
  import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
6
8
  import { type TokenProvider } from "./tokenProvider.js";
7
9
  import { type CatalogResult, type ContextBrief } from "./config.js";
@@ -53,11 +55,13 @@ export interface RegisterYagniDeps {
53
55
  /** The beat's once-per-repo marker store. Injectable (no disk in tests). */
54
56
  mineBeatMarkers?: MineBeatMarkers;
55
57
  /**
56
- * Workspace MCP server list seam (YAG-446). The default hits
57
- * `/api/yagni-code/mcp/servers` and is fail-soft: older backends and
58
- * un-rescoped tokens yield `[]`, never a startup error.
58
+ * Local-MCP startup seam: injectable so tests assert the wiring (approval
59
+ * skip, kill-switch, mutating-tool gate) without touching real config
60
+ * homes or spawning servers. Default runs the real startup.
59
61
  */
60
- fetchMcpServers?: typeof defaultFetchMcpServers;
62
+ startMcp?: typeof defaultStartMcp;
63
+ /** Test seam for the /plugin panel: runs `yagni plugin …` (defaults to spawning YAGNI_CLI_PATH). */
64
+ runPluginCli?: PluginCliRunner;
61
65
  /**
62
66
  * The CLI init pass (Onramp Door B). Injectable so tests assert it fires on a
63
67
  * fresh-workspace first-run and is skipped otherwise, without a network or disk.
@@ -115,6 +119,15 @@ export declare function buildRunSpendUrl(baseUrl: string, runId: string): string
115
119
  * Returns null on anything that doesn't look like the real shape.
116
120
  */
117
121
  export declare function parseSpendResponse(data: unknown): SpendResponse | null;
122
+ /**
123
+ * Local trail for EVERY terminal gate outcome (sandbox auto-allow included),
124
+ * always on so e2e and support can see what the gate did even when the
125
+ * storage tier is off. Decision K: the always-on line carries outcome
126
+ * metadata ONLY — no command content. The command prefix and the exec
127
+ * justification are command/model content and ride the debug tier (never
128
+ * uploaded by /feedback) plus the opt-in storage stream.
129
+ */
130
+ export declare function logGateOutcomeTrail(ev: GuardianGateEvent, env: NodeJS.ProcessEnv): void;
118
131
  export declare function registerYagni(pi: ExtensionAPI, deps?: RegisterYagniDeps): Promise<void>;
119
132
  export default function (pi: ExtensionAPI): Promise<void>;
120
133
  export { makeAskYagniTool } from "./askYagniTool.js";
@@ -1,4 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { Text } from "@earendil-works/pi-tui";
3
5
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
4
6
  import { makeChildUsageState } from "./childUsage.js";
@@ -29,11 +31,17 @@ import { createYagniFooterFactory, cyclePermissionMode, formatCwd, GIT_MUTATING_
29
31
  import { RerouteNotifier } from "./rerouteNotice.js";
30
32
  import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
31
33
  import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
32
- import { fetchMcpServers as defaultFetchMcpServers, registerMcpCommand, registerMcpTools, } from "./mcpTools.js";
34
+ import { looksMutating } from "./mcp/tools.js";
35
+ import { startMcp as defaultStartMcp, wireShutdown, deriveStartupConnectivityNotices } from "./mcp/startup.js";
33
36
  import { registerGoCommand } from "./pipeline/goCommand.js";
34
37
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
35
38
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
39
+ import { registerSandbox } from "./sandbox/session.js";
40
+ import { sandboxAutoAllowDecision } from "./sandbox/bash.js";
41
+ import { registerTelemetry } from "./telemetry/register.js";
36
42
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
43
+ import { loadPermissionRules } from "./permissionRules/loadConfig.js";
44
+ import { registerPluginPanel } from "./plugins/panel.js";
37
45
  import { registerSubagents } from "./subagents.js";
38
46
  import { createUltraHolder, registerUltraCommand } from "./ultra.js";
39
47
  import { registerTodos } from "./todos.js";
@@ -98,6 +106,39 @@ export function parseSpendResponse(data) {
98
106
  return null;
99
107
  return d;
100
108
  }
109
+ /**
110
+ * Local trail for EVERY terminal gate outcome (sandbox auto-allow included),
111
+ * always on so e2e and support can see what the gate did even when the
112
+ * storage tier is off. Decision K: the always-on line carries outcome
113
+ * metadata ONLY — no command content. The command prefix and the exec
114
+ * justification are command/model content and ride the debug tier (never
115
+ * uploaded by /feedback) plus the opt-in storage stream.
116
+ */
117
+ export function logGateOutcomeTrail(ev, env) {
118
+ logEvent({
119
+ source: "guardian",
120
+ level: "info",
121
+ event: "gate_outcome",
122
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
123
+ fields: {
124
+ outcome: ev.outcome,
125
+ mode: ev.mode,
126
+ consulted: ev.consulted,
127
+ },
128
+ });
129
+ if (isDebug(env)) {
130
+ logEvent({
131
+ source: "guardian",
132
+ level: "debug",
133
+ event: "gate_outcome",
134
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
135
+ fields: {
136
+ commandPrefix: storagePrefix(ev.command),
137
+ ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
138
+ },
139
+ });
140
+ }
141
+ }
101
142
  export async function registerYagni(pi, deps = {}) {
102
143
  const baseUrl = deps.baseUrl ?? resolveBaseUrl();
103
144
  const now = deps.now ?? (() => Date.now());
@@ -238,6 +279,9 @@ export async function registerYagni(pi, deps = {}) {
238
279
  // before_agent_start handler below) and widens the tool's fan-out ceiling.
239
280
  const ultraHolder = createUltraHolder();
240
281
  registerSubagents(pi, { isUltra: () => ultraHolder.get(), workingLine, childUsage });
282
+ // /plugin + /reload-plugins: manage Claude Code marketplace plugins from the
283
+ // session, driving the launcher's `yagni plugin …` (YAGNI_CLI_PATH).
284
+ registerPluginPanel(pi, { env: deps.env, runCli: deps.runPluginCli });
241
285
  registerUltraCommand(pi, ultraHolder);
242
286
  // Shared fetch timeout for the small, interactive display-path reads below
243
287
  // (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
@@ -293,22 +337,100 @@ export async function registerYagni(pi, deps = {}) {
293
337
  const decisionCapture = evalMode ? undefined : makeDecisionCapture(decisionClientOpts);
294
338
  if (!evalMode)
295
339
  registerDecisionCommands(pi, decisionClientOpts);
296
- // Workspace MCP servers (YAG-446): register one tool per enabled server
297
- // tool, executing through the backend proxy, plus the /mcp listing
298
- // command. Fail-soft fetch (older backend / un-rescoped token no MCP
299
- // tools); skipped in eval mode like the other external-side-effect tools.
300
- const fetchMcp = deps.fetchMcpServers ?? defaultFetchMcpServers;
301
- const mcpServers = evalMode
302
- ? []
303
- : await fetchMcp({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
304
- const { mutatingToolNames: mcpMutatingTools } = registerMcpTools(pi, mcpServers, { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
305
- registerMcpCommand(pi, mcpServers, { baseUrl });
340
+ // Local MCP servers (standard client, replaces the backend-proxy /mcp of
341
+ // the YAG-446 era): load user/project/local config, approval-gate project
342
+ // servers, connect, register one tool per server tool plus /mcp panel.
343
+ // Fail-soft everywhere; skipped entirely in eval mode (external side
344
+ // effects) and under the YAGNI_CODE_MCP_DISABLED kill-switch.
345
+ const startMcpImpl = deps.startMcp ?? defaultStartMcp;
346
+ // One-shot notices queued by startMcp at session start (the `.mcp.json`
347
+ // approval prompt); flushed once on the first provider response. Connectivity
348
+ // notices are NOT here they are recomputed live at flush (see below).
349
+ const mcpApprovalNotices = [];
350
+ const mcpOutcome = evalMode
351
+ ? { manager: undefined, mutatingToolNames: [] }
352
+ : await startMcpImpl(pi, {
353
+ cwd: process.cwd(),
354
+ env,
355
+ hasUI: true,
356
+ notify: (msg) => mcpApprovalNotices.push(msg),
357
+ });
358
+ const mcpMutatingSet = new Set(mcpOutcome.mutatingToolNames);
359
+ // Wire names carry the original tool name after the second `__`: parse it
360
+ // out and re-run the heuristic for tools registered after startup (a
361
+ // reconnect can re-register tools the original classification never saw).
362
+ const mcpStartupIsMutating = (wireName) => {
363
+ const parts = wireName.split("__");
364
+ const original = parts.slice(2).join("__");
365
+ if (!original)
366
+ return false;
367
+ return looksMutating(original);
368
+ };
369
+ if (!evalMode && mcpOutcome.manager) {
370
+ wireShutdown(pi, mcpOutcome.manager);
371
+ // Startup notify (documented CC deviation): one quiet line per notice on
372
+ // the first provider response — never raw stdout, never blocking.
373
+ //
374
+ // Connectivity notices are recomputed against LIVE manager state at flush
375
+ // time (not the frozen startMcp snapshot): if the user healed a server via
376
+ // /mcp before the first turn, it must no longer read as broken/unauth.
377
+ // The static queue only carries non-connectivity notices (e.g. the
378
+ // `.mcp.json` approval prompt), which stay one-shot.
379
+ let connectivityNoticesFlushed = false;
380
+ // permission-rule notices are one-shot like connectivity notices.
381
+ let rulesNoticesFlushed = false;
382
+ pi.on("after_provider_response", (_event, ctx) => {
383
+ try {
384
+ if (!ctx.hasUI)
385
+ return;
386
+ const connectivity = connectivityNoticesFlushed
387
+ ? []
388
+ : deriveStartupConnectivityNotices(mcpOutcome.manager);
389
+ connectivityNoticesFlushed = true;
390
+ for (const notice of connectivity)
391
+ ctx.ui.notify(notice, "info");
392
+ while (mcpApprovalNotices.length > 0) {
393
+ ctx.ui.notify(mcpApprovalNotices.shift(), "info");
394
+ }
395
+ // one-shot permission-rule config warnings (bad files,
396
+ // never-consulted tools). One consolidated line, never a wall.
397
+ if (!rulesNoticesFlushed) {
398
+ rulesNoticesFlushed = true;
399
+ const parts = [];
400
+ if (loadedRules.diagnostics.warnings.length > 0) {
401
+ parts.push(`${loadedRules.diagnostics.warnings.length} permission-rule config warning(s)`);
402
+ }
403
+ if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
404
+ parts.push(`rules for unknown tools: ${loadedRules.diagnostics.neverConsultedTools.join(", ")} (ignored)`);
405
+ }
406
+ if (parts.length > 0) {
407
+ ctx.ui.notify(`Permission rules: ${parts.join(" · ")}`, "warning");
408
+ }
409
+ }
410
+ }
411
+ catch {
412
+ // A notice must never break a turn.
413
+ }
414
+ });
415
+ }
306
416
  // P3 + W4: interactive permission tiers + plan mode via /mode, now with a
307
417
  // session bless store (three-way review-mode select) and a capture hook that
308
418
  // drafts a decision on "don't ask again". Default auto, so still additive.
309
419
  // Mutating MCP tools join write/edit/bash in the gate policy: plan mode
310
420
  // holds them, review mode confirms them.
311
421
  const modeHolder = createModeHolder();
422
+ // OTel export (traces, metrics, log events; Claude Code parity). A no-op
423
+ // unless the launcher's gate set YAGNI_OTEL_EXPORT=1 — see the CLI's
424
+ // otel.ts for the sources and src/telemetry for the exporter.
425
+ const telemetry = registerTelemetry(pi, { env });
426
+ {
427
+ let previousMode = modeHolder.get();
428
+ modeHolder.onSet((m) => {
429
+ if (m !== previousMode)
430
+ telemetry.permissionModeChanged(previousMode, m);
431
+ previousMode = m;
432
+ });
433
+ }
312
434
  const footerInvalidateHandle = { invalidateGit: () => { }, requestRender: () => { } };
313
435
  const guardianState = makeGuardianState();
314
436
  // Disabled by the local env override OR the workspace kill switch
@@ -336,12 +458,18 @@ export async function registerYagni(pi, deps = {}) {
336
458
  const guardianLogSink = (payload) => {
337
459
  const event = typeof payload.event === "string" ? payload.event : "guardian_event";
338
460
  const { event: _ignored, ...fields } = payload;
461
+ // `malformed` (repair failed — still really failing) and a failed
462
+ // telemetry POST are errors. `repaired: true` (the ladder salvaged the
463
+ // verdict) is a warn — it worked, but we keep counting it. Everything
464
+ // else is routine info.
465
+ const level = event === "guardian_event_post_failed" || payload.outcome === "malformed"
466
+ ? "error"
467
+ : payload.repaired === true && payload.outcome !== undefined
468
+ ? "warn"
469
+ : "info";
339
470
  logEvent({
340
471
  source: "guardian",
341
- // `malformed` is a real failure (this is the "unclear verdict" bug the
342
- // whole capture exists to diagnose) — an error, not routine info. The
343
- // failed-telemetry event is already an error.
344
- level: event === "guardian_event_post_failed" || payload.outcome === "malformed" ? "error" : "info",
472
+ level,
345
473
  event,
346
474
  sessionId: env.YAGNI_SESSION_ID ?? undefined,
347
475
  fields,
@@ -352,6 +480,25 @@ export async function registerYagni(pi, deps = {}) {
352
480
  // the startup load is the trust boundary; live reload was reviewed and
353
481
  // rejected as a same-session self-authorization path, PR #1698).
354
482
  const sessionGrants = evalMode ? [] : loadGrants();
483
+ // settings-based permission rules, loaded once at startup (same
484
+ // trust-boundary posture as grants: no mid-session reload). User config
485
+ // (~/.yagni-code/config.json) + project config (.yagni-code/config.json);
486
+ // project allow rules are trust-gated at evaluation time, deny/ask always.
487
+ const loadedRules = evalMode
488
+ ? { rules: [], diagnostics: { warnings: [], neverConsultedTools: [] } }
489
+ : loadPermissionRules({ cwd: process.cwd() });
490
+ for (const w of loadedRules.diagnostics.warnings) {
491
+ logEvent({ source: "permission-rules", level: "warn", event: "config_warning", fields: { warning: w } });
492
+ }
493
+ if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
494
+ logEvent({
495
+ source: "permission-rules",
496
+ level: "warn",
497
+ event: "never_consulted_tools",
498
+ fields: { tools: loadedRules.diagnostics.neverConsultedTools.join(", ") },
499
+ });
500
+ }
501
+ const rulesStateHome = codeStateHome(null, env);
355
502
  const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
356
503
  // YAG-506: load user-configurable lifecycle hooks config and create the
357
504
  // hook runner for the permission gate. Skipped in eval mode.
@@ -359,25 +506,75 @@ export async function registerYagni(pi, deps = {}) {
359
506
  const hookRunner = evalMode ? null : makeHookRunner({ config: hooksConfig });
360
507
  if (!evalMode)
361
508
  registerHooks(pi, { config: hooksConfig });
509
+ // OS-level bash sandboxing. Registers the sandbox-aware bash tool
510
+ // (dangerouslyDisableSandbox param + wrap + violation annotation) and the
511
+ // session lifecycle (init/reset, fail-soft unless failIfUnavailable).
512
+ // Null on unsupported platforms — stock bash stays. Shares the loaded
513
+ // permission rules so sandbox config and the gate read one rule source.
514
+ const sandboxHandle = evalMode
515
+ ? null
516
+ : registerSandbox(pi, {
517
+ cwd: process.cwd(),
518
+ env,
519
+ hasUI: (ctx) => ctx.hasUI,
520
+ // Anchors project-protected paths (.yagni-code + its config.json in
521
+ // denyWrite) and project-sourced permission rules to the repo the
522
+ // session runs in — same root the gate uses for its rule anchoring.
523
+ projectRoot: process.cwd(),
524
+ });
525
+ if (sandboxHandle)
526
+ sandboxHandle.setRules(loadedRules.rules);
362
527
  registerPermissionGate(pi, {
528
+ ...(sandboxHandle
529
+ ? {
530
+ sandboxAutoAllow: (toolName, params) => sandboxAutoAllowDecision(toolName, params, sandboxHandle.manager, sandboxHandle.settings()),
531
+ }
532
+ : {}),
363
533
  modeHolder,
364
534
  guardianState,
365
535
  guardianLimits,
366
536
  guardianTier,
537
+ onToolDecision: (ev) => telemetry.toolDecision(ev),
538
+ ...(loadedRules.rules.length > 0
539
+ ? {
540
+ permissionRules: loadedRules.rules,
541
+ rulesUserStateHome: rulesStateHome,
542
+ rulesProjectRoot: process.cwd(),
543
+ onRuleVerdict: (ev) => {
544
+ logEvent({
545
+ source: "permission-rules",
546
+ level: "debug",
547
+ event: `rule_${ev.verdict}`,
548
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
549
+ fields: {
550
+ tool: ev.toolName,
551
+ via: ev.matchedVia,
552
+ source: ev.rule.source,
553
+ rule: ev.rule.raw,
554
+ },
555
+ });
556
+ },
557
+ }
558
+ : {}),
367
559
  guardianDisabled,
368
560
  childUsage,
369
561
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
370
562
  onGuardianReview: (ev) => {
371
563
  guardianLogSink(ev);
372
- // A malformed verdict is a real failure on our side that must reach
373
- // Sentry for EVERY workspace (independent of the opt-in storage tier).
374
- // Fire-and-forget to the dedicated telemetry endpoint; fail-soft, and
564
+ // A malformed verdict (repair FAILED still really failing) is a real
565
+ // failure on our side that must reach Sentry for EVERY workspace
566
+ // (independent of the opt-in storage tier). A REPAIRED verdict (repair
567
+ // salvaged it) posts too, with outcome "malformed_repaired" so the
568
+ // backend can count salvage hits on their own Sentry issue. Both
569
+ // fire-and-forget to the dedicated telemetry endpoint; fail-soft, and
375
570
  // suppressed under test/eval so a CI run never phones prod.
376
- if (ev.outcome === "malformed" && !evalMode && !runningUnderTest(env)) {
571
+ const isMalformed = ev.outcome === "malformed";
572
+ const isRepaired = ev.outcome !== "malformed" && ev.repaired === true;
573
+ if ((isMalformed || isRepaired) && !evalMode && !runningUnderTest(env)) {
377
574
  void (async () => {
378
575
  try {
379
576
  const body = {
380
- outcome: ev.outcome,
577
+ outcome: isRepaired ? "malformed_repaired" : ev.outcome,
381
578
  ...(ev.rawOutput ? { rawOutput: ev.rawOutput } : {}),
382
579
  ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
383
580
  ...(ev.tier ? { tier: ev.tier } : {}),
@@ -410,69 +607,106 @@ export async function registerYagni(pi, deps = {}) {
410
607
  if (!evalMode)
411
608
  appendGrant(grant);
412
609
  },
610
+ // persist a user-level allow rule (Guardian ask dialog's third
611
+ // option). Atomic write, never overwrites other keys, fail-soft.
612
+ persistUserRule: (ruleString) => {
613
+ if (evalMode)
614
+ return;
615
+ try {
616
+ const userPath = join(rulesStateHome, "config.json");
617
+ let parsed = {};
618
+ if (existsSync(userPath)) {
619
+ parsed = JSON.parse(readFileSync(userPath, "utf-8"));
620
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
621
+ return;
622
+ }
623
+ const perms = (parsed.permissions ?? {});
624
+ const allow = Array.isArray(perms.allow) ? perms.allow : [];
625
+ if (!allow.includes(ruleString))
626
+ allow.push(ruleString);
627
+ perms.allow = allow;
628
+ parsed.permissions = perms;
629
+ const tmp = join(rulesStateHome, `.config.json.yagni-${process.pid}-${Date.now()}.tmp`);
630
+ writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
631
+ renameSync(tmp, userPath);
632
+ logEvent({
633
+ source: "permission-rules",
634
+ level: "info",
635
+ event: "user_rule_saved",
636
+ 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" },
646
+ });
647
+ }
648
+ },
413
649
  // Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
414
650
  // "off" → nothing (not even sent); "hash" → sha256 + family prefix +
415
651
  // metadata, no command content; "raw" → adds client-REDACTED command and
416
652
  // rationale. Fire-and-forget: one attempt, short timeout, failures logged
417
653
  // fail-soft to the unified sink (source:"guardian") — a storage outage
418
654
  // never touches the session.
419
- onGuardianEvent: guardianStorageTier === "off" || evalMode
420
- ? undefined
421
- : (ev) => {
422
- void (async () => {
423
- try {
424
- const body = {
425
- sessionId: env.YAGNI_SESSION_ID ?? null,
426
- commandHash: createHash("sha256").update(ev.command).digest("hex"),
427
- commandPrefix: storagePrefix(ev.command),
428
- outcome: ev.outcome,
429
- mode: ev.mode,
430
- ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
431
- ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
432
- ...(ev.tier ? { tier: ev.tier } : {}),
433
- ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
434
- };
435
- if (guardianStorageTier === "raw") {
436
- body.command = redactCommand(ev.command);
437
- if (ev.rationale)
438
- body.rationale = redactCommand(ev.rationale);
439
- }
440
- const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
441
- method: "POST",
442
- headers: {
443
- "content-type": "application/json",
444
- authorization: `Bearer ${getTokenFn() ?? ""}`,
445
- ...attributionHeaders(deps.env),
446
- },
447
- body: JSON.stringify(body),
448
- }, {
449
- fetchImpl: authedFetch,
450
- policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
451
- });
452
- if (!res.ok) {
453
- guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
454
- }
655
+ onGuardianEvent: (ev) => {
656
+ logGateOutcomeTrail(ev, env);
657
+ // Opt-in storage stream (YAG-510): tier decides what leaves the machine.
658
+ if (guardianStorageTier === "off" || evalMode)
659
+ return;
660
+ void (async () => {
661
+ try {
662
+ const body = {
663
+ sessionId: env.YAGNI_SESSION_ID ?? null,
664
+ commandHash: createHash("sha256").update(ev.command).digest("hex"),
665
+ commandPrefix: storagePrefix(ev.command),
666
+ outcome: ev.outcome,
667
+ mode: ev.mode,
668
+ ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
669
+ ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
670
+ ...(ev.tier ? { tier: ev.tier } : {}),
671
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
672
+ };
673
+ if (guardianStorageTier === "raw") {
674
+ body.command = redactCommand(ev.command);
675
+ if (ev.rationale)
676
+ body.rationale = redactCommand(ev.rationale);
455
677
  }
456
- catch {
457
- guardianLogSink({
458
- event: "guardian_event_post_failed",
459
- kind: "network",
460
- });
678
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
679
+ method: "POST",
680
+ headers: {
681
+ "content-type": "application/json",
682
+ authorization: `Bearer ${getTokenFn() ?? ""}`,
683
+ ...attributionHeaders(deps.env),
684
+ },
685
+ body: JSON.stringify(body),
686
+ }, {
687
+ fetchImpl: authedFetch,
688
+ policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
689
+ });
690
+ if (!res.ok) {
691
+ guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
461
692
  }
462
- })();
463
- },
464
- ...(mcpMutatingTools.length > 0
693
+ }
694
+ catch {
695
+ guardianLogSink({ event: "guardian_event_post_failed", kind: "network" });
696
+ }
697
+ })();
698
+ },
699
+ ...(mcpMutatingSet.size > 0 || mcpOutcome.manager
465
700
  ? {
466
701
  policy: {
467
- planBlockTools: [
468
- ...DEFAULT_PERMISSION_POLICY.planBlockTools,
469
- ...mcpMutatingTools,
470
- ],
471
- reviewConfirmTools: [
472
- ...DEFAULT_PERMISSION_POLICY.reviewConfirmTools,
473
- ...mcpMutatingTools,
474
- ],
702
+ planBlockTools: [...DEFAULT_PERMISSION_POLICY.planBlockTools],
703
+ reviewConfirmTools: [...DEFAULT_PERMISSION_POLICY.reviewConfirmTools],
475
704
  alwaysConfirmTools: DEFAULT_PERMISSION_POLICY.alwaysConfirmTools,
705
+ // Predicate (not a static spread): matches any registered
706
+ // mcp__* tool that startup classified as mutating, including
707
+ // tools re-registered after a /mcp reconnect.
708
+ isMutating: (toolName) => mcpMutatingSet.has(toolName) ||
709
+ (toolName.startsWith("mcp__") && mcpStartupIsMutating(toolName)),
476
710
  },
477
711
  }
478
712
  : {}),
@@ -693,12 +927,25 @@ export async function registerYagni(pi, deps = {}) {
693
927
  try {
694
928
  registerCondensedTools(pi, {
695
929
  ...(scratchpadDirPath ? { scratchpadDir: scratchpadDirPath } : {}),
930
+ // condensed owns the FINAL bash registration (it re-registers
931
+ // built-ins last), so the sandbox composes INTO it here. Identity when
932
+ // the sandbox is off.
933
+ ...(sandboxHandle ? { wrapBash: sandboxHandle.composeBash } : {}),
696
934
  });
697
935
  }
698
936
  catch {
699
937
  // Rendering must never break activation; pi's built-ins remain.
700
938
  }
701
939
  }
940
+ else if (sandboxHandle && env.YAGNI_CLASSIC_TOOL_ROWS !== "1") {
941
+ // Condensed inactive (eval mode / desktop): the sandbox's own
942
+ // registration is the one that reaches the tool registry. NOT under the
943
+ // classic-rows escape hatch — classic bash must stay pi's stock
944
+ // registration (the hatch's contract); a competing one would replace
945
+ // classic rendering with the sandbox's wrap. Desktop/eval still need
946
+ // the sandbox to reach bash, so the hatch is the one carve-out.
947
+ sandboxHandle.registerOwnBash();
948
+ }
702
949
  // Own the identity + inject live company context (and repo rules) on every
703
950
  // turn. The extension loads identically in every pi process this app spawns —
704
951
  // the interactive driver AND every `/go` stage child, subagent, and advisor
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Approval gate for project-scope `.mcp.json` servers, mirroring Claude Code's
3
+ * decision shape: a `.mcp.json` in the repo (shared, VCS-controlled) cannot
4
+ * spawn processes until the user has approved it. Choices live in the
5
+ * per-project entry of `~/.yagni-code/mcp.json` under Claude Code's exact key
6
+ * names (`enabledMcpjsonServers` / `disabledMcpjsonServers` /
7
+ * `enableAllProjectMcpServers`), so a decision made in one tool is respected
8
+ * by the other.
9
+ */
10
+ import { McpConfigError, UserMcpProjectEntry } from "./config.js";
11
+ export type ProjectApprovalDecision = "enabled" | "disabled" | "undecided";
12
+ export interface ProjectApprovalState {
13
+ enabledMcpjsonServers: string[];
14
+ disabledMcpjsonServers: string[];
15
+ enableAllProjectMcpServers: boolean;
16
+ }
17
+ /** Read the approval state for one project (abs path). Missing file/entry → fresh state. */
18
+ export declare function readProjectApproval(repoRoot: string): {
19
+ state: ProjectApprovalState;
20
+ errors: McpConfigError[];
21
+ };
22
+ /** Effective decision for one project-scope server. Undecided by default. */
23
+ export declare function decisionFor(state: ProjectApprovalState, serverName: string): ProjectApprovalDecision;
24
+ /** Which configured project servers await a decision (drives the session-start prompt). */
25
+ export declare function undecidedProjectServers(state: ProjectApprovalState, projectServerNames: string[]): string[];
26
+ /**
27
+ * Record a decision. `enableAll: true` mirrors Claude Code's "Yes, approve all
28
+ * project servers in this repo" (sets the blanket flag); a single enable/disable
29
+ * appends to the corresponding list. Idempotent.
30
+ */
31
+ export declare function recordProjectDecision(repoRoot: string, serverName: string | "all", decision: "enabled" | "disabled"): {
32
+ errors: McpConfigError[];
33
+ };
34
+ /** Clear this project's `.mcp.json` decisions (`yagni mcp reset-project-choices`). */
35
+ export declare function resetProjectChoices(repoRoot: string): {
36
+ errors: McpConfigError[];
37
+ };
38
+ /**
39
+ * Mutate `~/.yagni-code/mcp.json`'s per-project entry under a read-modify-write
40
+ * helper shared with CLI commands (add/remove at local scope).
41
+ */
42
+ export declare function updateProjectEntry(repoRoot: string, mutate: (entry: UserMcpProjectEntry) => void): {
43
+ errors: McpConfigError[];
44
+ };
45
+ //# sourceMappingURL=approval.d.ts.map