forgexa-cli 1.47.0__tar.gz → 1.47.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/PKG-INFO +1 -1
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/daemon.py +234 -2
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/runtime_evidence.py +430 -2
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/pyproject.toml +1 -1
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/README.md +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/setup.cfg +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.47.
|
|
2
|
+
__version__ = "1.47.1"
|
|
@@ -1075,7 +1075,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1075
1075
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1076
1076
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1077
1077
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1078
|
-
DAEMON_VERSION = "1.47.
|
|
1078
|
+
DAEMON_VERSION = "1.47.1"
|
|
1079
1079
|
|
|
1080
1080
|
|
|
1081
1081
|
def _detect_client_type() -> str:
|
|
@@ -5992,8 +5992,13 @@ class ProcessManager:
|
|
|
5992
5992
|
if not match:
|
|
5993
5993
|
return None
|
|
5994
5994
|
missing_model, suggested_model = match.groups()
|
|
5995
|
-
|
|
5995
|
+
missing_model = missing_model.strip()
|
|
5996
|
+
if missing_model.lower() == suggested_model.lower():
|
|
5996
5997
|
return None
|
|
5998
|
+
if "/" not in suggested_model and "/" in missing_model:
|
|
5999
|
+
provider = missing_model.split("/", 1)[0]
|
|
6000
|
+
if provider:
|
|
6001
|
+
suggested_model = f"{provider}/{suggested_model}"
|
|
5997
6002
|
return suggested_model
|
|
5998
6003
|
|
|
5999
6004
|
async def _run_opencode(
|
|
@@ -10446,6 +10451,18 @@ class RuntimeDaemon:
|
|
|
10446
10451
|
result,
|
|
10447
10452
|
before_sha=node_before_sha,
|
|
10448
10453
|
)
|
|
10454
|
+
await self._collect_runtime_type_evidence(
|
|
10455
|
+
workspace_path,
|
|
10456
|
+
task,
|
|
10457
|
+
result,
|
|
10458
|
+
before_sha=node_before_sha,
|
|
10459
|
+
)
|
|
10460
|
+
await self._collect_runtime_security_evidence(
|
|
10461
|
+
workspace_path,
|
|
10462
|
+
task,
|
|
10463
|
+
result,
|
|
10464
|
+
before_sha=node_before_sha,
|
|
10465
|
+
)
|
|
10449
10466
|
|
|
10450
10467
|
# 6. Report completion (include actual agent used if different from requested)
|
|
10451
10468
|
result.metrics["actual_agent"] = agent.agent_id
|
|
@@ -13491,6 +13508,221 @@ class RuntimeDaemon:
|
|
|
13491
13508
|
observation["collection_failure_reason"] = run["failure_reason"]
|
|
13492
13509
|
result.observations.append(observation)
|
|
13493
13510
|
|
|
13511
|
+
async def _collect_runtime_type_evidence(
|
|
13512
|
+
self,
|
|
13513
|
+
workspace_path: Path,
|
|
13514
|
+
task: TaskInfo,
|
|
13515
|
+
result: TaskResult,
|
|
13516
|
+
*,
|
|
13517
|
+
before_sha: str,
|
|
13518
|
+
) -> None:
|
|
13519
|
+
"""Run a frozen mypy check in a disposable post-commit worktree."""
|
|
13520
|
+
if (
|
|
13521
|
+
task.workspace_resolution != "cloud_worktree"
|
|
13522
|
+
or task.node_type not in ("testing", "fix")
|
|
13523
|
+
or not isinstance(task.runtime_evidence_plan, dict)
|
|
13524
|
+
):
|
|
13525
|
+
return
|
|
13526
|
+
|
|
13527
|
+
plan = task.runtime_evidence_plan
|
|
13528
|
+
plan_hash = plan.get("plan_hash")
|
|
13529
|
+
checks = plan.get("checks")
|
|
13530
|
+
check = next(
|
|
13531
|
+
(
|
|
13532
|
+
item for item in checks
|
|
13533
|
+
if isinstance(item, dict) and item.get("kind") == runtime_evidence.TYPE_CHECK_KIND
|
|
13534
|
+
),
|
|
13535
|
+
None,
|
|
13536
|
+
) if isinstance(checks, list) else None
|
|
13537
|
+
if not isinstance(check, dict):
|
|
13538
|
+
return
|
|
13539
|
+
after_sha = str((result.git or {}).get("commit_sha") or "").strip()
|
|
13540
|
+
if (
|
|
13541
|
+
plan.get("version") != runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION
|
|
13542
|
+
or not isinstance(plan_hash, str)
|
|
13543
|
+
or re.fullmatch(r"[0-9a-f]{64}", plan_hash) is None
|
|
13544
|
+
):
|
|
13545
|
+
run = {
|
|
13546
|
+
"status": "invalid_plan",
|
|
13547
|
+
"collection_status": "unavailable",
|
|
13548
|
+
"failure_reason": "invalid_frozen_plan",
|
|
13549
|
+
"summary": "The frozen type-check evidence plan is invalid.",
|
|
13550
|
+
}
|
|
13551
|
+
elif re.fullmatch(r"[0-9a-fA-F]{40}", after_sha) is None:
|
|
13552
|
+
run = {
|
|
13553
|
+
"status": "source_commit_unavailable",
|
|
13554
|
+
"collection_status": "unavailable",
|
|
13555
|
+
"failure_reason": "source_commit_unavailable",
|
|
13556
|
+
"summary": "The committed source revision is unavailable for type checking.",
|
|
13557
|
+
}
|
|
13558
|
+
else:
|
|
13559
|
+
run = await self._run_runtime_evidence_in_worktree(
|
|
13560
|
+
workspace_path,
|
|
13561
|
+
task,
|
|
13562
|
+
after_sha,
|
|
13563
|
+
check,
|
|
13564
|
+
runtime_evidence.run_type_check_evidence,
|
|
13565
|
+
)
|
|
13566
|
+
|
|
13567
|
+
source_commit_range = {"after_sha": after_sha} if after_sha else {}
|
|
13568
|
+
if re.fullmatch(r"[0-9a-fA-F]{40}", before_sha or ""):
|
|
13569
|
+
source_commit_range["before_sha"] = before_sha
|
|
13570
|
+
report = {
|
|
13571
|
+
"version": runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION,
|
|
13572
|
+
"plan_hash": plan_hash if isinstance(plan_hash, str) else "",
|
|
13573
|
+
"execution_attempt_id": task.execution_attempt_id,
|
|
13574
|
+
"check_kind": runtime_evidence.TYPE_CHECK_KIND,
|
|
13575
|
+
"command": check.get("command"),
|
|
13576
|
+
"source_commit_range": source_commit_range,
|
|
13577
|
+
"result": run,
|
|
13578
|
+
}
|
|
13579
|
+
report_content = json.dumps(report, sort_keys=True, separators=(",", ":"))
|
|
13580
|
+
report_checksum = hashlib.sha256(report_content.encode("utf-8")).hexdigest()
|
|
13581
|
+
artifact_path = (
|
|
13582
|
+
f".forgexa/evidence/{task.task_id}/{after_sha[:12] or 'unavailable'}/"
|
|
13583
|
+
"type-check.json"
|
|
13584
|
+
)
|
|
13585
|
+
artifact_ref = {"path": artifact_path, "checksum": report_checksum}
|
|
13586
|
+
result.artifacts.append({
|
|
13587
|
+
"path": artifact_path,
|
|
13588
|
+
"content": report_content,
|
|
13589
|
+
"content_hash": report_checksum,
|
|
13590
|
+
"type": "application/vnd.forgexa.gate-evidence+json",
|
|
13591
|
+
"system_generated": True,
|
|
13592
|
+
"artifact_role": "gate_evidence_report",
|
|
13593
|
+
})
|
|
13594
|
+
|
|
13595
|
+
evidence_identity = {
|
|
13596
|
+
"version": runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION,
|
|
13597
|
+
"plan_hash": report["plan_hash"],
|
|
13598
|
+
"check_kind": runtime_evidence.TYPE_CHECK_KIND,
|
|
13599
|
+
"execution_attempt_id": task.execution_attempt_id,
|
|
13600
|
+
"source_commit_range": source_commit_range,
|
|
13601
|
+
}
|
|
13602
|
+
observation = {
|
|
13603
|
+
"type": "lint_results",
|
|
13604
|
+
"collection_status": run.get("collection_status"),
|
|
13605
|
+
"summary": run.get("summary"),
|
|
13606
|
+
"lint_tool": "mypy",
|
|
13607
|
+
"lint_command": check.get("command"),
|
|
13608
|
+
"command": check.get("command"),
|
|
13609
|
+
"artifact_refs": [artifact_ref],
|
|
13610
|
+
"runtime_evidence": evidence_identity,
|
|
13611
|
+
}
|
|
13612
|
+
for key in ("lint_score", "issues", "type_errors", "truncated_count"):
|
|
13613
|
+
if key in run:
|
|
13614
|
+
observation[key] = run[key]
|
|
13615
|
+
if run.get("failure_reason"):
|
|
13616
|
+
observation["collection_failure_reason"] = run["failure_reason"]
|
|
13617
|
+
result.observations.append(observation)
|
|
13618
|
+
|
|
13619
|
+
async def _collect_runtime_security_evidence(
|
|
13620
|
+
self,
|
|
13621
|
+
workspace_path: Path,
|
|
13622
|
+
task: TaskInfo,
|
|
13623
|
+
result: TaskResult,
|
|
13624
|
+
*,
|
|
13625
|
+
before_sha: str,
|
|
13626
|
+
) -> None:
|
|
13627
|
+
"""Run a frozen Bandit scan in a disposable post-commit worktree."""
|
|
13628
|
+
if (
|
|
13629
|
+
task.workspace_resolution != "cloud_worktree"
|
|
13630
|
+
or task.node_type not in ("testing", "fix")
|
|
13631
|
+
or not isinstance(task.runtime_evidence_plan, dict)
|
|
13632
|
+
):
|
|
13633
|
+
return
|
|
13634
|
+
|
|
13635
|
+
plan = task.runtime_evidence_plan
|
|
13636
|
+
plan_hash = plan.get("plan_hash")
|
|
13637
|
+
checks = plan.get("checks")
|
|
13638
|
+
check = next(
|
|
13639
|
+
(
|
|
13640
|
+
item for item in checks
|
|
13641
|
+
if isinstance(item, dict) and item.get("kind") == runtime_evidence.SECURITY_CHECK_KIND
|
|
13642
|
+
),
|
|
13643
|
+
None,
|
|
13644
|
+
) if isinstance(checks, list) else None
|
|
13645
|
+
if not isinstance(check, dict):
|
|
13646
|
+
return
|
|
13647
|
+
after_sha = str((result.git or {}).get("commit_sha") or "").strip()
|
|
13648
|
+
if (
|
|
13649
|
+
plan.get("version") != runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION
|
|
13650
|
+
or not isinstance(plan_hash, str)
|
|
13651
|
+
or re.fullmatch(r"[0-9a-f]{64}", plan_hash) is None
|
|
13652
|
+
):
|
|
13653
|
+
run = {
|
|
13654
|
+
"status": "invalid_plan",
|
|
13655
|
+
"collection_status": "unavailable",
|
|
13656
|
+
"failure_reason": "invalid_frozen_plan",
|
|
13657
|
+
"summary": "The frozen security-check evidence plan is invalid.",
|
|
13658
|
+
}
|
|
13659
|
+
elif re.fullmatch(r"[0-9a-fA-F]{40}", after_sha) is None:
|
|
13660
|
+
run = {
|
|
13661
|
+
"status": "source_commit_unavailable",
|
|
13662
|
+
"collection_status": "unavailable",
|
|
13663
|
+
"failure_reason": "source_commit_unavailable",
|
|
13664
|
+
"summary": "The committed source revision is unavailable for security scanning.",
|
|
13665
|
+
}
|
|
13666
|
+
else:
|
|
13667
|
+
run = await self._run_runtime_evidence_in_worktree(
|
|
13668
|
+
workspace_path,
|
|
13669
|
+
task,
|
|
13670
|
+
after_sha,
|
|
13671
|
+
check,
|
|
13672
|
+
runtime_evidence.run_security_check_evidence,
|
|
13673
|
+
)
|
|
13674
|
+
|
|
13675
|
+
source_commit_range = {"after_sha": after_sha} if after_sha else {}
|
|
13676
|
+
if re.fullmatch(r"[0-9a-fA-F]{40}", before_sha or ""):
|
|
13677
|
+
source_commit_range["before_sha"] = before_sha
|
|
13678
|
+
report = {
|
|
13679
|
+
"version": runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION,
|
|
13680
|
+
"plan_hash": plan_hash if isinstance(plan_hash, str) else "",
|
|
13681
|
+
"execution_attempt_id": task.execution_attempt_id,
|
|
13682
|
+
"check_kind": runtime_evidence.SECURITY_CHECK_KIND,
|
|
13683
|
+
"command": check.get("command"),
|
|
13684
|
+
"source_commit_range": source_commit_range,
|
|
13685
|
+
"result": run,
|
|
13686
|
+
}
|
|
13687
|
+
report_content = json.dumps(report, sort_keys=True, separators=(",", ":"))
|
|
13688
|
+
report_checksum = hashlib.sha256(report_content.encode("utf-8")).hexdigest()
|
|
13689
|
+
artifact_path = (
|
|
13690
|
+
f".forgexa/evidence/{task.task_id}/{after_sha[:12] or 'unavailable'}/"
|
|
13691
|
+
"security-check.json"
|
|
13692
|
+
)
|
|
13693
|
+
artifact_ref = {"path": artifact_path, "checksum": report_checksum}
|
|
13694
|
+
result.artifacts.append({
|
|
13695
|
+
"path": artifact_path,
|
|
13696
|
+
"content": report_content,
|
|
13697
|
+
"content_hash": report_checksum,
|
|
13698
|
+
"type": "application/vnd.forgexa.gate-evidence+json",
|
|
13699
|
+
"system_generated": True,
|
|
13700
|
+
"artifact_role": "gate_evidence_report",
|
|
13701
|
+
})
|
|
13702
|
+
|
|
13703
|
+
evidence_identity = {
|
|
13704
|
+
"version": runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION,
|
|
13705
|
+
"plan_hash": report["plan_hash"],
|
|
13706
|
+
"check_kind": runtime_evidence.SECURITY_CHECK_KIND,
|
|
13707
|
+
"execution_attempt_id": task.execution_attempt_id,
|
|
13708
|
+
"source_commit_range": source_commit_range,
|
|
13709
|
+
}
|
|
13710
|
+
observation = {
|
|
13711
|
+
"type": "security_scan",
|
|
13712
|
+
"collection_status": run.get("collection_status"),
|
|
13713
|
+
"summary": run.get("summary"),
|
|
13714
|
+
"scanner": "bandit",
|
|
13715
|
+
"command": check.get("command"),
|
|
13716
|
+
"artifact_refs": [artifact_ref],
|
|
13717
|
+
"runtime_evidence": evidence_identity,
|
|
13718
|
+
}
|
|
13719
|
+
for key in ("security_scan_score", "vulnerabilities", "truncated_count"):
|
|
13720
|
+
if key in run:
|
|
13721
|
+
observation[key] = run[key]
|
|
13722
|
+
if run.get("failure_reason"):
|
|
13723
|
+
observation["collection_failure_reason"] = run["failure_reason"]
|
|
13724
|
+
result.observations.append(observation)
|
|
13725
|
+
|
|
13494
13726
|
async def _collect_staged_diff_stats(self, cwd: Path) -> dict:
|
|
13495
13727
|
"""Collect staged diff stats for building a rich commit message."""
|
|
13496
13728
|
status_map: dict[str, str] = {}
|
|
@@ -14,6 +14,8 @@ from typing import Any
|
|
|
14
14
|
RUNTIME_EVIDENCE_PLAN_VERSION = 1
|
|
15
15
|
TEST_COVERAGE_CHECK_KIND = "test_coverage"
|
|
16
16
|
STATIC_CHECK_KIND = "static_check"
|
|
17
|
+
TYPE_CHECK_KIND = "type_check"
|
|
18
|
+
SECURITY_CHECK_KIND = "security_check"
|
|
17
19
|
LOCAL_EVIDENCE_SUMMARY_VERSION = 1
|
|
18
20
|
_DEFAULT_TIMEOUT_SECONDS = 600
|
|
19
21
|
_MAX_FAILED_TESTS = 20
|
|
@@ -30,8 +32,23 @@ _SAFE_COVERAGE_REPORTS = frozenset({"term", "term-missing"})
|
|
|
30
32
|
_RUFF_STATIC_CHECK_ARGV = (
|
|
31
33
|
"python", "-m", "ruff", "check", ".", "--output-format=json",
|
|
32
34
|
)
|
|
35
|
+
_MYPY_TYPE_CHECK_ARGV = (
|
|
36
|
+
"python", "-m", "mypy", ".", "--show-error-codes", "--no-error-summary",
|
|
37
|
+
)
|
|
38
|
+
_BANDIT_SECURITY_CHECK_ARGV = (
|
|
39
|
+
"python", "-m", "bandit", "-r", ".", "-f", "json", "-q",
|
|
40
|
+
)
|
|
33
41
|
_SAFE_LOCAL_TEST_NAME = re.compile(r"^[A-Za-z0-9_./:-]+$")
|
|
34
42
|
_SAFE_LOCAL_RUFF_RULE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,199}$")
|
|
43
|
+
_SAFE_SECURITY_RULE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,199}$")
|
|
44
|
+
_MYPY_DIAGNOSTIC = re.compile(
|
|
45
|
+
r"^(?P<file>[^:\n]+):(?P<line>[1-9]\d*)(?::(?P<column>[1-9]\d*))?: "
|
|
46
|
+
r"error: (?P<message>.+?)(?:\s+\[(?P<code>[A-Za-z0-9_-]+)\])?\s*$"
|
|
47
|
+
)
|
|
48
|
+
_SENSITIVE_EVIDENCE_VALUE = re.compile(
|
|
49
|
+
r"(?i)\b(authorization|api[_-]?key|token|password|secret)\b"
|
|
50
|
+
r"(\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+"
|
|
51
|
+
)
|
|
35
52
|
|
|
36
53
|
|
|
37
54
|
def _safe_workspace_path(value: str, *, allow_node_id: bool = False) -> bool:
|
|
@@ -98,6 +115,32 @@ def ruff_static_check_command_argv(command: object) -> list[str] | None:
|
|
|
98
115
|
return list(_RUFF_STATIC_CHECK_ARGV) if tuple(argv) == _RUFF_STATIC_CHECK_ARGV else None
|
|
99
116
|
|
|
100
117
|
|
|
118
|
+
def mypy_type_check_command_argv(command: object) -> list[str] | None:
|
|
119
|
+
"""Return the sole supported mypy type-check argv, or ``None``."""
|
|
120
|
+
if not isinstance(command, str) or not command.strip():
|
|
121
|
+
return None
|
|
122
|
+
if any(character in command for character in _SHELL_METACHARACTERS):
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
argv = shlex.split(command)
|
|
126
|
+
except ValueError:
|
|
127
|
+
return None
|
|
128
|
+
return list(_MYPY_TYPE_CHECK_ARGV) if tuple(argv) == _MYPY_TYPE_CHECK_ARGV else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def bandit_security_check_command_argv(command: object) -> list[str] | None:
|
|
132
|
+
"""Return the sole supported Bandit security-check argv, or ``None``."""
|
|
133
|
+
if not isinstance(command, str) or not command.strip():
|
|
134
|
+
return None
|
|
135
|
+
if any(character in command for character in _SHELL_METACHARACTERS):
|
|
136
|
+
return None
|
|
137
|
+
try:
|
|
138
|
+
argv = shlex.split(command)
|
|
139
|
+
except ValueError:
|
|
140
|
+
return None
|
|
141
|
+
return list(_BANDIT_SECURITY_CHECK_ARGV) if tuple(argv) == _BANDIT_SECURITY_CHECK_ARGV else None
|
|
142
|
+
|
|
143
|
+
|
|
101
144
|
def _single_hook_command(policy: object, hook_name: str) -> str | None:
|
|
102
145
|
hooks = policy.get("hooks") if isinstance(policy, dict) else None
|
|
103
146
|
configured = hooks.get(hook_name) if isinstance(hooks, dict) else None
|
|
@@ -164,6 +207,36 @@ def _static_check(policy: object) -> dict[str, Any] | None:
|
|
|
164
207
|
}
|
|
165
208
|
|
|
166
209
|
|
|
210
|
+
def _type_check(policy: object) -> dict[str, Any] | None:
|
|
211
|
+
command = _single_hook_command(policy, "type_check")
|
|
212
|
+
argv = mypy_type_check_command_argv(command)
|
|
213
|
+
if argv is None or command is None:
|
|
214
|
+
return None
|
|
215
|
+
return {
|
|
216
|
+
"kind": TYPE_CHECK_KIND,
|
|
217
|
+
"tool": "mypy",
|
|
218
|
+
"command": command.strip(),
|
|
219
|
+
"argv": argv,
|
|
220
|
+
"timeout_seconds": _DEFAULT_TIMEOUT_SECONDS,
|
|
221
|
+
"report_format": "mypy_text",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _security_check(policy: object) -> dict[str, Any] | None:
|
|
226
|
+
command = _single_hook_command(policy, "security_check")
|
|
227
|
+
argv = bandit_security_check_command_argv(command)
|
|
228
|
+
if argv is None or command is None:
|
|
229
|
+
return None
|
|
230
|
+
return {
|
|
231
|
+
"kind": SECURITY_CHECK_KIND,
|
|
232
|
+
"tool": "bandit",
|
|
233
|
+
"command": command.strip(),
|
|
234
|
+
"argv": argv,
|
|
235
|
+
"timeout_seconds": _DEFAULT_TIMEOUT_SECONDS,
|
|
236
|
+
"report_format": "bandit_json",
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
167
240
|
def build_test_coverage_evidence_plan(
|
|
168
241
|
policy: object,
|
|
169
242
|
policy_snapshot: object = None,
|
|
@@ -180,7 +253,12 @@ def build_runtime_evidence_plan(
|
|
|
180
253
|
"""Freeze all supported, opt-in runtime evidence checks for one graph."""
|
|
181
254
|
checks = [
|
|
182
255
|
check
|
|
183
|
-
for check in (
|
|
256
|
+
for check in (
|
|
257
|
+
_test_coverage_check(policy),
|
|
258
|
+
_static_check(policy),
|
|
259
|
+
_type_check(policy),
|
|
260
|
+
_security_check(policy),
|
|
261
|
+
)
|
|
184
262
|
if check is not None
|
|
185
263
|
]
|
|
186
264
|
return _finalize_evidence_plan(checks, policy_snapshot)
|
|
@@ -406,6 +484,198 @@ def parse_ruff_json_output(output: object, exit_code: int) -> dict[str, Any]:
|
|
|
406
484
|
return result
|
|
407
485
|
|
|
408
486
|
|
|
487
|
+
def parse_mypy_output(output: object, exit_code: int) -> dict[str, Any]:
|
|
488
|
+
"""Parse bounded mypy diagnostics into repository-relative type errors."""
|
|
489
|
+
raw_text = output if isinstance(output, str) else ""
|
|
490
|
+
output_truncated = len(raw_text) > _MAX_OUTPUT_CHARS
|
|
491
|
+
text = raw_text[:_MAX_OUTPUT_CHARS]
|
|
492
|
+
if "No module named mypy" in text:
|
|
493
|
+
return {
|
|
494
|
+
"status": "tool_unavailable",
|
|
495
|
+
"collection_status": "unavailable",
|
|
496
|
+
"failure_reason": "mypy_unavailable",
|
|
497
|
+
"summary": "mypy is unavailable on this runtime.",
|
|
498
|
+
}
|
|
499
|
+
if exit_code not in (0, 1):
|
|
500
|
+
return {
|
|
501
|
+
"status": "execution_failed",
|
|
502
|
+
"collection_status": "incomplete",
|
|
503
|
+
"failure_reason": "mypy_execution_failed",
|
|
504
|
+
"summary": f"mypy exited with status {exit_code} before completing type checks.",
|
|
505
|
+
"exit_code": exit_code,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
type_errors: list[dict[str, Any]] = []
|
|
509
|
+
rejected_diagnostics = 0
|
|
510
|
+
for raw_line in text.splitlines():
|
|
511
|
+
match = _MYPY_DIAGNOSTIC.match(raw_line.strip())
|
|
512
|
+
if match is None:
|
|
513
|
+
continue
|
|
514
|
+
file_path = match.group("file").strip().replace("\\", "/")
|
|
515
|
+
if not _safe_workspace_path(file_path):
|
|
516
|
+
rejected_diagnostics += 1
|
|
517
|
+
continue
|
|
518
|
+
type_error: dict[str, Any] = {
|
|
519
|
+
"file": file_path,
|
|
520
|
+
"line": int(match.group("line")),
|
|
521
|
+
"message": _safe_evidence_message(match.group("message")),
|
|
522
|
+
}
|
|
523
|
+
column = match.group("column")
|
|
524
|
+
code = match.group("code")
|
|
525
|
+
if column is not None:
|
|
526
|
+
type_error["column"] = int(column)
|
|
527
|
+
if code is not None:
|
|
528
|
+
type_error["code"] = code
|
|
529
|
+
type_errors.append(type_error)
|
|
530
|
+
if len(type_errors) >= _MAX_LINT_ISSUES:
|
|
531
|
+
break
|
|
532
|
+
|
|
533
|
+
if exit_code == 1 and not type_errors:
|
|
534
|
+
return {
|
|
535
|
+
"status": "parse_failed",
|
|
536
|
+
"collection_status": "incomplete",
|
|
537
|
+
"failure_reason": (
|
|
538
|
+
"mypy_diagnostic_path_invalid"
|
|
539
|
+
if rejected_diagnostics else "mypy_diagnostics_unparseable"
|
|
540
|
+
),
|
|
541
|
+
"summary": "mypy did not emit usable repository-relative type diagnostics.",
|
|
542
|
+
"exit_code": exit_code,
|
|
543
|
+
}
|
|
544
|
+
result: dict[str, Any] = {
|
|
545
|
+
"status": "completed",
|
|
546
|
+
"collection_status": "completed",
|
|
547
|
+
"lint_score": 1.0 if not type_errors else 0.0,
|
|
548
|
+
"issues": [],
|
|
549
|
+
"type_errors": type_errors,
|
|
550
|
+
"summary": f"mypy completed: {len(type_errors)} type error(s).",
|
|
551
|
+
"exit_code": exit_code,
|
|
552
|
+
}
|
|
553
|
+
parsed_diagnostic_count = sum(
|
|
554
|
+
1 for line in text.splitlines() if _MYPY_DIAGNOSTIC.match(line.strip())
|
|
555
|
+
)
|
|
556
|
+
omitted_diagnostic_count = max(
|
|
557
|
+
0,
|
|
558
|
+
parsed_diagnostic_count - rejected_diagnostics - len(type_errors),
|
|
559
|
+
)
|
|
560
|
+
truncated_count = rejected_diagnostics + omitted_diagnostic_count + int(output_truncated)
|
|
561
|
+
if truncated_count:
|
|
562
|
+
result["truncated_count"] = truncated_count
|
|
563
|
+
return result
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def parse_bandit_json_output(output: object, exit_code: int) -> dict[str, Any]:
|
|
567
|
+
"""Parse bounded Bandit JSON into safe, repository-relative findings."""
|
|
568
|
+
text = output[:_MAX_OUTPUT_CHARS] if isinstance(output, str) else ""
|
|
569
|
+
if "No module named bandit" in text:
|
|
570
|
+
return {
|
|
571
|
+
"status": "tool_unavailable",
|
|
572
|
+
"collection_status": "unavailable",
|
|
573
|
+
"failure_reason": "bandit_unavailable",
|
|
574
|
+
"summary": "Bandit is unavailable on this runtime.",
|
|
575
|
+
}
|
|
576
|
+
if exit_code not in (0, 1):
|
|
577
|
+
return {
|
|
578
|
+
"status": "execution_failed",
|
|
579
|
+
"collection_status": "incomplete",
|
|
580
|
+
"failure_reason": "bandit_execution_failed",
|
|
581
|
+
"summary": f"Bandit exited with status {exit_code} before completing security checks.",
|
|
582
|
+
"exit_code": exit_code,
|
|
583
|
+
}
|
|
584
|
+
try:
|
|
585
|
+
document = json.loads(text)
|
|
586
|
+
except json.JSONDecodeError:
|
|
587
|
+
return {
|
|
588
|
+
"status": "parse_failed",
|
|
589
|
+
"collection_status": "incomplete",
|
|
590
|
+
"failure_reason": "bandit_json_unparseable",
|
|
591
|
+
"summary": "Bandit did not emit a parseable JSON report.",
|
|
592
|
+
}
|
|
593
|
+
if not isinstance(document, dict) or not isinstance(document.get("results"), list):
|
|
594
|
+
return {
|
|
595
|
+
"status": "parse_failed",
|
|
596
|
+
"collection_status": "incomplete",
|
|
597
|
+
"failure_reason": "bandit_json_invalid_shape",
|
|
598
|
+
"summary": "Bandit emitted an invalid JSON report shape.",
|
|
599
|
+
}
|
|
600
|
+
if document.get("errors"):
|
|
601
|
+
return {
|
|
602
|
+
"status": "execution_failed",
|
|
603
|
+
"collection_status": "incomplete",
|
|
604
|
+
"failure_reason": "bandit_scanner_errors",
|
|
605
|
+
"summary": "Bandit reported scanner errors before completing security checks.",
|
|
606
|
+
"exit_code": exit_code,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
findings: list[dict[str, Any]] = []
|
|
610
|
+
invalid_findings = 0
|
|
611
|
+
severity_counts = {"high": 0, "medium": 0, "low": 0, "info": 0}
|
|
612
|
+
for raw_finding in document["results"]:
|
|
613
|
+
if not isinstance(raw_finding, dict):
|
|
614
|
+
invalid_findings += 1
|
|
615
|
+
continue
|
|
616
|
+
rule_id = raw_finding.get("test_id")
|
|
617
|
+
message = raw_finding.get("issue_text")
|
|
618
|
+
filename = raw_finding.get("filename")
|
|
619
|
+
raw_severity = raw_finding.get("issue_severity")
|
|
620
|
+
if (
|
|
621
|
+
not isinstance(rule_id, str)
|
|
622
|
+
or _SAFE_SECURITY_RULE.fullmatch(rule_id.strip()) is None
|
|
623
|
+
or not isinstance(message, str)
|
|
624
|
+
or not message.strip()
|
|
625
|
+
or not isinstance(filename, str)
|
|
626
|
+
or not isinstance(raw_severity, str)
|
|
627
|
+
):
|
|
628
|
+
invalid_findings += 1
|
|
629
|
+
continue
|
|
630
|
+
file_path = filename.strip().replace("\\", "/")
|
|
631
|
+
while file_path.startswith("./"):
|
|
632
|
+
file_path = file_path[2:]
|
|
633
|
+
if not _safe_workspace_path(file_path):
|
|
634
|
+
invalid_findings += 1
|
|
635
|
+
continue
|
|
636
|
+
severity = raw_severity.strip().lower()
|
|
637
|
+
if severity not in severity_counts:
|
|
638
|
+
severity = "info"
|
|
639
|
+
finding: dict[str, Any] = {
|
|
640
|
+
"severity": severity,
|
|
641
|
+
"rule_id": rule_id.strip(),
|
|
642
|
+
"message": _safe_evidence_message(message),
|
|
643
|
+
"file": file_path,
|
|
644
|
+
}
|
|
645
|
+
line = raw_finding.get("line_number")
|
|
646
|
+
if isinstance(line, int) and not isinstance(line, bool) and line > 0:
|
|
647
|
+
finding["line"] = line
|
|
648
|
+
severity_counts[severity] += 1
|
|
649
|
+
if len(findings) < _MAX_LINT_ISSUES:
|
|
650
|
+
findings.append(finding)
|
|
651
|
+
|
|
652
|
+
if invalid_findings or (exit_code == 1 and not findings):
|
|
653
|
+
return {
|
|
654
|
+
"status": "parse_failed",
|
|
655
|
+
"collection_status": "incomplete",
|
|
656
|
+
"failure_reason": (
|
|
657
|
+
"bandit_finding_invalid"
|
|
658
|
+
if invalid_findings else "bandit_findings_unparseable"
|
|
659
|
+
),
|
|
660
|
+
"summary": "Bandit did not emit usable repository-relative security findings.",
|
|
661
|
+
"exit_code": exit_code,
|
|
662
|
+
}
|
|
663
|
+
result: dict[str, Any] = {
|
|
664
|
+
"status": "completed",
|
|
665
|
+
"collection_status": "completed",
|
|
666
|
+
"security_scan_score": _security_score(
|
|
667
|
+
severity_counts["high"], severity_counts["medium"], severity_counts["low"],
|
|
668
|
+
),
|
|
669
|
+
"vulnerabilities": findings,
|
|
670
|
+
"summary": f"Bandit completed: {len(findings)} security finding(s).",
|
|
671
|
+
"exit_code": exit_code,
|
|
672
|
+
}
|
|
673
|
+
total_findings = len(document["results"])
|
|
674
|
+
if total_findings > len(findings):
|
|
675
|
+
result["truncated_count"] = total_findings - len(findings)
|
|
676
|
+
return result
|
|
677
|
+
|
|
678
|
+
|
|
409
679
|
async def run_test_coverage_evidence(
|
|
410
680
|
workspace_path: str | Path,
|
|
411
681
|
check: object,
|
|
@@ -543,6 +813,143 @@ async def run_static_check_evidence(
|
|
|
543
813
|
return report
|
|
544
814
|
|
|
545
815
|
|
|
816
|
+
async def run_type_check_evidence(
|
|
817
|
+
workspace_path: str | Path,
|
|
818
|
+
check: object,
|
|
819
|
+
) -> dict[str, Any]:
|
|
820
|
+
"""Run the frozen mypy type check without invoking a shell."""
|
|
821
|
+
command = check.get("command") if isinstance(check, dict) else None
|
|
822
|
+
argv = mypy_type_check_command_argv(command)
|
|
823
|
+
if argv is None:
|
|
824
|
+
return {
|
|
825
|
+
"status": "invalid_plan",
|
|
826
|
+
"collection_status": "unavailable",
|
|
827
|
+
"failure_reason": "invalid_frozen_command",
|
|
828
|
+
"summary": "The frozen type-check command is invalid.",
|
|
829
|
+
}
|
|
830
|
+
timeout = check.get("timeout_seconds") if isinstance(check, dict) else None
|
|
831
|
+
timeout_seconds = (
|
|
832
|
+
timeout if isinstance(timeout, int) and 1 <= timeout <= 3600 else _DEFAULT_TIMEOUT_SECONDS
|
|
833
|
+
)
|
|
834
|
+
started_at = time.monotonic()
|
|
835
|
+
try:
|
|
836
|
+
process = await asyncio.create_subprocess_exec(
|
|
837
|
+
*argv,
|
|
838
|
+
cwd=str(Path(workspace_path)),
|
|
839
|
+
stdout=asyncio.subprocess.PIPE,
|
|
840
|
+
stderr=asyncio.subprocess.PIPE,
|
|
841
|
+
)
|
|
842
|
+
except FileNotFoundError:
|
|
843
|
+
return {
|
|
844
|
+
"status": "tool_unavailable",
|
|
845
|
+
"collection_status": "unavailable",
|
|
846
|
+
"failure_reason": "python_unavailable",
|
|
847
|
+
"summary": "Python is unavailable on this runtime.",
|
|
848
|
+
"duration_ms": int((time.monotonic() - started_at) * 1000),
|
|
849
|
+
}
|
|
850
|
+
except OSError:
|
|
851
|
+
return {
|
|
852
|
+
"status": "tool_unavailable",
|
|
853
|
+
"collection_status": "unavailable",
|
|
854
|
+
"failure_reason": "mypy_start_failed",
|
|
855
|
+
"summary": "mypy could not be started on this runtime.",
|
|
856
|
+
"duration_ms": int((time.monotonic() - started_at) * 1000),
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
try:
|
|
860
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout_seconds)
|
|
861
|
+
except asyncio.TimeoutError:
|
|
862
|
+
process.kill()
|
|
863
|
+
await process.communicate()
|
|
864
|
+
return {
|
|
865
|
+
"status": "timed_out",
|
|
866
|
+
"collection_status": "unavailable",
|
|
867
|
+
"failure_reason": "timeout",
|
|
868
|
+
"summary": f"mypy evidence collection timed out after {timeout_seconds}s.",
|
|
869
|
+
"duration_ms": int((time.monotonic() - started_at) * 1000),
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
output = (stdout + b"\n" + stderr).decode("utf-8", errors="replace")
|
|
873
|
+
report = parse_mypy_output(output, process.returncode or 0)
|
|
874
|
+
report["duration_ms"] = int((time.monotonic() - started_at) * 1000)
|
|
875
|
+
report["tool"] = "mypy"
|
|
876
|
+
report["command"] = command
|
|
877
|
+
return report
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
async def run_security_check_evidence(
|
|
881
|
+
workspace_path: str | Path,
|
|
882
|
+
check: object,
|
|
883
|
+
) -> dict[str, Any]:
|
|
884
|
+
"""Run the frozen Bandit security check without invoking a shell."""
|
|
885
|
+
command = check.get("command") if isinstance(check, dict) else None
|
|
886
|
+
argv = bandit_security_check_command_argv(command)
|
|
887
|
+
if argv is None:
|
|
888
|
+
return {
|
|
889
|
+
"status": "invalid_plan",
|
|
890
|
+
"collection_status": "unavailable",
|
|
891
|
+
"failure_reason": "invalid_frozen_command",
|
|
892
|
+
"summary": "The frozen security-check command is invalid.",
|
|
893
|
+
}
|
|
894
|
+
timeout = check.get("timeout_seconds") if isinstance(check, dict) else None
|
|
895
|
+
timeout_seconds = (
|
|
896
|
+
timeout if isinstance(timeout, int) and 1 <= timeout <= 3600 else _DEFAULT_TIMEOUT_SECONDS
|
|
897
|
+
)
|
|
898
|
+
started_at = time.monotonic()
|
|
899
|
+
try:
|
|
900
|
+
process = await asyncio.create_subprocess_exec(
|
|
901
|
+
*argv,
|
|
902
|
+
cwd=str(Path(workspace_path)),
|
|
903
|
+
stdout=asyncio.subprocess.PIPE,
|
|
904
|
+
stderr=asyncio.subprocess.PIPE,
|
|
905
|
+
)
|
|
906
|
+
except FileNotFoundError:
|
|
907
|
+
return {
|
|
908
|
+
"status": "tool_unavailable",
|
|
909
|
+
"collection_status": "unavailable",
|
|
910
|
+
"failure_reason": "python_unavailable",
|
|
911
|
+
"summary": "Python is unavailable on this runtime.",
|
|
912
|
+
"duration_ms": int((time.monotonic() - started_at) * 1000),
|
|
913
|
+
}
|
|
914
|
+
except OSError:
|
|
915
|
+
return {
|
|
916
|
+
"status": "tool_unavailable",
|
|
917
|
+
"collection_status": "unavailable",
|
|
918
|
+
"failure_reason": "bandit_start_failed",
|
|
919
|
+
"summary": "Bandit could not be started on this runtime.",
|
|
920
|
+
"duration_ms": int((time.monotonic() - started_at) * 1000),
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
try:
|
|
924
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout_seconds)
|
|
925
|
+
except asyncio.TimeoutError:
|
|
926
|
+
process.kill()
|
|
927
|
+
await process.communicate()
|
|
928
|
+
return {
|
|
929
|
+
"status": "timed_out",
|
|
930
|
+
"collection_status": "unavailable",
|
|
931
|
+
"failure_reason": "timeout",
|
|
932
|
+
"summary": f"Bandit evidence collection timed out after {timeout_seconds}s.",
|
|
933
|
+
"duration_ms": int((time.monotonic() - started_at) * 1000),
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
stdout_text = stdout.decode("utf-8", errors="replace")
|
|
937
|
+
stderr_text = stderr.decode("utf-8", errors="replace")
|
|
938
|
+
if "No module named bandit" in f"{stdout_text}\n{stderr_text}":
|
|
939
|
+
report = {
|
|
940
|
+
"status": "tool_unavailable",
|
|
941
|
+
"collection_status": "unavailable",
|
|
942
|
+
"failure_reason": "bandit_unavailable",
|
|
943
|
+
"summary": "Bandit is unavailable on this runtime.",
|
|
944
|
+
}
|
|
945
|
+
else:
|
|
946
|
+
report = parse_bandit_json_output(stdout_text, process.returncode or 0)
|
|
947
|
+
report["duration_ms"] = int((time.monotonic() - started_at) * 1000)
|
|
948
|
+
report["tool"] = "bandit"
|
|
949
|
+
report["command"] = command
|
|
950
|
+
return report
|
|
951
|
+
|
|
952
|
+
|
|
546
953
|
def _pytest_count(output: str, label: str) -> int:
|
|
547
954
|
match = re.search(rf"(\d+)\s+{label}s?\b", output, re.IGNORECASE)
|
|
548
955
|
return int(match.group(1)) if match else 0
|
|
@@ -580,4 +987,25 @@ def _local_failed_test_truncated_count(output: object) -> int:
|
|
|
580
987
|
for match in re.finditer(r"^(?:FAILED|ERROR)\s+([^\s]+)", output, re.MULTILINE)
|
|
581
988
|
if (name := _safe_local_test_name(match.group(1))) is not None
|
|
582
989
|
}
|
|
583
|
-
return max(0, len(names) - _MAX_FAILED_TESTS)
|
|
990
|
+
return max(0, len(names) - _MAX_FAILED_TESTS)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _safe_evidence_message(value: object) -> str:
|
|
994
|
+
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
|
995
|
+
text = _SENSITIVE_EVIDENCE_VALUE.sub(r"\1\2[redacted]", text)
|
|
996
|
+
text = re.sub(r"(?i)\bbearer\s+[^\s,;]+", "Bearer [redacted]", text)
|
|
997
|
+
return text[:1000]
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _security_score(high: int, medium: int, low: int) -> float:
|
|
1001
|
+
if high > 0:
|
|
1002
|
+
return 0.0
|
|
1003
|
+
if medium > 3:
|
|
1004
|
+
return 0.3
|
|
1005
|
+
if medium > 0:
|
|
1006
|
+
return max(0.5, 1.0 - medium * 0.15)
|
|
1007
|
+
if low > 5:
|
|
1008
|
+
return 0.8
|
|
1009
|
+
if low > 0:
|
|
1010
|
+
return max(0.85, 1.0 - low * 0.03)
|
|
1011
|
+
return 1.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|