@@ -173,33 +173,119 @@ impl PermissionEnforcer {
173173 }
174174}
175175
176- /// Simple workspace boundary check via string prefix.
176+ /// Workspace boundary check.
177+ ///
178+ /// Resolves `.` and `..` components lexically *before* comparing against the
179+ /// workspace root, so that traversal sequences like `/workspace/../../etc`
180+ /// cannot escape the sandbox via a naive string prefix match. Normalization is
181+ /// lexical (it does not touch the filesystem) because the target path may not
182+ /// exist yet on a write, and we must not depend on CWD.
177183fn is_within_workspace ( path : & str , workspace_root : & str ) -> bool {
178- let normalized = if path. starts_with ( '/' ) {
184+ let combined = if path. starts_with ( '/' ) {
179185 path. to_owned ( )
180186 } else {
181187 format ! ( "{workspace_root}/{path}" )
182188 } ;
183189
184- let root = if workspace_root. ends_with ( '/' ) {
185- workspace_root. to_owned ( )
190+ let normalized = lexically_normalize ( & combined) ;
191+ let root = lexically_normalize ( workspace_root) ;
192+ let root_with_slash = if root. ends_with ( '/' ) {
193+ root. clone ( )
186194 } else {
187- format ! ( "{workspace_root }/" )
195+ format ! ( "{root }/" )
188196 } ;
189197
190- normalized. starts_with ( & root) || normalized == workspace_root. trim_end_matches ( '/' )
198+ normalized == root || normalized. starts_with ( & root_with_slash)
199+ }
200+
201+ /// Collapse `.` and `..` segments without consulting the filesystem.
202+ /// `..` that would climb above an absolute root is clamped at `/`, so the
203+ /// result can never be a prefix-match for a deeper workspace root.
204+ fn lexically_normalize ( path : & str ) -> String {
205+ let is_absolute = path. starts_with ( '/' ) ;
206+ let mut stack: Vec < & str > = Vec :: new ( ) ;
207+ for component in path. split ( '/' ) {
208+ match component {
209+ "" | "." => { }
210+ ".." => {
211+ stack. pop ( ) ;
212+ }
213+ other => stack. push ( other) ,
214+ }
215+ }
216+ let joined = stack. join ( "/" ) ;
217+ if is_absolute {
218+ format ! ( "/{joined}" )
219+ } else {
220+ joined
221+ }
191222}
192223
193224/// Conservative heuristic: is this bash command read-only?
225+ ///
226+ /// Hardening notes:
227+ /// - Any shell metacharacter that could chain, substitute, pipe, or redirect
228+ /// into a state-changing command rejects the whole line. This blocks
229+ /// `cat x; rm -rf y`, `cat x | sh`, `$(...)`, backticks, redirects, and
230+ /// subshells regardless of the leading token.
231+ /// - Language interpreters (`python`, `node`, `ruby`) and build drivers
232+ /// (`cargo`, `rustc`) are NOT read-only: they execute arbitrary code, so they
233+ /// are excluded from the allow-list.
234+ /// - `git` is allowed only for a known set of non-mutating subcommands.
235+ /// - `find` is rejected when it carries an action that can execute or delete.
236+ ///
237+ /// Residual known gaps (documented, not yet closed): `sed`'s `w`/`e` script
238+ /// commands and `awk`'s `system()` can still mutate — these require quoting or
239+ /// metacharacters that the checks above usually catch, but a dedicated parser
240+ /// would be more robust. Tracked as follow-up.
194241fn is_read_only_command ( command : & str ) -> bool {
195- let first_token = command
196- . split_whitespace ( )
242+ // Shell metacharacters that enable command chaining, substitution,
243+ // piping, redirection, or subshells. Presence of any of these means we
244+ // cannot reason about the command from its leading token alone.
245+ const SHELL_METACHARS : & [ char ] =
246+ & [ ';' , '|' , '&' , '$' , '`' , '>' , '<' , '(' , ')' , '{' , '}' , '\n' ] ;
247+ if command. contains ( SHELL_METACHARS ) {
248+ return false ;
249+ }
250+
251+ let mut tokens = command. split_whitespace ( ) ;
252+ let first_token = tokens
197253 . next ( )
198254 . unwrap_or ( "" )
199255 . rsplit ( '/' )
200256 . next ( )
201257 . unwrap_or ( "" ) ;
202258
259+ // `git` is only read-only for a curated set of subcommands.
260+ if first_token == "git" {
261+ let subcommand = tokens. next ( ) . unwrap_or ( "" ) ;
262+ return matches ! (
263+ subcommand,
264+ "status"
265+ | "log"
266+ | "diff"
267+ | "show"
268+ | "branch"
269+ | "rev-parse"
270+ | "ls-files"
271+ | "blame"
272+ | "describe"
273+ | "tag"
274+ | "remote"
275+ ) ;
276+ }
277+
278+ // `find` can execute or delete via actions; reject those forms.
279+ if first_token == "find"
280+ && ( command. contains ( "-exec" )
281+ || command. contains ( "-execdir" )
282+ || command. contains ( "-delete" )
283+ || command. contains ( "-ok" )
284+ || command. contains ( "-fprintf" ) )
285+ {
286+ return false ;
287+ }
288+
203289 matches ! (
204290 first_token,
205291 "cat"
@@ -237,8 +323,6 @@ fn is_read_only_command(command: &str) -> bool {
237323 | "tr"
238324 | "cut"
239325 | "paste"
240- | "tee"
241- | "xargs"
242326 | "test"
243327 | "true"
244328 | "false"
@@ -257,18 +341,8 @@ fn is_read_only_command(command: &str) -> bool {
257341 | "tree"
258342 | "jq"
259343 | "yq"
260- | "python3"
261- | "python"
262- | "node"
263- | "ruby"
264- | "cargo"
265- | "rustc"
266- | "git"
267- | "gh"
268344 ) && !command. contains ( "-i " )
269345 && !command. contains ( "--in-place" )
270- && !command. contains ( " > " )
271- && !command. contains ( " >> " )
272346}
273347
274348#[ cfg( test) ]
@@ -375,6 +449,85 @@ mod tests {
375449 assert ! ( !is_read_only_command( "sed -i 's/a/b/' file" ) ) ;
376450 }
377451
452+ // --- Hardening regression tests (#2: read-only bypasses) ---
453+
454+ #[ test]
455+ fn read_only_rejects_command_chaining ( ) {
456+ // A leading read-only token must not launder a trailing destructive one.
457+ assert ! ( !is_read_only_command( "cat foo; rm -rf bar" ) ) ;
458+ assert ! ( !is_read_only_command( "cat foo && rm -rf bar" ) ) ;
459+ assert ! ( !is_read_only_command( "ls || rm bar" ) ) ;
460+ assert ! ( !is_read_only_command( "cat foo | sh" ) ) ;
461+ assert ! ( !is_read_only_command( "echo `rm bar`" ) ) ;
462+ assert ! ( !is_read_only_command( "echo $(rm bar)" ) ) ;
463+ assert ! ( !is_read_only_command( "echo x>file" ) ) ; // redirect without spaces
464+ }
465+
466+ #[ test]
467+ fn read_only_rejects_interpreters_and_build_drivers ( ) {
468+ // These execute arbitrary code and are no longer read-only.
469+ assert ! ( !is_read_only_command(
470+ "python3 -c \" import os; os.system('rm -rf .')\" "
471+ ) ) ;
472+ assert ! ( !is_read_only_command( "python script.py" ) ) ;
473+ assert ! ( !is_read_only_command( "node app.js" ) ) ;
474+ assert ! ( !is_read_only_command( "ruby x.rb" ) ) ;
475+ assert ! ( !is_read_only_command( "cargo run" ) ) ;
476+ assert ! ( !is_read_only_command( "rustc evil.rs" ) ) ;
477+ }
478+
479+ #[ test]
480+ fn read_only_gates_git_subcommands ( ) {
481+ // Read-only git subcommands remain allowed...
482+ assert ! ( is_read_only_command( "git status" ) ) ;
483+ assert ! ( is_read_only_command( "git diff HEAD~1" ) ) ;
484+ assert ! ( is_read_only_command( "git show abc123" ) ) ;
485+ // ...but mutating/exfiltrating ones are rejected.
486+ assert ! ( !is_read_only_command( "git commit -m x" ) ) ;
487+ assert ! ( !is_read_only_command( "git push origin main" ) ) ;
488+ assert ! ( !is_read_only_command( "git reset --hard" ) ) ;
489+ assert ! ( !is_read_only_command( "git clean -fd" ) ) ;
490+ assert ! ( !is_read_only_command( "git config user.email a@b.c" ) ) ;
491+ }
492+
493+ #[ test]
494+ fn read_only_rejects_find_actions ( ) {
495+ assert ! ( is_read_only_command( "find . -name Cargo.toml" ) ) ;
496+ assert ! ( !is_read_only_command( "find . -delete" ) ) ;
497+ // -exec uses braces/semicolon which also trip the metachar guard,
498+ // but the explicit action check is the primary defense.
499+ assert ! ( !is_read_only_command( "find . -execdir rm rf" ) ) ;
500+ }
501+
502+ // --- Hardening regression tests (#1: workspace path traversal) ---
503+
504+ #[ test]
505+ fn workspace_rejects_parent_traversal ( ) {
506+ assert ! ( !is_within_workspace( "/workspace/../etc/passwd" , "/workspace" ) ) ;
507+ assert ! ( !is_within_workspace(
508+ "/workspace/../../etc/crontab" ,
509+ "/workspace"
510+ ) ) ;
511+ assert ! ( !is_within_workspace( "../etc/passwd" , "/workspace" ) ) ;
512+ assert ! ( !is_within_workspace(
513+ "/workspace/sub/../../outside" ,
514+ "/workspace"
515+ ) ) ;
516+ // Legitimate paths still resolve inside.
517+ assert ! ( is_within_workspace( "/workspace/./src/main.rs" , "/workspace" ) ) ;
518+ assert ! ( is_within_workspace(
519+ "/workspace/src/../src/main.rs" ,
520+ "/workspace"
521+ ) ) ;
522+ }
523+
524+ #[ test]
525+ fn workspace_write_denies_traversal_escape ( ) {
526+ let enforcer = make_enforcer ( PermissionMode :: WorkspaceWrite ) ;
527+ let result = enforcer. check_file_write ( "/workspace/../../etc/crontab" , "/workspace" ) ;
528+ assert ! ( matches!( result, EnforcementResult :: Denied { .. } ) ) ;
529+ }
530+
378531 #[ test]
379532 fn active_mode_returns_policy_mode ( ) {
380533 // given
0 commit comments