fix(permissions): harden shell auto-approval path checks (#972)

Co-authored-by: RinZ27 <222222878+RinZ27@users.noreply.github.com>
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-15 21:48:08 -08:00
committed by GitHub
parent d252afd15c
commit 72d43c8a43
4 changed files with 181 additions and 55 deletions

View File

@@ -185,61 +185,76 @@ function isSafeSegment(segment: string): boolean {
return isReadOnlyShellCommand(stripQuotes(nested));
}
if (!ALWAYS_SAFE_COMMANDS.has(command)) {
if (command === "git") {
const subcommand = tokens[1];
if (!subcommand) {
return false;
}
return SAFE_GIT_SUBCOMMANDS.has(subcommand);
if (ALWAYS_SAFE_COMMANDS.has(command)) {
// `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") {
return !tokens.slice(1).some((t) => hasAbsoluteOrTraversalPathArg(t));
}
if (command === "gh") {
const category = tokens[1];
if (!category) {
return false;
}
if (!(category in SAFE_GH_COMMANDS)) {
return false;
}
const allowedActions = SAFE_GH_COMMANDS[category];
// null means any action is allowed (e.g., gh search, gh api, gh status)
if (allowedActions === null) {
return true;
}
// undefined means category not in map (shouldn't happen after 'in' check)
if (allowedActions === undefined) {
return false;
}
const action = tokens[2];
if (!action) {
return false;
}
return allowedActions.has(action);
// 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));
if (hasExternalPath) {
return false;
}
if (command === "letta") {
const group = tokens[1];
if (!group) {
return false;
}
if (!(group in SAFE_LETTA_COMMANDS)) {
return false;
}
const action = tokens[2];
if (!action) {
return false;
}
return SAFE_LETTA_COMMANDS[group]?.has(action) ?? false;
}
if (command === "find") {
return !/-delete|\s-exec\b/.test(segment);
}
if (command === "sort") {
return !/\s-o\b/.test(segment);
}
return false;
return true;
}
return true;
if (command === "git") {
const subcommand = tokens[1];
if (!subcommand) {
return false;
}
return SAFE_GIT_SUBCOMMANDS.has(subcommand);
}
if (command === "gh") {
const category = tokens[1];
if (!category) {
return false;
}
if (!(category in SAFE_GH_COMMANDS)) {
return false;
}
const allowedActions = SAFE_GH_COMMANDS[category];
// null means any action is allowed (e.g., gh search, gh api, gh status)
if (allowedActions === null) {
return true;
}
// undefined means category not in map (shouldn't happen after 'in' check)
if (allowedActions === undefined) {
return false;
}
const action = tokens[2];
if (!action) {
return false;
}
return allowedActions.has(action);
}
if (command === "letta") {
const group = tokens[1];
if (!group) {
return false;
}
if (!(group in SAFE_LETTA_COMMANDS)) {
return false;
}
const action = tokens[2];
if (!action) {
return false;
}
return SAFE_LETTA_COMMANDS[group]?.has(action) ?? false;
}
if (command === "find") {
return !/-delete|\s-exec\b/.test(segment);
}
if (command === "sort") {
return !/\s-o\b/.test(segment);
}
return false;
}
function isShellExecutor(command: string): boolean {
@@ -277,6 +292,42 @@ function extractDashCArgument(tokens: string[]): string | undefined {
return undefined;
}
function isAbsolutePathArg(value: string): boolean {
if (!value) {
return false;
}
// POSIX absolute paths
if (value.startsWith("/")) {
return true;
}
// Windows absolute paths (drive letter and UNC)
return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith("\\\\");
}
function isHomeAnchoredPathArg(value: string): boolean {
if (!value) {
return false;
}
return (
value.startsWith("~/") ||
value.startsWith("$HOME/") ||
value.startsWith("%USERPROFILE%\\") ||
value.startsWith("%USERPROFILE%/")
);
}
function hasAbsoluteOrTraversalPathArg(value: string): boolean {
if (isAbsolutePathArg(value) || isHomeAnchoredPathArg(value)) {
return true;
}
// Path traversal segments only
return /(^|[\\/])\.\.([\\/]|$)/.test(value);
}
/**
* Build the set of allowed memory directory prefixes for the current agent.
* Includes:
@@ -422,9 +473,34 @@ export function isMemoryDirCommand(
// OR if all path-like arguments point to the memory dir
if (cwd && isUnderMemoryDir(cwd, prefixes)) {
// We're operating within the memory dir
const tokens = tokenize(part);
const currentCwd = cwd;
if (!currentCwd) {
return false;
}
// Even if we're in the memory dir, we must ensure the command doesn't
// escape it via absolute paths or parent directory references.
const hasExternalPath = tokens.some((t) => {
if (isAbsolutePathArg(t) || isHomeAnchoredPathArg(t)) {
return !isUnderMemoryDir(t, prefixes);
}
if (hasAbsoluteOrTraversalPathArg(t)) {
const resolved = expandPath(resolve(expandPath(currentCwd), t));
return !isUnderMemoryDir(resolved, prefixes);
}
return false;
});
if (hasExternalPath) {
return false;
}
if (!MEMORY_DIR_APPROVE_ALL) {
// Strict mode: validate command type
const tokens = tokenize(part);
const cmd = tokens[0];
if (!cmd || !SAFE_MEMORY_DIR_COMMANDS.has(cmd)) {
return false;