Compare commits

...

3 Commits

Author SHA1 Message Date
Zecheng Zhang cddef939a7 fix(policy): recompile a scripted default profile, load config scripts after rebasing 2026-08-21 23:55:08 -07:00
Zecheng Zhang 72ae0b2945 refactor(policy): move the profile model and script evaluation into the policy layer 2026-08-21 22:52:24 -07:00
Zecheng Zhang 8b9bd5ae9b feat(policy): roles written by a script
A profile may state `script:` instead of its document. The script runs
once per role at ensure_sessions_loaded, on a sandboxed engine built for
the role, and what it returns is validated as an ordinary permission
document, so explain and the resolver need no second case.

The engine comes from the script, never from the workspace's runtime
world: that world serves agent code, is mutable after construction, and
drops entries silently when an optional dependency is missing, so a role
resolved out of it would stop working for reasons unrelated to the role.
monty on both hosts, `runtime:` to name another.

Failure refuses the whole set before any result is kept, so one broken
role cannot leave the roles ahead of it written and the ones behind it
still scripts.
2026-08-21 20:50:25 -07:00
97 changed files with 2187 additions and 441 deletions
+31 -4
View File
@@ -108,6 +108,7 @@ from mirage.resource.supabase import SupabaseConfig, SupabaseResource
from mirage.resource.tencent import TencentConfig, TencentResource
from mirage.resource.trello import TrelloConfig, TrelloResource
from mirage.resource.wasabi import WasabiConfig, WasabiResource
from mirage.runtime.types import ScriptSource
from mirage.shell.console import JobConsole
from mirage.shell.console.redis import RedisConsoleStore
from mirage.shell.job_table import ConsoleFactory
@@ -2228,7 +2229,7 @@ async def build_mounts(
built[mount["path"]] = resource
mode = MountMode.READ if mount.get("mode") == "read" else None
# A mount states infrastructure only: what it is, where it is,
# how it is served. Its permissions live in the role, under
# how it is served. Its permissions live in the profile, under
# `profiles.<name>.mounts.<prefix>`.
if mode is not None:
mounts[mount["path"]] = (resource, mode)
@@ -2326,11 +2327,11 @@ async def open_target(
mounts, cleanups = await build_mounts(target, run_id, service)
agent_id = target.get("agentId")
factory = console_factory(target, run_id)
# The target's roles, and which one shapes a session that names
# none. A role is the whole permission document, so this is every
# The target's profiles, and which one shapes a session that names
# none. A profile is the whole permission document, so this is every
# permission the target states; the models are the ones the YAML
# door validates with.
profiles = target.get("profiles") or None
profiles = scripted_profiles(target.get("profiles") or None)
default_profile = target.get("profile")
if consistency is not None:
ws = Workspace(mounts,
@@ -2389,3 +2390,29 @@ async def open_consistency(
functools.partial(teardown_target, [read_ws, shadow_ws],
[*read_cleanups, *shadow_cleanups], service),
)
def scripted_profiles(profiles: dict | None) -> dict | None:
"""Wrap a profile's inline script source the way the config door does.
A target is JSON, so it carries a profile's script as source rather
than as the path a YAML config would name. Loading is the config
layer's job everywhere else, so the battery does that one step here
and hands the workspace what code would pass.
Args:
profiles (dict | None): the target's profiles as written.
"""
if not profiles:
return profiles
out: dict = {}
for name, doc in profiles.items():
script = doc.get("script") if isinstance(doc, dict) else None
if isinstance(script, dict):
doc = {
**doc, "script":
ScriptSource(script["source"],
language=script.get("language", "python"))
}
out[name] = doc
return out
+8 -4
View File
@@ -81,12 +81,16 @@ async def run_target(target: dict, cases: list[dict], root: Path,
await harness.seed_mount_root(ws, mount["path"])
# Sessions a case can name via its "session" field, through the
# two doors a host really has. A string names one of the
# target's roles (`profile=`), which is the whole document that
# target's profiles (`profile=`), which is the whole document that
# session runs under. A mapping is an inline document added to
# the default role (`permissions=`): it may add ask and deny
# the default profile (`permissions=`): it may add ask and deny
# rules and hides, never an allow list, so a session that needs
# its own allow list has to be a role. An empty mapping is the
# default role with nothing added.
# its own allow list has to be a profile. An empty mapping is the
# default profile with nothing added.
# A profile written by a script is ready only after hydration,
# which every embedding program already awaits before it creates
# a session; the battery is a program like any other.
await ws.ensure_sessions_loaded()
for session_id, spec in (target.get("sessions") or {}).items():
if isinstance(spec, str):
ws.create_session(session_id, profile=spec)
+21 -4
View File
@@ -94,7 +94,8 @@ import {
import {
parseSessionProfile,
type SessionProfile,
} from '@struktoai/mirage-core/workspace/session/permissions'
} from '@struktoai/mirage-core/policy/profile'
import { ScriptSource } from '@struktoai/mirage-core/runtime/policy/types'
import * as lancedb from '@lancedb/lancedb'
import { QdrantClient } from '@qdrant/js-client-rest'
import { ChromaClient } from 'chromadb'
@@ -208,21 +209,37 @@ function consoleFactoryFor(target: Target): ConsoleFactory | undefined {
)
}
// The target's roles, and which one shapes a session that names none.
// A role is the whole permission document, so this is every permission
// The target's profiles, and which one shapes a session that names none.
// A profile is the whole permission document, so this is every permission
// the target states, including the per-mount ones; the parser is the
// one the YAML door uses, so a case runs under exactly what a
// deployment would write. Only the openers that consult it may declare
// one (main.ts refuses it on any other resource), the same way the
// console block rides ram alone: an unwired opener would run the target
// unbound and it would read as covered.
/**
* Wrap a profile's inline script source the way the config door does.
*
* A target is JSON, so it carries a profile's script as source rather than
* as the path a YAML config would name. Loading is the config layer's
* job everywhere else, so the battery does that one step here and hands
* the workspace what code would pass.
*/
function scriptedProfile(doc: unknown): unknown {
if (typeof doc !== 'object' || doc === null) return doc
const script = (doc as { script?: unknown }).script
if (typeof script !== 'object' || script === null) return doc
const { source, language } = script as { source: string; language?: 'python' | 'js' }
return { ...(doc as object), script: new ScriptSource(source, language ?? 'python') }
}
function permissionOptions(target: Target): {
profiles?: Record<string, SessionProfile>
profile?: string
} {
const profiles: Record<string, SessionProfile> = {}
for (const [name, doc] of Object.entries(target.profiles ?? {})) {
profiles[name] = parseSessionProfile(doc, `profile \`${name}\``)
profiles[name] = parseSessionProfile(scriptedProfile(doc), `profile \`${name}\``)
}
return {
...(Object.keys(profiles).length > 0 ? { profiles } : {}),
+7 -7
View File
@@ -18,7 +18,7 @@ import { tmpdir } from 'node:os'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Outcome, Scope } from '@struktoai/mirage-core/policy/index'
import type { SessionProfile } from '@struktoai/mirage-core/workspace/session/permissions'
import type { SessionProfile } from '@struktoai/mirage-core/policy/profile'
// integ/runtime holds the runtime suite (its own schema and runners,
// integ/runtime/run.{py,ts} + cli.sh), not battery cases; keep it out.
@@ -75,20 +75,20 @@ export interface Target {
// Scope an installed account CLI to this mount's folder, so the CLI and
// the mount are pointed at the same place.
cli_scope?: string
// The target's roles (`profiles:` in YAML). A role is the whole
// The target's profiles (`profiles:` in YAML). A profile is the whole
// permission document a session runs under, per-mount rules included;
// validated by the parser the YAML door uses.
profiles?: Record<string, unknown>
// Which role shapes a session that names none, its own included.
// Which profile shapes a session that names none, its own included.
profile?: string
mounts: Mount[]
// Sessions a case can name via its `session` field, through the two
// doors a host really has. A string names one of the target's roles,
// doors a host really has. A string names one of the target's profiles,
// which is the whole document that session runs under. A mapping is
// an inline document added to the default role: it may add ask and
// an inline document added to the default profile: it may add ask and
// deny rules and hides, never an allow list, so a session that needs
// its own allow list has to be a role. An empty mapping is the
// default role with nothing added.
// its own allow list has to be a profile. An empty mapping is the
// default profile with nothing added.
sessions?: Record<string, string | Record<string, unknown> | null>
// Session environment every case on this target runs under. The
// conformance runner passes the same map to the real binary, so a CLI
+13 -9
View File
@@ -14,7 +14,7 @@
import { writeFileSync } from 'node:fs'
import { ConsistencyPolicy } from '@struktoai/mirage-node'
import { parseSessionProfile } from '@struktoai/mirage-core/workspace/session/permissions'
import { parseSessionProfile } from '@struktoai/mirage-core/policy/profile'
import { ADAPTERS, openConsistency } from './adapters.ts'
import type { Case, Target } from './harness.ts'
import {
@@ -81,15 +81,15 @@ async function runTarget(
if (target.console !== undefined && target.mounts[0].resource !== 'ram') {
throw new Error(`target ${target.id}: console targets ride ram mounts`)
}
// Roles reach the workspace only through the openers that pass them
// Profiles reach the workspace only through the openers that pass them
// on, for the same reason: a target that declares one on an opener
// that drops it would run unbound and read as covered. Python needs no
// such list because it builds every target's workspace in one place.
const ROLE_OPENERS = ['ram', 'disk', 'email']
const declaresRoles = target.profiles !== undefined || target.profile !== undefined
if (declaresRoles && !ROLE_OPENERS.includes(target.mounts[0].resource)) {
const PROFILE_OPENERS = ['ram', 'disk', 'email']
const declaresProfiles = target.profiles !== undefined || target.profile !== undefined
if (declaresProfiles && !PROFILE_OPENERS.includes(target.mounts[0].resource)) {
throw new Error(
`target ${target.id}: roles ride ${ROLE_OPENERS.join(', ')} mounts`,
`target ${target.id}: profiles ride ${PROFILE_OPENERS.join(', ')} mounts`,
)
}
const { ws, cleanup } = await ADAPTERS[target.mounts[0].resource](target)
@@ -108,12 +108,16 @@ async function runTarget(
if (mount.seed_root) await seedMountRoot(ws, mount.path)
}
// Sessions a case can name via its `session` field, through the two
// doors a host really has. A string names one of the target's roles
// doors a host really has. A string names one of the target's profiles
// (`profile`), which is the whole document that session runs under.
// A mapping is an inline document added to the default role
// A mapping is an inline document added to the default profile
// (`permissions`): it may add ask and deny rules and hides, never an
// allow list, so a session that needs its own allow list has to be a
// role. An empty mapping is the default role with nothing added.
// profile. An empty mapping is the default profile with nothing added.
// A profile written by a script is ready only after hydration, which
// every embedding program already awaits before it creates a
// session; the battery is a program like any other.
await ws.ensureSessionsLoaded()
for (const [sessionId, spec] of Object.entries(target.sessions ?? {})) {
if (typeof spec === 'string') {
ws.createSession(sessionId, { profile: spec })
+1 -1
View File
@@ -44,7 +44,7 @@ import {
type RunResult,
type RuntimeEntry,
} from "@struktoai/mirage-node";
import { parseSessionProfile } from "@struktoai/mirage-core/workspace/session/permissions";
import { parseSessionProfile } from "@struktoai/mirage-core/policy/profile";
const HOST = "typescript";
const SUITE_DIR = dirname(fileURLToPath(import.meta.url));
+35
View File
@@ -0,0 +1,35 @@
{
"cases": [
{
"id": "script_role_allows_what_its_script_wrote",
"seq": 923080,
"targets": ["ram-commands"],
"session": "scripted",
"command": "echo written-by-a-script",
"expect": {"exit": 0, "stdout": "written-by-a-script\n", "stderr": ""}
},
{
"id": "script_role_hides_what_its_script_left_out",
"seq": 923081,
"targets": ["ram-commands"],
"session": "scripted",
"command": "rm /scratch/z",
"expect": {"exit": 127, "stdout": "", "stderr": "rm: command not found\n"}
},
{
"id": "script_role_denies_with_a_reason_it_computed",
"seq": 923082,
"targets": ["ram-commands"],
"session": "scripted",
"command": "cat /repo/sealed/k",
"expect": {"exit": 1, "stdout": "", "stderr": "cat: /repo/sealed/k: written by scripted\n"}
},
{
"id": "script_role_leaves_other_sessions_alone",
"seq": 923083,
"targets": ["ram-commands"],
"command": "echo unscripted",
"expect": {"exit": 0, "stdout": "unscripted\n", "stderr": ""}
}
]
}
+8 -1
View File
@@ -2477,7 +2477,8 @@
}
}
}
}
},
"scripted": "scripted"
},
"profile": "default",
"profiles": {
@@ -2629,6 +2630,12 @@
"xargs"
]
}
},
"scripted": {
"script": {
"language": "python",
"source": "allow = ['ls', 'cat', 'echo']\nrule = {'reason': 'written by ' + ctx['profile'],\n 'commands': {'cat': ['/repo/sealed/*']}}\n{'commands': {'allow': allow, 'deny': [rule]}}\n"
}
}
}
}
+3 -3
View File
@@ -61,14 +61,14 @@ def create_cmd(
"'/data' to keep the mount's own mode. Repeat per mount. "
"This narrows only; a mount you do not name keeps its own "
"mode, and keeping a session away from one is a hide in "
"its role."),
"its profile."),
),
profile: str | None = typer.Option(
None,
"--profile",
"-p",
help=("The role this session runs under, by name from the "
"workspace's profiles. A role is the whole permission "
help=("The profile this session runs under, by name from the "
"workspace's profiles. A profile is the whole permission "
"document; omit it to take the workspace default."),
),
) -> None:
+1 -1
View File
@@ -259,7 +259,7 @@ async def du_operand_exists(path: PathSpec, stattable: bool,
Where a dispatcher is wired it replaces the bound backend's stat,
because that stat sees one accessor and knows nothing of hides:
trusting it answered ``0 <path>`` for a hidden directory and
confirmed to the agent what the role was not meant to show it. The
confirmed to the agent what the profile was not meant to show it. The
content probe behind it counts only what the session may see, so it
cannot re-open what the first channel closed.
+18 -6
View File
@@ -27,6 +27,7 @@ from mirage.accessor.s3 import S3Config
from mirage.cache.file.config import CacheConfig, RedisCacheConfig
from mirage.cache.index.config import IndexConfig, RedisIndexConfig
from mirage.commands.cli.types import CLISpec
from mirage.policy.profile import SessionProfile
from mirage.resource.registry import build_resource
from mirage.runtime.base import Runtime
from mirage.runtime.table import build_runtime
@@ -36,7 +37,6 @@ from mirage.shell.job_table import ConsoleFactory
from mirage.types import (KERNEL_BACKENDS, ConsistencyPolicy, Limit,
MountBackend, MountMode, parse_mount_mode)
from mirage.workspace.mount.spec import Mount
from mirage.workspace.session.permissions import SessionProfile
from mirage.workspace.store import (DEFAULT_STATE_ROOT,
DiskWorkspaceStateStore,
RAMWorkspaceStateStore,
@@ -395,6 +395,11 @@ def _absolutize_scripts(raw: dict[str, Any], base: Path) -> None:
if isinstance(block, dict):
_absolutize_script_key(block, base)
_absolutize_cli_ref(block, base)
profiles = raw.get("profiles")
if isinstance(profiles, dict):
for block in profiles.values():
if isinstance(block, dict):
_absolutize_script_key(block, base)
def _absolutize_script_key(entry: dict[str, Any], base: Path) -> None:
@@ -505,12 +510,12 @@ class WorkspaceConfig(BaseModel):
# load. Its last expression names the runtime for the line, or
# None to fall to entry scripts.
policy: str | None = None
# The permission documents: one role per name. A role is the whole
# The permission documents: one profile per name. A profile is the whole
# document a session runs under, so there is no workspace-wide
# block; the policy script is the line-level counterpart.
profiles: dict[str, SessionProfile] | None = None
# The role a session gets when it names none; `default` when unset
# and a role of that name exists.
# The profile a session gets when it names none; `default` when unset
# and a profile of that name exists.
profile: str | None = None
mode: MountMode = MountMode.WRITE
consistency: ConsistencyPolicy = ConsistencyPolicy.LAZY
@@ -537,7 +542,7 @@ class WorkspaceConfig(BaseModel):
@model_validator(mode="after")
def _v_profile(self) -> "WorkspaceConfig":
# The workspace's default role must be one it defines; the
# The workspace's default profile must be one it defines; the
# loader's contract is a ValueError for a bad document.
if self.profile is not None and self.profile not in (self.profiles
or {}):
@@ -591,7 +596,14 @@ class WorkspaceConfig(BaseModel):
if self.policy is not None:
kwargs["policy"] = _load_script_source(self.policy)
if self.profiles is not None:
kwargs["profiles"] = dict(self.profiles)
kwargs["profiles"] = {
name:
(profile if profile.script is None else profile.model_copy(
update={
"script": _load_script_source(str(profile.script))
}))
for name, profile in self.profiles.items()
}
if self.profile is not None:
kwargs["profile"] = self.profile
+6 -6
View File
@@ -97,11 +97,11 @@ def _session_mode(mount_prefix: str) -> "MountMode":
"""The current session's mode cap for this mount.
``MountMode.EXEC`` (no narrowing) when no session is bound, when the
role names no mount, or when it names none for this one: a role's
profile names no mount, or when it names none for this one: a profile's
mount sections narrow what the mount already offers and never
decide whether it exists. A role that must not reach a mount hides
decide whether it exists. A profile that must not reach a mount hides
it, which answers ENOENT rather than a permission error naming
something the role cannot see.
something the profile cannot see.
Args:
mount_prefix (str): the mount's prefix, e.g. ``/s3``.
@@ -310,9 +310,9 @@ def effective_mount_mode(mount_prefix: str,
mount_mode: MountMode) -> MountMode:
"""The mount mode after narrowing by the current session's cap.
The mount's own mode is the strongest one available; a role's mode
can only weaken it (a READ mount stays read-only whatever the role
says). A mount the role does not name keeps its own mode.
The mount's own mode is the strongest one available; a profile's mode
can only weaken it (a READ mount stays read-only whatever the profile
says). A mount the profile does not name keeps its own mode.
Args:
mount_prefix (str): the mount's prefix, e.g. ``/s3``.
+10
View File
@@ -25,6 +25,9 @@ from mirage.policy.errors import PolicyDenied, PolicyError
from mirage.policy.policies import (Policies, post_execute_gate, post_ops_gate,
pre_ops_gate, pre_session_gate,
render_deny, render_pending)
from mirage.policy.profile import (CommandsBlock, CompiledProfile,
MountCommandsBlock, PathsBlock,
ProfileMount, SessionProfile, VarsBlock)
from mirage.policy.types import ( # isort: skip
VALIDITY, Action, Ask, CommandContext, CommandRule, AdmissionRules,
@@ -40,6 +43,8 @@ __all__ = [
"AskHandler",
"CommandContext",
"CommandRule",
"CommandsBlock",
"CompiledProfile",
"covers",
"Decision",
"decision_id",
@@ -53,11 +58,13 @@ __all__ = [
"Explanation",
"FALLBACK_LIMIT",
"Limit",
"MountCommandsBlock",
"MountRootPolicy",
"MountRootQuery",
"OpsContext",
"OpsResultContext",
"OutputCapPolicy",
"PathsBlock",
"Pending",
"PermissionsPolicy",
"Policies",
@@ -69,6 +76,7 @@ __all__ = [
"post_ops_gate",
"pre_ops_gate",
"pre_session_gate",
"ProfileMount",
"render_deny",
"render_pending",
"resolve_across_mounts",
@@ -78,5 +86,7 @@ __all__ = [
"SessionCommandsQuery",
"SessionContext",
"SessionDecisionsQuery",
"SessionProfile",
"VALIDITY",
"VarsBlock",
]
+2 -2
View File
@@ -21,7 +21,7 @@ from mirage.policy.types import (Action, Ask, CommandContext, Deny, DenyScope,
class PermissionsPolicy(Policy):
"""The role's ``commands`` rules, enforced.
"""The profile's ``commands`` rules, enforced.
Seeded by the workspace after ``MountRootPolicy`` (POSIX messages
still win) and before user policies, so a document rule speaks
@@ -39,7 +39,7 @@ class PermissionsPolicy(Policy):
refused whole or per operand by whether it names paths, or taken to
the approval door when it asks. ``pre_ops`` walks the deny rules
that are pure paths, so FUSE, programmatic ops and the warm cache
cannot bypass a path the role protects; there is no ask at the op
cannot bypass a path the profile protects; there is no ask at the op
door, which cannot wait on a host.
Args:
+4 -4
View File
@@ -21,7 +21,7 @@ from mirage.policy.types import AdmissionRules, CommandContext
def node_visible(path: Sequence[str], rules: AdmissionRules | None) -> bool:
"""Whether a session can see one node of a program tree.
A role without an allow list hides nothing; a role with one hides
A profile without an allow list hides nothing; a profile with one hides
every node no pattern of it reaches. Only a CLI's verbs can be
narrowed this way, and only because the walk canonicalizes them
(an alias resolved, the global options before the verb dropped)
@@ -40,7 +40,7 @@ def node_visible(path: Sequence[str], rules: AdmissionRules | None) -> bool:
def head_visible(name: str, rules: AdmissionRules | None) -> bool:
"""Whether a session can see a command at all.
A role without an allow list hides nothing; a role with one hides
A profile without an allow list hides nothing; a profile with one hides
every name none of its patterns start with. Grammar-tier builtins
and shell functions are the caller's exemptions, not this one's.
The head-word case of :func:`node_visible`.
@@ -63,9 +63,9 @@ def line_tokens(ctx: CommandContext) -> tuple[str, ...]:
def line_allowed(ctx: CommandContext, rules: AdmissionRules | None) -> bool:
"""Whether the role's allow list has a pattern for the line.
"""Whether the profile's allow list has a pattern for the line.
A role that states no list installs everything. A word that is not
A profile that states no list installs everything. A word that is not
a tool (``ctx.tool`` cleared by the door: shell grammar, the
agent's own function, an executed path) is always allowed here; a
deny rule is the only thing that can refuse it.
+1 -1
View File
@@ -67,7 +67,7 @@ def rule_at(live: LiveRules,
def decide(ctx: CommandContext, rules: AdmissionRules | None) -> Ruling:
"""The role's answer about one line: the whole law, in one place.
"""The profile's answer about one line: the whole law, in one place.
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,
+1 -1
View File
@@ -17,7 +17,7 @@ from mirage.policy.types import AdmissionRules, CommandRule
def has_rules(rules: AdmissionRules | None) -> bool:
"""Whether the role states any admission rule at all: an allow
"""Whether the profile states any admission rule at all: an allow
list, an ask or a deny.
Args:
@@ -20,6 +20,7 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from mirage.policy.constants import DEFAULT_ASK_REASON, DEFAULT_DENY_REASON
from mirage.policy.types import AdmissionRules, CommandRule
from mirage.runtime.types import ScriptSource
from mirage.types import HiddenPaths, HiddenVars, MountMode, parse_mount_mode
from mirage.utils.hidden import is_glob
@@ -238,7 +239,7 @@ def _patterns(v: Any) -> Any:
class PathsBlock(BaseModel):
"""``paths:`` of a role, or of one of its mount sections.
"""``paths:`` of a profile, or of one of its mount sections.
``hide`` entries use the document's one grammar: an entry with
``*``, ``?`` or ``[`` is a pattern, anything else an exact path
@@ -248,7 +249,7 @@ class PathsBlock(BaseModel):
``show`` arrives with its enforcement.
Args:
hide (tuple[str, ...]): what the role makes nonexistent.
hide (tuple[str, ...]): what the profile makes nonexistent.
"""
model_config = _DOC
@@ -275,9 +276,9 @@ class VarsBlock(BaseModel):
class CommandsBlock(BaseModel):
"""``commands:`` at the top level of a role.
"""``commands:`` at the top level of a profile.
``allow`` lists the command patterns the role installs; a name none
``allow`` lists the command patterns the profile installs; a name none
of them starts with is not a command for the session (127, absent
from ``type`` / ``which`` / ``man``), a line no pattern covers is
refused. The shell's own grammar builtins and the agent's functions
@@ -286,10 +287,10 @@ class CommandsBlock(BaseModel):
either is one command pattern with the default reason.
Args:
allow (tuple[str, ...] | None): the role's allow patterns;
allow (tuple[str, ...] | None): the profile's allow patterns;
None (unstated) installs everything.
ask (tuple[CommandRule, ...]): what needs sign-off, in order.
deny (tuple[CommandRule, ...]): the role's refusals, in order.
deny (tuple[CommandRule, ...]): the profile's refusals, in order.
"""
model_config = _DOC
@@ -315,7 +316,7 @@ class CommandsBlock(BaseModel):
@model_validator(mode="after")
def _v_absolute(self) -> "CommandsBlock":
# This block is the role's own, never a mount section's, so a
# This block is the profile's own, never a mount section's, so a
# rule's paths are virtual paths: absolute, or name patterns.
for verb, rules in (("ask", self.ask), ("deny", self.deny)):
for rule in rules:
@@ -355,14 +356,14 @@ class MountCommandsBlock(BaseModel):
class ProfileMount(BaseModel):
"""One mount's entry in a profile: what this role may do there.
"""One mount's entry in a profile: what this profile may do there.
Every field is optional, and an omitted mount is not a refusal: the
mount is reachable at the mode it declares in the workspace's
``mounts:``, which a role can only weaken (``weaker_mode``), never
raise. A role that must not touch a mount hides it, so the mount
``mounts:``, which a profile can only weaken (``weaker_mode``), never
raise. A profile that must not touch a mount hides it, so the mount
reads as nonexistent rather than as a permission error naming
something the role cannot see.
something the profile cannot see.
``commands`` here carries ask and deny only: an allow list installs
a command for the whole session, and visibility is answered before
@@ -372,7 +373,7 @@ class ProfileMount(BaseModel):
commit`` names no path).
Args:
mode (MountMode | None): this role's mode for the mount; None
mode (MountMode | None): this profile's mode for the mount; None
keeps the mount's own.
commands (MountCommandsBlock | None): ask and deny rules for
lines working inside it.
@@ -396,18 +397,18 @@ class ProfileMount(BaseModel):
class SessionProfile(BaseModel):
"""One role: the whole permission document a session runs under.
"""One profile: the whole permission document a session runs under.
A session is created from exactly one of these, and it is the only
place permissions are written. There is no workspace-wide block and
no mount-owned block above it, so reading this object is reading
everything the role may do; what a role does not say, it does not
everything the profile may do; what a profile does not say, it does not
restrict. Configuration, not enforcement: the resolver compiles it
onto the session's narrowing fields and the doors keep enforcing.
Deliberately not named a View, which per the view convention is a
door-scoped handle an agent holds, while a profile is what the
embedder uses to *define* one. Frozen so two agents with the same
role share one object and neither can bend the other's view.
profile share one object and neither can bend the other's view.
Two rules decide a line against it, and they are the whole law.
A rule naming no path is read by verb (deny before ask before
@@ -415,6 +416,12 @@ class SessionProfile(BaseModel):
hide, is read by anchor depth: the deeper entry wins, ties break by
verb.
A profile may instead be *written by a script*, which states ``script``
and nothing else. The script runs once per profile when the workspace
hydrates, and what it returns is validated as one of these, so every
reader below this point sees a plain document and neither ``explain``
nor the resolver has a second shape to handle.
Args:
cwd (str | None): the session's working directory at creation.
env (dict[str, str] | None): a process environment seeded and
@@ -423,10 +430,20 @@ class SessionProfile(BaseModel):
keyed by prefix: a mode, ask and deny rules for lines
working inside the mount, and hides under it. A mount the
mapping omits keeps its own mode and gains no rules.
paths (PathsBlock | None): the role's hides, absolute.
vars (VarsBlock | None): the role's hidden variables.
commands (CommandsBlock | None): the role's allow list and its
paths (PathsBlock | None): the profile's hides, absolute.
vars (VarsBlock | None): the profile's hidden variables.
commands (CommandsBlock | None): the profile's allow list and its
ask / deny rules, absolute paths.
script (ScriptSource | str | None): a program that writes this
profile. A ``str`` is the path form the config door accepts and
loads; code passes the loaded ``ScriptSource``, so a path
still spelled as a string when the workspace reads it means
the config layer never saw it, and is refused there.
runtime (str | None): the engine ``script`` runs on. Unset picks
the sandboxed engine for the script's language, which is the
only one either language has today. Meaningless without a
script, so stating one there is an error rather than a knob
that does nothing.
"""
model_config = _DOC
@@ -437,6 +454,8 @@ class SessionProfile(BaseModel):
paths: PathsBlock | None = None
vars: VarsBlock | None = None
commands: CommandsBlock | None = None
script: ScriptSource | str | None = None
runtime: str | None = None
@field_validator("mounts", mode="before")
@classmethod
@@ -458,6 +477,28 @@ class SessionProfile(BaseModel):
} if isinstance(entry, str) else entry)
return entries
@model_validator(mode="after")
def _v_script_alone(self) -> "SessionProfile":
# A scripted profile is written by its program, so an inline field
# beside it would be a second author for one document with no
# rule saying which wins. Refused at load rather than merged.
if self.script is None:
if self.runtime is not None:
raise ValueError(
"runtime names the engine a script runs on, and this "
"profile states no script")
return self
written = [
name
for name in ("cwd", "env", "mounts", "paths", "vars", "commands")
if getattr(self, name) is not None
]
if written:
raise ValueError(
f"a profile states either script or its document, not both; "
f"script is set beside {', '.join(written)}")
return self
@model_validator(mode="after")
def _v_absolute(self) -> "SessionProfile":
# `commands` checks its own rule paths (CommandsBlock), so only
@@ -486,11 +527,11 @@ class CompiledProfile:
Args:
mount_modes (dict[str, MountMode] | None): the mode each mount
section states; a mount absent from the map keeps its own.
hidden_paths (HiddenPaths | None): every path the role hides.
hidden_vars (HiddenVars | None): the role's hidden variables.
hidden_paths (HiddenPaths | None): every path the profile hides.
hidden_vars (HiddenVars | None): the profile's hidden variables.
env (dict[str, str] | None): variables to seed and export.
cwd (str | None): the working directory to start in.
commands (AdmissionRules | None): the role's admission rules,
commands (AdmissionRules | None): the profile's admission rules,
its own and its mount sections' in one list.
"""
+152
View File
@@ -0,0 +1,152 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
from collections.abc import Mapping, Sequence
from mirage.policy.errors import PolicyError
from mirage.policy.profile import SessionProfile
from mirage.runtime.base import Runtime
from mirage.runtime.errors import EvalError
from mirage.runtime.mixin import EvaluatorMixin
from mirage.runtime.script import eval_with_ctx, script_engine
from mirage.runtime.types import EvalValue, ScriptSource
SCRIPT_EVAL_TIMEOUT_SECONDS = 10.0
def script_context(name: str, mounts: Sequence[str]) -> dict[str, EvalValue]:
"""What a profile's script is told about the workspace.
Deliberately small, and deliberately not per session: the script
runs once for the profile, so it is told which profile it produces
permissions for and where the mounts are, and nothing that varies
between the sessions later created under it. A rule that depends on
*who* is asking is the caller's to make by naming a different
profile.
Args:
name (str): the profile the script produces permissions for.
mounts (Sequence[str]): the workspace's mount prefixes.
"""
return {"profile": name, "mounts": list(mounts)}
def _refuse(name: str, detail: str) -> PolicyError:
"""The one refusal wording, so every failure arm reads alike.
Args:
name (str): the profile whose script failed.
detail (str): what went wrong.
"""
return PolicyError(f"profile {name!r} script {detail}")
async def permissions_from_script(name: str, script: ScriptSource,
context: dict[str, EvalValue],
evaluator: EvaluatorMixin) -> SessionProfile:
"""Run one profile's script and validate the permissions it produced.
Every failure arm raises, and none of them falls back to empty
permissions: permissions that say nothing restrict nothing, so a
script that raised, timed out or answered with the wrong shape
would silently produce an unrestricted session, the opposite of
what stating the script asked for.
Args:
name (str): the profile the script produces permissions for.
script (ScriptSource): the program, as the config door loaded
it.
context (dict[str, EvalValue]): what the script sees as ``ctx``.
evaluator (EvaluatorMixin): the engine to run it on.
Raises:
PolicyError: the script failed, timed out, or returned
something other than permissions.
"""
try:
value = await eval_with_ctx(script.source, context, evaluator,
SCRIPT_EVAL_TIMEOUT_SECONDS)
except asyncio.TimeoutError as exc:
raise _refuse(
name, f"timed out after {SCRIPT_EVAL_TIMEOUT_SECONDS:g}s") from exc
except EvalError as exc:
arm = "syntax error" if exc.syntax else "failed"
raise _refuse(name, f"{arm}: {exc}") from exc
if not isinstance(value, Mapping):
raise _refuse(name, "must end in the permissions it produces")
try:
produced = SessionProfile.model_validate(dict(value))
except ValueError as exc:
raise _refuse(name, f"produced permissions that are not valid: {exc}")
if produced.script is not None:
raise _refuse(
name, "produced another script; a script produces permissions")
return produced
async def permissions_from_scripts(
scripted: Mapping[str, SessionProfile],
mounts: Sequence[str]) -> dict[str, SessionProfile]:
"""Run every profile's script, returning the permissions per name.
All of them run before any result is returned, so one broken
profile refuses the whole set rather than leaving the profiles that
happened to be evaluated first done and the rest still scripts;
without that, whether a session could be created depended on where
its profile sat in the mapping. Permissions are operator
configuration, so a workspace that cannot realize what it was given
does not serve; every refusal names the profile.
Engines are shared per kind rather than built per profile: each is
a worker subprocess, so building one for every scripted profile
would spawn N of them to run N short programs. Keyed on the class
because ``name`` is declared by Runtime, not by the evaluator
capability the engine is used as.
Args:
scripted (Mapping[str, SessionProfile]): the profiles that
state a script, keyed by profile name.
mounts (Sequence[str]): the workspace's mount prefixes.
Raises:
PolicyError: a script failed, named an engine it cannot have,
or is still a path, which means it reached this layer
without passing the config door that loads one.
"""
produced: dict[str, SessionProfile] = {}
engines: dict[type[Runtime], Runtime] = {}
try:
for name, profile in scripted.items():
script = profile.script
if isinstance(script, str):
raise PolicyError(
f"profile {name!r} names a script by path ({script!r}); "
f"only the config door loads one, pass ScriptSource in "
f"code")
assert script is not None
try:
engine = script_engine(script, profile.runtime)
except ValueError as exc:
raise PolicyError(f"profile {name!r} {exc}") from exc
engine = engines.setdefault(type(engine), engine)
# script_engine refuses anything that cannot evaluate, so
# this narrows a fact already established.
assert isinstance(engine, EvaluatorMixin)
produced[name] = await permissions_from_script(
name, script, script_context(name, mounts), engine)
finally:
for engine in engines.values():
await engine.close()
return produced
+8 -8
View File
@@ -49,7 +49,7 @@ class DenyScope(StrEnum):
class Outcome(StrEnum):
"""What the role's rules say about one line: the document's own
"""What the profile's rules say about one line: the document's own
three verbs and nothing else.
ALLOW is silence as well as consent, since a line no rule speaks
@@ -91,7 +91,7 @@ class CommandRule:
about) matching commands, on matching paths when it names any.
It is the compiled element of ``commands.deny`` and ``commands.ask``
wherever the role writes one, and reaches the workspace only inside
wherever the profile writes one, and reaches the workspace only inside
that document; the internal RulePolicy is what evaluates it. The
document writes a rule in one of three shapes, and each compiles to
rules of this class: a list of command patterns (a whole-line rule
@@ -131,7 +131,7 @@ class CommandRule:
@dataclass(frozen=True, slots=True)
class Ruling:
"""The role's answer about one line, and what produced it.
"""The profile's answer about one line, and what produced it.
Args:
outcome (Outcome): which verb spoke.
@@ -317,18 +317,18 @@ class SessionDecisionsQuery(Protocol):
@dataclass(frozen=True, slots=True)
class AdmissionRules:
"""One role's admission rules, compiled: the whole permission
"""One profile's admission rules, compiled: the whole permission
document a session runs under.
A session is evaluated against exactly one of these. It holds the
role's allow list, its ask and deny rules, and the rules its mount
profile's allow list, its ask and deny rules, and the rules its mount
entries carry, each stamped with the mount it was written under so
it applies to a line working inside that mount. There is nothing
above it and nothing beside it: two rules that both match are
resolved by anchor depth, then by verb (``policy/match/decide``).
Args:
allow (tuple[str, ...] | None): the role's allow patterns; None
allow (tuple[str, ...] | None): the profile's allow patterns; None
when it states no list (everything visible).
ask (tuple[CommandRule, ...]): rules admitted only with an
approval.
@@ -356,7 +356,7 @@ class SessionCommandsQuery(Protocol):
def commands_of(self, session_id: str) -> "AdmissionRules | None":
"""The compiled admission rules of one session; the default
role's for an id the manager does not know, the empty id of an
profile's for an id the manager does not know, the empty id of an
unbound door included.
Args:
@@ -542,7 +542,7 @@ class Explanation:
Args:
command (str): the head word, as the gate read it.
argv (tuple[str, ...]): the words after it.
outcome (Outcome): what the role's rules say.
outcome (Outcome): what the profile's rules say.
rule (CommandRule | None): the rule that spoke, None when the
allow list did or when nothing did.
reason (str): the rule's reason, empty when there is no rule.
+3 -4
View File
@@ -26,6 +26,7 @@ from mirage.runtime.policy.types import (DenyResult, PolicyContext,
PolicyDecision, PolicyFn,
PolicyResult, PolicyScript,
RouteResult, ScriptSource)
from mirage.runtime.script import eval_with_ctx
from mirage.runtime.table import bind_commands, catch_all, runtime_bindings_for
from mirage.runtime.types import EvalValue, Language
@@ -104,9 +105,8 @@ async def _eval_source(source: str, ctx_payload: dict[str, EvalValue],
"(install with: pip install mirage-ai[monty], or use a "
"Python callable instead)")
try:
result = await asyncio.wait_for(evaluator.eval(
source, inputs={"ctx": ctx_payload}),
timeout=POLICY_EVAL_TIMEOUT_SECONDS)
return await eval_with_ctx(source, ctx_payload, evaluator,
POLICY_EVAL_TIMEOUT_SECONDS)
except asyncio.TimeoutError as exc:
raise PolicyError(f"policy script timed out after "
f"{POLICY_EVAL_TIMEOUT_SECONDS:g}s") from exc
@@ -114,7 +114,6 @@ async def _eval_source(source: str, ctx_payload: dict[str, EvalValue],
prefix = ("policy script syntax error: "
if exc.syntax else "policy script failed: ")
raise ValueError(prefix + str(exc))
return result.value
async def evaluate_script(script: PolicyScript, ctx: PolicyContext,
+130
View File
@@ -0,0 +1,130 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
from mirage.runtime.base import Runtime
from mirage.runtime.mixin import EvaluatorMixin
from mirage.runtime.table import NAMED, build_runtime
from mirage.runtime.types import EvalValue, Language, ScriptSource
CTX_GLOBAL = "ctx"
# The sandboxed default engine per config-script language. A config
# script is operator configuration, so its engine is built fresh and
# never picked out of a workspace's runtime world: the world is the
# ordered set that serves *agent* code, it is mutable after
# construction, and an entry drops out of it silently when an optional
# dependency is missing.
#
# monty on BOTH hosts, deliberately not DEFAULT_PYTHON. The two hosts
# disagree about the default python engine (monty here, pyodide in
# TypeScript) because `@pydantic/monty` cannot answer builtin `open()`
# calls yet, and agent code reads files. A config script does no file
# I/O at all: it is handed a context and returns a value. So the reason
# for that split does not reach here, and naming one engine means one
# source produces one answer on either host rather than two engines
# that could disagree about the same program.
DEFAULT_SCRIPT_ENGINES: dict[Language, str] = {
"python": "monty",
"js": "quickjs",
}
async def eval_with_ctx(source: str, ctx: dict[str, EvalValue],
evaluator: EvaluatorMixin,
timeout: float) -> EvalValue:
"""Evaluate a config-borne script and return its last expression.
The one place the config-script calling convention is written down:
the payload arrives as a single global named ``ctx``, and the
script's last expression is its answer. Every config script speaks
it (the runtime router's ``policy:``, a runtime's entry script, a
profile's ``script:``), and they used to spell it out one at a time,
which is a convention two callers can drift apart on: the
TypeScript profile scripts passed the payload's keys as separate
globals for exactly one release, so ``ctx`` was undefined on that
host alone.
Deliberately raises rather than words its failures. Each caller
refuses in its own voice and its own error type (the runtime
router's PolicyError is a ValueError, the permissions layer's is
not), so a shared wording here would put one layer's words on the
other layer's failure.
Args:
source (str): the script program.
ctx (dict[str, EvalValue]): what the script sees as ``ctx``.
evaluator (EvaluatorMixin): the engine to run it on.
timeout (float): seconds to allow before giving up.
Raises:
asyncio.TimeoutError: the script outran ``timeout``.
EvalError: the script did not parse, or raised.
"""
result = await asyncio.wait_for(evaluator.eval(source,
inputs={CTX_GLOBAL: ctx}),
timeout=timeout)
return result.value
def script_engine(script: ScriptSource, runtime: str | None = None) -> Runtime:
"""Build the engine a config script runs on.
The engine the config named when it names one, else the sandboxed
default for the script's language. The mismatch checks run before
the build: an engine that cannot be installed here would otherwise
report its missing dependency for a config that names the wrong
engine, which sends the operator after the wrong fix.
Args:
script (ScriptSource): the program, carrying its language.
runtime (str | None): the engine the config named, if any.
Returns:
Runtime: the engine, which every arm above has proved carries
the evaluator capability. Typed as the Runtime it also is,
because ``name`` and ``close`` are the runtime's and the mixin
is capability-only; the caller narrows once to evaluate.
Raises:
ValueError: the named engine is unknown, cannot evaluate,
speaks another language, or its dependency is missing. The
message is a clause about "script", for the caller to
prefix with whose script it is.
"""
wanted = runtime or DEFAULT_SCRIPT_ENGINES[script.language]
named = NAMED.get(wanted)
if runtime is not None and named is not None:
if not issubclass(named, EvaluatorMixin):
raise ValueError(
f"script names runtime {wanted!r}, which runs programs but "
f"cannot evaluate one; use "
f"{DEFAULT_SCRIPT_ENGINES[script.language]!r}")
spoken = getattr(named, "language", None)
if spoken is not None and spoken != script.language:
raise ValueError(
f"script is {script.language}, but names runtime {wanted!r}, "
f"which speaks {spoken}")
try:
built = build_runtime(wanted)
except (ValueError, ImportError, OSError) as exc:
# An engine reports a missing dependency as its own error
# (ImportError for an absent extra, FileNotFoundError for
# quickjs's absent wasm), and each carries its own install hint.
raise ValueError(f"script names runtime {wanted!r}: {exc}") from exc
if not isinstance(built, EvaluatorMixin):
raise ValueError(
f"script names runtime {wanted!r}, which cannot evaluate")
return built
@@ -183,7 +183,7 @@ def _render_cli_entry(head: str, verbs: Sequence[str], spec: CLISpec,
the page for one leaf.
The allow list narrows a tree the same way it narrows the bare
listing, one level down: a role holding ``linear issue list`` reads
listing, one level down: a profile holding ``linear issue list`` reads
a manual for that verb and nothing else, because a row it cannot
run is an advertisement for a 126.
+3 -3
View File
@@ -25,7 +25,7 @@ from mirage.workspace.session import Session
def listed(name: str, session: Session) -> bool:
"""What the session's allow list says about a tool word.
A role without a list installs everything; a role with one installs
A profile without a list installs everything; a profile with one installs
only the names its patterns start with (``head_visible``). This is
the raw answer; ``command_visible`` and ``_layers`` add the words
that are never subjects.
@@ -59,7 +59,7 @@ def is_tool(name: str, session: Session) -> bool:
def command_visible(name: str, session: Session) -> bool:
"""Whether a session can see a command word at all.
The role's allow list (``commands.allow``) decides: a tool name no
The profile's allow list (``commands.allow``) decides: a tool name no
pattern of it starts with is not installed for the session, so it
is 127 at the chokepoint and absent from every enumerator; a word
that is not a tool (``is_tool``) is always visible.
@@ -77,7 +77,7 @@ def verb_visible(head: str, path: Sequence[str], session: Session) -> bool:
``command_visible`` answers for a word, which is all dispatch needs:
a CLI is routed by its head word and the verbs after it are the
program's own operand. Discovery needs the finer answer, because a
role allowed ``linear issue list`` is not allowed ``linear team``,
profile allowed ``linear issue list`` is not allowed ``linear team``,
and a manual that lists the second is advertising a line that
cannot run. ``is_tool``'s exemptions have nothing to say here:
shell grammar and functions are single words, so a verb path only
@@ -16,11 +16,6 @@ from mirage.context import (get_current_session, get_current_session_for,
reset_current_session, set_current_session)
from mirage.workspace.session.errors import ReadonlyVariableError
from mirage.workspace.session.manager import SessionManager
from mirage.workspace.session.permissions import (CommandsBlock,
CompiledProfile,
MountCommandsBlock,
PathsBlock, ProfileMount,
SessionProfile, VarsBlock)
from mirage.workspace.session.ram import RAMSessionStore
from mirage.workspace.session.session import Session
from mirage.workspace.session.state import (ensure_var_visible, env_snapshot,
@@ -34,13 +29,6 @@ __all__ = [
"env_snapshot",
"ensure_var_visible",
"exported_names",
"SessionProfile",
"CommandsBlock",
"CompiledProfile",
"MountCommandsBlock",
"PathsBlock",
"ProfileMount",
"VarsBlock",
"session_view",
"visible_arrays",
"visible_env",
+1 -1
View File
@@ -12,7 +12,7 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# The role a session is created from when none is named and the
# The profile a session is created from when none is named and the
# workspace defines one of this name.
DEFAULT_PROFILE = "default"
+3 -3
View File
@@ -16,10 +16,10 @@ import asyncio
import copy
from collections.abc import Mapping
from mirage.policy.profile import CompiledProfile
from mirage.policy.types import AdmissionRules, Decision
from mirage.types import MountMode
from mirage.workspace.record.types import CAS_MAX_RETRIES, generation_of
from mirage.workspace.session.permissions import CompiledProfile
from mirage.workspace.session.ram import RAMSessionStore
from mirage.workspace.session.resolve import apply_profile, narrow
from mirage.workspace.session.session import Session, vars_from_env
@@ -85,10 +85,10 @@ class SessionManager:
"""The admission rules one session runs under
(SessionCommandsQuery).
The default role's rules for an id this manager does not know,
The default profile's rules for an id this manager does not know,
the empty id of an unbound door included (FUSE, the host's own
``ws.ops``), so a door that names no session is judged like a
session that named no role rather than judged not at all.
session that named no profile rather than judged not at all.
Args:
session_id (str): the session, empty when none is bound.
+29 -29
View File
@@ -23,7 +23,7 @@ from mirage.workspace.session.session import Session, vars_from_env
from mirage.workspace.session.shell_dirs import set_cwd
from mirage.workspace.session.validate import check_rules
from mirage.workspace.session.permissions import ( # isort: skip
from mirage.policy.profile import ( # isort: skip
CommandsBlock, CompiledProfile, MountCommandsBlock, PathsBlock,
ProfileMount, SessionProfile, VarsBlock)
@@ -32,22 +32,22 @@ def resolve_profile(
profiles: Mapping[str, SessionProfile],
profile: str | SessionProfile | None,
) -> SessionProfile | None:
"""The role a session is created from.
"""The profile a session is created from.
A name is looked up as written; a profile object is itself; None
picks ``profiles.default`` when the workspace defines one and
leaves the session unrestricted otherwise. There is no inheritance
chain: a role is the whole document, so nothing is assembled from
chain: a profile is the whole document, so nothing is assembled from
somewhere else before it is read.
Args:
profiles (Mapping[str, SessionProfile]): the workspace's named
roles.
profiles.
profile (str | SessionProfile | None): what ``create_session``
was given.
Raises:
PolicyError: the name is not a role the workspace defines.
PolicyError: the name is not a profile the workspace defines.
"""
if profile is None:
return profiles.get(DEFAULT_PROFILE)
@@ -63,7 +63,7 @@ def _union_hide(a: PathsBlock | VarsBlock | None,
"""Every entry of both blocks, first spelling wins, order kept.
Args:
a (PathsBlock | VarsBlock | None): the role's block.
a (PathsBlock | VarsBlock | None): the profile's block.
b (PathsBlock | VarsBlock | None): the inline block.
"""
out: list[str] = []
@@ -78,8 +78,8 @@ 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
whether a profile happened to resolve, so both paths into
``with_inline`` run it: a workspace with no default profile must not
quietly accept a list a workspace with one refuses.
Args:
@@ -95,15 +95,15 @@ def refuse_allow(inline: CommandsBlock | None) -> None:
def _add_commands(base: CommandsBlock | None,
inline: CommandsBlock | None) -> CommandsBlock | None:
"""The role's commands block with the inline document's rules added.
"""The profile's commands block with the inline document's rules added.
An inline document may only restrict, so it carries ask and deny
rules and never an allow list: a list there would install a command
the role does not have, which is the one thing a per-call document
the profile does not have, which is the one thing a per-call document
must not do.
Args:
base (CommandsBlock | None): the role's block.
base (CommandsBlock | None): the profile's block.
inline (CommandsBlock | None): what ``create_session`` added.
Raises:
@@ -125,7 +125,7 @@ def _add_mount(base: ProfileMount | None,
mode, both rule lists, both hide lists.
Args:
base (ProfileMount | None): the role's entry, None when it
base (ProfileMount | None): the profile's entry, None when it
names no settings for this mount.
inline (ProfileMount | None): the inline entry.
"""
@@ -163,17 +163,17 @@ def _rules_of(block: MountCommandsBlock | None,
def with_inline(base: SessionProfile | None,
inline: SessionProfile | None) -> SessionProfile | None:
"""A role with the inline document of one ``create_session`` added.
"""A profile 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, and that
holds even when there is no role to add to. Modes take the weaker
holds even when there is no profile 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.
base (SessionProfile | None): the resolved profile.
inline (SessionProfile | None): what ``create_session`` added.
"""
if inline is None:
@@ -264,15 +264,15 @@ def _scoped_rules(rules: tuple[CommandRule, ...],
def compile_commands(profile: SessionProfile) -> AdmissionRules | None:
"""A role's admission rules: its own, plus every mount entry's,
in one list; None when the role states none.
"""A profile's admission rules: its own, plus every mount entry's,
in one list; None when the profile states none.
Mount rules come first so the entry closest to the data speaks
first when several rules match at the same anchor depth and only
the message differs.
Args:
profile (SessionProfile): the resolved role.
profile (SessionProfile): the resolved profile.
"""
ask: list[CommandRule] = []
deny: list[CommandRule] = []
@@ -291,13 +291,13 @@ def compile_commands(profile: SessionProfile) -> AdmissionRules | None:
def _hidden(profile: SessionProfile) -> HiddenPaths | None:
"""Every path the role hides: its own entries, and each mount
"""Every path the profile hides: its own entries, and each mount
entry's anchored to the mount it was written under, since the set
is one list for the whole session and nothing in it remembers which
section an entry came from (:func:`_anchored`).
Args:
profile (SessionProfile): the resolved role.
profile (SessionProfile): the resolved profile.
"""
entries: list[str] = []
if profile.paths is not None:
@@ -311,12 +311,12 @@ def _hidden(profile: SessionProfile) -> HiddenPaths | None:
def _modes(profile: SessionProfile) -> dict[str, MountMode] | None:
"""The mode each mount section states, None when none does.
A mount the role does not name is absent from the map and keeps
A mount the profile does not name is absent from the map and keeps
the mode it declares in the workspace's ``mounts:``; the map only
narrows, it never grants.
Args:
profile (SessionProfile): the resolved role.
profile (SessionProfile): the resolved profile.
"""
modes = {
prefix: entry.mode
@@ -327,10 +327,10 @@ def _modes(profile: SessionProfile) -> dict[str, MountMode] | None:
def compile_profile(effective: SessionProfile | None) -> CompiledProfile:
"""The session fields a role compiles to.
"""The session fields a profile compiles to.
Args:
effective (SessionProfile | None): the resolved role with any
effective (SessionProfile | None): the resolved profile with any
inline document already added; None is an unrestricted
session.
"""
@@ -355,7 +355,7 @@ def compile_profile(effective: SessionProfile | None) -> CompiledProfile:
def narrow(session: Session, compiled: CompiledProfile) -> None:
"""Stamp a compiled role's narrowing onto a session.
"""Stamp a compiled profile's narrowing onto a session.
The four fields no shell line can edit: the per-mount modes, hidden
paths, hidden variables, the admission rules. Applied at creation
@@ -365,7 +365,7 @@ def narrow(session: Session, compiled: CompiledProfile) -> None:
Args:
session (Session): the session to narrow.
compiled (CompiledProfile): the effective role.
compiled (CompiledProfile): the effective profile.
"""
session.mount_modes = (dict(compiled.mount_modes)
if compiled.mount_modes is not None else None)
@@ -375,9 +375,9 @@ def narrow(session: Session, compiled: CompiledProfile) -> None:
def apply_profile(session: Session, compiled: CompiledProfile) -> None:
"""Narrow a fresh session and seed its scratch state from the role.
"""Narrow a fresh session and seed its scratch state from the profile.
A role's env is a *process* environment, the same shape
A profile's env is a *process* environment, the same shape
``ws.env = {...}`` speaks, so every name in it is exported: seeding
them plain left ``$TOKEN`` expanding while every command, CLI and
guest runtime in the profiled session saw nothing, since all three
@@ -388,7 +388,7 @@ def apply_profile(session: Session, compiled: CompiledProfile) -> None:
Args:
session (Session): the session just created.
compiled (CompiledProfile): the effective role.
compiled (CompiledProfile): the effective profile.
"""
narrow(session, compiled)
if compiled.env:
+1 -1
View File
@@ -148,7 +148,7 @@ class Session:
# session door for vars), fork carries them, to_dict serializes.
hidden_paths: HiddenPaths | None = None
hidden_vars: HiddenVars | None = None
# The role's admission rules, compiled: its allow list, its ask and
# The profile's admission rules, compiled: its allow list, its ask and
# deny rules, and every rule its mount entries carry. One document,
# so there is nothing above it to join with. A durable restriction
# like hidden_paths, so it persists with the session record.
+1 -1
View File
@@ -504,7 +504,7 @@ async def set_var(session: Session,
# without per-read work. `-i` evaluates against the visible env,
# and a bad expression raises the arithmetic error as bash does.
# Coercion runs before the gate so a rule judges the value that
# will land: `declare -l role; role=ADMIN` stores `admin`, and a
# will land: `declare -l profile; profile=ADMIN` stores `admin`, and a
# rule refusing `admin` must see that, not the raw text.
if existing is not None and existing.attrs:
value = coerce_value(value, existing.attrs,
+80 -31
View File
@@ -31,7 +31,9 @@ from mirage.observe.record import OpRecord
from mirage.observe.store import ObserverStore
from mirage.ops import Ops
from mirage.policy import (AskHandler, Decisions, Explanation,
PermissionsPolicy, Policies, Policy, PolicyError)
PermissionsPolicy, Policies, Policy, PolicyError,
SessionProfile)
from mirage.policy.script import permissions_from_scripts
from mirage.provision import ProvisionResult
from mirage.resource.history import HISTORY_PREFIX, HistoryViewResource
from mirage.runtime.base import Runtime
@@ -49,8 +51,7 @@ from mirage.workspace.mount import MountEntry, MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.mount.namespace.store import NamespaceStore
from mirage.workspace.node.explain import explain_line
from mirage.workspace.session import (Session, SessionManager, SessionProfile,
SessionStore)
from mirage.workspace.session import Session, SessionManager, SessionStore
from mirage.workspace.session.resolve import (apply_profile, compile_profile,
resolve_profile, with_inline)
from mirage.workspace.session.validate import check_cli_verbs
@@ -117,9 +118,9 @@ class Workspace:
| None = None,
) -> None:
self._registry = MountRegistry()
# The permission documents: one role per name, and the role a
# session gets when it names none. A role is the whole document
# a session runs under, so there is no workspace-wide block
# The permission profiles: one per name, and the one a session
# gets when it names none. A profile is the whole document a
# session runs under, so there is no workspace-wide block
# above it. Both accept the plain mapping a YAML file or the
# TypeScript constructor would hold; model_validate is a no-op
# on an already-built model.
@@ -158,7 +159,7 @@ class Workspace:
self._default_agent_id = agent_id
self._session_mgr = SessionManager(session_id, store=stores.sessions)
# Admission policies, consulted in registration order after the
# built-ins the registry seeds: the role's admission rules
# built-ins the registry seeds: the profile's admission rules
# (PermissionsPolicy, reading each session's compiled rules
# from the manager by the id the door puts in the context), then
# Policy instances, then anything added later through
@@ -197,11 +198,11 @@ class Workspace:
# stamped onto the default session now and onto every session
# created or hydrated later.
# The workspace's own session is a session created without a
# name, so the default role shapes it too: the primary agent is
# not the one agent the document cannot reach.
default_role = self._role(None)
self._session_mgr.default_profile = (compile_profile(default_role)
if default_role is not None else
# name, so the default profile shapes it too: the primary agent
# is not the one agent the document cannot reach.
default_base = self._base_profile(None)
self._session_mgr.default_profile = (compile_profile(default_base)
if default_base is not None else
None)
self.observer = Observer(store=stores.observe)
@@ -258,8 +259,8 @@ class Workspace:
async def explain(self,
line: str,
session_id: str = "") -> list[Explanation]:
"""What a line would do under a session's role, without running
any of it.
"""What a line would do under a session's profile, without
running any of it.
The dry run of the gate every command passes through, so this
and the refusal an agent would read come out of one place and
@@ -267,13 +268,14 @@ class Workspace:
grant and puts no question to a host, which is what makes it
safe to call about a line nobody typed.
Host-side only. The structure of a role's rules is an operator's
business, so there is no builtin an agent can type to read it.
Host-side only. The structure of a profile's rules is an
operator's business, so there is no builtin an agent can type
to read it.
Args:
line (str): the line to judge, as an agent would type it.
session_id (str): whose role to judge it under; the default
session when empty.
session_id (str): whose profile to judge it under; the
default session when empty.
Returns:
list[Explanation]: one per command the gate reads, in gate
@@ -694,12 +696,12 @@ class Workspace:
profile: str | SessionProfile | Mapping[str, Any] | None = None,
permissions: SessionProfile | Mapping[str, Any] | None = None,
) -> Session:
"""Create a session under one role, with an optional inline
"""Create a session under one profile, with an optional inline
document of its own.
The role is a name from the workspace's ``profiles``, or the
workspace default when none is named, or a role document. The
inline ``permissions`` and ``mounts`` may add ask and deny
The profile is a name from the workspace's ``profiles``, or the
workspace default when none is named, or a profile document.
The inline ``permissions`` and ``mounts`` may add ask and deny
rules, hides and weaker modes; they may never add an allow
entry, which is the one rule about combining two documents.
@@ -710,21 +712,29 @@ class Workspace:
mode ("read", "write", "exec", or the filesystem aliases
"r", "rw", "rwx"), which may only be weaker than the
mount's own. A mount the mapping omits keeps its own
mode, so this narrows and never confines; a role that
must keep a session away from a mount hides it.
mode, so this narrows and never confines; a profile
that must keep a session away from a mount hides it.
profile (str | SessionProfile | Mapping[str, Any] | None):
the role to create the session from: a name, a
the profile to create the session under: a name, a
SessionProfile, or its plain document.
permissions (SessionProfile | Mapping[str, Any] | None): an
inline document of ask and deny rules and hides.
Raises:
PolicyError: an unknown role name, or an inline document
PolicyError: an unknown profile name, or an inline document
that states an allow list.
"""
if isinstance(profile, Mapping):
profile = SessionProfile.model_validate(profile)
base = self._role(profile)
base = self._base_profile(profile)
if base is not None and base.script is not None:
# Still a script means hydration has not run, and running it
# needs an await this door does not have. Every caller that
# creates a session already takes that door first, so this
# names it rather than guessing on their behalf.
raise PolicyError(
"a profile that states a script is ready only after "
"ensure_sessions_loaded(); await it before create_session()")
inline = (SessionProfile.model_validate(permissions)
if permissions is not None else None)
if mounts is not None:
@@ -748,10 +758,12 @@ class Workspace:
for name, install in self._registry.clis.items().items()
}
def _role(self,
profile: str | SessionProfile | None) -> SessionProfile | None:
"""The role a session is created under: the name as given, else
the workspace's default role.
def _base_profile(
self,
profile: str | SessionProfile | None) -> SessionProfile | None:
"""The base profile a session is created under, which the
inline ``permissions``/``mounts`` arguments then layer onto:
the profile as named, else the workspace default.
Args:
profile (str | SessionProfile | None): what the caller
@@ -772,10 +784,47 @@ class Workspace:
The discovery record resolves first so a minted default session
id can adopt the stored pointer before hydration keys off it.
Profile scripts run here too, once each, because this is the
async door every caller already takes before it creates a
session and it is the last moment a failure can refuse one that
does not exist yet.
"""
await self._meta.ensure()
await self._evaluate_profile_scripts()
await self._session_mgr.ensure_loaded()
async def _evaluate_profile_scripts(self) -> None:
"""Replace each profile's script with the permissions it
produced.
One call into the policy layer, which runs every script before
returning anything, so one broken profile refuses the whole set
(see permissions_from_scripts). Idempotent by construction: a
profile that has been evaluated no longer states a script, so a
second hydration finds nothing left to run.
Raises:
PolicyError: a script failed, or is still a path, which
means it reached the workspace without passing the
config door that loads one.
"""
scripted = {
name: profile
for name, profile in self._profiles.items()
if profile.script is not None
}
if not scripted:
return
mounts = [entry.prefix for entry in self._registry.mounts()]
self._profiles.update(await permissions_from_scripts(scripted, mounts))
if self._default_profile_name in scripted:
# The constructor compiled the default profile before its
# script ran, which is the script-only placeholder, so
# without this the default session keeps running under
# empty permissions.
self._session_mgr.default_profile = compile_profile(
self._profiles[self._default_profile_name])
@property
def workspace_id(self) -> str:
return self._workspace_id
+23 -1
View File
@@ -37,7 +37,7 @@ from mirage.workspace.store import (DiskWorkspaceStateStore,
RAMWorkspaceStateStore,
RedisWorkspaceStateStore)
from mirage.workspace.session.permissions import ( # isort: skip
from mirage.policy.profile import ( # isort: skip
CommandsBlock, PathsBlock, ProfileMount, SessionProfile, VarsBlock)
FIXTURES = Path(__file__).parent / "fixtures"
@@ -867,3 +867,25 @@ def test_shared_acceptance_fixture_is_accepted(fixture: str):
# never mirrored into the TypeScript key tables fails there.
for case in _shared_fixture_cases(fixture):
load_config(case["config"])
def test_profile_script_path_rebases_on_the_config_dir(tmp_path, monkeypatch):
# `script: roles/x.py` means "next to the config file", the same
# build-context rule the cli path form follows; without rebasing it
# resolves against the process cwd and only works by luck.
(tmp_path / "roles").mkdir()
(tmp_path / "roles" /
"x.py").write_text("{'commands': {'allow': ['ls']}}\n")
cfg_file = tmp_path / "ws.yaml"
cfg_file.write_text("""\
mounts:
/data:
resource: ram
profiles:
release: {script: roles/x.py}
""")
monkeypatch.chdir(tmp_path.parent)
cfg = load_config(cfg_file)
release = cfg.to_workspace_kwargs()["profiles"]["release"]
assert isinstance(release.script, ScriptSource)
assert "allow" in release.script.source
@@ -62,7 +62,7 @@ def _ctx(command: str,
tool=tool)
# One role, compiled: its own rules plus the ones its `mounts./repo`
# One profile, compiled: its own rules plus the ones its `mounts./repo`
# section carries, which the compiler stamped with that root.
MOUNT_DENY = CommandRule(reason="history is read-only here",
commands=("git push", ),
@@ -163,7 +163,7 @@ async def test_the_deeper_anchor_wins_and_deny_breaks_a_tie():
paths=(_path("/repo/x"), ))) == Ask("needs a nod", shallow,
(shallow, ))
# The other way round: an ask anchored deeper than a deny wins, so a
# role can carve an exception out of a broad refusal.
# profile can carve an exception out of a broad refusal.
flipped = PermissionsPolicy(
_Sessions({
"s":
+2 -2
View File
@@ -43,7 +43,7 @@ def test_head_visible_answers_the_roles_one_allow_list():
assert head_visible("git", rules)
assert not head_visible("cat", rules)
assert not head_visible("rm", rules)
# A role without a list hides nothing, and neither does no role.
# A profile without a list hides nothing, and neither does no profile.
assert head_visible("rm", AdmissionRules(deny=(CommandRule("x"), )))
assert head_visible("rm", None)
@@ -60,7 +60,7 @@ def test_node_visible_narrows_a_tree_one_verb_at_a_time():
assert not node_visible(("linear", "issue", "create"), rules)
# head_visible is the one-word case, and agrees.
assert head_visible("linear", rules) == node_visible(("linear", ), rules)
# A role without a list hides no node of any tree.
# A profile without a list hides no node of any tree.
assert node_visible(("linear", "team"), None)
assert node_visible(("linear", "team"),
AdmissionRules(deny=(CommandRule("x"), )))
@@ -25,6 +25,7 @@ from mirage.policy import Action, Ask, CommandContext, Decision, Policy, Scope
from mirage.policy.constants import DEFAULT_ASK_REASON, DEFAULT_DENY_REASON
from mirage.policy.errors import PolicyError
from mirage.policy.match import Outcome
from mirage.policy.profile import SessionProfile
from mirage.policy.types import CommandRule
from mirage.resource.ram import RAMResource
from mirage.runtime.base import Runtime
@@ -32,10 +33,9 @@ from mirage.runtime.mixin import LineExecutorMixin
from mirage.runtime.types import RunResult
from mirage.types import HiddenPaths, HiddenVars, MountMode
from mirage.workspace import Workspace
from mirage.workspace.session import SessionProfile
from mirage.workspace.session.state import seed_var
from mirage.workspace.session.permissions import ( # isort: skip
from mirage.policy.profile import ( # isort: skip
CommandsBlock, MountCommandsBlock, PathsBlock, ProfileMount, VarsBlock)
@@ -108,7 +108,7 @@ def test_a_mount_section_carries_a_mode_rules_and_hides():
def test_profile_mounts_refuses_a_bare_list():
# A list used to mean "only these mounts are reachable"; a mount a
# role does not name now keeps its own mode, so the list would
# profile does not name now keeps its own mode, so the list would
# quietly drop the confinement it used to carry.
with pytest.raises(ValidationError,
match=re.escape(
@@ -533,7 +533,7 @@ def test_profile_applies_every_narrowing_field():
sess = ws.create_session("agent", profile=ANALYST)
assert sess.mount_modes is not None
assert sess.mount_modes["/a"] == MountMode.WRITE
# A mount the role never names is absent from the map and keeps the
# A mount the profile never names is absent from the map and keeps the
# mode the workspace gave it; naming one mount is not an allowlist.
assert "/b" not in sess.mount_modes
assert sess.hidden_paths == HiddenPaths(paths=("/a/secrets", ))
@@ -542,7 +542,7 @@ def test_profile_applies_every_narrowing_field():
def test_one_profile_serves_many_sessions():
# A profile is a role, not a session: frozen, so two agents share
# A profile is a profile, not a session: frozen, so two agents share
# one object and neither can bend the other's view.
ws = _ws()
s1 = ws.create_session("agent1", profile=ANALYST)
@@ -577,8 +577,8 @@ def test_profiled_session_is_narrowed_end_to_end():
listing = await ws.execute("ls /a", session_id="agent")
denied = await ws.execute("cat /a/secrets/token.txt",
session_id="agent")
role = await ws.execute('echo "$ROLE"', session_id="agent")
return (await listing.stdout_str(), denied, await role.stdout_str())
profile = await ws.execute('echo "$ROLE"', session_id="agent")
return (await listing.stdout_str(), denied, await profile.stdout_str())
listing_out, denied, role_out = asyncio.run(run())
assert "x.txt" in listing_out
@@ -602,7 +602,7 @@ def test_profile_env_reaches_the_process_view():
assert "ROLE=analyst\n" in asyncio.run(run())
# Two roles, each the whole document it runs under: there is no
# Two profiles, each the whole document it runs under: there is no
# inheritance, so reading one is reading everything it may do.
PROFILES = {
"default":
@@ -663,8 +663,8 @@ def test_create_session_without_a_profile_takes_the_default_one():
def test_default_profile_shapes_the_workspace_session_too():
# The workspace's own session is a session created without a name,
# so `profiles.default` reaches it: the primary agent starts in the
# role's cwd, sees its exported env and its per-mount modes, and
# cannot see what it hides. A workspace with no default role leaves
# profile's cwd, sees its exported env and its per-mount modes, and
# cannot see what it hides. A workspace with no default profile leaves
# that session as it always was.
ws = Workspace(
{
@@ -698,7 +698,7 @@ def test_default_profile_shapes_the_workspace_session_too():
pwd_out, pager_out, other_exit, vault_exit = asyncio.run(run())
assert pwd_out == "/b\n"
assert pager_out == "cat\n"
# A mount the role does not name is reachable at its own mode: the
# A mount the profile does not name is reachable at its own mode: the
# `mounts` mapping narrows, it is not an allowlist.
assert other_exit == 0
assert vault_exit != 0
@@ -710,7 +710,7 @@ def test_default_profile_shapes_the_workspace_session_too():
def test_a_role_keeps_a_mount_away_by_hiding_it_not_by_omitting_it():
# Omission is not a refusal, so exclusion is a hide: the mount reads
# as nonexistent rather than as a permission error naming something
# the role cannot see.
# the profile cannot see.
ws = Workspace(
{
"/a": (RAMResource(), MountMode.WRITE),
@@ -742,7 +742,7 @@ def test_create_session_rejects_an_unknown_profile_name():
def test_workspace_names_a_default_role_by_name():
# `profile=` on the workspace picks which role shapes a session
# `profile=` on the workspace picks which profile shapes a session
# created without one, including its own.
ws = Workspace(
{
@@ -762,7 +762,7 @@ def test_workspace_names_a_default_role_by_name():
profile="gone")
def test_inline_permissions_add_to_the_named_role():
def test_inline_permissions_add_to_the_named_profile():
ws = _profiled_ws()
sess = ws.create_session("agent",
profile="reviewer",
@@ -772,8 +772,8 @@ def test_inline_permissions_add_to_the_named_role():
paths=PathsBlock(hide=("*.key", )),
vars=VarsBlock(hide=("AWS_*", ))))
assert sess.mount_modes is not None
# The role says read and the inline document says write: the weaker
# one wins, which is the role's.
# The profile says read and the inline document says write: the weaker
# one wins, which is the profile's.
assert sess.mount_modes["/a"] == MountMode.READ
assert sess.hidden_paths == HiddenPaths(paths=("/a/secrets", ),
patterns=("*.key", ))
@@ -783,7 +783,7 @@ def test_inline_permissions_add_to_the_named_role():
def test_inline_permissions_may_not_state_an_allow_list():
# The one rule about combining two documents: an inline document
# restricts, so an allow list there would install a command the role
# restricts, so an allow list there would install a command the profile
# was never given.
ws = _profiled_ws()
with pytest.raises(PolicyError, match="not an allow list"):
@@ -805,7 +805,7 @@ def test_profile_cwd_is_where_the_session_starts():
assert asyncio.run(run()) == "/b\n"
# One mount section, written the same way by both roles below: rules
# One mount section, written the same way by both profiles below: rules
# here reach a line that works inside /repo, by cwd or by operand, which
# is what a path-scoped rule cannot express (`cd /repo && git commit`
# names no path).
@@ -911,8 +911,8 @@ async def test_a_roles_allow_list_is_the_only_one_a_session_reads():
ws.create_session("rev", profile="reviewer")
try:
await ws.execute("mkdir -p /repo/d && touch /repo/d/x")
# The reviewer role lists `cat` and not python3, whatever the
# default role lists; it lists `git log`, so `git` is visible but
# The reviewer profile lists `cat` and not python3, whatever the
# default profile lists; it lists `git log`, so `git` is visible but
# a `git commit` line is covered by nothing (a refusal that names
# the program, not "command not found").
assert (await _line(ws, "cat /repo/d/x", "rev"))[0] == 0
+213
View File
@@ -0,0 +1,213 @@
import asyncio
import pytest
from mirage.policy.errors import PolicyError
from mirage.policy.profile import SessionProfile
from mirage.policy.script import (permissions_from_script,
permissions_from_scripts, script_context)
from mirage.runtime.errors import EvalError
from mirage.runtime.mixin import EvaluatorMixin
from mirage.runtime.types import EvalResult, EvalValue, ScriptSource
DOC = {"commands": {"allow": ["ls", "cat"]}, "cwd": "/repo"}
class FakeEngine(EvaluatorMixin):
"""Stands in for a built engine, recording what it saw."""
built: list["FakeEngine"] = []
def __init__(self,
value: EvalValue = None,
error: Exception | None = None,
delay: float = 0.0) -> None:
self.value = value
self.error = error
self.delay = delay
self.seen: dict[str, EvalValue] = {}
self.code = ""
self.evals = 0
self.closed = False
FakeEngine.built.append(self)
async def eval(self,
code: str,
*,
inputs: dict[str, EvalValue] | None = None,
session: str | None = None) -> EvalResult:
self.code = code
self.seen = dict(inputs or {})
self.evals += 1
if self.delay:
await asyncio.sleep(self.delay)
if self.error is not None:
raise self.error
return EvalResult(value=self.value)
async def close(self) -> None:
self.closed = True
@pytest.fixture(autouse=True)
def _reset_built():
FakeEngine.built = []
def _scripted(**runtimes: str | None) -> dict[str, SessionProfile]:
"""Profiles stating one script each, keyed by profile name.
Args:
runtimes (str | None): the engine each named profile states,
None for the language default.
"""
return {
name: SessionProfile(script=ScriptSource("..."), runtime=runtime)
for name, runtime in runtimes.items()
}
def test_script_context_names_the_profile_and_the_mounts():
ctx = script_context("release", ["/repo/", "/scratch/"])
assert ctx == {"profile": "release", "mounts": ["/repo/", "/scratch/"]}
def test_script_context_carries_nothing_per_session():
# The script runs once for the profile, so a per-session fact
# reaching it would be a promise the one evaluation cannot keep.
ctx = script_context("release", [])
assert "session_id" not in ctx
assert "agent_id" not in ctx
@pytest.mark.asyncio
async def test_the_produced_permissions_are_validated():
engine = FakeEngine(DOC)
produced = await permissions_from_script(
"release", ScriptSource("..."), script_context("release", ["/repo/"]),
engine)
assert produced.cwd == "/repo"
assert produced.commands is not None
assert produced.commands.allow == ("ls", "cat")
@pytest.mark.asyncio
async def test_the_script_is_shown_its_context():
engine = FakeEngine(DOC)
ctx = script_context("release", ["/repo/"])
await permissions_from_script("release", ScriptSource("SOURCE"), ctx,
engine)
assert engine.code == "SOURCE"
assert engine.seen == {"ctx": ctx}
@pytest.mark.asyncio
async def test_a_script_that_raised_is_refused():
engine = FakeEngine(error=EvalError("boom"))
with pytest.raises(PolicyError, match="script failed: boom"):
await permissions_from_script("release", ScriptSource("..."), {},
engine)
@pytest.mark.asyncio
async def test_a_syntax_error_is_named_as_one():
engine = FakeEngine(error=EvalError("bad token", syntax=True))
with pytest.raises(PolicyError, match="script syntax error"):
await permissions_from_script("release", ScriptSource("..."), {},
engine)
@pytest.mark.asyncio
async def test_a_script_that_timed_out_is_refused(monkeypatch):
monkeypatch.setattr("mirage.policy.script.SCRIPT_EVAL_TIMEOUT_SECONDS",
0.01)
engine = FakeEngine(DOC, delay=0.2)
with pytest.raises(PolicyError, match="timed out"):
await permissions_from_script("release", ScriptSource("..."), {},
engine)
@pytest.mark.asyncio
@pytest.mark.parametrize("value", [None, [1, 2], "commands", 7])
async def test_anything_but_permissions_is_refused(value):
# Empty permissions restrict nothing, so a wrong shape must never
# coerce to one; every arm here has to raise rather than fall back.
engine = FakeEngine(value)
with pytest.raises(PolicyError, match="must end in the permissions"):
await permissions_from_script("release", ScriptSource("..."), {},
engine)
@pytest.mark.asyncio
async def test_permissions_that_are_not_valid_are_refused():
engine = FakeEngine({"commands": {"allow": "ls"}})
with pytest.raises(PolicyError, match="not valid"):
await permissions_from_script("release", ScriptSource("..."), {},
engine)
@pytest.mark.asyncio
async def test_a_script_that_produced_a_script_is_refused():
engine = FakeEngine({"script": "roles/other.py"})
with pytest.raises(PolicyError, match="produced another script"):
await permissions_from_script("release", ScriptSource("..."), {},
engine)
@pytest.mark.asyncio
async def test_every_refusal_names_the_profile():
engine = FakeEngine([])
with pytest.raises(PolicyError, match="profile 'release' script"):
await permissions_from_script("release", ScriptSource("..."), {},
engine)
@pytest.mark.asyncio
async def test_a_script_still_spelled_as_a_path_is_refused():
scripted = {
"release": SessionProfile.model_validate({"script": "roles/x.py"})
}
with pytest.raises(PolicyError, match="names a script by path"):
await permissions_from_scripts(scripted, [])
def _no_engine(script: ScriptSource, runtime: str | None = None) -> FakeEngine:
raise ValueError(f"script names runtime {runtime!r}: nope")
def _good_engine(script: ScriptSource,
runtime: str | None = None) -> FakeEngine:
return FakeEngine(DOC)
def _broken_engine(script: ScriptSource,
runtime: str | None = None) -> FakeEngine:
return FakeEngine(error=EvalError("boom"))
@pytest.mark.asyncio
async def test_an_engine_refusal_is_worded_for_the_profile(monkeypatch):
monkeypatch.setattr("mirage.policy.script.script_engine", _no_engine)
with pytest.raises(PolicyError,
match="profile 'release' script names runtime"):
await permissions_from_scripts(_scripted(release="ghost"), [])
@pytest.mark.asyncio
async def test_profiles_of_one_language_share_one_engine(monkeypatch):
monkeypatch.setattr("mirage.policy.script.script_engine", _good_engine)
produced = await permissions_from_scripts(_scripted(a=None, b=None), [])
assert set(produced) == {"a", "b"}
# One engine is built per call, but only the first of a kind is
# kept: both scripts ran on it, and it alone was closed.
kept = FakeEngine.built[0]
assert kept.evals == 2
assert kept.closed
@pytest.mark.asyncio
async def test_the_engine_is_closed_when_a_script_fails(monkeypatch):
monkeypatch.setattr("mirage.policy.script.script_engine", _broken_engine)
with pytest.raises(PolicyError, match="profile 'release' script failed"):
await permissions_from_scripts(_scripted(release=None), [])
assert FakeEngine.built[0].closed
+107
View File
@@ -0,0 +1,107 @@
import asyncio
import pytest
from mirage.runtime.errors import EvalError
from mirage.runtime.mixin import EvaluatorMixin
from mirage.runtime.script import (CTX_GLOBAL, DEFAULT_SCRIPT_ENGINES,
eval_with_ctx, script_engine)
from mirage.runtime.table import NAMED
from mirage.runtime.types import EvalResult, EvalValue, ScriptSource
class Recorder(EvaluatorMixin):
"""Records the globals a script would have been shown."""
def __init__(self,
value: EvalValue = None,
delay: float = 0.0,
error: Exception | None = None) -> None:
self.value = value
self.delay = delay
self.error = error
self.inputs: dict[str, EvalValue] = {}
async def eval(self,
code: str,
*,
inputs: dict[str, EvalValue] | None = None,
session: str | None = None) -> EvalResult:
self.inputs = dict(inputs or {})
if self.delay:
await asyncio.sleep(self.delay)
if self.error is not None:
raise self.error
return EvalResult(value=self.value)
@pytest.mark.asyncio
async def test_the_payload_arrives_as_one_global_named_ctx():
# The convention every config script speaks. Spreading the payload's
# keys instead put `profile` in scope on one host and nothing on the
# other, which no test using a fake evaluator could see.
engine = Recorder("ok")
await eval_with_ctx("...", {"profile": "release"}, engine, 1.0)
assert engine.inputs == {"ctx": {"profile": "release"}}
assert CTX_GLOBAL == "ctx"
@pytest.mark.asyncio
async def test_it_answers_with_the_scripts_last_expression():
assert await eval_with_ctx("...", {}, Recorder({"a": 1}), 1.0) == {"a": 1}
@pytest.mark.asyncio
async def test_a_timeout_reaches_the_caller_unworded():
# Each caller refuses in its own voice and its own error type, so
# this raises the bare asyncio error rather than either layer's.
with pytest.raises(asyncio.TimeoutError):
await eval_with_ctx("...", {}, Recorder("ok", delay=0.2), 0.01)
@pytest.mark.asyncio
async def test_an_eval_failure_reaches_the_caller_unworded():
with pytest.raises(EvalError):
await eval_with_ctx("...", {}, Recorder(error=EvalError("boom")), 1.0)
def test_default_engines_name_monty_not_the_host_default():
# Deliberately not DEFAULT_PYTHON: the two hosts disagree about that
# (pyodide in TypeScript) for a reason that is about agent code
# reading files, which a config script never does. Naming one engine
# keeps one source producing one answer on either host.
assert DEFAULT_SCRIPT_ENGINES == {"python": "monty", "js": "quickjs"}
def test_every_default_engine_can_actually_evaluate():
for language, runtime in DEFAULT_SCRIPT_ENGINES.items():
assert issubclass(NAMED[runtime], EvaluatorMixin), language
def test_script_engine_defaults_to_the_language_engine():
engine = script_engine(ScriptSource("..."))
assert engine.name == "monty"
assert isinstance(engine, EvaluatorMixin)
def test_script_engine_takes_the_named_runtime():
engine = script_engine(ScriptSource("..."), "monty")
assert engine.name == "monty"
def test_script_engine_refuses_a_runtime_that_cannot_evaluate():
for runtime in ("local", "sandlock", "wasi"):
with pytest.raises(ValueError, match="cannot evaluate one"):
script_engine(ScriptSource("..."), runtime)
def test_script_engine_refuses_an_unknown_runtime():
with pytest.raises(ValueError, match="unknown runtime"):
script_engine(ScriptSource("..."), "nope")
def test_script_engine_refuses_a_runtime_of_the_wrong_language():
# Answered from the table before building, so a config naming the
# wrong engine reads as that, not as the engine's install hint.
with pytest.raises(ValueError, match="python, but names runtime"):
script_engine(ScriptSource("..."), "quickjs")
+3 -3
View File
@@ -103,7 +103,7 @@ async def test_delete_unknown_session_404():
@pytest.mark.asyncio
async def test_create_session_refuses_a_bare_mount_list():
# A list of prefixes used to mean "only these mounts". A role now
# A list of prefixes used to mean "only these mounts". A profile now
# narrows the mounts it names and never decides whether one exists,
# so the list would be a silent no-op that still reads like
# confinement: the door refuses it instead.
@@ -147,7 +147,7 @@ async def test_create_session_with_mount_modes():
@pytest.mark.asyncio
async def test_create_session_rejects_bad_role():
async def test_create_session_rejects_bad_profile():
app = build_app(idle_grace_seconds=10.0)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport,
@@ -167,7 +167,7 @@ async def test_create_session_rejects_bad_role():
@pytest.mark.asyncio
async def test_create_session_rejects_an_unknown_profile():
# PolicyError is not a ValueError, so naming an unknown role used to
# PolicyError is not a ValueError, so naming an unknown profile 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)
@@ -187,7 +187,7 @@ async def test_structure_fallback_serves_when_no_policy_objects():
@pytest.fixture
def scoped_session():
"""Bind a session whose role hides the parent mount's own content,
"""Bind a session whose profile hides the parent mount's own content,
leaving the mount nested below it reachable."""
session = Session(session_id="agent",
hidden_paths=HiddenPaths(paths=("/data/locked/other",
@@ -202,7 +202,7 @@ async def test_a_structure_answer_still_clears_the_sessions_hides(
scoped_session):
# The synthetic answer passes the session's view as well as the
# policy chain: it is produced above every backend, so a path the
# role hides would otherwise be served by the one code path that
# profile hides would otherwise be served by the one code path that
# asks no mount anything.
dispatcher, _ = _dispatcher(Policies())
_structure_only(dispatcher)
@@ -244,7 +244,7 @@ def test_handle_man_lists_only_the_verbs_the_allow_list_reaches():
assert "issue" in top
assert "team" not in top
# The same narrowing one level down, where the pattern names the
# only leaf the role holds.
# only leaf the profile holds.
inner = asyncio.run(handle_man(["linear", "issue"], reg,
session))[0].decode()
assert "create" in inner
@@ -17,11 +17,11 @@ import asyncio
import pytest
from mirage.policy import CommandRule
from mirage.policy.profile import CommandsBlock, SessionProfile
from mirage.resource import RAMResource
from mirage.types import MountMode
from mirage.workspace import Workspace
from mirage.workspace.executor.builtins.script import shebang_words
from mirage.workspace.session.permissions import CommandsBlock, SessionProfile
@pytest.fixture()
@@ -17,6 +17,7 @@ import errno
import pytest
from mirage.policy import PolicyDenied
from mirage.policy.profile import PathsBlock, SessionProfile
from mirage.policy.types import AdmissionRules, CommandRule
from mirage.resource.ram import RAMResource
from mirage.shell import parse
@@ -25,8 +26,6 @@ from mirage.workspace import Workspace
from mirage.workspace.expand.classify import classify_parts
from mirage.workspace.node.admission import (Admitted, admit, admit_line,
policy_scopes)
from mirage.workspace.session import SessionProfile
from mirage.workspace.session.permissions import PathsBlock
DOC = {
"commands": {
@@ -17,12 +17,12 @@ import asyncio
import pytest
from mirage.policy.match import Outcome
from mirage.policy.profile import CompiledProfile
from mirage.policy.types import AdmissionRules, CommandRule, Decision, Scope
from mirage.resource.ram import RAMResource
from mirage.types import HiddenPaths, HiddenVars, MountMode
from mirage.workspace import Workspace
from mirage.workspace.session import (CompiledProfile, RAMSessionStore,
SessionManager)
from mirage.workspace.session import RAMSessionStore, SessionManager
from mirage.workspace.session.state import seed_var
@@ -415,14 +415,14 @@ def test_commands_of_answers_the_sessions_own_rules():
late = mgr.create("late")
late.commands = own
assert mgr.commands_of("late") is own
# A session the role never narrowed states no rules, and so does an
# A session the profile never narrowed states no rules, and so does an
# id the manager does not know (the empty id of an unbound door
# included), unless a default role says otherwise.
# included), unless a default profile says otherwise.
assert mgr.commands_of("early") is None
assert mgr.commands_of("nobody") is None
assert mgr.commands_of("") is None
assert early.commands is None
# With a default role compiled in, an unknown id answers its rules
# With a default profile compiled in, an unknown id answers its rules
# rather than nothing, so an unbound door still fails toward refusal.
mgr.default_profile = CompiledProfile(
mount_modes=None,
@@ -8,7 +8,7 @@ from mirage.types import HiddenPaths, HiddenVars, MountMode, PathSpec
from mirage.utils.hidden import path_hidden
from mirage.workspace.session.session import Session
from mirage.workspace.session.permissions import ( # isort: skip
from mirage.policy.profile import ( # isort: skip
CommandsBlock, MountCommandsBlock, PathsBlock, ProfileMount,
SessionProfile, VarsBlock)
from mirage.workspace.session.resolve import ( # isort: skip
@@ -48,7 +48,7 @@ def test_with_inline_takes_the_weaker_mode_per_mount():
out = with_inline(base, inline)
assert out is not None and out.mounts is not None
# Every prefix either side names survives; a mount only the inline
# document names is not a grant, since a mount the role never named
# document names is not a grant, since a mount the profile never named
# was already reachable at its own mode.
assert out.mounts["/a"].mode is MountMode.WRITE
assert out.mounts["/b"].mode is MountMode.READ
@@ -112,7 +112,7 @@ def test_with_inline_adds_ask_and_deny_but_refuses_an_allow_list():
deny=(CommandRule(reason="no", commands=("mv", )), )))
out = with_inline(base, inline)
assert out is not None and out.commands is not None
# The allow list is the role's alone, and the added rules land after
# The allow list is the profile's alone, and the added rules land after
# it: an inline document restricts, it never installs.
assert out.commands.allow == ("ls", "git", "cat")
assert [r.commands for r in out.commands.ask] == [("git push", )]
@@ -120,9 +120,9 @@ 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
# And with no profile 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.
# default profile must not quietly accept what one with a profile refuses.
with pytest.raises(PolicyError, match="not an allow list"):
with_inline(None,
SessionProfile(commands=CommandsBlock(allow=("wc", ))))
@@ -239,8 +239,9 @@ def test_compile_profile_collects_the_hides_of_every_mount_section():
patterns=("/repo/*.pem", ))
assert path_hidden(out.hidden_paths, "/repo/deep/key.pem")
assert not path_hidden(out.hidden_paths, "/scratch/key.pem")
role = compile_profile(SessionProfile(paths=PathsBlock(hide=("*.pem", ))))
assert path_hidden(role.hidden_paths, "/scratch/key.pem")
profile = compile_profile(
SessionProfile(paths=PathsBlock(hide=("*.pem", ))))
assert path_hidden(profile.hidden_paths, "/scratch/key.pem")
def test_compile_profile_of_a_bare_or_absent_role_states_nothing():
@@ -249,7 +250,7 @@ def test_compile_profile_of_a_bare_or_absent_role_states_nothing():
empty.env, empty.cwd, empty.commands) == (None, None, None, None,
None, None)
assert compile_profile(SessionProfile()) == empty
# A role that names a mount without a mode narrows nothing: the
# A profile that names a mount without a mode narrows nothing: the
# mount keeps whatever the workspace gave it.
assert compile_profile(
SessionProfile(mounts={"/a": ProfileMount()})).mount_modes is None
+5 -5
View File
@@ -246,7 +246,7 @@ def test_visible_env_filters_reads_without_copying():
def test_a_shaped_write_gates_the_value_that_lands():
# `declare -l role; role=ADMIN` stores `admin`, so a rule refusing
# `declare -l profile; profile=ADMIN` stores `admin`, so a rule refusing
# `admin` has to see `admin`, not the raw text: coercion runs
# before the gate.
seen: list[str | None] = []
@@ -261,11 +261,11 @@ def test_a_shaped_write_gates_the_value_that_lands():
async def run():
view, session = _view(Policies([Capture()]))
seed_var(session, "role", "")
set_attr(session, "role", VarAttr.LOWER)
seed_var(session, "profile", "")
set_attr(session, "profile", VarAttr.LOWER)
with pytest.raises(PolicyDenied):
await view.set("role", "ADMIN")
assert session.env["role"] == ""
await view.set("profile", "ADMIN")
assert session.env["profile"] == ""
seed_var(session, "n", "0")
set_attr(session, "n", VarAttr.INTEGER)
await view.set("n", "3+4")
@@ -29,7 +29,7 @@ def _seed(name: str, body: bytes) -> RAMResource:
def test_a_hidden_mount_reads_as_absent():
# A role narrows the mounts it names and never decides whether one
# A profile narrows the mounts it names and never decides whether one
# exists, so keeping a session away from a mount is a hide, and a
# hide answers ENOENT: naming the mount in a refusal would confirm
# to the agent exactly what it was not meant to know is there.
+1 -1
View File
@@ -68,7 +68,7 @@ def _two_mounts(policies=None) -> Workspace:
def test_fuse_symlink_on_hidden_turf_is_refused():
# The R8 hole: a session-scoped kernel mount could create a link on
# a mount the role hides, because the FUSE symlink path wrote the
# a mount the profile hides, because the FUSE symlink path wrote the
# namespace table directly, at a layer no session view covers.
ws = _two_mounts()
sess = ws.create_session("agent", profile={"paths": {"hide": ["/b"]}})
@@ -93,7 +93,7 @@ async def test_cmdsub_reads_the_named_sessions_cwd():
@pytest.mark.asyncio
async def test_cmdsub_keeps_the_named_sessions_hides():
# A nested eval runs under the same session, so what the role hides
# A nested eval runs under the same session, so what the profile hides
# is as absent inside `$()` as outside it.
ws = _two_mounts()
ws.create_session("agent", profile={"paths": {"hide": ["/b"]}})
@@ -65,7 +65,7 @@ def test_mount_carries_backend_and_mountpoint():
def test_a_mount_carries_no_permissions():
# Permissions live in one document, the role, so a mount states
# Permissions live in one document, the profile, so a mount states
# infrastructure only: what it is, where it is, how it is served.
with pytest.raises(TypeError):
Mount(resource=RAMResource(), permissions={"paths": {"hide": ["x"]}})
+165 -13
View File
@@ -21,16 +21,17 @@ from mirage.commands.builtin.utils.limit import LimitExceededError
from mirage.io import IOResult
from mirage.policy import (CommandRule, ExecuteResultContext, OpsContext,
OpsResultContext, PolicyError)
from mirage.policy.profile import SessionProfile
from mirage.resource.ram import RAMResource
from mirage.runtime.types import ScriptSource
from mirage.types import Limit, MountMode, OnExceed
from mirage.workspace.session import SessionProfile
from mirage.workspace.session.permissions import ( # isort: skip
from mirage.policy.profile import ( # isort: skip
CommandsBlock, PathsBlock)
def _role(**blocks) -> dict[str, SessionProfile]:
"""The workspace's default role, spelled as one document.
def _profile(**blocks) -> dict[str, SessionProfile]:
"""The workspace's default profile, spelled as one document.
Permissions live in exactly one place now, so what these tests used
to pass as `permissions=` is `profiles.default`, which shapes the
@@ -52,7 +53,7 @@ async def test_workspace_guards_refuse_before_backend_io():
ws = Workspace(
{"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles=_role(commands=CommandsBlock(
profiles=_profile(commands=CommandsBlock(
deny=(CommandRule(reason="production data is protected",
commands=("rm", ),
paths=("/data/prod/*", )), ))),
@@ -111,7 +112,7 @@ async def test_guards_cover_shell_builtins_and_namespace_routes():
ws = Workspace(
{"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles=_role(commands=CommandsBlock(deny=(
profiles=_profile(commands=CommandsBlock(deny=(
CommandRule(reason="disabled", commands=("source", )),
CommandRule(reason="frozen",
commands=("touch", ),
@@ -138,7 +139,7 @@ async def test_guards_cover_path_valued_flags():
ws = Workspace(
{"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles=_role(commands=CommandsBlock(
profiles=_profile(commands=CommandsBlock(
deny=(CommandRule(reason="prod is protected",
commands=("shuf", ),
paths=("/data/prod/*", )), ))),
@@ -169,7 +170,7 @@ async def test_path_guards_hold_at_the_programmatic_door():
ws = Workspace(
{"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles=_role(commands=CommandsBlock(deny=(
profiles=_profile(commands=CommandsBlock(deny=(
CommandRule(reason="prod is protected", paths=(
"/data/prod/*", )), ))),
)
@@ -573,12 +574,12 @@ async def test_post_execute_sees_the_rightmost_producer():
@pytest.mark.asyncio
async def test_role_hides_bind_every_session_including_the_default():
async def test_profile_hides_bind_every_session_including_the_default():
ram = RAMResource()
ws = Workspace({"/data/": ram},
mode=MountMode.WRITE,
profiles=_role(paths=PathsBlock(hide=("/data/finance",
"*.key"))))
profiles=_profile(paths=PathsBlock(hide=("/data/finance",
"*.key"))))
try:
await ws.execute("mkdir -p /data/finance /data/pub")
await ws.ops.write("/data/pub/a.txt", b"a\n")
@@ -588,7 +589,7 @@ async def test_role_hides_bind_every_session_including_the_default():
assert b"finance" not in listing.stdout
assert b"b.key" not in listing.stdout
assert b"a.txt" in listing.stdout
# ... and neither can one created later from the same role.
# ... and neither can one created later from the same profile.
ws.create_session("late")
gone = await ws.execute("cat /data/pub/b.key", session_id="late")
assert gone.exit_code != 0
@@ -652,10 +653,161 @@ async def test_a_bare_name_under_deny_refuses_with_the_default_reason():
# is one command name with the default reason.
ws = Workspace({"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles=_role(commands=CommandsBlock(deny=("shred", ))))
profiles=_profile(commands=CommandsBlock(deny=("shred", ))))
try:
result = await ws.execute("shred /data/x")
assert result.exit_code == 126
assert result.stderr == b"shred: policy denied: denied by policy\n"
finally:
await ws.close()
GOOD_PROFILE = "{'commands': {'allow': ['ls', 'cat', 'echo']}}"
def _scripted(**sources: str) -> dict[str, dict[str, ScriptSource]]:
"""Profiles produced by scripts, one source per profile name.
Args:
sources (str): the program that produces each named profile.
"""
return {
name: {
"script": ScriptSource(src)
}
for name, src in sources.items()
}
@pytest.mark.asyncio
async def test_a_scripted_profile_is_the_permissions_its_script_produced():
ws = Workspace({"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles=_scripted(release=GOOD_PROFILE))
try:
await ws.ensure_sessions_loaded()
ws.create_session("s", profile="release")
assert (await ws.execute("echo hi", session_id="s")).exit_code == 0
denied = await ws.execute("rm /data/x", session_id="s")
assert denied.exit_code == 127
finally:
await ws.close()
@pytest.mark.asyncio
async def test_a_scripted_profile_is_not_ready_before_hydration():
ws = Workspace({"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles=_scripted(release=GOOD_PROFILE))
try:
with pytest.raises(PolicyError, match="ensure_sessions_loaded"):
ws.create_session("s", profile="release")
finally:
await ws.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("order", [("bad", "good"), ("good", "bad")])
async def test_one_broken_profile_refuses_every_scripted_profile(order):
# Whether a profile is usable must not depend on where it sits in the
# mapping: keeping each result as it arrived left the profiles ahead of
# the broken one done and the ones behind it still scripts.
sources = {"good": GOOD_PROFILE, "bad": "raise ValueError('boom')"}
ws = Workspace({"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles=_scripted(**{n: sources[n]
for n in order}))
try:
with pytest.raises(PolicyError, match="profile 'bad' script failed"):
await ws.ensure_sessions_loaded()
with pytest.raises(PolicyError, match="ensure_sessions_loaded"):
ws.create_session("s", profile="good")
finally:
await ws.close()
@pytest.mark.asyncio
async def test_hydrating_twice_runs_a_profile_script_once():
ws = Workspace({"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles=_scripted(release=GOOD_PROFILE))
try:
await ws.ensure_sessions_loaded()
await ws.ensure_sessions_loaded()
ws.create_session("s", profile="release")
assert (await ws.execute("echo hi", session_id="s")).exit_code == 0
finally:
await ws.close()
@pytest.mark.asyncio
async def test_a_profile_script_runs_in_a_world_with_no_evaluator():
# A profile is operator configuration, so the engine that produces it
# is a property of the profile. The runtime world is the ordered set
# that serves *agent* code: it is mutable after construction and
# drops entries silently when an optional dependency is missing, so a
# profile resolved out of it would stop working for reasons that have
# nothing to do with the profile.
ws = Workspace({"/data/": RAMResource()},
mode=MountMode.WRITE,
runtimes=["vfs"],
profiles=_scripted(release=GOOD_PROFILE))
try:
await ws.ensure_sessions_loaded()
ws.create_session("s", profile="release")
assert (await ws.execute("echo hi", session_id="s")).exit_code == 0
finally:
await ws.close()
@pytest.mark.asyncio
async def test_a_profile_may_name_the_engine_its_script_runs_on():
ws = Workspace({"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles={
"release": {
"script": ScriptSource(GOOD_PROFILE),
"runtime": "monty"
}
})
try:
await ws.ensure_sessions_loaded()
ws.create_session("s", profile="release")
assert (await ws.execute("echo hi", session_id="s")).exit_code == 0
finally:
await ws.close()
@pytest.mark.asyncio
async def test_a_scripted_default_profile_shapes_the_default_session():
# The constructor compiles the default profile before its script
# runs, which is the script-only placeholder; hydration recompiles
# it, or the primary agent keeps running under empty permissions.
ws = Workspace({"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles=_scripted(release=GOOD_PROFILE),
profile="release")
try:
await ws.ensure_sessions_loaded()
assert (await ws.execute("echo hi")).exit_code == 0
denied = await ws.execute("rm /data/x")
assert denied.exit_code == 127
finally:
await ws.close()
@pytest.mark.asyncio
async def test_a_profile_naming_an_engine_that_cannot_evaluate_is_refused():
ws = Workspace({"/data/": RAMResource()},
mode=MountMode.WRITE,
profiles={
"release": {
"script": ScriptSource(GOOD_PROFILE),
"runtime": "local"
}
})
try:
with pytest.raises(PolicyError, match="cannot evaluate one"):
await ws.ensure_sessions_loaded()
finally:
await ws.close()
+2 -2
View File
@@ -56,13 +56,13 @@ export function registerSessionCommands(program: Command): void {
"narrow a mount's mode for this session: '/data:read' (alias '/data:r'), " +
"'/scratch:rw', '/bin:rwx', or a bare '/data' to keep the mount's own " +
'mode; repeatable. This narrows only: a mount you do not name keeps its ' +
'own mode, and keeping a session away from one is a hide in its role',
'own mode, and keeping a session away from one is a hide in its profile',
(value: string, prev: string[]) => prev.concat([value]),
[] as string[],
)
.option(
'-p, --profile <name>',
"the role this session runs under, by name from the workspace's profiles; " +
"the profile this session runs under, by name from the workspace's profiles; " +
'omit it to take the workspace default',
)
.action(async (wsId: string, opts: { id?: string; mount?: string[]; profile?: string }) => {
@@ -207,7 +207,7 @@ const ENOENT_TEXT = 'No such file or directory'
* Where a dispatcher is wired it replaces the bound backend's stat,
* because that stat sees one accessor and knows nothing of hides:
* trusting it answered `0 <path>` for a hidden directory and confirmed
* to the agent what the role was not meant to show it. The content probe
* to the agent what the profile was not meant to show it. The content probe
* behind it counts only what the session may see, so it cannot re-open
* what the first channel closed.
*/
@@ -50,12 +50,12 @@ describe('weakerMode', () => {
})
})
describe('a role narrows the mounts it names', () => {
describe('a profile narrows the mounts it names', () => {
it('no bound session is unrestricted', () => {
expect(effectiveMountMode('/anything', MountMode.WRITE)).toBe(MountMode.WRITE)
})
it('a role naming no mount keeps every mode', async () => {
it('a profile naming no mount keeps every mode', async () => {
await runWithSession(new Session({ sessionId: 'free' }), () => {
expect(effectiveMountMode('/s3', MountMode.EXEC)).toBe(MountMode.EXEC)
return Promise.resolve()
@@ -85,11 +85,11 @@ describe('a role narrows the mounts it names', () => {
})
})
it('a mount the role does not name keeps its own mode', async () => {
it('a mount the profile does not name keeps its own mode', async () => {
// Naming three mounts is not an allowlist: a fourth is reachable at
// whatever the workspace gave it. A role that must not touch a
// whatever the workspace gave it. A profile that must not touch a
// mount hides it, which reads as ENOENT rather than as a permission
// error naming something the role cannot see.
// error naming something the profile cannot see.
await runWithSession(narrowedSession(), () => {
expect(effectiveMountMode('/other', MountMode.EXEC)).toBe(MountMode.EXEC)
expect(effectiveMountMode('/', MountMode.WRITE)).toBe(MountMode.WRITE)
@@ -148,8 +148,8 @@ describe('a binding belongs to the workspace that published it', () => {
})
describe('hides', () => {
it("a role's hides reach the predicate as paths and patterns", async () => {
// One list per session, built by the compiler from the role's own
it("a profile's hides reach the predicate as paths and patterns", async () => {
// One list per session, built by the compiler from the profile's own
// `paths.hide` and every mount section's, exact entries and glob
// patterns told apart once by `classifyPaths`.
const sess = new Session({
@@ -190,7 +190,7 @@ describe('hides', () => {
})
})
it('a hide activates the gate and a role without one does not', async () => {
it('a hide activates the gate and a profile without one does not', async () => {
const sess = new Session({ sessionId: 'agent', hiddenPaths: { paths: ['/repo/.env'] } })
await runWithSession(sess, () => {
expect(hiddenPathsActive()).toBe(true)
@@ -76,11 +76,11 @@ function normPrefix(mountPrefix: string): string {
* The current session's mode for this mount.
*
* `MountMode.EXEC` (no narrowing) when no session is bound, when the
* role names no mount, or when it names none for this one: a role's
* profile names no mount, or when it names none for this one: a profile's
* mount sections narrow what the mount already offers and never decide
* whether it exists. A role that must not reach a mount hides it, which
* whether it exists. A profile that must not reach a mount hides it, which
* answers ENOENT rather than a permission error naming something the
* role cannot see.
* profile cannot see.
*/
function sessionMode(mountPrefix: string): MountMode {
const sess = getCurrentSession()
@@ -138,7 +138,7 @@ export function sessionPathAllowed(sess: Session, virtual: string): boolean {
* enumeration surfaces filter names through it and the doors answer
* ENOENT (EACCES for creates) when it says no, so hiding reads as
* nonexistence, never as a denial that leaks the name. True when no
* session is bound. This is how a role keeps a session away from a
* session is bound. This is how a profile keeps a session away from a
* mount, since naming mounts only narrows their modes.
*/
export function pathAllowed(virtual: string): boolean {
@@ -232,10 +232,10 @@ export function redirectTargetJudged(virtual: string): boolean {
}
/**
* The mount mode after narrowing by the current session's role. The
* mount's own mode is the strongest one available; a role's mode can
* only weaken it (a READ mount stays read-only whatever the role says).
* A mount the role does not name keeps its own mode.
* The mount mode after narrowing by the current session's profile. The
* mount's own mode is the strongest one available; a profile's mode can
* only weaken it (a READ mount stays read-only whatever the profile says).
* A mount the profile does not name keeps its own mode.
*/
export function effectiveMountMode(mountPrefix: string, mountMode: MountMode): MountMode {
return weakerMode(mountMode, sessionMode(mountPrefix))
@@ -58,7 +58,7 @@ function ctx(
}
}
// One role, compiled: its own rules plus the ones its `mounts./repo`
// One profile, compiled: its own rules plus the ones its `mounts./repo`
// section carries, which the compiler stamped with that root.
const MOUNT_DENY: CommandRule = {
reason: 'history is read-only here',
@@ -155,7 +155,7 @@ describe('PermissionsPolicy', () => {
rules: [shallow],
})
// The other way round: an ask anchored deeper than a deny wins, so a
// role can carve an exception out of a broad refusal.
// profile can carve an exception out of a broad refusal.
const flipped = new PermissionsPolicy(
new Sessions({
s: {
@@ -25,7 +25,7 @@ import {
} from '../types.ts'
/**
* The role's `commands` rules, enforced.
* The profile's `commands` rules, enforced.
*
* Seeded by the workspace after `MountRootPolicy` (POSIX messages still
* win) and before user policies, so a document rule speaks before a
@@ -42,7 +42,7 @@ import {
* per operand by whether it names paths, or taken to the approval door
* when it asks. `preOps` walks the deny rules that are pure paths, so
* FUSE, programmatic ops and the warm cache cannot bypass a path the
* role protects; there is no ask at the op door, which cannot wait on a
* profile protects; there is no ask at the op door, which cannot wait on a
* host.
*/
export class PermissionsPolicy implements Policy {
@@ -27,14 +27,14 @@ function ctx(
}
describe('allow lists', () => {
it("headVisible answers the role's one allow list", () => {
it("headVisible answers the profile's one allow list", () => {
const rules: AdmissionRules = { allow: ['ls', 'git log'], ask: [], deny: [] }
// A name is visible when it starts a pattern of the list.
expect(headVisible('ls', rules)).toBe(true)
expect(headVisible('git', rules)).toBe(true)
expect(headVisible('cat', rules)).toBe(false)
expect(headVisible('rm', rules)).toBe(false)
// A role without a list hides nothing, and neither does no role.
// A profile without a list hides nothing, and neither does no profile.
expect(headVisible('rm', { allow: null, ask: [], deny: [{ reason: 'x' }] })).toBe(true)
expect(headVisible('rm', null)).toBe(true)
})
@@ -51,7 +51,7 @@ describe('allow lists', () => {
expect(nodeVisible(['linear', 'issue', 'create'], rules)).toBe(false)
// headVisible is the one-word case, and agrees.
expect(headVisible('linear', rules)).toBe(nodeVisible(['linear'], rules))
// A role without a list hides no node of any tree.
// A profile without a list hides no node of any tree.
expect(nodeVisible(['linear', 'team'], null)).toBe(true)
expect(nodeVisible(['linear', 'team'], { allow: null, ask: [], deny: [{ reason: 'x' }] })).toBe(
true,
@@ -18,7 +18,7 @@ import { patternMatches, patternReaches } from './pattern.ts'
/**
* Whether a session can see one node of a program tree.
*
* A role without an allow list hides nothing; a role with one hides every
* A profile without an allow list hides nothing; a profile with one hides every
* node no pattern of it reaches. Only a CLI's verbs can be narrowed this
* way, and only because the walk canonicalizes them (an alias resolved,
* the global options before the verb dropped) before any pattern is read.
@@ -31,8 +31,8 @@ export function nodeVisible(path: readonly string[], rules: AdmissionRules | nul
}
/**
* Whether a session can see a command at all. A role without an allow
* list hides nothing; a role with one hides every name none of its
* Whether a session can see a command at all. A profile without an allow
* list hides nothing; a profile with one hides every name none of its
* patterns start with. Grammar builtins and shell functions are the
* caller's exemptions, not this one's. The head-word case of
* `nodeVisible`.
@@ -50,7 +50,7 @@ export function lineTokens(ctx: CommandContext): readonly string[] {
}
/**
* Whether the role's allow list has a pattern for the whole line. A
* Whether the profile's allow list has a pattern for the whole line. A
* word that is not a tool (`ctx.tool` cleared by the door: shell grammar,
* the agent's own function, an executed path) is always allowed here; a
* deny rule is the only thing that can refuse it.
@@ -78,7 +78,7 @@ export function sourceOf(rule: CommandRule): string {
}
/**
* The role's answer about one line: the whole law, in one place.
* The profile's answer about one line: the whole law, in one place.
*
* 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,
@@ -22,7 +22,7 @@ import { hasRules, readsArgs, scopesPaths } from './reads.ts'
const EMPTY: AdmissionRules = { allow: null, ask: [], deny: [] }
describe('reads', () => {
it('hasRules is a role stating anything', () => {
it('hasRules is a profile stating anything', () => {
expect(hasRules(null)).toBe(false)
expect(hasRules(EMPTY)).toBe(false)
expect(hasRules({ ...EMPTY, allow: [] })).toBe(true)
@@ -16,7 +16,7 @@ import type { CommandRule, AdmissionRules } from '../types.ts'
import { patternNames, splitPattern } from './pattern.ts'
/**
* Whether the role states a command rule at all: an allow list, an ask
* Whether the profile states a command rule at all: an allow list, an ask
* or a deny.
*/
export function hasRules(rules: AdmissionRules | null): boolean {
@@ -14,9 +14,9 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_ASK_REASON, DEFAULT_DENY_REASON } from '../../policy/constants.ts'
import { MountMode } from '../../types.ts'
import { parseProfileMount, parseProfileMounts, parseSessionProfile } from './permissions.ts'
import { DEFAULT_ASK_REASON, DEFAULT_DENY_REASON } from './constants.ts'
import { MountMode } from '../types.ts'
import { parseProfileMount, parseProfileMounts, parseSessionProfile } from './profile.ts'
const mountSection = (raw: unknown): unknown => parseProfileMount(raw, '/repo', 'mounts[/repo]')
@@ -68,7 +68,7 @@ describe('parseSessionProfile', () => {
it('refuses a bare list of mounts', () => {
// A list used to mean "only these mounts are reachable"; a mount a
// role does not name now keeps its own mode, so the list would
// profile does not name now keeps its own mode, so the list would
// quietly drop the confinement it used to carry.
expect(() => parseSessionProfile({ mounts: ['/repo'] })).toThrow(
/mounts must be a mapping of prefix to its settings/,
@@ -252,7 +252,7 @@ describe('parseSessionProfile', () => {
},
)
it('refuses a blank hide entry, in a role and in a mount section', () => {
it('refuses a blank hide entry, in a profile and in a mount section', () => {
// "" is the root under the subtree rule: it would hide the whole tree.
expect(() => parseSessionProfile({ paths: { hide: ['/a', ''] } })).toThrow(
/hide\[1\] must name a path/,
@@ -12,15 +12,16 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { DEFAULT_ASK_REASON, DEFAULT_DENY_REASON } from '../../policy/constants.ts'
import type { CommandRule, AdmissionRules } from '../../policy/types.ts'
import type { HiddenPaths, HiddenVars } from '../../types.ts'
import { type MountMode, parseMountMode } from '../../types.ts'
import { isGlob } from '../../utils/hidden.ts'
import { stripSlash } from '../../utils/slash.ts'
import { DEFAULT_ASK_REASON, DEFAULT_DENY_REASON } from './constants.ts'
import type { CommandRule, AdmissionRules } from './types.ts'
import { ScriptSource } from '../runtime/policy/types.ts'
import type { HiddenPaths, HiddenVars } from '../types.ts'
import { type MountMode, parseMountMode } from '../types.ts'
import { isGlob } from '../utils/hidden.ts'
import { stripSlash } from '../utils/slash.ts'
/**
* `paths:` of a role, or of one of its mount sections. `hide` entries
* `paths:` of a profile, or of one of its mount sections. `hide` entries
* use the document's one grammar: an entry with `*`, `?` or `[` is a
* pattern, anything else an exact path and its subtree
* (`utils/hidden.classifyPaths`); every entry holds a token and is
@@ -37,8 +38,8 @@ export interface VarsBlock {
}
/**
* `commands:` at the top level of a role. `allow` lists the command
* patterns the role installs; a name none of them starts with is not a
* `commands:` at the top level of a profile. `allow` lists the command
* patterns the profile installs; a name none of them starts with is not a
* command for the session (127, absent from `type` / `which` / `man`),
* a line no pattern covers is refused. The shell's own grammar builtins
* and the agent's functions are not subjects. `ask` rules are admitted
@@ -66,12 +67,12 @@ export interface MountCommandsBlock {
}
/**
* One mount's entry in a role: what this role may do there. Every field
* One mount's entry in a profile: what this profile may do there. Every field
* is optional, and an omitted mount is not a refusal: the mount is
* reachable at the mode it declares in the workspace's `mounts:`, which
* a role can only weaken (`weakerMode`), never raise. A role that must
* a profile can only weaken (`weakerMode`), never raise. A profile that must
* not touch a mount hides it, so the mount reads as nonexistent rather
* than as a permission error naming something the role cannot see.
* than as a permission error naming something the profile cannot see.
*
* `commands` here carries ask and deny only: an allow list installs a
* command for the whole session, and visibility is answered before any
@@ -87,18 +88,18 @@ export interface ProfileMount {
}
/**
* One role: the whole permission document a session runs under.
* One profile: the whole permission document a session runs under.
*
* A session is created from exactly one of these, and it is the only
* place permissions are written. There is no workspace-wide block and
* no mount-owned block above it, so reading this object is reading
* everything the role may do; what a role does not say, it does not
* everything the profile may do; what a profile does not say, it does not
* restrict. Configuration, not enforcement: the resolver compiles it
* onto the session's narrowing fields and the doors keep enforcing.
* Deliberately not named a View, which per the view convention is a
* door-scoped handle an agent holds, while a profile is what the
* embedder uses to *define* one. Immutable by type, so two agents with
* the same role share one object and neither can bend the other's view.
* the same profile share one object and neither can bend the other's view.
*
* Two rules decide a line against it, and they are the whole law. A
* rule naming no path is read by verb (deny before ask before allow),
@@ -116,10 +117,23 @@ export interface SessionProfile {
readonly paths?: PathsBlock | null
readonly vars?: VarsBlock | null
readonly commands?: CommandsBlock | null
/**
* A program that writes this profile, instead of the fields above. A
* string is the path form the config door accepts and loads; code
* passes the loaded ScriptSource, so a path still spelled as a string
* when the workspace reads it means the config layer never saw it.
*/
readonly script?: ScriptSource | string | null
/**
* The engine `script` runs on. Unset picks the sandboxed engine for
* the script's language. Meaningless without a script, so stating one
* there is an error rather than a knob that does nothing.
*/
readonly runtime?: string | null
}
/**
* The session fields a role compiles to. `commands` is the role's
* The session fields a profile compiles to. `commands` is the profile's
* admission rules, its own and its mount sections' in one list.
*/
export interface CompiledProfile {
@@ -137,7 +151,21 @@ const VARS_FIELDS = ['hide'] as const
const COMMANDS_FIELDS = ['allow', 'ask', 'deny'] as const
const MOUNT_COMMANDS_FIELDS = ['ask', 'deny'] as const
const PROFILE_MOUNT_FIELDS = ['mode', 'commands', 'paths'] as const
const PROFILE_FIELDS = ['cwd', 'env', 'mounts', 'paths', 'vars', 'commands'] as const
const PROFILE_FIELDS = [
'cwd',
'env',
'mounts',
'paths',
'vars',
'commands',
'script',
'runtime',
] as const
// The fields a scripted profile may not also state: its script writes them,
// so one beside it would be a second author for one document with no
// rule saying which wins.
const PROFILE_DOCUMENT_FIELDS = ['cwd', 'env', 'mounts', 'paths', 'vars', 'commands'] as const
// A document mapping, not merely "an object": a Set, a Date or any class
// instance has no own enumerable string keys, so Object.entries would read
@@ -333,7 +361,7 @@ export function parseCommandsBlock(raw: unknown, where = 'commands'): CommandsBl
rejectUnknownKeys(obj, COMMANDS_FIELDS, where)
const ask = parseRules(obj.ask, where, 'ask')
const deny = parseRules(obj.deny, where, 'deny')
// This block is the role's own, never a mount section's, so a rule's
// This block is the profile's own, never a mount section's, so a rule's
// paths are virtual paths: absolute, or name patterns.
for (const rule of ask) requireAbsolute(rule.paths ?? [], `${where}.ask rule paths`)
for (const rule of deny) requireAbsolute(rule.paths ?? [], `${where}.deny rule paths`)
@@ -347,7 +375,7 @@ export function parseMountCommandsBlock(raw: unknown, where = 'commands'): Mount
return { ask: parseRules(obj.ask, where, 'ask'), deny: parseRules(obj.deny, where, 'deny') }
}
/** Validate one `mounts.<prefix>` section of a role. */
/** Validate one `mounts.<prefix>` section of a profile. */
export function parseProfileMount(raw: unknown, root: string, where: string): ProfileMount {
// A bare mode string is sugar for the section that carries only a mode.
const obj = typeof raw === 'string' ? { mode: raw } : asObject(raw, where)
@@ -374,7 +402,7 @@ export function parseProfileMount(raw: unknown, root: string, where: string): Pr
}
/**
* Normalize a role's `mounts` mapping: prefix to its settings, with a
* Normalize a profile's `mounts` mapping: prefix to its settings, with a
* bare mode string as sugar for a section carrying only a mode. A bare
* list used to mean "only these mounts" and now means nothing at all,
* so it fails loudly rather than quietly dropping the confinement it
@@ -409,7 +437,33 @@ export function parseSessionProfile(raw: unknown, where = 'profile'): SessionPro
paths?: PathsBlock | null
vars?: VarsBlock | null
commands?: CommandsBlock | null
script?: ScriptSource | string | null
runtime?: string | null
} = {}
if (obj.script !== undefined && obj.script !== null) {
if (!(obj.script instanceof ScriptSource) && typeof obj.script !== 'string') {
throw new Error(`${where}.script must be a script path or source`)
}
const stated = PROFILE_DOCUMENT_FIELDS.filter(
(field) => obj[field] !== undefined && obj[field] !== null,
)
if (stated.length > 0) {
throw new Error(
`${where} states either script or its document, not both; ` +
`script is set beside ${stated.join(', ')}`,
)
}
out.script = obj.script
}
if (obj.runtime !== undefined && obj.runtime !== null) {
if (typeof obj.runtime !== 'string') throw new Error(`${where}.runtime must be a string`)
if (out.script === undefined) {
throw new Error(
`${where}.runtime names the engine a script runs on, and this profile states no script`,
)
}
out.runtime = obj.runtime
}
if (obj.cwd !== undefined && obj.cwd !== null) {
if (typeof obj.cwd !== 'string') throw new Error(`${where}.cwd must be a string`)
out.cwd = obj.cwd
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest'
import { EVALUATOR, type Evaluator } from '../runtime/mixin.ts'
import { EvalError } from '../runtime/errors.ts'
import { ScriptSource } from '../runtime/policy/types.ts'
import type { EvalResult, EvalValue } from '../runtime/types.ts'
import { PolicyError } from './errors.ts'
import { parseSessionProfile } from './profile.ts'
import { permissionsFromScript, permissionsFromScripts, scriptContext } from './script.ts'
const DOC = { commands: { allow: ['ls', 'cat'] }, cwd: '/repo' }
class FakeEngine implements Evaluator {
readonly [EVALUATOR] = true as const
seen: Record<string, EvalValue> = {}
code = ''
constructor(
private readonly value: EvalValue = null,
private readonly error: Error | null = null,
) {}
eval(
code: string,
opts?: { inputs?: Record<string, EvalValue>; session?: string },
): Promise<EvalResult> {
this.code = code
this.seen = { ...(opts?.inputs ?? {}) }
if (this.error !== null) return Promise.reject(this.error)
return Promise.resolve({
value: this.value,
stdout: new Uint8Array(),
stderr: null,
exitCode: 0,
status: 'complete',
})
}
}
describe('scriptContext', () => {
it('names the profile and the mounts', () => {
expect(scriptContext('release', ['/repo/', '/scratch/'])).toEqual({
profile: 'release',
mounts: ['/repo/', '/scratch/'],
})
})
it('carries nothing per session', () => {
// The script runs once for the profile, so a per-session fact
// reaching it would be a promise the one evaluation cannot keep.
const ctx = scriptContext('release', [])
expect('session_id' in ctx).toBe(false)
expect('agent_id' in ctx).toBe(false)
})
})
describe('permissionsFromScript', () => {
it('validates the produced permissions', async () => {
const produced = await permissionsFromScript(
'release',
new ScriptSource('...', 'js'),
scriptContext('release', ['/repo/']),
new FakeEngine(DOC),
)
expect(produced.cwd).toBe('/repo')
expect(produced.commands?.allow).toEqual(['ls', 'cat'])
})
it('shows the script its context', async () => {
const engine = new FakeEngine(DOC)
const ctx = scriptContext('release', ['/repo/'])
await permissionsFromScript('release', new ScriptSource('SOURCE', 'js'), ctx, engine)
expect(engine.code).toBe('SOURCE')
expect(engine.seen).toEqual({ ctx })
})
it('refuses a script that threw', async () => {
const engine = new FakeEngine(null, new EvalError('boom'))
await expect(
permissionsFromScript('release', new ScriptSource('...', 'js'), {}, engine),
).rejects.toThrow(/script failed: boom/)
})
it('names a syntax error as one', async () => {
const engine = new FakeEngine(null, new EvalError('bad token', { syntax: true }))
await expect(
permissionsFromScript('release', new ScriptSource('...', 'js'), {}, engine),
).rejects.toThrow(/script syntax error/)
})
it.each([[null], [[1, 2]], ['commands'], [7]])(
'refuses %j instead of permissions',
async (value) => {
// Empty permissions restrict nothing, so a wrong shape must never
// coerce to one; every arm here has to throw rather than fall back.
const engine = new FakeEngine(value as EvalValue)
await expect(
permissionsFromScript('release', new ScriptSource('...', 'js'), {}, engine),
).rejects.toThrow(/must end in the permissions/)
},
)
it('refuses permissions that are not valid', async () => {
const engine = new FakeEngine({ commands: { allow: 'ls' } })
await expect(
permissionsFromScript('release', new ScriptSource('...', 'js'), {}, engine),
).rejects.toThrow(/not valid/)
})
it('refuses a script that produced a script', async () => {
const engine = new FakeEngine({ script: 'roles/other.py' })
await expect(
permissionsFromScript('release', new ScriptSource('...', 'js'), {}, engine),
).rejects.toThrow(/produced another script/)
})
it('names the profile in every refusal', async () => {
const engine = new FakeEngine([])
await expect(
permissionsFromScript('release', new ScriptSource('...', 'js'), {}, engine),
).rejects.toThrow(/profile 'release' script/)
})
it('throws the policy error type', async () => {
const engine = new FakeEngine([])
await expect(
permissionsFromScript('release', new ScriptSource('...', 'js'), {}, engine),
).rejects.toBeInstanceOf(PolicyError)
})
})
describe('permissionsFromScripts', () => {
it('refuses a script still spelled as a path', async () => {
const scripted = { release: parseSessionProfile({ script: 'roles/x.py' }) }
await expect(permissionsFromScripts(scripted, [])).rejects.toThrow(/names a script by path/)
})
})
@@ -0,0 +1,135 @@
import { CommandTimeoutError } from '../commands/builtin/utils/limit.ts'
import type { Runtime } from '../runtime/base.ts'
import { EvalError } from '../runtime/errors.ts'
import type { Evaluator } from '../runtime/mixin.ts'
import type { ScriptSource } from '../runtime/policy/types.ts'
import { evalWithCtx, scriptEngine } from '../runtime/script.ts'
import type { EvalValue } from '../runtime/types.ts'
import { PolicyError } from './errors.ts'
import { parseSessionProfile, type SessionProfile } from './profile.ts'
export const SCRIPT_EVAL_TIMEOUT_SECONDS = 10.0
/**
* What a profile's script is told about the workspace.
*
* Deliberately small, and deliberately not per session: the script runs
* once for the profile, so it is told which profile it produces
* permissions for and where the mounts are, and nothing that varies
* between the sessions later created under it. A rule that depends on
* *who* is asking is the caller's to make by naming a different
* profile.
*/
export function scriptContext(name: string, mounts: readonly string[]): Record<string, EvalValue> {
return { profile: name, mounts: [...mounts] }
}
/** The one refusal wording, so every failure arm reads alike. */
function refuse(name: string, detail: string): PolicyError {
return new PolicyError(`profile '${name}' script ${detail}`)
}
/**
* Run one profile's script and validate the permissions it produced.
*
* Every failure arm throws, and none of them falls back to empty
* permissions: permissions that say nothing restrict nothing, so a
* script that threw, timed out or answered with the wrong shape would
* silently produce an unrestricted session, the opposite of what
* stating the script asked for.
*/
export async function permissionsFromScript(
name: string,
script: ScriptSource,
context: Record<string, EvalValue>,
evaluator: Evaluator,
): Promise<SessionProfile> {
let value: EvalValue
try {
value = await evalWithCtx(
script.source,
context,
evaluator,
SCRIPT_EVAL_TIMEOUT_SECONDS,
`profile '${name}' script`,
)
} catch (err) {
if (err instanceof CommandTimeoutError) {
throw refuse(name, `timed out after ${String(SCRIPT_EVAL_TIMEOUT_SECONDS)}s`)
}
if (err instanceof EvalError) {
throw refuse(name, `${err.syntax ? 'syntax error' : 'failed'}: ${err.message}`)
}
throw refuse(name, `failed: ${err instanceof Error ? err.message : String(err)}`)
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
// No type named: python and TypeScript have different words for
// the same value (list/object, str/string), so quoting one would
// make the two hosts word one failure differently.
throw refuse(name, 'must end in the permissions it produces')
}
let produced: SessionProfile
try {
produced = parseSessionProfile(value, `profile '${name}' script`)
} catch (err) {
throw refuse(name, `produced permissions that are not valid: ${(err as Error).message}`)
}
if (produced.script != null) {
throw refuse(name, 'produced another script; a script produces permissions')
}
return produced
}
/**
* Run every profile's script, returning the permissions per name.
*
* All of them run before any result is returned, so one broken profile
* refuses the whole set rather than leaving the profiles that happened
* to be evaluated first done and the rest still scripts; without that,
* whether a session could be created depended on where its profile sat
* in the mapping. Permissions are operator configuration, so a
* workspace that cannot realize what it was given does not serve;
* every refusal names the profile.
*
* Engines are shared per kind rather than built per profile: each is a
* worker, so building one for every scripted profile would spawn N of
* them to run N short programs.
*/
export async function permissionsFromScripts(
scripted: Readonly<Record<string, SessionProfile>>,
mounts: readonly string[],
): Promise<Record<string, SessionProfile>> {
const produced: Record<string, SessionProfile> = {}
const engines = new Map<string, Runtime & Evaluator>()
try {
for (const [name, profile] of Object.entries(scripted)) {
const script = profile.script
if (typeof script === 'string') {
throw new PolicyError(
`profile '${name}' names a script by path ('${script}'); ` +
`only the config door loads one, pass ScriptSource in code`,
)
}
if (script == null) continue
let engine: Runtime & Evaluator
try {
engine = scriptEngine(script, profile.runtime ?? null)
} catch (err) {
const detail = err instanceof Error ? err.message : String(err)
throw new PolicyError(`profile '${name}' ${detail}`)
}
const cached = engines.get(engine.name)
if (cached === undefined) engines.set(engine.name, engine)
else engine = cached
produced[name] = await permissionsFromScript(
name,
script,
scriptContext(name, mounts),
engine,
)
}
} finally {
for (const engine of engines.values()) await engine.close()
}
return produced
}
+8 -8
View File
@@ -37,7 +37,7 @@ interface MountRootQuery {
export type DenyScope = 'command' | 'operand'
/**
* What the role's rules say about one line: the document's own three
* What the profile's rules say about one line: the document's own three
* verbs and nothing else.
*
* ALLOW is silence as well as consent, since a line no rule speaks
@@ -70,7 +70,7 @@ export interface Deny {
* One admission rule of the permissions document: refuse (or ask about)
* matching commands, on matching paths when it names any. It is the
* compiled element of `commands.deny` and `commands.ask` wherever the
* role writes one, and reaches the workspace only inside that document;
* profile writes one, and reaches the workspace only inside that document;
* the internal
* RulePolicy is what evaluates it. The document writes a rule in one of
* three shapes, and each compiles to rules of this shape: a list of
@@ -100,7 +100,7 @@ export interface CommandRule {
mount?: string
}
/** The role's answer about one line, and what produced it. */
/** The profile's answer about one line, and what produced it. */
export interface Ruling {
/** Which verb spoke. */
readonly outcome: Outcome
@@ -242,14 +242,14 @@ export interface SessionDecisionsQuery {
}
/**
* One role's admission rules, compiled: the whole permission document a
* One profile's admission rules, compiled: the whole permission document a
* session runs under. A session is evaluated against exactly one of
* these. It holds the role's allow list, its ask and deny rules, and
* these. It holds the profile's allow list, its ask and deny rules, and
* the rules its mount sections carry, each stamped with the mount it
* was written under so it applies to a line working inside that mount.
* There is nothing above it and nothing beside it: two rules that both
* match are resolved by anchor depth, then by verb (`policy/match/
* decide`). `allow` null when the role states no list.
* decide`). `allow` null when the profile states no list.
*/
export interface AdmissionRules {
allow: readonly string[] | null
@@ -269,7 +269,7 @@ export type LiveRules = readonly (readonly [Outcome, CommandRule])[]
* SessionManager satisfies it structurally, so the policy reads the
* rules by session id without this package importing the workspace.
* An id the manager does not know (or the empty id of an unbound door)
* answers the default role's rules, so it still fails toward refusal.
* answers the default profile's rules, so it still fails toward refusal.
*/
export interface SessionCommandsQuery {
commandsOf(sessionId: string): AdmissionRules | null
@@ -413,7 +413,7 @@ export interface Explanation {
readonly command: string
/** The words after it. */
readonly argv: readonly string[]
/** What the role's rules say. */
/** What the profile's rules say. */
readonly outcome: Outcome
/** The rule that spoke, null when the allow list did or nothing did. */
readonly rule: CommandRule | null
@@ -60,13 +60,13 @@ async function structureWorld(): Promise<Workspace> {
}
/**
* Two mounts, a role that hides the second.
* Two mounts, a profile that hides the second.
*
* `/open` (`pub.txt`) is reachable by session `agent`; `/closed`
* (`sec.txt`) is hidden from it. A hide, not an omitted mount: a role
* (`sec.txt`) is hidden from it. A hide, not an omitted mount: a profile
* narrows what it names and a mount it never names keeps its own mode,
* so hiding is how a deployment puts a mount out of reach, and it
* answers ENOENT rather than a refusal naming what the role cannot see.
* answers ENOENT rather than a refusal naming what the profile cannot see.
*/
async function scopedWorld(): Promise<Workspace> {
const parser = await getTestParser()
@@ -14,8 +14,9 @@
import type { Runtime } from '../base.ts'
import { LanguageRuntime } from '../language.ts'
import { evalWithCtx } from '../script.ts'
import { bindCommands, catchAll, runtimeBindingsFor } from '../table.ts'
import { CommandTimeoutError, runWithTimeout } from '../../commands/builtin/utils/limit.ts'
import { CommandTimeoutError } from '../../commands/builtin/utils/limit.ts'
import { EvalError } from '../errors.ts'
import { isEvaluator, type Evaluator } from '../mixin.ts'
import type { EvalValue } from '../types.ts'
@@ -102,12 +103,13 @@ async function evalSource(
)
}
try {
const result = await runWithTimeout(
evaluator.eval(source, { inputs: { ctx: ctxPayload } }),
return await evalWithCtx(
source,
ctxPayload,
evaluator,
POLICY_EVAL_TIMEOUT.seconds,
'policy script',
)
return result.value
} catch (caught) {
if (caught instanceof CommandTimeoutError) {
throw new PolicyError(
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import { CommandTimeoutError } from '../commands/builtin/utils/limit.ts'
import { EvalError } from './errors.ts'
import { EVALUATOR, isEvaluator, type Evaluator } from './mixin.ts'
import { ScriptSource } from './policy/types.ts'
import { CTX_GLOBAL, DEFAULT_SCRIPT_ENGINES, evalWithCtx, scriptEngine } from './script.ts'
import type { EvalResult, EvalValue } from './types.ts'
class Recorder implements Evaluator {
readonly [EVALUATOR] = true as const
inputs: Record<string, EvalValue> = {}
constructor(
private readonly value: EvalValue = null,
private readonly delayMs = 0,
private readonly error: Error | null = null,
) {}
async eval(
_code: string,
opts?: { inputs?: Record<string, EvalValue>; session?: string },
): Promise<EvalResult> {
this.inputs = { ...(opts?.inputs ?? {}) }
if (this.delayMs > 0) await new Promise((r) => setTimeout(r, this.delayMs))
if (this.error !== null) throw this.error
return {
value: this.value,
stdout: new Uint8Array(),
stderr: null,
exitCode: 0,
status: 'complete',
}
}
}
describe('evalWithCtx', () => {
it('shows the payload as one global named ctx', async () => {
// The convention every config script speaks. Spreading the payload's
// keys instead put `profile` in scope on one host and nothing on the
// other, which no test using a fake evaluator could see.
const engine = new Recorder('ok')
await evalWithCtx('...', { profile: 'release' }, engine, 1, 'test')
expect(engine.inputs).toEqual({ ctx: { profile: 'release' } })
expect(CTX_GLOBAL).toBe('ctx')
})
it("answers with the script's last expression", async () => {
expect(await evalWithCtx('...', {}, new Recorder({ a: 1 }), 1, 'test')).toEqual({ a: 1 })
})
it('lets a timeout reach the caller unworded', async () => {
// Each caller refuses in its own voice, so this throws the bare
// timeout rather than either layer's wording.
await expect(evalWithCtx('...', {}, new Recorder('ok', 200), 0.01, 'test')).rejects.toThrow(
CommandTimeoutError,
)
})
it('lets an eval failure reach the caller unworded', async () => {
await expect(
evalWithCtx('...', {}, new Recorder(null, 0, new EvalError('boom')), 1, 'test'),
).rejects.toThrow(EvalError)
})
})
describe('scriptEngine', () => {
it('names monty for python, not the host default', () => {
// Deliberately not DEFAULT_PYTHON: the two hosts disagree about
// that (pyodide here) for a reason that is about agent code reading
// files, which a config script never does. Naming one engine keeps
// one source producing one answer on either host.
expect(DEFAULT_SCRIPT_ENGINES).toEqual({ python: 'monty', js: 'quickjs' })
})
it('defaults to the language engine', () => {
const engine = scriptEngine(new ScriptSource('...', 'python'))
expect(engine.name).toBe('monty')
expect(isEvaluator(engine)).toBe(true)
})
it('takes the named runtime', () => {
expect(scriptEngine(new ScriptSource('...', 'python'), 'monty').name).toBe('monty')
})
it('refuses a runtime that cannot evaluate', () => {
expect(() => scriptEngine(new ScriptSource('...', 'js'), 'vfs')).toThrow(/cannot evaluate one/)
})
it('refuses an unknown runtime', () => {
expect(() => scriptEngine(new ScriptSource('...', 'js'), 'nope')).toThrow(/unknown runtime/)
})
it('refuses a runtime of the wrong language', () => {
expect(() => scriptEngine(new ScriptSource('...', 'python'), 'quickjs')).toThrow(
/python, but names runtime/,
)
})
})
@@ -0,0 +1,105 @@
import { runWithTimeout } from '../commands/builtin/utils/limit.ts'
import type { Runtime } from './base.ts'
import { LanguageRuntime } from './language.ts'
import { isEvaluator, type Evaluator } from './mixin.ts'
import type { ScriptSource } from './policy/types.ts'
import { buildRuntime } from './table.ts'
import type { EvalValue, RuntimeLanguage } from './types.ts'
export const CTX_GLOBAL = 'ctx'
/**
* The sandboxed default engine per config-script language. A config
* script is operator configuration, so its engine is built fresh and
* never picked out of a workspace's runtime world: the world is the
* ordered set that serves *agent* code, it is mutable after
* construction, and an entry drops out of it silently when an optional
* dependency is missing.
*
* monty on BOTH hosts, deliberately not DEFAULT_PYTHON. The two hosts
* disagree about the default python engine (pyodide here, monty in
* Python) because `@pydantic/monty` cannot answer builtin `open()`
* calls yet, and agent code reads files. A config script does no file
* I/O at all: it is handed a context and returns a value. So the
* reason for that split does not reach here, and naming one engine
* means one source produces one answer on either host rather than two
* engines that could disagree about the same program.
*/
export const DEFAULT_SCRIPT_ENGINES: Readonly<Record<RuntimeLanguage, string>> = {
python: 'monty',
js: 'quickjs',
}
/**
* Build the engine a config script runs on.
*
* The engine the config named when it names one, else the sandboxed
* default for the script's language. Throws a plain Error whose
* message is a clause about "script", for the caller to prefix with
* whose script it is.
*/
export function scriptEngine(
script: ScriptSource,
runtime: string | null = null,
): Runtime & Evaluator {
const wanted = runtime ?? DEFAULT_SCRIPT_ENGINES[script.language]
let built: Runtime
try {
built = buildRuntime(wanted)
} catch (err) {
// An engine reports a missing dependency as its own error, each
// carrying its own install hint.
const detail = err instanceof Error ? err.message : String(err)
throw new Error(`script names runtime '${wanted}': ${detail}`)
}
if (!isEvaluator(built)) {
throw new Error(
`script names runtime '${wanted}', which runs programs but cannot evaluate one; ` +
`use '${DEFAULT_SCRIPT_ENGINES[script.language]}'`,
)
}
// `language` is declared by LanguageRuntime, not by Runtime, so an
// engine that interprets no language at all cannot answer here and
// is left to the evaluation arm rather than compared against nothing.
if (built instanceof LanguageRuntime && built.language !== script.language) {
throw new Error(
`script is ${script.language}, but names runtime '${wanted}', which speaks ${built.language}`,
)
}
return built
}
/**
* Evaluate a config-borne script and return its last expression.
*
* The one place the config-script calling convention is written down:
* the payload arrives as a single global named `ctx`, and the script's
* last expression is its answer. Every config script speaks it (the
* runtime router's `policy:`, a runtime's entry script, a profile's
* `script:`), and they used to spell it out one at a time, which is a
* convention two callers can drift apart on: this host passed the
* payload's keys as separate globals for exactly one release, so `ctx`
* was undefined here and defined in Python.
*
* Deliberately throws rather than wording its failures. Each caller
* refuses in its own voice and its own error type (the runtime router's
* PolicyError is not the permissions layer's), so a shared wording here
* would put one layer's words on the other layer's failure.
*
* @throws CommandTimeoutError - the script outran `timeoutSeconds`.
* @throws EvalError - the script did not parse, or threw.
*/
export async function evalWithCtx(
source: string,
ctx: Record<string, EvalValue>,
evaluator: Evaluator,
timeoutSeconds: number,
label: string,
): Promise<EvalValue> {
const result = await runWithTimeout(
evaluator.eval(source, { inputs: { [CTX_GLOBAL]: ctx } }),
timeoutSeconds,
label,
)
return result.value
}
+1 -1
View File
@@ -87,7 +87,7 @@ export function weakerMode(a: MountMode, b: MountMode): MountMode {
* A sibling of `Session.mountModes`: per-session narrowing that the
* doors enforce, null-on-the-session means unrestricted. Hiding is
* "does not exist", never "forbidden" matching paths answer ENOENT
* and drop out of listings, which is what makes a hide the way a role
* and drop out of listings, which is what makes a hide the way a profile
* keeps a session away from a mount: naming mounts only narrows their
* modes, and a refusal would hand back the name.
*
@@ -128,7 +128,7 @@ function renderSection(title: string, entries: readonly ManEntry[]): string {
* lists the verbs and `man linear issue create` is the page for one leaf.
*
* The allow list narrows a tree the same way it narrows the bare listing,
* one level down: a role holding `linear issue list` reads a manual for
* one level down: a profile holding `linear issue list` reads a manual for
* that verb and nothing else, because a row it cannot run is an
* advertisement for a 126.
*/
@@ -177,7 +177,7 @@ describe('--help and man through the executor', () => {
expect(stdoutStr(await ws.execute('man'))).toContain('# clis')
})
it('man lists only the CLI verbs the role can reach', async () => {
it('man lists only the CLI verbs the profile can reach', async () => {
const ws = await cliWs()
ws.createSession('narrow', {
profile: { commands: { allow: ['man', 'linear issue'], ask: [], deny: [] } },
@@ -19,7 +19,7 @@ import { MountMode } from '../../types.ts'
import { classifyParts } from '../expand/classify/parts.ts'
import { getTestParser } from '../fixtures/workspace_fixture.ts'
import { Workspace } from '../workspace/workspace.ts'
import { parseSessionProfile, type SessionProfile } from '../session/permissions.ts'
import { parseSessionProfile, type SessionProfile } from '../../policy/profile.ts'
import { Admitted, admit, admitLine, policyScopes } from './admission.ts'
import { PolicyDenied } from '../../policy/index.ts'
import type { CommandRule, AdmissionRules } from '../../policy/types.ts'
@@ -44,14 +44,14 @@ afterEach(async () => {
for (const ws of open.splice(0)) await ws.close()
})
async function ws(role: SessionProfile | null = DOC): Promise<Workspace> {
async function ws(profile: SessionProfile | null = DOC): Promise<Workspace> {
const parser = await getTestParser()
const w = new Workspace(
{ '/data': new RAMResource() },
{
mode: MountMode.WRITE,
shellParser: parser,
...(role !== null ? { profiles: { default: role } } : {}),
...(profile !== null ? { profiles: { default: profile } } : {}),
},
)
open.push(w)
@@ -19,7 +19,7 @@ import type { Action, CommandContext, Policy } from '../../policy/index.ts'
import { RAMResource } from '../../resource/ram/ram.ts'
import { MountMode } from '../../types.ts'
import { getTestParser } from '../fixtures/workspace_fixture.ts'
import { parseSessionProfile } from '../session/permissions.ts'
import { parseSessionProfile } from '../../policy/profile.ts'
import { Workspace } from '../workspace/workspace.ts'
const DEC = new TextDecoder()
@@ -20,8 +20,8 @@ import { NAMESPACE_COMMANDS, SHELL_NAMES } from './constants.ts'
import { Consumer } from './types.ts'
/**
* What the session's allow list says about a tool word. A role without a
* list installs everything; a role with one installs only the names its
* What the session's allow list says about a tool word. A profile without a
* list installs everything; a profile with one installs only the names its
* patterns start with (`headVisible`). This is the raw answer;
* `commandVisible` and `layers` add the words that are never subjects.
*/
@@ -43,7 +43,7 @@ export function isTool(name: string, session: Session): boolean {
}
/**
* Whether a session can see a command word at all. The role's allow list
* Whether a session can see a command word at all. The profile's allow list
* (`commands.allow`) decides: a tool name no pattern of it starts with
* is not installed for the session, so it is 127 at the chokepoint and
* absent from every enumerator; a word that is not a tool (`isTool`) is
@@ -58,7 +58,7 @@ export function commandVisible(name: string, session: Session): boolean {
*
* `commandVisible` answers for a word, which is all dispatch needs: a CLI
* is routed by its head word and the verbs after it are the program's own
* operand. Discovery needs the finer answer, because a role allowed
* operand. Discovery needs the finer answer, because a profile allowed
* `linear issue list` is not allowed `linear team`, and a manual that
* lists the second is advertising a line that cannot run. `isTool`'s
* exemptions have nothing to say here: shell grammar and functions are
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest'
import { RAMResource } from '../resource/ram/ram.ts'
import { ScriptSource } from '../runtime/policy/types.ts'
import { MountMode } from '../types.ts'
import { getTestParser } from './fixtures/workspace_fixture.ts'
import { Workspace } from './workspace/workspace.ts'
const GOOD = "({commands: {allow: ['ls', 'cat', 'echo']}, cwd: '/data'})"
async function build(profiles: Record<string, unknown>, runtimes?: string[], profile?: string) {
const shellParser = await getTestParser()
return new Workspace(
{ '/data/': new RAMResource() },
{
mode: MountMode.WRITE,
shellParser,
profiles: profiles as never,
...(runtimes !== undefined ? { runtimes } : {}),
...(profile !== undefined ? { profile } : {}),
},
)
}
describe('profile scripts', () => {
it('runs a python profile script on monty, the engine both hosts name', async () => {
const src = "{'commands': {'allow': ['ls', 'echo']}, 'cwd': '/data'}"
const ws = await build({ release: { script: new ScriptSource(src, 'python') } })
await ws.ensureSessionsLoaded()
ws.createSession('s', { profile: 'release' })
expect((await ws.execute('echo hi', { sessionId: 's' })).exitCode).toBe(0)
expect(ws.getSession('s').cwd).toBe('/data')
await ws.close()
}, 120000)
it('runs a python profile script on pyodide when the profile names it', async () => {
// pyodide is this host's default python engine for *agent* code but
// not for profile scripts, so naming it is the only way it produces
// one; that makes this the explicit-`runtime:` path against a real
// engine.
const src = "{'commands': {'allow': ['ls', 'echo']}, 'cwd': '/data'}"
const ws = await build({
release: { script: new ScriptSource(src, 'python'), runtime: 'pyodide' },
})
await ws.ensureSessionsLoaded()
ws.createSession('s', { profile: 'release' })
expect((await ws.execute('echo hi', { sessionId: 's' })).exitCode).toBe(0)
expect(ws.getSession('s').cwd).toBe('/data')
await ws.close()
}, 180000)
it('shows the script one ctx global, not its keys spread', async () => {
// The python host wraps the context as `ctx`; spreading it here
// would put `profile` in scope on one host and nowhere on the other,
// which no fake evaluator can catch.
// The allow list is derived from ctx, so an unbound ctx throws at
// hydration and a spread one allows 'nope' instead of 'echo'.
const src = "({commands: {allow: ['ls', ctx.profile === 'release' ? 'echo' : 'nope']}})"
const ws = await build({ release: { script: new ScriptSource(src, 'js') } })
await ws.ensureSessionsLoaded()
ws.createSession('s', { profile: 'release' })
expect((await ws.execute('echo bound', { sessionId: 's' })).exitCode).toBe(0)
await ws.close()
})
it('produces the permissions and enforces them', async () => {
const ws = await build({ release: { script: new ScriptSource(GOOD, 'js') } })
await ws.ensureSessionsLoaded()
ws.createSession('s', { profile: 'release' })
expect((await ws.execute('echo hi', { sessionId: 's' })).exitCode).toBe(0)
expect((await ws.execute('rm /data/x', { sessionId: 's' })).exitCode).toBe(127)
expect(ws.getSession('s').cwd).toBe('/data')
await ws.close()
})
it('refuses createSession before hydration', async () => {
const ws = await build({ release: { script: new ScriptSource(GOOD, 'js') } })
expect(() => ws.createSession('s', { profile: 'release' })).toThrow(/ensureSessionsLoaded/)
await ws.close()
})
it.each([
['throws', "(() => { throw new Error('boom') })()", /script failed/],
['returns a non-document', '([1, 2, 3])', /must end in the permission/],
['writes an invalid document', "({commands: {allow: 'ls'}})", /not valid/],
])('refuses a script that %s', async (_label, src, pattern) => {
const ws = await build({ release: { script: new ScriptSource(src, 'js') } })
await expect(ws.ensureSessionsLoaded()).rejects.toThrow(pattern)
await ws.close()
})
it.each([[['bad', 'good']], [['good', 'bad']]])(
'one broken profile refuses every scripted profile (%s)',
async (order) => {
const sources: Record<string, string> = {
good: GOOD,
bad: "(() => { throw new Error('boom') })()",
}
const profiles: Record<string, unknown> = {}
for (const n of order) profiles[n] = { script: new ScriptSource(sources[n] ?? GOOD, 'js') }
const ws = await build(profiles)
await expect(ws.ensureSessionsLoaded()).rejects.toThrow(/profile 'bad' script/)
expect(() => ws.createSession('s', { profile: 'good' })).toThrow(/ensureSessionsLoaded/)
await ws.close()
},
)
it('runs in a world with no evaluator', async () => {
const ws = await build({ release: { script: new ScriptSource(GOOD, 'js') } }, ['vfs'])
await ws.ensureSessionsLoaded()
ws.createSession('s', { profile: 'release' })
expect((await ws.execute('echo hi', { sessionId: 's' })).exitCode).toBe(0)
await ws.close()
})
it('refuses an engine that cannot evaluate', async () => {
const ws = await build({ release: { script: new ScriptSource(GOOD, 'js'), runtime: 'vfs' } })
await expect(ws.ensureSessionsLoaded()).rejects.toThrow(/cannot evaluate one/)
await ws.close()
})
it('refuses a runtime of the wrong language', async () => {
const ws = await build({
release: { script: new ScriptSource(GOOD, 'js'), runtime: 'pyodide' },
})
await expect(ws.ensureSessionsLoaded()).rejects.toThrow(/but names runtime 'pyodide'/)
await ws.close()
})
it('a scripted default profile shapes the default session', async () => {
// The constructor compiles the default profile before its script
// runs, which is the script-only placeholder; hydration recompiles
// it, or the primary agent keeps running under empty permissions.
const ws = await build(
{ release: { script: new ScriptSource(GOOD, 'js') } },
undefined,
'release',
)
await ws.ensureSessionsLoaded()
expect((await ws.execute('echo hi')).exitCode).toBe(0)
expect((await ws.execute('rm /data/x')).exitCode).toBe(127)
await ws.close()
})
it('hydrating twice runs the script once', async () => {
const ws = await build({ release: { script: new ScriptSource(GOOD, 'js') } })
await ws.ensureSessionsLoaded()
await ws.ensureSessionsLoaded()
ws.createSession('s', { profile: 'release' })
expect((await ws.execute('echo hi', { sessionId: 's' })).exitCode).toBe(0)
await ws.close()
})
})
@@ -307,14 +307,14 @@ describe('SessionManager admission rules', () => {
const late = m.create('late')
late.commands = own
expect(m.commandsOf('late')).toBe(own)
// A session the role never narrowed states no rules, and so does an
// A session the profile never narrowed states no rules, and so does an
// id the manager does not know (the empty id of an unbound door
// included), unless a default role says otherwise.
// included), unless a default profile says otherwise.
expect(m.commandsOf('early')).toBeNull()
expect(m.commandsOf('nobody')).toBeNull()
expect(m.commandsOf('')).toBeNull()
expect(early.commands).toBeNull()
// With a default role compiled in, an unknown id answers its rules
// With a default profile compiled in, an unknown id answers its rules
// rather than nothing, so an unbound door still fails toward refusal.
m.defaultProfile = {
mountModes: null,
@@ -14,7 +14,7 @@
import { Session, varsFromEnv } from './session.ts'
import { setCwd } from './shell_dirs.ts'
import type { CompiledProfile } from './permissions.ts'
import type { CompiledProfile } from '../../policy/profile.ts'
import { RAMSessionStore } from './ram.ts'
import { applyProfile, narrow } from './resolve.ts'
import { CAS_MAX_RETRIES, generationOf, type SessionFields, type SessionStore } from './store.ts'
@@ -65,7 +65,7 @@ export class SessionManager {
* applied in full now (modes, hides, exported env, cwd), and its
* narrowing stamped again after hydration, where a record from before
* the profile existed would otherwise wake the primary agent
* unrestricted. null (no default role) leaves the session, and
* unrestricted. null (no default profile) leaves the session, and
* hydration, as they were.
*/
set defaultProfile(compiled: CompiledProfile | null) {
@@ -75,7 +75,7 @@ export class SessionManager {
/**
* The admission rules one session runs under (SessionCommandsQuery).
* The default role's rules for an id this manager does not know, the
* The default profile's rules for an id this manager does not know, the
* empty id of an unbound door included, so a door that names no
* session still fails toward refusal.
*/
@@ -20,7 +20,7 @@ import { matchOp, ruleScope } from '../../policy/match/rule.ts'
import type { OpsContext } from '../../policy/types.ts'
import { MountMode, PathSpec } from '../../types.ts'
import { pathHidden } from '../../utils/hidden.ts'
import { parseSessionProfile, type SessionProfile } from './permissions.ts'
import { parseSessionProfile, type SessionProfile } from '../../policy/profile.ts'
import {
applyProfile,
compileCommands,
@@ -79,7 +79,7 @@ describe('withInline', () => {
const inline = parseSessionProfile({ mounts: { '/a': 'rw', '/c': 'rwx' } })
const out = withInline(base, inline)
// Every prefix either side names survives; a mount only the inline
// document names is not a grant, since a mount the role never named
// document names is not a grant, since a mount the profile never named
// was already reachable at its own mode.
expect(out?.mounts?.get('/a')?.mode).toBe(MountMode.WRITE)
expect(out?.mounts?.get('/b')?.mode).toBe(MountMode.READ)
@@ -144,7 +144,7 @@ describe('withInline', () => {
commands: { deny: [{ reason: 'no', commands: ['mv'] }] },
})
const out = withInline(base, inline)
// The allow list is the role's alone, and the added rules land after
// The allow list is the profile's alone, and the added rules land after
// it: an inline document restricts, it never installs.
expect(out?.commands?.allow).toEqual(['ls', 'git', 'cat'])
expect(out?.commands?.ask?.map((r) => r.commands)).toEqual([['git push']])
@@ -152,9 +152,9 @@ 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
// And with no profile 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.
// default profile must not quietly accept what one with a profile refuses.
expect(() => withInline(null, parseSessionProfile({ commands: { allow: ['wc'] } }))).toThrow(
'not an allow list',
)
@@ -169,7 +169,7 @@ describe('withInline', () => {
})
describe('compileCommands', () => {
it("lists mount rules before the role's own", () => {
it("lists mount rules before the profile's own", () => {
const rules = compileCommands(
parseSessionProfile({
commands: { allow: ['ls'], deny: ['shutdown'] },
@@ -217,7 +217,7 @@ describe('compileCommands', () => {
expect(matchOp(rule, scope, readOp('/other/key.pem'))).toBe(false)
})
it('is null when the role states no rules', () => {
it('is null when the profile states no rules', () => {
expect(compileCommands({})).toBeNull()
expect(compileCommands(parseSessionProfile({ commands: {} }))).toBeNull()
expect(compileCommands(parseSessionProfile({ mounts: { '/repo': 'r' } }))).toBeNull()
@@ -266,12 +266,12 @@ describe('compileProfile', () => {
})
expect(pathHidden(out.hiddenPaths, '/repo/deep/key.pem')).toBe(true)
expect(pathHidden(out.hiddenPaths, '/scratch/key.pem')).toBe(false)
// The role's own hide is not a mount section's and stays global.
const role = compileProfile(parseSessionProfile({ paths: { hide: ['*.pem'] } }))
expect(pathHidden(role.hiddenPaths, '/scratch/key.pem')).toBe(true)
// The profile's own hide is not a mount section's and stays global.
const profile = compileProfile(parseSessionProfile({ paths: { hide: ['*.pem'] } }))
expect(pathHidden(profile.hiddenPaths, '/scratch/key.pem')).toBe(true)
})
it('of a bare or absent role states nothing', () => {
it('of a bare or absent profile states nothing', () => {
const empty = compileProfile(null)
expect(empty).toEqual({
mountModes: null,
@@ -282,7 +282,7 @@ describe('compileProfile', () => {
commands: null,
})
expect(compileProfile({})).toEqual(empty)
// A role that names a mount without a mode narrows nothing: the
// A profile that names a mount without a mode narrows nothing: the
// mount keeps whatever the workspace gave it.
expect(compileProfile(parseSessionProfile({ mounts: { '/a': {} } })).mountModes).toBeNull()
})
@@ -315,7 +315,7 @@ describe('narrow / applyProfile', () => {
expect(applied.vars.ROLE?.attrs.has(VarAttr.Export)).toBe(true)
})
it("carries the role's admission rules onto the session", () => {
it("carries the profile's admission rules onto the session", () => {
const compiled = compileProfile(
parseSessionProfile({ commands: { allow: ['ls'], ask: ['git'] } }),
)
@@ -27,16 +27,16 @@ import {
type ProfileMount,
type SessionProfile,
type VarsBlock,
} from './permissions.ts'
} from '../../policy/profile.ts'
import { DEFAULT_PROFILE } from './constants.ts'
import { varsFromEnv, type Session } from './session.ts'
import { setCwd } from './shell_dirs.ts'
/**
* The role a session is created from. A name is looked up as written; a
* The profile a session is created from. A name is looked up as written; a
* profile object is itself; null picks `profiles.default` when the
* workspace defines one and leaves the session unrestricted otherwise.
* There is no inheritance chain: a role is the whole document, so
* There is no inheritance chain: a profile is the whole document, so
* nothing is assembled from somewhere else before it is read. Throws
* PolicyError on a name the workspace does not define.
*/
@@ -72,18 +72,18 @@ function rulesOf(
}
/**
* The role's commands block with the inline document's rules added. An
* The profile's commands block with the inline document's rules added. An
* inline document may only restrict, so it carries ask and deny rules
* and never an allow list: a list there would install a command the
* role does not have, which is the one thing a per-call document must
* profile 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
* whether a profile happened to resolve, so both paths into `withInline`
* run it: a workspace with no default profile must not quietly accept a
* list a workspace with one refuses.
*/
export function refuseAllow(inline: CommandsBlock | null | undefined): void {
@@ -128,11 +128,11 @@ function addMount(base: ProfileMount | undefined, inline: ProfileMount | undefin
}
/**
* A role with the inline document of one `createSession` added.
* A profile 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, and that holds
* even when there is no role to add to. Modes take the weaker of the
* even when there is no profile 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.
@@ -214,8 +214,8 @@ function scopeRules(rules: readonly CommandRule[], root: string): CommandRule[]
}
/**
* A role's admission rules: its own, plus every mount section's, in one
* list; null when the role states none. Mount rules come first so the
* A profile's admission rules: its own, plus every mount section's, in one
* list; null when the profile states none. Mount rules come first so the
* section closest to the data speaks first when several rules match at
* the same anchor depth and only the message differs.
*/
@@ -238,7 +238,7 @@ export function compileCommands(profile: SessionProfile): AdmissionRules | null
}
/**
* Every path the role hides: its own entries, and each mount section's
* Every path the profile hides: its own entries, and each mount section's
* anchored to the mount it was written under, since the set is one list
* for the whole session and nothing in it remembers which section an
* entry came from (`anchored`).
@@ -253,7 +253,7 @@ function hiddenOf(profile: SessionProfile): HiddenPaths | null {
/**
* The mode each mount section states, null when none does. A mount the
* role does not name is absent from the map and keeps the mode it
* profile does not name is absent from the map and keeps the mode it
* declares in the workspace's `mounts:`; the map only narrows, it never
* grants.
*/
@@ -265,7 +265,7 @@ function modesOf(profile: SessionProfile): Map<string, MountMode> | null {
return modes.size > 0 ? modes : null
}
/** The session fields a role compiles to. */
/** The session fields a profile compiles to. */
export function compileProfile(effective: SessionProfile | null): CompiledProfile {
if (effective === null) {
return {
@@ -290,7 +290,7 @@ export function compileProfile(effective: SessionProfile | null): CompiledProfil
}
/**
* Stamp a compiled role's narrowing onto a session: the four fields no
* Stamp a compiled profile's narrowing onto a session: the four fields no
* shell line can edit (the per-mount modes, hidden paths, hidden
* variables, the admission rules). Applied at creation and again
* whenever a stored record could carry a stale copy (the default
@@ -305,8 +305,8 @@ export function narrow(session: Session, compiled: CompiledProfile): void {
}
/**
* Narrow a fresh session and seed its scratch state from the role.
* A role's env is a *process* environment, the same shape
* Narrow a fresh session and seed its scratch state from the profile.
* A profile's env is a *process* environment, the same shape
* `ws.env = {...}` speaks, so every name in it is exported: seeding
* them plain left `$TOKEN` expanding while every command, CLI and
* guest runtime in the profiled session saw nothing, since all three
@@ -99,7 +99,7 @@ describe('sessionView', () => {
})
it('a shaped write gates the value that lands', async () => {
// `declare -l role; role=ADMIN` stores `admin`, so a rule refusing
// `declare -l profile; profile=ADMIN` stores `admin`, so a rule refusing
// `admin` has to see `admin`, not the raw text: coercion runs
// before the gate.
const seen: (string | null)[] = []
@@ -113,10 +113,10 @@ describe('sessionView', () => {
const policies = new Policies()
policies.add(new Capture())
const [view, session] = makeView(policies)
seedVar(session, 'role', '')
setAttr(session, 'role', VarAttr.Lower)
await expect(view.set('role', 'ADMIN')).rejects.toBeInstanceOf(PolicyDenied)
expect(session.env.role).toBe('')
seedVar(session, 'profile', '')
setAttr(session, 'profile', VarAttr.Lower)
await expect(view.set('profile', 'ADMIN')).rejects.toBeInstanceOf(PolicyDenied)
expect(session.env.profile).toBe('')
seedVar(session, 'n', '0')
setAttr(session, 'n', VarAttr.Integer)
await view.set('n', '3+4')
@@ -368,7 +368,7 @@ async function setVar(
// so every reader agrees without per-read work. `-i` evaluates against
// the visible env, and a bad expression throws the arithmetic error
// as bash does. Coercion runs before the gate so a rule judges the
// value that will land: `declare -l role; role=ADMIN` stores `admin`,
// value that will land: `declare -l profile; profile=ADMIN` stores `admin`,
// and a rule refusing `admin` must see that, not the raw text.
const shaped =
existing !== undefined && existing.attrs.size > 0
@@ -19,7 +19,7 @@ import { IOResult } from '../../io/types.ts'
import type { AdmissionRules, CommandRule } from '../../policy/types.ts'
import { RAMResource } from '../../resource/ram/ram.ts'
import { MountMode } from '../../types.ts'
import { parseSessionProfile } from './permissions.ts'
import { parseSessionProfile } from '../../policy/profile.ts'
import { Workspace } from '../workspace/workspace.ts'
import { checkCliVerbs, checkRules } from './validate.ts'
@@ -19,7 +19,7 @@ import { RAMSessionStore } from './session/ram.ts'
import { RAMResource } from '../resource/ram/ram.ts'
import { FileType, MountMode, type FileStat } from '../types.ts'
import { getTestParser, stderrStr, stdoutStr } from './fixtures/workspace_fixture.ts'
import { parseSessionProfile } from './session/permissions.ts'
import { parseSessionProfile } from '../policy/profile.ts'
import { Workspace } from './workspace/workspace.ts'
const ENC = new TextEncoder()
@@ -131,7 +131,7 @@ describe('per-session mount grants', () => {
expect(stderrStr(denied)).toBe('/a/y.txt: Permission denied\n')
})
// A list used to mean "only these mounts are reachable"; a mount a role
// A list used to mean "only these mounts are reachable"; a mount a profile
// does not name now keeps its own mode, so the list would quietly drop
// the confinement it used to carry.
it('refuses a bare list of mounts', async () => {
@@ -141,7 +141,7 @@ describe('per-session mount grants', () => {
).toThrow('mounts must be a mapping of prefix to its settings')
})
it('a mount the role does not name stays reachable', async () => {
it('a mount the profile does not name stays reachable', async () => {
// The behavior change worth pinning: naming one mount is not an
// allowlist over the rest.
const { ws } = await makeGrantsWorkspace()
@@ -153,7 +153,7 @@ describe('per-session mount grants', () => {
})
it('a hidden mount reads as absent', async () => {
// A role narrows the mounts it names and never decides whether one
// A profile narrows the mounts it names and never decides whether one
// exists, so keeping a session away from a mount is a hide, and a
// hide answers ENOENT: naming the mount in a refusal would confirm
// to the agent exactly what it was not meant to know is there.
@@ -201,14 +201,14 @@ describe('per-session mount grants', () => {
expect(stdoutStr(io).trim()).toBe('1')
})
it('rejects invalid roles', async () => {
it('rejects invalid profiles', async () => {
const { ws } = await makeGrantsWorkspace()
expect(() => ws.createSession('agent', { mounts: { '/a': 'admin' as MountMode } })).toThrow(
'invalid mount mode',
)
})
it('accepts filesystem alias roles, rejects bit-style forms', async () => {
it('accepts filesystem alias profiles, rejects bit-style forms', async () => {
const { ws } = await makeGrantsWorkspace()
const sess = ws.createSession('agent', { mounts: { '/a': 'rw' } })
expect(sess.mountModes?.get('/a')).toBe(MountMode.WRITE)
@@ -35,7 +35,7 @@ import { LINE_EXECUTOR, type LineExecutor } from '../runtime/mixin.ts'
import type { RunResult } from '../runtime/types.ts'
import { MountMode, ResourceName } from '../types.ts'
import { cliSpecFor } from '../commands/cli/specs.ts'
import { parseSessionProfile, type SessionProfile } from './session/permissions.ts'
import { parseSessionProfile, type SessionProfile } from '../policy/profile.ts'
import { getTestParser, stderrStr, stdoutStr } from './fixtures/workspace_fixture.ts'
import { Workspace } from './workspace/workspace.ts'
@@ -900,8 +900,8 @@ describe('session profiles', () => {
expect(s1.env.ROLE).toBe('analyst')
const listing = await ws.execute('ls /a', { sessionId: 'agent1' })
expect(stdoutStr(listing)).not.toContain('secrets')
const role = await ws.execute('echo "$ROLE"', { sessionId: 'agent1' })
expect(stdoutStr(role)).toBe('analyst\n')
const profile = await ws.execute('echo "$ROLE"', { sessionId: 'agent1' })
expect(stdoutStr(profile)).toBe('analyst\n')
})
it('explicit mounts can only weaken a mode, never raise it', async () => {
@@ -914,23 +914,23 @@ describe('session profiles', () => {
{ mode: MountMode.WRITE, shellParser: parser },
)
open.push(ws)
const role = parseSessionProfile({
const profile = parseSessionProfile({
mounts: { '/a': 'write' },
paths: { hide: ['/a/secrets'] },
})
const sess = ws.createSession('agent', {
mounts: { '/a': 'read', '/b': 'read' },
profile: role,
profile: profile,
})
expect(sess.mountModes?.get('/a')).toBe(MountMode.READ)
expect(sess.mountModes?.get('/b')).toBe(MountMode.READ)
expect(sess.hiddenPaths).toEqual({ paths: ['/a/secrets'], patterns: [] })
const raised = ws.createSession('wider', { mounts: { '/a': 'rwx' }, profile: role })
const raised = ws.createSession('wider', { mounts: { '/a': 'rwx' }, profile: profile })
expect(raised.mountModes?.get('/a')).toBe(MountMode.WRITE)
})
it('a named role is the whole document, unnamed takes the default, unknown throws', async () => {
// Two roles, each the whole document it runs under: there is no
it('a named profile is the whole document, unnamed takes the default, unknown throws', async () => {
// Two profiles, each the whole document it runs under: there is no
// inheritance, so reading one is reading everything it may do.
const parser = await getTestParser()
const ws = new Workspace(
@@ -964,7 +964,7 @@ describe('session profiles', () => {
expect(dflt.hiddenPaths).toBeNull()
expect(dflt.cwd).toBe('/b')
expect(() => ws.createSession('x', { profile: 'nope' })).toThrow('unknown profile "nope"')
// An inline document adds to the named role: the weaker mode wins,
// An inline document adds to the named profile: the weaker mode wins,
// hides union, and an allow list there is refused outright.
const inline = ws.createSession('i', {
profile: 'reviewer',
@@ -994,10 +994,10 @@ describe('session profiles', () => {
expect(() => parseSessionProfile({ extends: 'default' })).toThrow('unknown field `extends`')
})
it('a role keeps a mount away by hiding it, not by omitting it', async () => {
it('a profile keeps a mount away by hiding it, not by omitting it', async () => {
// Omission is not a refusal, so exclusion is a hide: the mount
// reads as nonexistent rather than as a permission error naming
// something the role cannot see.
// something the profile cannot see.
const parser = await getTestParser()
const ws = new Workspace(
{ '/a': new RAMResource(), '/b': new RAMResource() },
@@ -1018,8 +1018,8 @@ describe('session profiles', () => {
expect(root.split(/\s+/)).not.toContain('a')
})
it('the workspace names its default role by name', async () => {
// `profile:` on the workspace picks which role shapes a session
it('the workspace names its default profile by name', async () => {
// `profile:` on the workspace picks which profile shapes a session
// created without one, including its own.
const parser = await getTestParser()
const profiles = {
@@ -1067,7 +1067,7 @@ describe('session profiles', () => {
expect(dflt.cwd).toBe('/b')
expect(stdoutStr(await ws.execute('pwd'))).toBe('/b\n')
expect(stdoutStr(await ws.execute('echo "$PAGER"'))).toBe('cat\n')
// A mount the role does not name is reachable at its own mode: the
// A mount the profile does not name is reachable at its own mode: the
// `mounts` mapping narrows, it is not an allowlist.
expect((await ws.execute('ls /a')).exitCode).toBe(0)
expect((await ws.execute('mkdir /b/vault')).exitCode).not.toBe(0)
@@ -1078,7 +1078,7 @@ describe('session profiles', () => {
expect(own.hiddenPaths).toBeNull()
})
it("a default role's hides, its own and its mount sections', bind every session", async () => {
it("a default profile's hides, its own and its mount sections', bind every session", async () => {
// One document: `paths.hide` at the top and `mounts./repo`'s own,
// compiled into the one hidden-paths spec every session carries.
const parser = await getTestParser()
@@ -1118,14 +1118,14 @@ describe('session profiles', () => {
expect(stdoutStr(await ws.execute('cat /other/.env'))).toBe('v')
const late = ws.createSession('late')
expect((await ws.execute('cat /other/pub/b.key', { sessionId: 'late' })).exitCode).not.toBe(0)
// The role is the session's own document now, so its hides are on
// The profile is the session's own document now, so its hides are on
// the session rather than bound beside it.
expect(late.hiddenPaths?.paths).toContain('/other/finance')
})
})
describe('command permissions end to end', () => {
// One mount section, written the same way by both roles below: rules
// One mount section, written the same way by both profiles below: rules
// here reach a line that works inside /repo, by cwd or by operand,
// which is what a path-scoped rule cannot express (`cd /repo && git
// commit` names no path).
@@ -1215,12 +1215,12 @@ describe('command permissions end to end', () => {
expect(await line(ws, 'history')).toEqual([127, '', 'history: command not found\n'])
})
it("a role's allow list is the only one a session reads", async () => {
it("a profile's allow list is the only one a session reads", async () => {
const ws = await commandsWs()
ws.createSession('rev', { profile: 'reviewer' })
await ws.execute('mkdir -p /repo/d && touch /repo/d/x')
// The reviewer role lists `cat` and not python3, whatever the
// default role lists; it lists `git log`, so `git` is visible but a
// The reviewer profile lists `cat` and not python3, whatever the
// default profile lists; it lists `git log`, so `git` is visible but a
// `git commit` line is covered by nothing (a refusal that names the
// program, not "command not found").
expect((await line(ws, 'cat /repo/d/x', 'rev'))[0]).toBe(0)
@@ -90,7 +90,7 @@ describe('nested evals run in the live ambient session', () => {
})
it("cmdsub keeps the named session's hides", async () => {
// A nested eval runs under the same session, so what the role hides
// A nested eval runs under the same session, so what the profile hides
// is as absent inside `$()` as outside it.
const ws = await makeTwoMounts()
ws.createSession('agent', { profile: { paths: { hide: ['/b'] } } })
@@ -27,7 +27,7 @@ import type { AskHandler, Policy } from '../../policy/index.ts'
import type { PolicyDecision, PolicyFn } from '../../runtime/policy/index.ts'
import type { RuntimeEntry } from '../../runtime/base.ts'
import type { NamespaceStore } from '../mount/namespace/store.ts'
import type { SessionProfile } from '../session/permissions.ts'
import type { SessionProfile } from '../../policy/profile.ts'
import type { SessionStore } from '../session/store.ts'
import type { WorkspaceStateStore } from '../store/base.ts'
@@ -115,7 +115,7 @@ export interface WorkspaceOptions {
*/
policy?: PolicyFn
/**
* The roles (`profiles:` in YAML). A role is the whole permission
* The profiles (`profiles:` in YAML). A profile is the whole permission
* document a session runs under, so there is no workspace-wide block
* and no mount-owned block above it. Each is a parsed profile, not the
* document as written: run the document through `parseSessionProfile`
@@ -127,7 +127,7 @@ export interface WorkspaceOptions {
*/
profiles?: Readonly<Record<string, SessionProfile>> | null
/**
* Which role shapes a session created without one, the workspace's own
* Which profile shapes a session created without one, the workspace's own
* session included. A name this document does not define is an error.
*/
profile?: string | null
@@ -71,8 +71,9 @@ import { buildFilePrompt } from '../file_prompt.ts'
import { SessionManager } from '../session/manager.ts'
import type { WorkspaceFields, WorkspaceStateStore } from '../store/base.ts'
import type { Session } from '../session/session.ts'
import { parseProfileMounts, type SessionProfile } from '../session/permissions.ts'
import { parseProfileMounts, type SessionProfile } from '../../policy/profile.ts'
import { applyProfile, compileProfile, resolveProfile, withInline } from '../session/resolve.ts'
import { permissionsFromScripts } from '../../policy/script.ts'
import { newSessionId, newWorkspaceId } from '../../utils/ids.ts'
import type { WatchRuntime } from '../../watch/base.ts'
import { resolveControlStores } from './build.ts'
@@ -176,8 +177,8 @@ export class Workspace {
})
rejectConfigScript('policy', options.policy)
this.policy = options.policy ?? null
// The permission documents: one role per name, and the role a
// session gets when it names none. A role is the whole document a
// The permission profiles: one per name, and the one a session
// gets when it names none. A profile is the whole document a
// session runs under, so there is no workspace-wide block above it.
this.profiles = { ...(options.profiles ?? {}) }
this.defaultProfileName = options.profile ?? null
@@ -244,9 +245,8 @@ export class Workspace {
// The workspace's own session is a session created without a name,
// so `profiles.default` shapes it too (design 3.4): the primary
// agent is not the one agent the document cannot reach.
const defaultProfile = this.roleFor(null)
this.sessionManager.defaultProfile =
defaultProfile === null ? null : compileProfile(defaultProfile)
const defaultBase = this.baseProfile(null)
this.sessionManager.defaultProfile = defaultBase === null ? null : compileProfile(defaultBase)
for (const resource of [...this.registry.allMounts().map((m) => m.resource), this.cache]) {
const resourceOps = resource.ops?.()
if (resourceOps === undefined) continue
@@ -497,10 +497,11 @@ export class Workspace {
}
/**
* The role a session is created under: the name as given, else the
* workspace's default role.
* The base profile a session is created under, which the inline
* `permissions`/`mounts` options then layer onto: the profile as
* named, else the workspace default.
*/
private roleFor(profile: string | SessionProfile | null): SessionProfile | null {
private baseProfile(profile: string | SessionProfile | null): SessionProfile | null {
if (profile === null && this.defaultProfileName !== null) {
return this.profiles[this.defaultProfileName] ?? null
}
@@ -508,21 +509,21 @@ export class Workspace {
}
/**
* Create a session under one role, with an optional inline document
* of its own.
* Create a session under one profile, with an optional inline
* document of its own.
*
* The role is a name from the workspace's `profiles`, or the workspace
* default when none is named, or a role document. The inline
* `permissions` and `mounts` may add ask and deny rules, hides and
* weaker modes; they may never add an allow entry, which is the one
* rule about combining two documents. `mounts` is sugar for
* The profile is a name from the workspace's `profiles`, or the
* workspace default when none is named, or a profile document. The
* inline `permissions` and `mounts` may add ask and deny rules, hides
* and weaker modes; they may never add an allow entry, which is the
* one rule about combining two documents. `mounts` is sugar for
* `permissions.mounts`: a mapping assigns each prefix a mode ('read',
* 'write', 'exec', or the filesystem aliases 'r', 'rw', 'rwx'), which
* may only be weaker than the mount's own. A mount the mapping omits
* keeps its own mode, so this narrows and never confines; a role that
* must keep a session away from a mount hides it. Throws PolicyError
* on an unknown role name, or on an inline document with an allow
* list.
* keeps its own mode, so this narrows and never confines; a profile
* that must keep a session away from a mount hides it. Throws
* PolicyError on an unknown profile name, or on an inline document
* with an allow list.
*/
createSession(
sessionId: string,
@@ -532,7 +533,17 @@ export class Workspace {
permissions?: SessionProfile | null
} = {},
): Session {
const base = this.roleFor(options.profile ?? null)
const base = this.baseProfile(options.profile ?? null)
if (base?.script != null) {
// Still a script means hydration has not run, and running it needs
// an await this door does not have. Every caller that creates a
// session already takes that door first, so this names it rather
// than guessing on their behalf.
throw new PolicyError(
'a profile that states a script is ready only after ' +
'ensureSessionsLoaded(); await it before createSession()',
)
}
let inline: SessionProfile | null = options.permissions ?? null
if (options.mounts != null) {
inline = withInline(inline, { mounts: parseProfileMounts(options.mounts) })
@@ -577,15 +588,43 @@ export class Workspace {
/**
* Hydrate sessions from the session store (idempotent). The discovery
* record resolves first so a minted default session id can adopt the
* stored pointer before hydration keys off it.
* stored pointer before hydration keys off it. Profile scripts run
* here too, once each, because this is the async door every caller
* already takes before it creates a session.
*/
async ensureSessionsLoaded(): Promise<void> {
await this.meta.ensure()
await this.evaluateProfileScripts()
await this.sessionManager.ensureLoaded()
}
/**
* What a line would do under a session's role, without running any of
* Replace each profile's script with the permissions it produced.
*
* One call into the policy layer, which runs every script before
* returning anything, so one broken profile refuses the whole set
* (see permissionsFromScripts). Idempotent by construction: a profile
* that has been evaluated no longer states a script, so a second
* hydration finds nothing left to run.
*/
private async evaluateProfileScripts(): Promise<void> {
const scripted = Object.fromEntries(
Object.entries(this.profiles).filter(([, profile]) => profile.script != null),
)
if (Object.keys(scripted).length === 0) return
const mounts = this.mounts().map((entry) => entry.prefix)
Object.assign(this.profiles, await permissionsFromScripts(scripted, mounts))
if (this.defaultProfileName !== null && this.defaultProfileName in scripted) {
// The constructor compiled the default profile before its script
// ran, which is the script-only placeholder, so without this the
// default session keeps running under empty permissions.
const produced = this.profiles[this.defaultProfileName]
this.sessionManager.defaultProfile = produced === undefined ? null : compileProfile(produced)
}
}
/**
* What a line would do under a session's profile, without running any
* it: one Explanation per command the gate reads, in gate order,
* nested lines included.
*
@@ -595,7 +634,7 @@ export class Workspace {
* puts no question to a host, which is what makes it safe to call
* about a line nobody typed.
*
* Host-side only. The structure of a role's rules is an operator's
* Host-side only. The structure of a profile's rules is an operator's
* business, so there is no builtin an agent can type to read it.
*/
async explain(line: string, sessionId = ''): Promise<Explanation[]> {
+3 -3
View File
@@ -229,7 +229,7 @@ describe('sandbox policy', () => {
workspaces.push(ws)
await ws.fs.writeFile('/allowed/a.txt', 'granted')
await ws.fs.writeFile('/secret/a.txt', 'classified')
// Exclusion is a hide: a mount the role does not name keeps its own
// Exclusion is a hide: a mount the profile does not name keeps its own
// mode, so confining a session to /allowed means hiding /secret.
ws.createSession('confined', {
mounts: { '/allowed': 'exec' },
@@ -288,14 +288,14 @@ describe('sandbox policy', () => {
await ws.fs.writeFile('/data/notes/a.txt', 'private')
ws.createSession('agent', { profile: 'scoped' })
const shell = await attachShell(ws, { sessionId: 'agent' })
// The role refuses this read, and read-only is not a way around it:
// The profile refuses this read, and read-only is not a way around it:
// every mount being `read` says nothing about a rule on a path.
const denied = await shell.run(
shell.resolve({ command: 'cat /data/notes/a.txt', sandboxPolicy: READ_ONLY }),
)
expect(denied.exitCode).not.toBe(0)
expect(denied.stderr.text).toContain('no notes')
// A word the role never installed is still not a command here.
// A word the profile never installed is still not a command here.
const missing = await shell.run(
shell.resolve({ command: 'sort /data/notes/a.txt', sandboxPolicy: READ_ONLY }),
)
+2 -2
View File
@@ -630,14 +630,14 @@ export class MirageShellExecutor extends ShellExecutor {
* source held, since `read` is the weakest mode there is; naming a
* mount only narrows it, so a prefix the map omits would keep its own
* mode rather than disappear. The other three are copied from the
* source session rather than recompiled, because the role it was
* source session rather than recompiled, because the profile it was
* created under is not something a session records.
*
* Leaving any of them behind widens. Hides are the obvious one: a
* binding confined to `/allowed` would read `/secret` in read-only
* mode although the same command is refused outside it. Command rules
* are the one modes cannot stand in for, because a mode bounds a
* mount and an account CLI reaches a service: a role that denies
* mount and an account CLI reaches a service: a profile that denies
* `slack message send` or `git push` still denies it here, where
* every mount being `read` says nothing at all about it.
*
+31 -1
View File
@@ -25,7 +25,7 @@ import { RedisWorkspaceStateStore } from './workspace/store/redis.ts'
import { RedisConsoleStore } from './shell/console/redis/index.ts'
import { RedisFileCacheStore } from './cache/file/redis.ts'
import { Workspace } from './workspace.ts'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -905,6 +905,36 @@ describe('CLI to daemon round trip', () => {
expect(cfg.defaultSessionId).toBe('mysess')
rmSync(dir, { recursive: true, force: true })
})
it('rebases a profile script path onto the config dir before loading it', async () => {
// The check door validates the profile without reading its script:
// reading at validation resolved `roles/x.js` against the process
// cwd (this test's cwd is the package, not the config dir), so
// checking a file config from anywhere else failed with ENOENT.
const dir = mkdtempSync(join(tmpdir(), 'mirage-profile-script-'))
mkdirSync(join(dir, 'roles'))
writeFileSync(join(dir, 'roles', 'x.js'), "({commands: {allow: ['ls']}})\n")
const file = join(dir, 'w.yaml')
writeFileSync(
file,
[
'mounts:',
' /data:',
' resource: ram',
'profiles:',
' release: {script: roles/x.js}',
'',
].join('\n'),
)
const wire = checkWorkspaceConfigFile(file)
const profiles = wire.profiles as Record<string, Record<string, unknown>>
expect(profiles.release?.script).toBe(join(dir, 'roles', 'x.js'))
const args = await configToWorkspaceArgs(loadWorkspaceConfigFile(file))
const release = args.options.profiles?.release
expect(release?.script).toBeInstanceOf(ScriptSource)
expect((release?.script as ScriptSource).source).toContain('allow')
rmSync(dir, { recursive: true, force: true })
})
})
// integ/fixtures/config/*.json are the contract: the python suite
+37 -9
View File
@@ -33,10 +33,7 @@ import {
parseMountMode,
} from '@struktoai/mirage-core/types'
import { snakeToCamel } from '@struktoai/mirage-core/utils/normalize'
import {
parseSessionProfile,
type SessionProfile,
} from '@struktoai/mirage-core/workspace/session/permissions'
import { parseSessionProfile, type SessionProfile } from '@struktoai/mirage-core/policy/profile'
import type { WorkspaceStateStore } from '@struktoai/mirage-core/workspace/store/base'
import { RAMWorkspaceStateStore } from '@struktoai/mirage-core/workspace/store/ram'
import { S3WorkspaceStateStore } from '@struktoai/mirage-core/workspace/store/s3'
@@ -336,7 +333,7 @@ function validateConfigKeys(raw: Record<string, unknown>): void {
rejectUnknownKeys(block, CLI_KEYS, `cli \`${name}\``)
}
}
// The roles validate through the core's own validators (the same
// The profiles validate through the core's own validators (the same
// shape the SDK and REST take), so a typo like `path:` on a deny rule
// fails here rather than widening the rule.
if (raw.profiles !== undefined && raw.profiles !== null) {
@@ -417,18 +414,44 @@ function parseLimits(
/**
* Validate the `profiles:` block: every entry through the core profile
* validator, so a misspelled field is a load error rather than a
* first-session one. A role is the whole document it runs under, so
* first-session one. A profile is the whole document it runs under, so
* there is no chain to resolve here.
*/
function parseProfiles(raw: unknown): Record<string, SessionProfile> {
if (!isPlainObject(raw)) throw new Error('config `profiles` must be a mapping')
const out: Record<string, SessionProfile> = {}
for (const [name, block] of Object.entries(raw)) {
// A path-form script stays the string the config wrote: the check
// door validates shape only, and runs before `absolutizeScripts`
// has rebased the path onto the config file's directory, so reading
// it here would resolve against the process cwd. The workspace door
// (`toWorkspaceOptions`) loads it, the python loader's split.
out[name] = parseSessionProfile(block, `profile \`${name}\``)
}
return out
}
/**
* Load each profile's path-form script into a ScriptSource.
*
* By this door the path is absolute for a file config (the check door
* rebased it onto the config file's directory); an object config's
* relative path resolves against the process cwd, as in Python. Code
* that passes a loaded ScriptSource is left alone.
*/
function loadProfileScripts(
profiles: Record<string, SessionProfile>,
): Record<string, SessionProfile> {
const out: Record<string, SessionProfile> = {}
for (const [name, profile] of Object.entries(profiles)) {
out[name] =
typeof profile.script === 'string'
? { ...profile, script: loadScriptSource(profile.script) }
: profile
}
return out
}
const VAR_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g
function walkInterpolate(v: unknown, env: Record<string, string>, missing: string[]): unknown {
@@ -578,9 +601,9 @@ export interface WorkspaceConfigRaw {
clis?: Record<string, CLIBlock> | null
runtimes?: (string | Record<string, unknown>)[] | null
policy?: string | null
/** The roles (`profiles:`); every entry validated by parseProfiles. */
/** The profiles (`profiles:`); every entry validated by parseProfiles. */
profiles?: unknown
/** Which role shapes a session created without one. */
/** Which profile shapes a session created without one. */
profile?: unknown
mode?: string
consistency?: string
@@ -674,6 +697,11 @@ function absolutizeScripts(raw: Record<string, unknown>, base: string): void {
absolutizeCliRef(block, base)
}
}
if (isPlainObject(raw.profiles)) {
for (const block of Object.values(raw.profiles)) {
if (isPlainObject(block)) absolutizeScriptKey(block, base)
}
}
}
/** Rebase one runtimes/clis entry's relative `script` path onto `base`. */
@@ -874,7 +902,7 @@ export async function configToWorkspaceArgs(cfg: WorkspaceConfigRaw): Promise<Wo
? { policy: loadScriptSource(cfg.policy) }
: {}),
...(cfg.profiles !== undefined && cfg.profiles !== null
? { profiles: parseProfiles(cfg.profiles) }
? { profiles: loadProfileScripts(parseProfiles(cfg.profiles)) }
: {}),
...(cfg.profile !== undefined && cfg.profile !== null
? { profile: cfg.profile as string }
@@ -35,7 +35,7 @@ async function mkCore(): Promise<MountCore> {
describe('MountCore', () => {
it('refuses a symlink on hidden turf for a scoped session', async () => {
// The R8 hole: a session-scoped kernel mount could create a link on
// a mount the role hides, because the FUSE symlink path wrote the
// a mount the profile hides, because the FUSE symlink path wrote the
// namespace table directly, at a layer no session view covers.
const ws = new Workspace(
{ '/data/': new RAMResource(), '/extra/': new RAMResource() },
+1 -4
View File
@@ -31,10 +31,7 @@ export {
type RedisNamespaceStoreOptions,
} from './workspace/namespace/redis.ts'
export { DiskRecordClient } from './workspace/record/disk.ts'
export {
parseSessionProfile,
type SessionProfile,
} from '@struktoai/mirage-core/workspace/session/permissions'
export { parseSessionProfile, type SessionProfile } from '@struktoai/mirage-core/policy/profile'
export { DiskSessionStore } from './workspace/session/disk.ts'
export { RedisSessionStore, type RedisSessionStoreOptions } from './workspace/session/redis.ts'
export {
@@ -45,7 +45,7 @@ describe('sessions router', () => {
})
it('accepts a mount mode mapping and refuses a bare list', async () => {
// A list of prefixes used to mean "only these mounts". A role now
// A list of prefixes used to mean "only these mounts". A profile now
// narrows the mounts it names and never decides whether one exists,
// so the list would be a silent no-op that still reads like
// confinement: the door refuses it instead.
@@ -42,7 +42,7 @@ interface CreateSessionBody {
* mapping omits keeps its own mode.
*/
mounts?: Record<string, string> | null
/** The role this session runs under, by name from the workspace. */
/** The profile this session runs under, by name from the workspace. */
profile?: string | null
}