openworkproof 1.0.0__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 (39) hide show
  1. openworkproof/__init__.py +1 -0
  2. openworkproof/acceptance.py +3006 -0
  3. openworkproof/cli.py +164 -0
  4. openworkproof/composition.py +832 -0
  5. openworkproof/evidence.py +8281 -0
  6. openworkproof/execution_adapter.py +256 -0
  7. openworkproof/external_acceptor.py +211 -0
  8. openworkproof/mcp_server.py +2939 -0
  9. openworkproof/mcp_transport.py +72 -0
  10. openworkproof/models.py +3380 -0
  11. openworkproof/policy.py +2494 -0
  12. openworkproof/predicates.py +333 -0
  13. openworkproof/repo_pipeline/__init__.py +74 -0
  14. openworkproof/repo_pipeline/analysis.py +152 -0
  15. openworkproof/repo_pipeline/errors.py +64 -0
  16. openworkproof/repo_pipeline/models.py +80 -0
  17. openworkproof/repo_pipeline/output.py +68 -0
  18. openworkproof/repo_pipeline/reader.py +47 -0
  19. openworkproof/repo_pipeline/sources.py +120 -0
  20. openworkproof/repo_pipeline/traversal.py +253 -0
  21. openworkproof/repo_tools.py +8382 -0
  22. openworkproof/runtime_context.py +189 -0
  23. openworkproof/schema_registry.py +623 -0
  24. openworkproof/schemas/v0.1/acceptance-receipt.schema.json +1 -0
  25. openworkproof/schemas/v0.1/acceptance-rejection-receipt.schema.json +1 -0
  26. openworkproof/schemas/v0.1/action-receipt.schema.json +1 -0
  27. openworkproof/schemas/v0.1/capability-grant.schema.json +1 -0
  28. openworkproof/schemas/v0.1/schema-registry.json +1 -0
  29. openworkproof/schemas/v0.1/work-order.schema.json +1 -0
  30. openworkproof/signing.py +421 -0
  31. openworkproof/state.py +765 -0
  32. openworkproof/team_network_client.py +419 -0
  33. openworkproof/trusted_helper.py +269 -0
  34. openworkproof-1.0.0.dist-info/METADATA +578 -0
  35. openworkproof-1.0.0.dist-info/RECORD +39 -0
  36. openworkproof-1.0.0.dist-info/WHEEL +5 -0
  37. openworkproof-1.0.0.dist-info/entry_points.txt +2 -0
  38. openworkproof-1.0.0.dist-info/licenses/LICENSE +202 -0
  39. openworkproof-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,68 @@
1
+ """Structured output: render a ``RepositoryAnalysis`` to canonical JSON.
2
+
3
+ The output is a single self-describing object so downstream tools can consume
4
+ it without extra context:
5
+
6
+ .. code-block:: json
7
+
8
+ {
9
+ "schema_version": "openworkproof/repo-analysis/0.1",
10
+ "root": "/abs/path",
11
+ "totals": {"files": 3, "bytes": 42},
12
+ "files": [{"path": "a.py", "language": "Python", "size_bytes": 42,
13
+ "sha256": "...", "lines": 2}],
14
+ "results": [{"analyzer": "language_stats", "data": {...}}]
15
+ }
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+
22
+ from .errors import OutputError, get_logger
23
+ from .models import RepositoryAnalysis
24
+
25
+ _logger = get_logger()
26
+
27
+ SCHEMA_VERSION = "openworkproof/repo-analysis/0.1"
28
+
29
+
30
+ def _file_record(entry) -> dict[str, object]:
31
+ return {
32
+ "path": entry.relative_path,
33
+ "language": entry.language,
34
+ "size_bytes": entry.size_bytes,
35
+ "sha256": entry.sha256,
36
+ "lines": entry.content.count("\n"),
37
+ }
38
+
39
+
40
+ def to_dict(analysis: RepositoryAnalysis) -> dict[str, object]:
41
+ return {
42
+ "schema_version": SCHEMA_VERSION,
43
+ "root": analysis.scan.root,
44
+ "totals": {
45
+ "files": analysis.scan.total_files,
46
+ "bytes": analysis.scan.total_bytes,
47
+ "skipped_directories": len(analysis.scan.skipped),
48
+ },
49
+ "files": [
50
+ _file_record(entry) for entry in analysis.scan.entries
51
+ ],
52
+ "results": [
53
+ {"analyzer": result.analyzer, "data": dict(result.data)}
54
+ for result in analysis.results
55
+ ],
56
+ }
57
+
58
+
59
+ def render_json(analysis: RepositoryAnalysis, *, indent: int | None = 2) -> str:
60
+ try:
61
+ return json.dumps(
62
+ to_dict(analysis),
63
+ ensure_ascii=False,
64
+ sort_keys=True,
65
+ indent=indent,
66
+ )
67
+ except (TypeError, ValueError) as error:
68
+ raise OutputError("repository analysis cannot be serialized") from error
@@ -0,0 +1,47 @@
1
+ """File reading: decode a text file with size limits and permission guards.
2
+
3
+ The reader is intentionally a small dependency-free seam so traversal and
4
+ analysis stages can be tested with injected readers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from .errors import DecodeError, PermissionDeniedError, get_logger
14
+
15
+ MAX_TEXT_BYTES = 1_048_576 # 1 MiB: larger files are skipped by the reader
16
+ _logger = get_logger()
17
+
18
+
19
+ def _decode_utf8(raw: bytes) -> str:
20
+ try:
21
+ return raw.decode("utf-8")
22
+ except UnicodeDecodeError as error:
23
+ raise DecodeError("file is not valid UTF-8 text") from error
24
+
25
+
26
+ def read_text_file(path: Path, *, max_bytes: int = MAX_TEXT_BYTES) -> str:
27
+ """Read one file as UTF-8 text with a size cap and permission guard."""
28
+ try:
29
+ size = path.stat().st_size
30
+ except OSError as error:
31
+ raise PermissionDeniedError(f"cannot stat file: {path}") from error
32
+ if size > max_bytes:
33
+ _logger.warning("skipping oversized file (%.1f KiB): %s", size / 1024, path)
34
+ raise DecodeError(f"file exceeds the {max_bytes}-byte text limit: {path}")
35
+ try:
36
+ raw = path.read_bytes()
37
+ except OSError as error:
38
+ raise PermissionDeniedError(f"cannot read file: {path}") from error
39
+ return _decode_utf8(raw)
40
+
41
+
42
+ def sha256_bytes(raw: bytes) -> str:
43
+ return hashlib.sha256(raw).hexdigest()
44
+
45
+
46
+ def file_sha256(path: Path) -> str:
47
+ return sha256_bytes(path.read_bytes())
@@ -0,0 +1,120 @@
1
+ """Input resolution: turn a local path or a remote repository URL into a
2
+ concrete readable repository root.
3
+
4
+ ``resolve_repository_source`` accepts:
5
+
6
+ - a local absolute or relative path to an existing directory;
7
+ - a GitHub URL such as ``https://github.com/owner/repo`` or
8
+ ``https://github.com/owner/repo/tree/<ref>``.
9
+
10
+ Remote sources are cloned into a caller-managed temporary root when
11
+ ``clone_enabled`` is true and the ``git`` executable is available; otherwise a
12
+ ``RemoteCloneError`` is raised so callers can degrade gracefully.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from pathlib import Path
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ import tempfile
23
+ from urllib.parse import urlparse
24
+
25
+ from .errors import (
26
+ PathNotFoundError,
27
+ PermissionDeniedError,
28
+ RemoteCloneError,
29
+ UnsupportedSourceError,
30
+ get_logger,
31
+ )
32
+
33
+ _GITHUB_URL = re.compile(
34
+ r"^https?://(?:www\.)?github\.com/(?P<owner>[A-Za-z0-9_.-]+)/"
35
+ r"(?P<repo>[A-Za-z0-9_.-]+)(?:/tree/(?P<ref>.+))?$"
36
+ )
37
+ _KNOWN_REMOTE_HOSTS = frozenset({"github.com"})
38
+ _logger = get_logger()
39
+
40
+
41
+ def _looks_like_remote_url(value: str) -> bool:
42
+ parsed = urlparse(value)
43
+ return parsed.scheme in {"http", "https"} and parsed.netloc != ""
44
+
45
+
46
+ def _classify(value: str) -> str:
47
+ if _looks_like_remote_url(value):
48
+ match = _GITHUB_URL.match(value)
49
+ if match is not None:
50
+ return "github"
51
+ host = urlparse(value).netloc
52
+ if host in _KNOWN_REMOTE_HOSTS:
53
+ return "github"
54
+ raise UnsupportedSourceError(f"unsupported remote host: {host}")
55
+ return "local"
56
+
57
+
58
+ def resolve_local_path(value: str | Path) -> Path:
59
+ path = Path(value).expanduser()
60
+ if not path.exists():
61
+ raise PathNotFoundError(f"repository path does not exist: {path}")
62
+ if not path.is_dir():
63
+ raise UnsupportedSourceError(f"repository source is not a directory: {path}")
64
+ if not os.access(path, os.R_OK):
65
+ raise PermissionDeniedError(f"repository path is not readable: {path}")
66
+ return path.resolve()
67
+
68
+
69
+ def _clone_github(owner: str, repo: str, ref: str | None) -> Path:
70
+ git = shutil.which("git")
71
+ if git is None:
72
+ raise RemoteCloneError("git executable is unavailable")
73
+ root = Path(tempfile.mkdtemp(prefix="owp-repo-"))
74
+ url = f"https://github.com/{owner}/{repo}.git"
75
+ try:
76
+ subprocess.run(
77
+ (git, "clone", "--depth", "1", url, str(root)),
78
+ check=True,
79
+ capture_output=True,
80
+ timeout=300,
81
+ )
82
+ if ref is not None and ref != "HEAD":
83
+ subprocess.run(
84
+ (git, "-C", str(root), "checkout", ref),
85
+ check=True,
86
+ capture_output=True,
87
+ timeout=120,
88
+ )
89
+ except (subprocess.CalledProcessError, OSError) as error:
90
+ shutil.rmtree(root, ignore_errors=True)
91
+ raise RemoteCloneError(f"remote clone failed for {owner}/{repo}") from error
92
+ return root
93
+
94
+
95
+ def resolve_repository_source(
96
+ source: str | Path,
97
+ *,
98
+ clone_enabled: bool = True,
99
+ ) -> Path:
100
+ """Resolve a local path or remote URL to a readable repository root.
101
+
102
+ Returns the local repository root. Remote sources are cloned into a
103
+ temporary directory that the caller must clean up.
104
+ """
105
+ if isinstance(source, Path):
106
+ return resolve_local_path(source)
107
+ if not isinstance(source, str) or not source:
108
+ raise UnsupportedSourceError("repository source must be a path or URL")
109
+ kind = _classify(source)
110
+ if kind == "local":
111
+ _logger.debug("resolved local repository source: %s", source)
112
+ return resolve_local_path(source)
113
+ match = _GITHUB_URL.match(source)
114
+ if match is None:
115
+ raise UnsupportedSourceError(f"unsupported repository URL: {source}")
116
+ if not clone_enabled:
117
+ raise RemoteCloneError("remote clone is disabled for this pipeline")
118
+ owner, repo, ref = match.group("owner"), match.group("repo"), match.group("ref")
119
+ _logger.info("cloning remote repository %s/%s", owner, repo)
120
+ return _clone_github(owner, repo, ref)
@@ -0,0 +1,253 @@
1
+ """Repository traversal: recursively walk a repository, apply directory
2
+ filters, recognise code file types, and read each supported file.
3
+
4
+ The traversal stage is a pure walker over the local repository root. It
5
+ accepts an injected ``read_file`` callable so tests and callers can substitute
6
+ the reader without touching the walk logic.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Callable
14
+
15
+ from .errors import ReadError, get_logger
16
+ from .models import RepoFileEntry, RepositoryScan, SkippedDirectory
17
+ from .reader import read_text_file, sha256_bytes
18
+
19
+ _logger = get_logger()
20
+
21
+ MAX_FILES = 5_000
22
+ MAX_DIRECTORY_DEPTH = 32
23
+
24
+ # Directories never scanned: VCS, dependency installs, build output, caches.
25
+ FILTERED_DIRECTORIES = frozenset(
26
+ {
27
+ ".git",
28
+ ".hg",
29
+ ".svn",
30
+ "node_modules",
31
+ "bower_components",
32
+ "__pycache__",
33
+ ".venv",
34
+ "venv",
35
+ ".tox",
36
+ ".mypy_cache",
37
+ ".pytest_cache",
38
+ ".ruff_cache",
39
+ ".idea",
40
+ ".vscode",
41
+ "dist",
42
+ "build",
43
+ "out",
44
+ "target",
45
+ ".next",
46
+ ".nuxt",
47
+ ".cache",
48
+ "coverage",
49
+ ".terraform",
50
+ "Pods",
51
+ "vendor",
52
+ }
53
+ )
54
+
55
+ # Extension -> language. Unknown text files still load with language None.
56
+ LANGUAGE_BY_EXTENSION: dict[str, str] = {
57
+ ".py": "Python",
58
+ ".pyi": "Python",
59
+ ".js": "JavaScript",
60
+ ".mjs": "JavaScript",
61
+ ".cjs": "JavaScript",
62
+ ".ts": "TypeScript",
63
+ ".tsx": "TypeScript",
64
+ ".jsx": "JavaScript",
65
+ ".go": "Go",
66
+ ".rs": "Rust",
67
+ ".java": "Java",
68
+ ".kt": "Kotlin",
69
+ ".swift": "Swift",
70
+ ".c": "C",
71
+ ".h": "C",
72
+ ".cpp": "C++",
73
+ ".cc": "C++",
74
+ ".hpp": "C++",
75
+ ".cs": "C#",
76
+ ".rb": "Ruby",
77
+ ".php": "PHP",
78
+ ".sh": "Shell",
79
+ ".bash": "Shell",
80
+ ".zsh": "Shell",
81
+ ".fish": "Shell",
82
+ ".ps1": "PowerShell",
83
+ ".sql": "SQL",
84
+ ".html": "HTML",
85
+ ".htm": "HTML",
86
+ ".css": "CSS",
87
+ ".scss": "SCSS",
88
+ ".less": "Less",
89
+ ".json": "JSON",
90
+ ".yaml": "YAML",
91
+ ".yml": "YAML",
92
+ ".toml": "TOML",
93
+ ".xml": "XML",
94
+ ".md": "Markdown",
95
+ ".rst": "RST",
96
+ ".tex": "LaTeX",
97
+ ".proto": "Protocol Buffers",
98
+ ".graphql": "GraphQL",
99
+ ".vue": "Vue",
100
+ ".svelte": "Svelte",
101
+ ".dockerfile": "Dockerfile",
102
+ ".tf": "Terraform",
103
+ ".lua": "Lua",
104
+ ".pl": "Perl",
105
+ ".r": "R",
106
+ ".m": "Objective-C",
107
+ ".dart": "Dart",
108
+ ".ex": "Elixir",
109
+ ".exs": "Elixir",
110
+ ".erl": "Erlang",
111
+ ".hs": "Haskell",
112
+ ".clj": "Clojure",
113
+ ".scala": "Scala",
114
+ ".groovy": "Groovy",
115
+ ".fs": "F#",
116
+ }
117
+
118
+ # File names treated as code regardless of extension.
119
+ NAME_LANGUAGES: dict[str, str] = {
120
+ "Dockerfile": "Dockerfile",
121
+ "Makefile": "Makefile",
122
+ "CMakeLists.txt": "CMake",
123
+ "Justfile": "Justfile",
124
+ "Jenkinsfile": "Jenkinsfile",
125
+ "Rakefile": "Ruby",
126
+ "Gemfile": "Ruby",
127
+ "Cargo.toml": "TOML",
128
+ "go.mod": "Go",
129
+ }
130
+
131
+
132
+ def language_for(path: Path) -> str | None:
133
+ name = path.name
134
+ if name in NAME_LANGUAGES:
135
+ return NAME_LANGUAGES[name]
136
+ return LANGUAGE_BY_EXTENSION.get(path.suffix.lower())
137
+
138
+
139
+ def _walk_relative(
140
+ top: Path,
141
+ current: Path,
142
+ *,
143
+ depth: int,
144
+ filters: frozenset[str],
145
+ max_files: int,
146
+ max_depth: int,
147
+ ) -> tuple[list[RepoFileEntry], list[SkippedDirectory]]:
148
+ entries: list[RepoFileEntry] = []
149
+ skipped: list[SkippedDirectory] = []
150
+ if depth > max_depth:
151
+ _logger.warning("traversal depth limit reached at %s", current)
152
+ return entries, skipped
153
+ try:
154
+ children = sorted(current.iterdir(), key=lambda item: item.name)
155
+ except OSError as error:
156
+ _logger.error("cannot list directory %s: %s", current, error)
157
+ return entries, skipped
158
+ for child in children:
159
+ if child.is_symlink():
160
+ _logger.debug("skipping symlink: %s", child)
161
+ continue
162
+ if child.is_dir():
163
+ if child.name in filters:
164
+ skipped.append(
165
+ SkippedDirectory(
166
+ relative_path=child.relative_to(top).as_posix(),
167
+ rule="filtered-directory",
168
+ )
169
+ )
170
+ _logger.debug("filtering directory: %s", child.name)
171
+ continue
172
+ sub_entries, sub_skipped = _walk_relative(
173
+ top,
174
+ child,
175
+ depth=depth + 1,
176
+ filters=filters,
177
+ max_files=max_files,
178
+ max_depth=max_depth,
179
+ )
180
+ entries.extend(sub_entries)
181
+ skipped.extend(sub_skipped)
182
+ continue
183
+ if not child.is_file():
184
+ continue
185
+ if len(entries) >= max_files:
186
+ _logger.warning("file count limit reached (%d files)", max_files)
187
+ break
188
+ language = language_for(child)
189
+ if language is None:
190
+ _logger.debug("skipping unrecognised file type: %s", child.name)
191
+ continue
192
+ entries.append(
193
+ RepoFileEntry(
194
+ relative_path=child.relative_to(top).as_posix(),
195
+ language=language,
196
+ size_bytes=0,
197
+ content="",
198
+ sha256="",
199
+ )
200
+ )
201
+ return entries, skipped
202
+
203
+
204
+ def scan_repository(
205
+ root: Path,
206
+ *,
207
+ filters: frozenset[str] = FILTERED_DIRECTORIES,
208
+ max_files: int = MAX_FILES,
209
+ max_depth: int = MAX_DIRECTORY_DEPTH,
210
+ read_file: Callable[[Path], str] = read_text_file,
211
+ ) -> RepositoryScan:
212
+ """Walk ``root`` and build a ``RepositoryScan`` of supported text files."""
213
+ placeholder_entries, skipped = _walk_relative(
214
+ root,
215
+ root,
216
+ depth=0,
217
+ filters=filters,
218
+ max_files=max_files,
219
+ max_depth=max_depth,
220
+ )
221
+ entries: list[RepoFileEntry] = []
222
+ total_bytes = 0
223
+ for placeholder in placeholder_entries:
224
+ path = root / placeholder.relative_path
225
+ try:
226
+ content = read_file(path)
227
+ except ReadError as error:
228
+ _logger.warning("skipping unreadable file %s: %s", path, error)
229
+ continue
230
+ raw = content.encode("utf-8")
231
+ total_bytes += len(raw)
232
+ entries.append(
233
+ RepoFileEntry(
234
+ relative_path=placeholder.relative_path,
235
+ language=placeholder.language,
236
+ size_bytes=len(raw),
237
+ content=content,
238
+ sha256=sha256_bytes(raw),
239
+ )
240
+ )
241
+ entries.sort(key=lambda entry: entry.relative_path)
242
+ languages: dict[str, int] = {}
243
+ for entry in entries:
244
+ if entry.language is not None:
245
+ languages[entry.language] = languages.get(entry.language, 0) + 1
246
+ return RepositoryScan(
247
+ root=str(root),
248
+ entries=tuple(entries),
249
+ skipped=tuple(skipped),
250
+ total_files=len(entries),
251
+ total_bytes=total_bytes,
252
+ languages=languages,
253
+ )