agent-action-policy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ *.egg
10
+ .eggs/
11
+
12
+ CLAUDE.md
13
+ PLAN.txt
14
+ .env
15
+ .env.local
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 QuartzUnit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-action-policy
3
+ Version: 0.1.0
4
+ Summary: Declarative action policies for AI agents — approve, deny, or escalate any tool call before execution
5
+ Project-URL: Homepage, https://github.com/QuartzUnit/agent-action-policy
6
+ Project-URL: Repository, https://github.com/QuartzUnit/agent-action-policy
7
+ Project-URL: Issues, https://github.com/QuartzUnit/agent-action-policy/issues
8
+ Author-email: hmj <hmj@quartzunit.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,ai-agent,guardrail,llm,policy,safety,security,tool-use
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Security
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Provides-Extra: yaml
26
+ Requires-Dist: pyyaml>=6.0; extra == 'yaml'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # agent-action-policy
30
+
31
+ Declarative action policies for AI agents — approve, deny, or escalate any tool call before execution.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install agent-action-policy
37
+ pip install agent-action-policy[yaml] # for YAML policy files
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ```python
43
+ from action_policy import PolicyEngine, Action
44
+
45
+ engine = PolicyEngine.from_dict({
46
+ "policies": [{
47
+ "name": "no-force-push",
48
+ "match": {"tool": "bash", "args_pattern": "git push --force"},
49
+ "action": "deny",
50
+ "reason": "Force push requires human approval",
51
+ }]
52
+ })
53
+
54
+ decision = engine.evaluate(tool="bash", args={"command": "git push --force origin main"})
55
+ print(decision.denied) # True
56
+ print(decision.reason) # "Force push requires human approval"
57
+ ```
58
+
59
+ ## Sandboxing vs Policy
60
+
61
+ | | Sandboxing (containers) | Policy (this library) |
62
+ |---|---|---|
63
+ | **Controls** | *Where* code runs | *What* the agent can do |
64
+ | **Granularity** | Process-level | Per-tool-call |
65
+ | **Configuration** | Infrastructure | YAML/Python |
66
+ | **Use with** | Any runtime | Any agent framework |
67
+
68
+ Sandboxing and policies are complementary. Use both.
69
+
70
+ ## Policy Definition (YAML)
71
+
72
+ ```yaml
73
+ policies:
74
+ - name: no-destructive-git
75
+ match:
76
+ tool: bash
77
+ args_pattern: "git (push --force|reset --hard|branch -D)"
78
+ action: deny
79
+ reason: "Destructive git operations require human approval"
80
+
81
+ - name: escalate-system-files
82
+ match:
83
+ tool: "~(file_write|write_file)"
84
+ path_patterns:
85
+ - "/etc/*"
86
+ - "/usr/*"
87
+ action: escalate
88
+ reason: "System file modification needs confirmation"
89
+
90
+ - name: approve-reads
91
+ match:
92
+ tool: "~(read|search|grep)"
93
+ action: approve
94
+ priority: 10 # lower = higher priority
95
+ ```
96
+
97
+ ## Built-in Templates
98
+
99
+ ```python
100
+ engine = PolicyEngine.from_template("safe_coding")
101
+ ```
102
+
103
+ | Template | What it protects |
104
+ |----------|-----------------|
105
+ | `safe_coding` | Blocks force-push, rm -rf, system file writes, credential access, hook skipping |
106
+ | `safe_browsing` | Blocks internal URLs, file:// protocol, escalates downloads |
107
+ | `safe_database` | Blocks DDL (DROP/TRUNCATE), escalates DELETE and WHERE-less UPDATE |
108
+ | `strict` | Whitelist mode — only read operations allowed, everything else denied |
109
+
110
+ ## Python API
111
+
112
+ ```python
113
+ # From YAML file
114
+ engine = PolicyEngine.from_yaml("policies.yaml")
115
+
116
+ # From dict
117
+ engine = PolicyEngine.from_dict({"policies": [...]})
118
+
119
+ # From template
120
+ engine = PolicyEngine.from_template("safe_coding")
121
+
122
+ # Evaluate
123
+ decision = engine.evaluate(tool="bash", args={"command": "rm -rf /"})
124
+ decision.action # Action.DENY
125
+ decision.denied # True
126
+ decision.reason # "..."
127
+ decision.policy_name # "no-rm-rf"
128
+
129
+ # Fail-closed mode (deny by default)
130
+ engine = PolicyEngine.from_template("strict", default_action=Action.DENY)
131
+
132
+ # Decorator
133
+ @engine.guard
134
+ def execute_tool(tool: str, args: dict = None):
135
+ ... # raises PolicyDenied or PolicyEscalated
136
+ ```
137
+
138
+ ## Pattern Matching
139
+
140
+ | Pattern type | Syntax | Example |
141
+ |-------------|--------|---------|
142
+ | Exact match | `tool_name` | `"bash"` |
143
+ | Glob | `*`, `?`, `[...]` | `"file_*"` |
144
+ | Regex | `~pattern` | `"~(bash\|shell\|exec)"` |
145
+ | Args regex | any regex | `"git\\s+push\\s+--force"` |
146
+ | Path glob | glob or `~regex` | `"/etc/*"`, `"~\\.env$"` |
147
+
148
+ ## License
149
+
150
+ MIT
@@ -0,0 +1,122 @@
1
+ # agent-action-policy
2
+
3
+ Declarative action policies for AI agents — approve, deny, or escalate any tool call before execution.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install agent-action-policy
9
+ pip install agent-action-policy[yaml] # for YAML policy files
10
+ ```
11
+
12
+ ## Quick Start
13
+
14
+ ```python
15
+ from action_policy import PolicyEngine, Action
16
+
17
+ engine = PolicyEngine.from_dict({
18
+ "policies": [{
19
+ "name": "no-force-push",
20
+ "match": {"tool": "bash", "args_pattern": "git push --force"},
21
+ "action": "deny",
22
+ "reason": "Force push requires human approval",
23
+ }]
24
+ })
25
+
26
+ decision = engine.evaluate(tool="bash", args={"command": "git push --force origin main"})
27
+ print(decision.denied) # True
28
+ print(decision.reason) # "Force push requires human approval"
29
+ ```
30
+
31
+ ## Sandboxing vs Policy
32
+
33
+ | | Sandboxing (containers) | Policy (this library) |
34
+ |---|---|---|
35
+ | **Controls** | *Where* code runs | *What* the agent can do |
36
+ | **Granularity** | Process-level | Per-tool-call |
37
+ | **Configuration** | Infrastructure | YAML/Python |
38
+ | **Use with** | Any runtime | Any agent framework |
39
+
40
+ Sandboxing and policies are complementary. Use both.
41
+
42
+ ## Policy Definition (YAML)
43
+
44
+ ```yaml
45
+ policies:
46
+ - name: no-destructive-git
47
+ match:
48
+ tool: bash
49
+ args_pattern: "git (push --force|reset --hard|branch -D)"
50
+ action: deny
51
+ reason: "Destructive git operations require human approval"
52
+
53
+ - name: escalate-system-files
54
+ match:
55
+ tool: "~(file_write|write_file)"
56
+ path_patterns:
57
+ - "/etc/*"
58
+ - "/usr/*"
59
+ action: escalate
60
+ reason: "System file modification needs confirmation"
61
+
62
+ - name: approve-reads
63
+ match:
64
+ tool: "~(read|search|grep)"
65
+ action: approve
66
+ priority: 10 # lower = higher priority
67
+ ```
68
+
69
+ ## Built-in Templates
70
+
71
+ ```python
72
+ engine = PolicyEngine.from_template("safe_coding")
73
+ ```
74
+
75
+ | Template | What it protects |
76
+ |----------|-----------------|
77
+ | `safe_coding` | Blocks force-push, rm -rf, system file writes, credential access, hook skipping |
78
+ | `safe_browsing` | Blocks internal URLs, file:// protocol, escalates downloads |
79
+ | `safe_database` | Blocks DDL (DROP/TRUNCATE), escalates DELETE and WHERE-less UPDATE |
80
+ | `strict` | Whitelist mode — only read operations allowed, everything else denied |
81
+
82
+ ## Python API
83
+
84
+ ```python
85
+ # From YAML file
86
+ engine = PolicyEngine.from_yaml("policies.yaml")
87
+
88
+ # From dict
89
+ engine = PolicyEngine.from_dict({"policies": [...]})
90
+
91
+ # From template
92
+ engine = PolicyEngine.from_template("safe_coding")
93
+
94
+ # Evaluate
95
+ decision = engine.evaluate(tool="bash", args={"command": "rm -rf /"})
96
+ decision.action # Action.DENY
97
+ decision.denied # True
98
+ decision.reason # "..."
99
+ decision.policy_name # "no-rm-rf"
100
+
101
+ # Fail-closed mode (deny by default)
102
+ engine = PolicyEngine.from_template("strict", default_action=Action.DENY)
103
+
104
+ # Decorator
105
+ @engine.guard
106
+ def execute_tool(tool: str, args: dict = None):
107
+ ... # raises PolicyDenied or PolicyEscalated
108
+ ```
109
+
110
+ ## Pattern Matching
111
+
112
+ | Pattern type | Syntax | Example |
113
+ |-------------|--------|---------|
114
+ | Exact match | `tool_name` | `"bash"` |
115
+ | Glob | `*`, `?`, `[...]` | `"file_*"` |
116
+ | Regex | `~pattern` | `"~(bash\|shell\|exec)"` |
117
+ | Args regex | any regex | `"git\\s+push\\s+--force"` |
118
+ | Path glob | glob or `~regex` | `"/etc/*"`, `"~\\.env$"` |
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agent-action-policy"
7
+ version = "0.1.0"
8
+ description = "Declarative action policies for AI agents — approve, deny, or escalate any tool call before execution"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "hmj", email = "hmj@quartzunit.com" }]
13
+ keywords = [
14
+ "agent",
15
+ "policy",
16
+ "safety",
17
+ "guardrail",
18
+ "tool-use",
19
+ "llm",
20
+ "ai-agent",
21
+ "security",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Intended Audience :: Developers",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.9",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
34
+ "Topic :: Security",
35
+ "Typing :: Typed",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ yaml = ["pyyaml>=6.0"]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/QuartzUnit/agent-action-policy"
43
+ Repository = "https://github.com/QuartzUnit/agent-action-policy"
44
+ Issues = "https://github.com/QuartzUnit/agent-action-policy/issues"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/action_policy"]
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+
52
+ [tool.ruff]
53
+ line-length = 120
54
+ target-version = "py39"
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "I", "W", "UP"]
@@ -0,0 +1,18 @@
1
+ """agent-action-policy — Declarative action policies for AI agents.
2
+
3
+ Approve, deny, or escalate any tool call before execution.
4
+ """
5
+
6
+ from action_policy.decision import Action, Decision
7
+ from action_policy.engine import PolicyDenied, PolicyEngine, PolicyEscalated
8
+ from action_policy.policy import PolicyRule
9
+
10
+ __all__ = [
11
+ "Action",
12
+ "Decision",
13
+ "PolicyDenied",
14
+ "PolicyEngine",
15
+ "PolicyEscalated",
16
+ "PolicyRule",
17
+ ]
18
+ __version__ = "0.1.0"
@@ -0,0 +1,39 @@
1
+ """Decision types for policy evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum, auto
7
+
8
+
9
+ class Action(Enum):
10
+ """Policy evaluation result."""
11
+
12
+ APPROVE = auto()
13
+ DENY = auto()
14
+ ESCALATE = auto()
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Decision:
19
+ """Result of a policy evaluation."""
20
+
21
+ action: Action
22
+ policy_name: str = ""
23
+ reason: str = ""
24
+
25
+ @property
26
+ def approved(self) -> bool:
27
+ return self.action == Action.APPROVE
28
+
29
+ @property
30
+ def denied(self) -> bool:
31
+ return self.action == Action.DENY
32
+
33
+ @property
34
+ def escalated(self) -> bool:
35
+ return self.action == Action.ESCALATE
36
+
37
+
38
+ # Singleton for default approve (no policy matched)
39
+ APPROVE_DEFAULT = Decision(action=Action.APPROVE, policy_name="", reason="No matching policy — default approve")
@@ -0,0 +1,123 @@
1
+ """Core PolicyEngine — evaluate tool calls against policies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ from action_policy.decision import APPROVE_DEFAULT, Action, Decision
10
+ from action_policy.loader import load_policies_from_dict, load_policies_from_yaml
11
+ from action_policy.policy import PolicyRule
12
+
13
+
14
+ class PolicyEngine:
15
+ """Declarative action policy engine for AI agents.
16
+
17
+ Evaluates tool calls against a set of policies and returns
18
+ APPROVE, DENY, or ESCALATE decisions.
19
+
20
+ Usage:
21
+ engine = PolicyEngine.from_yaml("policies.yaml")
22
+ decision = engine.evaluate(tool="bash", args={"command": "rm -rf /"})
23
+ # -> Decision(action=DENY, policy_name="no-destructive-bash", ...)
24
+
25
+ Default behavior (no matching policy): APPROVE (open by default).
26
+ Use `default_action=Action.DENY` for fail-closed mode.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ policies: list[PolicyRule] | None = None,
32
+ default_action: Action = Action.APPROVE,
33
+ default_reason: str = "",
34
+ ):
35
+ self._policies = sorted(policies or [], key=lambda p: p.priority)
36
+ self._default_action = default_action
37
+ self._default_reason = default_reason or (
38
+ "No matching policy — default approve" if default_action == Action.APPROVE
39
+ else "No matching policy — default deny (fail-closed)"
40
+ )
41
+
42
+ @classmethod
43
+ def from_yaml(cls, path: str | Path, **kwargs: Any) -> PolicyEngine:
44
+ """Load policies from a YAML file."""
45
+ policies = load_policies_from_yaml(path)
46
+ return cls(policies=policies, **kwargs)
47
+
48
+ @classmethod
49
+ def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> PolicyEngine:
50
+ """Load policies from a dict."""
51
+ policies = load_policies_from_dict(data)
52
+ return cls(policies=policies, **kwargs)
53
+
54
+ @classmethod
55
+ def from_template(cls, template_name: str, **kwargs: Any) -> PolicyEngine:
56
+ """Load a built-in policy template.
57
+
58
+ Available templates: safe_coding, safe_browsing, safe_database, strict
59
+ """
60
+ templates_dir = Path(__file__).parent / "templates"
61
+ path = templates_dir / f"{template_name}.yaml"
62
+ if not path.exists():
63
+ available = [f.stem for f in templates_dir.glob("*.yaml")]
64
+ raise ValueError(f"Unknown template '{template_name}'. Available: {available}")
65
+ return cls.from_yaml(path, **kwargs)
66
+
67
+ def evaluate(self, tool: str, args: dict[str, Any] | str | None = None) -> Decision:
68
+ """Evaluate a tool call against all policies.
69
+
70
+ First matching policy wins (ordered by priority).
71
+ """
72
+ for policy in self._policies:
73
+ if policy.matches(tool, args):
74
+ return policy.to_decision()
75
+
76
+ if self._default_action == Action.APPROVE:
77
+ return APPROVE_DEFAULT
78
+ return Decision(
79
+ action=self._default_action,
80
+ policy_name="default",
81
+ reason=self._default_reason,
82
+ )
83
+
84
+ def guard(self, func: Callable) -> Callable:
85
+ """Decorator that checks policy before executing the wrapped function.
86
+
87
+ The function must accept `tool` as its first argument and `args` as keyword.
88
+ Raises PolicyDenied if the policy denies the action.
89
+ """
90
+ @functools.wraps(func)
91
+ def wrapper(tool: str, args: dict | str | None = None, **kwargs: Any) -> Any:
92
+ decision = self.evaluate(tool, args)
93
+ if decision.denied:
94
+ raise PolicyDenied(decision)
95
+ if decision.escalated:
96
+ raise PolicyEscalated(decision)
97
+ return func(tool, args=args, **kwargs)
98
+ return wrapper
99
+
100
+ @property
101
+ def policies(self) -> list[PolicyRule]:
102
+ return list(self._policies)
103
+
104
+ def add_policy(self, policy: PolicyRule) -> None:
105
+ """Add a policy and re-sort by priority."""
106
+ self._policies.append(policy)
107
+ self._policies.sort(key=lambda p: p.priority)
108
+
109
+
110
+ class PolicyDenied(Exception):
111
+ """Raised when a policy denies an action."""
112
+
113
+ def __init__(self, decision: Decision):
114
+ self.decision = decision
115
+ super().__init__(f"Policy '{decision.policy_name}' denied: {decision.reason}")
116
+
117
+
118
+ class PolicyEscalated(Exception):
119
+ """Raised when a policy requires human escalation."""
120
+
121
+ def __init__(self, decision: Decision):
122
+ self.decision = decision
123
+ super().__init__(f"Policy '{decision.policy_name}' requires escalation: {decision.reason}")
@@ -0,0 +1,35 @@
1
+ """Policy loader from YAML files and dicts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from action_policy.policy import PolicyRule, policy_from_dict
9
+
10
+
11
+ def load_policies_from_yaml(path: str | Path) -> list[PolicyRule]:
12
+ """Load policies from a YAML file.
13
+
14
+ Requires PyYAML (optional dependency).
15
+ """
16
+ try:
17
+ import yaml
18
+ except ImportError as e:
19
+ raise ImportError(
20
+ "PyYAML is required for YAML loading. Install with: pip install agent-action-policy[yaml]"
21
+ ) from e
22
+
23
+ with open(path) as f:
24
+ data = yaml.safe_load(f)
25
+
26
+ return load_policies_from_dict(data)
27
+
28
+
29
+ def load_policies_from_dict(data: dict[str, Any]) -> list[PolicyRule]:
30
+ """Load policies from a dict structure (e.g., already-parsed YAML)."""
31
+ policies_data = data.get("policies", [])
32
+ policies = [policy_from_dict(p) for p in policies_data]
33
+ # Sort by priority (lower = higher priority)
34
+ policies.sort(key=lambda p: p.priority)
35
+ return policies
@@ -0,0 +1,74 @@
1
+ """Pattern matching utilities for policy rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import re
7
+ from functools import lru_cache
8
+
9
+
10
+ @lru_cache(maxsize=256)
11
+ def _compile_regex(pattern: str) -> re.Pattern:
12
+ """Compile and cache a regex pattern."""
13
+ return re.compile(pattern)
14
+
15
+
16
+ def match_tool(tool_name: str, pattern: str) -> bool:
17
+ """Match a tool name against a pattern.
18
+
19
+ Supports:
20
+ - Exact match: "bash"
21
+ - Glob: "file_*"
22
+ - Regex (prefixed with ~): "~(bash|shell|exec)"
23
+ """
24
+ if pattern.startswith("~"):
25
+ return bool(_compile_regex(pattern[1:]).search(tool_name))
26
+ if any(c in pattern for c in ("*", "?", "[")):
27
+ return fnmatch.fnmatch(tool_name, pattern)
28
+ return tool_name == pattern
29
+
30
+
31
+ def match_args(args: dict | str | None, pattern: str) -> bool:
32
+ """Match tool arguments against a pattern.
33
+
34
+ The pattern is matched against the string representation of args.
35
+ Supports:
36
+ - Regex (always): searches the stringified args
37
+ """
38
+ if args is None:
39
+ return False
40
+ text = _stringify_args(args)
41
+ try:
42
+ return bool(_compile_regex(pattern).search(text))
43
+ except re.error:
44
+ return False
45
+
46
+
47
+ def match_path(path: str, patterns: list[str]) -> bool:
48
+ """Match a file path against a list of glob/regex patterns.
49
+
50
+ Each pattern can be:
51
+ - Glob: "/etc/*", "~/.ssh/*"
52
+ - Regex (prefixed with ~): "~\\.env$"
53
+ """
54
+ for pat in patterns:
55
+ if pat.startswith("~"):
56
+ if _compile_regex(pat[1:]).search(path):
57
+ return True
58
+ elif fnmatch.fnmatch(path, pat):
59
+ return True
60
+ return False
61
+
62
+
63
+ def _stringify_args(args: dict | str | None) -> str:
64
+ """Convert args to string for pattern matching."""
65
+ if args is None:
66
+ return ""
67
+ if isinstance(args, str):
68
+ return args
69
+ if isinstance(args, dict):
70
+ parts = []
71
+ for k, v in sorted(args.items()):
72
+ parts.append(f"{k}={v}")
73
+ return " ".join(parts)
74
+ return str(args)