Skip to content

Commit ad6e299

Browse files
committed
Use pathrestriction by default for writes in cwd instead of approval=always
1 parent 6928037 commit ad6e299

File tree

10 files changed

+201
-71
lines changed

10 files changed

+201
-71
lines changed

sdk/python/polos/execution/sandbox_tools.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,16 +118,31 @@ async def get_env() -> ExecutionEnvironment:
118118
else:
119119
effective_exec_config = config.exec if config else None
120120

121-
# For local mode, default file-mutating tools (write, edit) to approval-always
122-
file_approval = (config.file_approval if config else None) or (
123-
"always" if env_type == "local" else None
124-
)
125-
126-
# Path restriction for read-only tools (read, glob, grep) -- approval gate
121+
# Path restriction -- used by read, write, edit, glob, grep for approval gating
127122
path_config: PathRestrictionConfig | None = None
128123
if config and config.local and config.local.path_restriction:
129124
path_config = PathRestrictionConfig(path_restriction=config.local.path_restriction)
130125

126+
# file_approval overrides path-restriction behavior for write/edit.
127+
# 'always' = approve every write/edit regardless of path.
128+
# 'none' = never approve (skip path restriction too).
129+
# None = use path restriction (approve only outside cwd).
130+
file_approval = config.file_approval if config else None
131+
132+
# Build write/edit config: explicit approval overrides path restriction
133+
from .tools.write import WriteToolConfig
134+
from .tools.edit import EditToolConfig
135+
136+
if file_approval:
137+
write_edit_config_w = WriteToolConfig(approval=file_approval)
138+
write_edit_config_e = EditToolConfig(approval=file_approval)
139+
elif path_config:
140+
write_edit_config_w = WriteToolConfig(path_config=path_config)
141+
write_edit_config_e = EditToolConfig(path_config=path_config)
142+
else:
143+
write_edit_config_w = None
144+
write_edit_config_e = None
145+
131146
# Determine which tools to include
132147
include = set(
133148
(config.tools if config and config.tools else None)
@@ -141,9 +156,9 @@ async def get_env() -> ExecutionEnvironment:
141156
if "read" in include:
142157
tools.append(create_read_tool(get_env, path_config))
143158
if "write" in include:
144-
tools.append(create_write_tool(get_env, file_approval))
159+
tools.append(create_write_tool(get_env, write_edit_config_w))
145160
if "edit" in include:
146-
tools.append(create_edit_tool(get_env, file_approval))
161+
tools.append(create_edit_tool(get_env, write_edit_config_e))
147162
if "glob" in include:
148163
tools.append(create_glob_tool(get_env, path_config))
149164
if "grep" in include:

sdk/python/polos/execution/tools/edit.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
"""Edit tool -- find-and-replace text in files in the execution environment."""
1+
"""Edit tool -- find-and-replace text in files in the execution environment.
2+
3+
When path_restriction is set, edits within the restriction proceed
4+
without approval. Edits outside the restriction suspend for user approval.
5+
Set approval to 'always' to require approval for every edit, or 'none'
6+
to skip approval entirely (overrides path restriction).
7+
"""
28

39
from __future__ import annotations
410

11+
import os
512
from collections.abc import Awaitable, Callable
613
from typing import Any, Literal
714

@@ -10,6 +17,7 @@
1017
from ...core.context import WorkflowContext
1118
from ...tools.tool import Tool
1219
from ..environment import ExecutionEnvironment
20+
from .path_approval import PathRestrictionConfig, is_path_allowed, require_path_approval
1321

1422

1523
class EditInput(BaseModel):
@@ -20,22 +28,43 @@ class EditInput(BaseModel):
2028
new_text: str = Field(description="Text to replace the old_text with")
2129

2230

31+
class EditToolConfig(BaseModel):
32+
"""Configuration for the edit tool."""
33+
34+
approval: Literal["always", "none"] | None = None
35+
path_config: PathRestrictionConfig | None = None
36+
37+
2338
def create_edit_tool(
2439
get_env: Callable[[], Awaitable[ExecutionEnvironment]],
25-
approval: Literal["always", "none"] | None = None,
40+
config: EditToolConfig | None = None,
2641
) -> Tool:
2742
"""Create the edit tool for find-and-replace in files.
2843
2944
Args:
3045
get_env: Async callable that returns the shared ExecutionEnvironment.
31-
approval: Optional approval mode ('always' requires user approval before edit).
46+
config: Optional configuration with approval mode and/or path restriction.
3247
3348
Returns:
3449
A Tool instance for edit.
3550
"""
3651

3752
async def handler(ctx: WorkflowContext, input: EditInput) -> dict[str, Any]:
3853
env = await get_env()
54+
55+
# Path-restricted approval: approve if outside cwd, skip if inside
56+
if (
57+
not (config and config.approval)
58+
and config
59+
and config.path_config
60+
and config.path_config.path_restriction
61+
):
62+
resolved = os.path.abspath(os.path.join(env.get_cwd(), input.path))
63+
if not is_path_allowed(resolved, config.path_config.path_restriction):
64+
await require_path_approval(
65+
ctx, "edit", resolved, config.path_config.path_restriction
66+
)
67+
3968
content = await env.read_file(input.path)
4069

4170
if input.old_text not in content:
@@ -65,7 +94,7 @@ async def wrapped_func(ctx: WorkflowContext, payload: dict[str, Any] | None):
6594
),
6695
parameters=EditInput.model_json_schema(),
6796
func=wrapped_func,
68-
approval=approval,
97+
approval="always" if config and config.approval == "always" else None,
6998
)
7099
tool._input_schema_class = EditInput
71100
return tool

sdk/python/polos/execution/tools/write.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
"""Write tool -- create or overwrite files in the execution environment."""
1+
"""Write tool -- create or overwrite files in the execution environment.
2+
3+
When path_restriction is set, writes within the restriction proceed
4+
without approval. Writes outside the restriction suspend for user approval.
5+
Set approval to 'always' to require approval for every write, or 'none'
6+
to skip approval entirely (overrides path restriction).
7+
"""
28

39
from __future__ import annotations
410

11+
import os
512
from collections.abc import Awaitable, Callable
613
from typing import Any, Literal
714

@@ -10,6 +17,7 @@
1017
from ...core.context import WorkflowContext
1118
from ...tools.tool import Tool
1219
from ..environment import ExecutionEnvironment
20+
from .path_approval import PathRestrictionConfig, is_path_allowed, require_path_approval
1321

1422

1523
class WriteInput(BaseModel):
@@ -19,22 +27,43 @@ class WriteInput(BaseModel):
1927
content: str = Field(description="Content to write to the file")
2028

2129

30+
class WriteToolConfig(BaseModel):
31+
"""Configuration for the write tool."""
32+
33+
approval: Literal["always", "none"] | None = None
34+
path_config: PathRestrictionConfig | None = None
35+
36+
2237
def create_write_tool(
2338
get_env: Callable[[], Awaitable[ExecutionEnvironment]],
24-
approval: Literal["always", "none"] | None = None,
39+
config: WriteToolConfig | None = None,
2540
) -> Tool:
2641
"""Create the write tool for writing file contents.
2742
2843
Args:
2944
get_env: Async callable that returns the shared ExecutionEnvironment.
30-
approval: Optional approval mode ('always' requires user approval before write).
45+
config: Optional configuration with approval mode and/or path restriction.
3146
3247
Returns:
3348
A Tool instance for write.
3449
"""
3550

3651
async def handler(ctx: WorkflowContext, input: WriteInput) -> dict[str, Any]:
3752
env = await get_env()
53+
54+
# Path-restricted approval: approve if outside cwd, skip if inside
55+
if (
56+
not (config and config.approval)
57+
and config
58+
and config.path_config
59+
and config.path_config.path_restriction
60+
):
61+
resolved = os.path.abspath(os.path.join(env.get_cwd(), input.path))
62+
if not is_path_allowed(resolved, config.path_config.path_restriction):
63+
await require_path_approval(
64+
ctx, "write", resolved, config.path_config.path_restriction
65+
)
66+
3867
await env.write_file(input.path, input.content)
3968
return {"success": True, "path": input.path}
4069

@@ -52,7 +81,7 @@ async def wrapped_func(ctx: WorkflowContext, payload: dict[str, Any] | None):
5281
),
5382
parameters=WriteInput.model_json_schema(),
5483
func=wrapped_func,
55-
approval=approval,
84+
approval="always" if config and config.approval == "always" else None,
5685
)
5786
tool._input_schema_class = WriteInput
5887
return tool

sdk/typescript/src/execution/sandbox-tools.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,24 @@ export function sandboxTools(config?: SandboxToolsConfig): ToolWorkflow[] {
103103
? { ...config?.exec, security: 'approval-always' }
104104
: config?.exec;
105105

106-
// For local mode, default file-mutating tools (write, edit) to approval-always
107-
const fileApproval = config?.fileApproval ?? (envType === 'local' ? 'always' : undefined);
108-
109-
// Path restriction for read-only tools (read, glob, grep) — approval gate
106+
// Path restriction — used by read, write, edit, glob, grep for approval gating
110107
const pathConfig = config?.local?.pathRestriction
111108
? { pathRestriction: config.local.pathRestriction }
112109
: undefined;
113110

111+
// fileApproval overrides path-restriction behavior for write/edit.
112+
// 'always' = approve every write/edit regardless of path.
113+
// 'none' = never approve (skip path restriction too).
114+
// undefined = use path restriction (approve only outside cwd).
115+
const fileApproval = config?.fileApproval;
116+
117+
// Build write/edit config: explicit approval overrides path restriction
118+
const writeEditConfig = fileApproval
119+
? { approval: fileApproval }
120+
: pathConfig
121+
? { pathConfig }
122+
: undefined;
123+
114124
// Determine which tools to include
115125
const include = new Set(
116126
config?.tools ?? (['exec', 'read', 'write', 'edit', 'glob', 'grep'] as const)
@@ -120,8 +130,8 @@ export function sandboxTools(config?: SandboxToolsConfig): ToolWorkflow[] {
120130

121131
if (include.has('exec')) tools.push(createExecTool(getEnv, effectiveExecConfig));
122132
if (include.has('read')) tools.push(createReadTool(getEnv, pathConfig));
123-
if (include.has('write')) tools.push(createWriteTool(getEnv, fileApproval));
124-
if (include.has('edit')) tools.push(createEditTool(getEnv, fileApproval));
133+
if (include.has('write')) tools.push(createWriteTool(getEnv, writeEditConfig));
134+
if (include.has('edit')) tools.push(createEditTool(getEnv, writeEditConfig));
125135
if (include.has('glob')) tools.push(createGlobTool(getEnv, pathConfig));
126136
if (include.has('grep')) tools.push(createGrepTool(getEnv, pathConfig));
127137

sdk/typescript/src/execution/tools/edit.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,33 @@
11
/**
22
* Edit tool — find-and-replace text in files in the execution environment.
3+
*
4+
* When pathRestriction is set, edits within the restriction proceed
5+
* without approval. Edits outside the restriction suspend for user approval.
6+
* Set approval to 'always' to require approval for every edit, or 'none'
7+
* to skip approval entirely (overrides path restriction).
38
*/
49

10+
import { resolve } from 'node:path';
511
import { z } from 'zod';
612
import { defineTool } from '../../core/tool.js';
713
import type { ToolWorkflow, ToolApproval } from '../../core/tool.js';
814
import type { ExecutionEnvironment } from '../types.js';
15+
import type { PathRestrictionConfig } from './path-approval.js';
16+
import { isPathAllowed, requirePathApproval } from './path-approval.js';
17+
18+
export interface EditToolConfig {
19+
/** Explicit approval override. 'always' = approve every edit, 'none' = never approve. */
20+
approval?: ToolApproval;
21+
/** Path restriction config — edits inside are allowed, outside require approval. */
22+
pathConfig?: PathRestrictionConfig;
23+
}
924

1025
/**
1126
* Create the edit tool for find-and-replace in files.
1227
*/
1328
export function createEditTool(
1429
getEnv: () => Promise<ExecutionEnvironment>,
15-
approval?: ToolApproval
30+
config?: EditToolConfig
1631
): ToolWorkflow {
1732
return defineTool(
1833
{
@@ -25,10 +40,20 @@ export function createEditTool(
2540
old_text: z.string().describe('Exact text to find and replace'),
2641
new_text: z.string().describe('Text to replace the old_text with'),
2742
}),
28-
approval,
43+
// Only use blanket approval if explicitly set to 'always'
44+
approval: config?.approval === 'always' ? 'always' : undefined,
2945
},
30-
async (_ctx, input) => {
46+
async (ctx, input) => {
3147
const env = await getEnv();
48+
49+
// Path-restricted approval: approve if outside cwd, skip if inside
50+
if (!config?.approval && config?.pathConfig?.pathRestriction) {
51+
const resolved = resolve(env.getCwd(), input.path);
52+
if (!isPathAllowed(resolved, config.pathConfig.pathRestriction)) {
53+
await requirePathApproval(ctx, 'edit', resolved, config.pathConfig.pathRestriction);
54+
}
55+
}
56+
3257
const content = await env.readFile(input.path);
3358

3459
if (!content.includes(input.old_text)) {

sdk/typescript/src/execution/tools/path-approval.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
2-
* Path-based approval for read-only sandbox tools.
2+
* Path-based approval for sandbox tools.
33
*
4-
* When pathRestriction is set, read-only tools (read, glob, grep) allow
4+
* When pathRestriction is set, tools (read, write, edit, glob, grep) allow
55
* operations within the restricted path without approval. Operations
66
* outside the restriction suspend for user approval.
77
*/

sdk/typescript/src/execution/tools/write.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,33 @@
11
/**
22
* Write tool — create or overwrite files in the execution environment.
3+
*
4+
* When pathRestriction is set, writes within the restriction proceed
5+
* without approval. Writes outside the restriction suspend for user approval.
6+
* Set approval to 'always' to require approval for every write, or 'none'
7+
* to skip approval entirely (overrides path restriction).
38
*/
49

10+
import { resolve } from 'node:path';
511
import { z } from 'zod';
612
import { defineTool } from '../../core/tool.js';
713
import type { ToolWorkflow, ToolApproval } from '../../core/tool.js';
814
import type { ExecutionEnvironment } from '../types.js';
15+
import type { PathRestrictionConfig } from './path-approval.js';
16+
import { isPathAllowed, requirePathApproval } from './path-approval.js';
17+
18+
export interface WriteToolConfig {
19+
/** Explicit approval override. 'always' = approve every write, 'none' = never approve. */
20+
approval?: ToolApproval;
21+
/** Path restriction config — writes inside are allowed, outside require approval. */
22+
pathConfig?: PathRestrictionConfig;
23+
}
924

1025
/**
1126
* Create the write tool for writing file contents.
1227
*/
1328
export function createWriteTool(
1429
getEnv: () => Promise<ExecutionEnvironment>,
15-
approval?: ToolApproval
30+
config?: WriteToolConfig
1631
): ToolWorkflow {
1732
return defineTool(
1833
{
@@ -24,10 +39,20 @@ export function createWriteTool(
2439
path: z.string().describe('Path to the file to write'),
2540
content: z.string().describe('Content to write to the file'),
2641
}),
27-
approval,
42+
// Only use blanket approval if explicitly set to 'always'
43+
approval: config?.approval === 'always' ? 'always' : undefined,
2844
},
29-
async (_ctx, input) => {
45+
async (ctx, input) => {
3046
const env = await getEnv();
47+
48+
// Path-restricted approval: approve if outside cwd, skip if inside
49+
if (!config?.approval && config?.pathConfig?.pathRestriction) {
50+
const resolved = resolve(env.getCwd(), input.path);
51+
if (!isPathAllowed(resolved, config.pathConfig.pathRestriction)) {
52+
await requirePathApproval(ctx, 'write', resolved, config.pathConfig.pathRestriction);
53+
}
54+
}
55+
3156
await env.writeFile(input.path, input.content);
3257
return { success: true, path: input.path };
3358
}

server/src/commands/dev.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,9 @@ pub async fn run(worker_port: u16) -> Result<()> {
151151
}
152152

153153
if let Ok(Ok(events)) = fs_rx.try_recv() {
154-
let has_relevant = events.iter().any(|e| {
155-
e.kind == DebouncedEventKind::Any && is_source_file(&e.path)
156-
});
154+
let has_relevant = events
155+
.iter()
156+
.any(|e| e.kind == DebouncedEventKind::Any && is_source_file(&e.path));
157157
if has_relevant {
158158
println!("File change detected. Restarting worker...");
159159
match spawn_worker(&cmd, &args, &worker_env) {

0 commit comments

Comments
 (0)