patchshuttle 0.1.0a2__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.
Files changed (44) hide show
  1. patchshuttle/__init__.py +98 -0
  2. patchshuttle/_diff.py +317 -0
  3. patchshuttle/_process.py +198 -0
  4. patchshuttle/_version.py +3 -0
  5. patchshuttle/actions/__init__.py +80 -0
  6. patchshuttle/actions/constructors.py +211 -0
  7. patchshuttle/actions/create.py +155 -0
  8. patchshuttle/actions/modify.py +174 -0
  9. patchshuttle/audit.py +588 -0
  10. patchshuttle/backup.py +712 -0
  11. patchshuttle/checks/__init__.py +37 -0
  12. patchshuttle/checks/constructors.py +67 -0
  13. patchshuttle/checks/runner.py +233 -0
  14. patchshuttle/cli.py +766 -0
  15. patchshuttle/config.py +247 -0
  16. patchshuttle/context.py +370 -0
  17. patchshuttle/errors.py +291 -0
  18. patchshuttle/execution.py +651 -0
  19. patchshuttle/formatters/__init__.py +25 -0
  20. patchshuttle/formatters/runner.py +240 -0
  21. patchshuttle/identifiers.py +20 -0
  22. patchshuttle/inventory.py +331 -0
  23. patchshuttle/logging.py +741 -0
  24. patchshuttle/models.py +496 -0
  25. patchshuttle/operations.py +292 -0
  26. patchshuttle/parser.py +243 -0
  27. patchshuttle/planner.py +1144 -0
  28. patchshuttle/policy.py +377 -0
  29. patchshuttle/py.typed +1 -0
  30. patchshuttle/registry.py +275 -0
  31. patchshuttle/resources/AI_GUIDE.md +163 -0
  32. patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
  33. patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
  34. patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
  35. patchshuttle/resources/__init__.py +1 -0
  36. patchshuttle/rollback.py +306 -0
  37. patchshuttle/runner.py +880 -0
  38. patchshuttle/verification.py +107 -0
  39. patchshuttle/workspace.py +382 -0
  40. patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
  41. patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
  42. patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
  43. patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
  44. patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
patchshuttle/audit.py ADDED
@@ -0,0 +1,588 @@
1
+ """Bounded read-only audit action execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import stat
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from importlib import metadata
14
+ from pathlib import Path, PurePosixPath
15
+
16
+ from patchshuttle._process import ProcessCommand, ProcessStatus, run_process
17
+ from patchshuttle._version import __version__
18
+ from patchshuttle.errors import ExecutionError, ExecutionErrorCode, PolicyError
19
+ from patchshuttle.inventory import (
20
+ InventoryError,
21
+ WorkspaceComparison,
22
+ capture_inventory,
23
+ compare_inventories,
24
+ )
25
+ from patchshuttle.models import JobKind
26
+ from patchshuttle.planner import Plan, plan_job
27
+ from patchshuttle.policy import PathKind, Policy, WorkspacePath
28
+
29
+ _UTF32_LE_BOM = b"\xff\xfe\x00\x00"
30
+ _UTF32_BE_BOM = b"\x00\x00\xfe\xff"
31
+ _UTF16_LE_BOM = b"\xff\xfe"
32
+ _UTF16_BE_BOM = b"\xfe\xff"
33
+ _UTF8_BOM = b"\xef\xbb\xbf"
34
+ _TRUNCATION_MARKER = "\n[TRUNCATED BY PATCHSHUTTLE]\n"
35
+
36
+
37
+ class AuditStatus(str):
38
+ """Stable audit action outcomes."""
39
+
40
+ COMPLETED = "COMPLETED"
41
+ NOT_AVAILABLE = "NOT_AVAILABLE"
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class AuditActionResult:
46
+ """One bounded audit action observation."""
47
+
48
+ id: str
49
+ name: str
50
+ status: str
51
+ scope: tuple[PurePosixPath, ...]
52
+ started_at: str
53
+ duration_ms: int
54
+ output: str = field(repr=False)
55
+ output_truncated: bool = False
56
+
57
+ @property
58
+ def success(self) -> bool:
59
+ return self.status in {AuditStatus.COMPLETED, AuditStatus.NOT_AVAILABLE}
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class AuditRunResult:
64
+ """Ordered results from a read-only audit plan."""
65
+
66
+ plan: Plan = field(repr=False)
67
+ results: tuple[AuditActionResult, ...]
68
+ workspace_comparison: WorkspaceComparison
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class _WalkEntry:
73
+ path: PurePosixPath
74
+ absolute: Path
75
+ mode: int
76
+ depth: int
77
+
78
+
79
+ def execute_audit_locked(plan: Plan) -> AuditRunResult:
80
+ """Execute one audit while the caller holds the workspace run lock."""
81
+
82
+ if plan.job.kind is not JobKind.AUDIT:
83
+ raise ExecutionError(
84
+ ExecutionErrorCode.JOB_KIND_UNSUPPORTED,
85
+ "the audit runner accepts only audit jobs",
86
+ )
87
+ _revalidate_plan(plan)
88
+ baseline = _capture_inventory(plan)
89
+ results: list[AuditActionResult] = []
90
+ for action, planned in zip(plan.job.actions, plan.actions):
91
+ started_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
92
+ started = time.monotonic_ns()
93
+ try:
94
+ status, output = _execute_action(plan, action.name, action.parameters)
95
+ output, truncated = _bounded_output(
96
+ output,
97
+ plan.workspace.config.execution.max_command_output_bytes,
98
+ )
99
+ truncated = truncated or _TRUNCATION_MARKER.strip() in output
100
+ except ExecutionError as error:
101
+ error.item_id = error.item_id or planned.id
102
+ error.path = error.path or (
103
+ planned.paths[0].as_posix() if planned.paths else None
104
+ )
105
+ error.audit_results = tuple(results)
106
+ raise
107
+ except (OSError, PolicyError, UnicodeError, ValueError) as exc:
108
+ raise ExecutionError(
109
+ ExecutionErrorCode.ACTION_FAILED,
110
+ "a read-only audit action failed",
111
+ item_id=planned.id,
112
+ path=(planned.paths[0].as_posix() if planned.paths else None),
113
+ audit_results=tuple(results),
114
+ ) from exc
115
+ results.append(
116
+ AuditActionResult(
117
+ id=planned.id,
118
+ name=planned.name,
119
+ status=status,
120
+ scope=planned.paths,
121
+ started_at=started_at,
122
+ duration_ms=(time.monotonic_ns() - started) // 1_000_000,
123
+ output=output,
124
+ output_truncated=truncated,
125
+ )
126
+ )
127
+ current = _capture_inventory(plan)
128
+ comparison = compare_inventories(baseline, current)
129
+ if comparison.unexpected_changes:
130
+ first = comparison.unexpected_changes[0]
131
+ raise ExecutionError(
132
+ ExecutionErrorCode.UNEXPECTED_WORKSPACE_CHANGE,
133
+ "an audit action changed the workspace",
134
+ path=first.path.as_posix(),
135
+ audit_results=tuple(results),
136
+ workspace_comparison=comparison,
137
+ )
138
+ return AuditRunResult(
139
+ plan=plan,
140
+ results=tuple(results),
141
+ workspace_comparison=comparison,
142
+ )
143
+
144
+
145
+ def _execute_action(plan: Plan, name: str, parameters) -> tuple[str, str]:
146
+ if name == "tree":
147
+ return AuditStatus.COMPLETED, _tree(plan, parameters)
148
+ if name == "read":
149
+ return AuditStatus.COMPLETED, _read(plan, parameters)
150
+ if name == "search":
151
+ return AuditStatus.COMPLETED, _search(plan, parameters)
152
+ if name == "find_files":
153
+ return AuditStatus.COMPLETED, _find_files(plan, parameters)
154
+ if name == "file_info":
155
+ return AuditStatus.COMPLETED, _file_info(plan, parameters)
156
+ if name == "hash":
157
+ return AuditStatus.COMPLETED, _hash(plan, parameters)
158
+ if name == "git_status":
159
+ return _git_status(plan)
160
+ if name == "environment":
161
+ return AuditStatus.COMPLETED, _environment(plan)
162
+ raise ExecutionError(
163
+ ExecutionErrorCode.ACTION_UNSUPPORTED,
164
+ "the audit runner does not support this action",
165
+ )
166
+
167
+
168
+ def _tree(plan: Plan, parameters) -> str:
169
+ policy = Policy(plan.workspace)
170
+ root = policy.resolve(parameters.path, allow_root=True)
171
+ lines = [f"{_display(root.relative)}/ [directory]"]
172
+ entries = _walk(
173
+ plan,
174
+ root,
175
+ maximum_depth=parameters.depth,
176
+ include_hidden=parameters.include_hidden,
177
+ )
178
+ limited = False
179
+ for entry in entries:
180
+ if len(lines) - 1 >= parameters.max_entries:
181
+ limited = True
182
+ break
183
+ kind = _mode_name(entry.mode)
184
+ suffix = "/" if stat.S_ISDIR(entry.mode) else ""
185
+ lines.append(f"{entry.path.as_posix()}{suffix} [{kind}]")
186
+ if limited:
187
+ lines.append("[ENTRY LIMIT REACHED]")
188
+ return "\n".join(lines)
189
+
190
+
191
+ def _read(plan: Plan, parameters) -> str:
192
+ policy = Policy(plan.workspace)
193
+ target = policy.resolve(parameters.path)
194
+ raw = _read_regular_file(plan, target)
195
+ encoding, text = _decode_text(raw)
196
+ lines = text.splitlines()
197
+ start = parameters.start_line
198
+ end = parameters.end_line or len(lines)
199
+ selected = [
200
+ f"{number:>6}: {lines[number - 1]}"
201
+ for number in range(start, min(end, len(lines)) + 1)
202
+ ]
203
+ header = f"path: {target.relative.as_posix()}\nencoding: {encoding}"
204
+ output = header + ("\n" + "\n".join(selected) if selected else "\n[NO LINES]")
205
+ limit = (
206
+ parameters.max_bytes or plan.workspace.config.execution.max_single_file_bytes
207
+ )
208
+ bounded, _ = _bounded_output(output, limit)
209
+ return bounded
210
+
211
+
212
+ def _search(plan: Plan, parameters) -> str:
213
+ policy = Policy(plan.workspace)
214
+ root = policy.resolve(parameters.path, allow_root=True)
215
+ files = _audit_files(plan, root, glob=parameters.glob)
216
+ needle = (
217
+ parameters.text if parameters.case_sensitive else parameters.text.casefold()
218
+ )
219
+ results: list[str] = []
220
+ skipped_binary = 0
221
+ for path, target in files:
222
+ try:
223
+ _, text = _decode_text(_read_regular_file(plan, target))
224
+ except (UnicodeError, ValueError):
225
+ skipped_binary += 1
226
+ continue
227
+ for number, line in enumerate(text.splitlines(), start=1):
228
+ compared = line if parameters.case_sensitive else line.casefold()
229
+ if needle in compared:
230
+ results.append(f"{path.as_posix()}:{number}:{line}")
231
+ if len(results) >= parameters.max_results:
232
+ break
233
+ if len(results) >= parameters.max_results:
234
+ break
235
+ header = [
236
+ f"literal: {parameters.text}",
237
+ f"case_sensitive: {str(parameters.case_sensitive).lower()}",
238
+ f"matches: {len(results)}",
239
+ f"binary_files_skipped: {skipped_binary}",
240
+ ]
241
+ if len(results) >= parameters.max_results:
242
+ header.append("result_limit_reached: true")
243
+ return "\n".join((*header, *results))
244
+
245
+
246
+ def _find_files(plan: Plan, parameters) -> str:
247
+ policy = Policy(plan.workspace)
248
+ root = policy.resolve(parameters.path, allow_root=True)
249
+ found: list[str] = []
250
+ for path, _ in _audit_files(plan, root, glob=parameters.glob):
251
+ found.append(path.as_posix())
252
+ if len(found) >= parameters.max_results:
253
+ break
254
+ lines = [f"glob: {parameters.glob}", f"matches: {len(found)}", *found]
255
+ if len(found) >= parameters.max_results:
256
+ lines.insert(2, "result_limit_reached: true")
257
+ return "\n".join(lines)
258
+
259
+
260
+ def _file_info(plan: Plan, parameters) -> str:
261
+ policy = Policy(plan.workspace)
262
+ target = policy.resolve(parameters.path)
263
+ metadata_value = target.absolute.lstat()
264
+ values = [
265
+ f"path: {target.relative.as_posix()}",
266
+ f"type: {target.kind.value}",
267
+ f"size_bytes: {metadata_value.st_size if target.kind is PathKind.FILE else 0}",
268
+ f"executable: {str(bool(metadata_value.st_mode & 0o111)).lower()}",
269
+ "modified_at: "
270
+ + datetime.fromtimestamp(
271
+ metadata_value.st_mtime,
272
+ tz=timezone.utc,
273
+ ).isoformat(timespec="seconds"),
274
+ ]
275
+ if target.kind is PathKind.FILE:
276
+ raw = _read_regular_file(plan, target)
277
+ try:
278
+ encoding, text = _decode_text(raw)
279
+ newline = _newline_style(text)
280
+ except (UnicodeError, ValueError):
281
+ encoding, newline = "binary_or_unsupported", "not_applicable"
282
+ values.extend((f"encoding: {encoding}", f"newline: {newline}"))
283
+ return "\n".join(values)
284
+
285
+
286
+ def _hash(plan: Plan, parameters) -> str:
287
+ target = Policy(plan.workspace).resolve(parameters.path)
288
+ raw = _read_regular_file(plan, target)
289
+ return "\n".join(
290
+ (
291
+ f"path: {target.relative.as_posix()}",
292
+ "algorithm: sha256",
293
+ f"sha256: {hashlib.sha256(raw).hexdigest()}",
294
+ f"size_bytes: {len(raw)}",
295
+ )
296
+ )
297
+
298
+
299
+ def _git_status(plan: Plan) -> tuple[str, str]:
300
+ git = shutil.which("git")
301
+ marker = plan.workspace.root / ".git"
302
+ if git is None or not marker.exists() or marker.is_symlink():
303
+ return AuditStatus.NOT_AVAILABLE, "Git repository or executable not available"
304
+ process = run_process(
305
+ ProcessCommand(
306
+ argv=(
307
+ git,
308
+ "-c",
309
+ "color.ui=false",
310
+ "status",
311
+ "--short",
312
+ "--branch",
313
+ "--untracked-files=normal",
314
+ ),
315
+ working_directory=plan.workspace.root,
316
+ timeout_seconds=plan.workspace.config.execution.default_timeout_seconds,
317
+ ),
318
+ maximum_output_bytes=plan.workspace.config.execution.max_command_output_bytes,
319
+ )
320
+ if process.status is not ProcessStatus.PASSED:
321
+ raise OSError(process.stderr or "git status failed")
322
+ output = process.stdout or "[CLEAN WORKTREE]"
323
+ if process.stdout_truncated:
324
+ output += _TRUNCATION_MARKER
325
+ return AuditStatus.COMPLETED, output.rstrip("\n")
326
+
327
+
328
+ def _environment(plan: Plan) -> str:
329
+ values = {
330
+ "operating_system": platform.platform(),
331
+ "python_implementation": platform.python_implementation(),
332
+ "python_version": platform.python_version(),
333
+ "patchshuttle_version": __version__,
334
+ "project_id": plan.workspace.project_id,
335
+ "working_directory": _redacted_cwd(plan.workspace.root),
336
+ "git": _tool_version("git"),
337
+ "pytest": _package_version("pytest"),
338
+ "isort": _package_version("isort"),
339
+ "black": _package_version("black"),
340
+ }
341
+ return "\n".join(f"{key}: {value}" for key, value in values.items())
342
+
343
+
344
+ def _walk(
345
+ plan: Plan,
346
+ root: WorkspacePath,
347
+ *,
348
+ maximum_depth: int,
349
+ include_hidden: bool,
350
+ ) -> tuple[_WalkEntry, ...]:
351
+ policy = Policy(plan.workspace)
352
+ pending = [(root.absolute, root.relative, 0)]
353
+ result: list[_WalkEntry] = []
354
+ inspected = 0
355
+ maximum = plan.workspace.config.execution.max_inventory_entries
356
+ while pending:
357
+ directory, parent, depth = pending.pop()
358
+ try:
359
+ with os.scandir(directory) as iterator:
360
+ children = sorted(iterator, key=lambda item: item.name)
361
+ except OSError as exc:
362
+ raise OSError(f"could not inspect {parent.as_posix()}") from exc
363
+ directories: list[tuple[Path, PurePosixPath, int]] = []
364
+ for child in children:
365
+ relative = parent / child.name
366
+ if (not include_hidden and child.name.startswith(".")) or _skip(
367
+ policy,
368
+ relative,
369
+ ):
370
+ continue
371
+ inspected += 1
372
+ if inspected > maximum:
373
+ raise OSError("audit traversal exceeded the configured entry limit")
374
+ try:
375
+ metadata_value = child.stat(follow_symlinks=False)
376
+ except OSError as exc:
377
+ raise OSError(f"could not inspect {relative.as_posix()}") from exc
378
+ result.append(
379
+ _WalkEntry(
380
+ path=relative,
381
+ absolute=Path(child.path),
382
+ mode=metadata_value.st_mode,
383
+ depth=depth + 1,
384
+ )
385
+ )
386
+ if stat.S_ISDIR(metadata_value.st_mode) and depth + 1 < maximum_depth:
387
+ directories.append((Path(child.path), relative, depth + 1))
388
+ pending.extend(reversed(directories))
389
+ return tuple(result)
390
+
391
+
392
+ def _audit_files(
393
+ plan: Plan,
394
+ root: WorkspacePath,
395
+ *,
396
+ glob: str | None,
397
+ ) -> tuple[tuple[PurePosixPath, WorkspacePath], ...]:
398
+ policy = Policy(plan.workspace)
399
+ if root.kind is PathKind.FILE:
400
+ candidates = (root.relative,)
401
+ base = root.relative.parent
402
+ else:
403
+ entries = _walk(
404
+ plan,
405
+ root,
406
+ maximum_depth=10_000,
407
+ include_hidden=True,
408
+ )
409
+ candidates = tuple(entry.path for entry in entries if stat.S_ISREG(entry.mode))
410
+ base = root.relative
411
+ files: list[tuple[PurePosixPath, WorkspacePath]] = []
412
+ for path in candidates:
413
+ compared = path.relative_to(base) if base.parts else path
414
+ if glob is not None and not (
415
+ compared.match(glob) or PurePosixPath(path.name).match(glob)
416
+ ):
417
+ continue
418
+ target = policy.resolve(path)
419
+ if target.kind is PathKind.FILE:
420
+ files.append((path, target))
421
+ return tuple(files)
422
+
423
+
424
+ def _read_regular_file(plan: Plan, target: WorkspacePath) -> bytes:
425
+ if target.kind is not PathKind.FILE:
426
+ raise OSError("audit target is not a regular file")
427
+ before = target.absolute.lstat()
428
+ maximum = plan.workspace.config.execution.max_single_file_bytes
429
+ if before.st_size > maximum:
430
+ raise OSError("audit file exceeds the configured size limit")
431
+ raw = target.absolute.read_bytes()
432
+ after = target.absolute.lstat()
433
+ current = Policy(plan.workspace).resolve(target.relative)
434
+ identity = lambda item: (
435
+ item.st_dev,
436
+ item.st_ino,
437
+ item.st_size,
438
+ item.st_mtime_ns,
439
+ item.st_mode,
440
+ )
441
+ if (
442
+ len(raw) > maximum
443
+ or identity(before) != identity(after)
444
+ or current.kind is not PathKind.FILE
445
+ or current.absolute != target.absolute
446
+ ):
447
+ raise OSError("audit file changed while it was read")
448
+ return raw
449
+
450
+
451
+ def _decode_text(raw: bytes) -> tuple[str, str]:
452
+ bom = b""
453
+ if raw.startswith(_UTF32_LE_BOM):
454
+ bom, codec, encoding = _UTF32_LE_BOM, "utf-32-le", "utf-32-le"
455
+ elif raw.startswith(_UTF32_BE_BOM):
456
+ bom, codec, encoding = _UTF32_BE_BOM, "utf-32-be", "utf-32-be"
457
+ elif raw.startswith(_UTF8_BOM):
458
+ bom, codec, encoding = _UTF8_BOM, "utf-8", "utf-8-sig"
459
+ elif raw.startswith(_UTF16_LE_BOM):
460
+ bom, codec, encoding = _UTF16_LE_BOM, "utf-16-le", "utf-16-le"
461
+ elif raw.startswith(_UTF16_BE_BOM):
462
+ bom, codec, encoding = _UTF16_BE_BOM, "utf-16-be", "utf-16-be"
463
+ else:
464
+ codec = encoding = "utf-8"
465
+ if b"\0" in raw:
466
+ raise ValueError("binary file")
467
+ text = raw[len(bom) :].decode(codec)
468
+ if any(_is_binary_control(character) for character in text):
469
+ raise ValueError("binary file")
470
+ return encoding, text
471
+
472
+
473
+ def _newline_style(text: str) -> str:
474
+ without_crlf = text.replace("\r\n", "")
475
+ has_crlf = "\r\n" in text
476
+ has_lf = "\n" in without_crlf
477
+ if "\r" in without_crlf or (has_crlf and has_lf):
478
+ return "mixed_or_cr"
479
+ if has_crlf:
480
+ return "crlf"
481
+ if has_lf:
482
+ return "lf"
483
+ return "none"
484
+
485
+
486
+ def _bounded_output(value: str, maximum: int) -> tuple[str, bool]:
487
+ raw = value.encode("utf-8")
488
+ if len(raw) <= maximum:
489
+ return value, False
490
+ marker = _TRUNCATION_MARKER.encode("utf-8")
491
+ if maximum <= len(marker):
492
+ return marker[:maximum].decode("utf-8"), True
493
+ retained = raw[: max(0, maximum - len(marker))]
494
+ return retained.decode("utf-8", errors="ignore") + _TRUNCATION_MARKER, True
495
+
496
+
497
+ def _skip(policy: Policy, path: PurePosixPath) -> bool:
498
+ return policy.is_ignored(path) or policy.is_protected(path)
499
+
500
+
501
+ def _mode_name(mode: int) -> str:
502
+ if stat.S_ISREG(mode):
503
+ return "file"
504
+ if stat.S_ISDIR(mode):
505
+ return "directory"
506
+ if stat.S_ISLNK(mode):
507
+ return "symlink"
508
+ return "other"
509
+
510
+
511
+ def _display(path: PurePosixPath) -> str:
512
+ return path.as_posix() if path.parts else "."
513
+
514
+
515
+ def _redacted_cwd(path: Path) -> str:
516
+ try:
517
+ home = Path.home().resolve()
518
+ resolved = path.resolve()
519
+ if resolved == home:
520
+ return "~"
521
+ return "~/" + resolved.relative_to(home).as_posix()
522
+ except (OSError, ValueError):
523
+ return path.as_posix()
524
+
525
+
526
+ def _package_version(name: str) -> str:
527
+ try:
528
+ return metadata.version(name)
529
+ except metadata.PackageNotFoundError:
530
+ return "NOT_AVAILABLE"
531
+
532
+
533
+ def _tool_version(name: str) -> str:
534
+ executable = shutil.which(name)
535
+ if executable is None:
536
+ return "NOT_AVAILABLE"
537
+ process = run_process(
538
+ ProcessCommand(
539
+ argv=(executable, "--version"),
540
+ working_directory=Path.cwd(),
541
+ timeout_seconds=10,
542
+ ),
543
+ maximum_output_bytes=4096,
544
+ )
545
+ if process.status is not ProcessStatus.PASSED:
546
+ return "NOT_AVAILABLE"
547
+ return (process.stdout or process.stderr).strip() or "AVAILABLE"
548
+
549
+
550
+ def _is_binary_control(character: str) -> bool:
551
+ value = ord(character)
552
+ return (value < 32 and character not in "\t\n\r") or value == 127
553
+
554
+
555
+ def _revalidate_plan(plan: Plan) -> None:
556
+ try:
557
+ current = plan_job(plan.job, plan.workspace)
558
+ except (OSError, PolicyError, ValueError) as exc:
559
+ raise ExecutionError(
560
+ ExecutionErrorCode.PLAN_STALE,
561
+ "the workspace no longer matches the approved audit plan",
562
+ item_id=getattr(exc, "item_id", None),
563
+ path=getattr(exc, "path", None),
564
+ ) from exc
565
+ if current != plan:
566
+ raise ExecutionError(
567
+ ExecutionErrorCode.PLAN_STALE,
568
+ "the workspace no longer matches the approved audit plan",
569
+ )
570
+
571
+
572
+ def _capture_inventory(plan: Plan):
573
+ try:
574
+ return capture_inventory(plan.workspace)
575
+ except InventoryError as exc:
576
+ raise ExecutionError(
577
+ ExecutionErrorCode.WORKSPACE_INVENTORY_FAILED,
578
+ "audit workspace inventory could not be captured",
579
+ path=exc.path.as_posix() if exc.path is not None else None,
580
+ ) from exc
581
+
582
+
583
+ __all__ = [
584
+ "AuditActionResult",
585
+ "AuditRunResult",
586
+ "AuditStatus",
587
+ "execute_audit_locked",
588
+ ]