Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ clap = { version = "4.6.1", features = ["derive"] }
# schemas are embedded (include_str!) and use only internal #/definitions refs,
# so no resolver is needed at all.
jsonschema = { version = "0.46.5", default-features = false }
# Bash write-mutation patterns (sandbox policy). default-features off drops the
# Unicode tables: the shell-command patterns are ASCII, and ASCII `\b`/`\s` is
# the intended semantics, so `perf` (matching speed) is all we keep beyond `std`.
# Eval validation, transcript checks, dispatch parsing, and staging patterns.
# Default features are disabled to drop Unicode tables: these authored patterns
# use ASCII syntax, so `perf` (matching speed) is all we keep beyond `std`.
regex = { version = "1.12.3", default-features = false, features = ["std", "perf"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["preserve_order"] }
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ eval-magic harness show codex
- `eval-magic docs isolation` explains how live or installed skill sources can contaminate a
comparison and how to verify isolation. Its source is
[docs/guides/isolation.md](docs/guides/isolation.md).
- `eval-magic docs guard` explains eval-authored command allowances, packaged defaults, and the
containment checks those allowances cannot bypass. Its source is
[docs/guides/guard.md](docs/guides/guard.md).
- [docs/developer_overview.md](docs/developer_overview.md) maps the codebase, sources of truth,
verification workflow, and internal documentation.

Expand Down
32 changes: 32 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,38 @@ fn main() {
let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
fs::write(out_dir.join("guide_topics.rs"), generated)
.expect("failed to write generated guide topic table");

let profile_dir = manifest_dir.join("guard-profiles");
println!("cargo:rerun-if-changed={}", profile_dir.display());
let generated = render_guard_profiles(&profile_dir);
fs::write(out_dir.join("guard_profiles.rs"), generated)
.expect("failed to write generated guard profile table");
}

fn render_guard_profiles(profile_dir: &Path) -> String {
let mut profiles: Vec<PathBuf> = fs::read_dir(profile_dir)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", profile_dir.display()))
.map(|entry| entry.expect("failed to read guard profile entry").path())
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("toml"))
.collect();
profiles.sort();
assert!(
!profiles.is_empty(),
"guard-profiles must contain a TOML profile"
);

let mut generated = String::from("const PACKAGED_GUARD_PROFILES: &[(&str, &str)] = &[\n");
for path in profiles {
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_else(|| panic!("guard profile path is not UTF-8: {}", path.display()));
let body = fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
generated.push_str(&format!(" ({name:?}, {body:?}),\n"));
}
generated.push_str("];\n");
generated
}

fn discover_guides(guide_dir: &Path) -> Vec<Guide> {
Expand Down
3 changes: 3 additions & 0 deletions docs/claude-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,6 @@ only write boundary a dispatch has, since the session itself runs under `bypassP
invokes the hidden `guard` subcommand (**stable on-disk contract — never rename**), which denies
via Claude Code's `hookSpecificOutput` JSON shape and stays silent to allow. Both layers fail open.
A deny aborts the offending dispatch; `detect-stray-writes` remains the after-the-fact backstop.
The shared cwd-aware policy allows ordinary installs, builds, tests, and in-place edits inside the
task env, while explicit outside destinations, output escapes, repository-routing escapes, and
remote Git mutations remain blocked.
3 changes: 3 additions & 0 deletions docs/cline-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ the descriptor references. "Probe capture" refers to the observed dispatches des
`.cline/plugins/slow-powers-eval-guard/index.js` whose `beforeTool` hook forwards every tool
call to `eval-magic guard-hook --harness cline` (`run_commands`' `commands` array joined into
one `command` string for the shared arbiter) and returns `{skip: true, reason}` on deny.
The shared cwd-aware policy allows ordinary installs, builds, tests, and in-place edits inside
the task env while denying recognized explicit destinations outside it, output escapes,
repository-routing escapes, and remote Git mutations.
Spike-verified on 3.0.53 (all in a throwaway dir, hand-staged plugin): project plugin dirs
auto-load in headless one-shot dispatches (a bare `index.js` needs no package.json; a loose
`.js` file at the plugins root is IGNORED); the hook context is `{snapshot, tool, toolCall,
Expand Down
5 changes: 4 additions & 1 deletion docs/codex-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ update/delete source, and move destination from that body and resolves relative
hook payload's `cwd`. Bash output validation uses a quote-aware lexical scan for `>`, `>>`, `>|`,
file-descriptor-prefixed redirects, and `tee`: every literal target must resolve under an allowed
root, while dynamic, malformed, or outside targets are blocked. Merely mentioning an allowed root
elsewhere in the command does not scope an unrelated redirect.
elsewhere in the command does not scope an unrelated redirect. The same cwd-aware policy allows
ordinary installs, builds, tests, and in-place edits inside the task env while denying recognized
explicit destinations outside it. Repository-routing escapes and remote Git mutations remain
blocked.

Guard installation initializes `.eval-magic-outputs/guard-denials.jsonl` and records its absolute
path in the optional marker field `denialLogPath`. Each block appends only timestamp, harness,
Expand Down
3 changes: 3 additions & 0 deletions docs/developer_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ preconditions, handoffs, and recovery commands.
a task environment is built from and the skills under test resolve through it.
- `schema/` contains the JSON schemas for user input and generated artifacts.
- `harnesses/` contains built-in descriptors, descriptor scaffolding, and embedded harness assets.
- `guard-profiles/` contains packaged command-policy defaults discovered and embedded by `build.rs`.
- `profiles/` contains shared prompt profiles.
- `tests/cli/` covers CLI and packaging contracts; `tests/run/` covers campaign behavior across
the run boundary. Focused unit tests normally live beside the implementation.
Expand Down Expand Up @@ -148,5 +149,7 @@ implementation evidence in an internal note.
`eval-magic docs isolation`.
- [Shipped codebase guide](guides/codebase.md) is the repository source for
`eval-magic docs codebase`.
- [Shipped guard guide](guides/guard.md) is the repository source for
`eval-magic docs guard`.
- [Shipped conversations guide](guides/conversations.md) is the repository source for
`eval-magic docs conversations`.
148 changes: 148 additions & 0 deletions docs/guides/guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Configuring guarded commands

> **Audience:** eval authors deciding which development commands an agent may run inside a task
> environment.

The write guard combines a fixed containment boundary with an eval-authored command policy. Use
the `guard` field in the `evals.json` file to grant the tools or command prefixes a task needs.
Without an explicit `guard` field, eval-magic detects packaged profiles from the staged task tree.

## Understand the two policy layers

Containment checks run before command allowances. A command policy cannot override these checks:

- Direct write and patch tools must target the task environment.
- Shell redirects and `tee` targets must resolve inside the task environment.
- Remote Git mutations and repository-routing escapes are blocked.
- Recognized package, build, and edit destinations must stay inside the task environment. Global
and user installation modes are blocked unless the classifier can prove an in-task destination.

After those checks, the command policy handles recognized development mutations. A recognized
command that has no matching allowance is blocked. A tool claimed by `allow_commands` also has its
other subcommands blocked. Commands that neither the containment classifier nor the command policy
recognizes remain best-effort allowed and are inspected by `detect-stray-writes` after the run.

## Choose the configuration scope

A config-level `guard` field is the default for every eval:

```json
{
"skill_name": "rust-maintainer",
"guard": {
"allow_tools": ["cargo"]
},
"evals": [
{
"id": "repair-workspace",
"prompt": "Repair the workspace and run its tests.",
"expected_output": "The workspace tests pass."
}
]
}
```

A per-eval `guard` field completely replaces the config-level field. It does not merge with it.
Even an empty object disables the config default and automatic profile detection for that eval:

```json
{
"skill_name": "web-maintainer",
"guard": {
"profiles": ["language/javascript"]
},
"evals": [
{
"id": "serve-next-app",
"prompt": "Start the development server.",
"expected_output": "The server starts.",
"guard": {
"allow_commands": ["npm run dev"]
}
}
]
}
```

Any explicit `guard` field disables automatic detection. When it names `profiles`, only those
profiles are expanded.

## Choose allowance granularity

The `guard` object accepts three arrays:

- `allow_tools` contains executable basenames. An entry such as `cargo` permits all Cargo
subcommands after containment checks.
- `allow_commands` contains one literal shell command per entry. Each entry is a token prefix, so
`cargo test` permits `cargo test --workspace` but not `cargo build`.
- `profiles` contains packaged profile IDs. Profile commands are added to `allow_commands`.

Command rules cannot contain pipes, separators, redirects, variable expansions, or command
substitutions. Eval validation rejects those shapes. Matching normalizes an executable path to its
basename, skips leading environment assignments, and understands the `env`, `command`, `exec`,
`nice`, and `timeout` wrappers. A literal command passed through `sh -c`, `bash -c`, or `zsh -c` is
matched recursively. Every segment of a compound command must be allowed independently.

For example, this policy permits the listed Next.js lifecycle scripts but claims no other npm
subcommands:

```json
{
"guard": {
"allow_commands": [
"npm run dev",
"npm run build",
"npm run start"
]
}
}
```

With that policy, `npm run dev -- --hostname 127.0.0.1` is allowed and `npm install` is blocked.
Use `allow_tools` only when every subcommand of the tool is appropriate for the eval.

## Use packaged profiles

Packaged profiles provide lightweight defaults. Detection is recursive, so a frontend and backend
in the same task environment can activate multiple profiles. The detector skips `.git`,
`.eval-magic-outputs`, harness configuration and staged-skill directories, `target`,
`node_modules`, and `.venv`.

The packaged profiles are:

- `language/rust` is detected from `Cargo.toml`. It allows `cargo build`, `check`, `test`, `run`,
`fmt`, and `clippy`.
- `language/javascript` is detected from `package.json`. It allows npm install, CI, test, build,
lint, and typecheck commands, plus corresponding pnpm, Yarn, and Bun install, add, test, build,
lint, and typecheck commands.
- `framework/nextjs` is detected when `package.json` declares a `next` dependency. It allows npm,
pnpm, Yarn, and Bun dev, build, and start scripts, plus direct `next` invocations through `npx`,
`pnpm exec`, `yarn`, and `bunx`.
- `language/python` is detected from `pyproject.toml`, `setup.py`, or a
`requirements*.txt` file. It allows pip installs, Python module invocations for pip, build,
pytest, and unittest, and direct `pytest`.

Name profiles explicitly when the files in the task tree are not the policy you want:

```json
{
"guard": {
"profiles": ["language/javascript", "framework/nextjs"],
"allow_commands": ["npm run integration"]
}
}
```

Explicit commands and expanded profile commands are deduplicated in the effective policy.

## Audit the effective policy

Each task in `dispatch.json` records its fully expanded `guard_policy`. The armed marker records the
same policy as `guardPolicy`, so the live hook and the campaign plan cannot resolve defaults
differently. `detect-stray-writes` reads the frozen task policy from `dispatch.json` and applies the
same classifier after the run. A legacy dispatch without `guard_policy` uses an empty command
policy rather than guessing which defaults applied.

The command policy is not a complete shell sandbox. Keep task environments isolated, inspect guard
denials and stray-write findings during ingest, and use narrow `allow_commands` entries when the
eval does not need every operation a tool exposes.
8 changes: 5 additions & 3 deletions docs/opencode-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,11 @@ Two boundary notes, shared with the other harnesses' guards:
- The marker's sole allowed root is the private task env on every host. Host temp locations such
as `/tmp` and `$TMPDIR` remain out of bounds; dispatch prompts direct scratch work to
`<env>/tmp/` instead without rewriting `TMPDIR`, `TMP`, or `TEMP`.
- Bash coverage is the shared heuristic denylist (installs, git mutations, redirects, config-dir
tampering): a bare `touch /abs/outside/path` matches no pattern and is allowed — after-the-fact
detection of those is `detect-stray-writes`' job, same as claude/codex.
- Bash coverage is the shared target-aware heuristic. Ordinary installs, builds, tests, and
in-place edits run from the task env; recognized explicit project/output destinations must also
remain there. Output redirects, repository-routing escapes, and remote Git mutations remain
blocked. A bare `touch /abs/outside/path` matches no pattern and is allowed — after-the-fact
detection of those is `detect-stray-writes`' job, same as the other harnesses.

One hook-shape caveat: `tool.execute.before` fires for *every* tool (OpenCode has no matcher
surface), so each tool call spawns one `eval-magic guard-hook`. Classification stays in the
Expand Down
28 changes: 28 additions & 0 deletions guard-profiles/framework-nextjs.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
id = "framework/nextjs"
package_json_dependencies = ["next"]
allow_commands = [
"npm run dev",
"npm run build",
"npm run start",
"pnpm run dev",
"pnpm run build",
"pnpm run start",
"yarn run dev",
"yarn run build",
"yarn run start",
"bun run dev",
"bun run build",
"bun run start",
"npx next dev",
"npx next build",
"npx next start",
"pnpm exec next dev",
"pnpm exec next build",
"pnpm exec next start",
"yarn next dev",
"yarn next build",
"yarn next start",
"bunx next dev",
"bunx next build",
"bunx next start",
]
28 changes: 28 additions & 0 deletions guard-profiles/language-javascript.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
id = "language/javascript"
markers = ["package.json"]
allow_commands = [
"npm install",
"npm ci",
"npm test",
"npm run build",
"npm run lint",
"npm run typecheck",
"pnpm install",
"pnpm add",
"pnpm test",
"pnpm run build",
"pnpm run lint",
"pnpm run typecheck",
"yarn install",
"yarn add",
"yarn test",
"yarn run build",
"yarn run lint",
"yarn run typecheck",
"bun install",
"bun add",
"bun test",
"bun run build",
"bun run lint",
"bun run typecheck",
]
16 changes: 16 additions & 0 deletions guard-profiles/language-python.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
id = "language/python"
markers = ["pyproject.toml", "setup.py"]
marker_patterns = ["requirements*.txt"]
allow_commands = [
"pip install",
"pip3 install",
"python -m pip install",
"python3 -m pip install",
"python -m build",
"python3 -m build",
"python -m pytest",
"python3 -m pytest",
"python -m unittest",
"python3 -m unittest",
"pytest",
]
10 changes: 10 additions & 0 deletions guard-profiles/language-rust.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id = "language/rust"
markers = ["Cargo.toml"]
allow_commands = [
"cargo build",
"cargo check",
"cargo test",
"cargo run",
"cargo fmt",
"cargo clippy",
]
3 changes: 1 addition & 2 deletions harnesses/template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ label = "{label}"

## Where the harness discovers project-local skills. Declaring skills_dir unlocks native
## staging; without it every run is forced to --no-stage. The first path segment of skills_dir
## must appear in config_dirs (it feeds the staging sibling filter, guard tamper rules, and
## stray-write lookbehind).
## must appear in config_dirs (it feeds the staging sibling filter and task-repository baseline).
## VERIFY: which directory does the harness actually scan for skills? Quote the doc or the
## observed behavior in the notes file.
# skills_dir = ".{label}/skills"
Expand Down
Loading
Loading