diff --git a/.changeset/yolo-temp-approval.md b/.changeset/yolo-temp-approval.md new file mode 100644 index 0000000..b76cee1 --- /dev/null +++ b/.changeset/yolo-temp-approval.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kaos": patch +"@moonshot-ai/kimi-code": patch +--- + +Allow temp directory access outside workspace in yolo mode without approval. + +Add `gettmpdir()` method to the `Kaos` interface and its implementations (`LocalKaos` and `SSHKaos`). diff --git a/packages/agent-core/src/agent/permission/policies/yolo-workspace-access.ts b/packages/agent-core/src/agent/permission/policies/yolo-workspace-access.ts index b95b954..9125b20 100644 --- a/packages/agent-core/src/agent/permission/policies/yolo-workspace-access.ts +++ b/packages/agent-core/src/agent/permission/policies/yolo-workspace-access.ts @@ -1,6 +1,7 @@ import type { ToolInputDisplay } from '../../../tools/display'; import { DEFAULT_WORKSPACE_ACCESS_POLICY, + isWithinDirectory, resolvePathAccess, type PathAccessOperation, } from '../../../tools/policies/path-access'; @@ -55,6 +56,14 @@ export const YoloOutsideWorkspacePermissionPolicy: PermissionPolicy = { } if (!access.outsideWorkspace) return undefined; + + // In yolo mode, temp directory access does not require approval + const kaos = agent.runtime.kaos; + const pathClass = kaos.pathClass(); + if (isWithinDirectory(access.path, kaos.gettmpdir(), pathClass)) { + return undefined; + } + return { kind: 'ask', display: { diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index 3e57bb6..058a59e 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -516,6 +516,7 @@ function createResumeNoSideEffectKaos(): Kaos { normpath: (p: string) => p, gethome: () => '/home/test', getcwd: () => '/workspace', + gettmpdir: () => '/tmp', chdir: () => fail('chdir'), stat: () => fail('stat'), iterdir: () => fail('iterdir'), diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts index 987e87b..846933e 100644 --- a/packages/agent-core/test/agent/permission.test.ts +++ b/packages/agent-core/test/agent/permission.test.ts @@ -389,11 +389,9 @@ describe('Permission auto mode', () => { ); it.each([ - ['Read', { path: '/tmp/notes.md' }, 'read'], - ['ReadMediaFile', { path: '/tmp/image.png' }, 'read'], - ['Write', { path: '/tmp/notes.md', content: 'x' }, 'write'], - ['Edit', { path: '/tmp/notes.md', old_string: 'a', new_string: 'b' }, 'edit'], - ['Grep', { pattern: 'TODO', path: '/tmp' }, 'grep'], + ['Read', { path: '/outside/notes.md' }, 'read'], + ['ReadMediaFile', { path: '/outside/image.png' }, 'read'], + ['Grep', { pattern: 'TODO', path: '/outside' }, 'grep'], ] as const)( 'requests approval for %s outside the workspace in yolo mode', async (toolName, args, operation) => { @@ -421,6 +419,28 @@ describe('Permission auto mode', () => { }, ); + it.each([ + ['Read', { path: '/tmp/notes.md' }], + ['ReadMediaFile', { path: '/tmp/image.png' }], + ['Write', { path: '/tmp/notes.md', content: 'x' }], + ['Edit', { path: '/tmp/notes.md', old_string: 'a', new_string: 'b' }], + ['Grep', { pattern: 'TODO', path: '/tmp' }], + ] as const)( + 'does not request approval for %s outside the workspace in yolo mode when target is temp directory', + async (toolName, args) => { + const { manager, requestApproval } = makePermissionManager(async () => ({ + decision: 'approved', + })); + manager.setMode('yolo'); + + await expect( + manager.beforeToolCall(hookContext({ id: `call_${toolName}`, toolName, args })), + ).resolves.toBeUndefined(); + + expect(requestApproval).not.toHaveBeenCalled(); + }, + ); + it.each([ ['Read', { path: '/workspace/notes.md' }], ['ReadMediaFile', { path: '/workspace/image.png' }], @@ -504,7 +524,7 @@ describe('Permission auto mode', () => { hookContext({ id: 'call_read_session', toolName: 'Read', - args: { path: '/tmp/notes.md' }, + args: { path: '/outside/notes.md' }, }), ); diff --git a/packages/agent-core/test/tools/fixtures/fake-kaos.ts b/packages/agent-core/test/tools/fixtures/fake-kaos.ts index 0f6c558..b920cd9 100644 --- a/packages/agent-core/test/tools/fixtures/fake-kaos.ts +++ b/packages/agent-core/test/tools/fixtures/fake-kaos.ts @@ -27,6 +27,7 @@ export function createFakeKaos(overrides?: Partial): Kaos { normpath: (p: string) => p, gethome: () => '/home/test', getcwd: () => '/workspace', + gettmpdir: () => '/tmp', chdir: () => notImplemented('chdir'), stat: () => notImplemented('stat'), iterdir: () => notImplemented('iterdir'), diff --git a/packages/kaos/src/kaos.ts b/packages/kaos/src/kaos.ts index 252bae2..d75b2d2 100644 --- a/packages/kaos/src/kaos.ts +++ b/packages/kaos/src/kaos.ts @@ -22,6 +22,8 @@ export interface Kaos { gethome(): string; /** Return the current working directory. */ getcwd(): string; + /** Return the temp directory for the current environment. */ + gettmpdir(): string; // ── Directory operations (async) ──────────────────────────────────── diff --git a/packages/kaos/src/local.ts b/packages/kaos/src/local.ts index d0c6795..b0ac8ec 100644 --- a/packages/kaos/src/local.ts +++ b/packages/kaos/src/local.ts @@ -10,7 +10,7 @@ import { stat, writeFile, } from 'node:fs/promises'; -import { homedir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { isAbsolute, join as pathJoin, normalize } from 'node:path'; import type { Readable, Writable } from 'node:stream'; @@ -176,6 +176,10 @@ export class LocalKaos implements Kaos { return this._cwd; } + gettmpdir(): string { + return tmpdir(); + } + /** * Change the working directory of this LocalKaos instance. * diff --git a/packages/kaos/src/ssh.ts b/packages/kaos/src/ssh.ts index 71a92ca..1bb31a5 100644 --- a/packages/kaos/src/ssh.ts +++ b/packages/kaos/src/ssh.ts @@ -429,12 +429,14 @@ export class SSHKaos implements Kaos { private _sftp: SFTPWrapper; private _home: string; private _cwd: string; + private _tmpdir: string; - private constructor(client: Client, sftp: SFTPWrapper, home: string, cwd: string) { + private constructor(client: Client, sftp: SFTPWrapper, home: string, cwd: string, tmpdir: string) { this._client = client; this._sftp = sftp; this._home = home; this._cwd = cwd; + this._tmpdir = tmpdir; } private _resolvePath(path: string): string { @@ -501,7 +503,14 @@ export class SSHKaos implements Kaos { } } - return new SSHKaos(client, sftp, home, cwd); + let tmpdir = '/tmp'; + try { + tmpdir = await sftpRealpath(sftp, '/tmp'); + } catch { + // fallback to /tmp + } + + return new SSHKaos(client, sftp, home, cwd, tmpdir); } catch (error) { client.end(); throw error; @@ -526,6 +535,10 @@ export class SSHKaos implements Kaos { return this._cwd; } + gettmpdir(): string { + return this._tmpdir; + } + // ── Directory operations (async) ─────────────────────────────────── async chdir(path: string): Promise { diff --git a/packages/kaos/test/current.test.ts b/packages/kaos/test/current.test.ts index 92baa00..ead331b 100644 --- a/packages/kaos/test/current.test.ts +++ b/packages/kaos/test/current.test.ts @@ -37,6 +37,7 @@ function createMockKaos(name: string): Kaos { normpath: (p: string) => p, gethome: () => '/', getcwd: () => '/', + gettmpdir: () => '/tmp', chdir: async () => {}, stat: () => Promise.resolve({ diff --git a/packages/kaos/test/e2e/async-isolation.test.ts b/packages/kaos/test/e2e/async-isolation.test.ts index 345c0db..e49bbc2 100644 --- a/packages/kaos/test/e2e/async-isolation.test.ts +++ b/packages/kaos/test/e2e/async-isolation.test.ts @@ -14,6 +14,7 @@ function createNamedKaos(kaosName: string): Kaos { normpath: (p: string) => base.normpath(p), gethome: () => base.gethome(), getcwd: () => base.getcwd(), + gettmpdir: () => base.gettmpdir(), chdir: async (p: string) => base.chdir(p), stat: async (p: string, opts?: { followSymlinks?: boolean }) => base.stat(p, opts), iterdir: (p: string) => base.iterdir(p), diff --git a/packages/kaos/test/e2e/path-cross-platform.test.ts b/packages/kaos/test/e2e/path-cross-platform.test.ts index adf845f..3b3bd9c 100644 --- a/packages/kaos/test/e2e/path-cross-platform.test.ts +++ b/packages/kaos/test/e2e/path-cross-platform.test.ts @@ -23,6 +23,9 @@ function createMockKaos(overrides: Partial & { name: string }): Kaos { getcwd(): string { return '/default/cwd'; }, + gettmpdir(): string { + return '/tmp'; + }, async chdir(): Promise { // no-op }, diff --git a/packages/kaos/test/path.test.ts b/packages/kaos/test/path.test.ts index a290c36..253dd95 100644 --- a/packages/kaos/test/path.test.ts +++ b/packages/kaos/test/path.test.ts @@ -17,6 +17,7 @@ function makeMockKaos(pathClass: 'posix' | 'win32', overrides: Partial = { normpath: (p: string) => (pathClass === 'win32' ? win32Path.normalize(p) : p), gethome: () => (pathClass === 'win32' ? 'C:\\Users\\test' : '/home/test'), getcwd: () => (pathClass === 'win32' ? 'C:\\work\\project' : '/work/project'), + gettmpdir: () => (pathClass === 'win32' ? 'C:\\Users\\test\\AppData\\Local\\Temp' : '/tmp'), chdir: async () => {}, stat: async () => ({ stMode: 0, @@ -225,6 +226,7 @@ describe('KaosPath', () => { normpath: (p: string) => win32Path.normalize(p), gethome: () => 'C:\\Users\\test', getcwd: () => 'C:\\work\\project', + gettmpdir: () => 'C:\\Users\\test\\AppData\\Local\\Temp', chdir: async () => {}, stat: async () => ({ stMode: 0,