Skip to content

Commit 27ed7fc

Browse files
author
Randolf Jung
committed
feat(task): add inferred sandbox reads
1 parent 6f5c72e commit 27ed7fc

11 files changed

Lines changed: 368 additions & 8 deletions

File tree

docs/sandboxing.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,22 @@ allow_write = ["./node_modules"]
7272
allow_net = ["registry.npmjs.org"]
7373
```
7474

75+
Tasks can also opt in to inferred filesystem reads:
76+
77+
```toml
78+
[tasks.build]
79+
run = "npm run build"
80+
depends = ["generate"]
81+
sources = ["src/**/*.ts"]
82+
outputs = ["dist/**"]
83+
sandbox = true
84+
```
85+
86+
With `sandbox = true`, matched source files and the task file are readable, and outputs from all
87+
prerequisite dependencies are readable. Explicit `allow_read` paths extend those inferred reads;
88+
other sandbox settings, including `allow_write`, continue to compose normally. Declaring `sources`
89+
without `sandbox = true` does not enable sandboxing.
90+
7591
CLI flags on `mise run` override task-level config:
7692

7793
```bash
@@ -101,6 +117,21 @@ When filesystem restrictions are active, certain paths remain accessible so tool
101117

102118
- `--allow-write` paths are implicitly readable
103119
- `--allow-read` paths include system essentials above
120+
- With task `sandbox = true`, declared `sources` enable read restrictions and are automatically
121+
readable
122+
- With task `sandbox = true`, declared outputs of all prerequisite dependencies enable read
123+
restrictions and are automatically readable
124+
125+
Explicit `allow_read` paths extend these inferred permissions. Output writes are not inferred; use
126+
`allow_write` when a task also needs write restrictions.
127+
128+
Source globs are resolved to the files they match, including source exclusions. Dependency output
129+
globs grant read access to the static path before the first wildcard.
130+
131+
Inferred permissions are an ergonomics feature, not a hermetic build boundary. Tools may need
132+
additional explicit access for configuration, caches, formatters, or undeclared inputs. The task
133+
working directory is not implicitly readable; add `allow_read = ["."]` when a tool needs to inspect
134+
it.
104135

105136
## Platform Support
106137

docs/tasks/task-configuration.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,28 @@ When both a global timeout and a per-task timeout are set, the shorter of the tw
10801080
timeout cannot extend beyond the global timeout. The `--timeout` CLI flag overrides the global
10811081
setting.
10821082

1083+
### `sandbox`
1084+
1085+
- **Type**: `bool`
1086+
- **Default**: `false`
1087+
1088+
Infer filesystem sandbox reads from the task's declared inputs. Matched `sources` and the task file
1089+
become readable, and outputs of all prerequisite dependencies become readable. Explicit
1090+
`allow_read` paths extend the inferred reads; other sandbox settings continue to compose normally.
1091+
1092+
```mise-toml
1093+
[tasks.build]
1094+
run = "npm run build"
1095+
depends = ["generate"]
1096+
sources = ["src/**/*.ts"]
1097+
outputs = ["dist/**"]
1098+
sandbox = true
1099+
```
1100+
1101+
Source globs resolve to the files they currently match. Dependency output globs grant read access
1102+
to their static prefix. Output writes are not inferred; use `allow_write` when needed. See
1103+
[Sandboxing](/sandboxing.html) for platform behavior and limitations.
1104+
10831105
### `deny_all`
10841106

10851107
- **Type**: `bool`

docs/tasks/templates.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ When a task extends a template, fields are merged according to these rules:
5555
| `dir` | Local overrides; defaults to config_root if not in template |
5656
| `sources`, `outputs`, `cache` | Local overrides completely |
5757
| `output` | Local overrides template (if set) |
58-
| Sandbox deny fields | Compose with task-local settings |
58+
| `sandbox` and sandbox deny fields | Compose with task-local settings |
5959
| Sandbox allow fields | Template and task-local values are combined |
6060
| `description`, `shell`, `timeout`, etc. | Local overrides template (if set) |
6161
| `quiet`, `hide`, `raw`, `interactive`, `raw_args` | Not supported on templates (set explicitly on each task) |
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env bash
2+
3+
# https://github.com/jdx/mise/discussions/12264
4+
# Filesystem sandboxing is unavailable on Windows and on Linux kernels without Landlock.
5+
case "$(uname -s)" in
6+
Darwin) ;;
7+
Linux)
8+
if ! mise x --deny-read -- true >/dev/null 2>&1; then
9+
echo "skipping: task sandbox inference requires Landlock"
10+
return 0
11+
fi
12+
;;
13+
*)
14+
echo "skipping: task sandbox inference is unsupported on this platform"
15+
return 0
16+
;;
17+
esac
18+
19+
mkdir -p input work/scratch seed-output dep-output output
20+
echo source >input/source.txt
21+
echo private >input/private.md
22+
23+
cat <<'EOF' >source-task.sh
24+
#!/usr/bin/env bash
25+
echo source-file
26+
EOF
27+
chmod +x source-task.sh
28+
29+
cat <<'EOF' >mise.toml
30+
[tasks.seed]
31+
dir = "work"
32+
run = "echo seed > ../seed-output/result.txt"
33+
outputs = ["../seed-output/**"]
34+
35+
[tasks.prepare]
36+
dir = "work"
37+
run = "echo dependency > ../dep-output/result.txt"
38+
depends = ["seed"]
39+
outputs = ["../dep-output/**"]
40+
41+
[tasks.build]
42+
dir = "work"
43+
run = "cat ../input/source.txt ../seed-output/result.txt ../dep-output/result.txt > ../output/result.txt && echo explicit > scratch/result.txt"
44+
depends = ["prepare"]
45+
sources = ["../input/*.txt"]
46+
outputs = ["../output/**"]
47+
sandbox = true
48+
allow_read = ["."]
49+
allow_write = ["../output", "scratch"]
50+
51+
[tasks.undeclared-read]
52+
dir = "work"
53+
run = "cat ../input/private.md"
54+
sources = ["../input/*.txt"]
55+
sandbox = true
56+
allow_read = ["."]
57+
58+
[tasks.not-sandboxed]
59+
dir = "work"
60+
run = "cat ../input/private.md"
61+
sources = ["../input/*.txt"]
62+
63+
[tasks.source-file]
64+
file = "source-task.sh"
65+
dir = "work"
66+
sandbox = true
67+
allow_read = ["."]
68+
EOF
69+
70+
assert "mise run build" ""
71+
assert "cat output/result.txt" $'source\nseed\ndependency'
72+
assert "cat work/scratch/result.txt" "explicit"
73+
assert_fail "mise run undeclared-read"
74+
assert "mise run --force not-sandboxed" "private"
75+
assert "mise run source-file" "source-file"

schema/mise-task.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

schema/mise.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3347,6 +3347,11 @@
33473347
"description": "timeout for this task",
33483348
"type": "string"
33493349
},
3350+
"sandbox": {
3351+
"default": false,
3352+
"description": "infer filesystem reads from sources and prerequisite outputs",
3353+
"type": "boolean"
3354+
},
33503355
"deny_all": {
33513356
"default": false,
33523357
"description": "block reads, writes, network, and env vars",

src/task/deps.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,10 @@ impl fmt::Display for TaskCycleError {
6969
impl std::error::Error for TaskCycleError {}
7070

7171
#[derive(Debug, Clone, Default, PartialEq, Eq)]
72-
/// State contributed by a task's completed direct dependencies.
72+
/// State contributed by a task's completed dependencies.
7373
pub(crate) struct TaskDependencyState {
74+
/// Prerequisite tasks whose declared outputs may be read by this task.
75+
pub(crate) dependencies: Vec<Task>,
7476
/// Stable artifact identities to include in the task's cache key.
7577
pub cache_keys: Vec<String>,
7678
/// Whether any dependency executed or restored outputs.
@@ -104,6 +106,7 @@ pub(crate) struct Deps {
104106
executed: HashSet<TaskKey>, // tasks that actually began executing (not just scheduled)
105107
did_work: HashSet<TaskKey>, // tasks that executed or restored outputs (not freshness-skipped)
106108
cache_keys: HashMap<TaskKey, String>, // stable artifact identities published by completed tasks
109+
tasks: HashMap<TaskKey, Task>, // resolved definitions retained after graph nodes are removed
107110
dep_edges: HashMap<TaskKey, HashSet<TaskKey>>, // maps each task to its direct dependency task keys
108111
post_dep_parents: HashMap<TaskKey, HashSet<TaskKey>>, // maps each post-subtree task to its triggering parents
109112
tx: mpsc::UnboundedSender<Option<Task>>,
@@ -306,6 +309,10 @@ impl Deps {
306309
let executed = HashSet::new();
307310
let did_work = HashSet::new();
308311
let cache_keys = HashMap::new();
312+
let tasks = graph
313+
.node_indices()
314+
.map(|idx| (task_key(&graph[idx]), graph[idx].clone()))
315+
.collect();
309316
Ok(Self {
310317
graph,
311318
tx,
@@ -314,6 +321,7 @@ impl Deps {
314321
executed,
315322
did_work,
316323
cache_keys,
324+
tasks,
317325
dep_edges,
318326
post_dep_parents,
319327
})
@@ -450,7 +458,23 @@ impl Deps {
450458
.collect::<Vec<_>>();
451459
cache_keys.sort();
452460
cache_keys.dedup();
461+
let mut prerequisite_keys = deps.clone();
462+
let mut pending = deps.iter().copied().collect_vec();
463+
while let Some(key) = pending.pop() {
464+
for dependency in self.dep_edges.get(key).into_iter().flatten() {
465+
if prerequisite_keys.insert(dependency) {
466+
pending.push(dependency);
467+
}
468+
}
469+
}
470+
let mut dependencies = prerequisite_keys
471+
.iter()
472+
.filter_map(|key| self.tasks.get(*key).cloned())
473+
.collect_vec();
474+
dependencies.sort();
475+
dependencies.dedup();
453476
TaskDependencyState {
477+
dependencies,
454478
cache_keys,
455479
any_did_work: deps.iter().any(|dep_key| self.did_work.contains(dep_key)),
456480
any_unkeyed_did_work: deps.iter().any(|dep_key| {
@@ -669,6 +693,7 @@ mod tests {
669693
executed: HashSet::new(),
670694
did_work: HashSet::new(),
671695
cache_keys: HashMap::new(),
696+
tasks: HashMap::new(),
672697
dep_edges,
673698
post_dep_parents,
674699
tx,
@@ -706,6 +731,7 @@ mod tests {
706731
assert_eq!(
707732
deps.dependency_state(&c),
708733
TaskDependencyState {
734+
dependencies: vec![],
709735
cache_keys: vec![],
710736
any_did_work: true,
711737
any_unkeyed_did_work: true,
@@ -716,13 +742,56 @@ mod tests {
716742
assert_eq!(
717743
deps.dependency_state(&c),
718744
TaskDependencyState {
745+
dependencies: vec![],
719746
cache_keys: vec!["b-key".to_string()],
720747
any_did_work: true,
721748
any_unkeyed_did_work: false,
722749
}
723750
);
724751
}
725752

753+
// https://github.com/jdx/mise/discussions/12264
754+
#[test]
755+
fn dependency_state_retains_direct_dependency_tasks() {
756+
let dependency = Task {
757+
outputs: crate::task::TaskOutputs::Files(vec!["dist".to_string()]),
758+
..task("dependency")
759+
};
760+
let parent = task("parent");
761+
let mut deps = deps_with_relationships(
762+
HashMap::from([(task_key(&parent), HashSet::from([task_key(&dependency)]))]),
763+
HashMap::new(),
764+
);
765+
deps.tasks.insert(task_key(&dependency), dependency.clone());
766+
767+
assert_eq!(deps.dependency_state(&parent).dependencies, [dependency]);
768+
}
769+
770+
// https://github.com/jdx/mise/discussions/12264
771+
#[test]
772+
fn dependency_state_retains_transitive_prerequisite_tasks() {
773+
let first = Task {
774+
outputs: crate::task::TaskOutputs::Files(vec!["first-output".to_string()]),
775+
..task("first")
776+
};
777+
let second = Task {
778+
outputs: crate::task::TaskOutputs::Files(vec!["second-output".to_string()]),
779+
..task("second")
780+
};
781+
let parent = task("parent");
782+
let mut deps = deps_with_relationships(
783+
HashMap::from([
784+
(task_key(&parent), HashSet::from([task_key(&second)])),
785+
(task_key(&second), HashSet::from([task_key(&first)])),
786+
]),
787+
HashMap::new(),
788+
);
789+
deps.tasks.insert(task_key(&first), first.clone());
790+
deps.tasks.insert(task_key(&second), second.clone());
791+
792+
assert_eq!(deps.dependency_state(&parent).dependencies, [first, second]);
793+
}
794+
726795
#[test]
727796
fn dependency_state_includes_post_dependency_parents() {
728797
let parent = task("parent");
@@ -737,6 +806,7 @@ mod tests {
737806
assert_eq!(
738807
deps.dependency_state(&post),
739808
TaskDependencyState {
809+
dependencies: vec![],
740810
cache_keys: vec!["parent-key".to_string()],
741811
any_did_work: true,
742812
any_unkeyed_did_work: false,

0 commit comments

Comments
 (0)