add workspace

This commit is contained in:
hc 2026-04-11 23:16:45 +08:00
parent 84ef3d2f9a
commit de0fad496c
6 changed files with 43 additions and 9 deletions

View File

@ -25,6 +25,8 @@
行动时必须以运行时注入的环境信息为准特别是平台、shell、工作目录和可用工具列表。 行动时必须以运行时注入的环境信息为准特别是平台、shell、工作目录和可用工具列表。
当前 workspace 是默认操作边界,不应主动读写工作区外文件,也不应主动探索无关路径。
REPL 内的会话管理命令优先使用 slash command。 REPL 内的会话管理命令优先使用 slash command。
REPL 内所有 slash command 由程序直接处理,不进入 agent loop。 REPL 内所有 slash command 由程序直接处理,不进入 agent loop。
@ -49,6 +51,7 @@ REPL 内所有 slash command 由程序直接处理,不进入 agent loop。
- `Read`:用于读取文件内容。 - `Read`:用于读取文件内容。
- 需要按文件名或路径模式查找时,优先使用 `Glob` - 需要按文件名或路径模式查找时,优先使用 `Glob`
- 需要搜索文件内容时,优先使用 `Grep` - 需要搜索文件内容时,优先使用 `Grep`
- 默认只应在当前 workspace 内读写和执行与项目相关的操作。
- 修改已有文件内容时,优先使用 `Edit` 工具。 - 修改已有文件内容时,优先使用 `Edit` 工具。
- 创建新文件时,优先使用 `Write` 工具。 - 创建新文件时,优先使用 `Write` 工具。
- `Bash`:用于执行必须通过 shell 完成的最小命令。 - `Bash`:用于执行必须通过 shell 完成的最小命令。

View File

@ -27,6 +27,7 @@
- 长期有价值的项目约束或偏好,优先使用 `/remember` 保存 - 长期有价值的项目约束或偏好,优先使用 `/remember` 保存
- 高风险操作会触发确认,优先先读再改,减少无意义审批 - 高风险操作会触发确认,优先先读再改,减少无意义审批
- 复杂任务可先切换到 `/mode plan` 进行规划,再切换回 `/mode build` 执行 - 复杂任务可先切换到 `/mode plan` 进行规划,再切换回 `/mode build` 执行
- 先在 workspace 内定位和操作,避免无关路径探索
- Windows 下先验证 shell 兼容性,再选择命令写法 - Windows 下先验证 shell 兼容性,再选择命令写法
## 沟通风格 ## 沟通风格

View File

@ -115,6 +115,8 @@ def handle_command(
status = permissions.status() status = permissions.status()
console.print(f"auto_approve: {status['auto_approve']}") console.print(f"auto_approve: {status['auto_approve']}")
console.print(f"mode: {status['mode']}") console.print(f"mode: {status['mode']}")
console.print(f"workspace: {status['workspace']}")
console.print(f"boundary: {status['boundary_policy']}")
console.print(f"自动允许: {', '.join(status['auto_allowed_tools'])}") console.print(f"自动允许: {', '.join(status['auto_allowed_tools'])}")
console.print(f"需要确认: {', '.join(status['confirm_required_tools'])}") console.print(f"需要确认: {', '.join(status['confirm_required_tools'])}")
return agent return agent

View File

@ -184,8 +184,10 @@ class Agent:
f"- sys.platform: {sys.platform}", f"- sys.platform: {sys.platform}",
f"- shell: {shell_name}", f"- shell: {shell_name}",
f"- workspace: {workspace}", f"- workspace: {workspace}",
"- 默认边界: workspace only",
f"- 可用工具: {tool_names}", f"- 可用工具: {tool_names}",
"- 行动时必须以以上运行环境信息为准,不要默认套用 Unix/Linux 命令习惯。", "- 行动时必须以以上运行环境信息为准,不要默认套用 Unix/Linux 命令习惯。",
"- 默认只应在当前 workspace 内读写文件并执行与项目相关的操作,不要主动探索工作区外路径。",
] ]
) )
@ -287,13 +289,13 @@ class Agent:
def _check_tool_permission(self, name: str, payload: dict[str, Any]) -> str | None: def _check_tool_permission(self, name: str, payload: dict[str, Any]) -> str | None:
if not self.permission_checker: if not self.permission_checker:
return None return None
if not self.permission_checker.is_allowed(name): if not self.permission_checker.is_allowed(name, payload):
return self.permission_checker.denial_reason(name) return self.permission_checker.denial_reason(name, payload)
if self.permission_checker.requires_confirmation(name): if self.permission_checker.requires_confirmation(name):
if not self.confirm_tool: if not self.confirm_tool:
return self.permission_checker.denial_reason(name) return self.permission_checker.denial_reason(name, payload)
if not self.confirm_tool(name, payload): if not self.confirm_tool(name, payload):
return self.permission_checker.denial_reason(name) return self.permission_checker.denial_reason(name, payload)
return None return None
def _run_tool(self, name: str, payload: dict[str, Any]) -> str: def _run_tool(self, name: str, payload: dict[str, Any]) -> str:

View File

@ -32,7 +32,11 @@ def render_stream(agent: Agent, user_input: str) -> None:
console.print(f"[cyan]->[/cyan] {event['name']}({event['input']})") console.print(f"[cyan]->[/cyan] {event['name']}({event['input']})")
elif event["type"] == "tool_result": elif event["type"] == "tool_result":
output = str(event.get("output", "")) output = str(event.get("output", ""))
if output.startswith("Tool blocked in plan mode:") or output.startswith("Permission denied for tool:"): if (
output.startswith("Tool blocked in plan mode:")
or output.startswith("Permission denied for tool:")
or output.startswith("Bash command appears to target paths outside the workspace")
):
console.print(f"[red]{output}[/red]") console.print(f"[red]{output}[/red]")
else: else:
console.print(f"[green]✓[/green] {event['name']} done") console.print(f"[green]✓[/green] {event['name']} done")
@ -112,7 +116,7 @@ def run(
store = SessionStore(root) store = SessionStore(root)
memory = MemoryStore(root) memory = MemoryStore(root)
mode = ModeState() mode = ModeState()
permissions = PermissionChecker(auto_approve=auto_approve) permissions = PermissionChecker(root, auto_approve=auto_approve)
permissions.set_mode(mode.mode) permissions.set_mode(mode.mode)
if history: if history:
render_history(store) render_history(store)

View File

@ -1,21 +1,30 @@
from __future__ import annotations from __future__ import annotations
import re
from pathlib import Path
class PermissionChecker: class PermissionChecker:
def __init__(self, auto_approve: bool = False) -> None: def __init__(self, workspace: Path, auto_approve: bool = False) -> None:
self.workspace = workspace.resolve()
self.auto_approve = auto_approve self.auto_approve = auto_approve
self.mode = "build" self.mode = "build"
self.boundary_policy = "workspace only"
self.auto_allowed_tools = ["Read", "Glob", "Grep"] self.auto_allowed_tools = ["Read", "Glob", "Grep"]
self.confirm_required_tools = ["Write", "Edit", "Bash"] self.confirm_required_tools = ["Write", "Edit", "Bash"]
def is_allowed(self, tool_name: str) -> bool: def is_allowed(self, tool_name: str, payload: dict[str, object] | None = None) -> bool:
if self.mode == "plan" and tool_name in self.confirm_required_tools: if self.mode == "plan" and tool_name in self.confirm_required_tools:
return False return False
if tool_name == "Bash" and payload and self._bash_targets_outside_workspace(str(payload.get("command", ""))):
return False
return True return True
def denial_reason(self, tool_name: str) -> str: def denial_reason(self, tool_name: str, payload: dict[str, object] | None = None) -> str:
if self.mode == "plan" and tool_name in self.confirm_required_tools: if self.mode == "plan" and tool_name in self.confirm_required_tools:
return f"Tool blocked in plan mode: {tool_name}" return f"Tool blocked in plan mode: {tool_name}"
if tool_name == "Bash" and payload and self._bash_targets_outside_workspace(str(payload.get("command", ""))):
return "Bash command appears to target paths outside the workspace"
return f"Permission denied for tool: {tool_name}" return f"Permission denied for tool: {tool_name}"
def requires_confirmation(self, tool_name: str) -> bool: def requires_confirmation(self, tool_name: str) -> bool:
@ -34,6 +43,19 @@ class PermissionChecker:
return { return {
"auto_approve": self.auto_approve, "auto_approve": self.auto_approve,
"mode": self.mode, "mode": self.mode,
"workspace": str(self.workspace),
"boundary_policy": self.boundary_policy,
"auto_allowed_tools": list(self.auto_allowed_tools), "auto_allowed_tools": list(self.auto_allowed_tools),
"confirm_required_tools": list(self.confirm_required_tools), "confirm_required_tools": list(self.confirm_required_tools),
} }
def _bash_targets_outside_workspace(self, command: str) -> bool:
workspace_text = str(self.workspace).lower()
windows_paths = re.findall(r"[A-Za-z]:\\[^\s\"']+", command)
unix_paths = re.findall(r"(?<![A-Za-z]):?(/[^^\s\"']+)", command)
candidates = windows_paths + unix_paths
for candidate in candidates:
normalized = str(Path(candidate).resolve()).lower()
if workspace_text not in normalized:
return True
return False