Skip to content

feat(execpolicy): wire ask-only permissions into runtime#2885

Closed
greyfreedom wants to merge 1 commit into
Hmbown:mainfrom
greyfreedom:feat/permissions-ask-engine-wiring
Closed

feat(execpolicy): wire ask-only permissions into runtime#2885
greyfreedom wants to merge 1 commit into
Hmbown:mainfrom
greyfreedom:feat/permissions-ask-engine-wiring

Conversation

@greyfreedom

@greyfreedom greyfreedom commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Builds on the harvested ask-only permissions.toml schema/loading work from #2404 (3df018994 plus maintainer follow-up 69cff9375) 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

  • Convert loaded PermissionsToml records into a user-layer ExecPolicyEngine.
  • Use the loaded exec policy engine when building app-server runtime state.
  • Make typed ask rules require approval in approval modes that can prompt.
  • Treat matching ask rules as forbidden under approval_policy = "never".
  • Preserve deny priority and make ask rules override trusted-prefix auto-skip.
  • Pass simple path fields from function/MCP tool payloads into ExecPolicyContext.
  • Add tests for runtime wiring, ask/trust/deny precedence, reject behavior, and path extraction.

Non-goals

  • No typed allow/deny records.
  • No approval UI persistence.
  • No glob expansion.
  • No patch/diff path extraction.

Related discussion:

Validation

  • cargo fmt --all
  • cargo test -p codewhale-execpolicy --all-features
  • cargo test -p codewhale-config --all-features
  • cargo test -p codewhale-core --all-features
  • cargo test -p codewhale-app-server --all-features
  • cargo clippy -p codewhale-execpolicy -p codewhale-config -p codewhale-core -p codewhale-app-server --all-targets --all-features -- -D warnings

Greptile 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 under approval_policy = "never", overriding trusted-prefix auto-skip.

  • ExecPolicyEngine match arm reordering: ask rules now intercept all approval modes (not just Never), with Reject { rules: true } checked first and UnlessTrusted/OnFailure auto-skip arms now only reachable when no ask rule matches.
  • Layer-priority selection for ask rules: 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 the path argument from Function/MCP payloads for path-based ask-rule matching; Custom and LocalShell payloads return None.

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

Filename Overview
crates/execpolicy/src/lib.rs Core logic refactored to wire ask rules into runtime: match arm reordering now makes ask rules trigger approval in all approval modes (not just Never), layer priority correctly uses max_by_key(layer, specificity). The new ask-rule NeedsApproval arm incorrectly uses ctx.cwd as a NetworkPolicyAmendment host — a filesystem path, not a network host.
crates/config/src/lib.rs Adds PermissionsToml::ruleset() and ConfigStore::exec_policy_engine(), cleanly bridging the loaded permissions into the execpolicy layer. The branch on is_empty() to pick the legacy constructor is a minor inconsistency but functionally equivalent.
crates/app-server/src/lib.rs Replaces empty ExecPolicyEngine::new() with store.exec_policy_engine() wiring the loaded permissions into runtime state. Integration test correctly exercises the full policy-check path.
crates/core/src/lib.rs Adds permission_path_for_call() to extract the path argument from Function/MCP payloads and passes it into ExecPolicyContext. Logic is straightforward and tested; silently ignoring malformed JSON arguments is a safe fallback.
config.example.toml Doc comment updated to reflect that loaded ask rules now feed the execution policy engine. Wording matches the actual behavior.
docs/CONFIGURATION.md Documentation updated to describe the new ask-rule runtime behavior (approval in promptable modes, forbidden under approval_policy=never). Accurate and concise.

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]
Loading

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (1): Last reviewed commit: "feat(execpolicy): wire typed ask permiss..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

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 .github/APPROVED_CONTRIBUTORS will be closed automatically.

Please read CONTRIBUTING.md for the expected contribution shape. A maintainer can grant PR access by commenting /lgtm on a pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +327 to 330
.filter(|(_, rule)| match (rule.path.as_deref(), ctx.path) {
(Some(pattern), Some(path)) => {
normalize_path_value(pattern) == normalize_path_value(path)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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.

Comment on lines +407 to +410
proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
host: ctx.cwd.to_string(),
action: NetworkPolicyRuleAction::Allow,
}],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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![],

Comment thread crates/config/src/lib.rs
Comment on lines +2814 to +2823
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);

Comment on lines +402 to +412
_ 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,
}],
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Codex Fix in Claude Code Fix in Cursor

Comment on lines +399 to +401
AskForApproval::Reject { rules, .. } if *rules => ExecApprovalRequirement::Forbidden {
reason: "Policy is configured to reject rule-exceptions.".to_string(),
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

Fix in Codex Fix in Claude Code Fix in Cursor

Hmbown pushed a commit that referenced this pull request Jun 7, 2026
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>
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

Thanks @greyfreedom — your contribution landed in 17dbed13c71e on main:

feat(execpolicy): wire permissions.toml ask-rules into runtime

Closing this PR now that the code is on main. Credit lives in the commit message and (where applicable) the CHANGELOG.md entry for the next release. Apologies for not closing this at the time of the merge — the auto-close workflow is new in v0.8.31.

If you want to land more work and would prefer your future PRs merge cleanly without a harvest step, the CONTRIBUTING.md doc has a short note on what makes a contribution mergeable as-is.

@github-actions github-actions Bot closed this Jun 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant