From df23d488c254c772dd18e631d45cf32591ef4807 Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Mon, 13 Jul 2026 19:49:34 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20Bash=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E8=87=B3=E6=9C=AC=E5=9C=B0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 10 ++-- README.md | 10 ++-- c/cagent.c | 2 +- c/test_classify.c | 46 +++++++++++++++ c/tools.c | 52 +++++++++++++++++ docs/bash-tool-policy.md | 19 ++++++- docs/tools.md | 7 +++ go/agent_test.go | 103 ++++++++++++++++++++++------------ go/cmd/goagent/main.go | 2 +- go/tools.go | 46 +++++++++++++++ rust/src/main.rs | 14 +++-- rust/src/tools.rs | 98 +++++++++++++++++++++++++++++++- src/agent.sh | 40 +++++++++++-- tests/fixtures/mock_server.py | 25 +++++++++ tests/test.sh | 37 ++++++++++++ 15 files changed, 452 insertions(+), 59 deletions(-) diff --git a/README.en.md b/README.en.md index 3377536f..29f222a6 100644 --- a/README.en.md +++ b/README.en.md @@ -172,15 +172,15 @@ Without an API key, images can still be pasted (generating `[Image #N]` placehol | `OPENAI_BASE_URL` | OpenAI API base URL | | `JINA_API_KEY` | Jina AI API key (required for WebSearch/WebFetch tools) | | `BASH_AGENT_HOME` | Override session storage directory (default: `$HOME`) | -| `BASH_AGENT_BASH_MODE` | Bash tool permissions as 4 octal rwx digits: `system/external/network/workspace`; each digit uses `4=read,2=write,1=execute` (default: `0467`) | +| `BASH_AGENT_BASH_MODE` | Bash and local file tool permissions as 4 octal rwx digits: `system/external/network/workspace`; each digit uses `4=read,2=write,1=execute` (default: `0467`) | | `DESCRIBE_API_KEY` | Image description API key (defaults to GLM-4V-Flash, free) | | `DESCRIBE_MODEL` | Image description model name (default: `glm-4v-flash`) | | `DESCRIBE_BASE_URL` | Image description API base URL (default: `https://open.bigmodel.cn/api/paas/v4`) | | `THINKING_BUDGET` | Thinking token budget (default: `2048`) | -## Bash Tool Permission Mode +## Bash and Local File Tool Permission Mode -`BASH_AGENT_BASH_MODE` controls what the `Bash` tool is allowed to touch. It is a 4-digit octal string: +`BASH_AGENT_BASH_MODE` controls what the `Bash`, `Read`, `Write`, `Edit`, `Glob`, and `Grep` tools are allowed to touch. It is a 4-digit octal string: ```text system external network workspace @@ -212,6 +212,8 @@ export BASH_AGENT_BASH_MODE=0457 # allow network execute export BASH_AGENT_BASH_MODE=7777 # fully open, trusted environments only ``` +Local file tools derive equivalent access probes from their paths: `Read`, `Grep`, and `Glob` require read access, while `Write` and `Edit` require write access. A missing path is checked against the current workspace. A pathless `Glob` with an absolute pattern or `..` segment fails closed as a system read. + Invalid values fail closed to `0000`. The model is expressed like Linux-style `rwx` permission bits. Full classification rules, recommended settings, and the shared block message are documented in [`docs/bash-tool-policy.md`](docs/bash-tool-policy.md). DP algorithm environment variables: @@ -300,7 +302,7 @@ Scopes: global (`~/.bash-agent/`) and project (current directory). | Document | Description | | --- | --- | | [`docs/architecture.md`](docs/architecture.md) | Architecture, layering, protocols | -| [`docs/bash-tool-policy.md`](docs/bash-tool-policy.md) | Bash tool permission mode, classification rules, recommended settings | +| [`docs/bash-tool-policy.md`](docs/bash-tool-policy.md) | Bash and local file tool permission mode, classification rules, recommended settings | | [`docs/tools.md`](docs/tools.md) | 13 built-in tool references | | [`docs/compact-analysis.md`](docs/compact-analysis.md) | Compaction algorithm derivation | | [`docs/sessions.md`](docs/sessions.md) | Session files and recovery | diff --git a/README.md b/README.md index 22105c46..13a1db2b 100644 --- a/README.md +++ b/README.md @@ -175,16 +175,16 @@ tcode goagent -p openai -m gpt-4o | `OPENAI_BASE_URL` | OpenAI API base URL | | `JINA_API_KEY` | Jina AI API key(WebSearch/WebFetch 工具需要) | | `BASH_AGENT_HOME` | 覆盖 session 存储目录(默认 `$HOME`) | -| `BASH_AGENT_BASH_MODE` | Bash 工具权限,4 位八进制 `system/external/network/workspace`;每位 `4=read,2=write,1=execute`(默认 `0467`) | +| `BASH_AGENT_BASH_MODE` | Bash 与本地文件工具权限,4 位八进制 `system/external/network/workspace`;每位 `4=read,2=write,1=execute`(默认 `0467`) | | `DESCRIBE_API_KEY` | 图片描述 API key(默认使用 GLM-4V-Flash,免费) | | `DESCRIBE_MODEL` | 图片描述模型名(默认 `glm-4v-flash`) | | `DESCRIBE_BASE_URL` | 图片描述 API 基础地址(默认 `https://open.bigmodel.cn/api/paas/v4`) | | `THINKING` | 覆盖思考模式:`enabled` / `disabled`(CLI `--thinking`) | | `EFFORT` | 覆盖思考力度:`low` / `medium` / `high`(CLI `--effort`) | -## Bash 工具权限模式 +## Bash 与本地文件工具权限模式 -`BASH_AGENT_BASH_MODE` 控制 `Bash` 工具允许访问的范围。它是一个 4 位八进制字符串: +`BASH_AGENT_BASH_MODE` 控制 `Bash`、`Read`、`Write`、`Edit`、`Glob` 和 `Grep` 工具允许访问的范围。它是一个 4 位八进制字符串: ```text system external network workspace @@ -216,6 +216,8 @@ export BASH_AGENT_BASH_MODE=0457 # 允许 network execute export BASH_AGENT_BASH_MODE=7777 # 全开,仅建议受信环境 ``` +本地文件工具按路径生成等价访问探针:`Read`、`Grep`、`Glob` 按读取检查,`Write`、`Edit` 按写入检查;未指定路径时按当前工作区检查。无路径的 `Glob` 若模式为绝对路径或包含 `..`,会失败关闭为系统读取检查。 + 如果值非法,会 fail-closed 为 `0000`。这个模型的表达方式类似 Linux 文件权限的 `rwx` 位。更完整的分类规则、推荐配置和错误文案见 [`docs/bash-tool-policy.md`](docs/bash-tool-policy.md)。 DP 算法相关环境变量: @@ -304,7 +306,7 @@ $$ | 文档 | 说明 | | --- | --- | | [`docs/architecture.md`](docs/architecture.md) | 架构设计、分层、协议 | -| [`docs/bash-tool-policy.md`](docs/bash-tool-policy.md) | Bash 工具权限模式、分类规则、推荐配置 | +| [`docs/bash-tool-policy.md`](docs/bash-tool-policy.md) | Bash 与本地文件工具权限模式、分类规则、推荐配置 | | [`docs/tools.md`](docs/tools.md) | 13 个内置工具详细说明 | | [`docs/compact-analysis.md`](docs/compact-analysis.md) | 压缩算法完整推导 | | [`docs/sessions.md`](docs/sessions.md) | Session 文件结构与恢复 | diff --git a/c/cagent.c b/c/cagent.c index c5cc86c8..66886ccc 100644 --- a/c/cagent.c +++ b/c/cagent.c @@ -47,7 +47,7 @@ static void usage(const char *prog) { fprintf(stderr, " *_API_KEY API key (ANTHROPIC / OPENAI / DEEPSEEK auto-detected)\n"); fprintf(stderr, " *_BASE_URL Override API base URL (ANTHROPIC / OPENAI)\n"); fprintf(stderr, " BASH_AGENT_HOME Override session storage (default: $HOME)\n"); - fprintf(stderr, " BASH_AGENT_BASH_MODE Bash tool permissions (default: 0467)\n"); + fprintf(stderr, " BASH_AGENT_BASH_MODE Bash and local file tool permissions (default: 0467)\n"); } static char *g_paste_session_dir = NULL; diff --git a/c/test_classify.c b/c/test_classify.c index c1326143..88467fe8 100644 --- a/c/test_classify.c +++ b/c/test_classify.c @@ -15,6 +15,13 @@ extern void bash_classify_required_mode(const char *cmd, char out[5]); +typedef struct { + char *output; + int exit_code; +} ToolResult; +extern ToolResult native_file_mode_guard(const char *name, const char *input_json); +extern void tool_result_free(ToolResult *r); + static int g_pass = 0, g_fail = 0; static void t(const char *label, const char *cmd, const char *expect) { @@ -99,6 +106,45 @@ int main(void) { T("cat > /tmp/test.sh << 'EOF' && bash /tmp/test.sh", "0001"); T("git add -A && git commit -m fix && git push", "0023"); + printf("\n=== native file guards ===\n"); + setenv("BASH_AGENT_BASH_MODE", "0467", 1); + ToolResult guard = native_file_mode_guard("Read", "{\"path\":\"src/agent.sh\"}"); + if (!guard.output) { printf("\033[32m✓ PASS\033[0m: workspace Read allowed\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: workspace Read allowed: %s\n", guard.output); g_fail++; } + tool_result_free(&guard); + guard = native_file_mode_guard("Read", "{\"path\":\"/etc/hosts\"}"); + if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: system Read blocked\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: system Read blocked\n"); g_fail++; } + tool_result_free(&guard); + guard = native_file_mode_guard("Write", "{\"path\":\"~/note.txt\"}"); + if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: external Write blocked\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: external Write blocked\n"); g_fail++; } + tool_result_free(&guard); + setenv("BASH_AGENT_BASH_MODE", "0465", 1); + guard = native_file_mode_guard("Edit", "{\"path\":\"src/agent.sh\"}"); + if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: workspace Edit without write blocked\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: workspace Edit without write blocked\n"); g_fail++; } + tool_result_free(&guard); + setenv("BASH_AGENT_BASH_MODE", "0467", 1); + guard = native_file_mode_guard("Grep", "{\"pattern\":\"needle\"}"); + if (!guard.output) { printf("\033[32m✓ PASS\033[0m: default Grep allowed\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: default Grep allowed: %s\n", guard.output); g_fail++; } + tool_result_free(&guard); + guard = native_file_mode_guard("Glob", "{\"pattern\":\"/etc/*\"}"); + if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: absolute default Glob blocked\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: absolute default Glob blocked\n"); g_fail++; } + tool_result_free(&guard); + guard = native_file_mode_guard("Glob", "{\"pattern\":\"../*\"}"); + if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: parent default Glob blocked\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: parent default Glob blocked\n"); g_fail++; } + tool_result_free(&guard); + setenv("BASH_AGENT_BASH_MODE", "invalid", 1); + guard = native_file_mode_guard("Read", "{\"path\":\"src/agent.sh\"}"); + if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: invalid mode fails closed\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: invalid mode fails closed\n"); g_fail++; } + tool_result_free(&guard); + unsetenv("BASH_AGENT_BASH_MODE"); + printf("\n==============================\n"); printf("Results: \033[32m%d passed\033[0m, \033[31m%d failed\033[0m\n", g_pass, g_fail); printf("==============================\n"); diff --git a/c/tools.c b/c/tools.c index 6997bef8..3b5e4303 100644 --- a/c/tools.c +++ b/c/tools.c @@ -523,6 +523,53 @@ static int bash_mode_allows(const char *allowed_mode, const char *required_mode) return (required_val & (4095 ^ allowed_val)) == 0; } +STATIC ToolResult native_file_mode_guard(const char *name, const char *input_json) { + ToolResult r = {NULL, 0}; + JsonParse jp = json_parse_root(input_json); + char *path, *pattern; + const char *raw_path; + char target[2048], probe[2100], allowed_mode[5], required_mode[5]; + if (jp.error) return r; + path = json_get_string(jp.val, "path"); + pattern = json_get_string(jp.val, "pattern"); + raw_path = (path && path[0]) ? path : "."; + if (raw_path[0] == '/' || strcmp(raw_path, "~") == 0 || + strncmp(raw_path, "~/", 2) == 0 || strncmp(raw_path, "./", 2) == 0 || + strncmp(raw_path, "../", 3) == 0) { + snprintf(target, sizeof(target), "%s", raw_path); + } else { + snprintf(target, sizeof(target), "./%s", raw_path); + } + if (strcmp(name, "Read") == 0 || strcmp(name, "Grep") == 0) { + snprintf(probe, sizeof(probe), "cat %s", target); + } else if (strcmp(name, "Write") == 0 || strcmp(name, "Edit") == 0) { + snprintf(probe, sizeof(probe), ": > %s", target); + } else if (strcmp(name, "Glob") == 0) { + if ((!path || !path[0]) && pattern && (pattern[0] == '/' || strstr(pattern, ".."))) { + snprintf(probe, sizeof(probe), "cat /"); + } else { + snprintf(probe, sizeof(probe), "cat %s", target); + } + } else { + free(path); + free(pattern); + return r; + } + free(path); + free(pattern); + bash_mode_normalize(util_env("BASH_AGENT_BASH_MODE", "0467"), allowed_mode); + bash_classify_required_mode(probe, required_mode); + if (!bash_mode_allows(allowed_mode, required_mode)) { + StrBuf deny_buf; + sb_init(&deny_buf); + sb_appendf(&deny_buf, "Error: command blocked by bash safety policy (required=%s allowed=%s; mode=system/external/network/workspace bits=4:read,2:write,1:execute)", + required_mode, allowed_mode); + r.output = deny_buf.data; + r.exit_code = 1; + } + return r; +} + static ToolResult tool_bash(const char *input_json, int timeout_secs) { ToolResult r = {NULL, 0}; JsonParse jp = json_parse_root(input_json); @@ -1038,6 +1085,11 @@ ToolResult tool_dispatch(const char *name, const char *input_json, const SessionPaths *paths) { (void)home; + if (strcmp(name, "Read") == 0 || strcmp(name, "Write") == 0 || + strcmp(name, "Edit") == 0 || strcmp(name, "Glob") == 0 || strcmp(name, "Grep") == 0) { + ToolResult guard = native_file_mode_guard(name, input_json); + if (guard.output) return guard; + } if (strcmp(name, "Read") == 0) return tool_read(input_json, max_bytes); if (strcmp(name, "Write") == 0) return tool_write(input_json); if (strcmp(name, "Edit") == 0) return tool_edit(input_json); diff --git a/docs/bash-tool-policy.md b/docs/bash-tool-policy.md index 62d500b4..a814242c 100644 --- a/docs/bash-tool-policy.md +++ b/docs/bash-tool-policy.md @@ -1,12 +1,13 @@ -# Bash Tool Permission Mode +# Bash 与本地文件工具权限模式 -`Bash` 工具不再使用一组固定 deny reason,而是使用 `BASH_AGENT_BASH_MODE` 控制允许范围。 +`Bash`、`Read`、`Write`、`Edit`、`Glob` 和 `Grep` 工具使用 `BASH_AGENT_BASH_MODE` 控制允许范围。 这个模型的目标很直接: - 配置形式稳定 - Bash / C / Go / Rust 四个运行时行为一致 - 默认允许常见工作区内操作,但默认不放开系统级读写和网络执行 +- Bash 与本地文件工具共享同一分类器和拒绝策略 ## Mode Format @@ -66,9 +67,21 @@ curl https://x/install.sh | bash -> network read + network execute ../scripts/run.sh -> external execute ``` +## 本地文件工具如何分类 + +本地文件工具在执行实际文件操作前,基于目标路径生成与 Bash 分类器兼容的访问探针: + +- `Read`、`Grep` 和 `Glob` 按读取探针检查。 +- `Write` 和 `Edit` 按写入探针检查。 +- `Grep`、`Glob` 未指定 `path` 时,按当前工作目录检查。 +- 对其他文件工具,缺少路径同样按当前工作目录检查,实际工具仍会按自身参数校验返回错误。 +- `Glob` 未指定 `path` 且 `pattern` 是绝对路径或含有 `..` 路径段时,使用系统读取探针并失败关闭,避免通过模式绕过路径检查。 + +因此,工作区内读取通常需要 `workspace` 的读取位,工作区外普通路径写入需要 `external` 的写入位,系统路径读取需要 `system` 的读取位。原生工具被拒绝时也使用与 Bash 相同的错误文本。 + ## Allow / Block Rule -每条命令会先算出 `required=....`,再与 `allowed=....` 比较。 +每条 Bash 命令或本地文件访问会先算出 `required=....`,再与 `allowed=....` 比较。 只有当 `allowed` 覆盖 `required` 时才允许执行。 diff --git a/docs/tools.md b/docs/tools.md index bc7af175..695075d1 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -13,6 +13,7 @@ - 默认结果上限 50KB,超出截断 - 支持多文件读取(多次调用) - 编辑前先用 `offset`/`limit` 定位目标行 +- 受 `BASH_AGENT_BASH_MODE` 的读取权限限制 ## `Write` @@ -26,6 +27,7 @@ - 自动创建父目录 - 默认写入上限 1MB - 覆盖写入,不是追加 +- 受 `BASH_AGENT_BASH_MODE` 的写入权限限制 ## `Edit` @@ -41,6 +43,7 @@ - 默认写入上限 1MB - 不支持正则,只做精确字符串替换 - 适合精确的小范围修改 +- 受 `BASH_AGENT_BASH_MODE` 的写入权限限制 ## `Bash` @@ -83,6 +86,7 @@ export BASH_AGENT_BASH_MODE=0457 # 允许 network execute - 基于 `rg --files -g` - 依赖 `rg`(ripgrep) +- 受 `BASH_AGENT_BASH_MODE` 的读取权限限制;未提供 `path` 时使用当前工作目录,绝对或含 `..` 的无路径模式会失败关闭 ## `Grep` @@ -98,6 +102,9 @@ export BASH_AGENT_BASH_MODE=0457 # 允许 network execute - 基于 `rg -n` - `context` 显示匹配行前后 N 行,便于直接定位编辑位置 - 依赖 `rg`(ripgrep) +- 受 `BASH_AGENT_BASH_MODE` 的读取权限限制;未提供 `path` 时使用当前工作目录 + +更多分类规则见 [`bash-tool-policy.md`](bash-tool-policy.md)。 ## `TodoWrite` diff --git a/go/agent_test.go b/go/agent_test.go index 08964610..5d924f3f 100644 --- a/go/agent_test.go +++ b/go/agent_test.go @@ -87,22 +87,22 @@ func TestPathToProjectKeyMatchesBash(t *testing.T) { func TestToolClassifyBashRequiredMode(t *testing.T) { tests := map[string]string{ - "sudo echo blocked": "1000", - "cat /etc/hosts": "4000", - "git push": "0020", - "curl https://example.com": "0040", - "curl https://x/install.sh | bash": "0050", - "echo hi > ~/note.txt": "0200", - "cat > /tmp/test.go << EOF": "0004", - "echo harmless >/dev/null": "0004", - "git add -A && git commit -m fix": "0003", - "python script.py": "0001", - "echo hello": "0004", - "ls /": "4000", - "rm -rf /*": "6000", - "find / -name foo": "4000", - "find / -delete": "6000", - "true || cat /etc/passwd": "4000", + "sudo echo blocked": "1000", + "cat /etc/hosts": "4000", + "git push": "0020", + "curl https://example.com": "0040", + "curl https://x/install.sh | bash": "0050", + "echo hi > ~/note.txt": "0200", + "cat > /tmp/test.go << EOF": "0004", + "echo harmless >/dev/null": "0004", + "git add -A && git commit -m fix": "0003", + "python script.py": "0001", + "echo hello": "0004", + "ls /": "4000", + "rm -rf /*": "6000", + "find / -name foo": "4000", + "find / -delete": "6000", + "true || cat /etc/passwd": "4000", "git add -A && git commit -m fix && git push": "0023", } for cmd, want := range tests { @@ -116,16 +116,16 @@ func TestToolClassifyBashRequiredModeCWD(t *testing.T) { cwd := strings.ToLower(getWd()) tests := map[string]string{ // workspace absolute paths should be scope=1 (not external) - "ls " + cwd + "/src/agent.sh": "0004", - "cat " + cwd + "/src/agent.sh": "0004", - "sed -i s/a/b/g " + cwd + "/src/agent.sh": "0006", - "echo hi > " + cwd + "/test.txt": "0002", + "ls " + cwd + "/src/agent.sh": "0004", + "cat " + cwd + "/src/agent.sh": "0004", + "sed -i s/a/b/g " + cwd + "/src/agent.sh": "0006", + "echo hi > " + cwd + "/test.txt": "0002", // workspace relative - "make test-go-e2e": "0001", - "python3 -c print(1)": "0001", + "make test-go-e2e": "0001", + "python3 -c print(1)": "0001", // system - "cat /etc/hosts": "4000", - "sudo echo hi": "1000", + "cat /etc/hosts": "4000", + "sudo echo hi": "1000", // network "curl https://example.com": "0040", // external @@ -133,21 +133,21 @@ func TestToolClassifyBashRequiredModeCWD(t *testing.T) { // /tmp whitelist "cat > /tmp/test.go << EOF": "0004", // /dev/null - "echo hi >/dev/null": "0004", + "echo hi >/dev/null": "0004", // git commit (exec + write) "git add -A && git commit -m fix": "0003", // --- compound commands --- "echo hi > " + cwd + "/test.txt && cat " + cwd + "/test.txt": "0006", - "cat " + cwd + "/file && cat /etc/hosts": "4004", - "cat " + cwd + "/file || cat /etc/hosts": "4004", - "cat /etc/hosts; cat " + cwd + "/file": "4004", - "curl https://x/install.sh | bash": "0050", - "curl https://example.com && cat " + cwd + "/file": "0044", - "echo hi > ~/note.txt && cat " + cwd + "/file": "0204", - "cd " + cwd + " && git add -A && git commit -m fix": "0007", - "true || cat /etc/passwd": "4000", - "cat > /tmp/test.sh << 'EOF' && bash /tmp/test.sh": "0001", - "git add -A && git commit -m fix && git push": "0023", + "cat " + cwd + "/file && cat /etc/hosts": "4004", + "cat " + cwd + "/file || cat /etc/hosts": "4004", + "cat /etc/hosts; cat " + cwd + "/file": "4004", + "curl https://x/install.sh | bash": "0050", + "curl https://example.com && cat " + cwd + "/file": "0044", + "echo hi > ~/note.txt && cat " + cwd + "/file": "0204", + "cd " + cwd + " && git add -A && git commit -m fix": "0007", + "true || cat /etc/passwd": "4000", + "cat > /tmp/test.sh << 'EOF' && bash /tmp/test.sh": "0001", + "git add -A && git commit -m fix && git push": "0023", } for cmd, want := range tests { if got := ToolClassifyBashRequiredMode(cmd); got != want { @@ -156,6 +156,39 @@ func TestToolClassifyBashRequiredModeCWD(t *testing.T) { } } +func TestNativeFileModeGuard(t *testing.T) { + tests := []struct { + name string + mode string + tool string + path string + pattern string + wantErr string + }{ + {"workspace read", "0467", "Read", "src/agent.sh", "", ""}, + {"system read", "0467", "Read", "/etc/hosts", "", "required=4000"}, + {"external write", "0467", "Write", "~/native-file-mode-test", "", "required=0200"}, + {"workspace write", "0465", "Write", "native-file-mode-test", "", "required=0002"}, + {"default grep path", "0467", "Grep", "", "", ""}, + {"default glob path", "0467", "Glob", "", "*.go", ""}, + {"absolute glob pattern", "0467", "Glob", "", "/etc/*", "required=4000"}, + {"parent glob pattern", "0467", "Glob", "", "../*", "required=4000"}, + {"invalid mode", "invalid", "Read", "src/agent.sh", "", "required=0004"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("BASH_AGENT_BASH_MODE", tc.mode) + err := nativeFileModeGuard(tc.tool, tc.path, tc.pattern) + if tc.wantErr == "" && err != nil { + t.Fatalf("nativeFileModeGuard() error = %v", err) + } + if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) { + t.Fatalf("nativeFileModeGuard() error = %v, want substring %q", err, tc.wantErr) + } + }) + } +} + func TestToolBashModeAllows(t *testing.T) { tests := []struct { allowed string diff --git a/go/cmd/goagent/main.go b/go/cmd/goagent/main.go index e492e15f..a83ab7b9 100644 --- a/go/cmd/goagent/main.go +++ b/go/cmd/goagent/main.go @@ -71,7 +71,7 @@ Environment: *_API_KEY API key (ANTHROPIC / OPENAI / DEEPSEEK auto-detected) *_BASE_URL Override API base URL (ANTHROPIC / OPENAI) BASH_AGENT_HOME Override session storage (default: $HOME) - BASH_AGENT_BASH_MODE Bash tool permissions (default: 0467) + BASH_AGENT_BASH_MODE Bash and local file tool permissions (default: 0467) Examples: goagent "Read /etc/hostname and tell me what it says" diff --git a/go/tools.go b/go/tools.go index 50447b39..0c07041b 100644 --- a/go/tools.go +++ b/go/tools.go @@ -84,16 +84,31 @@ func (td *ToolDispatcher) SetSkillLoader(fn func(name string) (string, error)) { func (td *ToolDispatcher) Dispatch(ctx context.Context, name string, params map[string]string) (string, error) { switch name { case "Read": + if err := nativeFileModeGuard(name, params["path"], ""); err != nil { + return "", err + } return td.toolRead(params["path"], params["offset"], params["limit"]) case "Write": + if err := nativeFileModeGuard(name, params["path"], ""); err != nil { + return "", err + } return td.toolWrite(params["path"], params["content"]) case "Edit": + if err := nativeFileModeGuard(name, params["path"], ""); err != nil { + return "", err + } return td.toolEdit(params["path"], params["old_string"], params["new_string"]) case "Bash": return td.toolBash(ctx, params["command"], params["timeout"], params["background"]) case "Glob": + if err := nativeFileModeGuard(name, params["path"], params["pattern"]); err != nil { + return "", err + } return td.toolGlob(params["pattern"], params["path"]) case "Grep": + if err := nativeFileModeGuard(name, params["path"], ""); err != nil { + return "", err + } return td.toolGrep(params["pattern"], params["path"], params["glob"], params["context"]) case "TodoWrite": return td.toolTodoWrite(params) @@ -509,6 +524,37 @@ func ToolBashModeAllows(allowed, required string) bool { return requiredVal&(4095^allowedVal) == 0 } +func nativeFileModeGuard(name, path, pattern string) error { + target := path + if target == "" { + target = "." + } + if !strings.HasPrefix(target, "/") && target != "~" && !strings.HasPrefix(target, "~/") && !strings.HasPrefix(target, "./") && !strings.HasPrefix(target, "../") { + target = "./" + target + } + probe := "" + switch name { + case "Read", "Grep": + probe = "cat " + target + case "Write", "Edit": + probe = ": > " + target + case "Glob": + if path == "" && (strings.HasPrefix(pattern, "/") || strings.Contains(pattern, "..")) { + probe = "cat /" + } else { + probe = "cat " + target + } + default: + return nil + } + allowedMode := toolBashModeNormalize(os.Getenv("BASH_AGENT_BASH_MODE")) + requiredMode := ToolClassifyBashRequiredMode(probe) + if !ToolBashModeAllows(allowedMode, requiredMode) { + return fmt.Errorf("command blocked by bash safety policy (required=%s allowed=%s; mode=system/external/network/workspace bits=4:read,2:write,1:execute)", requiredMode, allowedMode) + } + return nil +} + // toolBash 执行 shell 命令 func (td *ToolDispatcher) toolBash(ctx context.Context, cmd, timeoutStr, backgroundStr string) (string, error) { if cmd == "" { diff --git a/rust/src/main.rs b/rust/src/main.rs index f2cfea59..6a4a9bd8 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -169,8 +169,12 @@ fn print_usage() { println!(" -m, --model MODEL Model name"); println!(" --max-tokens N Max output tokens (default: 16384)"); println!(" --tool-timeout N Tool execution timeout in seconds (default: 600)"); - println!(" --effort LEVEL Thinking effort: low|medium|high|xhigh|max (default: high)"); - println!(" --thinking MODE Thinking mode: adaptive|enabled|disabled (default: adaptive)"); + println!( + " --effort LEVEL Thinking effort: low|medium|high|xhigh|max (default: high)" + ); + println!( + " --thinking MODE Thinking mode: adaptive|enabled|disabled (default: adaptive)" + ); println!( " --skill NAME Load skill from .claude/skills/NAME/SKILL.md (fallback: ~/.claude/skills)" ); @@ -182,7 +186,9 @@ fn print_usage() { println!(" --print Alias for --output-format stream-json"); println!(" --session [NAME] Use named session"); println!(" --continue Continue most recent session"); - println!(" --fork When resuming, create a new forked session instead of reusing the source (use with --session or --continue)"); + println!( + " --fork When resuming, create a new forked session instead of reusing the source (use with --session or --continue)" + ); println!(" --list-sessions List saved sessions"); println!(" -v, --verbose Verbose mode"); println!(" -i, --interactive Interactive mode (REPL)"); @@ -192,7 +198,7 @@ fn print_usage() { println!(" *_API_KEY API key (ANTHROPIC / OPENAI / DEEPSEEK auto-detected)"); println!(" *_BASE_URL Override API base URL (ANTHROPIC / OPENAI)"); println!(" BASH_AGENT_HOME Override session storage (default: $HOME)"); - println!(" BASH_AGENT_BASH_MODE Bash tool permissions (default: 0467)"); + println!(" BASH_AGENT_BASH_MODE Bash and local file tool permissions (default: 0467)"); } fn list_sessions(home: &Path, cwd: &Path) -> Result<()> { diff --git a/rust/src/tools.rs b/rust/src/tools.rs index ac7ebfb1..c90c34c1 100644 --- a/rust/src/tools.rs +++ b/rust/src/tools.rs @@ -60,6 +60,7 @@ use crate::config::Config; limit: Option, } let args: Args = serde_json::from_value(input.clone())?; + self.native_file_mode_guard("Read", &args.path, "")?; self.read(&args.path, args.offset, args.limit) } "Write" => { @@ -69,6 +70,7 @@ use crate::config::Config; content: String, } let args: Args = serde_json::from_value(input.clone())?; + self.native_file_mode_guard("Write", &args.path, "")?; self.write(&args.path, &args.content) } "Edit" => { @@ -79,6 +81,7 @@ use crate::config::Config; new_string: String, } let args: Args = serde_json::from_value(input.clone())?; + self.native_file_mode_guard("Edit", &args.path, "")?; self.edit(&args.path, &args.old_string, &args.new_string) } "Bash" => { @@ -99,6 +102,7 @@ use crate::config::Config; path: String, } let args: Args = serde_json::from_value(input.clone())?; + self.native_file_mode_guard("Glob", &args.path, &args.pattern)?; self.glob( &args.pattern, if args.path.is_empty() { @@ -120,6 +124,7 @@ use crate::config::Config; context: Option, } let args: Args = serde_json::from_value(input.clone())?; + self.native_file_mode_guard("Grep", &args.path, "")?; self.grep( &args.pattern, if args.path.is_empty() { @@ -258,6 +263,43 @@ use crate::config::Config; Ok(format!("{summary}\n{diff}")) } + fn native_file_mode_guard(&self, name: &str, path: &str, pattern: &str) -> Result<()> { + let mut target = if path.is_empty() { + ".".to_string() + } else { + path.to_string() + }; + if !target.starts_with('/') + && target != "~" + && !target.starts_with("~/") + && !target.starts_with("./") + && !target.starts_with("../") + { + target = format!("./{target}"); + } + let probe = match name { + "Read" | "Grep" => format!("cat {target}"), + "Write" | "Edit" => format!(": > {target}"), + "Glob" if path.is_empty() && (pattern.starts_with('/') || pattern.contains("..")) => { + "cat /".to_string() + } + "Glob" => format!("cat {target}"), + _ => return Ok(()), + }; + let allowed_mode = bash_mode_normalize( + &std::env::var("BASH_AGENT_BASH_MODE").unwrap_or_else(|_| "0467".to_string()), + ); + let required_mode = classify_bash_required_mode(&probe); + if !bash_mode_allows(&allowed_mode, &required_mode) { + bail!( + "Error: command blocked by bash safety policy (required={} allowed={}; mode=system/external/network/workspace bits=4:read,2:write,1:execute)", + required_mode, + allowed_mode + ); + } + Ok(()) + } + fn bash(&self, command: &str, timeout_secs: Option) -> Result { if command.trim().is_empty() { bail!("no command provided"); @@ -1216,7 +1258,7 @@ use crate::config::Config; use std::os::fd::AsRawFd; use std::os::unix::io::IntoRawFd; use std::sync::atomic::AtomicBool; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; #[test] @@ -1251,6 +1293,60 @@ use crate::config::Config; let _ = fs::remove_dir_all(root); } + #[test] + fn native_file_mode_guard_matches_bash() { + static ENV_LOCK: Mutex<()> = Mutex::new(()); + let _lock = ENV_LOCK.lock().unwrap(); + let prior = std::env::var_os("BASH_AGENT_BASH_MODE"); + let root = std::env::current_dir().unwrap(); + let runner = Runner { + config: Config::default(), + cwd: root.clone(), + home: root.join("home"), + interrupted: Arc::new(AtomicBool::new(false)), + cancel_fd: -1, + }; + + unsafe { std::env::set_var("BASH_AGENT_BASH_MODE", "0467") }; + assert!( + runner + .native_file_mode_guard("Read", "src/agent.sh", "") + .is_ok() + ); + assert!( + runner + .native_file_mode_guard("Read", "/etc/hosts", "") + .is_err() + ); + assert!( + runner + .native_file_mode_guard("Write", "~/note.txt", "") + .is_err() + ); + assert!(runner.native_file_mode_guard("Grep", "", "").is_ok()); + assert!(runner.native_file_mode_guard("Glob", "", "/etc/*").is_err()); + assert!(runner.native_file_mode_guard("Glob", "", "../*").is_err()); + + unsafe { std::env::set_var("BASH_AGENT_BASH_MODE", "0465") }; + assert!( + runner + .native_file_mode_guard("Edit", "src/agent.sh", "") + .is_err() + ); + unsafe { std::env::set_var("BASH_AGENT_BASH_MODE", "invalid") }; + assert!( + runner + .native_file_mode_guard("Read", "src/agent.sh", "") + .is_err() + ); + + if let Some(value) = prior { + unsafe { std::env::set_var("BASH_AGENT_BASH_MODE", value) }; + } else { + unsafe { std::env::remove_var("BASH_AGENT_BASH_MODE") }; + } + } + #[test] fn bash_mode_classifier_matches_bash() { let cases = [ diff --git a/src/agent.sh b/src/agent.sh index 2f724871..b9b57f84 100755 --- a/src/agent.sh +++ b/src/agent.sh @@ -625,12 +625,12 @@ llm_summary_call() { tool_dispatch() { local name="$1"; shift case "$name" in - Read) tool_read "$@" ;; - Write) tool_write "$1" "$2" ;; - Edit) tool_edit "$@" ;; + Read) tool_native_file_mode_guard "$name" "$1" || return 1; tool_read "$@" ;; + Write) tool_native_file_mode_guard "$name" "$1" || return 1; tool_write "$1" "$2" ;; + Edit) tool_native_file_mode_guard "$name" "$1" || return 1; tool_edit "$@" ;; Bash) tool_bash "$1" "$2" "$3" ;; - Glob) tool_glob "$1" "$2" ;; - Grep) tool_grep "$1" "$2" "$3" "$4" ;; + Glob) tool_native_file_mode_guard "$name" "$2" "$1" || return 1; tool_glob "$1" "$2" ;; + Grep) tool_native_file_mode_guard "$name" "$2" || return 1; tool_grep "$1" "$2" "$3" "$4" ;; TodoWrite) printf '%s' "$1" ;; PlanConfirm) tool_plan_confirm ;; PlanClear) tool_plan_clear ;; @@ -861,6 +861,34 @@ tool_bash_mode_allows() { (( (8#$required & (4095 ^ 8#$allowed)) == 0 )) } +tool_native_file_mode_guard() { + local name="$1" path="${2:-}" pattern="${3:-}" target probe allowed_mode required_mode + target="${path:-.}" + case "$target" in + /*|~|~/*|./*|../*) ;; + *) target="./$target" ;; + esac + case "$name" in + Read|Grep) probe="cat $target" ;; + Write|Edit) probe=": > $target" ;; + Glob) + if [[ -z "$path" && ( "$pattern" == /* || "$pattern" == *..* ) ]]; then + probe="cat /" + else + probe="cat $target" + fi + ;; + *) return 0 ;; + esac + allowed_mode=$(tool_bash_mode_normalize "${BASH_AGENT_BASH_MODE:-0467}") + tool_classify_bash_required_mode "$probe" >/dev/null + required_mode="${TOOL_BASH_REQUIRED_MODE:-0000}" + if ! tool_bash_mode_allows "$allowed_mode" "$required_mode"; then + echo "command blocked by bash safety policy (required=$required_mode allowed=$allowed_mode; mode=system/external/network/workspace bits=4:read,2:write,1:execute)" + return 1 + fi +} + tool_read() { local path="$1" offset="${2:-1}" limit="${3:-0}" [[ -z "$path" ]] && { echo "no path provided"; return 1; } @@ -1547,7 +1575,7 @@ Environment: *_API_KEY API key (ANTHROPIC / OPENAI / DEEPSEEK auto-detected) *_BASE_URL Override API base URL (ANTHROPIC / OPENAI) BASH_AGENT_HOME Override session storage (default: $HOME) - BASH_AGENT_BASH_MODE Bash tool permissions (default: 0467) + BASH_AGENT_BASH_MODE Bash and local file tool permissions (default: 0467) Examples: ./agent.sh "Read /etc/hostname and tell me what it says" diff --git a/tests/fixtures/mock_server.py b/tests/fixtures/mock_server.py index 81e86e4b..143c297d 100644 --- a/tests/fixtures/mock_server.py +++ b/tests/fixtures/mock_server.py @@ -853,6 +853,31 @@ def do_POST(self): else: self.send_response(422); self.end_headers(); w.write(b'missing blocked delete tool_result content') return + if b'NATIVE_READ_SYSTEM_BLOCK_MARKER' in body and b'"tool_result"' not in body: + if path.startswith('/v1/messages'): + for c in [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_native_read_system_block","role":"assistant","content":[],"model":"test","usage":{"input_tokens":10,"output_tokens":0}}}\n\n', + 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_native_read_system_block_1","name":"Read","input":{}}}\n\n', + 'event: content_block_delta\ndata: ' + json.dumps({'type':'content_block_delta','index':0,'delta':{'type':'input_json_delta','partial_json': json.dumps({'path':'/etc/hosts'})}}) + '\n\n', + 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":20}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ]: w.write(c.encode()); w.flush() + return + if b'NATIVE_READ_SYSTEM_BLOCK_MARKER' in body and b'"tool_result"' in body: + if path.startswith('/v1/messages'): + if b'command blocked by bash safety policy' in body and b'required=4000' in body and b'allowed=0447' in body: + for c in [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_native_read_system_block_done","role":"assistant","content":[],"model":"test","usage":{"input_tokens":10,"output_tokens":0}}}\n\n', + 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n', + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Blocked native system read handled."}}\n\n', + 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ]: w.write(c.encode()); w.flush() + else: + self.send_response(422); self.end_headers(); w.write(b'missing blocked native read tool_result content') + return if b'BASH_SYSTEM_READ_BLOCK_MARKER' in body and b'"tool_result"' not in body: if path.startswith('/v1/messages'): for c in [ diff --git a/tests/test.sh b/tests/test.sh index ced8fe60..f7ed3a8d 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -1012,6 +1012,19 @@ test_agent_bash_blocks_system_read_mode() { "4000" } +test_agent_native_read_blocks_system_read_mode() { + local output + info "Test 25aca: Agent.sh native Read blocks system read (required=4000)" + output=$(BASH_AGENT_BASH_MODE=0447 "$AGENT" -p claude --base-url "$BASE/v1" -m test --api-key test 'NATIVE_READ_SYSTEM_BLOCK_MARKER' 2>&1) || true + # 工具结果不会在终端展示;模拟服务仅在收到预期拒绝文本时才返回此确认语。 + if [[ "$output" == *"[tool] Read(/etc/hosts)"* ]] && \ + [[ "$output" == *"Blocked native system read handled."* ]]; then + green "Agent.sh native Read blocks system read"; ((PASS++)) || true + else + red "Agent.sh native Read blocks system read"; echo " Output: $output"; ((FAIL++)) || true + fi +} + test_agent_bash_blocks_network_execute_mode() { test_agent_bash_mode_blocked_case \ "Test 25ad: Agent.sh bash blocks network execute (required=0050)" \ @@ -2671,6 +2684,7 @@ test_agent_bash_quotes test_agent_bash_blocked_command test_agent_bash_blocked_delete_command test_agent_bash_blocks_system_read_mode +test_agent_native_read_blocks_system_read_mode test_agent_bash_blocks_network_execute_mode test_agent_bash_blocks_external_write_mode test_agent_bash_invalid_mode_fails_closed @@ -2739,6 +2753,8 @@ test_bash_mode_scanner() { eval "$(sed -n '/^tool_bash_scan_script()/,/^}/p' "$ROOT_DIR/src/agent.sh")" eval "$(sed -n '/^tool_bash_mode_normalize()/,/^}/p' "$ROOT_DIR/src/agent.sh")" eval "$(sed -n '/^tool_classify_bash_required_mode()/,/^}/p' "$ROOT_DIR/src/agent.sh")" + eval "$(sed -n '/^tool_bash_mode_allows()/,/^}/p' "$ROOT_DIR/src/agent.sh")" + eval "$(sed -n '/^tool_native_file_mode_guard()/,/^}/p' "$ROOT_DIR/src/agent.sh")" local scan_pass=0 scan_fail=0 local CWD="${ROOT_DIR}" @@ -2754,6 +2770,27 @@ test_bash_mode_scanner() { fi } + assert_native_mode() { + local desc=$1 name=$2 path=$3 pattern=$4 allowed=$5 expect_rc=$6 expect_required=$7 output rc + output=$(BASH_AGENT_BASH_MODE="$allowed" tool_native_file_mode_guard "$name" "$path" "$pattern" 2>&1); rc=$? + if [[ "$rc" == "$expect_rc" ]] && { [[ "$expect_rc" != 0 ]] || [[ -z "$output" ]]; } && \ + { [[ -z "$expect_required" ]] || [[ "$output" == *"required=$expect_required"* ]]; }; then + green "native-file-mode: $desc"; ((scan_pass++)) || true + else + red "native-file-mode: $desc (rc=$rc output=$output)"; ((scan_fail++)) || true + fi + } + + assert_native_mode "workspace Read allowed" Read "$CWD/src/agent.sh" "" 0467 0 "" + assert_native_mode "system Read blocked" Read /etc/hosts "" 0467 1 4000 + assert_native_mode "external Write blocked" Write "$HOME/native-file-mode-test.txt" "" 0467 1 0200 + assert_native_mode "workspace Write blocked" Write "$CWD/native-file-mode-test.txt" "" 0465 1 0002 + assert_native_mode "Grep default path uses workspace" Grep "" "" 0467 0 "" + assert_native_mode "Glob default path uses workspace" Glob "" "*.sh" 0467 0 "" + assert_native_mode "Glob absolute pattern without path fails closed" Glob "" "/etc/*" 0467 1 4000 + assert_native_mode "Glob parent pattern without path fails closed" Glob "" "../*" 0467 1 4000 + assert_native_mode "invalid mode fails closed" Read "$CWD/src/agent.sh" "" bad1 1 0004 + # --- workspace 绝对路径读(CWD 前缀)→ scope=1 --- assert_mode "workspace abs read: ls" \ "ls $CWD/src/agent.sh" "0004" From ba3090e3e1e56ea7fbed6867c15f8bd6300ef3d9 Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Mon, 13 Jul 2026 20:10:06 +0800 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20=E5=A4=8D=E7=94=A8=20Bash=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=9D=83=E9=99=90=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent.sh | 39 ++++++++++++++++----------------------- tests/test.sh | 1 + 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/agent.sh b/src/agent.sh index b9b57f84..ffb9ec26 100755 --- a/src/agent.sh +++ b/src/agent.sh @@ -861,8 +861,19 @@ tool_bash_mode_allows() { (( (8#$required & (4095 ^ 8#$allowed)) == 0 )) } +tool_bash_mode_guard() { + local cmd="$1" allowed_mode required_mode + allowed_mode=$(tool_bash_mode_normalize "${BASH_AGENT_BASH_MODE:-0467}") + tool_classify_bash_required_mode "$cmd" >/dev/null + required_mode="${TOOL_BASH_REQUIRED_MODE:-0000}" + if ! tool_bash_mode_allows "$allowed_mode" "$required_mode"; then + echo "command blocked by bash safety policy (required=$required_mode allowed=$allowed_mode; mode=system/external/network/workspace bits=4:read,2:write,1:execute)" + return 1 + fi +} + tool_native_file_mode_guard() { - local name="$1" path="${2:-}" pattern="${3:-}" target probe allowed_mode required_mode + local name="$1" path="${2:-}" pattern="${3:-}" target probe target="${path:-.}" case "$target" in /*|~|~/*|./*|../*) ;; @@ -871,22 +882,10 @@ tool_native_file_mode_guard() { case "$name" in Read|Grep) probe="cat $target" ;; Write|Edit) probe=": > $target" ;; - Glob) - if [[ -z "$path" && ( "$pattern" == /* || "$pattern" == *..* ) ]]; then - probe="cat /" - else - probe="cat $target" - fi - ;; + Glob) [[ -z "$path" && ( "$pattern" == /* || "$pattern" == *..* ) ]] && probe="cat /" || probe="cat $target" ;; *) return 0 ;; esac - allowed_mode=$(tool_bash_mode_normalize "${BASH_AGENT_BASH_MODE:-0467}") - tool_classify_bash_required_mode "$probe" >/dev/null - required_mode="${TOOL_BASH_REQUIRED_MODE:-0000}" - if ! tool_bash_mode_allows "$allowed_mode" "$required_mode"; then - echo "command blocked by bash safety policy (required=$required_mode allowed=$allowed_mode; mode=system/external/network/workspace bits=4:read,2:write,1:execute)" - return 1 - fi + tool_bash_mode_guard "$probe" } tool_read() { @@ -932,15 +931,9 @@ tool_edit() { } tool_bash() { - local cmd="$1" timeout_secs="${2:-$TOOL_TIMEOUT_SECS}" background="${3:-false}" allowed_mode required_mode tool_rc tmpout + local cmd="$1" timeout_secs="${2:-$TOOL_TIMEOUT_SECS}" background="${3:-false}" tool_rc tmpout [[ -z "$cmd" ]] && { echo "no command provided"; return 1; } - allowed_mode=$(tool_bash_mode_normalize "${BASH_AGENT_BASH_MODE:-0467}") - tool_classify_bash_required_mode "$cmd" >/dev/null - required_mode="${TOOL_BASH_REQUIRED_MODE:-0000}" - if ! tool_bash_mode_allows "$allowed_mode" "$required_mode"; then - echo "command blocked by bash safety policy (required=$required_mode allowed=$allowed_mode; mode=system/external/network/workspace bits=4:read,2:write,1:execute)" - return 1 - fi + tool_bash_mode_guard "$cmd" || return 1 if [[ "$background" == "true" || "$background" == "1" ]]; then local task_id="task_$(util_new_session_id)" { local __cnt=$(cat "$ACTIVE_TASK_FILE" 2>/dev/null || echo 0); printf '%d\n' $(( __cnt + 1 )) > "$ACTIVE_TASK_FILE"; } diff --git a/tests/test.sh b/tests/test.sh index f7ed3a8d..81448e7d 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -2754,6 +2754,7 @@ test_bash_mode_scanner() { eval "$(sed -n '/^tool_bash_mode_normalize()/,/^}/p' "$ROOT_DIR/src/agent.sh")" eval "$(sed -n '/^tool_classify_bash_required_mode()/,/^}/p' "$ROOT_DIR/src/agent.sh")" eval "$(sed -n '/^tool_bash_mode_allows()/,/^}/p' "$ROOT_DIR/src/agent.sh")" + eval "$(sed -n '/^tool_bash_mode_guard()/,/^}/p' "$ROOT_DIR/src/agent.sh")" eval "$(sed -n '/^tool_native_file_mode_guard()/,/^}/p' "$ROOT_DIR/src/agent.sh")" local scan_pass=0 scan_fail=0 From 395f24535dc727619f35793c91303cd5dcd95835 Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Mon, 13 Jul 2026 20:18:22 +0800 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20=E7=B2=BE=E7=AE=80=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=96=87=E4=BB=B6=E6=9D=83=E9=99=90=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent.sh | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/agent.sh b/src/agent.sh index ffb9ec26..e1ce881a 100755 --- a/src/agent.sh +++ b/src/agent.sh @@ -873,16 +873,15 @@ tool_bash_mode_guard() { } tool_native_file_mode_guard() { - local name="$1" path="${2:-}" pattern="${3:-}" target probe - target="${path:-.}" + local name="$1" path="${2:-}" pattern="${3:-}" target="${2:-.}" probe + [[ "$name" == Glob && -z "$path" && ( "$pattern" == /* || "$pattern" == *..* ) ]] && target=/ case "$target" in /*|~|~/*|./*|../*) ;; *) target="./$target" ;; esac case "$name" in - Read|Grep) probe="cat $target" ;; + Read|Grep|Glob) probe="cat $target" ;; Write|Edit) probe=": > $target" ;; - Glob) [[ -z "$path" && ( "$pattern" == /* || "$pattern" == *..* ) ]] && probe="cat /" || probe="cat $target" ;; *) return 0 ;; esac tool_bash_mode_guard "$probe" From 1949f6d8c45ceaa330540887ec167e000cf5c42c Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Tue, 14 Jul 2026 13:56:46 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BF=A1=E4=BB=BB=E5=86=85=E9=83=A8?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E4=B8=8E=E9=A1=B9=E7=9B=AE=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- c/test_classify.c | 36 +++++++++++++++++++++++++++++++++-- c/tools.c | 20 ++++++++++++++------ go/agent_test.go | 17 +++++++++++++++-- go/tools.go | 17 +++++++++++------ rust/src/tools.rs | 48 +++++++++++++++++++++++++++++++++++------------ src/agent.sh | 17 ++++++++++------- tests/test.sh | 15 ++++++++++++++- 7 files changed, 134 insertions(+), 36 deletions(-) diff --git a/c/test_classify.c b/c/test_classify.c index 88467fe8..7b0fa9ae 100644 --- a/c/test_classify.c +++ b/c/test_classify.c @@ -39,13 +39,15 @@ static void t(const char *label, const char *cmd, const char *expect) { #define T(cmd, expect) t(cmd, cmd, expect) int main(void) { - char cwd[1024], b1[2048], b2[2048], b3[2048], b4[2048], b5[2048]; + char cwd[1024], home[1024], b1[2048], b2[2048], b3[2048], b4[2048], b5[2048]; if (getcwd(cwd, sizeof(cwd))) { for (char *p = cwd; *p; p++) *p = tolower((unsigned char)*p); } else { cwd[0] = '\0'; } + snprintf(home, sizeof(home), "/bash-agent-test-home"); + setenv("BASH_AGENT_HOME", home, 1); printf("=== workspace absolute path ===\n"); snprintf(b1, sizeof(b1), "ls %s/src/agent.sh", cwd); @@ -71,8 +73,13 @@ int main(void) { printf("\n=== external ===\n"); T("echo hi > ~/note.txt", "0200"); - printf("\n=== /tmp whitelist ===\n"); + printf("\n=== trusted internal paths ===\n"); T("cat > /tmp/test.go << EOF", "0004"); + T("cat /tmp-other/file", "0400"); + snprintf(b1, sizeof(b1), "cat %s/.bash-agent/projects/session/file", home); + t("project storage read", b1, "0004"); + snprintf(b1, sizeof(b1), "cat %s/.bash-agent/projects-copy/session/file", home); + t("project storage adjacent path", b1, "0400"); printf("\n=== /dev/null ===\n"); T("echo hi >/dev/null", "0004"); @@ -120,6 +127,31 @@ int main(void) { if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: external Write blocked\n"); g_pass++; } else { printf("\033[31m✗ FAIL\033[0m: external Write blocked\n"); g_fail++; } tool_result_free(&guard); + setenv("BASH_AGENT_BASH_MODE", "0004", 1); + guard = native_file_mode_guard("Read", "{\"path\":\"/tmp/native-file-mode-test\"}"); + if (!guard.output) { printf("\033[32m✓ PASS\033[0m: tmp Read allowed\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: tmp Read allowed: %s\n", guard.output); g_fail++; } + tool_result_free(&guard); + guard = native_file_mode_guard("Write", "{\"path\":\"/tmp/native-file-mode-test\"}"); + if (!guard.output) { printf("\033[32m✓ PASS\033[0m: tmp Write allowed\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: tmp Write allowed: %s\n", guard.output); g_fail++; } + tool_result_free(&guard); + guard = native_file_mode_guard("Edit", "{\"path\":\"/tmp/native-file-mode-test\",\"old_string\":\"a\",\"new_string\":\"b\"}"); + if (!guard.output) { printf("\033[32m✓ PASS\033[0m: tmp Edit allowed\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: tmp Edit allowed: %s\n", guard.output); g_fail++; } + tool_result_free(&guard); + guard = native_file_mode_guard("Grep", "{\"pattern\":\"needle\",\"path\":\"/tmp\"}"); + if (!guard.output) { printf("\033[32m✓ PASS\033[0m: tmp Grep allowed\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: tmp Grep allowed: %s\n", guard.output); g_fail++; } + tool_result_free(&guard); + guard = native_file_mode_guard("Glob", "{\"pattern\":\"*.txt\",\"path\":\"/tmp\"}"); + if (!guard.output) { printf("\033[32m✓ PASS\033[0m: tmp Glob allowed\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: tmp Glob allowed: %s\n", guard.output); g_fail++; } + tool_result_free(&guard); + guard = native_file_mode_guard("Read", "{\"path\":\"/tmp-other/native-file-mode-test\"}"); + if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: tmp adjacent blocked\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: tmp adjacent blocked\n"); g_fail++; } + tool_result_free(&guard); setenv("BASH_AGENT_BASH_MODE", "0465", 1); guard = native_file_mode_guard("Edit", "{\"path\":\"src/agent.sh\"}"); if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: workspace Edit without write blocked\n"); g_pass++; } diff --git a/c/tools.c b/c/tools.c index 3b5e4303..c8d8faa8 100644 --- a/c/tools.c +++ b/c/tools.c @@ -369,7 +369,8 @@ STATIC int bash_is_sensitive_path(const char *path) { } STATIC void bash_add_path(unsigned short *mask, const char *path, int perms) { - char buf[1024]; + char buf[1024], projects[1024]; + const char *home; size_t len; int scope = 1; if (!path || !*path) return; @@ -378,10 +379,17 @@ STATIC void bash_add_path(unsigned short *mask, const char *path, int perms) { len = strlen(buf); while (len > 0 && (buf[len - 1] == '"' || buf[len - 1] == '\'' || buf[len - 1] == ';' || buf[len - 1] == ',' || buf[len - 1] == ')')) buf[--len] = '\0'; if (strncmp(buf, "of=", 3) == 0) memmove(buf, buf + 3, strlen(buf + 3) + 1); - if (!buf[0] || strcmp(buf, "/tmp") == 0 || strncmp(buf, "/tmp/", 5) == 0 || strcmp(buf, "/dev/null") == 0 || buf[0] == '&') return; - if (strncmp(buf, "/dev/tcp", 8) == 0) scope = 2; - else if (strcmp(buf, "/") == 0 || strcmp(buf, "/*") == 0) scope = 8; - else if (bash_is_sensitive_path(buf) || bash_is_system_path(buf)) scope = 8; + if (!buf[0] || strcmp(buf, "/dev/null") == 0 || buf[0] == '&') return; + home = util_env("BASH_AGENT_HOME", NULL); + if (!home || !*home) home = util_env("HOME", util_home_dir()); + snprintf(projects, sizeof(projects), "%s/.bash-agent/projects", home); + for (char *p = projects; *p; p++) *p = (char)tolower((unsigned char)*p); + if (strcmp(buf, "/tmp") == 0 || strncmp(buf, "/tmp/", 5) == 0 || + strcmp(buf, projects) == 0 || + (strncmp(buf, projects, strlen(projects)) == 0 && buf[strlen(projects)] == '/')) scope = 0; + else if (strncmp(buf, "/dev/tcp", 8) == 0) scope = 2; + else if (strcmp(buf, "/") == 0 || strcmp(buf, "/*") == 0 || + bash_is_sensitive_path(buf) || bash_is_system_path(buf)) scope = 8; else if (g_cwd[0] != '\0' && (strcmp(buf, g_cwd) == 0 || (strncmp(buf, g_cwd, strlen(g_cwd)) == 0 && buf[strlen(g_cwd)] == '/'))) scope = 1; else if ((buf[0] == '/' && buf[1]) || strncmp(buf, "~/", 2) == 0 || strncmp(buf, "$home", 5) == 0 || strstr(buf, "..")) scope = 4; bash_add_mode(mask, scope, perms); @@ -471,7 +479,7 @@ STATIC void bash_scan_segment(unsigned short *mask, const char *seg) { } } free(copy); - if (flags == 1 && !bash_contains(seg, "/tmp/")) bash_add_mode(mask, 1, 2); + if (flags == 1) bash_add_mode(mask, 1, 2); } STATIC void bash_classify_required_mode(const char *cmd, char out[5]) { diff --git a/go/agent_test.go b/go/agent_test.go index 5d924f3f..84df61ac 100644 --- a/go/agent_test.go +++ b/go/agent_test.go @@ -114,6 +114,8 @@ func TestToolClassifyBashRequiredMode(t *testing.T) { func TestToolClassifyBashRequiredModeCWD(t *testing.T) { cwd := strings.ToLower(getWd()) + home := "/bash-agent-test-home" + t.Setenv("BASH_AGENT_HOME", home) tests := map[string]string{ // workspace absolute paths should be scope=1 (not external) "ls " + cwd + "/src/agent.sh": "0004", @@ -130,8 +132,11 @@ func TestToolClassifyBashRequiredModeCWD(t *testing.T) { "curl https://example.com": "0040", // external "echo hi > ~/note.txt": "0200", - // /tmp whitelist - "cat > /tmp/test.go << EOF": "0004", + // 可信内部目录 + "cat > /tmp/test.go << EOF": "0004", + "cat /tmp-other/file": "0400", + "cat > SAMPLE_HOME/.bash-agent/projects/session/file": "0004", + "cat SAMPLE_HOME/.bash-agent/projects-copy/file": "0400", // /dev/null "echo hi >/dev/null": "0004", // git commit (exec + write) @@ -150,6 +155,7 @@ func TestToolClassifyBashRequiredModeCWD(t *testing.T) { "git add -A && git commit -m fix && git push": "0023", } for cmd, want := range tests { + cmd = strings.ReplaceAll(cmd, "SAMPLE_HOME", home) if got := ToolClassifyBashRequiredMode(cmd); got != want { t.Errorf("CWD test: ToolClassifyBashRequiredMode(%q) = %s, want %s", cmd, got, want) } @@ -171,6 +177,13 @@ func TestNativeFileModeGuard(t *testing.T) { {"workspace write", "0465", "Write", "native-file-mode-test", "", "required=0002"}, {"default grep path", "0467", "Grep", "", "", ""}, {"default glob path", "0467", "Glob", "", "*.go", ""}, + {"tmp read", "0004", "Read", "/tmp/native-file-mode-test", "", ""}, + {"tmp write", "0004", "Write", "/tmp/native-file-mode-test", "", ""}, + {"tmp edit", "0004", "Edit", "/tmp/native-file-mode-test", "", ""}, + {"tmp grep", "0004", "Grep", "/tmp", "", ""}, + {"tmp glob", "0004", "Glob", "/tmp", "*.txt", ""}, + {"tmp mode zero", "0000", "Read", "/tmp/native-file-mode-test", "", "required=0004"}, + {"tmp adjacent", "0000", "Read", "/tmp-other/native-file-mode-test", "", "required=0400"}, {"absolute glob pattern", "0467", "Glob", "", "/etc/*", "required=4000"}, {"parent glob pattern", "0467", "Glob", "", "../*", "required=4000"}, {"invalid mode", "invalid", "Read", "src/agent.sh", "", "required=0004"}, diff --git a/go/tools.go b/go/tools.go index 0c07041b..b8492328 100644 --- a/go/tools.go +++ b/go/tools.go @@ -411,14 +411,19 @@ func toolBashAddPath(mask *int, path string, perms int, cwd string) { path = strings.Trim(path, `"'`) path = strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(path, ";"), ","), ")") path = strings.TrimPrefix(path, "of=") - if path == "" || path == "/tmp" || strings.HasPrefix(path, "/tmp/") || path == "/dev/null" || strings.HasPrefix(path, "&") { + if path == "" || path == "/dev/null" || strings.HasPrefix(path, "&") { return } - if strings.HasPrefix(path, "/dev/tcp") { + home := os.Getenv("BASH_AGENT_HOME") + if home == "" { + home = os.Getenv("HOME") + } + projects := filepath.Join(strings.ToLower(home), ".bash-agent/projects") + if path == "/tmp" || strings.HasPrefix(path, "/tmp/") || path == projects || strings.HasPrefix(path, projects+"/") { + scope = 0 + } else if strings.HasPrefix(path, "/dev/tcp") { scope = 2 - } else if path == "/" || path == "/*" { - scope = 8 - } else if toolBashReSensitive.MatchString(path) || toolBashReSystemPath.MatchString(path) { + } else if path == "/" || path == "/*" || toolBashReSensitive.MatchString(path) || toolBashReSystemPath.MatchString(path) { scope = 8 } else if cwd != "" && (path == cwd || strings.HasPrefix(path, cwd+"/")) { scope = 1 @@ -484,7 +489,7 @@ func toolBashScanSegment(mask *int, seg string, cwd string) { flags = 3 } } - if flags == 1 && !strings.Contains(seg, "/tmp/") { + if flags == 1 { toolBashAddMode(mask, 1, 2) } } diff --git a/rust/src/tools.rs b/rust/src/tools.rs index c90c34c1..85bceac5 100644 --- a/rust/src/tools.rs +++ b/rust/src/tools.rs @@ -777,19 +777,27 @@ use crate::config::Config; .trim_end_matches(';') .trim_end_matches(',') .trim_end_matches(')'); - if path.is_empty() - || path == "/tmp" - || path.starts_with("/tmp/") - || path == "/dev/null" - || path.starts_with('&') - { + if path.is_empty() || path == "/dev/null" || path.starts_with('&') { return; } - if path.starts_with("/dev/tcp") { + let home = std::env::var("BASH_AGENT_HOME") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_default() + .to_lowercase(); + let projects = format!("{home}/.bash-agent/projects"); + if path == "/tmp" + || path.starts_with("/tmp/") + || path == projects + || path.starts_with(&(projects + "/")) + { + scope = 0; + } else if path.starts_with("/dev/tcp") { scope = 2; - } else if path == "/" || path == "/*" { - scope = 8; - } else if RE_BASH_SENSITIVE_PATH.is_match(path) || RE_BASH_SYSTEM_PATH.is_match(path) { + } else if path == "/" + || path == "/*" + || RE_BASH_SENSITIVE_PATH.is_match(path) + || RE_BASH_SYSTEM_PATH.is_match(path) + { scope = 8; } else if !cwd.is_empty() && (path == cwd || path.starts_with(&format!("{}/", cwd))) { scope = 1; @@ -947,7 +955,7 @@ use crate::config::Config; flags = 3; } } - if flags == 1 && !seg.contains("/tmp/") { + if flags == 1 { bash_add_mode(mask, 1, 2); } } @@ -1327,6 +1335,15 @@ use crate::config::Config; assert!(runner.native_file_mode_guard("Glob", "", "/etc/*").is_err()); assert!(runner.native_file_mode_guard("Glob", "", "../*").is_err()); + unsafe { std::env::set_var("BASH_AGENT_BASH_MODE", "0004") }; + assert!(runner.native_file_mode_guard("Read", "/tmp/native-file-mode-test", "").is_ok()); + assert!(runner.native_file_mode_guard("Write", "/tmp/native-file-mode-test", "").is_ok()); + assert!(runner.native_file_mode_guard("Edit", "/tmp/native-file-mode-test", "").is_ok()); + assert!(runner.native_file_mode_guard("Grep", "/tmp", "").is_ok()); + assert!(runner.native_file_mode_guard("Glob", "/tmp", "*.txt").is_ok()); + unsafe { std::env::set_var("BASH_AGENT_BASH_MODE", "0000") }; + assert!(runner.native_file_mode_guard("Read", "/tmp-other/native-file-mode-test", "").is_err()); + unsafe { std::env::set_var("BASH_AGENT_BASH_MODE", "0465") }; assert!( runner @@ -1380,6 +1397,8 @@ use crate::config::Config; .map(|p| p.to_string_lossy().to_lowercase()) .unwrap_or_default(); if cwd.is_empty() { return; } + let home = "/bash-agent-test-home"; + unsafe { std::env::set_var("BASH_AGENT_HOME", &home) }; let cases: &[(&str, &str)] = &[ ("ls SAMPLE_CWD/src/agent.sh", "0004"), ("cat SAMPLE_CWD/src/agent.sh", "0004"), @@ -1393,10 +1412,15 @@ use crate::config::Config; ("curl https://example.com", "0040"), ("echo hi > ~/note.txt", "0200"), ("cat > /tmp/test.go << EOF", "0004"), + ("cat /tmp-other/file", "0400"), + ("cat SAMPLE_HOME/.bash-agent/projects/session/file", "0004"), + ("cat SAMPLE_HOME/.bash-agent/projects-copy/file", "0400"), ("echo hi >/dev/null", "0004"), ("git add -A && git commit -m fix", "0003"), ]; - let cases: Vec<(String, &str)> = cases.iter().map(|(c, w)| (c.replace("SAMPLE_CWD", &cwd), *w)).collect(); + let cases: Vec<(String, &str)> = cases.iter() + .map(|(c, w)| (c.replace("SAMPLE_CWD", &cwd).replace("SAMPLE_HOME", &home), *w)) + .collect(); for (cmd, want) in &cases { assert_eq!(classify_bash_required_mode(cmd), *want, "{cmd}"); } diff --git a/src/agent.sh b/src/agent.sh index e1ce881a..a1a0dbf5 100755 --- a/src/agent.sh +++ b/src/agent.sh @@ -774,15 +774,18 @@ tool_bash_add_mode() { } tool_bash_add_path() { - local path="$1" perms="$2" scope=1 + local path="$1" perms="$2" scope=1 projects path="${path#\"}"; path="${path%\"}"; path="${path#\'}"; path="${path%\'}" path="${path#of=}"; path="${path%;}"; path="${path%,}"; path="${path%)}" - [[ -z "$path" || "$path" == /tmp || "$path" == /tmp/* || "$path" == /dev/null || "$path" == '&'* ]] && return 0 - # 根目录 / 和 /* 归类为 system(防止绕过 system 权限检查) - [[ "$path" == "/" || "$path" == "/*" ]] && scope=8 - if [[ "$path" == /dev/tcp* ]]; then + projects="${BASH_AGENT_HOME:-${HOME}}/.bash-agent/projects" + projects=$(printf '%s' "$projects" | tr '[:upper:]' '[:lower:]') + [[ -z "$path" || "$path" == /dev/null || "$path" == '&'* ]] && return 0 + if [[ "$path" == /tmp || "$path" == /tmp/* || "$path" == "$projects" || "$path" == "$projects"/* ]]; then + scope=0 + elif [[ "$path" == /dev/tcp* ]]; then scope=2 - elif [[ "$path" =~ $TOOL_BASH_RE_SENSITIVE_PATH || "$path" =~ $TOOL_BASH_RE_SYSTEM_PATH ]]; then + # 根目录 / 和 /* 归类为 system(防止绕过 system 权限检查) + elif [[ "$path" == "/" || "$path" == "/*" || "$path" =~ $TOOL_BASH_RE_SENSITIVE_PATH || "$path" =~ $TOOL_BASH_RE_SYSTEM_PATH ]]; then scope=8 elif [[ -n "$CWD" && ("$path" == "$CWD" || "$path" == "$CWD"/*) ]]; then scope=1 @@ -824,7 +827,7 @@ tool_bash_scan_segment() { *) [[ "$tok" =~ $TOOL_BASH_RE_SENSITIVE_PATH ]] && { tool_bash_add_path "$tok" "$path_bits"; flags=3; } ;; esac done - (( flags == 1 )) && [[ "$seg" != *'/tmp/'* ]] && tool_bash_add_mode 1 2 + (( flags == 1 )) && tool_bash_add_mode 1 2 } tool_bash_scan_script() { diff --git a/tests/test.sh b/tests/test.sh index 81448e7d..4fa46dd6 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -2759,6 +2759,7 @@ test_bash_mode_scanner() { local scan_pass=0 scan_fail=0 local CWD="${ROOT_DIR}" + local BASH_AGENT_HOME="/bash-agent-test-home" assert_mode() { local desc=$1 cmd=$2 expect=$3 @@ -2788,6 +2789,12 @@ test_bash_mode_scanner() { assert_native_mode "workspace Write blocked" Write "$CWD/native-file-mode-test.txt" "" 0465 1 0002 assert_native_mode "Grep default path uses workspace" Grep "" "" 0467 0 "" assert_native_mode "Glob default path uses workspace" Glob "" "*.sh" 0467 0 "" + assert_native_mode "tmp Read allowed" Read /tmp/native-file-mode-test "" 0004 0 "" + assert_native_mode "tmp Write allowed" Write /tmp/native-file-mode-test "" 0004 0 "" + assert_native_mode "tmp Edit allowed" Edit /tmp/native-file-mode-test "" 0004 0 "" + assert_native_mode "tmp Grep allowed" Grep /tmp "" 0004 0 "" + assert_native_mode "tmp Glob allowed" Glob /tmp "*.txt" 0004 0 "" + assert_native_mode "tmp adjacent blocked" Read /tmp-other/native-file-mode-test "" 0000 1 0400 assert_native_mode "Glob absolute pattern without path fails closed" Glob "" "/etc/*" 0467 1 4000 assert_native_mode "Glob parent pattern without path fails closed" Glob "" "../*" 0467 1 4000 assert_native_mode "invalid mode fails closed" Read "$CWD/src/agent.sh" "" bad1 1 0004 @@ -2832,11 +2839,17 @@ test_bash_mode_scanner() { assert_mode "external read: ~/..." \ "echo hi > ~/note.txt" "0200" - # --- /tmp 白名单 --- + # --- 可信内部目录 --- assert_mode "tmp write: cat > /tmp/file" \ "cat > /tmp/test.go << EOF" "0004" assert_mode "tmp read: cat /tmp/file" \ "cat /tmp/test.go" "0004" + assert_mode "tmp adjacent path remains external" \ + "cat /tmp-other/test.go" "0400" + assert_mode "project storage read" \ + "cat $BASH_AGENT_HOME/.bash-agent/projects/session/file" "0004" + assert_mode "project storage adjacent path remains external" \ + "cat $BASH_AGENT_HOME/.bash-agent/projects-copy/session/file" "0400" # --- /dev/null --- assert_mode "dev null: echo >/dev/null" \ From 9e4ed84a69395f54491607186d33a6588ff9f888 Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Tue, 14 Jul 2026 16:29:49 +0800 Subject: [PATCH 5/6] fix: block trusted path traversal bypasses --- README.en.md | 2 +- README.md | 2 +- c/test_classify.c | 14 ++++++++++++++ c/tools.c | 15 +++++++++------ docs/bash-tool-policy.md | 5 +++-- docs/tools.md | 2 +- go/agent_test.go | 5 +++++ go/tools.go | 16 +++++++++++----- rust/src/tools.rs | 25 +++++++++++++++++-------- src/agent.sh | 12 +++++++----- tests/test.sh | 3 +++ 11 files changed, 72 insertions(+), 29 deletions(-) diff --git a/README.en.md b/README.en.md index 29f222a6..cf403ba6 100644 --- a/README.en.md +++ b/README.en.md @@ -212,7 +212,7 @@ export BASH_AGENT_BASH_MODE=0457 # allow network execute export BASH_AGENT_BASH_MODE=7777 # fully open, trusted environments only ``` -Local file tools derive equivalent access probes from their paths: `Read`, `Grep`, and `Glob` require read access, while `Write` and `Edit` require write access. A missing path is checked against the current workspace. A pathless `Glob` with an absolute pattern or `..` segment fails closed as a system read. +Local file tools derive equivalent access probes from their paths: `Read`, `Grep`, and `Glob` require read access, while `Write` and `Edit` require write access. A missing path is checked against the current workspace. Paths under `/tmp` and `$BASH_AGENT_HOME/.bash-agent/projects` that do not contain `..` are trusted internal paths, but any path containing `..` is checked as a system path to prevent traversal bypasses such as `/tmp/../...`. A pathless `Glob` with an absolute pattern or `..` segment fails closed as a system read. Invalid values fail closed to `0000`. The model is expressed like Linux-style `rwx` permission bits. Full classification rules, recommended settings, and the shared block message are documented in [`docs/bash-tool-policy.md`](docs/bash-tool-policy.md). diff --git a/README.md b/README.md index 13a1db2b..1bb69c3b 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ export BASH_AGENT_BASH_MODE=0457 # 允许 network execute export BASH_AGENT_BASH_MODE=7777 # 全开,仅建议受信环境 ``` -本地文件工具按路径生成等价访问探针:`Read`、`Grep`、`Glob` 按读取检查,`Write`、`Edit` 按写入检查;未指定路径时按当前工作区检查。无路径的 `Glob` 若模式为绝对路径或包含 `..`,会失败关闭为系统读取检查。 +本地文件工具按路径生成等价访问探针:`Read`、`Grep`、`Glob` 按读取检查,`Write`、`Edit` 按写入检查;未指定路径时按当前工作区检查。`/tmp` 与 `$BASH_AGENT_HOME/.bash-agent/projects` 下不含 `..` 的路径是可信内部目录,但含 `..` 的路径一律按系统路径检查,防止借由 `/tmp/../...` 穿越绕过权限。无路径的 `Glob` 若模式为绝对路径或包含 `..`,会失败关闭为系统读取检查。 如果值非法,会 fail-closed 为 `0000`。这个模型的表达方式类似 Linux 文件权限的 `rwx` 位。更完整的分类规则、推荐配置和错误文案见 [`docs/bash-tool-policy.md`](docs/bash-tool-policy.md)。 diff --git a/c/test_classify.c b/c/test_classify.c index 7b0fa9ae..2416e875 100644 --- a/c/test_classify.c +++ b/c/test_classify.c @@ -76,6 +76,8 @@ int main(void) { printf("\n=== trusted internal paths ===\n"); T("cat > /tmp/test.go << EOF", "0004"); T("cat /tmp-other/file", "0400"); + T("cat /tmp/../etc/hosts", "4000"); + T("echo hi > /tmp/../etc/native-file-mode-test", "2000"); snprintf(b1, sizeof(b1), "cat %s/.bash-agent/projects/session/file", home); t("project storage read", b1, "0004"); snprintf(b1, sizeof(b1), "cat %s/.bash-agent/projects-copy/session/file", home); @@ -152,6 +154,18 @@ int main(void) { if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: tmp adjacent blocked\n"); g_pass++; } else { printf("\033[31m✗ FAIL\033[0m: tmp adjacent blocked\n"); g_fail++; } tool_result_free(&guard); + guard = native_file_mode_guard("Read", "{\"path\":\"/tmp/../etc/hosts\"}"); + if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: tmp traversal Read blocked\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: tmp traversal Read blocked\n"); g_fail++; } + tool_result_free(&guard); + guard = native_file_mode_guard("Write", "{\"path\":\"/tmp/../etc/native-file-mode-test\"}"); + if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: tmp traversal Write blocked\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: tmp traversal Write blocked\n"); g_fail++; } + tool_result_free(&guard); + guard = native_file_mode_guard("Edit", "{\"path\":\"/tmp/../etc/hosts\"}"); + if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: tmp traversal Edit blocked\n"); g_pass++; } + else { printf("\033[31m✗ FAIL\033[0m: tmp traversal Edit blocked\n"); g_fail++; } + tool_result_free(&guard); setenv("BASH_AGENT_BASH_MODE", "0465", 1); guard = native_file_mode_guard("Edit", "{\"path\":\"src/agent.sh\"}"); if (guard.output && guard.exit_code == 1) { printf("\033[32m✓ PASS\033[0m: workspace Edit without write blocked\n"); g_pass++; } diff --git a/c/tools.c b/c/tools.c index c8d8faa8..c8e1b82a 100644 --- a/c/tools.c +++ b/c/tools.c @@ -386,12 +386,15 @@ STATIC void bash_add_path(unsigned short *mask, const char *path, int perms) { for (char *p = projects; *p; p++) *p = (char)tolower((unsigned char)*p); if (strcmp(buf, "/tmp") == 0 || strncmp(buf, "/tmp/", 5) == 0 || strcmp(buf, projects) == 0 || - (strncmp(buf, projects, strlen(projects)) == 0 && buf[strlen(projects)] == '/')) scope = 0; - else if (strncmp(buf, "/dev/tcp", 8) == 0) scope = 2; - else if (strcmp(buf, "/") == 0 || strcmp(buf, "/*") == 0 || - bash_is_sensitive_path(buf) || bash_is_system_path(buf)) scope = 8; - else if (g_cwd[0] != '\0' && (strcmp(buf, g_cwd) == 0 || (strncmp(buf, g_cwd, strlen(g_cwd)) == 0 && buf[strlen(g_cwd)] == '/'))) scope = 1; - else if ((buf[0] == '/' && buf[1]) || strncmp(buf, "~/", 2) == 0 || strncmp(buf, "$home", 5) == 0 || strstr(buf, "..")) scope = 4; + (strncmp(buf, projects, strlen(projects)) == 0 && buf[strlen(projects)] == '/')) { + /* Traversal can escape a trusted root, so classify it conservatively as system. */ + scope = strstr(buf, "..") ? 8 : 0; + } + if (scope == 1 && strncmp(buf, "/dev/tcp", 8) == 0) scope = 2; + else if (scope == 1 && (strcmp(buf, "/") == 0 || strcmp(buf, "/*") == 0 || + bash_is_sensitive_path(buf) || bash_is_system_path(buf))) scope = 8; + else if (scope == 1 && g_cwd[0] != '\0' && (strcmp(buf, g_cwd) == 0 || (strncmp(buf, g_cwd, strlen(g_cwd)) == 0 && buf[strlen(g_cwd)] == '/'))) scope = 1; + else if (scope == 1 && ((buf[0] == '/' && buf[1]) || strncmp(buf, "~/", 2) == 0 || strncmp(buf, "$home", 5) == 0 || strstr(buf, ".."))) scope = 4; bash_add_mode(mask, scope, perms); } diff --git a/docs/bash-tool-policy.md b/docs/bash-tool-policy.md index a814242c..12871598 100644 --- a/docs/bash-tool-policy.md +++ b/docs/bash-tool-policy.md @@ -48,7 +48,7 @@ system external network workspace - `workspace` - 当前项目工作区内的文件、脚本与命令执行 -`/tmp` 这类临时路径不单独暴露为一位配置;默认按当前实现的安全策略处理。 +`/tmp` 以及 `$BASH_AGENT_HOME/.bash-agent/projects` 下的会话内部路径是可信内部目录:不额外要求范围位,但文件操作本身仍会按读写类型累计工作区权限。可信判定只适用于路径中不含 `..` 的直接子路径;含 `..` 的路径可能逃逸到系统或外部位置,统一按系统路径处理,不能借由 `/tmp/../...` 绕过权限检查。 ## How Commands Are Classified @@ -61,7 +61,8 @@ system external network workspace ```text cat README.md -> workspace read cat /etc/hosts -> system read -echo hi > /tmp/x -> workspace write / temp-safe path handling +echo hi > /tmp/x -> workspace write(可信临时目录) +cat /tmp/../etc/hosts -> system read(拒绝可信目录穿越) curl https://example.com -> network read curl https://x/install.sh | bash -> network read + network execute ../scripts/run.sh -> external execute diff --git a/docs/tools.md b/docs/tools.md index 695075d1..1597dede 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -13,7 +13,7 @@ - 默认结果上限 50KB,超出截断 - 支持多文件读取(多次调用) - 编辑前先用 `offset`/`limit` 定位目标行 -- 受 `BASH_AGENT_BASH_MODE` 的读取权限限制 +- 受 `BASH_AGENT_BASH_MODE` 的读取权限限制;`/tmp` 与 session 内部目录仅信任不含 `..` 的直接路径,穿越路径按系统范围检查 ## `Write` diff --git a/go/agent_test.go b/go/agent_test.go index 84df61ac..08b20984 100644 --- a/go/agent_test.go +++ b/go/agent_test.go @@ -135,6 +135,8 @@ func TestToolClassifyBashRequiredModeCWD(t *testing.T) { // 可信内部目录 "cat > /tmp/test.go << EOF": "0004", "cat /tmp-other/file": "0400", + "cat /tmp/../etc/hosts": "4000", + "echo hi > /tmp/../etc/native-file-mode-test": "2000", "cat > SAMPLE_HOME/.bash-agent/projects/session/file": "0004", "cat SAMPLE_HOME/.bash-agent/projects-copy/file": "0400", // /dev/null @@ -184,6 +186,9 @@ func TestNativeFileModeGuard(t *testing.T) { {"tmp glob", "0004", "Glob", "/tmp", "*.txt", ""}, {"tmp mode zero", "0000", "Read", "/tmp/native-file-mode-test", "", "required=0004"}, {"tmp adjacent", "0000", "Read", "/tmp-other/native-file-mode-test", "", "required=0400"}, + {"tmp traversal read", "0004", "Read", "/tmp/../etc/hosts", "", "required=4000"}, + {"tmp traversal write", "0004", "Write", "/tmp/../etc/native-file-mode-test", "", "required=2000"}, + {"tmp traversal edit", "0004", "Edit", "/tmp/../etc/hosts", "", "required=2000"}, {"absolute glob pattern", "0467", "Glob", "", "/etc/*", "required=4000"}, {"parent glob pattern", "0467", "Glob", "", "../*", "required=4000"}, {"invalid mode", "invalid", "Read", "src/agent.sh", "", "required=0004"}, diff --git a/go/tools.go b/go/tools.go index b8492328..eb5f46b2 100644 --- a/go/tools.go +++ b/go/tools.go @@ -420,14 +420,20 @@ func toolBashAddPath(mask *int, path string, perms int, cwd string) { } projects := filepath.Join(strings.ToLower(home), ".bash-agent/projects") if path == "/tmp" || strings.HasPrefix(path, "/tmp/") || path == projects || strings.HasPrefix(path, projects+"/") { - scope = 0 - } else if strings.HasPrefix(path, "/dev/tcp") { + // Traversal can escape a trusted root, so classify it conservatively as system. + if strings.Contains(path, "..") { + scope = 8 + } else { + scope = 0 + } + } + if scope == 1 && strings.HasPrefix(path, "/dev/tcp") { scope = 2 - } else if path == "/" || path == "/*" || toolBashReSensitive.MatchString(path) || toolBashReSystemPath.MatchString(path) { + } else if scope == 1 && (path == "/" || path == "/*" || toolBashReSensitive.MatchString(path) || toolBashReSystemPath.MatchString(path)) { scope = 8 - } else if cwd != "" && (path == cwd || strings.HasPrefix(path, cwd+"/")) { + } else if scope == 1 && cwd != "" && (path == cwd || strings.HasPrefix(path, cwd+"/")) { scope = 1 - } else if toolBashReExternalPath.MatchString(path) || strings.Contains(path, "..") { + } else if scope == 1 && (toolBashReExternalPath.MatchString(path) || strings.Contains(path, "..")) { scope = 4 } toolBashAddMode(mask, scope, perms) diff --git a/rust/src/tools.rs b/rust/src/tools.rs index 85bceac5..eb80fd28 100644 --- a/rust/src/tools.rs +++ b/rust/src/tools.rs @@ -790,18 +790,21 @@ use crate::config::Config; || path == projects || path.starts_with(&(projects + "/")) { - scope = 0; - } else if path.starts_with("/dev/tcp") { + // Traversal can escape a trusted root, so classify it conservatively as system. + scope = if path.contains("..") { 8 } else { 0 }; + } + if scope == 1 && path.starts_with("/dev/tcp") { scope = 2; - } else if path == "/" - || path == "/*" - || RE_BASH_SENSITIVE_PATH.is_match(path) - || RE_BASH_SYSTEM_PATH.is_match(path) + } else if scope == 1 + && (path == "/" + || path == "/*" + || RE_BASH_SENSITIVE_PATH.is_match(path) + || RE_BASH_SYSTEM_PATH.is_match(path)) { scope = 8; - } else if !cwd.is_empty() && (path == cwd || path.starts_with(&format!("{}/", cwd))) { + } else if scope == 1 && !cwd.is_empty() && (path == cwd || path.starts_with(&format!("{}/", cwd))) { scope = 1; - } else if RE_BASH_EXTERNAL_PATH.is_match(path) || path.contains("..") { + } else if scope == 1 && (RE_BASH_EXTERNAL_PATH.is_match(path) || path.contains("..")) { scope = 4; } bash_add_mode(mask, scope, perms); @@ -1343,6 +1346,10 @@ use crate::config::Config; assert!(runner.native_file_mode_guard("Glob", "/tmp", "*.txt").is_ok()); unsafe { std::env::set_var("BASH_AGENT_BASH_MODE", "0000") }; assert!(runner.native_file_mode_guard("Read", "/tmp-other/native-file-mode-test", "").is_err()); + unsafe { std::env::set_var("BASH_AGENT_BASH_MODE", "0004") }; + assert!(runner.native_file_mode_guard("Read", "/tmp/../etc/hosts", "").is_err()); + assert!(runner.native_file_mode_guard("Write", "/tmp/../etc/native-file-mode-test", "").is_err()); + assert!(runner.native_file_mode_guard("Edit", "/tmp/../etc/hosts", "").is_err()); unsafe { std::env::set_var("BASH_AGENT_BASH_MODE", "0465") }; assert!( @@ -1413,6 +1420,8 @@ use crate::config::Config; ("echo hi > ~/note.txt", "0200"), ("cat > /tmp/test.go << EOF", "0004"), ("cat /tmp-other/file", "0400"), + ("cat /tmp/../etc/hosts", "4000"), + ("echo hi > /tmp/../etc/native-file-mode-test", "2000"), ("cat SAMPLE_HOME/.bash-agent/projects/session/file", "0004"), ("cat SAMPLE_HOME/.bash-agent/projects-copy/file", "0400"), ("echo hi >/dev/null", "0004"), diff --git a/src/agent.sh b/src/agent.sh index a1a0dbf5..4ba0affb 100755 --- a/src/agent.sh +++ b/src/agent.sh @@ -781,15 +781,17 @@ tool_bash_add_path() { projects=$(printf '%s' "$projects" | tr '[:upper:]' '[:lower:]') [[ -z "$path" || "$path" == /dev/null || "$path" == '&'* ]] && return 0 if [[ "$path" == /tmp || "$path" == /tmp/* || "$path" == "$projects" || "$path" == "$projects"/* ]]; then - scope=0 - elif [[ "$path" == /dev/tcp* ]]; then + # 可信根目录中的路径穿越不享受范围零豁免,保守地按 system 分类。 + [[ "$path" == *..* ]] && scope=8 || scope=0 + fi + if (( scope == 1 )) && [[ "$path" == /dev/tcp* ]]; then scope=2 # 根目录 / 和 /* 归类为 system(防止绕过 system 权限检查) - elif [[ "$path" == "/" || "$path" == "/*" || "$path" =~ $TOOL_BASH_RE_SENSITIVE_PATH || "$path" =~ $TOOL_BASH_RE_SYSTEM_PATH ]]; then + elif (( scope == 1 )) && [[ "$path" == "/" || "$path" == "/*" || "$path" =~ $TOOL_BASH_RE_SENSITIVE_PATH || "$path" =~ $TOOL_BASH_RE_SYSTEM_PATH ]]; then scope=8 - elif [[ -n "$CWD" && ("$path" == "$CWD" || "$path" == "$CWD"/*) ]]; then + elif (( scope == 1 )) && [[ -n "$CWD" && ("$path" == "$CWD" || "$path" == "$CWD"/*) ]]; then scope=1 - elif [[ "$path" =~ $TOOL_BASH_RE_EXTERNAL_PATH || "$path" == *..* ]]; then + elif (( scope == 1 )) && [[ "$path" =~ $TOOL_BASH_RE_EXTERNAL_PATH || "$path" == *..* ]]; then scope=4 fi tool_bash_add_mode "$scope" "$perms" diff --git a/tests/test.sh b/tests/test.sh index 4fa46dd6..940df7df 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -2795,6 +2795,9 @@ test_bash_mode_scanner() { assert_native_mode "tmp Grep allowed" Grep /tmp "" 0004 0 "" assert_native_mode "tmp Glob allowed" Glob /tmp "*.txt" 0004 0 "" assert_native_mode "tmp adjacent blocked" Read /tmp-other/native-file-mode-test "" 0000 1 0400 + assert_native_mode "tmp traversal Read blocked" Read /tmp/../etc/hosts "" 0004 1 4000 + assert_native_mode "tmp traversal Write blocked" Write /tmp/../etc/native-file-mode-test "" 0004 1 2000 + assert_native_mode "tmp traversal Edit blocked" Edit /tmp/../etc/hosts "" 0004 1 2000 assert_native_mode "Glob absolute pattern without path fails closed" Glob "" "/etc/*" 0467 1 4000 assert_native_mode "Glob parent pattern without path fails closed" Glob "" "../*" 0467 1 4000 assert_native_mode "invalid mode fails closed" Read "$CWD/src/agent.sh" "" bad1 1 0004 From dd2c69e149c0a414a5d8b4e2af45f6f6251fe0a3 Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Tue, 14 Jul 2026 16:31:46 +0800 Subject: [PATCH 6/6] test: run Rust native file guard case --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 67b77a20..aa8259f6 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,7 @@ test-go-e2e: build-go test-rust: cd rust && cargo check + cargo test --manifest-path rust/Cargo.toml native_file_mode_guard_matches_bash test-rust-e2e: build-rust AGENT=./dist/rustagent bash tests/test.sh