Files
strukto-ai--mirage/python/mirage/policy/match/rule.py
T
Zecheng Zhang 325e8d33a2 fix(permissions): score the entry that matched, and read entries by the same law
Codex review on #876, four findings, all reproduced first.

- A rule was scored by its deepest path entry, so an entry that says
  nothing about the operand lent it depth: an ask on /repo/* and
  /else/very/deep/* outranked a deny anchored at /repo/private/* and an
  approval reopened it. match_rule now reports the depth of the entry
  that actually matched.
- io_refusal read every deny before any ask while decide ordered by
  anchor depth, so a line admitted under an approved deeper ask was
  refused at every entry the carve-out was written for. Both gates now
  share better_match.
- with_inline returned early when no base role resolved, so an inline
  allow list was accepted when a workspace happened to declare no
  default role. refuse_allow is called on both paths.
- PolicyError is not a ValueError, so an unknown profile name left the
  sessions router as a 500 instead of a 422.

anchor_depth moves beside is_glob in utils/hidden, since both gates need
it and rule sits below decide.

Also: parity.sh still asserted the old model (naming a mount confines
the session), which is what turned CI red; it now pins that an unnamed
mount keeps its own mode and that exclusion is a hide. Drops a dead
"not allowed to access mount" row from the TS fuse errno table, whose
error class this branch removed.
2026-08-20 17:06:12 -07:00

319 lines
13 KiB
Python

# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import functools
from collections.abc import Collection, Sequence
from dataclasses import dataclass
from mirage.policy.constants import METADATA_OPS, SUBTREE_COMMANDS, SUBTREE_OPS
from mirage.policy.match.allow import line_tokens
from mirage.policy.match.pattern import pattern_matches
from mirage.policy.types import (AdmissionRules, CommandContext, CommandRule,
OpsContext)
from mirage.types import HiddenPaths
from mirage.utils.hidden import (anchor_depth, classify_paths, path_covers,
path_hidden)
# Which verb wins when two rules speak at the same anchor depth: deny
# before ask. Both gates order by it, which is what keeps the entry
# gate from contradicting the admission gate.
DENY_FIRST = 0
ASK_SECOND = 1
def better_match(current: tuple[int, int] | None, depth: int,
verb: int) -> bool:
"""Whether a match beats the best one so far: deeper anchor first,
then the stronger verb, then the earlier rule (which is why this is
strict).
Shared by ``decide`` and :func:`io_refusal` so a line and the
entries it reaches mid-walk are read by one law.
Args:
current (tuple[int, int] | None): the best (depth, verb) so far.
depth (int): the candidate's anchor depth.
verb (int): ``DENY_FIRST`` or ``ASK_SECOND``.
"""
if current is None:
return True
best_depth, best_verb = current
if depth != best_depth:
return depth > best_depth
return verb < best_verb
@dataclass(frozen=True, slots=True)
class RuleMatch:
"""A rule that applies to a line, and how far it reaches.
``match_rule`` returns None when the rule does not apply, a
RuleMatch with ``operand`` None when the rule refuses (or asks
about) the whole line, and a RuleMatch naming the operand when the
rule is path-scoped and one operand fell under its paths, so the
refusal is scoped to that operand (``rm: x: <reason>``, exit 1)
rather than to the command (``rm: policy denied: <reason>``, 126).
Args:
operand (str | None): the operand as typed that a path-scoped
rule matched; None when the rule reaches the whole line.
depth (int): the anchor depth of the deepest entry that
actually covered the operand, which is what the path axis
orders by. Scoring the rule's deepest entry instead would
lend an unrelated entry's depth to this match: an ask on
``/repo/*`` and ``/else/very/deep/*`` would outrank a deny
anchored at ``/repo/private/*`` and reopen it. 0 when the
rule names no paths, which is off the path axis entirely.
"""
operand: str | None
depth: int = 0
def _under(path: str, root: str) -> bool:
return root == "/" or path == root or path.startswith(root + "/")
def _touches(mount: str, ctx: CommandContext) -> bool:
"""Whether a line works inside a mount: its cwd is under the root,
one of its paths is, or the command walks a directory holding the
root (``grep -r x /scratch`` enters ``/scratch/child``: the fan-out
reruns the traversal inside each descendant mount and no admission
fires again there, so the ancestor operand is the one place the
mount's rule can speak).
Args:
mount (str): the mount root.
ctx (CommandContext): the classified command.
"""
if _under(ctx.cwd, mount):
return True
if any(_under(p.virtual, mount) for p in ctx.paths):
return True
return ctx.walks and any(_under(mount, p.virtual) for p in ctx.paths)
def match_rule(rule: CommandRule, scope: HiddenPaths | None,
ctx: CommandContext) -> RuleMatch | None:
"""Whether a rule applies to a line, and to which operand.
Three questions in order: the rule's command patterns (a prefix
of the line's tokens; none means every command), the rule's mount
(a rule written under a mount section applies only to a line
working inside it), the
rule's paths (none means the whole line; otherwise the first
operand under them scopes the match).
Args:
rule (CommandRule): the rule.
scope (HiddenPaths | None): the rule's paths, classified once
through ``classify_paths``; None when the rule names none.
ctx (CommandContext): the classified command.
"""
if rule.commands:
tokens = line_tokens(ctx)
if not any(pattern_matches(p, tokens) for p in rule.commands):
return None
if rule.mount and not _touches(rule.mount, ctx):
return None
if scope is None:
return RuleMatch(operand=None)
for p in ctx.paths:
if path_hidden(scope, p.virtual):
return RuleMatch(operand=p.raw_path or p.virtual,
depth=hidden_depth(rule, p.virtual))
return _subtree_match(rule, scope, ctx)
def _subtree_match(rule: CommandRule, scope: HiddenPaths,
ctx: CommandContext) -> RuleMatch | None:
"""The operand of a subtree command that holds the scope, if any.
``rm -r /x`` and ``mv /x /y`` take ``/x/locked/*`` along, so for
the commands in ``SUBTREE_COMMANDS`` an operand at or above the
directory holding the scope matches like an operand inside it.
``mv``'s last operand is its destination, which only matches when
it is that directory itself (moving into ``/x/locked`` lands in
the scope; moving into ``/x`` does not).
Args:
rule (CommandRule): the rule, read for the entry that matched.
scope (HiddenPaths): the rule's classified paths.
ctx (CommandContext): the classified command.
"""
if ctx.command not in SUBTREE_COMMANDS:
return None
operands = list(ctx.operands)
dst = (operands.pop()
if ctx.command == "mv" and len(operands) > 1 else None)
for p in operands:
if path_covers(scope, p.virtual):
return RuleMatch(operand=p.raw_path or p.virtual,
depth=covers_depth(rule, p.virtual))
if dst is not None and path_covers(scope, dst.virtual, ancestors=False):
return RuleMatch(operand=dst.raw_path or dst.virtual,
depth=covers_depth(rule, dst.virtual,
ancestors=False))
return None
@functools.lru_cache(maxsize=1024)
def _entry_scope(entry: str) -> HiddenPaths | None:
"""One document entry, classified alone so it can be scored on its
own; remembered, since a rule is re-read on every line.
Args:
entry (str): one entry of a rule's ``paths``.
"""
return classify_paths((entry, ))
def hidden_depth(rule: CommandRule, virtual: str) -> int:
"""The anchor depth of the deepest entry of a rule that holds this
path, 0 when none does.
Args:
rule (CommandRule): the rule that matched.
virtual (str): absolute virtual path the rule matched on.
"""
return max((anchor_depth(e)
for e in rule.paths if path_hidden(_entry_scope(e), virtual)),
default=0)
def covers_depth(rule: CommandRule,
virtual: str,
ancestors: bool = True) -> int:
"""The anchor depth of the deepest entry of a rule that sits at or
under this path, 0 when none does.
The subtree counterpart of :func:`hidden_depth`, for an operand
that would take the scope along rather than lie inside it.
Args:
rule (CommandRule): the rule that matched.
virtual (str): absolute virtual path of the subtree operand.
ancestors (bool): whether an ancestor of the scope counts.
"""
return max((anchor_depth(e) for e in rule.paths
if path_covers(_entry_scope(e), virtual, ancestors)),
default=0)
@functools.lru_cache(maxsize=1024)
def rule_scope(rule: CommandRule) -> HiddenPaths | None:
"""A rule's paths, classified once and remembered: None when the
rule names none, so a caller can tell a whole-line rule from a
path-scoped one without re-reading the document grammar.
Args:
rule (CommandRule): the rule, which is frozen and so a key.
"""
return classify_paths(rule.paths)
def match_io(rule: CommandRule, scope: HiddenPaths | None,
tokens: Sequence[str], virtual: str) -> bool:
"""Whether a rule reaches an entry a command touches on its own,
below its operands: the rule names the line (its command patterns
against the line's tokens, none meaning every command) and its
paths hold the entry. A rule with no paths spoke about the whole
line at admission and has nothing to add at an entry; the
directory holding a scope is not in it, so a listing still shows
a refused entry's name, which is what deny means: present, and
refused.
Args:
rule (CommandRule): the rule.
scope (HiddenPaths | None): the rule's classified paths.
tokens (Sequence[str]): the line as an admission pattern reads
it, command name first.
virtual (str): absolute virtual path of the entry.
"""
if scope is None:
return False
if rule.commands and not any(
pattern_matches(p, tokens) for p in rule.commands):
return False
return path_hidden(scope, virtual)
def io_refusal(rules: AdmissionRules | None, tokens: Sequence[str],
virtual: str, granted: Collection[CommandRule]) -> str | None:
"""The reason a command may not touch an entry it reached on its
own, None when it may.
The same law the admission gate applies to a line, and literally
the same comparison (:func:`better_match`): anchor depth first,
deny before ask at equal depth. Reading every deny before any ask
instead would let a broad deny on ``/repo/*`` overrule an approved
ask on ``/repo/sealed/*`` that the gate had just admitted the line
under, so the carve-out would survive admission and then refuse
every entry it was written for.
The winning rule then answers: a deny refuses, an ask refuses
unless the line holds a grant under it (the nod the gate took for
``rm -r /x`` covers the entries under ``/x``; a walk that wanders
into an asked scope from outside gets no nod mid-command, so it is
refused and the agent names the path to be asked).
Args:
rules (AdmissionRules | None): the session's admission rules.
tokens (Sequence[str]): the line's tokens, command name first.
virtual (str): absolute virtual path of the entry.
granted (Collection[CommandRule]): the ask rules the line runs
under a grant for.
"""
if rules is None:
return None
best: tuple[int, int] | None = None
chosen: tuple[CommandRule, int] | None = None
for verb, written in ((DENY_FIRST, rules.deny), (ASK_SECOND, rules.ask)):
for rule in written:
if not match_io(rule, rule_scope(rule), tokens, virtual):
continue
depth = hidden_depth(rule, virtual)
if not better_match(best, depth, verb):
continue
best = (depth, verb)
chosen = (rule, verb)
if chosen is None:
return None
rule, verb = chosen
if verb == ASK_SECOND and rule in granted:
return None
return rule.reason
def match_op(rule: CommandRule, scope: HiddenPaths | None,
ctx: OpsContext) -> bool:
"""Whether a rule refuses an op: only a pure path rule can, since an
op does not know which command issued it. The op's path is tested
against the scope, and an op that moves or removes a whole subtree
(``SUBTREE_OPS``) is also refused on the directory holding the
scope or on any ancestor, since it would take the scope along. A
metadata op (``METADATA_OPS``) passes: deny is present and refused,
so the entry stats and its content is what the door withholds.
Args:
rule (CommandRule): the rule.
scope (HiddenPaths | None): the rule's classified paths.
ctx (OpsContext): the op about to run.
"""
if rule.commands or scope is None or ctx.op in METADATA_OPS:
return False
if path_hidden(scope, ctx.path.virtual):
return True
return ctx.op in SUBTREE_OPS and path_covers(scope, ctx.path.virtual)