feat(policy): absorb output safeguards into the policy layer as Limit (#694)

* feat(policy): absorb output safeguards into the policy layer as Limit

* fix(policy): loop-based trailing-slash strip in limitOverride (CodeQL polynomial-redos)

* fix(integ): surface policy EACCES through the TS fuse read/write callbacks, skip code-policy cases in the CLI harness

* chore(deps): bump cryptography 50.0.0 and aiohttp 3.14.3 for audit advisories

* fix(policy): stamp builtin producers at the dispatch chokepoint, purge safeguard vocabulary from docs
This commit is contained in:
Zecheng Zhang
2026-08-03 18:32:34 -07:00
committed by GitHub
parent d13ec69ba4
commit a39755b3b1
149 changed files with 3850 additions and 1889 deletions
+6 -6
View File
@@ -148,13 +148,13 @@ jobs:
fi
echo
echo "--- per-mount command safeguards"
echo "--- per-mount command limits"
cat > /tmp/sg.yaml <<'YAML'
mounts:
/:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
cat:
max_lines: 2
on_exceed: truncate
@@ -164,7 +164,7 @@ jobs:
/:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
cat:
max_lines: 2
on_exceed: error
@@ -435,13 +435,13 @@ jobs:
fi
echo
echo "--- per-mount command safeguards"
echo "--- per-mount command limits"
cat > /tmp/sg.yaml <<'YAML'
mounts:
/:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
cat:
max_lines: 2
on_exceed: truncate
@@ -451,7 +451,7 @@ jobs:
/:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
cat:
max_lines: 2
on_exceed: error
+5 -10
View File
@@ -310,14 +310,9 @@ mirage workspace clone demo --at <version> --id demo_at_v1
| `mirage job wait JOB [--timeout SECS]` | Block until the job is done; returns the result. |
| `mirage job cancel JOB` | Cancel a running job. |
## Per-mount safeguards
## Per-mount command limits
<Note>
Per-mount `command_safeguards` are **Python CLI only** today. The TypeScript
CLI config schema does not carry them yet.
</Note>
Cap what a command may stream back per mount with `command_safeguards`, so a
Cap what a command may stream back per mount with `command_limits`, so a
runaway `cat`/`grep`/`rg` can't flood the agent or hang. Each entry sets
`max_lines` / `max_bytes` (output cap) and/or `timeout_seconds` (deadline),
with `on_exceed: truncate` (stop, exit 0, add a stderr notice) or
@@ -328,7 +323,7 @@ mounts:
/data:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
head: # cap output, keep going
max_lines: 100
on_exceed: truncate
@@ -342,8 +337,8 @@ mounts:
Caps fire on the **terminal** command of a pipeline only, so
`cat big.txt | head -n 30` still shows 30 lines. Truncation exits `0`, `error`
exits `1`, and a timeout exits `124` -- each with a stderr notice. Without a
`command_safeguards` block, `cat`/`grep`/`rg`/`head`/`tail` still cap at 2000
lines by default. See [Output Safeguards](/python/quickstart#output-safeguards)
`command_limits` block, `cat`/`grep`/`rg`/`head`/`tail` still cap at 2000
lines by default. See [Output Limits](/python/quickstart#output-limits)
for the SDK form and the same fields.
## Daemon control
+8 -8
View File
@@ -105,12 +105,12 @@ decorator (reuse a helper like `make_file_read_provision(my_stat)` or
reports `unknown`. Full semantics live in the
[CLI provision docs](/home/cli#5-dry-run-with-provision).
## Output Safeguards
## Output Limits
To keep huge reads from flooding an agent, `cat`, `grep`, `rg`, `head`,
and `tail` cap their **final** output at 2000 lines by default. When a
cap fires, the agent sees the truncated bytes plus a stderr notice
(`output truncated at safeguard limit (2000 lines); ...`); exit code
(`output truncated at limit (2000 lines); ...`); exit code
stays 0.
Caps fire only on the **terminal** command of a pipeline, so
@@ -119,7 +119,7 @@ Caps fire only on the **terminal** command of a pipeline, so
### Configure per mount
Limits are per-command and per-mount. Attach them when you mount a
resource by passing a `(resource, mode, {command: CommandSafeguard})`
resource by passing a `(resource, mode, {command: Limit})`
tuple. Each guard sets `max_lines` / `max_bytes` (output cap) and/or
`timeout_seconds` (deadline); `on_exceed` is `TRUNCATE` (default, exit 0
plus notice) or `ERROR` (exit 1 plus notice):
@@ -127,7 +127,7 @@ plus notice) or `ERROR` (exit 1 plus notice):
```python
from mirage import MountMode, Workspace
from mirage.resource.ram import RAMResource
from mirage.types import CommandSafeguard, OnExceed
from mirage.types import Limit, OnExceed
ws = Workspace(
{
@@ -135,9 +135,9 @@ ws = Workspace(
RAMResource(),
MountMode.WRITE,
{
"head": CommandSafeguard(max_lines=100), # cap, keep going
"grep": CommandSafeguard(max_lines=50, on_exceed=OnExceed.ERROR),
"rg": CommandSafeguard(timeout_seconds=30), # deadline
"head": Limit(max_lines=100), # cap, keep going
"grep": Limit(max_lines=50, on_exceed=OnExceed.ERROR),
"rg": Limit(timeout_seconds=30), # deadline
},
),
},
@@ -145,7 +145,7 @@ ws = Workspace(
)
```
The same limits are available to the CLI as a `command_safeguards`
The same limits are available to the CLI as a `command_limits`
block in the workspace YAML.
## Next Steps
+3 -3
View File
@@ -66,10 +66,10 @@ so `std.open` returns `null` instead of writing.
This is capability isolation, not a resource sandbox: the sandboxed code
cannot reach your files or the network, but its CPU and memory are bounded
only by `command_safeguards` timeouts. For untrusted code or hard resource
only by `command_limits` timeouts. For untrusted code or hard resource
limits, run behind a sandboxed deployment.
Each run gets its own epoch-interruption engine, so a `command_safeguards`
Each run gets its own epoch-interruption engine, so a `command_limits`
timeout traps the run and reclaims the thread instead of leaking it.
## Setup
@@ -107,7 +107,7 @@ The first run compiles `qjs-wasi.wasm` and caches the compilation as
## Resource limits
`node`/`js` is a command like any other: the same `command_safeguards`
`node`/`js` is a command like any other: the same `command_limits`
that guard `cat` or `python3` guard it, enforced at the same central
point. A run that exceeds `timeout_seconds` answers with exit 124, and
`max_bytes`/`max_lines` cap its output. Firing the guard also cancels
+2 -2
View File
@@ -171,7 +171,7 @@ runtimes:
mounts:
/data:
resource: ram
command_safeguards:
command_limits:
python3:
timeout_seconds: 30
```
@@ -187,7 +187,7 @@ languages. `monty` embeds its interpreter and has no options yet.
## Resource limits
`python3` is a command like any other: the same `command_safeguards`
`python3` is a command like any other: the same `command_limits`
blocks that guard `cat` or `grep` guard it, enforced at the same
central point. A run that exceeds `timeout_seconds` answers with exit
124 and `python3: timed out after Ns` on stderr, exactly like any
+1 -1
View File
@@ -197,7 +197,7 @@ mounts:
## Resource limits
A captured line is a command like any other: the same `command_safeguards`
A captured line is a command like any other: the same `command_limits`
that guard `cat` or `grep` guard `python3`, including in the sandbox. A run
that exceeds `timeout_seconds` answers exit 124; `max_bytes` and `max_lines`
cap its output the same way. There is no sandbox-specific limit surface.
+10 -10
View File
@@ -90,12 +90,12 @@ register your own command, pass `provision:` to `command({...})`
reports `unknown`. Full semantics live in the
[CLI provision docs](/home/cli#5-dry-run-with-provision).
## Output Safeguards
## Output Limits
To keep huge reads from flooding an agent, `cat`, `grep`, `rg`, `head`,
and `tail` cap their **final** output at 2000 lines by default. When a
cap fires, the agent sees the truncated bytes plus a stderr notice
(`output truncated at safeguard limit (2000 lines); ...`); the exit code
(`output truncated at limit (2000 lines); ...`); the exit code
stays 0.
Caps fire only on the **terminal** command of a pipeline, so
@@ -103,14 +103,14 @@ Caps fire only on the **terminal** command of a pipeline, so
### Configure per mount
Pass a `commandSafeguards` option keyed by mount prefix, then command.
Each `CommandSafeguard` sets `maxLines` / `maxBytes` (output cap) and/or
Pass a `commandLimits` option keyed by mount prefix, then command.
Each `Limit` sets `maxLines` / `maxBytes` (output cap) and/or
`timeoutSeconds` (deadline); `onExceed` is `TRUNCATE` (default, exit 0
plus notice) or `ERROR` (exit 1 plus notice):
```ts
import {
CommandSafeguard,
Limit,
MountMode,
OnExceed,
RAMResource,
@@ -121,18 +121,18 @@ const ws = new Workspace(
{ '/data': new RAMResource() },
{
mode: MountMode.WRITE,
commandSafeguards: {
commandLimits: {
'/data': {
head: new CommandSafeguard({ maxLines: 100 }), // cap, keep going
grep: new CommandSafeguard({ maxLines: 50, onExceed: OnExceed.ERROR }),
rg: new CommandSafeguard({ timeoutSeconds: 30 }), // deadline
head: new Limit({ maxLines: 100 }), // cap, keep going
grep: new Limit({ maxLines: 50, onExceed: OnExceed.ERROR }),
rg: new Limit({ timeoutSeconds: 30 }), // deadline
},
},
},
)
```
The same limits are available to the CLI as a `command_safeguards` block in the workspace YAML.
The same limits are available to the CLI as a `command_limits` block in the workspace YAML.
## Next Steps
+3 -3
View File
@@ -69,7 +69,7 @@ while a mount read or write awaits the dispatch, matching the Python
This is capability isolation, not a resource sandbox: the sandboxed code
cannot reach your files or the network, but CPU and memory are bounded only
by soft engine caps and `command_safeguards` timeouts. For untrusted code or
by soft engine caps and `command_limits` timeouts. For untrusted code or
hard resource limits, run behind a sandboxed deployment.
## Setup
@@ -104,14 +104,14 @@ runtimes:
mounts:
/data:
resource: ram
command_safeguards:
command_limits:
js:
timeout_seconds: 30
```
## Resource limits
`node`/`js` is a command like any other: the same `command_safeguards`
`node`/`js` is a command like any other: the same `command_limits`
that guard `cat` or `python3` guard it, enforced at the same central
point. A run that exceeds `timeout_seconds` answers with exit 124, and
`max_bytes`/`max_lines` cap its output.
+2 -2
View File
@@ -76,7 +76,7 @@ runtimes:
mounts:
/data:
resource: ram
command_safeguards:
command_limits:
python3:
timeout_seconds: 30
```
@@ -102,7 +102,7 @@ const ws = new Workspace(resources, {
## Resource limits
`python3` is a command like any other: the same `command_safeguards`
`python3` is a command like any other: the same `command_limits`
blocks that guard `cat` or `grep` guard it, enforced at the same
central point. A run that exceeds `timeout_seconds` answers with exit
124 and `python3: timed out after Ns` on stderr, exactly like any
+1 -1
View File
@@ -197,7 +197,7 @@ mounts:
## Resource limits
A captured line is a command like any other: the same `command_safeguards`
A captured line is a command like any other: the same `command_limits`
that guard `cat` or `grep` guard `python3`, including in the sandbox. A run
that exceeds `timeoutSeconds` answers exit 124; `maxBytes` and `maxLines`
cap its output the same way. There is no sandbox-specific limit surface.
+6 -6
View File
@@ -127,24 +127,24 @@ mirage workspace delete cross_loaded
./mirage-ts workspace delete cross_loaded
```
## 7. Per-mount safeguards (Python CLI)
## 7. Per-mount command limits (Python CLI)
A mount can cap what a command streams back with `command_safeguards`,
A mount can cap what a command streams back with `command_limits`,
so a runaway `cat`/`grep`/`rg` can't flood the agent or hang forever.
Each entry sets `max_lines` / `max_bytes` (output cap) and/or
`timeout_seconds` (deadline), with `on_exceed: truncate` (stop, exit 0,
add a notice) or `on_exceed: error` (stop, exit 1, add a notice).
This section is **Python-only**: the TS config schema does not yet carry
`command_safeguards`. It runs against its own workspace
(`cross_sg`) from [workspace_safeguards.yaml](workspace_safeguards.yaml),
Both CLIs parse and apply the same `command_limits` block (the cross
harness pins this). This walkthrough uses the Python CLI against its own
workspace (`cross_sg`) from [workspace_limits.yaml](workspace_limits.yaml),
so the steps above are untouched. That file guards `/s3` with:
`head` → 10 lines / truncate, `grep` → 20 lines / error, `rg` → a 1 ms
timeout.
```bash
set -a && source .env.development && set +a
mirage workspace create examples/python/cross/workspace_safeguards.yaml --id cross_sg
mirage workspace create examples/python/cross/workspace_limits.yaml --id cross_sg
```
Warm the object once (the first S3 read fetches the whole object and can
@@ -1,9 +1,9 @@
mode: WRITE
consistency: LAZY
# Same /s3 mount as workspace.yaml, plus per-mount command_safeguards so the
# Same /s3 mount as workspace.yaml, plus per-mount command_limits so the
# guards can be observed firing from the CLI. Python-only: the TS config
# schema does not yet carry command_safeguards, so this lives in its own file
# schema does not yet carry command_limits, so this lives in its own file
# to keep the shared workspace.yaml usable from both CLIs.
mounts:
/s3:
@@ -13,7 +13,7 @@ mounts:
region: ${AWS_DEFAULT_REGION}
aws_access_key_id: ${AWS_ACCESS_KEY_ID}
aws_secret_access_key: ${AWS_SECRET_ACCESS_KEY}
command_safeguards:
command_limits:
# Cap head output at 10 lines and keep going (exit 0 + notice).
head:
max_lines: 10
+6 -6
View File
@@ -48,17 +48,17 @@ seed() {
$cli execute -w "$id" -c "echo cross-history-marker" >/dev/null
}
# The /guard mount in cross.yaml caps `cat` at 2 lines. Safeguards apply at
# The /guard mount in cross.yaml caps `cat` at 2 lines. Limits apply at
# create time (not load) in both languages, so assert on the writer: this
# proves both CLIs parse + apply the same snake_case command_safeguards block.
check_safeguard() {
# proves both CLIs parse + apply the same snake_case command_limits block.
check_limit() {
local cli="$1" name="$2"
local lines
lines="$($cli execute -w cross_w -c "cat /guard/big.txt" | stdout_of | grep -c .)"
if [ "$lines" == "2" ]; then
echo " OK safeguard caps cat to 2 lines ($name)"
echo " OK limit caps cat to 2 lines ($name)"
else
echo " FAIL safeguard not applied by $name: got $lines lines (expected 2)"
echo " FAIL limit not applied by $name: got $lines lines (expected 2)"
fail=1
fi
}
@@ -90,7 +90,7 @@ run_direction() {
create_json="$($writer_cli workspace create "$YAML" --id cross_w)"
check_default_session "$create_json" "$writer_name"
seed "$writer_cli" cross_w
check_safeguard "$writer_cli" "$writer_name"
check_limit "$writer_cli" "$writer_name"
local expected=()
local i
+2 -2
View File
@@ -35,10 +35,10 @@ mounts:
access_key_id: ${AWS_ACCESS_KEY_ID}
secret_access_key: ${AWS_SECRET_ACCESS_KEY}
# Snake_case command_safeguards must parse + apply identically in both CLIs.
# Snake_case command_limits must parse + apply identically in both CLIs.
/guard:
resource: ram
command_safeguards:
command_limits:
cat:
max_lines: 2
on_exceed: truncate
+70
View File
@@ -21,6 +21,8 @@ import tempfile
from mirage import Mount, MountBackend, MountMode, Workspace
from mirage.fuse.mount import mount_background
from mirage.policy import Policy
from mirage.policy.types import Deny, OpsContext, OpsResultContext
from mirage.resource.ram import RAMResource
from mirage.types import FileStat
@@ -47,6 +49,73 @@ class SizelessOps:
API_CONTENT = b'{"messages": 2}\n'
class SealReadsPolicy(Policy):
"""pre_ops deny: a sealed path never reaches the backend."""
async def pre_ops(self, ctx: OpsContext) -> Deny | None:
if not ctx.write and ctx.path.virtual.endswith(".sealed"):
return Deny(message="sealed\n")
return None
class RedactReadsPolicy(Policy):
"""post_ops deny: refuse read results carrying a marker."""
async def post_ops(self, ctx: OpsResultContext) -> Deny | None:
data = ctx.result if isinstance(ctx.result,
(bytes, bytearray)) else None
if ctx.op == "read" and data is not None and b"TOPSECRET" in data:
return Deny(message="redacted\n")
return None
def run_policy_probe(result: dict[str, object]) -> None:
"""Record that op policies gate the kernel path too.
FUSE serves the workspace's op door, so a pre_ops deny (sealed
path) and a post_ops deny (redacted content) must both surface as
EACCES to ordinary file APIs, while unguarded reads pass.
Args:
result (dict[str, object]): the probe result to extend.
"""
res = RAMResource()
res._store.dirs.add("/")
res._store.files["/clean.txt"] = b"hello\n"
res._store.files["/secret.txt"] = b"TOPSECRET plans\n"
res._store.files["/x.sealed"] = b"nope\n"
with Workspace(
{
"/guarded": Mount(
res, mode=MountMode.READ, backend=MountBackend.FUSE)
},
policies=[SealReadsPolicy(),
RedactReadsPolicy()]) as ws:
mp = ws.fuse_mountpoints["/guarded"]
with open(f"{mp}/clean.txt", "rb") as fh:
result["policy_clean_read"] = fh.read().decode().strip()
# A denied path must FAIL, never serve content. On FUSE the
# refusal is EACCES; WinFsp respells the same refusal (EBADF
# observed), so any OSError counts there and the strict errno
# stays pinned everywhere else.
try:
with open(f"{mp}/x.sealed", "rb") as fh:
fh.read()
result["policy_sealed_eacces"] = False
except PermissionError:
result["policy_sealed_eacces"] = True
except OSError:
result["policy_sealed_eacces"] = sys.platform == "win32"
try:
with open(f"{mp}/secret.txt", "rb") as fh:
fh.read()
result["policy_redact_eacces"] = False
except PermissionError:
result["policy_redact_eacces"] = True
except OSError:
result["policy_redact_eacces"] = sys.platform == "win32"
def run_sizeless_probe(result: dict[str, object]) -> None:
"""Record the size-unknown semantics into the shared result.
@@ -135,6 +204,7 @@ def main() -> None:
result["collision_rejected"] = collision
run_sizeless_probe(result)
run_policy_probe(result)
print(json.dumps(result))
+61
View File
@@ -25,6 +25,10 @@ import {
MountMode,
RAMResource,
Workspace,
type Action,
type OpsContext,
type OpsResultContext,
type Policy,
} from "@struktoai/mirage-node";
// Size-unknown probe: a stat wrapper simulates API-backed resources (Linear,
@@ -64,6 +68,62 @@ async function runSizelessProbe(
}
}
// Policy probe: FUSE serves the workspace's op door, so a preOps deny
// (sealed path) and a postOps deny (redacted content) must both surface as
// EACCES to ordinary file APIs, while unguarded reads pass.
class SealReadsPolicy implements Policy {
preOps(ctx: OpsContext): Action | null {
if (!ctx.write && ctx.path.virtual.endsWith(".sealed")) {
return { kind: "deny", message: "sealed\n" };
}
return null;
}
}
class RedactReadsPolicy implements Policy {
postOps(ctx: OpsResultContext): Action | null {
const data = ctx.result instanceof Uint8Array ? new TextDecoder().decode(ctx.result) : null;
if (ctx.op === "read" && data !== null && data.includes("TOPSECRET")) {
return { kind: "deny", message: "redacted\n" };
}
return null;
}
}
async function runPolicyProbe(
result: Record<string, string | number | boolean | null>,
): Promise<void> {
const enc = new TextEncoder();
const res = new RAMResource();
res.store.dirs.add("/");
res.store.files.set("/clean.txt", enc.encode("hello\n"));
res.store.files.set("/secret.txt", enc.encode("TOPSECRET plans\n"));
res.store.files.set("/x.sealed", enc.encode("nope\n"));
const ws = new Workspace(
{ "/guarded": new Mount(res, { mode: MountMode.READ, backend: MountBackend.FUSE }) },
{ policies: [new SealReadsPolicy(), new RedactReadsPolicy()] },
);
try {
await ws.fuseReady();
const mp = ws.fuseMountpoints["/guarded"];
result.policy_clean_read = (await readFile(`${mp}/clean.txt`, "utf8")).trim();
try {
await readFile(`${mp}/x.sealed`);
result.policy_sealed_eacces = false;
} catch (err) {
result.policy_sealed_eacces = (err as { code?: string }).code === "EACCES";
}
try {
await readFile(`${mp}/secret.txt`);
result.policy_redact_eacces = false;
} catch (err) {
result.policy_redact_eacces = (err as { code?: string }).code === "EACCES";
}
} finally {
await ws.close();
}
}
// Per-mount FUSE: two mounts exposed at distinct OS paths simultaneously. Reads
// go through the real kernel -> FUSE handler. Async fs APIs are required: the
// mounts' napi callbacks run on the single Node event loop, so a *sync* read
@@ -127,6 +187,7 @@ async function main(): Promise<void> {
await ws.close();
}
await runSizelessProbe(result);
await runPolicyProbe(result);
process.stdout.write(JSON.stringify(result) + "\n");
}
+4 -1
View File
@@ -10,5 +10,8 @@
"collision_rejected": true,
"api_stat_preopen_ok": true,
"api_cat": "{\"messages\": 2}",
"api_size_postread": 16
"api_size_postread": 16,
"policy_clean_read": "hello",
"policy_sealed_eacces": true,
"policy_redact_eacces": true
}
-1
View File
@@ -7,7 +7,6 @@
"battery": "tsx runners/typescript/main.ts",
"fuse": "tsx fuse/fuse.ts",
"notion:mcp-parity": "tsx notion_mcp_parity.ts",
"safeguard": "tsx safeguard.ts",
"runtime": "tsx runtime/run.ts",
"slack:server": "tsx server/slack.ts",
"slack:setup": "prisma generate --schema prisma/schema.prisma && prisma db push --schema prisma/schema.prisma --skip-generate"
+2 -2
View File
@@ -207,7 +207,7 @@
]
},
{
"id": "link_op_safeguard",
"id": "link_op_limit",
"world": {
"runtimes": [
"monty",
@@ -216,7 +216,7 @@
"mounts": {
"/data": {
"resource": "ram",
"safeguards": {
"limits": {
"read": {
"max_bytes": 8
}
+12 -8
View File
@@ -5,12 +5,13 @@
# workspace create`) and executed with `mirage execute`. This is the
# yaml -> daemon -> CLI construction path: entry captures, config
# blocks, per-entry scripts (policy), the global route, per-mount
# command_safeguards, and the per-line --runtime argument.
# command_limits, and the per-line --runtime argument.
#
# Cases whose steps need the SDK surface (add_runtime, rename, s3_put)
# or a runner-local test runtime (echobox) or non-ram mounts are
# skipped as sdk-only. Expect semantics: exit and stdout are exact,
# stderr is a containment check (the CLI owns its stderr framing).
# Cases whose steps need the SDK surface (add_runtime, rename, s3_put,
# read_op) or a runner-local test runtime (echobox) or runner-local
# code policies (world.policies) or non-ram mounts are skipped as
# sdk-only. Expect semantics: exit and stdout are exact, stderr is a
# containment check (the CLI owns its stderr framing).
#
# A yaml file is any JSON document here: YAML is a superset of JSON,
# so the driver emits the case world as JSON with jq and both loaders
@@ -41,14 +42,17 @@ requirement_met() {
esac
}
# Whether this case can run over the CLI at all.
# Whether this case can run over the CLI at all. Worlds carrying code
# policies (runner-local Policy classes) cannot cross the yaml/daemon
# boundary, and read_op steps need the SDK op door.
cli_expressible() {
local case_json="$1"
jq -e '
((.world.mounts // {"/ram": {"resource": "ram"}})
| to_entries | all(.value.resource == "ram"))
and (((.world.policies // []) | length) == 0)
and (((.world.runtimes // []) | map(select(type == "object" and .name == "echobox")) | length) == 0)
and (((.steps // []) | map(select(has("add_runtime") or has("rename") or has("s3_put"))) | length) == 0)
and (((.steps // []) | map(select(has("add_runtime") or has("rename") or has("s3_put") or has("read_op"))) | length) == 0)
' >/dev/null <<<"$case_json"
}
@@ -73,7 +77,7 @@ write_world_yaml() {
jq '{mode: "EXEC",
mounts: ((.mounts // {"/ram": {"resource": "ram"}})
| map_values({resource: .resource}
+ (if .safeguards then {command_safeguards: .safeguards} else {} end)))}
+ (if .limits then {command_limits: .limits} else {} end)))}
+ (if .runtimes then {runtimes: .runtimes} else {} end)
+ (if .policy then {policy: .policy} else {} end)' \
<<<"$world_json" > "$work/ws.yaml"
+1 -1
View File
@@ -129,7 +129,7 @@
"mounts": {
"/ram": {
"resource": "ram",
"safeguards": {
"limits": {
"sleep": {
"timeout_seconds": 1
}
+703
View File
@@ -0,0 +1,703 @@
{
"suite": "limits",
"cases": [
{
"id": "single_cat_truncate",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"limits": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"limits": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"limits": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"limits": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /a/f.txt",
"expect": {
"exit": 0,
"stdout": "1\n2\n3\n4\n",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "single_cat_error",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"limits": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"limits": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"limits": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"limits": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /b/f.txt",
"expect": {
"exit": 1,
"stdout": "",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "within_limit_no_fire",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"limits": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"limits": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"limits": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"limits": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /a/small.txt",
"expect": {
"exit": 0,
"stdout": "x\ny\n"
}
}
]
},
{
"id": "max_bytes_truncate",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"limits": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"limits": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"limits": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"limits": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /c/f.txt",
"expect": {
"exit": 0,
"stdout": "abcde",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "semicolon_rightmost_truncate",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"limits": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"limits": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"limits": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"limits": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /b/f.txt ; cat /a/f.txt",
"expect": {
"exit": 0,
"stdout": "6\n7\n8\n9\n",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "and_rightmost_error",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"limits": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"limits": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"limits": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"limits": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /a/f.txt && cat /b/f.txt",
"expect": {
"exit": 1,
"stdout": "",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "or_rightmost_truncate",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"limits": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"limits": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"limits": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"limits": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "false || cat /a/f.txt",
"expect": {
"exit": 0,
"stdout": "1\n2\n3\n4\n",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "subshell_rightmost",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"limits": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"limits": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"limits": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"limits": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "( cat /b/f.txt ; cat /a/f.txt )",
"expect": {
"exit": 0,
"stdout": "6\n7\n8\n9\n",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "cross_mount_cat_tightest",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"limits": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"limits": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"limits": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"limits": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /a/f.txt /b/f.txt",
"expect": {
"exit": 1,
"stdout": "",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "traversal_grep_r_tightest",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"limits": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"limits": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"limits": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"limits": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "grep -r match /a",
"expect": {
"exit": 1,
"stdout": ""
}
}
]
}
]
}
+2 -2
View File
@@ -317,7 +317,7 @@
]
},
{
"id": "safeguard_timeout",
"id": "limit_timeout",
"world": {
"runtimes": [
"monty",
@@ -329,7 +329,7 @@
"files": {
"slow.py": "n = 0\nfor i in range(300000000):\n n = n + 1\n"
},
"safeguards": {
"limits": {
"python3": {
"timeout_seconds": 1
}
+303
View File
@@ -516,6 +516,309 @@
}
}
]
},
{
"id": "hook_pre_command_custom",
"world": {
"mounts": {
"/ram": {
"resource": "ram",
"files": {
"a.txt": "hello\n"
}
}
},
"policies": [
{
"name": "deny_flag",
"command": "grep",
"flag": "-r",
"message": "grep: recursive search is not allowed here\n"
}
]
},
"steps": [
{
"command": "grep -r hello /ram",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "grep: recursive search is not allowed here\n"
}
},
{
"command": "grep hello /ram/a.txt",
"expect": {
"exit": 0,
"stdout": "hello\n",
"stderr": ""
}
}
]
},
{
"id": "hook_pre_ops_write_lock",
"world": {
"mounts": {
"/ram": {
"resource": "ram",
"files": {
"open.txt": "free\n"
}
}
},
"policies": [
{
"name": "lock_writes",
"prefix": "/ram/locked/"
}
]
},
"steps": [
{
"command": "echo hi > /ram/locked/f.txt",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "/ram/locked/f.txt: Permission denied\n"
}
},
{
"command": "echo hi > /ram/note.txt && cat /ram/note.txt",
"expect": {
"exit": 0,
"stdout": "hi\n",
"stderr": ""
}
},
{
"command": "cat /ram/open.txt",
"expect": {
"exit": 0,
"stdout": "free\n",
"stderr": ""
}
}
]
},
{
"id": "hook_pre_ops_read_seal",
"world": {
"mounts": {
"/ram": {
"resource": "ram",
"files": {
"x.sealed": "nope\n",
"y.txt": "yes\n"
}
}
},
"policies": [
{
"name": "seal_reads",
"suffix": ".sealed"
}
]
},
"steps": [
{
"read_op": "/ram/y.txt",
"expect": {
"content": "yes\n"
}
},
{
"read_op": "/ram/x.sealed",
"expect": {
"errno": "EACCES"
}
}
]
},
{
"id": "hook_post_ops_redact",
"world": {
"mounts": {
"/ram": {
"resource": "ram",
"files": {
"clean.txt": "hello\n",
"secret.txt": "TOPSECRET plans\n"
}
}
},
"policies": [
{
"name": "redact_reads",
"marker": "TOPSECRET"
}
]
},
"steps": [
{
"read_op": "/ram/clean.txt",
"expect": {
"content": "hello\n"
}
},
{
"read_op": "/ram/secret.txt",
"expect": {
"errno": "EACCES"
}
}
]
},
{
"id": "hook_post_ops_cap_and_deny_wins",
"world": {
"mounts": {
"/ram": {
"resource": "ram",
"files": {
"big.log": "abcdefghij\n",
"note.txt": "abcdefghij\n",
"secret.log": "TOPSECRET plans\n"
}
}
},
"policies": [
{
"name": "op_read_cap",
"suffix": ".log",
"max_bytes": 5
},
{
"name": "redact_reads",
"marker": "TOPSECRET"
}
]
},
"steps": [
{
"read_op": "/ram/big.log",
"expect": {
"content": "abcde"
}
},
{
"read_op": "/ram/note.txt",
"expect": {
"content": "abcdefghij\n"
}
},
{
"read_op": "/ram/secret.log",
"expect": {
"errno": "EACCES"
}
}
]
},
{
"id": "hook_post_execute_cap_merges_tightest",
"world": {
"mounts": {
"/ram": {
"resource": "ram",
"files": {
"data.txt": "1\n2\n3\n4\n5\n"
},
"limits": {
"cat": {
"max_lines": 3
}
}
}
},
"policies": [
{
"name": "line_cap",
"max_lines": 2
}
]
},
"steps": [
{
"command": "cat /ram/data.txt",
"expect": {
"exit": 0,
"stdout": "1\n2\n",
"stderr_contains": "output truncated"
}
},
{
"command": "head -n 1 /ram/data.txt",
"expect": {
"exit": 0,
"stdout": "1\n",
"stderr": ""
}
}
]
},
{
"id": "hook_post_execute_error_mode",
"world": {
"mounts": {
"/ram": {
"resource": "ram",
"files": {
"data.txt": "1\n2\n3\n4\n5\n"
}
}
},
"policies": [
{
"name": "line_cap",
"max_lines": 2,
"on_exceed": "error"
}
]
},
"steps": [
{
"command": "cat /ram/data.txt",
"expect": {
"exit": 1,
"stdout": "",
"stderr_contains": "output truncated"
}
},
{
"command": "head -n 1 /ram/data.txt",
"expect": {
"exit": 0,
"stdout": "1\n",
"stderr": ""
}
}
]
},
{
"id": "hook_post_execute_fail_closed",
"world": {
"mounts": {
"/ram": {
"resource": "ram"
}
},
"policies": [
{
"name": "boom"
}
]
},
"steps": [
{
"command": "echo hi",
"expect": {
"exit": 1,
"stdout": "",
"stderr_contains": "policy Boom failed"
}
}
]
}
]
}
+136 -3
View File
@@ -27,11 +27,17 @@ import uuid # noqa: E402
from typing import Any # noqa: E402
from mirage import MountMode, Workspace # noqa: E402
from mirage.policy import Policy # noqa: E402
from mirage.policy.types import CommandContext # noqa: E402
from mirage.policy.types import Deny # noqa: E402
from mirage.policy.types import ExecuteResultContext # noqa: E402
from mirage.policy.types import OpsContext # noqa: E402
from mirage.policy.types import OpsResultContext # noqa: E402
from mirage.runtime.base import Runtime # noqa: E402
from mirage.runtime.policy import ScriptSource # noqa: E402
from mirage.runtime.table import build_runtime # noqa: E402
from mirage.runtime.types import RunArgs, RunResult # noqa: E402
from mirage.types import CommandSafeguard, PathSpec # noqa: E402
from mirage.types import Limit, PathSpec # noqa: E402
HOST = "python"
SUITE_DIR = Path(__file__).parent
@@ -60,6 +66,112 @@ class EchoBox(Runtime):
exit_code=0)
class DenyFlag(Policy):
"""Test-only pre_command policy: refuse a command carrying a flag."""
def __init__(self, spec: dict[str, Any]) -> None:
self._command = spec["command"]
self._flag = spec["flag"]
self._message = spec["message"]
async def pre_command(self, ctx: CommandContext) -> Deny | None:
if ctx.command == self._command and self._flag in ctx.argv:
return Deny(message=self._message)
return None
class LockWrites(Policy):
"""Test-only pre_ops policy: refuse write ops under a prefix."""
def __init__(self, spec: dict[str, Any]) -> None:
self._prefix = spec["prefix"]
async def pre_ops(self, ctx: OpsContext) -> Deny | None:
if ctx.write and ctx.path.virtual.startswith(self._prefix):
return Deny(message="locked\n")
return None
class SealReads(Policy):
"""Test-only pre_ops policy: refuse read ops on a path suffix."""
def __init__(self, spec: dict[str, Any]) -> None:
self._suffix = spec["suffix"]
async def pre_ops(self, ctx: OpsContext) -> Deny | None:
if not ctx.write and ctx.path.virtual.endswith(self._suffix):
return Deny(message="sealed\n")
return None
class RedactReads(Policy):
"""Test-only post_ops policy: refuse read results holding a marker."""
def __init__(self, spec: dict[str, Any]) -> None:
self._marker = spec["marker"].encode()
async def post_ops(self, ctx: OpsResultContext) -> Deny | None:
data = ctx.result if isinstance(ctx.result,
(bytes, bytearray)) else None
if ctx.op == "read" and data is not None and self._marker in data:
return Deny(message="redacted\n")
return None
class OpReadCap(Policy):
"""Test-only post_ops policy: cap read bytes on a path suffix."""
def __init__(self, spec: dict[str, Any]) -> None:
self._suffix = spec["suffix"]
self._max_bytes = spec["max_bytes"]
async def post_ops(self, ctx: OpsResultContext) -> Limit | None:
if ctx.op == "read" and ctx.path.virtual.endswith(self._suffix):
return Limit(max_bytes=self._max_bytes)
return None
class LineCap(Policy):
"""Test-only post_execute policy: bound every line's output."""
def __init__(self, spec: dict[str, Any]) -> None:
self._limit = Limit(**{k: v for k, v in spec.items() if k != "name"})
async def post_execute(self, ctx: ExecuteResultContext) -> Limit | None:
return self._limit
class Boom(Policy):
"""Test-only post_execute policy that throws: must fail closed."""
def __init__(self, spec: dict[str, Any]) -> None:
pass
async def post_execute(self, ctx: ExecuteResultContext) -> Limit | None:
raise RuntimeError("boom")
POLICY_KINDS = {
"deny_flag": DenyFlag,
"lock_writes": LockWrites,
"seal_reads": SealReads,
"redact_reads": RedactReads,
"op_read_cap": OpReadCap,
"line_cap": LineCap,
"boom": Boom,
}
def _build_policy(spec: dict[str, Any]) -> Policy:
"""One world policies entry, dispatched on its ``name``.
Args:
spec (dict[str, Any]): the entry; ``name`` picks the test policy
class, the remaining keys are its config.
"""
return POLICY_KINDS[spec["name"]](spec)
def _expand(value: Any) -> Any:
"""Expand ``${ENV}`` placeholders in config values.
@@ -210,8 +322,8 @@ async def _build_workspace(world: dict[str, Any], run_id: str) -> Workspace:
for prefix, spec in mount_specs.items():
resource = await _build_resource(spec, run_id)
guards = {
cmd: CommandSafeguard(**kwargs)
for cmd, kwargs in spec.get("safeguards", {}).items()
cmd: Limit(**kwargs)
for cmd, kwargs in spec.get("limits", {}).items()
}
mounts[prefix] = (resource, MountMode.EXEC,
guards) if guards else resource
@@ -222,6 +334,8 @@ async def _build_workspace(world: dict[str, Any], run_id: str) -> Workspace:
kwargs["runtimes"] = [_build_entry(e) for e in world["runtimes"]]
if "policy" in world:
kwargs["policy"] = ScriptSource(world["policy"])
if "policies" in world:
kwargs["policies"] = [_build_policy(s) for s in world["policies"]]
ws = Workspace(mounts, mode=MountMode.EXEC, **kwargs)
for prefix, name, data in seeds:
await ws.dispatch("write",
@@ -278,6 +392,25 @@ async def _run_step(ws: Workspace, case_id: str, index: int,
f"expected {expect.get('errno')}"
]
return []
if "read_op" in step:
# Reads through the op door (the surface FUSE and programmatic
# access share), where pre_ops/post_ops policies fire.
content = ""
try:
result, _ = await ws.dispatch(
"read", PathSpec.from_str_path(step["read_op"]))
errno_name = "NONE"
content = bytes(result).decode()
except PermissionError:
errno_name = "EACCES"
problems = []
if errno_name != expect.get("errno", "NONE"):
problems.append(f"read_op errno {errno_name}, "
f"expected {expect.get('errno', 'NONE')}")
if "content" in expect and content != expect["content"]:
problems.append(f"read_op content {content!r}, "
f"expected {expect['content']!r}")
return [f"{case_id} {label}: {p}" for p in problems]
command = step["command"]
kwargs: dict[str, Any] = {}
if "runtime" in step:
+153 -4
View File
@@ -19,7 +19,7 @@ import { CreateBucketCommand, PutObjectCommand, S3Client } from "@aws-sdk/client
import { MongoClient } from "mongodb";
import {
buildRuntime,
CommandSafeguard,
Limit,
MongoDBResource,
MountMode,
PathSpec,
@@ -30,7 +30,13 @@ import {
ScriptSource,
snakeToCamel,
Workspace,
type Action,
type CommandContext,
type ExecuteResultContext,
type MountSpec,
type OpsContext,
type OpsResultContext,
type Policy,
type Resource,
type RunResult,
type RuntimeEntry,
@@ -52,6 +58,7 @@ interface Expect {
stderr_contains?: string;
throws_contains?: string;
errno?: string;
content?: string;
}
interface Step {
@@ -61,21 +68,36 @@ interface Step {
add_runtime?: string;
s3_put?: { key: string; body: string };
rename?: { src: string; dst: string };
read_op?: string;
expect?: Expect;
}
interface MountSpecJson {
resource: string;
files?: Record<string, string>;
safeguards?: Record<string, Record<string, unknown>>;
limits?: Record<string, Record<string, unknown>>;
}
interface World {
runtimes?: (string | Record<string, unknown>)[];
policy?: string;
policies?: PolicySpec[];
mounts?: Record<string, MountSpecJson>;
}
interface PolicySpec {
name: string;
command?: string;
flag?: string;
message?: string;
prefix?: string;
suffix?: string;
marker?: string;
max_bytes?: number;
max_lines?: number;
on_exceed?: string;
}
interface Case {
id: string;
hosts?: string[];
@@ -115,6 +137,110 @@ class EchoBox extends Runtime {
}
}
// Test-only policies, one per hook, mirroring the Python runner: the
// world's `policies` entries pick a class by `name` and carry its config.
class DenyFlag implements Policy {
private readonly spec: PolicySpec;
constructor(spec: PolicySpec) {
this.spec = spec;
}
preCommand(ctx: CommandContext): Action | null {
if (ctx.command === this.spec.command && ctx.argv.includes(this.spec.flag ?? "")) {
return { kind: "deny", message: this.spec.message ?? "" };
}
return null;
}
}
class LockWrites implements Policy {
private readonly prefix: string;
constructor(spec: PolicySpec) {
this.prefix = spec.prefix ?? "";
}
preOps(ctx: OpsContext): Action | null {
if (ctx.write && ctx.path.virtual.startsWith(this.prefix)) {
return { kind: "deny", message: "locked\n" };
}
return null;
}
}
class SealReads implements Policy {
private readonly suffix: string;
constructor(spec: PolicySpec) {
this.suffix = spec.suffix ?? "";
}
preOps(ctx: OpsContext): Action | null {
if (!ctx.write && ctx.path.virtual.endsWith(this.suffix)) {
return { kind: "deny", message: "sealed\n" };
}
return null;
}
}
class RedactReads implements Policy {
private readonly marker: string;
constructor(spec: PolicySpec) {
this.marker = spec.marker ?? "";
}
postOps(ctx: OpsResultContext): Action | null {
const data = ctx.result instanceof Uint8Array ? DEC.decode(ctx.result) : null;
if (ctx.op === "read" && data !== null && data.includes(this.marker)) {
return { kind: "deny", message: "redacted\n" };
}
return null;
}
}
class OpReadCap implements Policy {
private readonly suffix: string;
private readonly maxBytes: number;
constructor(spec: PolicySpec) {
this.suffix = spec.suffix ?? "";
this.maxBytes = spec.max_bytes ?? 0;
}
postOps(ctx: OpsResultContext): Action | null {
if (ctx.op === "read" && ctx.path.virtual.endsWith(this.suffix)) {
return new Limit({ maxBytes: this.maxBytes });
}
return null;
}
}
class LineCap implements Policy {
private readonly limit: Limit;
constructor(spec: PolicySpec) {
const { name: _name, ...fields } = spec;
this.limit = new Limit(camelizeKeys(fields));
}
postExecute(): Action | null {
return this.limit;
}
}
class Boom implements Policy {
constructor(_spec: PolicySpec) {}
postExecute(_ctx: ExecuteResultContext): Action | null {
throw new Error("boom");
}
}
const POLICY_KINDS: Record<string, new (spec: PolicySpec) => Policy> = {
deny_flag: DenyFlag,
lock_writes: LockWrites,
seal_reads: SealReads,
redact_reads: RedactReads,
op_read_cap: OpReadCap,
line_cap: LineCap,
boom: Boom,
};
function buildPolicy(spec: PolicySpec): Policy {
const cls = POLICY_KINDS[spec.name];
if (cls === undefined) throw new Error(`unknown policy kind: ${spec.name}`);
return new cls(spec);
}
function expand(value: unknown): unknown {
if (typeof value === "string") {
return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name: string) => process.env[name] ?? "");
@@ -237,9 +363,9 @@ async function buildWorkspace(world: World, runId: string): Promise<Workspace> {
for (const [prefix, spec] of Object.entries(mountSpecs)) {
const resource = await buildResource(spec, runId);
const guards = Object.fromEntries(
Object.entries(spec.safeguards ?? {}).map(([cmd, kwargs]) => [
Object.entries(spec.limits ?? {}).map(([cmd, kwargs]) => [
cmd,
new CommandSafeguard(camelizeKeys(kwargs)),
new Limit(camelizeKeys(kwargs)),
]),
);
mounts[prefix] =
@@ -251,6 +377,7 @@ async function buildWorkspace(world: World, runId: string): Promise<Workspace> {
const options: Record<string, unknown> = { mode: MountMode.EXEC };
if (world.runtimes !== undefined) options.runtimes = world.runtimes.map(buildEntry);
if (world.policy !== undefined) options.policy = new ScriptSource(world.policy);
if (world.policies !== undefined) options.policies = world.policies.map(buildPolicy);
const ws = new Workspace(mounts, options);
for (const [prefix, name, content] of seeds) {
await ws.dispatch("write", `${prefix}/${name}`, [ENC.encode(content)]);
@@ -308,6 +435,28 @@ async function runStep(ws: Workspace, caseId: string, index: number, step: Step)
}
return [];
}
if (step.read_op !== undefined) {
// Reads through the op door (the surface FUSE and programmatic
// access share), where preOps/postOps policies fire.
let errnoName = "NONE";
let content = "";
try {
const result = await ws.dispatch("read", step.read_op, []);
content = DEC.decode(result as Uint8Array);
} catch (err) {
errnoName = (err as { code?: string }).code ?? "NONE";
}
const problems: string[] = [];
if (errnoName !== (expect.errno ?? "NONE")) {
problems.push(`read_op errno ${errnoName}, expected ${expect.errno ?? "NONE"}`);
}
if (expect.content !== undefined && content !== expect.content) {
problems.push(
`read_op content ${JSON.stringify(content)}, expected ${JSON.stringify(expect.content)}`,
);
}
return problems.map((p) => `${caseId} ${label}: ${p}`);
}
const command = step.command ?? "";
const options: Record<string, unknown> = {};
if (step.runtime !== undefined) options.runtime = step.runtime;
-703
View File
@@ -1,703 +0,0 @@
{
"suite": "safeguard",
"cases": [
{
"id": "single_cat_truncate",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"safeguards": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"safeguards": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"safeguards": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"safeguards": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /a/f.txt",
"expect": {
"exit": 0,
"stdout": "1\n2\n3\n4\n",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "single_cat_error",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"safeguards": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"safeguards": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"safeguards": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"safeguards": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /b/f.txt",
"expect": {
"exit": 1,
"stdout": "",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "within_limit_no_fire",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"safeguards": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"safeguards": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"safeguards": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"safeguards": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /a/small.txt",
"expect": {
"exit": 0,
"stdout": "x\ny\n"
}
}
]
},
{
"id": "max_bytes_truncate",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"safeguards": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"safeguards": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"safeguards": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"safeguards": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /c/f.txt",
"expect": {
"exit": 0,
"stdout": "abcde",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "semicolon_rightmost_truncate",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"safeguards": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"safeguards": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"safeguards": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"safeguards": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /b/f.txt ; cat /a/f.txt",
"expect": {
"exit": 0,
"stdout": "6\n7\n8\n9\n",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "and_rightmost_error",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"safeguards": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"safeguards": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"safeguards": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"safeguards": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /a/f.txt && cat /b/f.txt",
"expect": {
"exit": 1,
"stdout": "",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "or_rightmost_truncate",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"safeguards": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"safeguards": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"safeguards": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"safeguards": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "false || cat /a/f.txt",
"expect": {
"exit": 0,
"stdout": "1\n2\n3\n4\n",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "subshell_rightmost",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"safeguards": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"safeguards": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"safeguards": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"safeguards": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "( cat /b/f.txt ; cat /a/f.txt )",
"expect": {
"exit": 0,
"stdout": "6\n7\n8\n9\n",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "cross_mount_cat_tightest",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"safeguards": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"safeguards": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"safeguards": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"safeguards": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "cat /a/f.txt /b/f.txt",
"expect": {
"exit": 1,
"stdout": "",
"stderr_contains": "output truncated"
}
}
]
},
{
"id": "traversal_grep_r_tightest",
"world": {
"mounts": {
"/a": {
"resource": "ram",
"files": {
"f.txt": "1\n2\n3\n4\n5\n",
"small.txt": "x\ny\n"
},
"safeguards": {
"cat": {
"max_lines": 4,
"on_exceed": "truncate"
},
"grep": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/a/sub": {
"resource": "ram",
"files": {
"g.txt": "match\nmatch\nmatch\n"
},
"safeguards": {
"grep": {
"max_lines": 1,
"on_exceed": "error"
}
}
},
"/b": {
"resource": "ram",
"files": {
"f.txt": "6\n7\n8\n9\n10\n"
},
"safeguards": {
"cat": {
"max_lines": 2,
"on_exceed": "error"
}
}
},
"/c": {
"resource": "ram",
"files": {
"f.txt": "abcdefgh"
},
"safeguards": {
"cat": {
"max_bytes": 5,
"on_exceed": "truncate"
}
}
}
}
},
"steps": [
{
"command": "grep -r match /a",
"expect": {
"exit": 1,
"stdout": ""
}
}
]
}
]
}
@@ -18,7 +18,7 @@ import time
from collections.abc import AsyncIterator
from mirage.io.types import ByteSource, IOResult, materialize
from mirage.types import CommandSafeguard, OnExceed
from mirage.types import Limit, OnExceed
from mirage.utils.stream import ensure_stream
logger = logging.getLogger(__name__)
@@ -32,7 +32,7 @@ class CommandTimeoutError(Exception):
self.seconds = seconds
class SafeguardExceededError(Exception):
class LimitExceededError(Exception):
def __init__(self, message: str) -> None:
super().__init__(message)
@@ -62,27 +62,27 @@ async def with_timeout(
def maybe_with_timeout(
stream: ByteSource | None,
safeguard: CommandSafeguard | None,
limit: Limit | None,
command: str,
) -> ByteSource | None:
"""Wrap a byte stream with a timeout if the safeguard calls for one.
"""Wrap a byte stream with a timeout if the limit calls for one.
Returns the stream untouched when it is None, already bytes, or the
safeguard has no positive timeout. Single source of the wrap rule
limit has no positive timeout. Single source of the wrap rule
shared by stdout, stderr, and any other stream channel.
Args:
stream (ByteSource | None): the stream to maybe wrap.
safeguard (CommandSafeguard | None): resolved safeguard.
limit (Limit | None): resolved limit.
command (str): command name for the timeout message.
"""
if stream is None or isinstance(stream, bytes):
return stream
if safeguard is None or not safeguard.timeout_seconds:
if limit is None or not limit.timeout_seconds:
return stream
if safeguard.timeout_seconds <= 0:
if limit.timeout_seconds <= 0:
return stream
return with_timeout(stream, safeguard.timeout_seconds, command)
return with_timeout(stream, limit.timeout_seconds, command)
async def run_with_timeout(coro, seconds: float | None, name: str):
@@ -114,26 +114,26 @@ def _trim_to_lines(buf: bytes, max_lines: int) -> bytes:
return buf
def _build_notice(safeguard: CommandSafeguard) -> bytes:
def _build_notice(limit: Limit) -> bytes:
parts: list[str] = []
if safeguard.max_lines is not None:
parts.append(f"{safeguard.max_lines} lines")
if safeguard.max_bytes is not None:
parts.append(f"{safeguard.max_bytes} bytes")
limit = " / ".join(parts)
return (f"output truncated at safeguard limit ({limit}); "
if limit.max_lines is not None:
parts.append(f"{limit.max_lines} lines")
if limit.max_bytes is not None:
parts.append(f"{limit.max_bytes} bytes")
detail = " / ".join(parts)
return (f"output truncated at limit ({detail}); "
"narrow with grep, or read more with head -n / tail -n / "
"a more specific path\n").encode()
async def apply_safeguard(
async def apply_limit(
src: ByteSource,
safeguard: CommandSafeguard | None,
limit: Limit | None,
) -> tuple[ByteSource | None, IOResult]:
if safeguard is None:
if limit is None:
return src, IOResult()
max_lines = safeguard.max_lines
max_bytes = safeguard.max_bytes
max_lines = limit.max_lines
max_bytes = limit.max_bytes
if max_lines is None and max_bytes is None:
return src, IOResult()
buf = bytearray()
@@ -151,8 +151,8 @@ async def apply_safeguard(
data = bytes(buf)
if not truncated:
return data, IOResult()
notice = _build_notice(safeguard)
if safeguard.on_exceed is OnExceed.ERROR:
notice = _build_notice(limit)
if limit.on_exceed is OnExceed.ERROR:
return None, IOResult(exit_code=1, stderr=notice)
return data, IOResult(stderr=notice)
@@ -161,7 +161,7 @@ async def guard_output(
stdout: ByteSource | None,
stderr: ByteSource | None,
exit_code: int,
safeguard: CommandSafeguard | None,
limit: Limit | None,
) -> tuple[ByteSource | None, ByteSource | None, int]:
"""Apply output caps at a boundary and merge the outcome.
@@ -174,11 +174,11 @@ async def guard_output(
stderr (ByteSource | None): the error stream to carry the
notice.
exit_code (int): the run's exit code.
safeguard (CommandSafeguard | None): resolved safeguard.
limit (Limit | None): resolved limit.
"""
if stdout is None:
return stdout, stderr, exit_code
data, sg_io = await apply_safeguard(stdout, safeguard)
data, sg_io = await apply_limit(stdout, limit)
if sg_io.stderr is not None:
existing = (await materialize(stderr) if stderr is not None else b"")
stderr = existing + await materialize(sg_io.stderr)
@@ -187,30 +187,30 @@ async def guard_output(
return data, stderr, exit_code
async def apply_op_safeguard(result, safeguard: CommandSafeguard | None):
async def apply_op_limit(result, limit: Limit | None):
"""Apply byte/line caps to a byte-producing VFS op result.
VFS ops have no stderr/exit envelope, so on TRUNCATE the capped bytes
are returned (and the notice logged) and on ERROR a
SafeguardExceededError is raised. Non-byte results (stat, listings)
LimitExceededError is raised. Non-byte results (stat, listings)
and unconfigured guards pass through untouched.
Args:
result: the op result (capped only when bytes or a byte stream).
safeguard (CommandSafeguard | None): resolved op safeguard.
limit (Limit | None): resolved op limit.
"""
if safeguard is None:
if limit is None:
return result
if safeguard.max_bytes is None and safeguard.max_lines is None:
if limit.max_bytes is None and limit.max_lines is None:
return result
if not isinstance(result,
(bytes, bytearray)) and not hasattr(result, "__aiter__"):
return result
data, sg_io = await apply_safeguard(result, safeguard)
data, sg_io = await apply_limit(result, limit)
if sg_io.exit_code != 0:
message = (await sg_io.stderr_str()
if sg_io.stderr else "safeguard exceeded")
raise SafeguardExceededError(message.strip())
if sg_io.stderr else "limit exceeded")
raise LimitExceededError(message.strip())
if sg_io.stderr:
logger.debug("vfs op output truncated: %s",
(await sg_io.stderr_str()).strip())
+4 -4
View File
@@ -19,7 +19,7 @@ from pydantic import BaseModel
from mirage.commands.cli.compile import validate_cli
from mirage.commands.spec.types import CommandSpec
from mirage.types import CommandSafeguard
from mirage.types import Limit
# The group-level flag bag the walk accumulates, keyed by canonical
# dashed spelling like ParsedArgs.flags.
@@ -64,7 +64,7 @@ class CLISpec(CommandSpec):
subcommands (tuple[CLISpec, ...]): child nodes (argparse
``add_subparsers().add_parser(...)``).
write (bool): leaf mutates backend state (policy classification).
safeguard (CommandSafeguard | None): safeguard category for the
limit (Limit | None): limit category for the
leaf.
config_model (type[BaseModel] | None): root only. Pydantic model
validating an installation's config from YAML ``clis:`` or
@@ -75,11 +75,11 @@ class CLISpec(CommandSpec):
fn: Callable[..., Any] | None = None
subcommands: tuple["CLISpec", ...] = ()
write: bool = False
# hash=False: CommandSafeguard is a mutable dataclass, and the
# hash=False: Limit is a mutable dataclass, and the
# frozen CLISpec must stay hashable for compile_spec's per-spec
# cache. Equality still compares the field; only the hash skips it
# (a collision is legal, a TypeError is not).
safeguard: CommandSafeguard | None = field(default=None, hash=False)
limit: Limit | None = field(default=None, hash=False)
config_model: type[BaseModel] | None = None
def __post_init__(self) -> None:
+4 -4
View File
@@ -21,7 +21,7 @@ from mirage.commands.spec.help import render_help
from mirage.commands.spec.types import Option
from mirage.io.stream import yield_bytes
from mirage.io.types import IOResult
from mirage.types import CommandSafeguard
from mirage.types import Limit
from mirage.version import __version__
HELP_OPTION = Option(
@@ -109,7 +109,7 @@ class RegisteredCommand:
src: str | None = None
dst: str | None = None
write: bool = False
safeguard: CommandSafeguard | None = None
limit: Limit | None = None
def command(
@@ -122,7 +122,7 @@ def command(
dry_run: Callable[..., Any] | None = None,
aggregate: Callable[..., Any] | None = None,
write: bool = False,
safeguard: CommandSafeguard | None = None,
limit: Limit | None = None,
) -> Callable[..., Any]:
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
@@ -139,7 +139,7 @@ def command(
provision_fn=provision or dry_run,
aggregate=aggregate,
write=write,
safeguard=safeguard,
limit=limit,
)
cmds.append(rc)
setattr(wrapped_fn, "_registered_commands", cmds)
+3 -4
View File
@@ -28,7 +28,7 @@ from mirage.resource.registry import build_resource
from mirage.runtime.base import Runtime
from mirage.runtime.table import build_runtime
from mirage.runtime.types import ScriptSource
from mirage.types import (KERNEL_BACKENDS, CommandSafeguard, ConsistencyPolicy,
from mirage.types import (KERNEL_BACKENDS, ConsistencyPolicy, Limit,
MountBackend, MountMode)
from mirage.workspace.mount.spec import Mount
from mirage.workspace.store import (DEFAULT_STATE_ROOT,
@@ -259,8 +259,7 @@ class MountBlock(BaseModel):
resource: str
mode: MountMode | None = None
config: dict[str, Any] = Field(default_factory=dict)
command_safeguards: dict[str,
CommandSafeguard] = Field(default_factory=dict)
command_limits: dict[str, Limit] = Field(default_factory=dict)
# How the mount is exposed: vfs (default, mirage's own filesystem only),
# fuse, or fskit. mountpoint is honored by the kernel backends.
backend: MountBackend = MountBackend.VFS
@@ -422,7 +421,7 @@ class WorkspaceConfig(BaseModel):
resources[prefix] = Mount(
resource=prov,
mode=mode,
command_safeguards=block.command_safeguards,
command_limits=block.command_limits,
)
kwargs: dict[str, Any] = {
"resources": resources,
+9 -9
View File
@@ -16,7 +16,7 @@ from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from mirage.io.cachable_iterator import CachableAsyncIterator
from mirage.types import CommandSafeguard
from mirage.types import Producer
ByteSource = bytes | AsyncIterator[bytes]
@@ -47,12 +47,12 @@ class IOResult:
reads (dict[str, ByteSource]): Paths read with content or streams.
writes (dict[str, ByteSource]): Paths written with content or streams.
cache (list[str]): Paths worth caching (from reads or writes).
safeguard (CommandSafeguard | None): Output cap for the command
that produced this result, resolved at dispatch time and
applied to the final output at the workspace boundary.
TODO: hoist this and any future finalization-policy fields
off IOResult (output data) into a separate
FinalizationContext returned alongside (stream, io).
producer (Producer | None): provenance of this result (which
command, spanning which mounts); merge keeps the rightmost
producer, mirroring whose stream the shell shows. The
workspace boundary hands it to the policy layer as
context. Facts ride the envelope, policy decisions never
do.
_stream_source (IOResult | None): Reference to the original IOResult
that owns the lazy stream. Needed because streaming commands
(e.g. grep) set exit_code lazily via exit_on_empty — the
@@ -79,7 +79,7 @@ class IOResult:
reads: dict[str, ByteSource] = field(default_factory=dict)
writes: dict[str, ByteSource] = field(default_factory=dict)
cache: list[str] = field(default_factory=list)
safeguard: CommandSafeguard | None = None
producer: Producer | None = None
_stream_source: "IOResult | None" = field(default=None, repr=False)
def __setattr__(self, name: str, value: object) -> None:
@@ -142,7 +142,7 @@ class IOResult:
**other.writes
},
cache=self.cache + other.cache,
safeguard=other.safeguard,
producer=other.producer,
)
result._stream_source = other
return result
+5 -2
View File
@@ -19,6 +19,7 @@ from typing import Any
from mirage.accessor.base import Accessor
from mirage.cache.index import IndexCacheStore
from mirage.commands.builtin.utils.limit import apply_op_limit
from mirage.commands.resolve import COMPOUND_EXTENSIONS
from mirage.context import assert_mount_allowed, effective_mount_mode
from mirage.observe import OpRecord
@@ -248,8 +249,10 @@ class Ops:
# completed backend op, so the caches and observation must
# reflect it before the deny suppresses it.
if self._policies is not None:
await post_ops_gate(self._policies, op, scope, write, mount_prefix,
result)
bound = await post_ops_gate(self._policies, op, scope, write,
mount_prefix, result)
if bound is not None:
result = await apply_op_limit(result, bound)
if (op == "stat" and self._stat_overlay is not None
and isinstance(result, FileStat)):
return self._stat_overlay(path, result)
+17 -4
View File
@@ -13,30 +13,43 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.policy.base import Policy
from mirage.policy.builtin import (DEFAULT_COMMAND_LIMITS, FALLBACK_LIMIT,
MountRootPolicy, OutputCapPolicy,
resolve_across_mounts, resolve_limit,
resolve_producer)
from mirage.policy.errors import PolicyDenied, PolicyError
from mirage.policy.mount_root import MountRootPolicy
from mirage.policy.policies import Policies, post_ops_gate, pre_ops_gate
from mirage.policy.policies import (Policies, post_execute_gate, post_ops_gate,
pre_ops_gate)
from mirage.policy.spec import SpecPolicy, wildcard_regex
from mirage.policy.types import (VALIDITY, Action, CommandContext, Deny,
GuardSpec, MountRootQuery, OpsContext,
OpsResultContext)
ExecuteResultContext, GuardSpec, Limit,
MountRootQuery, OpsContext, OpsResultContext)
__all__ = [
"Action",
"CommandContext",
"DEFAULT_COMMAND_LIMITS",
"Deny",
"ExecuteResultContext",
"FALLBACK_LIMIT",
"GuardSpec",
"Limit",
"MountRootPolicy",
"MountRootQuery",
"OpsContext",
"OpsResultContext",
"OutputCapPolicy",
"Policies",
"Policy",
"PolicyDenied",
"PolicyError",
"SpecPolicy",
"VALIDITY",
"post_execute_gate",
"post_ops_gate",
"pre_ops_gate",
"resolve_across_mounts",
"resolve_producer",
"resolve_limit",
"wildcard_regex",
]
+16 -3
View File
@@ -12,8 +12,8 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.policy.types import (Action, CommandContext, OpsContext,
OpsResultContext)
from mirage.policy.types import (Action, CommandContext, ExecuteResultContext,
OpsContext, OpsResultContext)
class Policy:
@@ -47,9 +47,22 @@ class Policy:
return None
async def post_ops(self, ctx: OpsResultContext) -> Action | None:
"""Observe one completed VFS op; a Deny suppresses its result.
"""Observe one completed VFS op; a Deny suppresses its result,
a Limit caps a byte-producing one.
Args:
ctx (OpsResultContext): the op and its raw result.
"""
return None
async def post_execute(self, ctx: ExecuteResultContext) -> Action | None:
"""Bound one finished execute() line's output.
A Limit returned here merges with every other opining policy's
(tightest per field) and caps the line's stdout at the
workspace boundary.
Args:
ctx (ExecuteResultContext): the finished line's facts.
"""
return None
+29
View File
@@ -0,0 +1,29 @@
# ========= 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. =========
from mirage.policy.builtin.mount_root import MountRootPolicy
from mirage.policy.builtin.output_cap import (DEFAULT_COMMAND_LIMITS,
FALLBACK_LIMIT, OutputCapPolicy,
resolve_across_mounts,
resolve_limit, resolve_producer)
__all__ = [
"DEFAULT_COMMAND_LIMITS",
"FALLBACK_LIMIT",
"MountRootPolicy",
"OutputCapPolicy",
"resolve_across_mounts",
"resolve_producer",
"resolve_limit",
]
+141
View File
@@ -0,0 +1,141 @@
# ========= 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. =========
from collections.abc import Callable, Iterable
from typing import Any
from mirage.policy.base import Policy
from mirage.policy.types import Action, ExecuteResultContext, OpsResultContext
from mirage.types import Limit, Producer
_DEFAULT_MAX_LINES = 2000
_DEFAULT_TIMEOUT_SECONDS = 600.0
DEFAULT_COMMAND_LIMITS: dict[str, Limit] = {
name:
Limit(max_lines=_DEFAULT_MAX_LINES,
timeout_seconds=_DEFAULT_TIMEOUT_SECONDS)
for name in ("cat", "grep", "rg", "head", "tail")
}
FALLBACK_LIMIT = Limit(timeout_seconds=_DEFAULT_TIMEOUT_SECONDS)
OverrideLookup = Callable[[str, str], Limit | None]
def resolve_limit(
name: str,
mounts: Iterable[Any] = (),
command_default: Limit | None = None,
mount_override: Limit | None = None,
) -> Limit | None:
"""Resolve one command's bound, the one precedence engine.
Precedence is override semantics, not the container's tighten-only
merge (an override may loosen a default), which is why it lives
inside this built-in and nowhere else: an explicit mount_override,
then the command's own declared default, then aggregation across
the mounts the command spans (tightest per field), then the global
table.
Args:
name (str): command name being resolved.
mounts (Iterable): the mounts the command spans (may be empty).
command_default (Limit | None): the registered command's own
default, when the caller knows it.
mount_override (Limit | None): one mount's per-command
override, when the caller knows it.
"""
if mount_override is not None:
return mount_override
if command_default is not None:
return command_default
spanned = list(mounts)
if spanned:
return resolve_across_mounts(name, spanned)
return DEFAULT_COMMAND_LIMITS.get(name, FALLBACK_LIMIT)
def resolve_across_mounts(
name: str,
mounts: Iterable[Any],
) -> Limit | None:
"""Resolve and aggregate the bound across the mounts a command spans.
A command that touches several mounts but yields one stream
(cross-mount cat, fan-out find/grep -r/du/tree/ls -R) must respect
every spanned mount's bound, so each mount's per-command override is
resolved and combined with Limit.aggr (tightest per field).
Args:
name (str): command name being resolved.
mounts (Iterable): the mounts the command spans.
"""
resolved = [
resolve_limit(name, mount_override=m.command_limits.get(name))
for m in mounts
]
return Limit.aggr(resolved)
def resolve_producer(producer: Producer,
override_for: OverrideLookup) -> Limit | None:
"""Resolve the bound a producer's facts name.
Shared by OutputCapPolicy and the dispatch sites that still need
the resolved timeout locally: per-prefix override first, then the
producer's declared bound, then the global table, aggregated to
the tightest value when the command spanned several mounts.
Args:
producer (Producer): facts stamped at the dispatch site.
override_for (OverrideLookup): (prefix, name) -> that mount's
configured override.
"""
if not producer.command:
return None
if not producer.prefixes:
return resolve_limit(producer.command,
command_default=producer.declared)
per_mount = [
resolve_limit(producer.command,
command_default=producer.declared,
mount_override=override_for(prefix, producer.command))
for prefix in producer.prefixes
]
return Limit.aggr(per_mount)
class OutputCapPolicy(Policy):
"""The built-in output cap, seeded by the registry.
Answers post_execute with the resolution the ``command_limits:`` config
surface promises (override semantics, see resolve_limit) and
post_ops with a mount's per-op bound. Config parses into this
policy; the container and dispatch know nothing about caps.
Args:
override_for (OverrideLookup): maps (mount prefix, command or
op name) to that mount's configured override, injected by
the registry so this module stays a leaf.
"""
def __init__(self, override_for: OverrideLookup) -> None:
self._override_for = override_for
async def post_execute(self, ctx: ExecuteResultContext) -> Action | None:
return resolve_producer(ctx.producer, self._override_for)
async def post_ops(self, ctx: OpsResultContext) -> Action | None:
return self._override_for(ctx.prefix, ctx.op)
+70 -21
View File
@@ -19,12 +19,16 @@ from typing import Any
from mirage.policy.base import Policy
from mirage.policy.errors import PolicyDenied, PolicyError
from mirage.policy.spec import SpecPolicy
from mirage.policy.types import (VALIDITY, CommandContext, Deny, GuardSpec,
OpsContext, OpsResultContext)
from mirage.types import PathSpec
from mirage.policy.types import (VALIDITY, CommandContext, Deny,
ExecuteResultContext, GuardSpec, OpsContext,
OpsResultContext)
from mirage.types import Limit, PathSpec
logger = logging.getLogger(__name__)
HookContext = (CommandContext | OpsContext | OpsResultContext
| ExecuteResultContext)
async def pre_ops_gate(policies: "Policies", op: str, path: PathSpec,
write: bool, prefix: str) -> None:
@@ -53,9 +57,13 @@ async def pre_ops_gate(policies: "Policies", op: str, path: PathSpec,
async def post_ops_gate(policies: "Policies", op: str, path: PathSpec,
write: bool, prefix: str, result: Any) -> None:
write: bool, prefix: str, result: Any) -> Limit | None:
"""Fire post_ops at an op door; a Deny suppresses the result.
Returns the merged Limit bound (tightest per field across every
opining policy) for the door to apply to a byte-producing result,
or None when no policy bounds this op.
Args:
policies (Policies): the workspace's admission policies.
op (str): operation name.
@@ -65,8 +73,8 @@ async def post_ops_gate(policies: "Policies", op: str, path: PathSpec,
result (Any): the op's raw result, offered to the hooks.
"""
if not policies.wants("post_ops"):
return
deny = await policies.post_ops(
return None
deny, bound = await policies.post_ops(
OpsResultContext(op=op,
path=path,
write=write,
@@ -75,6 +83,25 @@ async def post_ops_gate(policies: "Policies", op: str, path: PathSpec,
if deny is not None:
raise PolicyDenied(errno.EACCES, deny.message.rstrip("\n"),
path.virtual)
return bound
async def post_execute_gate(
policies: "Policies",
ctx: ExecuteResultContext) -> tuple[Deny | None, Limit | None]:
"""Fire post_execute at the workspace boundary.
Returns the fail-closed Deny (a raising policy) if any, and the
merged Limit bound for the boundary to enforce on the line's
output stream.
Args:
policies (Policies): the workspace's policies.
ctx (ExecuteResultContext): the finished line's facts.
"""
if not policies.wants("post_execute"):
return None, None
return await policies.post_execute(ctx)
class Policies:
@@ -134,9 +161,16 @@ class Policies:
break
self._wanted = frozenset(wanted)
async def _fire(self, hook: str, ctx: CommandContext | OpsContext
| OpsResultContext, subject: str) -> Deny | None:
async def _fire(self, hook: str, ctx: HookContext,
subject: str) -> tuple[Deny | None, Limit | None]:
"""One loop for every hook: first Deny wins, Limits merge.
A refusal short-circuits (limits are moot once the result is
suppressed); Limit actions accumulate and aggregate to the
tightest value per field.
"""
base = getattr(Policy, hook)
limits: list[Limit] = []
for policy in self._policies:
if getattr(type(policy), hook) is base:
continue
@@ -145,16 +179,18 @@ class Policies:
action = await getattr(policy, hook)(ctx)
except Exception as exc:
logger.error("%s policy %s raised: %s", hook, name, exc)
return Deny(f"{subject}: policy {name} failed: {exc}\n")
return Deny(f"{subject}: policy {name} failed: {exc}\n"), None
if action is None:
continue
if not isinstance(action,
Deny) or action.kind not in VALIDITY[hook]:
raise PolicyError(
f"{hook} of {name} returned {action!r}; "
f"legal kinds here: {sorted(VALIDITY[hook])}")
return action
return None
legal = VALIDITY[hook]
if isinstance(action, Deny) and Deny.kind in legal:
return action, None
if isinstance(action, Limit) and Limit.kind in legal:
limits.append(action)
continue
raise PolicyError(f"{hook} of {name} returned {action!r}; "
f"legal kinds here: {sorted(legal)}")
return None, Limit.aggr(limits)
async def pre_command(self, ctx: CommandContext) -> Deny | None:
"""Fire pre_command across the policies; first Deny wins.
@@ -162,7 +198,8 @@ class Policies:
Args:
ctx (CommandContext): the classified command.
"""
return await self._fire("pre_command", ctx, ctx.command)
deny, _ = await self._fire("pre_command", ctx, ctx.command)
return deny
async def pre_ops(self, ctx: OpsContext) -> Deny | None:
"""Fire pre_ops across the policies; first Deny wins.
@@ -170,13 +207,25 @@ class Policies:
Args:
ctx (OpsContext): the op about to run.
"""
return await self._fire("pre_ops", ctx, ctx.op)
deny, _ = await self._fire("pre_ops", ctx, ctx.op)
return deny
async def post_ops(self, ctx: OpsResultContext) -> Deny | None:
"""Fire post_ops across the policies; a Deny suppresses the
result.
async def post_ops(
self, ctx: OpsResultContext) -> tuple[Deny | None, Limit | None]:
"""Fire post_ops; a Deny suppresses the result, Limits merge.
Args:
ctx (OpsResultContext): the op and its raw result.
"""
return await self._fire("post_ops", ctx, ctx.op)
async def post_execute(
self,
ctx: ExecuteResultContext) -> tuple[Deny | None, Limit | None]:
"""Fire post_execute; Limits merge to the boundary bound.
Args:
ctx (ExecuteResultContext): the finished line's facts.
"""
return await self._fire("post_execute", ctx, ctx.producer.command
or "line")
+29 -15
View File
@@ -15,7 +15,7 @@
from dataclasses import dataclass
from typing import Any, ClassVar, Protocol
from mirage.types import PathSpec
from mirage.types import Limit, PathSpec, Producer
class MountRootQuery(Protocol):
@@ -30,20 +30,8 @@ class MountRootQuery(Protocol):
...
class Action:
"""Base of every policy answer.
A hook returns an Action to state an opinion or None to stay
silent. ``kind`` is the wire discriminant, mirrored by the
TypeScript union tag; each hook accepts a fixed set of kinds
(VALIDITY), enforced at the seam.
"""
kind: ClassVar[str] = ""
@dataclass(frozen=True, slots=True)
class Deny(Action):
class Deny:
"""Refuse the command with a message on stderr.
Args:
@@ -58,6 +46,13 @@ class Deny(Action):
exit_code: int = 1
# The closed vocabulary of policy answers: a hook returns an Action to
# state an opinion or None to stay silent. Deny refuses (first opinion
# wins); Limit bounds (every opinion merges to the tightest, Limit.aggr).
# Each hook accepts a fixed set of kinds (VALIDITY), enforced loud.
Action = Deny | Limit
@dataclass(frozen=True, slots=True)
class GuardSpec:
"""A declarative guard: refuse matching commands on matching paths.
@@ -142,8 +137,27 @@ class OpsResultContext:
result: Any
@dataclass(frozen=True, slots=True)
class ExecuteResultContext:
"""One finished execute() line, as post_execute hooks see it.
Fires at the workspace boundary before the line's output stream is
finalized, so a Limit returned here bounds what the caller sees.
Args:
producer (Producer): provenance of the surviving stream (the
rightmost command, per shell semantics); a Producer with an
empty command when no dispatch site stamped one.
exit_code (int): the line's exit code so far.
"""
producer: Producer
exit_code: int
VALIDITY: dict[str, frozenset[str]] = {
"pre_command": frozenset({Deny.kind}),
"pre_ops": frozenset({Deny.kind}),
"post_ops": frozenset({Deny.kind}),
"post_ops": frozenset({Deny.kind, Limit.kind}),
"post_execute": frozenset({Limit.kind}),
}
+1 -1
View File
@@ -90,7 +90,7 @@ class QuickJsRuntime(Runtime, EvaluatorMixin):
Each run gets its own epoch-interruption engine (via the shared
wasm runtime), so a cancelled run traps it and reclaims the
thread; a safeguard timeout stops the engine instead of leaking it.
thread; a limit timeout stops the engine instead of leaking it.
The module comes from the config `home` (the yaml entry's
``config`` block ends up here) or the MIRAGE_QUICKJS_HOME
-82
View File
@@ -1,82 +0,0 @@
# ========= 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. =========
from collections.abc import Iterable
from typing import Any
from mirage.types import CommandSafeguard
_DEFAULT_MAX_LINES = 2000
_DEFAULT_TIMEOUT_SECONDS = 600.0
DEFAULT_COMMAND_SAFEGUARDS: dict[str, CommandSafeguard] = {
name:
CommandSafeguard(max_lines=_DEFAULT_MAX_LINES,
timeout_seconds=_DEFAULT_TIMEOUT_SECONDS)
for name in ("cat", "grep", "rg", "head", "tail")
}
FALLBACK_SAFEGUARD = CommandSafeguard(timeout_seconds=_DEFAULT_TIMEOUT_SECONDS)
def resolve_safeguard(
name: str,
mounts: Iterable[Any] = (),
command_default: CommandSafeguard | None = None,
mount_override: CommandSafeguard | None = None,
) -> CommandSafeguard | None:
"""Resolve one command's safeguard, the one entry point.
Precedence: an explicit mount_override, then the command's own
default, then aggregation across the mounts the command spans
(tightest per field), then the global table.
Args:
name (str): command name being resolved.
mounts (Iterable): the mounts the command spans (may be empty).
command_default (CommandSafeguard | None): the registered
command's own default, passed by mount dispatch.
mount_override (CommandSafeguard | None): one mount's
per-command override, passed by mount dispatch.
"""
if mount_override is not None:
return mount_override
if command_default is not None:
return command_default
spanned = list(mounts)
if spanned:
return resolve_across_mounts(name, spanned)
return DEFAULT_COMMAND_SAFEGUARDS.get(name, FALLBACK_SAFEGUARD)
def resolve_across_mounts(
name: str,
mounts: Iterable[Any],
) -> CommandSafeguard | None:
"""Resolve and aggregate the safeguard across the mounts a command spans.
A command that touches several mounts but yields one stream
(cross-mount cat, fan-out find/grep -r/du/tree/ls -R) must respect
every spanned mount's guard, so each mount's per-command override is
resolved and combined with CommandSafeguard.aggr (tightest per field).
Args:
name (str): command name being resolved.
mounts (Iterable): the mounts the command spans.
"""
resolved = [
resolve_safeguard(name, mount_override=m.command_safeguards.get(name))
for m in mounts
]
return CommandSafeguard.aggr(resolved)
+1 -1
View File
@@ -31,7 +31,7 @@ class LocalRuntime(Runtime):
Each run spawns `<interpreter> -c <code>`; the code sees the host
filesystem and environment, not the workspace mounts. Cancelling the
run kills the subprocess, so a safeguard timeout reclaims it.
run kills the subprocess, so a limit timeout reclaims it.
The interpreter defaults to the one running mirage; point the
config `home` (the yaml entry's ``config`` block ends up here) or
+1 -1
View File
@@ -274,7 +274,7 @@ class MontyRuntime(Runtime, EvaluatorMixin):
# run_async executes on Monty's own tokio pool and returns an
# asyncio-compatible future: the loop stays free, and cancelling
# the future halts the interpreter (verified: CPU drops to zero),
# so a safeguard timeout reclaims the run instead of leaking a
# so a limit timeout reclaims the run instead of leaking a
# burning thread.
loop = asyncio.get_running_loop()
collector = pydantic_monty.CollectStreams()
+1 -1
View File
@@ -58,7 +58,7 @@ class WasiRuntime(Runtime):
Runs execute on a worker thread with the GIL released, and each run
gets its own epoch-interruption engine: cancelling the `run` task
bumps the epoch, which traps the run and reclaims the thread, so a
safeguard timeout stops the interpreter instead of leaking it.
limit timeout stops the interpreter instead of leaking it.
The build directory comes from the config `home` (the yaml entry's
``config`` block ends up here) or the MIRAGE_WASI_HOME environment
+47 -13
View File
@@ -16,17 +16,17 @@ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
from dataclasses import dataclass
from datetime import datetime
from enum import Enum, StrEnum
from typing import Annotated, Any, TypeAlias
from typing import Annotated, Any, ClassVar, TypeAlias
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt
class Aggr:
"""Declares how one CommandSafeguard field aggregates across guards.
"""Declares how one Limit field aggregates across stacked limits.
Attach to a field via Annotated[..., Aggr(rule)]; ``rule`` takes the
list of that field's values across the stacked safeguards and returns
the aggregated value. CommandSafeguard.aggr reads these rules so each
list of that field's values across the stacked limits and returns
the aggregated value. Limit.aggr reads these rules so each
field's aggregation behavior lives next to the field.
Args:
@@ -276,7 +276,17 @@ def _prefer_error(values: Iterable["OnExceed"]) -> "OnExceed":
for v in values) else OnExceed.TRUNCATE)
class CommandSafeguard(BaseModel):
class Limit(BaseModel):
"""A bound on a result: the policy layer's limit arm and the shape
every cap config parses into.
Carries its fields inline (the Deny precedent: an action is its
payload). ``kind`` is the wire discriminant; ``aggr`` is the
composition law (AND to the tightest per bound, ANY on error mode).
"""
kind: ClassVar[str] = "limit"
max_bytes: Annotated[NonNegativeInt | None, Aggr(_min_positive)] = None
max_lines: Annotated[NonNegativeInt | None, Aggr(_min_positive)] = None
timeout_seconds: Annotated[float | None, Aggr(_min_positive)] = None
@@ -285,19 +295,19 @@ class CommandSafeguard(BaseModel):
@classmethod
def aggr(
cls,
safeguards: "Iterable[CommandSafeguard | None]",
) -> "CommandSafeguard | None":
"""Aggregate several safeguards using each field's declared rule.
limits: "Iterable[Limit | None]",
) -> "Limit | None":
"""Aggregate several limits using each field's declared rule.
Every field carries an Aggr(rule) in its annotation; this applies
that rule to the field's values across the present guards. Returns
None when nothing is configured. Used wherever guards stack
(cross-mount fan-out, layered configs).
that rule to the field's values across the present limits. Returns
None when nothing is configured. Used wherever bounds stack
(policy composition, cross-mount fan-out, layered configs).
Args:
safeguards (Iterable[CommandSafeguard | None]): guards to merge.
limits (Iterable[Limit | None]): limits to merge.
"""
present = [s for s in safeguards if s is not None]
present = [s for s in limits if s is not None]
if not present:
return None
kwargs: dict[str, Any] = {}
@@ -310,6 +320,30 @@ class CommandSafeguard(BaseModel):
return cls(**kwargs)
@dataclass(frozen=True, slots=True)
class Producer:
"""Provenance of a result: who produced it, and where.
Rides the IO envelope from the dispatch site to the workspace
boundary; merge keeps the rightmost producer, so this names the
command whose stream the caller actually sees. Post-layer policies
(output caps today; budgets and attribution later) read it as
context. Facts only: policy decisions never travel on the
envelope.
Args:
command (str): the producing command's name.
prefixes (tuple[str, ...]): mount prefixes the command spanned.
declared (Limit | None): the bound the command's own
registration declared, when the dispatch site knows it
(e.g. a CLI leaf); None for commands with no declaration.
"""
command: str
prefixes: tuple[str, ...] = ()
declared: Limit | None = None
class VFSWriteOp(str, Enum):
WRITE = "write"
UNLINK = "unlink"
+9 -3
View File
@@ -17,6 +17,7 @@ from typing import Any
from mirage.cache.file import io as cache_io
from mirage.cache.manager import CacheManager
from mirage.commands.builtin.utils.limit import apply_op_limit
from mirage.io import IOResult
from mirage.observe.record import OpRecord
from mirage.ops.config import NO_FOLLOW_OPS, STAMP_WRITE_OPS
@@ -83,8 +84,10 @@ class Dispatcher:
cached = await self._cache.get(path.virtual)
if cached is not None and await self._reconciler.may_serve_cached(
mount, path.virtual):
await post_ops_gate(policies, op, path, write, mount.prefix,
cached)
bound = await post_ops_gate(policies, op, path, write,
mount.prefix, cached)
if bound is not None:
cached = await apply_op_limit(cached, bound)
return cached, IOResult(reads={path.virtual: cached})
if op == "rename" and isinstance(kwargs.get("dst"), PathSpec):
@@ -110,7 +113,10 @@ class Dispatcher:
await self.invalidate_after_write(mount, path, observed=observed)
if op == "rename" and isinstance(kwargs.get("dst"), PathSpec):
await self.invalidate_after_write(mount, kwargs["dst"])
await post_ops_gate(policies, op, path, write, mount.prefix, result)
bound = await post_ops_gate(policies, op, path, write, mount.prefix,
result)
if bound is not None:
result = await apply_op_limit(result, bound)
return result, IOResult()
async def stat(self, path: str) -> FileStat:
+10 -10
View File
@@ -14,8 +14,8 @@
from dataclasses import replace
from mirage.commands.builtin.utils.safeguard import (maybe_with_timeout,
run_with_timeout)
from mirage.commands.builtin.utils.limit import (maybe_with_timeout,
run_with_timeout)
from mirage.commands.cli.walk import walk
from mirage.commands.config import HELP_OPTION
from mirage.commands.spec import flag_kwarg_name
@@ -23,8 +23,8 @@ from mirage.commands.spec.help import render_help
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.runtime.policy.safeguard import resolve_safeguard
from mirage.types import PathSpec
from mirage.policy import resolve_limit
from mirage.types import PathSpec, Producer
from mirage.workspace.cli.types import CLIInstall
from mirage.workspace.executor.command.flags import option_error, parse_flags
from mirage.workspace.executor.command.run import exec_node
@@ -114,26 +114,26 @@ async def handle_cli(
# validate_cli guarantees fn XOR subcommands and walk only
# returns fn-bearing nodes as leaf; reaching this is a bug.
raise RuntimeError(f"walk returned a leaf without fn for {prog!r}")
# The leaf's declared safeguard bounds the handler body and its
# The leaf's declared limit bounds the handler body and its
# streams, exactly like mount dispatch: without the wrap a blocking
# leaf hangs forever and an unbounded-output leaf ignores its own
# limits.
safeguard = resolve_safeguard(prog, command_default=leaf.safeguard)
timeout = safeguard.timeout_seconds if safeguard is not None else None
limit = resolve_limit(prog, command_default=leaf.limit)
timeout = limit.timeout_seconds if limit is not None else None
out = await run_with_timeout(
fn(install.config, parsed.paths, *parsed.texts, **kw), timeout, prog)
if out is None:
stdout, io = None, IOResult()
else:
stdout, io = out
io.safeguard = safeguard
io.producer = Producer(command=prog, declared=leaf.limit)
if parsed.warnings:
warn = "".join(f"{prog}: {w}\n" for w in parsed.warnings).encode()
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = warn + existing
stdout = maybe_with_timeout(stdout, io.safeguard, prog)
io.stderr = maybe_with_timeout(io.stderr, io.safeguard, prog)
stdout = maybe_with_timeout(stdout, limit, prog)
io.stderr = maybe_with_timeout(io.stderr, limit, prog)
return stdout, io, await exec_node(cmd_str, io, parsed.paths)
@@ -20,17 +20,17 @@ from mirage.commands.builtin.generic.crossmount import (handle_cross_mount,
is_cross_mount)
from mirage.commands.builtin.generic.crossmount.detect import strategy_for
from mirage.commands.builtin.generic.crossmount.types import Strategy
from mirage.commands.builtin.utils.safeguard import maybe_with_timeout
from mirage.commands.builtin.utils.limit import maybe_with_timeout
from mirage.commands.config import version_request
from mirage.commands.spec import SPECS
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.policy import resolve_limit, resolve_producer
from mirage.runtime.policy import PolicyDecision
from mirage.runtime.policy.safeguard import resolve_safeguard
from mirage.shell.call_stack import CallStack
from mirage.shell.job_table import JobTable
from mirage.types import PathSpec
from mirage.types import PathSpec, Producer
from mirage.workspace.executor.command.cli import handle_cli
from mirage.workspace.executor.command.flags import option_error, parse_flags
from mirage.workspace.executor.command.functions import run_shell_function
@@ -217,9 +217,10 @@ async def handle_command(
for w in cross_parsed.warnings).encode()
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = warn + existing
# The native sub-runs carry their own mount's safeguard; the
# cross-mount command as a whole uses the strictest one across the
# operand mounts, regardless of which sub-run merged last.
# The native sub-runs carry their own mount's scope; the
# cross-mount command as a whole is bounded by the strictest
# cap across the operand mounts, regardless of which sub-run
# merged last.
mounts = []
for s in path_scopes:
try:
@@ -227,8 +228,10 @@ async def handle_command(
except ValueError:
# a scope outside any mount contributes nothing here
pass
io.safeguard = resolve_safeguard(cmd_name, mounts)
stdout = maybe_with_timeout(stdout, io.safeguard, cmd_name)
io.producer = Producer(command=cmd_name,
prefixes=tuple(m.prefix for m in mounts))
stdout = maybe_with_timeout(stdout, resolve_limit(cmd_name, mounts),
cmd_name)
return stdout, io, await exec_node(cmd_str, io, path_scopes)
# Reject unsupported cross-mount commands. Path-flag targets count: a
@@ -328,7 +331,9 @@ async def handle_command(
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = warn_bytes + existing
stdout = maybe_with_timeout(stdout, io.safeguard, cmd_name)
io.stderr = maybe_with_timeout(io.stderr, io.safeguard, cmd_name)
resolved = (resolve_producer(io.producer, registry.limit_override)
if io.producer is not None else None)
stdout = maybe_with_timeout(stdout, resolved, cmd_name)
io.stderr = maybe_with_timeout(io.stderr, resolved, cmd_name)
return stdout, io, await exec_node(cmd_str, io, paths)
@@ -16,7 +16,7 @@ import functools
from typing import Any
from mirage.commands.builtin.generic.ls import LS_FAILURE
from mirage.commands.builtin.utils.safeguard import CommandTimeoutError
from mirage.commands.builtin.utils.limit import CommandTimeoutError
from mirage.commands.errors import UsageError
from mirage.io import IOResult
from mirage.io.stream import materialize, wrap_cachable_streams
@@ -271,7 +271,7 @@ async def run_on_mount(
return None, IOResult(exit_code=exc.exit_code,
stderr=f"{exc}\n".encode())
except CommandTimeoutError:
# A safeguard timeout is answered by the workspace-level handler
# A limit timeout is answered by the workspace-level handler
# (exit 124), not here.
raise
except Exception as exc:
+4 -4
View File
@@ -18,8 +18,7 @@ from mirage.commands.errors import FindParseError
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.runtime.policy.safeguard import resolve_across_mounts
from mirage.types import PathSpec
from mirage.types import PathSpec, Producer
from mirage.utils.path import respell_one
from mirage.workspace.executor.find_action_dispatch import _apply_find_actions
from mirage.workspace.mount import MountEntry, MountRegistry
@@ -365,8 +364,9 @@ async def _fan_out_traversal(
final_io_exit = 1
merged_io.exit_code = final_io_exit
merged_io.safeguard = resolve_across_mounts(cmd_name,
[primary_mount, *descendants])
merged_io.producer = Producer(
command=cmd_name,
prefixes=tuple(m.prefix for m in [primary_mount, *descendants]))
exec_node = ExecutionNode(command=cmd_str,
exit_code=final_io_exit,
stderr=await materialize(merged_io.stderr))
+1 -1
View File
@@ -16,7 +16,7 @@ import asyncio
import tree_sitter
from mirage.commands.builtin.utils.safeguard import CommandTimeoutError
from mirage.commands.builtin.utils.limit import CommandTimeoutError
from mirage.io import IOResult
from mirage.io.types import ByteSource, materialize
from mirage.shell.errors import ExitSignal
+1 -1
View File
@@ -16,7 +16,7 @@ from typing import Any
import tree_sitter
from mirage.commands.builtin.utils.safeguard import run_with_timeout
from mirage.commands.builtin.utils.limit import run_with_timeout
from mirage.io import IOResult
from mirage.io.stream import async_chain, close_quietly, merge_stdout_stderr
from mirage.io.types import ByteSource, materialize
+18 -16
View File
@@ -19,8 +19,7 @@ from typing import Any, Callable
from mirage.cache.context import push_cache_manager
from mirage.cache.manager import CacheManager
from mirage.commands.builtin.utils.safeguard import (apply_op_safeguard,
run_with_timeout)
from mirage.commands.builtin.utils.limit import run_with_timeout
from mirage.commands.config import RegisteredCommand
from mirage.commands.resolve import get_extension
from mirage.commands.spec import CommandSpec
@@ -32,10 +31,11 @@ from mirage.observe.context import (push_mount_prefix, push_revisions,
with_revisions)
from mirage.ops.registry import RegisteredOp
from mirage.ops.types import LinkView, StatOverlay, StatPath
from mirage.policy import resolve_limit
from mirage.resource.base import BaseResource
from mirage.runtime.base import Runtime
from mirage.runtime.policy.safeguard import CommandSafeguard, resolve_safeguard
from mirage.types import ConsistencyPolicy, MountMode, PathSpec
from mirage.types import (ConsistencyPolicy, Limit, MountMode, PathSpec,
Producer)
from mirage.utils.errors import enotsup
from mirage.utils.key_prefix import mount_key
from mirage.utils.params import accepts_kwarg
@@ -135,7 +135,7 @@ class MountEntry:
# command resolution. None until first built; invalidated on
# register.
self._prefix_index: dict[str, list[int]] | None = None
self.command_safeguards: dict[str, CommandSafeguard] = {}
self.command_limits: dict[str, Limit] = {}
self._ops: dict[tuple[Any, ...], RegisteredOp] = {}
self._general_ops: dict[str, RegisteredOp] = {}
# key: (cmd_name, target_resource_type)
@@ -553,17 +553,17 @@ class MountEntry:
exit_code=1,
stderr=(f"{cmd_name}: read-only mount "
f"at {self.prefix}".encode()))
# The dispatch-level guard only sees default safeguards
# The dispatch-level guard only sees default limits
# (the mount is unknown before routing), so the
# mount-resolved timeout must also bound the command
# body: eager commands do their work inside cmd.fn,
# where the stream-consumption guard never runs.
resolved_safeguard = resolve_safeguard(
resolved_limit = resolve_limit(
cmd_name,
command_default=cmd.safeguard,
mount_override=self.command_safeguards.get(cmd_name))
cmd_timeout = (resolved_safeguard.timeout_seconds
if resolved_safeguard is not None else None)
command_default=cmd.limit,
mount_override=self.command_limits.get(cmd_name))
cmd_timeout = (resolved_limit.timeout_seconds
if resolved_limit is not None else None)
call_kw = kw | {
key: value
for key, value in offered.items()
@@ -575,9 +575,9 @@ class MountEntry:
if result is not None:
stream, io = _wrap_cmd_streams(result, mount_prefix,
self.revisions or None)
# TODO: hand back a finalization context separately
# instead of stamping policy onto io.safeguard.
io.safeguard = resolved_safeguard
io.producer = Producer(command=cmd_name,
prefixes=(self.prefix, ),
declared=cmd.limit)
return stream, io
return None, IOResult()
finally:
@@ -635,7 +635,9 @@ class MountEntry:
resource_path=mount_key(path, mount_prefix),
)
kwargs.setdefault("index", self.resource.index)
op_override = self.command_safeguards.get(op_name)
# Per-op caps are policy and fire at the op doors (post_ops);
# only the timeout stays here, bounding the backend call itself.
op_override = self.command_limits.get(op_name)
op_timeout = (op_override.timeout_seconds
if op_override is not None else None)
prev_prefix = push_mount_prefix(mount_prefix)
@@ -647,7 +649,7 @@ class MountEntry:
result = await run_with_timeout(result, op_timeout,
op_name)
if result is not None:
return await apply_op_safeguard(result, op_override)
return result
return None
finally:
reset_revisions(revs_token)
+24 -5
View File
@@ -18,11 +18,11 @@ from mirage.cache.file.mixin import FileCacheMixin
from mirage.cache.manager import CacheManager
from mirage.commands.builtin.general import COMMANDS as GENERAL_COMMANDS
from mirage.ops.config import OpsMount
from mirage.policy import MountRootPolicy, Policies
from mirage.policy import MountRootPolicy, OutputCapPolicy, Policies
from mirage.resource.base import BaseResource
from mirage.resource.dev import DevResource
from mirage.runtime.base import Runtime
from mirage.types import ConsistencyPolicy, MountMode, PathSpec
from mirage.types import ConsistencyPolicy, Limit, MountMode, PathSpec
from mirage.workspace.cli import CLIRegistry
from mirage.workspace.mount.mount import MountEntry
@@ -85,11 +85,14 @@ class MountRegistry:
self.runtime_unavailable: dict[str, str] = {}
# Command admission policies. Policies itself is a bare
# mechanism; the registry seeds the POSIX mount-root rule
# (mount-root semantics are mount semantics) and user policies
# follow it (Workspace guards= / policies= / yaml guards:).
# (mount-root semantics are mount semantics) and the built-in
# output cap (fed the per-mount overrides), and user policies
# follow them (Workspace guards= / policies= / yaml guards:).
# Registry-hosted like runtime_bindings so the executor reaches
# them without new parameter threading.
self.policies = Policies([MountRootPolicy()])
self.policies = Policies(
[MountRootPolicy(),
OutputCapPolicy(self.limit_override)])
# Installed CLIs. Not mount state: CLIs are fully separate from
# mounts (a CLI exists because it was installed, never because
@@ -198,6 +201,22 @@ class MountRegistry:
return m
raise ValueError(f"no mount with prefix {prefix!r}")
def limit_override(self, prefix: str, name: str) -> Limit | None:
"""One mount's configured cap for a command or op name.
The lookup OutputCapPolicy is seeded with; tolerant of a
prefix that matches no mount (unmounted between stamp and
boundary) by answering None.
Args:
prefix (str): the mount prefix as stamped at dispatch.
name (str): command or op name.
"""
for m in self._mounts:
if m.prefix == prefix or m.prefix.rstrip("/") == prefix:
return m.command_limits.get(name)
return None
def is_mount_root(self, path: str) -> bool:
stripped = path.strip("/")
norm = "/" + stripped + "/" if stripped else "/"
+2 -4
View File
@@ -15,8 +15,7 @@
from dataclasses import dataclass, field
from mirage.resource.base import BaseResource
from mirage.runtime.policy.safeguard import CommandSafeguard
from mirage.types import MountBackend, MountMode
from mirage.types import Limit, MountBackend, MountMode
@dataclass(frozen=True)
@@ -29,5 +28,4 @@ class Mount:
# Where to mount, for the kernel backends. None picks a temporary
# directory appropriate for the backend. Ignored when backend is VFS.
mountpoint: str | None = None
command_safeguards: dict[str,
CommandSafeguard] = field(default_factory=dict)
command_limits: dict[str, Limit] = field(default_factory=dict)
@@ -15,16 +15,15 @@
import asyncio
from typing import Any
from mirage.commands.builtin.utils.safeguard import run_with_timeout
from mirage.commands.builtin.utils.limit import run_with_timeout
from mirage.io import IOResult
from mirage.io.types import materialize
from mirage.policy import CommandContext
from mirage.policy import CommandContext, resolve_limit
from mirage.runtime.policy import PolicyDecision
from mirage.runtime.policy.safeguard import resolve_safeguard
from mirage.shell.types import NodeType as NT
from mirage.shell.types import ShellBuiltin as SB
from mirage.shell.xtrace import trace_command
from mirage.types import PathSpec, word_text
from mirage.types import PathSpec, Producer, word_text
from mirage.utils.path import CycleError
from mirage.workspace.executor.command import handle_command
from mirage.workspace.executor.command.routing import path_flag_scopes
@@ -225,9 +224,9 @@ async def _dispatch_command_body(
argv = await expand_argv(parts, session, execute_fn, call_stack, registry)
# Safeguards resolve against the expanded name, so `$CMD`-style
# Limits resolve against the expanded name, so `$CMD`-style
# invocations get their real command's policy.
resolved = resolve_safeguard(argv.name) if argv.name else None
resolved = resolve_limit(argv.name) if argv.name else None
timeout = (resolved.timeout_seconds if resolved is not None else None)
body = _run_argv(recurse, dispatch, registry, namespace, execute_fn, argv,
session, stdin, call_stack, job_table, cancel,
@@ -237,6 +236,11 @@ async def _dispatch_command_body(
xtrace = bool(session.shell_options.get("xtrace"))
stdout, io, exec_node = await run_with_timeout(body, timeout, argv.name
or "?")
if io.producer is None and argv.name:
# Builtins and other non-mount routes return no rider; stamp the
# expanded name here so post_execute policies keyed on a command
# (echo, printf, ...) still see it.
io.producer = Producer(command=argv.name)
if proc_sub_stderr:
io.stderr = b"".join(proc_sub_stderr) + await materialize(io.stderr)
exec_node.stderr = io.stderr
+18 -3
View File
@@ -15,11 +15,14 @@
import asyncio
from typing import Any, Callable
from mirage.commands.builtin.utils.safeguard import guard_output
from mirage.commands.builtin.utils.limit import guard_output
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.policy import ExecuteResultContext, post_execute_gate
from mirage.runtime.policy import PolicyDecision
from mirage.shell.barrier import BarrierPolicy, apply_barrier
from mirage.shell.job_table import JobTable
from mirage.types import Producer
from mirage.workspace.mount import MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.node.execute_node import execute_node
@@ -43,7 +46,7 @@ async def run_command_tree(
"""Run a parsed command tree and finalize its output stream.
Executes the AST root, then applies the value barrier and the
command safeguard, folding the safeguard's stderr and exit code
command limit, folding the limit's stderr and exit code
into the result. This is the seam between the Workspace shell
(sessions, drift, recording) and the command executor: a caller
hands in a parsed tree plus its dependencies and gets back the
@@ -85,7 +88,19 @@ async def run_command_tree(
routing_decision=routing_decision,
)
stdout = await apply_barrier(stdout, io, BarrierPolicy.VALUE)
# The boundary consultation: the envelope's producer facts become
# the post_execute context; the built-in cap and any user policies
# answer with Limits (tightest merged), enforced by guard_output.
ctx = ExecuteResultContext(producer=io.producer or Producer(command=""),
exit_code=io.exit_code)
deny, bound = await post_execute_gate(registry.policies, ctx)
if deny is not None:
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = existing + deny.message.encode()
io.exit_code = deny.exit_code
io.stdout = None
return io, exec_node
stdout, io.stderr, io.exit_code = await guard_output(
stdout, io.stderr, io.exit_code, io.safeguard)
stdout, io.stderr, io.exit_code, bound)
io.stdout = stdout
return io, exec_node
+5 -5
View File
@@ -17,14 +17,14 @@ import logging
from functools import partial
from typing import Any
from mirage.commands.builtin.utils.safeguard import (CommandTimeoutError,
run_with_timeout)
from mirage.commands.builtin.utils.limit import (CommandTimeoutError,
run_with_timeout)
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.observe.context import RecordingScope
from mirage.policy import resolve_limit
from mirage.provision import ProvisionResult
from mirage.runtime.policy import PolicyDecision, PolicyDeny, PolicyError
from mirage.runtime.policy.safeguard import resolve_safeguard
from mirage.shell.parse import (find_syntax_error, find_unterminated_backtick,
parse)
from mirage.workspace.abort import MirageAbortError
@@ -175,7 +175,7 @@ async def execute_line(
exec_recursion = partial(recurse, ws, cancel, decision)
if provision:
name = command_name(command)
guard = resolve_safeguard(name) if name else None
guard = resolve_limit(name) if name else None
timeout = guard.timeout_seconds if guard is not None else None
return await run_with_timeout(
provision_node(ws._registry, ws.dispatch, plan_eval_stub,
@@ -185,7 +185,7 @@ async def execute_line(
if line_runtime is not None:
io = await run_whole_line(
line_runtime, command, stdin, effective_session,
ws._registry.mounts(),
ws._registry.mounts(), ws._registry.policies,
ws._dispatcher.invalidate_all_after_remote)
session.last_exit_code = io.exit_code
return io
+1 -1
View File
@@ -12,7 +12,7 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.commands.builtin.utils.safeguard import CommandTimeoutError
from mirage.commands.builtin.utils.limit import CommandTimeoutError
from mirage.commands.errors import FindParseError, UsageError
from mirage.io import IOResult
from mirage.runtime.policy import PolicyDeny
+22 -11
View File
@@ -14,12 +14,13 @@
from collections.abc import Awaitable, Callable
from mirage.commands.builtin.utils.safeguard import (guard_output,
run_with_timeout)
from mirage.commands.builtin.utils.limit import guard_output, run_with_timeout
from mirage.io import IOResult
from mirage.io.types import ByteSource, materialize
from mirage.policy import (ExecuteResultContext, Policies, post_execute_gate,
resolve_limit)
from mirage.runtime.base import Runtime
from mirage.runtime.policy.safeguard import resolve_safeguard
from mirage.types import Producer
from mirage.workspace.mount import MountEntry
from mirage.workspace.session import Session
from mirage.workspace.workspace.utils import command_name
@@ -27,27 +28,28 @@ from mirage.workspace.workspace.utils import command_name
async def run_whole_line(
runtime: Runtime, command: str, stdin: ByteSource | None,
session: Session, mounts: list[MountEntry],
session: Session, mounts: list[MountEntry], policies: Policies,
invalidate: Callable[[], Awaitable[None]]) -> IOResult:
"""Hand the raw line to one runtime instead of walking its tree.
A whole line is a command like any other: the same safeguard
resolution and boundary rule as the tree, so ``timeout_seconds``
answers 124 and ``max_bytes``/``max_lines`` cap the output.
A whole line is a command like any other: the same boundary
consultation as the tree, so ``timeout_seconds`` answers 124 and
the policies' merged Limit caps the output.
Args:
runtime (Runtime): the runtime that captured the whole line.
command (str): the raw command line.
stdin (ByteSource | None): bytes piped into the line.
session (Session): session supplying cwd and env.
mounts (list[MountEntry]): mounts whose per-command safeguards
apply.
mounts (list[MountEntry]): mounts the line may span (every
mount: a whole-line runtime sees the full workspace).
policies (Policies): the workspace's policies.
invalidate (Callable[[], Awaitable[None]]): drops local read
caches once the line has run.
"""
data = await materialize(stdin) if stdin is not None else None
name = command_name(command)
guard = resolve_safeguard(name, mounts)
guard = resolve_limit(name, mounts)
timeout = guard.timeout_seconds if guard is not None else None
try:
result = await run_with_timeout(
@@ -57,7 +59,16 @@ async def run_whole_line(
# The line may have written anywhere in the runtime's view of
# the workspace; local read caches are stale.
await invalidate()
producer = Producer(command=name, prefixes=tuple(m.prefix for m in mounts))
deny, bound = await post_execute_gate(
policies,
ExecuteResultContext(producer=producer, exit_code=result.exit_code))
if deny is not None:
existing = result.stderr or b""
return IOResult(exit_code=deny.exit_code,
stdout=None,
stderr=existing + deny.message.encode())
stdout, stderr, exit_code = await guard_output(result.stdout or b"",
result.stderr,
result.exit_code, guard)
result.exit_code, bound)
return IOResult(exit_code=exit_code, stdout=stdout, stderr=stderr)
+8 -7
View File
@@ -32,7 +32,7 @@ def normalize_resources(resources: dict[str, ResourceMount],
Raises:
TypeError: a tuple entry is not (resource, mode) or
(resource, mode, command_safeguards).
(resource, mode, command_limits).
"""
specs: list[MountSpec] = []
for prefix, value in resources.items():
@@ -45,18 +45,19 @@ def normalize_resources(resources: dict[str, ResourceMount],
if value.mode is not None else default_mode,
backend=value.backend,
mountpoint=value.mountpoint,
safeguards=dict(value.command_safeguards or {}),
command_limits=dict(value.command_limits or {}),
))
elif isinstance(value, tuple):
if len(value) not in (2, 3):
raise TypeError("resource tuples must be (resource, mode) or "
"(resource, mode, command_safeguards)")
safeguards = dict(value[2]) if len(value) == 3 and value[2] else {}
"(resource, mode, command_limits)")
command_limits = dict(
value[2]) if len(value) == 3 and value[2] else {}
specs.append(
MountSpec(prefix=prefix,
resource=value[0],
mode=value[1],
safeguards=safeguards))
command_limits=command_limits))
else:
specs.append(
MountSpec(prefix=prefix, resource=value, mode=default_mode))
@@ -90,8 +91,8 @@ def install_mounts(registry: MountRegistry, specs: list[MountSpec],
for spec in specs:
spec.resource.set_index(index)
entry = registry.mount(spec.prefix, spec.resource, spec.mode)
if spec.safeguards:
entry.command_safeguards.update(spec.safeguards)
if spec.command_limits:
entry.command_limits.update(spec.command_limits)
implicit_root = registry.root_mount is None
if implicit_root:
registry.mount("/", RAMResource(), default_mode)
+3 -5
View File
@@ -16,14 +16,12 @@ from dataclasses import dataclass, field
from typing import TypeAlias
from mirage.resource.base import BaseResource
from mirage.runtime.policy.safeguard import CommandSafeguard
from mirage.types import MountBackend, MountMode
from mirage.types import Limit, MountBackend, MountMode
from mirage.workspace.mount.spec import Mount
ResourceMount: TypeAlias = (BaseResource | Mount
| tuple[BaseResource, MountMode]
| tuple[BaseResource, MountMode,
dict[str, CommandSafeguard]])
| tuple[BaseResource, MountMode, dict[str, Limit]])
@dataclass(frozen=True, slots=True)
@@ -40,4 +38,4 @@ class MountSpec:
mode: MountMode
backend: MountBackend = MountBackend.VFS
mountpoint: str | None = None
safeguards: dict[str, CommandSafeguard] = field(default_factory=dict)
command_limits: dict[str, Limit] = field(default_factory=dict)
+8 -8
View File
@@ -341,23 +341,23 @@ def test_missing_env_var_fails_fast_before_daemon_call(daemon, tmp_path):
_run_cli(env, "workspace", "create", str(cfg), expect_exit=2)
SAFEGUARD_TRUNCATE_YAML = """\
LIMIT_TRUNCATE_YAML = """\
mounts:
/:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
cat:
max_lines: 2
on_exceed: truncate
"""
SAFEGUARD_ERROR_YAML = """\
LIMIT_ERROR_YAML = """\
mounts:
/:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
cat:
max_lines: 2
on_exceed: error
@@ -372,8 +372,8 @@ def _write_named(tmp_path: Path, name: str, text: str) -> Path:
return p
def test_execute_safeguard_truncates_output(daemon, tmp_path):
cfg = _write_named(tmp_path, "sg_trunc.yaml", SAFEGUARD_TRUNCATE_YAML)
def test_execute_limit_truncates_output(daemon, tmp_path):
cfg = _write_named(tmp_path, "sg_trunc.yaml", LIMIT_TRUNCATE_YAML)
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
"sg-trunc")
_run_cli(daemon["env"], "execute", "--workspace_id", "sg-trunc",
@@ -385,8 +385,8 @@ def test_execute_safeguard_truncates_output(daemon, tmp_path):
_run_cli(daemon["env"], "workspace", "delete", "sg-trunc")
def test_execute_safeguard_error_exits_1(daemon, tmp_path):
cfg = _write_named(tmp_path, "sg_err.yaml", SAFEGUARD_ERROR_YAML)
def test_execute_limit_error_exits_1(daemon, tmp_path):
cfg = _write_named(tmp_path, "sg_err.yaml", LIMIT_ERROR_YAML)
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id", "sg-err")
_run_cli(daemon["env"], "execute", "--workspace_id", "sg-err", "--command",
_SEED_5_LINES)
@@ -17,9 +17,9 @@ import time
import pytest
from mirage.commands.builtin.utils.safeguard import (CommandTimeoutError,
run_with_timeout,
with_timeout)
from mirage.commands.builtin.utils.limit import (CommandTimeoutError,
run_with_timeout,
with_timeout)
from mirage.io.types import materialize
@@ -17,13 +17,12 @@ from collections.abc import AsyncIterator
import pytest
from mirage.commands.builtin.utils.safeguard import (CommandTimeoutError,
apply_safeguard,
maybe_with_timeout,
run_with_timeout)
from mirage.commands.builtin.utils.limit import (CommandTimeoutError,
apply_limit,
maybe_with_timeout,
run_with_timeout)
from mirage.io.types import materialize
from mirage.runtime.policy.safeguard import CommandSafeguard
from mirage.types import OnExceed
from mirage.types import Limit, OnExceed
_TEN = b"".join(f"line{i}\n".encode() for i in range(10))
@@ -47,22 +46,22 @@ async def _sleep_forever():
@pytest.mark.asyncio
async def test_no_safeguard_passthrough():
out, io = await apply_safeguard(_TEN, None)
async def test_no_limit_passthrough():
out, io = await apply_limit(_TEN, None)
assert out == _TEN and io.exit_code == 0 and io.stderr is None
@pytest.mark.asyncio
async def test_under_limit_not_truncated():
sg = CommandSafeguard(max_lines=100)
out, io = await apply_safeguard(_TEN, sg)
sg = Limit(max_lines=100)
out, io = await apply_limit(_TEN, sg)
assert out == _TEN and io.stderr is None
@pytest.mark.asyncio
async def test_truncate_by_lines():
sg = CommandSafeguard(max_lines=3)
out, io = await apply_safeguard(_TEN, sg)
sg = Limit(max_lines=3)
out, io = await apply_limit(_TEN, sg)
assert out == b"line0\nline1\nline2\n"
assert io.exit_code == 0
assert b"truncated" in (await materialize(io.stderr))
@@ -70,8 +69,8 @@ async def test_truncate_by_lines():
@pytest.mark.asyncio
async def test_error_by_lines():
sg = CommandSafeguard(max_lines=3, on_exceed=OnExceed.ERROR)
out, io = await apply_safeguard(_TEN, sg)
sg = Limit(max_lines=3, on_exceed=OnExceed.ERROR)
out, io = await apply_limit(_TEN, sg)
assert out is None
assert io.exit_code == 1
assert b"truncated" in (await materialize(io.stderr))
@@ -79,46 +78,44 @@ async def test_error_by_lines():
@pytest.mark.asyncio
async def test_truncate_by_bytes():
sg = CommandSafeguard(max_bytes=10)
out, io = await apply_safeguard(_TEN, sg)
sg = Limit(max_bytes=10)
out, io = await apply_limit(_TEN, sg)
assert out == _TEN[:10]
assert b"truncated" in (await materialize(io.stderr))
@pytest.mark.asyncio
async def test_streaming_input_truncates_and_stops_early():
sg = CommandSafeguard(max_lines=2)
out, io = await apply_safeguard(_stream(_TEN), sg)
sg = Limit(max_lines=2)
out, io = await apply_limit(_stream(_TEN), sg)
assert out == b"line0\nline1\n"
assert b"truncated" in (await materialize(io.stderr))
def test_maybe_with_timeout_passthrough_when_no_safeguard():
def test_maybe_with_timeout_passthrough_when_no_limit():
stream = _stream(_TEN)
assert maybe_with_timeout(stream, None, "cat") is stream
def test_maybe_with_timeout_passthrough_when_bytes():
assert maybe_with_timeout(_TEN, CommandSafeguard(timeout_seconds=1),
"cat") == _TEN
assert maybe_with_timeout(_TEN, Limit(timeout_seconds=1), "cat") == _TEN
def test_maybe_with_timeout_passthrough_when_no_timeout():
stream = _stream(_TEN)
assert maybe_with_timeout(stream, CommandSafeguard(max_lines=3),
"cat") is stream
assert maybe_with_timeout(stream, Limit(max_lines=3), "cat") is stream
def test_maybe_with_timeout_passthrough_when_nonpositive():
stream = _stream(_TEN)
assert maybe_with_timeout(stream, CommandSafeguard(timeout_seconds=0),
assert maybe_with_timeout(stream, Limit(timeout_seconds=0),
"cat") is stream
@pytest.mark.asyncio
async def test_maybe_with_timeout_wraps_and_fires():
wrapped = maybe_with_timeout(_slow_stream(),
CommandSafeguard(timeout_seconds=0.1), "cat")
wrapped = maybe_with_timeout(_slow_stream(), Limit(timeout_seconds=0.1),
"cat")
with pytest.raises(CommandTimeoutError):
await materialize(wrapped)
+1 -1
View File
@@ -70,7 +70,7 @@ def test_single_verb_cli_is_a_leaf_root():
curl_like = CLISpec(name="hello", fn=_verb)
assert curl_like.subcommands == ()
assert curl_like.write is False
assert curl_like.safeguard is None
assert curl_like.limit is None
def test_group_may_carry_its_own_options():
@@ -15,7 +15,7 @@
import pytest
from mirage.policy import CommandContext, MountRootPolicy
from mirage.policy.mount_root import has_parents_flag
from mirage.policy.builtin.mount_root import has_parents_flag
from mirage.resource.ram import RAMResource
from mirage.types import MountMode, PathSpec
from mirage.workspace.mount import MountRegistry
@@ -0,0 +1,132 @@
# ========= 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 pytest
from pydantic import ValidationError
from mirage.policy.builtin.output_cap import (DEFAULT_COMMAND_LIMITS,
OutputCapPolicy, resolve_limit,
resolve_producer)
from mirage.policy.types import OpsResultContext
from mirage.types import Limit, OnExceed, PathSpec, Producer
def test_defaults():
sg = Limit()
assert sg.max_bytes is None
assert sg.max_lines is None
assert sg.on_exceed == OnExceed.TRUNCATE
def test_on_exceed_coerces_from_string():
sg = Limit(on_exceed="truncate")
assert sg.on_exceed is OnExceed.TRUNCATE
def test_rejects_unknown_on_exceed():
with pytest.raises(ValidationError):
Limit(on_exceed="explode")
def test_rejects_negative_limits():
with pytest.raises(ValidationError):
Limit(max_bytes=-1)
with pytest.raises(ValidationError):
Limit(max_lines=-5)
def test_resolve_prefers_mount_override():
override = Limit(max_lines=5)
default = Limit(max_lines=50)
assert resolve_limit("cat",
command_default=default,
mount_override=override) is override
def test_resolve_falls_back_to_command_default():
default = Limit(max_lines=50)
assert resolve_limit("cat", command_default=default) is default
def test_resolve_falls_back_to_central_default():
assert resolve_limit("cat") is DEFAULT_COMMAND_LIMITS["cat"]
def test_resolve_unknown_command_returns_fallback_limit():
from mirage.policy.builtin.output_cap import FALLBACK_LIMIT
assert resolve_limit("nl") is FALLBACK_LIMIT
assert FALLBACK_LIMIT.timeout_seconds is not None
def _override_table(table):
return lambda prefix, name: table.get((prefix, name))
def test_resolve_producer_prefers_the_mount_override():
producer = Producer(command="cat",
prefixes=("/a/", ),
declared=Limit(max_lines=50))
resolved = resolve_producer(
producer, _override_table({("/a/", "cat"): Limit(max_lines=4)}))
assert resolved is not None
assert resolved.max_lines == 4
def test_resolve_producer_falls_back_to_declared_then_table():
declared = Producer(command="cat",
prefixes=(),
declared=Limit(max_lines=50))
resolved = resolve_producer(declared, _override_table({}))
assert resolved is not None
assert resolved.max_lines == 50
table = resolve_producer(Producer(command="cat"), _override_table({}))
assert table is not None
assert table.max_lines == DEFAULT_COMMAND_LIMITS["cat"].max_lines
def test_resolve_producer_aggregates_tightest_across_prefixes():
producer = Producer(command="cat", prefixes=("/a/", "/b/"))
resolved = resolve_producer(
producer,
_override_table({
("/a/", "cat"): Limit(max_lines=9),
("/b/", "cat"): Limit(max_lines=3),
}))
assert resolved is not None
assert resolved.max_lines == 3
def test_resolve_producer_empty_command_has_no_bound():
assert resolve_producer(Producer(command=""), _override_table({})) is None
@pytest.mark.asyncio
async def test_output_cap_policy_answers_post_ops_from_the_op_table():
policy = OutputCapPolicy(
_override_table({("/a/", "read"): Limit(max_bytes=4)}))
capped = await policy.post_ops(
OpsResultContext(op="read",
path=PathSpec.from_str_path("/a/x"),
write=False,
prefix="/a/",
result=b"payload"))
assert isinstance(capped, Limit)
assert capped.max_bytes == 4
silent = await policy.post_ops(
OpsResultContext(op="write",
path=PathSpec.from_str_path("/a/x"),
write=True,
prefix="/a/",
result=None))
assert silent is None
+78 -5
View File
@@ -16,12 +16,12 @@ import errno
import pytest
from mirage.policy import (Action, CommandContext, Deny, GuardSpec,
MountRootPolicy, OpsContext, OpsResultContext,
Policies, Policy, PolicyError, post_ops_gate,
pre_ops_gate)
from mirage.policy import (Action, CommandContext, Deny, ExecuteResultContext,
GuardSpec, MountRootPolicy, OpsContext,
OpsResultContext, Policies, Policy, PolicyError,
post_execute_gate, post_ops_gate, pre_ops_gate)
from mirage.resource.ram import RAMResource
from mirage.types import MountMode, PathSpec
from mirage.types import Limit, MountMode, PathSpec, Producer
from mirage.workspace.mount import MountRegistry
@@ -191,3 +191,76 @@ async def test_post_ops_gate_suppresses_the_result():
await post_ops_gate(policies, "read", _path("/data/x"), False,
"/data/", b"a long secret payload")
assert excinfo.value.errno == errno.EACCES
class CapFour(Policy):
async def post_ops(self, ctx: OpsResultContext) -> Action | None:
return Limit(max_bytes=4)
class CapTwo(Policy):
async def post_ops(self, ctx: OpsResultContext) -> Action | None:
return Limit(max_bytes=2)
class LimitOnPre(Policy):
async def pre_command(self, ctx: CommandContext) -> Action | None:
return Limit(max_bytes=1)
class CapLines(Policy):
async def post_execute(self, ctx: ExecuteResultContext) -> Action | None:
return Limit(max_lines=2)
def _ops_result_ctx() -> OpsResultContext:
return OpsResultContext(op="read",
path=_path("/data/x"),
write=False,
prefix="/data/",
result=b"payload")
@pytest.mark.asyncio
async def test_post_ops_limits_merge_to_the_tightest():
policies = Policies()
policies.add(CapFour())
policies.add(CapTwo())
deny, bound = await policies.post_ops(_ops_result_ctx())
assert deny is None
assert bound is not None
assert bound.max_bytes == 2
@pytest.mark.asyncio
async def test_post_ops_gate_returns_the_merged_bound():
policies = Policies()
policies.add(CapFour())
bound = await post_ops_gate(policies, "read", _path("/data/x"), False,
"/data/", b"payload")
assert bound is not None
assert bound.max_bytes == 4
@pytest.mark.asyncio
async def test_a_limit_is_illegal_on_pre_command():
policies = Policies()
policies.add(LimitOnPre())
with pytest.raises(PolicyError, match="LimitOnPre"):
await policies.pre_command(_ctx("ls"))
@pytest.mark.asyncio
async def test_post_execute_gate_merges_user_limits():
policies = Policies()
policies.add(CapLines())
deny, bound = await post_execute_gate(
policies,
ExecuteResultContext(producer=Producer(command="echo"), exit_code=0))
assert deny is None
assert bound is not None
assert bound.max_lines == 2
@@ -1,68 +0,0 @@
# ========= 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 pytest
from pydantic import ValidationError
from mirage.runtime.policy.safeguard import (DEFAULT_COMMAND_SAFEGUARDS,
CommandSafeguard,
resolve_safeguard)
from mirage.types import OnExceed
def test_defaults():
sg = CommandSafeguard()
assert sg.max_bytes is None
assert sg.max_lines is None
assert sg.on_exceed == OnExceed.TRUNCATE
def test_on_exceed_coerces_from_string():
sg = CommandSafeguard(on_exceed="truncate")
assert sg.on_exceed is OnExceed.TRUNCATE
def test_rejects_unknown_on_exceed():
with pytest.raises(ValidationError):
CommandSafeguard(on_exceed="explode")
def test_rejects_negative_limits():
with pytest.raises(ValidationError):
CommandSafeguard(max_bytes=-1)
with pytest.raises(ValidationError):
CommandSafeguard(max_lines=-5)
def test_resolve_prefers_mount_override():
override = CommandSafeguard(max_lines=5)
default = CommandSafeguard(max_lines=50)
assert resolve_safeguard("cat",
command_default=default,
mount_override=override) is override
def test_resolve_falls_back_to_command_default():
default = CommandSafeguard(max_lines=50)
assert resolve_safeguard("cat", command_default=default) is default
def test_resolve_falls_back_to_central_default():
assert resolve_safeguard("cat") is DEFAULT_COMMAND_SAFEGUARDS["cat"]
def test_resolve_unknown_command_returns_fallback_safeguard():
from mirage.runtime.policy.safeguard import FALLBACK_SAFEGUARD
assert resolve_safeguard("nl") is FALLBACK_SAFEGUARD
assert FALLBACK_SAFEGUARD.timeout_seconds is not None
+5 -5
View File
@@ -21,7 +21,7 @@ from mirage.cache.index.config import IndexEntry
from mirage.io.types import materialize
from mirage.runtime.sandbox import RemoteSandbox, SandboxConfig
from mirage.runtime.types import RunArgs, RunResult
from mirage.types import CommandSafeguard
from mirage.types import Limit
class RecordingSandbox(RemoteSandbox):
@@ -135,13 +135,13 @@ async def test_line_timeout_answers_124():
await asyncio.sleep(0.5)
return await super().exec_line(line, stdin, env, cwd)
guards = {"python3": CommandSafeguard(timeout_seconds=0.05)}
guards = {"python3": Limit(timeout_seconds=0.05)}
box = SlowBox(captures=("python3", ))
ws = Workspace({"/data": (RAMResource(), MountMode.EXEC, guards)},
mode=MountMode.EXEC,
runtimes=[box, "vfs"])
try:
# A captured line obeys the same command_safeguards as any
# A captured line obeys the same command_limits as any
# command: the mount's python3 timeout answers exit 124.
io = await ws.execute("python3 train.py")
assert io.exit_code == 124
@@ -159,7 +159,7 @@ async def test_line_output_caps_truncate_with_notice():
env: dict[str, str], cwd: str) -> RunResult:
return RunResult(stdout=b"a\nb\nc\n", stderr=None, exit_code=0)
guards = {"python3": CommandSafeguard(max_lines=2)}
guards = {"python3": Limit(max_lines=2)}
box = ChattyBox(captures=("python3", ))
ws = Workspace({"/data": (RAMResource(), MountMode.EXEC, guards)},
mode=MountMode.EXEC,
@@ -168,7 +168,7 @@ async def test_line_output_caps_truncate_with_notice():
io = await ws.execute("python3 train.py")
assert io.exit_code == 0
assert await materialize(io.stdout) == b"a\nb\n"
assert b"truncated at safeguard limit" in await materialize(io.stderr)
assert b"truncated at limit" in await materialize(io.stderr)
finally:
await ws.close()
+24 -24
View File
@@ -15,8 +15,8 @@
import pytest
from pydantic import ValidationError
from mirage.types import (Aggr, CommandSafeguard, FileStat, MountMode,
OnExceed, PathSpec, parse_mount_mode, word_text)
from mirage.types import (Aggr, FileStat, Limit, MountMode, OnExceed, PathSpec,
parse_mount_mode, word_text)
def test_filestat_defaults():
@@ -33,56 +33,56 @@ def test_filestat_immutable():
def test_aggr_none_inputs_is_none():
assert CommandSafeguard.aggr([None, None]) is None
assert CommandSafeguard.aggr([]) is None
assert Limit.aggr([None, None]) is None
assert Limit.aggr([]) is None
def test_aggr_keeps_single_safeguard():
sg = CommandSafeguard(timeout_seconds=5, max_lines=100)
out = CommandSafeguard.aggr([None, sg, None])
def test_aggr_keeps_single_limit():
sg = Limit(timeout_seconds=5, max_lines=100)
out = Limit.aggr([None, sg, None])
assert out.timeout_seconds == 5
assert out.max_lines == 100
def test_aggr_takes_smallest_positive_timeout():
a = CommandSafeguard(timeout_seconds=10)
b = CommandSafeguard(timeout_seconds=2)
c = CommandSafeguard(timeout_seconds=None)
out = CommandSafeguard.aggr([a, b, c])
a = Limit(timeout_seconds=10)
b = Limit(timeout_seconds=2)
c = Limit(timeout_seconds=None)
out = Limit.aggr([a, b, c])
assert out.timeout_seconds == 2
def test_aggr_nonpositive_timeout_is_unbounded():
a = CommandSafeguard(timeout_seconds=0)
b = CommandSafeguard(timeout_seconds=5)
out = CommandSafeguard.aggr([a, b])
a = Limit(timeout_seconds=0)
b = Limit(timeout_seconds=5)
out = Limit.aggr([a, b])
assert out.timeout_seconds == 5
def test_aggr_takes_smallest_caps():
a = CommandSafeguard(max_bytes=1000, max_lines=None)
b = CommandSafeguard(max_bytes=500, max_lines=50)
out = CommandSafeguard.aggr([a, b])
a = Limit(max_bytes=1000, max_lines=None)
b = Limit(max_bytes=500, max_lines=50)
out = Limit.aggr([a, b])
assert out.max_bytes == 500
assert out.max_lines == 50
def test_aggr_error_beats_truncate():
a = CommandSafeguard(on_exceed=OnExceed.TRUNCATE)
b = CommandSafeguard(on_exceed=OnExceed.ERROR)
out = CommandSafeguard.aggr([a, b])
a = Limit(on_exceed=OnExceed.TRUNCATE)
b = Limit(on_exceed=OnExceed.ERROR)
out = Limit.aggr([a, b])
assert out.on_exceed is OnExceed.ERROR
def test_aggr_all_truncate_stays_truncate():
a = CommandSafeguard(timeout_seconds=1)
b = CommandSafeguard(timeout_seconds=2)
out = CommandSafeguard.aggr([a, b])
a = Limit(timeout_seconds=1)
b = Limit(timeout_seconds=2)
out = Limit.aggr([a, b])
assert out.on_exceed is OnExceed.TRUNCATE
def test_every_field_declares_an_aggr_rule():
for name, field in CommandSafeguard.model_fields.items():
for name, field in Limit.model_fields.items():
assert any(
isinstance(m, Aggr)
for m in field.metadata), (f"field {name!r} has no Aggr rule")
@@ -17,12 +17,12 @@ import asyncio
import pytest
from pydantic import BaseModel
from mirage.commands.builtin.utils.safeguard import CommandTimeoutError
from mirage.commands.builtin.utils.limit import CommandTimeoutError
from mirage.commands.cli.types import CLISpec
from mirage.commands.spec.types import Operand, Option
from mirage.io import IOResult
from mirage.io.types import materialize
from mirage.types import CommandSafeguard
from mirage.types import Limit
from mirage.workspace.cli.types import CLIInstall
from mirage.workspace.executor.command.cli import handle_cli
from mirage.workspace.session import Session
@@ -128,14 +128,13 @@ async def slow_send(config, paths, *texts, **flags):
@pytest.mark.asyncio
async def test_leaf_safeguard_bounds_the_handler():
# The declared safeguard wraps the handler body like mount
async def test_leaf_limit_bounds_the_handler():
# The declared limit wraps the handler body like mount
# dispatch: a blocking leaf times out instead of hanging.
spec = CLISpec(name="prog",
subcommands=(CLISpec(
name="run",
fn=slow_send,
safeguard=CommandSafeguard(timeout_seconds=0.05)), ))
subcommands=(CLISpec(name="run",
fn=slow_send,
limit=Limit(timeout_seconds=0.05)), ))
install = CLIInstall(name="prog", spec=spec, config=None)
with pytest.raises(CommandTimeoutError, match="prog run"):
await handle_cli(install, ["prog", "run"], Session("t"))
@@ -23,7 +23,7 @@ class TraversalMount:
self.output = output
self.exit_code = exit_code
self.error = error
self.command_safeguards = {}
self.command_limits = {}
async def execute_cmd(self, *args, **kwargs):
if self.error is not None:
@@ -15,9 +15,9 @@
import pytest
from mirage import MountMode, Workspace
from mirage.commands.builtin.utils.safeguard import SafeguardExceededError
from mirage.commands.builtin.utils.limit import LimitExceededError
from mirage.resource.ram import RAMResource
from mirage.types import CommandSafeguard, OnExceed
from mirage.types import Limit, OnExceed, PathSpec
async def _read_long(accessor, scope, *args, **kwargs):
@@ -36,54 +36,62 @@ async def _ws_mount():
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
await ws.execute("echo hi > /data/f.txt")
mount = next(m for m in ws._registry._mounts if m.prefix == "/data/")
return mount
return ws, mount
async def _dispatch_read(ws):
# Op caps are policy and fire at the op doors, not inside
# Mount.execute_op; route through the dispatcher door.
result, _ = await ws._dispatcher.dispatch(
"read", PathSpec.from_str_path("/data/f.txt"))
return result
@pytest.mark.asyncio
async def test_vfs_read_truncates_to_max_bytes(monkeypatch):
mount = await _ws_mount()
mount.command_safeguards["read"] = CommandSafeguard(max_bytes=5)
ws, mount = await _ws_mount()
mount.command_limits["read"] = Limit(max_bytes=5)
monkeypatch.setattr(mount._ops[("read", None)], "fn", _read_long)
assert await mount.execute_op("read", "/data/f.txt") == b"hello"
assert await _dispatch_read(ws) == b"hello"
@pytest.mark.asyncio
async def test_vfs_read_truncates_to_max_lines(monkeypatch):
mount = await _ws_mount()
mount.command_safeguards["read"] = CommandSafeguard(max_lines=2)
ws, mount = await _ws_mount()
mount.command_limits["read"] = Limit(max_lines=2)
monkeypatch.setattr(mount._ops[("read", None)], "fn", _read_lines)
assert await mount.execute_op("read", "/data/f.txt") == b"a\nb\n"
assert await _dispatch_read(ws) == b"a\nb\n"
@pytest.mark.asyncio
async def test_vfs_read_on_exceed_error_raises(monkeypatch):
mount = await _ws_mount()
mount.command_safeguards["read"] = CommandSafeguard(
max_bytes=5, on_exceed=OnExceed.ERROR)
ws, mount = await _ws_mount()
mount.command_limits["read"] = Limit(max_bytes=5, on_exceed=OnExceed.ERROR)
monkeypatch.setattr(mount._ops[("read", None)], "fn", _read_long)
with pytest.raises(SafeguardExceededError):
await mount.execute_op("read", "/data/f.txt")
with pytest.raises(LimitExceededError):
await _dispatch_read(ws)
@pytest.mark.asyncio
async def test_vfs_read_within_limit_untouched(monkeypatch):
mount = await _ws_mount()
mount.command_safeguards["read"] = CommandSafeguard(max_bytes=100)
ws, mount = await _ws_mount()
mount.command_limits["read"] = Limit(max_bytes=100)
monkeypatch.setattr(mount._ops[("read", None)], "fn", _read_short)
assert await mount.execute_op("read", "/data/f.txt") == b"hi"
assert await _dispatch_read(ws) == b"hi"
@pytest.mark.asyncio
async def test_vfs_unconfigured_read_untouched(monkeypatch):
mount = await _ws_mount()
ws, mount = await _ws_mount()
monkeypatch.setattr(mount._ops[("read", None)], "fn", _read_long)
assert await mount.execute_op("read", "/data/f.txt") == b"hello world"
assert await _dispatch_read(ws) == b"hello world"
@pytest.mark.asyncio
async def test_vfs_stat_not_capped_by_byte_limit(monkeypatch):
mount = await _ws_mount()
mount.command_safeguards["stat"] = CommandSafeguard(max_bytes=1)
result = await mount.execute_op("stat", "/data/f.txt")
ws, mount = await _ws_mount()
mount.command_limits["stat"] = Limit(max_bytes=1)
result, _ = await ws._dispatcher.dispatch(
"stat", PathSpec.from_str_path("/data/f.txt"))
assert result is not None
assert not isinstance(result, (bytes, bytearray))
@@ -17,9 +17,9 @@ import asyncio
import pytest
from mirage import MountMode, Workspace
from mirage.commands.builtin.utils.safeguard import CommandTimeoutError
from mirage.commands.builtin.utils.limit import CommandTimeoutError
from mirage.resource.ram import RAMResource
from mirage.types import CommandSafeguard
from mirage.types import Limit
async def _slow_op(accessor, scope, *args, **kwargs):
@@ -42,7 +42,7 @@ async def _ws_mount():
@pytest.mark.asyncio
async def test_vfs_op_honors_per_mount_timeout(monkeypatch):
mount = await _ws_mount()
mount.command_safeguards["stat"] = CommandSafeguard(timeout_seconds=0.05)
mount.command_limits["stat"] = Limit(timeout_seconds=0.05)
monkeypatch.setattr(mount._ops[("stat", None)], "fn", _slow_op)
with pytest.raises(CommandTimeoutError):
await mount.execute_op("stat", "/data/f.txt")
@@ -378,7 +378,7 @@ def _remote_registry_with_cache():
@pytest.mark.asyncio
async def test_resolve_mount_keeps_cached_read_on_real_mount():
# Warm reads are served in place by with_read_cache, so a cached
# read-only command stays on its real mount (keeping its safeguards and
# read-only command stays on its real mount (keeping its limits and
# custom handlers) instead of being redirected to the cache mount.
reg, cache = _remote_registry_with_cache()
await cache.set("/ssh/a.txt", b"hi")
@@ -15,7 +15,7 @@
import asyncio
from mirage.resource.ram import RAMResource
from mirage.types import CommandSafeguard, MountMode, OnExceed
from mirage.types import Limit, MountMode, OnExceed
from mirage.workspace import Workspace
@@ -27,10 +27,10 @@ def _build_ws(n_lines: int) -> Workspace:
return Workspace({"/": (r, MountMode.WRITE)})
def _override(ws: Workspace, name: str, safeguard: CommandSafeguard) -> None:
def _override(ws: Workspace, name: str, limit: Limit) -> None:
mounts = list(ws._registry._mounts)
for m in mounts:
m.command_safeguards[name] = safeguard
m.command_limits[name] = limit
async def _run(ws: Workspace, cmd: str):
@@ -71,7 +71,7 @@ def test_pipe_terminal_under_limit_no_notice():
def test_mount_override_caps_small():
ws = _build_ws(5)
_override(ws, "cat", CommandSafeguard(max_lines=3))
_override(ws, "cat", Limit(max_lines=3))
code, out, err = asyncio.run(_run(ws, "cat /big.txt"))
assert code == 0
assert out == "line0\nline1\nline2\n"
@@ -80,8 +80,7 @@ def test_mount_override_caps_small():
def test_on_exceed_error_mode():
ws = _build_ws(5)
_override(ws, "cat", CommandSafeguard(max_lines=3,
on_exceed=OnExceed.ERROR))
_override(ws, "cat", Limit(max_lines=3, on_exceed=OnExceed.ERROR))
code, out, err = asyncio.run(_run(ws, "cat /big.txt"))
assert code == 1
assert out == ""
+70 -79
View File
@@ -19,10 +19,11 @@ import time
import pytest
from mirage import MountMode, Workspace
from mirage.policy import resolve_producer
from mirage.policy.builtin import output_cap as sg
from mirage.resource.ram import RAMResource
from mirage.runtime.policy import safeguard as sg
from mirage.runtime.python import LocalRuntime
from mirage.types import CommandSafeguard, OnExceed
from mirage.types import Limit, OnExceed
async def _slow_provision(*args, **kwargs):
@@ -31,17 +32,16 @@ async def _slow_provision(*args, **kwargs):
@pytest.fixture
def restore_defaults():
snapshot = dict(sg.DEFAULT_COMMAND_SAFEGUARDS)
snapshot = dict(sg.DEFAULT_COMMAND_LIMITS)
yield
sg.DEFAULT_COMMAND_SAFEGUARDS.clear()
sg.DEFAULT_COMMAND_SAFEGUARDS.update(snapshot)
sg.DEFAULT_COMMAND_LIMITS.clear()
sg.DEFAULT_COMMAND_LIMITS.update(snapshot)
def _ws(safeguards: dict | None = None) -> Workspace:
if safeguards:
return Workspace(
{"/data": (RAMResource(), MountMode.WRITE, safeguards)},
mode=MountMode.WRITE)
def _ws(limits: dict | None = None) -> Workspace:
if limits:
return Workspace({"/data": (RAMResource(), MountMode.WRITE, limits)},
mode=MountMode.WRITE)
return Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
@@ -54,9 +54,8 @@ async def test_quick_builtin_under_default_does_not_fire():
@pytest.mark.asyncio
async def test_builtin_default_safeguard_fires(restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
timeout_seconds=0.1)
async def test_builtin_default_limit_fires(restore_defaults):
sg.DEFAULT_COMMAND_LIMITS["sleep"] = Limit(timeout_seconds=0.1)
ws = _ws()
r = await ws.execute("sleep 2")
assert r.exit_code == 124
@@ -64,9 +63,8 @@ async def test_builtin_default_safeguard_fires(restore_defaults):
@pytest.mark.asyncio
async def test_fallback_safeguard_applies_to_unknown_command(restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
timeout_seconds=0.05)
async def test_fallback_limit_applies_to_unknown_command(restore_defaults):
sg.DEFAULT_COMMAND_LIMITS["sleep"] = Limit(timeout_seconds=0.05)
ws = _ws()
r = await ws.execute("sleep 1")
assert r.exit_code == 124
@@ -74,8 +72,7 @@ async def test_fallback_safeguard_applies_to_unknown_command(restore_defaults):
@pytest.mark.asyncio
async def test_pipeline_first_stage_to_trip_wins(restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
timeout_seconds=0.1)
sg.DEFAULT_COMMAND_LIMITS["sleep"] = Limit(timeout_seconds=0.1)
ws = _ws()
r = await ws.execute("sleep 2 | echo done")
assert r.exit_code == 124
@@ -84,8 +81,7 @@ async def test_pipeline_first_stage_to_trip_wins(restore_defaults):
@pytest.mark.asyncio
async def test_timeout_zero_disables(restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
timeout_seconds=0)
sg.DEFAULT_COMMAND_LIMITS["sleep"] = Limit(timeout_seconds=0)
ws = _ws()
r = await ws.execute("sleep 0.1")
assert r.exit_code == 0
@@ -93,28 +89,27 @@ async def test_timeout_zero_disables(restore_defaults):
@pytest.mark.asyncio
async def test_mount_override_threaded_via_constructor():
overrides = {"cat": CommandSafeguard(timeout_seconds=42.0, max_lines=99)}
ws = _ws(safeguards=overrides)
overrides = {"cat": Limit(timeout_seconds=42.0, max_lines=99)}
ws = _ws(limits=overrides)
mount = next(m for m in ws._registry._mounts if m.prefix == "/data/")
assert mount.command_safeguards["cat"].timeout_seconds == 42.0
assert mount.command_safeguards["cat"].max_lines == 99
assert mount.command_limits["cat"].timeout_seconds == 42.0
assert mount.command_limits["cat"].max_lines == 99
@pytest.mark.asyncio
async def test_mount_override_beats_command_default():
overrides = {"cat": CommandSafeguard(timeout_seconds=999.0)}
ws = _ws(safeguards=overrides)
overrides = {"cat": Limit(timeout_seconds=999.0)}
ws = _ws(limits=overrides)
mount = next(m for m in ws._registry._mounts if m.prefix == "/data/")
from mirage.runtime.policy.safeguard import resolve_safeguard
resolved = resolve_safeguard(
"cat", mount_override=mount.command_safeguards.get("cat"))
from mirage.policy import resolve_limit
resolved = resolve_limit("cat",
mount_override=mount.command_limits.get("cat"))
assert resolved.timeout_seconds == 999.0
@pytest.mark.asyncio
async def test_timeout_sets_shared_cancel_event(restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
timeout_seconds=0.05)
sg.DEFAULT_COMMAND_LIMITS["sleep"] = Limit(timeout_seconds=0.05)
ws = _ws()
cancel = asyncio.Event()
r = await ws.execute("sleep 1", cancel=cancel)
@@ -123,9 +118,8 @@ async def test_timeout_sets_shared_cancel_event(restore_defaults):
@pytest.mark.asyncio
async def test_cross_mount_cat_honors_command_default_safeguard(
restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["cat"] = CommandSafeguard(max_lines=4)
async def test_cross_mount_cat_honors_command_default_limit(restore_defaults):
sg.DEFAULT_COMMAND_LIMITS["cat"] = Limit(max_lines=4)
a = RAMResource()
b = RAMResource()
a._store.dirs.add("/")
@@ -141,14 +135,16 @@ async def test_cross_mount_cat_honors_command_default_safeguard(
@pytest.mark.asyncio
async def test_fan_out_find_has_safeguard_set():
async def test_fan_out_find_has_limit_set():
a = RAMResource()
a._store.dirs.add("/")
a._store.files["/x.txt"] = b"hi\n"
ws = Workspace({"/a/": a}, mode=MountMode.WRITE)
r = await ws.execute("find /")
assert r.safeguard is not None
assert r.safeguard.timeout_seconds is not None
assert r.producer is not None
resolved = resolve_producer(r.producer, ws._registry.limit_override)
assert resolved is not None
assert resolved.timeout_seconds is not None
@pytest.mark.asyncio
@@ -162,15 +158,16 @@ async def test_cross_mount_honors_per_mount_timeout_override():
ws = Workspace(
{
"/a/": (a, MountMode.WRITE, {
"cat": CommandSafeguard(timeout_seconds=3.0)
"cat": Limit(timeout_seconds=3.0)
}),
"/b/":
b,
"/b/": b,
},
mode=MountMode.WRITE)
r = await ws.execute("cat /a/x.txt /b/y.txt")
assert r.safeguard is not None
assert r.safeguard.timeout_seconds == 3.0
assert r.producer is not None
resolved = resolve_producer(r.producer, ws._registry.limit_override)
assert resolved is not None
assert resolved.timeout_seconds == 3.0
@pytest.mark.asyncio
@@ -186,13 +183,15 @@ async def test_fan_out_uses_tightest_timeout_among_mounts():
"/p/":
parent,
"/p/sub/": (child, MountMode.WRITE, {
"find": CommandSafeguard(timeout_seconds=2.0)
"find": Limit(timeout_seconds=2.0)
}),
},
mode=MountMode.WRITE)
r = await ws.execute("find /p")
assert r.safeguard is not None
assert r.safeguard.timeout_seconds == 2.0
assert r.producer is not None
resolved = resolve_producer(r.producer, ws._registry.limit_override)
assert resolved is not None
assert resolved.timeout_seconds == 2.0
@pytest.mark.asyncio
@@ -206,22 +205,23 @@ async def test_fan_out_tightest_when_parent_is_tighter():
ws = Workspace(
{
"/p/": (parent, MountMode.WRITE, {
"find": CommandSafeguard(timeout_seconds=2.0)
"find": Limit(timeout_seconds=2.0)
}),
"/p/sub/": (child, MountMode.WRITE, {
"find": CommandSafeguard(timeout_seconds=9.0)
"find": Limit(timeout_seconds=9.0)
}),
},
mode=MountMode.WRITE)
r = await ws.execute("find /p")
assert r.safeguard is not None
assert r.safeguard.timeout_seconds == 2.0
assert r.producer is not None
resolved = resolve_producer(r.producer, ws._registry.limit_override)
assert resolved is not None
assert resolved.timeout_seconds == 2.0
@pytest.mark.asyncio
async def test_background_job_propagates_timeout(restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
timeout_seconds=0.05)
sg.DEFAULT_COMMAND_LIMITS["sleep"] = Limit(timeout_seconds=0.05)
ws = _ws()
r1 = await ws.execute("sleep 2 &")
assert r1.exit_code == 0
@@ -233,8 +233,7 @@ async def test_background_job_propagates_timeout(restore_defaults):
@pytest.mark.asyncio
async def test_stderr_redirect_does_not_swallow_timeout_exit(restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
timeout_seconds=0.05)
sg.DEFAULT_COMMAND_LIMITS["sleep"] = Limit(timeout_seconds=0.05)
ws = _ws()
r = await ws.execute("sleep 2 2>&1")
assert r.exit_code == 124
@@ -244,8 +243,7 @@ async def test_stderr_redirect_does_not_swallow_timeout_exit(restore_defaults):
@pytest.mark.asyncio
async def test_job_table_reports_completed_bg_without_wait(restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
timeout_seconds=0.05)
sg.DEFAULT_COMMAND_LIMITS["sleep"] = Limit(timeout_seconds=0.05)
ws = _ws()
await ws.execute("sleep 5 &")
await asyncio.sleep(0.2)
@@ -275,7 +273,7 @@ async def test_timeout_wrap_preserves_lazy_zero_exit_on_match():
@pytest.mark.asyncio
async def test_truncation_keeps_lazy_exit_zero_on_match():
ws = _ws({"grep": CommandSafeguard(max_lines=2, timeout_seconds=600)})
ws = _ws({"grep": Limit(max_lines=2, timeout_seconds=600)})
await ws.execute("printf 'a\\na\\na\\na\\n' > /data/f.txt")
r = await ws.execute("grep a /data/f.txt")
assert r.exit_code == 0
@@ -284,8 +282,7 @@ async def test_truncation_keeps_lazy_exit_zero_on_match():
@pytest.mark.asyncio
async def test_provision_dry_run_honors_timeout(monkeypatch, restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["cat"] = CommandSafeguard(
timeout_seconds=0.1)
sg.DEFAULT_COMMAND_LIMITS["cat"] = Limit(timeout_seconds=0.1)
monkeypatch.setattr("mirage.workspace.workspace.execute.provision_node",
_slow_provision)
ws = _ws()
@@ -296,8 +293,7 @@ async def test_provision_dry_run_honors_timeout(monkeypatch, restore_defaults):
@pytest.mark.asyncio
async def test_timeout_preserves_partial_records_and_logs(
caplog, restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
timeout_seconds=0.1)
sg.DEFAULT_COMMAND_LIMITS["sleep"] = Limit(timeout_seconds=0.1)
ws = _ws()
await ws.execute("echo hello > /data/f.txt")
before = len(ws._ops.records)
@@ -309,7 +305,7 @@ async def test_timeout_preserves_partial_records_and_logs(
@pytest.mark.asyncio
async def test_cross_mount_cat_aggregates_tightest_safeguard():
async def test_cross_mount_cat_aggregates_tightest_limit():
a = RAMResource()
b = RAMResource()
a._store.dirs.add("/")
@@ -319,25 +315,22 @@ async def test_cross_mount_cat_aggregates_tightest_safeguard():
ws = Workspace(
{
"/a/": (a, MountMode.WRITE, {
"cat":
CommandSafeguard(max_lines=100, on_exceed=OnExceed.TRUNCATE)
"cat": Limit(max_lines=100, on_exceed=OnExceed.TRUNCATE)
}),
"/b/":
(b, MountMode.WRITE, {
"cat": CommandSafeguard(max_lines=1, on_exceed=OnExceed.ERROR)
"/b/": (b, MountMode.WRITE, {
"cat": Limit(max_lines=1, on_exceed=OnExceed.ERROR)
}),
},
mode=MountMode.WRITE)
r = await ws.execute("cat /a/x.txt /b/y.txt")
assert r.safeguard.max_lines == 1
assert r.safeguard.on_exceed is OnExceed.ERROR
resolved = resolve_producer(r.producer, ws._registry.limit_override)
assert resolved.max_lines == 1
assert resolved.on_exceed is OnExceed.ERROR
@pytest.mark.asyncio
async def test_python3_default_safeguard_fires_like_any_command(
restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["python3"] = CommandSafeguard(
timeout_seconds=0.2)
async def test_python3_default_limit_fires_like_any_command(restore_defaults):
sg.DEFAULT_COMMAND_LIMITS["python3"] = Limit(timeout_seconds=0.2)
ws = Workspace({"/data": RAMResource()},
mode=MountMode.EXEC,
runtimes=[LocalRuntime()])
@@ -348,12 +341,11 @@ async def test_python3_default_safeguard_fires_like_any_command(
@pytest.mark.asyncio
async def test_python3_mount_safeguard_fires_like_any_command(
restore_defaults):
async def test_python3_mount_limit_fires_like_any_command(restore_defaults):
ws = Workspace(
{
"/data": (RAMResource(), MountMode.EXEC, {
"python3": CommandSafeguard(timeout_seconds=0.2)
"python3": Limit(timeout_seconds=0.2)
})
},
mode=MountMode.EXEC,
@@ -366,8 +358,7 @@ async def test_python3_mount_safeguard_fires_like_any_command(
@pytest.mark.asyncio
async def test_python3_timeout_reclaims_monty_interpreter(restore_defaults):
sg.DEFAULT_COMMAND_SAFEGUARDS["python3"] = CommandSafeguard(
timeout_seconds=0.2)
sg.DEFAULT_COMMAND_LIMITS["python3"] = Limit(timeout_seconds=0.2)
ram = RAMResource()
ram._store.files["/spin.py"] = b"n = 0\nwhile True:\n n = n + 1\n"
ws = Workspace({"/data": ram}, mode=MountMode.EXEC)
@@ -383,13 +374,13 @@ async def test_python3_timeout_reclaims_monty_interpreter(restore_defaults):
@pytest.mark.asyncio
async def test_python3_mount_safeguard_follows_script_path(restore_defaults):
async def test_python3_mount_limit_follows_script_path(restore_defaults):
ram = RAMResource()
ram._store.files["/slow.py"] = b"import time; time.sleep(5)\n"
ws = Workspace(
{
"/data": (ram, MountMode.EXEC, {
"python3": CommandSafeguard(timeout_seconds=0.2)
"python3": Limit(timeout_seconds=0.2)
})
},
mode=MountMode.EXEC,
@@ -12,7 +12,7 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.commands.builtin.utils.safeguard import CommandTimeoutError
from mirage.commands.builtin.utils.limit import CommandTimeoutError
from mirage.commands.errors import FindParseError, UsageError
from mirage.runtime.policy import PolicyDeny
from mirage.workspace.workspace.failure import failure_result
@@ -15,8 +15,7 @@
import pytest
from mirage.resource.ram import RAMResource
from mirage.runtime.policy.safeguard import CommandSafeguard
from mirage.types import MountBackend, MountMode
from mirage.types import Limit, MountBackend, MountMode
from mirage.workspace.mount.spec import Mount
from mirage.workspace.workspace.mounts import (kernel_targets,
normalize_resources)
@@ -29,7 +28,7 @@ def test_bare_resource_takes_the_default_mode():
assert specs[0].resource is resource
assert specs[0].mode == MountMode.WRITE
assert specs[0].backend == MountBackend.VFS
assert specs[0].safeguards == {}
assert specs[0].command_limits == {}
def test_pair_tuple_carries_its_own_mode():
@@ -38,13 +37,13 @@ def test_pair_tuple_carries_its_own_mode():
assert specs[0].mode == MountMode.READ
def test_triple_tuple_carries_safeguards():
guard = CommandSafeguard(timeout_seconds=1)
def test_triple_tuple_carries_limits():
guard = Limit(timeout_seconds=1)
specs = normalize_resources(
{"/a": (RAMResource(), MountMode.READ, {
"curl": guard
})}, MountMode.WRITE)
assert specs[0].safeguards == {"curl": guard}
assert specs[0].command_limits == {"curl": guard}
def test_mount_without_a_mode_falls_back_to_the_default():
@@ -81,10 +80,10 @@ def test_kernel_targets_selects_only_real_mountpoints():
assert kernel_targets(specs) == [("/fuse", MountBackend.FUSE, "/tmp/mp")]
def test_safeguards_are_copied_not_aliased():
guard = CommandSafeguard(timeout_seconds=1)
def test_limits_are_copied_not_aliased():
guard = Limit(timeout_seconds=1)
source = {"curl": guard}
specs = normalize_resources(
{"/a": (RAMResource(), MountMode.READ, source)}, MountMode.WRITE)
source["wget"] = guard
assert set(specs[0].safeguards) == {"curl"}
assert set(specs[0].command_limits) == {"curl"}
@@ -17,9 +17,9 @@ import errno
import pytest
from mirage import Action, CommandContext, Deny, GuardSpec, Policy, Workspace
from mirage.policy import OpsContext, OpsResultContext
from mirage.policy import ExecuteResultContext, OpsContext, OpsResultContext
from mirage.resource.ram import RAMResource
from mirage.types import MountMode
from mirage.types import Limit, MountMode, OnExceed
class NoInterpreters(Policy):
@@ -216,3 +216,164 @@ async def test_pre_ops_policy_holds_on_the_dispatcher_door():
assert b"done" in ok.stdout
finally:
await ws.close()
class CapLines(Policy):
async def post_execute(self, ctx: ExecuteResultContext) -> Action | None:
return Limit(max_lines=2)
class CapReadBytes(Policy):
async def post_ops(self, ctx: OpsResultContext) -> Action | None:
if ctx.op == "read":
return Limit(max_bytes=4)
return None
@pytest.mark.asyncio
async def test_user_limit_policy_caps_line_output():
# A user Limit merges with the built-in cap (tightest wins) and
# bounds what execute() returns.
ws = Workspace({"/data/": RAMResource()}, mode=MountMode.WRITE)
try:
ws.policies.add(CapLines())
await ws.ops.write("/data/big.txt", b"1\n2\n3\n4\n5\n")
r = await ws.execute("cat /data/big.txt")
assert (await r.stdout_str()).count("\n") == 2
assert "output truncated" in (await r.stderr_str())
finally:
await ws.close()
@pytest.mark.asyncio
async def test_user_limit_policy_caps_op_reads():
# A post_ops Limit bounds the programmatic door too: ws.ops (and
# FUSE behind it) serve capped bytes.
ws = Workspace({"/data/": RAMResource()}, mode=MountMode.WRITE)
try:
ws.policies.add(CapReadBytes())
await ws.ops.write("/data/f.txt", b"hello world")
assert await ws.ops.read("/data/f.txt") == b"hell"
finally:
await ws.close()
class CapBytesHard(Policy):
async def post_execute(self, ctx: ExecuteResultContext) -> Action | None:
return Limit(max_bytes=4, on_exceed=OnExceed.ERROR)
class Boom(Policy):
async def post_execute(self, ctx: ExecuteResultContext) -> Action | None:
raise RuntimeError("boom")
class DenyReads(Policy):
async def post_ops(self, ctx: OpsResultContext) -> Action | None:
if ctx.op == "read":
return Deny("reads are suppressed\n")
return None
class SeeProducer(Policy):
def __init__(self) -> None:
self.seen: list[str] = []
async def post_execute(self, ctx: ExecuteResultContext) -> Action | None:
self.seen.append(ctx.producer.command)
return None
@pytest.mark.asyncio
async def test_two_limit_policies_merge_to_the_tightest_end_to_end():
ws = Workspace({"/data/": RAMResource()}, mode=MountMode.WRITE)
try:
ws.policies.add(CapLines())
ws.policies.add(SuppressNothingCapThree())
await ws.ops.write("/data/big.txt", b"1\n2\n3\n4\n5\n")
r = await ws.execute("cat /data/big.txt")
# CapLines says 2, SuppressNothingCapThree says 3: tightest wins.
assert (await r.stdout_str()).count("\n") == 2
finally:
await ws.close()
class SuppressNothingCapThree(Policy):
async def post_execute(self, ctx: ExecuteResultContext) -> Action | None:
return Limit(max_lines=3)
@pytest.mark.asyncio
async def test_error_mode_limit_fails_the_line():
# ANY-error: a user policy in error mode turns overflow into exit 1
# with no stdout, GNU-style notice on stderr.
ws = Workspace({"/data/": RAMResource()}, mode=MountMode.WRITE)
try:
ws.policies.add(CapBytesHard())
await ws.ops.write("/data/f.txt", b"hello world\n")
r = await ws.execute("cat /data/f.txt")
assert r.exit_code == 1
assert r.stdout is None or await r.stdout_str() == ""
assert "output truncated" in (await r.stderr_str())
ok = await ws.execute("echo ok")
assert ok.exit_code == 0 # within the bound: no refusal
assert await ok.stdout_str() == "ok\n"
finally:
await ws.close()
@pytest.mark.asyncio
async def test_a_post_ops_deny_beats_a_limit():
# A refusal suppresses the result; bounding it would be meaningless.
ws = Workspace({"/data/": RAMResource()}, mode=MountMode.WRITE)
try:
ws.policies.add(CapReadBytes())
ws.policies.add(DenyReads())
await ws.ops.write("/data/f.txt", b"hello world")
with pytest.raises(PermissionError) as excinfo:
await ws.ops.read("/data/f.txt")
assert "reads are suppressed" in str(excinfo.value)
finally:
await ws.close()
@pytest.mark.asyncio
async def test_a_raising_post_execute_policy_fails_the_line_closed():
ws = Workspace({"/data/": RAMResource()}, mode=MountMode.WRITE)
try:
ws.policies.add(Boom())
r = await ws.execute("echo hi")
assert r.exit_code == 1
err = await r.stderr_str()
assert "Boom" in err
assert "boom" in err
assert r.stdout is None or await r.stdout_str() == ""
finally:
await ws.close()
@pytest.mark.asyncio
async def test_post_execute_sees_the_rightmost_producer():
# The provenance a policy reads follows shell semantics: the tail
# of a pipe, the right side of `;` and `||`.
ws = Workspace({"/data/": RAMResource()}, mode=MountMode.WRITE)
try:
spy = SeeProducer()
ws.policies.add(spy)
await ws.ops.write("/data/f.txt", b"a\nb\n")
await ws.execute("cat /data/f.txt | wc -l")
await ws.execute("cat /data/f.txt ; head -n 1 /data/f.txt")
await ws.execute("false || cat /data/f.txt")
# Builtins carry provenance too: a policy keyed on echo sees it.
await ws.execute("echo hi")
await ws.execute("cat /data/f.txt ; echo done")
assert spy.seen == ["wc", "head", "cat", "echo", "echo"]
finally:
await ws.close()
@@ -659,7 +659,7 @@ export function withDefaultProvisions<A extends Accessor>(
src: c.src,
dst: c.dst,
write: c.write,
safeguard: c.safeguard,
limit: c.limit,
})
})
}
@@ -106,7 +106,7 @@ async function doFetch(url: string, options: HttpRequestOptions): Promise<HttpRe
resp = await fetch(applyProxy(url), init)
} catch (err) {
// A transport failure carries no status. Abort (the timeout) has to
// propagate as itself so the safeguard layer can report it.
// propagate as itself so the limit layer can report it.
if (err instanceof DOMException && err.name === 'AbortError') throw err
const { host, port } = endpoint(url)
throw new HttpConnectError(host, port)
@@ -14,8 +14,8 @@
import { describe, expect, it } from 'vitest'
import { materialize } from '../../../io/types.ts'
import { CommandSafeguard, OnExceed } from '../../../types.ts'
import { applySafeguard } from './safeguard.ts'
import { Limit, OnExceed } from '../../../types.ts'
import { applyLimit } from './limit.ts'
const ENC = new TextEncoder()
const DEC = new TextDecoder()
@@ -33,47 +33,47 @@ async function bytesOf(out: Uint8Array | AsyncIterable<Uint8Array> | null): Prom
return materialize(out)
}
describe('applySafeguard', () => {
it('passes through when safeguard is null', async () => {
const [out, io] = await applySafeguard(TEN, null)
describe('applyLimit', () => {
it('passes through when limit is null', async () => {
const [out, io] = await applyLimit(TEN, null)
expect(await bytesOf(out)).toEqual(TEN)
expect(io.exitCode).toBe(0)
expect(io.stderr).toBeNull()
})
it('passes through when under limit', async () => {
const sg = new CommandSafeguard({ maxLines: 100 })
const [out, io] = await applySafeguard(TEN, sg)
const sg = new Limit({ maxLines: 100 })
const [out, io] = await applyLimit(TEN, sg)
expect(await bytesOf(out)).toEqual(TEN)
expect(io.stderr).toBeNull()
})
it('truncates by lines', async () => {
const sg = new CommandSafeguard({ maxLines: 3 })
const [out, io] = await applySafeguard(TEN, sg)
const sg = new Limit({ maxLines: 3 })
const [out, io] = await applyLimit(TEN, sg)
expect(DEC.decode(await bytesOf(out))).toBe('line0\nline1\nline2\n')
expect(io.exitCode).toBe(0)
expect(DEC.decode(await materialize(io.stderr))).toContain('truncated')
})
it('error mode returns null stdout + exit 1', async () => {
const sg = new CommandSafeguard({ maxLines: 3, onExceed: OnExceed.ERROR })
const [out, io] = await applySafeguard(TEN, sg)
const sg = new Limit({ maxLines: 3, onExceed: OnExceed.ERROR })
const [out, io] = await applyLimit(TEN, sg)
expect(out).toBeNull()
expect(io.exitCode).toBe(1)
expect(DEC.decode(await materialize(io.stderr))).toContain('truncated')
})
it('truncates by bytes', async () => {
const sg = new CommandSafeguard({ maxBytes: 10 })
const [out, io] = await applySafeguard(TEN, sg)
const sg = new Limit({ maxBytes: 10 })
const [out, io] = await applyLimit(TEN, sg)
expect(await bytesOf(out)).toEqual(TEN.subarray(0, 10))
expect(DEC.decode(await materialize(io.stderr))).toContain('truncated')
})
it('truncates streaming input early', async () => {
const sg = new CommandSafeguard({ maxLines: 2 })
const [out, io] = await applySafeguard(stream(TEN), sg)
const sg = new Limit({ maxLines: 2 })
const [out, io] = await applyLimit(stream(TEN), sg)
expect(DEC.decode(await bytesOf(out))).toBe('line0\nline1\n')
expect(DEC.decode(await materialize(io.stderr))).toContain('truncated')
})
@@ -14,7 +14,7 @@
import { yieldBytes } from '../../../io/stream.ts'
import { type ByteSource, IOResult, materialize } from '../../../io/types.ts'
import { type CommandSafeguard, OnExceed } from '../../../types.ts'
import { type Limit, OnExceed } from '../../../types.ts'
const NEWLINE = 0x0a
const ENC = new TextEncoder()
@@ -31,10 +31,10 @@ export class CommandTimeoutError extends Error {
}
}
export class SafeguardExceededError extends Error {
export class LimitExceededError extends Error {
constructor(message: string) {
super(message)
this.name = 'SafeguardExceededError'
this.name = 'LimitExceededError'
}
}
@@ -78,11 +78,11 @@ async function* withTimeout(
export function maybeWithTimeout(
stream: ByteSource | null,
safeguard: CommandSafeguard | null,
limit: Limit | null,
command: string,
): ByteSource | null {
if (stream === null || stream instanceof Uint8Array) return stream
const timeout = safeguard?.timeoutSeconds ?? null
const timeout = limit?.timeoutSeconds ?? null
if (timeout === null || timeout <= 0) return stream
return withTimeout(stream, timeout, command)
}
@@ -109,13 +109,13 @@ function trimToLines(buf: Uint8Array, maxLines: number): Uint8Array {
return buf
}
function buildNotice(safeguard: CommandSafeguard): Uint8Array {
function buildNotice(limit: Limit): Uint8Array {
const parts: string[] = []
if (safeguard.maxLines !== null) parts.push(`${String(safeguard.maxLines)} lines`)
if (safeguard.maxBytes !== null) parts.push(`${String(safeguard.maxBytes)} bytes`)
const limit = parts.join(' / ')
if (limit.maxLines !== null) parts.push(`${String(limit.maxLines)} lines`)
if (limit.maxBytes !== null) parts.push(`${String(limit.maxBytes)} bytes`)
const detail = parts.join(' / ')
return ENC.encode(
`output truncated at safeguard limit (${limit}); ` +
`output truncated at limit (${detail}); ` +
`narrow with grep, or read more with head -n / tail -n / ` +
`a more specific path\n`,
)
@@ -139,12 +139,12 @@ function countNewlines(buf: Uint8Array): number {
return n
}
export async function applySafeguard(
export async function applyLimit(
src: ByteSource,
safeguard: CommandSafeguard | null,
limit: Limit | null,
): Promise<[ByteSource | null, IOResult]> {
if (safeguard === null) return [src, new IOResult()]
const { maxLines, maxBytes } = safeguard
if (limit === null) return [src, new IOResult()]
const { maxLines, maxBytes } = limit
if (maxLines === null && maxBytes === null) return [src, new IOResult()]
const chunks: Uint8Array[] = []
@@ -176,8 +176,8 @@ export async function applySafeguard(
}
if (!truncated) return [data, new IOResult()]
const notice = buildNotice(safeguard)
if (safeguard.onExceed === OnExceed.ERROR) {
const notice = buildNotice(limit)
if (limit.onExceed === OnExceed.ERROR) {
return [null, new IOResult({ exitCode: 1, stderr: notice })]
}
return [data, new IOResult({ stderr: notice })]
@@ -194,10 +194,10 @@ export async function guardOutput(
stdout: ByteSource | null,
stderr: ByteSource | null,
exitCode: number,
safeguard: CommandSafeguard | null,
limit: Limit | null,
): Promise<[ByteSource | null, ByteSource | null, number]> {
if (stdout === null) return [stdout, stderr, exitCode]
const [data, sgIo] = await applySafeguard(stdout, safeguard)
const [data, sgIo] = await applyLimit(stdout, limit)
if (sgIo.stderr !== null) {
const existing = stderr !== null ? await materialize(stderr) : new Uint8Array()
const added = await materialize(sgIo.stderr)
@@ -209,20 +209,16 @@ export async function guardOutput(
return [data, stderr, sgIo.exitCode !== 0 ? sgIo.exitCode : exitCode]
}
export async function applyOpSafeguard(
result: unknown,
safeguard: CommandSafeguard | null,
): Promise<unknown> {
if (safeguard === null) return result
if (safeguard.maxBytes === null && safeguard.maxLines === null) return result
export async function applyOpLimit(result: unknown, limit: Limit | null): Promise<unknown> {
if (limit === null) return result
if (limit.maxBytes === null && limit.maxLines === null) return result
const isBytes = result instanceof Uint8Array
const isStream = result !== null && typeof result === 'object' && Symbol.asyncIterator in result
if (!isBytes && !isStream) return result
const [data, sgIo] = await applySafeguard(result as ByteSource, safeguard)
const [data, sgIo] = await applyLimit(result as ByteSource, limit)
if (sgIo.exitCode !== 0) {
const message =
sgIo.stderr instanceof Uint8Array ? DEC.decode(sgIo.stderr) : 'safeguard exceeded'
throw new SafeguardExceededError(message.trim())
const message = sgIo.stderr instanceof Uint8Array ? DEC.decode(sgIo.stderr) : 'limit exceeded'
throw new LimitExceededError(message.trim())
}
return data
}
@@ -75,7 +75,7 @@ describe('CLISpec', () => {
const single = new CLISpec({ name: 'hello', fn: verb })
expect(single.subcommands).toEqual([])
expect(single.write).toBe(false)
expect(single.safeguard).toBeNull()
expect(single.limit).toBeNull()
})
it('allows group-level options', () => {
@@ -13,7 +13,7 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { ByteSource } from '../../io/types.ts'
import type { CommandSafeguard, PathSpec } from '../../types.ts'
import type { Limit, PathSpec } from '../../types.ts'
import type { CommandFnResult } from '../config.ts'
import { compileSpec } from '../spec/compile.ts'
import type { ZodObject, ZodRawShape } from 'zod'
@@ -50,7 +50,7 @@ export interface CLISpecInit extends CommandSpecInit {
fn?: CLIVerbFn | null
subcommands?: readonly CLISpec[]
write?: boolean
safeguard?: CommandSafeguard | null
limit?: Limit | null
configModel?: CLIConfigModel | null
}
@@ -84,7 +84,7 @@ export class CLISpec extends CommandSpec {
readonly fn: CLIVerbFn | null
readonly subcommands: readonly CLISpec[]
readonly write: boolean
readonly safeguard: CommandSafeguard | null
readonly limit: Limit | null
readonly configModel: CLIConfigModel | null
constructor(init: CLISpecInit) {
@@ -94,7 +94,7 @@ export class CLISpec extends CommandSpec {
this.fn = init.fn ?? null
this.subcommands = Object.freeze([...(init.subcommands ?? [])])
this.write = init.write ?? false
this.safeguard = init.safeguard ?? null
this.limit = init.limit ?? null
this.configModel = init.configModel ?? null
if (this.name === '' || /\s/.test(this.name)) {
throw new Error(`cli name '${this.name}' must be a single non-empty word`)
@@ -16,7 +16,7 @@ import type { Accessor } from '../accessor/base.ts'
import type { IndexCacheStore } from '../cache/index/index.ts'
import { IOResult, type ByteSource } from '../io/types.ts'
import type { Resource } from '../resource/base.ts'
import type { CommandSafeguard, PathSpec } from '../types.ts'
import type { Limit, PathSpec } from '../types.ts'
import type { Runtime } from '../workspace/executor/runtime.ts'
import type { LinkView, StatOverlay, StatPath } from '../ops/types.ts'
import { VERSION } from '../version.ts'
@@ -105,7 +105,7 @@ export interface RegisteredCommandInit {
src?: string | null
dst?: string | null
write?: boolean
safeguard?: CommandSafeguard | null
limit?: Limit | null
}
export class RegisteredCommand {
@@ -119,7 +119,7 @@ export class RegisteredCommand {
readonly src: string | null
readonly dst: string | null
readonly write: boolean
readonly safeguard: CommandSafeguard | null
readonly limit: Limit | null
constructor(init: RegisteredCommandInit) {
this.name = init.name
@@ -132,7 +132,7 @@ export class RegisteredCommand {
this.src = init.src ?? null
this.dst = init.dst ?? null
this.write = init.write ?? false
this.safeguard = init.safeguard ?? null
this.limit = init.limit ?? null
}
}
@@ -145,7 +145,7 @@ export interface CommandOptions<A extends Accessor = Accessor> {
provision?: ProvisionFn<A> | null
aggregate?: AggregateFn | null
write?: boolean
safeguard?: CommandSafeguard | null
limit?: Limit | null
}
export const HELP_OPTION = new Option({
@@ -239,7 +239,7 @@ export function command<A extends Accessor = Accessor>(
provisionFn: (options.provision ?? null) as ProvisionFn | null,
aggregate: options.aggregate ?? null,
write: options.write ?? false,
safeguard: options.safeguard ?? null,
limit: options.limit ?? null,
}),
)
}
+11 -7
View File
@@ -14,8 +14,8 @@
export { VERSION } from './version.ts'
export {
CommandSafeguard,
type CommandSafeguardInit,
Limit,
type LimitInit,
ConsistencyPolicy,
CapacityState,
type CapacityResult,
@@ -141,12 +141,14 @@ export { makeGenericOps } from './ops/generic/factory.ts'
export { extractWriteData } from './ops/write_args.ts'
export { RAM_COMMANDS } from './commands/builtin/ram/index.ts'
export {
DEFAULT_COMMAND_SAFEGUARDS,
FALLBACK_SAFEGUARD,
DEFAULT_COMMAND_LIMITS,
FALLBACK_LIMIT,
OutputCapPolicy,
resolveAcrossMounts,
resolveSafeguard,
} from './workspace/executor/policy/safeguard.ts'
export { CommandTimeoutError, SafeguardExceededError } from './commands/builtin/utils/safeguard.ts'
resolveProducer,
resolveLimit,
} from './policy/builtin/output_cap.ts'
export { CommandTimeoutError, LimitExceededError } from './commands/builtin/utils/limit.ts'
export { GENERAL_COMMANDS } from './commands/builtin/general/index.ts'
export { GENERAL_BC } from './commands/builtin/general/bc.ts'
export { GENERAL_CURL } from './commands/builtin/general/curl.ts'
@@ -539,10 +541,12 @@ export {
SpecPolicy,
VALIDITY,
hasParentsFlag,
postExecuteGate,
wildcardRegex,
type Action,
type CommandContext,
type Deny,
type ExecuteResultContext,
type GuardSpec,
type MountRootQuery,
type OpsContext,
+10 -10
View File
@@ -12,7 +12,7 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { CommandSafeguard } from '../types.ts'
import type { Producer } from '../types.ts'
import { CachableAsyncIterator } from './cachable_iterator.ts'
export type ByteSource = Uint8Array | AsyncIterable<Uint8Array>
@@ -33,7 +33,7 @@ export interface IOResultInit {
reads?: Record<string, ByteSource>
writes?: Record<string, ByteSource>
cache?: string[]
safeguard?: CommandSafeguard | null
producer?: Producer | null
}
export class IOResult {
@@ -43,12 +43,12 @@ export class IOResult {
reads: Record<string, ByteSource>
writes: Record<string, ByteSource>
cache: string[]
// Output cap for the command that produced this result. Resolved at
// dispatch time by Mount.executeCmd, applied at the workspace
// boundary after VALUE barrier. TODO: hoist to a finalization
// context returned alongside (stream, io) when a second policy
// field appears.
safeguard: CommandSafeguard | null
// Provenance of this result (which command, spanning which
// mounts); merge keeps the rightmost producer, mirroring whose
// stream the shell shows. The workspace boundary hands it to the
// policy layer as context. Facts ride the envelope, policy
// decisions never do.
producer: Producer | null
streamSource: IOResult | null
constructor(init: IOResultInit = {}) {
@@ -58,7 +58,7 @@ export class IOResult {
this.reads = init.reads ?? {}
this.writes = init.writes ?? {}
this.cache = init.cache ?? []
this.safeguard = init.safeguard ?? null
this.producer = init.producer ?? null
this.streamSource = null
}
@@ -116,7 +116,7 @@ export class IOResult {
reads: { ...this.reads, ...other.reads },
writes: { ...this.writes, ...other.writes },
cache: [...this.cache, ...other.cache],
safeguard: other.safeguard,
producer: other.producer,
})
result.streamSource = other
return result
+15 -2
View File
@@ -12,7 +12,13 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { Action, CommandContext, OpsContext, OpsResultContext } from './types.ts'
import type {
Action,
CommandContext,
ExecuteResultContext,
OpsContext,
OpsResultContext,
} from './types.ts'
/**
* One concern's answers to the workspace lifecycle.
@@ -31,6 +37,13 @@ export interface Policy {
* belong at preCommand or precomputed into policy state.
*/
preOps?(ctx: OpsContext): Action | null | Promise<Action | null>
/** Observe one completed VFS op; a Deny suppresses its result. */
/** Observe one completed VFS op; a Deny suppresses its result, a
* Limit caps a byte-producing one. */
postOps?(ctx: OpsResultContext): Action | null | Promise<Action | null>
/**
* Bound one finished execute() line's output. A Limit returned here
* merges with every other opining policy's (tightest per field) and
* caps the line's stdout at the workspace boundary.
*/
postExecute?(ctx: ExecuteResultContext): Action | null | Promise<Action | null>
}
@@ -14,11 +14,11 @@
import { describe, expect, it } from 'vitest'
import { RAMResource } from '../resource/ram/ram.ts'
import { MountMode, PathSpec } from '../types.ts'
import { MountRegistry } from '../workspace/mount/registry.ts'
import { RAMResource } from '../../resource/ram/ram.ts'
import { MountMode, PathSpec } from '../../types.ts'
import { MountRegistry } from '../../workspace/mount/registry.ts'
import { MountRootPolicy, hasParentsFlag } from './mount_root.ts'
import type { CommandContext } from './types.ts'
import type { CommandContext } from '../types.ts'
function registry(): MountRegistry {
return new MountRegistry({ '/data': new RAMResource() }, MountMode.WRITE, {})
@@ -12,8 +12,8 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { Policy } from './base.ts'
import type { Action, CommandContext, Deny } from './types.ts'
import type { Policy } from '../base.ts'
import type { Action, CommandContext, Deny } from '../types.ts'
/**
* Spot ln's -s/--symbolic by raw token scan. Same reason as
@@ -0,0 +1,175 @@
// ========= 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 { describe, expect, it } from 'vitest'
import { Limit, OnExceed, PathSpec } from '../../types.ts'
import {
DEFAULT_COMMAND_LIMITS,
FALLBACK_LIMIT,
OutputCapPolicy,
resolveAcrossMounts,
resolveProducer,
resolveLimit,
} from './output_cap.ts'
describe('Limit', () => {
it('defaults to no limit + truncate', () => {
const sg = new Limit()
expect(sg.maxBytes).toBeNull()
expect(sg.maxLines).toBeNull()
expect(sg.onExceed).toBe(OnExceed.TRUNCATE)
})
it('accepts onExceed override', () => {
const sg = new Limit({ onExceed: OnExceed.ERROR })
expect(sg.onExceed).toBe(OnExceed.ERROR)
})
it('rejects negative limits', () => {
expect(() => new Limit({ maxBytes: -1 })).toThrow(TypeError)
expect(() => new Limit({ maxLines: -5 })).toThrow(TypeError)
})
it('rejects non-integer limits', () => {
expect(() => new Limit({ maxLines: 1.5 })).toThrow(TypeError)
})
})
describe('resolveLimit', () => {
it('prefers mount override over command default', () => {
const override = new Limit({ maxLines: 5 })
const cmd = new Limit({ maxLines: 50 })
expect(resolveLimit('cat', [], cmd, override)).toBe(override)
})
it('falls back to command default when no override', () => {
const cmd = new Limit({ maxLines: 50 })
expect(resolveLimit('cat', [], cmd, null)).toBe(cmd)
})
it('falls back to central default for known names', () => {
expect(resolveLimit('cat')).toBe(DEFAULT_COMMAND_LIMITS.cat)
})
it('returns FALLBACK_LIMIT for unknown command', () => {
expect(resolveLimit('nl')).toBe(FALLBACK_LIMIT)
expect(FALLBACK_LIMIT.timeoutSeconds).not.toBeNull()
})
it('includes the same five names as Python defaults, with 2000 lines + 600s', () => {
expect(Object.keys(DEFAULT_COMMAND_LIMITS).sort()).toEqual(
['cat', 'grep', 'head', 'rg', 'tail'].sort(),
)
for (const name of ['cat', 'grep', 'rg', 'head', 'tail']) {
const sg = DEFAULT_COMMAND_LIMITS[name]
expect(sg).toBeDefined()
expect(sg?.maxLines).toBe(2000)
expect(sg?.timeoutSeconds).toBe(600)
}
})
})
describe('Limit.aggr', () => {
it('returns null when nothing present', () => {
expect(Limit.aggr([null, null])).toBeNull()
})
it('takes the tightest positive cap/timeout and prefers ERROR', () => {
const a = new Limit({ maxLines: 100, timeoutSeconds: 30 })
const b = new Limit({ maxLines: 50, timeoutSeconds: 60, onExceed: OnExceed.ERROR })
const merged = Limit.aggr([a, b, null])
expect(merged?.maxLines).toBe(50)
expect(merged?.timeoutSeconds).toBe(30)
expect(merged?.onExceed).toBe(OnExceed.ERROR)
})
})
describe('resolveAcrossMounts', () => {
it('aggregates per-mount overrides, falling back to command default', () => {
const m1 = { commandLimits: new Map([['cat', new Limit({ maxLines: 10 })]]) }
const m2 = { commandLimits: new Map<string, Limit>() }
const merged = resolveAcrossMounts('cat', [m1, m2])
expect(merged?.maxLines).toBe(10)
})
})
describe('prototype-colliding command names', () => {
it('falls through to the fallback instead of an Object.prototype member', () => {
const sg = resolveLimit('toString')
expect(sg).toBe(FALLBACK_LIMIT)
expect(resolveLimit('constructor')).toBe(FALLBACK_LIMIT)
})
})
describe('resolveProducer', () => {
const table = (entries: Record<string, Limit>) => (prefix: string, name: string) =>
entries[`${prefix}|${name}`] ?? null
it('prefers the mount override over the declared bound', () => {
const resolved = resolveProducer(
{ command: 'cat', prefixes: ['/a/'], declared: new Limit({ maxLines: 50 }) },
table({ '/a/|cat': new Limit({ maxLines: 4 }) }),
)
expect(resolved?.maxLines).toBe(4)
})
it('falls back to declared, then the table', () => {
const declared = resolveProducer(
{ command: 'cat', prefixes: [], declared: new Limit({ maxLines: 50 }) },
table({}),
)
expect(declared?.maxLines).toBe(50)
const fromTable = resolveProducer({ command: 'cat', prefixes: [], declared: null }, table({}))
expect(fromTable?.maxLines).toBe(DEFAULT_COMMAND_LIMITS.cat?.maxLines)
})
it('aggregates the tightest across prefixes', () => {
const resolved = resolveProducer(
{ command: 'cat', prefixes: ['/a/', '/b/'], declared: null },
table({
'/a/|cat': new Limit({ maxLines: 9 }),
'/b/|cat': new Limit({ maxLines: 3 }),
}),
)
expect(resolved?.maxLines).toBe(3)
})
it('an empty command has no bound', () => {
expect(resolveProducer({ command: '', prefixes: [], declared: null }, table({}))).toBeNull()
})
})
describe('OutputCapPolicy', () => {
it('answers postOps from the op table', () => {
const policy = new OutputCapPolicy((prefix, name) =>
prefix === '/a/' && name === 'read' ? new Limit({ maxBytes: 4 }) : null,
)
const capped = policy.postOps({
op: 'read',
path: new PathSpec({ virtual: '/a/x', directory: '/a', resourcePath: '' }),
write: false,
prefix: '/a/',
result: null,
})
expect(capped).toEqual(new Limit({ maxBytes: 4 }))
const silent = policy.postOps({
op: 'write',
path: new PathSpec({ virtual: '/a/x', directory: '/a', resourcePath: '' }),
write: true,
prefix: '/a/',
result: null,
})
expect(silent).toBeNull()
})
})
@@ -0,0 +1,113 @@
// ========= 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 { Limit, type Producer } from '../../types.ts'
import type { Policy } from '../base.ts'
import type { Action, ExecuteResultContext, OpsResultContext } from '../types.ts'
const DEFAULT_MAX_LINES = 2000
const DEFAULT_TIMEOUT_SECONDS = 600
// Null prototype: command names are script-controlled, so a name like
// `toString` must fall through to the fallback instead of resolving an
// `Object.prototype` member as a limit.
export const DEFAULT_COMMAND_LIMITS: Record<string, Limit> = Object.assign(
Object.create(null) as Record<string, Limit>,
Object.fromEntries(
['cat', 'grep', 'rg', 'head', 'tail'].map((name) => [
name,
new Limit({
maxLines: DEFAULT_MAX_LINES,
timeoutSeconds: DEFAULT_TIMEOUT_SECONDS,
}),
]),
),
)
export const FALLBACK_LIMIT = new Limit({ timeoutSeconds: DEFAULT_TIMEOUT_SECONDS })
interface LimitMount {
commandLimits: Map<string, Limit>
}
/**
* Resolve one command's limit, the one entry point.
*
* Precedence: an explicit mountOverride, then the command's own
* default, then aggregation across the mounts the command spans
* (tightest per field), then the global table.
*/
export function resolveLimit(
name: string,
mounts: readonly LimitMount[] = [],
commandDefault: Limit | null = null,
mountOverride: Limit | null = null,
): Limit | null {
if (mountOverride !== null) return mountOverride
if (commandDefault !== null) return commandDefault
if (mounts.length > 0) return resolveAcrossMounts(name, mounts)
return DEFAULT_COMMAND_LIMITS[name] ?? FALLBACK_LIMIT
}
export function resolveAcrossMounts(name: string, mounts: Iterable<LimitMount>): Limit | null {
const resolved = [...mounts].map((m) =>
resolveLimit(name, [], null, m.commandLimits.get(name) ?? null),
)
return Limit.aggr(resolved)
}
export type OverrideLookup = (prefix: string, name: string) => Limit | null
/**
* Resolve the bound a producer's facts name. Shared by OutputCapPolicy
* and the dispatch sites that still need the resolved timeout locally:
* per-prefix override first, then the producer's declared bound, then
* the global table, aggregated to the tightest value when the command
* spanned several mounts.
*/
export function resolveProducer(producer: Producer, overrideFor: OverrideLookup): Limit | null {
if (producer.command === '') return null
if (producer.prefixes.length === 0) {
return resolveLimit(producer.command, [], producer.declared)
}
const perMount = producer.prefixes.map((prefix) =>
resolveLimit(producer.command, [], producer.declared, overrideFor(prefix, producer.command)),
)
return Limit.aggr(perMount)
}
/**
* The built-in output cap, seeded by the registry. Answers postExecute
* with the resolution the `command_limits:` config surface promises
* (override semantics, see resolveLimit) and postOps with a
* mount's per-op cap. Config parses into this policy; the container
* and dispatch know nothing about caps. `overrideFor` maps (mount
* prefix, command or op name) to that mount's configured override,
* injected by the registry so this module stays a leaf.
*/
export class OutputCapPolicy implements Policy {
private readonly overrideFor: OverrideLookup
constructor(overrideFor: OverrideLookup) {
this.overrideFor = overrideFor
}
postExecute(ctx: ExecuteResultContext): Action | null {
return resolveProducer(ctx.producer, this.overrideFor)
}
postOps(ctx: OpsResultContext): Action | null {
return this.overrideFor(ctx.prefix, ctx.op)
}
}
+4 -2
View File
@@ -14,14 +14,16 @@
export type { Policy } from './base.ts'
export { PolicyDenied } from './errors.ts'
export { MountRootPolicy, hasParentsFlag } from './mount_root.ts'
export { Policies, postOpsGate, preOpsGate } from './policies.ts'
export { MountRootPolicy, hasParentsFlag } from './builtin/mount_root.ts'
export { OutputCapPolicy, resolveProducer, resolveLimit } from './builtin/output_cap.ts'
export { Policies, postExecuteGate, postOpsGate, preOpsGate } from './policies.ts'
export { SpecPolicy, wildcardRegex } from './spec.ts'
export {
VALIDITY,
type Action,
type CommandContext,
type Deny,
type ExecuteResultContext,
type GuardSpec,
type MountRootQuery,
type OpsContext,
@@ -19,14 +19,21 @@ import { beforeAll, describe, expect, it } from 'vitest'
import { OpsRegistry } from '../ops/registry.ts'
import { RAMResource } from '../resource/ram/ram.ts'
import { createShellParser, type ShellParser } from '../shell/parse.ts'
import { MountMode, PathSpec } from '../types.ts'
import { Limit, MountMode, OnExceed, PathSpec } from '../types.ts'
import { MountRegistry } from '../workspace/mount/registry.ts'
import { Workspace } from '../workspace/workspace.ts'
import type { Policy } from './base.ts'
import { MountRootPolicy } from './mount_root.ts'
import { MountRootPolicy } from './builtin/mount_root.ts'
import { PolicyDenied } from './errors.ts'
import { Policies, postOpsGate, preOpsGate } from './policies.ts'
import type { Action, CommandContext, GuardSpec, OpsContext, OpsResultContext } from './types.ts'
import { Policies, postExecuteGate, postOpsGate, preOpsGate } from './policies.ts'
import type {
Action,
CommandContext,
ExecuteResultContext,
GuardSpec,
OpsContext,
OpsResultContext,
} from './types.ts'
const require = createRequire(import.meta.url)
const engineWasm = readFileSync(require.resolve('web-tree-sitter/web-tree-sitter.wasm'))
@@ -371,3 +378,204 @@ describe('workspace policies', () => {
}
})
})
class CapFour implements Policy {
postOps(_ctx: OpsResultContext): Action | null {
return new Limit({ maxBytes: 4 })
}
}
class CapTwo implements Policy {
postOps(_ctx: OpsResultContext): Action | null {
return new Limit({ maxBytes: 2 })
}
}
class LimitOnPre implements Policy {
preCommand(_ctx: CommandContext): Action | null {
return new Limit({ maxBytes: 1 })
}
}
class CapLines implements Policy {
postExecute(_ctx: ExecuteResultContext): Action | null {
return new Limit({ maxLines: 2 })
}
}
describe('Limit', () => {
const opsCtx = (): OpsResultContext => ({
op: 'read',
path: path('/data/x'),
write: false,
prefix: '/data/',
result: new TextEncoder().encode('payload'),
})
it('postOps limits merge to the tightest', async () => {
const policies = new Policies()
policies.add(new CapFour())
policies.add(new CapTwo())
const [deny, bound] = await policies.postOps(opsCtx())
expect(deny).toBeNull()
expect(bound?.maxBytes).toBe(2)
})
it('postOpsGate returns the merged bound', async () => {
const policies = new Policies()
policies.add(new CapFour())
const bound = await postOpsGate(policies, 'read', path('/data/x'), false, '/data/', null)
expect(bound?.maxBytes).toBe(4)
})
it('a limit is illegal on preCommand', async () => {
const policies = new Policies()
policies.add(new LimitOnPre())
await expect(policies.preCommand(ctx('ls', []))).rejects.toThrow(/LimitOnPre/)
})
it('postExecuteGate merges user limits', async () => {
const policies = new Policies()
policies.add(new CapLines())
const [deny, bound] = await postExecuteGate(policies, {
producer: { command: 'echo', prefixes: [], declared: null },
exitCode: 0,
})
expect(deny).toBeNull()
expect(bound?.maxLines).toBe(2)
})
it('a user limit policy caps line output', async () => {
const ws = executableWorkspace()
try {
ws.policies.add(new CapLines())
await ws.dispatch('write', '/data/big.txt', [new TextEncoder().encode('1\n2\n3\n4\n5\n')])
const r = await ws.execute('cat /data/big.txt')
const out = new TextDecoder().decode(r.stdout)
expect(out.split('\n').filter((l) => l !== '').length).toBe(2)
expect(new TextDecoder().decode(r.stderr)).toContain('output truncated')
} finally {
await ws.close()
}
})
it('a postOps limit caps the op door', async () => {
const ws = executableWorkspace()
try {
ws.policies.add(new CapFour())
await ws.dispatch('write', '/data/f.txt', [new TextEncoder().encode('hello world')])
const served = await ws.dispatch('read', '/data/f.txt')
expect(new TextDecoder().decode(served as Uint8Array)).toBe('hell')
} finally {
await ws.close()
}
})
})
class CapThree implements Policy {
postExecute(_ctx: ExecuteResultContext): Action | null {
return new Limit({ maxLines: 3 })
}
}
class CapBytesHard implements Policy {
postExecute(_ctx: ExecuteResultContext): Action | null {
return new Limit({ maxBytes: 4, onExceed: OnExceed.ERROR })
}
}
class Boom implements Policy {
postExecute(_ctx: ExecuteResultContext): Action | null {
throw new Error('boom')
}
}
class DenyReads implements Policy {
postOps(ctx: OpsResultContext): Action | null {
return ctx.op === 'read' ? { kind: 'deny', message: 'reads are suppressed\n' } : null
}
}
class SeeProducer implements Policy {
readonly seen: string[] = []
postExecute(ctx: ExecuteResultContext): Action | null {
this.seen.push(ctx.producer.command)
return null
}
}
describe('Limit end to end', () => {
it('two limit policies merge to the tightest', async () => {
const ws = executableWorkspace()
try {
ws.policies.add(new CapLines())
ws.policies.add(new CapThree())
await ws.dispatch('write', '/data/big.txt', [new TextEncoder().encode('1\n2\n3\n4\n5\n')])
const r = await ws.execute('cat /data/big.txt')
const out = new TextDecoder().decode(r.stdout)
expect(out.split('\n').filter((l) => l !== '').length).toBe(2)
} finally {
await ws.close()
}
})
it('an error-mode limit fails the line', async () => {
const ws = executableWorkspace()
try {
ws.policies.add(new CapBytesHard())
await ws.dispatch('write', '/data/f.txt', [new TextEncoder().encode('hello world\n')])
const r = await ws.execute('cat /data/f.txt')
expect(r.exitCode).toBe(1)
expect(new TextDecoder().decode(r.stderr)).toContain('output truncated')
const ok = await ws.execute('echo ok')
expect(ok.exitCode).toBe(0)
expect(new TextDecoder().decode(ok.stdout)).toBe('ok\n')
} finally {
await ws.close()
}
})
it('a postOps deny beats a limit', async () => {
const ws = executableWorkspace()
try {
ws.policies.add(new CapFour())
ws.policies.add(new DenyReads())
await ws.dispatch('write', '/data/f.txt', [new TextEncoder().encode('hello world')])
await expect(ws.dispatch('read', '/data/f.txt')).rejects.toThrow(/reads are suppressed/)
} finally {
await ws.close()
}
})
it('a throwing postExecute policy fails the line closed', async () => {
const ws = executableWorkspace()
try {
ws.policies.add(new Boom())
const r = await ws.execute('echo hi')
expect(r.exitCode).toBe(1)
const err = new TextDecoder().decode(r.stderr)
expect(err).toContain('Boom')
expect(err).toContain('boom')
} finally {
await ws.close()
}
})
it('postExecute sees the rightmost producer', async () => {
const ws = executableWorkspace()
try {
const spy = new SeeProducer()
ws.policies.add(spy)
await ws.dispatch('write', '/data/f.txt', [new TextEncoder().encode('a\nb\n')])
await ws.execute('cat /data/f.txt | wc -l')
await ws.execute('cat /data/f.txt ; head -n 1 /data/f.txt')
await ws.execute('false || cat /data/f.txt')
// Builtins carry provenance too: a policy keyed on echo sees it.
await ws.execute('echo hi')
await ws.execute('cat /data/f.txt ; echo done')
expect(spy.seen).toEqual(['wc', 'head', 'cat', 'echo', 'echo'])
} finally {
await ws.close()
}
})
})
+61 -20
View File
@@ -12,7 +12,7 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { PathSpec } from '../types.ts'
import { Limit, type PathSpec } from '../types.ts'
import type { Policy } from './base.ts'
import { PolicyDenied, PolicyError } from './errors.ts'
import { SpecPolicy } from './spec.ts'
@@ -20,6 +20,7 @@ import {
VALIDITY,
type CommandContext,
type Deny,
type ExecuteResultContext,
type GuardSpec,
type OpsContext,
type OpsResultContext,
@@ -47,7 +48,12 @@ export async function preOpsGate(
}
}
/** Fire postOps at the op door; a Deny suppresses the result. */
/**
* Fire postOps at the op door; a Deny suppresses the result. Returns
* the merged Limit bound (tightest per field across every opining
* policy) for the door to apply to a byte-producing result, or null
* when no policy bounds this op.
*/
export async function postOpsGate(
policies: Policies,
op: string,
@@ -55,12 +61,26 @@ export async function postOpsGate(
write: boolean,
prefix: string,
result: unknown,
): Promise<void> {
if (!policies.wants('postOps')) return
const deny = await policies.postOps({ op, path, write, prefix, result })
): Promise<Limit | null> {
if (!policies.wants('postOps')) return null
const [deny, bound] = await policies.postOps({ op, path, write, prefix, result })
if (deny !== null) {
throw new PolicyDenied(deny.message.replace(/\n$/, ''), path.virtual)
}
return bound
}
/**
* Fire postExecute at the workspace boundary. Returns the fail-closed
* Deny (a throwing policy) if any, and the merged Limit bound for the
* boundary to enforce on the line's output stream.
*/
export async function postExecuteGate(
policies: Policies,
ctx: ExecuteResultContext,
): Promise<[Deny | null, Limit | null]> {
if (!policies.wants('postExecute')) return [null, null]
return policies.postExecute(ctx)
}
/**
@@ -115,7 +135,8 @@ export class Policies {
const hooked =
typeof candidate.preCommand === 'function' ||
typeof candidate.preOps === 'function' ||
typeof candidate.postOps === 'function'
typeof candidate.postOps === 'function' ||
typeof candidate.postExecute === 'function'
if (!hooked && 'reason' in entry) {
this.policies.push(new SpecPolicy(entry))
} else {
@@ -124,25 +145,37 @@ export class Policies {
this.rescan()
}
/**
* One loop for every hook: the first Deny wins (limits are moot once
* the result is suppressed), Limit actions accumulate and merge
* to the tightest value per field.
*/
private async fire(
hook: Hook,
ctx: CommandContext | OpsContext | OpsResultContext,
ctx: CommandContext | OpsContext | OpsResultContext | ExecuteResultContext,
subject: string,
): Promise<Deny | null> {
): Promise<[Deny | null, Limit | null]> {
const limits: Limit[] = []
for (const policy of this.policies) {
const fn = policy[hook]
if (fn === undefined) continue
const name = policy.constructor.name || 'policy'
let action
try {
action = await fn.call(policy, ctx as CommandContext & OpsContext & OpsResultContext)
action = await fn.call(
policy,
ctx as CommandContext & OpsContext & OpsResultContext & ExecuteResultContext,
)
} catch (err) {
const detail = err instanceof Error ? err.message : String(err)
return {
kind: 'deny',
message: `${subject}: policy ${name} failed: ${detail}\n`,
exitCode: 1,
}
return [
{
kind: 'deny',
message: `${subject}: policy ${name} failed: ${detail}\n`,
exitCode: 1,
},
null,
]
}
if (action === null) continue
const kind: unknown = typeof action === 'object' ? action.kind : undefined
@@ -152,23 +185,31 @@ export class Policies {
`legal kinds here: ${[...VALIDITY[hook]].join(', ')}`,
)
}
return action
if (action.kind === 'deny') return [action, null]
limits.push(action)
}
return null
return [null, Limit.aggr(limits)]
}
/** Fire preCommand across the policies; the first Deny wins. */
async preCommand(ctx: CommandContext): Promise<Deny | null> {
return this.fire('preCommand', ctx, ctx.command)
const [deny] = await this.fire('preCommand', ctx, ctx.command)
return deny
}
/** Fire preOps across the policies; the first Deny wins. */
async preOps(ctx: OpsContext): Promise<Deny | null> {
return this.fire('preOps', ctx, ctx.op)
const [deny] = await this.fire('preOps', ctx, ctx.op)
return deny
}
/** Fire postOps across the policies; a Deny suppresses the result. */
async postOps(ctx: OpsResultContext): Promise<Deny | null> {
/** Fire postOps; a Deny suppresses the result, Limits merge. */
async postOps(ctx: OpsResultContext): Promise<[Deny | null, Limit | null]> {
return this.fire('postOps', ctx, ctx.op)
}
/** Fire postExecute; Limits merge to the boundary bound. */
async postExecute(ctx: ExecuteResultContext): Promise<[Deny | null, Limit | null]> {
return this.fire('postExecute', ctx, ctx.producer.command || 'line')
}
}

Some files were not shown because too many files have changed in this diff Show More