feat: add pure ssh2 remote operations

This commit is contained in:
云服务部-叶林立
2026-08-21 19:51:43 +08:00
parent d3bf562189
commit 0ac50eb581
62 changed files with 3701 additions and 47 deletions
@@ -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.
*