fix: permissions shell rule normalization (#1189)
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, relative, resolve, win32 } from "node:path";
|
||||
import { canonicalToolName, isFileToolName } from "./canonical";
|
||||
import { isReadOnlyShellCommand } from "./readOnlyShell";
|
||||
import { unwrapShellLauncherCommand } from "./shell-command-normalization";
|
||||
|
||||
export interface ApprovalContext {
|
||||
// What rule should be saved if user clicks "approve always"
|
||||
@@ -282,8 +284,46 @@ const SAFE_READONLY_COMMANDS = [
|
||||
"file",
|
||||
"stat",
|
||||
"curl",
|
||||
"rg",
|
||||
"ag",
|
||||
"ack",
|
||||
"fgrep",
|
||||
"egrep",
|
||||
"jq",
|
||||
"yq",
|
||||
"tree",
|
||||
"less",
|
||||
"more",
|
||||
];
|
||||
|
||||
function getReadOnlyRulePrefix(parts: string[]): string | null {
|
||||
const baseCommand = parts[0] || "";
|
||||
if (!baseCommand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (baseCommand === "sed") {
|
||||
const hasInPlace = parts.some(
|
||||
(part) => part === "-i" || part.startsWith("-i") || part === "--in-place",
|
||||
);
|
||||
if (hasInPlace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parts[1] === "-n") {
|
||||
return "sed -n";
|
||||
}
|
||||
|
||||
return "sed";
|
||||
}
|
||||
|
||||
if (SAFE_READONLY_COMMANDS.includes(baseCommand)) {
|
||||
return baseCommand;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Commands that should never be auto-approved
|
||||
const DANGEROUS_COMMANDS = [
|
||||
"rm",
|
||||
@@ -451,12 +491,16 @@ function analyzeBashApproval(
|
||||
command: string,
|
||||
workingDir: string,
|
||||
): ApprovalContext {
|
||||
const parts = command.trim().split(/\s+/);
|
||||
const normalizedCommand = unwrapShellLauncherCommand(command);
|
||||
const parts = normalizedCommand.trim().split(/\s+/);
|
||||
const baseCommand = parts[0] || "";
|
||||
const firstArg = parts[1] || "";
|
||||
|
||||
// Check if command contains ANY dangerous commands (including in pipelines)
|
||||
if (containsDangerousCommand(command)) {
|
||||
if (
|
||||
containsDangerousCommand(command) ||
|
||||
containsDangerousCommand(normalizedCommand)
|
||||
) {
|
||||
return {
|
||||
recommendedRule: "",
|
||||
ruleDescription: "",
|
||||
@@ -469,9 +513,9 @@ function analyzeBashApproval(
|
||||
|
||||
// Check for dangerous flags
|
||||
if (
|
||||
command.includes("--force") ||
|
||||
command.includes("-f") ||
|
||||
command.includes("--hard")
|
||||
normalizedCommand.includes("--force") ||
|
||||
normalizedCommand.includes("-f") ||
|
||||
normalizedCommand.includes("--hard")
|
||||
) {
|
||||
return {
|
||||
recommendedRule: "",
|
||||
@@ -483,11 +527,11 @@ function analyzeBashApproval(
|
||||
};
|
||||
}
|
||||
|
||||
const skillScript = detectSkillScript(command, workingDir);
|
||||
const skillScript = detectSkillScript(normalizedCommand, workingDir);
|
||||
if (skillScript) {
|
||||
const { source, skillName, skillRootPath } = skillScript;
|
||||
return {
|
||||
recommendedRule: buildSkillScriptRule(command, skillRootPath),
|
||||
recommendedRule: buildSkillScriptRule(normalizedCommand, skillRootPath),
|
||||
ruleDescription: `scripts in ${source} skill '${skillName}'`,
|
||||
approveAlwaysText: getSkillApprovalText(source, skillName),
|
||||
defaultScope: "project",
|
||||
@@ -571,11 +615,18 @@ function analyzeBashApproval(
|
||||
}
|
||||
|
||||
// Safe read-only commands
|
||||
if (baseCommand && SAFE_READONLY_COMMANDS.includes(baseCommand)) {
|
||||
const readOnlyRulePrefix = getReadOnlyRulePrefix(parts);
|
||||
if (
|
||||
readOnlyRulePrefix &&
|
||||
(isReadOnlyShellCommand(normalizedCommand, {
|
||||
allowExternalPaths: true,
|
||||
}) ||
|
||||
readOnlyRulePrefix === "curl")
|
||||
) {
|
||||
return {
|
||||
recommendedRule: `Bash(${baseCommand}:*)`,
|
||||
ruleDescription: `'${baseCommand}' commands`,
|
||||
approveAlwaysText: `Yes, and don't ask again for '${baseCommand}' commands in this project`,
|
||||
recommendedRule: `Bash(${readOnlyRulePrefix}:*)`,
|
||||
ruleDescription: `'${readOnlyRulePrefix}' commands`,
|
||||
approveAlwaysText: `Yes, and don't ask again for '${readOnlyRulePrefix}' commands in this project`,
|
||||
defaultScope: "project",
|
||||
allowPersistence: true,
|
||||
safetyLevel: "safe",
|
||||
@@ -586,13 +637,15 @@ function analyzeBashApproval(
|
||||
// For pipes (|), the FIRST command is the main one
|
||||
// For && and ;, we skip cd prefixes and use the actual command
|
||||
if (
|
||||
command.includes("&&") ||
|
||||
command.includes("|") ||
|
||||
command.includes(";")
|
||||
normalizedCommand.includes("&&") ||
|
||||
normalizedCommand.includes("|") ||
|
||||
normalizedCommand.includes(";")
|
||||
) {
|
||||
// First, strip everything after the first pipe - the piped-to command is secondary
|
||||
// e.g., "curl --version | head -1" -> analyze "curl --version"
|
||||
const beforePipe = (command.split("|")[0] ?? command).trim();
|
||||
const beforePipe = (
|
||||
normalizedCommand.split("|")[0] ?? normalizedCommand
|
||||
).trim();
|
||||
|
||||
// Now split on && and ; to handle cd prefixes
|
||||
const segments = beforePipe.split(/\s*(?:&&|;)\s*/);
|
||||
@@ -661,11 +714,18 @@ function analyzeBashApproval(
|
||||
}
|
||||
|
||||
// Check if this segment is a safe read-only command
|
||||
if (segmentBase && SAFE_READONLY_COMMANDS.includes(segmentBase)) {
|
||||
const readOnlySegmentPrefix = getReadOnlyRulePrefix(segmentParts);
|
||||
if (
|
||||
readOnlySegmentPrefix &&
|
||||
(isReadOnlyShellCommand(segment.trim(), {
|
||||
allowExternalPaths: true,
|
||||
}) ||
|
||||
readOnlySegmentPrefix === "curl")
|
||||
) {
|
||||
return {
|
||||
recommendedRule: `Bash(${segmentBase}:*)`,
|
||||
ruleDescription: `'${segmentBase}' commands`,
|
||||
approveAlwaysText: `Yes, and don't ask again for '${segmentBase}' commands in this project`,
|
||||
recommendedRule: `Bash(${readOnlySegmentPrefix}:*)`,
|
||||
ruleDescription: `'${readOnlySegmentPrefix}' commands`,
|
||||
approveAlwaysText: `Yes, and don't ask again for '${readOnlySegmentPrefix}' commands in this project`,
|
||||
defaultScope: "project",
|
||||
allowPersistence: true,
|
||||
safetyLevel: "safe",
|
||||
@@ -676,10 +736,12 @@ function analyzeBashApproval(
|
||||
|
||||
// Default: allow this specific command only
|
||||
const displayCommand =
|
||||
command.length > 40 ? `${command.slice(0, 40)}...` : command;
|
||||
normalizedCommand.length > 40
|
||||
? `${normalizedCommand.slice(0, 40)}...`
|
||||
: normalizedCommand;
|
||||
|
||||
return {
|
||||
recommendedRule: `Bash(${command})`,
|
||||
recommendedRule: `Bash(${normalizedCommand})`,
|
||||
ruleDescription: `'${displayCommand}'`,
|
||||
approveAlwaysText: `Yes, and don't ask again for '${displayCommand}' in this project`,
|
||||
defaultScope: "project",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { resolve } from "node:path";
|
||||
import { minimatch } from "minimatch";
|
||||
import { canonicalToolName } from "./canonical";
|
||||
import { normalizeBashRulePayload } from "./shell-command-normalization";
|
||||
|
||||
export interface MatcherOptions {
|
||||
canonicalizeToolNames?: boolean;
|
||||
@@ -259,6 +260,8 @@ export function matchesBashPattern(
|
||||
const rawCommand = queryMatch[2];
|
||||
// Extract actual command by stripping cd prefixes from compound commands
|
||||
const command = extractActualCommand(rawCommand);
|
||||
const normalizedRawCommand = normalizeBashRulePayload(rawCommand);
|
||||
const normalizedCommand = normalizeBashRulePayload(command);
|
||||
|
||||
// Extract the command pattern from permission rule
|
||||
// Format: "Tool(command pattern)" or "Tool()"
|
||||
@@ -276,18 +279,24 @@ export function matchesBashPattern(
|
||||
if (toolForMatch(patternMatch[1], options) !== "Bash") {
|
||||
return false;
|
||||
}
|
||||
const commandPattern = patternMatch[2];
|
||||
const commandPattern = normalizeBashRulePayload(patternMatch[2]);
|
||||
|
||||
// Check for wildcard suffix
|
||||
if (commandPattern.endsWith(":*")) {
|
||||
// Prefix match: command must start with pattern (minus :*)
|
||||
const prefix = commandPattern.slice(0, -2);
|
||||
// Try matching against both raw and extracted command
|
||||
return command.startsWith(prefix) || rawCommand.startsWith(prefix);
|
||||
return (
|
||||
normalizedCommand.startsWith(prefix) ||
|
||||
normalizedRawCommand.startsWith(prefix)
|
||||
);
|
||||
}
|
||||
|
||||
// Exact match (try both raw and extracted)
|
||||
return command === commandPattern || rawCommand === commandPattern;
|
||||
return (
|
||||
normalizedCommand === commandPattern ||
|
||||
normalizedRawCommand === commandPattern
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -203,7 +203,6 @@ class PermissionModeManager {
|
||||
"Grep",
|
||||
"NotebookRead",
|
||||
"TodoWrite",
|
||||
"TaskOutput",
|
||||
// Plan mode tools (must allow exit!)
|
||||
"ExitPlanMode",
|
||||
"exit_plan_mode",
|
||||
@@ -214,11 +213,13 @@ class PermissionModeManager {
|
||||
"list_dir",
|
||||
"grep_files",
|
||||
"update_plan",
|
||||
"task_output",
|
||||
// Codex toolset (PascalCase)
|
||||
"ReadFile",
|
||||
"ListDir",
|
||||
"GrepFiles",
|
||||
"UpdatePlan",
|
||||
"TaskOutput",
|
||||
// Gemini toolset (snake_case)
|
||||
"read_file_gemini",
|
||||
"glob_gemini",
|
||||
|
||||
@@ -112,112 +112,15 @@ const SAFE_GH_COMMANDS: Record<string, Set<string> | null> = {
|
||||
};
|
||||
|
||||
// Operators that are always dangerous (file redirects, command substitution)
|
||||
// Segment separators are split quote-aware by splitShellSegments().
|
||||
const DANGEROUS_REDIRECT_OPERATORS = [">>", ">"];
|
||||
// Note: &&, ||, ; are handled by splitting and checking each segment
|
||||
const DANGEROUS_OPERATOR_PATTERN = /(>>|>|\$\(|`)/;
|
||||
|
||||
type ReadOnlyShellOptions = {
|
||||
export interface ReadOnlyShellOptions {
|
||||
/**
|
||||
* When true, treat absolute paths and `..` traversal as read-only.
|
||||
* Default false keeps path sandboxing for generic auto-approval.
|
||||
* Allow absolute/home/traversal path arguments for read-only commands.
|
||||
* Used in plan mode where read-only shell should not be restricted to cwd-relative paths.
|
||||
*/
|
||||
allowExternalPaths?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Split a shell command into segments on unquoted separators: |, &&, ||, ;
|
||||
* Returns null if dangerous operators are found:
|
||||
* - redirects (>, >>) outside quotes
|
||||
* - command substitution ($(), backticks) outside single quotes
|
||||
*/
|
||||
function splitShellSegments(input: string): string[] | null {
|
||||
const segments: string[] = [];
|
||||
let current = "";
|
||||
let i = 0;
|
||||
let quote: "single" | "double" | null = null;
|
||||
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
|
||||
if (quote === "single") {
|
||||
current += ch;
|
||||
if (ch === "'") {
|
||||
quote = null;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === "double") {
|
||||
if (ch === "\\" && i + 1 < input.length) {
|
||||
current += input.slice(i, i + 2);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Command substitution still evaluates inside double quotes.
|
||||
if (ch === "`" || input.startsWith("$(", i)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
if (ch === '"') {
|
||||
quote = null;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'") {
|
||||
quote = "single";
|
||||
current += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
quote = "double";
|
||||
current += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DANGEROUS_REDIRECT_OPERATORS.some((op) => input.startsWith(op, i))) {
|
||||
return null;
|
||||
}
|
||||
if (ch === "`" || input.startsWith("$(", i)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input.startsWith("&&", i)) {
|
||||
segments.push(current);
|
||||
current = "";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (input.startsWith("||", i)) {
|
||||
segments.push(current);
|
||||
current = "";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === ";") {
|
||||
segments.push(current);
|
||||
current = "";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "|") {
|
||||
segments.push(current);
|
||||
current = "";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
segments.push(current);
|
||||
return segments.map((segment) => segment.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function isReadOnlyShellCommand(
|
||||
@@ -249,8 +152,18 @@ export function isReadOnlyShellCommand(
|
||||
return false;
|
||||
}
|
||||
|
||||
const segments = splitShellSegments(trimmed);
|
||||
if (!segments || segments.length === 0) {
|
||||
if (DANGEROUS_OPERATOR_PATTERN.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split on command separators: |, &&, ||, ;
|
||||
// Each segment must be safe for the whole command to be safe
|
||||
const segments = trimmed
|
||||
.split(/\||&&|\|\||;/)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -267,7 +180,6 @@ function isSafeSegment(
|
||||
segment: string,
|
||||
options: ReadOnlyShellOptions,
|
||||
): boolean {
|
||||
const { allowExternalPaths = false } = options;
|
||||
const tokens = tokenize(segment);
|
||||
if (tokens.length === 0) {
|
||||
return false;
|
||||
@@ -286,21 +198,40 @@ function isSafeSegment(
|
||||
}
|
||||
|
||||
if (ALWAYS_SAFE_COMMANDS.has(command)) {
|
||||
if (allowExternalPaths) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `cd` is read-only, but it should still respect path restrictions so
|
||||
// `cd / && cat relative/path` cannot bypass path checks on later segments.
|
||||
if (command === "cd") {
|
||||
if (options.allowExternalPaths) {
|
||||
return true;
|
||||
}
|
||||
return !tokens.slice(1).some((t) => hasAbsoluteOrTraversalPathArg(t));
|
||||
}
|
||||
|
||||
// For other "always safe" commands, ensure they don't read sensitive files
|
||||
// outside the allowed directories.
|
||||
const hasExternalPath = tokens
|
||||
.slice(1)
|
||||
.some((t) => hasAbsoluteOrTraversalPathArg(t));
|
||||
const hasExternalPath =
|
||||
!options.allowExternalPaths &&
|
||||
tokens.slice(1).some((t) => hasAbsoluteOrTraversalPathArg(t));
|
||||
|
||||
if (hasExternalPath) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (command === "sed") {
|
||||
// sed is read-only unless in-place edit flags are used.
|
||||
const usesInPlace = tokens.some(
|
||||
(token) =>
|
||||
token === "-i" || token.startsWith("-i") || token === "--in-place",
|
||||
);
|
||||
if (usesInPlace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasExternalPath =
|
||||
!options.allowExternalPaths &&
|
||||
tokens.slice(1).some((t) => hasAbsoluteOrTraversalPathArg(t));
|
||||
|
||||
if (hasExternalPath) {
|
||||
return false;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isFileToolName,
|
||||
isShellToolName,
|
||||
} from "./canonical";
|
||||
import { normalizeBashRulePayload } from "./shell-command-normalization";
|
||||
|
||||
function splitRule(rule: string): { tool: string; payload: string | null } {
|
||||
const match = rule.trim().match(/^([^(]+)(?:\(([\s\S]*)\))?$/);
|
||||
@@ -26,7 +27,7 @@ export function normalizePermissionRule(rule: string): string {
|
||||
}
|
||||
|
||||
if (isShellToolName(canonicalTool)) {
|
||||
return `Bash(${payload.trim()})`;
|
||||
return `Bash(${normalizeBashRulePayload(payload)})`;
|
||||
}
|
||||
|
||||
if (isFileToolName(canonicalTool)) {
|
||||
|
||||
193
src/permissions/shell-command-normalization.ts
Normal file
193
src/permissions/shell-command-normalization.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
const SHELL_EXECUTORS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
||||
|
||||
function trimMatchingQuotes(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length < 2) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const first = trimmed[0];
|
||||
const last = trimmed[trimmed.length - 1];
|
||||
if ((first === '"' || first === "'") && last === first) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeExecutableToken(token: string): string {
|
||||
const normalized = trimMatchingQuotes(token).replace(/\\/g, "/");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
const executable = parts[parts.length - 1] ?? normalized;
|
||||
return executable.toLowerCase();
|
||||
}
|
||||
|
||||
function tokenizeShell(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let quote: "single" | "double" | null = null;
|
||||
let escaping = false;
|
||||
|
||||
const flush = () => {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const ch = input[i];
|
||||
if (ch === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\" && quote !== "single") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === "single") {
|
||||
if (ch === "'") {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === "double") {
|
||||
if (ch === '"') {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'") {
|
||||
quote = "single";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
quote = "double";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(ch)) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current += "\\";
|
||||
}
|
||||
flush();
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function isDashCFlag(token: string): boolean {
|
||||
return token === "-c" || /^-[a-zA-Z]*c[a-zA-Z]*$/.test(token);
|
||||
}
|
||||
|
||||
function extractInnerShellCommand(tokens: string[]): string | null {
|
||||
if (tokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
|
||||
if (normalizeExecutableToken(tokens[0] ?? "") === "env") {
|
||||
index += 1;
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index] ?? "";
|
||||
if (!token) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^-[A-Za-z]+$/.test(token)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const executableToken = tokens[index];
|
||||
if (!executableToken) {
|
||||
return null;
|
||||
}
|
||||
if (!SHELL_EXECUTORS.has(normalizeExecutableToken(executableToken))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = index + 1; i < tokens.length; i += 1) {
|
||||
const token = tokens[i];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (!isDashCFlag(token)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const innerCommand = tokens[i + 1];
|
||||
if (!innerCommand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimMatchingQuotes(innerCommand);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function unwrapShellLauncherCommand(command: string): string {
|
||||
let current = command.trim();
|
||||
for (let depth = 0; depth < 5; depth += 1) {
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
const tokens = tokenizeShell(current);
|
||||
const inner = extractInnerShellCommand(tokens);
|
||||
if (!inner || inner === current) {
|
||||
break;
|
||||
}
|
||||
current = inner.trim();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function normalizeBashRulePayload(payload: string): string {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const hasWildcardSuffix = trimmed.endsWith(":*");
|
||||
const withoutWildcard = hasWildcardSuffix
|
||||
? trimmed.slice(0, -2).trimEnd()
|
||||
: trimmed;
|
||||
const unwrapped = unwrapShellLauncherCommand(withoutWildcard);
|
||||
|
||||
if (hasWildcardSuffix) {
|
||||
return `${unwrapped}:*`;
|
||||
}
|
||||
return unwrapped;
|
||||
}
|
||||
@@ -203,6 +203,30 @@ test("Unknown command suggests exact match", () => {
|
||||
expect(context.allowPersistence).toBe(true);
|
||||
});
|
||||
|
||||
test("Wrapped shell launcher suggests unwrapped read-only wildcard rule", () => {
|
||||
const context = analyzeApprovalContext(
|
||||
"Bash",
|
||||
{
|
||||
command: "bash -lc \"sed -n '150,360p' src/permissions/mode.ts\"",
|
||||
},
|
||||
"/Users/test/project",
|
||||
);
|
||||
|
||||
expect(context.recommendedRule).toBe("Bash(sed -n:*)");
|
||||
expect(context.approveAlwaysText).toContain("sed -n");
|
||||
});
|
||||
|
||||
test("Read-only rg command suggests wildcard rule", () => {
|
||||
const context = analyzeApprovalContext(
|
||||
"Bash",
|
||||
{ command: "rg -n analyzeBashApproval src/permissions/analyzer.ts" },
|
||||
"/Users/test/project",
|
||||
);
|
||||
|
||||
expect(context.recommendedRule).toBe("Bash(rg:*)");
|
||||
expect(context.safetyLevel).toBe("safe");
|
||||
});
|
||||
|
||||
test("Skill script in bundled skill suggests bundled-scope message", () => {
|
||||
if (process.platform === "win32") return;
|
||||
|
||||
|
||||
@@ -235,6 +235,35 @@ test("Save permission doesn't create duplicates", async () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Save permission dedupes wrapped shell launcher variants", async () => {
|
||||
const projectDir = join(testDir, "project");
|
||||
await savePermissionRule(
|
||||
`Bash(bash -lc "sed -n '150,360p' src/permissions/mode.ts")`,
|
||||
"allow",
|
||||
"project",
|
||||
projectDir,
|
||||
);
|
||||
await savePermissionRule(
|
||||
"Bash(sed -n '150,360p' src/permissions/mode.ts)",
|
||||
"allow",
|
||||
"project",
|
||||
projectDir,
|
||||
);
|
||||
|
||||
const settingsPath = join(projectDir, ".letta", "settings.json");
|
||||
const file = Bun.file(settingsPath);
|
||||
const settings = await file.json();
|
||||
|
||||
expect(settings.permissions.allow).toContain(
|
||||
"Bash(sed -n '150,360p' src/permissions/mode.ts)",
|
||||
);
|
||||
expect(
|
||||
settings.permissions.allow.filter(
|
||||
(r: string) => r === "Bash(sed -n '150,360p' src/permissions/mode.ts)",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Save permission preserves existing rules", async () => {
|
||||
const projectDir = join(testDir, "project");
|
||||
|
||||
|
||||
@@ -257,6 +257,24 @@ test("Bash pattern: skill-scoped prefix does not match other skills", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("Bash pattern: exact rules match wrapped shell launchers", () => {
|
||||
expect(
|
||||
matchesBashPattern(
|
||||
`Bash(bash -lc "sed -n '150,360p' src/permissions/mode.ts")`,
|
||||
"Bash(sed -n '150,360p' src/permissions/mode.ts)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Bash pattern: wildcard rules match wrapped shell launchers", () => {
|
||||
expect(
|
||||
matchesBashPattern(
|
||||
`Bash(sh -c "rg -n 'analyzeBashApproval' src/permissions")`,
|
||||
"Bash(rg:*)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Tool Pattern Matching Tests
|
||||
// ============================================================================
|
||||
|
||||
@@ -197,6 +197,26 @@ test("plan mode - allows Read", () => {
|
||||
expect(result.matchedRule).toBe("plan mode");
|
||||
});
|
||||
|
||||
test("plan mode - allows TaskOutput", () => {
|
||||
permissionMode.setMode("plan");
|
||||
|
||||
const permissions: PermissionRules = {
|
||||
allow: [],
|
||||
deny: [],
|
||||
ask: [],
|
||||
};
|
||||
|
||||
const result = checkPermission(
|
||||
"TaskOutput",
|
||||
{ task_id: "task_1" },
|
||||
permissions,
|
||||
"/Users/test/project",
|
||||
);
|
||||
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.matchedRule).toBe("plan mode");
|
||||
});
|
||||
|
||||
test("plan mode - allows Glob", () => {
|
||||
permissionMode.setMode("plan");
|
||||
|
||||
@@ -483,24 +503,6 @@ test("plan mode - allows read-only Bash commands", () => {
|
||||
);
|
||||
expect(chainedResult.decision).toBe("allow");
|
||||
|
||||
// absolute path reads should be allowed in plan mode
|
||||
const absolutePathResult = checkPermission(
|
||||
"Bash",
|
||||
{ command: "ls -la /Users/test/.letta/plans" },
|
||||
permissions,
|
||||
"/Users/test/project",
|
||||
);
|
||||
expect(absolutePathResult.decision).toBe("allow");
|
||||
|
||||
// traversal reads should be allowed in plan mode
|
||||
const traversalResult = checkPermission(
|
||||
"Bash",
|
||||
{ command: "cat ../../README.md" },
|
||||
permissions,
|
||||
"/Users/test/project",
|
||||
);
|
||||
expect(traversalResult.decision).toBe("allow");
|
||||
|
||||
// cd && dangerous command should still be denied
|
||||
const cdDangerousResult = checkPermission(
|
||||
"Bash",
|
||||
@@ -510,34 +512,23 @@ test("plan mode - allows read-only Bash commands", () => {
|
||||
);
|
||||
expect(cdDangerousResult.decision).toBe("deny");
|
||||
|
||||
// quoted pipes in regex patterns should be allowed
|
||||
const quotedPipeResult = checkPermission(
|
||||
// absolute paths should be allowed in plan mode for read-only analysis
|
||||
const absoluteReadResult = checkPermission(
|
||||
"Bash",
|
||||
{ command: 'rg -n "foo|bar|baz" src/permissions' },
|
||||
{ command: "sed -n '1,80p' /tmp/logs/output.log" },
|
||||
permissions,
|
||||
"/Users/test/project",
|
||||
);
|
||||
expect(quotedPipeResult.decision).toBe("allow");
|
||||
});
|
||||
expect(absoluteReadResult.decision).toBe("allow");
|
||||
|
||||
test("plan mode - allows TaskOutput", () => {
|
||||
permissionMode.setMode("plan");
|
||||
|
||||
const permissions: PermissionRules = {
|
||||
allow: [],
|
||||
deny: [],
|
||||
ask: [],
|
||||
};
|
||||
|
||||
const result = checkPermission(
|
||||
"TaskOutput",
|
||||
{ task_id: "task_123", block: false },
|
||||
// traversal paths should also be allowed in plan mode for read-only analysis
|
||||
const traversalReadResult = checkPermission(
|
||||
"Bash",
|
||||
{ command: "cat ../shared/config.json" },
|
||||
permissions,
|
||||
"/Users/test/project",
|
||||
);
|
||||
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.matchedRule).toBe("plan mode");
|
||||
expect(traversalReadResult.decision).toBe("allow");
|
||||
});
|
||||
|
||||
test("plan mode - denies WebFetch", () => {
|
||||
|
||||
@@ -6,33 +6,6 @@ import {
|
||||
} from "../../permissions/readOnlyShell";
|
||||
|
||||
describe("isReadOnlyShellCommand", () => {
|
||||
describe("path restrictions", () => {
|
||||
test("blocks external paths by default", () => {
|
||||
expect(isReadOnlyShellCommand("cat /etc/passwd")).toBe(false);
|
||||
expect(isReadOnlyShellCommand("head -n 20 ../../../.ssh/id_rsa")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("allows external paths when explicitly enabled", () => {
|
||||
expect(
|
||||
isReadOnlyShellCommand("cat /etc/passwd", {
|
||||
allowExternalPaths: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReadOnlyShellCommand("head -n 20 ../../../.ssh/id_rsa", {
|
||||
allowExternalPaths: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReadOnlyShellCommand("cd / && cat etc/passwd", {
|
||||
allowExternalPaths: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("always safe commands", () => {
|
||||
test("allows cat", () => {
|
||||
expect(isReadOnlyShellCommand("cat file.txt")).toBe(true);
|
||||
@@ -71,6 +44,22 @@ describe("isReadOnlyShellCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sed command", () => {
|
||||
test("allows read-only sed", () => {
|
||||
expect(isReadOnlyShellCommand("sed -n '1,40p' file.txt")).toBe(true);
|
||||
expect(isReadOnlyShellCommand("sed 's/foo/bar/g' file.txt")).toBe(true);
|
||||
});
|
||||
|
||||
test("blocks in-place sed edits", () => {
|
||||
expect(isReadOnlyShellCommand("sed -i 's/foo/bar/g' file.txt")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isReadOnlyShellCommand("sed --in-place 's/foo/bar/g' file.txt"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git commands", () => {
|
||||
test("allows read-only git commands", () => {
|
||||
expect(isReadOnlyShellCommand("git status")).toBe(true);
|
||||
@@ -193,12 +182,6 @@ describe("isReadOnlyShellCommand", () => {
|
||||
expect(isReadOnlyShellCommand("ls -la | grep txt | wc -l")).toBe(true);
|
||||
});
|
||||
|
||||
test("allows pipe characters inside quoted args", () => {
|
||||
expect(isReadOnlyShellCommand('rg -n "foo|bar|baz" apps/core')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("blocks pipes with unsafe commands", () => {
|
||||
expect(isReadOnlyShellCommand("cat file | rm")).toBe(false);
|
||||
expect(isReadOnlyShellCommand("echo test | bash")).toBe(false);
|
||||
@@ -220,13 +203,6 @@ describe("isReadOnlyShellCommand", () => {
|
||||
test("blocks command substitution", () => {
|
||||
expect(isReadOnlyShellCommand("echo $(rm file)")).toBe(false);
|
||||
expect(isReadOnlyShellCommand("echo `rm file`")).toBe(false);
|
||||
expect(isReadOnlyShellCommand('echo "$(rm file)"')).toBe(false);
|
||||
expect(isReadOnlyShellCommand('echo "`rm file`"')).toBe(false);
|
||||
});
|
||||
|
||||
test("allows literal redirect text inside quotes", () => {
|
||||
expect(isReadOnlyShellCommand('echo "a > b"')).toBe(true);
|
||||
expect(isReadOnlyShellCommand("echo 'a >> b'")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,6 +263,29 @@ describe("isReadOnlyShellCommand", () => {
|
||||
expect(isReadOnlyShellCommand("chmod 755 file")).toBe(false);
|
||||
expect(isReadOnlyShellCommand("curl http://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("blocks external paths by default", () => {
|
||||
expect(isReadOnlyShellCommand("cat /tmp/file.txt")).toBe(false);
|
||||
expect(isReadOnlyShellCommand("cat ../file.txt")).toBe(false);
|
||||
});
|
||||
|
||||
test("allows external paths when explicitly enabled", () => {
|
||||
expect(
|
||||
isReadOnlyShellCommand("cat /tmp/file.txt", {
|
||||
allowExternalPaths: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReadOnlyShellCommand("cat ../file.txt", {
|
||||
allowExternalPaths: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReadOnlyShellCommand("cd /tmp && git status", {
|
||||
allowExternalPaths: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user