docs(permissions): publish and lock authorization order (#4980)

Publish the implemented authorization order and lock its precedence with regression coverage. Preserve the contributor's documentation, translation, and contract-test work.
This commit is contained in:
Turisla authored and GitHub committed 2026-07-30 05:15:28 -07:00
1 parent 8c6bf0e07b
commit 171f0b2a27
18 files changed
+432 -46

No files matched your search

+4 -1
View File
@@ -1,4 +1,4 @@
<!-- source: README.md sha256:f25cf99b305a -->
<!-- source: README.md sha256:e2678f36a915 -->
# Codewhale
Un agente de programación de código abierto para tu terminal — trae tu propio modelo.
@@ -76,6 +76,9 @@ la ruta normal de aprobación.
- [docs/FLEET.md](docs/FLEET.md) — fleets, el libro mayor y resume
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, hooks y la
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — cómo se combinan
los modos, hooks, reglas de permisos, límites mínimos de seguridad, reglas del
repositorio, aprobaciones y sandboxing
- [docs/HOOKS.md](docs/HOOKS.md) — los once eventos de hooks del ciclo de vida
de la TUI, sus payloads y los tres que pueden dirigir un turno (`codewhale
exec` y los subcomandos de la CLI no activan hooks)
+3 -1
View File
@@ -1,4 +1,4 @@
<!-- source: README.md sha256:f25cf99b305a -->
<!-- source: README.md sha256:e2678f36a915 -->
# Codewhale
ターミナルで動くオープンソースのコーディングエージェント — モデルはあなたが持ち込む。
@@ -49,6 +49,8 @@ TUI では、`/model` がプロバイダとモデルをまとめて切り替え
- [docs/FLEET.md](docs/FLEET.md) — Fleet、台帳、再開
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`、フック、
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — モード、フック、
権限ルール、安全フロア、リポジトリルール、承認、サンドボックスの組み合わせ方
- [docs/HOOKS.md](docs/HOOKS.md) — 11 個の TUI ライフサイクルフックイベント、
そのペイロード、ターンを誘導できる 3 イベント(`codewhale exec` と CLI
サブコマンドではフックは発火しません)
+3 -1
View File
@@ -1,4 +1,4 @@
<!-- source: README.md sha256:f25cf99b305a -->
<!-- source: README.md sha256:e2678f36a915 -->
# Codewhale
터미널에서 쓰는 오픈소스 코딩 에이전트 — 모델은 당신이 가져옵니다.
@@ -49,6 +49,8 @@ TUI 안에서: `/model`은 프로바이더와 모델을 함께 전환하고, `/f
- [docs/FLEET.md](docs/FLEET.md) — Fleet, 원장, 재개
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, 훅,
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — 모드, 훅, 권한
규칙, 안전 기준선, 저장소 규칙, 승인, 샌드박스가 함께 적용되는 방식
- [docs/HOOKS.md](docs/HOOKS.md) — 11개의 TUI 수명 주기 훅 이벤트, 해당
페이로드, 턴을 조정할 수 있는 3개 이벤트 (`codewhale exec`와 CLI 하위
명령은 훅을 실행하지 않음)
+3
View File
@@ -72,6 +72,9 @@ path.
- [docs/FLEET.md](docs/FLEET.md) — fleets, the ledger, and resume
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, hooks, and
the constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — how modes,
hooks, permission rules, safety floors, repo law, approvals, and sandboxing
compose
- [docs/HOOKS.md](docs/HOOKS.md) — the eleven TUI lifecycle hook events, their
payloads, and which three of them can steer a turn (`codewhale exec` and the
CLI subcommands do not fire hooks)
+4 -1
View File
@@ -1,4 +1,4 @@
<!-- source: README.md sha256:f25cf99b305a -->
<!-- source: README.md sha256:e2678f36a915 -->
# Codewhale
Um agente de programação de código aberto para o seu terminal — traga o seu próprio modelo.
@@ -77,6 +77,9 @@ de aprovação.
- [docs/FLEET.md](docs/FLEET.md) — fleets, o livro-razão e resume
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, hooks e a
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — como modos, hooks,
regras de permissão, limites mínimos de segurança, regras do repositório,
aprovações e sandboxing se combinam
- [docs/HOOKS.md](docs/HOOKS.md) — os onze eventos de hook do ciclo de vida da
TUI, seus payloads e os três que podem direcionar um turno (`codewhale exec`
e os subcomandos da CLI não disparam hooks)
+4 -1
View File
@@ -1,4 +1,4 @@
<!-- source: README.md sha256:f25cf99b305a -->
<!-- source: README.md sha256:e2678f36a915 -->
# Codewhale
Открытый агент для программирования в вашем терминале — модель приносите с собой.
@@ -79,6 +79,9 @@ Ask / Auto-Review / Full Access. `!` запускает команду обол
- [docs/FLEET.md](docs/FLEET.md) — флиты, журнал и возобновление работы
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, хуки и
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — как сочетаются
режимы, хуки, правила разрешений, минимальные требования безопасности, правила
репозитория, подтверждения и песочница
- [docs/HOOKS.md](docs/HOOKS.md) — одиннадцать событий хуков жизненного цикла
TUI, их полезная нагрузка и три из них, способные направлять ход (`codewhale
exec` и подкоманды CLI хуки не запускают)
+4 -1
View File
@@ -1,4 +1,4 @@
<!-- source: README.md sha256:f25cf99b305a -->
<!-- source: README.md sha256:e2678f36a915 -->
# Codewhale
Агент для програмування з відкритим кодом у вашому терміналі — модель приносите ви.
@@ -77,6 +77,9 @@ Ask / Auto-Review / Full Access. `!` виконує команду оболон
- [docs/FLEET.md](docs/FLEET.md) — флоти, журнал і відновлення
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, хуки й
конституція
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — як поєднуються
режими, хуки, правила дозволів, мінімальні вимоги безпеки, правила репозиторію,
затвердження та пісочниця
- [docs/HOOKS.md](docs/HOOKS.md) — одинадцять подій хуків життєвого циклу TUI,
їхні корисні навантаження та три з них, що можуть скеровувати хід (`codewhale
exec` і підкоманди CLI хуків не запускають)
+4 -1
View File
@@ -1,4 +1,4 @@
<!-- source: README.md sha256:f25cf99b305a -->
<!-- source: README.md sha256:e2678f36a915 -->
# Codewhale
Một coding agent mã nguồn mở cho terminal của bạn — mang theo model của riêng bạn.
@@ -74,6 +74,9 @@ duyệt bình thường.
- [docs/FLEET.md](docs/FLEET.md) — fleet, sổ cái và resume
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, hook và
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — cách các chế độ,
hook, quy tắc quyền, mức an toàn tối thiểu, luật của repo, quy trình phê duyệt
và sandbox phối hợp với nhau
- [docs/HOOKS.md](docs/HOOKS.md) — mười một sự kiện hook trong vòng đời TUI,
payload của chúng và ba sự kiện có thể điều hướng một lượt (`codewhale exec`
và các lệnh con CLI không kích hoạt hook)
+2 -1
View File
@@ -1,4 +1,4 @@
<!-- source: README.md sha256:f25cf99b305a -->
<!-- source: README.md sha256:e2678f36a915 -->
# Codewhale
一个面向终端的开源编程智能体——模型由你自带。
@@ -47,6 +47,7 @@ codewhale web # local browser client on 127.0.0.1
- [docs/PROVIDERS.md](docs/PROVIDERS.md) — 每一条 provider 路由:托管、网关与本地
- [docs/FLEET.md](docs/FLEET.md) — Fleet、账本与恢复
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`、hooks 与 constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — 模式、hook、权限规则、安全下限、仓库规则、审批和沙箱如何组合
- [docs/HOOKS.md](docs/HOOKS.md) — 十一个 TUI 生命周期 hook 事件、其载荷,以及其中可引导回合的三个事件(`codewhale exec` 和 CLI 子命令不会触发 hooks)
- [docs/WEB.md](docs/WEB.md) — 仅限回环地址的内置浏览器客户端及其一次性身份验证边界
+15 -10
View File
@@ -7,8 +7,9 @@ use bash_arity::BashArityDict;
use codewhale_protocol::NetworkPolicyAmendment;
use serde::{Deserialize, Serialize};
/// Priority layer for a permission ruleset. Higher ordinal = higher priority.
/// On conflict, the highest-priority layer's longest matching prefix wins.
/// Priority layer for typed permission-rule selection. Higher ordinal = higher
/// priority. Matching typed rules compare layer before action and specificity.
/// Hard denied prefixes are merged across layers and checked first.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RulesetLayer {
@@ -92,10 +93,12 @@ fn default_rule_action() -> PermissionAction {
/// - `"ask"` — the approval prompt is forced (default, backward compatible).
/// - `"allow"` — the tool call proceeds without asking.
///
/// Deny always wins over ask, which wins over allow. Command-prefix-based
/// deny and allow rules are promoted into the execution-policy engine's
/// `denied_prefixes` / `trusted_prefixes` for arity-aware matching;
/// path-only rules are evaluated separately.
/// Inside one ruleset layer, deny wins over ask, which wins over allow.
/// Higher-priority layers are selected before action and specificity.
/// Command-prefix-based deny and allow rules loaded from `permissions.toml`
/// are also promoted into the execution-policy engine's `denied_prefixes` /
/// `trusted_prefixes` for arity-aware matching; path-only rules are evaluated
/// separately.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ToolAskRule {
@@ -433,8 +436,9 @@ impl ExecPolicyEngine {
/// Evaluates a command against the policy and returns a decision.
///
/// The evaluation order is: deny rules first (always win), then trusted prefix
/// matching (arity-aware), then typed ask rules, and finally the approval mode.
/// The evaluation order is: hard denied prefixes, a trusted-prefix candidate,
/// the winning typed rule (layer, action, specificity), and finally the
/// approval-mode fallback. A typed ask can override the trusted candidate.
pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes();
// Deny rules match positional tokens at a word boundary: the command
@@ -504,8 +508,9 @@ impl ExecPolicyEngine {
let ask_rule = self.matching_ask_rule(&ctx);
// Handle explicit deny/allow actions before mode-based resolution.
// Deny wins over everything; allow skips approval regardless of mode.
// Apply the one typed rule selected by layer, action, and specificity
// before mode-based resolution. Within a layer, deny outranks ask and
// allow; a higher-layer rule has already won before this match.
if let Some(rule) = &ask_rule {
match rule.action {
PermissionAction::Deny => {
@@ -0,0 +1,179 @@
use codewhale_execpolicy::{
AskForApproval, ExecPolicyContext, ExecPolicyEngine, PermissionAction, Ruleset, ToolAskRule,
};
struct AuthorizationCase {
name: &'static str,
engine: ExecPolicyEngine,
command: &'static str,
approval: AskForApproval,
expected_allow: bool,
expected_approval: bool,
expected_phase: &'static str,
expected_action: Option<PermissionAction>,
expected_rule: Option<&'static str>,
}
fn command_rule(command: &str, action: PermissionAction) -> ToolAskRule {
ToolAskRule {
action,
..ToolAskRule::exec_shell(command)
}
}
fn tool_rule(action: PermissionAction) -> ToolAskRule {
ToolAskRule {
action,
..ToolAskRule::new("exec_shell")
}
}
#[test]
fn authorization_order_contract_matches_documented_precedence() {
let cases = vec![
AuthorizationCase {
name: "hard denied prefix beats typed allow",
engine: ExecPolicyEngine::with_rulesets(vec![
Ruleset::user(vec![], vec!["cargo publish".to_string()])
.with_ask_rules(vec![command_rule("cargo publish", PermissionAction::Allow)]),
]),
command: "cargo publish --dry-run",
approval: AskForApproval::OnRequest,
expected_allow: false,
expected_approval: false,
expected_phase: "forbidden",
expected_action: None,
expected_rule: Some("cargo publish"),
},
AuthorizationCase {
name: "higher typed layer beats lower action and specificity",
engine: ExecPolicyEngine::with_rulesets(vec![
Ruleset::agent(vec![], vec![]).with_ask_rules(vec![command_rule(
"cargo test --workspace",
PermissionAction::Deny,
)]),
Ruleset::user(vec![], vec![])
.with_ask_rules(vec![command_rule("cargo test", PermissionAction::Allow)]),
]),
command: "cargo test --workspace",
approval: AskForApproval::OnRequest,
expected_allow: true,
expected_approval: false,
expected_phase: "allowed",
expected_action: Some(PermissionAction::Allow),
expected_rule: Some("tool=exec_shell command=cargo test"),
},
AuthorizationCase {
name: "typed action beats specificity inside one layer",
engine: ExecPolicyEngine::with_rulesets(vec![
Ruleset::user(vec![], vec![]).with_ask_rules(vec![
tool_rule(PermissionAction::Deny),
command_rule("cargo test --workspace", PermissionAction::Allow),
]),
]),
command: "cargo test --workspace",
approval: AskForApproval::OnRequest,
expected_allow: false,
expected_approval: false,
expected_phase: "forbidden",
expected_action: Some(PermissionAction::Deny),
expected_rule: Some("tool=exec_shell"),
},
AuthorizationCase {
name: "specificity breaks a same-layer same-action tie",
engine: ExecPolicyEngine::with_rulesets(vec![
Ruleset::user(vec![], vec![]).with_ask_rules(vec![
tool_rule(PermissionAction::Allow),
command_rule("cargo test", PermissionAction::Allow),
]),
]),
command: "cargo test --workspace",
approval: AskForApproval::OnRequest,
expected_allow: true,
expected_approval: false,
expected_phase: "allowed",
expected_action: Some(PermissionAction::Allow),
expected_rule: Some("tool=exec_shell command=cargo test"),
},
AuthorizationCase {
name: "typed ask beats a trusted prefix",
engine: ExecPolicyEngine::with_rulesets(vec![
Ruleset::user(vec!["cargo test".to_string()], vec![])
.with_ask_rules(vec![command_rule("cargo test", PermissionAction::Ask)]),
]),
command: "cargo test --workspace",
approval: AskForApproval::UnlessTrusted,
expected_allow: true,
expected_approval: true,
expected_phase: "needs_approval",
expected_action: Some(PermissionAction::Ask),
expected_rule: Some("tool=exec_shell command=cargo test"),
},
AuthorizationCase {
name: "typed ask fails closed when prompts are forbidden",
engine: ExecPolicyEngine::with_rulesets(vec![
Ruleset::user(vec![], vec![])
.with_ask_rules(vec![command_rule("cargo test", PermissionAction::Ask)]),
]),
command: "cargo test --workspace",
approval: AskForApproval::Never,
expected_allow: false,
expected_approval: false,
expected_phase: "forbidden",
expected_action: Some(PermissionAction::Ask),
expected_rule: Some("tool=exec_shell command=cargo test"),
},
AuthorizationCase {
name: "trusted prefix feeds the approval fallback",
engine: ExecPolicyEngine::with_rulesets(vec![Ruleset::user(
vec!["cargo test".to_string()],
vec![],
)]),
command: "cargo test --workspace",
approval: AskForApproval::UnlessTrusted,
expected_allow: true,
expected_approval: false,
expected_phase: "allowed",
expected_action: None,
expected_rule: Some("cargo test"),
},
];
for case in cases {
let decision = case
.engine
.check(ExecPolicyContext {
command: case.command,
cwd: "/workspace",
tool: Some("exec_shell"),
path: None,
ask_for_approval: case.approval,
sandbox_mode: Some("workspace-write"),
})
.unwrap_or_else(|error| panic!("{}: policy check failed: {error}", case.name));
assert_eq!(decision.allow, case.expected_allow, "{}: allow", case.name);
assert_eq!(
decision.requires_approval, case.expected_approval,
"{}: requires_approval",
case.name
);
assert_eq!(
decision.requirement.phase(),
case.expected_phase,
"{}: phase",
case.name
);
assert_eq!(
decision.matched_action, case.expected_action,
"{}: matched action",
case.name
);
assert_eq!(
decision.matched_rule.as_deref(),
case.expected_rule,
"{}: matched rule",
case.name
);
}
}
+46 -18
View File
@@ -7146,7 +7146,7 @@ async fn full_access_blocks_non_bypassable_registered_tools_at_engine_boundary()
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn full_access_blocks_repo_law_ask_without_prompt_or_write() {
async fn full_access_permission_allow_cannot_bypass_repo_law() {
let _lock = lock_test_env();
let workspace = tempdir().expect("tempdir");
let law_dir = workspace.path().join(".codewhale");
@@ -7162,20 +7162,34 @@ async fn full_access_blocks_repo_law_ask_without_prompt_or_write() {
)
.expect("write repo law fixture");
let target = workspace.path().join("CHANGELOG.md");
let allow_rule = codewhale_execpolicy::ToolAskRule::file_path("write_file", "CHANGELOG.md")
.into_exact_workspace_allow(workspace.path().to_string_lossy().into_owned());
let engine_config = EngineConfig {
model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
workspace: workspace.path().to_path_buf(),
snapshots_enabled: false,
subagents_enabled: false,
exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![
codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(vec![allow_rule]),
]),
..EngineConfig::default()
};
let tool_input = json!({"path": "CHANGELOG.md", "content": "must not be written\n"});
assert_eq!(
file_tool_ask_rule_decision(
&engine_config,
"write_file",
&tool_input,
workspace.path(),
crate::tui::approval::ApprovalMode::Bypass,
),
Some(ToolAskRuleDecision::Allow),
"precondition: the remembered grant must match before repo law tightens the plan"
);
assert_full_access_model_tool_batch_is_blocked(
engine_config,
vec![(
"write_file",
json!({"path": "CHANGELOG.md", "content": "must not be written\n"}),
)],
vec![("write_file", tool_input)],
&[(
"write_file",
"Repository law blocked tool 'write_file' in Full Access: Repo law holds this write: \"Release notes need human review\"",
@@ -7364,7 +7378,7 @@ async fn auto_review_auto_resolves_hallucinated_question_without_prompting() {
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn full_access_blocks_background_catastrophic_shell_without_prompt_or_side_effect() {
async fn full_access_permission_allow_cannot_bypass_background_catastrophic_floor() {
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -7379,11 +7393,13 @@ async fn full_access_blocks_background_catastrophic_shell_without_prompt_or_side
// sentinel. The policy-level sibling tests exercise real destructive
// command shapes directly without ever dispatching them to a shell.
let command = format!("echo \"rm -rf /\" > \"{}\"", victim.display());
let arguments = serde_json::to_string(&json!({
let allow_rule = codewhale_execpolicy::ToolAskRule::exec_shell(command.clone())
.into_exact_workspace_allow(workspace.path().to_string_lossy().into_owned());
let tool_input = json!({
"command": command,
"background": true,
}))
.expect("serialize tool arguments");
});
let arguments = serde_json::to_string(&tool_input).expect("serialize tool arguments");
let tool_delta = json!({
"id": "chatcmpl-bg",
@@ -7440,16 +7456,28 @@ async fn full_access_blocks_background_catastrophic_shell_without_prompt_or_side
base_url: Some(server.uri()),
..Config::default()
};
let (engine, handle) = Engine::new(
EngineConfig {
model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
workspace: workspace.path().to_path_buf(),
snapshots_enabled: false,
subagents_enabled: false,
..EngineConfig::default()
},
&api_config,
let engine_config = EngineConfig {
model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
workspace: workspace.path().to_path_buf(),
snapshots_enabled: false,
subagents_enabled: false,
exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![
codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(vec![allow_rule]),
]),
..EngineConfig::default()
};
assert_eq!(
exec_shell_ask_rule_decision(
&engine_config,
"exec_shell",
&tool_input,
workspace.path(),
crate::tui::approval::ApprovalMode::Bypass,
),
Some(ToolAskRuleDecision::Allow),
"precondition: the remembered grant must match before the safety floor tightens the plan"
);
let (engine, handle) = Engine::new(engine_config, &api_config);
let run_task = tokio::spawn(engine.run());
handle
+9 -9
View File
@@ -354,15 +354,15 @@ impl AutoReviewPolicy {
}
}
/// The non-bypassable floor beneath rules and modes. Ask and Auto-Review
/// surface a hold for approval; Full Access and other non-interactive
/// approval postures convert the same hold into a hard block. It keys on
/// `ToolActionKind` — what the call actually does — not on `RiskLevel`,
/// whose `Destructive` bucket means "not provably read-only" and exists for
/// modal styling. Keying the floor on that bucket held ordinary background
/// test runs and read-only sub-agent fanout for durable review even in YOLO
/// (#3883). Genuinely destructive, secret-touching, and publish-like actions
/// remain enforced in every mode.
/// The safety floor beneath configured rules. Ask and Auto-Review surface an
/// applicable hold for approval; non-interactive approval postures convert the
/// same hold into a hard block. Full Access deliberately skips the interactive
/// publish hold, but the catastrophic destructive background/headless floor
/// still applies. The floor keys on `ToolActionKind` — what the call actually
/// does — not on `RiskLevel`, whose `Destructive` bucket means "not provably
/// read-only" and exists for modal styling. Keying the floor on that bucket
/// held ordinary background test runs and read-only sub-agent fanout for
/// durable review even in YOLO (#3883).
fn safety_floor(ctx: &AutoReviewContext<'_>) -> Option<AutoReviewDecision> {
match (ctx.action_kind, ctx.run_origin) {
// Full Access (Bypass) means exactly that: the user granted publish
+139
View File
@@ -0,0 +1,139 @@
# Authorization order
Codewhale combines tool availability, hooks, typed permission rules, approval
posture, repository policy, and sandboxing. An approval from one layer is not a
universal bypass: a later safety layer can still require review or block the
call, and an approval is not an operating-system sandbox grant.
This page records the order implemented by the interactive engine's
model-requested tool path. Entrypoints that use only part of that path, such as
the core runtime's direct tool API, keep the relative order of the layers they
do use.
## Model tool-call pipeline
The interactive engine evaluates a model-requested tool call in this order:
| Order | Layer | Result |
|---:|---|---|
| 1 | Effective configuration and posture | User settings, command/runtime overrides, and the project overlay resolve before the turn. A project overlay may tighten `approval_policy`, `sandbox_mode`, or shell availability, but may not loosen them. |
| 2 | Mode and tool admission | Plan-mode restrictions, input parse errors, per-command tool deny/allow lists, caller restrictions, and missing execution registrations fail before policy rules are considered. A tool present in both command lists is denied. |
| 3 | Preparation, then `tool_call_before` hooks | Registry preparation is side-effect free. Foreground hooks then fold as `deny > ask > allow`, and a strict matching hook that produces no verdict fails closed. The last `updatedInput` wins; the engine prepares the rewritten input again before later gates inspect it. |
| 4 | Registered-tool baseline | The prepared tool's `ApprovalRequirement` establishes its ordinary approval need. A hook `ask` is applied after that assignment so the baseline cannot erase it. Non-bypassable registered holds remain forced; Full Access converts them to hard blocks instead of opening a contradictory modal. Plan mode also blocks write-capable tools here. |
| 5 | Typed `permissions.toml` rule | A matching `deny` blocks. A matching `allow` may clear only ordinary registry approval; it cannot clear a hook `ask` or a non-bypassable registered hold. A matching `ask` forces review only in a posture that can prompt. Full Access/auto-approval is not downgraded into a prompt, while an explicit typed `deny` still blocks. |
| 6 | Auto-review policy and built-in safety floor | Configured block rules run before the built-in floor, then configured allow rules and the deterministic fallback. This layer runs after typed permissions and can add a prompt or block, but cannot remove an earlier hold. Full Access deliberately skips the interactive publish hold; catastrophic destructive background/headless actions remain protected. |
| 7 | Repository law | Protected path invariants can only add a prompt or block. A repo-law prompt becomes a hard block in Full Access, which has no contradictory approval modal. |
| 8 | Human approval | A remaining prompt is sent to the approval channel. Denial stops the call. Approval authorizes this planned call; session and persistent choices affect later matching calls but do not erase a later gate from this call. |
| 9 | Tool authority and execution sandbox | Worker authority envelopes, native tool path checks, and the selected OS or external sandbox still apply during execution. A sandbox denial remains a denial unless the user separately authorizes a supported elevation path. |
The ordering is intentionally monotonic after the typed permission layer:
auto-review and repository law can tighten a result, not turn a previous block
or prompt into an unreviewed execution. There are explicit posture choices
inside those layers—for example, Full Access does not create an interactive
publish prompt—but those choices do not let an earlier remembered grant erase
a hold that the layer actually produced.
## Typed permission-rule selection
`permissions.toml` is currently a sibling of the active user `config.toml`.
There is no project-local permission-rule source today. An optional `workspace`
field scopes one user rule to a repository; it does not create a project
overlay. `/permissions` reports that source, matcher, scope, and whether the
scope applies to the current workspace.
The execution-policy engine evaluates matching rules as follows:
1. Normalize the tool, command, workspace, and any workspace-relative path.
Unsafe or external paths do not become matchable file rules.
2. Check denied command prefixes against the whole command and every chained
segment. These hard prefix denies are merged across rulesets and always win.
3. Compute a trusted-prefix candidate for an unchained shell command. This is a
candidate for the approval-mode fallback, not an immediate decision.
4. Select one matching typed rule by this lexicographic precedence:
1. higher source layer: `User > Agent > BuiltinDefault`;
2. stronger action inside that layer: `deny > ask > allow`;
3. the more specific matcher when layer and action tie.
5. Apply the selected typed action. `deny` forbids the call, `allow` skips the
execution-policy approval, and `ask` requires approval. A typed `ask`
overrides a trusted prefix.
6. If no typed action decides the result, apply the approval-mode fallback
using the trusted-prefix candidate.
Source layer is compared before typed action. Consequently, a user-layer typed
`allow` can override an agent-layer typed `deny`; inside the same layer, `deny`
still beats `ask`, which beats `allow`, regardless of file order or matcher
specificity. Hard denied prefixes are the exception: they are checked before
typed-layer selection and cannot be overridden by a typed allow.
Specificity is only a tie-breaker after source and action. A constrained
command, exact-command, path, or workspace matcher beats a tool-wide rule with
the same action in the same layer. Specificity never lets a narrow allow beat a
same-layer deny.
For chained shell commands, a trusted prefix never approves the whole chain. A
typed deny that wins for any individual segment blocks the full invocation.
## Approval posture and missing prompts
The execution-policy result is combined with the registered-tool baseline; the
two should not be interpreted independently.
- Ask and Auto-Review may surface tool safety approvals. Auto-Review does not
pause for model-authored user questions, which are a separate channel.
- Full Access and YOLO-compatible auto-approval paths do not let a typed `ask`
downgrade the session into prompting. Typed deny, non-bypassable registered
holds, catastrophic background/headless safety holds, and repository law
still fail closed where their respective layers apply.
- With `approval_policy = "never"`, a matching typed `ask` is forbidden because
the required prompt cannot be shown.
Runtime adapters may transport an approval decision differently from the
interactive modal. That transport and any continuation protocol are separate
from this ordering contract; see [Runtime API](RUNTIME_API.md).
## Project overlays
The project config overlay at `<workspace>/.codewhale/config.toml` is not a
permission-rule layer. It can only move approval and sandbox posture toward
more restrictive values:
- approval: `auto` → `on-request`/`untrusted` → `never`;
- sandbox: `danger-full-access` → `workspace-write` → `read-only`;
- shell availability: `true` may become `false`, never the reverse.
Project config cannot add credentials, hooks, provider authority, or a
project-local `permissions.toml`. See
[Configuration](CONFIGURATION.md#per-project-overlay-485) for the complete
overlay allow-list.
## Regression coverage
The contract is exercised by tests at the layers that own each decision:
- `authorization_order_contract_matches_documented_precedence` covers hard
prefix denial and the typed `layer → action → specificity → approval-mode`
sequence through the public execution-policy API.
- `hook_fold_deny_wins_over_ask_and_allow` covers foreground hook folding.
- `non_bypassable_registered_tools_block_without_prompt_in_full_access` covers
registered holds.
- `full_access_permission_allow_cannot_bypass_background_catastrophic_floor`
and `full_access_permission_allow_cannot_bypass_repo_law` cover later safety
layers overriding a remembered allow.
- `project_merge_only_tightens_approval_and_sandbox_policy` covers project
overlay monotonicity.
Focused commands:
```bash
cargo test -p codewhale-execpolicy --test authorization_order --locked
cargo test -p codewhale-tui --bin codewhale-tui --locked full_access_permission_allow_cannot_bypass
cargo test -p codewhale-config --locked project_merge_only_tightens_approval_and_sandbox_policy
```
## Related references
- [Configuration](CONFIGURATION.md) — rule schema, `/permissions`, hooks, and
project overlays
- [Modes](MODES.md) — Plan/Act/Operate and permission posture
- [Sandbox threat model](SANDBOX.md) — platform enforcement and fallbacks
- [Runtime API](RUNTIME_API.md) — approval events and remote resolution
+4
View File
@@ -1446,6 +1446,10 @@ If you are upgrading from older releases:
with process-tree containment only and must not be described as read-only
filesystem isolation, workspace-write enforcement, network blocking,
registry isolation, or AppContainer isolation until those are implemented.
- The cross-layer relationship between mode admission, hooks, registered tool
requirements, typed rules, auto-review, repo law, human approval, and the
execution sandbox is defined in
[Authorization order](AUTHORIZATION_ORDER.md).
- `permissions.toml` (sibling file, optional): typed permission rule records
loaded next to `config.toml`, for example `~/.codewhale/permissions.toml`.
This active user file is the only permission-rule source today; project
+4 -1
View File
@@ -158,7 +158,10 @@ the way out instead of disappearing with the alternate screen.
## Permission Posture
Permission posture controls tool approval and whether a turn may pause for a
missing user decision. Cycle it with `Shift+Tab`, or edit it at runtime:
missing user decision. It is one layer of the full
[authorization order](AUTHORIZATION_ORDER.md), not a bypass for tool admission,
repository law, or sandbox enforcement. Cycle it with `Shift+Tab`, or edit it
at runtime:
```text
/config
+3
View File
@@ -682,6 +682,9 @@ not persisted in `TurnRecord`.
are auto-approved in the non-interactive runtime path, shell safety checks
run in auto-approved mode, and spawned sub-agents inherit that setting.
- When omitted, `auto_approve` defaults to `false`.
- [Authorization order](AUTHORIZATION_ORDER.md) describes where typed rules,
registered tool requirements, safety floors, repository law, approval
transport, and sandbox enforcement sit relative to one another.
### SSE event stream
+2
View File
@@ -6,6 +6,8 @@ controls: an approval is not a sandbox, and selecting `workspace-write` does
not prove that the current platform has an OS wrapper available.
This document describes only behavior wired into the command execution path.
See [Authorization order](AUTHORIZATION_ORDER.md) for the policy layers that
run before execution reaches this boundary.
## Platform overview