-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathworkspace.rs
More file actions
308 lines (292 loc) · 13.3 KB
/
Copy pathworkspace.rs
File metadata and controls
308 lines (292 loc) · 13.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
// Workspace path confinement. Every file tool resolves paths against a
// root and rejects escapes (absolute paths, `..` traversal, symlinks pointing
// out). bash runs with cwd locked to the root.
// Also includes a restricted-path list (.env, .git/**, .ssh/**, id_rsa, …)
// that the approval gate uses to PROMPT (under Destructive/Always) rather than
// hard-block. Under Approval::Never the list is not enforced at all.
//
// The path CONFINEMENT itself (reject absolute / `..` / symlink-escape) is
// ALSO approval-gated: under `Approval::Never` the file tools call
// `resolve_unconfined` (see tools::resolve_ws) which skips every confinement
// check, so the model may read/write ANY path — absolute, parent-traversing,
// or symlinked-out — matching the "trust the model fully" intent of Never.
// The dangerous-path list is a separate, independent guard (also Never-off).
use std::path::{Path, PathBuf};
/// Restricted paths the agent should not read or write without explicit
/// approval. These are shell/ssh config files, VCS internals, and
/// secret-bearing files (.env, private keys) that could cause permanent
/// damage or leak secrets if touched by an AI.
///
/// Enforcement is approval-gated (see main::restricted_path_for_tool): under
/// `Approval::Never` these are NOT enforced at all (ALL file restrictions
/// disabled); under `Destructive`/`Always` a match forces an approval prompt
/// (for reads AND writes) instead of an unconditional hard block.
const DANGEROUS_PATHS: &[&str] = &[
".git/**",
"**/.bashrc",
"**/.bash_profile",
"**/.profile",
"**/.zshrc",
"**/.ssh/**",
"**/.gnupg/**",
"**/id_rsa",
"**/id_ed25519",
"**/.env",
"**/.env.local",
"**/.env.production",
];
/// Check if a resolved path matches any restricted pattern.
/// Returns Some(reason) if restricted, None if allowed. The approval gate
/// turns a match into a prompt (Destructive/Always) — Never skips it entirely.
pub fn check_dangerous_path(input: &str) -> Option<String> {
for pattern in DANGEROUS_PATHS {
if glob_match_path(pattern, input) {
return Some(format!(
"path {input:?} matches restricted pattern '{pattern}'"
));
}
}
None
}
/// Collapse `./` segments, duplicate slashes, and trailing slashes from a
/// relative path so `.//.git/config`, `a/./b`, and `a//b` all match their
/// canonical forms. Dot-dirs like `.git` are kept (the `.` is part of the
/// name, not a current-dir marker).
fn normalize_rel_path(s: &str) -> String {
s.split('/')
.filter(|seg| !seg.is_empty() && *seg != ".")
.collect::<Vec<_>>()
.join("/")
}
/// Simple glob match for path patterns. Supports ** (any depth). Matching is
/// case-insensitive (so `.GIT/config`/`.SSH/...`/`.ENV` are blocked on
/// case-insensitive filesystems like macOS/Windows) and the path is normalized
/// so `.//.git/config` can't slip past a pattern by adding a stray `./`.
fn glob_match_path(pattern: &str, path: &str) -> bool {
let pattern = normalize_rel_path(pattern).to_ascii_lowercase();
let path = normalize_rel_path(path).to_ascii_lowercase();
// ** matches any path depth
if pattern.contains("**") {
// Split on **, match prefix and suffix
let parts: Vec<&str> = pattern.split("**").collect();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1];
let suffix = suffix.trim_start_matches('/');
if suffix.is_empty() {
// ** with a leading anchor (e.g. `.git/**`) — match the anchor
// as a path COMPONENT (anywhere in the tree), NOT as a substring.
// `.git/**` must match `.git/config` and `sub/.git/refs` but NOT
// `.github/workflows/ci.yml`, which merely contains the substring
// `.git` (a false-positive that blocked editing `.github/**`).
let anchor = prefix.trim_start_matches('/').trim_end_matches('/');
if anchor.is_empty() {
return true; // bare `**` matches everything
}
return path.split('/').any(|seg| seg == anchor);
}
// Check if path starts with prefix and ends with suffix
// For "**/.bashrc", match any path ending with /.bashrc
if prefix.is_empty() {
return path == suffix
|| path.ends_with(&format!("/{suffix}"))
|| path == format!("/{suffix}");
}
if path.starts_with(prefix) {
return path.ends_with(suffix) || path.ends_with(&format!("/{suffix}"));
}
return false;
}
if parts.len() == 3 {
// **/anchor/** — match if the path has `anchor` as a path segment
// (e.g. **/.ssh/** matches ".ssh/config" and "home/.ssh/x"); the two
// ** absorb anything before/after the anchor. Fixes a latent bug
// where two-** patterns never matched.
let prefix = parts[0];
let middle = parts[1];
let suffix = parts[2];
if !path.starts_with(prefix) || !path.ends_with(suffix) {
return false;
}
let anchor = middle.trim_matches('/');
if anchor.is_empty() {
return true;
}
return path.split('/').any(|seg| seg == anchor);
}
}
// Exact or suffix match for patterns without **
path == pattern || path.ends_with(&format!("/{pattern}"))
}
/// Resolve `input` against `root`, refusing absolute paths and `..` escapes.
/// Symlinks are canonicalized and must stay within the canonical root.
pub fn resolve(root: &Path, input: &str) -> Result<PathBuf, String> {
let p = Path::new(input);
// Reject absolute paths outright — the agent works inside the workspace.
if p.is_absolute() {
return Err(format!(
"path {input:?} is absolute; only workspace-relative paths allowed"
));
}
// Reject any component that escapes via `..`.
for comp in p.components() {
use std::path::Component::*;
match comp {
Prefix(_) | RootDir => {
return Err(format!("path {input:?} escapes the workspace"));
}
ParentDir => {
return Err(format!(
"path {input:?} contains '..'; workspace escape denied"
));
}
CurDir | Normal(_) => {}
}
}
let joined = root.join(p);
// Canonicalize to catch symlink escapes. The root itself may not exist yet
// for write_file (parents created later), so canonicalize the parent chain
// leniently: canonicalize what exists, then re-check the tail.
let canon_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let canon = match std::fs::canonicalize(&joined) {
Ok(c) => c,
Err(_) => {
// Target doesn't exist yet (write/create). Canonicalize the EXISTING
// prefix incrementally so an intermediate symlinked directory that
// points OUTSIDE the workspace is resolved (and then rejected by the
// confinement check below). A naive `cur.join(comp)` would leave the
// symlink un-resolved and the subsequent write would escape (P0-2).
let mut cur = canon_root.clone();
for comp in p.components() {
if let std::path::Component::Normal(s) = comp {
let candidate = cur.join(s);
cur = std::fs::canonicalize(&candidate).unwrap_or(candidate);
}
}
cur
}
};
if !canon.starts_with(&canon_root) {
return Err(format!("path {input:?} resolves outside the workspace"));
}
Ok(canon)
}
/// Resolve `input` against `root` WITHOUT path confinement — the untrusted-
/// model guards (absolute-path rejection, `..` traversal rejection, symlink-
/// escape rejection) are all SKIPPED. Used under `Approval::Never`, where the
/// model is fully trusted and ALL file restrictions are disabled.
///
/// Absolute paths are returned as-is; relative paths are joined to `root`
/// (so `src/foo.rs` still resolves to `<root>/src/foo.rs`, and `../escape`
/// becomes `<root>/../escape`, which the OS resolves naturally when the path is
/// opened). No canonicalization is performed — it was only needed to detect
/// symlink escapes, which are no longer rejected here.
pub fn resolve_unconfined(root: &Path, input: &str) -> Result<PathBuf, String> {
let p = Path::new(input);
if p.is_absolute() {
return Ok(p.to_path_buf());
}
Ok(root.join(p))
}
/// True if `path` (already resolved) is confined within `root`.
#[allow(dead_code)]
pub fn is_confined(root: &Path, path: &Path) -> bool {
let canon_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
canon.starts_with(&canon_root)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmp_root() -> PathBuf {
// ponytail: unique dir per call via atomic counter — the old fixed name
// raced under parallel `cargo test` (one thread removes it while another
// canonicalizes). Mirrors tools.rs::tmp_ws.
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::SeqCst);
let d = std::env::temp_dir().join(format!("catalyst_code_ws_test_{}", n));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
fs::write(d.join("a.txt"), "hi").unwrap();
fs::create_dir_all(d.join("sub")).unwrap();
d
}
#[test]
fn relative_inside_ok() {
let r = tmp_root();
let p = resolve(&r, "a.txt").unwrap();
assert!(p.ends_with("a.txt"));
let p = resolve(&r, "sub/b.txt").unwrap();
assert!(p.starts_with(std::fs::canonicalize(&r).unwrap()));
}
#[test]
fn absolute_rejected() {
let r = tmp_root();
assert!(resolve(&r, "/etc/passwd").is_err());
}
#[test]
fn parent_dir_rejected() {
let r = tmp_root();
assert!(resolve(&r, "../escape").is_err());
assert!(resolve(&r, "sub/../../escape").is_err());
}
#[test]
fn dangerous_paths_case_insensitive() {
// Case-insensitive match so `.GIT/config`/`.SSH/...`/`.ENV` are blocked
// on case-insensitive filesystems (macOS, Windows), not just lowercase.
assert!(check_dangerous_path(".GIT/config").is_some());
assert!(check_dangerous_path(".SSH/authorized_keys").is_some());
assert!(check_dangerous_path(".ENV").is_some());
assert!(check_dangerous_path(".git/config").is_some());
}
#[test]
fn dangerous_paths_normalize_dot_slash() {
// `.//.git/config` must still match `.git/**` after normalization.
assert!(check_dangerous_path(".//.git/config").is_some());
assert!(check_dangerous_path("a/./.env").is_some());
// A legit path that merely contains "git" (no leading dot) is NOT .git/**.
assert!(check_dangerous_path("mygitthing/file").is_none());
// `.github/**` must NOT match `.git/**` (substring false-positive):
// `.github` is a path component, not the `.git` directory.
assert!(check_dangerous_path(".github/workflows/ci.yml").is_none());
assert!(check_dangerous_path(".github/ISSUE_TEMPLATE/foo.md").is_none());
// A nested `.git` directory IS blocked (component match anywhere).
assert!(check_dangerous_path("vendor/lib/.git/config").is_some());
assert!(check_dangerous_path("sub/.git/HEAD").is_some());
}
#[cfg(unix)]
#[test]
fn unconfined_allows_absolute_and_parent_traversal() {
// Under Approval::Never the file tools use resolve_unconfined: absolute
// paths and `..` traversal are NOT rejected (the model is fully trusted).
let r = tmp_root();
// Absolute path is returned verbatim (NOT rejected).
let p = resolve_unconfined(&r, "/etc/passwd").unwrap();
assert_eq!(p, PathBuf::from("/etc/passwd"));
// `..` traversal is allowed — joined to root, OS resolves the `..`.
let p = resolve_unconfined(&r, "../escape").unwrap();
assert!(p.ends_with("../escape"));
// A normal relative path still resolves under the root.
let p = resolve_unconfined(&r, "sub/b.txt").unwrap();
assert!(p.starts_with(std::fs::canonicalize(&r).unwrap()));
}
#[cfg(unix)]
#[test]
fn symlinked_dir_escape_rejected() {
use std::os::unix::fs::symlink;
let r = tmp_root();
// `linkdir` is a symlink to a directory OUTSIDE the workspace.
let outside =
std::env::temp_dir().join(format!("catalyst_code_escape_{}", std::process::id()));
let _ = fs::remove_dir_all(&outside);
fs::create_dir_all(&outside).unwrap();
symlink(&outside, r.join("linkdir")).unwrap();
// Resolving a NEW file through the symlinked dir must be rejected (P0-2).
assert!(resolve(&r, "linkdir/newfile").is_err());
// A normal new file inside the workspace still resolves.
assert!(resolve(&r, "sub/newfile").is_ok());
let _ = fs::remove_dir_all(&outside);
}
}