Skip to content

Commit 8868b34

Browse files
authored
refactor(core): delegate sandbox denial parsing to SandboxManager (#23928)
1 parent 73dd732 commit 8868b34

File tree

10 files changed

+270
-146
lines changed

10 files changed

+270
-146
lines changed

packages/core/src/policy/policy-engine.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ describe('PolicyEngine', () => {
375375
isKnownSafeCommand: vi
376376
.fn()
377377
.mockImplementation((args) => args[0] === 'npm'),
378+
parseDenials: vi.fn().mockReturnValue(undefined),
378379
} as unknown as SandboxManager;
379380

380381
engine = new PolicyEngine({

packages/core/src/sandbox/linux/LinuxSandboxManager.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import {
1616
GOVERNANCE_FILES,
1717
getSecretFileFindArgs,
1818
sanitizePaths,
19+
type ParsedSandboxDenial,
1920
} from '../../services/sandboxManager.js';
21+
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
2022
import {
2123
sanitizeEnvironment,
2224
getSecureSanitizationConfig,
@@ -38,6 +40,7 @@ import {
3840
isKnownSafeCommand,
3941
isDangerousCommand,
4042
} from '../utils/commandSafety.js';
43+
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
4144

4245
let cachedBpfPath: string | undefined;
4346

@@ -154,6 +157,10 @@ export class LinuxSandboxManager implements SandboxManager {
154157
return isDangerousCommand(args);
155158
}
156159

160+
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
161+
return parsePosixSandboxDenials(result);
162+
}
163+
157164
private getMaskFilePath(): string {
158165
if (
159166
LinuxSandboxManager.maskFilePath &&

packages/core/src/sandbox/macos/MacOsSandboxManager.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
type SandboxedCommand,
1111
type SandboxPermissions,
1212
type GlobalSandboxOptions,
13+
type ParsedSandboxDenial,
1314
} from '../../services/sandboxManager.js';
15+
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
1416
import {
1517
sanitizeEnvironment,
1618
getSecureSanitizationConfig,
@@ -27,6 +29,7 @@ import {
2729
} from '../utils/commandSafety.js';
2830
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
2931
import { verifySandboxOverrides } from '../utils/commandUtils.js';
32+
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
3033

3134
export interface MacOsSandboxOptions extends GlobalSandboxOptions {
3235
/** The current sandbox mode behavior from config. */
@@ -59,6 +62,10 @@ export class MacOsSandboxManager implements SandboxManager {
5962
return isDangerousCommand(args);
6063
}
6164

65+
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
66+
return parsePosixSandboxDenials(result);
67+
}
68+
6269
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
6370
await initializeShellParsers();
6471
const sanitizationConfig = getSecureSanitizationConfig(
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect } from 'vitest';
8+
import { parsePosixSandboxDenials } from './sandboxDenialUtils.js';
9+
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
10+
11+
describe('parsePosixSandboxDenials', () => {
12+
it('should detect file system denial and extract paths', () => {
13+
const parsed = parsePosixSandboxDenials({
14+
output: 'ls: /root: Operation not permitted',
15+
} as unknown as ShellExecutionResult);
16+
expect(parsed).toBeDefined();
17+
expect(parsed?.filePaths).toContain('/root');
18+
});
19+
20+
it('should detect network denial', () => {
21+
const parsed = parsePosixSandboxDenials({
22+
output: 'curl: (6) Could not resolve host: google.com',
23+
} as unknown as ShellExecutionResult);
24+
expect(parsed).toBeDefined();
25+
expect(parsed?.network).toBe(true);
26+
});
27+
28+
it('should use fallback heuristic for absolute paths', () => {
29+
const parsed = parsePosixSandboxDenials({
30+
output:
31+
'operation not permitted\nsome error happened with /some/path/to/file',
32+
} as unknown as ShellExecutionResult);
33+
expect(parsed).toBeDefined();
34+
expect(parsed?.filePaths).toContain('/some/path/to/file');
35+
});
36+
37+
it('should return undefined if no denial detected', () => {
38+
const parsed = parsePosixSandboxDenials({
39+
output: 'hello world',
40+
} as unknown as ShellExecutionResult);
41+
expect(parsed).toBeUndefined();
42+
});
43+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { type ParsedSandboxDenial } from '../../services/sandboxManager.js';
8+
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
9+
10+
/**
11+
* Common POSIX-style sandbox denial detection.
12+
* Used by macOS and Linux sandbox managers.
13+
*/
14+
export function parsePosixSandboxDenials(
15+
result: ShellExecutionResult,
16+
): ParsedSandboxDenial | undefined {
17+
const output = result.output || '';
18+
const errorOutput = result.error?.message;
19+
const combined = (output + ' ' + (errorOutput || '')).toLowerCase();
20+
21+
const isFileDenial = [
22+
'operation not permitted',
23+
'vim:e303',
24+
'should be read/write',
25+
'sandbox_apply',
26+
'sandbox: ',
27+
].some((keyword) => combined.includes(keyword));
28+
29+
const isNetworkDenial = [
30+
'error connecting to',
31+
'network is unreachable',
32+
'could not resolve host',
33+
'connection refused',
34+
'no address associated with hostname',
35+
].some((keyword) => combined.includes(keyword));
36+
37+
if (!isFileDenial && !isNetworkDenial) {
38+
return undefined;
39+
}
40+
41+
const filePaths = new Set<string>();
42+
43+
// Extract denied paths (POSIX absolute paths)
44+
const regex =
45+
/(?:^|\s)['"]?(\/[\w.-/]+)['"]?:\s*[Oo]peration not permitted/gi;
46+
let match;
47+
while ((match = regex.exec(output)) !== null) {
48+
filePaths.add(match[1]);
49+
}
50+
if (errorOutput) {
51+
while ((match = regex.exec(errorOutput)) !== null) {
52+
filePaths.add(match[1]);
53+
}
54+
}
55+
56+
// Fallback heuristic: look for any absolute path in the output if it was a file denial
57+
if (isFileDenial && filePaths.size === 0) {
58+
const fallbackRegex =
59+
/(?:^|[\s"'[\]])(\/[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+)(?:$|[\s"'[\]:])/gi;
60+
let m;
61+
while ((m = fallbackRegex.exec(output)) !== null) {
62+
const p = m[1];
63+
if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) {
64+
filePaths.add(p);
65+
}
66+
}
67+
if (errorOutput) {
68+
while ((m = fallbackRegex.exec(errorOutput)) !== null) {
69+
const p = m[1];
70+
if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) {
71+
filePaths.add(p);
72+
}
73+
}
74+
}
75+
}
76+
77+
return {
78+
network: isNetworkDenial || undefined,
79+
filePaths: filePaths.size > 0 ? Array.from(filePaths) : undefined,
80+
};
81+
}

packages/core/src/sandbox/windows/WindowsSandboxManager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import {
1818
sanitizePaths,
1919
tryRealpath,
2020
type SandboxPermissions,
21+
type ParsedSandboxDenial,
2122
} from '../../services/sandboxManager.js';
23+
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
2224
import {
2325
sanitizeEnvironment,
2426
getSecureSanitizationConfig,
@@ -77,6 +79,10 @@ export class WindowsSandboxManager implements SandboxManager {
7779
return isDangerousCommand(args);
7880
}
7981

82+
parseDenials(_result: ShellExecutionResult): ParsedSandboxDenial | undefined {
83+
return undefined; // TODO: Implement Windows-specific denial parsing
84+
}
85+
8086
/**
8187
* Ensures a file or directory exists.
8288
*/

packages/core/src/services/sandboxManager.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
getSecureSanitizationConfig,
2222
type EnvironmentSanitizationConfig,
2323
} from './environmentSanitization.js';
24-
24+
import type { ShellExecutionResult } from './shellExecutionService.js';
2525
export interface SandboxPermissions {
2626
/** Filesystem permissions. */
2727
fileSystem?: {
@@ -91,6 +91,16 @@ export interface SandboxedCommand {
9191
cwd?: string;
9292
}
9393

94+
/**
95+
* A structured result from parsing sandbox denials.
96+
*/
97+
export interface ParsedSandboxDenial {
98+
/** If the denial is related to file system access, these are the paths that were blocked. */
99+
filePaths?: string[];
100+
/** If the denial is related to network access. */
101+
network?: boolean;
102+
}
103+
94104
/**
95105
* Interface for a service that prepares commands for sandboxed execution.
96106
*/
@@ -109,6 +119,11 @@ export interface SandboxManager {
109119
* Checks if a command with its arguments is explicitly known to be dangerous for this sandbox.
110120
*/
111121
isDangerousCommand(args: string[]): boolean;
122+
123+
/**
124+
* Parses the output of a command to detect sandbox denials.
125+
*/
126+
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined;
112127
}
113128

114129
/**
@@ -236,10 +251,14 @@ export class NoopSandboxManager implements SandboxManager {
236251
? isWindowsDangerousCommand(args)
237252
: isMacDangerousCommand(args);
238253
}
254+
255+
parseDenials(): undefined {
256+
return undefined;
257+
}
239258
}
240259

241260
/**
242-
* SandboxManager that implements actual sandboxing.
261+
* A SandboxManager implementation that just runs locally (no sandboxing yet).
243262
*/
244263
export class LocalSandboxManager implements SandboxManager {
245264
async prepareCommand(_req: SandboxRequest): Promise<SandboxedCommand> {
@@ -253,6 +272,10 @@ export class LocalSandboxManager implements SandboxManager {
253272
isDangerousCommand(_args: string[]): boolean {
254273
return false;
255274
}
275+
276+
parseDenials(): undefined {
277+
return undefined;
278+
}
256279
}
257280

258281
/**

packages/core/src/services/sandboxedFileSystemService.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ class MockSandboxManager implements SandboxManager {
4343
isDangerousCommand(): boolean {
4444
return false;
4545
}
46+
47+
parseDenials(): undefined {
48+
return undefined;
49+
}
4650
}
4751

4852
describe('SandboxedFileSystemService', () => {

packages/core/src/services/shellExecutionService.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1914,6 +1914,7 @@ describe('ShellExecutionService environment variables', () => {
19141914
}),
19151915
isKnownSafeCommand: vi.fn().mockReturnValue(false),
19161916
isDangerousCommand: vi.fn().mockReturnValue(false),
1917+
parseDenials: vi.fn().mockReturnValue(undefined),
19171918
};
19181919

19191920
const configWithSandbox: ShellExecutionConfig = {

0 commit comments

Comments
 (0)