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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
"mounts": {
|
||||
"/ram": {
|
||||
"resource": "ram",
|
||||
"safeguards": {
|
||||
"limits": {
|
||||
"sleep": {
|
||||
"timeout_seconds": 1
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+34
-34
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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}),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "/"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+23
-26
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
+1
-1
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+30
-22
@@ -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")
|
||||
|
||||
+5
-6
@@ -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 == ""
|
||||
@@ -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)
|
||||
|
||||
+15
-15
@@ -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')
|
||||
})
|
||||
+24
-28
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
+4
-4
@@ -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, {})
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user