act-cli 0.5.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {act_cli-0.5.0 → act_cli-0.5.1}/Cargo.lock +2 -2
  2. {act_cli-0.5.0 → act_cli-0.5.1}/Cargo.toml +1 -1
  3. {act_cli-0.5.0 → act_cli-0.5.1}/PKG-INFO +1 -1
  4. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/runtime/fs_matcher.rs +122 -6
  5. {act_cli-0.5.0 → act_cli-0.5.1}/README.md +0 -0
  6. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/Cargo.toml +0 -0
  7. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/README.md +0 -0
  8. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/build.rs +0 -0
  9. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/config.rs +0 -0
  10. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/format.rs +0 -0
  11. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/http.rs +0 -0
  12. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/main.rs +0 -0
  13. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/mcp.rs +0 -0
  14. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/resolve.rs +0 -0
  15. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/runtime/bindings/mod.rs +0 -0
  16. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/runtime/effective.rs +0 -0
  17. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/runtime/fs_policy.rs +0 -0
  18. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/runtime/http_client.rs +0 -0
  19. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/runtime/http_policy.rs +0 -0
  20. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/runtime/mod.rs +0 -0
  21. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/src/runtime/network.rs +0 -0
  22. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/wit/deps/act-core/act-core.wit +0 -0
  23. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/wit/deps/act-core/act-events.wit +0 -0
  24. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/wit/deps/act-core/act-resources.wit +0 -0
  25. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/wit/deps.lock +0 -0
  26. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/wit/deps.toml +0 -0
  27. {act_cli-0.5.0 → act_cli-0.5.1}/act-cli/wit/world.wit +0 -0
  28. {act_cli-0.5.0 → act_cli-0.5.1}/pyproject.toml +0 -0
@@ -4,7 +4,7 @@ version = 4
4
4
 
5
5
  [[package]]
6
6
  name = "act-build"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  dependencies = [
9
9
  "act-types",
10
10
  "anyhow",
@@ -25,7 +25,7 @@ dependencies = [
25
25
 
26
26
  [[package]]
27
27
  name = "act-cli"
28
- version = "0.5.0"
28
+ version = "0.5.1"
29
29
  dependencies = [
30
30
  "act-types",
31
31
  "anyhow",
@@ -3,7 +3,7 @@ members = ["act-cli"]
3
3
  resolver = "3"
4
4
 
5
5
  [workspace.package]
6
- version = "0.5.0"
6
+ version = "0.5.1"
7
7
  edition = "2024"
8
8
  license = "MIT OR Apache-2.0"
9
9
  repository = "https://github.com/actcore/act-cli"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: act-cli
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -18,10 +18,19 @@
18
18
  //! Decision rule:
19
19
  //! - Mode = Deny → always `Deny`.
20
20
  //! - Mode = Open → always `Allow`.
21
- //! - Mode = Allowlist → `Deny` if any deny pattern matches; else `Allow`
22
- //! if any allow pattern matches; else `Deny`.
21
+ //! - Mode = Allowlist:
22
+ //! - `Deny` if any deny pattern matches.
23
+ //! - `Allow` if any allow pattern matches.
24
+ //! - `Allow` if the path is a directory **ancestor** of any allowed
25
+ //! pattern's literal prefix. WASI path resolution stats every
26
+ //! intermediate directory when opening a nested path, so a user
27
+ //! granting `/tmp/work/db.sqlite` implicitly grants traversal on
28
+ //! `/tmp/work` and `/tmp` (metadata only — those dirs aren't
29
+ //! "allowed" for listing, but they are for the traversal needed
30
+ //! to reach the target).
31
+ //! - `Deny` otherwise.
23
32
 
24
- use std::path::Path;
33
+ use std::path::{Path, PathBuf};
25
34
 
26
35
  use anyhow::{Context, Result};
27
36
  use globset::{Glob, GlobSet, GlobSetBuilder};
@@ -43,15 +52,27 @@ pub struct FsMatcher {
43
52
  mode: PolicyMode,
44
53
  allow: GlobSet,
45
54
  deny: GlobSet,
55
+ /// Literal path prefix of each allow entry — the longest ancestor
56
+ /// with no glob metacharacter. `/a/b/c.db` → `/a/b/c.db`;
57
+ /// `/tmp/*.db` → `/tmp`; `/foo/bar/**` → `/foo/bar`. Used to permit
58
+ /// traversal of intermediate directories on the path to any
59
+ /// allowed target.
60
+ allow_prefixes: Vec<PathBuf>,
46
61
  }
47
62
 
48
63
  impl FsMatcher {
49
64
  /// Compile a matcher from a resolved `FsConfig`.
50
65
  pub fn compile(cfg: &FsConfig) -> Result<Self> {
66
+ let mut allow_prefixes = Vec::new();
67
+ for pat in &cfg.allow {
68
+ let expanded = expand_pattern(pat);
69
+ allow_prefixes.push(PathBuf::from(literal_prefix(&expanded)));
70
+ }
51
71
  Ok(Self {
52
72
  mode: cfg.mode,
53
73
  allow: compile_set("allow", &cfg.allow)?,
54
74
  deny: compile_set("deny", &cfg.deny)?,
75
+ allow_prefixes,
55
76
  })
56
77
  }
57
78
 
@@ -65,15 +86,60 @@ impl FsMatcher {
65
86
  return FsDecision::Deny;
66
87
  }
67
88
  if self.allow.is_match(path) {
68
- FsDecision::Allow
69
- } else {
70
- FsDecision::Deny
89
+ return FsDecision::Allow;
90
+ }
91
+ // Ancestor-traversal check: allow stat/open on any directory
92
+ // that lies on the path to some allowed target.
93
+ if self
94
+ .allow_prefixes
95
+ .iter()
96
+ .any(|prefix| is_ancestor(path, prefix))
97
+ {
98
+ return FsDecision::Allow;
71
99
  }
100
+ FsDecision::Deny
72
101
  }
73
102
  }
74
103
  }
75
104
  }
76
105
 
106
+ /// Extract the longest leading path segment of `pattern` that contains no
107
+ /// glob metacharacter (`*`, `?`, `[`, `{`). That segment is the literal
108
+ /// prefix under which the glob might match.
109
+ fn literal_prefix(pattern: &str) -> &str {
110
+ // Find the first component containing a metachar. Keep everything before it.
111
+ let bytes = pattern.as_bytes();
112
+ let mut last_boundary = 0usize;
113
+ let mut i = 0usize;
114
+ while i < bytes.len() {
115
+ let b = bytes[i];
116
+ if b == b'/' {
117
+ last_boundary = i;
118
+ } else if matches!(b, b'*' | b'?' | b'[' | b'{') {
119
+ return &pattern[..last_boundary];
120
+ }
121
+ i += 1;
122
+ }
123
+ // No metachar found — the whole pattern is literal.
124
+ pattern
125
+ }
126
+
127
+ /// Is `candidate` an ancestor of `target` (i.e., `target` is `candidate`
128
+ /// with zero or more additional components)? Works by walking `target`'s
129
+ /// ancestor chain looking for an exact match. Returns `false` if
130
+ /// `candidate` is empty.
131
+ fn is_ancestor(candidate: &Path, target: &Path) -> bool {
132
+ if candidate.as_os_str().is_empty() {
133
+ return false;
134
+ }
135
+ for ancestor in target.ancestors() {
136
+ if ancestor == candidate {
137
+ return true;
138
+ }
139
+ }
140
+ false
141
+ }
142
+
77
143
  fn compile_set(label: &str, patterns: &[String]) -> Result<GlobSet> {
78
144
  let mut b = GlobSetBuilder::new();
79
145
  for p in patterns {
@@ -213,6 +279,56 @@ mod tests {
213
279
  );
214
280
  }
215
281
 
282
+ #[test]
283
+ fn ancestor_of_allowed_literal_file_is_traversable() {
284
+ // Allowing /tmp/work/db.sqlite implicitly grants traversal on
285
+ // /tmp/work and /tmp so the WASI path-walker can stat each
286
+ // intermediate directory before reaching the target.
287
+ let m =
288
+ FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/work/db.sqlite"], &[])).unwrap();
289
+ assert_eq!(
290
+ m.decide(&PathBuf::from("/tmp/work/db.sqlite")),
291
+ FsDecision::Allow
292
+ );
293
+ assert_eq!(m.decide(&PathBuf::from("/tmp/work")), FsDecision::Allow);
294
+ assert_eq!(m.decide(&PathBuf::from("/tmp")), FsDecision::Allow);
295
+ assert_eq!(m.decide(&PathBuf::from("/")), FsDecision::Allow);
296
+ // Sibling dir not on the path — still denied.
297
+ assert_eq!(m.decide(&PathBuf::from("/tmp/other")), FsDecision::Deny);
298
+ assert_eq!(m.decide(&PathBuf::from("/var")), FsDecision::Deny);
299
+ }
300
+
301
+ #[test]
302
+ fn ancestor_of_glob_literal_prefix_is_traversable() {
303
+ let m =
304
+ FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/work/**/*.db"], &[])).unwrap();
305
+ // Literal prefix is /tmp/work. Ancestors allowed.
306
+ assert_eq!(m.decide(&PathBuf::from("/tmp/work")), FsDecision::Allow);
307
+ assert_eq!(m.decide(&PathBuf::from("/tmp")), FsDecision::Allow);
308
+ // A .db inside is allowed by the glob.
309
+ assert_eq!(
310
+ m.decide(&PathBuf::from("/tmp/work/a/b.db")),
311
+ FsDecision::Allow
312
+ );
313
+ // Non-.db file below is NOT allowed — ancestor rule only covers
314
+ // *reaching* the allowed target, not reading siblings.
315
+ assert_eq!(
316
+ m.decide(&PathBuf::from("/tmp/work/a/b.txt")),
317
+ FsDecision::Deny
318
+ );
319
+ }
320
+
321
+ #[test]
322
+ fn ancestor_does_not_leak_past_first_glob_component() {
323
+ // `/tmp/*.db` — the literal prefix is `/tmp`. Ancestors of that
324
+ // (i.e. `/`) are traversable, but so is `/tmp` itself. What
325
+ // shouldn't leak: a sibling of the glob target.
326
+ let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/*.db"], &[])).unwrap();
327
+ assert_eq!(m.decide(&PathBuf::from("/tmp")), FsDecision::Allow);
328
+ assert_eq!(m.decide(&PathBuf::from("/tmp/foo.db")), FsDecision::Allow);
329
+ assert_eq!(m.decide(&PathBuf::from("/tmp/foo.txt")), FsDecision::Deny);
330
+ }
331
+
216
332
  #[test]
217
333
  fn extension_glob() {
218
334
  let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/**/*.md"], &[])).unwrap();
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes