Merge branch 'main' of bitbucket.org:siakitem/my-pi

# Conflicts:
#	AGENTS.md
#	README.md
#	package-lock.json
#	package.json
#	pi-tool-search/CHANGELOG.md
#	pi-tool-search/README.md
#	pi-tool-search/docs/dynamic-tool-loading.md
#	pi-tool-search/extensions/bundle-groups.ts
#	pi-tool-search/test/bundle-groups.test.ts
This commit is contained in:
叶林立
2026-08-26 10:59:34 +08:00
102 changed files with 8994 additions and 54 deletions
+7
View File
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Preserve a registered shell-alias input preview as authorizer evidence and permission review context, so wrappers such as `ssh_bash` can disclose their remote execution target while retaining full Bash policy enforcement.
- Add `shellTools.<name>.decisionFloor: "ask"`, which raises Bash allows to reviewable asks without weakening existing asks or hard denies, and preserve a global floor through field-level project config merging.
## [26.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.0...pi-permission-system-v26.2.1) (2026-08-17)
+2
View File
@@ -115,6 +115,8 @@ Project config (policy and runtime knobs) is loaded only once the project is tru
Within a surface map like `bash` or `mcp`, **last matching rule wins** — put broad catch-alls first and specific overrides after.
The optional `shellTools` field records which non-`bash` tools carry shell semantics (e.g. an `exec_command` tool that replaces native `bash`), so they are gated at full parity with native `bash` — see [docs/configuration.md](docs/configuration.md#shelltools--gating-aliased-shell-tools).
Registered custom input previews for those aliases are preserved as authorization evidence and review-log context, allowing an extension to disclose execution context such as an SSH target without weakening bash enforcement.
A shell alias may also set `decisionFloor: "ask"`: Bash `allow` results are raised into the configured authorizer chain, while existing `ask` and hard `deny` decisions remain unchanged.
The optional `authorizerChain` field names registered case-by-case decision links (e.g. a light model judge) to consult when a request lands on `ask`, ahead of the interactive prompt.
A downstream extension registers a link via `getPermissionsService().registerAuthorizer(name, authorize)`; it decides nothing until you name it here (opt-in), config order fixes the chain order, and the chain owner caps any link's `allow` on `external_directory`/`path` to keep it within your policy — see [docs/configuration.md](docs/configuration.md#authorizer-chain--case-by-case-decision-links).
@@ -18,7 +18,8 @@
"authorizerChain": [],
"shellTools": {
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" },
"ssh_bash": { "commandArgument": "command", "decisionFloor": "ask" }
},
"permission": {
+7 -4
View File
@@ -177,12 +177,14 @@ Some extensions replace `bash` with a differently-named tool — for example [`@
Without a hint, the permission system cannot tell that such a tool is really a shell, so it gates it as a generic extension tool and the bash rules never apply.
`shellTools` records that hint, and an aliased tool is then gated at full parity with native `bash` — command decomposition, wrapper flooring, path and external-directory token gates, and `bash:` rules — with the invoked tool name preserved in the review log.
If the extension registers a custom input formatter for the aliased tool, its preview is also carried as authorization evidence and persisted in the permission review context. This lets wrappers disclose execution context that is not part of the command itself, such as an SSH target, container, or remote working directory.
Each key is a tool name; its value maps the tool's input arguments (the keys of the tool call's `arguments` object):
```jsonc
{
"shellTools": {
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" },
"ssh_bash": { "commandArgument": "command", "decisionFloor": "ask" }
}
}
```
@@ -191,12 +193,13 @@ Each key is a tool name; its value maps the tool's input arguments (the keys of
| ----------------- | -------- | ------------------------------------------------------------------------- |
| `commandArgument` | yes | The tool's input argument holding the shell command string (e.g. `cmd`). |
| `workdirArgument` | no | The tool's input argument holding the working directory (e.g. `workdir`). |
| `decisionFloor` | no | `"ask"` raises Bash `allow` to `ask`; existing `ask`/`deny` are preserved. |
Use `decisionFloor: "ask"` when the aliased shell crosses an extra trust boundary, such as SSH or container execution. With yolo mode disabled, every otherwise-allowed invocation then reaches the configured authorizer chain unless deterministic Bash, path, or external-directory policy already denies it. Only `"ask"` is accepted; the setting cannot weaken a hard deny.
When `workdirArgument` is set, the tool's working directory is the base the command's relative paths resolve against, and the working directory itself is gated by `external_directory` when it falls outside the session's working directory.
Merge semantics: `shellTools` **shallow-merges by tool name** across global → project.
A project entry overrides a specific tool's mapping on a key collision but never drops a global entry — so adding a project-scoped alias cannot silently remove enforcement for a tool the global config already covers.
To change a specific tool's mapping, set that tool's key at the project scope (the alias object is replaced wholesale, not deep-merged).
Merge semantics: `shellTools` merges by tool name and then by descriptor field across global → project. Project values can replace argument names, but omitting a globally configured `decisionFloor: "ask"` does not remove it. Because no weaker floor value is valid, a project cannot downgrade that global review boundary.
`shellTools` only ever *tightens* enforcement and is inert when the named tool is not registered in the current session.
Opting a project out of a shell-aliasing extension is a package-disable concern, not a `shellTools` edit.
@@ -171,19 +171,31 @@
"description": "Optional name of the tool's input argument holding the working directory (e.g. 'workdir').",
"type": "string",
"minLength": 1
},
"decisionFloor": {
"description": "Require every invocation of this shell alias to be reviewed at least as an ask; deny remains deny.",
"markdownDescription": "Sets the minimum review decision for this alias. `allow` results from Bash policy are raised to `ask`, while existing `ask` and `deny` results are preserved. Only `\"ask\"` is accepted so a project override cannot weaken a global floor.",
"type": "string",
"const": "ask"
}
},
"required": ["commandArgument"],
"required": [
"commandArgument"
],
"additionalProperties": false,
"description": "Maps one shell-aliased tool to the input arguments holding its command and (optionally) its working directory."
"description": "Maps one shell-aliased tool to the input arguments holding its command and optional working directory, plus an optional ask decision floor."
},
"description": "Maps non-bash tool names that carry shell semantics to the input arguments holding their command and working directory.",
"markdownDescription": "Records which non-`bash` tools carry shell semantics, mapping each tool name to the input argument holding its command (and optionally its working directory).\n\nUse this when an extension replaces the native `bash` tool under a different name — e.g. `@howaboua/pi-codex-conversion` registers `exec_command` with a `cmd` argument and an optional `workdir`. Recording the alias lets the permission system gate that tool through the same bash enforcement stack as native `bash` (command decomposition, wrapper flooring, path/external-directory token gates, and `bash:` rules).\n\nExample:\n\n```json\n\"shellTools\": {\n \"exec_command\": { \"commandArgument\": \"cmd\", \"workdirArgument\": \"workdir\" }\n}\n```\n\n**Merge order:** shallow-merge by tool name across global → project. A project entry overrides a specific tool's mapping on key collision but never drops a global entry.",
"markdownDescription": "Records which non-`bash` tools carry shell semantics, mapping each tool name to the input argument holding its command (and optionally its working directory).\n\nUse this when an extension replaces the native `bash` tool under a different name — e.g. `@howaboua/pi-codex-conversion` registers `exec_command` with a `cmd` argument and an optional `workdir`. Recording the alias lets the permission system gate that tool through the same bash enforcement stack as native `bash` (command decomposition, wrapper flooring, path/external-directory token gates, and `bash:` rules).\n\nSet `decisionFloor` to `\"ask\"` when the wrapper crosses an additional trust boundary, such as SSH or container execution. Bash `allow` results are raised to `ask`; existing `ask` and `deny` results remain unchanged.\n\nExample:\n\n```json\n\"shellTools\": {\n \"exec_command\": { \"commandArgument\": \"cmd\", \"workdirArgument\": \"workdir\" },\n \"ssh_bash\": { \"commandArgument\": \"command\", \"decisionFloor\": \"ask\" }\n}\n```\n\n**Merge order:** merge each tool mapping by field across global → project. Project values can replace argument names, while a global `decisionFloor: \"ask\"` survives when the project entry omits it.",
"examples": [
{
"exec_command": {
"commandArgument": "cmd",
"workdirArgument": "workdir"
},
"ssh_bash": {
"commandArgument": "command",
"decisionFloor": "ask"
}
}
]
@@ -248,7 +260,9 @@
"maxLength": 500
}
},
"required": ["action"],
"required": [
"action"
],
"additionalProperties": false,
"description": "Deny with an optional custom reason shown to the agent when the action is blocked."
}
@@ -47,6 +47,8 @@ export interface ShellInvocation {
command: string;
/** The working directory the command runs in, if the tool projects one. */
workdir: string | undefined;
/** Optional minimum review decision imposed by a configured shell alias. */
decisionFloor?: ShellToolsConfig[string]["decisionFloor"];
}
/**
@@ -78,6 +80,7 @@ export function resolveShellInvocation(
return {
command: getNonEmptyString(record.command) ?? "",
workdir: undefined,
decisionFloor: undefined,
};
}
@@ -88,6 +91,7 @@ export function resolveShellInvocation(
workdir: alias.workdirArgument
? (getNonEmptyString(record[alias.workdirArgument]) ?? undefined)
: undefined,
decisionFloor: alias.decisionFloor,
};
}
+14 -9
View File
@@ -242,17 +242,22 @@ export function mergeUnifiedConfigs(
}
}
// shellTools: shallow-merge by tool name so a project entry overrides a
// colliding tool's alias but never drops a global entry (a dropped alias is
// a silent enforcement regression).
// shellTools: merge by tool name, then by descriptor field. A project can
// replace argument names, but omitting a global decisionFloor must not silently
// remove that security boundary.
const baseShell = base.shellTools;
const overrideShell = override.shellTools;
if (baseShell && overrideShell) {
merged.shellTools = { ...baseShell, ...overrideShell };
} else if (baseShell) {
merged.shellTools = baseShell;
} else if (overrideShell) {
merged.shellTools = overrideShell;
if (baseShell || overrideShell) {
const toolNames = new Set([
...Object.keys(baseShell ?? {}),
...Object.keys(overrideShell ?? {}),
]);
merged.shellTools = Object.fromEntries(
[...toolNames].map((toolName) => [
toolName,
{ ...baseShell?.[toolName], ...overrideShell?.[toolName] },
]),
) as ShellToolsConfig;
}
// Permission: deep-shallow merge
+9 -2
View File
@@ -120,10 +120,16 @@ const shellToolAliasSchema = z
description:
"Optional name of the tool's input argument holding the working directory (e.g. 'workdir').",
}),
decisionFloor: z.literal("ask").optional().meta({
description:
"Require every invocation of this shell alias to be reviewed at least as an ask; deny remains deny.",
markdownDescription:
"Sets the minimum review decision for this alias. `allow` results from Bash policy are raised to `ask`, while existing `ask` and `deny` results are preserved. Only `\"ask\"` is accepted so a project override cannot weaken a global floor.",
}),
})
.meta({
description:
"Maps one shell-aliased tool to the input arguments holding its command and (optionally) its working directory.",
"Maps one shell-aliased tool to the input arguments holding its command and optional working directory, plus an optional ask decision floor.",
});
const shellToolsSchema = z
@@ -137,10 +143,11 @@ const shellToolsSchema = z
description:
"Maps non-bash tool names that carry shell semantics to the input arguments holding their command and working directory.",
markdownDescription:
'Records which non-`bash` tools carry shell semantics, mapping each tool name to the input argument holding its command (and optionally its working directory).\n\nUse this when an extension replaces the native `bash` tool under a different name — e.g. `@howaboua/pi-codex-conversion` registers `exec_command` with a `cmd` argument and an optional `workdir`. Recording the alias lets the permission system gate that tool through the same bash enforcement stack as native `bash` (command decomposition, wrapper flooring, path/external-directory token gates, and `bash:` rules).\n\nExample:\n\n```json\n"shellTools": {\n "exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }\n}\n```\n\n**Merge order:** shallow-merge by tool name across global → project. A project entry overrides a specific tool\'s mapping on key collision but never drops a global entry.',
'Records which non-`bash` tools carry shell semantics, mapping each tool name to the input argument holding its command (and optionally its working directory).\n\nUse this when an extension replaces the native `bash` tool under a different name — e.g. `@howaboua/pi-codex-conversion` registers `exec_command` with a `cmd` argument and an optional `workdir`. Recording the alias lets the permission system gate that tool through the same bash enforcement stack as native `bash` (command decomposition, wrapper flooring, path/external-directory token gates, and `bash:` rules).\n\nSet `decisionFloor` to `"ask"` when the wrapper crosses an additional trust boundary, such as SSH or container execution. Bash `allow` results are raised to `ask`; existing `ask` and `deny` results remain unchanged.\n\nExample:\n\n```json\n"shellTools": {\n "exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" },\n "ssh_bash": { "commandArgument": "command", "decisionFloor": "ask" }\n}\n```\n\n**Merge order:** merge each tool mapping by field across global → project. Project values can replace argument names, while a global `decisionFloor: "ask"` survives when the project entry omits it.',
examples: [
{
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
ssh_bash: { commandArgument: "command", decisionFloor: "ask" },
},
],
});
@@ -0,0 +1,30 @@
import type { ShellToolsConfig } from "#src/config-schema";
import type { PermissionCheckResult } from "#src/types";
/** Synthetic rule marker recorded when a shell alias raises allow to ask. */
export const SHELL_TOOL_DECISION_FLOOR_PATTERN = "<shell-tool-decision-floor>";
type ShellDecisionFloor = ShellToolsConfig[string]["decisionFloor"];
/**
* Enforce the minimum review decision configured for a shell alias.
*
* The only supported floor is `ask`: it raises an allow to ask while preserving
* existing asks and hard denies. The synthetic result no longer reports a
* session source, so a prior Bash session approval cannot bypass an alias whose
* contract requires every invocation to enter the authorizer chain.
*/
export function applyShellDecisionFloor(
check: PermissionCheckResult,
floor: ShellDecisionFloor,
): PermissionCheckResult {
if (floor !== "ask" || check.state !== "allow") return check;
return {
...check,
state: "ask",
source: "bash",
matchedPattern: SHELL_TOOL_DECISION_FLOOR_PATTERN,
reason: undefined,
};
}
@@ -25,6 +25,7 @@ import { describePathGate } from "./path";
import type { GateRunner } from "./runner";
import { describeSkillReadGate } from "./skill-read";
import { describeToolGate } from "./tool";
import { applyShellDecisionFloor } from "./shell-decision-floor";
import type { GateOutcome, ToolCallContext } from "./types";
/**
@@ -164,25 +165,27 @@ export class ToolCallGatePipeline {
): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
if (shell) {
if (bashProgram) {
const check = resolveBashCommandCheck(
bashProgram.commandText(),
bashProgram.commands(),
tcc.agentName ?? undefined,
this.resolver,
);
return {
toolCheck: resolveBashCommandCheck(
bashProgram.commandText(),
bashProgram.commands(),
tcc.agentName ?? undefined,
this.resolver,
),
toolCheck: applyShellDecisionFloor(check, shell.decisionFloor),
};
}
// A shell invocation whose command did not parse (e.g. empty) still
// resolves on the `bash` surface, so an aliased tool never falls through
// to its own extension-tool surface.
const check = this.resolver.resolve({
kind: "tool",
surface: "bash",
input: { command: shell.command },
agentName: tcc.agentName ?? undefined,
});
return {
toolCheck: this.resolver.resolve({
kind: "tool",
surface: "bash",
input: { command: shell.command },
agentName: tcc.agentName ?? undefined,
}),
toolCheck: applyShellDecisionFloor(check, shell.decisionFloor),
};
}
@@ -65,6 +65,10 @@ export function describeToolGate(
tcc.input,
PATH_BEARING_TOOLS,
);
if (shell && tcc.toolName !== check.toolName) {
permissionLogContext.toolInputPreview =
formatter.formatToolInputForPrompt(tcc.toolName, tcc.input) || undefined;
}
// Compute session approval suggestion for the "for this session" option.
const suggestion = suggestSessionPattern(
@@ -47,7 +47,7 @@ export function buildToolAskPayload(facts: ToolAskFacts): PromptPayload {
executedUnit: check.executedUnit ?? null,
},
evidence: bash
? fullCommandEvidence(facts)
? [...fullCommandEvidence(facts), ...invokedShellInputPreviewEvidence(facts)]
: inputPreviewEvidence(facts, mcp),
annotations: [],
};
@@ -86,6 +86,14 @@ function fullCommandEvidence(facts: ToolAskFacts): PromptEvidence[] {
return [{ label: "full command", text: fullCommand, detail: null }];
}
/** Extra context supplied by the concrete tool that exposes shell semantics. */
function invokedShellInputPreviewEvidence(facts: ToolAskFacts): PromptEvidence[] {
const invokedToolName = distinctInvokedName(facts);
if (invokedToolName === null) return [];
const preview = facts.formatter?.formatToolInputForPrompt(invokedToolName, facts.input);
return preview ? [{ label: "input", text: preview, detail: null }] : [];
}
/**
* The per-tool input preview, when a formatter is registered and produces one.
*
@@ -677,7 +677,7 @@ describe("mergeUnifiedConfigs", () => {
});
});
it("shallow-merges shellTools by tool name: override adds without dropping base", () => {
it("merges shellTools by tool name: override adds without dropping base", () => {
const merged = mergeUnifiedConfigs(
{ shellTools: { exec_command: { commandArgument: "cmd" } } },
{ shellTools: { run_shell: { commandArgument: "script" } } },
@@ -688,17 +688,25 @@ describe("mergeUnifiedConfigs", () => {
});
});
it("override shellTools replaces a colliding tool's alias wholesale", () => {
it("field-merges a colliding shell alias and preserves its decision floor", () => {
const merged = mergeUnifiedConfigs(
{
shellTools: {
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
exec_command: {
commandArgument: "cmd",
workdirArgument: "workdir",
decisionFloor: "ask",
},
},
},
{ shellTools: { exec_command: { commandArgument: "command" } } },
);
expect(merged.shellTools).toEqual({
exec_command: { commandArgument: "command" },
exec_command: {
commandArgument: "command",
workdirArgument: "workdir",
decisionFloor: "ask",
},
});
});
@@ -127,6 +127,25 @@ describe("unifiedConfigSchema", () => {
expect(result.success).toBe(true);
});
it("accepts ask as a shell alias decision floor", () => {
const result = unifiedConfigSchema.safeParse({
shellTools: {
ssh_bash: { commandArgument: "command", decisionFloor: "ask" },
},
});
expect(result.success).toBe(true);
});
it.each(["allow", "deny", "invalid"] as const)(
"rejects %s as a shell alias decision floor",
(decisionFloor) => {
const result = unifiedConfigSchema.safeParse({
shellTools: { ssh_bash: { commandArgument: "command", decisionFloor } },
});
expect(result.success).toBe(false);
},
);
it("rejects an alias missing commandArgument", () => {
const result = unifiedConfigSchema.safeParse({
shellTools: { exec_command: { workdirArgument: "workdir" } },
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
applyShellDecisionFloor,
SHELL_TOOL_DECISION_FLOOR_PATTERN,
} from "#src/handlers/gates/shell-decision-floor";
import type { PermissionCheckResult, PermissionState } from "#src/types";
function check(state: PermissionState, source: PermissionCheckResult["source"] = "bash") {
return {
state,
toolName: "bash",
source,
origin: source === "session" ? ("session" as const) : ("global" as const),
command: "pwd",
matchedPattern: source === "session" ? "pwd" : "*",
} satisfies PermissionCheckResult;
}
describe("applyShellDecisionFloor", () => {
it("raises allow to ask", () => {
expect(applyShellDecisionFloor(check("allow"), "ask")).toEqual({
...check("allow"),
state: "ask",
source: "bash",
matchedPattern: SHELL_TOOL_DECISION_FLOOR_PATTERN,
reason: undefined,
});
});
it("raises a session allow to ask without retaining the session fast path", () => {
const result = applyShellDecisionFloor(check("allow", "session"), "ask");
expect(result.state).toBe("ask");
expect(result.source).toBe("bash");
expect(result.matchedPattern).toBe(SHELL_TOOL_DECISION_FLOOR_PATTERN);
});
it.each(["ask", "deny"] as const)("preserves an existing %s", (state) => {
const original = check(state);
expect(applyShellDecisionFloor(original, "ask")).toBe(original);
});
it("does nothing when no floor is configured", () => {
const original = check("allow");
expect(applyShellDecisionFloor(original, undefined)).toBe(original);
});
});
@@ -120,6 +120,36 @@ describe("describeToolGate", () => {
expect(desc.payload.request.invokedToolName).toBe("exec_command");
});
it("records a shell alias formatter preview for review and authorization context", () => {
const shell: ShellInvocation = { command: "rm -rf dist", workdir: undefined };
const formatter = new ToolPreviewFormatter(
{
toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
},
{
get: (name) =>
name === "ssh_bash"
? () => "SSH target 'packaging-server' in remote cwd '/srv/build'"
: undefined,
},
);
const desc = describeToolGate(
makeTcc({ toolName: "ssh_bash", input: { command: "rm -rf dist" } }),
makeCheckResult("ask", { toolName: "bash", source: "bash", command: "rm -rf dist" }),
formatter,
undefined,
shell,
);
expect(desc.promptDetails.toolInputPreview).toBe(
"SSH target 'packaging-server' in remote cwd '/srv/build'",
);
expect(desc.logContext.toolInputPreview).toBe(
"SSH target 'packaging-server' in remote cwd '/srv/build'",
);
});
it("returns mcp surface with target in decision.value for MCP tools", () => {
const check = makeCheckResult("ask", {
toolName: "mcp",
@@ -32,6 +32,14 @@ const execShellTools = {
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
};
const reviewedExecShellTools = {
exec_command: {
commandArgument: "cmd",
workdirArgument: "workdir",
decisionFloor: "ask" as const,
},
};
describe("shell-tool alias gating (#574)", () => {
it("denies an aliased command that a bash: rule denies", async () => {
const { handler, events } = makeHandler({
@@ -84,6 +92,37 @@ describe("shell-tool alias gating (#574)", () => {
);
});
it("raises an allowed aliased command to ask before execution", async () => {
const prompter = denyingPrompter();
const { handler, events } = makeHandler({
shellTools: reviewedExecShellTools,
tools: ["exec_command"],
prompter,
session: {
checkPermission: makeBashCommandCheck({
deny: /rm -rf/,
denyMatched: "rm -rf *",
allowMatched: "*",
}),
},
});
await handler.handleToolCall(
makeToolCallEvent("exec_command", { input: { cmd: "git status" } }),
makeCtx(),
);
expect(prompter.escalate).toHaveBeenCalledOnce();
expect(getDecisionEvents(events)).toContainEqual(
expect.objectContaining({
surface: "bash",
value: "git status",
result: "deny",
matchedPattern: "<shell-tool-decision-floor>",
}),
);
});
it("decomposes a chained aliased command so a denied sub-command still blocks", async () => {
const { handler, events } = makeHandler({
shellTools: execShellTools,
@@ -189,6 +189,28 @@ describe("buildToolAskPayload", () => {
),
).toBeUndefined();
});
test("carries a registered shell alias preview as authorization evidence", () => {
const formatter = makeFormatter({
get: (name) =>
name === "ssh_bash"
? () => "SSH target 'packaging-server' in remote cwd '/srv/build'"
: undefined,
});
const payload = buildPayload({
check: toolResult("bash", { command: "rm -rf dist" }),
surface: "bash",
invokedToolName: "ssh_bash",
input: { command: "rm -rf dist" },
formatter,
});
expect(findEvidence(payload, "input")).toEqual({
label: "input",
text: "SSH target 'packaging-server' in remote cwd '/srv/build'",
detail: null,
});
});
});
describe("mcp", () => {