feat(execpolicy): wire ask-only permissions into runtime#2885
feat(execpolicy): wire ask-only permissions into runtime#2885greyfreedom wants to merge 1 commit into
Conversation
|
Thanks @greyfreedom for taking the time to contribute. This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in Please read |
There was a problem hiding this comment.
Code Review
This pull request integrates the execution policy engine with the permissions.toml configuration, enabling the extraction of target paths from tool calls and evaluating them against layered rulesets. While the overall implementation is solid and well-tested, there are a few critical issues to address: a security vulnerability in path normalization that could allow policy bypasses, an incorrect network policy amendment using a directory path as a host, and a test that should use tempfile for reliable cleanup.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| .filter(|(_, rule)| match (rule.path.as_deref(), ctx.path) { | ||
| (Some(pattern), Some(path)) => { | ||
| normalize_path_value(pattern) == normalize_path_value(path) | ||
| } |
There was a problem hiding this comment.
The path comparison here is highly vulnerable to bypasses because normalize_path_value only performs basic string replacements and trimming. It does not resolve relative path components (like . or ..), duplicate slashes (like //), or absolute vs. relative path differences.\n\nFor example, if a rule blocks secrets/api_key.txt, an attacker or model can bypass this check by using:\n- ./secrets/api_key.txt\n- secrets//api_key.txt\n- /workspace/secrets/api_key.txt (if the workspace is at /workspace)\n\nTo prevent these bypasses, paths should be resolved to absolute paths (e.g., by joining relative paths with ctx.cwd or the workspace root) and then normalized/cleaned (using path_clean::clean or std::path::Path::canonicalize if the files exist) before comparison.
| proposed_network_policy_amendments: vec![NetworkPolicyAmendment { | ||
| host: ctx.cwd.to_string(), | ||
| action: NetworkPolicyRuleAction::Allow, | ||
| }], |
There was a problem hiding this comment.
Proposing a NetworkPolicyAmendment with host set to ctx.cwd (which is a directory path like \"/workspace\") for a matched ask_rule is incorrect. \n\nFirst, ctx.cwd is a local directory path, not a network host.\nSecond, ask_rule can match non-network tools (such as file tools like read_file or edit_file), where proposing any network policy amendment is completely unnecessary and will result in confusing UI prompts.\n\nFor ask_rule matches, proposed_network_policy_amendments should be empty (vec![]).
proposed_network_policy_amendments: vec![],| let unique = SystemTime::now() | ||
| .duration_since(UNIX_EPOCH) | ||
| .expect("clock") | ||
| .as_nanos(); | ||
| let dir = std::env::temp_dir().join(format!( | ||
| "codewhale-permissions-engine-{}-{unique}", | ||
| std::process::id() | ||
| )); | ||
| fs::create_dir_all(&dir).expect("mkdir"); | ||
| let config_path = dir.join(CONFIG_FILE_NAME); |
There was a problem hiding this comment.
Using manual directory creation with SystemTime and std::process::id() is prone to leaving leftover directories in the temporary folder if the test panics or fails before fs::remove_dir_all is reached.\n\nSince tempfile is already a dependency in the workspace (used in crates/app-server), it is highly recommended to add it as a dev-dependency to crates/config and use tempfile::tempdir() here. This ensures automatic cleanup even on test failures. Note that you should also remove the manual cleanup line let _ = fs::remove_dir_all(dir); at the end of the test.
let tmp = tempfile::tempdir().expect("tempdir");\n let config_path = tmp.path().join(CONFIG_FILE_NAME);| _ if let Some(rule) = &ask_rule => { | ||
| matched_ask_rule = Some(rule.label()); | ||
| ExecApprovalRequirement::NeedsApproval { | ||
| reason: format!("Typed ask rule '{}' requires approval.", rule.label()), | ||
| proposed_execpolicy_amendment: None, | ||
| proposed_network_policy_amendments: vec![NetworkPolicyAmendment { | ||
| host: ctx.cwd.to_string(), | ||
| action: NetworkPolicyRuleAction::Allow, | ||
| }], | ||
| } | ||
| } |
There was a problem hiding this comment.
Filesystem path used as network policy host
ctx.cwd is a filesystem directory (e.g., /workspace), but NetworkPolicyAmendment.host is documented as a network host. For file-access ask rules (e.g., edit_file with a path field), this proposes an amendment to allow the network host /workspace, which is syntactically invalid and would be presented to the user as a confusing or broken approval prompt. The pre-existing _ fallback also does this, but the ask-rule branch is new and can now be reached by non-shell tools. The proposed_network_policy_amendments field should either be empty (vec![]) for ask-rule matches, or populated with an actual resolved hostname extracted from the tool context.
| AskForApproval::Reject { rules, .. } if *rules => ExecApprovalRequirement::Forbidden { | ||
| reason: "Policy is configured to reject rule-exceptions.".to_string(), | ||
| }, |
There was a problem hiding this comment.
matched_rule is silently None when Reject { rules: true } wins over a matching ask rule
The Reject { rules, .. } if *rules arm fires before the ask-rule arm, so matched_ask_rule is never set. The resulting ExecPolicyDecision exposes matched_rule: None even though a typed ask rule was the semantic cause of the rejection. This makes it harder to diagnose why a command was blocked. The test reject_rules_mode_still_forbids_matching_ask_rule explicitly asserts this behavior, but consider whether users should see the matching rule label for debuggability.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Harvested from PR #2885 by @greyfreedom. Wires ask-rules into the app-server and core ExecPolicyEngine (previously inert). Removes the original PR's NeedsApproval arm that incorrectly allow-listed the working directory as a network host. Co-Authored-By: greyfreedom <11493871+greyfreedom@users.noreply.github.com>
|
Thanks @greyfreedom — your contribution landed in
Closing this PR now that the code is on If you want to land more work and would prefer your future PRs merge cleanly without a harvest step, the |
Summary
Builds on the harvested ask-only
permissions.tomlschema/loading work from #2404 (3df018994plus maintainer follow-up69cff9375) and wires typed ask permission records into the runtime execution policy path.This keeps the next slice intentionally narrow: ask-only rules are now executable policy input, while typed allow/deny behavior and approval UI persistence remain out of scope.
What changed
PermissionsTomlrecords into a user-layerExecPolicyEngine.approval_policy = "never".pathfields from function/MCP tool payloads intoExecPolicyContext.Non-goals
Related discussion:
Validation
cargo fmt --allcargo test -p codewhale-execpolicy --all-featurescargo test -p codewhale-config --all-featurescargo test -p codewhale-core --all-featurescargo test -p codewhale-app-server --all-featurescargo clippy -p codewhale-execpolicy -p codewhale-config -p codewhale-core -p codewhale-app-server --all-targets --all-features -- -D warningsGreptile Summary
This PR wires ask-only permission rules (loaded from
permissions.toml) into the runtime execution policy path. Typed ask rules now force approval in all promptable approval modes and are forbidden underapproval_policy = "never", overriding trusted-prefix auto-skip.ExecPolicyEnginematch arm reordering: ask rules now intercept all approval modes (not justNever), withReject { rules: true }checked first andUnlessTrusted/OnFailureauto-skip arms now only reachable when no ask rule matches.max_by_key((layer, specificity))means a less-specific user-layer rule will beat a more-specific agent-layer rule — intentional per the added test but worth being aware of.permission_path_for_call: extracts thepathargument fromFunction/MCPpayloads for path-based ask-rule matching;CustomandLocalShellpayloads returnNone.Confidence Score: 3/5
The runtime wiring and match-arm reordering are correct overall, but the ask-rule approval branch produces a NetworkPolicyAmendment whose host field is populated with a filesystem path — an invalid network host — which would surface as a malformed or confusing approval prompt, particularly for file-access tools.
The match-arm reordering, layer-priority selection, and config-to-engine bridging all look correct and are well-tested. The concrete defect is in the new ask-rule NeedsApproval arm: it copies the existing pattern of using ctx.cwd as the NetworkPolicyAmendment host, but applies it to all ask-rule matches including file-access tools where a cwd path can never be a valid network host. The test suite verifies proposed_execpolicy_amendment using .. to ignore other fields, so the bad network amendment goes uncaught.
crates/execpolicy/src/lib.rs — the NeedsApproval arm for ask rules (lines ~402–412) needs the proposed_network_policy_amendments corrected or removed.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[invoke_tool called] --> B[permission_path_for_call\nextract path from args] B --> C[exec_policy.check\nExecPolicyContext] C --> D{deny prefix\nmatch?} D -- yes --> E[Forbidden\nmatched_rule = deny prefix] D -- no --> F{matching\nask rule?} F -- no --> G{AskForApproval mode} F -- yes --> H{AskForApproval::Never?} H -- yes --> I[Forbidden\nmatched_rule = ask rule label] H -- no --> J{Reject rules=true?} J -- yes --> K[Forbidden\nmatched_rule = None] J -- no --> L[NeedsApproval\nmatched_rule = ask rule label] G --> M{Never?} M -- yes --> N[Skip] G --> O{Reject rules=true?} O -- yes --> P[Forbidden] G --> Q{UnlessTrusted + trusted?} Q -- yes --> R[Skip] G --> S{OnFailure?} S -- yes --> T[Skip] G --> U[NeedsApproval]Reviews (1): Last reviewed commit: "feat(execpolicy): wire typed ask permiss..." | Re-trigger Greptile