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.
This commit is contained in:
+15
-3
@@ -43,6 +43,11 @@ mounts:
|
||||
/ro:
|
||||
resource: ram
|
||||
mode: READ
|
||||
profiles:
|
||||
blind:
|
||||
paths:
|
||||
hide:
|
||||
- /side
|
||||
YML
|
||||
|
||||
# The workspace default stays WRITE so the implicit scratch root (always
|
||||
@@ -120,7 +125,11 @@ probe() {
|
||||
|
||||
$cli workspace delete pw >/dev/null 2>&1 </dev/null || true
|
||||
|
||||
# ── session modes: -m /mount:mode caps what a session may do per mount ──
|
||||
# ── session modes: -m /mount:mode narrows a mount for this session ──
|
||||
# Naming a mount is not an allowlist: a mount the session never names
|
||||
# keeps its own mode, and narrowing only ever weakens (capped asks for
|
||||
# write on a READ mount and stays read-only). Excluding a mount is a
|
||||
# hide, which answers absence, so -p blind is the other half of this.
|
||||
$cli workspace delete gw >/dev/null 2>&1 </dev/null || true
|
||||
$cli workspace create "$MODES_YAML" --id gw >/dev/null </dev/null
|
||||
$cli execute -w gw -c 'echo hello > /data/a.txt' </dev/null >/dev/null
|
||||
@@ -129,10 +138,12 @@ probe() {
|
||||
$cli session create gw --id writer -m /data:rw </dev/null >/dev/null
|
||||
$cli session create gw --id lister -m /data </dev/null >/dev/null
|
||||
$cli session create gw --id capped -m /ro:write </dev/null >/dev/null
|
||||
$cli session create gw --id blind -p blind </dev/null >/dev/null
|
||||
echo "mode.reader_cat=$($cli execute -w gw -s reader -c 'cat /data/a.txt' </dev/null | sout)"
|
||||
echo "mode.reader_rm_err=$($cli execute -w gw -s reader -c 'rm /data/a.txt' </dev/null | serr)"
|
||||
echo "mode.reader_redirect=$($cli execute -w gw -s reader -c 'echo leak > /data/new.txt' </dev/null | verdict)"
|
||||
echo "mode.reader_side_err=$($cli execute -w gw -s reader -c 'cat /side/s.txt' </dev/null | serr)"
|
||||
echo "mode.reader_side=$($cli execute -w gw -s reader -c 'cat /side/s.txt' </dev/null | sout)"
|
||||
echo "mode.blind_side_err=$($cli execute -w gw -s blind -c 'cat /side/s.txt' </dev/null | serr)"
|
||||
echo "mode.writer_write=$($cli execute -w gw -s writer -c 'echo w > /data/w.txt && cat /data/w.txt' </dev/null | sout)"
|
||||
echo "mode.lister_inherits=$($cli execute -w gw -s lister -c 'echo l > /data/l.txt && cat /data/l.txt' </dev/null | sout)"
|
||||
echo "mode.capped_ro=$($cli execute -w gw -s capped -c 'echo up > /ro/y.txt' </dev/null | verdict)"
|
||||
@@ -241,7 +252,8 @@ expect "session.s2_pwd" "/"
|
||||
expect "mode.reader_cat" "hello"
|
||||
expect "mode.reader_rm_err" "rm: read-only mount at /data/"
|
||||
expect "mode.reader_redirect" "denied"
|
||||
expect "mode.reader_side_err" "cat: session 'reader' not allowed to access mount '/side'"
|
||||
expect "mode.reader_side" "aside"
|
||||
expect "mode.blind_side_err" "cat: /side/s.txt: No such file or directory"
|
||||
expect "mode.writer_write" "w"
|
||||
expect "mode.lister_inherits" "l"
|
||||
expect "mode.capped_ro" "denied"
|
||||
|
||||
@@ -165,6 +165,61 @@
|
||||
"stderr": "git: policy denied: no branches\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "conflict_depth_prep",
|
||||
"seq": 923241,
|
||||
"targets": [
|
||||
"ram-commands"
|
||||
],
|
||||
"command": "mkdir -p /scratch/span/sealed /scratch/deep/a/b/c /scratch/carve/open /scratch/carve/other && echo s > /scratch/span/sealed/s && echo d > /scratch/deep/a/b/c/x && echo o > /scratch/carve/open/o && echo t > /scratch/carve/other/t",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "conflict_an_unrelated_entry_does_not_lend_a_rule_its_depth",
|
||||
"seq": 923242,
|
||||
"targets": [
|
||||
"ram-commands"
|
||||
],
|
||||
"session": "spanner",
|
||||
"command": "cat /scratch/span/sealed/s",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "cat: /scratch/span/sealed/s: span sealed\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "conflict_the_unrelated_entry_still_speaks_where_it_anchors",
|
||||
"seq": 923243,
|
||||
"targets": [
|
||||
"ram-commands"
|
||||
],
|
||||
"session": "spanner",
|
||||
"command": "cat /scratch/deep/a/b/c/x",
|
||||
"expect": {
|
||||
"exit": 126,
|
||||
"stdout": "",
|
||||
"stderr": "cat: requires approval: span review (approval 18558a102920)\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "conflict_the_deeper_ask_admits_the_line_over_the_broad_deny",
|
||||
"seq": 923244,
|
||||
"targets": [
|
||||
"ram-commands"
|
||||
],
|
||||
"session": "carver",
|
||||
"command": "rm -r /scratch/carve",
|
||||
"expect": {
|
||||
"exit": 126,
|
||||
"stdout": "",
|
||||
"stderr": "rm: requires approval: carve nod (approval 4b76fdb133ff)\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "conflict_a_hidden_path_outranks_the_ask_and_the_deny",
|
||||
"seq": 923078,
|
||||
|
||||
@@ -2366,6 +2366,55 @@
|
||||
"/vault/d/inner"
|
||||
]
|
||||
}
|
||||
},
|
||||
"spanner": {
|
||||
"commands": {
|
||||
"ask": [
|
||||
{
|
||||
"reason": "span review",
|
||||
"commands": {
|
||||
"cat": [
|
||||
"/scratch/span/*",
|
||||
"/scratch/deep/a/b/c/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"deny": [
|
||||
{
|
||||
"reason": "span sealed",
|
||||
"commands": {
|
||||
"cat": [
|
||||
"/scratch/span/sealed/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"carver": {
|
||||
"commands": {
|
||||
"ask": [
|
||||
{
|
||||
"reason": "carve nod",
|
||||
"commands": {
|
||||
"rm": [
|
||||
"/scratch/carve/open/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"deny": [
|
||||
{
|
||||
"reason": "carve locked",
|
||||
"commands": {
|
||||
"rm": [
|
||||
"/scratch/carve/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "default",
|
||||
|
||||
@@ -26,9 +26,9 @@ NO_XATTR = posix_errno(FsCondition.NO_XATTR)
|
||||
# nothing else: SFTP 3 reports a non-empty directory as asyncssh's
|
||||
# SFTPFailure, which is not an OSError and has no code of its own, so
|
||||
# only the wording separates it from any other server-side failure.
|
||||
# The needles that duplicated a typed arm are gone: "read-only" and
|
||||
# "not allowed to access mount" arrive as PermissionError, "no mount"
|
||||
# as ValueError, and classify names all three.
|
||||
# The needles that duplicated a typed arm are gone: "read-only" arrives
|
||||
# as PermissionError and "no mount" as ValueError, and classify names
|
||||
# both.
|
||||
_MESSAGE_CODES: tuple[tuple[tuple[str, ...], int], ...] = (
|
||||
(("not empty", "enotempty"), errno.ENOTEMPTY),
|
||||
(("not a directory", "enotdir"), errno.ENOTDIR),
|
||||
|
||||
@@ -13,21 +13,24 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.policy.match.allow import head_visible, line_allowed, line_tokens
|
||||
from mirage.policy.match.decide import (Decision, Outcome, anchor_depth,
|
||||
decide, rule_depth)
|
||||
from mirage.policy.match.decide import Decision, Outcome, decide
|
||||
from mirage.policy.match.pattern import (intersect_patterns, pattern_matches,
|
||||
pattern_names, split_pattern)
|
||||
from mirage.policy.match.reads import has_rules, reads_args, scopes_paths
|
||||
from mirage.policy.match.rule import (RuleMatch, io_refusal, match_io,
|
||||
match_op, match_rule, rule_scope)
|
||||
from mirage.policy.match.rule import (RuleMatch, covers_depth, hidden_depth,
|
||||
io_refusal, match_io, match_op,
|
||||
match_rule, rule_scope)
|
||||
from mirage.utils.hidden import anchor_depth
|
||||
|
||||
__all__ = [
|
||||
"Decision",
|
||||
"Outcome",
|
||||
"RuleMatch",
|
||||
"anchor_depth",
|
||||
"covers_depth",
|
||||
"decide",
|
||||
"has_rules",
|
||||
"hidden_depth",
|
||||
"head_visible",
|
||||
"intersect_patterns",
|
||||
"io_refusal",
|
||||
@@ -39,7 +42,6 @@ __all__ = [
|
||||
"pattern_matches",
|
||||
"pattern_names",
|
||||
"reads_args",
|
||||
"rule_depth",
|
||||
"rule_scope",
|
||||
"scopes_paths",
|
||||
"split_pattern",
|
||||
|
||||
@@ -16,9 +16,9 @@ from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from mirage.policy.match.allow import line_allowed
|
||||
from mirage.policy.match.rule import match_rule, rule_scope
|
||||
from mirage.policy.match.rule import (ASK_SECOND, DENY_FIRST, better_match,
|
||||
match_rule, rule_scope)
|
||||
from mirage.policy.types import AdmissionRules, CommandContext, CommandRule
|
||||
from mirage.utils.hidden import is_glob
|
||||
|
||||
|
||||
class Outcome(StrEnum):
|
||||
@@ -58,70 +58,11 @@ class Decision:
|
||||
source: str = ""
|
||||
|
||||
|
||||
def anchor_depth(entry: str) -> int:
|
||||
"""How specific a path entry is: the number of literal components
|
||||
before its first wildcard.
|
||||
|
||||
The one measure the path axis orders by. ``/repo/sealed/*`` is 2,
|
||||
``/repo/*`` and the plain subtree ``/repo`` are 1, and a slashless
|
||||
name pattern like ``*.key`` is 0, since it anchors nothing. Every
|
||||
pattern the document allows has an answer, so two rules about one
|
||||
path are always comparable and nothing is ever guessed.
|
||||
|
||||
Args:
|
||||
entry (str): a path entry as written in the document.
|
||||
"""
|
||||
depth = 0
|
||||
for part in entry.strip("/").split("/"):
|
||||
if not part or is_glob(part):
|
||||
break
|
||||
depth += 1
|
||||
return depth
|
||||
|
||||
|
||||
def rule_depth(rule: CommandRule) -> int:
|
||||
"""A rule's place on the path axis: the depth of its deepest path
|
||||
entry, or 0 when it names none.
|
||||
|
||||
A rule naming no path is not on this axis at all, **wherever it is
|
||||
written**, so one in a mount section scores 0 exactly as a top-level
|
||||
one does and the two are separated by verb alone. Writing it under
|
||||
``mounts./repo`` scopes it to lines working inside that mount
|
||||
(``match_rule`` reads ``rule.mount``); it does not make it more
|
||||
specific than a rule about the whole session. That is what keeps
|
||||
"denied generally, asked inside one mount" inexpressible for a
|
||||
pathless rule, which in practice means an account CLI: such a CLI
|
||||
reaches a service and touches no mount, so scoping it to one was
|
||||
never meaningful.
|
||||
|
||||
Args:
|
||||
rule (CommandRule): an ask or deny rule.
|
||||
"""
|
||||
return max((anchor_depth(entry) for entry in rule.paths), default=0)
|
||||
|
||||
|
||||
# Which verb wins when two rules match at the same anchor depth. Deny
|
||||
# before ask, and the allow list is not a rule so it never ties.
|
||||
_VERB_ORDER = {Outcome.DENY: 0, Outcome.ASK: 1}
|
||||
|
||||
|
||||
def _better(current: tuple[int, int] | None, depth: int,
|
||||
outcome: Outcome) -> 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).
|
||||
|
||||
Args:
|
||||
current (tuple[int, int] | None): the best (depth, verb) so far.
|
||||
depth (int): the candidate's anchor depth.
|
||||
outcome (Outcome): the candidate's verb.
|
||||
"""
|
||||
if current is None:
|
||||
return True
|
||||
best_depth, best_verb = current
|
||||
if depth != best_depth:
|
||||
return depth > best_depth
|
||||
return _VERB_ORDER[outcome] < best_verb
|
||||
# before ask, and the allow list is not a rule so it never ties. The
|
||||
# ordering itself lives in ``match.rule`` because the entry gate reads
|
||||
# by it too.
|
||||
_VERB_ORDER = {Outcome.DENY: DENY_FIRST, Outcome.ASK: ASK_SECOND}
|
||||
|
||||
|
||||
def decide(ctx: CommandContext, rules: AdmissionRules | None) -> Decision:
|
||||
@@ -129,10 +70,21 @@ def decide(ctx: CommandContext, rules: AdmissionRules | None) -> Decision:
|
||||
|
||||
Two rules, because a command name and a path are not the same kind
|
||||
of thing. A rule naming no path is read by verb, deny before ask,
|
||||
wherever it was written. A rule carrying paths is read by anchor
|
||||
depth, the deeper entry winning, ties broken by verb. The allow
|
||||
list is asked first, since a line no list covers never reaches a
|
||||
rule.
|
||||
wherever it was written: it is off the path axis entirely, so one
|
||||
in a mount section scores 0 exactly as a top-level one does.
|
||||
Writing it under ``mounts./repo`` scopes it to lines working inside
|
||||
that mount (``match_rule`` reads ``rule.mount``); it does not make
|
||||
it more specific than a rule about the whole session. That is what
|
||||
keeps "denied generally, asked inside one mount" inexpressible for
|
||||
a pathless rule, which in practice means an account CLI: such a CLI
|
||||
reaches a service and touches no mount, so scoping it to one was
|
||||
never meaningful.
|
||||
|
||||
A rule carrying paths is read by anchor depth, the deeper entry
|
||||
winning, ties broken by verb. The depth is the matched entry's, not
|
||||
the rule's deepest, so an entry that says nothing about this
|
||||
operand cannot lend it specificity. The allow list is asked first,
|
||||
since a line no list covers never reaches a rule.
|
||||
|
||||
``PermissionsPolicy`` renders this into the outcome table and
|
||||
``explain`` reports it, so the two cannot disagree about what a
|
||||
@@ -154,10 +106,9 @@ def decide(ctx: CommandContext, rules: AdmissionRules | None) -> Decision:
|
||||
hit = match_rule(rule, rule_scope(rule), ctx)
|
||||
if hit is None:
|
||||
continue
|
||||
depth = rule_depth(rule)
|
||||
if not _better(best, depth, outcome):
|
||||
if not better_match(best, hit.depth, _VERB_ORDER[outcome]):
|
||||
continue
|
||||
best = (depth, _VERB_ORDER[outcome])
|
||||
best = (hit.depth, _VERB_ORDER[outcome])
|
||||
chosen = Decision(outcome=outcome,
|
||||
rule=rule,
|
||||
matched_path=hit.operand,
|
||||
|
||||
@@ -22,7 +22,36 @@ 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 classify_paths, path_covers, path_hidden
|
||||
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)
|
||||
@@ -39,9 +68,17 @@ class RuleMatch:
|
||||
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:
|
||||
@@ -94,11 +131,12 @@ def match_rule(rule: CommandRule, scope: HiddenPaths | 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)
|
||||
return _subtree_match(scope, ctx)
|
||||
return RuleMatch(operand=p.raw_path or p.virtual,
|
||||
depth=hidden_depth(rule, p.virtual))
|
||||
return _subtree_match(rule, scope, ctx)
|
||||
|
||||
|
||||
def _subtree_match(scope: HiddenPaths,
|
||||
def _subtree_match(rule: CommandRule, scope: HiddenPaths,
|
||||
ctx: CommandContext) -> RuleMatch | None:
|
||||
"""The operand of a subtree command that holds the scope, if any.
|
||||
|
||||
@@ -110,6 +148,7 @@ def _subtree_match(scope: HiddenPaths,
|
||||
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.
|
||||
"""
|
||||
@@ -120,12 +159,58 @@ def _subtree_match(scope: HiddenPaths,
|
||||
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)
|
||||
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)
|
||||
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
|
||||
@@ -169,13 +254,19 @@ def io_refusal(rules: AdmissionRules | None, tokens: Sequence[str],
|
||||
"""The reason a command may not touch an entry it reached on its
|
||||
own, None when it may.
|
||||
|
||||
The same precedence the admission gate applies to a line: the deny
|
||||
rules first, the first that reaches the entry refusing it; then the
|
||||
ask rules, where the first that reaches it refuses unless the line
|
||||
holds a grant under that rule (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).
|
||||
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.
|
||||
@@ -186,13 +277,23 @@ def io_refusal(rules: AdmissionRules | None, tokens: Sequence[str],
|
||||
"""
|
||||
if rules is None:
|
||||
return None
|
||||
for rule in rules.deny:
|
||||
if match_io(rule, rule_scope(rule), tokens, virtual):
|
||||
return rule.reason
|
||||
for rule in rules.ask:
|
||||
if match_io(rule, rule_scope(rule), tokens, virtual):
|
||||
return None if rule in granted else rule.reason
|
||||
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,
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mirage.policy.errors import PolicyError
|
||||
|
||||
router = APIRouter(prefix="/v1/workspaces/{workspace_id}/sessions")
|
||||
|
||||
|
||||
@@ -58,7 +60,11 @@ async def create_session(workspace_id: str, req: CreateSessionRequest,
|
||||
sess = entry.runner.ws.create_session(sid,
|
||||
mounts=req.mounts or None,
|
||||
profile=req.profile)
|
||||
except ValueError as exc:
|
||||
except (ValueError, PolicyError) as exc:
|
||||
# An unknown profile name and a refused inline document are
|
||||
# both the caller's mistake, and PolicyError is not a
|
||||
# ValueError, so naming it here is what keeps them 422 rather
|
||||
# than 500.
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await entry.runner.call(entry.runner.ws.flush_sessions())
|
||||
return SessionResponse(session_id=sess.session_id, cwd=sess.cwd)
|
||||
|
||||
@@ -32,6 +32,33 @@ def is_glob(entry: str) -> bool:
|
||||
return any(ch in GLOB_CHARS for ch in entry)
|
||||
|
||||
|
||||
def anchor_depth(entry: str) -> int:
|
||||
"""How specific a path entry is: the number of literal components
|
||||
before its first wildcard.
|
||||
|
||||
The one measure the permissions document's path axis orders by.
|
||||
``/repo/sealed/*`` is 2, ``/repo/*`` and the plain subtree
|
||||
``/repo`` are 1, and a slashless name pattern like ``*.key`` is 0,
|
||||
since it anchors nothing. Every pattern the document allows has an
|
||||
answer, so two entries about one path are always comparable and
|
||||
nothing is ever guessed.
|
||||
|
||||
It lives here beside :func:`is_glob` rather than in the policy
|
||||
layer because both gates need it: admission scores the rule that
|
||||
covers an operand, and the entry gate scores the rule that covers
|
||||
an entry reached mid-walk.
|
||||
|
||||
Args:
|
||||
entry (str): a path entry as written in the document.
|
||||
"""
|
||||
depth = 0
|
||||
for part in entry.strip("/").split("/"):
|
||||
if not part or is_glob(part):
|
||||
break
|
||||
depth += 1
|
||||
return depth
|
||||
|
||||
|
||||
def classify_paths(entries: Iterable[str]) -> HiddenPaths | None:
|
||||
"""Compile document path entries into the matcher's shape.
|
||||
|
||||
|
||||
@@ -73,6 +73,25 @@ def _union_hide(a: PathsBlock | VarsBlock | None,
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def refuse_allow(inline: CommandsBlock | None) -> None:
|
||||
"""Refuse an allow list in an inline document.
|
||||
|
||||
The refusal belongs to *where the document was written*, not to
|
||||
whether a role happened to resolve, so both paths into
|
||||
``with_inline`` run it: a workspace with no default role must not
|
||||
quietly accept a list a workspace with one refuses.
|
||||
|
||||
Args:
|
||||
inline (CommandsBlock | None): what ``create_session`` added.
|
||||
|
||||
Raises:
|
||||
PolicyError: the inline document states an allow list.
|
||||
"""
|
||||
if inline is not None and inline.allow is not None:
|
||||
raise PolicyError("inline permissions may add ask and deny rules, "
|
||||
"not an allow list")
|
||||
|
||||
|
||||
def _add_commands(base: CommandsBlock | None,
|
||||
inline: CommandsBlock | None) -> CommandsBlock | None:
|
||||
"""The role's commands block with the inline document's rules added.
|
||||
@@ -91,9 +110,7 @@ def _add_commands(base: CommandsBlock | None,
|
||||
"""
|
||||
if inline is None:
|
||||
return base
|
||||
if inline.allow is not None:
|
||||
raise PolicyError("inline permissions may add ask and deny rules, "
|
||||
"not an allow list")
|
||||
refuse_allow(inline)
|
||||
if base is None:
|
||||
return inline
|
||||
return CommandsBlock(allow=base.allow,
|
||||
@@ -148,19 +165,21 @@ def with_inline(base: SessionProfile | None,
|
||||
"""A role with the inline document of one ``create_session`` added.
|
||||
|
||||
The one rule about combining two documents: an inline document may
|
||||
add ask and deny rules and hides, never an allow list. Modes take
|
||||
the weaker of the two, ``cwd`` and ``env`` are the inline
|
||||
document's when it states them (they are session presets, not
|
||||
permissions). Either side None returns the other unchanged.
|
||||
add ask and deny rules and hides, never an allow list, and that
|
||||
holds even when there is no role to add to. Modes take the weaker
|
||||
of the two, ``cwd`` and ``env`` are the inline document's when it
|
||||
states them (they are session presets, not permissions). Either
|
||||
side None returns the other unchanged.
|
||||
|
||||
Args:
|
||||
base (SessionProfile | None): the resolved role.
|
||||
inline (SessionProfile | None): what ``create_session`` added.
|
||||
"""
|
||||
if base is None:
|
||||
return inline
|
||||
if inline is None:
|
||||
return base
|
||||
refuse_allow(inline.commands)
|
||||
if base is None:
|
||||
return inline
|
||||
hide_paths = _union_hide(base.paths, inline.paths)
|
||||
hide_vars = _union_hide(base.vars, inline.vars)
|
||||
env = None
|
||||
|
||||
@@ -177,6 +177,30 @@ async def test_the_deeper_anchor_wins_and_deny_breaks_a_tie():
|
||||
assert isinstance(answer, Ask) and answer.reason == "nod here"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unrelated_entry_does_not_lend_a_rule_its_depth():
|
||||
# The rule is scored by the entry that covered this operand, not by
|
||||
# its deepest entry. Scoring the deepest would let `/else/very/deep/*`
|
||||
# -- which says nothing about /repo -- carry the ask past a deny
|
||||
# anchored right at /repo/private, and an approval would then reopen
|
||||
# exactly what the deny sealed.
|
||||
ask = CommandRule(reason="review",
|
||||
commands=("cat", ),
|
||||
paths=("/repo/*", "/else/very/deep/*"))
|
||||
deny = CommandRule(reason="private",
|
||||
commands=("cat", ),
|
||||
paths=("/repo/private/*", ))
|
||||
policy = PermissionsPolicy(
|
||||
_Sessions({"s": AdmissionRules(ask=(ask, ), deny=(deny, ))}))
|
||||
assert await policy.pre_command(
|
||||
_ctx("cat", "/repo/private/x", paths=(_path("/repo/private/x"), ))
|
||||
) == Deny("/repo/private/x: private", DenyScope.OPERAND)
|
||||
# The unrelated entry still speaks where it does anchor.
|
||||
answer = await policy.pre_command(
|
||||
_ctx("cat", "/else/very/deep/x", paths=(_path("/else/very/deep/x"), )))
|
||||
assert isinstance(answer, Ask) and answer.reason == "review"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_pathless_rule_is_read_by_verb_wherever_it_is_written():
|
||||
# The command axis, and the one thing it deliberately cannot say.
|
||||
|
||||
@@ -60,7 +60,9 @@ def test_match_rule_by_command_pattern_operand_and_mount():
|
||||
hit = match_rule(
|
||||
scoped, scope,
|
||||
_ctx("rm", paths=(_path("/repo/x", raw="x"), ), cwd="/repo"))
|
||||
assert hit == RuleMatch(operand="x")
|
||||
# The depth is the matched entry's, which is what the path axis
|
||||
# orders by.
|
||||
assert hit == RuleMatch(operand="x", depth=1)
|
||||
assert match_rule(scoped, scope,
|
||||
_ctx("rm", paths=(_path("/scratch/x"), ))) is None
|
||||
# A mount-tier rule applies to a line whose cwd or paths lie under
|
||||
@@ -142,19 +144,20 @@ def test_a_subtree_command_on_the_directory_holding_the_scope_matches():
|
||||
for command in ("rm", "rmdir"):
|
||||
for operand in ("/x/locked", "/x", "/"):
|
||||
assert match_rule(rule, scope, _subtree_ctx(
|
||||
command, operand)) == RuleMatch(operand=operand)
|
||||
command, operand)) == RuleMatch(operand=operand, depth=2)
|
||||
assert match_rule(rule, scope, _subtree_ctx(command,
|
||||
"/x/other")) is None
|
||||
assert match_rule(rule, scope,
|
||||
_subtree_ctx("mv", "/x/locked",
|
||||
"/y")) == RuleMatch(operand="/x/locked")
|
||||
"/y")) == RuleMatch(operand="/x/locked",
|
||||
depth=2)
|
||||
assert match_rule(rule, scope,
|
||||
_subtree_ctx("mv", "/x",
|
||||
"/y")) == RuleMatch(operand="/x")
|
||||
"/y")) == RuleMatch(operand="/x", depth=2)
|
||||
# mv's destination matches only as the holding directory itself:
|
||||
# moving into it lands in the scope, moving into an ancestor does not.
|
||||
assert match_rule(rule, scope, _subtree_ctx(
|
||||
"mv", "/z", "/x/locked")) == RuleMatch(operand="/x/locked")
|
||||
"mv", "/z", "/x/locked")) == RuleMatch(operand="/x/locked", depth=2)
|
||||
assert match_rule(rule, scope, _subtree_ctx("mv", "/z", "/x")) is None
|
||||
# A reader given the same operand is not a whole-line refusal: its
|
||||
# I/O under the scope is the command tier's to refuse, file by file.
|
||||
@@ -165,7 +168,8 @@ def test_a_subtree_command_on_the_directory_holding_the_scope_matches():
|
||||
commands=("rm", ),
|
||||
paths=("/x/locked/*", ))
|
||||
assert match_rule(named, classify_paths(named.paths),
|
||||
_subtree_ctx("rm", "/x")) == RuleMatch(operand="/x")
|
||||
_subtree_ctx("rm", "/x")) == RuleMatch(operand="/x",
|
||||
depth=2)
|
||||
assert match_rule(named, classify_paths(named.paths),
|
||||
_subtree_ctx("mv", "/x", "/y")) is None
|
||||
|
||||
@@ -278,3 +282,24 @@ def test_io_refusal_applies_the_gate_precedence_to_an_entry():
|
||||
deny=(CommandRule(reason="no", commands=("rm", )), ))
|
||||
assert io_refusal(whole, tokens, "/data/x", ()) is None
|
||||
assert io_refusal(None, tokens, "/data/x", ()) is None
|
||||
|
||||
|
||||
def test_io_refusal_orders_by_anchor_depth_like_the_admission_gate():
|
||||
# A broad deny with an approved ask carved out of it. The gate
|
||||
# admits `rm -r /repo` under the deeper ask, so the entry gate has
|
||||
# to read the same way: taking every deny before any ask would
|
||||
# refuse every entry the carve-out was written for, leaving a line
|
||||
# that was admitted unable to touch anything.
|
||||
deny = CommandRule(reason="ro repo",
|
||||
commands=("rm", ),
|
||||
paths=("/repo/*", ))
|
||||
ask = CommandRule(reason="sealed",
|
||||
commands=("rm", ),
|
||||
paths=("/repo/sealed/*", ))
|
||||
rules = AdmissionRules(ask=(ask, ), deny=(deny, ))
|
||||
tokens = ("rm", "-r", "/repo")
|
||||
assert io_refusal(rules, tokens, "/repo/sealed/secret", (ask, )) is None
|
||||
# Without the grant the deeper ask still wins, and asks.
|
||||
assert io_refusal(rules, tokens, "/repo/sealed/secret", ()) == "sealed"
|
||||
# Outside the carve-out the broad deny is what is left.
|
||||
assert io_refusal(rules, tokens, "/repo/other/x", (ask, )) == "ro repo"
|
||||
|
||||
@@ -165,6 +165,26 @@ async def test_create_session_rejects_bad_role():
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_rejects_an_unknown_profile():
|
||||
# PolicyError is not a ValueError, so naming an unknown role used to
|
||||
# escape the handler as a 500: the caller's typo read as our bug.
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
r = await client.post(
|
||||
f"/v1/workspaces/{wid}/sessions",
|
||||
json={
|
||||
"session_id": "agent_d",
|
||||
"profile": "nope",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
assert "nope" in r.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_isolated_per_workspace():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
|
||||
@@ -118,6 +118,12 @@ def test_with_inline_adds_ask_and_deny_but_refuses_an_allow_list():
|
||||
with pytest.raises(PolicyError, match="not an allow list"):
|
||||
with_inline(base,
|
||||
SessionProfile(commands=CommandsBlock(allow=("wc", ))))
|
||||
# And with no role to add to: the refusal belongs to where the
|
||||
# document was written, so a workspace that happens to declare no
|
||||
# default role must not quietly accept what one with a role refuses.
|
||||
with pytest.raises(PolicyError, match="not an allow list"):
|
||||
with_inline(None,
|
||||
SessionProfile(commands=CommandsBlock(allow=("wc", ))))
|
||||
|
||||
|
||||
def test_with_inline_leaves_a_stated_block_alone_when_the_other_is_bare():
|
||||
|
||||
@@ -169,6 +169,28 @@ describe('PermissionsPolicy', () => {
|
||||
).toMatchObject({ kind: 'ask', reason: 'nod here' })
|
||||
})
|
||||
|
||||
it('an unrelated entry does not lend a rule its depth', () => {
|
||||
// The rule is scored by the entry that covered this operand, not by
|
||||
// its deepest entry. Scoring the deepest would let
|
||||
// `/else/very/deep/*` -- which says nothing about /repo -- carry the
|
||||
// ask past a deny anchored right at /repo/private, and an approval
|
||||
// would then reopen exactly what the deny sealed.
|
||||
const ask: CommandRule = {
|
||||
reason: 'review',
|
||||
commands: ['cat'],
|
||||
paths: ['/repo/*', '/else/very/deep/*'],
|
||||
}
|
||||
const deny: CommandRule = { reason: 'private', commands: ['cat'], paths: ['/repo/private/*'] }
|
||||
const p = new PermissionsPolicy(new Sessions({ s: { allow: null, ask: [ask], deny: [deny] } }))
|
||||
expect(
|
||||
p.preCommand(ctx('cat', ['/repo/private/x'], { paths: [path('/repo/private/x')] })),
|
||||
).toEqual({ kind: 'deny', reason: '/repo/private/x: private', scope: 'operand' })
|
||||
// The unrelated entry still speaks where it does anchor.
|
||||
expect(
|
||||
p.preCommand(ctx('cat', ['/else/very/deep/x'], { paths: [path('/else/very/deep/x')] })),
|
||||
).toMatchObject({ kind: 'ask', reason: 'review' })
|
||||
})
|
||||
|
||||
it('a pathless rule is read by verb wherever it is written', () => {
|
||||
// The command axis, and the one thing it deliberately cannot say.
|
||||
// A rule naming no path scores nothing on the path axis even when a
|
||||
|
||||
@@ -18,7 +18,9 @@ export { Approvals, askRule } from './approvals.ts'
|
||||
export { CallbackApprover, RecordApprover, requestId, type Approver } from './approver.ts'
|
||||
export { MountRootPolicy } from './builtin/mount_root.ts'
|
||||
export { PermissionsPolicy } from './builtin/permissions.ts'
|
||||
export { Outcome, anchorDepth, decide, ruleDepth, sourceOf, type Decision } from './match/decide.ts'
|
||||
export { Outcome, decide, sourceOf, type Decision } from './match/decide.ts'
|
||||
export { anchorDepth } from '../utils/hidden.ts'
|
||||
export { coversDepth, hiddenDepth } from './match/rule.ts'
|
||||
export { OutputCapPolicy, resolveProducer, resolveLimit } from './builtin/output_cap.ts'
|
||||
export { DEFAULT_ASK_REASON, DEFAULT_DENY_REASON, POLICY_DENIED_EXIT } from './constants.ts'
|
||||
export {
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import type { CommandContext, CommandRule, AdmissionRules } from '../types.ts'
|
||||
import { isGlob } from '../../utils/hidden.ts'
|
||||
import { lineAllowed } from './allow.ts'
|
||||
import { matchRule, ruleScope } from './rule.ts'
|
||||
import { ASK_SECOND, betterMatch, DENY_FIRST, matchRule, ruleScope } from './rule.ts'
|
||||
|
||||
/**
|
||||
* What the role's rules say about one line. RUN is silence: no rule
|
||||
@@ -48,62 +47,13 @@ export interface Decision {
|
||||
readonly source: string
|
||||
}
|
||||
|
||||
/**
|
||||
* How specific a path entry is: the number of literal components before
|
||||
* its first wildcard.
|
||||
*
|
||||
* The one measure the path axis orders by. `/repo/sealed/*` is 2,
|
||||
* `/repo/*` and the plain subtree `/repo` are 1, and a slashless name
|
||||
* pattern like `*.key` is 0, since it anchors nothing. Every pattern the
|
||||
* document allows has an answer, so two rules about one path are always
|
||||
* comparable and nothing is ever guessed.
|
||||
*/
|
||||
export function anchorDepth(entry: string): number {
|
||||
let depth = 0
|
||||
for (const part of entry.replace(/^\/+|\/+$/g, '').split('/')) {
|
||||
if (part === '' || isGlob(part)) break
|
||||
depth += 1
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule's place on the path axis: the depth of its deepest path entry,
|
||||
* or 0 when it names none.
|
||||
*
|
||||
* A rule naming no path is not on this axis at all, **wherever it is
|
||||
* written**, so one in a mount section scores 0 exactly as a top-level
|
||||
* one does and the two are separated by verb alone. Writing it under
|
||||
* `mounts./repo` scopes it to lines working inside that mount
|
||||
* (`matchRule` reads `rule.mount`); it does not make it more specific
|
||||
* than a rule about the whole session. That is what keeps "denied
|
||||
* generally, asked inside one mount" inexpressible for a pathless rule,
|
||||
* which in practice means an account CLI: such a CLI reaches a service
|
||||
* and touches no mount, so scoping it to one was never meaningful.
|
||||
*/
|
||||
export function ruleDepth(rule: CommandRule): number {
|
||||
const paths = rule.paths ?? []
|
||||
return paths.length === 0 ? 0 : Math.max(...paths.map(anchorDepth))
|
||||
}
|
||||
|
||||
// Which verb wins when two rules match at the same anchor depth. Deny
|
||||
// before ask, and the allow list is not a rule so it never ties.
|
||||
const VERB_ORDER: Readonly<Record<string, number>> = { [Outcome.DENY]: 0, [Outcome.ASK]: 1 }
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
function better(
|
||||
current: readonly [number, number] | null,
|
||||
depth: number,
|
||||
outcome: Outcome,
|
||||
): boolean {
|
||||
if (current === null) return true
|
||||
const [bestDepth, bestVerb] = current
|
||||
if (depth !== bestDepth) return depth > bestDepth
|
||||
return (VERB_ORDER[outcome] ?? 0) < bestVerb
|
||||
// before ask, and the allow list is not a rule so it never ties. The
|
||||
// ordering itself lives in `match/rule` because the entry gate reads by
|
||||
// it too.
|
||||
const VERB_ORDER: Readonly<Record<string, number>> = {
|
||||
[Outcome.DENY]: DENY_FIRST,
|
||||
[Outcome.ASK]: ASK_SECOND,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,9 +69,20 @@ export function sourceOf(rule: CommandRule): string {
|
||||
*
|
||||
* Two rules, because a command name and a path are not the same kind of
|
||||
* thing. A rule naming no path is read by verb, deny before ask,
|
||||
* wherever it was written. A rule carrying paths is read by anchor
|
||||
* depth, the deeper entry winning, ties broken by verb. The allow list
|
||||
* is asked first, since a line no list covers never reaches a rule.
|
||||
* wherever it was written: it is off the path axis entirely, so one in a
|
||||
* mount section scores 0 exactly as a top-level one does. Writing it
|
||||
* under `mounts./repo` scopes it to lines working inside that mount
|
||||
* (`matchRule` reads `rule.mount`); it does not make it more specific
|
||||
* than a rule about the whole session. That is what keeps "denied
|
||||
* generally, asked inside one mount" inexpressible for a pathless rule,
|
||||
* which in practice means an account CLI: such a CLI reaches a service
|
||||
* and touches no mount, so scoping it to one was never meaningful.
|
||||
*
|
||||
* A rule carrying paths is read by anchor depth, the deeper entry
|
||||
* winning, ties broken by verb. The depth is the matched entry's, not
|
||||
* the rule's deepest, so an entry that says nothing about this operand
|
||||
* cannot lend it specificity. The allow list is asked first, since a
|
||||
* line no list covers never reaches a rule.
|
||||
*
|
||||
* `PermissionsPolicy` renders this into the outcome table and `explain`
|
||||
* reports it, so the two cannot disagree about what a line would do.
|
||||
@@ -145,9 +106,9 @@ export function decide(ctx: CommandContext, rules: AdmissionRules | null): Decis
|
||||
for (const rule of written) {
|
||||
const hit = matchRule(rule, ruleScope(rule), ctx)
|
||||
if (hit === null) continue
|
||||
const depth = ruleDepth(rule)
|
||||
if (!better(best, depth, outcome)) continue
|
||||
best = [depth, VERB_ORDER[outcome] ?? 0]
|
||||
const verb = VERB_ORDER[outcome] ?? 0
|
||||
if (!betterMatch(best, hit.depth, verb)) continue
|
||||
best = [hit.depth, verb]
|
||||
chosen = {
|
||||
outcome,
|
||||
rule,
|
||||
|
||||
@@ -43,13 +43,16 @@ describe('rules', () => {
|
||||
const whole: CommandRule = { reason: 'no', commands: ['git push'] }
|
||||
expect(matchRule(whole, null, ctx('git', { tokens: ['git', 'push', 'origin'] }))).toEqual({
|
||||
operand: null,
|
||||
depth: 0,
|
||||
})
|
||||
expect(matchRule(whole, null, ctx('git', { tokens: ['git', 'pull'] }))).toBeNull()
|
||||
const scoped: CommandRule = { reason: 'no', commands: ['rm'], paths: ['/repo/*'] }
|
||||
const scope = classifyPaths(scoped.paths ?? [])
|
||||
// The depth is the matched entry's, which is what the path axis
|
||||
// orders by.
|
||||
expect(
|
||||
matchRule(scoped, scope, ctx('rm', { paths: [path('/repo/x', 'x')], cwd: '/repo' })),
|
||||
).toEqual({ operand: 'x' })
|
||||
).toEqual({ operand: 'x', depth: 1 })
|
||||
expect(matchRule(scoped, scope, ctx('rm', { paths: [path('/scratch/x')] }))).toBeNull()
|
||||
// A mount-tier rule applies to a line whose cwd or paths lie under
|
||||
// the mount, and to nothing else.
|
||||
@@ -82,7 +85,7 @@ describe('rules', () => {
|
||||
// above the root, stays untouched.
|
||||
const mount: CommandRule = { reason: 'boxed', mount: '/scratch/child' }
|
||||
expect(matchRule(mount, null, ctx('grep', { paths: [path('/scratch')], walks: true }))).toEqual(
|
||||
{ operand: null },
|
||||
{ operand: null, depth: 0 },
|
||||
)
|
||||
expect(matchRule(mount, null, ctx('grep', { paths: [path('/scratch')] }))).toBeNull()
|
||||
expect(
|
||||
@@ -126,18 +129,23 @@ describe('rules', () => {
|
||||
const scope = classifyPaths(rule.paths ?? [])
|
||||
for (const command of ['rm', 'rmdir']) {
|
||||
for (const operand of ['/x/locked', '/x', '/']) {
|
||||
expect(matchRule(rule, scope, subtreeCtx(command, operand))).toEqual({ operand })
|
||||
expect(matchRule(rule, scope, subtreeCtx(command, operand))).toEqual({ operand, depth: 2 })
|
||||
}
|
||||
expect(matchRule(rule, scope, subtreeCtx(command, '/x/other'))).toBeNull()
|
||||
}
|
||||
expect(matchRule(rule, scope, subtreeCtx('mv', '/x/locked', '/y'))).toEqual({
|
||||
operand: '/x/locked',
|
||||
depth: 2,
|
||||
})
|
||||
expect(matchRule(rule, scope, subtreeCtx('mv', '/x', '/y'))).toEqual({
|
||||
operand: '/x',
|
||||
depth: 2,
|
||||
})
|
||||
expect(matchRule(rule, scope, subtreeCtx('mv', '/x', '/y'))).toEqual({ operand: '/x' })
|
||||
// mv's destination matches only as the holding directory itself:
|
||||
// moving into it lands in the scope, moving into an ancestor does not.
|
||||
expect(matchRule(rule, scope, subtreeCtx('mv', '/z', '/x/locked'))).toEqual({
|
||||
operand: '/x/locked',
|
||||
depth: 2,
|
||||
})
|
||||
expect(matchRule(rule, scope, subtreeCtx('mv', '/z', '/x'))).toBeNull()
|
||||
// A reader given the same operand is not a whole-line refusal: its
|
||||
@@ -147,7 +155,10 @@ describe('rules', () => {
|
||||
// A command-scoped rule judges its own command the same way.
|
||||
const named: CommandRule = { reason: 'locked', commands: ['rm'], paths: ['/x/locked/*'] }
|
||||
const namedScope = classifyPaths(named.paths ?? [])
|
||||
expect(matchRule(named, namedScope, subtreeCtx('rm', '/x'))).toEqual({ operand: '/x' })
|
||||
expect(matchRule(named, namedScope, subtreeCtx('rm', '/x'))).toEqual({
|
||||
operand: '/x',
|
||||
depth: 2,
|
||||
})
|
||||
expect(matchRule(named, namedScope, subtreeCtx('mv', '/x', '/y'))).toBeNull()
|
||||
})
|
||||
|
||||
@@ -265,4 +276,21 @@ describe('rules', () => {
|
||||
expect(ioRefusal(whole, tokens, '/data/x', [])).toBeNull()
|
||||
expect(ioRefusal(null, tokens, '/data/x', [])).toBeNull()
|
||||
})
|
||||
|
||||
it('ioRefusal orders by anchor depth like the admission gate', () => {
|
||||
// A broad deny with an approved ask carved out of it. The gate
|
||||
// admits `rm -r /repo` under the deeper ask, so the entry gate has
|
||||
// to read the same way: taking every deny before any ask would
|
||||
// refuse every entry the carve-out was written for, leaving a line
|
||||
// that was admitted unable to touch anything.
|
||||
const deny: CommandRule = { reason: 'ro repo', commands: ['rm'], paths: ['/repo/*'] }
|
||||
const ask: CommandRule = { reason: 'sealed', commands: ['rm'], paths: ['/repo/sealed/*'] }
|
||||
const rules: AdmissionRules = { allow: null, ask: [ask], deny: [deny] }
|
||||
const tokens = ['rm', '-r', '/repo']
|
||||
expect(ioRefusal(rules, tokens, '/repo/sealed/secret', [ask])).toBeNull()
|
||||
// Without the grant the deeper ask still wins, and asks.
|
||||
expect(ioRefusal(rules, tokens, '/repo/sealed/secret', [])).toBe('sealed')
|
||||
// Outside the carve-out the broad deny is what is left.
|
||||
expect(ioRefusal(rules, tokens, '/repo/other/x', [ask])).toBe('ro repo')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,12 +13,37 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import type { HiddenPaths } from '../../types.ts'
|
||||
import { classifyPaths, pathCovers, pathHidden } from '../../utils/hidden.ts'
|
||||
import { anchorDepth, classifyPaths, pathCovers, pathHidden } from '../../utils/hidden.ts'
|
||||
import { METADATA_OPS, SUBTREE_COMMANDS, SUBTREE_OPS } from '../constants.ts'
|
||||
import type { CommandContext, CommandRule, AdmissionRules, OpsContext } from '../types.ts'
|
||||
import { lineTokens } from './allow.ts'
|
||||
import { patternMatches } from './pattern.ts'
|
||||
|
||||
// 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.
|
||||
export const DENY_FIRST = 0
|
||||
export const ASK_SECOND = 1
|
||||
|
||||
/**
|
||||
* 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 `ioRefusal` so a line and the entries it
|
||||
* reaches mid-walk are read by one law.
|
||||
*/
|
||||
export function betterMatch(
|
||||
current: readonly [number, number] | null,
|
||||
depth: number,
|
||||
verb: number,
|
||||
): boolean {
|
||||
if (current === null) return true
|
||||
const [bestDepth, bestVerb] = current
|
||||
if (depth !== bestDepth) return depth > bestDepth
|
||||
return verb < bestVerb
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule that applies to a line, and how far it reaches. `matchRule`
|
||||
* returns null when the rule does not apply, `{operand: null}` when the
|
||||
@@ -29,6 +54,15 @@ import { patternMatches } from './pattern.ts'
|
||||
*/
|
||||
export interface RuleMatch {
|
||||
operand: string | null
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
depth: number
|
||||
}
|
||||
|
||||
function under(path: string, root: string): boolean {
|
||||
@@ -67,11 +101,53 @@ export function matchRule(
|
||||
if (!commands.some((p) => patternMatches(p, tokens))) return null
|
||||
}
|
||||
if (rule.mount !== undefined && rule.mount !== '' && !touches(rule.mount, ctx)) return null
|
||||
if (scope === null) return { operand: null }
|
||||
if (scope === null) return { operand: null, depth: 0 }
|
||||
for (const p of ctx.paths) {
|
||||
if (pathHidden(scope, p.virtual)) return { operand: p.rawPath || p.virtual }
|
||||
if (pathHidden(scope, p.virtual)) {
|
||||
return { operand: p.rawPath || p.virtual, depth: hiddenDepth(rule, p.virtual) }
|
||||
}
|
||||
}
|
||||
return subtreeMatch(scope, ctx)
|
||||
return subtreeMatch(rule, scope, ctx)
|
||||
}
|
||||
|
||||
const entryScopes = new Map<string, HiddenPaths | null>()
|
||||
|
||||
/**
|
||||
* One document entry, classified alone so it can be scored on its own;
|
||||
* remembered, since a rule is re-read on every line.
|
||||
*/
|
||||
function entryScope(entry: string): HiddenPaths | null {
|
||||
const known = entryScopes.get(entry)
|
||||
if (known !== undefined) return known
|
||||
const scope = classifyPaths([entry])
|
||||
entryScopes.set(entry, scope)
|
||||
return scope
|
||||
}
|
||||
|
||||
/**
|
||||
* The anchor depth of the deepest entry of a rule that holds this path,
|
||||
* 0 when none does.
|
||||
*/
|
||||
export function hiddenDepth(rule: CommandRule, virtual: string): number {
|
||||
let best = 0
|
||||
for (const entry of rule.paths ?? []) {
|
||||
if (pathHidden(entryScope(entry), virtual)) best = Math.max(best, anchorDepth(entry))
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `hiddenDepth`, for an operand that would take the scope along rather
|
||||
* than lie inside it.
|
||||
*/
|
||||
export function coversDepth(rule: CommandRule, virtual: string, ancestors = true): number {
|
||||
let best = 0
|
||||
for (const entry of rule.paths ?? []) {
|
||||
if (pathCovers(entryScope(entry), virtual, ancestors)) best = Math.max(best, anchorDepth(entry))
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,15 +159,21 @@ export function matchRule(
|
||||
* (moving into `/x/locked` lands in the scope; moving into `/x` does
|
||||
* not).
|
||||
*/
|
||||
function subtreeMatch(scope: HiddenPaths, ctx: CommandContext): RuleMatch | null {
|
||||
function subtreeMatch(
|
||||
rule: CommandRule,
|
||||
scope: HiddenPaths,
|
||||
ctx: CommandContext,
|
||||
): RuleMatch | null {
|
||||
if (!SUBTREE_COMMANDS.has(ctx.command)) return null
|
||||
const operands = [...(ctx.operands ?? [])]
|
||||
const dst = ctx.command === 'mv' && operands.length > 1 ? operands.pop() : undefined
|
||||
for (const p of operands) {
|
||||
if (pathCovers(scope, p.virtual)) return { operand: p.rawPath || p.virtual }
|
||||
if (pathCovers(scope, p.virtual)) {
|
||||
return { operand: p.rawPath || p.virtual, depth: coversDepth(rule, p.virtual) }
|
||||
}
|
||||
}
|
||||
if (dst !== undefined && pathCovers(scope, dst.virtual, false)) {
|
||||
return { operand: dst.rawPath || dst.virtual }
|
||||
return { operand: dst.rawPath || dst.virtual, depth: coversDepth(rule, dst.virtual, false) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -156,10 +238,16 @@ export function matchIo(
|
||||
* The reason a command may not touch an entry it reached on its own,
|
||||
* null when it may.
|
||||
*
|
||||
* The same precedence the admission gate applies to a line: every deny
|
||||
* rule first, the first that reaches the entry refusing it; then the
|
||||
* ask rules, where the first that reaches it refuses unless the line
|
||||
* holds a grant under that rule (the nod the gate took for `rm -r /x`
|
||||
* The same law the admission gate applies to a line, and literally the
|
||||
* same comparison (`betterMatch`): 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).
|
||||
@@ -171,13 +259,21 @@ export function ioRefusal(
|
||||
granted: readonly CommandRule[],
|
||||
): string | null {
|
||||
if (rules === null) return null
|
||||
for (const rule of rules.deny) {
|
||||
if (matchIo(rule, ruleScope(rule), tokens, virtual)) return rule.reason
|
||||
}
|
||||
for (const rule of rules.ask) {
|
||||
if (matchIo(rule, ruleScope(rule), tokens, virtual)) {
|
||||
return granted.includes(rule) ? null : rule.reason
|
||||
let best: [number, number] | null = null
|
||||
let chosen: { rule: CommandRule; verb: number } | null = null
|
||||
for (const [verb, written] of [
|
||||
[DENY_FIRST, rules.deny],
|
||||
[ASK_SECOND, rules.ask],
|
||||
] as const) {
|
||||
for (const rule of written) {
|
||||
if (!matchIo(rule, ruleScope(rule), tokens, virtual)) continue
|
||||
const depth = hiddenDepth(rule, virtual)
|
||||
if (!betterMatch(best, depth, verb)) continue
|
||||
best = [depth, verb]
|
||||
chosen = { rule, verb }
|
||||
}
|
||||
}
|
||||
return null
|
||||
if (chosen === null) return null
|
||||
if (chosen.verb === ASK_SECOND && granted.includes(chosen.rule)) return null
|
||||
return chosen.rule.reason
|
||||
}
|
||||
|
||||
@@ -29,6 +29,31 @@ export function isGlob(entry: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* How specific a path entry is: the number of literal components before
|
||||
* its first wildcard.
|
||||
*
|
||||
* The one measure the permissions document's path axis orders by.
|
||||
* `/repo/sealed/*` is 2, `/repo/*` and the plain subtree `/repo` are 1,
|
||||
* and a slashless name pattern like `*.key` is 0, since it anchors
|
||||
* nothing. Every pattern the document allows has an answer, so two
|
||||
* entries about one path are always comparable and nothing is ever
|
||||
* guessed.
|
||||
*
|
||||
* It lives here beside `isGlob` rather than in the policy layer because
|
||||
* both gates need it: admission scores the rule that covers an operand,
|
||||
* and the entry gate scores the rule that covers an entry reached
|
||||
* mid-walk.
|
||||
*/
|
||||
export function anchorDepth(entry: string): number {
|
||||
let depth = 0
|
||||
for (const part of entry.replace(/^\/+|\/+$/g, '').split('/')) {
|
||||
if (part === '' || isGlob(part)) break
|
||||
depth += 1
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile document path entries into the matcher's shape: glob =
|
||||
* pattern, plain = exact subtree, in the order written. The same split
|
||||
|
||||
@@ -135,6 +135,12 @@ describe('withInline', () => {
|
||||
expect(() => withInline(base, parseSessionProfile({ commands: { allow: ['wc'] } }))).toThrow(
|
||||
'not an allow list',
|
||||
)
|
||||
// And with no role to add to: the refusal belongs to where the
|
||||
// document was written, so a workspace that happens to declare no
|
||||
// default role must not quietly accept what one with a role refuses.
|
||||
expect(() => withInline(null, parseSessionProfile({ commands: { allow: ['wc'] } }))).toThrow(
|
||||
'not an allow list',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a stated block alone when the other is bare', () => {
|
||||
|
||||
@@ -77,14 +77,26 @@ function rulesOf(
|
||||
* role does not have, which is the one thing a per-call document must
|
||||
* not do.
|
||||
*/
|
||||
/**
|
||||
* Refuse an allow list in an inline document.
|
||||
*
|
||||
* The refusal belongs to *where the document was written*, not to
|
||||
* whether a role happened to resolve, so both paths into `withInline`
|
||||
* run it: a workspace with no default role must not quietly accept a
|
||||
* list a workspace with one refuses.
|
||||
*/
|
||||
export function refuseAllow(inline: CommandsBlock | null | undefined): void {
|
||||
if (inline?.allow !== null && inline?.allow !== undefined) {
|
||||
throw new PolicyError('inline permissions may add ask and deny rules, not an allow list')
|
||||
}
|
||||
}
|
||||
|
||||
function addCommands(
|
||||
base: CommandsBlock | null | undefined,
|
||||
inline: CommandsBlock | null | undefined,
|
||||
): CommandsBlock | null {
|
||||
if (inline === null || inline === undefined) return base ?? null
|
||||
if (inline.allow !== null && inline.allow !== undefined) {
|
||||
throw new PolicyError('inline permissions may add ask and deny rules, not an allow list')
|
||||
}
|
||||
refuseAllow(inline)
|
||||
if (base === null || base === undefined) return inline
|
||||
return {
|
||||
allow: base.allow ?? null,
|
||||
@@ -118,17 +130,19 @@ function addMount(base: ProfileMount | undefined, inline: ProfileMount | undefin
|
||||
* A role with the inline document of one `createSession` added.
|
||||
*
|
||||
* The one rule about combining two documents: an inline document may
|
||||
* add ask and deny rules and hides, never an allow list. Modes take the
|
||||
* weaker of the two, `cwd` and `env` are the inline document's when it
|
||||
* states them (they are session presets, not permissions). Either side
|
||||
* null returns the other unchanged.
|
||||
* add ask and deny rules and hides, never an allow list, and that holds
|
||||
* even when there is no role to add to. Modes take the weaker of the
|
||||
* two, `cwd` and `env` are the inline document's when it states them
|
||||
* (they are session presets, not permissions). Either side null returns
|
||||
* the other unchanged.
|
||||
*/
|
||||
export function withInline(
|
||||
base: SessionProfile | null,
|
||||
inline: SessionProfile | null,
|
||||
): SessionProfile | null {
|
||||
if (base === null) return inline
|
||||
if (inline === null) return base
|
||||
refuseAllow(inline.commands)
|
||||
if (base === null) return inline
|
||||
const hidePaths = unionHide(base.paths, inline.paths)
|
||||
const hideVars = unionHide(base.vars, inline.vars)
|
||||
const out: {
|
||||
|
||||
@@ -54,7 +54,6 @@ describe('classifyErrno', () => {
|
||||
['is a directory', EISDIR],
|
||||
['permission denied', EACCES],
|
||||
['read-only mount', EACCES],
|
||||
['not allowed to access mount /x', EACCES],
|
||||
['file exists', EEXIST],
|
||||
['no such file or directory', ENOENT],
|
||||
['no mount at /x', ENOENT],
|
||||
|
||||
@@ -61,9 +61,6 @@ const MESSAGE_ERRNO: [string[], number][] = [
|
||||
[['not empty', 'enotempty'], ENOTEMPTY],
|
||||
[['not a directory', 'enotdir'], ENOTDIR],
|
||||
[['is a directory', 'eisdir'], EISDIR],
|
||||
// A session capability rejection (MountNotAllowedError) is a permission
|
||||
// failure, mirroring Python's PermissionError -> EACCES.
|
||||
[['not allowed to access mount'], EACCES],
|
||||
[['permission', 'eacces', 'read-only'], EACCES],
|
||||
[['file exists', 'eexist'], EEXIST],
|
||||
[['not found', 'no such', 'enoent', 'no mount'], ENOENT],
|
||||
|
||||
Reference in New Issue
Block a user