mortis-engine 0.1.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.
- mortis_engine/__init__.py +3 -0
- mortis_engine/__main__.py +3 -0
- mortis_engine/admission.py +117 -0
- mortis_engine/assessments.py +346 -0
- mortis_engine/cli/__init__.py +39 -0
- mortis_engine/cli/analysis.py +113 -0
- mortis_engine/cli/config.py +41 -0
- mortis_engine/cli/connect.py +255 -0
- mortis_engine/cli/findings.py +46 -0
- mortis_engine/cli/followups.py +70 -0
- mortis_engine/cli/reports.py +89 -0
- mortis_engine/cli/review.py +279 -0
- mortis_engine/code_graph/Dockerfile +8 -0
- mortis_engine/code_graph/__init__.py +122 -0
- mortis_engine/code_graph/build.py +45 -0
- mortis_engine/code_graph/contracts.py +266 -0
- mortis_engine/code_graph/extract.py +248 -0
- mortis_engine/code_graph/requirements.txt +14 -0
- mortis_engine/code_graph/syntax.py +73 -0
- mortis_engine/code_graph/transport.py +107 -0
- mortis_engine/code_graph/validation.py +75 -0
- mortis_engine/config.py +196 -0
- mortis_engine/discovery.py +76 -0
- mortis_engine/findings.py +150 -0
- mortis_engine/gateway.py +85 -0
- mortis_engine/history.py +178 -0
- mortis_engine/llm_bedrock.py +112 -0
- mortis_engine/llm_http.py +616 -0
- mortis_engine/operations.py +135 -0
- mortis_engine/providers.py +68 -0
- mortis_engine/reporting.py +163 -0
- mortis_engine/review/__init__.py +14 -0
- mortis_engine/review/assessment.py +407 -0
- mortis_engine/review/background.py +103 -0
- mortis_engine/review/context.py +71 -0
- mortis_engine/review/contracts.py +37 -0
- mortis_engine/review/engine.py +628 -0
- mortis_engine/review/errors.py +2 -0
- mortis_engine/review/evidence.py +68 -0
- mortis_engine/review/execution.py +216 -0
- mortis_engine/review/file_policy.py +67 -0
- mortis_engine/review/planning.py +532 -0
- mortis_engine/review/policy.py +31 -0
- mortis_engine/review/preview.py +67 -0
- mortis_engine/review/service.py +208 -0
- mortis_engine/review/settings.py +137 -0
- mortis_engine/review/snapshots.py +239 -0
- mortis_engine/review/source.py +273 -0
- mortis_engine/review/source_navigation.py +104 -0
- mortis_engine/review/summary.py +141 -0
- mortis_engine/review/verification.py +271 -0
- mortis_engine/review/workflow_contracts.py +70 -0
- mortis_engine/review/workflows.py +398 -0
- mortis_engine/schemas.py +156 -0
- mortis_engine/security.py +47 -0
- mortis_engine/security_scans/__init__.py +1 -0
- mortis_engine/security_scans/advisories.py +82 -0
- mortis_engine/security_scans/context.py +214 -0
- mortis_engine/security_scans/contracts.py +77 -0
- mortis_engine/security_scans/engine.py +510 -0
- mortis_engine/security_scans/inference.py +133 -0
- mortis_engine/security_scans/payload.py +78 -0
- mortis_engine/security_scans/policy.py +39 -0
- mortis_engine/security_scans/sandbox.py +36 -0
- mortis_engine/security_scans/scanner.py +169 -0
- mortis_engine/security_scans/service.py +466 -0
- mortis_engine/workflow.py +274 -0
- mortis_engine-0.1.0.dist-info/METADATA +430 -0
- mortis_engine-0.1.0.dist-info/RECORD +72 -0
- mortis_engine-0.1.0.dist-info/WHEEL +4 -0
- mortis_engine-0.1.0.dist-info/entry_points.txt +2 -0
- mortis_engine-0.1.0.dist-info/licenses/LICENSE +205 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Eight OS-locked request slots per local connection, shared across repositories."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import errno
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
from .history import encode, private_directory
|
|
14
|
+
from .llm_http import InferenceError
|
|
15
|
+
|
|
16
|
+
CAPACITY = 8
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def connection_key(connection, references=None):
|
|
20
|
+
"""Hash only the destination and nonsecret references, never credential values."""
|
|
21
|
+
options = connection.get("options", {})
|
|
22
|
+
parsed = urlsplit(connection.get("base_url", ""))
|
|
23
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
24
|
+
raise ValueError("Provider URLs cannot contain credentials, query parameters, or fragments")
|
|
25
|
+
host = (parsed.hostname or "").lower()
|
|
26
|
+
if ":" in host:
|
|
27
|
+
host = f"[{host}]"
|
|
28
|
+
port = parsed.port
|
|
29
|
+
if port and port != {"https": 443, "http": 80}.get(parsed.scheme.lower()):
|
|
30
|
+
host += f":{port}"
|
|
31
|
+
destination = urlunsplit((parsed.scheme.lower(), host, parsed.path.rstrip("/"), "", ""))
|
|
32
|
+
protocol = connection.get("protocol", "").lower()
|
|
33
|
+
if protocol == "anthropic" and not destination.endswith("/v1"):
|
|
34
|
+
destination += "/v1"
|
|
35
|
+
auth_header = options.get("auth_header") or ("x-api-key" if protocol == "anthropic" else "authorization")
|
|
36
|
+
region, profile = "", ""
|
|
37
|
+
if protocol == "anthropic-bedrock":
|
|
38
|
+
region = options.get("aws_region") or os.environ.get("AWS_REGION", "")
|
|
39
|
+
profile = options.get("aws_profile") or os.environ.get("AWS_PROFILE") or os.environ.get("AWS_DEFAULT_PROFILE", "")
|
|
40
|
+
value = {"provider": connection.get("provider", "").lower(), "protocol": protocol,
|
|
41
|
+
"destination": destination, "auth_header": auth_header.lower(),
|
|
42
|
+
"aws_region": region, "aws_profile": profile,
|
|
43
|
+
"references": references or {}}
|
|
44
|
+
return hashlib.sha256(encode(value).encode()).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def state_directory(connection):
|
|
48
|
+
root = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state")
|
|
49
|
+
if not root.is_absolute():
|
|
50
|
+
raise ValueError("XDG_STATE_HOME must be an absolute path.")
|
|
51
|
+
key = connection.get("_admission_key") or connection_key(connection)
|
|
52
|
+
if not isinstance(key, str) or len(key) != 64 or any(char not in "0123456789abcdef" for char in key):
|
|
53
|
+
raise ValueError("Invalid internal connection admission key.")
|
|
54
|
+
return private_directory(private_directory(private_directory(root / "mortis") / "admission") / key)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def try_slot(directory):
|
|
58
|
+
for number in range(CAPACITY):
|
|
59
|
+
path = directory / f"{number}.lock"
|
|
60
|
+
if path.is_symlink():
|
|
61
|
+
raise ValueError("Mortis admission requires private regular slot files.")
|
|
62
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
63
|
+
try:
|
|
64
|
+
info = os.fstat(fd)
|
|
65
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077 or info.st_nlink != 1
|
|
66
|
+
or hasattr(os, "getuid") and info.st_uid != os.getuid()):
|
|
67
|
+
raise ValueError("Mortis admission requires private regular slot files.")
|
|
68
|
+
if os.name == "nt":
|
|
69
|
+
import msvcrt
|
|
70
|
+
|
|
71
|
+
if not info.st_size:
|
|
72
|
+
os.write(fd, b"0")
|
|
73
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
74
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
75
|
+
else:
|
|
76
|
+
import fcntl
|
|
77
|
+
|
|
78
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
os.close(fd)
|
|
81
|
+
if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
|
|
82
|
+
raise
|
|
83
|
+
except BaseException:
|
|
84
|
+
os.close(fd)
|
|
85
|
+
raise
|
|
86
|
+
else:
|
|
87
|
+
return fd
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@asynccontextmanager
|
|
92
|
+
async def slot(connection, *, stopped, until, waiting=lambda: None):
|
|
93
|
+
directory = None
|
|
94
|
+
notified = False
|
|
95
|
+
descriptor = None
|
|
96
|
+
try:
|
|
97
|
+
while True:
|
|
98
|
+
if stopped():
|
|
99
|
+
raise InferenceError("cancelled", "Inference was cancelled while waiting for connection capacity.", consumption_unknown=False)
|
|
100
|
+
remaining = until - time.monotonic()
|
|
101
|
+
if remaining <= 0:
|
|
102
|
+
raise InferenceError("deadline", "The stage deadline expired while waiting for connection capacity.", consumption_unknown=False)
|
|
103
|
+
directory = directory or state_directory(connection)
|
|
104
|
+
descriptor = try_slot(directory)
|
|
105
|
+
if descriptor is not None:
|
|
106
|
+
break
|
|
107
|
+
if not notified:
|
|
108
|
+
notified = True
|
|
109
|
+
waiting()
|
|
110
|
+
await asyncio.sleep(min(0.05, remaining))
|
|
111
|
+
# Recheck after the filesystem operation and before any reservation or dispatch.
|
|
112
|
+
if stopped() or time.monotonic() >= until:
|
|
113
|
+
raise InferenceError("cancelled" if stopped() else "deadline", "Inference stopped before dispatch.", consumption_unknown=False)
|
|
114
|
+
yield
|
|
115
|
+
finally:
|
|
116
|
+
if descriptor is not None:
|
|
117
|
+
os.close(descriptor)
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Exact-input, candidate-scoped assessment retries without rewriting their origin."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import copy
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .history import History, encode, private_directory
|
|
12
|
+
from .llm_http import InferenceError
|
|
13
|
+
from .review import context as review_context
|
|
14
|
+
from .review import planning, source, workflows
|
|
15
|
+
from .review.service import credentials_for_history, effective_connection
|
|
16
|
+
from .workflow import Session
|
|
17
|
+
|
|
18
|
+
INPUT_VERSION = "mortis.assessment-input/v1"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def connection_identity(connection):
|
|
22
|
+
return planning.fingerprint({key: value for key, value in connection.items() if key != "_admission_key"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _revisions(value):
|
|
26
|
+
if isinstance(value, dict):
|
|
27
|
+
for key, child in value.items():
|
|
28
|
+
if key in {"base_sha", "head_sha", "source_sha", "original_sha", "_settings_sha", "settings_sha", "sha"}:
|
|
29
|
+
if isinstance(child, str) and len(child) == 40 and all(c in "0123456789abcdef" for c in child):
|
|
30
|
+
yield child
|
|
31
|
+
elif key not in {"inventory", "_graph"}:
|
|
32
|
+
yield from _revisions(child)
|
|
33
|
+
elif isinstance(value, list):
|
|
34
|
+
for child in value:
|
|
35
|
+
yield from _revisions(child)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _retain(session, repo, env, revisions, value):
|
|
39
|
+
from .review import file_policy
|
|
40
|
+
|
|
41
|
+
cached = getattr(session, "_assessment_objects", {})
|
|
42
|
+
objects = set()
|
|
43
|
+
options = value["options"]
|
|
44
|
+
for sha in revisions:
|
|
45
|
+
if sha in cached:
|
|
46
|
+
objects.update(cached[sha])
|
|
47
|
+
continue
|
|
48
|
+
retained = set(source.git(repo, env, "rev-list", sha).splitlines())
|
|
49
|
+
retained.add(source.git(repo, env, "rev-parse", sha + "^{tree}").strip())
|
|
50
|
+
entries = source.git(repo, env, "ls-tree", "-r", "-t", "-l", "-z", sha).rstrip("\0").split("\0")
|
|
51
|
+
blobs = {}
|
|
52
|
+
for entry in filter(None, entries):
|
|
53
|
+
metadata, path = entry.split("\t", 1)
|
|
54
|
+
mode, kind, identity, size = metadata.split()
|
|
55
|
+
if kind == "tree":
|
|
56
|
+
retained.add(identity)
|
|
57
|
+
elif mode in {"100644", "100755"} and int(size) <= 1_500_000:
|
|
58
|
+
blobs[path] = (identity, int(size))
|
|
59
|
+
allowed = {path for path in blobs if not file_policy.excluded(path, file_policy.policy(options))}
|
|
60
|
+
if value["workflow_kind"] == "review":
|
|
61
|
+
allowed &= planning.allowed_context(repo, env, value["base_sha"], value["head_sha"],
|
|
62
|
+
allowed, options, lambda: False)
|
|
63
|
+
else:
|
|
64
|
+
allowed &= set(value["context"]["available_paths"])
|
|
65
|
+
total = 0
|
|
66
|
+
for path in sorted(allowed):
|
|
67
|
+
try:
|
|
68
|
+
source.read_source(repo, env, sha, path)
|
|
69
|
+
except ValueError:
|
|
70
|
+
continue
|
|
71
|
+
total += blobs[path][1]
|
|
72
|
+
if total > 32_000_000:
|
|
73
|
+
raise ValueError("Original supporting source exceeds the 32 MB retry retention limit.")
|
|
74
|
+
retained.add(blobs[path][0])
|
|
75
|
+
objects.update(retained)
|
|
76
|
+
cached[sha] = retained
|
|
77
|
+
directory = private_directory(source._object_cache(session.history.objects) / "pack")
|
|
78
|
+
source.git(repo, env, "pack-objects", str(directory / "pack"),
|
|
79
|
+
input_bytes=("\n".join(sorted(objects)) + "\n").encode())
|
|
80
|
+
session._assessment_objects = cached
|
|
81
|
+
return sorted(objects)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def pin_input(session, candidate_id, kind, candidate, context, options, task_key, repo, env, **evidence):
|
|
85
|
+
"""Persist sanitized input before dispatch; existing records can never be replaced."""
|
|
86
|
+
from .findings import snapshot
|
|
87
|
+
|
|
88
|
+
if kind == "review":
|
|
89
|
+
options = {**options, "_evidence_policy": options.get("_evidence_policy", planning.EVIDENCE_POLICY)}
|
|
90
|
+
row = session.history.row(session.run_id)
|
|
91
|
+
plan = json.loads(row["plan"])
|
|
92
|
+
value = review_context.sanitize({"schema_version": INPUT_VERSION, "validator_version": 1,
|
|
93
|
+
"workflow_kind": kind, "candidate": candidate, "context": context, "options": options,
|
|
94
|
+
"task_key": task_key, "connection": connection_identity(session.connection),
|
|
95
|
+
"admission_key": session.connection.get("_admission_key"),
|
|
96
|
+
"triage_versions": plan.get("triage_versions", snapshot(session.history, kind, row["scope"])),
|
|
97
|
+
**evidence}, session.credentials)
|
|
98
|
+
revisions = sorted(set(_revisions(value)))
|
|
99
|
+
try:
|
|
100
|
+
value["retained_objects"] = _retain(session, repo, env, revisions, value)
|
|
101
|
+
except (ValueError, TimeoutError, OSError) as exc:
|
|
102
|
+
value["retry_blocked_reason"] = review_context.sanitize(str(exc), session.credentials)
|
|
103
|
+
value["revisions"] = revisions
|
|
104
|
+
session.history.save_assessment_input(session.run_id, candidate_id, value)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _children(history, parent, candidate):
|
|
108
|
+
result = []
|
|
109
|
+
for row in history.db.execute("SELECT * FROM runs WHERE kind='assessment' ORDER BY started, id"):
|
|
110
|
+
plan = json.loads(row["plan"])
|
|
111
|
+
if plan.get("parent_run_id") == parent and plan.get("candidate_id") == candidate:
|
|
112
|
+
result.append(row)
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _unknown(history, parent, value, children):
|
|
117
|
+
kind = value["workflow_kind"]
|
|
118
|
+
stage = "assessment" if kind == "review" else "security_assessment"
|
|
119
|
+
for (record_stage, key), (_, row, _) in history.attempts(parent).items():
|
|
120
|
+
relevant = (record_stage.startswith("security_") if kind == "scan" else
|
|
121
|
+
record_stage == stage and (key == value["task_key"] or key.startswith(value["task_key"] + ":")))
|
|
122
|
+
if relevant and row.get("consumption_unknown"):
|
|
123
|
+
return True
|
|
124
|
+
return any(record.get("consumption_unknown") for child in children
|
|
125
|
+
for _, record, _ in history.attempts(child["id"]).values())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _eligibility(history, origin, candidate, value, children):
|
|
129
|
+
if candidate.get("status") != "failed":
|
|
130
|
+
return "Only failed assessments can be retried."
|
|
131
|
+
if value is None:
|
|
132
|
+
return "This run predates retry support or has no exact saved assessment evidence."
|
|
133
|
+
if value.get("schema_version") != INPUT_VERSION or value.get("validator_version") != 1:
|
|
134
|
+
return "The saved assessment input or validator version is incompatible."
|
|
135
|
+
if value.get("retry_blocked_reason"):
|
|
136
|
+
return value["retry_blocked_reason"]
|
|
137
|
+
if origin["report"] is None:
|
|
138
|
+
return "Resume the original run before retrying its failed assessment."
|
|
139
|
+
if _unknown(history, origin["id"], value, children):
|
|
140
|
+
return "Unknown token consumption in this assessment lineage blocks retry."
|
|
141
|
+
if any(child["report"] is None for child in children):
|
|
142
|
+
return "An unfinished assessment retry exists; resume it using its run ID."
|
|
143
|
+
from .findings import snapshot
|
|
144
|
+
if snapshot(history, origin["kind"], origin["scope"]) != value["triage_versions"]:
|
|
145
|
+
return "Finding decisions changed since the assessment was pinned; its history is stale."
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _candidates(history, run_id):
|
|
150
|
+
origin = history.row(run_id)
|
|
151
|
+
if origin["kind"] not in {"review", "scan"}:
|
|
152
|
+
raise ValueError("Choose a review or security scan run.")
|
|
153
|
+
report = json.loads(origin["report"]) if origin["report"] else {}
|
|
154
|
+
return origin, report.get("candidates", [])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _validate_evidence(repo, env, value):
|
|
158
|
+
try:
|
|
159
|
+
for sha in value["revisions"]:
|
|
160
|
+
source.resolve_commit(repo, env, sha)
|
|
161
|
+
source.git(repo, env, "cat-file", "-e", sha + "^{tree}")
|
|
162
|
+
checked = source.git(repo, env, "cat-file", "--batch-check",
|
|
163
|
+
input_bytes=("\n".join(value["retained_objects"]) + "\n").encode())
|
|
164
|
+
if any(line.endswith(" missing") for line in checked.splitlines()):
|
|
165
|
+
raise ValueError("Missing retained object")
|
|
166
|
+
except (KeyError, ValueError) as exc:
|
|
167
|
+
raise ValueError("Exact original assessment evidence is unavailable; retry was withheld.") from exc
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def list_assessments(repository, *, run_id):
|
|
171
|
+
"""List candidates and retry blockers without loading connection credentials."""
|
|
172
|
+
with History(repository) as history:
|
|
173
|
+
origin, candidates = _candidates(history, run_id)
|
|
174
|
+
inputs = history.assessment_inputs(run_id)
|
|
175
|
+
rows = []
|
|
176
|
+
for candidate in candidates:
|
|
177
|
+
children = _children(history, run_id, candidate["id"])
|
|
178
|
+
success = next((json.loads(row["report"]) for row in children if row["report"]
|
|
179
|
+
and json.loads(row["report"]).get("status") == "completed"), None)
|
|
180
|
+
reason = _eligibility(history, origin, candidate, inputs.get(candidate["id"]), children)
|
|
181
|
+
if success:
|
|
182
|
+
reason = "A successful retry is already saved; assess returns it without dispatch."
|
|
183
|
+
elif reason is None:
|
|
184
|
+
try:
|
|
185
|
+
with source.local_repository(repository, object_cache=history.objects) as (repo, env):
|
|
186
|
+
_validate_evidence(repo, env, inputs[candidate["id"]])
|
|
187
|
+
except (ValueError, OSError) as exc:
|
|
188
|
+
reason = str(exc)
|
|
189
|
+
rows.append({"candidate_id": candidate["id"], "path": candidate["snapshot"].get("path"),
|
|
190
|
+
"status": candidate["status"], "error": candidate.get("error"),
|
|
191
|
+
"retry_eligible": reason is None, "blocked_reason": reason,
|
|
192
|
+
"retry_run_id": success["run_id"] if success else children[-1]["id"] if children else None})
|
|
193
|
+
if origin["kind"] == "scan" and not candidates and report_has_legacy_failures(origin):
|
|
194
|
+
return {"schema_version": "mortis.assessments/v1", "run_id": run_id, "candidates": [],
|
|
195
|
+
"blocked_reason": "This scan predates durable assessment candidates and retry support."}
|
|
196
|
+
return {"schema_version": "mortis.assessments/v1", "run_id": run_id, "candidates": rows}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def report_has_legacy_failures(origin):
|
|
200
|
+
report = json.loads(origin["report"]) if origin["report"] else {}
|
|
201
|
+
return any(unit.get("task", "").startswith("assessment:") and unit.get("status") == "failed"
|
|
202
|
+
for unit in report.get("coverage", {}).get("units", []))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _lineage_usage(history, parent, value, children):
|
|
206
|
+
records = []
|
|
207
|
+
for run_id in [parent, *(child["id"] for child in children)]:
|
|
208
|
+
for (stage, key), (_, record, _) in history.attempts(run_id).items():
|
|
209
|
+
if run_id == parent and not (stage == ("assessment" if value["workflow_kind"] == "review" else "security_assessment")
|
|
210
|
+
and (key == value["task_key"] or key.startswith(value["task_key"] + ":"))):
|
|
211
|
+
continue
|
|
212
|
+
records.append({"run_id": run_id, **record})
|
|
213
|
+
charge = sum(Session.charge(record) for record in records)
|
|
214
|
+
return {"requests": records, "reserved_tokens": charge,
|
|
215
|
+
"total_tokens": None if any(row["consumption_unknown"] for row in records) else charge}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _finding(value, candidate_id, result):
|
|
219
|
+
if result["verdict"] != "confirmed":
|
|
220
|
+
return None
|
|
221
|
+
candidate = copy.deepcopy(value["candidate"])
|
|
222
|
+
if value["workflow_kind"] == "review":
|
|
223
|
+
identity = result["finding_id"] or "finding-" + candidate_id.removeprefix("candidate-")
|
|
224
|
+
old = next((old for old in value["context"].get("history", []) if old["id"] == identity), None)
|
|
225
|
+
if not result["suggestion_supported"]:
|
|
226
|
+
candidate["suggestion_code"] = ""
|
|
227
|
+
return {**candidate, "id": identity, "assessment": result, "status": old["status"] if old else "open"}
|
|
228
|
+
from .security import digest
|
|
229
|
+
from .security_scans.service import observe
|
|
230
|
+
fingerprint = digest(json.dumps(["code", candidate["path"], candidate["category"],
|
|
231
|
+
candidate["evidence"][0]["quote"], candidate["title"]]))
|
|
232
|
+
identity = result["finding_id"] or fingerprint[:32]
|
|
233
|
+
old = next((old for old in value["context"].get("history", []) if old["id"] == identity), None)
|
|
234
|
+
evidence = {**candidate, "kind": "code", "source_sha": value["head_sha"],
|
|
235
|
+
"source_hash": value["redaction"]["inventory"][candidate["path"]]["hash"],
|
|
236
|
+
"fingerprint": fingerprint, "assessment": result, "sources": ["mortis"]}
|
|
237
|
+
return observe(evidence, old)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def _execute(repository, repo, env, value, session):
|
|
241
|
+
options = value["options"]
|
|
242
|
+
if value["workflow_kind"] == "review":
|
|
243
|
+
options = {**options, "_evidence_policy": planning.saved_evidence_policy(options)}
|
|
244
|
+
report = {"base_sha": value["base_sha"], "head_sha": value["head_sha"]}
|
|
245
|
+
return await workflows._assess(repo, env, workflows._run(report, options, session),
|
|
246
|
+
value["candidate"], {}, session, pinned_context=value["context"])
|
|
247
|
+
from .review import file_policy
|
|
248
|
+
from .security_scans import engine, inference
|
|
249
|
+
from .security_scans.scanner import identity_key
|
|
250
|
+
with tempfile.TemporaryDirectory(prefix="mortis-assessment-") as temporary:
|
|
251
|
+
item = engine.Snapshot(Path(temporary), value["head_sha"], env, lambda: False,
|
|
252
|
+
git=repo, key=identity_key(create=False))
|
|
253
|
+
item.repository = Path(repository).resolve()
|
|
254
|
+
item.policy = file_policy.policy(options)
|
|
255
|
+
item.inventory = value["redaction"]["inventory"]
|
|
256
|
+
item.secret_rules = value["redaction"]["secret_rules"]
|
|
257
|
+
item.secret_lines = value["redaction"]["secret_lines"]
|
|
258
|
+
item.deadline = session.deadline
|
|
259
|
+
result, _ = await inference.ask(item, session, "assessment", value["context"], value["task_key"],
|
|
260
|
+
options, prepared=True)
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def retry_assessment(repository, *, run_id=None, candidate_id=None, resume=None,
|
|
265
|
+
connection, get_credentials, profile=None, progress=lambda value: None):
|
|
266
|
+
"""Retry one failed candidate against pinned inputs, under a durable candidate claim."""
|
|
267
|
+
if resume and (run_id or candidate_id):
|
|
268
|
+
raise ValueError("--resume uses the parent and candidate of the saved retry.")
|
|
269
|
+
with History(repository) as history:
|
|
270
|
+
if resume:
|
|
271
|
+
retry = history.row(resume, "assessment")
|
|
272
|
+
saved = json.loads(retry["plan"])
|
|
273
|
+
run_id, candidate_id = saved["parent_run_id"], saved["candidate_id"]
|
|
274
|
+
elif not run_id or not candidate_id:
|
|
275
|
+
raise ValueError("Assessment retry requires a run ID and candidate ID.")
|
|
276
|
+
claim = hashlib.sha256(encode([run_id, candidate_id]).encode()).hexdigest()[:32]
|
|
277
|
+
with history.claim(claim):
|
|
278
|
+
origin, candidates = _candidates(history, run_id)
|
|
279
|
+
candidate = next((item for item in candidates if item["id"] == candidate_id), None)
|
|
280
|
+
if candidate is None:
|
|
281
|
+
raise ValueError("No saved assessment candidate with that ID; this run may predate retry support.")
|
|
282
|
+
children = _children(history, run_id, candidate_id)
|
|
283
|
+
success = next((json.loads(row["report"]) for row in children if row["report"]
|
|
284
|
+
and json.loads(row["report"]).get("status") == "completed"), None)
|
|
285
|
+
if success:
|
|
286
|
+
return success
|
|
287
|
+
if resume and retry["report"]:
|
|
288
|
+
return json.loads(retry["report"])
|
|
289
|
+
value = history.assessment_input(run_id, candidate_id)
|
|
290
|
+
reason = _eligibility(history, origin, candidate, value,
|
|
291
|
+
[child for child in children if child["id"] != resume])
|
|
292
|
+
if reason:
|
|
293
|
+
raise ValueError(reason)
|
|
294
|
+
if _unknown(history, run_id, value, children):
|
|
295
|
+
raise ValueError("Unknown token consumption in this assessment lineage blocks retry.")
|
|
296
|
+
connection = effective_connection(connection, value["options"])
|
|
297
|
+
if (connection_identity(connection) != value["connection"]
|
|
298
|
+
or connection.get("_admission_key") != value.get("admission_key")):
|
|
299
|
+
raise ValueError("The selected connection changed since the original assessment.")
|
|
300
|
+
if resume and saved["input"] != planning.fingerprint(value):
|
|
301
|
+
raise ValueError("The retry's saved assessment input changed.")
|
|
302
|
+
# Validate retained revisions before credentials or provider-client initialization.
|
|
303
|
+
with source.local_repository(repository, object_cache=history.objects) as (repo, env):
|
|
304
|
+
_validate_evidence(repo, env, value)
|
|
305
|
+
if value["workflow_kind"] == "scan":
|
|
306
|
+
from .security_scans import engine
|
|
307
|
+
from .security_scans.scanner import identity_key
|
|
308
|
+
if engine.secure_digest("assessment-redaction-key", identity_key(create=False)) != value["redaction"]["key_check"]:
|
|
309
|
+
raise ValueError("The original security redaction key changed; retry was withheld.")
|
|
310
|
+
credentials = credentials_for_history(history, get_credentials)
|
|
311
|
+
if review_context.sanitize(value["context"], credentials) != value["context"]:
|
|
312
|
+
raise ValueError("The original evidence no longer matches safe redaction; retry was withheld.")
|
|
313
|
+
child_id = resume or history.begin("assessment", origin["scope"], {
|
|
314
|
+
"parent_run_id": run_id, "candidate_id": candidate_id, "profile": profile,
|
|
315
|
+
"input": planning.fingerprint(value)})
|
|
316
|
+
timeout = value["options"].get("timeout_seconds")
|
|
317
|
+
deadline = time.monotonic() + timeout if timeout else None
|
|
318
|
+
session = Session(connection, credentials, history=history, run_id=child_id,
|
|
319
|
+
deadline=deadline, progress=progress)
|
|
320
|
+
progress({"phase": "assessment"})
|
|
321
|
+
|
|
322
|
+
async def execute():
|
|
323
|
+
async with session:
|
|
324
|
+
return await _execute(repository, repo, env, value, session)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
result = asyncio.run(execute())
|
|
328
|
+
status, error = "completed", None
|
|
329
|
+
except (ValueError, OSError, InferenceError) as exc:
|
|
330
|
+
result, status = None, "failed"
|
|
331
|
+
error = review_context.sanitize(str(exc), credentials)
|
|
332
|
+
from .findings import snapshot
|
|
333
|
+
if snapshot(history, origin["kind"], origin["scope"]) != value["triage_versions"]:
|
|
334
|
+
status = "failed"
|
|
335
|
+
error = "Finding decisions changed while retrying; the saved result cannot update newer history."
|
|
336
|
+
children = _children(history, run_id, candidate_id)
|
|
337
|
+
report = {"schema_version": "mortis.assessment/v1", "run_id": child_id,
|
|
338
|
+
"parent_run_id": run_id, "candidate_id": candidate_id, "workflow_kind": origin["kind"],
|
|
339
|
+
"status": status, "result": result,
|
|
340
|
+
"finding": _finding(value, candidate_id, result) if result and status == "completed" else None,
|
|
341
|
+
"head_sha": value["head_sha"], "model": connection["model"], "provider": connection["provider"],
|
|
342
|
+
"usage": session.usage(), "lineage_usage": _lineage_usage(history, run_id, value, children)}
|
|
343
|
+
if error:
|
|
344
|
+
report["error"] = error
|
|
345
|
+
history.finish(child_id, report)
|
|
346
|
+
return report
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Command-line entry point for Mortis Engine."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from keyring.errors import KeyringError
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from mortis_engine import __version__
|
|
10
|
+
from mortis_engine.llm_http import InferenceError
|
|
11
|
+
|
|
12
|
+
from . import analysis, config, connect, findings, followups, reports, review
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None):
|
|
16
|
+
parser = argparse.ArgumentParser(prog="mortis", description="Review local Git changes with your AI connection.")
|
|
17
|
+
parser.add_argument("--version", action="version", version=f"mortis {__version__}")
|
|
18
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
connect.add_parser(commands)
|
|
20
|
+
review.add_parser(commands)
|
|
21
|
+
config.add_parser(commands)
|
|
22
|
+
analysis.add_parser(commands)
|
|
23
|
+
findings.add_parser(commands)
|
|
24
|
+
followups.add_parser(commands)
|
|
25
|
+
reports.add_parser(commands)
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
try:
|
|
28
|
+
return args.run(args) or 0
|
|
29
|
+
except ValidationError as exc:
|
|
30
|
+
errors = [".".join(map(str, error["loc"])) + ": " + error["msg"] for error in exc.errors(include_input=False)]
|
|
31
|
+
print("Invalid configuration. " + "; ".join(errors), file=sys.stderr)
|
|
32
|
+
except KeyringError:
|
|
33
|
+
print("The OS credential vault could not be accessed. Use environment variable references or unlock the vault.", file=sys.stderr)
|
|
34
|
+
except (ValueError, OSError, InferenceError) as exc:
|
|
35
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
36
|
+
except (KeyboardInterrupt, EOFError):
|
|
37
|
+
print("Command cancelled.", file=sys.stderr)
|
|
38
|
+
return 130
|
|
39
|
+
return 1
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Terminal commands for saved runs and local repository workflows."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from mortis_engine import config, operations
|
|
8
|
+
from mortis_engine.history import History
|
|
9
|
+
from mortis_engine.review import settings
|
|
10
|
+
|
|
11
|
+
from .reports import destinations, output_options, write_outputs
|
|
12
|
+
from .review import execution_options, limit_overrides, options, progress_display, selected, terminal
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(args):
|
|
16
|
+
if args.command != "scan":
|
|
17
|
+
return _run(args)
|
|
18
|
+
started = time.monotonic()
|
|
19
|
+
try:
|
|
20
|
+
return _run(args)
|
|
21
|
+
finally:
|
|
22
|
+
print(f"Elapsed: {time.monotonic() - started:.1f}s", file=sys.stderr, flush=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run(args):
|
|
26
|
+
outputs = destinations(args.output) if args.command == "scan" else []
|
|
27
|
+
overrides = limit_overrides(args)
|
|
28
|
+
settings.apply_overrides({}, overrides, scan=args.command == "scan")
|
|
29
|
+
if args.resume and (getattr(args, "ref", None) or args.effort or getattr(args, "run_id", None)
|
|
30
|
+
or getattr(args, "rotations", None) or getattr(args, "concurrency", None) is not None or overrides):
|
|
31
|
+
raise ValueError("--resume uses the inputs and options of the saved run.")
|
|
32
|
+
if args.command in {"verify", "summary"} and not (args.run_id or args.resume):
|
|
33
|
+
raise ValueError(f"mortis {args.command} requires --run RUN_ID. Use mortis runs to see the available IDs.")
|
|
34
|
+
name, profile = selected(args)
|
|
35
|
+
with progress_display(getattr(args, "progress", False), scan=True) as progress:
|
|
36
|
+
result = operations.run(args.repo, kind=args.command, connection=config.connection(name, profile),
|
|
37
|
+
get_credentials=lambda: config.credentials(profile), profile=name, ref=getattr(args, "ref", None) or "HEAD",
|
|
38
|
+
finding_run=getattr(args, "run_id", None), resume=args.resume, effort=args.effort,
|
|
39
|
+
rotations=getattr(args, "rotations", ()), concurrency=getattr(args, "concurrency", None), progress=progress,
|
|
40
|
+
overrides=overrides)
|
|
41
|
+
write_outputs(result, outputs)
|
|
42
|
+
if args.format == "json":
|
|
43
|
+
print(json.dumps(result, indent=2))
|
|
44
|
+
else:
|
|
45
|
+
lines = [f"{args.command.capitalize()} {result['status']} {result['head_sha'][:12]}", f"Run: {result['run_id']}"]
|
|
46
|
+
if args.command == "scan":
|
|
47
|
+
coverage = result["coverage"]
|
|
48
|
+
lines.append(f"Coverage: {coverage['completed_files']}/{coverage['selected_files']} files. Grade: {result['grade'] or 'unavailable'}")
|
|
49
|
+
lines.extend(f"[{r['severity']}] {r['path']} — {r['title']} ({r['outcome']}, {r['id']})" for r in result["findings"])
|
|
50
|
+
lines.extend(result["problems"])
|
|
51
|
+
if error := result["stages"]["trivy"].get("error"):
|
|
52
|
+
lines.append(error)
|
|
53
|
+
elif args.command == "verify":
|
|
54
|
+
lines.extend(f"{r['finding_id']} — {r['verdict']}: " + ((r.get("result") or {}).get("explanation") or r.get("error", ""))
|
|
55
|
+
for r in result["verifications"])
|
|
56
|
+
elif args.command == "summary":
|
|
57
|
+
lines.append(result["result"]["narrative"])
|
|
58
|
+
lines.extend(f"{g['title']} — {g['description']} ({', '.join(g['paths'])})" for g in result["result"]["groups"])
|
|
59
|
+
if result["result"].get("diagram"):
|
|
60
|
+
from mortis_engine.review.summary import diagram_section
|
|
61
|
+
lines.append(diagram_section(result["result"]["diagram"]))
|
|
62
|
+
else:
|
|
63
|
+
lines.append(result["background"])
|
|
64
|
+
print(terminal("\n".join(lines)))
|
|
65
|
+
return 0 if result["status"] == "completed" else 2 if result["status"] == "partial" else 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def runs(args):
|
|
69
|
+
with History(args.repo) as history:
|
|
70
|
+
if args.id:
|
|
71
|
+
row = history.row(args.id)
|
|
72
|
+
if row["report"] is None:
|
|
73
|
+
records = [record for _, record, _ in history.attempts(args.id).values()]
|
|
74
|
+
value = {"run_id": args.id, "status": "unfinished", "attempts": records}
|
|
75
|
+
else:
|
|
76
|
+
value = history.report(args.id)
|
|
77
|
+
print(json.dumps(value, indent=2))
|
|
78
|
+
else:
|
|
79
|
+
rows = history.list()
|
|
80
|
+
print(terminal("\n".join(f"{r['id']} {r['kind']} {r['scope']} " +
|
|
81
|
+
("finished" if r["finished"] else "unfinished") for r in rows)) or "No saved runs.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_graph(args):
|
|
85
|
+
from mortis_engine.code_graph.build import main
|
|
86
|
+
|
|
87
|
+
return main([])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def add_parser(commands):
|
|
91
|
+
graph = commands.add_parser("graph", help="Manage the isolated local source parser")
|
|
92
|
+
build = graph.add_subparsers(dest="action", required=True).add_parser("build", help="Build the local Docker parser image")
|
|
93
|
+
build.set_defaults(run=build_graph)
|
|
94
|
+
for command, description in (("scan", "Scan a local revision with Trivy and your AI connection"),
|
|
95
|
+
("verify", "Check saved findings against a local revision"),
|
|
96
|
+
("summary", "Generate a summary for a saved review"),
|
|
97
|
+
("background", "Generate repository background from a pinned revision")):
|
|
98
|
+
parser = commands.add_parser(command, help=description)
|
|
99
|
+
options(parser, kind=command)
|
|
100
|
+
if command == "scan":
|
|
101
|
+
execution_options(parser)
|
|
102
|
+
output_options(parser)
|
|
103
|
+
parser.add_argument("--acknowledge-rotation", metavar="FINDING_ID", dest="rotations", action="append",
|
|
104
|
+
default=[], help="Record that this retained secret has been rotated or revoked")
|
|
105
|
+
if command != "summary":
|
|
106
|
+
parser.add_argument("--ref", help="Local branch, tag or commit (default: HEAD)")
|
|
107
|
+
if command in {"summary", "verify"}:
|
|
108
|
+
parser.add_argument("--run", dest="run_id", help="Saved review ID from mortis runs")
|
|
109
|
+
parser.set_defaults(run=run)
|
|
110
|
+
parser = commands.add_parser("runs", help="List saved local runs or print a saved report")
|
|
111
|
+
parser.add_argument("id", nargs="?", help="Print the report or progress for this run")
|
|
112
|
+
parser.add_argument("--repo", default=".")
|
|
113
|
+
parser.set_defaults(run=runs)
|