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
//! CRITICAL security fix (guarantor audit, traced to
//! `permissions::rules::evaluate_path` @ `crates/harness/src/permissions/rules.rs:240`
//! and `agent::permissions_gate_denial_impl`): the ONE shared home for this
//! crate's path-traversal / symlink-escape safety primitives.
//!
//! # Why this module exists
//! The exact `.git`-clobber bug class [`crate::checkpoint`] fixed for its
//! own restore path (P5-9, commits d37d66e/20962bf) was independently
//! reachable through the DEFAULT `write_file`/`edit_file`/`apply_patch`/
//! `read_file` tools, via `permissions.protected_paths`, because the fix was
//! never propagated: `evaluate_path` glob-matched the RAW model-supplied
//! path string with no normalization at all, so `write_file
//! path="x/../.git/config"` did not literally match a `.git/**`
//! protected-path rule even though it resolves right back onto the real
//! `.git/config`. Root cause (the audit's own words): "NO shared safe-path
//! helper — FOUR separate path-validation impls of differing quality; the
//! correct dual lexical+resolved check lives ONLY in checkpoint." This
//! module is the fix for THAT: [`crate::checkpoint`]'s proven primitives,
//! extracted here and reused by [`crate::checkpoint`] itself (delegating,
//! not duplicating), the permissions gate
//! ([`crate::permissions::rules::evaluate_path_safe`] /
//! `crate::agent::Agent::permissions_gate_denial_impl`), and
//! [`crate::tools`]'s `WorkspaceWrite` sandbox containment
//! (`path_within`).
//!
//! A fourth pre-existing impl, `crate::agent::import_target_is_contained`
//! (the `@`-import containment check, P4b), is NOT migrated here — it
//! solves a narrower, already-existing-file-only problem (a plain
//! `std::fs::canonicalize` on both sides) with its own long-standing test
//! coverage, and migrating it carries real regression risk for no security
//! gain (imports were never part of this bug class: there is no reported
//! bypass against it). Left as-is.
//! // TODO(safe-path consolidation): consider routing
//! // `crate::agent::import_target_is_contained` through
//! // [`resolve_real`]/[`contained`] too, for a not-yet-existing-target
//! // symlink-tail edge case it doesn't currently need (imports only ever
//! // target existing files) — tracked, not required by this fix.
//!
//! # The two views of a path
//! Every caller-supplied path has two distinct "real" forms that can
//! disagree exactly when a path component is a symlink:
//! - the **lexical** form: `..`/`.` collapsed textually
//! ([`crate::tools::normalize`]), symlinks never consulted;
//! - the **resolved** form: the longest EXISTING ancestor is canonicalized
//! (following symlinks), then any not-yet-existing tail is re-appended
//! verbatim ([`resolve_real`]) — this is what lets a `write_file` target
//! that doesn't exist yet still be checked (`std::fs::canonicalize` alone
//! errors on a non-existent path).
//!
//! A traversal payload like `x/../.git/config` looks clean LEXICALLY at the
//! raw-string level (no literal `.git/` prefix) but normalizes right back to
//! `.git/config` — the lexical form alone already defeats it. A symlink
//! payload (a pre-existing `foo -> .git`, target `foo/config`) is clean in
//! BOTH the raw string AND the lexical-normalized form (`foo/config`, no
//! `.git/` prefix anywhere) AND still lexically "contained" under root (a
//! symlink doesn't escape the root path string, it just relands elsewhere
//! inside it) — only the RESOLVED form reveals it actually lands on
//! `.git/config`. Any protected-floor / containment check that consults
//! only one of these two forms can be bypassed by the other; every check in
//! this module (and every caller of it) consults BOTH.
use ;
/// Resolve `path` to its canonical, symlink-followed absolute form:
/// lexically collapses `..`/`.` first ([`crate::tools::normalize`] — so a
/// not-yet-created path's literal `..` can't lexically claim containment),
/// then walks up from the normalized path to its longest EXISTING ancestor
/// and canonicalizes THAT (resolving any symlink along the way), then
/// re-appends the not-yet-existing tail components verbatim. Fails (`None`)
/// on any resolution error. THE single resolution routine every containment
/// check in this crate builds on.
pub
/// Symlink- and traversal-safe containment check: is `path` (absolute,
/// possibly not-yet-existing) confined under `root`? First lexically
/// collapses `..`/`.` ([`crate::tools::normalize`] — so a not-yet-created
/// path's literal `..` can't lexically claim containment), then resolves
/// the longest EXISTING ancestor (following any symlink along the way — see
/// [`resolve_real`]). Fails closed (`false`) on any resolution error,
/// including `root` itself failing to canonicalize.
pub
/// Lexically normalize `path` (expected to be `root.join(rel)` for some
/// caller-declared `rel`) via [`crate::tools::normalize`], then — if the
/// normalized form is still under `root` — return its project-relative,
/// `/`-separated tail. Callers compute this ONCE per entry and feed the
/// single result to a protected-floor check, so that check sees EXACTLY the
/// same normalized path [`contained`]'s containment check computes.
pub
/// Resolved (symlink-following) counterpart to [`normalized_project_rel`]:
/// canonicalizes `path`'s longest existing ancestor (resolving any symlink
/// along the way — see [`resolve_real`]) and returns the resulting
/// absolute path's tail relative to `root`'s own canonical form, as a
/// `/`-separated string — or `None` if resolution fails, or the resolved
/// path lands outside `root` entirely.
pub
/// Up-front rejection for a caller-declared relative path that could never
/// be a legitimate, honestly-produced project-relative path: an absolute
/// path, or one containing a `..` (`ParentDir`), root, empty, or
/// Windows-prefix component. Returns the refusal reason, or `None` if `rel`
/// is clean. Intended for callers whose input is EXPECTED to always be a
/// clean relative path already (e.g. a checkpoint manifest entry) — NOT for
/// a raw model-supplied tool `path` argument, which may legitimately be
/// absolute (see [`resolve_for_matching`] for that case instead).
pub
/// Is `rel` (a project-relative, `/`-separated path) a hard floor that must
/// never be written to, regardless of caller-supplied config? `.git` (and
/// everything under it) is unconditional; `extra_globs` (e.g.
/// `Config::permissions_protected_paths`) layers additional patterns on
/// top.
pub
/// The outcome of [`resolve_for_matching`] — the two safe glob-matching
/// subjects for a raw, caller-supplied `path` tool argument (which, unlike
/// a checkpoint manifest entry, may legitimately be absolute — e.g. a
/// `write_file` call under `SandboxPolicy::DangerFullAccess`), OR a reason
/// it can't be resolved safely.
pub
/// Resolve a raw, caller-supplied `path` argument (relative to `root` if
/// not already absolute — the same "join onto cwd" contract
/// `crate::tools::ToolContext::resolve` uses) into the two safe
/// glob-matching subjects a permissions-gate protected-path / path-rule
/// check must consult, per this module's doc comment. This is what closes
/// the CRITICAL bug: `write_file path="x/../.git/config"` does not
/// literally glob-match a `.git/**` protected-path rule as a raw string,
/// but `resolve_for_matching` computes its `lexical_rel` (and, since no
/// symlink is involved here, `resolved_rel`) as `.git/config`, which does.
pub