{"record":{"id":"f8c147692ab17766","repo":"Hmbown/CodeWhale","slug":"persistent-allow-rules-must-be-scoped-to-a-workspa","errorCode":null,"errorMessage":"persistent allow rules must be scoped to a workspace","messagePattern":"persistent allow rules must be scoped to a workspace","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/config/src/lib.rs","lineNumber":5261,"sourceCode":"    }\n\n    /// Atomically append exact, repo-scoped allow rules to the sibling\n    /// `permissions.toml` file.\n    ///\n    /// The caller is responsible for deciding which tool calls are eligible;\n    /// this boundary rejects broad or incorrectly typed records so a UI bug\n    /// cannot persist an unscoped allow grant.\n    pub fn append_allow_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {\n        for rule in rules {\n            if rule.action != PermissionAction::Allow {\n                bail!(\"append_allow_rules only accepts action = \\\"allow\\\"\");\n            }\n            let Some(workspace) = rule\n                .workspace\n                .as_deref()\n                .and_then(codewhale_execpolicy::normalize_workspace_scope)\n            else {\n                bail!(\"persistent allow rules must be scoped to a workspace\");\n            };\n            if rule.command.is_some() && !rule.command_exact {\n                bail!(\"persistent command allow rules must use exact matching\");\n            }\n            if rule.command.is_none() && rule.path.is_none() {\n                bail!(\"persistent allow rules must match an exact command or path\");\n            }\n            if let Some(command) = rule.command.as_deref()\n                && command.trim().is_empty()\n            {\n                bail!(\"persistent command allow rules must not be empty\");\n            }\n            if let Some(path) = rule.path.as_deref()\n                && codewhale_execpolicy::normalize_workspace_relative_path(path, &workspace)\n                    .is_none_or(|path| path.is_empty())\n            {\n                bail!(\"persistent path allow rules must stay within the workspace\");\n            }","sourceCodeStart":5243,"sourceCodeEnd":5279,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/0c42157ee52f9d55af2b506d71b46249910f77d3/crates/config/src/lib.rs#L5243-L5279","documentation":"append_allow_rules (crates/config/src/lib.rs:5261) refuses to persist an allow rule whose workspace field is missing or fails codewhale_execpolicy::normalize_workspace_scope. Persistent allow grants must be scoped to one workspace so a UI bug can never write a global, unscoped allow. Session-scoped rules bypass this path entirely.","triggerScenarios":"Constructing a ToolAskRule with action=Allow and workspace=None (or an empty/malformed workspace value) and passing it to append_allow_rules; typically a caller that builds rules by hand instead of via the permission-prompt flow.","commonSituations":"Custom UI/automation persisting 'always allow' decisions without capturing the current workspace root, deserialized rules from another machine losing the workspace field, refactors that drop the field.","solutions":["Set rule.workspace to the absolute path of the workspace the decision was made in before appending","Use the same construction path as the interactive permission prompt (it fills workspace automatically)","Pre-validate with normalize_workspace_scope and surface a form error instead of hitting the bail"],"exampleFix":"// before\nlet rule = ToolAskRule { action: PermissionAction::Allow, tool: tool.clone(), workspace: None, /* ... */ };\nconfig.append_allow_rules(&[rule])?; // -> must be scoped to a workspace\n\n// after\nlet rule = ToolAskRule { action: PermissionAction::Allow, tool: tool.clone(), workspace: Some(workspace_root.display().to_string()), /* ... */ };\nconfig.append_allow_rules(&[rule])?;","handlingStrategy":"validation","validationCode":"use codewhale_execpolicy::normalize_workspace_scope;\nlet Some(ws) = rule.workspace.as_deref().and_then(normalize_workspace_scope) else {\n    /* refuse before append: fill rule.workspace with the current workspace root */\n    unreachable!()\n};","typeGuard":"fn is_persistable_allow_rule(rule: &ToolAskRule) -> bool {\n    rule.action == PermissionAction::Allow\n        && rule.workspace.as_deref().and_then(normalize_workspace_scope).is_some()\n}","tryCatchPattern":"if let Err(e) = config.append_allow_rules(&[rule]) {\n    if e.to_string().contains(\"scoped to a workspace\") { /* re-prompt with workspace filled */ }\n    else { return Err(e); }\n}","preventionTips":["Always populate workspace from the session's workspace root when persisting approvals","Never hand-construct persistent allow rules without running the validation set first","Keep blanket 'always allow tool' decisions session-scoped"],"tags":["rust","permissions","security","validation","workspace"],"backgroundTag":"permission-rule-validation-failed","analyzedSha":"0c42157ee52f9d55af2b506d71b46249910f77d3","analyzedAt":"2026-08-20T21:50:45.477Z","schemaVersion":2},"datasetVersion":"2026-08-21T13:17:26.733Z"}