mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-05 10:03:39 +00:00
Replace the exec approval parser/planner path with Tree-sitter-backed authorization planning, carrying planner decisions through node and gateway execution.
This keeps unpersistable shell shapes one-shot, adds typed `unavailableDecisions` for approval prompts, and refreshes coverage for allowlist matching, command rendering, durable allow-always persistence, and host approval paths.
Verification:
- GitHub PR checks for ce2381192d: CLEAN, 142 success, 32 skipped, 0 failed, 0 pending.
- /Users/jmerhi/.nvm/versions/node/v24.12.0/bin/node scripts/plugin-sdk-surface-report.mjs --check
- /Users/jmerhi/.nvm/versions/node/v24.12.0/bin/node scripts/run-vitest.mjs test/scripts/plugin-sdk-surface-report.test.ts --reporter=verbose
- Focused exec approval suite: 13 files, 467 tests.
291 lines
8.7 KiB
TypeScript
291 lines
8.7 KiB
TypeScript
import type { SourceSpan } from "./command-explainer/types.js";
|
|
import type { ExecSegmentSatisfiedBy } from "./exec-approvals-allowlist.js";
|
|
import { resolvePlannedSegmentArgv } from "./exec-approvals-analysis.js";
|
|
import type {
|
|
ExecAuthorizationCandidate,
|
|
ExecAuthorizationPlan,
|
|
} from "./exec-authorization-plan.js";
|
|
|
|
export type AuthorizedShellRenderMode = "safeBins" | "enforced";
|
|
|
|
export type AuthorizedShellRenderResult =
|
|
| { ok: true; command: string }
|
|
| { ok: false; reason: string };
|
|
|
|
type SourceReplacement = {
|
|
startIndex: number;
|
|
endIndex: number;
|
|
text: string;
|
|
};
|
|
|
|
const SHELL_BARE_TOKEN_PATTERN = /^[A-Za-z0-9_@%+=:,./-]+$/u;
|
|
|
|
function shellEscapeSingleArg(value: string): string {
|
|
const singleQuoteEscape = `'"'"'`;
|
|
return `'${value.replace(/'/g, singleQuoteEscape)}'`;
|
|
}
|
|
|
|
function renderBareShellToken(value: string): string {
|
|
return value.length > 0 && SHELL_BARE_TOKEN_PATTERN.test(value)
|
|
? value
|
|
: shellEscapeSingleArg(value);
|
|
}
|
|
|
|
function renderSourcePreservingArgv(argv: readonly string[]): string {
|
|
return argv.map((token) => renderBareShellToken(token)).join(" ");
|
|
}
|
|
|
|
function hasUnquotedShellExpansionSource(value: string): boolean {
|
|
let quote: "single" | "double" | null = null;
|
|
let escaped = false;
|
|
let atWordStart = true;
|
|
for (const char of value) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (quote !== "single" && char === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (quote === "single") {
|
|
if (char === "'") {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
if (quote === "double") {
|
|
if (char === '"') {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
if (char === "'") {
|
|
quote = "single";
|
|
continue;
|
|
}
|
|
if (char === '"') {
|
|
quote = "double";
|
|
continue;
|
|
}
|
|
if (/\s/u.test(char)) {
|
|
atWordStart = true;
|
|
continue;
|
|
}
|
|
if (char === "~" && atWordStart) {
|
|
return true;
|
|
}
|
|
if (char === "{" || char === "*" || char === "?" || char === "[") {
|
|
return true;
|
|
}
|
|
atWordStart = false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function hasArgumentShellExpansionSource(candidate: ExecAuthorizationCandidate): boolean {
|
|
const executableEnd = Math.max(
|
|
0,
|
|
candidate.sourceStep.executableSpan.endIndex - candidate.sourceStep.span.startIndex,
|
|
);
|
|
return hasUnquotedShellExpansionSource(candidate.sourceStep.text.slice(executableEnd));
|
|
}
|
|
|
|
function validateSpan(params: {
|
|
command: string;
|
|
span: SourceSpan;
|
|
expectedText: string;
|
|
}): AuthorizedShellRenderResult {
|
|
const { span } = params;
|
|
if (
|
|
!Number.isInteger(span.startIndex) ||
|
|
!Number.isInteger(span.endIndex) ||
|
|
span.startIndex < 0 ||
|
|
span.endIndex > params.command.length ||
|
|
span.startIndex >= span.endIndex
|
|
) {
|
|
return { ok: false, reason: "invalid source span" };
|
|
}
|
|
if (params.command.slice(span.startIndex, span.endIndex) !== params.expectedText) {
|
|
return { ok: false, reason: "source span mismatch" };
|
|
}
|
|
return { ok: true, command: "" };
|
|
}
|
|
|
|
function sourceStepSlice(params: {
|
|
candidate: ExecAuthorizationCandidate;
|
|
span: SourceSpan;
|
|
}): string {
|
|
const relativeStart = Math.max(
|
|
0,
|
|
params.span.startIndex - params.candidate.sourceStep.span.startIndex,
|
|
);
|
|
const relativeEnd = Math.max(
|
|
relativeStart,
|
|
params.span.endIndex - params.candidate.sourceStep.span.startIndex,
|
|
);
|
|
return params.candidate.sourceStep.text.slice(relativeStart, relativeEnd);
|
|
}
|
|
|
|
function shouldRewriteCandidate(params: {
|
|
mode: AuthorizedShellRenderMode;
|
|
satisfiedBy: ExecSegmentSatisfiedBy | undefined;
|
|
}): boolean {
|
|
if (params.mode === "enforced") {
|
|
return true;
|
|
}
|
|
return params.satisfiedBy === "safeBins" || params.satisfiedBy === "inlineChain";
|
|
}
|
|
|
|
function hasDispatchWrapper(segment: ExecAuthorizationCandidate["sourceSegment"]): boolean {
|
|
return (segment.resolution?.wrapperChain?.length ?? 0) > 0;
|
|
}
|
|
|
|
function replacementForCandidate(params: {
|
|
command: string;
|
|
candidate: ExecAuthorizationCandidate;
|
|
mode: AuthorizedShellRenderMode;
|
|
satisfiedBy: ExecSegmentSatisfiedBy | undefined;
|
|
}): AuthorizedShellRenderResult | SourceReplacement | null {
|
|
if (!shouldRewriteCandidate({ mode: params.mode, satisfiedBy: params.satisfiedBy })) {
|
|
return null;
|
|
}
|
|
const plannedArgv = resolvePlannedSegmentArgv(params.candidate.sourceSegment);
|
|
if (!plannedArgv) {
|
|
return { ok: false, reason: "segment execution plan unavailable" };
|
|
}
|
|
if (params.satisfiedBy === "safeBins" && hasArgumentShellExpansionSource(params.candidate)) {
|
|
return { ok: false, reason: "shell expansion in safe-bin arguments" };
|
|
}
|
|
if (params.mode === "enforced" && hasArgumentShellExpansionSource(params.candidate)) {
|
|
return { ok: false, reason: "shell expansion in enforced arguments" };
|
|
}
|
|
if (params.mode === "enforced" && params.candidate.transport.kind === "shell-wrapper") {
|
|
return { ok: false, reason: "shell quoting required in wrapper payload" };
|
|
}
|
|
if (hasDispatchWrapper(params.candidate.sourceSegment)) {
|
|
const spanResult = validateSpan({
|
|
command: params.command,
|
|
span: params.candidate.sourceStep.span,
|
|
expectedText: params.candidate.sourceStep.text,
|
|
});
|
|
if (!spanResult.ok) {
|
|
return spanResult;
|
|
}
|
|
return {
|
|
startIndex: params.candidate.sourceStep.span.startIndex,
|
|
endIndex: params.candidate.sourceStep.span.endIndex,
|
|
text: renderSourcePreservingArgv(plannedArgv),
|
|
};
|
|
}
|
|
const executable = plannedArgv[0];
|
|
if (!executable) {
|
|
return { ok: false, reason: "segment execution plan unavailable" };
|
|
}
|
|
const renderedExecutable = renderBareShellToken(executable);
|
|
if (
|
|
params.satisfiedBy === "safeBins" &&
|
|
params.candidate.transport.kind === "shell-wrapper" &&
|
|
renderedExecutable !== executable
|
|
) {
|
|
return { ok: false, reason: "shell quoting required in wrapper payload" };
|
|
}
|
|
const spanResult = validateSpan({
|
|
command: params.command,
|
|
span: params.candidate.sourceStep.executableSpan,
|
|
expectedText: sourceStepSlice({
|
|
candidate: params.candidate,
|
|
span: params.candidate.sourceStep.executableSpan,
|
|
}),
|
|
});
|
|
if (!spanResult.ok) {
|
|
return spanResult;
|
|
}
|
|
return {
|
|
startIndex: params.candidate.sourceStep.executableSpan.startIndex,
|
|
endIndex: params.candidate.sourceStep.executableSpan.endIndex,
|
|
text: renderedExecutable,
|
|
};
|
|
}
|
|
|
|
function collectCandidateReplacements(params: {
|
|
command: string;
|
|
candidates: readonly ExecAuthorizationCandidate[];
|
|
mode: AuthorizedShellRenderMode;
|
|
segmentSatisfiedBy: readonly ExecSegmentSatisfiedBy[];
|
|
}): AuthorizedShellRenderResult | SourceReplacement[] {
|
|
const replacements: SourceReplacement[] = [];
|
|
for (const [index, candidate] of params.candidates.entries()) {
|
|
const replacement = replacementForCandidate({
|
|
command: params.command,
|
|
candidate,
|
|
mode: params.mode,
|
|
satisfiedBy: params.segmentSatisfiedBy[index],
|
|
});
|
|
if (!replacement) {
|
|
continue;
|
|
}
|
|
if ("ok" in replacement) {
|
|
return replacement;
|
|
}
|
|
replacements.push(replacement);
|
|
}
|
|
return replacements;
|
|
}
|
|
|
|
function applyReplacements(params: {
|
|
command: string;
|
|
replacements: readonly SourceReplacement[];
|
|
}): AuthorizedShellRenderResult {
|
|
const sorted = params.replacements.toSorted((left, right) => left.startIndex - right.startIndex);
|
|
let previousEnd = -1;
|
|
for (const replacement of sorted) {
|
|
if (replacement.startIndex < previousEnd) {
|
|
return { ok: false, reason: "overlapping replacement ranges" };
|
|
}
|
|
previousEnd = replacement.endIndex;
|
|
}
|
|
|
|
let command = params.command;
|
|
for (const replacement of sorted.toReversed()) {
|
|
command =
|
|
command.slice(0, replacement.startIndex) +
|
|
replacement.text +
|
|
command.slice(replacement.endIndex);
|
|
}
|
|
return { ok: true, command };
|
|
}
|
|
|
|
export function buildAuthorizedShellCommandFromPlan(params: {
|
|
plan: ExecAuthorizationPlan;
|
|
mode: AuthorizedShellRenderMode;
|
|
segmentSatisfiedBy?: readonly ExecSegmentSatisfiedBy[];
|
|
}): AuthorizedShellRenderResult {
|
|
if (!params.plan.ok) {
|
|
return { ok: false, reason: params.plan.reason };
|
|
}
|
|
if (params.plan.dialect !== "posix-shell") {
|
|
return { ok: false, reason: "unsupported command dialect" };
|
|
}
|
|
|
|
const candidates = params.plan.groups.flatMap((group) => group.candidates);
|
|
const segmentSatisfiedBy = params.segmentSatisfiedBy ?? [];
|
|
if (segmentSatisfiedBy.length !== candidates.length) {
|
|
return { ok: false, reason: "segment metadata mismatch" };
|
|
}
|
|
|
|
const replacements = collectCandidateReplacements({
|
|
command: params.plan.originalCommand,
|
|
candidates,
|
|
mode: params.mode,
|
|
segmentSatisfiedBy,
|
|
});
|
|
if ("ok" in replacements) {
|
|
return replacements;
|
|
}
|
|
return applyReplacements({
|
|
command: params.plan.originalCommand,
|
|
replacements,
|
|
});
|
|
}
|