From 7ff6d563f127973f2b05bef76d86908eccc52ffb Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Fri, 20 Feb 2026 11:35:37 -0800 Subject: [PATCH] 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 --- .../core/src/policy/policy-engine.test.ts | 52 +++++++++++++++++++ packages/core/src/policy/toml-loader.test.ts | 7 ++- packages/core/src/policy/utils.test.ts | 51 +++++++++++++++++- packages/core/src/policy/utils.ts | 32 +++++++++++- 4 files changed, 136 insertions(+), 6 deletions(-) diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 11e8333f47..d2291aaae6 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -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) diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index e706b16bf7..2fccb7faa9 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -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); }); diff --git a/packages/core/src/policy/utils.test.ts b/packages/core/src/policy/utils.test.ts index 90f3c632c7..6313552f31 100644 --- a/packages/core/src/policy/utils.test.ts +++ b/packages/core/src/policy/utils.test.ts @@ -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); + }); + }); }); }); diff --git a/packages/core/src/policy/utils.ts b/packages/core/src/policy/utils.ts index 3742ba3ed6..102fa9c09c 100644 --- a/packages/core/src/policy/utils.ts +++ b/packages/core/src/policy/utils.ts @@ -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];