fix(policy): support regex anchors in commandRegex

Modified buildArgsPatterns to correctly transform ^ and $ anchors
into JSON-aware patterns, allowing precise matching of command values.

Closes #19688
This commit is contained in:
Abhijit Balaji
2026-02-20 11:35:37 -08:00
parent 58d637f919
commit 7ff6d563f1
4 changed files with 136 additions and 6 deletions
@@ -1482,6 +1482,58 @@ describe('PolicyEngine', () => {
});
});
describe('commandRegex anchors', () => {
it('should ALLOW tmux command with $ anchor', async () => {
const patterns = buildArgsPatterns(
undefined,
undefined,
'tmux send-keys -t [a-z0-9:]+ (C-c|Up|Enter|Up Enter)$',
);
const regex = new RegExp(patterns[0]!);
engine.addRule({
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
priority: 100,
argsPattern: regex,
});
const toolCall = {
name: 'run_shell_command',
args: {
command: 'tmux send-keys -t superpowers:6 C-c',
},
};
const result = await engine.check(toolCall, undefined);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should ALLOW git status with ^ anchor', async () => {
const patterns = buildArgsPatterns(undefined, undefined, '^git status');
const regex = new RegExp(patterns[0]!);
engine.addRule({
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
priority: 100,
argsPattern: regex,
});
const toolCall = {
name: 'run_shell_command',
args: {
command: 'git status',
},
};
const result = await engine.check(toolCall, undefined);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
});
describe('Plan Mode vs Subagent Priority (Regression)', () => {
it('should DENY subagents in Plan Mode despite dynamic allow rules', async () => {
// Plan Mode Deny (1.06) > Subagent Allow (1.05)
+3 -4
View File
@@ -111,7 +111,7 @@ priority = 100
expect(result.errors).toHaveLength(0);
});
it('should NOT match if ^ is used in commandRegex because it matches against full JSON', async () => {
it('should match if ^ is used in commandRegex (TDD: currently fails)', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
toolName = "run_shell_command"
@@ -121,11 +121,10 @@ priority = 100
`);
expect(result.rules).toHaveLength(1);
// The generated pattern is "command":"^git status
// This will NOT match '{"command":"git status"}' because of the '{"' at the start.
// After the fix, this should match correctly
expect(
result.rules[0].argsPattern?.test('{"command":"git status"}'),
).toBe(false);
).toBe(true);
expect(result.errors).toHaveLength(0);
});
+50 -1
View File
@@ -125,7 +125,6 @@ describe('policy/utils', () => {
const validJsonArgs = '{"command":"echo \\"foo\\""}';
expect(regex.test(validJsonArgs)).toBe(true);
});
it('should NOT match prefixes followed by raw backslashes (security check)', () => {
// Testing that we blocked the hole: "echo\foo"
const prefix = 'echo ';
@@ -144,5 +143,55 @@ describe('policy/utils', () => {
const gitAttack = '{"command":"git\\\\status"}';
expect(gitRegex.test(gitAttack)).toBe(false);
});
describe('commandRegex anchors', () => {
it('should transform ^ anchor correctly (TDD: currently failing to match)', () => {
const patterns = buildArgsPatterns(undefined, undefined, '^git status');
const regex = new RegExp(patterns[0]!);
// JSON stringified command: {"command":"git status"}
const json = '{"command":"git status"}';
// This CURRENTLY fails because the pattern is "command":"^git status
expect(regex.test(json)).toBe(true);
});
it('should transform $ anchor correctly (TDD: currently failing to match)', () => {
const patterns = buildArgsPatterns(
undefined,
undefined,
'tmux send-keys -t [a-z0-9:]+ (C-c|Up|Enter|Up Enter)$',
);
const regex = new RegExp(patterns[0]!);
const json = '{"command":"tmux send-keys -t superpowers:6 C-c"}';
// This CURRENTLY fails because $ matches end of JSON string, not end of command value
expect(regex.test(json)).toBe(true);
});
it('should handle $ anchor when other fields follow (TDD: currently failing)', () => {
const patterns = buildArgsPatterns(undefined, undefined, 'git status$');
const regex = new RegExp(patterns[0]!);
const json = '{"command":"git status","dir_path":"/tmp"}';
expect(regex.test(json)).toBe(true);
});
it('should NOT match if $ anchor is used and more text follows in command', () => {
const patterns = buildArgsPatterns(undefined, undefined, 'git status$');
const regex = new RegExp(patterns[0]!);
const json = '{"command":"git status --porcelain"}';
expect(regex.test(json)).toBe(false);
});
it('should handle escaped anchors as literals', () => {
const patterns = buildArgsPatterns(
undefined,
undefined,
'git status\\$',
);
const regex = new RegExp(patterns[0]!);
// Literal $ in command: git status$
// JSON: {"command":"git status$"}
const json = '{"command":"git status$"}';
expect(regex.test(json)).toBe(true);
});
});
});
});
+31 -1
View File
@@ -77,7 +77,37 @@ export function buildArgsPatterns(
}
if (commandRegex) {
return [`"command":"${commandRegex}`];
let pattern = commandRegex;
// 1. Handle ^ (Start Anchor)
// If the regex starts with ^, we remove it because the pattern is already
// implicitly anchored to the start of the command value by the prepended
// "command":" prefix. We only do this if it's not escaped.
if (pattern.startsWith('^')) {
pattern = pattern.slice(1);
}
// 2. Handle $ (End Anchor)
// If the regex ends with $, we replace it with "(?:,|\\}) to match the
// closing quote of the command value in the JSON-stringified arguments.
// We only do this if the $ is not escaped by an odd number of backslashes.
if (pattern.endsWith('$')) {
let backslashCount = 0;
for (let i = pattern.length - 2; i >= 0; i--) {
if (pattern[i] === '\\') {
backslashCount++;
} else {
break;
}
}
if (backslashCount % 2 === 0) {
// Anchor to the end of the JSON string value: " followed by , or }
pattern = pattern.slice(0, -1) + '"(?:,|\\})';
}
}
return [`"command":"${pattern}`];
}
return [argsPattern];