Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@
variables and such a key would otherwise silently shadow the built-in
helper; manifests that previously used either name as a variable now fail
to parse ([#79](https://github.com/leynos/netsuke/issues/79))
- Open the capability used for glob metadata checks at the pattern's longest
literal directory prefix rather than at the filesystem root or the working
directory, so glob expansion holds only the authority its pattern can reach;
a pattern whose literal prefix is missing or names something other than a
directory now expands to no matches, and a match reached through a symbolic
link — the match itself or an intermediate directory — that resolves
outside that prefix or dangles is skipped rather than failing the
expansion, though a cyclic symbolic link still fails the expansion; opening
a symbolic-link prefix now fails, and glob tracing redacts caller-controlled
path fields as `<redacted>`
([#173](https://github.com/leynos/netsuke/issues/173))

### Removed

Expand All @@ -69,6 +80,13 @@
`BuildTargets::as_slice().is_empty()`
([#75](https://github.com/leynos/netsuke/issues/75))

### Fixed

- Expand parent-relative glob patterns such as `glob('../shared/*.h')`; their
matches previously reached the working-directory capability as `../…` and
were rejected as sandbox escapes
([#173](https://github.com/leynos/netsuke/issues/173))

## [0.1.0] - 2026-07-28

_Initial release._
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ clap = { version = "4.5.0", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde-saphyr = "0.0.6"
minijinja = { version = "2.12.0", features = ["loader"] }
cap-primitives = "3.4.4"
cap-std = { version = "3.4.4", features = ["fs_utf8"] }
camino = "1.2.0"
semver = { version = "1", features = ["serde"] }
Expand Down
155 changes: 155 additions & 0 deletions docs/adr-010-scope-glob-capability-to-literal-prefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Architecture decision record (ADR): Scope the glob metadata capability to the literal prefix

## Status

Accepted.

## Date

2026-08-09

## Context and problem statement

Manifest glob expansion (`src/manifest/glob/mod.rs`) matches a caller-supplied
pattern such as `src/**/*.c` and returns the matching file paths. Matching
itself runs through the `glob` crate, which walks the filesystem directly.
Filtering the walk's results down to regular files, however, goes through a
metadata check, and that check was routed through a `cap_std::fs::Dir`
capability handle rather than a raw filesystem call.

The handle's authority was disproportionate to what any single pattern could
need. `glob_paths` opened it at `/` for an absolute pattern and at `.` for a
relative one, so the capability covered the entire filesystem root or the
entire working directory regardless of how narrow the pattern's own literal
component was. A pattern such as `src/**/*.c` names only the `src/` subtree,
yet the metadata check could resolve any path reachable from the root or the
working directory. Issue #173 asked for this ambient authority to be
reviewed against the least-privilege principle the capability was meant to
enforce.

## Decision

Open the metadata capability at the pattern's longest literal directory
prefix instead of at a fixed root:

- **Literal prefix extraction.** `walk::literal_dir_prefix` scans the
normalized pattern up to the first glob metacharacter (`*`, `?`, `[`, or
`{`) and trims the result back to the last path separator, yielding the
deepest directory the pattern names without wildcards. For `src/**/*.c`
this is `src/`. A pattern with no literal directory component, such as
`*.c`, yields `.`, keeping the working-directory scope for patterns that
cannot narrow it. The scan steps over bracketed literal escapes such as
`[*]`, so `src/[*]x/generated/*.c` reaches `src/[*]x/generated/` rather
than stopping at the first `[`.
- **`GlobRoot` couples the handle and the prefix.** `walk::open_root_dir`
opens a `cap_std::fs::Dir` at the literal prefix one component at a time,
refusing symbolic links, and wraps it together with the lexical prefix in a
`GlobRoot`. Every subsequent metadata lookup relativizes the matched path
against the prefix (`GlobRoot::relativise`) before resolving it through the
handle, so the capability only ever sees paths inside the subtree it was
opened at.
- **A missing or non-directory prefix yields no capability at all.**
`open_root_dir` returns `Ok(None)` when the prefix does not exist or is not
a directory (`walk::prefix_is_unopenable`), and `glob_paths` returns an
empty match set in that case, mirroring the empty result the matcher would
produce anyway. `diagnostics::record_unopenable_prefix` records the
outcome so a degraded expansion remains observable.
- **The matcher still walks ambiently.** The `glob` crate's own traversal is
unchanged; only the metadata check used to filter directories out of its
results is capability-scoped. Narrowing the capability's opening point
therefore narrows what the metadata check can resolve, not what the walk
itself can see on disk.

## Rationale

- **Least privilege follows the pattern's own scope.** A pattern can only
ever match inside its literal prefix, so opening the capability there gives
the metadata check exactly the authority the pattern could use, rather
than the authority of the whole filesystem root or working directory.
- **Relativization keeps the capability boundary honest.** Resolving a
matched path against the capability requires rebasing it onto the prefix
first (`GlobRoot::relativise`); a path that does not start with the prefix
cannot be looked up at all, so the capability cannot be handed an
Comment thread
coderabbitai[bot] marked this conversation as resolved.
absolute or differently rooted path by accident.
- **An unopenable prefix is an empty result, not an error.** A pattern whose
literal prefix does not exist can match nothing, so treating a missing or
non-directory prefix as an empty expansion (rather than a hard failure)
matches the semantics a caller already expects from a glob that matches no
files.
- **A symbolic-link prefix is rejected.** Opening each literal directory
component without following links prevents a pattern such as `src/link/*.c`
from gaining the authority of `link`'s target, while retaining the lexical
prefix in `GlobRoot` for later match relativization.
- **Symbolic-link handling is explicit and bounded.**
`GlobRoot::metadata_relative` classifies a failed
metadata lookup as a skipped link only when `is_unresolvable_link` identifies
`PermissionDenied` or `NotFound` and `traverses_symlink` confirms a final or
intermediate symbolic link. Other failures, including symlink loops, still
propagate. This classification is paired with bounded skipped-match
recording via `diagnostics::record_unreachable_symlink`.

## Consequences

- **The matcher's own traversal remains ambient.** Scoping the metadata
capability does not scope the `glob` crate's directory walk; that crate
still reads the filesystem directly rather than through `cap_std`.
Replacing it with a capability-native matcher would close this remaining
gap but was out of scope for this change (see Alternatives considered).
- **A match reached through an escaping symbolic link is silently skipped.**
If a matched path resolves through a symlink whose target leaves the
literal prefix, the capability cannot follow it. `GlobRoot::metadata`
treats this the same as a dangling link: the match is dropped from the
results rather than failing the whole expansion, and the drop is recorded
via `diagnostics::record_unreachable_symlink`. A symlink loop is treated
differently and still fails the expansion, because a cycle describes a
broken tree rather than a file that is simply unreachable through the
capability.
- **An escaping symlink and a permission-denied symlink are
indistinguishable.** `cap_std` reports both an out-of-prefix resolution
and a genuine permission failure inside the prefix as
`io::ErrorKind::PermissionDenied`. The capability cannot tell these apart,
so a link that is legitimately unreadable inside the prefix is skipped
alongside one that escapes it, rather than being reported as a distinct
error.
- **Parent-relative patterns now work.** A pattern such as `../*.txt`
previously reached the working-directory handle as a `../…` lookup and was
rejected as a sandbox escape. Because the capability is now opened at the
pattern's own literal prefix (which can itself be `../`), a parent-relative
pattern gets a capability rooted at that parent directory instead of being
rejected outright.
- **Diagnostics stay bounded.** `diagnostics.rs` records the unopenable-prefix
and unreachable-symlink outcomes as low-cardinality counters
(`netsuke_manifest_glob_expansions_total`,
`netsuke_manifest_glob_entries_skipped_total`) labelled only by a closed
set of outcome and reason strings. Tracing replaces every caller-controlled
path field — patterns, prefixes, and sampled relative matches — with the
stable `<redacted>` marker. Errors may retain the original caller input so
invalid patterns can be explained precisely.

## Alternatives considered

- **Keep the ambient root.** Rejected. Opening the handle at `/` or `.`
regardless of the pattern's own scope gives the metadata check no
least-privilege benefit at all; the capability wraps the check in `cap_std`
API surface without constraining what it can resolve.
- **Always open at the working directory.** Rejected. This breaks absolute
patterns, which need a handle rooted above the working directory to
resolve at all, and it does not help parent-relative patterns such as
`../*.txt`, which would still need to escape the working-directory handle
to resolve.
- **Replace the `glob` crate with a capability-native matcher.** Rejected for
this change. This would close the remaining gap where the matcher's own
traversal reads the filesystem ambiently, but it is a materially larger
change than scoping the existing metadata check, and is named here as the
way to close that boundary rather than attempted as part of this decision.

## Implementation references

- Capability scoping and symbolic-link handling:
[`src/manifest/glob/walk.rs`](../src/manifest/glob/walk.rs)
- Expansion entry point and capability composition:
[`src/manifest/glob/mod.rs`](../src/manifest/glob/mod.rs)
- Bounded diagnostics for unopenable prefixes and skipped matches:
[`src/manifest/glob/diagnostics.rs`](../src/manifest/glob/diagnostics.rs)
- Developer guide:
[`docs/developers-guide.md`](developers-guide.md#manifest-glob-module-boundary)
7 changes: 5 additions & 2 deletions docs/contents.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,17 @@ operator, user, and contributor references are easier to find.
- [adr-009-bounded-redacted-manifest-telemetry.md](adr-009-bounded-redacted-manifest-telemetry.md):
Manifest telemetry decision record, separating observability from evaluation
and bounding and redacting the emitted metrics and spans.
- [adr-010-scope-glob-capability-to-literal-prefix.md](adr-010-scope-glob-capability-to-literal-prefix.md):
Glob capability-scoping decision record, opening the metadata capability at
a pattern's literal directory prefix instead of an ambient root.

## User and operator guides

- [quickstart.md](quickstart.md): First-run walkthrough for building with
Netsuke.
- [v0-1-0-migration-guide.md](v0-1-0-migration-guide.md): Migration notes for
the v0.1.0 child-environment API additions, and the stability caveat that
covers them.
the v0.1.0 child-environment API additions and glob behaviour, and the
stability caveat that covers them.
- [users-guide.md](users-guide.md): End-user reference for authoring and
running Netsuke manifests, including executable discovery and
`command_available` branch selection.
Expand Down
81 changes: 81 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +1601,87 @@ sibling submodule needs them; widen the boundary only by adding a deliberate
re-export in `src/manifest/mod.rs`. The comments in the source are supporting
detail for these rules, not a substitute for them.

### Capability scope

The metadata check that filters directories out of a glob's results goes
through a `cap_std::fs::Dir` handle rather than a raw filesystem call.
`walk::open_root_dir` opens that handle at the pattern's longest literal
directory prefix, computed by `walk::literal_dir_prefix`: the pattern text up
to the first `*`, `?`, `[`, or `{`, trimmed back to the last path separator.
For `src/**/*.c` that prefix is `src/`.

`walk::open_literal_prefix` owns the opening policy: it opens the lexical root
or current directory ambiently once, then opens each normal literal component
without following symbolic links. It is used only to establish `GlobRoot`;
metadata lookups remain the responsibility of that root.

- **Bracketed literal escapes do not stop the scan.** The `[*]`, `[?]`,
`[[]`, `[]]`, `[{]`, and `[}]` forms that `normalize::force_literal_escapes`
produces from `\*`, `\?`, and the like name a literal character rather than
a wildcard, so `src/[*]x/generated/*.c` reaches `src/[*]x/generated/`, not
`src/`. A genuine character class such as `[ab]` is still a wildcard and
still stops the scan. The resulting prefix is still pattern text, so
`walk::unescape_literal_escapes` resolves it to the path it names
(`src/[*]x/` becomes the directory `src/*x/`) before the capability is
opened and before any match is stripped of it.
- **`GlobRoot` couples the handle with the prefix.** Matches keep the
pattern's own rooting as they arrive from the `glob` crate's walker — an
absolute pattern yields absolute matches, while a parent-relative pattern
such as `../*.txt` yields matches like `../out.txt` — so
`GlobRoot::relativise` rebases each one onto the prefix before the
metadata lookup. A path that does not start with the prefix is rejected
outright rather than resolved through a wider capability.
- **No literal directory component falls back to the working directory.** A
pattern such as `*.c` yields a prefix of `.`. `walk::prefix_is_unopenable`
treats a missing prefix, and a prefix that names something other than a
directory, as no capability at all; `glob_paths` then returns an empty
match set rather than an error. Any other failure to open the prefix
propagates.
- **`walk::is_unresolvable_link` governs which failed lookups are skipped
rather than fatal.** Only `io::ErrorKind::PermissionDenied` (an escape from
the capability's tree, or a genuine permission failure the capability
cannot distinguish from one) and `io::ErrorKind::NotFound` (a dangling
link) count, and only when some component of the matched path is actually
a symbolic link. A `FilesystemLoop` is a broken tree rather than an absent
file, so it propagates instead of being skipped.
- **The boundary that remains.** The match walk itself is the `glob` crate's,
and that crate traverses the filesystem ambiently. Only the metadata check
is capability-scoped, so narrowing the capability's opening point narrows
what the metadata check can resolve, not what the walk itself can see on
disk.

[ADR-010](adr-010-scope-glob-capability-to-literal-prefix.md) records the
decision to scope the capability this way and the alternatives it rejected.

### Glob expansion observability

`src/manifest/glob::expand_glob` returns bounded observations for two outcomes
of the capability-scoped walk that are expected rather than erroneous, so
neither reaches the top-level diagnostics: a literal prefix that names no
directory, and matches dropped because a symbolic link cannot be resolved
through the capability, including an unreadable link within the prefix. It
aggregates every skipped entry while retaining at most the first four
unreachable-symlink paths as a trace sample. The
`src/manifest/mod.rs` adapter records those observations after the query at the
Jinja `glob` helper's orchestration boundary, via `glob::record_expansion`.
Keeping recording there leaves the expansion query free of metrics and tracing
side effects while keeping a degraded expansion visible without having to
reproduce it.

- **Metrics** — `netsuke_manifest_glob_expansions_total`, labelled
`outcome` (`matched`, `unopenable_prefix`), and
`netsuke_manifest_glob_entries_skipped_total`, labelled `reason`
(`unreachable_symlink`, `not_a_file`). The skipped-entry counter includes
every skipped entry, not only the sampled paths. Labels carry only these
closed sets, never the pattern or a path, in line with the low-cardinality
rule in `AGENTS.md`.
- **Tracing** — every caller-controlled path field is replaced with the stable
`<redacted>` marker: patterns, prefixes, and sampled relative matches. A
skipped unreachable-symlink event is emitted only for the retained sample,
with no more than four such events per expansion. Metrics retain only
bounded aggregate status and reason data; errors may retain the caller's
original pattern so invalid input can be explained precisely.

## Test isolation utilities

Environment variable mutations and working-directory changes are process-global
Expand Down
17 changes: 14 additions & 3 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1062,9 +1062,10 @@ providing a secure bridge to the underlying system.

- `glob(pattern: &str) -> Result<Vec<String>, Error>`: Expand filesystem
patterns (e.g., `src/**/*.c`) into a list of matched paths. Results are
yielded in lexicographic order by the iterator and returned unchanged.
Symlinks are followed by the `glob` crate by default. Matching is case-
sensitive on all platforms. `glob_with` enforces
yielded in lexicographic order by the iterator and returned unchanged. A
match reached through a symbolic link that escapes the pattern's literal
directory prefix, or that dangles, is skipped; a symlink loop fails the
expansion. Matching is case-sensitive on all platforms. `glob_with` enforces
`require_literal_separator = true` internally, so wildcards do not cross path
separators unless `**` is used. Callers may use `/` or `\\` in patterns;
these are normalized to the host platform before matching. Results contain
Expand All @@ -1079,6 +1080,16 @@ providing a secure bridge to the underlying system.
`config\*.yml` maps to `config/*.yml`. On Windows, backslash escapes are not
supported. This provides globbing support not available in Ninja itself,
which does not support globbing.[^3]

The metadata check that filters directories out of the results runs
through a capability opened at the pattern's literal directory prefix
(`src/` for `src/**/*.c`) rather than at an ambient root; the match walk
itself remains the `glob` crate's own, which traverses the filesystem
ambiently.
[ADR-010](adr-010-scope-glob-capability-to-literal-prefix.md) records this
decision; see the
[developer's guide](developers-guide.md#capability-scope) for the prefix
computation and symlink-handling rules.
- `python_version(requirement: &str) -> Result<bool, Error>`: An example of a
domain-specific helper function that demonstrates the extensibility of this
architecture. This function would execute `python --version` or
Expand Down
Loading
Loading