* fix(security): harden project settings trust boundary + MCP sanitization - Sanitize MCP tool result text with recursivelySanitizeUnicode() to prevent Unicode injection via malicious MCP servers (tool definitions and prompts were already sanitized, but tool call results were not) - Read sandbox.enabled only from trusted settings sources (user, local, flag, policy) — exclude projectSettings to prevent malicious repos from silently disabling the sandbox via .claude/settings.json - Disable git hooks in plugin marketplace clone/pull/submodule operations with core.hooksPath=/dev/null to prevent code execution from cloned repos - Remove ANTHROPIC_FOUNDRY_API_KEY from SAFE_ENV_VARS to prevent credential injection from project-scoped settings without trust verification - Add ssrfGuardedLookup to WebFetch HTTP requests to block DNS rebinding attacks that could reach cloud metadata or internal services Security: closes trust boundary gap where project settings could override security-critical configuration. Follows the existing pattern established by hasAllowBypassPermissionsMode() which already excludes projectSettings. Co-authored-by: auriti <auriti@users.noreply.github.com> * fix(security): remove unauthenticated file-based permission polling Remove the legacy file-based permission polling from useSwarmPermissionPoller that read from ~/.claude/teams/{name}/permissions/resolved/ — an unauthenticated directory where any local process could forge approval files to auto-approve tool uses for swarm teammates. The file polling was dead code: - The useSwarmPermissionPoller() hook was never mounted by any component - resolvePermission() (the file writer) was never imported outside its module - Permission responses are delivered exclusively via the mailbox system: Leader: sendPermissionResponseViaMailbox() → writeToMailbox() Worker: useInboxPoller → processMailboxPermissionResponse() Changes: - Remove file polling loop, processResponse(), and React hook imports from useSwarmPermissionPoller.ts (now a pure callback registry module) - Mark 7 file-based functions as @deprecated in permissionSync.ts - Add 4 regression tests verifying the removal No exported functions removed — only deprecated. All 5 consumer modules verified: they import only mailbox-based functions that remain unchanged. --------- Co-authored-by: auriti <auriti@users.noreply.github.com>
191 lines
8.0 KiB
TypeScript
191 lines
8.0 KiB
TypeScript
/**
|
|
* Security hardening regression tests.
|
|
*
|
|
* Covers:
|
|
* 1. MCP tool result Unicode sanitization
|
|
* 2. Sandbox settings source filtering (exclude projectSettings)
|
|
* 3. Plugin git clone/pull hooks disabled
|
|
* 4. ANTHROPIC_FOUNDRY_API_KEY removed from SAFE_ENV_VARS
|
|
* 5. WebFetch SSRF protection via ssrfGuardedLookup
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test'
|
|
import { resolve } from 'path'
|
|
|
|
const SRC = resolve(import.meta.dir, '..')
|
|
const file = (relative: string) => Bun.file(resolve(SRC, relative))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 1: MCP tool result Unicode sanitization
|
|
// ---------------------------------------------------------------------------
|
|
describe('MCP tool result sanitization', () => {
|
|
test('transformResultContent sanitizes text content', async () => {
|
|
const content = await file('services/mcp/client.ts').text()
|
|
// Tool definitions are already sanitized (line ~1798)
|
|
expect(content).toContain('recursivelySanitizeUnicode(result.tools)')
|
|
// Tool results must also be sanitized
|
|
expect(content).toMatch(
|
|
/case 'text':[\s\S]*?recursivelySanitizeUnicode\(resultContent\.text\)/,
|
|
)
|
|
})
|
|
|
|
test('resource text content is also sanitized', async () => {
|
|
const content = await file('services/mcp/client.ts').text()
|
|
expect(content).toMatch(
|
|
/recursivelySanitizeUnicode\(\s*`\$\{prefix\}\$\{resource\.text\}`/,
|
|
)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 2: Sandbox settings source filtering
|
|
// ---------------------------------------------------------------------------
|
|
describe('Sandbox settings trust boundary', () => {
|
|
test('getSandboxEnabledSetting does not use getSettings_DEPRECATED', async () => {
|
|
const content = await file('utils/sandbox/sandbox-adapter.ts').text()
|
|
// Extract the getSandboxEnabledSetting function body
|
|
const fnMatch = content.match(
|
|
/function getSandboxEnabledSetting\(\)[^{]*\{([\s\S]*?)\n\}/,
|
|
)
|
|
expect(fnMatch).not.toBeNull()
|
|
const fnBody = fnMatch![1]
|
|
// Must NOT use getSettings_DEPRECATED (reads all sources including project)
|
|
expect(fnBody).not.toContain('getSettings_DEPRECATED')
|
|
// Must use getSettingsForSource for individual trusted sources
|
|
expect(fnBody).toContain("getSettingsForSource('userSettings')")
|
|
expect(fnBody).toContain("getSettingsForSource('policySettings')")
|
|
// Must NOT read from projectSettings
|
|
expect(fnBody).not.toContain("'projectSettings'")
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 3: Plugin git hooks disabled
|
|
// ---------------------------------------------------------------------------
|
|
describe('Plugin git operations disable hooks', () => {
|
|
test('gitClone includes core.hooksPath=/dev/null', async () => {
|
|
const content = await file('utils/plugins/marketplaceManager.ts').text()
|
|
// The clone args must disable hooks
|
|
const cloneSection = content.slice(
|
|
content.indexOf('export async function gitClone('),
|
|
content.indexOf('export async function gitClone(') + 2000,
|
|
)
|
|
expect(cloneSection).toContain("'core.hooksPath=/dev/null'")
|
|
})
|
|
|
|
test('gitPull includes core.hooksPath=/dev/null', async () => {
|
|
const content = await file('utils/plugins/marketplaceManager.ts').text()
|
|
const pullSection = content.slice(
|
|
content.indexOf('export async function gitPull('),
|
|
content.indexOf('export async function gitPull(') + 2000,
|
|
)
|
|
expect(pullSection).toContain("'core.hooksPath=/dev/null'")
|
|
})
|
|
|
|
test('gitSubmoduleUpdate includes core.hooksPath=/dev/null', async () => {
|
|
const content = await file('utils/plugins/marketplaceManager.ts').text()
|
|
const subSection = content.slice(
|
|
content.indexOf('async function gitSubmoduleUpdate('),
|
|
content.indexOf('async function gitSubmoduleUpdate(') + 1000,
|
|
)
|
|
expect(subSection).toContain("'core.hooksPath=/dev/null'")
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 4: ANTHROPIC_FOUNDRY_API_KEY not in SAFE_ENV_VARS
|
|
// ---------------------------------------------------------------------------
|
|
describe('SAFE_ENV_VARS excludes credentials', () => {
|
|
test('ANTHROPIC_FOUNDRY_API_KEY is not in SAFE_ENV_VARS', async () => {
|
|
const content = await file('utils/managedEnvConstants.ts').text()
|
|
// Extract the SAFE_ENV_VARS set definition
|
|
const safeStart = content.indexOf('export const SAFE_ENV_VARS')
|
|
const safeEnd = content.indexOf('])', safeStart)
|
|
const safeSection = content.slice(safeStart, safeEnd)
|
|
expect(safeSection).not.toContain('ANTHROPIC_FOUNDRY_API_KEY')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 5: WebFetch SSRF protection
|
|
// ---------------------------------------------------------------------------
|
|
describe('WebFetch SSRF guard', () => {
|
|
test('getWithPermittedRedirects uses ssrfGuardedLookup', async () => {
|
|
const content = await file('tools/WebFetchTool/utils.ts').text()
|
|
expect(content).toContain(
|
|
"import { ssrfGuardedLookup } from '../../utils/hooks/ssrfGuard.js'",
|
|
)
|
|
// The axios.get call in getWithPermittedRedirects must include lookup
|
|
const fnSection = content.slice(
|
|
content.indexOf('export async function getWithPermittedRedirects('),
|
|
content.indexOf('export async function getWithPermittedRedirects(') +
|
|
1000,
|
|
)
|
|
expect(fnSection).toContain('lookup: ssrfGuardedLookup')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 6: Swarm permission file polling removed (security hardening)
|
|
// ---------------------------------------------------------------------------
|
|
describe('Swarm permission file polling removed', () => {
|
|
test('useSwarmPermissionPoller hook no longer exists', async () => {
|
|
const content = await file(
|
|
'hooks/useSwarmPermissionPoller.ts',
|
|
).text()
|
|
// The file-based polling hook must not exist — it read from an
|
|
// unauthenticated resolved/ directory where any local process could
|
|
// forge approval files.
|
|
expect(content).not.toContain('function useSwarmPermissionPoller(')
|
|
// The file-based processResponse must not exist
|
|
expect(content).not.toContain('function processResponse(')
|
|
})
|
|
|
|
test('poller does not import from permissionSync', async () => {
|
|
const content = await file(
|
|
'hooks/useSwarmPermissionPoller.ts',
|
|
).text()
|
|
// Must not import anything from permissionSync — all file-based
|
|
// functions have been removed from this module's dependencies
|
|
expect(content).not.toContain('permissionSync')
|
|
})
|
|
|
|
test('file-based permission functions are marked deprecated', async () => {
|
|
const content = await file(
|
|
'utils/swarm/permissionSync.ts',
|
|
).text()
|
|
// All file-based functions must have @deprecated JSDoc
|
|
const deprecatedFns = [
|
|
'writePermissionRequest',
|
|
'readPendingPermissions',
|
|
'readResolvedPermission',
|
|
'resolvePermission',
|
|
'pollForResponse',
|
|
'removeWorkerResponse',
|
|
]
|
|
for (const fn of deprecatedFns) {
|
|
// Find the function and check that @deprecated appears before it
|
|
const fnIndex = content.indexOf(`export async function ${fn}(`)
|
|
if (fnIndex === -1) continue // submitPermissionRequest is a const, not async function
|
|
const preceding = content.slice(Math.max(0, fnIndex - 500), fnIndex)
|
|
expect(preceding).toContain('@deprecated')
|
|
}
|
|
})
|
|
|
|
test('mailbox-based functions are NOT deprecated', async () => {
|
|
const content = await file(
|
|
'utils/swarm/permissionSync.ts',
|
|
).text()
|
|
// These are the active path — must not be deprecated
|
|
const activeFns = [
|
|
'sendPermissionRequestViaMailbox',
|
|
'sendPermissionResponseViaMailbox',
|
|
]
|
|
for (const fn of activeFns) {
|
|
const fnIndex = content.indexOf(`export async function ${fn}(`)
|
|
expect(fnIndex).not.toBe(-1)
|
|
const preceding = content.slice(Math.max(0, fnIndex - 300), fnIndex)
|
|
expect(preceding).not.toContain('@deprecated')
|
|
}
|
|
})
|
|
}) |