Skip to content
8 changes: 7 additions & 1 deletion src/commands/commit-push-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getEnhancedPRAttribution,
} from '../utils/attribution.js'
import { getDefaultBranch } from '../utils/git.js'
import { getForbiddenCommitMessagePatterns } from '../utils/governancePolicy.js'
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js'

Expand All @@ -29,6 +30,11 @@ function getPromptContent(
): string {
const { commit: commitAttribution, pr: defaultPrAttribution } =
getAttributionTexts()
const forbiddenPatterns = getForbiddenCommitMessagePatterns()
const forbiddenPatternText =
forbiddenPatterns.length > 0
? `\n- The commit message must not contain these forbidden patterns: ${forbiddenPatterns.map(p => `\`${p}\``).join(', ')}`
: ''
// Use provided PR attribution or fall back to default
const effectivePrAttribution = prAttribution ?? defaultPrAttribution
const safeUser = process.env.SAFEUSER || ''
Expand Down Expand Up @@ -71,7 +77,7 @@ function getPromptContent(
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
- NEVER run force push to main/master, warn the user if they request it
- Do not commit files that likely contain secrets (.env, credentials.json, etc)
- Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported
- Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported${forbiddenPatternText}

## Your task

Expand Down
8 changes: 7 additions & 1 deletion src/commands/commit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Command } from '../commands.js'
import { getAttributionTexts } from '../utils/attribution.js'
import { getForbiddenCommitMessagePatterns } from '../utils/governancePolicy.js'
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js'

Expand All @@ -11,6 +12,11 @@ const ALLOWED_TOOLS = [

function getPromptContent(): string {
const { commit: commitAttribution } = getAttributionTexts()
const forbiddenPatterns = getForbiddenCommitMessagePatterns()
const forbiddenPatternText =
forbiddenPatterns.length > 0
? `\n- The commit message must not contain these forbidden patterns: ${forbiddenPatterns.map(p => `\`${p}\``).join(', ')}`
: ''

let prefix = ''
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
Expand All @@ -31,7 +37,7 @@ function getPromptContent(): string {
- CRITICAL: ALWAYS create NEW commits. NEVER use git commit --amend, unless the user explicitly requests it
- Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
- Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported
- Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported${forbiddenPatternText}

## Your task

Expand Down
8 changes: 8 additions & 0 deletions src/components/ProviderManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import {
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'

type SettingsModule = typeof import('../utils/settings/settings.js')

const actualSettingsModule = (await import(
`../utils/settings/settings.ts?providerManagerSettingsActual=${Date.now()}-${Math.random()}`
)) as SettingsModule

const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'

Expand Down Expand Up @@ -411,6 +417,7 @@ function mockProviderManagerDependencies(
}))

mock.module('../utils/settings/settings.js', () => ({
...actualSettingsModule,
updateSettingsForSource: () => ({ error: null }),
}))

Expand Down Expand Up @@ -524,6 +531,7 @@ beforeEach(async () => {
afterEach(() => {
try {
mock.restore()
mock.module('../utils/settings/settings.js', () => actualSettingsModule)

for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) {
Expand Down
21 changes: 18 additions & 3 deletions src/memdir/memdir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { logForDebugging } from '../utils/debug.js'
import { hasEmbeddedSearchTools } from '../utils/embeddedTools.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import { formatFileSize } from '../utils/format.js'
import { isMemoryWriteApprovalRequired } from '../utils/governancePolicy.js'
import { getProjectDir } from '../utils/sessionStorage.js'
import { getInitialSettings } from '../utils/settings/settings.js'
import {
Expand Down Expand Up @@ -202,6 +203,10 @@ export function buildMemoryLines(
extraGuidelines?: string[],
skipIndex = false,
): string[] {
const requiresApproval = isMemoryWriteApprovalRequired()
const dirGuidance = requiresApproval
? 'Do not create or update files there until the user explicitly approves the specific memory write.'
: DIR_EXISTS_GUIDANCE
const howToSave = skipIndex
? [
'## How to save memories',
Expand Down Expand Up @@ -236,10 +241,16 @@ export function buildMemoryLines(
const lines: string[] = [
`# ${displayName}`,
'',
`You have a persistent, file-based memory system at \`${memoryDir}\`. ${DIR_EXISTS_GUIDANCE}`,
`You have a persistent, file-based memory system at \`${memoryDir}\`. ${dirGuidance}`,
'',
"You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.",
'',
...(requiresApproval
? [
'Before creating, updating, or deleting persistent memory files, explicitly ask the user for approval and wait for confirmation.',
'',
]
: []),
'If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.',
'',
...TYPES_SECTION_INDIVIDUAL,
Expand Down Expand Up @@ -456,7 +467,9 @@ export async function loadMemoryPrompt(): Promise<string | null> {
// creates the auto dir as a side effect. If the team dir ever moves
// out from under the auto dir, add a second ensureMemoryDirExists call
// for autoDir here.
await ensureMemoryDirExists(teamDir)
if (!isMemoryWriteApprovalRequired()) {
await ensureMemoryDirExists(teamDir)
}
logMemoryDirCounts(autoDir, {
memory_type:
'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
Expand All @@ -476,7 +489,9 @@ export async function loadMemoryPrompt(): Promise<string | null> {
const autoDir = getAutoMemPath()
// Harness guarantees the directory exists so the model can write without
// checking. The prompt text reflects this ("already exists").
await ensureMemoryDirExists(autoDir)
if (!isMemoryWriteApprovalRequired()) {
await ensureMemoryDirExists(autoDir)
}
logMemoryDirCounts(autoDir, {
memory_type:
'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
Expand Down
5 changes: 4 additions & 1 deletion src/memdir/paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { afterEach, beforeEach, expect, test, mock } from 'bun:test'
import { setAllowedSettingSources } from '../bootstrap/state.js'
import { SETTING_SOURCES } from '../utils/settings/constants.js'
import * as realSettings from '../utils/settings/settings.js'
import { isAutoMemoryEnabled } from './paths.ts'

const realSettings = (await import(
`../utils/settings/settings.js?pathsTestReal=${Date.now()}-${Math.random()}`
)) as typeof import('../utils/settings/settings.js')

// Pin issue #1326: `memory.autoWrite` is a discoverable alias for the legacy
// `autoMemoryEnabled` setting, and either key opts out for governance /
// regulated / client-sensitive repos. The opt-out is evaluated across the raw
Expand Down
13 changes: 12 additions & 1 deletion src/memdir/teamMemPrompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from './memoryTypes.js'
import { getAutoMemPath } from './paths.js'
import { getTeamMemPath } from './teamMemPaths.js'
import { isMemoryWriteApprovalRequired } from '../utils/governancePolicy.js'

/**
* Build the combined prompt when both auto memory and team memory are enabled.
Expand All @@ -25,6 +26,10 @@ export function buildCombinedMemoryPrompt(
): string {
const autoDir = getAutoMemPath()
const teamDir = getTeamMemPath()
const requiresApproval = isMemoryWriteApprovalRequired()
const dirGuidance = requiresApproval
? 'Do not create or update files there until the user explicitly approves the specific memory write.'
: DIRS_EXIST_GUIDANCE

const howToSave = skipIndex
? [
Expand Down Expand Up @@ -60,10 +65,16 @@ export function buildCombinedMemoryPrompt(
const lines = [
'# Memory',
'',
`You have a persistent, file-based memory system with two directories: a private directory at \`${autoDir}\` and a shared team directory at \`${teamDir}\`. ${DIRS_EXIST_GUIDANCE}`,
`You have a persistent, file-based memory system with two directories: a private directory at \`${autoDir}\` and a shared team directory at \`${teamDir}\`. ${dirGuidance}`,
'',
"You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.",
'',
...(requiresApproval
? [
'Before creating, updating, or deleting persistent memory files, explicitly ask the user for approval and wait for confirmation.',
'',
]
: []),
'If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.',
'',
'## Memory scope',
Expand Down
2 changes: 2 additions & 0 deletions src/services/autoDream/autoDream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { ToolUseContext } from '../../Tool.js'
import { logEvent } from '../analytics/index.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
import { isAutoMemoryEnabled, getAutoMemPath } from '../../memdir/paths.js'
import { isMemoryWriteApprovalRequired } from '../../utils/governancePolicy.js'
import { isAutoDreamEnabled } from './config.js'
import { getProjectDir } from '../../utils/sessionStorage.js'
import {
Expand Down Expand Up @@ -96,6 +97,7 @@ function isGateOpen(): boolean {
if (getKairosActive()) return false // KAIROS mode uses disk-skill dream
if (getIsRemoteMode()) return false
if (!isAutoMemoryEnabled()) return false
if (isMemoryWriteApprovalRequired()) return false
return isAutoDreamEnabled()
}

Expand Down
5 changes: 5 additions & 0 deletions src/services/extractMemories/extractMemories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type {
} from '../../types/message.js'
import { createAbortController } from '../../utils/abortController.js'
import { count, uniq } from '../../utils/array.js'
import { isMemoryWriteApprovalRequired } from '../../utils/governancePolicy.js'
import { logForDebugging } from '../../utils/debug.js'
import {
createCacheSafeParams,
Expand Down Expand Up @@ -546,6 +547,10 @@ export function initExtractMemories(): void {
return
}

if (isMemoryWriteApprovalRequired()) {
return
}

// Skip in remote mode
if (getIsRemoteMode()) {
return
Expand Down
131 changes: 131 additions & 0 deletions src/tools/BashTool/bashPermissions.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'

import { getEmptyToolPermissionContext } from '../../Tool.js'
import {
getOriginalCwd,
setAllowedSettingSources,
setOriginalCwd,
} from '../../bootstrap/state.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
import { SETTING_SOURCES } from '../../utils/settings/constants.js'
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
import {
_test,
Expand Down Expand Up @@ -183,6 +193,127 @@ test('checkSandboxAutoAllow caps fanout when astSubcommands is null', () => {
})
})

test('git commit governance policy runs through the production Bash permission path', async () => {
const originalCwd = getOriginalCwd()
const projectDir = await mkdtemp(join(tmpdir(), 'openclaude-git-policy-'))

try {
setOriginalCwd(projectDir)
setAllowedSettingSources([...SETTING_SOURCES])
await mkdir(join(projectDir, '.openclaude'), { recursive: true })
await writeFile(
join(projectDir, '.openclaude', 'settings.local.json'),
JSON.stringify({
git: { forbiddenCommitMessagePatterns: ['Generated with'] },
}),
)
resetSettingsCache()

const result = await bashToolHasPermission(
{ command: 'git commit -m "fix: policy\n\nGenerated with OpenClaude"' },
makeToolUseContext(),
)
const compoundResult = await bashToolHasPermission(
{
command:
'cd repo && git commit -m "fix: policy\n\nGenerated with OpenClaude"',
},
makeToolUseContext(),
)
const safeCommitThenEchoResult = await bashToolHasPermission(
{
command:
'git commit -m "safe" && echo -m "Generated with OpenClaude"',
},
makeToolUseContext(),
)
const commandWrappedResult = await bashToolHasPermission(
{
command:
'command git commit -m "fix: policy\n\nGenerated with OpenClaude"',
},
makeToolUseContext(),
)
const commandPathWrappedResult = await bashToolHasPermission(
{
command:
'command -p git commit -m "fix: policy\n\nGenerated with OpenClaude"',
},
makeToolUseContext(),
)
const envWrappedResult = await bashToolHasPermission(
{
command:
'env git commit -m "fix: policy\n\nGenerated with OpenClaude"',
},
makeToolUseContext(),
)
const envSplitStringWrappedResult = await bashToolHasPermission(
{
command:
'env -S \'git commit -m "fix: policy\n\nGenerated with OpenClaude"\'',
},
makeToolUseContext(),
)
const envSplitStringAssignmentWrappedResult = await bashToolHasPermission(
{
command:
'env -S \'GIT_AUTHOR_NAME=bot git commit -m "fix: policy\n\nGenerated with OpenClaude"\'',
},
makeToolUseContext(),
)

expect(result.behavior).toBe('ask')
expect(compoundResult.behavior).toBe('ask')
expect(safeCommitThenEchoResult.behavior).not.toBe('ask')
expect(commandWrappedResult.behavior).toBe('ask')
expect(commandPathWrappedResult.behavior).toBe('ask')
expect(envWrappedResult.behavior).toBe('ask')
expect(envSplitStringWrappedResult.behavior).toBe('ask')
expect(envSplitStringAssignmentWrappedResult.behavior).toBe('ask')
expect(result.decisionReason).toMatchObject({
type: 'safetyCheck',
reason: 'Git commit message contains forbidden pattern: Generated with',
classifierApprovable: false,
})
expect(compoundResult.decisionReason).toMatchObject({
type: 'safetyCheck',
reason: 'Git commit message contains forbidden pattern: Generated with',
classifierApprovable: false,
})
expect(commandWrappedResult.decisionReason).toMatchObject({
type: 'safetyCheck',
reason: 'Git commit message contains forbidden pattern: Generated with',
classifierApprovable: false,
})
expect(commandPathWrappedResult.decisionReason).toMatchObject({
type: 'safetyCheck',
reason: 'Git commit message contains forbidden pattern: Generated with',
classifierApprovable: false,
})
expect(envWrappedResult.decisionReason).toMatchObject({
type: 'safetyCheck',
reason: 'Git commit message contains forbidden pattern: Generated with',
classifierApprovable: false,
})
expect(envSplitStringWrappedResult.decisionReason).toMatchObject({
type: 'safetyCheck',
reason: 'Git commit message contains forbidden pattern: Generated with',
classifierApprovable: false,
})
expect(envSplitStringAssignmentWrappedResult.decisionReason).toMatchObject({
type: 'safetyCheck',
reason: 'Git commit message contains forbidden pattern: Generated with',
classifierApprovable: false,
})
} finally {
setOriginalCwd(originalCwd)
setAllowedSettingSources([...SETTING_SOURCES])
resetSettingsCache()
await rm(projectDir, { recursive: true, force: true })
}
})

// SEC-02 regression: array subscript with command substitution must NOT be stripped.
// Bash executes FOO[$(cmd)]=val as a side effect; if the pattern matched the
// subscript, the env-var prefix would be stripped while $(cmd) silently ran.
Expand Down
Loading