fix(s03): let Gate 2 own the workspace boundary instead of safe_path

safe_path hard-raised on out-of-workspace paths while Gate 2 asked about the same condition, so approving an out-of-workspace write never took effect. Make the permission pipeline the sole boundary authority: drop safe_path, resolve paths directly in the file tools, and extend the Gate 2 rule to read_file. Sync all three READMEs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viper
2026-07-27 20:27:22 +08:00
committed by root
parent a9cafe953a
commit 4d8d420e41
4 changed files with 11 additions and 18 deletions

View File

@@ -28,7 +28,7 @@ The three gates correspond to three decisions:
| Gate | Purpose | On Match | | Gate | Purpose | On Match |
|------|---------|----------| |------|---------|----------|
| 1. Deny List | Permanently forbidden operations (`rm -rf /`, `sudo`) | Denied immediately, not executed | | 1. Deny List | Permanently forbidden operations (`rm -rf /`, `sudo`) | Denied immediately, not executed |
| 2. Rule Matching | Context-dependent operations (writing outside workspace, `rm` files) | Passed to Gate 3 | | 2. Rule Matching | Context-dependent operations (reading/writing outside workspace, `rm` files) | Passed to Gate 3 |
| 3. User Approval | After Gate 2 matches, pauses for user confirmation | User decides allow or deny | | 3. User Approval | After Gate 2 matches, pauses for user confirmation | User decides allow or deny |
None of the three gates match → execute directly. Most routine operations take this path. None of the three gates match → execute directly. Most routine operations take this path.
@@ -59,7 +59,7 @@ def check_deny_list(command: str) -> str | None:
```python ```python
PERMISSION_RULES = [ PERMISSION_RULES = [
{ {
"tools": ["write_file", "edit_file"], "tools": ["read_file", "write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR), "check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Writing outside workspace", "message": "Writing outside workspace",
}, },

View File

@@ -28,7 +28,7 @@ s02 のループは完全に維持される。唯一の変更は、ツール実
| ゲート | 役割 | 一致時 | | ゲート | 役割 | 一致時 |
|--------|------|--------| |--------|------|--------|
| 1. 拒否リスト | 常に禁止される操作(`rm -rf /``sudo` | 即座に拒否、実行しない | | 1. 拒否リスト | 常に禁止される操作(`rm -rf /``sudo` | 即座に拒否、実行しない |
| 2. ルールマッチング | コンテキスト依存の操作(作業ディレクトリ外への書き込み`rm` ファイル) | ゲート 3 へ | | 2. ルールマッチング | コンテキスト依存の操作(作業ディレクトリ外への読み書き、`rm` ファイル) | ゲート 3 へ |
| 3. ユーザー承認 | ゲート 2 が一致した場合、ユーザー確認を待機 | ユーザーが許可または拒否を決定 | | 3. ユーザー承認 | ゲート 2 が一致した場合、ユーザー確認を待機 | ユーザーが許可または拒否を決定 |
3 つのゲートのどれにも一致しない → 直接実行。日常の操作の大部分はこの経路を通る。 3 つのゲートのどれにも一致しない → 直接実行。日常の操作の大部分はこの経路を通る。
@@ -59,7 +59,7 @@ def check_deny_list(command: str) -> str | None:
```python ```python
PERMISSION_RULES = [ PERMISSION_RULES = [
{ {
"tools": ["write_file", "edit_file"], "tools": ["read_file", "write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR), "check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Writing outside workspace", "message": "Writing outside workspace",
}, },

View File

@@ -28,7 +28,7 @@ s02 的循环完全保留。唯一的变动在工具执行前插入 `check_permi
| 闸门 | 作用 | 命中后 | | 闸门 | 作用 | 命中后 |
|------|------|--------| |------|------|--------|
| 1. 拒绝列表 | 永远禁止的操作(`rm -rf /``sudo` | 直接拒绝,不执行 | | 1. 拒绝列表 | 永远禁止的操作(`rm -rf /``sudo` | 直接拒绝,不执行 |
| 2. 规则匹配 | 取决于上下文的操作(写工作区外、`rm` 文件) | 交给闸门 3 | | 2. 规则匹配 | 取决于上下文的操作(读/写工作区外、`rm` 文件) | 交给闸门 3 |
| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 | | 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 |
三道都没命中 → 直接执行。大部分日常操作走这条路。 三道都没命中 → 直接执行。大部分日常操作走这条路。
@@ -59,7 +59,7 @@ def check_deny_list(command: str) -> str | None:
```python ```python
PERMISSION_RULES = [ PERMISSION_RULES = [
{ {
"tools": ["write_file", "edit_file"], "tools": ["read_file", "write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR), "check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Writing outside workspace", "message": "Writing outside workspace",
}, },

View File

@@ -54,16 +54,9 @@ SYSTEM = f"You are a coding agent at {WORKDIR}. All destructive operations requi
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# FROM s02 (unchanged): Tool Implementations # FROM s02 : Tool Implementations
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str: def run_bash(command: str) -> str:
try: try:
r = subprocess.run(command, shell=True, cwd=WORKDIR, r = subprocess.run(command, shell=True, cwd=WORKDIR,
@@ -76,7 +69,7 @@ def run_bash(command: str) -> str:
def run_read(path: str, limit: int | None = None) -> str: def run_read(path: str, limit: int | None = None) -> str:
try: try:
lines = safe_path(path).read_text().splitlines() lines = (WORKDIR / path).resolve().read_text().splitlines()
if limit and limit < len(lines): if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"] lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines) return "\n".join(lines)
@@ -86,7 +79,7 @@ def run_read(path: str, limit: int | None = None) -> str:
def run_write(path: str, content: str) -> str: def run_write(path: str, content: str) -> str:
try: try:
file_path = safe_path(path) file_path = (WORKDIR / path).resolve()
file_path.parent.mkdir(parents=True, exist_ok=True) file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content) file_path.write_text(content)
return f"Wrote {len(content)} bytes to {path}" return f"Wrote {len(content)} bytes to {path}"
@@ -96,7 +89,7 @@ def run_write(path: str, content: str) -> str:
def run_edit(path: str, old_text: str, new_text: str) -> str: def run_edit(path: str, old_text: str, new_text: str) -> str:
try: try:
file_path = safe_path(path) file_path = (WORKDIR / path).resolve()
text = file_path.read_text() text = file_path.read_text()
if old_text not in text: if old_text not in text:
return f"Error: text not found in {path}" return f"Error: text not found in {path}"
@@ -157,7 +150,7 @@ def check_deny_list(command: str) -> str | None:
# Gate 2: Rule matching — context-dependent checks # Gate 2: Rule matching — context-dependent checks
PERMISSION_RULES = [ PERMISSION_RULES = [
{"tools": ["write_file", "edit_file"], {"tools": ["read_file", "write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR), "check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Writing outside workspace"}, "message": "Writing outside workspace"},
{"tools": ["bash"], {"tools": ["bash"],