From 1bf197b040d8451260e7267268a146c76bf63d91 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:21:35 +0800 Subject: [PATCH] Tool-call inspection guards ThinkWatch's own data directory; the docs say what protects config.yaml A new built-in tool rule, `thinkwatch-data` (high: cut under enforce), fires when a tool call's path or command points into ThinkWatch's data directory: `~/.thinkwatch` (any `.thinkwatch` path component), `%APPDATA%\ThinkWatch`, and `/var/lib/thinkwatch` or `/etc/thinkwatch` on a server. The directory holds every upstream key in plain text and the settings of these protections, so reading it takes every credential in one step and writing it switches the protections off. It is a code check rather than a regex so that mentioning the path does not count: in JSON arguments only path-like keys (file_path, path, cwd, workdir, ...) and command-like keys (command, cmd, code, args, ...) are read, plus a patch file header in any value; raw tool input needs a patch header or a file command before the path on the same line. The JSON is read string by string rather than parsed, since the wall checks half-received arguments and arguments over its cap are kept only in part. The configuration reference and README now state the local boundary: 0600/0700 keeps out other users, not programs running as the same user; outbound redaction protects what leaves the machine, not the file; the control key is masked so a control-plane write cannot change it, not to hide it from local programs. Refs #286 Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +- README.zh-CN.md | 4 +- crates/tw-guard/data/rules.yaml | 9 + crates/tw-guard/src/tools/mod.rs | 1 + crates/tw-guard/src/tools/net.rs | 7 +- crates/tw-guard/src/tools/own_data.rs | 396 ++++++++++++++++++++++++++ crates/tw-guard/src/tools/rules.rs | 21 +- crates/tw-guard/src/tools/wall.rs | 26 ++ crates/tw-guard/src/view.rs | 16 +- docs/config.md | 20 ++ docs/config.zh-CN.md | 5 + 11 files changed, 505 insertions(+), 11 deletions(-) create mode 100644 crates/tw-guard/src/tools/own_data.rs diff --git a/README.md b/README.md index 87bfdd2..e64940f 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,9 @@ Documentation: [configuration reference](docs/config.md) · - **Malicious tool calls are cut off.** A relay can rewrite an answer and slip in a tool call for the client to run. Tool-call inspection can cut off an answer whose tool call downloads and runs code, sends out environment - variables or credential files, reads private keys, or installs a startup item - or scheduled job, before the client receives it whole. + variables or credential files, reads private keys, reads or changes + ThinkWatch's own configuration, or installs a startup item or scheduled job, + before the client receives it whole. - **Hidden instructions are removed.** Characters invisible on screen can carry instructions that a model reads; the content filter can delete them from user messages and tool results before a request leaves, or refuse a request that @@ -110,7 +111,11 @@ each with a `.sha256` checksum; the Linux `.tar.gz` includes the systemd unit. `twcore` keeps its configuration and data in `~/.thinkwatch` (`%APPDATA%\ThinkWatch` on Windows), or in `THINKWATCH_HOME`. Every field is in -the [configuration reference](docs/config.md). +the [configuration reference](docs/config.md). The configuration holds keys in +plain text and only its owner can read it, which keeps out other users but not +programs running as the same user; outbound redaction protects what leaves the +machine, not the file. [Where the file is](docs/config.md#where-the-file-is) +describes what protects it. ## Control plane diff --git a/README.zh-CN.md b/README.zh-CN.md index e2c3d5c..d07fc7b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -25,7 +25,7 @@ ThinkWatch Core 是 ThinkWatch 的网关引擎,由一组 Rust crate 及其构 - **一次接入,随时切换**。客户端只保留一个地址和一把密钥,更换上游或模型都在网关中完成,客户端无需改配置或重启。Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 四种格式双向转换,流式输出同样适用。 - **出站脱敏**。出站脱敏可在请求发出前把 API 密钥、私钥和连接串中的口令替换为占位符,并在回答回显时还原,中转站因此看不到真实的值。 -- **切断恶意工具调用**。中转站可以改写回答,塞入让客户端执行的工具调用。回答中的工具调用若是下载即执行、外发环境变量或凭据文件、读取私钥、写入开机启动项或定时任务,工具调用审查可以在客户端收到完整调用之前切断回答。 +- **切断恶意工具调用**。中转站可以改写回答,塞入让客户端执行的工具调用。回答中的工具调用若是下载即执行、外发环境变量或凭据文件、读取私钥、读写 ThinkWatch 自己的配置、写入开机启动项或定时任务,工具调用审查可以在客户端收到完整调用之前切断回答。 - **清除隐藏指令**。屏幕上看不见的字符可以夹带模型会读取的指令,内容过滤可以在请求发出前把它们从用户消息和工具结果中删除,也可以拒绝要求模型忽略自身指令的请求。出站脱敏、工具调用审查和内容过滤出厂均为观察档,只记录检出的内容,不改变任何请求。 - **每个请求都可追溯**。每个请求连同决定其去向的规则、每次尝试、格式转换、用量、费用及价格来源、首 token 时间和生成速度一并保存。试算可以在不发出请求的情况下说明请求会被送往何处;已保存的请求可以对另一个上游重放,以便对比。 - **路由与故障转移**。规则可按模型、密钥、格式、请求大小、工具、图片、思考等条件匹配,把请求交给一个上游或策略组(按顺序、手动指定、轮流、最低延迟、最低价格)。响应的首字节到达客户端之前,失败的上游由下一个候选替换,并按其给出的失败原因暂停相应的时间。 @@ -58,7 +58,7 @@ twc control-key # 标准输出是 ThinkWatch Lite 所 **预编译二进制**:每个 [Release](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/latest) 都提供 macOS(Apple silicon)、Windows(x64、ARM64)和 Linux(x86_64、aarch64)版本,均附 `.sha256` 校验文件;Linux 的 `.tar.gz` 包含 systemd 服务单元。 -`twcore` 的配置和数据存放在 `~/.thinkwatch`(Windows 上为 `%APPDATA%\ThinkWatch`),设置了 `THINKWATCH_HOME` 时存放在它指定的目录。每个字段的说明见[配置手册](docs/config.zh-CN.md)。 +`twcore` 的配置和数据存放在 `~/.thinkwatch`(Windows 上为 `%APPDATA%\ThinkWatch`),设置了 `THINKWATCH_HOME` 时存放在它指定的目录。每个字段的说明见[配置手册](docs/config.zh-CN.md)。配置以明文保存密钥,只有所有者可读:挡得住其他用户,挡不住以同一用户身份运行的程序;出站脱敏保护的是带出本机的内容,不是这份文件。它受什么保护,见配置手册的[文件位置](docs/config.zh-CN.md#文件位置)一节。 ## 控制面 diff --git a/crates/tw-guard/data/rules.yaml b/crates/tw-guard/data/rules.yaml index e7bbefb..416db2f 100644 --- a/crates/tw-guard/data/rules.yaml +++ b/crates/tw-guard/data/rules.yaml @@ -110,6 +110,15 @@ dangerous: why: Sends a credential to a host that is neither local nor the credential's own provider level: high check: credential-to-network + # ThinkWatch 自己的数据目录:里面是明文的全部上游密钥和这几项防护的设置。读它一步就 + # 拿走全部凭据,改它就能关掉管着自己的防护 —— 高危。**提到这个路径不算**(改文档、 + # 回答「配置在哪」),只看路径和命令参数,所以由代码实现,见 src/tools/own_data.rs。 + - id: thinkwatch-data + name: Read or change ThinkWatch's own data + pattern: '' + why: Reads or changes ThinkWatch's data directory, which holds every upstream key in plain text and the settings of these protections + level: high + check: thinkwatch-data # **写入启动项**:只要写进去了,下次开终端就执行 —— 而且是在你完全 # 不知情的时候。它和「下载即执行」并列为高危,理由是一样的: # 一步就能拿到执行权。 diff --git a/crates/tw-guard/src/tools/mod.rs b/crates/tw-guard/src/tools/mod.rs index 2338a66..42c2732 100644 --- a/crates/tw-guard/src/tools/mod.rs +++ b/crates/tw-guard/src/tools/mod.rs @@ -1,5 +1,6 @@ //! 工具调用审查:上游返回的工具调用过一遍规则,高危的可以在那一帧上切断。 pub mod net; +mod own_data; pub mod rules; pub mod wall; diff --git a/crates/tw-guard/src/tools/net.rs b/crates/tw-guard/src/tools/net.rs index f7c6086..e3a5d44 100644 --- a/crates/tw-guard/src/tools/net.rs +++ b/crates/tw-guard/src/tools/net.rs @@ -1,4 +1,4 @@ -//! 工具调用审查里两条**代码实现**的危险命令规则。 +//! 工具调用审查里**代码实现**的危险命令规则:这里的两条,和 [`super::own_data`] 那一条。 //! //! 正则认不出这两件事,因为判断要跨工具调用的参数、把几样东西凑到一起看: //! @@ -25,6 +25,8 @@ pub enum Check { CredentialToNetwork, /// 把本地文件的内容上传到外部主机 FileToNetwork, + /// 读写 ThinkWatch 自己的数据目录([`super::own_data`]) + OwnData, } impl Check { @@ -33,12 +35,14 @@ impl Check { match self { Check::CredentialToNetwork => "credential-to-network", Check::FileToNetwork => "file-to-network", + Check::OwnData => "thinkwatch-data", } } pub fn from_slug(s: &str) -> Option { match s { "credential-to-network" => Some(Check::CredentialToNetwork), "file-to-network" => Some(Check::FileToNetwork), + "thinkwatch-data" => Some(Check::OwnData), _ => None, } } @@ -51,6 +55,7 @@ impl Check { match self { Check::CredentialToNetwork => credential_to_network(args), Check::FileToNetwork => file_to_network(args), + Check::OwnData => super::own_data::find(args), } } } diff --git a/crates/tw-guard/src/tools/own_data.rs b/crates/tw-guard/src/tools/own_data.rs new file mode 100644 index 0000000..22f9761 --- /dev/null +++ b/crates/tw-guard/src/tools/own_data.rs @@ -0,0 +1,396 @@ +//! 工具调用碰 ThinkWatch 自己的数据目录。 +//! +//! 那个目录里有明文的全部上游密钥(`config.yaml`),也有管着这几项防护的设置。 +//! 读它,一步就拿走全部凭据;改它,就能关掉管着自己的防护 —— 连文件都不必写: +//! 拿着文件里的控制面钥匙经 socket 改配置,是同一件事。文件的 `0600` 挡的是别的 +//! 用户,挡不住以当前用户身份运行的智能体,所以这一道放在网关看得见的地方:模型 +//! 发出的工具调用。 +//! +//! # 为什么是代码,不是一条正则 +//! +//! **提到这个路径不等于动它。**改文档、写注释、回答「配置在哪」的工具调用里到处是 +//! `~/.thinkwatch`;路径一出现就切断,每个编辑这些文档的会话都会被误切(和 +//! `ordinary_documentation_does_not_trip_the_rules` 同一个道理)。所以按参数的 +//! **角色**看: +//! +//! - 参数是 JSON(函数调用):只看路径类的键(`file_path`、`path`、`cwd`、`workdir`……) +//! 和命令类的键(`command`、`cmd`、`code`、`args`……)的值;`content`、`new_string`、 +//! `description` 这类正文不看。任何值里有补丁的文件头(`*** Update File: …`)也算 +//! —— 那是在改这个文件。 +//! +//! **JSON 不解析,逐个字符串往下读**:流式时每来一片就对攒到一半的参数查一遍 +//! ([`super::wall`]),超过上限的参数也只攒前一段 —— 两种都不是完整的 JSON。解析不了 +//! 就退回按原文看,那会在一段还没收齐的正文里误切。 +//! - 参数不是 JSON(自定义工具的原文,比如 Codex 的 `apply_patch`):补丁的文件头, +//! 或者同一行里先有一个读写文件的命令、后有这个目录。 +//! +//! # 认得哪些位置 +//! +//! 默认的那几处:`~/.thinkwatch`(任何名为 `.thinkwatch` 的一级路径)、Windows 的 +//! `%APPDATA%\ThinkWatch`、服务器上的 `/var/lib/thinkwatch` 和 `/etc/thinkwatch` +//! (`env` 里是密钥)。`THINKWATCH_HOME` 指到别处的这里不知道,要用户自己加一条 +//! 自定义规则。**不分大小写**:macOS 默认的文件系统不分。 + +use std::ops::Range; +use std::sync::OnceLock; + +use regex::Regex; + +/// 指进数据目录的一段路径。前后的边界让 `.thinkwatch-lite`、`foo.thinkwatch`、 +/// `~/Dev/thinkwatch-core` 都不算。分隔符写成 `[\\/]+`:JSON 原文里 Windows 的 +/// 反斜杠是成对的。 +const DIR: &str = concat!( + r#"(?:(?:^|[\s"'`=:(,\\/~])\.thinkwatch"#, + r#"|(?:%appdata%|\$env:appdata|\$\{?appdata\}?|appdata[\\/]+roaming)[\\/]+thinkwatch"#, + r#"|(?:^|[\s"'`=:(,])/(?:var/lib|etc)/thinkwatch)"#, + r#"(?:[\\/\s"'`;|&),]|$)"#, +); + +/// 读写文件的命令。原文(不是 JSON)里,同一行先有它、后有数据目录才算。 +/// +/// **不收常见英文词**(`open`、`code`、`type`、`copy`、`more`):原文里也有说明文字。 +const VERB: &str = concat!( + r"(?:\b(?:cat|bat|head|tail|less|cp|mv|scp|rsync|sed|awk|grep|rg|vi|vim|nano|sqlite3", + r"|curl|socat|nc|rm|ls|cd|tee|chmod|get-content|set-content|remove-item|notepad)\b|>)", +); + +/// 补丁里「改哪个文件」的那一行。 +const PATCH: &str = r"^\*\*\*\s*(?:add file|update file|delete file|move to):"; + +fn dir() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| { + crate::bounded(&format!("(?i){DIR}")).expect("the data-directory pattern compiles") + }) +} + +/// 原文里算数的一行:补丁文件头,或者命令在前、目录在后。 +fn line() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| { + crate::bounded(&format!("(?im)(?:{PATCH}|{VERB})[^\n]*?{DIR}")) + .expect("the command-line pattern compiles") + }) +} + +/// 任何一个值里的补丁文件头(JSON 里补丁可能放在 `input`、`patch` 之类的键下)。 +fn patch() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| { + crate::bounded(&format!("(?im){PATCH}[^\n]*?{DIR}")).expect("the patch pattern compiles") + }) +} + +/// 这个键的值是路径或命令,而不是正文。 +fn operative(key: &str) -> bool { + let k = key.to_ascii_lowercase(); + ["path", "file", "dir", "cwd", "folder"] + .iter() + .any(|w| k.contains(w)) + || k.ends_with("url") + || k.ends_with("uri") + || matches!( + k.as_str(), + "command" | "commands" | "cmd" | "script" | "code" | "args" | "argv" + ) +} + +/// `within` 那一段里第一处数据目录,返回在 `s` 里的位置。 +fn dir_in(s: &str, within: Range) -> Option> { + let m = dir().find(&s[within.clone()])?; + Some(within.start + m.start()..within.start + m.end()) +} + +/// 一个字符串值里算数的命中,返回那一段路径(给摘录定位用)。 +/// +/// `closed` 为假是还没收齐的最后一个值:目录名后面得已经跟着分隔符,否则 +/// `~/.thinkwatch` 后面也许是 `-backup`。 +fn hit_in(s: &str, operative_key: bool, closed: bool) -> Option { + let m = if operative_key { + dir_in(s, 0..s.len()) + } else { + patch().find(s).and_then(|p| dir_in(s, p.range())) + }?; + let ends_at_eof = + m.end == s.len() && !s[..m.end].ends_with(|c: char| "\\/ \t\n\"'`;|&),".contains(c)); + if !closed && ends_at_eof { + return None; + } + Some(s[token(s, m)].to_string()) +} + +/// 读到哪一层了:对象里记着当前的键,数组里的值算外层那个键的。 +enum Frame { + Obj { key: String, at_key: bool }, + Arr, +} + +/// 逐个字符串读一段(也许没收齐的)JSON,找第一处算数的命中。 +fn json_hit(args: &str) -> Option { + let b = args.as_bytes(); + let mut stack: Vec = Vec::new(); + let mut i = 0; + while i < b.len() { + match b[i] { + b'{' => stack.push(Frame::Obj { + key: String::new(), + at_key: true, + }), + b'[' => stack.push(Frame::Arr), + b'}' | b']' => { + stack.pop(); + } + b',' => { + if let Some(Frame::Obj { at_key, .. }) = stack.last_mut() { + *at_key = true; + } + } + b'"' => { + let (raw, closed, next) = string_at(args, i + 1); + i = next; + let s = unescape(raw); + if let Some(Frame::Obj { key, at_key }) = stack.last_mut() + && *at_key + { + *key = s; + *at_key = false; + continue; + } + let op = stack + .iter() + .rev() + .find_map(|f| match f { + Frame::Obj { key, .. } => Some(operative(key)), + Frame::Arr => None, + }) + .unwrap_or(false); + if let Some(t) = hit_in(&s, op, closed) { + return Some(t); + } + continue; + } + _ => {} + } + i += 1; + } + None +} + +/// 从 `start`(开引号之后)读到闭引号:`(原文, 闭合了没有, 下一个位置)`。 +fn string_at(s: &str, start: usize) -> (&str, bool, usize) { + let b = s.as_bytes(); + let mut i = start; + while i < b.len() { + match b[i] { + b'\\' => i += 2, + b'"' => return (&s[start..i], true, i + 1), + _ => i += 1, + } + } + (&s[start..], false, b.len()) +} + +/// JSON 字符串转义还原。**宽松**:没收齐的结尾、坏掉的 `\u` 直接丢掉。 +fn unescape(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut it = raw.chars(); + while let Some(c) = it.next() { + if c != '\\' { + out.push(c); + continue; + } + match it.next() { + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('r') => out.push('\r'), + Some('u') => { + let hex: String = it.by_ref().take(4).collect(); + if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { + out.push(ch); + } + } + Some('b' | 'f') | None => {} + Some(other) => out.push(other), + } + } + out +} + +/// 摘录:命中所在的那一整段路径(`~/.thinkwatch/config.yaml`),而不只是目录名。 +fn token(s: &str, r: Range) -> Range { + let stop = |c: u8| c.is_ascii_whitespace() || b"\"'`=(,;|&<>".contains(&c); + let b = s.as_bytes(); + let mut start = r.start; + // 正则把前面那个分隔符也吃进来了 + while start < r.end && stop(b[start]) { + start += 1; + } + while start > 0 && !stop(b[start - 1]) { + start -= 1; + } + let mut end = r.end; + while end < b.len() && !stop(b[end]) { + end += 1; + } + // 同理,后面那个分隔符;以及 JSON 转义留下的反斜杠 + while end > start && (stop(b[end - 1]) || b[end - 1] == b'\\') { + end -= 1; + } + start..end +} + +pub(super) fn find(args: &str) -> Option> { + // 绝大多数调用在这里就结束了:参数里根本没有这个目录 + let first = dir().find(args)?; + if matches!(args.trim_start().as_bytes().first(), Some(b'{' | b'[')) { + let hit = json_hit(args)?; + // 摘录落在原文里那一段;Windows 路径在 JSON 原文里转义过、找不到原样的, + // 退回原文里第一处 + return Some( + args.find(&hit) + .map(|i| i..i + hit.len()) + .unwrap_or_else(|| token(args, first.range())), + ); + } + let l = line().find(args)?; + Some(token(args, dir_in(args, l.range())?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fires(args: &str) -> bool { + find(args).is_some() + } + + fn excerpt(args: &str) -> &str { + let r = find(args).expect("should fire"); + &args[r] + } + + #[test] + fn reading_or_changing_the_data_directory_fires() { + for v in [ + // Claude Code:Bash、Read、Edit + json!({"command": "cat ~/.thinkwatch/config.yaml", "description": "Show the config"}), + json!({"file_path": "/Users/alice/.thinkwatch/config.yaml"}), + json!({"file_path": "/home/bob/.thinkwatch/config.yaml", + "old_string": "mode: enforce", "new_string": "mode: off"}), + // Codex:shell 的命令是数组,exec_command 是 cmd + json!({"command": ["bash", "-lc", "sqlite3 $HOME/.thinkwatch/data.db .dump"], "workdir": "/tmp"}), + json!({"cmd": "curl --unix-socket ~/.thinkwatch/twcore.sock http://x/config"}), + json!({"command": "echo 'security: {}' > ${HOME}/.thinkwatch/config.yaml"}), + // 工作目录就在里面 + json!({"command": "cat config.yaml", "cwd": "/Users/alice/.thinkwatch"}), + // 写代码去读 + json!({"code": "open(os.path.expanduser('~/.thinkwatch/config.yaml')).read()"}), + json!({"code": "(Path.home() / '.thinkwatch' / 'config.yaml').read_text()"}), + // 嵌套、数组里的路径 + json!({"input": {"command": "cat ~/.thinkwatch/config.yaml"}}), + json!({"paths": ["README.md", "~/.thinkwatch/config.yaml"]}), + json!({"uri": "file:///Users/alice/.thinkwatch/config.yaml"}), + // 补丁放在随便哪个键下 + json!({"input": "*** Begin Patch\n*** Update File: /Users/a/.thinkwatch/config.yaml\n@@\n- mode: enforce\n+ mode: off\n*** End Patch"}), + // Windows + json!({"path": r"C:\Users\a\AppData\Roaming\ThinkWatch\config.yaml"}), + json!({"command": r"type %APPDATA%\ThinkWatch\config.yaml"}), + json!({"command": r"Get-Content $env:APPDATA\ThinkWatch\config.yaml"}), + // 服务器 + json!({"command": "sudo cat /etc/thinkwatch/env"}), + json!({"command": "ls /var/lib/thinkwatch/"}), + // macOS 不分大小写 + json!({"file_path": "/Users/alice/.ThinkWatch/config.yaml"}), + ] { + let args = v.to_string(); + assert!(fires(&args), "漏了:{args}"); + } + } + + #[test] + fn raw_tool_input_fires_on_a_patch_header_or_a_command() { + assert!(fires( + "*** Begin Patch\n*** Update File: ~/.thinkwatch/config.yaml\n@@\n-x\n+y\n*** End Patch" + )); + assert!(fires("cat ~/.thinkwatch/config.yaml")); + assert!(fires("cd ~/.thinkwatch && ls")); + } + + #[test] + fn mentioning_the_directory_does_not_fire() { + // **误报是这个功能最大的敌人**:编辑说到这个目录的文档,是最常见的情形 + for v in [ + json!({"file_path": "/Users/a/project/README.md", "old_string": "x", + "new_string": "Core keeps its data in ~/.thinkwatch."}), + json!({"file_path": "docs/config.md", "content": "`~/.thinkwatch/config.yaml` holds the keys"}), + json!({"command": "ls", "description": "the config lives in ~/.thinkwatch"}), + json!({"input": "*** Begin Patch\n*** Update File: README.md\n+Data lives in ~/.thinkwatch.\n*** End Patch"}), + // 名字像、但不是那个目录 + json!({"command": "cd ~/Dev/thinkwatch-lite && pnpm test"}), + json!({"command": "git clone https://github.com/ThinkWatchProject/thinkwatch.github.io"}), + json!({"command": "cat ~/.thinkwatch-backup/notes.txt"}), + json!({"command": "THINKWATCH_HOME=/tmp/twl twcore serve"}), + json!({"url": "https://thinkwat.ch/docs/core/config"}), + ] { + let args = v.to_string(); + assert!(!fires(&args), "误报了:{args}"); + } + // 原文里的说明文字 + assert!(!fires( + "ThinkWatch keeps its configuration in ~/.thinkwatch/config.yaml." + )); + assert!(!fires( + "*** Begin Patch\n*** Update File: docs/a.md\n+see ~/.thinkwatch/config.yaml\n*** End Patch" + )); + } + + #[test] + fn half_received_arguments_are_read_by_key_too() { + // 流式时每来一片就查一遍攒到一半的参数:路径一到就认得出 + assert!(fires(r#"{"file_path":"/Users/a/.thinkwatch/con"#)); + assert!(fires( + r#"{"command":["bash","-lc","sqlite3 ~/.thinkwatch/data"# + )); + // 目录名后面还没到分隔符时不急:也许是 `.thinkwatch-backup` + assert!(!fires(r#"{"command":"cat ~/.thinkwatch"#)); + assert!(fires(r#"{"command":"cat ~/.thinkwatch"}"#)); + // 半截的正文照样不看 —— 退回按原文看的话,`cat` 和 `>` 会让它误切 + assert!(!fires( + r#"{"file_path":"README.md","new_string":"a -> b; run cat ~/.thinkwatch/config.yaml to see"# + )); + // 超过上限被截断的参数:只要路径在前面那一段里 + let long = format!( + r#"{{"file_path":"/Users/a/.thinkwatch/config.yaml","content":"{}"#, + "x".repeat(100_000) + ); + assert!(fires(&long)); + } + + #[test] + fn escapes_in_the_json_text_are_undone_before_matching() { + // 补丁里的换行在 JSON 原文里是 `\n`,文件头要还原了才认得出 + let args = json!({"patch": "*** Begin Patch\n*** Delete File: ~/.thinkwatch/config.yaml\n*** End Patch"}) + .to_string(); + assert!(fires(&args)); + assert_eq!(unescape(r#"a\"b\\c\u0041\"#), "a\"b\\cA"); + } + + #[test] + fn the_excerpt_is_the_whole_path() { + let args = json!({"command": "cat ~/.thinkwatch/config.yaml | head"}).to_string(); + assert_eq!(excerpt(&args), "~/.thinkwatch/config.yaml"); + // 正文里先提到了,摘录仍落在真正动手的那一处 + let args = json!({"description": "read ~/.thinkwatch", "file_path": "/Users/a/.thinkwatch/data.db"}) + .to_string(); + assert_eq!(excerpt(&args), "/Users/a/.thinkwatch/data.db"); + assert_eq!( + excerpt("cat ~/.thinkwatch/config.yaml"), + "~/.thinkwatch/config.yaml" + ); + // Windows 路径在 JSON 原文里转义过:退回原文里那一段,照样是一整段路径 + let args = + json!({"path": r"C:\Users\a\AppData\Roaming\ThinkWatch\config.yaml"}).to_string(); + assert!(excerpt(&args).contains("ThinkWatch"), "{}", excerpt(&args)); + } +} diff --git a/crates/tw-guard/src/tools/rules.rs b/crates/tw-guard/src/tools/rules.rs index 8d01a0a..72b910c 100644 --- a/crates/tw-guard/src/tools/rules.rs +++ b/crates/tw-guard/src/tools/rules.rs @@ -559,7 +559,7 @@ mod tests { #[test] fn code_backed_rules_are_in_tool_inspection_but_not_in_the_config_scan() { - // 代码实现的规则(凭据外传、上传本地文件)是内置危险命令规则:工具调用审查要有, + // 代码实现的规则(凭据外传、上传本地文件、读写数据目录)是内置危险命令规则:工具调用审查要有, // 但客户端配置扫描不要(它只会直接读 `re`,而这些的 `re` 是永不匹配的) let tools = tool_rules(&[], |_| None, []).unwrap(); let a = tools @@ -585,9 +585,24 @@ mod tests { assert_eq!(b.check, Some(Check::FileToNetwork)); assert!(!b.high, "上传文件出厂只记录"); - // 配置扫描里两条都不在 + // 读写自己的数据目录:一步拿走全部上游密钥,高危 + let c = tools + .rules + .iter() + .find(|r| r.id == "thinkwatch-data") + .expect("数据目录规则应在工具调用审查里"); + assert_eq!(c.check, Some(Check::OwnData)); + assert!(c.high, "读写数据目录高危"); + let args = r#"{"command":"cat ~/.thinkwatch/config.yaml"}"#; + assert_eq!(c.find(args).unwrap().text, "~/.thinkwatch/config.yaml"); + + // 配置扫描里都不在 assert!(!scan_rules().rules.iter().any(|r| r.check.is_some())); - for id in ["secret-to-unknown-host", "upload-file-to-host"] { + for id in [ + "secret-to-unknown-host", + "upload-file-to-host", + "thinkwatch-data", + ] { assert!(!scan_rules().rules.iter().any(|r| r.id == id), "{id}"); } // 单独试一条也能编出来(走 one_builtin → compile) diff --git a/crates/tw-guard/src/tools/wall.rs b/crates/tw-guard/src/tools/wall.rs index 8a5020a..9e2c250 100644 --- a/crates/tw-guard/src/tools/wall.rs +++ b/crates/tw-guard/src/tools/wall.rs @@ -834,6 +834,32 @@ mod tests { assert!(!v[0].excerpt.contains(FAKE_KEY), "摘录里不能有密钥"); } + #[test] + fn reading_the_gateways_own_config_is_cut_through_the_streaming_wall() { + // 路径分两片到:拼齐之前不认,拼齐的那一片不转发 + let mut w = Wall::new(rules()); + w.feed(start(0, "Bash").as_bytes()); + assert!( + w.feed(arg(0, r#"{"command":"cat ~/.thi"#).as_bytes()) + .is_empty() + ); + let v = w.feed(arg(0, r#"nkwatch/config.yaml"}"#).as_bytes()); + assert_eq!(v.len(), 1, "{v:?}"); + assert!(v[0].cut, "读写数据目录是高危,拦截档下切断"); + assert_eq!(v[0].rule, "thinkwatch-data"); + assert_eq!(v[0].excerpt, "~/.thinkwatch/config.yaml"); + } + + #[test] + fn editing_a_document_that_mentions_the_gateways_data_passes_the_wall() { + let mut w = Wall::new(rules()); + w.feed(start(0, "Edit").as_bytes()); + let args = r#"{"file_path":"README.md","old_string":"x","new_string":"Keys live in ~/.thinkwatch/config.yaml; cat it > /dev/null"}"#; + for part in [&args[..40], &args[40..80], &args[80..]] { + assert!(w.feed(arg(0, part).as_bytes()).is_empty(), "{part}"); + } + } + #[test] fn a_credential_to_its_own_provider_passes_the_wall() { let mut w = Wall::new(rules()); diff --git a/crates/tw-guard/src/view.rs b/crates/tw-guard/src/view.rs index 43ef65b..c59eb7a 100644 --- a/crates/tw-guard/src/view.rs +++ b/crates/tw-guard/src/view.rs @@ -508,8 +508,8 @@ mod tests { #[test] fn the_code_backed_tool_rules_are_listed_with_a_builtin_matcher() { - // 代码实现的两条规则(凭据外传、上传本地文件)在规则表里照样列得出来: - // 带专门的 matcher(没有正则可展示),处置按出厂(A 切断、B 仅记录) + // 代码实现的规则(凭据外传、上传本地文件、读写数据目录)在规则表里照样列得出来: + // 带专门的 matcher(没有正则可展示),处置按出厂(A、C 切断,B 仅记录) let v = inspect_tools(&ToolPolicy::default()); let a = v .rules @@ -536,6 +536,18 @@ mod tests { } ); assert_eq!(b.action, Some(RuleAction::Record), "出厂只记录"); + let c = v + .rules + .iter() + .find(|r| r.id == "thinkwatch-data") + .expect("数据目录规则应当在表里"); + assert_eq!( + c.matcher, + Matcher::Builtin { + check: "thinkwatch-data".into() + } + ); + assert_eq!(c.action, Some(RuleAction::Cut), "高危,拦截档下切断"); // 经过一趟 JSON 还认得回来 let json = serde_json::to_value(&a.matcher).unwrap(); assert_eq!(json["kind"], "builtin"); diff --git a/docs/config.md b/docs/config.md index f542b4d..3389d82 100644 --- a/docs/config.md +++ b/docs/config.md @@ -25,6 +25,25 @@ control socket (`twcore.sock`; on Windows a loopback port recorded in `control.port`). The directory is private to its owner (`0700`), the file is `0600`: it holds keys in plain text. +These permissions are the file's only protection on this machine. They keep +out other users, not programs running as the same user: such a program can +read every key in the file, and with the control key it holds, change the +configuration through the control plane. Outbound redaction does not change +that; it protects what a request carries off the machine, not what is on disk. +The control key is masked where the configuration is shown so that a write +through the control plane cannot change it +([`listen.control`](#cfg-listen-control)), not to keep it from local +programs; upstream and gateway keys are shown as written. + +Tool-call inspection has a built-in rule for this directory, +`thinkwatch-data`. A tool call whose path or command points into one of the +default locations above, or into `/var/lib/thinkwatch` or `/etc/thinkwatch` +on a server, is recorded, and under `enforce` the response is cut off, so a +model cannot be steered into reading these keys or rewriting its own +protections. Mentioning the path, as in a document being edited, does not +count. With `THINKWATCH_HOME` elsewhere, a custom rule in +[`security.inspect_tools`](#cfg-security-inspect_tools) can cover that path. + `twcore serve` writes a starting configuration when there is none, and `twcore init` writes one on request. Both produce this: @@ -736,6 +755,7 @@ Built-in rules: | `exfil-credentials-reversed` | Send out a credential file (verb first) | `cut` | | `ssh-key-read` | Read a private key or cloud credential | `cut` | | `secret-to-unknown-host` | Send a credential to an unknown host | `cut` | +| `thinkwatch-data` | Read or change ThinkWatch's own data | `cut` | | `write-startup-item` | Write a startup item | `cut` | | `crontab-install` | Install a scheduled job | `cut` | | `rm-rf-root` | Delete home or root | `record` | diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index 3e0307d..e7a455c 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -15,6 +15,10 @@ ThinkWatch Core 只读一个文件:`config.yaml`。本文逐项说明其中每 `THINKWATCH_HOME` 替换整个目录;`--config <路径>` 为单条命令指定文件。core 的其余数据也在这个目录里:请求数据库(`data.db`)、配置历史(`history/`)、下载的价目表(`model_prices.json`),以及本地控制通道的 socket 文件(`twcore.sock`;Windows 上是回环端口,记录在 `control.port` 中)。目录只有所有者可访问(`0700`),配置文件权限为 `0600`:其中以明文保存密钥。 +这两项权限就是这份文件在本机上的全部保护:挡得住其他用户,挡不住以同一用户身份运行的程序。这样的程序能读到文件里的每一把密钥,也能用其中的控制通道密钥经控制通道修改配置。出站脱敏不改变这一点:它保护的是请求带出本机的内容,不是磁盘上的文件。显示配置时给控制通道密钥打码,是为了让经控制通道的写入改不了它(见 [`listen.control`](#cfg-listen-control)),而不是对本机程序隐藏它;上游密钥和网关密钥按原样显示。 + +工具调用审查为这个目录内置了一条规则 `thinkwatch-data`:工具调用的路径或命令指向上述默认位置之一,或服务器上的 `/var/lib/thinkwatch`、`/etc/thinkwatch` 时记录下来,`enforce` 下切断响应。这样模型无法被诱导去读取这些密钥、改写约束它自己的防护。只是提到这个路径(例如在正在编辑的文档里)不算。`THINKWATCH_HOME` 指向别处时,可以在 [`security.inspect_tools`](#cfg-security-inspect_tools) 里为那个路径加一条自定义规则。 + 没有配置文件时,`twcore serve` 会写入一份初始配置;`twcore init` 也可以按需生成。两者生成的内容如下: ```yaml @@ -581,6 +585,7 @@ pricing: | `exfil-credentials-reversed` | Send out a credential file (verb first) | `cut` | | `ssh-key-read` | Read a private key or cloud credential | `cut` | | `secret-to-unknown-host` | Send a credential to an unknown host | `cut` | +| `thinkwatch-data` | Read or change ThinkWatch's own data | `cut` | | `write-startup-item` | Write a startup item | `cut` | | `crontab-install` | Install a scheduled job | `cut` | | `rm-rf-root` | Delete home or root | `record` |