@yagni-app/code 1.0.6 → 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.
- package/README.md +84 -46
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -5
- package/dist/claudePlugins.js +129 -21
- package/dist/cli.js +16 -4
- package/dist/doctor.d.ts +21 -8
- package/dist/doctor.js +53 -28
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/index.d.ts +13 -0
- package/dist/extension/index.js +230 -43
- package/dist/extension/mcp/cliConfig.d.ts +1 -1
- package/dist/extension/mcp/cliConfig.js +1 -1
- package/dist/extension/mcp/config.d.ts +24 -2
- package/dist/extension/mcp/config.js +75 -3
- package/dist/extension/mcp/manager.d.ts +3 -1
- package/dist/extension/mcp/manager.js +2 -2
- package/dist/extension/mcp/panel.d.ts +0 -1
- package/dist/extension/mcp/panel.js +13 -3
- package/dist/extension/mcp/startup.js +8 -6
- package/dist/extension/permission/gate.d.ts +97 -2
- package/dist/extension/permission/gate.js +375 -26
- package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
- package/dist/extension/permissionRules/bashFileArgs.js +236 -0
- package/dist/extension/permissionRules/engine.d.ts +50 -0
- package/dist/extension/permissionRules/engine.js +238 -0
- package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
- package/dist/extension/permissionRules/loadConfig.js +90 -0
- package/dist/extension/permissionRules/parser.d.ts +38 -0
- package/dist/extension/permissionRules/parser.js +136 -0
- package/dist/extension/permissionRules/pathRules.d.ts +58 -0
- package/dist/extension/permissionRules/pathRules.js +120 -0
- package/dist/extension/permissionRules/shellRules.d.ts +52 -0
- package/dist/extension/permissionRules/shellRules.js +221 -0
- package/dist/extension/pipeline/invocation.d.ts +3 -6
- package/dist/extension/pipeline/invocation.js +3 -6
- package/dist/extension/pipeline/runner.d.ts +0 -1
- package/dist/extension/pipeline/runner.js +6 -14
- package/dist/extension/plugins/inventory.d.ts +88 -0
- package/dist/extension/plugins/inventory.js +144 -0
- package/dist/extension/plugins/panel.d.ts +45 -0
- package/dist/extension/plugins/panel.js +293 -0
- package/dist/extension/sandbox/bash.d.ts +99 -0
- package/dist/extension/sandbox/bash.js +190 -0
- package/dist/extension/sandbox/config.d.ts +114 -0
- package/dist/extension/sandbox/config.js +366 -0
- package/dist/extension/sandbox/manager.d.ts +98 -0
- package/dist/extension/sandbox/manager.js +216 -0
- package/dist/extension/sandbox/panel.d.ts +111 -0
- package/dist/extension/sandbox/panel.js +342 -0
- package/dist/extension/sandbox/session.d.ts +85 -0
- package/dist/extension/sandbox/session.js +775 -0
- package/dist/extension/telemetry/attrs.d.ts +96 -0
- package/dist/extension/telemetry/attrs.js +149 -0
- package/dist/extension/telemetry/config.d.ts +99 -0
- package/dist/extension/telemetry/config.js +193 -0
- package/dist/extension/telemetry/index.d.ts +7 -0
- package/dist/extension/telemetry/index.js +7 -0
- package/dist/extension/telemetry/probe.d.ts +29 -0
- package/dist/extension/telemetry/probe.js +122 -0
- package/dist/extension/telemetry/register.d.ts +40 -0
- package/dist/extension/telemetry/register.js +192 -0
- package/dist/extension/telemetry/sdk.d.ts +63 -0
- package/dist/extension/telemetry/sdk.js +207 -0
- package/dist/extension/telemetry/tracker.d.ts +131 -0
- package/dist/extension/telemetry/tracker.js +551 -0
- package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
- package/dist/extension/vendor/ignore.d.ts +86 -0
- package/dist/extension/vendor/ignore.js +788 -0
- package/dist/goHeadless.d.ts +1 -1
- package/dist/goHeadless.js +2 -2
- package/dist/launch.d.ts +4 -3
- package/dist/launch.js +7 -4
- package/dist/mcpCommand.d.ts +10 -1
- package/dist/mcpCommand.js +42 -10
- package/dist/otel.d.ts +67 -90
- package/dist/otel.js +152 -195
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +18 -0
- package/dist/pluginCommand.d.ts +43 -0
- package/dist/pluginCommand.js +499 -0
- package/dist/pluginStore.d.ts +170 -0
- package/dist/pluginStore.js +554 -0
- package/package.json +19 -3
package/dist/extension/index.js
CHANGED
|
@@ -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";
|
|
@@ -34,7 +36,12 @@ import { startMcp as defaultStartMcp, wireShutdown, deriveStartupConnectivityNot
|
|
|
34
36
|
import { registerGoCommand } from "./pipeline/goCommand.js";
|
|
35
37
|
import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
|
|
36
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";
|
|
37
42
|
import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
|
|
43
|
+
import { loadPermissionRules } from "./permissionRules/loadConfig.js";
|
|
44
|
+
import { registerPluginPanel } from "./plugins/panel.js";
|
|
38
45
|
import { registerSubagents } from "./subagents.js";
|
|
39
46
|
import { createUltraHolder, registerUltraCommand } from "./ultra.js";
|
|
40
47
|
import { registerTodos } from "./todos.js";
|
|
@@ -99,6 +106,39 @@ export function parseSpendResponse(data) {
|
|
|
99
106
|
return null;
|
|
100
107
|
return d;
|
|
101
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
|
+
}
|
|
102
142
|
export async function registerYagni(pi, deps = {}) {
|
|
103
143
|
const baseUrl = deps.baseUrl ?? resolveBaseUrl();
|
|
104
144
|
const now = deps.now ?? (() => Date.now());
|
|
@@ -239,6 +279,9 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
239
279
|
// before_agent_start handler below) and widens the tool's fan-out ceiling.
|
|
240
280
|
const ultraHolder = createUltraHolder();
|
|
241
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 });
|
|
242
285
|
registerUltraCommand(pi, ultraHolder);
|
|
243
286
|
// Shared fetch timeout for the small, interactive display-path reads below
|
|
244
287
|
// (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
|
|
@@ -334,6 +377,8 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
334
377
|
// The static queue only carries non-connectivity notices (e.g. the
|
|
335
378
|
// `.mcp.json` approval prompt), which stay one-shot.
|
|
336
379
|
let connectivityNoticesFlushed = false;
|
|
380
|
+
// permission-rule notices are one-shot like connectivity notices.
|
|
381
|
+
let rulesNoticesFlushed = false;
|
|
337
382
|
pi.on("after_provider_response", (_event, ctx) => {
|
|
338
383
|
try {
|
|
339
384
|
if (!ctx.hasUI)
|
|
@@ -347,6 +392,21 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
347
392
|
while (mcpApprovalNotices.length > 0) {
|
|
348
393
|
ctx.ui.notify(mcpApprovalNotices.shift(), "info");
|
|
349
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
|
+
}
|
|
350
410
|
}
|
|
351
411
|
catch {
|
|
352
412
|
// A notice must never break a turn.
|
|
@@ -359,6 +419,18 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
359
419
|
// Mutating MCP tools join write/edit/bash in the gate policy: plan mode
|
|
360
420
|
// holds them, review mode confirms them.
|
|
361
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
|
+
}
|
|
362
434
|
const footerInvalidateHandle = { invalidateGit: () => { }, requestRender: () => { } };
|
|
363
435
|
const guardianState = makeGuardianState();
|
|
364
436
|
// Disabled by the local env override OR the workspace kill switch
|
|
@@ -408,6 +480,25 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
408
480
|
// the startup load is the trust boundary; live reload was reviewed and
|
|
409
481
|
// rejected as a same-session self-authorization path, PR #1698).
|
|
410
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);
|
|
411
502
|
const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
|
|
412
503
|
// YAG-506: load user-configurable lifecycle hooks config and create the
|
|
413
504
|
// hook runner for the permission gate. Skipped in eval mode.
|
|
@@ -415,11 +506,56 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
415
506
|
const hookRunner = evalMode ? null : makeHookRunner({ config: hooksConfig });
|
|
416
507
|
if (!evalMode)
|
|
417
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);
|
|
418
527
|
registerPermissionGate(pi, {
|
|
528
|
+
...(sandboxHandle
|
|
529
|
+
? {
|
|
530
|
+
sandboxAutoAllow: (toolName, params) => sandboxAutoAllowDecision(toolName, params, sandboxHandle.manager, sandboxHandle.settings()),
|
|
531
|
+
}
|
|
532
|
+
: {}),
|
|
419
533
|
modeHolder,
|
|
420
534
|
guardianState,
|
|
421
535
|
guardianLimits,
|
|
422
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
|
+
: {}),
|
|
423
559
|
guardianDisabled,
|
|
424
560
|
childUsage,
|
|
425
561
|
guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
|
|
@@ -471,57 +607,95 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
471
607
|
if (!evalMode)
|
|
472
608
|
appendGrant(grant);
|
|
473
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
|
+
},
|
|
474
649
|
// Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
|
|
475
650
|
// "off" → nothing (not even sent); "hash" → sha256 + family prefix +
|
|
476
651
|
// metadata, no command content; "raw" → adds client-REDACTED command and
|
|
477
652
|
// rationale. Fire-and-forget: one attempt, short timeout, failures logged
|
|
478
653
|
// fail-soft to the unified sink (source:"guardian") — a storage outage
|
|
479
654
|
// never touches the session.
|
|
480
|
-
onGuardianEvent:
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
method: "POST",
|
|
503
|
-
headers: {
|
|
504
|
-
"content-type": "application/json",
|
|
505
|
-
authorization: `Bearer ${getTokenFn() ?? ""}`,
|
|
506
|
-
...attributionHeaders(deps.env),
|
|
507
|
-
},
|
|
508
|
-
body: JSON.stringify(body),
|
|
509
|
-
}, {
|
|
510
|
-
fetchImpl: authedFetch,
|
|
511
|
-
policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
|
|
512
|
-
});
|
|
513
|
-
if (!res.ok) {
|
|
514
|
-
guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
|
|
515
|
-
}
|
|
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);
|
|
516
677
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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 });
|
|
522
692
|
}
|
|
523
|
-
}
|
|
524
|
-
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
guardianLogSink({ event: "guardian_event_post_failed", kind: "network" });
|
|
696
|
+
}
|
|
697
|
+
})();
|
|
698
|
+
},
|
|
525
699
|
...(mcpMutatingSet.size > 0 || mcpOutcome.manager
|
|
526
700
|
? {
|
|
527
701
|
policy: {
|
|
@@ -753,12 +927,25 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
753
927
|
try {
|
|
754
928
|
registerCondensedTools(pi, {
|
|
755
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 } : {}),
|
|
756
934
|
});
|
|
757
935
|
}
|
|
758
936
|
catch {
|
|
759
937
|
// Rendering must never break activation; pi's built-ins remain.
|
|
760
938
|
}
|
|
761
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
|
+
}
|
|
762
949
|
// Own the identity + inject live company context (and repo rules) on every
|
|
763
950
|
// turn. The extension loads identically in every pi process this app spawns —
|
|
764
951
|
// the interactive driver AND every `/go` stage child, subagent, and advisor
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* from its bundled copy). Pure config I/O — no pi imports, no TUI, no network —
|
|
5
5
|
* so importing it from the launcher is side-effect-free.
|
|
6
6
|
*/
|
|
7
|
-
export { McpServerConfig, McpScope, PROJECT_CONFIG_FILENAME, ScopedMcpServerConfig, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
|
|
7
|
+
export { McpServerConfig, McpScope, PLUGIN_MCP_ENV, PROJECT_CONFIG_FILENAME, ScopedMcpServerConfig, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readPluginMcpServers, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
|
|
8
8
|
export { ProjectApprovalState, decisionFor, readProjectApproval, recordProjectDecision, resetProjectChoices, undecidedProjectServers, } from "./approval.js";
|
|
9
9
|
export { McpAuthFile, StoredOAuthEntry, deleteStoredOAuthEntry, getServerKey, getStoredOAuthEntry, mcpAuthPath, readMcpAuth, updateStoredOAuthEntry, writeMcpAuth, } from "./authStore.js";
|
|
10
10
|
export { revokeTokensOnRemove } from "./auth.js";
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* from its bundled copy). Pure config I/O — no pi imports, no TUI, no network —
|
|
5
5
|
* so importing it from the launcher is side-effect-free.
|
|
6
6
|
*/
|
|
7
|
-
export { PROJECT_CONFIG_FILENAME, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
|
|
7
|
+
export { PLUGIN_MCP_ENV, PROJECT_CONFIG_FILENAME, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readPluginMcpServers, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
|
|
8
8
|
export { decisionFor, readProjectApproval, recordProjectDecision, resetProjectChoices, undecidedProjectServers, } from "./approval.js";
|
|
9
9
|
export { deleteStoredOAuthEntry, getServerKey, getStoredOAuthEntry, mcpAuthPath, readMcpAuth, updateStoredOAuthEntry, writeMcpAuth, } from "./authStore.js";
|
|
10
10
|
export { revokeTokensOnRemove } from "./auth.js";
|
|
@@ -6,13 +6,18 @@
|
|
|
6
6
|
* A repo configured for Claude Code works here with zero changes.
|
|
7
7
|
* - user: top-level `mcpServers` in `~/.yagni-code/mcp.json`.
|
|
8
8
|
* - local: `projects[<absPath>].mcpServers` in the same file, keyed by cwd.
|
|
9
|
+
* - plugin: servers bundled in Claude Code plugins (`.mcp.json` at the plugin
|
|
10
|
+
* root), handed over by the yagni launcher as JSON in
|
|
11
|
+
* `YAGNI_CLAUDE_PLUGIN_MCP` (see yagni-code-cli claudeCompat.ts). Entries
|
|
12
|
+
* marked `gated` came from repo config and ride the project approval gate.
|
|
9
13
|
*
|
|
10
|
-
* Merge precedence (Claude Code parity): user < project < local —
|
|
14
|
+
* Merge precedence (Claude Code parity): plugin < user < project < local —
|
|
15
|
+
* later wins, so an explicit user entry beats a plugin's.
|
|
11
16
|
* Writes are atomic (temp file + rename) so two concurrent sessions can never
|
|
12
17
|
* interleave into a corrupt config. Validation errors are collected and
|
|
13
18
|
* surfaced, never fatal: one malformed server entry must not hide the rest.
|
|
14
19
|
*/
|
|
15
|
-
export type McpScope = "user" | "project" | "local";
|
|
20
|
+
export type McpScope = "user" | "project" | "local" | "plugin";
|
|
16
21
|
export interface McpStdioServerConfig {
|
|
17
22
|
type?: "stdio";
|
|
18
23
|
command: string;
|
|
@@ -39,7 +44,24 @@ export interface ScopedMcpServerConfig {
|
|
|
39
44
|
scope: McpScope;
|
|
40
45
|
/** Where this entry came from (file path) — surfaced in /mcp and `yagni mcp get`. */
|
|
41
46
|
sourcePath: string;
|
|
47
|
+
/** `name@marketplace` of the plugin that bundles this server (plugin scope). */
|
|
48
|
+
plugin?: string;
|
|
49
|
+
/** Plugin server that came from repo config: needs the project approval gate. */
|
|
50
|
+
gated?: boolean;
|
|
42
51
|
}
|
|
52
|
+
/** Launcher → extension: plugin-bundled MCP servers (JSON array). */
|
|
53
|
+
export declare const PLUGIN_MCP_ENV = "YAGNI_CLAUDE_PLUGIN_MCP";
|
|
54
|
+
export interface PluginMcpEnvEntry {
|
|
55
|
+
plugin: string;
|
|
56
|
+
sourcePath: string;
|
|
57
|
+
gated: boolean;
|
|
58
|
+
servers: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
/** Parse the launcher's plugin MCP payload; malformed input degrades to errors, never a throw. */
|
|
61
|
+
export declare function readPluginMcpServers(env?: NodeJS.ProcessEnv): {
|
|
62
|
+
servers: ScopedMcpServerConfig[];
|
|
63
|
+
errors: McpConfigError[];
|
|
64
|
+
};
|
|
43
65
|
export interface McpConfigError {
|
|
44
66
|
/** File the problem was found in. */
|
|
45
67
|
sourcePath: string;
|
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
* A repo configured for Claude Code works here with zero changes.
|
|
7
7
|
* - user: top-level `mcpServers` in `~/.yagni-code/mcp.json`.
|
|
8
8
|
* - local: `projects[<absPath>].mcpServers` in the same file, keyed by cwd.
|
|
9
|
+
* - plugin: servers bundled in Claude Code plugins (`.mcp.json` at the plugin
|
|
10
|
+
* root), handed over by the yagni launcher as JSON in
|
|
11
|
+
* `YAGNI_CLAUDE_PLUGIN_MCP` (see yagni-code-cli claudeCompat.ts). Entries
|
|
12
|
+
* marked `gated` came from repo config and ride the project approval gate.
|
|
9
13
|
*
|
|
10
|
-
* Merge precedence (Claude Code parity): user < project < local —
|
|
14
|
+
* Merge precedence (Claude Code parity): plugin < user < project < local —
|
|
15
|
+
* later wins, so an explicit user entry beats a plugin's.
|
|
11
16
|
* Writes are atomic (temp file + rename) so two concurrent sessions can never
|
|
12
17
|
* interleave into a corrupt config. Validation errors are collected and
|
|
13
18
|
* surfaced, never fatal: one malformed server entry must not hide the rest.
|
|
@@ -15,6 +20,70 @@
|
|
|
15
20
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
21
|
import { dirname, join } from "node:path";
|
|
17
22
|
import { codeStateHome } from "../stateHome.js";
|
|
23
|
+
/** Launcher → extension: plugin-bundled MCP servers (JSON array). */
|
|
24
|
+
export const PLUGIN_MCP_ENV = "YAGNI_CLAUDE_PLUGIN_MCP";
|
|
25
|
+
const CLAUDE_COMPAT_DISABLE_ENV = "YAGNI_DISABLE_CLAUDE_COMPAT";
|
|
26
|
+
/** Parse the launcher's plugin MCP payload; malformed input degrades to errors, never a throw. */
|
|
27
|
+
export function readPluginMcpServers(env = process.env) {
|
|
28
|
+
const disabled = env[CLAUDE_COMPAT_DISABLE_ENV];
|
|
29
|
+
if (disabled !== undefined && disabled !== "" && disabled !== "0")
|
|
30
|
+
return { servers: [], errors: [] };
|
|
31
|
+
const raw = env[PLUGIN_MCP_ENV];
|
|
32
|
+
if (!raw)
|
|
33
|
+
return { servers: [], errors: [] };
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return {
|
|
40
|
+
servers: [],
|
|
41
|
+
errors: [{ sourcePath: PLUGIN_MCP_ENV, message: `could not parse plugin MCP payload (${err instanceof Error ? err.message : String(err)})` }],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(parsed))
|
|
45
|
+
return { servers: [], errors: [{ sourcePath: PLUGIN_MCP_ENV, message: "plugin MCP payload is not an array" }] };
|
|
46
|
+
const servers = [];
|
|
47
|
+
const errors = [];
|
|
48
|
+
const seen = new Map();
|
|
49
|
+
for (const item of parsed) {
|
|
50
|
+
if (!item || typeof item !== "object")
|
|
51
|
+
continue;
|
|
52
|
+
const entry = item;
|
|
53
|
+
if (typeof entry.plugin !== "string" || !entry.servers || typeof entry.servers !== "object")
|
|
54
|
+
continue;
|
|
55
|
+
const sourcePath = typeof entry.sourcePath === "string" ? entry.sourcePath : entry.plugin;
|
|
56
|
+
for (const [name, value] of Object.entries(entry.servers)) {
|
|
57
|
+
const validation = validateServerConfig(value);
|
|
58
|
+
if (!validation.ok) {
|
|
59
|
+
errors.push({ sourcePath, serverName: name, message: `${validation.message} (plugin ${entry.plugin})` });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const owner = seen.get(name);
|
|
63
|
+
if (owner) {
|
|
64
|
+
errors.push({
|
|
65
|
+
sourcePath,
|
|
66
|
+
serverName: name,
|
|
67
|
+
message: `also defined by plugin ${owner} — keeping that one, skipping ${entry.plugin}'s`,
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const config = value;
|
|
72
|
+
const expanded = expandServerEnv(config, env);
|
|
73
|
+
if (expanded.missingVars.length > 0) {
|
|
74
|
+
errors.push({
|
|
75
|
+
sourcePath,
|
|
76
|
+
serverName: name,
|
|
77
|
+
message: `missing environment variable(s): ${expanded.missingVars.join(", ")} — server skipped`,
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
seen.set(name, entry.plugin);
|
|
82
|
+
servers.push({ name, config, scope: "plugin", sourcePath, plugin: entry.plugin, gated: entry.gated === true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { servers, errors };
|
|
86
|
+
}
|
|
18
87
|
export const PROJECT_CONFIG_FILENAME = ".mcp.json";
|
|
19
88
|
/**
|
|
20
89
|
* Expand `${VAR}` / `${VAR:-default}` in a config string (Claude Code parity —
|
|
@@ -239,7 +308,10 @@ function defaultExists(p) {
|
|
|
239
308
|
export function loadMcpServers(cwd, env = process.env) {
|
|
240
309
|
const repoRoot = resolveProjectRoot(cwd);
|
|
241
310
|
const errors = [];
|
|
242
|
-
const byScope = { user: [], project: [], local: [] };
|
|
311
|
+
const byScope = { plugin: [], user: [], project: [], local: [] };
|
|
312
|
+
const pluginLoad = readPluginMcpServers(env);
|
|
313
|
+
errors.push(...pluginLoad.errors);
|
|
314
|
+
byScope.plugin.push(...pluginLoad.servers);
|
|
243
315
|
const { file: userFile, errors: userErrors } = readUserMcpConfig();
|
|
244
316
|
errors.push(...userErrors);
|
|
245
317
|
const userServers = userFile.mcpServers ?? {};
|
|
@@ -300,7 +372,7 @@ export function loadMcpServers(cwd, env = process.env) {
|
|
|
300
372
|
}
|
|
301
373
|
}
|
|
302
374
|
const merged = new Map();
|
|
303
|
-
for (const scope of ["user", "project", "local"]) {
|
|
375
|
+
for (const scope of ["plugin", "user", "project", "local"]) {
|
|
304
376
|
for (const server of byScope[scope])
|
|
305
377
|
merged.set(server.name, server);
|
|
306
378
|
}
|
|
@@ -20,6 +20,8 @@ export interface ManagedServer {
|
|
|
20
20
|
scope: McpScope;
|
|
21
21
|
config: McpStdioServerConfig | McpHttpServerConfig;
|
|
22
22
|
status: McpServerStatus;
|
|
23
|
+
/** Plugin server sourced from repo config: rides the project approval gate. */
|
|
24
|
+
gated?: boolean;
|
|
23
25
|
/** Failure detail for the failed/needs_auth states (panel + logs). */
|
|
24
26
|
error?: string;
|
|
25
27
|
client?: Client;
|
|
@@ -51,7 +53,7 @@ export declare class McpManager {
|
|
|
51
53
|
list(): ManagedServer[];
|
|
52
54
|
get(name: string): ManagedServer | undefined;
|
|
53
55
|
/** Registers a server without connecting (panel listing, kill-switch states). */
|
|
54
|
-
register(name: string, scope: McpScope, config: McpStdioServerConfig | McpHttpServerConfig, status?: McpServerStatus): ManagedServer;
|
|
56
|
+
register(name: string, scope: McpScope, config: McpStdioServerConfig | McpHttpServerConfig, status?: McpServerStatus, gated?: boolean): ManagedServer;
|
|
55
57
|
connect(name: string): Promise<ManagedServer | undefined>;
|
|
56
58
|
disconnect(name: string): Promise<void>;
|
|
57
59
|
/** Reconnect with bounded attempts, panel-visible progress. */
|
|
@@ -47,11 +47,11 @@ export class McpManager {
|
|
|
47
47
|
return this.servers.get(name);
|
|
48
48
|
}
|
|
49
49
|
/** Registers a server without connecting (panel listing, kill-switch states). */
|
|
50
|
-
register(name, scope, config, status = "disabled") {
|
|
50
|
+
register(name, scope, config, status = "disabled", gated = false) {
|
|
51
51
|
const existing = this.servers.get(name);
|
|
52
52
|
if (existing)
|
|
53
53
|
return existing;
|
|
54
|
-
const server = { name, scope, config, status };
|
|
54
|
+
const server = { name, scope, config, status, ...(gated ? { gated } : {}) };
|
|
55
55
|
this.servers.set(name, server);
|
|
56
56
|
return server;
|
|
57
57
|
}
|
|
@@ -21,7 +21,6 @@ export interface PanelServerInfo {
|
|
|
21
21
|
}
|
|
22
22
|
export declare function statusIcon(status: string): string;
|
|
23
23
|
export declare function statusText(server: ManagedServer): string;
|
|
24
|
-
/** Plain-text listing, mirroring CC's `claude mcp list` and panel states. */
|
|
25
24
|
export declare function renderPanelListing(servers: ManagedServer[], errors: string[]): string;
|
|
26
25
|
export interface PanelDeps {
|
|
27
26
|
cwd: string;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* a `disabledMcpServers` list under projects[absPath]). A disable/enable
|
|
11
11
|
* toggle reconnects or disconnects the live client and refreshes tools.
|
|
12
12
|
*/
|
|
13
|
+
import { recordProjectDecision } from "./approval.js";
|
|
13
14
|
import { readUserMcpConfig, resolveProjectRoot, writeUserMcpConfig } from "./config.js";
|
|
14
15
|
import { registerServerPrompts } from "./prompts.js";
|
|
15
16
|
import { registerServerTools } from "./tools.js";
|
|
@@ -39,6 +40,7 @@ export function statusText(server) {
|
|
|
39
40
|
return STATUS_TEXT[server.status] ?? server.status;
|
|
40
41
|
}
|
|
41
42
|
/** Plain-text listing, mirroring CC's `claude mcp list` and panel states. */
|
|
43
|
+
const SCOPE_LABELS = { local: "Local", project: "Project", user: "User", plugin: "Plugin" };
|
|
42
44
|
export function renderPanelListing(servers, errors) {
|
|
43
45
|
if (servers.length === 0 && errors.length === 0) {
|
|
44
46
|
return [
|
|
@@ -47,14 +49,14 @@ export function renderPanelListing(servers, errors) {
|
|
|
47
49
|
].join("\n");
|
|
48
50
|
}
|
|
49
51
|
const lines = [];
|
|
50
|
-
const byScope = { user: [], project: [], local: [] };
|
|
52
|
+
const byScope = { user: [], project: [], local: [], plugin: [] };
|
|
51
53
|
for (const s of servers)
|
|
52
54
|
(byScope[s.scope] ??= []).push(s);
|
|
53
|
-
for (const scope of ["local", "project", "user"]) {
|
|
55
|
+
for (const scope of ["local", "project", "user", "plugin"]) {
|
|
54
56
|
const group = byScope[scope];
|
|
55
57
|
if (!group?.length)
|
|
56
58
|
continue;
|
|
57
|
-
lines.push(`${scope
|
|
59
|
+
lines.push(`${SCOPE_LABELS[scope]} servers:`);
|
|
58
60
|
for (const s of group) {
|
|
59
61
|
lines.push(` ${s.name} · ${statusIcon(s.status)} ${statusText(s)}${s.error ? ` — ${s.error}` : ""}`);
|
|
60
62
|
}
|
|
@@ -163,13 +165,21 @@ export function registerMcpPanel(pi, manager, configErrors) {
|
|
|
163
165
|
ui.notify(`No MCP server named "${name}".`, "error");
|
|
164
166
|
continue;
|
|
165
167
|
}
|
|
168
|
+
// Repo-sourced servers (.mcp.json, or a repo plugin's bundle) carry an
|
|
169
|
+
// approval decision that must outlive this session — record it, so the
|
|
170
|
+
// startup gate stops re-asking.
|
|
171
|
+
const approvalGated = server.scope === "project" || server.gated === true;
|
|
166
172
|
if (action === "disable") {
|
|
167
173
|
await manager.disconnect(name);
|
|
168
174
|
persistDisabled(name, true);
|
|
175
|
+
if (approvalGated)
|
|
176
|
+
recordProjectDecision(repoRoot(), name, "disabled");
|
|
169
177
|
ui.notify(`○ ${name} disabled`, "info");
|
|
170
178
|
}
|
|
171
179
|
else {
|
|
172
180
|
persistDisabled(name, false);
|
|
181
|
+
if (approvalGated)
|
|
182
|
+
recordProjectDecision(repoRoot(), name, "enabled");
|
|
173
183
|
const result = await manager.connect(name);
|
|
174
184
|
if (result?.status === "connected") {
|
|
175
185
|
await refreshForServer(name);
|