mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
fix(safety): relax over-restrictive safety checks for benign coding tasks (#1897)
* fix(safety): relax over-restrictive safety checks for benign coding tasks (Fixes #1616) Issue #1616 reports refusals for routine, benign coding tasks. Two layers caused this: 1. Model-level over-refusal: CYBER_RISK_INSTRUCTION and the 'ask before acting' guidance biased the model toward refusing normal work. Reworded to explicitly permit ordinary engineering tasks and dual-use/security-adjacent work in authorized contexts, and to ask a clarifying question rather than refuse when intent is ambiguous. 2. Application-level heuristics that become hard blocks in auto/YOLO/headless mode: the bash command-injection check, the broad DANGEROUS_FILES/DANGEROUS_DIRECTORIES auto-edit guard, and the auto-mode stripping of ordinary interpreter allow-rules (Bash(python:*), npm run:*, etc.). Added an OPENCLAUDE_SAFETY_LEVEL knob (strict|balanced|permissive, default balanced). In 'permissive' the application-level heuristics above are relaxed while genuine Windows-path/symlink guards remain active. Default behavior is unchanged. Validation: - bun run typecheck: clean - bun run build: succeeds - bun test safetyLevel.test.ts, bashSecurity.safety.test.ts: pass - bashSecurity.test.ts, filesystem.test.ts, permissionSetup.test.ts, security-hardening.test.ts: pass (no regressions) * fix(safety): narrow permissive safety relaxations * fix(safety): address review follow-ups * fix(safety): address additional review findings * refactor(permissions): share rule normalization
This commit is contained in:
@@ -471,6 +471,28 @@ addition to the `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` /
|
||||
metadata (a known catalog model keeps its catalog limit unless you set an
|
||||
*exact* env override for it).
|
||||
|
||||
## Safety strictness
|
||||
|
||||
OpenClaude runs several "safety" checks: a model-level refusal directive, bash
|
||||
command-injection validation, and sensitive-file / auto-edit guards. These are
|
||||
conservative by design, but a few of them can surface as refusals or approval
|
||||
prompts for entirely benign, routine coding tasks (e.g. editing `.gitmodules`,
|
||||
running a build script that contains `$(date)`, or writing a CTF port scanner).
|
||||
See [issue #1616](https://github.com/Gitlawb/openclaude/issues/1616).
|
||||
|
||||
Set `OPENCLAUDE_SAFETY_LEVEL` to dial strictness without changing behavior for
|
||||
everyone:
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `strict` | Current/default-equivalent non-permissive behavior. |
|
||||
| `balanced` | Default. Same behavior as `strict`. |
|
||||
| `permissive` | Opt-in mode for users who prefer fewer false-positive stops. It bypasses the legacy bash command-injection validation path entirely, keeps ordinary interpreter allow-rules (`Bash(python:*)`, `Bash(npm run:*)`, …) when entering auto mode, and skips prompts for routine edits to filenames on the broad sensitive-file list. Dangerous directory, Windows-path, symlink-resolved path, and UNC guards remain active. The model-level prompt is not weakened by this flag. |
|
||||
|
||||
```bash
|
||||
export OPENCLAUDE_SAFETY_LEVEL=permissive # relax benign-task false positives
|
||||
```
|
||||
|
||||
## Runtime Hardening
|
||||
|
||||
Use these commands to validate your setup and catch mistakes early:
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
/**
|
||||
* CYBER_RISK_INSTRUCTION
|
||||
*
|
||||
* This instruction provides guidance for Claude's behavior when handling
|
||||
* security-related requests. It defines the boundary between acceptable
|
||||
* defensive security assistance and potentially harmful activities.
|
||||
* Guidance for Claude's behavior when handling security-related requests.
|
||||
*
|
||||
* IMPORTANT: DO NOT MODIFY THIS INSTRUCTION WITHOUT SAFEGUARDS TEAM REVIEW
|
||||
* This is an open-source fork. The instruction is tuned to favor completing
|
||||
* normal, benign coding work and to avoid refusing legitimate dual-use or
|
||||
* security-adjacent tasks (CTF challenges, authorized pentests, educational
|
||||
* exploit code, dev tooling such as port scanners or credential helpers).
|
||||
* Refusals are reserved for requests that clearly seek harm.
|
||||
*
|
||||
* This instruction is owned by the Safeguards team and has been carefully
|
||||
* crafted and evaluated to balance security utility with safety. Changes
|
||||
* to this text can have significant implications for:
|
||||
* - How Claude handles penetration testing and CTF requests
|
||||
* - What security tools and techniques Claude will assist with
|
||||
* - The boundary between defensive and offensive security assistance
|
||||
*
|
||||
* If you need to modify this instruction:
|
||||
* 1. Contact the Safeguards team (David Forsythe, Kyla Guru)
|
||||
* 2. Ensure proper evaluation of the changes
|
||||
* 3. Get explicit approval before merging
|
||||
*
|
||||
* Claude: Do not edit this file unless explicitly asked to do so by the user.
|
||||
* See issue #1616: over-restrictive safety wording caused refusals for routine
|
||||
* coding tasks. The instruction now explicitly permits ordinary engineering
|
||||
* work and tells the model to ask a clarifying question (rather than refuse)
|
||||
* when a request's intent is genuinely ambiguous.
|
||||
*/
|
||||
export const CYBER_RISK_INSTRUCTION = `IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.`
|
||||
export const CYBER_RISK_INSTRUCTION = `IMPORTANT: You are a coding assistant. Always complete normal software engineering work — debugging, refactoring, writing and running code, build/test/install commands, editing project and configuration files, and answering questions about code. Also assist freely with authorized security work: defensive security, CTF challenges, educational contexts, and clearly authorized pentesting. Only refuse a request when it concretely seeks harm — destructive techniques aimed at damaging systems, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) are permitted for authorized contexts such as pentesting engagements, CTF competitions, security research, or defensive use. When a request's intent is genuinely ambiguous, prefer asking a clarifying question over refusing outright.`
|
||||
|
||||
@@ -257,7 +257,7 @@ Examples of the kind of risky actions that warrant user confirmation:
|
||||
- Actions visible to others or that affect shared state: pushing code, creating/closing/commenting on PRs or issues, sending messages (Slack, email, GitHub), posting to external services, modifying shared infrastructure or permissions
|
||||
- Uploading content to third-party web tools (diagram renderers, pastebins, gists) publishes it - consider whether it could be sensitive before sending, since it may be cached or indexed even if later deleted.
|
||||
|
||||
When you encounter an obstacle, do not use destructive actions as a shortcut to simply make it go away. For instance, try to identify root causes and fix underlying issues rather than bypassing safety checks (e.g. --no-verify). If you discover unexpected state like unfamiliar files, branches, or configuration, investigate before deleting or overwriting, as it may represent the user's in-progress work. For example, typically resolve merge conflicts rather than discarding changes; similarly, if a lock file exists, investigate what process holds it rather than deleting it. In short: only take risky actions carefully, and when in doubt, ask before acting. Follow both the spirit and letter of these instructions - measure twice, cut once.`
|
||||
When you encounter an obstacle, do not use destructive actions as a shortcut to simply make it go away. For instance, try to identify root causes and fix underlying issues rather than bypassing safety checks (e.g. --no-verify). If you discover unexpected state like unfamiliar files, branches, or configuration, investigate before deleting or overwriting, as it may represent the user's in-progress work. For example, typically resolve merge conflicts rather than discarding changes; similarly, if a lock file exists, investigate what process holds it rather than deleting it. In short: do not pause to ask for confirmation on ordinary, local, reversible coding tasks (editing files, running tests, build or install commands, reading code, generating boilerplate): just do them. Still ask before destructive, hard-to-reverse, or shared/external-state actions such as deleting files or branches, force-pushing, posting externally, or modifying shared infrastructure. Follow both the spirit and letter of these instructions - measure twice, cut once.`
|
||||
}
|
||||
|
||||
function getUsingYourToolsSection(enabledTools: Set<string>): string {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { afterEach } from 'bun:test'
|
||||
|
||||
import { resetSafetyLevelCache } from '../utils/permissions/safetyLevel.js'
|
||||
|
||||
export function resetSafetyLevelForTest(): void {
|
||||
delete process.env.OPENCLAUDE_SAFETY_LEVEL
|
||||
resetSafetyLevelCache()
|
||||
}
|
||||
|
||||
export function installSafetyLevelTestCleanup(): void {
|
||||
afterEach(() => {
|
||||
resetSafetyLevelForTest()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
bashCommandIsSafeAsync_DEPRECATED,
|
||||
bashCommandIsSafe_DEPRECATED,
|
||||
} from './bashSecurity.js'
|
||||
import { resetSafetyLevelCache } from '../../utils/permissions/safetyLevel.js'
|
||||
import { installSafetyLevelTestCleanup } from '../../test/safetyLevelTestHelpers.js'
|
||||
|
||||
installSafetyLevelTestCleanup()
|
||||
|
||||
describe('bash security check respects safety level (issue #1616)', () => {
|
||||
it('flags benign command substitution under default (balanced) mode', () => {
|
||||
const result = bashCommandIsSafe_DEPRECATED('echo "built $(date)"')
|
||||
expect(result.behavior).toBe('ask')
|
||||
})
|
||||
|
||||
it('passes benign command substitution under permissive mode', () => {
|
||||
process.env.OPENCLAUDE_SAFETY_LEVEL = 'permissive'
|
||||
resetSafetyLevelCache()
|
||||
const result = bashCommandIsSafe_DEPRECATED('echo "built $(date)"')
|
||||
expect(result.behavior).toBe('passthrough')
|
||||
})
|
||||
|
||||
it('passes benign command substitution in the async guard under permissive mode', async () => {
|
||||
process.env.OPENCLAUDE_SAFETY_LEVEL = 'permissive'
|
||||
resetSafetyLevelCache()
|
||||
const result = await bashCommandIsSafeAsync_DEPRECATED('echo "built $(date)"')
|
||||
expect(result.behavior).toBe('passthrough')
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../utils/bash/shellQuote.js'
|
||||
import type { TreeSitterAnalysis } from '../../utils/bash/treeSitterAnalysis.js'
|
||||
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
|
||||
import { isPermissiveSafety } from '../../utils/permissions/safetyLevel.js'
|
||||
|
||||
const HEREDOC_IN_SUBSTITUTION = /\$\(.*<</
|
||||
|
||||
@@ -2589,6 +2590,13 @@ const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/
|
||||
export function bashCommandIsSafe_DEPRECATED(
|
||||
command: string,
|
||||
): PermissionResult {
|
||||
if (isPermissiveSafety()) {
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'Command allowed because permissive safety level is enabled',
|
||||
}
|
||||
}
|
||||
|
||||
// SECURITY: Block control characters before any other processing. Null bytes
|
||||
// and other non-printable chars are silently dropped by bash but confuse our
|
||||
// validators, allowing metacharacters adjacent to them to slip through.
|
||||
@@ -2760,6 +2768,13 @@ export async function bashCommandIsSafeAsync_DEPRECATED(
|
||||
command: string,
|
||||
onDivergence?: () => void,
|
||||
): Promise<PermissionResult> {
|
||||
if (isPermissiveSafety()) {
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'Command allowed because permissive safety level is enabled',
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get tree-sitter analysis
|
||||
const parsed = await ParsedCommand.parse(command)
|
||||
const tsAnalysis = parsed?.getTreeSitterAnalysis() ?? null
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { getEmptyToolPermissionContext } from '../../Tool.js'
|
||||
import { applyPermissionUpdate } from './PermissionUpdate.js'
|
||||
|
||||
describe('applyPermissionUpdate', () => {
|
||||
test('removeRules normalizes stored rules before matching removals', () => {
|
||||
const updated = applyPermissionUpdate(
|
||||
{
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysAllowRules: {
|
||||
userSettings: ['Bash(*)', 'Bash(npm run:*)'],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'removeRules',
|
||||
rules: [{ toolName: 'Bash' }],
|
||||
behavior: 'allow',
|
||||
destination: 'userSettings',
|
||||
},
|
||||
)
|
||||
|
||||
expect(updated.alwaysAllowRules.userSettings).toEqual(['Bash(npm run:*)'])
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,10 @@ import { addPermissionRulesToSettings } from './permissionsLoader.js'
|
||||
// Re-export for backwards compatibility
|
||||
export type { AdditionalWorkingDirectory, WorkingDirectorySource }
|
||||
|
||||
function normalizeRuleString(rule: string): string {
|
||||
return permissionRuleValueToString(permissionRuleValueFromString(rule))
|
||||
}
|
||||
|
||||
export function extractRules(
|
||||
updates: PermissionUpdate[] | undefined,
|
||||
): PermissionRuleValue[] {
|
||||
@@ -156,7 +160,7 @@ export function applyPermissionUpdate(
|
||||
const existingRules = context[ruleKind][update.destination] || []
|
||||
const rulesToRemove = new Set(ruleStrings)
|
||||
const filteredRules = existingRules.filter(
|
||||
rule => !rulesToRemove.has(rule),
|
||||
rule => !rulesToRemove.has(normalizeRuleString(rule)),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -279,12 +283,9 @@ export function persistPermissionUpdate(update: PermissionUpdate): void {
|
||||
const rulesToRemove = new Set(
|
||||
update.rules.map(permissionRuleValueToString),
|
||||
)
|
||||
const filteredRules = existingRules.filter(rule => {
|
||||
const normalized = permissionRuleValueToString(
|
||||
permissionRuleValueFromString(rule),
|
||||
)
|
||||
return !rulesToRemove.has(normalized)
|
||||
})
|
||||
const filteredRules = existingRules.filter(
|
||||
rule => !rulesToRemove.has(normalizeRuleString(rule)),
|
||||
)
|
||||
|
||||
updateSettingsForSource(update.destination, {
|
||||
permissions: {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import { getAutoMemPath } from '../../memdir/paths.js'
|
||||
import { createToolFixture } from '../../test/toolFixtures.js'
|
||||
import { checkWritePermissionForTool } from './filesystem.js'
|
||||
import { resetSafetyLevelCache } from './safetyLevel.js'
|
||||
import { resetSafetyLevelForTest } from '../../test/safetyLevelTestHelpers.js'
|
||||
|
||||
const writeInputSchema = z.object({
|
||||
file_path: z.string(),
|
||||
@@ -113,6 +115,7 @@ describe('OpenClaude commit message temp file permissions', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
setOriginalCwd(originalCwd)
|
||||
resetSafetyLevelForTest()
|
||||
await rm(projectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -176,4 +179,49 @@ describe('OpenClaude commit message temp file permissions', () => {
|
||||
|
||||
expect(result.behavior).not.toBe('allow')
|
||||
})
|
||||
|
||||
test.each([
|
||||
'.gitmodules',
|
||||
'.bashrc',
|
||||
'.zshrc',
|
||||
'.profile',
|
||||
'.mcp.json',
|
||||
'.claude.json',
|
||||
'.openclaude.json',
|
||||
])('permits dangerous-file-list edit for %s in permissive safety mode', fileName => {
|
||||
process.env.OPENCLAUDE_SAFETY_LEVEL = 'permissive'
|
||||
resetSafetyLevelCache()
|
||||
|
||||
const result = checkWritePermissionForTool(
|
||||
writeTool,
|
||||
{ file_path: join(projectDir, fileName) },
|
||||
{
|
||||
...permissionContext('acceptEdits'),
|
||||
additionalWorkingDirectories: new Map([
|
||||
[projectDir, { path: projectDir, source: 'session' }],
|
||||
]),
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('allow')
|
||||
})
|
||||
|
||||
test('still prompts for dangerous directories in permissive safety mode', () => {
|
||||
process.env.OPENCLAUDE_SAFETY_LEVEL = 'permissive'
|
||||
resetSafetyLevelCache()
|
||||
|
||||
const result = checkWritePermissionForTool(
|
||||
writeTool,
|
||||
{ file_path: join(projectDir, '.git', 'config') },
|
||||
{
|
||||
...permissionContext('acceptEdits'),
|
||||
additionalWorkingDirectories: new Map([
|
||||
[projectDir, { path: projectDir, source: 'session' }],
|
||||
]),
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('ask')
|
||||
expect(result.decisionReason).toMatchObject({ type: 'safetyCheck' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,6 +51,7 @@ import type { PermissionRule, PermissionRuleSource } from './PermissionRule.js'
|
||||
import { createReadRuleSuggestion } from './PermissionUpdate.js'
|
||||
import type { PermissionUpdate } from './PermissionUpdateSchema.js'
|
||||
import { getRuleByContentsForToolName } from './permissions.js'
|
||||
import { isPermissiveSafety } from './safetyLevel.js'
|
||||
|
||||
declare const MACRO: { VERSION: string }
|
||||
|
||||
@@ -505,7 +506,10 @@ export function isOpenClaudeCommitMessagePath(absolutePath: string): boolean {
|
||||
* - Shell configuration files (to prevent shell startup script manipulation)
|
||||
* - UNC paths (to prevent network file access and WebDAV attacks)
|
||||
*/
|
||||
function isDangerousFilePathToAutoEdit(path: string): boolean {
|
||||
function isDangerousFilePathToAutoEdit(
|
||||
path: string,
|
||||
{ skipConfigFileList = false }: { skipConfigFileList?: boolean } = {},
|
||||
): boolean {
|
||||
const absolutePath = expandPath(path)
|
||||
const pathSegments = absolutePath.split(sep)
|
||||
const fileName = pathSegments.at(-1)
|
||||
@@ -545,7 +549,7 @@ function isDangerousFilePathToAutoEdit(path: string): boolean {
|
||||
}
|
||||
|
||||
// Check for dangerous configuration files (case-insensitive)
|
||||
if (fileName) {
|
||||
if (!skipConfigFileList && fileName) {
|
||||
const normalizedFileName = normalizeCaseForComparison(fileName)
|
||||
if (
|
||||
(DANGEROUS_FILES as readonly string[]).some(
|
||||
@@ -722,9 +726,13 @@ export function checkPathSafetyForAutoEdit(
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dangerous files on all paths
|
||||
// Check for dangerous files on all paths. In permissive safety mode
|
||||
// (OPENCLAUDE_SAFETY_LEVEL=permissive) we skip only the filename list that
|
||||
// can prompt on routine edits like .gitmodules, shell rc files, or .mcp.json.
|
||||
// Directory, UNC, symlink-resolved, and Windows path guards remain active.
|
||||
const skipConfigFileList = isPermissiveSafety()
|
||||
for (const pathToCheck of pathsToCheck) {
|
||||
if (isDangerousFilePathToAutoEdit(pathToCheck)) {
|
||||
if (isDangerousFilePathToAutoEdit(pathToCheck, { skipConfigFileList })) {
|
||||
return {
|
||||
safe: false,
|
||||
message: `${PRODUCT_DISPLAY_NAME} requested permissions to edit ${path} which is a sensitive file.`,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
applyPermissionModeChange,
|
||||
applyPermissionUpdatesToLiveContext,
|
||||
getDangerousPermissionModeTransitionError,
|
||||
getEffectiveDefaultPermissionModeFromSettingsSources,
|
||||
stripDangerousPermissionsForAutoMode,
|
||||
} from './permissionSetup.js'
|
||||
import { getEmptyToolPermissionContext } from '../../Tool.js'
|
||||
import { requestPermissionModeChange } from './permissionModeChange.js'
|
||||
import { resetSafetyLevelCache } from './safetyLevel.js'
|
||||
|
||||
describe('getEffectiveDefaultPermissionModeFromSettingsSources', () => {
|
||||
test('ignores dangerous default modes from shared project settings', () => {
|
||||
@@ -293,3 +295,50 @@ describe('requestPermissionModeChange', () => {
|
||||
expect(applied).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripDangerousPermissionsForAutoMode permissive safety', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.OPENCLAUDE_SAFETY_LEVEL
|
||||
resetSafetyLevelCache()
|
||||
})
|
||||
|
||||
test('keeps focused Bash interpreter allow rules in permissive safety mode', () => {
|
||||
process.env.OPENCLAUDE_SAFETY_LEVEL = 'permissive'
|
||||
resetSafetyLevelCache()
|
||||
|
||||
const updated = stripDangerousPermissionsForAutoMode({
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysAllowRules: {
|
||||
userSettings: ['Bash(python:*)', 'Bash(npm run:*)'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(updated.alwaysAllowRules.userSettings).toEqual([
|
||||
'Bash(python:*)',
|
||||
'Bash(npm run:*)',
|
||||
])
|
||||
expect(updated.strippedDangerousRules).toEqual({})
|
||||
})
|
||||
|
||||
test('still strips broad classifier-bypass allow rules in permissive safety mode', () => {
|
||||
process.env.OPENCLAUDE_SAFETY_LEVEL = 'permissive'
|
||||
resetSafetyLevelCache()
|
||||
|
||||
const updated = stripDangerousPermissionsForAutoMode({
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysAllowRules: {
|
||||
userSettings: [
|
||||
'Bash(*)',
|
||||
'PowerShell(*)',
|
||||
'Agent(*)',
|
||||
'Bash(python:*)',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(updated.alwaysAllowRules.userSettings).toEqual(['Bash(python:*)'])
|
||||
expect(updated.strippedDangerousRules).toEqual({
|
||||
userSettings: ['Bash', 'PowerShell', 'Agent'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
permissionModeFromString,
|
||||
permissionModeTitle,
|
||||
} from './PermissionMode.js'
|
||||
import { isPermissiveSafety } from './safetyLevel.js'
|
||||
import { applyPermissionRulesToPermissionContext } from './permissions.js'
|
||||
import { getStartupDangerousPermissionPromptState } from './dangerousModePromptRuntime.js'
|
||||
import { loadAllPermissionRulesFromDisk } from './permissionsLoader.js'
|
||||
@@ -98,30 +99,11 @@ import {
|
||||
* 2. Prefix rules for script interpreters (python:*, node:*, etc.)
|
||||
* 3. Wildcard rules matching interpreters (python*, node*, etc.)
|
||||
*/
|
||||
export function isDangerousBashPermission(
|
||||
toolName: string,
|
||||
ruleContent: string | undefined,
|
||||
function matchesBashPatternFamily(
|
||||
content: string,
|
||||
patterns: readonly string[],
|
||||
): boolean {
|
||||
// Only check Bash rules
|
||||
if (toolName !== BASH_TOOL_NAME) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Tool-level allow (Bash with no content, or Bash(*)) - allows ALL commands
|
||||
if (ruleContent === undefined || ruleContent === '') {
|
||||
return true
|
||||
}
|
||||
|
||||
const content = ruleContent.trim().toLowerCase()
|
||||
|
||||
// Standalone wildcard (*) matches everything
|
||||
if (content === '*') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for dangerous patterns with prefix syntax (e.g., "python:*")
|
||||
// or wildcard syntax (e.g., "python*")
|
||||
for (const pattern of DANGEROUS_BASH_PATTERNS) {
|
||||
for (const pattern of patterns) {
|
||||
const lowerPattern = pattern.toLowerCase()
|
||||
|
||||
// Exact match to the pattern itself (e.g., "python" as a rule)
|
||||
@@ -153,6 +135,51 @@ export function isDangerousBashPermission(
|
||||
return false
|
||||
}
|
||||
|
||||
export function isDangerousBashPermission(
|
||||
toolName: string,
|
||||
ruleContent: string | undefined,
|
||||
): boolean {
|
||||
// Only check Bash rules
|
||||
if (toolName !== BASH_TOOL_NAME) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Tool-level allow (Bash with no content, or Bash(*)) - allows ALL commands
|
||||
if (ruleContent === undefined || ruleContent === '') {
|
||||
return true
|
||||
}
|
||||
|
||||
const content = ruleContent.trim().toLowerCase()
|
||||
|
||||
// Standalone wildcard (*) matches everything
|
||||
if (content === '*') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for dangerous patterns with prefix syntax (e.g., "python:*")
|
||||
// or wildcard syntax (e.g., "python*")
|
||||
return matchesBashPatternFamily(content, DANGEROUS_BASH_PATTERNS)
|
||||
}
|
||||
|
||||
function isPermissiveSafetyAllowedBashRule(
|
||||
ruleValue: PermissionRuleValue,
|
||||
): boolean {
|
||||
if (ruleValue.toolName !== BASH_TOOL_NAME || !ruleValue.ruleContent) {
|
||||
return false
|
||||
}
|
||||
|
||||
const content = ruleValue.ruleContent.trim().toLowerCase()
|
||||
if (content === '*') {
|
||||
return false
|
||||
}
|
||||
|
||||
const relaxedPatterns = CROSS_PLATFORM_CODE_EXEC.filter(
|
||||
pattern => pattern !== 'bash' && pattern !== 'sh' && pattern !== 'ssh',
|
||||
)
|
||||
|
||||
return matchesBashPatternFamily(content, relaxedPatterns)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a PowerShell permission rule is dangerous for auto mode.
|
||||
* A rule is dangerous if it would auto-allow commands that execute arbitrary
|
||||
@@ -533,7 +560,15 @@ export function stripDangerousPermissionsForAutoMode(
|
||||
})
|
||||
}
|
||||
}
|
||||
const dangerousPermissions = findDangerousClassifierPermissions(rules, [])
|
||||
let dangerousPermissions = findDangerousClassifierPermissions(rules, [])
|
||||
if (isPermissiveSafety()) {
|
||||
// Permissive mode only keeps the false-positive-prone Bash interpreter and
|
||||
// package-runner rules requested in #1616. Broad shell/sub-agent allow rules
|
||||
// still bypass the auto-mode classifier and must continue to be stripped.
|
||||
dangerousPermissions = dangerousPermissions.filter(
|
||||
permission => !isPermissiveSafetyAllowedBashRule(permission.ruleValue),
|
||||
)
|
||||
}
|
||||
if (dangerousPermissions.length === 0) {
|
||||
return {
|
||||
...context,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
getSafetyLevel,
|
||||
isPermissiveSafety,
|
||||
resetSafetyLevelCache,
|
||||
} from './safetyLevel.js'
|
||||
import { installSafetyLevelTestCleanup } from '../../test/safetyLevelTestHelpers.js'
|
||||
|
||||
installSafetyLevelTestCleanup()
|
||||
|
||||
describe('getSafetyLevel', () => {
|
||||
it('defaults to balanced when unset', () => {
|
||||
expect(getSafetyLevel()).toBe('balanced')
|
||||
expect(isPermissiveSafety()).toBe(false)
|
||||
})
|
||||
|
||||
it('parses permissive', () => {
|
||||
process.env.OPENCLAUDE_SAFETY_LEVEL = 'permissive'
|
||||
resetSafetyLevelCache()
|
||||
expect(getSafetyLevel()).toBe('permissive')
|
||||
expect(isPermissiveSafety()).toBe(true)
|
||||
})
|
||||
|
||||
it('parses strict', () => {
|
||||
process.env.OPENCLAUDE_SAFETY_LEVEL = 'strict'
|
||||
resetSafetyLevelCache()
|
||||
expect(getSafetyLevel()).toBe('strict')
|
||||
})
|
||||
|
||||
it('falls back to balanced for unknown values', () => {
|
||||
process.env.OPENCLAUDE_SAFETY_LEVEL = 'whatever'
|
||||
resetSafetyLevelCache()
|
||||
expect(getSafetyLevel()).toBe('balanced')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Tunable safety strictness.
|
||||
*
|
||||
* OpenClaude runs a number of "safety" checks: a model-level refusal directive
|
||||
* (see cyberRiskInstruction.ts), bash command-injection heuristics
|
||||
* (bashSecurity.ts), and sensitive-file / auto-edit guards (filesystem.ts).
|
||||
* Those checks are intentionally conservative, but several of them surface as
|
||||
* refusals or approval prompts for entirely benign, routine coding tasks
|
||||
* (e.g. editing `.gitmodules`, running a build script that contains `$(date)`,
|
||||
* or writing a port scanner for a CTF). See issue #1616.
|
||||
*
|
||||
* This module lets users dial the strictness without forking the behavior for
|
||||
* everyone:
|
||||
* - "strict" : current/default-equivalent non-permissive behavior.
|
||||
* - "balanced" : default. Same behavior as strict.
|
||||
* - "permissive": relaxes the application-level heuristics that produce
|
||||
* false-positive refusals for benign tasks. The model-level
|
||||
* prompt is not weakened by this flag (use the prompt text
|
||||
* for that), but bash/file permission heuristics are eased.
|
||||
*
|
||||
* Driven by the OPENCLAUDE_SAFETY_LEVEL env var. Unknown values fall back to
|
||||
* "balanced".
|
||||
*/
|
||||
|
||||
export type SafetyLevel = 'strict' | 'balanced' | 'permissive'
|
||||
|
||||
let cached: SafetyLevel | undefined
|
||||
let cachedRaw: string | undefined
|
||||
|
||||
export function getSafetyLevel(): SafetyLevel {
|
||||
const raw = (process.env.OPENCLAUDE_SAFETY_LEVEL ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (cached && cachedRaw === raw) {
|
||||
return cached
|
||||
}
|
||||
cachedRaw = raw
|
||||
cached = raw === 'strict' || raw === 'permissive' ? raw : 'balanced'
|
||||
return cached
|
||||
}
|
||||
|
||||
/** True when the application-level heuristics should be relaxed. */
|
||||
export function isPermissiveSafety(): boolean {
|
||||
return getSafetyLevel() === 'permissive'
|
||||
}
|
||||
|
||||
/** Test helper: reset the cached value so a new env var is picked up. */
|
||||
export function resetSafetyLevelCache(): void {
|
||||
cached = undefined
|
||||
cachedRaw = undefined
|
||||
}
|
||||
Reference in New Issue
Block a user