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` |