Skip to content

feat(when): allow when clauses to check filesystem state via fs.* functions#341

Merged
fohte merged 3 commits into
mainfrom
fohte/add-fs-functions
May 5, 2026
Merged

feat(when): allow when clauses to check filesystem state via fs.* functions#341
fohte merged 3 commits into
mainfrom
fohte/add-fs-functions

Conversation

@fohte
Copy link
Copy Markdown
Owner

@fohte fohte commented May 5, 2026

Purpose

  • The information a runok when: clause can read is limited to env / flags / args / paths / redirects / pipe / vars / flag_groups / os, with no access to live filesystem state at evaluation time
    • Setups like "allow git commit only when the pre-commit skill ran" -- gating on a marker file touched by a Claude Code skill -- cannot be expressed
  • No existing runok surface fills the same role
    • The <path:name> placeholder is pattern matching on a command argument; it does not check the disk
    • An extension calling --check is possible, but spawning a subprocess just for an existence test is overkill

Approach

  • Add fs.exists(path) / fs.is_file(path) / fs.is_dir(path), callable from when: clauses
    • Ship the same three-piece set as bash's [ -e ] / [ -f ] / [ -d ] from the start, so the API does not drift in naming later
  • I/O error policy: collapse NotFound to false, surface anything else (e.g. EACCES) as a CEL evaluation error
    • Folding permission errors into false would let when: fs.exists(marker) silently misclassify a non-statable parent as "marker absent" and quietly bypass the gate
  • Symlinks are followed; broken symlinks return false from all three
  • Empty-string arguments return false (not an error)
rules:
  # Allow `git commit` only when the pre-commit skill has run
  - allow: 'git commit *'
    when: "fs.exists('/tmp/runok-precommit-ok')"
  - ask: 'git commit *'

  # Allow `git push` only when the working tree is a git repository
  - allow: 'git push *'
    when: "fs.is_dir('.git')"
Design decisions

Naming style

Decision Design Pros Cons
Chosen fs.exists / fs.is_file / fs.is_dir (snake_case) Matches the existing flag_groups snake_case / mirrors bash's -f and -d intuitively Diverges from CEL's standard library convention (camelCase)
Rejected fs.isFile / fs.isDir (camelCase) Matches CEL built-ins like startsWith Diverges from runok's own context variables (flag_groups); confusing to authors

I/O error handling

Decision Design Pros Cons
Chosen Collapse only NotFound to false; anything else becomes a CEL error Prevents permission failures from being mistaken for "marker absent" and quietly opening the gate A when: clause given an unstatable path stops evaluation with an error
Rejected Collapse every stat failure to false Simpler implementation Equates "not readable" with "not present"; lets cases that should deny slip to allow

fs.* dispatch

Decision Design Pros Cons
Chosen Register fs as a sentinel map; register exists / is_file / is_dir as functions with a receiver check Users write fs.exists("p") (consistent with other context surfaces) / misuse like 'foo'.exists('bar') is rejected cel-interpreter has no real namespace, so the receiver check has to be implemented by hand
Rejected Register flat function names fs_exists / fs_is_file / fs_is_dir Simpler, no receiver check needed Forces fs_exists("p") syntax, diverging from paths.X / flags.X / vars.X

fohte added 2 commits May 5, 2026 15:04
…em checks

intent(when): let `when:` clauses gate on the existence of an external
  marker file (e.g. one written by a Claude Code skill) so a rule can
  branch on out-of-process state, which the existing env / flags / args /
  paths / redirects / pipe / vars / flag_groups / os surface cannot reach.
decision(when): collapse `NotFound` into `false` but surface every other
  IO error (EACCES, etc.) as a CEL evaluation error — folding permission
  failures into "false" would silently misclassify a missing-permission
  parent directory as "marker absent" and quietly bypass the gate.
decision(when): register `fs` as a sentinel map and reject calls whose
  receiver is not that exact map, so `add_function("exists", ...)` does
  not turn `'foo'.exists('bar')` into a working call on arbitrary values.
constraint(when): cel-interpreter has no real namespace concept —
  `fs.exists("p")` is parsed as a method call on a value `fs` with
  function name `exists`, and registered functions live in a flat name
  table. The 1-arg `exists` registration does not collide with CEL's
  built-in `list.exists(v, pred)` macro because the parser dispatches the
  2-arg comprehension form before reaching function lookup.
rejected(when): a separate `fs_exists` / `fs_is_file` / `fs_is_dir`
  flat-named API — would force users to write `fs_exists("p")` instead of
  the bash-shaped `fs.exists("p")` and diverge from how every other
  context surface (`paths.X`, `flags.X`, `vars.X`) is spelled.
intent(release-notes): satisfy the project rule that next.md entries must
  carry a real PR link before merge.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 5, 2026

Codecov Report

❌ Patch coverage is 97.01493% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.03%. Comparing base (9bb68ce) to head (ee51d01).

Files with missing lines Patch % Lines
src/rules/expr_evaluator.rs 97.01% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #341      +/-   ##
==========================================
+ Coverage   88.98%   89.03%   +0.04%     
==========================================
  Files          53       53              
  Lines       12513    12580      +67     
==========================================
+ Hits        11135    11200      +65     
- Misses       1378     1380       +2     
Flag Coverage Δ
Linux 88.84% <97.01%> (+0.02%) ⬆️
macOS 90.17% <97.01%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces new fs.* CEL functions (fs.exists, fs.is_file, fs.is_dir) to enable filesystem-based conditions in when clauses, complete with documentation and tests. The implementation includes logic to follow symlinks and properly handle I/O errors. Review feedback suggests optimizing the sentinel key lookup to prevent unnecessary allocations and refactoring the filesystem check implementations to reduce code duplication.

Comment thread src/rules/expr_evaluator.rs Outdated
Comment thread src/rules/expr_evaluator.rs
intent(when): address Gemini Code Assist review feedback on PR #341 -- the
  per-call `Key` allocation in `require_fs_target` was wasteful, and the
  `fs.is_file` / `fs.is_dir` bodies duplicated the same receiver check,
  empty-path short-circuit, and stat-error classification.
decision(when): cache the sentinel `Key` in a `OnceLock` rather than a
  thread-local or `Lazy` -- `OnceLock` is in std (MSRV is fine on edition
  2024), needs no extra deps, and the `Key` is read-only after init.
@fohte fohte merged commit 1e675fc into main May 5, 2026
10 checks passed
@fohte fohte deleted the fohte/add-fs-functions branch May 5, 2026 06:39
@fohte-bot fohte-bot Bot mentioned this pull request May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant