mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
8c6bf0e07b
commit
171f0b2a27
18 files changed
+432
-46
No files matched your search
+4
-1
@@ -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
@@ -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
@@ -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 하위
|
||||
명령은 훅을 실행하지 않음)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) — 仅限回环地址的内置浏览器客户端及其一次性身份验证边界
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user