hermes-plugin-guard 0.1.3__py3-none-any.whl

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,7 @@
1
+ """Static security checks for Hermes Agent plugins."""
2
+
3
+ from .models import Finding, ScanResult, Severity
4
+ from .scanner import scan
5
+
6
+ __all__ = ["Finding", "ScanResult", "Severity", "scan"]
7
+ __version__ = "0.1.3"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,252 @@
1
+ """Rule catalog for stable IDs, help text, and SARIF metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .models import Rule, Severity
6
+
7
+
8
+ def _rule(
9
+ rule_id: str,
10
+ title: str,
11
+ description: str,
12
+ remediation: str,
13
+ severity: Severity,
14
+ category: str,
15
+ ) -> Rule:
16
+ return Rule(
17
+ id=rule_id,
18
+ title=title,
19
+ description=description,
20
+ remediation=remediation,
21
+ default_severity=severity,
22
+ category=category,
23
+ )
24
+
25
+
26
+ RULES: dict[str, Rule] = {
27
+ "HPG001": _rule(
28
+ "HPG001",
29
+ "Plugin declaration missing",
30
+ (
31
+ "Hermes directory plugins use plugin.yaml; pip-distributed plugins use the "
32
+ "hermes_agent.plugins entry-point group."
33
+ ),
34
+ (
35
+ "Add plugin.yaml to a directory plugin or declare "
36
+ '[project.entry-points."hermes_agent.plugins"] in pyproject.toml.'
37
+ ),
38
+ Severity.HIGH,
39
+ "manifest",
40
+ ),
41
+ "HPG002": _rule(
42
+ "HPG002",
43
+ "Plugin manifest is invalid",
44
+ "An unreadable or non-mapping manifest cannot be interpreted safely.",
45
+ "Fix the YAML syntax and keep the root value as a mapping.",
46
+ Severity.HIGH,
47
+ "manifest",
48
+ ),
49
+ "HPG003": _rule(
50
+ "HPG003",
51
+ "Required plugin metadata missing",
52
+ "Name, version, and description make plugins identifiable and reviewable.",
53
+ "Add the missing metadata to plugin.yaml or the pyproject.toml project table.",
54
+ Severity.MEDIUM,
55
+ "manifest",
56
+ ),
57
+ "HPG004": _rule(
58
+ "HPG004",
59
+ "Unknown plugin kind",
60
+ "Unknown plugin kinds are coerced by Hermes and may use the wrong loader.",
61
+ "Use standalone, backend, exclusive, platform, or model-provider.",
62
+ Severity.MEDIUM,
63
+ "manifest",
64
+ ),
65
+ "HPG005": _rule(
66
+ "HPG005",
67
+ "Plugin entry point missing",
68
+ "Directory plugins need __init__.py; dashboard plugins need their declared bundle.",
69
+ "Add the missing entry file and expose the loader expected by the plugin kind.",
70
+ Severity.HIGH,
71
+ "manifest",
72
+ ),
73
+ "HPG006": _rule(
74
+ "HPG006",
75
+ "Hook declaration mismatch",
76
+ "Literal hook registrations should match the hooks declared by the manifest.",
77
+ "Update plugin.yaml or the ctx.register_hook call so they agree.",
78
+ Severity.MEDIUM,
79
+ "manifest",
80
+ ),
81
+ "HPG101": _rule(
82
+ "HPG101",
83
+ "Dynamic code execution",
84
+ (
85
+ "eval, exec, compile, and direct __import__ calls can execute or obscure "
86
+ "content that static review cannot fully bound."
87
+ ),
88
+ "Use normal imports, a parser, or a narrow allow-listed dispatcher.",
89
+ Severity.HIGH,
90
+ "python",
91
+ ),
92
+ "HPG102": _rule(
93
+ "HPG102",
94
+ "Unsafe deserialization",
95
+ "pickle and unsafe YAML loaders can execute attacker-controlled code.",
96
+ "Use JSON, yaml.safe_load, or an explicitly safe loader.",
97
+ Severity.HIGH,
98
+ "python",
99
+ ),
100
+ "HPG103": _rule(
101
+ "HPG103",
102
+ "Direct process execution",
103
+ "Direct process APIs bypass Hermes' terminal approval and output-safety pipeline.",
104
+ "Use ctx.dispatch_tool('terminal', ...) or strictly constrain arguments and shell=False.",
105
+ Severity.HIGH,
106
+ "python",
107
+ ),
108
+ "HPG104": _rule(
109
+ "HPG104",
110
+ "Sensitive path reference",
111
+ "The plugin references a path commonly containing credentials or private keys.",
112
+ "Remove the access or document and constrain it to the minimum required file.",
113
+ Severity.HIGH,
114
+ "python",
115
+ ),
116
+ "HPG105": _rule(
117
+ "HPG105",
118
+ "Unrestricted network listener",
119
+ "Binding to all interfaces can expose an unauthenticated plugin service.",
120
+ "Bind to loopback by default and require explicit authentication for remote use.",
121
+ Severity.HIGH,
122
+ "python",
123
+ ),
124
+ "HPG106": _rule(
125
+ "HPG106",
126
+ "Network capability",
127
+ "The plugin imports a networking client or socket module.",
128
+ "Review destinations, TLS verification, timeouts, and data sent off-device.",
129
+ Severity.INFO,
130
+ "python",
131
+ ),
132
+ "HPG107": _rule(
133
+ "HPG107",
134
+ "Undeclared secret environment access",
135
+ (
136
+ "A secret-like environment variable is read but not declared in requires_env "
137
+ "or optional_env."
138
+ ),
139
+ ("Declare it in plugin.yaml so users see the credential requirement before enablement."),
140
+ Severity.MEDIUM,
141
+ "python",
142
+ ),
143
+ "HPG108": _rule(
144
+ "HPG108",
145
+ "Destructive filesystem operation",
146
+ "Deletion APIs can remove user or workspace data when paths are not constrained.",
147
+ "Constrain targets to a plugin-owned directory and add refusal checks.",
148
+ Severity.MEDIUM,
149
+ "python",
150
+ ),
151
+ "HPG109": _rule(
152
+ "HPG109",
153
+ "TLS verification disabled",
154
+ "Disabling certificate verification permits machine-in-the-middle attacks.",
155
+ "Keep TLS verification enabled or trust a narrowly scoped custom CA.",
156
+ Severity.HIGH,
157
+ "python",
158
+ ),
159
+ "HPG110": _rule(
160
+ "HPG110",
161
+ "Privileged plugin surface",
162
+ "Tool overrides, pre-auth gateway hooks, and message injection can alter core control flow.",
163
+ "Remove the privileged option or document why it is required and gate it explicitly.",
164
+ Severity.HIGH,
165
+ "python",
166
+ ),
167
+ "HPG111": _rule(
168
+ "HPG111",
169
+ "Load-time side effect",
170
+ "Process, network, or destructive calls during import/register run when a plugin is enabled.",
171
+ "Move the action behind an explicit tool or user-triggered command.",
172
+ Severity.HIGH,
173
+ "python",
174
+ ),
175
+ "HPG112": _rule(
176
+ "HPG112",
177
+ "Outbound network egress",
178
+ "A concrete outbound connection or request can send plugin or user data off-device.",
179
+ (
180
+ "Document and constrain destinations, send only required data, use encrypted "
181
+ "transport, and make network access visible to users."
182
+ ),
183
+ Severity.MEDIUM,
184
+ "python",
185
+ ),
186
+ "HPG201": _rule(
187
+ "HPG201",
188
+ "Secret material committed",
189
+ "Private keys, tokens, and credential files must not ship with a plugin.",
190
+ "Revoke the credential, remove it from history, and use requires_env instead.",
191
+ Severity.CRITICAL,
192
+ "secrets",
193
+ ),
194
+ "HPG202": _rule(
195
+ "HPG202",
196
+ "Unpinned remote dependency",
197
+ "Mutable Git or URL dependencies can change without a plugin code review.",
198
+ "Pin Git dependencies to a full commit SHA and verify the source.",
199
+ Severity.HIGH,
200
+ "dependencies",
201
+ ),
202
+ "HPG203": _rule(
203
+ "HPG203",
204
+ "Dependency has no upper bound",
205
+ "An open-ended dependency range increases supply-chain and compatibility risk.",
206
+ "Add a compatible upper bound, for example >=1.2,<2.",
207
+ Severity.LOW,
208
+ "dependencies",
209
+ ),
210
+ "HPG204": _rule(
211
+ "HPG204",
212
+ "Remote installer executes without verification",
213
+ (
214
+ "Downloading a mutable script and piping it directly to a shell executes code "
215
+ "that was not part of the reviewed plugin."
216
+ ),
217
+ (
218
+ "Pin a versioned artifact, verify its checksum or signature, and execute it only "
219
+ "after verification."
220
+ ),
221
+ Severity.HIGH,
222
+ "dependencies",
223
+ ),
224
+ "HPG301": _rule(
225
+ "HPG301",
226
+ "Open-source license missing",
227
+ "A public repository without a license is not safely reusable or contributable.",
228
+ "Add an OSI-approved LICENSE file.",
229
+ Severity.MEDIUM,
230
+ "project",
231
+ ),
232
+ "HPG302": _rule(
233
+ "HPG302",
234
+ "Security policy missing",
235
+ "Maintainers need a private path for reporting plugin vulnerabilities.",
236
+ "Add SECURITY.md with supported versions and a reporting channel.",
237
+ Severity.LOW,
238
+ "project",
239
+ ),
240
+ "HPG303": _rule(
241
+ "HPG303",
242
+ "Tests missing",
243
+ "Plugins with full agent privileges should have repeatable safety tests.",
244
+ "Add automated tests covering registration and high-risk paths.",
245
+ Severity.LOW,
246
+ "project",
247
+ ),
248
+ }
249
+
250
+
251
+ def get_rule(rule_id: str) -> Rule:
252
+ return RULES[rule_id]
@@ -0,0 +1,143 @@
1
+ """Command-line interface for hermes-plugin-guard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ from . import __version__
11
+ from .catalog import RULES
12
+ from .models import Severity
13
+ from .reporters import render
14
+ from .scanner import scan
15
+
16
+ FORMATS = ("text", "json", "sarif", "github")
17
+ THRESHOLDS = ("critical", "high", "medium", "low", "info", "none")
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="hermes-plugin-guard",
23
+ description=(
24
+ "Statically inspect Hermes Agent plugins before enabling them. "
25
+ "Target code is never imported or executed."
26
+ ),
27
+ )
28
+ parser.add_argument(
29
+ "--version",
30
+ action="version",
31
+ version=f"%(prog)s {__version__}",
32
+ )
33
+ subparsers = parser.add_subparsers(dest="command", required=True)
34
+
35
+ scan_parser = subparsers.add_parser(
36
+ "scan",
37
+ help="scan a plugin directory or repository",
38
+ )
39
+ scan_parser.add_argument(
40
+ "path",
41
+ nargs="?",
42
+ default=".",
43
+ help="plugin or repository path (default: current directory)",
44
+ )
45
+ scan_parser.add_argument(
46
+ "--format",
47
+ dest="report_format",
48
+ choices=FORMATS,
49
+ default="text",
50
+ help="output format (default: text)",
51
+ )
52
+ scan_parser.add_argument(
53
+ "--output",
54
+ type=Path,
55
+ help="write the report to this file instead of stdout",
56
+ )
57
+ scan_parser.add_argument(
58
+ "--fail-on",
59
+ choices=THRESHOLDS,
60
+ default="high",
61
+ help="minimum severity that returns exit 1 (default: high)",
62
+ )
63
+ scan_parser.add_argument(
64
+ "--exclude",
65
+ "--exclude-rule",
66
+ dest="excluded_rules",
67
+ action="append",
68
+ default=[],
69
+ metavar="RULE_ID",
70
+ help="exclude a rule ID; may be repeated",
71
+ )
72
+
73
+ rules_parser = subparsers.add_parser(
74
+ "rules",
75
+ help="list all checks and their default severity",
76
+ )
77
+ rules_parser.add_argument(
78
+ "--format",
79
+ dest="report_format",
80
+ choices=("text", "json"),
81
+ default="text",
82
+ )
83
+ return parser
84
+
85
+
86
+ def main(argv: Sequence[str] | None = None) -> int:
87
+ parser = build_parser()
88
+ args = parser.parse_args(argv)
89
+
90
+ if args.command == "rules":
91
+ return _print_rules(args.report_format)
92
+
93
+ excluded_rules = [value.upper() for value in args.excluded_rules]
94
+ unknown = sorted(set(excluded_rules) - set(RULES))
95
+ if unknown:
96
+ parser.error(f"unknown rule ID(s): {', '.join(unknown)}")
97
+
98
+ threshold = None if args.fail_on == "none" else Severity.parse(args.fail_on)
99
+ try:
100
+ result = scan(args.path, excluded_rules=excluded_rules)
101
+ report = render(result, args.report_format, threshold)
102
+ _write_report(report, args.output)
103
+ except (FileNotFoundError, NotADirectoryError, OSError, ValueError) as exc:
104
+ print(f"hermes-plugin-guard: error: {exc}", file=sys.stderr)
105
+ return 2
106
+ return 1 if result.fails_at(threshold) else 0
107
+
108
+
109
+ def _write_report(report: str, output: Path | None) -> None:
110
+ if output is None:
111
+ sys.stdout.write(report)
112
+ return
113
+ output.parent.mkdir(parents=True, exist_ok=True)
114
+ output.write_text(report, encoding="utf-8")
115
+
116
+
117
+ def _print_rules(report_format: str) -> int:
118
+ if report_format == "json":
119
+ import json
120
+
121
+ payload = [
122
+ {
123
+ "id": rule.id,
124
+ "severity": rule.default_severity.label,
125
+ "title": rule.title,
126
+ "category": rule.category,
127
+ "description": rule.description,
128
+ "remediation": rule.remediation,
129
+ }
130
+ for rule in RULES.values()
131
+ ]
132
+ print(json.dumps(payload, indent=2, sort_keys=True))
133
+ return 0
134
+
135
+ for rule in RULES.values():
136
+ print(f"{rule.id} {rule.default_severity.label.upper():8} {rule.title}")
137
+ print(f" {rule.description}")
138
+ print(f" Fix: {rule.remediation}")
139
+ return 0
140
+
141
+
142
+ if __name__ == "__main__": # pragma: no cover
143
+ raise SystemExit(main())
@@ -0,0 +1,264 @@
1
+ """Supply-chain checks for Python dependency declarations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tomllib
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+ import yaml
11
+
12
+ from .catalog import get_rule
13
+ from .manifest import MAX_MANIFEST_BYTES
14
+ from .models import Finding
15
+
16
+ FULL_SHA_RE = re.compile(r"(?i)(?:@|/)[0-9a-f]{40}(?:[#?]|$)")
17
+ NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+")
18
+ REMOTE_FETCH_RE = re.compile(r"(?i)\b(?:curl|wget)\b[^\n]*(?:https?://)")
19
+ PIPE_TO_SHELL_RE = re.compile(
20
+ r"(?i)\|\s*(?:(?:/usr/bin/env|env)\s+)?(?:ba|da|fi|z)?sh\b"
21
+ r"|\|\s*(?:iex|invoke-expression)\b"
22
+ )
23
+
24
+
25
+ def inspect_dependencies(root: Path, repository_root: Path) -> list[Finding]:
26
+ findings: list[Finding] = []
27
+ seen: set[Path] = set()
28
+ candidates = sorted(root.glob("requirements*.txt"))
29
+ pyproject = root / "pyproject.toml"
30
+ if pyproject.is_file():
31
+ candidates.append(pyproject)
32
+ manifest = root / "plugin.yaml"
33
+ if manifest.is_file():
34
+ candidates.append(manifest)
35
+
36
+ for path in candidates:
37
+ resolved = path.resolve()
38
+ if resolved in seen:
39
+ continue
40
+ seen.add(resolved)
41
+ if path.name == "pyproject.toml":
42
+ findings.extend(_inspect_pyproject(path, repository_root))
43
+ elif path.name == "plugin.yaml":
44
+ findings.extend(_inspect_plugin_manifest(path, repository_root))
45
+ else:
46
+ findings.extend(_inspect_requirements(path, repository_root))
47
+ return findings
48
+
49
+
50
+ def _inspect_requirements(path: Path, root: Path) -> list[Finding]:
51
+ try:
52
+ lines = path.read_text(encoding="utf-8").splitlines()
53
+ except (OSError, UnicodeError):
54
+ return []
55
+ entries = (
56
+ (line_number, line.strip())
57
+ for line_number, line in enumerate(lines, start=1)
58
+ if line.strip() and not line.lstrip().startswith(("#", "-r", "--requirement"))
59
+ )
60
+ return _inspect_entries(entries, _relative(path, root))
61
+
62
+
63
+ def _inspect_pyproject(path: Path, root: Path) -> list[Finding]:
64
+ try:
65
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
66
+ except (OSError, UnicodeError, tomllib.TOMLDecodeError):
67
+ return []
68
+
69
+ dependencies: list[str] = []
70
+ project = data.get("project")
71
+ project_name = ""
72
+ if isinstance(project, dict):
73
+ raw_name = project.get("name")
74
+ if isinstance(raw_name, str):
75
+ project_name = raw_name
76
+ raw = project.get("dependencies")
77
+ if isinstance(raw, list):
78
+ dependencies.extend(item for item in raw if isinstance(item, str))
79
+ optional = project.get("optional-dependencies")
80
+ if isinstance(optional, dict):
81
+ for group in optional.values():
82
+ if isinstance(group, list):
83
+ dependencies.extend(item for item in group if isinstance(item, str))
84
+
85
+ build_system = data.get("build-system")
86
+ if isinstance(build_system, dict):
87
+ raw = build_system.get("requires")
88
+ if isinstance(raw, list):
89
+ dependencies.extend(item for item in raw if isinstance(item, str))
90
+
91
+ lines = path.read_text(encoding="utf-8").splitlines()
92
+ entries: list[tuple[int, str]] = []
93
+ search_from = 0
94
+ for dependency in dependencies:
95
+ line_number = 1
96
+ for index in range(search_from, len(lines)):
97
+ if dependency in lines[index]:
98
+ line_number = index + 1
99
+ search_from = index + 1
100
+ break
101
+ entries.append((line_number, dependency))
102
+ ignored_names = {project_name} if project_name else set()
103
+ return _inspect_entries(entries, _relative(path, root), ignored_names=ignored_names)
104
+
105
+
106
+ def _inspect_plugin_manifest(path: Path, root: Path) -> list[Finding]:
107
+ try:
108
+ if path.stat().st_size > MAX_MANIFEST_BYTES:
109
+ # The structural manifest pass reports HPG002. Do not parse the
110
+ # same oversized untrusted YAML again during dependency analysis.
111
+ return []
112
+ with path.open(encoding="utf-8") as stream:
113
+ text = stream.read(MAX_MANIFEST_BYTES + 1)
114
+ if len(text) > MAX_MANIFEST_BYTES:
115
+ return []
116
+ data = yaml.safe_load(text) or {}
117
+ except (OSError, UnicodeError, yaml.YAMLError, RecursionError):
118
+ return []
119
+ if not isinstance(data, dict):
120
+ return []
121
+
122
+ lines = text.splitlines()
123
+ pip_dependencies = data.get("pip_dependencies")
124
+ entries: list[tuple[int, str]] = []
125
+ if isinstance(pip_dependencies, list):
126
+ search_from = 0
127
+ for dependency in pip_dependencies:
128
+ if not isinstance(dependency, str):
129
+ continue
130
+ line_number, search_from = _line_for_yaml_value(
131
+ lines,
132
+ dependency,
133
+ search_from,
134
+ key=None,
135
+ )
136
+ entries.append((line_number, dependency))
137
+
138
+ relative = _relative(path, root)
139
+ findings = _inspect_entries(entries, relative)
140
+ external_dependencies = data.get("external_dependencies")
141
+ if not isinstance(external_dependencies, list):
142
+ return findings
143
+
144
+ search_from = 0
145
+ for dependency in external_dependencies:
146
+ if not isinstance(dependency, dict):
147
+ continue
148
+ install = dependency.get("install")
149
+ if not isinstance(install, str) or not _pipes_remote_script_to_shell(install):
150
+ continue
151
+ line_number, search_from = _line_for_yaml_value(
152
+ lines,
153
+ install,
154
+ search_from,
155
+ key="install",
156
+ )
157
+ rule = get_rule("HPG204")
158
+ findings.append(
159
+ Finding(
160
+ rule_id="HPG204",
161
+ severity=rule.default_severity,
162
+ message="External dependency downloads a remote script and pipes it to a shell.",
163
+ path=relative,
164
+ line=line_number,
165
+ evidence="remote download | shell",
166
+ )
167
+ )
168
+ return findings
169
+
170
+
171
+ def _line_for_yaml_value(
172
+ lines: list[str],
173
+ value: str,
174
+ search_from: int,
175
+ *,
176
+ key: str | None,
177
+ ) -> tuple[int, int]:
178
+ def is_candidate(line: str) -> bool:
179
+ stripped = line.lstrip()
180
+ marker = f"{key}:" if key else "-"
181
+ return stripped.startswith(marker) and value in line
182
+
183
+ for index in range(search_from, len(lines)):
184
+ if is_candidate(lines[index]):
185
+ return index + 1, index + 1
186
+ for index, line in enumerate(lines):
187
+ if is_candidate(line):
188
+ return index + 1, index + 1
189
+ return 1, search_from
190
+
191
+
192
+ def _pipes_remote_script_to_shell(command: str) -> bool:
193
+ return bool(REMOTE_FETCH_RE.search(command) and PIPE_TO_SHELL_RE.search(command))
194
+
195
+
196
+ def _inspect_entries(
197
+ entries: Iterable[tuple[int, str]],
198
+ relative_path: str,
199
+ *,
200
+ ignored_names: set[str] | None = None,
201
+ ) -> list[Finding]:
202
+ findings: list[Finding] = []
203
+ normalized_ignored = {_normalize_name(name) for name in ignored_names or set()}
204
+ for line, raw_entry in entries:
205
+ entry = raw_entry.split(";", 1)[0].strip()
206
+ if not entry or entry.startswith(("-e ", "--editable ")):
207
+ entry = entry.split(maxsplit=1)[-1] if " " in entry else entry
208
+
209
+ is_remote = (
210
+ "git+" in entry
211
+ or " @ http://" in entry
212
+ or " @ https://" in entry
213
+ or entry.startswith(("http://", "https://"))
214
+ )
215
+ if is_remote and not FULL_SHA_RE.search(entry):
216
+ rule = get_rule("HPG202")
217
+ findings.append(
218
+ Finding(
219
+ rule_id="HPG202",
220
+ severity=rule.default_severity,
221
+ message=f"Remote dependency is not pinned to a full commit SHA: {entry!r}.",
222
+ path=relative_path,
223
+ line=line,
224
+ evidence=entry,
225
+ )
226
+ )
227
+ continue
228
+
229
+ if is_remote or entry.startswith((".", "/", "file:")):
230
+ continue
231
+ if not NAME_RE.match(entry):
232
+ continue
233
+
234
+ match = NAME_RE.match(entry)
235
+ if match is None:
236
+ continue
237
+ if _normalize_name(match.group(0)) in normalized_ignored:
238
+ continue
239
+ spec = entry[match.end() :]
240
+ bounded = "==" in spec or "~=" in spec or "<" in spec
241
+ if not bounded:
242
+ rule = get_rule("HPG203")
243
+ findings.append(
244
+ Finding(
245
+ rule_id="HPG203",
246
+ severity=rule.default_severity,
247
+ message=f"Dependency has no compatible upper bound: {entry!r}.",
248
+ path=relative_path,
249
+ line=line,
250
+ evidence=entry,
251
+ )
252
+ )
253
+ return findings
254
+
255
+
256
+ def _normalize_name(name: str) -> str:
257
+ return re.sub(r"[-_.]+", "-", name).casefold()
258
+
259
+
260
+ def _relative(path: Path, root: Path) -> str:
261
+ try:
262
+ return path.resolve().relative_to(root.resolve()).as_posix()
263
+ except ValueError:
264
+ return path.as_posix()