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:
Zecheng Zhang
2026-08-20 17:06:12 -07:00
parent 2b5c2b42f3
commit 325e8d33a2
24 changed files with 665 additions and 218 deletions
+15 -3
View File
@@ -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"
+55
View File
@@ -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,
+49
View File
@@ -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",
+3 -3
View File
@@ -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),
+7 -5
View File
@@ -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",
+23 -72
View File
@@ -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,
+121 -20
View File
@@ -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,
+7 -1
View File
@@ -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)
+27
View File
@@ -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.
+28 -9
View File
@@ -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.
+31 -6
View File
@@ -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
+3 -1
View File
@@ -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')
})
})
+114 -18
View File
@@ -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],