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
14 changes: 9 additions & 5 deletions packages/agent-modules/permission/src/tools/fs-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ export class FsToolPermissionChecker implements ToolPermissionChecker {
}

// Extract file path from input
const filePath = extractFilePath(input);
const filePath =
extractFilePath(input) ??
(toolName === 'grep' || toolName === 'glob' || toolName === 'list'
? context.workingDirectory
: undefined);
if (!filePath) return undefined;

const decision = evaluatePathCapability(filePath, toolActionForFsTool(toolName), context, {
Expand Down Expand Up @@ -109,9 +113,9 @@ function toolActionForFsTool(toolName: string): 'read' | 'write' {

/** Extract the primary file path from a tool's input object. */
function extractFilePath(input: Record<string, unknown>): string | undefined {
if (typeof input.file_path === 'string') return input.file_path;
if (typeof input.filePath === 'string') return input.filePath;
if (typeof input.path === 'string') return input.path;
if (typeof input.pattern === 'string') return input.pattern;
if (typeof input.file_path === 'string' && input.file_path) return input.file_path;
if (typeof input.filePath === 'string' && input.filePath) return input.filePath;
if (typeof input.path === 'string' && input.path) return input.path;
// A search pattern is not a filesystem path. Directory tools default to cwd.
return undefined;
}
66 changes: 66 additions & 0 deletions packages/agent-modules/permission/src/tools/fs-permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,54 @@ export function isTempDirectory(resolvedPath: string): boolean {
// Internal whitelist paths
// ---------------------------------------------------------------------------

/** Resolve existing parents too, so a directory alias cannot hide a protected read. */
function resolveExistingPath(filePath: string): string {
try {
return fs.realpathSync(filePath);
} catch {
const parent = path.dirname(filePath);
return parent === filePath
? filePath
: path.join(resolveExistingPath(parent), path.basename(filePath));
}
}

/** Runtime state is private; only designated agent assets get implicit read access. */
function isProtectedRuntimeRead(
filePath: string,
context: PathCheckContext,
recursive: boolean,
): boolean {
const within = (target: string, root: string) => {
const relative = path.relative(root, target);
return (
relative === '' ||
(!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
);
};
const roots = [context.dataDir, path.join(context.homeDir ?? os.homedir(), '.minimax')];
const canonicalPath = resolveExistingPath(filePath);
for (const root of roots) {
if (!root) continue;
const resolvedRoot = path.resolve(root);
const canonicalRoot = resolveExistingPath(resolvedRoot);
if (recursive && within(canonicalRoot, canonicalPath)) return true;
if (!within(filePath, resolvedRoot) && !within(canonicalPath, canonicalRoot)) continue;
// Do not resolve the asset subdirectory itself: a skills/ symlink pointing
// at credentials must not turn its destination into a trusted asset root.
const readableAsset =
within(canonicalPath, path.join(canonicalRoot, 'skills')) ||
(context.agentName != null &&
within(canonicalPath, path.join(canonicalRoot, 'agents', context.agentName, 'workspace'))) ||
isInternalWhitelistedPath(canonicalPath, {
...context,
dataDir: canonicalRoot,
});
if (!readableAsset) return true;
}
return false;
}

/**
* Check if a path is in the internal whitelist (always writable by the system).
*
Expand Down Expand Up @@ -835,6 +883,24 @@ export function isPathAllowed(
}
}

// Runtime state can contain credentials and session data. Check before ALL
// implicit allowances, including workspace/temp and internal aliases. A grep
// of the data root must be reviewed just like a direct config.yaml read.
if (
READ_ONLY_TOOLS.has(toolName) &&
isProtectedRuntimeRead(resolved, context, toolName === 'grep')
) {
return {
allowed: false,
reason: {
type: 'safetyCheck',
description: `Private runtime data access requires approval. Path: ${resolved}`,
category: 'credentialFile',
classifierApprovable: false,
},
};
}

// Step 2: Internal path whitelist
if (isInternalWhitelistedPath(resolved, context)) {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export function evaluatePathCapability(
behavior: 'ask',
reason: result.reason,
bypassImmune: wouldBeBypassImmune,
...(result.reason.classifierApprovable === false ? { skipAutoClassifier: true } : {}),
...buildPathRuleSuggestion(filePath, action, context),
};
}
Expand Down
52 changes: 4 additions & 48 deletions packages/local-runtime/src/permissions/facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,51 +977,7 @@ export class LocalPermissionFacade {
// Helpers
// -------------------------------------------------------------------------

/**
* Self-paths that mavis auto-allows for READ-ONLY tools without
* prompting. **Write-side tools always go through the normal review
* flow even on these paths.**
*
* Rationale: mavis's own runtime data (`cfg.dataDir`) and the user-wide
* default dataDir (`<dataDir>`) hold the runtime's own assets — built-in
* skills (`<dataDir>/skills/<name>/SKILL.md`), session transcripts,
* agent configs, generated hooks. The agent READING those is not
* "external filesystem access" in any meaningful sense; it is the
* runtime reading its own application directory. Prompting the user
* for "may the agent read its own SKILL.md?" is noise on par with an
* OS asking "may VS Code read its own settings.json?".
*
* **Writes are deliberately NOT auto-allowed.** The sensitive-file
* safeguards in fs-permission (`isCredentialFile`, `isEnvFile`,
* `isSensitiveGitFile`, `isSystemSensitivePath`) only run on the
* READ side (`fs-permission.ts:818-875` lives under the read-policy
* path). On the WRITE side, `sandboxAllowPaths` would short-circuit
* to allow at `fs-permission.ts:968` BEFORE any credential / .env /
* .git/config check could intervene — letting a default/auto-mode
* agent silently create or overwrite
* `<dataDir>/.aws/credentials`, `<dataDir>/.env`, or
* `<dataDir>/.git/config`. By only injecting the self-paths for
* read-only tools (set parity with `READ_ONLY_TOOLS` =
* `{read, glob, grep, list}`), writes fall through to the normal
* ask flow — the user sees a card for every mavis-internal write,
* which keeps the sensitive-write attack surface unchanged.
*
* What this still does NOT bypass on the read side:
* - `isSystemSensitivePath` / `isCredentialFile` / `isEnvFile` /
* `isInDangerousDirectory + isSensitiveGitFile` checks
* (`fs-permission.ts:818-875`) all run BEFORE the sandbox-allow
* branch (`fs-permission.ts:968`), so a stray
* `<dataDir>/.aws/credentials` or `<dataDir>/.env` still ASKs.
* - The HARD-blocked fs-read registry (ssh / aws / *.pem / Windows
* registry hive / macOS keychain) runs even earlier
* (`path-capability.ts:82`).
* - bypass-immune deny in the facade itself (UNC, root rm).
*
* The defensive filter drops a self-path if it would become a parent of
* `workingDirectory` — if a user accidentally configured their workspace
* inside `<dataDir>/`, we don't want the sandbox allow to silently widen
* the workspace boundary; the normal workspace check covers it.
*/
/** Built-in skill assets may be read without granting access to private runtime state. */
private resolveSandboxAllowPaths(toolName: string, workingDirectory: string): string[] {
// Read-only tool set parity with `READ_ONLY_TOOLS`
// (`packages/local-runtime/src/permission/tools/fs-permission.ts`).
Expand All @@ -1033,8 +989,8 @@ export class LocalPermissionFacade {

const candidates: string[] = [];
const dataDir = this.deps.configGetter().dataDir;
if (dataDir) candidates.push(path.resolve(dataDir));
candidates.push(path.resolve(homedir(), '.minimax'));
if (dataDir) candidates.push(path.resolve(dataDir, 'skills'));
candidates.push(path.resolve(homedir(), '.minimax', '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 Expand Up @@ -1255,7 +1211,7 @@ export class LocalPermissionFacade {
` 1. Remove ${scriptPath} and restart the app — startup re-seeds the script.\n` +
' 2. If it fails again, verify the runtime data directory is writable and not ' +
'mounted noexec, and that the script keeps its execute bit.\n' +
' 3. Two app versions sharing one data directory overwrite each other\'s script; ' +
" 3. Two app versions sharing one data directory overwrite each other's script; " +
'close the other version, then restart.',
denySource: 'safety-immune',
};
Expand Down
Loading
Loading