@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.
- package/README.md +89 -27
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -3
- package/dist/claudePlugins.js +129 -19
- package/dist/cli.js +28 -4
- package/dist/doctor.d.ts +42 -4
- package/dist/doctor.js +150 -15
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/index.d.ts +18 -5
- package/dist/extension/index.js +319 -72
- package/dist/extension/mcp/approval.d.ts +45 -0
- package/dist/extension/mcp/approval.js +164 -0
- package/dist/extension/mcp/auth.d.ts +124 -0
- package/dist/extension/mcp/auth.js +560 -0
- package/dist/extension/mcp/authStore.d.ts +61 -0
- package/dist/extension/mcp/authStore.js +105 -0
- package/dist/extension/mcp/callbackPage.d.ts +31 -0
- package/dist/extension/mcp/callbackPage.js +222 -0
- package/dist/extension/mcp/cliConfig.d.ts +12 -0
- package/dist/extension/mcp/cliConfig.js +12 -0
- package/dist/extension/mcp/config.d.ts +153 -0
- package/dist/extension/mcp/config.js +381 -0
- package/dist/extension/mcp/log.d.ts +28 -0
- package/dist/extension/mcp/log.js +82 -0
- package/dist/extension/mcp/manager.d.ts +100 -0
- package/dist/extension/mcp/manager.js +273 -0
- package/dist/extension/mcp/names.d.ts +25 -0
- package/dist/extension/mcp/names.js +40 -0
- package/dist/extension/mcp/panel.d.ts +33 -0
- package/dist/extension/mcp/panel.js +268 -0
- package/dist/extension/mcp/prompts.d.ts +23 -0
- package/dist/extension/mcp/prompts.js +93 -0
- package/dist/extension/mcp/startup.d.ts +55 -0
- package/dist/extension/mcp/startup.js +152 -0
- package/dist/extension/mcp/tools.d.ts +31 -0
- package/dist/extension/mcp/tools.js +117 -0
- package/dist/extension/mcp/transports.d.ts +17 -0
- package/dist/extension/mcp/transports.js +44 -0
- package/dist/extension/permission/gate.d.ts +104 -2
- package/dist/extension/permission/gate.js +387 -31
- package/dist/extension/permission/guardian.d.ts +24 -5
- package/dist/extension/permission/guardian.js +162 -24
- 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/personas.js +5 -0
- 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 +122 -0
- package/dist/mcpCommand.js +787 -0
- package/dist/otel.d.ts +69 -63
- package/dist/otel.js +154 -119
- 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/dist/upgrade.d.ts +11 -2
- package/dist/upgrade.js +48 -8
- package/package.json +20 -3
- package/dist/extension/mcpTools.d.ts +0 -57
- 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 {
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
|
|
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";
|
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";
|
|
@@ -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 {
|
|
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
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
|
373
|
-
//
|
|
374
|
-
//
|
|
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
|
-
|
|
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:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|