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.
Files changed (28) hide show
  1. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/PKG-INFO +1 -1
  2. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/daemon.py +234 -2
  4. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/runtime_evidence.py +430 -2
  5. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/pyproject.toml +1 -1
  7. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/README.md +0 -0
  8. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/agent_core.py +0 -0
  11. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/autoupgrade.py +0 -0
  12. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/main.py +0 -0
  13. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli/py.typed +0 -0
  14. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  15. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  16. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
  17. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/requires.txt +0 -0
  18. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/forgexa_cli.egg-info/top_level.txt +0 -0
  19. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/setup.cfg +0 -0
  20. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_auth_and_runtime_commands.py +0 -0
  21. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_autoupgrade.py +0 -0
  22. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_check_command.py +0 -0
  23. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
  24. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_local_bind_commands.py +0 -0
  25. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_runtime_credentials.py +0 -0
  26. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_session_credentials.py +0 -0
  27. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_silent_install.py +0 -0
  28. {forgexa_cli-1.47.0 → forgexa_cli-1.47.1}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.47.0
3
+ Version: 1.47.1
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.47.0"
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.0"
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
- if missing_model.strip().lower() == suggested_model.lower():
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 (_test_coverage_check(policy), _static_check(policy))
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.47.0
3
+ Version: 1.47.1
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.47.0"
3
+ version = "1.47.1"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes