SuperCharge Claude Code v1.0.0 - Complete Customization Package
Features: - 30+ Custom Skills (cognitive, development, UI/UX, autonomous agents) - RalphLoop autonomous agent integration - Multi-AI consultation (Qwen) - Agent management system with sync capabilities - Custom hooks for session management - MCP servers integration - Plugin marketplace setup - Comprehensive installation script Components: - Skills: always-use-superpowers, ralph, brainstorming, ui-ux-pro-max, etc. - Agents: 100+ agents across engineering, marketing, product, etc. - Hooks: session-start-superpowers, qwen-consult, ralph-auto-trigger - Commands: /brainstorm, /write-plan, /execute-plan - MCP Servers: zai-mcp-server, web-search-prime, web-reader, zread - Binaries: ralphloop wrapper Installation: ./supercharge.sh
This commit is contained in:
32
plugins/claude-code-safety-net/src/core/analyze.ts
Normal file
32
plugins/claude-code-safety-net/src/core/analyze.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { AnalyzeOptions, AnalyzeResult } from '../types.ts';
|
||||
|
||||
import { analyzeCommandInternal } from './analyze/analyze-command.ts';
|
||||
import { findHasDelete } from './analyze/find.ts';
|
||||
import { extractParallelChildCommand } from './analyze/parallel.ts';
|
||||
import { hasRecursiveForceFlags } from './analyze/rm-flags.ts';
|
||||
import { segmentChangesCwd } from './analyze/segment.ts';
|
||||
import { extractXargsChildCommand, extractXargsChildCommandWithInfo } from './analyze/xargs.ts';
|
||||
import { loadConfig } from './config.ts';
|
||||
|
||||
export function analyzeCommand(
|
||||
command: string,
|
||||
options: AnalyzeOptions = {},
|
||||
): AnalyzeResult | null {
|
||||
const config = options.config ?? loadConfig(options.cwd);
|
||||
return analyzeCommandInternal(command, 0, { ...options, config });
|
||||
}
|
||||
|
||||
export { loadConfig };
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export { findHasDelete as _findHasDelete };
|
||||
/** @internal Exported for testing */
|
||||
export { extractParallelChildCommand as _extractParallelChildCommand };
|
||||
/** @internal Exported for testing */
|
||||
export { hasRecursiveForceFlags as _hasRecursiveForceFlags };
|
||||
/** @internal Exported for testing */
|
||||
export { segmentChangesCwd as _segmentChangesCwd };
|
||||
/** @internal Exported for testing */
|
||||
export { extractXargsChildCommand as _extractXargsChildCommand };
|
||||
/** @internal Exported for testing */
|
||||
export { extractXargsChildCommandWithInfo as _extractXargsChildCommandWithInfo };
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
type AnalyzeOptions,
|
||||
type AnalyzeResult,
|
||||
type Config,
|
||||
MAX_RECURSION_DEPTH,
|
||||
} from '../../types.ts';
|
||||
|
||||
import { splitShellCommands } from '../shell.ts';
|
||||
|
||||
import { dangerousInText } from './dangerous-text.ts';
|
||||
import { analyzeSegment, segmentChangesCwd } from './segment.ts';
|
||||
|
||||
const REASON_STRICT_UNPARSEABLE =
|
||||
'Command could not be safely analyzed (strict mode). Verify manually.';
|
||||
|
||||
const REASON_RECURSION_LIMIT =
|
||||
'Command exceeds maximum recursion depth and cannot be safely analyzed.';
|
||||
|
||||
export type InternalOptions = AnalyzeOptions & { config: Config };
|
||||
|
||||
export function analyzeCommandInternal(
|
||||
command: string,
|
||||
depth: number,
|
||||
options: InternalOptions,
|
||||
): AnalyzeResult | null {
|
||||
if (depth >= MAX_RECURSION_DEPTH) {
|
||||
return { reason: REASON_RECURSION_LIMIT, segment: command };
|
||||
}
|
||||
|
||||
const segments = splitShellCommands(command);
|
||||
|
||||
// Strict mode: block if command couldn't be parsed (unclosed quotes, etc.)
|
||||
// Detected when splitShellCommands returns a single segment containing the raw command
|
||||
if (
|
||||
options.strict &&
|
||||
segments.length === 1 &&
|
||||
segments[0]?.length === 1 &&
|
||||
segments[0][0] === command &&
|
||||
command.includes(' ')
|
||||
) {
|
||||
return { reason: REASON_STRICT_UNPARSEABLE, segment: command };
|
||||
}
|
||||
|
||||
const originalCwd = options.cwd;
|
||||
let effectiveCwd: string | null | undefined = options.cwd;
|
||||
|
||||
for (const segment of segments) {
|
||||
const segmentStr = segment.join(' ');
|
||||
|
||||
if (segment.length === 1 && segment[0]?.includes(' ')) {
|
||||
const textReason = dangerousInText(segment[0]);
|
||||
if (textReason) {
|
||||
return { reason: textReason, segment: segmentStr };
|
||||
}
|
||||
if (segmentChangesCwd(segment)) {
|
||||
effectiveCwd = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const reason = analyzeSegment(segment, depth, {
|
||||
...options,
|
||||
cwd: originalCwd,
|
||||
effectiveCwd,
|
||||
analyzeNested: (nestedCommand: string): string | null => {
|
||||
return analyzeCommandInternal(nestedCommand, depth + 1, options)?.reason ?? null;
|
||||
},
|
||||
});
|
||||
if (reason) {
|
||||
return { reason, segment: segmentStr };
|
||||
}
|
||||
|
||||
if (segmentChangesCwd(segment)) {
|
||||
effectiveCwd = null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
101
plugins/claude-code-safety-net/src/core/analyze/constants.ts
Normal file
101
plugins/claude-code-safety-net/src/core/analyze/constants.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export const DISPLAY_COMMANDS: ReadonlySet<string> = new Set([
|
||||
'echo',
|
||||
'printf',
|
||||
'cat',
|
||||
'head',
|
||||
'tail',
|
||||
'less',
|
||||
'more',
|
||||
'grep',
|
||||
'rg',
|
||||
'ag',
|
||||
'ack',
|
||||
'sed',
|
||||
'awk',
|
||||
'cut',
|
||||
'tr',
|
||||
'sort',
|
||||
'uniq',
|
||||
'wc',
|
||||
'tee',
|
||||
'man',
|
||||
'help',
|
||||
'info',
|
||||
'type',
|
||||
'which',
|
||||
'whereis',
|
||||
'whatis',
|
||||
'apropos',
|
||||
'file',
|
||||
'stat',
|
||||
'ls',
|
||||
'll',
|
||||
'dir',
|
||||
'tree',
|
||||
'pwd',
|
||||
'date',
|
||||
'cal',
|
||||
'uptime',
|
||||
'whoami',
|
||||
'id',
|
||||
'groups',
|
||||
'hostname',
|
||||
'uname',
|
||||
'env',
|
||||
'printenv',
|
||||
'set',
|
||||
'export',
|
||||
'alias',
|
||||
'history',
|
||||
'jobs',
|
||||
'fg',
|
||||
'bg',
|
||||
'test',
|
||||
'true',
|
||||
'false',
|
||||
'read',
|
||||
'return',
|
||||
'exit',
|
||||
'break',
|
||||
'continue',
|
||||
'shift',
|
||||
'wait',
|
||||
'trap',
|
||||
'basename',
|
||||
'dirname',
|
||||
'realpath',
|
||||
'readlink',
|
||||
'md5sum',
|
||||
'sha256sum',
|
||||
'base64',
|
||||
'xxd',
|
||||
'od',
|
||||
'hexdump',
|
||||
'strings',
|
||||
'diff',
|
||||
'cmp',
|
||||
'comm',
|
||||
'join',
|
||||
'paste',
|
||||
'column',
|
||||
'fmt',
|
||||
'fold',
|
||||
'nl',
|
||||
'pr',
|
||||
'expand',
|
||||
'unexpand',
|
||||
'rev',
|
||||
'tac',
|
||||
'shuf',
|
||||
'seq',
|
||||
'yes',
|
||||
'timeout',
|
||||
'time',
|
||||
'sleep',
|
||||
'watch',
|
||||
'logger',
|
||||
'write',
|
||||
'wall',
|
||||
'mesg',
|
||||
'notify-send',
|
||||
]);
|
||||
@@ -0,0 +1,64 @@
|
||||
export function dangerousInText(text: string): string | null {
|
||||
const t = text.toLowerCase();
|
||||
const stripped = t.trimStart();
|
||||
const isEchoOrRg = stripped.startsWith('echo ') || stripped.startsWith('rg ');
|
||||
|
||||
const patterns: Array<{
|
||||
regex: RegExp;
|
||||
reason: string;
|
||||
skipForEchoRg?: boolean;
|
||||
caseSensitive?: boolean;
|
||||
}> = [
|
||||
{
|
||||
regex: /\brm\s+(-[^\s]*r[^\s]*\s+-[^\s]*f|-[^\s]*f[^\s]*\s+-[^\s]*r|-[^\s]*rf|-[^\s]*fr)\b/,
|
||||
reason: 'rm -rf',
|
||||
},
|
||||
{
|
||||
regex: /\bgit\s+reset\s+--hard\b/,
|
||||
reason: 'git reset --hard',
|
||||
},
|
||||
{
|
||||
regex: /\bgit\s+reset\s+--merge\b/,
|
||||
reason: 'git reset --merge',
|
||||
},
|
||||
{
|
||||
regex: /\bgit\s+clean\s+(-[^\s]*f|-f)\b/,
|
||||
reason: 'git clean -f',
|
||||
},
|
||||
{
|
||||
regex: /\bgit\s+push\s+[^|;]*(-f\b|--force\b)(?!-with-lease)/,
|
||||
reason: 'git push --force (use --force-with-lease instead)',
|
||||
},
|
||||
{
|
||||
regex: /\bgit\s+branch\s+-D\b/,
|
||||
reason: 'git branch -D',
|
||||
caseSensitive: true,
|
||||
},
|
||||
{
|
||||
regex: /\bgit\s+stash\s+(drop|clear)\b/,
|
||||
reason: 'git stash drop/clear',
|
||||
},
|
||||
{
|
||||
regex: /\bgit\s+checkout\s+--\s/,
|
||||
reason: 'git checkout --',
|
||||
},
|
||||
{
|
||||
regex: /\bgit\s+restore\b(?!.*--(staged|help))/,
|
||||
reason: 'git restore (without --staged)',
|
||||
},
|
||||
{
|
||||
regex: /\bfind\b[^\n;|&]*\s-delete\b/,
|
||||
reason: 'find -delete',
|
||||
skipForEchoRg: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { regex, reason, skipForEchoRg, caseSensitive } of patterns) {
|
||||
if (skipForEchoRg && isEchoOrRg) continue;
|
||||
const target = caseSensitive ? text : t;
|
||||
if (regex.test(target)) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
125
plugins/claude-code-safety-net/src/core/analyze/find.ts
Normal file
125
plugins/claude-code-safety-net/src/core/analyze/find.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { getBasename, stripWrappers } from '../shell.ts';
|
||||
|
||||
import { hasRecursiveForceFlags } from './rm-flags.ts';
|
||||
|
||||
const REASON_FIND_DELETE = 'find -delete permanently removes files. Use -print first to preview.';
|
||||
|
||||
export function analyzeFind(tokens: readonly string[]): string | null {
|
||||
// Check for -delete outside of -exec/-execdir blocks
|
||||
if (findHasDelete(tokens.slice(1))) {
|
||||
return REASON_FIND_DELETE;
|
||||
}
|
||||
|
||||
// Check all -exec and -execdir blocks for dangerous commands
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (token === '-exec' || token === '-execdir') {
|
||||
const execTokens = tokens.slice(i + 1);
|
||||
const semicolonIdx = execTokens.indexOf(';');
|
||||
const plusIdx = execTokens.indexOf('+');
|
||||
// If no terminator found, shell-quote may have parsed it as an operator
|
||||
// In that case, treat the rest of the tokens as the exec command
|
||||
const endIdx =
|
||||
semicolonIdx !== -1 && plusIdx !== -1
|
||||
? Math.min(semicolonIdx, plusIdx)
|
||||
: semicolonIdx !== -1
|
||||
? semicolonIdx
|
||||
: plusIdx !== -1
|
||||
? plusIdx
|
||||
: execTokens.length; // No terminator - use all remaining tokens
|
||||
|
||||
let execCommand = execTokens.slice(0, endIdx);
|
||||
// Strip wrappers (env, sudo, command)
|
||||
execCommand = stripWrappers(execCommand);
|
||||
if (execCommand.length > 0) {
|
||||
let head = getBasename(execCommand[0] ?? '');
|
||||
// Handle busybox wrapper
|
||||
if (head === 'busybox' && execCommand.length > 1) {
|
||||
execCommand = execCommand.slice(1);
|
||||
head = getBasename(execCommand[0] ?? '');
|
||||
}
|
||||
if (head === 'rm' && hasRecursiveForceFlags(execCommand)) {
|
||||
return 'find -exec rm -rf is dangerous. Use explicit file list instead.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if find command has -delete action (not as argument to another option).
|
||||
* Handles cases like "find -name -delete" where -delete is a filename pattern.
|
||||
*/
|
||||
export function findHasDelete(tokens: readonly string[]): boolean {
|
||||
let i = 0;
|
||||
let insideExec = false;
|
||||
let execDepth = 0;
|
||||
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (!token) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track -exec/-execdir blocks
|
||||
if (token === '-exec' || token === '-execdir') {
|
||||
insideExec = true;
|
||||
execDepth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// End of -exec block
|
||||
if (insideExec && (token === ';' || token === '+')) {
|
||||
execDepth--;
|
||||
if (execDepth === 0) {
|
||||
insideExec = false;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip -delete inside -exec blocks
|
||||
if (insideExec) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Options that take an argument - skip the next token
|
||||
if (
|
||||
token === '-name' ||
|
||||
token === '-iname' ||
|
||||
token === '-path' ||
|
||||
token === '-ipath' ||
|
||||
token === '-regex' ||
|
||||
token === '-iregex' ||
|
||||
token === '-type' ||
|
||||
token === '-user' ||
|
||||
token === '-group' ||
|
||||
token === '-perm' ||
|
||||
token === '-size' ||
|
||||
token === '-mtime' ||
|
||||
token === '-ctime' ||
|
||||
token === '-atime' ||
|
||||
token === '-newer' ||
|
||||
token === '-printf' ||
|
||||
token === '-fprint' ||
|
||||
token === '-fprintf'
|
||||
) {
|
||||
i += 2; // Skip option and its argument
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found -delete outside of -exec and not as an argument
|
||||
if (token === '-delete') {
|
||||
return true;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DANGEROUS_PATTERNS } from '../../types.ts';
|
||||
|
||||
export function extractInterpreterCodeArg(tokens: readonly string[]): string | null {
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
if ((token === '-c' || token === '-e') && tokens[i + 1]) {
|
||||
return tokens[i + 1] ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function containsDangerousCode(code: string): boolean {
|
||||
for (const pattern of DANGEROUS_PATTERNS) {
|
||||
if (pattern.test(code)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
337
plugins/claude-code-safety-net/src/core/analyze/parallel.ts
Normal file
337
plugins/claude-code-safety-net/src/core/analyze/parallel.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { SHELL_WRAPPERS } from '../../types.ts';
|
||||
import { analyzeGit } from '../rules-git.ts';
|
||||
import { analyzeRm } from '../rules-rm.ts';
|
||||
import { getBasename, stripWrappers } from '../shell.ts';
|
||||
|
||||
import { analyzeFind } from './find.ts';
|
||||
import { hasRecursiveForceFlags } from './rm-flags.ts';
|
||||
import { extractDashCArg } from './shell-wrappers.ts';
|
||||
|
||||
const REASON_PARALLEL_RM =
|
||||
'parallel rm -rf with dynamic input is dangerous. Use explicit file list instead.';
|
||||
const REASON_PARALLEL_SHELL =
|
||||
'parallel with shell -c can execute arbitrary commands from dynamic input.';
|
||||
|
||||
export interface ParallelAnalyzeContext {
|
||||
cwd: string | undefined;
|
||||
originalCwd: string | undefined;
|
||||
paranoidRm: boolean | undefined;
|
||||
allowTmpdirVar: boolean;
|
||||
analyzeNested: (command: string) => string | null;
|
||||
}
|
||||
|
||||
export function analyzeParallel(
|
||||
tokens: readonly string[],
|
||||
context: ParallelAnalyzeContext,
|
||||
): string | null {
|
||||
const parseResult = parseParallelCommand(tokens);
|
||||
|
||||
if (!parseResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { template, args, hasPlaceholder } = parseResult;
|
||||
|
||||
if (template.length === 0) {
|
||||
// parallel ::: 'cmd1' 'cmd2' - commands mode
|
||||
// Analyze each arg as a command
|
||||
for (const arg of args) {
|
||||
const reason = context.analyzeNested(arg);
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let childTokens = stripWrappers([...template]);
|
||||
let head = getBasename(childTokens[0] ?? '').toLowerCase();
|
||||
|
||||
if (head === 'busybox' && childTokens.length > 1) {
|
||||
childTokens = childTokens.slice(1);
|
||||
head = getBasename(childTokens[0] ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
// Check for shell wrapper with -c
|
||||
if (SHELL_WRAPPERS.has(head)) {
|
||||
const dashCArg = extractDashCArg(childTokens);
|
||||
if (dashCArg) {
|
||||
// If script IS just the placeholder, stdin provides entire script - dangerous
|
||||
if (dashCArg === '{}' || dashCArg === '{1}') {
|
||||
return REASON_PARALLEL_SHELL;
|
||||
}
|
||||
// If script contains placeholder
|
||||
if (dashCArg.includes('{}')) {
|
||||
if (args.length > 0) {
|
||||
// Expand with actual args and analyze
|
||||
for (const arg of args) {
|
||||
const expandedScript = dashCArg.replace(/{}/g, arg);
|
||||
const reason = context.analyzeNested(expandedScript);
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Stdin mode with placeholder - analyze the script template
|
||||
// Check if the script pattern is dangerous (e.g., rm -rf {})
|
||||
const reason = context.analyzeNested(dashCArg);
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Script doesn't have placeholder - analyze it directly
|
||||
const reason = context.analyzeNested(dashCArg);
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
// If there's a placeholder in the shell wrapper args (not script),
|
||||
// it's still dangerous
|
||||
if (hasPlaceholder) {
|
||||
return REASON_PARALLEL_SHELL;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// bash -c without script argument
|
||||
// If there are args from :::, those become the scripts - dangerous pattern
|
||||
if (args.length > 0) {
|
||||
// The pattern of passing scripts via ::: to bash -c is inherently dangerous
|
||||
return REASON_PARALLEL_SHELL;
|
||||
}
|
||||
// Stdin provides the script - dangerous
|
||||
if (hasPlaceholder) {
|
||||
return REASON_PARALLEL_SHELL;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// For rm -rf, expand with actual args and analyze each expansion
|
||||
if (head === 'rm' && hasRecursiveForceFlags(childTokens)) {
|
||||
if (hasPlaceholder && args.length > 0) {
|
||||
// Expand template with each arg and analyze
|
||||
for (const arg of args) {
|
||||
const expandedTokens = childTokens.map((t) => t.replace(/{}/g, arg));
|
||||
const rmResult = analyzeRm(expandedTokens, {
|
||||
cwd: context.cwd,
|
||||
originalCwd: context.originalCwd,
|
||||
paranoid: context.paranoidRm,
|
||||
allowTmpdirVar: context.allowTmpdirVar,
|
||||
});
|
||||
if (rmResult) {
|
||||
return rmResult;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// No placeholder or no args - analyze template as-is
|
||||
// If there are args (from :::), they get appended, analyze with first arg
|
||||
if (args.length > 0) {
|
||||
const expandedTokens = [...childTokens, args[0] ?? ''];
|
||||
const rmResult = analyzeRm(expandedTokens, {
|
||||
cwd: context.cwd,
|
||||
originalCwd: context.originalCwd,
|
||||
paranoid: context.paranoidRm,
|
||||
allowTmpdirVar: context.allowTmpdirVar,
|
||||
});
|
||||
if (rmResult) {
|
||||
return rmResult;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return REASON_PARALLEL_RM;
|
||||
}
|
||||
|
||||
if (head === 'find') {
|
||||
const findResult = analyzeFind(childTokens);
|
||||
if (findResult) {
|
||||
return findResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (head === 'git') {
|
||||
const gitResult = analyzeGit(childTokens);
|
||||
if (gitResult) {
|
||||
return gitResult;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ParallelParseResult {
|
||||
template: string[];
|
||||
args: string[];
|
||||
hasPlaceholder: boolean;
|
||||
}
|
||||
|
||||
function parseParallelCommand(tokens: readonly string[]): ParallelParseResult | null {
|
||||
// Options that take a value as the next token
|
||||
const parallelOptsWithValue = new Set([
|
||||
'-S',
|
||||
'--sshlogin',
|
||||
'--slf',
|
||||
'--sshloginfile',
|
||||
'-a',
|
||||
'--arg-file',
|
||||
'--colsep',
|
||||
'-I',
|
||||
'--replace',
|
||||
'--results',
|
||||
'--result',
|
||||
'--res',
|
||||
]);
|
||||
|
||||
let i = 1;
|
||||
const templateTokens: string[] = [];
|
||||
let markerIndex = -1;
|
||||
|
||||
// First pass: find the ::: marker and extract template
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (!token) break;
|
||||
|
||||
if (token === ':::') {
|
||||
markerIndex = i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (token === '--') {
|
||||
// Everything after -- until ::: is the template
|
||||
i++;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (token === undefined || token === ':::') break;
|
||||
templateTokens.push(token);
|
||||
i++;
|
||||
}
|
||||
if (i < tokens.length && tokens[i] === ':::') {
|
||||
markerIndex = i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (token.startsWith('-')) {
|
||||
// Handle -jN attached option
|
||||
if (token.startsWith('-j') && token.length > 2 && /^\d+$/.test(token.slice(2))) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle --option=value
|
||||
if (token.startsWith('--') && token.includes('=')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle options that take a value
|
||||
if (parallelOptsWithValue.has(token)) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle -j as separate option
|
||||
if (token === '-j' || token === '--jobs') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown option - skip it
|
||||
i++;
|
||||
} else {
|
||||
// Start of template
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (token === undefined || token === ':::') break;
|
||||
templateTokens.push(token);
|
||||
i++;
|
||||
}
|
||||
if (i < tokens.length && tokens[i] === ':::') {
|
||||
markerIndex = i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract args after :::
|
||||
const args: string[] = [];
|
||||
if (markerIndex !== -1) {
|
||||
for (let j = markerIndex + 1; j < tokens.length; j++) {
|
||||
const token = tokens[j];
|
||||
if (token && token !== ':::') {
|
||||
args.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if template has placeholder
|
||||
const hasPlaceholder = templateTokens.some(
|
||||
(t) => t.includes('{}') || t.includes('{1}') || t.includes('{.}'),
|
||||
);
|
||||
|
||||
// If no template and no marker, no valid parallel command
|
||||
if (templateTokens.length === 0 && markerIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { template: templateTokens, args, hasPlaceholder };
|
||||
}
|
||||
|
||||
export function extractParallelChildCommand(tokens: readonly string[]): string[] {
|
||||
// Legacy behavior: return everything after options until end
|
||||
// This includes ::: marker and args if present
|
||||
const parallelOptsWithValue = new Set([
|
||||
'-S',
|
||||
'--sshlogin',
|
||||
'--slf',
|
||||
'--sshloginfile',
|
||||
'-a',
|
||||
'--arg-file',
|
||||
'--colsep',
|
||||
'-I',
|
||||
'--replace',
|
||||
'--results',
|
||||
'--result',
|
||||
'--res',
|
||||
]);
|
||||
|
||||
let i = 1;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (!token) break;
|
||||
|
||||
if (token === ':::') {
|
||||
// ::: as first non-option means no template
|
||||
return [];
|
||||
}
|
||||
|
||||
if (token === '--') {
|
||||
return [...tokens.slice(i + 1)];
|
||||
}
|
||||
|
||||
if (token.startsWith('-')) {
|
||||
if (token.startsWith('-j') && token.length > 2 && /^\d+$/.test(token.slice(2))) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('--') && token.includes('=')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (parallelOptsWithValue.has(token)) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (token === '-j' || token === '--jobs') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
// Return everything from here to end (including ::: and args)
|
||||
return [...tokens.slice(i)];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
19
plugins/claude-code-safety-net/src/core/analyze/rm-flags.ts
Normal file
19
plugins/claude-code-safety-net/src/core/analyze/rm-flags.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function hasRecursiveForceFlags(tokens: readonly string[]): boolean {
|
||||
let hasRecursive = false;
|
||||
let hasForce = false;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token === '--') break;
|
||||
|
||||
if (token === '-r' || token === '-R' || token === '--recursive') {
|
||||
hasRecursive = true;
|
||||
} else if (token === '-f' || token === '--force') {
|
||||
hasForce = true;
|
||||
} else if (token.startsWith('-') && !token.startsWith('--')) {
|
||||
if (token.includes('r') || token.includes('R')) hasRecursive = true;
|
||||
if (token.includes('f')) hasForce = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasRecursive && hasForce;
|
||||
}
|
||||
264
plugins/claude-code-safety-net/src/core/analyze/segment.ts
Normal file
264
plugins/claude-code-safety-net/src/core/analyze/segment.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
type AnalyzeOptions,
|
||||
type Config,
|
||||
INTERPRETERS,
|
||||
PARANOID_INTERPRETERS_SUFFIX,
|
||||
SHELL_WRAPPERS,
|
||||
} from '../../types.ts';
|
||||
|
||||
import { checkCustomRules } from '../rules-custom.ts';
|
||||
import { analyzeGit } from '../rules-git.ts';
|
||||
import { analyzeRm, isHomeDirectory } from '../rules-rm.ts';
|
||||
import {
|
||||
getBasename,
|
||||
normalizeCommandToken,
|
||||
stripEnvAssignmentsWithInfo,
|
||||
stripWrappers,
|
||||
stripWrappersWithInfo,
|
||||
} from '../shell.ts';
|
||||
|
||||
import { DISPLAY_COMMANDS } from './constants.ts';
|
||||
import { analyzeFind } from './find.ts';
|
||||
import { containsDangerousCode, extractInterpreterCodeArg } from './interpreters.ts';
|
||||
import { analyzeParallel } from './parallel.ts';
|
||||
import { hasRecursiveForceFlags } from './rm-flags.ts';
|
||||
import { extractDashCArg } from './shell-wrappers.ts';
|
||||
import { isTmpdirOverriddenToNonTemp } from './tmpdir.ts';
|
||||
import { analyzeXargs } from './xargs.ts';
|
||||
|
||||
const REASON_INTERPRETER_DANGEROUS = 'Detected potentially dangerous command in interpreter code.';
|
||||
const REASON_INTERPRETER_BLOCKED = 'Interpreter one-liners are blocked in paranoid mode.';
|
||||
const REASON_RM_HOME_CWD =
|
||||
'rm -rf in home directory is dangerous. Change to a project directory first.';
|
||||
|
||||
export type InternalOptions = AnalyzeOptions & {
|
||||
config: Config;
|
||||
effectiveCwd: string | null | undefined;
|
||||
analyzeNested: (command: string) => string | null;
|
||||
};
|
||||
|
||||
function deriveCwdContext(options: Pick<InternalOptions, 'cwd' | 'effectiveCwd'>): {
|
||||
cwdUnknown: boolean;
|
||||
cwdForRm: string | undefined;
|
||||
originalCwd: string | undefined;
|
||||
} {
|
||||
const cwdUnknown = options.effectiveCwd === null;
|
||||
const cwdForRm = cwdUnknown ? undefined : (options.effectiveCwd ?? options.cwd);
|
||||
const originalCwd = cwdUnknown ? undefined : options.cwd;
|
||||
return { cwdUnknown, cwdForRm, originalCwd };
|
||||
}
|
||||
|
||||
export function analyzeSegment(
|
||||
tokens: string[],
|
||||
depth: number,
|
||||
options: InternalOptions,
|
||||
): string | null {
|
||||
if (tokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { tokens: strippedEnv, envAssignments: leadingEnvAssignments } =
|
||||
stripEnvAssignmentsWithInfo(tokens);
|
||||
const { tokens: stripped, envAssignments: wrapperEnvAssignments } =
|
||||
stripWrappersWithInfo(strippedEnv);
|
||||
|
||||
const envAssignments = new Map(leadingEnvAssignments);
|
||||
for (const [k, v] of wrapperEnvAssignments) {
|
||||
envAssignments.set(k, v);
|
||||
}
|
||||
|
||||
if (stripped.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const head = stripped[0];
|
||||
if (!head) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedHead = normalizeCommandToken(head);
|
||||
const basename = getBasename(head);
|
||||
const { cwdForRm, originalCwd } = deriveCwdContext(options);
|
||||
const allowTmpdirVar = !isTmpdirOverriddenToNonTemp(envAssignments);
|
||||
|
||||
if (SHELL_WRAPPERS.has(normalizedHead)) {
|
||||
const dashCArg = extractDashCArg(stripped);
|
||||
if (dashCArg) {
|
||||
return options.analyzeNested(dashCArg);
|
||||
}
|
||||
}
|
||||
|
||||
if (INTERPRETERS.has(normalizedHead)) {
|
||||
const codeArg = extractInterpreterCodeArg(stripped);
|
||||
if (codeArg) {
|
||||
if (options.paranoidInterpreters) {
|
||||
return REASON_INTERPRETER_BLOCKED + PARANOID_INTERPRETERS_SUFFIX;
|
||||
}
|
||||
|
||||
const innerReason = options.analyzeNested(codeArg);
|
||||
if (innerReason) {
|
||||
return innerReason;
|
||||
}
|
||||
|
||||
if (containsDangerousCode(codeArg)) {
|
||||
return REASON_INTERPRETER_DANGEROUS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedHead === 'busybox' && stripped.length > 1) {
|
||||
return analyzeSegment(stripped.slice(1), depth, options);
|
||||
}
|
||||
|
||||
const isGit = basename.toLowerCase() === 'git';
|
||||
const isRm = basename === 'rm';
|
||||
const isFind = basename === 'find';
|
||||
const isXargs = basename === 'xargs';
|
||||
const isParallel = basename === 'parallel';
|
||||
|
||||
if (isGit) {
|
||||
const gitResult = analyzeGit(stripped);
|
||||
if (gitResult) {
|
||||
return gitResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRm) {
|
||||
if (cwdForRm && isHomeDirectory(cwdForRm)) {
|
||||
if (hasRecursiveForceFlags(stripped)) {
|
||||
return REASON_RM_HOME_CWD;
|
||||
}
|
||||
}
|
||||
const rmResult = analyzeRm(stripped, {
|
||||
cwd: cwdForRm,
|
||||
originalCwd,
|
||||
paranoid: options.paranoidRm,
|
||||
allowTmpdirVar,
|
||||
});
|
||||
if (rmResult) {
|
||||
return rmResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFind) {
|
||||
const findResult = analyzeFind(stripped);
|
||||
if (findResult) {
|
||||
return findResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (isXargs) {
|
||||
const xargsResult = analyzeXargs(stripped, {
|
||||
cwd: cwdForRm,
|
||||
originalCwd,
|
||||
paranoidRm: options.paranoidRm,
|
||||
allowTmpdirVar,
|
||||
});
|
||||
if (xargsResult) {
|
||||
return xargsResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (isParallel) {
|
||||
const parallelResult = analyzeParallel(stripped, {
|
||||
cwd: cwdForRm,
|
||||
originalCwd,
|
||||
paranoidRm: options.paranoidRm,
|
||||
allowTmpdirVar,
|
||||
analyzeNested: options.analyzeNested,
|
||||
});
|
||||
if (parallelResult) {
|
||||
return parallelResult;
|
||||
}
|
||||
}
|
||||
|
||||
const matchedKnown = isGit || isRm || isFind || isXargs || isParallel;
|
||||
|
||||
if (!matchedKnown) {
|
||||
// Fallback: scan tokens for embedded git/rm/find commands
|
||||
// This catches cases like "command -px git reset --hard" where the head
|
||||
// token is not a known command but contains dangerous commands later
|
||||
// Skip for display-only commands that don't execute their arguments
|
||||
if (!DISPLAY_COMMANDS.has(normalizedHead)) {
|
||||
for (let i = 1; i < stripped.length; i++) {
|
||||
const token = stripped[i];
|
||||
if (!token) continue;
|
||||
|
||||
const cmd = normalizeCommandToken(token);
|
||||
if (cmd === 'rm') {
|
||||
const rmTokens = ['rm', ...stripped.slice(i + 1)];
|
||||
const reason = analyzeRm(rmTokens, {
|
||||
cwd: cwdForRm,
|
||||
originalCwd,
|
||||
paranoid: options.paranoidRm,
|
||||
allowTmpdirVar,
|
||||
});
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
if (cmd === 'git') {
|
||||
const gitTokens = ['git', ...stripped.slice(i + 1)];
|
||||
const reason = analyzeGit(gitTokens);
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
if (cmd === 'find') {
|
||||
const findTokens = ['find', ...stripped.slice(i + 1)];
|
||||
const reason = analyzeFind(findTokens);
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const customRulesTopLevelOnly = isGit || isRm || isFind || isXargs || isParallel;
|
||||
if (depth === 0 || !customRulesTopLevelOnly) {
|
||||
const customResult = checkCustomRules(stripped, options.config.rules);
|
||||
if (customResult) {
|
||||
return customResult;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const CWD_CHANGE_REGEX =
|
||||
/^\s*(?:\$\(\s*)?[({]*\s*(?:command\s+|builtin\s+)?(?:cd|pushd|popd)(?:\s|$)/;
|
||||
|
||||
export function segmentChangesCwd(segment: readonly string[]): boolean {
|
||||
const stripped = stripLeadingGrouping(segment);
|
||||
const unwrapped = stripWrappers([...stripped]);
|
||||
|
||||
if (unwrapped.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let head = unwrapped[0] ?? '';
|
||||
if (head === 'builtin' && unwrapped.length > 1) {
|
||||
head = unwrapped[1] ?? '';
|
||||
}
|
||||
|
||||
if (head === 'cd' || head === 'pushd' || head === 'popd') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const joined = segment.join(' ');
|
||||
return CWD_CHANGE_REGEX.test(joined);
|
||||
}
|
||||
|
||||
function stripLeadingGrouping(tokens: readonly string[]): readonly string[] {
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (token === '{' || token === '(' || token === '$(') {
|
||||
i++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return tokens.slice(i);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function extractDashCArg(tokens: readonly string[]): string | null {
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
if (token === '-c' && tokens[i + 1]) {
|
||||
return tokens[i + 1] ?? null;
|
||||
}
|
||||
|
||||
if (token.startsWith('-') && token.includes('c') && !token.startsWith('--')) {
|
||||
const nextToken = tokens[i + 1];
|
||||
if (nextToken && !nextToken.startsWith('-')) {
|
||||
return nextToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
38
plugins/claude-code-safety-net/src/core/analyze/tmpdir.ts
Normal file
38
plugins/claude-code-safety-net/src/core/analyze/tmpdir.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
export function isTmpdirOverriddenToNonTemp(envAssignments: Map<string, string>): boolean {
|
||||
if (!envAssignments.has('TMPDIR')) {
|
||||
return false;
|
||||
}
|
||||
const tmpdirValue = envAssignments.get('TMPDIR') ?? '';
|
||||
|
||||
// Empty TMPDIR is dangerous: $TMPDIR/foo expands to /foo
|
||||
if (tmpdirValue === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a known temp path (exact match or subpath)
|
||||
const sysTmpdir = tmpdir();
|
||||
if (
|
||||
isPathOrSubpath(tmpdirValue, '/tmp') ||
|
||||
isPathOrSubpath(tmpdirValue, '/var/tmp') ||
|
||||
isPathOrSubpath(tmpdirValue, sysTmpdir)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path equals or is a subpath of basePath.
|
||||
* E.g., isPathOrSubpath("/tmp/foo", "/tmp") → true
|
||||
* isPathOrSubpath("/tmp-malicious", "/tmp") → false
|
||||
*/
|
||||
function isPathOrSubpath(path: string, basePath: string): boolean {
|
||||
if (path === basePath) {
|
||||
return true;
|
||||
}
|
||||
// Ensure basePath ends with / for proper prefix matching
|
||||
const baseWithSlash = basePath.endsWith('/') ? basePath : `${basePath}/`;
|
||||
return path.startsWith(baseWithSlash);
|
||||
}
|
||||
180
plugins/claude-code-safety-net/src/core/analyze/xargs.ts
Normal file
180
plugins/claude-code-safety-net/src/core/analyze/xargs.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { SHELL_WRAPPERS } from '../../types.ts';
|
||||
import { analyzeGit } from '../rules-git.ts';
|
||||
import { analyzeRm } from '../rules-rm.ts';
|
||||
import { getBasename, stripWrappers } from '../shell.ts';
|
||||
|
||||
import { analyzeFind } from './find.ts';
|
||||
import { hasRecursiveForceFlags } from './rm-flags.ts';
|
||||
|
||||
const REASON_XARGS_RM =
|
||||
'xargs rm -rf with dynamic input is dangerous. Use explicit file list instead.';
|
||||
const REASON_XARGS_SHELL = 'xargs with shell -c can execute arbitrary commands from dynamic input.';
|
||||
|
||||
export interface XargsAnalyzeContext {
|
||||
cwd: string | undefined;
|
||||
originalCwd: string | undefined;
|
||||
paranoidRm: boolean | undefined;
|
||||
allowTmpdirVar: boolean;
|
||||
}
|
||||
|
||||
export function analyzeXargs(
|
||||
tokens: readonly string[],
|
||||
context: XargsAnalyzeContext,
|
||||
): string | null {
|
||||
const { childTokens: rawChildTokens } = extractXargsChildCommandWithInfo(tokens);
|
||||
|
||||
let childTokens = stripWrappers(rawChildTokens);
|
||||
|
||||
if (childTokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let head = getBasename(childTokens[0] ?? '').toLowerCase();
|
||||
|
||||
if (head === 'busybox' && childTokens.length > 1) {
|
||||
childTokens = childTokens.slice(1);
|
||||
head = getBasename(childTokens[0] ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
// Check for shell wrapper with -c
|
||||
if (SHELL_WRAPPERS.has(head)) {
|
||||
// xargs bash -c is always dangerous - stdin feeds into the shell execution
|
||||
// Either no script arg (stdin IS the script) or script with dynamic input
|
||||
return REASON_XARGS_SHELL;
|
||||
}
|
||||
|
||||
if (head === 'rm' && hasRecursiveForceFlags(childTokens)) {
|
||||
const rmResult = analyzeRm(childTokens, {
|
||||
cwd: context.cwd,
|
||||
originalCwd: context.originalCwd,
|
||||
paranoid: context.paranoidRm,
|
||||
allowTmpdirVar: context.allowTmpdirVar,
|
||||
});
|
||||
if (rmResult) {
|
||||
return rmResult;
|
||||
}
|
||||
// Even if analyzeRm passes (e.g., temp paths), xargs rm -rf is still dangerous
|
||||
// because stdin provides dynamic input
|
||||
return REASON_XARGS_RM;
|
||||
}
|
||||
|
||||
if (head === 'find') {
|
||||
const findResult = analyzeFind(childTokens);
|
||||
if (findResult) {
|
||||
return findResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (head === 'git') {
|
||||
const gitResult = analyzeGit(childTokens);
|
||||
if (gitResult) {
|
||||
return gitResult;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface XargsParseResult {
|
||||
childTokens: string[];
|
||||
replacementToken: string | null;
|
||||
}
|
||||
|
||||
export function extractXargsChildCommandWithInfo(tokens: readonly string[]): XargsParseResult {
|
||||
// Options that take a value as the next token
|
||||
const xargsOptsWithValue = new Set([
|
||||
'-L',
|
||||
'-n',
|
||||
'-P',
|
||||
'-s',
|
||||
'-a',
|
||||
'-E',
|
||||
'-e',
|
||||
'-d',
|
||||
'-J',
|
||||
'--max-args',
|
||||
'--max-procs',
|
||||
'--max-chars',
|
||||
'--arg-file',
|
||||
'--eof',
|
||||
'--delimiter',
|
||||
'--max-lines',
|
||||
]);
|
||||
|
||||
let replacementToken: string | null = null;
|
||||
let i = 1;
|
||||
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (!token) break;
|
||||
|
||||
if (token === '--') {
|
||||
return { childTokens: [...tokens.slice(i + 1)], replacementToken };
|
||||
}
|
||||
|
||||
if (token.startsWith('-')) {
|
||||
// Handle -I (replacement option)
|
||||
if (token === '-I') {
|
||||
// -I TOKEN - next arg is the token
|
||||
replacementToken = (tokens[i + 1] as string | undefined) ?? '{}';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-I') && token.length > 2) {
|
||||
// -ITOKEN - token is attached
|
||||
replacementToken = token.slice(2);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle --replace option
|
||||
// In GNU xargs, --replace takes an optional argument via =
|
||||
// --replace alone uses {}, --replace=FOO uses FOO
|
||||
if (token === '--replace') {
|
||||
// --replace (defaults to {})
|
||||
replacementToken = '{}';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('--replace=')) {
|
||||
// --replace=TOKEN or --replace= (empty defaults to {})
|
||||
const value = token.slice('--replace='.length);
|
||||
replacementToken = value === '' ? '{}' : value;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle -J (macOS xargs replacement, consumes value)
|
||||
if (token === '-J') {
|
||||
// -J just consumes its value, doesn't enable placeholder mode for analysis
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (xargsOptsWithValue.has(token)) {
|
||||
i += 2;
|
||||
} else if (token.startsWith('--') && token.includes('=')) {
|
||||
i++;
|
||||
} else if (
|
||||
token.startsWith('-L') ||
|
||||
token.startsWith('-n') ||
|
||||
token.startsWith('-P') ||
|
||||
token.startsWith('-s')
|
||||
) {
|
||||
// These can have attached values like -n5
|
||||
i++;
|
||||
} else {
|
||||
// Unknown option, skip it
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
return { childTokens: [...tokens.slice(i)], replacementToken };
|
||||
}
|
||||
}
|
||||
|
||||
return { childTokens: [], replacementToken };
|
||||
}
|
||||
|
||||
export function extractXargsChildCommand(tokens: readonly string[]): string[] {
|
||||
return extractXargsChildCommandWithInfo(tokens).childTokens;
|
||||
}
|
||||
94
plugins/claude-code-safety-net/src/core/audit.ts
Normal file
94
plugins/claude-code-safety-net/src/core/audit.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { AuditLogEntry } from '../types.ts';
|
||||
|
||||
/**
|
||||
* Sanitize session ID to prevent path traversal attacks.
|
||||
* Returns null if the session ID is invalid.
|
||||
* @internal Exported for testing
|
||||
*/
|
||||
export function sanitizeSessionIdForFilename(sessionId: string): string | null {
|
||||
const raw = sessionId.trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Replace any non-safe characters with underscores
|
||||
let safe = raw.replace(/[^A-Za-z0-9_.-]+/g, '_');
|
||||
|
||||
// Strip leading/trailing special chars and limit length
|
||||
safe = safe.replace(/^[._-]+|[._-]+$/g, '').slice(0, 128);
|
||||
|
||||
if (!safe || safe === '.' || safe === '..') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an audit log entry for a denied command.
|
||||
* Logs are written to ~/.cc-safety-net/logs/<session_id>.jsonl
|
||||
*/
|
||||
export function writeAuditLog(
|
||||
sessionId: string,
|
||||
command: string,
|
||||
segment: string,
|
||||
reason: string,
|
||||
cwd: string | null,
|
||||
options: { homeDir?: string } = {},
|
||||
): void {
|
||||
const safeSessionId = sanitizeSessionIdForFilename(sessionId);
|
||||
if (!safeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const home = options.homeDir ?? homedir();
|
||||
const logsDir = join(home, '.cc-safety-net', 'logs');
|
||||
|
||||
try {
|
||||
if (!existsSync(logsDir)) {
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const logFile = join(logsDir, `${safeSessionId}.jsonl`);
|
||||
const entry: AuditLogEntry = {
|
||||
ts: new Date().toISOString(),
|
||||
command: redactSecrets(command).slice(0, 300),
|
||||
segment: redactSecrets(segment).slice(0, 300),
|
||||
reason,
|
||||
cwd,
|
||||
};
|
||||
|
||||
appendFileSync(logFile, `${JSON.stringify(entry)}\n`, 'utf-8');
|
||||
} catch {
|
||||
// Silently ignore errors (matches Python behavior)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact secrets from text to avoid leaking sensitive information in logs.
|
||||
*/
|
||||
export function redactSecrets(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// KEY=VALUE patterns for common secret-ish keys
|
||||
result = result.replace(
|
||||
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASS|KEY|CREDENTIALS)[A-Z0-9_]*)=([^\s]+)/gi,
|
||||
'$1=<redacted>',
|
||||
);
|
||||
|
||||
// Authorization headers
|
||||
result = result.replace(/(['"]?\s*authorization\s*:\s*)([^'"]+)(['"]?)/gi, '$1<redacted>$3');
|
||||
result = result.replace(/(authorization\s*:\s*)([^\s"']+)(\s+[^\s"']+)?/gi, '$1<redacted>');
|
||||
|
||||
// URL credentials: scheme://user:pass@host
|
||||
result = result.replace(/(https?:\/\/)([^\s/:@]+):([^\s@]+)@/gi, '$1<redacted>:<redacted>@');
|
||||
|
||||
// Common GitHub token prefixes
|
||||
result = result.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '<redacted>');
|
||||
|
||||
return result;
|
||||
}
|
||||
222
plugins/claude-code-safety-net/src/core/config.ts
Normal file
222
plugins/claude-code-safety-net/src/core/config.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
COMMAND_PATTERN,
|
||||
type Config,
|
||||
MAX_REASON_LENGTH,
|
||||
NAME_PATTERN,
|
||||
type ValidationResult,
|
||||
} from '../types.ts';
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
version: 1,
|
||||
rules: [],
|
||||
};
|
||||
|
||||
export interface LoadConfigOptions {
|
||||
/** Override user config directory (for testing) */
|
||||
userConfigDir?: string;
|
||||
}
|
||||
|
||||
export function loadConfig(cwd?: string, options?: LoadConfigOptions): Config {
|
||||
const safeCwd = typeof cwd === 'string' ? cwd : process.cwd();
|
||||
const userConfigDir = options?.userConfigDir ?? join(homedir(), '.cc-safety-net');
|
||||
const userConfigPath = join(userConfigDir, 'config.json');
|
||||
const projectConfigPath = join(safeCwd, '.safety-net.json');
|
||||
|
||||
const userConfig = loadSingleConfig(userConfigPath);
|
||||
const projectConfig = loadSingleConfig(projectConfigPath);
|
||||
|
||||
return mergeConfigs(userConfig, projectConfig);
|
||||
}
|
||||
|
||||
function loadSingleConfig(path: string): Config | null {
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
if (!content.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
const result = validateConfig(parsed);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure rules array exists (may be undefined if not in input)
|
||||
const cfg = parsed as Record<string, unknown>;
|
||||
return {
|
||||
version: cfg.version as number,
|
||||
rules: (cfg.rules as Config['rules']) ?? [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeConfigs(userConfig: Config | null, projectConfig: Config | null): Config {
|
||||
if (!userConfig && !projectConfig) {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
if (!userConfig) {
|
||||
return projectConfig ?? DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
if (!projectConfig) {
|
||||
return userConfig;
|
||||
}
|
||||
|
||||
const projectRuleNames = new Set(projectConfig.rules.map((r) => r.name.toLowerCase()));
|
||||
|
||||
const mergedRules = [
|
||||
...userConfig.rules.filter((r) => !projectRuleNames.has(r.name.toLowerCase())),
|
||||
...projectConfig.rules,
|
||||
];
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
rules: mergedRules,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function validateConfig(config: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const ruleNames = new Set<string>();
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
errors.push('Config must be an object');
|
||||
return { errors, ruleNames };
|
||||
}
|
||||
|
||||
const cfg = config as Record<string, unknown>;
|
||||
|
||||
if (cfg.version !== 1) {
|
||||
errors.push('version must be 1');
|
||||
}
|
||||
|
||||
if (cfg.rules !== undefined) {
|
||||
if (!Array.isArray(cfg.rules)) {
|
||||
errors.push('rules must be an array');
|
||||
} else {
|
||||
for (let i = 0; i < cfg.rules.length; i++) {
|
||||
const rule = cfg.rules[i] as unknown;
|
||||
const ruleErrors = validateRule(rule, i, ruleNames);
|
||||
errors.push(...ruleErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, ruleNames };
|
||||
}
|
||||
|
||||
function validateRule(rule: unknown, index: number, ruleNames: Set<string>): string[] {
|
||||
const errors: string[] = [];
|
||||
const prefix = `rules[${index}]`;
|
||||
|
||||
if (!rule || typeof rule !== 'object') {
|
||||
errors.push(`${prefix}: must be an object`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
const r = rule as Record<string, unknown>;
|
||||
|
||||
if (typeof r.name !== 'string') {
|
||||
errors.push(`${prefix}.name: required string`);
|
||||
} else {
|
||||
if (!NAME_PATTERN.test(r.name)) {
|
||||
errors.push(
|
||||
`${prefix}.name: must match pattern (letters, numbers, hyphens, underscores; max 64 chars)`,
|
||||
);
|
||||
}
|
||||
const lowerName = r.name.toLowerCase();
|
||||
if (ruleNames.has(lowerName)) {
|
||||
errors.push(`${prefix}.name: duplicate rule name "${r.name}"`);
|
||||
} else {
|
||||
ruleNames.add(lowerName);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof r.command !== 'string') {
|
||||
errors.push(`${prefix}.command: required string`);
|
||||
} else if (!COMMAND_PATTERN.test(r.command)) {
|
||||
errors.push(`${prefix}.command: must match pattern (letters, numbers, hyphens, underscores)`);
|
||||
}
|
||||
|
||||
if (r.subcommand !== undefined) {
|
||||
if (typeof r.subcommand !== 'string') {
|
||||
errors.push(`${prefix}.subcommand: must be a string if provided`);
|
||||
} else if (!COMMAND_PATTERN.test(r.subcommand)) {
|
||||
errors.push(
|
||||
`${prefix}.subcommand: must match pattern (letters, numbers, hyphens, underscores)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(r.block_args)) {
|
||||
errors.push(`${prefix}.block_args: required array`);
|
||||
} else {
|
||||
if (r.block_args.length === 0) {
|
||||
errors.push(`${prefix}.block_args: must have at least one element`);
|
||||
}
|
||||
for (let i = 0; i < r.block_args.length; i++) {
|
||||
const arg = r.block_args[i];
|
||||
if (typeof arg !== 'string') {
|
||||
errors.push(`${prefix}.block_args[${i}]: must be a string`);
|
||||
} else if (arg === '') {
|
||||
errors.push(`${prefix}.block_args[${i}]: must not be empty`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof r.reason !== 'string') {
|
||||
errors.push(`${prefix}.reason: required string`);
|
||||
} else if (r.reason === '') {
|
||||
errors.push(`${prefix}.reason: must not be empty`);
|
||||
} else if (r.reason.length > MAX_REASON_LENGTH) {
|
||||
errors.push(`${prefix}.reason: must be at most ${MAX_REASON_LENGTH} characters`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateConfigFile(path: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const ruleNames = new Set<string>();
|
||||
|
||||
if (!existsSync(path)) {
|
||||
errors.push(`File not found: ${path}`);
|
||||
return { errors, ruleNames };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
if (!content.trim()) {
|
||||
errors.push('Config file is empty');
|
||||
return { errors, ruleNames };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return validateConfig(parsed);
|
||||
} catch (e) {
|
||||
errors.push(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return { errors, ruleNames };
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserConfigPath(): string {
|
||||
return join(homedir(), '.cc-safety-net', 'config.json');
|
||||
}
|
||||
|
||||
export function getProjectConfigPath(cwd?: string): string {
|
||||
return resolve(cwd ?? process.cwd(), '.safety-net.json');
|
||||
}
|
||||
|
||||
export type { ValidationResult };
|
||||
4
plugins/claude-code-safety-net/src/core/env.ts
Normal file
4
plugins/claude-code-safety-net/src/core/env.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function envTruthy(name: string): boolean {
|
||||
const value = process.env[name];
|
||||
return value === '1' || value?.toLowerCase() === 'true';
|
||||
}
|
||||
36
plugins/claude-code-safety-net/src/core/format.ts
Normal file
36
plugins/claude-code-safety-net/src/core/format.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
type RedactFn = (text: string) => string;
|
||||
|
||||
export interface FormatBlockedMessageInput {
|
||||
reason: string;
|
||||
command?: string;
|
||||
segment?: string;
|
||||
maxLen?: number;
|
||||
redact?: RedactFn;
|
||||
}
|
||||
|
||||
export function formatBlockedMessage(input: FormatBlockedMessageInput): string {
|
||||
const { reason, command, segment } = input;
|
||||
const maxLen = input.maxLen ?? 200;
|
||||
const redact = input.redact ?? ((t: string) => t);
|
||||
|
||||
let message = `BLOCKED by Safety Net\n\nReason: ${reason}`;
|
||||
|
||||
if (command) {
|
||||
const safeCommand = redact(command);
|
||||
message += `\n\nCommand: ${excerpt(safeCommand, maxLen)}`;
|
||||
}
|
||||
|
||||
if (segment && segment !== command) {
|
||||
const safeSegment = redact(segment);
|
||||
message += `\n\nSegment: ${excerpt(safeSegment, maxLen)}`;
|
||||
}
|
||||
|
||||
message +=
|
||||
'\n\nIf this operation is truly needed, ask the user for explicit permission and have them run the command manually.';
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function excerpt(text: string, maxLen: number): string {
|
||||
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
|
||||
}
|
||||
98
plugins/claude-code-safety-net/src/core/rules-custom.ts
Normal file
98
plugins/claude-code-safety-net/src/core/rules-custom.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { CustomRule } from '../types.ts';
|
||||
import { extractShortOpts, getBasename } from './shell.ts';
|
||||
|
||||
export function checkCustomRules(tokens: string[], rules: CustomRule[]): string | null {
|
||||
if (tokens.length === 0 || rules.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = getBasename(tokens[0] ?? '');
|
||||
const subcommand = extractSubcommand(tokens);
|
||||
const shortOpts = extractShortOpts(tokens);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!matchesCommand(command, rule.command)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.subcommand && subcommand !== rule.subcommand) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchesBlockArgs(tokens, rule.block_args, shortOpts)) {
|
||||
return `[${rule.name}] ${rule.reason}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesCommand(command: string, ruleCommand: string): boolean {
|
||||
return command === ruleCommand;
|
||||
}
|
||||
|
||||
const OPTIONS_WITH_VALUES = new Set([
|
||||
'-c',
|
||||
'-C',
|
||||
'--git-dir',
|
||||
'--work-tree',
|
||||
'--namespace',
|
||||
'--config-env',
|
||||
]);
|
||||
|
||||
function extractSubcommand(tokens: string[]): string | null {
|
||||
let skipNext = false;
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--') {
|
||||
const nextToken = tokens[i + 1];
|
||||
if (nextToken && !nextToken.startsWith('-')) {
|
||||
return nextToken;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (OPTIONS_WITH_VALUES.has(token)) {
|
||||
skipNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('-')) {
|
||||
for (const opt of OPTIONS_WITH_VALUES) {
|
||||
if (token.startsWith(`${opt}=`)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesBlockArgs(tokens: string[], blockArgs: string[], shortOpts: Set<string>): boolean {
|
||||
const blockArgsSet = new Set(blockArgs);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (blockArgsSet.has(token)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const opt of shortOpts) {
|
||||
if (blockArgsSet.has(opt)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
354
plugins/claude-code-safety-net/src/core/rules-git.ts
Normal file
354
plugins/claude-code-safety-net/src/core/rules-git.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { extractShortOpts, getBasename } from './shell.ts';
|
||||
|
||||
const REASON_CHECKOUT_DOUBLE_DASH =
|
||||
"git checkout -- discards uncommitted changes permanently. Use 'git stash' first.";
|
||||
const REASON_CHECKOUT_REF_PATH =
|
||||
"git checkout <ref> -- <path> overwrites working tree with ref version. Use 'git stash' first.";
|
||||
const REASON_CHECKOUT_PATHSPEC_FROM_FILE =
|
||||
"git checkout --pathspec-from-file can overwrite multiple files. Use 'git stash' first.";
|
||||
const REASON_CHECKOUT_AMBIGUOUS =
|
||||
"git checkout with multiple positional args may overwrite files. Use 'git switch' for branches or 'git restore' for files.";
|
||||
const REASON_RESTORE =
|
||||
"git restore discards uncommitted changes. Use 'git stash' first, or use --staged to only unstage.";
|
||||
const REASON_RESTORE_WORKTREE =
|
||||
"git restore --worktree explicitly discards working tree changes. Use 'git stash' first.";
|
||||
const REASON_RESET_HARD =
|
||||
"git reset --hard destroys all uncommitted changes permanently. Use 'git stash' first.";
|
||||
const REASON_RESET_MERGE = "git reset --merge can lose uncommitted changes. Use 'git stash' first.";
|
||||
const REASON_CLEAN =
|
||||
"git clean -f removes untracked files permanently. Use 'git clean -n' to preview first.";
|
||||
const REASON_PUSH_FORCE =
|
||||
'git push --force destroys remote history. Use --force-with-lease for safer force push.';
|
||||
const REASON_BRANCH_DELETE =
|
||||
'git branch -D force-deletes without merge check. Use -d for safe delete.';
|
||||
const REASON_STASH_DROP =
|
||||
"git stash drop permanently deletes stashed changes. Consider 'git stash list' first.";
|
||||
const REASON_STASH_CLEAR = 'git stash clear deletes ALL stashed changes permanently.';
|
||||
const REASON_WORKTREE_REMOVE_FORCE =
|
||||
'git worktree remove --force can delete uncommitted changes. Remove --force flag.';
|
||||
|
||||
const GIT_GLOBAL_OPTS_WITH_VALUE = new Set([
|
||||
'-c',
|
||||
'-C',
|
||||
'--git-dir',
|
||||
'--work-tree',
|
||||
'--namespace',
|
||||
'--super-prefix',
|
||||
'--config-env',
|
||||
]);
|
||||
|
||||
const CHECKOUT_OPTS_WITH_VALUE = new Set([
|
||||
'-b',
|
||||
'-B',
|
||||
'--orphan',
|
||||
'--conflict',
|
||||
'--pathspec-from-file',
|
||||
'--unified',
|
||||
]);
|
||||
|
||||
const CHECKOUT_OPTS_WITH_OPTIONAL_VALUE = new Set(['--recurse-submodules', '--track', '-t']);
|
||||
|
||||
const CHECKOUT_KNOWN_OPTS_NO_VALUE = new Set([
|
||||
'-q',
|
||||
'--quiet',
|
||||
'-f',
|
||||
'--force',
|
||||
'-d',
|
||||
'--detach',
|
||||
'-m',
|
||||
'--merge',
|
||||
'-p',
|
||||
'--patch',
|
||||
'--ours',
|
||||
'--theirs',
|
||||
'--no-track',
|
||||
'--overwrite-ignore',
|
||||
'--no-overwrite-ignore',
|
||||
'--ignore-other-worktrees',
|
||||
'--progress',
|
||||
'--no-progress',
|
||||
]);
|
||||
|
||||
function splitAtDoubleDash(tokens: readonly string[]): {
|
||||
index: number;
|
||||
before: readonly string[];
|
||||
after: readonly string[];
|
||||
} {
|
||||
const index = tokens.indexOf('--');
|
||||
if (index === -1) {
|
||||
return { index: -1, before: tokens, after: [] };
|
||||
}
|
||||
return {
|
||||
index,
|
||||
before: tokens.slice(0, index),
|
||||
after: tokens.slice(index + 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function analyzeGit(tokens: readonly string[]): string | null {
|
||||
const { subcommand, rest } = extractGitSubcommandAndRest(tokens);
|
||||
|
||||
if (!subcommand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (subcommand.toLowerCase()) {
|
||||
case 'checkout':
|
||||
return analyzeGitCheckout(rest);
|
||||
case 'restore':
|
||||
return analyzeGitRestore(rest);
|
||||
case 'reset':
|
||||
return analyzeGitReset(rest);
|
||||
case 'clean':
|
||||
return analyzeGitClean(rest);
|
||||
case 'push':
|
||||
return analyzeGitPush(rest);
|
||||
case 'branch':
|
||||
return analyzeGitBranch(rest);
|
||||
case 'stash':
|
||||
return analyzeGitStash(rest);
|
||||
case 'worktree':
|
||||
return analyzeGitWorktree(rest);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractGitSubcommandAndRest(tokens: readonly string[]): {
|
||||
subcommand: string | null;
|
||||
rest: string[];
|
||||
} {
|
||||
if (tokens.length === 0) {
|
||||
return { subcommand: null, rest: [] };
|
||||
}
|
||||
|
||||
const firstToken = tokens[0];
|
||||
const command = firstToken ? getBasename(firstToken).toLowerCase() : null;
|
||||
if (command !== 'git') {
|
||||
return { subcommand: null, rest: [] };
|
||||
}
|
||||
|
||||
let i = 1;
|
||||
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (!token) break;
|
||||
|
||||
if (token === '--') {
|
||||
const nextToken = tokens[i + 1];
|
||||
if (nextToken && !nextToken.startsWith('-')) {
|
||||
return { subcommand: nextToken, rest: tokens.slice(i + 2) };
|
||||
}
|
||||
return { subcommand: null, rest: tokens.slice(i + 1) };
|
||||
}
|
||||
|
||||
if (token.startsWith('-')) {
|
||||
if (GIT_GLOBAL_OPTS_WITH_VALUE.has(token)) {
|
||||
i += 2;
|
||||
} else if (token.startsWith('-c') && token.length > 2) {
|
||||
i++;
|
||||
} else if (token.startsWith('-C') && token.length > 2) {
|
||||
i++;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
return { subcommand: token, rest: tokens.slice(i + 1) };
|
||||
}
|
||||
}
|
||||
|
||||
return { subcommand: null, rest: [] };
|
||||
}
|
||||
|
||||
function analyzeGitCheckout(tokens: readonly string[]): string | null {
|
||||
const { index: doubleDashIdx, before: beforeDash } = splitAtDoubleDash(tokens);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token === '-b' || token === '-B' || token === '--orphan') {
|
||||
return null;
|
||||
}
|
||||
if (token === '--pathspec-from-file') {
|
||||
return REASON_CHECKOUT_PATHSPEC_FROM_FILE;
|
||||
}
|
||||
if (token.startsWith('--pathspec-from-file=')) {
|
||||
return REASON_CHECKOUT_PATHSPEC_FROM_FILE;
|
||||
}
|
||||
}
|
||||
|
||||
if (doubleDashIdx !== -1) {
|
||||
const hasRefBeforeDash = beforeDash.some((t) => !t.startsWith('-'));
|
||||
|
||||
if (hasRefBeforeDash) {
|
||||
return REASON_CHECKOUT_REF_PATH;
|
||||
}
|
||||
return REASON_CHECKOUT_DOUBLE_DASH;
|
||||
}
|
||||
|
||||
const positionalArgs = getCheckoutPositionalArgs(tokens);
|
||||
if (positionalArgs.length >= 2) {
|
||||
return REASON_CHECKOUT_AMBIGUOUS;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCheckoutPositionalArgs(tokens: readonly string[]): string[] {
|
||||
const positional: string[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (!token) break;
|
||||
|
||||
if (token === '--') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (token.startsWith('-')) {
|
||||
if (CHECKOUT_OPTS_WITH_VALUE.has(token)) {
|
||||
i += 2;
|
||||
} else if (token.startsWith('--') && token.includes('=')) {
|
||||
i++;
|
||||
} else if (CHECKOUT_OPTS_WITH_OPTIONAL_VALUE.has(token)) {
|
||||
const nextToken = tokens[i + 1];
|
||||
if (
|
||||
nextToken &&
|
||||
!nextToken.startsWith('-') &&
|
||||
(token === '--recurse-submodules' || token === '--track' || token === '-t')
|
||||
) {
|
||||
const validModes =
|
||||
token === '--recurse-submodules' ? ['checkout', 'on-demand'] : ['direct', 'inherit'];
|
||||
if (validModes.includes(nextToken)) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else if (
|
||||
token.startsWith('--') &&
|
||||
!CHECKOUT_KNOWN_OPTS_NO_VALUE.has(token) &&
|
||||
!CHECKOUT_OPTS_WITH_VALUE.has(token) &&
|
||||
!CHECKOUT_OPTS_WITH_OPTIONAL_VALUE.has(token)
|
||||
) {
|
||||
const nextToken = tokens[i + 1];
|
||||
if (nextToken && !nextToken.startsWith('-')) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
positional.push(token);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return positional;
|
||||
}
|
||||
|
||||
function analyzeGitRestore(tokens: readonly string[]): string | null {
|
||||
let hasStaged = false;
|
||||
for (const token of tokens) {
|
||||
if (token === '--help' || token === '--version') {
|
||||
return null;
|
||||
}
|
||||
// --worktree explicitly discards working tree changes, even with --staged
|
||||
if (token === '--worktree' || token === '-W') {
|
||||
return REASON_RESTORE_WORKTREE;
|
||||
}
|
||||
if (token === '--staged' || token === '-S') {
|
||||
hasStaged = true;
|
||||
}
|
||||
}
|
||||
// Only safe if --staged is present (and --worktree is not)
|
||||
return hasStaged ? null : REASON_RESTORE;
|
||||
}
|
||||
|
||||
function analyzeGitReset(tokens: readonly string[]): string | null {
|
||||
for (const token of tokens) {
|
||||
if (token === '--hard') {
|
||||
return REASON_RESET_HARD;
|
||||
}
|
||||
if (token === '--merge') {
|
||||
return REASON_RESET_MERGE;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function analyzeGitClean(tokens: readonly string[]): string | null {
|
||||
for (const token of tokens) {
|
||||
if (token === '-n' || token === '--dry-run') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const shortOpts = extractShortOpts(tokens.filter((t) => t !== '--'));
|
||||
if (tokens.includes('--force') || shortOpts.has('-f')) {
|
||||
return REASON_CLEAN;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function analyzeGitPush(tokens: readonly string[]): string | null {
|
||||
let hasForceWithLease = false;
|
||||
const shortOpts = extractShortOpts(tokens.filter((t) => t !== '--'));
|
||||
const hasForce = tokens.includes('--force') || shortOpts.has('-f');
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token === '--force-with-lease' || token.startsWith('--force-with-lease=')) {
|
||||
hasForceWithLease = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasForce && !hasForceWithLease) {
|
||||
return REASON_PUSH_FORCE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function analyzeGitBranch(tokens: readonly string[]): string | null {
|
||||
const shortOpts = extractShortOpts(tokens.filter((t) => t !== '--'));
|
||||
if (shortOpts.has('-D')) {
|
||||
return REASON_BRANCH_DELETE;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function analyzeGitStash(tokens: readonly string[]): string | null {
|
||||
for (const token of tokens) {
|
||||
if (token === 'drop') {
|
||||
return REASON_STASH_DROP;
|
||||
}
|
||||
if (token === 'clear') {
|
||||
return REASON_STASH_CLEAR;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function analyzeGitWorktree(tokens: readonly string[]): string | null {
|
||||
const hasRemove = tokens.includes('remove');
|
||||
if (!hasRemove) return null;
|
||||
|
||||
const { before } = splitAtDoubleDash(tokens);
|
||||
for (const token of before) {
|
||||
if (token === '--force' || token === '-f') {
|
||||
return REASON_WORKTREE_REMOVE_FORCE;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export {
|
||||
extractGitSubcommandAndRest as _extractGitSubcommandAndRest,
|
||||
getCheckoutPositionalArgs as _getCheckoutPositionalArgs,
|
||||
};
|
||||
292
plugins/claude-code-safety-net/src/core/rules-rm.ts
Normal file
292
plugins/claude-code-safety-net/src/core/rules-rm.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { normalize, resolve } from 'node:path';
|
||||
|
||||
import { hasRecursiveForceFlags } from './analyze/rm-flags.ts';
|
||||
|
||||
const REASON_RM_RF =
|
||||
'rm -rf outside cwd is blocked. Use explicit paths within the current directory, or delete manually.';
|
||||
const REASON_RM_RF_ROOT_HOME =
|
||||
'rm -rf targeting root or home directory is extremely dangerous and always blocked.';
|
||||
|
||||
export interface AnalyzeRmOptions {
|
||||
cwd?: string;
|
||||
originalCwd?: string;
|
||||
paranoid?: boolean;
|
||||
allowTmpdirVar?: boolean;
|
||||
tmpdirOverridden?: boolean;
|
||||
}
|
||||
|
||||
interface RmContext {
|
||||
readonly anchoredCwd: string | null;
|
||||
readonly resolvedCwd: string | null;
|
||||
readonly paranoid: boolean;
|
||||
readonly trustTmpdirVar: boolean;
|
||||
readonly homeDir: string;
|
||||
}
|
||||
|
||||
type TargetClassification =
|
||||
| { kind: 'root_or_home_target' }
|
||||
| { kind: 'cwd_self_target' }
|
||||
| { kind: 'temp_target' }
|
||||
| { kind: 'within_anchored_cwd' }
|
||||
| { kind: 'outside_anchored_cwd' };
|
||||
|
||||
export function analyzeRm(tokens: string[], options: AnalyzeRmOptions = {}): string | null {
|
||||
const {
|
||||
cwd,
|
||||
originalCwd,
|
||||
paranoid = false,
|
||||
allowTmpdirVar = true,
|
||||
tmpdirOverridden = false,
|
||||
} = options;
|
||||
const anchoredCwd = originalCwd ?? cwd ?? null;
|
||||
const resolvedCwd = cwd ?? null;
|
||||
const trustTmpdirVar = allowTmpdirVar && !tmpdirOverridden;
|
||||
const ctx: RmContext = {
|
||||
anchoredCwd,
|
||||
resolvedCwd,
|
||||
paranoid,
|
||||
trustTmpdirVar,
|
||||
homeDir: getHomeDirForRmPolicy(),
|
||||
};
|
||||
|
||||
if (!hasRecursiveForceFlags(tokens)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targets = extractTargets(tokens);
|
||||
|
||||
for (const target of targets) {
|
||||
const classification = classifyTarget(target, ctx);
|
||||
const reason = reasonForClassification(classification, ctx);
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTargets(tokens: readonly string[]): string[] {
|
||||
const targets: string[] = [];
|
||||
let pastDoubleDash = false;
|
||||
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
if (token === '--') {
|
||||
pastDoubleDash = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pastDoubleDash) {
|
||||
targets.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!token.startsWith('-')) {
|
||||
targets.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function classifyTarget(target: string, ctx: RmContext): TargetClassification {
|
||||
if (isDangerousRootOrHomeTarget(target)) {
|
||||
return { kind: 'root_or_home_target' };
|
||||
}
|
||||
|
||||
const anchoredCwd = ctx.anchoredCwd;
|
||||
if (anchoredCwd) {
|
||||
if (isCwdSelfTarget(target, anchoredCwd)) {
|
||||
return { kind: 'cwd_self_target' };
|
||||
}
|
||||
}
|
||||
|
||||
if (isTempTarget(target, ctx.trustTmpdirVar)) {
|
||||
return { kind: 'temp_target' };
|
||||
}
|
||||
|
||||
if (anchoredCwd) {
|
||||
if (isCwdHomeForRmPolicy(anchoredCwd, ctx.homeDir)) {
|
||||
return { kind: 'root_or_home_target' };
|
||||
}
|
||||
|
||||
if (isTargetWithinCwd(target, anchoredCwd, ctx.resolvedCwd ?? anchoredCwd)) {
|
||||
return { kind: 'within_anchored_cwd' };
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'outside_anchored_cwd' };
|
||||
}
|
||||
|
||||
function reasonForClassification(
|
||||
classification: TargetClassification,
|
||||
ctx: RmContext,
|
||||
): string | null {
|
||||
switch (classification.kind) {
|
||||
case 'root_or_home_target':
|
||||
return REASON_RM_RF_ROOT_HOME;
|
||||
case 'cwd_self_target':
|
||||
return REASON_RM_RF;
|
||||
case 'temp_target':
|
||||
return null;
|
||||
case 'within_anchored_cwd':
|
||||
if (ctx.paranoid) {
|
||||
return `${REASON_RM_RF} (SAFETY_NET_PARANOID_RM enabled)`;
|
||||
}
|
||||
return null;
|
||||
case 'outside_anchored_cwd':
|
||||
return REASON_RM_RF;
|
||||
}
|
||||
}
|
||||
|
||||
function isDangerousRootOrHomeTarget(path: string): boolean {
|
||||
const normalized = path.trim();
|
||||
|
||||
if (normalized === '/' || normalized === '/*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === '~' || normalized === '~/' || normalized.startsWith('~/')) {
|
||||
if (normalized === '~' || normalized === '~/' || normalized === '~/*') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized === '$HOME' || normalized === '$HOME/' || normalized === '$HOME/*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === '${HOME}' || normalized === '${HOME}/' || normalized === '${HOME}/*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTempTarget(path: string, allowTmpdirVar: boolean): boolean {
|
||||
const normalized = path.trim();
|
||||
|
||||
if (normalized.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized === '/tmp' || normalized.startsWith('/tmp/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === '/var/tmp' || normalized.startsWith('/var/tmp/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const systemTmpdir = tmpdir();
|
||||
if (normalized.startsWith(`${systemTmpdir}/`) || normalized === systemTmpdir) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowTmpdirVar) {
|
||||
if (normalized === '$TMPDIR' || normalized.startsWith('$TMPDIR/')) {
|
||||
return true;
|
||||
}
|
||||
if (normalized === '${TMPDIR}' || normalized.startsWith('${TMPDIR}/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getHomeDirForRmPolicy(): string {
|
||||
return process.env.HOME ?? homedir();
|
||||
}
|
||||
|
||||
function isCwdHomeForRmPolicy(cwd: string, homeDir: string): boolean {
|
||||
try {
|
||||
const normalizedCwd = normalize(cwd);
|
||||
const normalizedHome = normalize(homeDir);
|
||||
return normalizedCwd === normalizedHome;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCwdSelfTarget(target: string, cwd: string): boolean {
|
||||
if (target === '.' || target === './') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = resolve(cwd, target);
|
||||
const realCwd = realpathSync(cwd);
|
||||
const realResolved = realpathSync(resolved);
|
||||
return realResolved === realCwd;
|
||||
} catch {
|
||||
// realpathSync throws if the path doesn't exist; fall back to a
|
||||
// normalize/resolve based comparison.
|
||||
try {
|
||||
const resolved = resolve(cwd, target);
|
||||
const normalizedCwd = normalize(cwd);
|
||||
return resolved === normalizedCwd;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isTargetWithinCwd(target: string, originalCwd: string, effectiveCwd?: string): boolean {
|
||||
const resolveCwd = effectiveCwd ?? originalCwd;
|
||||
if (target.startsWith('~') || target.startsWith('$HOME') || target.startsWith('${HOME}')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.includes('$') || target.includes('`')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.startsWith('/')) {
|
||||
try {
|
||||
const normalizedTarget = normalize(target);
|
||||
const normalizedCwd = `${normalize(originalCwd)}/`;
|
||||
return normalizedTarget.startsWith(normalizedCwd);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (target.startsWith('./') || !target.includes('/')) {
|
||||
try {
|
||||
const resolved = resolve(resolveCwd, target);
|
||||
const normalizedOriginalCwd = normalize(originalCwd);
|
||||
return resolved.startsWith(`${normalizedOriginalCwd}/`) || resolved === normalizedOriginalCwd;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (target.startsWith('../')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = resolve(resolveCwd, target);
|
||||
const normalizedCwd = normalize(originalCwd);
|
||||
return resolved.startsWith(`${normalizedCwd}/`) || resolved === normalizedCwd;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isHomeDirectory(cwd: string): boolean {
|
||||
const home = process.env.HOME ?? homedir();
|
||||
try {
|
||||
const normalizedCwd = normalize(cwd);
|
||||
const normalizedHome = normalize(home);
|
||||
return normalizedCwd === normalizedHome;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
442
plugins/claude-code-safety-net/src/core/shell.ts
Normal file
442
plugins/claude-code-safety-net/src/core/shell.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import { type ParseEntry, parse } from 'shell-quote';
|
||||
import { MAX_STRIP_ITERATIONS, SHELL_OPERATORS } from '../types.ts';
|
||||
|
||||
// Proxy that preserves variable references as $VAR strings instead of expanding them
|
||||
const ENV_PROXY = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, name) => `$${String(name)}`,
|
||||
},
|
||||
);
|
||||
|
||||
export function splitShellCommands(command: string): string[][] {
|
||||
if (hasUnclosedQuotes(command)) {
|
||||
return [[command]];
|
||||
}
|
||||
const normalizedCommand = command.replace(/\n/g, ' ; ');
|
||||
const tokens = parse(normalizedCommand, ENV_PROXY);
|
||||
const segments: string[][] = [];
|
||||
let current: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (token === undefined) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isOperator(token)) {
|
||||
if (current.length > 0) {
|
||||
segments.push(current);
|
||||
current = [];
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof token !== 'string') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle string tokens
|
||||
const nextToken = tokens[i + 1];
|
||||
if (token === '$' && nextToken && isParenOpen(nextToken)) {
|
||||
if (current.length > 0) {
|
||||
segments.push(current);
|
||||
current = [];
|
||||
}
|
||||
const { innerSegments, endIndex } = extractCommandSubstitution(tokens, i + 2);
|
||||
for (const seg of innerSegments) {
|
||||
segments.push(seg);
|
||||
}
|
||||
i = endIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const backtickSegments = extractBacktickSubstitutions(token);
|
||||
if (backtickSegments.length > 0) {
|
||||
for (const seg of backtickSegments) {
|
||||
segments.push(seg);
|
||||
}
|
||||
}
|
||||
current.push(token);
|
||||
i++;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
segments.push(current);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function extractBacktickSubstitutions(token: string): string[][] {
|
||||
const segments: string[][] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < token.length) {
|
||||
const backtickStart = token.indexOf('`', i);
|
||||
if (backtickStart === -1) break;
|
||||
|
||||
const backtickEnd = token.indexOf('`', backtickStart + 1);
|
||||
if (backtickEnd === -1) break;
|
||||
|
||||
const innerCommand = token.slice(backtickStart + 1, backtickEnd);
|
||||
if (innerCommand.trim()) {
|
||||
const innerSegments = splitShellCommands(innerCommand);
|
||||
for (const seg of innerSegments) {
|
||||
segments.push(seg);
|
||||
}
|
||||
}
|
||||
i = backtickEnd + 1;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function isParenOpen(token: ParseEntry | undefined): boolean {
|
||||
return typeof token === 'object' && token !== null && 'op' in token && token.op === '(';
|
||||
}
|
||||
|
||||
function isParenClose(token: ParseEntry | undefined): boolean {
|
||||
return typeof token === 'object' && token !== null && 'op' in token && token.op === ')';
|
||||
}
|
||||
|
||||
function extractCommandSubstitution(
|
||||
tokens: ParseEntry[],
|
||||
startIndex: number,
|
||||
): { innerSegments: string[][]; endIndex: number } {
|
||||
const innerSegments: string[][] = [];
|
||||
let currentSegment: string[] = [];
|
||||
let depth = 1;
|
||||
let i = startIndex;
|
||||
|
||||
while (i < tokens.length && depth > 0) {
|
||||
const token = tokens[i];
|
||||
|
||||
if (isParenOpen(token)) {
|
||||
depth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isParenClose(token)) {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth === 1 && token && isOperator(token)) {
|
||||
if (currentSegment.length > 0) {
|
||||
innerSegments.push(currentSegment);
|
||||
currentSegment = [];
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof token === 'string') {
|
||||
currentSegment.push(token);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (currentSegment.length > 0) {
|
||||
innerSegments.push(currentSegment);
|
||||
}
|
||||
|
||||
return { innerSegments, endIndex: i };
|
||||
}
|
||||
|
||||
function hasUnclosedQuotes(command: string): boolean {
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let escaped = false;
|
||||
|
||||
for (const char of command) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" && !inDouble) {
|
||||
inSingle = !inSingle;
|
||||
} else if (char === '"' && !inSingle) {
|
||||
inDouble = !inDouble;
|
||||
}
|
||||
}
|
||||
|
||||
return inSingle || inDouble;
|
||||
}
|
||||
|
||||
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
||||
|
||||
function parseEnvAssignment(token: string): { name: string; value: string } | null {
|
||||
if (!ENV_ASSIGNMENT_RE.test(token)) {
|
||||
return null;
|
||||
}
|
||||
const eqIdx = token.indexOf('=');
|
||||
if (eqIdx < 0) {
|
||||
return null;
|
||||
}
|
||||
return { name: token.slice(0, eqIdx), value: token.slice(eqIdx + 1) };
|
||||
}
|
||||
|
||||
export interface EnvStrippingResult {
|
||||
tokens: string[];
|
||||
envAssignments: Map<string, string>;
|
||||
}
|
||||
|
||||
export function stripEnvAssignmentsWithInfo(tokens: string[]): EnvStrippingResult {
|
||||
const envAssignments = new Map<string, string>();
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (!token) {
|
||||
break;
|
||||
}
|
||||
const assignment = parseEnvAssignment(token);
|
||||
if (!assignment) {
|
||||
break;
|
||||
}
|
||||
envAssignments.set(assignment.name, assignment.value);
|
||||
i++;
|
||||
}
|
||||
return { tokens: tokens.slice(i), envAssignments };
|
||||
}
|
||||
|
||||
export interface WrapperStrippingResult {
|
||||
tokens: string[];
|
||||
envAssignments: Map<string, string>;
|
||||
}
|
||||
|
||||
export function stripWrappers(tokens: string[]): string[] {
|
||||
return stripWrappersWithInfo(tokens).tokens;
|
||||
}
|
||||
|
||||
export function stripWrappersWithInfo(tokens: string[]): WrapperStrippingResult {
|
||||
let result = [...tokens];
|
||||
const allEnvAssignments = new Map<string, string>();
|
||||
|
||||
for (let iteration = 0; iteration < MAX_STRIP_ITERATIONS; iteration++) {
|
||||
const before = result.join(' ');
|
||||
|
||||
const { tokens: strippedTokens, envAssignments } = stripEnvAssignmentsWithInfo(result);
|
||||
for (const [k, v] of envAssignments) {
|
||||
allEnvAssignments.set(k, v);
|
||||
}
|
||||
result = strippedTokens;
|
||||
if (result.length === 0) break;
|
||||
|
||||
while (
|
||||
result.length > 0 &&
|
||||
result[0]?.includes('=') &&
|
||||
!ENV_ASSIGNMENT_RE.test(result[0] ?? '')
|
||||
) {
|
||||
// Conservative parsing: only strict NAME=value is treated as an env assignment.
|
||||
// Other leading tokens that contain '=' (e.g. NAME+=value) are dropped to reach
|
||||
// the actual executable token.
|
||||
result = result.slice(1);
|
||||
}
|
||||
if (result.length === 0) break;
|
||||
|
||||
const head = result[0]?.toLowerCase();
|
||||
|
||||
// Guard: unknown wrapper type, exit loop
|
||||
if (head !== 'sudo' && head !== 'env' && head !== 'command') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (head === 'sudo') {
|
||||
result = stripSudo(result);
|
||||
}
|
||||
if (head === 'env') {
|
||||
const envResult = stripEnvWithInfo(result);
|
||||
result = envResult.tokens;
|
||||
for (const [k, v] of envResult.envAssignments) {
|
||||
allEnvAssignments.set(k, v);
|
||||
}
|
||||
}
|
||||
if (head === 'command') {
|
||||
result = stripCommand(result);
|
||||
}
|
||||
|
||||
if (result.join(' ') === before) break;
|
||||
}
|
||||
|
||||
const { tokens: finalTokens, envAssignments: finalAssignments } =
|
||||
stripEnvAssignmentsWithInfo(result);
|
||||
for (const [k, v] of finalAssignments) {
|
||||
allEnvAssignments.set(k, v);
|
||||
}
|
||||
|
||||
return { tokens: finalTokens, envAssignments: allEnvAssignments };
|
||||
}
|
||||
|
||||
const SUDO_OPTS_WITH_VALUE = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U']);
|
||||
|
||||
function stripSudo(tokens: string[]): string[] {
|
||||
let i = 1;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (!token) break;
|
||||
|
||||
if (token === '--') {
|
||||
return tokens.slice(i + 1);
|
||||
}
|
||||
|
||||
// Guard: not an option, exit loop
|
||||
if (!token.startsWith('-')) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (SUDO_OPTS_WITH_VALUE.has(token)) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
return tokens.slice(i);
|
||||
}
|
||||
|
||||
const ENV_OPTS_NO_VALUE = new Set(['-i', '-0', '--null']);
|
||||
const ENV_OPTS_WITH_VALUE = new Set([
|
||||
'-u',
|
||||
'--unset',
|
||||
'-C',
|
||||
'--chdir',
|
||||
'-S',
|
||||
'--split-string',
|
||||
'-P',
|
||||
]);
|
||||
|
||||
function stripEnvWithInfo(tokens: string[]): EnvStrippingResult {
|
||||
const envAssignments = new Map<string, string>();
|
||||
let i = 1;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (!token) break;
|
||||
|
||||
if (token === '--') {
|
||||
return { tokens: tokens.slice(i + 1), envAssignments };
|
||||
}
|
||||
|
||||
if (ENV_OPTS_NO_VALUE.has(token)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ENV_OPTS_WITH_VALUE.has(token)) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('-u=') || token.startsWith('--unset=')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('-C=') || token.startsWith('--chdir=')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('-P')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('-')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not an option - try to parse as env assignment
|
||||
const assignment = parseEnvAssignment(token);
|
||||
if (!assignment) {
|
||||
break;
|
||||
}
|
||||
envAssignments.set(assignment.name, assignment.value);
|
||||
i++;
|
||||
}
|
||||
return { tokens: tokens.slice(i), envAssignments };
|
||||
}
|
||||
|
||||
function stripCommand(tokens: string[]): string[] {
|
||||
let i = 1;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (!token) break;
|
||||
|
||||
if (token === '-p' || token === '-v' || token === '-V') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--') {
|
||||
return tokens.slice(i + 1);
|
||||
}
|
||||
|
||||
// Check for combined short opts like -pv
|
||||
if (token.startsWith('-') && !token.startsWith('--') && token.length > 1) {
|
||||
const chars = token.slice(1);
|
||||
if (!/^[pvV]+$/.test(chars)) {
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
return tokens.slice(i);
|
||||
}
|
||||
|
||||
export function extractShortOpts(tokens: string[]): Set<string> {
|
||||
const opts = new Set<string>();
|
||||
let pastDoubleDash = false;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token === '--') {
|
||||
pastDoubleDash = true;
|
||||
continue;
|
||||
}
|
||||
if (pastDoubleDash) continue;
|
||||
|
||||
if (token.startsWith('-') && !token.startsWith('--') && token.length > 1) {
|
||||
for (let i = 1; i < token.length; i++) {
|
||||
const char = token[i];
|
||||
if (!char || !/[a-zA-Z]/.test(char)) {
|
||||
break;
|
||||
}
|
||||
opts.add(`-${char}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
export function normalizeCommandToken(token: string): string {
|
||||
return getBasename(token).toLowerCase();
|
||||
}
|
||||
|
||||
export function getBasename(token: string): string {
|
||||
return token.includes('/') ? (token.split('/').pop() ?? token) : token;
|
||||
}
|
||||
|
||||
function isOperator(token: ParseEntry): boolean {
|
||||
return (
|
||||
typeof token === 'object' &&
|
||||
token !== null &&
|
||||
'op' in token &&
|
||||
SHELL_OPERATORS.has(token.op as string)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user