From 57344f796714167070f047a8dfc04d7c9aacd4e1 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Thu, 20 Nov 2025 17:29:02 -0500 Subject: [PATCH 1/4] feat: `--ignore-read` --- cli/args/flags.rs | 110 +++++++++--- cli/args/mod.rs | 18 +- libs/config/deno_json/permissions.rs | 24 ++- runtime/permissions/lib.rs | 157 ++++++++++++++---- .../ignore_read/config/__test__.jsonc | 4 + .../permission/ignore_read/config/deno.json | 9 + .../permission/ignore_read/config/main.ts | 6 + .../ignore_read/flags/__test__.jsonc | 20 +++ .../permission/ignore_read/flags/data.txt | 0 .../permission/ignore_read/flags/main.ts | 13 ++ 10 files changed, 305 insertions(+), 56 deletions(-) create mode 100644 tests/specs/permission/ignore_read/config/__test__.jsonc create mode 100644 tests/specs/permission/ignore_read/config/deno.json create mode 100644 tests/specs/permission/ignore_read/config/main.ts create mode 100644 tests/specs/permission/ignore_read/flags/__test__.jsonc create mode 100644 tests/specs/permission/ignore_read/flags/data.txt create mode 100644 tests/specs/permission/ignore_read/flags/main.ts diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 98dfaa2fb47dab..adb5419217dc93 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -824,6 +824,7 @@ pub struct PermissionFlags { pub deny_net: Option>, pub allow_read: Option>, pub deny_read: Option>, + pub ignore_read: Option>, pub allow_run: Option>, pub deny_run: Option>, pub allow_sys: Option>, @@ -847,6 +848,7 @@ impl PermissionFlags { || self.deny_net.is_some() || self.allow_read.is_some() || self.deny_read.is_some() + || self.ignore_read.is_some() || self.allow_run.is_some() || self.deny_run.is_some() || self.allow_sys.is_some() @@ -980,11 +982,22 @@ impl Flags { } match &self.permissions.ignore_env { - Some(env_ignorelist) if env_ignorelist.is_empty() => { + Some(ignorelist) if ignorelist.is_empty() => { args.push("--ignore-env".to_string()); } - Some(env_ignorelist) => { - let s = format!("--ignore-env={}", env_ignorelist.join(",")); + Some(ignorelist) => { + let s = format!("--ignore-env={}", ignorelist.join(",")); + args.push(s); + } + _ => {} + } + + match &self.permissions.ignore_read { + Some(ignorelist) if ignorelist.is_empty() => { + args.push("--ignore-read".to_string()); + } + Some(ignorelist) => { + let s = format!("--ignore-read={}", ignorelist.join(",")); args.push(s); } _ => {} @@ -4221,6 +4234,20 @@ fn permission_args(app: Command, requires: Option<&'static str>) -> Command { } arg }; + let make_deny_ignore_read_arg = |arg: Arg| { + let mut arg = arg + .num_args(0..) + .action(ArgAction::Append) + .require_equals(true) + .value_name("PATH") + .long_help("false") + .value_hint(ValueHint::AnyPath) + .hide(true); + if let Some(requires) = requires { + arg = arg.requires(requires) + } + arg + }; app .after_help(cstr!(r#"Permission options: Docs: https://docs.deno.com/go/permissions @@ -4262,6 +4289,10 @@ fn permission_args(app: Command, requires: Option<&'static str>) -> Command { --deny-ffi | --deny-ffi="./libfoo.so" --deny-import[=<...] Deny importing from remote hosts. Optionally specify denied IP addresses and host names, with ports as necessary. --deny-import | --deny-import="example.com:443,github.com:443" + --ignore-env[=<...] Ignore access to environment variables returning `undefined`. Optionally specify ignored environment variables. + --ignore-env | --ignore-env="PORT,HOME,PATH" + --ignore-read[=<...] Ignore file system read access with a `NotFound` error. Optionally specify ignored paths. + --ignore-read | --ignore-read="/etc,/var/log.txt" DENO_TRACE_PERMISSIONS Environmental variable to enable stack traces in permission prompts. DENO_TRACE_PERMISSIONS=1 deno run main.ts DENO_AUDIT_PERMISSIONS Environmental variable to generate a JSONL file with all permissions accesses. @@ -4310,23 +4341,8 @@ fn permission_args(app: Command, requires: Option<&'static str>) -> Command { arg } ) - .arg( - { - let mut arg = Arg::new("deny-read") - .long("deny-read") - .num_args(0..) - .action(ArgAction::Append) - .require_equals(true) - .value_name("PATH") - .long_help("false") - .value_hint(ValueHint::AnyPath) - .hide(true); - if let Some(requires) = requires { - arg = arg.requires(requires) - } - arg - } - ) + .arg(make_deny_ignore_read_arg(Arg::new("deny-read").long("deny-read"))) + .arg(make_deny_ignore_read_arg(Arg::new("ignore-read").long("ignore-read"))) .arg( { let mut arg = Arg::new("allow-write") @@ -6711,6 +6727,11 @@ fn permission_args_parse( flags.permissions.deny_read = Some(read_wl); } + if let Some(read_wl) = matches.remove_many::("ignore-read") { + flags.permissions.ignore_read = Some(read_wl.collect()); + debug!("read ignorelist: {:#?}", &flags.permissions.ignore_read); + } + if let Some(write_wl) = matches.remove_many::("allow-write") { let write_wl = write_wl .flat_map(flat_escape_split_commas) @@ -9191,6 +9212,55 @@ mod tests { ); } + #[test] + fn ignore_read_ignorelist() { + let r = flags_from_vec(svec![ + "deno", + "run", + "--ignore-read=something.txt", + "script.ts" + ]); + assert_eq!( + r.unwrap(), + Flags { + subcommand: DenoSubcommand::Run(RunFlags::new_default( + "script.ts".to_string(), + )), + permissions: PermissionFlags { + ignore_read: Some(svec!["something.txt"]), + ..Default::default() + }, + code_cache_enabled: true, + ..Flags::default() + } + ); + } + + #[test] + fn ignore_read_ignorelist_multiple() { + let r = flags_from_vec(svec![ + "deno", + "run", + "--ignore-read=something.txt", + "--ignore-read=something2.txt", + "script.ts" + ]); + assert_eq!( + r.unwrap(), + Flags { + subcommand: DenoSubcommand::Run(RunFlags::new_default( + "script.ts".to_string(), + )), + permissions: PermissionFlags { + ignore_read: Some(svec!["something.txt", "something2.txt"]), + ..Default::default() + }, + code_cache_enabled: true, + ..Flags::default() + } + ); + } + #[test] fn allow_write_allowlist() { use test_util::TempDir; diff --git a/cli/args/mod.rs b/cli/args/mod.rs index 3ca9442644aff7..18d7e1bc5ca72d 100644 --- a/cli/args/mod.rs +++ b/cli/args/mod.rs @@ -1664,6 +1664,11 @@ fn flags_to_permissions_options( config.and_then(|c| c.permissions.read.deny.as_ref()), &make_fs_config_value_absolute, ), + ignore_read: handle_deny_or_ignore( + flags.ignore_read.as_ref(), + config.and_then(|c| c.permissions.read.ignore.as_ref()), + &identity, + ), allow_run: handle_allow( flags.allow_all, config.and_then(|c| c.permissions.all), @@ -1806,7 +1811,7 @@ mod test { .unwrap(), permissions: PermissionsObject { all: None, - read: AllowDenyPermissionConfig { + read: AllowDenyIgnorePermissionConfig { allow: Some(PermissionConfigValue::Some(vec![ ".".to_string(), "./read-allow".to_string(), @@ -1814,6 +1819,9 @@ mod test { deny: Some(PermissionConfigValue::Some(vec![ "./read-deny".to_string(), ])), + ignore: Some(PermissionConfigValue::Some(vec![ + "./read-ignore".to_string(), + ])), }, write: AllowDenyPermissionConfig { allow: Some(PermissionConfigValue::Some(vec![ @@ -1917,6 +1925,13 @@ mod test { .into_string() .unwrap() ]), + ignore_read: Some(vec![ + base_dir + .join("read-ignore") + .into_os_string() + .into_string() + .unwrap() + ]), allow_run: Some(vec![ "run-allow".to_string(), base_dir @@ -1992,6 +2007,7 @@ mod test { deny_ffi: None, allow_read: Some(vec!["./folder".to_string()]), deny_read: None, + ignore_read: None, allow_run: Some(vec![]), deny_run: None, allow_sys: Some(vec![]), diff --git a/libs/config/deno_json/permissions.rs b/libs/config/deno_json/permissions.rs index 565db1c6e5aec7..0f0ec828bd485f 100644 --- a/libs/config/deno_json/permissions.rs +++ b/libs/config/deno_json/permissions.rs @@ -182,8 +182,8 @@ pub struct PermissionsObjectWithBase { pub struct PermissionsObject { #[serde(default)] pub all: Option, - #[serde(default, deserialize_with = "deserialize_allow_deny")] - pub read: AllowDenyPermissionConfig, + #[serde(default, deserialize_with = "deserialize_allow_deny_ignore")] + pub read: AllowDenyIgnorePermissionConfig, #[serde(default, deserialize_with = "deserialize_allow_deny")] pub write: AllowDenyPermissionConfig, #[serde(default, deserialize_with = "deserialize_allow_deny")] @@ -293,9 +293,10 @@ mod test { .unwrap(), PermissionsObject { all: Some(true), - read: AllowDenyPermissionConfig { + read: AllowDenyIgnorePermissionConfig { allow: Some(PermissionConfigValue::All), deny: None, + ignore: None, }, write: AllowDenyPermissionConfig { allow: Some(PermissionConfigValue::All), @@ -343,9 +344,10 @@ mod test { .unwrap(), PermissionsObject { all: None, - read: AllowDenyPermissionConfig { + read: AllowDenyIgnorePermissionConfig { allow: Some(PermissionConfigValue::Some(vec!["test".to_string()])), - deny: None + deny: None, + ignore: None, }, write: AllowDenyPermissionConfig { allow: Some(PermissionConfigValue::Some(vec!["test".to_string()])), @@ -384,6 +386,7 @@ mod test { "read": { "allow": ["test"], "deny": ["test-deny"], + "ignore": ["test-ignore"], }, "write": [], "sys": { @@ -393,11 +396,14 @@ mod test { .unwrap(), PermissionsObject { all: None, - read: AllowDenyPermissionConfig { + read: AllowDenyIgnorePermissionConfig { allow: Some(PermissionConfigValue::Some(vec!["test".to_string()])), deny: Some(PermissionConfigValue::Some(vec![ "test-deny".to_string() ])), + ignore: Some(PermissionConfigValue::Some(vec![ + "test-ignore".to_string() + ])) }, write: AllowDenyPermissionConfig { allow: Some(PermissionConfigValue::None), @@ -416,16 +422,20 @@ mod test { "read": { "allow": true, "deny": ["test-deny"], + "ignore": ["test-ignore"], }, })) .unwrap(), PermissionsObject { all: None, - read: AllowDenyPermissionConfig { + read: AllowDenyIgnorePermissionConfig { allow: Some(PermissionConfigValue::All), deny: Some(PermissionConfigValue::Some(vec![ "test-deny".to_string() ])), + ignore: Some(PermissionConfigValue::Some(vec![ + "test-ignore".to_string() + ])), }, ..Default::default() } diff --git a/runtime/permissions/lib.rs b/runtime/permissions/lib.rs index 1960d307e372af..d4e7c382530b6b 100644 --- a/runtime/permissions/lib.rs +++ b/runtime/permissions/lib.rs @@ -2997,20 +2997,6 @@ impl UnaryPermission { self.check_desc(Some(desc), true, api_name) } - #[inline] - pub fn check_partial( - &mut self, - desc: &ReadQueryDescriptor, - api_name: Option<&str>, - ) -> Result<(), PermissionDeniedError> { - audit_and_skip_check_if_is_permission_fully_granted!( - self, - ReadQueryDescriptor::flag_name(), - desc.display_name() - ); - self.check_desc(Some(desc), false, api_name) - } - pub fn check_all( &mut self, api_name: Option<&str>, @@ -3389,6 +3375,7 @@ pub struct PermissionsOptions { pub deny_ffi: Option>, pub allow_read: Option>, pub deny_read: Option>, + pub ignore_read: Option>, pub allow_run: Option>, pub deny_run: Option>, pub allow_sys: Option>, @@ -3548,13 +3535,16 @@ impl Permissions { } Ok(Self { - read: Permissions::new_unary( + read: Permissions::new_unary_with_ignore( parse_maybe_vec(opts.allow_read.as_deref(), |item| { parser.parse_read_descriptor(item) })?, parse_maybe_vec(opts.deny_read.as_deref(), |item| { parser.parse_read_descriptor(item) })?, + parse_maybe_vec(opts.ignore_read.as_deref(), |text| { + parser.parse_read_descriptor(text) + })?, opts.prompt, ), write: Permissions::new_unary( @@ -3708,6 +3698,29 @@ pub enum PermissionCheckError { #[class(uri)] #[error(transparent)] HostParse(#[from] HostParseError), + #[class(inherit)] + #[error(transparent)] + Io(std::io::Error), +} + +fn ignored_to_not_found(err: PermissionDeniedError) -> PermissionCheckError { + #[cfg(unix)] + fn not_found() -> std::io::Error { + std::io::Error::from_raw_os_error(libc::ENOENT) + } + + #[cfg(windows)] + fn not_found() -> std::io::Error { + std::io::Error::from_raw_os_error( + winapi::shared::winerror::ERROR_FILE_NOT_FOUND as i32, + ) + } + + if err.state == PermissionState::Ignored { + PermissionCheckError::Io(not_found()) + } else { + PermissionCheckError::PermissionDenied(err) + } } impl PermissionCheckError { @@ -3721,6 +3734,7 @@ impl PermissionCheckError { std::io::ErrorKind::Other } PermissionCheckError::PathResolve(e) => e.kind(), + PermissionCheckError::Io(e) => e.kind(), } } @@ -3734,6 +3748,7 @@ impl PermissionCheckError { std::io::Error::new(self.kind(), format!("{}", self)) } Self::PathResolve(e) => e.into_io_error(), + Self::Io(e) => e, } } } @@ -3885,7 +3900,7 @@ impl PermissionsContainer { .into_read(), Some("import()"), ) - .map_err(PermissionCheckError::PermissionDenied), + .map_err(ignored_to_not_found), Err(_) => { Err(PermissionCheckError::InvalidFilePath(specifier.clone())) } @@ -3976,7 +3991,7 @@ impl PermissionsContainer { let path = if should_check_read { let inner = &mut inner.read; let desc = path_descriptor.into_read(); - inner.check(&desc, api_name)?; + inner.check(&desc, api_name).map_err(ignored_to_not_found)?; desc.0 } else { path_descriptor @@ -4011,8 +4026,12 @@ impl PermissionsContainer { &self, api_name: &str, ) -> Result<(), PermissionCheckError> { - self.inner.lock().read.check_all(Some(api_name))?; - Ok(()) + self + .inner + .lock() + .read + .check_all(Some(api_name)) + .map_err(ignored_to_not_found) } #[inline(always)] @@ -6571,14 +6590,103 @@ mod tests { } } + #[test] + fn test_read_ignore() { + set_prompter(Box::new(TestPrompter)); + let _prompt_value = PERMISSION_PROMPT_STUB_VALUE_SETTER.lock(); + let parser = TestPermissionDescriptorParser; + { + let mut perms = Permissions::none_without_prompt(); + perms.read = UnaryPermission { + granted_global: false, + ..Permissions::new_unary_with_ignore( + Some(Vec::from([ReadDescriptor( + parser.join_path_with_root("allowed"), + )])), + Some(Vec::from([ReadDescriptor( + parser.join_path_with_root("denied"), + )])), + Some(Vec::from([ReadDescriptor( + parser.join_path_with_root("ignored"), + )])), + false, + ) + }; + let allowed_query = parser + .parse_path_query(Cow::Borrowed(Path::new("/allowed"))) + .unwrap() + .into_read(); + assert_eq!( + perms.read.query(Some(&allowed_query)), + PermissionState::Granted + ); + let ignored_query = parser + .parse_path_query(Cow::Borrowed(Path::new("/ignored"))) + .unwrap() + .into_read(); + assert_eq!( + perms.read.query(Some(&ignored_query)), + PermissionState::Ignored + ); + let denied_query = parser + .parse_path_query(Cow::Borrowed(Path::new("/denied"))) + .unwrap() + .into_read(); + assert_eq!( + perms.read.query(Some(&denied_query)), + PermissionState::Denied + ); + } + { + let mut perms = Permissions::none_without_prompt(); + perms.read = UnaryPermission { + granted_global: false, + ..Permissions::new_unary_with_ignore( + Some(Vec::from([ReadDescriptor( + parser.join_path_with_root("prefix/allowed"), + )])), + Some(Vec::from([ReadDescriptor( + parser.join_path_with_root("prefix"), + )])), + Some(Vec::from([ReadDescriptor( + parser.join_path_with_root("prefix/ignored"), + )])), + false, + ) + }; + let denied_query = parser + .parse_path_query(Cow::Borrowed(Path::new("/prefix/test"))) + .unwrap() + .into_read(); + assert_eq!( + perms.read.query(Some(&denied_query)), + PermissionState::Denied + ); + let ignored_query = parser + .parse_path_query(Cow::Borrowed(Path::new("/prefix/ignored/test"))) + .unwrap() + .into_read(); + assert_eq!( + perms.read.query(Some(&ignored_query)), + PermissionState::Ignored + ); + let allowed_query = parser + .parse_path_query(Cow::Borrowed(Path::new("/prefix/allowed/test"))) + .unwrap() + .into_read(); + assert_eq!( + perms.read.query(Some(&allowed_query)), + PermissionState::Granted + ); + } + } + #[test] fn test_check_partial_denied() { let parser = TestPermissionDescriptorParser; let mut perms = Permissions::from_options( &parser, &PermissionsOptions { - allow_read: Some(vec![]), - deny_read: Some(svec!["/foo/bar"]), allow_write: Some(vec![]), deny_write: Some(svec!["/foo/bar"]), ..Default::default() @@ -6586,13 +6694,6 @@ mod tests { ) .unwrap(); - let read_query = parser - .parse_path_query(Cow::Borrowed(Path::new("/foo"))) - .unwrap() - .into_read(); - perms.read.check_partial(&read_query, None).unwrap(); - assert!(perms.read.check(&read_query, None).is_err()); - let write_query = parser .parse_path_query(Cow::Borrowed(Path::new("/foo"))) .unwrap() diff --git a/tests/specs/permission/ignore_read/config/__test__.jsonc b/tests/specs/permission/ignore_read/config/__test__.jsonc new file mode 100644 index 00000000000000..91fbd03289b4b1 --- /dev/null +++ b/tests/specs/permission/ignore_read/config/__test__.jsonc @@ -0,0 +1,4 @@ +{ + "args": "run --quiet -P main.ts", + "output": "true\n" +} diff --git a/tests/specs/permission/ignore_read/config/deno.json b/tests/specs/permission/ignore_read/config/deno.json new file mode 100644 index 00000000000000..0c4dc3720d28f8 --- /dev/null +++ b/tests/specs/permission/ignore_read/config/deno.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "default": { + "read": { + "ignore": true + } + } + } +} diff --git a/tests/specs/permission/ignore_read/config/main.ts b/tests/specs/permission/ignore_read/config/main.ts new file mode 100644 index 00000000000000..034ef0774f8bb0 --- /dev/null +++ b/tests/specs/permission/ignore_read/config/main.ts @@ -0,0 +1,6 @@ +try { + Deno.readTextFileSync("./deno.json"); + console.log("loaded"); +} catch (err) { + console.log(err instanceof Deno.errors.NotFound); +} diff --git a/tests/specs/permission/ignore_read/flags/__test__.jsonc b/tests/specs/permission/ignore_read/flags/__test__.jsonc new file mode 100644 index 00000000000000..e483fc64db557e --- /dev/null +++ b/tests/specs/permission/ignore_read/flags/__test__.jsonc @@ -0,0 +1,20 @@ +{ + "tests": { + "all": { + "args": "run --ignore-read main.ts", + "output": "true\ntrue\n" + }, + "some": { + "args": "run --ignore-read=deno.json main.ts", + "output": "true\nfalse\n" + }, + "some_allow_env": { + "args": "run --ignore-read=deno.json --allow-read main.ts", + "output": "true\nloaded\n" + }, + "deny_env": { + "args": "run --deny-read --ignore-read=deno.json main.ts", + "output": "true\nfalse\n" + } + } +} diff --git a/tests/specs/permission/ignore_read/flags/data.txt b/tests/specs/permission/ignore_read/flags/data.txt new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/specs/permission/ignore_read/flags/main.ts b/tests/specs/permission/ignore_read/flags/main.ts new file mode 100644 index 00000000000000..8f4cebf50df3da --- /dev/null +++ b/tests/specs/permission/ignore_read/flags/main.ts @@ -0,0 +1,13 @@ +try { + Deno.readTextFileSync("./deno.json"); + console.log("loaded"); +} catch (err) { + console.log(err instanceof Deno.errors.NotFound); +} + +try { + Deno.readTextFileSync("data.txt"); + console.log("loaded"); +} catch (err) { + console.log(err instanceof Deno.errors.NotFound); +} From e3f8a07e2e03254706e3095ceffc70d9392d6f8c Mon Sep 17 00:00:00 2001 From: David Sherret Date: Fri, 21 Nov 2025 09:40:45 -0500 Subject: [PATCH 2/4] try again --- runtime/permissions/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/permissions/lib.rs b/runtime/permissions/lib.rs index d4e7c382530b6b..de605a009c87e8 100644 --- a/runtime/permissions/lib.rs +++ b/runtime/permissions/lib.rs @@ -3704,7 +3704,7 @@ pub enum PermissionCheckError { } fn ignored_to_not_found(err: PermissionDeniedError) -> PermissionCheckError { - #[cfg(unix)] + #[cfg(not(windows))] fn not_found() -> std::io::Error { std::io::Error::from_raw_os_error(libc::ENOENT) } From b659308342cc10e5a869e77effca76dd056a2562 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Fri, 21 Nov 2025 09:41:46 -0500 Subject: [PATCH 3/4] fix test --- cli/args/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/args/mod.rs b/cli/args/mod.rs index 18d7e1bc5ca72d..840b746ff319e5 100644 --- a/cli/args/mod.rs +++ b/cli/args/mod.rs @@ -1667,7 +1667,7 @@ fn flags_to_permissions_options( ignore_read: handle_deny_or_ignore( flags.ignore_read.as_ref(), config.and_then(|c| c.permissions.read.ignore.as_ref()), - &identity, + &make_fs_config_value_absolute, ), allow_run: handle_allow( flags.allow_all, From 774c594d2cae760d8ff83bcadade2c97332b8b37 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Fri, 21 Nov 2025 12:17:55 -0500 Subject: [PATCH 4/4] maybe fix --- runtime/permissions/lib.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/runtime/permissions/lib.rs b/runtime/permissions/lib.rs index de605a009c87e8..a65a6547d2d744 100644 --- a/runtime/permissions/lib.rs +++ b/runtime/permissions/lib.rs @@ -3704,7 +3704,15 @@ pub enum PermissionCheckError { } fn ignored_to_not_found(err: PermissionDeniedError) -> PermissionCheckError { - #[cfg(not(windows))] + #[cfg(target_arch = "wasm32")] + fn not_found() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "No such file or directory (os error 2)", + ) + } + + #[cfg(all(not(windows), not(target_arch = "wasm32")))] fn not_found() -> std::io::Error { std::io::Error::from_raw_os_error(libc::ENOENT) }