@yagni-app/code 1.0.9 → 1.1.0
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 +32 -0
- package/dist/cli.js +1 -1
- package/dist/connectClaudeCode.d.ts +4 -3
- package/dist/connectClaudeCode.js +70 -37
- package/dist/connectCodex.d.ts +6 -2
- package/dist/connectCodex.js +43 -22
- package/dist/connectFiles.d.ts +5 -0
- package/dist/connectFiles.js +49 -0
- package/dist/extension/hooks.d.ts +13 -3
- package/dist/extension/hooks.js +170 -5
- package/dist/extension/mcp/cliConfig.d.ts +1 -1
- package/dist/extension/mcp/cliConfig.js +1 -1
- package/dist/extension/mcp/config.d.ts +2 -0
- package/dist/extension/mcp/config.js +2 -0
- package/dist/extension/mcp/tools.js +69 -6
- package/dist/extension/pipeline/scrubSecrets.js +5 -0
- package/dist/extension/resilientFetch.d.ts +3 -3
- package/dist/extension/resilientFetch.js +7 -4
- package/dist/extension/sandbox/config.d.ts +6 -1
- package/dist/extension/sandbox/config.js +19 -1
- package/dist/extension/sandbox/manager.d.ts +9 -2
- package/dist/extension/sandbox/manager.js +30 -5
- package/dist/extension/sandbox/panel.js +2 -1
- package/dist/extension/sandbox/session.js +7 -0
- package/dist/extension/sandbox/worktreeGit.d.ts +57 -0
- package/dist/extension/sandbox/worktreeGit.js +157 -0
- package/dist/mcpCommand.d.ts +7 -0
- package/dist/mcpCommand.js +161 -18
- package/dist/refresh.js +5 -2
- package/dist/token.d.ts +2 -1
- package/dist/token.js +29 -3
- package/package.json +2 -2
package/dist/extension/hooks.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* at lifecycle events. The config format mirrors Claude Code's `settings.json`
|
|
7
7
|
* hooks shape so a user can copy-paste between them.
|
|
8
8
|
*
|
|
9
|
-
* Supported events (
|
|
10
|
-
* PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd.
|
|
9
|
+
* Supported events (9): SessionStart, UserPromptSubmit, PreToolUse,
|
|
10
|
+
* PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd, Stop.
|
|
11
11
|
*
|
|
12
12
|
* Exit code semantics (matching Claude Code / Codex):
|
|
13
13
|
* 0 = success; stdout parsed as JSON for structured decisions
|
|
@@ -21,8 +21,10 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { codeStateHome } from "./stateHome.js";
|
|
24
|
+
import { isDriverCaller } from "./config.js";
|
|
24
25
|
import { isDebug } from "./diagnostics.js";
|
|
25
26
|
import { logEvent } from "./errorSink.js";
|
|
27
|
+
import { scrubSecrets } from "./pipeline/scrubSecrets.js";
|
|
26
28
|
const SUPPORTED_EVENTS = [
|
|
27
29
|
"SessionStart",
|
|
28
30
|
"UserPromptSubmit",
|
|
@@ -32,6 +34,7 @@ const SUPPORTED_EVENTS = [
|
|
|
32
34
|
"PreCompact",
|
|
33
35
|
"PostCompact",
|
|
34
36
|
"SessionEnd",
|
|
37
|
+
"Stop",
|
|
35
38
|
];
|
|
36
39
|
// ---------------------------------------------------------------------------
|
|
37
40
|
// Config loading
|
|
@@ -301,8 +304,72 @@ export function parseCompactCancel(stdout) {
|
|
|
301
304
|
return parsed.continue === false;
|
|
302
305
|
}
|
|
303
306
|
// ---------------------------------------------------------------------------
|
|
307
|
+
// Stop capture
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
/**
|
|
310
|
+
* Extract the last assistant message's text and stopReason from an
|
|
311
|
+
* agent_end event. Walks backwards (the shape cmux/state.ts walks forwards)
|
|
312
|
+
* so an aborted final turn with no text still reports its stopReason —
|
|
313
|
+
* the reason is what decides whether Stop hooks fire at all.
|
|
314
|
+
*/
|
|
315
|
+
function lastAssistantInfo(event) {
|
|
316
|
+
const messages = event?.messages;
|
|
317
|
+
if (!Array.isArray(messages))
|
|
318
|
+
return undefined;
|
|
319
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
320
|
+
const message = messages[index];
|
|
321
|
+
if (!message || typeof message !== "object")
|
|
322
|
+
continue;
|
|
323
|
+
const typed = message;
|
|
324
|
+
if (typed.role !== "assistant")
|
|
325
|
+
continue;
|
|
326
|
+
const text = typeof typed.content === "string"
|
|
327
|
+
? typed.content.trim() || undefined
|
|
328
|
+
: textFromBlocks(typed.content);
|
|
329
|
+
return { text, stopReason: typeof typed.stopReason === "string" ? typed.stopReason : undefined };
|
|
330
|
+
}
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
/** Join text blocks from a content array (mirrors cmux's textFromContent). */
|
|
334
|
+
function textFromBlocks(content) {
|
|
335
|
+
if (!Array.isArray(content))
|
|
336
|
+
return undefined;
|
|
337
|
+
const parts = [];
|
|
338
|
+
for (const block of content) {
|
|
339
|
+
if (!block || typeof block !== "object")
|
|
340
|
+
continue;
|
|
341
|
+
const typed = block;
|
|
342
|
+
if (typed.type === "text" && typeof typed.text === "string" && typed.text.trim()) {
|
|
343
|
+
parts.push(typed.text);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return parts.join("\n") || undefined;
|
|
347
|
+
}
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
304
349
|
// Diagnostic logging
|
|
305
350
|
// ---------------------------------------------------------------------------
|
|
351
|
+
/**
|
|
352
|
+
* A short upload-safe label for a hook-exec exception, for the always-on
|
|
353
|
+
* trail line. Tiers, in order: a Node error code ("ENOENT", "ETIMEDOUT"),
|
|
354
|
+
* the message's first token (a machine word like "spawn"), the constructor
|
|
355
|
+
* name, and typeof for non-Error throws (execImpl is an injected seam —
|
|
356
|
+
* a string throw is possible). Message-derived tiers are scrubbed — a
|
|
357
|
+
* message can begin with content (a credentials URL), and this label rides
|
|
358
|
+
* the default-on (upload-safe) tier.
|
|
359
|
+
*/
|
|
360
|
+
export function hookErrorLabel(err) {
|
|
361
|
+
const code = err?.code;
|
|
362
|
+
// Scrub BEFORE slicing: a secret-shaped value longer than the 40-char cap
|
|
363
|
+
// would be split first and the fragment could match no pattern.
|
|
364
|
+
if (typeof code === "string" && code)
|
|
365
|
+
return scrubSecrets(code).slice(0, 40);
|
|
366
|
+
if (err instanceof Error) {
|
|
367
|
+
if (err.message)
|
|
368
|
+
return scrubSecrets(err.message.split(/[\s:]+/)[0]).slice(0, 40);
|
|
369
|
+
return err.constructor.name;
|
|
370
|
+
}
|
|
371
|
+
return typeof err;
|
|
372
|
+
}
|
|
306
373
|
function logHookEvent(env, payload) {
|
|
307
374
|
if (!isDebug(env))
|
|
308
375
|
return;
|
|
@@ -656,6 +723,64 @@ export function registerHooks(pi, deps = {}) {
|
|
|
656
723
|
}
|
|
657
724
|
});
|
|
658
725
|
}
|
|
726
|
+
// --- Stop (agent finished responding; driver turn complete) ---
|
|
727
|
+
// Two-phase capture, mirroring the cmux bridge: agent_settled carries no
|
|
728
|
+
// payload, so the last assistant message is captured at agent_end and
|
|
729
|
+
// consumed at settled. Firing is VOID, not awaited — pi awaits settled
|
|
730
|
+
// handlers before unblocking the TUI's prompt loop and RPC waitForIdle,
|
|
731
|
+
// so an awaited hook would keep a finished session looking busy for up
|
|
732
|
+
// to the full hook timeout.
|
|
733
|
+
const stopGroups = config["Stop"] ?? [];
|
|
734
|
+
if (stopGroups.length > 0 && isDriverCaller(env)) {
|
|
735
|
+
let pendingStop;
|
|
736
|
+
pi.on("agent_end", (event) => {
|
|
737
|
+
pendingStop = lastAssistantInfo(event);
|
|
738
|
+
});
|
|
739
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
740
|
+
// hasUI is fixed per session — checking it first keeps the capture
|
|
741
|
+
// consume order-independent (a headless session simply never fires).
|
|
742
|
+
if (!ctx.hasUI)
|
|
743
|
+
return;
|
|
744
|
+
const captured = pendingStop;
|
|
745
|
+
if (!captured)
|
|
746
|
+
return;
|
|
747
|
+
// Mid-retry/compaction/continuation settles are not the turn's end —
|
|
748
|
+
// pi sets isIdle only once no automatic follow-up work will run. The
|
|
749
|
+
// capture is NOT consumed here: a settle that isn't idle leaves it for
|
|
750
|
+
// the genuine end-of-turn settle (an agent_end between the two
|
|
751
|
+
// overwrites it with the continuation's own final message).
|
|
752
|
+
try {
|
|
753
|
+
if (!ctx.isIdle())
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
pendingStop = undefined;
|
|
760
|
+
// Claude Code parity: its query loop returns before running Stop hooks
|
|
761
|
+
// when the user aborted or the model errored — "finished, your move"
|
|
762
|
+
// would be a lie for a killed turn. Retry/continuation agent_ends
|
|
763
|
+
// overwrite the capture, so an error followed by a successful retry
|
|
764
|
+
// still fires (with the retry's message).
|
|
765
|
+
if (captured.stopReason === "aborted" || captured.stopReason === "error")
|
|
766
|
+
return;
|
|
767
|
+
const cwd = ctx.cwd;
|
|
768
|
+
const inputJson = JSON.stringify({
|
|
769
|
+
session_id: sessionId,
|
|
770
|
+
cwd,
|
|
771
|
+
hook_event_name: "Stop",
|
|
772
|
+
stop_hook_active: false,
|
|
773
|
+
...(captured.text ? { last_assistant_message: captured.text } : {}),
|
|
774
|
+
});
|
|
775
|
+
void (async () => {
|
|
776
|
+
for (const group of filterByTrust(stopGroups, trusted(ctx))) {
|
|
777
|
+
for (const entry of group.hooks) {
|
|
778
|
+
await runSideEffectHook(entry.command, inputJson, cwd, "Stop", ctx, env, execImpl);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
})();
|
|
782
|
+
});
|
|
783
|
+
}
|
|
659
784
|
}
|
|
660
785
|
/** Run a side-effect-only hook (no control effects, output ignored). */
|
|
661
786
|
async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, execImpl) {
|
|
@@ -665,6 +790,13 @@ async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, e
|
|
|
665
790
|
YAGNI_HOOK_CWD: cwd,
|
|
666
791
|
YAGNI_HOOK_SESSION_ID: env.YAGNI_SESSION_ID ?? "",
|
|
667
792
|
});
|
|
793
|
+
const failed = !!result.error || (result.exitCode !== 0 && result.exitCode !== null);
|
|
794
|
+
// One outcome, two tiers: the debug line carries the full detail
|
|
795
|
+
// (stderr, timing) and is YAGNI_DEBUG-gated; the warn line is always-on
|
|
796
|
+
// and upload-safe, so "hook fired and failed" is distinguishable from
|
|
797
|
+
// "hook never fired" in a default session. The command rides the warn
|
|
798
|
+
// line scrubbed (below) so N hooks on one event produce distinguishable
|
|
799
|
+
// lines without leaking anything the user embedded in the command.
|
|
668
800
|
logHookEvent(env, {
|
|
669
801
|
event: eventName,
|
|
670
802
|
command,
|
|
@@ -674,14 +806,47 @@ async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, e
|
|
|
674
806
|
...(result.error ? { error: result.error } : {}),
|
|
675
807
|
...(result.stderr.trim() ? { stderr: result.stderr.trim().slice(0, 512) } : {}),
|
|
676
808
|
});
|
|
677
|
-
if (
|
|
809
|
+
if (failed) {
|
|
810
|
+
logEvent({
|
|
811
|
+
source: "hooks",
|
|
812
|
+
level: "warn",
|
|
813
|
+
event: "hook_failed",
|
|
814
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
815
|
+
fields: {
|
|
816
|
+
event_name: eventName,
|
|
817
|
+
// Scrubbed at write time: the trail file stores lines raw (scrubbing
|
|
818
|
+
// otherwise happens only at readSessionTrail), and a user's hook
|
|
819
|
+
// command can embed secrets inline (curl -H "Authorization: Bearer …").
|
|
820
|
+
// The full, unscrubbed command stays on the YAGNI_DEBUG tier.
|
|
821
|
+
command: scrubSecrets(command).slice(0, 80),
|
|
822
|
+
exit_code: result.exitCode,
|
|
823
|
+
// Scrub the whole error string before taking the first colon token —
|
|
824
|
+
// result.error embeds a raw err.message (spawn failed/err paths in
|
|
825
|
+
// execHook), and the sibling hookErrorLabel path scrubs message-derived
|
|
826
|
+
// tiers the same way.
|
|
827
|
+
...(result.error ? { error: scrubSecrets(result.error).split(":")[0] } : {}),
|
|
828
|
+
},
|
|
829
|
+
});
|
|
678
830
|
if (ctx.hasUI && result.stderr.trim()) {
|
|
679
831
|
ctx.ui.notify(`Hook '${eventName}' exited with code ${result.exitCode}: ${result.stderr.trim().slice(0, 200)}`, "warning");
|
|
680
832
|
}
|
|
681
833
|
}
|
|
682
834
|
}
|
|
683
|
-
catch {
|
|
684
|
-
// Fail-soft: a hook error never breaks the session
|
|
835
|
+
catch (err) {
|
|
836
|
+
// Fail-soft: a hook error never breaks the session — but never silent.
|
|
837
|
+
// The label distinguishes "spawn ENOENT" from a timeout (see
|
|
838
|
+
// hookErrorLabel); the command is scrubbed like the sibling path's.
|
|
839
|
+
logEvent({
|
|
840
|
+
source: "hooks",
|
|
841
|
+
level: "warn",
|
|
842
|
+
event: "hook_exec_threw",
|
|
843
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
844
|
+
fields: {
|
|
845
|
+
event_name: eventName,
|
|
846
|
+
command: scrubSecrets(command).slice(0, 80),
|
|
847
|
+
error: hookErrorLabel(err),
|
|
848
|
+
},
|
|
849
|
+
});
|
|
685
850
|
}
|
|
686
851
|
}
|
|
687
852
|
// ---------------------------------------------------------------------------
|
|
@@ -7,6 +7,6 @@
|
|
|
7
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
|
-
export { revokeTokensOnRemove } from "./auth.js";
|
|
10
|
+
export { authenticate, revokeTokensOnRemove } from "./auth.js";
|
|
11
11
|
export { probeServer, McpHealthResult, McpHealthStatus } from "./manager.js";
|
|
12
12
|
//# sourceMappingURL=cliConfig.d.ts.map
|
|
@@ -7,6 +7,6 @@
|
|
|
7
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
|
-
export { revokeTokensOnRemove } from "./auth.js";
|
|
10
|
+
export { authenticate, revokeTokensOnRemove } from "./auth.js";
|
|
11
11
|
export { probeServer } from "./manager.js";
|
|
12
12
|
//# sourceMappingURL=cliConfig.js.map
|
|
@@ -36,6 +36,8 @@ export interface McpHttpServerConfig {
|
|
|
36
36
|
url: string;
|
|
37
37
|
headers?: Record<string, string>;
|
|
38
38
|
oauth?: McpOAuthConfig;
|
|
39
|
+
/** Optional explicit tool names, used by the Worker connection to avoid duplicating existing context tools. */
|
|
40
|
+
tools?: string[];
|
|
39
41
|
}
|
|
40
42
|
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig;
|
|
41
43
|
export interface ScopedMcpServerConfig {
|
|
@@ -267,6 +267,8 @@ export function validateServerConfig(value) {
|
|
|
267
267
|
return { ok: true };
|
|
268
268
|
}
|
|
269
269
|
if (type === "http" || type === "sse") {
|
|
270
|
+
if (v["tools"] !== undefined && (!Array.isArray(v["tools"]) || v["tools"].length > 100 || v["tools"].some(name => typeof name !== "string" || name.length === 0 || name.length > 128)))
|
|
271
|
+
return { ok: false, message: "tools must be an array of up to 100 tool names" };
|
|
270
272
|
if (typeof v["url"] !== "string" || v["url"].length === 0) {
|
|
271
273
|
return { ok: false, message: `${type} server requires a non-empty "url"` };
|
|
272
274
|
}
|
|
@@ -14,7 +14,11 @@ import { buildMcpToolName } from "./names.js";
|
|
|
14
14
|
export const MAX_MCP_DESCRIPTION_LENGTH = 2048;
|
|
15
15
|
export async function registerServerTools(pi, manager, serverName, env = process.env) {
|
|
16
16
|
const server = manager.get(serverName);
|
|
17
|
-
const result = {
|
|
17
|
+
const result = {
|
|
18
|
+
tools: [],
|
|
19
|
+
mutatingToolNames: [],
|
|
20
|
+
warnings: [],
|
|
21
|
+
};
|
|
18
22
|
if (!server?.client)
|
|
19
23
|
return result;
|
|
20
24
|
let toolList;
|
|
@@ -26,7 +30,12 @@ export async function registerServerTools(pi, manager, serverName, env = process
|
|
|
26
30
|
return result;
|
|
27
31
|
}
|
|
28
32
|
const toolTimeout = toolTimeoutFromEnv(env);
|
|
33
|
+
const selectedTools = server.config.type === "http" || server.config.type === "sse"
|
|
34
|
+
? server.config.tools
|
|
35
|
+
: undefined;
|
|
29
36
|
for (const tool of toolList.tools ?? []) {
|
|
37
|
+
if (selectedTools && !selectedTools.includes(tool.name))
|
|
38
|
+
continue;
|
|
30
39
|
const fullToolName = buildMcpToolName(serverName, tool.name);
|
|
31
40
|
if (!fullToolName) {
|
|
32
41
|
result.warnings.push(`${serverName}: tool "${tool.name}" produced an empty wire name; skipped`);
|
|
@@ -45,7 +54,10 @@ export async function registerServerTools(pi, manager, serverName, env = process
|
|
|
45
54
|
throw new Error(`MCP server "${serverName}" is not connected (try /mcp reconnect).`);
|
|
46
55
|
}
|
|
47
56
|
const args = (params && typeof params === "object" ? params : {});
|
|
48
|
-
const call = active.client.callTool({
|
|
57
|
+
const call = active.client.callTool({
|
|
58
|
+
name: tool.name,
|
|
59
|
+
arguments: args,
|
|
60
|
+
});
|
|
49
61
|
const settled = toolTimeout
|
|
50
62
|
? await withTimeout(call, toolTimeout, `MCP tool call timed out after ${toolTimeout}ms`)
|
|
51
63
|
: await call;
|
|
@@ -53,7 +65,12 @@ export async function registerServerTools(pi, manager, serverName, env = process
|
|
|
53
65
|
},
|
|
54
66
|
};
|
|
55
67
|
pi.registerTool(definition);
|
|
56
|
-
result.tools.push({
|
|
68
|
+
result.tools.push({
|
|
69
|
+
toolName: fullToolName,
|
|
70
|
+
serverName,
|
|
71
|
+
originalName: tool.name,
|
|
72
|
+
description,
|
|
73
|
+
});
|
|
57
74
|
if (mutating)
|
|
58
75
|
result.mutatingToolNames.push(fullToolName);
|
|
59
76
|
}
|
|
@@ -67,6 +84,19 @@ export function capDescription(description) {
|
|
|
67
84
|
/** Cheap heuristic in the spirit of Claude Code's input-hint check; per-tool annotations arrive via listTools only in newer servers. */
|
|
68
85
|
export function looksMutating(toolName, description) {
|
|
69
86
|
const name = toolName.toLowerCase();
|
|
87
|
+
if ([
|
|
88
|
+
"critique_plan",
|
|
89
|
+
"review_pr",
|
|
90
|
+
"test_pr",
|
|
91
|
+
"validate_qa_replay",
|
|
92
|
+
"accept_qa_replay",
|
|
93
|
+
"authorize_qa_fork",
|
|
94
|
+
"engage_worker",
|
|
95
|
+
"propose_instruction_change",
|
|
96
|
+
"prepare_review_publication",
|
|
97
|
+
"resolve_decision",
|
|
98
|
+
].includes(name))
|
|
99
|
+
return true;
|
|
70
100
|
const writeHints = /^(create|add|update|edit|delete|remove|set|write|send|post|put|patch|deploy|publish|close|merge|assign|move|archive|trash|restore)/;
|
|
71
101
|
if (writeHints.test(name))
|
|
72
102
|
return true;
|
|
@@ -91,16 +121,49 @@ function schemaFor(inputSchema) {
|
|
|
91
121
|
}
|
|
92
122
|
function renderCallResult(settled, serverName, toolName) {
|
|
93
123
|
const parts = [];
|
|
124
|
+
const images = [];
|
|
125
|
+
let imageBytes = 0;
|
|
94
126
|
for (const item of settled.content ?? []) {
|
|
95
|
-
if (item &&
|
|
127
|
+
if (item &&
|
|
128
|
+
typeof item === "object" &&
|
|
129
|
+
item.type === "text") {
|
|
96
130
|
parts.push(String(item.text ?? ""));
|
|
97
131
|
}
|
|
132
|
+
else if (item &&
|
|
133
|
+
typeof item === "object" &&
|
|
134
|
+
"type" in item &&
|
|
135
|
+
item.type === "image") {
|
|
136
|
+
const value = item;
|
|
137
|
+
if (typeof value.data !== "string" ||
|
|
138
|
+
typeof value.mimeType !== "string" ||
|
|
139
|
+
!["image/png", "image/jpeg", "image/webp"].includes(value.mimeType) ||
|
|
140
|
+
value.data.length > 12 * 1024 * 1024 ||
|
|
141
|
+
images.length >= 8 ||
|
|
142
|
+
value.data.length % 4 !== 0 ||
|
|
143
|
+
!/^[A-Za-z0-9+/]*={0,2}$/.test(value.data))
|
|
144
|
+
throw new Error("MCP image is unsupported or exceeds its limit.");
|
|
145
|
+
const bytes = Buffer.byteLength(value.data, "base64");
|
|
146
|
+
imageBytes += bytes;
|
|
147
|
+
if (!bytes || imageBytes > 8 * 1024 * 1024)
|
|
148
|
+
throw new Error("MCP image is unsupported or exceeds its limit.");
|
|
149
|
+
images.push({
|
|
150
|
+
type: "image",
|
|
151
|
+
data: value.data,
|
|
152
|
+
mimeType: value.mimeType,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
98
155
|
}
|
|
99
|
-
const text = parts.join("\n") ||
|
|
156
|
+
const text = parts.join("\n") ||
|
|
157
|
+
(images.length
|
|
158
|
+
? `Image returned by ${serverName}.${toolName} (untrusted tool content).`
|
|
159
|
+
: "(no text content)");
|
|
100
160
|
if (settled.isError === true) {
|
|
101
161
|
throw new Error(`MCP tool error from "${serverName}.${toolName}": ${text}`);
|
|
102
162
|
}
|
|
103
|
-
return {
|
|
163
|
+
return {
|
|
164
|
+
content: [{ type: "text", text }, ...images],
|
|
165
|
+
details: { server: serverName, tool: toolName },
|
|
166
|
+
};
|
|
104
167
|
}
|
|
105
168
|
function withTimeout(promise, ms, message) {
|
|
106
169
|
return new Promise((resolve, reject) => {
|
|
@@ -22,6 +22,11 @@ const PATTERNS = [
|
|
|
22
22
|
[/\b([A-Za-z0-9_]*(?:secret|password|passwd|api[_-]?key|token|private[_-]?key|access[_-]?key)[A-Za-z0-9_]*)\b(\s*[:=]\s*)("[^"]+"|'[^']+'|`[^`]+`|[^\s"']+)/gi, "$1$2[REDACTED]"],
|
|
23
23
|
// Long base64-ish blobs (likely keys/JWTs)
|
|
24
24
|
[/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]"],
|
|
25
|
+
// Opaque bearer tokens in Authorization headers: no provider prefix, no
|
|
26
|
+
// secret-named key, and usually under the base64 length threshold — none of
|
|
27
|
+
// the patterns above catch them. Keep the scheme, redact the token. Header
|
|
28
|
+
// names and auth schemes are case-insensitive (RFC 9110) — hence the flag.
|
|
29
|
+
[/(\bauthorization\s*[:=]\s*["']?bearer\s+)([a-z0-9._~+/=-]+)/gi, "$1[REDACTED]"],
|
|
25
30
|
];
|
|
26
31
|
export function scrubSecrets(text) {
|
|
27
32
|
let out = text;
|
|
@@ -52,9 +52,9 @@ export interface ResilientFetchOpts {
|
|
|
52
52
|
export declare function resilientFetch(url: string, init: RequestInit, opts?: ResilientFetchOpts): Promise<Response>;
|
|
53
53
|
/**
|
|
54
54
|
* Map a non-ok response to a friendly, body-truncated message for a tool's throw.
|
|
55
|
-
*
|
|
56
|
-
* status plus a capped slice of the body so a giant
|
|
57
|
-
* agent. No em-dashes (house copy rule).
|
|
55
|
+
* Authentication failures get a re-login hint; permission denials stay distinct.
|
|
56
|
+
* Other failures carry the status plus a capped slice of the body so a giant
|
|
57
|
+
* HTML error never floods the agent. No em-dashes (house copy rule).
|
|
58
58
|
*/
|
|
59
59
|
export declare function friendlyFetchError(label: string, res: Response): Promise<string>;
|
|
60
60
|
//# sourceMappingURL=resilientFetch.d.ts.map
|
|
@@ -105,9 +105,9 @@ export async function resilientFetch(url, init, opts = {}) {
|
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
107
|
* Map a non-ok response to a friendly, body-truncated message for a tool's throw.
|
|
108
|
-
*
|
|
109
|
-
* status plus a capped slice of the body so a giant
|
|
110
|
-
* agent. No em-dashes (house copy rule).
|
|
108
|
+
* Authentication failures get a re-login hint; permission denials stay distinct.
|
|
109
|
+
* Other failures carry the status plus a capped slice of the body so a giant
|
|
110
|
+
* HTML error never floods the agent. No em-dashes (house copy rule).
|
|
111
111
|
*/
|
|
112
112
|
export async function friendlyFetchError(label, res) {
|
|
113
113
|
let body = "";
|
|
@@ -119,9 +119,12 @@ export async function friendlyFetchError(label, res) {
|
|
|
119
119
|
}
|
|
120
120
|
if (body.length > MAX_ERROR_BODY)
|
|
121
121
|
body = `${body.slice(0, MAX_ERROR_BODY)} ...`;
|
|
122
|
-
if (res.status === 401
|
|
122
|
+
if (res.status === 401) {
|
|
123
123
|
return `${label} is not authorized (HTTP ${res.status}). Run \`yagni login\` to re-authenticate.`;
|
|
124
124
|
}
|
|
125
|
+
if (res.status === 403) {
|
|
126
|
+
return `${label} does not have permission (HTTP 403). Report the permission limitation if it blocks the task.`;
|
|
127
|
+
}
|
|
125
128
|
if (res.status === 429) {
|
|
126
129
|
return `${label} was rate limited (HTTP 429). Try again in a moment.`;
|
|
127
130
|
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* allowlist; loopback needs allowLocalBinding (allowedDomains cannot open it
|
|
20
20
|
* — loopback bypasses the proxy via no_proxy).
|
|
21
21
|
*/
|
|
22
|
+
import { type WorktreeGitAccess } from "./worktreeGit.js";
|
|
22
23
|
import type { PermissionRule } from "../permissionRules/loadConfig.js";
|
|
23
24
|
export interface SandboxNetworkSettings {
|
|
24
25
|
allowedDomains?: string[];
|
|
@@ -95,6 +96,10 @@ export interface SandboxRuntimeMerge {
|
|
|
95
96
|
allowWrite: string[];
|
|
96
97
|
denyWrite: string[];
|
|
97
98
|
};
|
|
99
|
+
/** git safe.directory entries for srt (GIT_CONFIG_* env): the worktree
|
|
100
|
+
* root + common git dir, so bwrap's uid-mapping does not trip "dubious
|
|
101
|
+
* ownership" on the main-repo path. Empty when not a worktree session. */
|
|
102
|
+
gitSafeDirectories: string[];
|
|
98
103
|
/** Permission rules that could not map into sandbox restrictions (surfaced
|
|
99
104
|
* in diagnostics — never silently dropped). */
|
|
100
105
|
droppedRules: string[];
|
|
@@ -111,5 +116,5 @@ export interface SandboxRuntimeMerge {
|
|
|
111
116
|
* cwd-relative bare) resolved per-source; sandbox.filesystem.* uses standard
|
|
112
117
|
* path semantics (/ = absolute) exactly like Claude's two resolvers.
|
|
113
118
|
*/
|
|
114
|
-
export declare function mergeRulesIntoSandbox(settings: SandboxSettings, rules: readonly PermissionRule[], base: SandboxRuntimePaths): SandboxRuntimeMerge;
|
|
119
|
+
export declare function mergeRulesIntoSandbox(settings: SandboxSettings, rules: readonly PermissionRule[], base: SandboxRuntimePaths, worktreeGit?: WorktreeGitAccess | null): SandboxRuntimeMerge;
|
|
115
120
|
//# sourceMappingURL=config.d.ts.map
|
|
@@ -295,7 +295,7 @@ homeDirDefault = homeDirDefaultFn;
|
|
|
295
295
|
* cwd-relative bare) resolved per-source; sandbox.filesystem.* uses standard
|
|
296
296
|
* path semantics (/ = absolute) exactly like Claude's two resolvers.
|
|
297
297
|
*/
|
|
298
|
-
export function mergeRulesIntoSandbox(settings, rules, base) {
|
|
298
|
+
export function mergeRulesIntoSandbox(settings, rules, base, worktreeGit) {
|
|
299
299
|
const homeDir = base.homeDir ?? homeDirDefault();
|
|
300
300
|
const allowWrite = new Set([base.cwd, tmpdir()]);
|
|
301
301
|
const denyWrite = new Set();
|
|
@@ -360,6 +360,23 @@ export function mergeRulesIntoSandbox(settings, rules, base) {
|
|
|
360
360
|
denyRead.add(resolveSandboxFsPath(p, base));
|
|
361
361
|
for (const p of settings.filesystem?.allowRead ?? [])
|
|
362
362
|
allowRead.add(resolveSandboxFsPath(p, base));
|
|
363
|
+
// Linked-worktree git access: allow writes to the shared common git dir
|
|
364
|
+
// only (routine worktree git — index.lock, refs, objects — never writes
|
|
365
|
+
// outside it), and pin hooks + config read-only within the newly allowed
|
|
366
|
+
// dir. The explicit config/hooks denies are redundant on macOS (srt's
|
|
367
|
+
// mandatory globs already cover **/.git/hooks and **/.git/config) but
|
|
368
|
+
// load-bearing on Linux, where srt's deny enforcement only ro-binds
|
|
369
|
+
// denies WITHIN allowWrite paths and its cwd-anchored mandatory sweep
|
|
370
|
+
// never sees the main repo — and for bare-repo worktrees, where the
|
|
371
|
+
// `.git` globs match nothing. Pure-merge contract: the caller passes the
|
|
372
|
+
// resolved value; this function never touches the filesystem.
|
|
373
|
+
const gitSafeDirectories = [];
|
|
374
|
+
if (worktreeGit) {
|
|
375
|
+
allowWrite.add(worktreeGit.commonGitDir);
|
|
376
|
+
denyWrite.add(join(worktreeGit.commonGitDir, "hooks"));
|
|
377
|
+
denyWrite.add(join(worktreeGit.commonGitDir, "config"));
|
|
378
|
+
gitSafeDirectories.push(worktreeGit.worktreeRoot, worktreeGit.commonGitDir);
|
|
379
|
+
}
|
|
363
380
|
// Protected paths: always denyWrite, never exempted (Claude parity + our
|
|
364
381
|
// own surfaces). Note srt denyWrite also denies read-of-ignored writes.
|
|
365
382
|
denyWrite.add(join(base.userStateHome, "config.json"));
|
|
@@ -382,6 +399,7 @@ export function mergeRulesIntoSandbox(settings, rules, base) {
|
|
|
382
399
|
allowWrite: [...allowWrite],
|
|
383
400
|
denyWrite: [...denyWrite],
|
|
384
401
|
},
|
|
402
|
+
gitSafeDirectories,
|
|
385
403
|
droppedRules,
|
|
386
404
|
};
|
|
387
405
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* bracket. Pure functions (config building) live in config.ts.
|
|
11
11
|
*/
|
|
12
12
|
import { type SandboxSettings, type SandboxRuntimeMerge } from "./config.js";
|
|
13
|
+
import { type WorktreeGitAccess } from "./worktreeGit.js";
|
|
13
14
|
import type { PermissionRule } from "../permissionRules/loadConfig.js";
|
|
14
15
|
/** Where the network ask-callback surfaces a decision. */
|
|
15
16
|
export type NetworkAskHandler = (host: string) => Promise<boolean>;
|
|
@@ -39,6 +40,9 @@ export interface SandboxSessionState {
|
|
|
39
40
|
dependencies: SandboxDependencyStatus | null;
|
|
40
41
|
/** The settings snapshot this session initialized with. */
|
|
41
42
|
settings: SandboxSettings | null;
|
|
43
|
+
/** Cached linked-worktree git resolution — resolved once at initialize()
|
|
44
|
+
* (worktree linkage does not change mid-session), undefined until then. */
|
|
45
|
+
worktreeGit: WorktreeGitAccess | null | undefined;
|
|
42
46
|
}
|
|
43
47
|
/** Default vendored rg shipped with the CLI install. */
|
|
44
48
|
export declare function defaultRgPath(env?: NodeJS.ProcessEnv): string | undefined;
|
|
@@ -69,7 +73,9 @@ export declare class YagniSandboxManager {
|
|
|
69
73
|
/**
|
|
70
74
|
* Build the runtime filesystem/network config from current settings +
|
|
71
75
|
* permission rules. Exposed for diagnostics (/sandbox config display) and
|
|
72
|
-
* tests; initialize() consumes it internally.
|
|
76
|
+
* tests; initialize() consumes it internally. Uses the cached worktree
|
|
77
|
+
* resolution when initialize() already ran; a pre-init caller (tests,
|
|
78
|
+
* panel) resolves live if the cwd is a linked worktree root.
|
|
73
79
|
*/
|
|
74
80
|
buildRuntimeMerge(rules: readonly PermissionRule[]): SandboxRuntimeMerge;
|
|
75
81
|
private runtimeConfig;
|
|
@@ -94,7 +100,8 @@ export declare class YagniSandboxManager {
|
|
|
94
100
|
refreshConfig(rules: readonly PermissionRule[]): void;
|
|
95
101
|
/**
|
|
96
102
|
* Re-init from scratch (config grant changed semantics srt can't hot-swap,
|
|
97
|
-
* or a toggle). Tears down proxies + violation store first.
|
|
103
|
+
* or a toggle). Tears down proxies + violation store first. The worktree
|
|
104
|
+
* cache clears with the state reset and re-resolves on the next init.
|
|
98
105
|
*/
|
|
99
106
|
reinitialize(rules: readonly PermissionRule[]): Promise<string | undefined>;
|
|
100
107
|
/** Tear down srt (proxies, violation store, seatbelt state). */
|
|
@@ -13,6 +13,7 @@ import { SandboxManager as SrtSandboxManager } from "@anthropic-ai/sandbox-runti
|
|
|
13
13
|
import { logEvent } from "../errorSink.js";
|
|
14
14
|
import { codeStateHome } from "../stateHome.js";
|
|
15
15
|
import { loadSandboxSettings, mergeRulesIntoSandbox, } from "./config.js";
|
|
16
|
+
import { resolveWorktreeGitAccess } from "./worktreeGit.js";
|
|
16
17
|
/** Default vendored rg shipped with the CLI install. */
|
|
17
18
|
export function defaultRgPath(env = process.env) {
|
|
18
19
|
const agentDir = env.PI_CODING_AGENT_DIR;
|
|
@@ -21,7 +22,7 @@ export function defaultRgPath(env = process.env) {
|
|
|
21
22
|
return `${agentDir.replace(/\/$/, "")}/bin/rg`;
|
|
22
23
|
}
|
|
23
24
|
export class YagniSandboxManager {
|
|
24
|
-
state = { initialized: false, dependencies: null, settings: null };
|
|
25
|
+
state = { initialized: false, dependencies: null, settings: null, worktreeGit: undefined };
|
|
25
26
|
askHandler = null;
|
|
26
27
|
opts;
|
|
27
28
|
/** Config warnings already logged this process — buildRuntimeMerge runs
|
|
@@ -95,7 +96,9 @@ export class YagniSandboxManager {
|
|
|
95
96
|
/**
|
|
96
97
|
* Build the runtime filesystem/network config from current settings +
|
|
97
98
|
* permission rules. Exposed for diagnostics (/sandbox config display) and
|
|
98
|
-
* tests; initialize() consumes it internally.
|
|
99
|
+
* tests; initialize() consumes it internally. Uses the cached worktree
|
|
100
|
+
* resolution when initialize() already ran; a pre-init caller (tests,
|
|
101
|
+
* panel) resolves live if the cwd is a linked worktree root.
|
|
99
102
|
*/
|
|
100
103
|
buildRuntimeMerge(rules) {
|
|
101
104
|
const { settings, diagnostics } = loadSandboxSettings({
|
|
@@ -105,12 +108,15 @@ export class YagniSandboxManager {
|
|
|
105
108
|
stateHomeOverride: this.opts.stateHomeOverride,
|
|
106
109
|
});
|
|
107
110
|
this.surfaceConfigDiagnostics(diagnostics);
|
|
111
|
+
const worktreeGit = this.state.worktreeGit !== undefined
|
|
112
|
+
? this.state.worktreeGit
|
|
113
|
+
: resolveWorktreeGitAccess(this.opts.cwd);
|
|
108
114
|
return mergeRulesIntoSandbox(settings, rules, {
|
|
109
115
|
cwd: this.opts.cwd,
|
|
110
116
|
userStateHome: this.opts.stateHomeOverride ?? codeStateHome(null, this.opts.env, this.opts.userHome),
|
|
111
117
|
projectRoot: this.opts.projectRoot ?? null,
|
|
112
118
|
homeDir: this.opts.userHome,
|
|
113
|
-
});
|
|
119
|
+
}, worktreeGit);
|
|
114
120
|
}
|
|
115
121
|
runtimeConfig(merge, settings) {
|
|
116
122
|
return {
|
|
@@ -129,6 +135,9 @@ export class YagniSandboxManager {
|
|
|
129
135
|
allowWrite: merge.filesystem.allowWrite,
|
|
130
136
|
denyWrite: merge.filesystem.denyWrite,
|
|
131
137
|
},
|
|
138
|
+
...(merge.gitSafeDirectories.length > 0
|
|
139
|
+
? { git: { safeDirectories: merge.gitSafeDirectories } }
|
|
140
|
+
: {}),
|
|
132
141
|
ignoreViolations: settings.ignoreViolations,
|
|
133
142
|
enableWeakerNestedSandbox: settings.enableWeakerNestedSandbox,
|
|
134
143
|
enableWeakerNetworkIsolation: settings.enableWeakerNetworkIsolation,
|
|
@@ -176,6 +185,21 @@ export class YagniSandboxManager {
|
|
|
176
185
|
this.state.dependencies = null;
|
|
177
186
|
return `sandbox initialization failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
178
187
|
}
|
|
188
|
+
// Cache the worktree resolution only after a successful init — a failed
|
|
189
|
+
// init leaves it undefined so buildRuntimeMerge keeps resolving live.
|
|
190
|
+
this.state.worktreeGit = resolveWorktreeGitAccess(this.opts.cwd);
|
|
191
|
+
// Security-relevant allow widening needs its trail: one line when the
|
|
192
|
+
// session's git writes were allowed outside cwd (path-only metadata,
|
|
193
|
+
// same exposure class as sandbox_config_warning fields; silent when
|
|
194
|
+
// null — that is every non-worktree session).
|
|
195
|
+
if (this.state.worktreeGit) {
|
|
196
|
+
logEvent({
|
|
197
|
+
source: "sandbox",
|
|
198
|
+
level: "info",
|
|
199
|
+
event: "sandbox_worktree_git_allow",
|
|
200
|
+
fields: { commonGitDir: this.state.worktreeGit.commonGitDir },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
179
203
|
this.state.initialized = true;
|
|
180
204
|
this.state.settings = load.settings;
|
|
181
205
|
this.opts.events?.onStateChange?.(true);
|
|
@@ -198,7 +222,8 @@ export class YagniSandboxManager {
|
|
|
198
222
|
}
|
|
199
223
|
/**
|
|
200
224
|
* Re-init from scratch (config grant changed semantics srt can't hot-swap,
|
|
201
|
-
* or a toggle). Tears down proxies + violation store first.
|
|
225
|
+
* or a toggle). Tears down proxies + violation store first. The worktree
|
|
226
|
+
* cache clears with the state reset and re-resolves on the next init.
|
|
202
227
|
*/
|
|
203
228
|
async reinitialize(rules) {
|
|
204
229
|
if (this.state.initialized) {
|
|
@@ -208,7 +233,7 @@ export class YagniSandboxManager {
|
|
|
208
233
|
}
|
|
209
234
|
/** Tear down srt (proxies, violation store, seatbelt state). */
|
|
210
235
|
async reset() {
|
|
211
|
-
this.state = { initialized: false, dependencies: null, settings: null };
|
|
236
|
+
this.state = { initialized: false, dependencies: null, settings: null, worktreeGit: undefined };
|
|
212
237
|
try {
|
|
213
238
|
await SrtSandboxManager.reset();
|
|
214
239
|
}
|