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:
uroma
2026-01-22 15:35:55 +00:00
Unverified
commit 7a491b1548
1013 changed files with 170070 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
import { runClaudeCodeHook } from './claude-code.ts';
import { CUSTOM_RULES_DOC } from './custom-rules-doc.ts';
import { runGeminiCLIHook } from './gemini-cli.ts';
import { printHelp, printVersion } from './help.ts';
import { printStatusline } from './statusline.ts';
import { verifyConfig } from './verify-config.ts';
function printCustomRulesDoc(): void {
console.log(CUSTOM_RULES_DOC);
}
type HookMode = 'claude-code' | 'gemini-cli' | 'statusline';
function handleCliFlags(): HookMode | null {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printHelp();
process.exit(0);
}
if (args.includes('--version') || args.includes('-V')) {
printVersion();
process.exit(0);
}
if (args.includes('--verify-config') || args.includes('-vc')) {
process.exit(verifyConfig());
}
if (args.includes('--custom-rules-doc')) {
printCustomRulesDoc();
process.exit(0);
}
if (args.includes('--statusline')) {
return 'statusline';
}
if (args.includes('--claude-code') || args.includes('-cc')) {
return 'claude-code';
}
if (args.includes('--gemini-cli') || args.includes('-gc')) {
return 'gemini-cli';
}
console.error(`Unknown option: ${args[0]}`);
console.error("Run 'cc-safety-net --help' for usage.");
process.exit(1);
}
async function main(): Promise<void> {
const mode = handleCliFlags();
if (mode === 'claude-code') {
await runClaudeCodeHook();
} else if (mode === 'gemini-cli') {
await runGeminiCLIHook();
} else if (mode === 'statusline') {
await printStatusline();
}
}
main().catch((error: unknown) => {
console.error('Safety Net error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,81 @@
import { analyzeCommand, loadConfig } from '../core/analyze.ts';
import { redactSecrets, writeAuditLog } from '../core/audit.ts';
import { envTruthy } from '../core/env.ts';
import { formatBlockedMessage } from '../core/format.ts';
import type { HookInput, HookOutput } from '../types.ts';
function outputDeny(reason: string, command?: string, segment?: string): void {
const message = formatBlockedMessage({
reason,
command,
segment,
redact: redactSecrets,
});
const output: HookOutput = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: message,
},
};
console.log(JSON.stringify(output));
}
export async function runClaudeCodeHook(): Promise<void> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
const inputText = Buffer.concat(chunks).toString('utf-8').trim();
if (!inputText) {
return;
}
let input: HookInput;
try {
input = JSON.parse(inputText) as HookInput;
} catch {
if (envTruthy('SAFETY_NET_STRICT')) {
outputDeny('Failed to parse hook input JSON (strict mode)');
}
return;
}
if (input.tool_name !== 'Bash') {
return;
}
const command = input.tool_input?.command;
if (!command) {
return;
}
const cwd = input.cwd ?? process.cwd();
const strict = envTruthy('SAFETY_NET_STRICT');
const paranoidAll = envTruthy('SAFETY_NET_PARANOID');
const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM');
const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS');
const config = loadConfig(cwd);
const result = analyzeCommand(command, {
cwd,
config,
strict,
paranoidRm,
paranoidInterpreters,
});
if (result) {
const sessionId = input.session_id;
if (sessionId) {
writeAuditLog(sessionId, command, result.segment, result.reason, cwd);
}
outputDeny(result.reason, command, result.segment);
}
}

View File

@@ -0,0 +1,116 @@
export const CUSTOM_RULES_DOC = `# Custom Rules Reference
Agent reference for generating \`.safety-net.json\` config files.
## Config Locations
| Scope | Path | Priority |
|-------|------|----------|
| User | \`~/.cc-safety-net/config.json\` | Lower |
| Project | \`.safety-net.json\` (cwd) | Higher (overrides user) |
Duplicate rule names (case-insensitive) → project wins.
## Schema
\`\`\`json
{
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
"version": 1,
"rules": [...]
}
\`\`\`
- \`$schema\`: Optional. Enables IDE autocomplete and inline validation.
- \`version\`: Required. Must be \`1\`.
- \`rules\`: Optional. Defaults to \`[]\`.
**Always include \`$schema\`** when generating config files for IDE support.
## Rule Fields
| Field | Required | Constraints |
|-------|----------|-------------|
| \`name\` | Yes | \`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$\` — unique (case-insensitive) |
| \`command\` | Yes | \`^[a-zA-Z][a-zA-Z0-9_-]*$\` — basename only, not path |
| \`subcommand\` | No | Same pattern as command. Omit to match any. |
| \`block_args\` | Yes | Non-empty array of non-empty strings |
| \`reason\` | Yes | Non-empty string, max 256 chars |
## Guidelines:
- \`name\`: kebab-case, descriptive (e.g., \`block-git-add-all\`)
- \`command\`: binary name only, lowercase
- \`subcommand\`: omit if rule applies to any subcommand
- \`block_args\`: include all variants (e.g., both \`-g\` and \`--global\`)
- \`reason\`: explain why blocked AND suggest alternative
## Matching Behavior
- **Command**: Normalized to basename (\`/usr/bin/git\`\`git\`)
- **Subcommand**: First non-option argument after command
- **Arguments**: Matched literally. Command blocked if **any** \`block_args\` item present.
- **Short options**: Expanded (\`-Ap\` matches \`-A\`)
- **Long options**: Exact match (\`--all-files\` does NOT match \`--all\`)
- **Execution order**: Built-in rules first, then custom rules (additive only)
## Examples
### Block \`git add -A\`
\`\`\`json
{
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
"version": 1,
"rules": [
{
"name": "block-git-add-all",
"command": "git",
"subcommand": "add",
"block_args": ["-A", "--all", "."],
"reason": "Use 'git add <specific-files>' instead."
}
]
}
\`\`\`
### Block global npm install
\`\`\`json
{
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
"version": 1,
"rules": [
{
"name": "block-npm-global",
"command": "npm",
"subcommand": "install",
"block_args": ["-g", "--global"],
"reason": "Use npx or local install."
}
]
}
\`\`\`
### Block docker system prune
\`\`\`json
{
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
"version": 1,
"rules": [
{
"name": "block-docker-prune",
"command": "docker",
"subcommand": "system",
"block_args": ["prune"],
"reason": "Use targeted cleanup instead."
}
]
}
\`\`\`
## Error Handling
Invalid config → silent fallback to built-in rules only. No custom rules applied.
`;

View File

@@ -0,0 +1,84 @@
import { analyzeCommand, loadConfig } from '../core/analyze.ts';
import { redactSecrets, writeAuditLog } from '../core/audit.ts';
import { envTruthy } from '../core/env.ts';
import { formatBlockedMessage } from '../core/format.ts';
import type { GeminiHookInput, GeminiHookOutput } from '../types.ts';
function outputGeminiDeny(reason: string, command?: string, segment?: string): void {
const message = formatBlockedMessage({
reason,
command,
segment,
redact: redactSecrets,
});
// Gemini CLI expects exit code 0 with JSON for policy blocks; exit 2 is for hook errors.
const output: GeminiHookOutput = {
decision: 'deny',
reason: message,
systemMessage: message,
};
console.log(JSON.stringify(output));
}
export async function runGeminiCLIHook(): Promise<void> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
const inputText = Buffer.concat(chunks).toString('utf-8').trim();
if (!inputText) {
return;
}
let input: GeminiHookInput;
try {
input = JSON.parse(inputText) as GeminiHookInput;
} catch {
if (envTruthy('SAFETY_NET_STRICT')) {
outputGeminiDeny('Failed to parse hook input JSON (strict mode)');
}
return;
}
if (input.hook_event_name !== 'BeforeTool') {
return;
}
if (input.tool_name !== 'run_shell_command') {
return;
}
const command = input.tool_input?.command;
if (!command) {
return;
}
const cwd = input.cwd ?? process.cwd();
const strict = envTruthy('SAFETY_NET_STRICT');
const paranoidAll = envTruthy('SAFETY_NET_PARANOID');
const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM');
const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS');
const config = loadConfig(cwd);
const result = analyzeCommand(command, {
cwd,
config,
strict,
paranoidRm,
paranoidInterpreters,
});
if (result) {
const sessionId = input.session_id;
if (sessionId) {
writeAuditLog(sessionId, command, result.segment, result.reason, cwd);
}
outputGeminiDeny(result.reason, command, result.segment);
}
}

View File

@@ -0,0 +1,32 @@
declare const __PKG_VERSION__: string | undefined;
const version = typeof __PKG_VERSION__ !== 'undefined' ? __PKG_VERSION__ : 'dev';
export function printHelp(): void {
console.log(`cc-safety-net v${version}
Blocks destructive git and filesystem commands before execution.
USAGE:
cc-safety-net -cc, --claude-code Run as Claude Code PreToolUse hook (reads JSON from stdin)
cc-safety-net -gc, --gemini-cli Run as Gemini CLI BeforeTool hook (reads JSON from stdin)
cc-safety-net -vc, --verify-config Validate config files
cc-safety-net --custom-rules-doc Print custom rules documentation
cc-safety-net --statusline Print status line with mode indicators
cc-safety-net -h, --help Show this help
cc-safety-net -V, --version Show version
ENVIRONMENT VARIABLES:
SAFETY_NET_STRICT=1 Fail-closed on unparseable commands
SAFETY_NET_PARANOID=1 Enable all paranoid checks
SAFETY_NET_PARANOID_RM=1 Block non-temp rm -rf within cwd
SAFETY_NET_PARANOID_INTERPRETERS=1 Block interpreter one-liners
CONFIG FILES:
~/.cc-safety-net/config.json User-scope config
.safety-net.json Project-scope config`);
}
export function printVersion(): void {
console.log(version);
}

View File

@@ -0,0 +1,117 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { envTruthy } from '../core/env.ts';
/**
* Read piped stdin content asynchronously.
* Returns null if stdin is a TTY (no piped input) or empty.
*/
async function readStdinAsync(): Promise<string | null> {
if (process.stdin.isTTY) {
return null;
}
return new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => {
const trimmed = data.trim();
resolve(trimmed || null);
});
process.stdin.on('error', () => {
resolve(null);
});
});
}
function getSettingsPath(): string {
// Allow override for testing
if (process.env.CLAUDE_SETTINGS_PATH) {
return process.env.CLAUDE_SETTINGS_PATH;
}
return join(homedir(), '.claude', 'settings.json');
}
interface ClaudeSettings {
enabledPlugins?: Record<string, boolean>;
}
function isPluginEnabled(): boolean {
const settingsPath = getSettingsPath();
if (!existsSync(settingsPath)) {
// Default to disabled if settings file doesn't exist
return false;
}
try {
const content = readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(content) as ClaudeSettings;
// If enabledPlugins doesn't exist or plugin not listed, default to disabled
if (!settings.enabledPlugins) {
return false;
}
const pluginKey = 'safety-net@cc-marketplace';
// If not explicitly set, default to disabled
if (!(pluginKey in settings.enabledPlugins)) {
return false;
}
return settings.enabledPlugins[pluginKey] === true;
} catch {
// On any error (invalid JSON, etc.), default to disabled
return false;
}
}
export async function printStatusline(): Promise<void> {
const enabled = isPluginEnabled();
// Build our status string
let status: string;
if (!enabled) {
status = '🛡️ Safety Net ❌';
} else {
const strict = envTruthy('SAFETY_NET_STRICT');
const paranoidAll = envTruthy('SAFETY_NET_PARANOID');
const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM');
const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS');
let modeEmojis = '';
// Strict mode: 🔒
if (strict) {
modeEmojis += '🔒';
}
// Paranoid modes: 👁️ if PARANOID or (PARANOID_RM + PARANOID_INTERPRETERS)
// Otherwise individual emojis: 🗑️ for RM, 🐚 for interpreters
if (paranoidAll || (paranoidRm && paranoidInterpreters)) {
modeEmojis += '👁️';
} else if (paranoidRm) {
modeEmojis += '🗑️';
} else if (paranoidInterpreters) {
modeEmojis += '🐚';
}
// If no mode flags, show ✅
const statusEmoji = modeEmojis || '✅';
status = `🛡️ Safety Net ${statusEmoji}`;
}
// Check for piped stdin input and prepend with separator
// Skip JSON input (Claude Code pipes status JSON that shouldn't be echoed)
const stdinInput = await readStdinAsync();
if (stdinInput && !stdinInput.startsWith('{')) {
console.log(`${stdinInput} | ${status}`);
} else {
console.log(status);
}
}

View File

@@ -0,0 +1,132 @@
/**
* Verify user and project scope config files for safety-net.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
getProjectConfigPath,
getUserConfigPath,
type ValidationResult,
validateConfigFile,
} from '../core/config.ts';
export interface VerifyConfigOptions {
userConfigPath?: string;
projectConfigPath?: string;
}
const HEADER = 'Safety Net Config';
const SEPARATOR = '═'.repeat(HEADER.length);
const SCHEMA_URL =
'https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json';
function printHeader(): void {
console.log(HEADER);
console.log(SEPARATOR);
}
function printValidConfig(scope: string, path: string, result: ValidationResult): void {
console.log(`\n✓ ${scope} config: ${path}`);
if (result.ruleNames.size > 0) {
console.log(' Rules:');
let i = 1;
for (const name of result.ruleNames) {
console.log(` ${i}. ${name}`);
i++;
}
} else {
console.log(' Rules: (none)');
}
}
function printInvalidConfig(scope: string, path: string, errors: string[]): void {
console.error(`\n✗ ${scope} config: ${path}`);
console.error(' Errors:');
let errorNum = 1;
for (const error of errors) {
for (const part of error.split('; ')) {
console.error(` ${errorNum}. ${part}`);
errorNum++;
}
}
}
function addSchemaIfMissing(path: string): boolean {
try {
const content = readFileSync(path, 'utf-8');
const parsed = JSON.parse(content) as Record<string, unknown>;
if (parsed.$schema) {
return false;
}
const updated = { $schema: SCHEMA_URL, ...parsed };
writeFileSync(path, JSON.stringify(updated, null, 2), 'utf-8');
return true;
} catch {
return false;
}
}
/**
* Verify config files and print results.
* @returns Exit code (0 = success, 1 = errors found)
*/
export function verifyConfig(options: VerifyConfigOptions = {}): number {
const userConfig = options.userConfigPath ?? getUserConfigPath();
const projectConfig = options.projectConfigPath ?? getProjectConfigPath();
let hasErrors = false;
const configsChecked: Array<{
scope: string;
path: string;
result: ValidationResult;
}> = [];
printHeader();
if (existsSync(userConfig)) {
const result = validateConfigFile(userConfig);
configsChecked.push({ scope: 'User', path: userConfig, result });
if (result.errors.length > 0) {
hasErrors = true;
}
}
if (existsSync(projectConfig)) {
const result = validateConfigFile(projectConfig);
configsChecked.push({
scope: 'Project',
path: resolve(projectConfig),
result,
});
if (result.errors.length > 0) {
hasErrors = true;
}
}
if (configsChecked.length === 0) {
console.log('\nNo config files found. Using built-in rules only.');
return 0;
}
for (const { scope, path, result } of configsChecked) {
if (result.errors.length > 0) {
printInvalidConfig(scope, path, result.errors);
} else {
if (addSchemaIfMissing(path)) {
console.log(`\nAdded $schema to ${scope.toLowerCase()} config.`);
}
printValidConfig(scope, path, result);
}
}
if (hasErrors) {
console.error('\nConfig validation failed.');
return 1;
}
console.log('\nAll configs valid.');
return 0;
}

View 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 };

View File

@@ -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;
}

View 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',
]);

View File

@@ -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;
}

View 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;
}

View File

@@ -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;
}

View 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 [];
}

View 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;
}

View 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);
}

View File

@@ -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;
}

View 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);
}

View 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;
}

View 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;
}

View 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 };

View File

@@ -0,0 +1,4 @@
export function envTruthy(name: string): boolean {
const value = process.env[name];
return value === '1' || value?.toLowerCase() === 'true';
}

View 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;
}

View 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;
}

View 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,
};

View 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;
}
}

View 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)
);
}

View File

@@ -0,0 +1,27 @@
import { SET_CUSTOM_RULES_TEMPLATE } from './templates/set-custom-rules.ts';
import { VERIFY_CUSTOM_RULES_TEMPLATE } from './templates/verify-custom-rules.ts';
import type { BuiltinCommandName, BuiltinCommands, CommandDefinition } from './types.ts';
const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, CommandDefinition> = {
'set-custom-rules': {
description: 'Set custom rules for Safety Net',
template: SET_CUSTOM_RULES_TEMPLATE,
},
'verify-custom-rules': {
description: 'Verify custom rules for Safety Net',
template: VERIFY_CUSTOM_RULES_TEMPLATE,
},
};
export function loadBuiltinCommands(disabledCommands?: BuiltinCommandName[]): BuiltinCommands {
const disabled = new Set(disabledCommands ?? []);
const commands: BuiltinCommands = {};
for (const [name, definition] of Object.entries(BUILTIN_COMMAND_DEFINITIONS)) {
if (!disabled.has(name as BuiltinCommandName)) {
commands[name] = definition;
}
}
return commands;
}

View File

@@ -0,0 +1,2 @@
export * from './commands.ts';
export * from './types.ts';

View File

@@ -0,0 +1,67 @@
export const SET_CUSTOM_RULES_TEMPLATE = `You are helping the user configure custom blocking rules for claude-code-safety-net.
## Context
### Schema Documentation
!\`npx -y cc-safety-net --custom-rules-doc\`
## Your Task
Follow this flow exactly:
### Step 1: Ask for Scope
Ask: **Which scope would you like to configure?**
- **User** (\`~/.cc-safety-net/config.json\`) - applies to all your projects
- **Project** (\`.safety-net.json\`) - applies only to this project
### Step 2: Show Examples and Ask for Rules
Show examples in natural language:
- "Block \`git add -A\` and \`git add .\` to prevent blanket staging"
- "Block \`npm install -g\` to prevent global package installs"
- "Block \`docker system prune\` to prevent accidental cleanup"
Ask the user to describe rules in natural language. They can list multiple.
### Step 3: Generate JSON Config
Parse user input and generate valid schema JSON using the schema documentation above.
### Step 4: Show Config and Confirm
Display the generated JSON and ask:
- "Does this look correct?"
- "Would you like to modify anything?"
### Step 5: Check and Handle Existing Config
1. Check existing User Config with \`cat ~/.cc-safety-net/config.json 2>/dev/null || echo "No user config found"\`
2. Check existing Project Config with \`cat .safety-net.json 2>/dev/null || echo "No project config found"\`
If the chosen scope already has a config:
Show the existing config to the user.
Ask: **Merge** (add new rules, duplicates use new version) or **Replace**?
### Step 6: Write and Validate
Write the config to the chosen scope, then validate with \`npx -y cc-safety-net --verify-config\`.
If validation errors:
- Show specific errors
- Offer to fix with your best suggestion
- Confirm before proceeding
### Step 7: Confirm Success
Tell the user:
1. Config saved to [path]
2. **Changes take effect immediately** - no restart needed
3. Summary of rules added
## Important Notes
- Custom rules can only ADD restrictions, not bypass built-in protections
- Rule names must be unique (case-insensitive)
- Invalid config → entire config ignored, only built-in rules apply`;

View File

@@ -0,0 +1,12 @@
export const VERIFY_CUSTOM_RULES_TEMPLATE = `You are helping the user verify the custom rules config file.
## Your Task
Run \`npx -y cc-safety-net --verify-config\` to check current validation status
If the config has validation errors:
1. Show the specific validation errors
2. Run \`npx -y cc-safety-net --custom-rules-doc\` to read the schema documentation
3. Offer to fix them with your best suggestion
4. Ask for confirmation before proceeding
5. After fixing, run \`npx -y cc-safety-net --verify-config\` to verify again`;

View File

@@ -0,0 +1,12 @@
export type BuiltinCommandName = 'set-custom-rules' | 'verify-custom-rules';
// export interface BuiltinCommandConfig {
// disabled_commands?: BuiltinCommandName[];
// }
export interface CommandDefinition {
description?: string;
template: string;
}
export type BuiltinCommands = Record<string, CommandDefinition>;

View File

@@ -0,0 +1,47 @@
import type { Plugin } from '@opencode-ai/plugin';
import { analyzeCommand, loadConfig } from './core/analyze.ts';
import { envTruthy } from './core/env.ts';
import { formatBlockedMessage } from './core/format.ts';
import { loadBuiltinCommands } from './features/builtin-commands/index.ts';
export const SafetyNetPlugin: Plugin = async ({ directory }) => {
const safetyNetConfig = loadConfig(directory);
const strict = envTruthy('SAFETY_NET_STRICT');
const paranoidAll = envTruthy('SAFETY_NET_PARANOID');
const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM');
const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS');
return {
config: async (opencodeConfig: Record<string, unknown>) => {
const builtinCommands = loadBuiltinCommands();
const existingCommands = (opencodeConfig.command as Record<string, unknown>) ?? {};
opencodeConfig.command = {
...builtinCommands,
...existingCommands,
};
},
'tool.execute.before': async (input, output) => {
if (input.tool === 'bash') {
const command = output.args.command;
const result = analyzeCommand(command, {
cwd: directory,
config: safetyNetConfig,
strict,
paranoidRm,
paranoidInterpreters,
});
if (result) {
const message = formatBlockedMessage({
reason: result.reason,
command,
segment: result.segment,
});
throw new Error(message);
}
}
},
};
};

View File

@@ -0,0 +1,148 @@
/**
* Shared types for the safety-net plugin.
*/
/** Custom rule definition from .safety-net.json */
export interface CustomRule {
/** Unique identifier for the rule */
name: string;
/** Base command to match (e.g., "git", "npm") */
command: string;
/** Optional subcommand to match (e.g., "add", "install") */
subcommand?: string;
/** Arguments that trigger the block */
block_args: string[];
/** Message shown when blocked */
reason: string;
}
/** Configuration loaded from .safety-net.json */
export interface Config {
/** Schema version (must be 1) */
version: number;
/** Custom blocking rules */
rules: CustomRule[];
}
/** Result of config validation */
export interface ValidationResult {
/** List of validation error messages */
errors: string[];
/** Set of rule names found (for duplicate detection) */
ruleNames: Set<string>;
}
/** Result of command analysis */
export interface AnalyzeResult {
/** The reason the command was blocked */
reason: string;
/** The specific segment that triggered the block */
segment: string;
}
/** Claude Code hook input format */
export interface HookInput {
session_id?: string;
transcript_path?: string;
cwd?: string;
permission_mode?: string;
hook_event_name: string;
tool_name: string;
tool_input: {
command: string;
description?: string;
};
tool_use_id?: string;
}
/** Claude Code hook output format */
export interface HookOutput {
hookSpecificOutput: {
hookEventName: string;
permissionDecision: 'allow' | 'deny';
permissionDecisionReason?: string;
};
}
/** Gemini CLI hook input format */
export interface GeminiHookInput {
session_id?: string;
transcript_path?: string;
cwd?: string;
hook_event_name: string;
timestamp?: string;
tool_name?: string;
tool_input?: {
command?: string;
[key: string]: unknown;
};
}
/** Gemini CLI hook output format */
export interface GeminiHookOutput {
decision: 'deny';
reason: string;
systemMessage: string;
continue?: boolean;
stopReason?: string;
suppressOutput?: boolean;
}
/** Options for command analysis */
export interface AnalyzeOptions {
/** Current working directory */
cwd?: string;
/** Effective cwd after cd commands (null = unknown, undefined = use cwd) */
effectiveCwd?: string | null;
/** Loaded configuration */
config?: Config;
/** Fail-closed on unparseable commands */
strict?: boolean;
/** Block non-temp rm -rf even within cwd */
paranoidRm?: boolean;
/** Block interpreter one-liners */
paranoidInterpreters?: boolean;
/** Allow $TMPDIR paths (false when TMPDIR is overridden to non-temp) */
allowTmpdirVar?: boolean;
}
/** Audit log entry */
export interface AuditLogEntry {
ts: string;
command: string;
segment: string;
reason: string;
cwd?: string | null;
}
/** Constants */
export const MAX_RECURSION_DEPTH = 10;
export const MAX_STRIP_ITERATIONS = 20;
export const NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
export const COMMAND_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
export const MAX_REASON_LENGTH = 256;
/** Shell operators that split commands */
export const SHELL_OPERATORS = new Set(['&&', '||', '|&', '|', '&', ';', '\n']);
/** Shell wrappers that need recursive analysis */
export const SHELL_WRAPPERS = new Set(['bash', 'sh', 'zsh', 'ksh', 'dash', 'fish', 'csh', 'tcsh']);
/** Interpreters that can execute code */
export const INTERPRETERS = new Set(['python', 'python3', 'python2', 'node', 'ruby', 'perl']);
/** Dangerous commands to detect in interpreter code */
export const DANGEROUS_PATTERNS = [
/\brm\s+.*-[rR].*-f\b/,
/\brm\s+.*-f.*-[rR]\b/,
/\brm\s+-rf\b/,
/\brm\s+-fr\b/,
/\bgit\s+reset\s+--hard\b/,
/\bgit\s+checkout\s+--\b/,
/\bgit\s+clean\s+-f\b/,
/\bfind\b.*\s-delete\b/,
];
export const PARANOID_INTERPRETERS_SUFFIX =
'\n\n(Paranoid mode: interpreter one-liners are blocked.)';