mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae56b87e81 | |||
| c0a37e824c | |||
| b807914aab | |||
| 0f4e5d5d82 | |||
| b1d9442c74 |
@@ -148,7 +148,7 @@ export interface UpdatePolicy {
|
||||
persist?: boolean;
|
||||
persistScope?: 'workspace' | 'user';
|
||||
argsPattern?: string;
|
||||
commandPrefix?: string | string[];
|
||||
commandPrefix?: string | string[] | string[][];
|
||||
mcpName?: string;
|
||||
allowRedirection?: boolean;
|
||||
modes?: ApprovalMode[];
|
||||
|
||||
@@ -538,7 +538,7 @@ interface TomlRule {
|
||||
mcpName?: string;
|
||||
decision?: string;
|
||||
priority?: number;
|
||||
commandPrefix?: string | string[];
|
||||
commandPrefix?: string | string[] | string[][];
|
||||
argsPattern?: string;
|
||||
allowRedirection?: boolean;
|
||||
modes?: ApprovalMode[];
|
||||
@@ -554,7 +554,7 @@ function findMatchingRule(
|
||||
criteria: {
|
||||
toolName: string;
|
||||
mcpName?: string;
|
||||
commandPrefix?: string | string[];
|
||||
commandPrefix?: string | string[] | string[][];
|
||||
argsPattern?: string;
|
||||
},
|
||||
): TomlRule | undefined {
|
||||
@@ -614,7 +614,12 @@ export function createPolicyUpdater(
|
||||
|
||||
if (message.commandPrefix) {
|
||||
// Convert commandPrefix(es) to argsPatterns for in-memory rules
|
||||
const patterns = buildArgsPatterns(undefined, message.commandPrefix);
|
||||
const patterns = buildArgsPatterns(
|
||||
undefined,
|
||||
message.commandPrefix,
|
||||
undefined,
|
||||
message.allowRedirection,
|
||||
);
|
||||
const tier =
|
||||
message.persistScope === 'user'
|
||||
? USER_POLICY_TIER
|
||||
|
||||
@@ -25,12 +25,14 @@ vi.mock('node:fs/promises');
|
||||
vi.mock('../config/storage.js');
|
||||
vi.mock('../utils/shell-utils.js', () => ({
|
||||
getCommandRoots: vi.fn(),
|
||||
getCommandSegments: vi.fn(),
|
||||
stripShellWrapper: vi.fn(),
|
||||
hasRedirection: vi.fn(),
|
||||
isArgumentRestrictedCommand: vi.fn(),
|
||||
}));
|
||||
interface ParsedPolicy {
|
||||
rule?: Array<{
|
||||
commandPrefix?: string | string[];
|
||||
commandPrefix?: string | string[] | string[][];
|
||||
mcpName?: string;
|
||||
toolName?: string;
|
||||
}>;
|
||||
@@ -82,7 +84,7 @@ describe('createPolicyUpdater', () => {
|
||||
priority: ALWAYS_ALLOW_PRIORITY,
|
||||
mcpName: 'test-mcp',
|
||||
argsPattern: new RegExp(
|
||||
escapeRegex('"command":"echo') + '(?:[\\s"]|\\\\")',
|
||||
`\\x00${escapeRegex('"command":"')}echo\\b(?:(?:[^"&|;\\n\\r<>]|\\\\"))*${escapeRegex('"')}\\x00`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -93,7 +95,7 @@ describe('createPolicyUpdater', () => {
|
||||
priority: ALWAYS_ALLOW_PRIORITY,
|
||||
mcpName: 'test-mcp',
|
||||
argsPattern: new RegExp(
|
||||
escapeRegex('"command":"ls') + '(?:[\\s"]|\\\\")',
|
||||
`\\x00${escapeRegex('"command":"')}ls\\b(?:(?:[^"&|;\\n\\r<>]|\\\\"))*${escapeRegex('"')}\\x00`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -172,7 +174,7 @@ describe('createPolicyUpdater', () => {
|
||||
toolName: 'run_shell_command',
|
||||
priority: ALWAYS_ALLOW_PRIORITY,
|
||||
argsPattern: new RegExp(
|
||||
escapeRegex('"command":"git') + '(?:[\\s"]|\\\\")',
|
||||
`\\x00${escapeRegex('"command":"git')}\\b(?:(?:[^"&|;\n\r<>]|\\\\"))*${escapeRegex('"')}\\x00`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -259,10 +261,14 @@ describe('ShellToolInvocation Policy Update', () => {
|
||||
(c: string) => c,
|
||||
);
|
||||
vi.mocked(shellUtils.hasRedirection).mockReturnValue(false);
|
||||
vi.mocked(shellUtils.isArgumentRestrictedCommand).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should extract multiple root commands for chained commands', () => {
|
||||
vi.mocked(shellUtils.getCommandRoots).mockReturnValue(['git', 'npm']);
|
||||
it('should extract multiple command segments for chained commands', () => {
|
||||
vi.mocked(shellUtils.getCommandSegments).mockReturnValue([
|
||||
['git'],
|
||||
['npm'],
|
||||
]);
|
||||
|
||||
const invocation = new ShellToolInvocation(
|
||||
mockConfig,
|
||||
@@ -276,14 +282,14 @@ describe('ShellToolInvocation Policy Update', () => {
|
||||
const options = (
|
||||
invocation as unknown as TestableShellToolInvocation
|
||||
).getPolicyUpdateOptions(ToolConfirmationOutcome.ProceedAlways);
|
||||
expect(options!.commandPrefix).toEqual(['git', 'npm']);
|
||||
expect(shellUtils.getCommandRoots).toHaveBeenCalledWith(
|
||||
expect(options!.commandPrefix).toEqual([['git'], ['npm']]);
|
||||
expect(shellUtils.getCommandSegments).toHaveBeenCalledWith(
|
||||
'git status && npm test',
|
||||
);
|
||||
});
|
||||
|
||||
it('should extract a single root command', () => {
|
||||
vi.mocked(shellUtils.getCommandRoots).mockReturnValue(['ls']);
|
||||
it('should extract a single command segment', () => {
|
||||
vi.mocked(shellUtils.getCommandSegments).mockReturnValue([['ls']]);
|
||||
|
||||
const invocation = new ShellToolInvocation(
|
||||
mockConfig,
|
||||
@@ -297,12 +303,12 @@ describe('ShellToolInvocation Policy Update', () => {
|
||||
const options = (
|
||||
invocation as unknown as TestableShellToolInvocation
|
||||
).getPolicyUpdateOptions(ToolConfirmationOutcome.ProceedAlways);
|
||||
expect(options!.commandPrefix).toEqual(['ls']);
|
||||
expect(shellUtils.getCommandRoots).toHaveBeenCalledWith('ls -la /tmp');
|
||||
expect(options!.commandPrefix).toEqual([['ls']]);
|
||||
expect(shellUtils.getCommandSegments).toHaveBeenCalledWith('ls -la /tmp');
|
||||
});
|
||||
|
||||
it('should include allowRedirection if command has redirection', () => {
|
||||
vi.mocked(shellUtils.getCommandRoots).mockReturnValue(['echo']);
|
||||
vi.mocked(shellUtils.getCommandSegments).mockReturnValue([['echo']]);
|
||||
vi.mocked(shellUtils.hasRedirection).mockReturnValue(true);
|
||||
|
||||
const invocation = new ShellToolInvocation(
|
||||
@@ -316,7 +322,7 @@ describe('ShellToolInvocation Policy Update', () => {
|
||||
const options = (
|
||||
invocation as unknown as TestableShellToolInvocation
|
||||
).getPolicyUpdateOptions(ToolConfirmationOutcome.ProceedAlways);
|
||||
expect(options!.commandPrefix).toEqual(['echo']);
|
||||
expect(options!.commandPrefix).toEqual([['echo']]);
|
||||
expect(options!.allowRedirection).toBe(true);
|
||||
expect(shellUtils.hasRedirection).toHaveBeenCalledWith(
|
||||
'echo "hello" > file.txt',
|
||||
|
||||
@@ -150,6 +150,64 @@ describe('Shell Safety Policy', () => {
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
describe('Sequence-based matching (e.g. ["git", "log"])', () => {
|
||||
let sequencePolicyEngine: PolicyEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
const argsPatterns = buildArgsPatterns(
|
||||
undefined,
|
||||
[['git', 'log']],
|
||||
undefined,
|
||||
);
|
||||
sequencePolicyEngine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
argsPattern: new RegExp(argsPatterns[0]!),
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 10,
|
||||
},
|
||||
],
|
||||
defaultDecision: PolicyDecision.ASK_USER,
|
||||
});
|
||||
});
|
||||
|
||||
it('SHOULD allow "git log" and variations with flags', async () => {
|
||||
const commands = [
|
||||
'git log',
|
||||
'git log --oneline',
|
||||
'git --no-pager log',
|
||||
'git -c color.ui=always log',
|
||||
];
|
||||
|
||||
for (const command of commands) {
|
||||
const result = await sequencePolicyEngine.check(
|
||||
{ name: 'run_shell_command', args: { command } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
}
|
||||
});
|
||||
|
||||
it('SHOULD NOT allow "git push" or unrelated git commands', async () => {
|
||||
const toolCall: FunctionCall = {
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'git push' },
|
||||
};
|
||||
const result = await sequencePolicyEngine.check(toolCall, undefined);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('SHOULD NOT allow compound commands if any part is disallowed', async () => {
|
||||
const toolCall: FunctionCall = {
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'git log && rm -rf /' },
|
||||
};
|
||||
const result = await sequencePolicyEngine.check(toolCall, undefined);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
});
|
||||
|
||||
it('SHOULD NOT allow "git log; rm -rf /" (semicolon separator)', async () => {
|
||||
const toolCall: FunctionCall = {
|
||||
name: 'run_shell_command',
|
||||
@@ -327,6 +385,7 @@ describe('Shell Safety Policy', () => {
|
||||
undefined,
|
||||
'git log',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const policyWithRedirection = new PolicyEngine({
|
||||
rules: [
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ApprovalMode,
|
||||
PRIORITY_SUBAGENT_TOOL,
|
||||
} from './types.js';
|
||||
import { stableStringify } from './stable-stringify.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -94,11 +95,15 @@ priority = 100
|
||||
expect(result.rules[0].toolName).toBe('run_shell_command');
|
||||
expect(result.rules[1].toolName).toBe('run_shell_command');
|
||||
expect(
|
||||
result.rules[0].argsPattern?.test('{"command":"git status"}'),
|
||||
result.rules[0].argsPattern?.test(
|
||||
stableStringify({ command: 'git status' }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.rules[1].argsPattern?.test(
|
||||
stableStringify({ command: 'git log' }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.rules[1].argsPattern?.test('{"command":"git log"}')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -160,13 +165,19 @@ priority = 100
|
||||
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(
|
||||
result.rules[0].argsPattern?.test('{"command":"git status"}'),
|
||||
result.rules[0].argsPattern?.test(
|
||||
stableStringify({ command: 'git status' }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.rules[0].argsPattern?.test('{"command":"git log --all"}'),
|
||||
result.rules[0].argsPattern?.test(
|
||||
stableStringify({ command: 'git log --all' }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.rules[0].argsPattern?.test('{"command":"git branch"}'),
|
||||
result.rules[0].argsPattern?.test(
|
||||
stableStringify({ command: 'git branch' }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
@@ -184,7 +195,9 @@ priority = 100
|
||||
// The generated pattern is "command":"^git status
|
||||
// This will NOT match '{"command":"git status"}' because of the '{"' at the start.
|
||||
expect(
|
||||
result.rules[0].argsPattern?.test('{"command":"git status"}'),
|
||||
result.rules[0].argsPattern?.test(
|
||||
stableStringify({ command: 'git status' }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
@@ -421,10 +434,14 @@ priority = 100
|
||||
expect(result.rules).toHaveLength(1);
|
||||
// The regex should have escaped the * and .
|
||||
expect(
|
||||
result.rules[0].argsPattern?.test('{"command":"git log file.txt"}'),
|
||||
result.rules[0].argsPattern?.test(
|
||||
stableStringify({ command: 'git log file.txt' }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
result.rules[0].argsPattern?.test('{"command":"git log *.txt"}'),
|
||||
result.rules[0].argsPattern?.test(
|
||||
stableStringify({ command: 'git log *.txt' }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -41,7 +41,9 @@ const PolicyRuleSchema = z.object({
|
||||
subagent: z.string().optional(),
|
||||
mcpName: z.string().optional(),
|
||||
argsPattern: z.string().optional(),
|
||||
commandPrefix: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
commandPrefix: z
|
||||
.union([z.string(), z.array(z.union([z.string(), z.array(z.string())]))])
|
||||
.optional(),
|
||||
commandRegex: z.string().optional(),
|
||||
decision: z.nativeEnum(PolicyDecision),
|
||||
// Priority must be in range [0, 999] to prevent tier overflow.
|
||||
@@ -76,7 +78,9 @@ const SafetyCheckerRuleSchema = z.object({
|
||||
toolName: z.union([z.string(), z.array(z.string())]),
|
||||
mcpName: z.string().optional(),
|
||||
argsPattern: z.string().optional(),
|
||||
commandPrefix: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
commandPrefix: z
|
||||
.union([z.string(), z.array(z.union([z.string(), z.array(z.string())]))])
|
||||
.optional(),
|
||||
commandRegex: z.string().optional(),
|
||||
priority: z.number().int().default(0),
|
||||
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
|
||||
@@ -458,6 +462,7 @@ export async function loadPoliciesFromToml(
|
||||
rule.argsPattern,
|
||||
rule.commandPrefix,
|
||||
rule.commandRegex,
|
||||
rule.allowRedirection ?? rule.allow_redirection,
|
||||
);
|
||||
|
||||
// For each argsPattern, expand toolName arrays
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { escapeRegex, buildArgsPatterns, isSafeRegExp } from './utils.js';
|
||||
import { stableStringify } from './stable-stringify.js';
|
||||
|
||||
describe('policy/utils', () => {
|
||||
describe('escapeRegex', () => {
|
||||
@@ -69,44 +70,51 @@ describe('policy/utils', () => {
|
||||
|
||||
it('should build pattern from a single commandPrefix', () => {
|
||||
const result = buildArgsPatterns(undefined, 'ls', undefined);
|
||||
expect(result).toEqual(['\\"command\\":\\"ls(?:[\\s"]|\\\\")']);
|
||||
expect(result).toEqual([
|
||||
'\\x00\\"command\\":\\"ls\\b(?:(?:[^"&|;\n\r<>]|\\\\"))*\\"\\x00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should build patterns from an array of commandPrefixes', () => {
|
||||
const result = buildArgsPatterns(undefined, ['echo', 'ls'], undefined);
|
||||
expect(result).toEqual([
|
||||
'\\"command\\":\\"echo(?:[\\s"]|\\\\")',
|
||||
'\\"command\\":\\"ls(?:[\\s"]|\\\\")',
|
||||
'\\x00\\"command\\":\\"echo\\b(?:(?:[^"&|;\n\r<>]|\\\\"))*\\"\\x00',
|
||||
'\\x00\\"command\\":\\"ls\\b(?:(?:[^"&|;\n\r<>]|\\\\"))*\\"\\x00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should build pattern from commandRegex', () => {
|
||||
const result = buildArgsPatterns(undefined, undefined, 'rm -rf .*');
|
||||
expect(result).toEqual(['"command":"rm -rf .*']);
|
||||
expect(result).toEqual([
|
||||
'\\x00\\"command\\":\\"rm -rf .*(?:(?!&|;\n\r<>)(?:[^"&|;\n\r<>]|\\\\"))*\\"\\x00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should prioritize commandPrefix over commandRegex and argsPattern', () => {
|
||||
const result = buildArgsPatterns('raw', 'prefix', 'regex');
|
||||
expect(result).toEqual(['\\"command\\":\\"prefix(?:[\\s"]|\\\\")']);
|
||||
expect(result).toEqual([
|
||||
'\\x00\\"command\\":\\"prefix\\b(?:(?:[^"&|;\n\r<>]|\\\\"))*\\"\\x00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should prioritize commandRegex over argsPattern if no commandPrefix', () => {
|
||||
const result = buildArgsPatterns('raw', undefined, 'regex');
|
||||
expect(result).toEqual(['"command":"regex']);
|
||||
expect(result).toEqual([
|
||||
'\\x00\\"command\\":\\"regex(?:(?!&|;\n\r<>)(?:[^"&|;\n\r<>]|\\\\"))*\\"\\x00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should escape characters in commandPrefix', () => {
|
||||
const result = buildArgsPatterns(undefined, 'git checkout -b', undefined);
|
||||
expect(result).toEqual([
|
||||
'\\"command\\":\\"git\\ checkout\\ \\-b(?:[\\s"]|\\\\")',
|
||||
'\\x00\\"command\\":\\"git\\ checkout\\ \\-b\\b(?:(?:[^"&|;\n\r<>]|\\\\"))*\\"\\x00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correctly escape quotes in commandPrefix', () => {
|
||||
const result = buildArgsPatterns(undefined, 'git "fix"', undefined);
|
||||
expect(result).toEqual([
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
'\\\"command\\\":\\\"git\\ \\\\\\\"fix\\\\\\\"(?:[\\s\"]|\\\\\")',
|
||||
'\\x00\\"command\\":\\"git\\ \\\\\\"fix\\\\\\"(?:(?:[^"&|;\n\r<>]|\\\\"))*\\"\\x00',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -121,9 +129,8 @@ describe('policy/utils', () => {
|
||||
const patterns = buildArgsPatterns(undefined, prefix, undefined);
|
||||
const regex = new RegExp(patterns[0]!);
|
||||
|
||||
// Mimic JSON stringified args
|
||||
// echo "foo" -> {"command":"echo \"foo\""}
|
||||
const validJsonArgs = '{"command":"echo \\"foo\\""}';
|
||||
// Mimic JSON stringified args with null byte boundaries
|
||||
const validJsonArgs = stableStringify({ command: 'echo "foo"' });
|
||||
expect(regex.test(validJsonArgs)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -134,16 +141,76 @@ describe('policy/utils', () => {
|
||||
const regex = new RegExp(patterns[0]!);
|
||||
|
||||
// echo\foo -> {"command":"echo\\foo"}
|
||||
// In regex matching: "echo " is followed by "\" which is NOT in [\s"] and is not \"
|
||||
const attackJsonArgs = '{"command":"echo\\\\foo"}';
|
||||
const attackJsonArgs = stableStringify({ command: 'echo\\foo' });
|
||||
expect(regex.test(attackJsonArgs)).toBe(false);
|
||||
|
||||
// Also validation for "git " matching "git\status"
|
||||
const gitPatterns = buildArgsPatterns(undefined, 'git ', undefined);
|
||||
const gitRegex = new RegExp(gitPatterns[0]!);
|
||||
// git\status -> {"command":"git\\status"}
|
||||
const gitAttack = '{"command":"git\\\\status"}';
|
||||
const gitAttack = stableStringify({ command: 'git\\status' });
|
||||
expect(gitAttack).not.toMatch(gitRegex);
|
||||
});
|
||||
|
||||
it('should NOT match chained commands using shell operators (security check)', () => {
|
||||
// Testing that we block "git log && rm -rf /" when the prefix is "git log"
|
||||
const prefix = 'git log';
|
||||
const patterns = buildArgsPatterns(undefined, prefix, undefined);
|
||||
const regex = new RegExp(patterns[0]!);
|
||||
|
||||
const attackJsonArgs = stableStringify({
|
||||
command: 'git log && rm -rf /',
|
||||
});
|
||||
expect(regex.test(attackJsonArgs)).toBe(false);
|
||||
|
||||
const semicolonAttack = stableStringify({
|
||||
command: 'git log; rm -rf /',
|
||||
});
|
||||
expect(regex.test(semicolonAttack)).toBe(false);
|
||||
});
|
||||
|
||||
describe('sequence matching', () => {
|
||||
it('should build pattern from a sequence of tokens', () => {
|
||||
const result = buildArgsPatterns(
|
||||
undefined,
|
||||
[['git', 'log']],
|
||||
undefined,
|
||||
);
|
||||
const regex = new RegExp(result[0]!);
|
||||
|
||||
expect(regex.test(stableStringify({ command: 'git log' }))).toBe(true);
|
||||
expect(
|
||||
regex.test(stableStringify({ command: 'git log --oneline' })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
regex.test(stableStringify({ command: 'git --no-pager log' })),
|
||||
).toBe(true);
|
||||
expect(regex.test(stableStringify({ command: 'git push' }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow flags and options between tokens', () => {
|
||||
const result = buildArgsPatterns(
|
||||
undefined,
|
||||
[['python3', 'main.py']],
|
||||
undefined,
|
||||
);
|
||||
const regex = new RegExp(result[0]!);
|
||||
|
||||
expect(
|
||||
regex.test(stableStringify({ command: 'python3 main.py' })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
regex.test(stableStringify({ command: 'python3 main.py --help' })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
regex.test(stableStringify({ command: 'python3 -u main.py' })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
regex.test(stableStringify({ command: 'python3 other.py' })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,21 +49,57 @@ export function isSafeRegExp(pattern: string): boolean {
|
||||
* the internal argsPattern representation used by the PolicyEngine.
|
||||
*
|
||||
* @param argsPattern An optional raw regex string for arguments.
|
||||
* @param commandPrefix An optional command prefix (or list of prefixes) to allow.
|
||||
* @param commandPrefix An optional command prefix (or list of prefixes/sequences) to allow.
|
||||
* @param commandRegex An optional command regex string to allow.
|
||||
* @param allowRedirection Whether to allow redirection operators in the command.
|
||||
* @returns An array of string patterns (or undefined) for the PolicyEngine.
|
||||
*/
|
||||
export function buildArgsPatterns(
|
||||
argsPattern?: string,
|
||||
commandPrefix?: string | string[],
|
||||
commandPrefix?: string | Array<string | string[]>,
|
||||
commandRegex?: string,
|
||||
allowRedirection?: boolean,
|
||||
): Array<string | undefined> {
|
||||
const shellSeparators = '&|;\n\r';
|
||||
const forbiddenChars = allowRedirection
|
||||
? shellSeparators
|
||||
: `${shellSeparators}<>`;
|
||||
|
||||
const forbiddenCharClass = `(?:[^"${forbiddenChars}]|\\\\")`;
|
||||
|
||||
if (commandPrefix) {
|
||||
const prefixes = Array.isArray(commandPrefix)
|
||||
? commandPrefix
|
||||
: [commandPrefix];
|
||||
|
||||
return prefixes.map((prefix) => {
|
||||
if (Array.isArray(prefix)) {
|
||||
// Handle sequence matching (e.g., ['git', 'log'])
|
||||
// We want to match these tokens in order, allowing flags in between.
|
||||
if (prefix.length === 0) return undefined;
|
||||
|
||||
// Escape each token and ensure word boundaries
|
||||
const escapedTokens = prefix.map((token) => escapeRegex(token));
|
||||
|
||||
// Start with matching \0"command":"<first_token>
|
||||
let pattern = `\\x00${escapeRegex('"command":"')}${escapedTokens[0]}`;
|
||||
|
||||
// For subsequent tokens, allow flags in between
|
||||
for (let i = 1; i < escapedTokens.length; i++) {
|
||||
// Allow whitespace or flags (starting with -) in between tokens.
|
||||
// [^"]*? is replaced by forbiddenCharClass to stay within the command string value
|
||||
// and avoid separators.
|
||||
const gap = `(?:(?:\\s+|-(?:${forbiddenCharClass})*?)\\s*)`;
|
||||
pattern += `${gap}*${escapedTokens[i]}`;
|
||||
}
|
||||
|
||||
// After the last token, we also don't allow forbidden characters until the end of the command string.
|
||||
// We match until the closing quote of the command value followed by \0.
|
||||
// We add a word boundary if the last token ends in a word character.
|
||||
const suffix = /\w$/.test(prefix[prefix.length - 1]) ? '\\b' : '';
|
||||
return `${pattern}${suffix}(?:${forbiddenCharClass})*${escapeRegex('"')}\\x00`;
|
||||
}
|
||||
|
||||
// JSON.stringify safely encodes the prefix in quotes.
|
||||
// We remove ONLY the trailing quote to match it as an open prefix string.
|
||||
const encodedPrefix = JSON.stringify(prefix);
|
||||
@@ -72,18 +108,22 @@ export function buildArgsPatterns(
|
||||
encodedPrefix.length - 1,
|
||||
);
|
||||
|
||||
// Escape the exact JSON literal segment we expect to see
|
||||
const matchSegment = escapeRegex(`"command":${openQuotePrefix}`);
|
||||
// Escape the exact JSON literal segment we expect to see, including \0 boundary.
|
||||
const matchSegment = `\\x00${escapeRegex(
|
||||
`"command":${openQuotePrefix}`,
|
||||
)}`;
|
||||
|
||||
// We allow [\s], ["], or the specific sequence [\"] (for escaped quotes
|
||||
// in JSON). We do NOT allow generic [\\], which would match "git\status"
|
||||
// -> "gitstatus".
|
||||
return `${matchSegment}(?:[\\s"]|\\\\")`;
|
||||
// We allow any characters except forbidden shell separators until the closing quote and \0.
|
||||
// We add a word boundary if the prefix ends in a word character.
|
||||
const suffix = /\w$/.test(prefix) ? '\\b' : '';
|
||||
return `${matchSegment}${suffix}(?:${forbiddenCharClass})*${escapeRegex('"')}\\x00`;
|
||||
});
|
||||
}
|
||||
|
||||
if (commandRegex) {
|
||||
return [`"command":"${commandRegex}`];
|
||||
return [
|
||||
`\\x00${escapeRegex('"command":"')}${commandRegex}(?:(?!${forbiddenChars})${forbiddenCharClass})*${escapeRegex('"')}\\x00`,
|
||||
];
|
||||
}
|
||||
|
||||
return [argsPattern];
|
||||
@@ -102,10 +142,10 @@ export function buildParamArgsPattern(
|
||||
value: unknown,
|
||||
): string {
|
||||
const encodedValue = JSON.stringify(value);
|
||||
// We wrap the JSON string in escapeRegex and prepend/append \\0 to explicitly
|
||||
// We wrap the JSON string in escapeRegex and prepend/append \\x00 to explicitly
|
||||
// match top-level JSON properties generated by stableStringify, preventing
|
||||
// argument injection bypass attacks.
|
||||
return `\\\\0${escapeRegex(`"${paramName}":${encodedValue}`)}\\\\0`;
|
||||
return `\\x00${escapeRegex(`"${paramName}":${encodedValue}`)}\\x00`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,8 +77,28 @@ export function isKnownSafeCommand(args: string[]): boolean {
|
||||
// Normalize zsh to bash
|
||||
const normalizedArgs = args.map((a) => (a === 'zsh' ? 'bash' : a));
|
||||
|
||||
const shellMetaCharacters = [
|
||||
'&',
|
||||
'|',
|
||||
';',
|
||||
'<',
|
||||
'>',
|
||||
'(',
|
||||
')',
|
||||
'$',
|
||||
'`',
|
||||
'\\\\',
|
||||
'\\n',
|
||||
'\\r',
|
||||
];
|
||||
const hasShellMeta = (str: string) =>
|
||||
shellMetaCharacters.some((meta) => str.includes(meta));
|
||||
|
||||
if (isSafeToCallWithExec(normalizedArgs)) {
|
||||
return true;
|
||||
// Ensure no arguments contain shell metacharacters that could lead to injection
|
||||
// when this is called from a shell context (like PolicyEngine).
|
||||
// We allow the root command itself (normalizedArgs[0]) but check all arguments.
|
||||
return !normalizedArgs.slice(1).some(hasShellMeta);
|
||||
}
|
||||
|
||||
// Support `bash -lc "..."`
|
||||
|
||||
@@ -718,7 +718,7 @@ describe('policy.ts', () => {
|
||||
expect.objectContaining({
|
||||
toolName: 'write_file',
|
||||
argsPattern:
|
||||
'\\\\0' + escapeRegex('"file_path":"src/foo.ts"') + '\\\\0',
|
||||
'\\x00' + escapeRegex('"file_path":"src/foo.ts"') + '\\x00',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ import { type Config } from '../config/config.js';
|
||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { GLOB_TOOL_NAME, GLOB_DISPLAY_NAME } from './tool-names.js';
|
||||
import { buildPatternArgsPattern } from '../policy/utils.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { GLOB_DEFINITION } from './definitions/coreTools.js';
|
||||
@@ -124,9 +123,7 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
override getPolicyUpdateOptions(
|
||||
_outcome: ToolConfirmationOutcome,
|
||||
): PolicyUpdateOptions | undefined {
|
||||
return {
|
||||
argsPattern: buildPatternArgsPattern(this.params.pattern),
|
||||
};
|
||||
return {};
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
|
||||
@@ -31,7 +31,6 @@ import type { Config } from '../config/config.js';
|
||||
import type { FileExclusions } from '../utils/ignorePatterns.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { GREP_TOOL_NAME, GREP_DISPLAY_NAME } from './tool-names.js';
|
||||
import { buildPatternArgsPattern } from '../policy/utils.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { GREP_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
@@ -306,9 +305,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
override getPolicyUpdateOptions(
|
||||
_outcome: ToolConfirmationOutcome,
|
||||
): PolicyUpdateOptions | undefined {
|
||||
return {
|
||||
argsPattern: buildPatternArgsPattern(this.params.pattern),
|
||||
};
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,6 @@ import type { Config } from '../config/config.js';
|
||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { LS_TOOL_NAME, LS_DISPLAY_NAME } from './tool-names.js';
|
||||
import { buildDirPathArgsPattern } from '../policy/utils.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { LS_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
@@ -130,9 +129,7 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
|
||||
override getPolicyUpdateOptions(
|
||||
_outcome: ToolConfirmationOutcome,
|
||||
): PolicyUpdateOptions | undefined {
|
||||
return {
|
||||
argsPattern: buildDirPathArgsPattern(this.params.dir_path),
|
||||
};
|
||||
return {};
|
||||
}
|
||||
|
||||
// Helper for consistent error formatting
|
||||
|
||||
@@ -41,12 +41,15 @@ import { formatBytes } from '../utils/formatters.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import {
|
||||
getCommandRoots,
|
||||
getCommandSegments,
|
||||
initializeShellParsers,
|
||||
stripShellWrapper,
|
||||
parseCommandDetails,
|
||||
hasRedirection,
|
||||
isArgumentRestrictedCommand,
|
||||
normalizeCommand,
|
||||
} from '../utils/shell-utils.js';
|
||||
import { buildParamArgsPattern } from '../policy/utils.js';
|
||||
import { SHELL_TOOL_NAME } from './tool-names.js';
|
||||
import { PARAM_ADDITIONAL_PERMISSIONS } from './definitions/base-declarations.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
@@ -232,13 +235,22 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
outcome === ToolConfirmationOutcome.ProceedAlways
|
||||
) {
|
||||
const command = stripShellWrapper(this.params.command);
|
||||
const rootCommands = [...new Set(getCommandRoots(command))];
|
||||
const segments = getCommandSegments(command);
|
||||
const allowRedirection = hasRedirection(command) ? true : undefined;
|
||||
|
||||
if (rootCommands.length > 0) {
|
||||
return { commandPrefix: rootCommands, allowRedirection };
|
||||
// Filter out "naked" restricted commands to prevent over-broad matching
|
||||
const safeSegments = segments.filter(
|
||||
(seg) => !isArgumentRestrictedCommand(seg),
|
||||
);
|
||||
if (safeSegments.length > 0) {
|
||||
return { commandPrefix: safeSegments, allowRedirection };
|
||||
}
|
||||
return { commandPrefix: this.params.command, allowRedirection };
|
||||
// If the command is naked and sensitive, we fall back to the raw command as an exact argsPattern
|
||||
// instead of a broad prefix rule.
|
||||
return {
|
||||
argsPattern: buildParamArgsPattern('command', this.params.command),
|
||||
allowRedirection,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -171,11 +171,8 @@ export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
|
||||
* when granting persistent or session-wide approval.
|
||||
*/
|
||||
export const TOOLS_REQUIRING_NARROWING = new Set([
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
READ_MANY_FILES_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
|
||||
@@ -149,7 +149,7 @@ export function isBackgroundExecutionData(
|
||||
*/
|
||||
export interface PolicyUpdateOptions {
|
||||
argsPattern?: string;
|
||||
commandPrefix?: string | string[];
|
||||
commandPrefix?: string | string[] | string[][];
|
||||
mcpName?: string;
|
||||
toolName?: string;
|
||||
allowRedirection?: boolean;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { Config } from '../config/config.js';
|
||||
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { PolicyEngine } from '../policy/policy-engine.js';
|
||||
@@ -30,6 +30,7 @@ describe('Tracker Tools Integration', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tracker-tools-test-'));
|
||||
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
|
||||
config = new Config({
|
||||
sessionId: 'test-session',
|
||||
targetDir: tempDir,
|
||||
@@ -41,6 +42,7 @@ describe('Tracker Tools Integration', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import {
|
||||
getCommandSegments,
|
||||
initializeShellParsers,
|
||||
isArgumentRestrictedCommand,
|
||||
} from './shell-utils.js';
|
||||
|
||||
describe('getCommandSegments', () => {
|
||||
beforeAll(async () => {
|
||||
await initializeShellParsers();
|
||||
});
|
||||
|
||||
it('should include first positional argument for restricted commands', () => {
|
||||
expect(getCommandSegments('git status')).toEqual([['git', 'status']]);
|
||||
expect(getCommandSegments('npm install')).toEqual([['npm', 'install']]);
|
||||
expect(getCommandSegments('rm -rf /tmp/foo')).toEqual([['rm', '/tmp/foo']]);
|
||||
expect(getCommandSegments('node script.js')).toEqual([
|
||||
['node', 'script.js'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should only include the binary for unrestricted commands', () => {
|
||||
expect(getCommandSegments('grep pattern file.txt')).toEqual([['grep']]);
|
||||
expect(getCommandSegments('sed "s/a/b/g" file.txt')).toEqual([['sed']]);
|
||||
expect(getCommandSegments('ls -la /tmp')).toEqual([['ls']]);
|
||||
expect(getCommandSegments('cat file.txt')).toEqual([['cat']]);
|
||||
expect(getCommandSegments('head -n 10 file.txt')).toEqual([['head']]);
|
||||
expect(getCommandSegments('rg "search term" .')).toEqual([['rg']]);
|
||||
});
|
||||
|
||||
it('should handle chained commands with mixed sensitivity', () => {
|
||||
expect(
|
||||
getCommandSegments('grep pattern file.txt && git add file.txt'),
|
||||
).toEqual([['grep'], ['git', 'add']]);
|
||||
});
|
||||
|
||||
it('should handle pipes', () => {
|
||||
expect(getCommandSegments('ls | grep pattern')).toEqual([['ls'], ['grep']]);
|
||||
});
|
||||
|
||||
it('should handle subshells', () => {
|
||||
// Current behavior for subshells might vary depending on how they are parsed,
|
||||
// but we expect at least the outer commands to be captured.
|
||||
expect(getCommandSegments('(cd /tmp && ls)')).toEqual([['cd'], ['ls']]);
|
||||
});
|
||||
|
||||
it('should ignore flags but keep positional arguments for restricted commands', () => {
|
||||
expect(getCommandSegments('git --no-pager log --oneline')).toEqual([
|
||||
['git', 'log'],
|
||||
]);
|
||||
expect(getCommandSegments('npm run test -- --grep="foo"')).toEqual([
|
||||
['npm', 'run'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isArgumentRestrictedCommand', () => {
|
||||
it('should return true for naked restricted commands', () => {
|
||||
expect(isArgumentRestrictedCommand(['git'])).toBe(true);
|
||||
expect(isArgumentRestrictedCommand(['rm'])).toBe(true);
|
||||
expect(isArgumentRestrictedCommand(['node'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for restricted commands with arguments', () => {
|
||||
expect(isArgumentRestrictedCommand(['git', 'status'])).toBe(false);
|
||||
expect(isArgumentRestrictedCommand(['rm', '/tmp'])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for unrestricted commands (naked or not)', () => {
|
||||
expect(isArgumentRestrictedCommand(['grep'])).toBe(false);
|
||||
expect(isArgumentRestrictedCommand(['ls'])).toBe(false);
|
||||
expect(isArgumentRestrictedCommand(['cd'])).toBe(false);
|
||||
expect(isArgumentRestrictedCommand(['grep', 'foo'])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -796,6 +796,165 @@ export function getCommandRoots(command: string): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binaries that are generally safe and whose arguments are typically variable and non-sensitive.
|
||||
* Rules for these binaries should be saved "naked" (binary only) without positional arguments.
|
||||
*/
|
||||
export const ARGUMENT_UNRESTRICTED_BINARIES = new Set([
|
||||
'grep',
|
||||
'sed',
|
||||
'awk',
|
||||
'rg',
|
||||
'ls',
|
||||
'cat',
|
||||
'head',
|
||||
'tail',
|
||||
'wc',
|
||||
'sort',
|
||||
'uniq',
|
||||
'cut',
|
||||
'tr',
|
||||
'echo',
|
||||
'printf',
|
||||
'true',
|
||||
'false',
|
||||
'pwd',
|
||||
'whoami',
|
||||
'id',
|
||||
'hostname',
|
||||
'uname',
|
||||
'cd',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Binaries that are considered sensitive and should not be allowed to be saved as "naked" rules.
|
||||
* Rules for these binaries must include at least one positional argument (e.g., "git diff", "rm /tmp").
|
||||
*/
|
||||
export const ARGUMENT_RESTRICTED_BINARIES = new Set([
|
||||
'git',
|
||||
'node',
|
||||
'npm',
|
||||
'npx',
|
||||
'yarn',
|
||||
'pnpm',
|
||||
'python',
|
||||
'python3',
|
||||
'pip',
|
||||
'pip3',
|
||||
'bash',
|
||||
'sh',
|
||||
'zsh',
|
||||
'sudo',
|
||||
'docker',
|
||||
'kubectl',
|
||||
'aws',
|
||||
'gcloud',
|
||||
'az',
|
||||
'chmod',
|
||||
'chown',
|
||||
'rm',
|
||||
'mv',
|
||||
'cp',
|
||||
'find',
|
||||
'curl',
|
||||
'wget',
|
||||
'ssh',
|
||||
'scp',
|
||||
'rsync',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Checks if a command sequence represents a "naked" restricted command.
|
||||
* A command is naked if it only contains the binary name without any subcommands or scripts.
|
||||
*
|
||||
* @param sequence - The command sequence (e.g., ['git', 'log']).
|
||||
* @returns true if the command is a naked restricted command.
|
||||
*/
|
||||
export function isArgumentRestrictedCommand(sequence: string[]): boolean {
|
||||
if (sequence.length === 0) return false;
|
||||
const binary = sequence[0];
|
||||
// A command is "naked" if it has only one token (the binary itself)
|
||||
// and that binary is in the restricted list.
|
||||
return sequence.length === 1 && ARGUMENT_RESTRICTED_BINARIES.has(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts sequences of positional arguments for each command in a potentially chained shell command.
|
||||
* Flags (starting with -) are ignored, and subcommands/scripts are captured.
|
||||
*
|
||||
* @param command - The shell command string to parse.
|
||||
* @returns An array of sequences, where each sequence is string[].
|
||||
* @example "git --no-pager log" -> [["git", "log"]]
|
||||
* @example "git log && ls -la" -> [["git", "log"], ["ls"]]
|
||||
*/
|
||||
export function getCommandSegments(command: string): string[][] {
|
||||
if (!command) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const bashResult = parseBashCommandDetails(command);
|
||||
if (!bashResult || bashResult.hasError) {
|
||||
// Fallback to simple root extraction if tree-sitter fails or is not bash
|
||||
const roots = getCommandRoots(command);
|
||||
return roots.map((r) => [r]);
|
||||
}
|
||||
|
||||
const tree = parseCommandTree(command);
|
||||
if (!tree) {
|
||||
const roots = getCommandRoots(command);
|
||||
return roots.map((r) => [r]);
|
||||
}
|
||||
|
||||
const segments: string[][] = [];
|
||||
|
||||
function walk(node: Node) {
|
||||
if (node.type === 'command') {
|
||||
const sequence: string[] = [];
|
||||
const nameNode = node.childForFieldName('name');
|
||||
let binaryName = '';
|
||||
if (nameNode) {
|
||||
binaryName = normalizeCommandName(nameNode.text);
|
||||
sequence.push(binaryName);
|
||||
}
|
||||
|
||||
// Safe utilities like grep/sed should be allowed "nakedly" (binary only)
|
||||
// because their arguments are highly variable and non-sensitive.
|
||||
const limit = ARGUMENT_UNRESTRICTED_BINARIES.has(binaryName) ? 1 : 2;
|
||||
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
// Limit sequence length based on binary sensitivity
|
||||
if (sequence.length >= limit) break;
|
||||
|
||||
const child = node.child(i);
|
||||
if (!child || child.id === nameNode?.id) continue;
|
||||
|
||||
// We only care about words/strings that are arguments
|
||||
if (
|
||||
child.type === 'word' ||
|
||||
child.type === 'string' ||
|
||||
child.type === 'raw_string'
|
||||
) {
|
||||
const text = child.text;
|
||||
if (!text.startsWith('-')) {
|
||||
sequence.push(normalizeCommandName(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sequence.length > 0) {
|
||||
segments.push(sequence);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child) walk(child);
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree.rootNode);
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function stripShellWrapper(command: string): string {
|
||||
const pattern =
|
||||
/^\s*(?:(?:(?:\S+\/)?(?:sh|bash|zsh))\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i;
|
||||
|
||||
Reference in New Issue
Block a user