Skip to content
48 changes: 48 additions & 0 deletions src/__tests__/chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,52 @@ describe("parseChain", () => {
expect(result.segments).toHaveLength(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding more edge cases to test the parsing of various redirect formats.

expect(result.parseError).toBe(false);
});

it("captures fd redirect as well-known", () => {
const result = parseChain("ls -la 2>&1");
expect(result.segments).toHaveLength(1);
expect(result.segments[0].redirects).toHaveLength(1);
expect(result.segments[0].redirects[0].target).toBe("1");
expect(result.segments[0].redirects[0].wellKnown).toBe(true);
expect(result.segments[0].command).toContain("2>&1");
});

it("captures /dev/null redirect as well-known", () => {
const result = parseChain("ls -la > /dev/null");
expect(result.segments).toHaveLength(1);
expect(result.segments[0].redirects).toHaveLength(1);
expect(result.segments[0].redirects[0].target).toBe("/dev/null");
expect(result.segments[0].redirects[0].wellKnown).toBe(true);
expect(result.segments[0].command).toContain(">/dev/null");
});

it("captures file redirect as not well-known", () => {
const result = parseChain("ls -la > /tmp/out.txt");
expect(result.segments).toHaveLength(1);
expect(result.segments[0].redirects).toHaveLength(1);
expect(result.segments[0].redirects[0].target).toBe("/tmp/out.txt");
expect(result.segments[0].redirects[0].wellKnown).toBe(false);
expect(result.segments[0].command).toContain(">/tmp/out.txt");
});

it("captures heredoc as well-known", () => {
const result = parseChain("cat << EOF");
expect(result.segments).toHaveLength(1);
expect(result.segments[0].redirects).toHaveLength(1);
expect(result.segments[0].redirects[0].wellKnown).toBe(true);
});

it("captures redirect in chain", () => {
const result = parseChain("echo hello > file.txt && cat file.txt");
expect(result.segments).toHaveLength(2);
expect(result.segments[0].redirects).toHaveLength(1);
expect(result.segments[0].redirects[0].target).toBe("file.txt");
expect(result.segments[0].redirects[0].wellKnown).toBe(false);
expect(result.segments[1].redirects).toHaveLength(0);
});

it("includes redirect in command text", () => {
const result = parseChain("echo test 2>/dev/null");
expect(result.segments[0].command).toBe("echo test 2>/dev/null");
});
});
125 changes: 118 additions & 7 deletions src/__tests__/enforce.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { describe, it, expect, beforeEach } from "vitest";
import { resolveSegment, resolveChain, beforeExecute, handlePermissionAsk, clearStoredDecision } from "../enforce.js";
import type { PluginConfig } from "../config.js";
import type { ChainSegment } from "../chain.js";

const defaultConfig: PluginConfig = {
bashRules: [
{ pattern: "*", action: "ask" },
{ pattern: "git *", action: "allow" },
{ pattern: "sudo *", action: "deny" },
],
editRules: [],
externalDirectoryRules: [
{ pattern: "./**", action: "allow" },
],
Expand All @@ -26,6 +28,7 @@ describe("resolveSegment", () => {
const configNoMatch: PluginConfig = {
...defaultConfig,
bashRules: [{ pattern: "*", action: "ask" }],
editRules: [],
};
const act = resolveSegment("cat /etc/passwd", "cat", "/project", configNoMatch);
expect(act).not.toBeNull();
Expand All @@ -34,6 +37,7 @@ describe("resolveSegment", () => {
it("most restrictive wins across checks", () => {
const config: PluginConfig = {
bashRules: [{ pattern: "*", action: "ask" }],
editRules: [],
externalDirectoryRules: [{ pattern: "*", action: "deny" }],
externalDirectoryDefault: null,
enabled: true,
Expand All @@ -45,6 +49,7 @@ describe("resolveSegment", () => {
it("no check triggers returns null", () => {
const config: PluginConfig = {
bashRules: [],
editRules: [],
externalDirectoryRules: [],
externalDirectoryDefault: null,
enabled: true,
Expand All @@ -58,8 +63,8 @@ describe("resolveChain", () => {
it("all segments allowed — chain let through", () => {
const chain = resolveChain(
[
{ command: "git status", commandName: "git" },
{ command: "git log", commandName: "git" },
{ command: "git status", commandName: "git", redirects: [] },
{ command: "git log", commandName: "git", redirects: [] },
],
"/project",
defaultConfig,
Expand All @@ -70,14 +75,15 @@ describe("resolveChain", () => {
it("any segment not allowed — chain takes its action", () => {
const config: PluginConfig = {
bashRules: [{ pattern: "git *", action: "allow" }],
editRules: [],
externalDirectoryRules: [],
externalDirectoryDefault: null,
enabled: true,
};
const chain = resolveChain(
[
{ command: "git status", commandName: "git" },
{ command: "rm -rf /", commandName: "rm" },
{ command: "git status", commandName: "git", redirects: [] },
{ command: "rm -rf /", commandName: "rm", redirects: [] },
],
"/project",
config,
Expand All @@ -88,8 +94,8 @@ describe("resolveChain", () => {
it("deny in any segment denies whole chain", () => {
const chain = resolveChain(
[
{ command: "git status", commandName: "git" },
{ command: "sudo rm -rf /", commandName: "sudo" },
{ command: "git status", commandName: "git", redirects: [] },
{ command: "sudo rm -rf /", commandName: "sudo", redirects: [] },
],
"/project",
defaultConfig,
Expand All @@ -99,7 +105,7 @@ describe("resolveChain", () => {

it("single segment with no issues", () => {
const chain = resolveChain(
[{ command: "git status", commandName: "git" }],
[{ command: "git status", commandName: "git", redirects: [] }],
"/project",
defaultConfig,
);
Expand Down Expand Up @@ -156,6 +162,7 @@ describe("handlePermissionAsk", () => {
it("does nothing for stored ask decisions", () => {
const config: PluginConfig = {
bashRules: [{ pattern: "*", action: "ask" }],
editRules: [],
externalDirectoryRules: [],
externalDirectoryDefault: null,
enabled: true,
Expand All @@ -172,3 +179,107 @@ describe("handlePermissionAsk", () => {
expect(output.status).toBe("ask");
});
});

describe("redirect enforcement", () => {
const cwd = "/project";

it("well-known fd redirect does not trigger edit check", () => {
const config: PluginConfig = {
bashRules: [{ pattern: "*", action: "allow" }],
editRules: [{ pattern: "*", action: "deny" }],
externalDirectoryRules: [],
externalDirectoryDefault: null,
enabled: true,
};
const action = resolveSegment("ls -la", "ls", cwd, config, [
{ operator: ">&", target: "1", fileDescriptor: 2, wellKnown: true },
]);
expect(action).toBe("allow");
});

it("/dev/null redirect does not trigger edit check", () => {
const config: PluginConfig = {
bashRules: [{ pattern: "*", action: "allow" }],
editRules: [{ pattern: "*", action: "deny" }],
externalDirectoryRules: [],
externalDirectoryDefault: null,
enabled: true,
};
const action = resolveSegment("ls -la", "ls", cwd, config, [
{ operator: ">", target: "/dev/null", fileDescriptor: undefined, wellKnown: true },
]);
expect(action).toBe("allow");
});

it("file redirect inside cwd checks only edit rules", () => {
const config: PluginConfig = {
bashRules: [{ pattern: "*", action: "allow" }],
editRules: [{ pattern: "/project/**", action: "allow" }],
externalDirectoryRules: [{ pattern: "*", action: "deny" }],
externalDirectoryDefault: null,
enabled: true,
};
const action = resolveSegment("ls", "ls", cwd, config, [
{ operator: ">", target: "output.txt", fileDescriptor: undefined, wellKnown: false },
]);
expect(action).toBe("allow");
});

it("file redirect outside cwd checks both edit and external_directory", () => {
const config: PluginConfig = {
bashRules: [{ pattern: "*", action: "allow" }],
editRules: [{ pattern: "/etc/**", action: "deny" }],
externalDirectoryRules: [{ pattern: "./**", action: "allow" }],
externalDirectoryDefault: "ask",
enabled: true,
};
const action = resolveSegment("echo hello", "echo", cwd, config, [
{ operator: ">", target: "/etc/passwd", fileDescriptor: undefined, wellKnown: false },
]);
expect(action).toBe("deny");
});

it("file redirect outside cwd with denied external_directory", () => {
const config: PluginConfig = {
bashRules: [{ pattern: "*", action: "allow" }],
editRules: [],
externalDirectoryRules: [],
externalDirectoryDefault: "deny",
enabled: true,
};
const action = resolveSegment("echo hello", "echo", cwd, config, [
{ operator: ">", target: "/tmp/foo", fileDescriptor: undefined, wellKnown: false },
]);
expect(action).toBe("deny");
});

it("redirect with ask edit rule produces ask", () => {
const config: PluginConfig = {
bashRules: [{ pattern: "*", action: "allow" }],
editRules: [{ pattern: "*", action: "ask" }],
externalDirectoryRules: [],
externalDirectoryDefault: null,
enabled: true,
};
const action = resolveSegment("ls", "ls", cwd, config, [
{ operator: ">", target: "out.txt", fileDescriptor: undefined, wellKnown: false },
]);
expect(action).toBe("ask");
});

it("redirect check combined with bash deny still denies", () => {
const config: PluginConfig = {
bashRules: [{ pattern: "*", action: "deny" }],
editRules: [{ pattern: "*", action: "allow" }],
externalDirectoryRules: [],
externalDirectoryDefault: null,
enabled: true,
};
const action = resolveSegment("sudo rm -rf /", "sudo rm -rf /", cwd, config, [
{ operator: ">", target: "out.txt", fileDescriptor: undefined, wellKnown: false },
]);
expect(action).toBe("deny");
});


});
48 changes: 43 additions & 5 deletions src/chain.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { parse } from "unbash";
import type { Script, Statement, Node, CommandExpansionPart, Command, AndOr, Pipeline } from "unbash";
import type { Script, Statement, Node, CommandExpansionPart, Command, AndOr, Pipeline, Redirect } from "unbash";

export interface RedirectInfo {
operator: string;
target: string;
fileDescriptor: number | undefined;
wellKnown: boolean;
}

export interface ChainSegment {
command: string;
commandName: string;
redirects: RedirectInfo[];
}

export interface ChainResult {
Expand All @@ -12,6 +20,23 @@ export interface ChainResult {
errors: string[];
}

function isWellKnownRedirect(redir: Redirect): boolean {
const target = redir.target?.text ?? redir.content ?? "";
if (target === "/dev/null") return true;
if (/^\d+$/.test(target)) return true;
if (redir.operator === "<<" || redir.operator === "<<-" || redir.operator === "<<<") return true;
return false;
}

function redirectToInfo(redir: Redirect): RedirectInfo {
return {
operator: redir.operator,
target: redir.target?.text ?? redir.content ?? "",
fileDescriptor: redir.fileDescriptor,
wellKnown: isWellKnownRedirect(redir),
};
}

function getCommandText(cmd: Command): string {
const parts: string[] = [];
if (cmd.name) {
Expand All @@ -20,6 +45,12 @@ function getCommandText(cmd: Command): string {
for (const word of cmd.suffix) {
parts.push(word.text);
}
for (const redir of cmd.redirects) {
const prefix = redir.fileDescriptor !== undefined ? String(redir.fileDescriptor) : "";
const op = redir.operator;
const target = redir.target?.text ?? "";
parts.push(`${prefix}${op}${target}`);
}
return parts.join(" ");
}

Expand Down Expand Up @@ -50,16 +81,23 @@ function extractCommandsFromNode(node: Node): Command[] {
return result;
}

function buildSegment(cmd: Command, stmtRedirects: Redirect[]): ChainSegment {
const cmdRedirects = (cmd.redirects ?? []).map(redirectToInfo);
const statementRedirects = (stmtRedirects ?? []).map(redirectToInfo);
return {
command: getCommandText(cmd),
commandName: getCommandName(cmd),
redirects: [...cmdRedirects, ...statementRedirects],
};
}

function extractCommandsFromScript(script: Script): ChainSegment[] {
const segments: ChainSegment[] = [];
for (const stmt of script.commands) {
const cmds = extractCommandsFromNode(stmt.command);
for (const cmd of cmds) {
if (cmd.type === "Command") {
segments.push({
command: getCommandText(cmd),
commandName: getCommandName(cmd),
});
segments.push(buildSegment(cmd, stmt.redirects));
}
}
}
Expand Down
16 changes: 15 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface ExternalDirectoryRule {

export interface PluginConfig {
bashRules: BashPermissionRule[];
editRules: BashPermissionRule[];
externalDirectoryRules: ExternalDirectoryRule[];
externalDirectoryDefault: ExternalDirectoryAction | null;
enabled: boolean;
Expand All @@ -25,6 +26,7 @@ export function parseConfig(config: Record<string, unknown>): PluginConfig {
const permission = config.permission as Record<string, unknown> | undefined;

let bashRules: BashPermissionRule[] = [];
let editRules: BashPermissionRule[] = [];
let externalDirectoryRules: ExternalDirectoryRule[] = [];
let externalDirectoryDefault: ExternalDirectoryAction | null = null;
let enabled = true;
Expand All @@ -42,6 +44,18 @@ export function parseConfig(config: Record<string, unknown>): PluginConfig {
}));
}

const edit = permission.edit;
if (typeof edit === "string" && isPermissionAction(edit)) {
editRules = [{ pattern: "*", action: edit }];
} else if (edit && typeof edit === "object") {
editRules = Object.entries(edit)
.filter((entry): entry is [string, unknown] => true)
.map(([pattern, action]) => ({
pattern,
action: (isPermissionAction(String(action)) ? String(action) : "ask") as "ask" | "allow" | "deny",
}));
}

const wildAction = bashRules.find((r) => r.pattern === "*")?.action;
if (wildAction === "allow") {
enabled = false;
Expand All @@ -63,7 +77,7 @@ export function parseConfig(config: Record<string, unknown>): PluginConfig {
enabled = false;
}

return { bashRules, externalDirectoryRules, externalDirectoryDefault, enabled };
return { bashRules, editRules, externalDirectoryRules, externalDirectoryDefault, enabled };
}

export function matchBashPermission(segment: string, rules: BashPermissionRule[]): "ask" | "allow" | "deny" | null {
Expand Down
Loading
Loading