Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cddef939a7 | |||
| 72ae0b2945 | |||
| 8b9bd5ae9b |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"), )))
|
||||
|
||||
+20
-20
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]}})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+5
-5
@@ -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/,
|
||||
+74
-20
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user