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
14 changes: 7 additions & 7 deletions docs/src/content/docs/rule-evaluation/compound-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ After evaluating each sub-command, runok aggregates the results using the same [

> The most restrictive action across all sub-commands becomes the final action.

The priority order is: `deny` > `ask` > `allow` > `default`.
The priority order is: `deny` > `ask` > `allow`.

### Example

Expand All @@ -55,15 +55,15 @@ rules:

For the command `git add . && rm -rf /tmp`:

1. `git add .` → `allow` (priority 1)
2. `rm -rf /tmp` → `deny` (priority 3)
1. `git add .` → `allow` (priority 0)
2. `rm -rf /tmp` → `deny` (priority 2)
3. Final result: **deny** (strictest wins)

The entire compound command is blocked because one sub-command is denied.

## Default action resolution

Before merging sub-command results, `Action::Default` (no rule matched) is resolved to the configured [`defaults.action`](/configuration/schema/#defaultsaction). This ensures unmatched sub-commands participate in the aggregation at their effective restriction level.
When a sub-command does not match any rule, its action is resolved immediately to the configured [`defaults.action`](/configuration/schema/#defaultsaction) (defaulting to `ask` if unconfigured). This ensures unmatched sub-commands participate in the aggregation at their effective restriction level.

```yaml
defaults:
Expand All @@ -75,11 +75,11 @@ rules:

For the command `git status && unknown-cmd`:

1. `git status` → `allow` (priority 1)
2. `unknown-cmd` → `default` → resolved to `ask` (priority 2)
1. `git status` → `allow` (priority 0)
2. `unknown-cmd` → no rule matched → resolved to `ask` (priority 1)
3. Final result: **ask** (strictest wins)

Without this resolution, the unmatched `unknown-cmd` would silently pass because `default` has the lowest priority.
Without this resolution, unmatched sub-commands would be silently ignored.

## Sandbox policy aggregation

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/rule-evaluation/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ When runok receives a command:
c. Merge direct and wrapped command results using Explicit Deny Wins.
3. **Aggregate**: Merge all sub-command results (strictest wins).
4. **Resolve [sandbox](/sandbox/overview/)**: Merge sandbox policies from matched presets.
5. **Return**: The final action (`allow`, `deny`, `ask`, or `default` if no rule matched) and sandbox policy.
5. **Return**: The final action (`allow`, `deny`, or `ask`) and sandbox policy. If no rule matched, the action is resolved to the configured `defaults.action` (defaulting to `ask`).
29 changes: 14 additions & 15 deletions docs/src/content/docs/rule-evaluation/priority-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@ When multiple rules match a single command, runok must decide which action to ta

Each action has a fixed restriction level. When multiple rules match, the **most restrictive** action wins:

| Priority | Action | Meaning |
| ----------- | --------- | -------------------------------------- |
| 3 (highest) | `deny` | Block the command |
| 2 | `ask` | Prompt the user for confirmation |
| 1 | `allow` | Permit the command |
| 0 | (default) | No rule matched; use `defaults.action` |
| Priority | Action | Meaning |
| ----------- | ------- | -------------------------------- |
| 2 (highest) | `deny` | Block the command |
| 1 | `ask` | Prompt the user for confirmation |
| 0 | `allow` | Permit the command |

This priority is defined in `action_priority()` in the rule engine (`src/rules/rule_engine.rs`).

Expand All @@ -28,11 +27,11 @@ This priority is defined in `action_priority()` in the rule engine (`src/rules/r

```yaml
rules:
- allow: 'git *' # priority 1
- deny: 'git push -f|--force *' # priority 3 — always wins
- allow: 'git *' # priority 0
- deny: 'git push -f|--force *' # priority 2 — always wins
```

In this example, `git push --force main` matches both rules. The `deny` (priority 3) overrides the `allow` (priority 1), so the command is blocked.
In this example, `git push --force main` matches both rules. The `deny` (priority 2) overrides the `allow` (priority 0), so the command is blocked.

## Comparison with AWS IAM

Expand All @@ -47,16 +46,16 @@ The key difference is that runok adds an `ask` tier between `allow` and `deny`,

## The default action

When no rule matches a command, the result is `Default`. You can configure what happens in this case with [`defaults.action`](/configuration/schema/#defaultsaction):
When no rule matches a command, the action is resolved immediately to the configured [`defaults.action`](/configuration/schema/#defaultsaction):

```yaml
defaults:
action: ask # "allow", "deny", or "ask"
```

If `defaults.action` is not set, the adapter layer (e.g., Claude Code integration) determines the behavior.
If `defaults.action` is not set, it defaults to `ask`.

During [compound command evaluation](/rule-evaluation/compound-commands/), `Default` is resolved to the configured `defaults.action` **before** merging with other sub-command results. This ensures that unmatched sub-commands participate in the Explicit Deny Wins comparison at their effective restriction level, rather than being silently ignored.
Because unmatched commands are resolved at evaluation time, they participate directly in the Explicit Deny Wins comparison at their effective restriction level. For example, during [compound command evaluation](/rule-evaluation/compound-commands/), an unmatched sub-command resolved to `ask` (priority 1) will correctly outrank an `allow` (priority 0) sub-command.

## Wrapped command interactions

Expand All @@ -76,10 +75,10 @@ rules:

When evaluating `sudo rm -rf /`:

1. `allow: "sudo *"` matches directly (priority 1).
1. `allow: "sudo *"` matches directly (priority 0).
2. `sudo <cmd>` extracts the wrapped command `rm -rf /` and evaluates it recursively.
3. `deny: "rm -rf /"` matches the wrapped command (priority 3).
4. The results are merged: `deny` (priority 3) wins over `allow` (priority 1).
3. `deny: "rm -rf /"` matches the wrapped command (priority 2).
4. The results are merged: `deny` (priority 2) wins over `allow` (priority 0).

The command is denied.

Expand Down
6 changes: 3 additions & 3 deletions docs/src/content/docs/rule-evaluation/wrapper-recursion.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Command: `sudo bash -c "rm -rf /"`

**Step 2: Depth 1 — Evaluate `bash -c "rm -rf /"`**

- Direct rules: no match → `default`
- Direct rules: no match → resolved to `defaults.action`
- `bash -c <cmd>` matches → wrapped command: `rm -rf /`
- Recurse into wrapped command (depth 2)

Expand All @@ -71,7 +71,7 @@ Command: `sudo bash -c "rm -rf /"`

**Unwinding:**

- Depth 1: merge `default` with `deny` → **deny** wins
- Depth 1: merge `defaults.action` with `deny` → **deny** wins
- Depth 0: merge `allow` with `deny` → **deny** wins

Final result: **deny**. The dangerous wrapped command is blocked even though `sudo *` is allowed.
Expand Down Expand Up @@ -110,4 +110,4 @@ When a wrapper pattern uses placeholders like `<opts>`, the placeholder consumes

## Default action resolution

When the wrapped command does not match any rule, the `Action::Default` is resolved to the configured [`defaults.action`](/configuration/schema/#defaultsaction) **before** merging with the outer command's direct match result. This ensures that unmatched wrapped commands are not silently ignored.
When the wrapped command does not match any rule, its action is resolved immediately to the configured [`defaults.action`](/configuration/schema/#defaultsaction) (defaulting to `ask` if unconfigured). This ensures that unmatched wrapped commands participate in the merge at their effective restriction level, rather than being silently ignored.
1 change: 0 additions & 1 deletion src/adapter/check_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ fn build_check_output(result: &ActionResult) -> CheckOutput {
deny.fix_suggestion.clone(),
),
Action::Ask(message) => ("ask".to_string(), message.clone(), None),
Action::Default => ("ask".to_string(), None, None),
};

let sandbox = build_sandbox_info(&result.sandbox);
Expand Down
32 changes: 5 additions & 27 deletions src/adapter/exec_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ impl Endpoint for ExecAdapter {
Ok(exit_code)
}
Action::Deny(deny_response) => {
eprintln!("runok: denied: {}", deny_response.matched_rule);
if deny_response.matched_rule.is_empty() {
eprintln!("runok: command denied by default policy");
} else {
eprintln!("runok: denied: {}", deny_response.matched_rule);
}
if let Some(ref message) = deny_response.message {
eprintln!(" reason: {}", message);
}
Expand All @@ -127,10 +131,6 @@ impl Endpoint for ExecAdapter {
eprintln!("runok: {}", msg);
Ok(3)
}
Action::Default => {
// Should not reach here; run() handles Default before calling handle_action
Ok(0)
}
}
}

Expand Down Expand Up @@ -182,9 +182,6 @@ impl Endpoint for ExecAdapter {
.unwrap_or("command would require confirmation");
eprintln!("runok: dry-run: {}", msg);
}
Action::Default => {
eprintln!("runok: dry-run: no matching rule (default behavior)");
}
}
Ok(0)
}
Expand Down Expand Up @@ -435,24 +432,6 @@ mod tests {
assert_eq!(result, 3);
}

// --- handle_action: Default ---

#[rstest]
fn handle_action_default_returns_exit_0() {
let adapter = ExecAdapter::new(
vec!["git".into(), "status".into()],
None,
Box::new(MockExecutor::new(0)),
);
let result = adapter
.handle_action(ActionResult {
action: Action::Default,
sandbox: SandboxInfo::Preset(None),
})
.unwrap();
assert_eq!(result, 0);
}

// --- handle_no_match ---

#[rstest]
Expand Down Expand Up @@ -600,7 +579,6 @@ mod tests {
Action::Ask(Some("please confirm".to_string())),
0
)]
#[case::default_action(Action::Default, 0)]
fn handle_dry_run_always_returns_exit_0(
#[case] action: Action,
#[case] expected_exit_code: i32,
Expand Down
10 changes: 5 additions & 5 deletions src/adapter/hook_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ pub struct ClaudeCodeHookAdapter {
/// Build a combined reason string from a `DenyResponse`, including
/// the matched rule, optional message, and optional fix suggestion.
fn build_deny_reason(deny: &DenyResponse) -> String {
let mut reason = format!("denied: {}", deny.matched_rule);
let mut reason = if deny.matched_rule.is_empty() {
"command denied by default policy".to_string()
} else {
format!("denied: {}", deny.matched_rule)
};
if let Some(ref message) = deny.message {
reason.push_str(&format!(" ({})", message));
}
Expand Down Expand Up @@ -100,10 +104,6 @@ impl ClaudeCodeHookAdapter {
let updated = Self::sandbox_updated_input(&result.sandbox, &bash_input.command)?;
("ask", message.clone(), updated)
}
Action::Default => {
// run() dispatches Default to handle_no_match, but handle safely.
("allow", None, None)
}
};

Ok(Self::build_output(decision, reason, updated_input))
Expand Down
Loading
Loading