Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -687,8 +687,10 @@ function isProtectedRuntimeRead(
// at credentials must not turn its destination into a trusted asset root.
const readableAsset =
within(canonicalPath, path.join(canonicalRoot, 'skills')) ||
within(canonicalPath, path.join(canonicalRoot, '.builtin-skills')) ||
(context.agentName != null &&
within(canonicalPath, path.join(canonicalRoot, 'agents', context.agentName, 'workspace'))) ||
(within(canonicalPath, path.join(canonicalRoot, 'agents', context.agentName, 'workspace')) ||
within(canonicalPath, path.join(canonicalRoot, 'agents', context.agentName, 'skills')))) ||
isInternalWhitelistedPath(canonicalPath, {
...context,
dataDir: canonicalRoot,
Expand Down
19 changes: 15 additions & 4 deletions packages/local-runtime/src/permissions/facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,11 @@ export class LocalPermissionFacade {
rules,
workingDirectory,
allowedWorkingPaths: [...pluginContext.directories],
sandboxAllowPaths: this.resolveSandboxAllowPaths(params.toolName, workingDirectory),
sandboxAllowPaths: this.resolveSandboxAllowPaths(
params.toolName,
workingDirectory,
params.agentName,
),
trustedExactWritePaths: params.trustedExactWritePaths,
dataDir: this.deps.configGetter().dataDir,
homeDir: homedir(),
Expand Down Expand Up @@ -978,7 +982,11 @@ export class LocalPermissionFacade {
// -------------------------------------------------------------------------

/** Built-in skill assets may be read without granting access to private runtime state. */
private resolveSandboxAllowPaths(toolName: string, workingDirectory: string): string[] {
private resolveSandboxAllowPaths(
toolName: string,
workingDirectory: string,
agentName?: string,
): string[] {
// Read-only tool set parity with `READ_ONLY_TOOLS`
// (`packages/local-runtime/src/permission/tools/fs-permission.ts`).
// We don't import it directly to avoid coupling facade construction
Expand All @@ -989,8 +997,11 @@ export class LocalPermissionFacade {

const candidates: string[] = [];
const dataDir = this.deps.configGetter().dataDir;
if (dataDir) candidates.push(path.resolve(dataDir, 'skills'));
candidates.push(path.resolve(homedir(), '.minimax', 'skills'));
for (const root of [dataDir, path.join(homedir(), '.minimax')]) {
if (!root) continue;
candidates.push(path.resolve(root, 'skills'), path.resolve(root, '.builtin-skills'));
if (agentName) candidates.push(path.resolve(root, 'agents', agentName, 'skills'));
}
// De-duplicate (dataDir may already equal <dataDir> on a default install).
const unique = Array.from(new Set(candidates));
if (!workingDirectory) return unique;
Expand Down
109 changes: 109 additions & 0 deletions packages/local-runtime/test/unit/local-permission-facade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1709,6 +1709,111 @@ describe('LocalPermissionFacade', () => {
});

describe('sandbox self-paths (dataDir auto-allow)', () => {
describe.each(['default', 'auto', 'bypassPermissions'] as const)(
'skill resources in %s mode',
(permissionMode) => {
it.each(['read', 'grep', 'glob', 'list'])(
'allows %s for global, builtin and active-agent skill resources',
async (toolName) => {
const classify = vi.fn(async (): Promise<CloudClassifyVerdict> => ({
kind: 'allow',
reasonLocalized: 'ok',
}));
const { facade, dataDir } = freshFacade({
permissionMode,
cloudGateway: { classify },
dataDirParent: homedir(),
});
try {
for (const root of [
path.join(dataDir, 'skills'),
path.join(dataDir, '.builtin-skills'),
path.join(dataDir, 'agents', 'fixture-agent', 'skills'),
]) {
const resourceDir = path.join(root, 'fixture-skill', 'references');
mkdirSync(resourceDir, { recursive: true });
const resource = path.join(resourceDir, 'guide.txt');
writeFileSync(resource, 'synthetic skill reference');
const result = await facade.checkPermission({
toolName,
agentName: 'fixture-agent',
input: { path: toolName === 'read' ? resource : resourceDir, pattern: '*' },
});
expect(result.behavior).toBe('allow');
}
expect(classify).not.toHaveBeenCalled();
} finally {
cleanup(dataDir);
}
},
);
},
);

it.each(['default', 'auto'] as const)(
'%s mode keeps other agents and runtime state outside skill read access',
async (permissionMode) => {
const classify = vi.fn(async (): Promise<CloudClassifyVerdict> => ({
kind: 'allow',
reasonLocalized: 'ok',
}));
const { facade, dataDir } = freshFacade({
permissionMode,
cloudGateway: { classify },
dataDirParent: homedir(),
});
try {
for (const relativePath of [
'agents/other-agent/skills/fixture-skill/references/guide.txt',
'agents/fixture-agent/config.yaml',
'agents/fixture-agent/skills-backup/guide.txt',
'.builtin-skills-backup/guide.txt',
]) {
const resource = path.join(dataDir, relativePath);
mkdirSync(path.dirname(resource), { recursive: true });
writeFileSync(resource, 'synthetic private state');
const result = await facade.checkPermission({
toolName: 'read',
agentName: 'fixture-agent',
input: { path: resource },
});
expect(result.behavior).toBe('ask');
}
expect(classify).not.toHaveBeenCalled();
} finally {
cleanup(dataDir);
}
},
);

it.each(['.builtin-skills', 'agents/fixture-agent/skills'])(
'requires approval when the %s root aliases private runtime state',
async (skillRoot) => {
const { facade, dataDir } = freshFacade({ dataDirParent: homedir() });
try {
const privateRoot = path.join(dataDir, 'private-state');
mkdirSync(privateRoot);
writeFileSync(path.join(privateRoot, 'notes.txt'), 'synthetic private state');
const alias = path.join(dataDir, skillRoot);
mkdirSync(path.dirname(alias), { recursive: true });
symlinkSync(privateRoot, alias, 'dir');
for (const toolName of ['read', 'grep']) {
const result = await facade.checkPermission({
toolName,
agentName: 'fixture-agent',
input: {
path: toolName === 'read' ? path.join(alias, 'notes.txt') : alias,
pattern: '*',
},
});
expect(result.behavior).toBe('ask');
}
} finally {
cleanup(dataDir);
}
},
);

it.each(['read', 'grep', 'glob', 'list'])(
'keeps private runtime %s requests out of automatic approval',
async (toolName) => {
Expand Down Expand Up @@ -1757,10 +1862,14 @@ describe('LocalPermissionFacade', () => {
writeFileSync(configPath, 'synthetic: fixture-only-secret\n');
mkdirSync(path.join(dataDir, 'memory'));
mkdirSync(path.join(dataDir, 'skills'));
mkdirSync(path.join(dataDir, '.builtin-skills'));
mkdirSync(path.join(dataDir, 'agents', 'fixture-agent', 'skills'), { recursive: true });
const aliases = [
path.join(workspaceDir, 'notes.txt'),
path.join(dataDir, 'memory', 'notes.txt'),
path.join(dataDir, 'skills', 'notes.txt'),
path.join(dataDir, '.builtin-skills', 'notes.txt'),
path.join(dataDir, 'agents', 'fixture-agent', 'skills', 'notes.txt'),
];
for (const alias of aliases) {
symlinkSync(configPath, alias);
Expand Down
Loading