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

View File

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

View File

@ -115,6 +115,8 @@ def handle_command(
status = permissions.status()
console.print(f"auto_approve: {status['auto_approve']}")
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['confirm_required_tools'])}")
return agent

View File

@ -184,8 +184,10 @@ class Agent:
f"- sys.platform: {sys.platform}",
f"- shell: {shell_name}",
f"- workspace: {workspace}",
"- 默认边界: workspace only",
f"- 可用工具: {tool_names}",
"- 行动时必须以以上运行环境信息为准,不要默认套用 Unix/Linux 命令习惯。",
"- 默认只应在当前 workspace 内读写文件并执行与项目相关的操作,不要主动探索工作区外路径。",
]
)
@ -287,13 +289,13 @@ class Agent:
def _check_tool_permission(self, name: str, payload: dict[str, Any]) -> str | None:
if not self.permission_checker:
return None
if not self.permission_checker.is_allowed(name):
return self.permission_checker.denial_reason(name)
if not self.permission_checker.is_allowed(name, payload):
return self.permission_checker.denial_reason(name, payload)
if self.permission_checker.requires_confirmation(name):
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):
return self.permission_checker.denial_reason(name)
return self.permission_checker.denial_reason(name, payload)
return None
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']})")
elif event["type"] == "tool_result":
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]")
else:
console.print(f"[green]✓[/green] {event['name']} done")
@ -112,7 +116,7 @@ def run(
store = SessionStore(root)
memory = MemoryStore(root)
mode = ModeState()
permissions = PermissionChecker(auto_approve=auto_approve)
permissions = PermissionChecker(root, auto_approve=auto_approve)
permissions.set_mode(mode.mode)
if history:
render_history(store)

View File

@ -1,21 +1,30 @@
from __future__ import annotations
import re
from pathlib import Path
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.mode = "build"
self.boundary_policy = "workspace only"
self.auto_allowed_tools = ["Read", "Glob", "Grep"]
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:
return False
if tool_name == "Bash" and payload and self._bash_targets_outside_workspace(str(payload.get("command", ""))):
return False
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:
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}"
def requires_confirmation(self, tool_name: str) -> bool:
@ -34,6 +43,19 @@ class PermissionChecker:
return {
"auto_approve": self.auto_approve,
"mode": self.mode,
"workspace": str(self.workspace),
"boundary_policy": self.boundary_policy,
"auto_allowed_tools": list(self.auto_allowed_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