sourcecode 4.8.0__py3-none-any.whl → 4.9.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.
sourcecode/__init__.py CHANGED
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "4.8.0"
7
+ __version__ = "4.9.0"
@@ -0,0 +1,147 @@
1
+ """Signed audit report over existing ASK evidence.
2
+
3
+ This module packages facts other commands already publish. It does not score ROI,
4
+ choose a fix, or introduce a second judgement layer: the report summary is a
5
+ reader-friendly index over the embedded evidence blocks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ from sourcecode import __version__
18
+
19
+ AUDIT_REPORT_SCHEMA = "audit-report-v1"
20
+
21
+
22
+ def _canonical_json(data: dict[str, Any]) -> bytes:
23
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
24
+
25
+
26
+ def _sign(payload: dict[str, Any], key: bytes) -> dict[str, str]:
27
+ canonical = _canonical_json(payload)
28
+ return {
29
+ "algorithm": "hmac-sha256",
30
+ "payload_sha256": hashlib.sha256(canonical).hexdigest(),
31
+ "key_id": hashlib.sha256(key).hexdigest()[:12],
32
+ "signature": hmac.new(key, canonical, hashlib.sha256).hexdigest(),
33
+ }
34
+
35
+
36
+ def build_audit_report(
37
+ root: Path,
38
+ *,
39
+ profiles: Optional[set[str]] = None,
40
+ sign_key: Optional[bytes] = None,
41
+ risk_limit: int = 10,
42
+ ) -> dict[str, Any]:
43
+ """Build the buyer-readable audit bundle from existing command payloads."""
44
+ from sourcecode.posture import build_posture
45
+ from sourcecode.risk import build_risk
46
+
47
+ root = Path(root).resolve()
48
+ risk = build_risk(root, limit=risk_limit, min_band="low", profiles=profiles)
49
+ posture = build_posture(root, profiles or set())
50
+ top_risks = risk.get("risks", [])[:risk_limit]
51
+ access = ((posture.get("endpoints") or {}).get("effective_access") or {})
52
+
53
+ unsigned: dict[str, Any] = {
54
+ "schema_version": AUDIT_REPORT_SCHEMA,
55
+ "tool": {"name": "ask", "version": __version__},
56
+ "generated_at": datetime.now(timezone.utc).isoformat(),
57
+ "repository": {"path": str(root)},
58
+ **({"profile_set": sorted(profiles)} if profiles else {}),
59
+ "summary": {
60
+ "risk_model": risk.get("model"),
61
+ "total_defects": risk.get("total_defects"),
62
+ "total_findings": risk.get("total_findings"),
63
+ "risk_bands": risk.get("by_band", {}),
64
+ "top_risks_shown": len(top_risks),
65
+ "endpoint_access": access.get("summary", {}),
66
+ "unresolved_beans": len(posture.get("unresolved") or []),
67
+ },
68
+ "top_risks": top_risks,
69
+ "evidence": {
70
+ "risk": risk,
71
+ "posture": posture,
72
+ },
73
+ "limits": {
74
+ "statement": (
75
+ "This report packages existing ASK evidence. It does not certify "
76
+ "compliance, choose fixes, assign ROI, or replace a human audit decision."
77
+ ),
78
+ "non_coverage": (risk.get("non_coverage") or {}).get("items", []),
79
+ },
80
+ }
81
+ # Sign and publish the same JSON-normalized material. Some embedded evidence
82
+ # may contain tuples or other JSON-coercible values; normalizing before signing
83
+ # avoids a signature over an in-memory shape the user never receives.
84
+ normalized = json.loads(_canonical_json(unsigned).decode("utf-8"))
85
+ if sign_key:
86
+ normalized["signature"] = _sign(normalized, sign_key)
87
+ return normalized
88
+
89
+
90
+ def render_markdown(report: dict[str, Any]) -> str:
91
+ """Human report renderer over the JSON contract."""
92
+ summary = report.get("summary") or {}
93
+ repo = (report.get("repository") or {}).get("path", "")
94
+ profile = ", ".join(report.get("profile_set") or []) or "default"
95
+ lines = [
96
+ "# ASK Audit Report",
97
+ "",
98
+ f"- Repository: `{repo}`",
99
+ f"- ASK version: `{(report.get('tool') or {}).get('version', '')}`",
100
+ f"- Profile set: `{profile}`",
101
+ f"- Total defects: `{summary.get('total_defects')}`",
102
+ f"- Risk bands: `{summary.get('risk_bands')}`",
103
+ f"- Endpoint access: `{summary.get('endpoint_access')}`",
104
+ "",
105
+ "## Top Risks",
106
+ "",
107
+ ]
108
+ risks = report.get("top_risks") or []
109
+ if not risks:
110
+ lines.append("No composed risks were reported.")
111
+ else:
112
+ lines.extend([
113
+ "| Band | Score | Rules | File | Line | Title |",
114
+ "|---|---:|---|---|---:|---|",
115
+ ])
116
+ for row in risks:
117
+ rules = ", ".join(str(r) for r in row.get("rule_ids", []))
118
+ lines.append(
119
+ "| "
120
+ + " | ".join([
121
+ str(row.get("band", "")),
122
+ str(row.get("severity_effective", "")),
123
+ rules,
124
+ str(row.get("source_file", "")),
125
+ str(row.get("first_line", "")),
126
+ str(row.get("title", "")).replace("|", "\\|"),
127
+ ])
128
+ + " |"
129
+ )
130
+ lines.extend([
131
+ "",
132
+ "## Limits",
133
+ "",
134
+ str((report.get("limits") or {}).get("statement", "")),
135
+ ])
136
+ signature = report.get("signature")
137
+ if isinstance(signature, dict):
138
+ lines.extend([
139
+ "",
140
+ "## Signature",
141
+ "",
142
+ f"- Algorithm: `{signature.get('algorithm')}`",
143
+ f"- Payload SHA-256: `{signature.get('payload_sha256')}`",
144
+ f"- Key ID: `{signature.get('key_id')}`",
145
+ f"- Signature: `{signature.get('signature')}`",
146
+ ])
147
+ return "\n".join(lines) + "\n"
sourcecode/cli.py CHANGED
@@ -184,7 +184,8 @@ COMMAND_TIERS: "tuple[tuple[str, str, tuple[str, ...]], ...]" = (
184
184
  "cache", "auth", "mcp", "telemetry",
185
185
  )),
186
186
  ("experimental", "shape may change in a minor — do not gate CI on it", (
187
- "risk", "enrich", "data-exposure", "migrate-recipe", "posture", "archetype",
187
+ "risk", "enrich", "audit-report", "data-exposure", "migrate-recipe",
188
+ "posture", "archetype",
188
189
  )),
189
190
  # `retrieve` publishes 15+ intents whose answers the other commands already
190
191
  # give better: measured on the battery, `security-surface` merely re-states
@@ -332,7 +333,7 @@ def _build_help_text() -> str:
332
333
  plan_badge = "[yellow]Free[/yellow] · [dim]ask activate <key>[/dim] to unlock Pro"
333
334
 
334
335
  text = f"""\
335
- [bold]ASK Engine[/bold] [dim]· CLI: ask[/dim] {plan_badge}
336
+ [bold]ASK Engine {__version__}[/bold] [dim]· CLI: ask[/dim] {plan_badge}
336
337
 
337
338
  Deterministic Java/Spring semantics and reusable structural context for AI coding agents.
338
339
 
@@ -359,6 +360,7 @@ of files) in minutes. Semantic analysis itself is sub-second; repo indexing domi
359
360
  impact <Class> . [dim]# reverse deps → endpoints reached[/dim]
360
361
  pr-impact . --files - [dim]# same, scoped to a diff on stdin; gating codes[/dim]
361
362
  posture . --since main [dim]# did this branch open an endpoint? (exp.)[/dim]
363
+ audit-report . --format markdown [dim]# signed human evidence bundle (exp.)[/dim]
362
364
  verify . [dim]# contract gate, baseline-relative[/dim]
363
365
  verify-edit . [dim]# did working-tree edits change behaviour?[/dim]
364
366
  [dim]modernize · explain <Class> · validation · export · repo-ir[/dim]
@@ -6908,6 +6910,73 @@ def _render_risk_table(
6908
6910
  return "\n".join(lines)
6909
6911
 
6910
6912
 
6913
+ def _render_enrich_table(
6914
+ data: dict[str, Any],
6915
+ *,
6916
+ rule_filter: Optional[str],
6917
+ band_filter: Optional[str],
6918
+ top_n: int,
6919
+ ) -> str:
6920
+ """Render SARIF-enriched findings as a bounded human triage table."""
6921
+ rule = (rule_filter or "").strip().upper()
6922
+ band = (band_filter or "").strip().lower()
6923
+ rows = []
6924
+ for row in data.get("findings", []):
6925
+ if not isinstance(row, dict):
6926
+ continue
6927
+ external = row.get("external", {}) if isinstance(row.get("external"), dict) else {}
6928
+ rule_id = str(external.get("rule_id") or ",".join(str(r) for r in row.get("rule_ids", [])))
6929
+ if rule and rule != rule_id.upper():
6930
+ continue
6931
+ if band and str(row.get("band", "")).lower() != band:
6932
+ continue
6933
+ rows.append(row)
6934
+ total = len(rows)
6935
+ if top_n > 0:
6936
+ rows = rows[:top_n]
6937
+
6938
+ def _cell(value: object, width: int) -> str:
6939
+ text = "" if value is None else str(value)
6940
+ text = text.replace("\n", " ")
6941
+ if len(text) > width:
6942
+ text = text[: max(0, width - 1)] + "..."
6943
+ return text
6944
+
6945
+ lines = [
6946
+ (
6947
+ f"Enriched findings table: showing {len(rows)}/{total} finding(s)"
6948
+ f"{' (rule=' + rule + ')' if rule else ''}"
6949
+ f"{' (band=' + band + ')' if band else ''}"
6950
+ f"{' (top-n=' + str(top_n) + ')' if top_n > 0 else ''}"
6951
+ )
6952
+ ]
6953
+ if not rows:
6954
+ lines.append("No matching enriched findings.")
6955
+ return "\n".join(lines)
6956
+
6957
+ headers = ("band", "score", "tool", "rule", "reach", "auth", "file", "line", "title")
6958
+ widths = (8, 7, 12, 18, 7, 14, 34, 6, 48)
6959
+ lines.append(" | ".join(_cell(h, w).ljust(w) for h, w in zip(headers, widths)))
6960
+ lines.append("-+-".join("-" * w for w in widths))
6961
+ for row in rows:
6962
+ factors = row.get("factors", {}) if isinstance(row.get("factors"), dict) else {}
6963
+ auth = factors.get("auth_verdict", {}) if isinstance(factors.get("auth_verdict"), dict) else {}
6964
+ external = row.get("external", {}) if isinstance(row.get("external"), dict) else {}
6965
+ values = (
6966
+ row.get("band", ""),
6967
+ row.get("severity_effective", ""),
6968
+ external.get("tool", ""),
6969
+ external.get("rule_id", ""),
6970
+ row.get("endpoints_reached", ""),
6971
+ auth.get("value", ""),
6972
+ row.get("source_file", ""),
6973
+ row.get("first_line", "-"),
6974
+ row.get("title", ""),
6975
+ )
6976
+ lines.append(" | ".join(_cell(v, w).ljust(w) for v, w in zip(values, widths)))
6977
+ return "\n".join(lines)
6978
+
6979
+
6911
6980
  def _render_migrate_check_table(
6912
6981
  data: dict[str, Any],
6913
6982
  *,
@@ -7668,9 +7737,10 @@ def risk_cmd(
7668
7737
  it, and every row publishes every factor with the authority each came from, so
7669
7738
  a reader can disagree with one and keep the rest. An axis that could not be
7670
7739
  measured is `unknown`, multiplies by 1.0, and is named in `blind_axes`. The
7671
- two input-path axes — a query built by concatenation, a route accepting an
7672
- unconstrained body — are adjacencies: no dataflow is followed (NC-001, in the
7673
- payload's `non_coverage`).
7740
+ input-path axes are bounded: a query may be merely concatenated, or may carry
7741
+ an annotated HTTP input through positional calls to a known query sink; a
7742
+ reached route may accept an unconstrained body. General taint is still NC-001,
7743
+ in the payload's `non_coverage`.
7674
7744
 
7675
7745
  \b
7676
7746
  One row is one **defect** — the same identity `spring-audit` groups by, so the
@@ -7758,6 +7828,28 @@ def enrich_cmd(
7758
7828
  limit: int = typer.Option(
7759
7829
  50, "--limit", help="How many enriched findings to publish (highest first)."
7760
7830
  ),
7831
+ table: bool = typer.Option(
7832
+ False,
7833
+ "--table",
7834
+ help="Render a human table instead of JSON/YAML. Use with --rule, --band and --top-n.",
7835
+ ),
7836
+ rule_filter: Optional[str] = typer.Option(
7837
+ None,
7838
+ "--rule",
7839
+ "--only",
7840
+ help="With --table, show only enriched findings from one external rule id.",
7841
+ ),
7842
+ band_filter: Optional[str] = typer.Option(
7843
+ None,
7844
+ "--band",
7845
+ help="With --table, show only one composed band: critical | high | medium | low.",
7846
+ ),
7847
+ top_n: int = typer.Option(
7848
+ 0,
7849
+ "--top-n",
7850
+ min=0,
7851
+ help="With --table, show only the top N findings after filtering. 0 means all.",
7852
+ ),
7761
7853
  min_band: str = typer.Option(
7762
7854
  "low",
7763
7855
  "--min-band",
@@ -7779,9 +7871,10 @@ def enrich_cmd(
7779
7871
 
7780
7872
  \b
7781
7873
  This product does not detect injection, CVEs or secrets, and says so in every
7782
- security payload (NC-001, NC-002). A scanner that does has no bean graph, no
7783
- profile model and no endpoint id it shares with anything, so its output is a
7784
- list nobody can order. `enrich` reads that list and answers the question the
7874
+ security payload (NC-001, NC-002). `risk` contributes one bounded HTTP-input
7875
+ to query-sink factor, not a scanner. A scanner has no bean graph, no profile
7876
+ model and no endpoint id it shares with anything, so its output is a list
7877
+ nobody can order. `enrich` reads that list and answers the question the
7785
7878
  scanner cannot: which of these findings sits in code an unauthenticated
7786
7879
  request reaches.
7787
7880
 
@@ -7813,6 +7906,7 @@ def enrich_cmd(
7813
7906
  ask enrich . --sarif semgrep.sarif
7814
7907
  ask enrich . --sarif codeql.sarif --min-band high
7815
7908
  ask enrich . --sarif trivy.sarif --profile prod
7909
+ ask enrich . --sarif semgrep.sarif --table --rule java-sqli --band high --top-n 20
7816
7910
  ask enrich /path/to/repo --sarif trivy.sarif -o enriched.json
7817
7911
  """
7818
7912
  from sourcecode.sarif import enrich_sarif
@@ -7835,6 +7929,16 @@ def enrich_cmd(
7835
7929
  expected="critical|high|medium|low",
7836
7930
  )
7837
7931
  raise typer.Exit(code=1)
7932
+ if band_filter is not None:
7933
+ band_filter = band_filter.strip().lower()
7934
+ if band_filter is not None and band_filter not in ("critical", "high", "medium", "low"):
7935
+ _emit_error_json(
7936
+ INVALID_INPUT_CODE,
7937
+ f"--band expects critical | high | medium | low (got {band_filter!r}).",
7938
+ hint="Example: --table --band high",
7939
+ expected="critical|high|medium|low",
7940
+ )
7941
+ raise typer.Exit(code=1)
7838
7942
  if not Path(sarif).is_file():
7839
7943
  _emit_error_json(
7840
7944
  INVALID_INPUT_CODE,
@@ -7844,11 +7948,12 @@ def enrich_cmd(
7844
7948
  )
7845
7949
  raise typer.Exit(code=1)
7846
7950
 
7951
+ enrich_limit = 100000 if table and (rule_filter or band_filter or top_n) else limit
7847
7952
  _prog = Progress()
7848
7953
  _prog.start("composing risk factors")
7849
7954
  try:
7850
7955
  data = enrich_sarif(
7851
- Path(sarif), path, limit=limit, min_band=min_band,
7956
+ Path(sarif), path, limit=enrich_limit, min_band=min_band,
7852
7957
  profiles=_profile_set(profile),
7853
7958
  )
7854
7959
  except (ValueError, json.JSONDecodeError) as exc:
@@ -7863,8 +7968,14 @@ def enrich_cmd(
7863
7968
  finally:
7864
7969
  _prog.stop()
7865
7970
 
7971
+ if table:
7972
+ output = _render_enrich_table(
7973
+ data, rule_filter=rule_filter, band_filter=band_filter, top_n=top_n
7974
+ )
7975
+ else:
7976
+ output = _serialize_dict(data, format)
7866
7977
  _emit_command_output(
7867
- _serialize_dict(data, format),
7978
+ output,
7868
7979
  output_path,
7869
7980
  copy,
7870
7981
  success_msg=(
@@ -7874,6 +7985,102 @@ def enrich_cmd(
7874
7985
  )
7875
7986
 
7876
7987
 
7988
+ @app.command("audit-report")
7989
+ def audit_report_cmd(
7990
+ path: Path = typer.Argument(
7991
+ Path("."),
7992
+ help="Repository path (default: current directory).",
7993
+ ),
7994
+ profile: Optional[str] = typer.Option(
7995
+ None,
7996
+ "--profile",
7997
+ help="Active profile set, comma-separated (e.g. prod or prod,metrics).",
7998
+ ),
7999
+ sign_key: Optional[Path] = typer.Option(
8000
+ None,
8001
+ "--sign-key",
8002
+ help="Sign the canonical JSON report with this local HMAC key file.",
8003
+ ),
8004
+ output_path: Optional[Path] = typer.Option(
8005
+ None, "--output", "-o", help="Write the report to a file instead of stdout."
8006
+ ),
8007
+ format: str = typer.Option(
8008
+ "json", "--format", "-f", help="Output format: json, yaml or markdown."
8009
+ ),
8010
+ copy: bool = _copy_option(),
8011
+ ) -> None:
8012
+ """[EXPERIMENTAL] Human audit bundle over existing ASK evidence, optionally signed.
8013
+
8014
+ \b
8015
+ This packages the same evidence `risk` and `posture` already publish into a
8016
+ report a buyer can read: top risks, effective runtime posture, source-linked
8017
+ evidence and non-coverage. It does not certify compliance, choose fixes or
8018
+ assign ROI.
8019
+
8020
+ \b
8021
+ `--sign-key` adds an HMAC-SHA256 signature over the canonical JSON payload.
8022
+ The key is local and explicit; no network trust service or new PKI is implied.
8023
+
8024
+ \b
8025
+ Examples:
8026
+ ask audit-report .
8027
+ ask audit-report . --profile prod --format markdown
8028
+ ask audit-report . --sign-key audit.key -o audit-report.json
8029
+ """
8030
+ from sourcecode.audit_report import build_audit_report, render_markdown
8031
+
8032
+ path = _admit_path(path)
8033
+ fmt = format.strip().lower()
8034
+ if fmt not in ("json", "yaml", "markdown"):
8035
+ _emit_error_json(
8036
+ INVALID_INPUT_CODE,
8037
+ f"--format expects json | yaml | markdown (got {format!r}).",
8038
+ hint="Example: ask audit-report . --format markdown",
8039
+ expected="json|yaml|markdown",
8040
+ )
8041
+ raise typer.Exit(code=1)
8042
+ key_bytes = None
8043
+ if sign_key is not None:
8044
+ key_path = Path(sign_key).expanduser()
8045
+ if not key_path.is_file():
8046
+ _emit_error_json(
8047
+ INVALID_INPUT_CODE,
8048
+ f"--sign-key must name a readable file (got {str(sign_key)!r}).",
8049
+ hint="Example: ask audit-report . --sign-key audit.key",
8050
+ expected="an existing key file",
8051
+ )
8052
+ raise typer.Exit(code=1)
8053
+ key_bytes = key_path.read_bytes()
8054
+ if not key_bytes:
8055
+ _emit_error_json(
8056
+ INVALID_INPUT_CODE,
8057
+ "--sign-key file is empty; refusing to publish an unverifiable signature.",
8058
+ hint="Write a random local secret to the key file first.",
8059
+ expected="a non-empty key file",
8060
+ )
8061
+ raise typer.Exit(code=1)
8062
+
8063
+ _prog = Progress()
8064
+ _prog.start("packaging audit evidence")
8065
+ try:
8066
+ data = build_audit_report(
8067
+ path, profiles=_profile_set(profile), sign_key=key_bytes,
8068
+ )
8069
+ finally:
8070
+ _prog.stop()
8071
+ output = render_markdown(data) if fmt == "markdown" else _serialize_dict(data, fmt)
8072
+ _emit_command_output(
8073
+ output,
8074
+ output_path,
8075
+ copy,
8076
+ success_msg=f"audit report written to {output_path}",
8077
+ # A signed artifact cannot be mutated after signing. The report carries
8078
+ # tool/version metadata itself, and `signature.payload_sha256` is over
8079
+ # the exact JSON payload the user receives.
8080
+ stamp_envelope=False,
8081
+ )
8082
+
8083
+
7877
8084
  @app.command("migrate-recipe")
7878
8085
  def migrate_recipe_cmd(
7879
8086
  path: Path = typer.Argument(
@@ -8145,6 +8352,14 @@ def posture_cmd(
8145
8352
  "by what it leaves unauthenticated."
8146
8353
  ),
8147
8354
  ),
8355
+ env_file: Optional[Path] = typer.Option(
8356
+ None,
8357
+ "--env-file",
8358
+ help=(
8359
+ "With --resolve-environments, read SPRING_PROFILES_ACTIVE or "
8360
+ "spring.profiles.active from an explicit deployment env/dotenv file."
8361
+ ),
8362
+ ),
8148
8363
  property_overrides: Optional[list[str]] = typer.Option(
8149
8364
  None,
8150
8365
  "--property",
@@ -8192,6 +8407,7 @@ def posture_cmd(
8192
8407
  ask posture . --since origin/main --fail-on opened
8193
8408
  ask posture . --profile prod --property app.security.enabled=true
8194
8409
  ask posture . --resolve-environments
8410
+ ask posture . --resolve-environments --env-file prod.env
8195
8411
  """
8196
8412
  from sourcecode.posture import (
8197
8413
  FAIL_ON_CHOICES,
@@ -8266,6 +8482,14 @@ def posture_cmd(
8266
8482
  hint="ask posture . --profile prod --diff-ref origin/main:HEAD",
8267
8483
  )
8268
8484
  raise typer.Exit(code=1)
8485
+ if env_file and not resolve_environments_flag:
8486
+ _emit_error_json(
8487
+ INVALID_INPUT_CODE,
8488
+ "--env-file supplies deployment evidence for --resolve-environments; it cannot "
8489
+ "be used with a single profile, profile diff or ref comparison.",
8490
+ hint="ask posture . --resolve-environments --env-file prod.env",
8491
+ )
8492
+ raise typer.Exit(code=1)
8269
8493
 
8270
8494
  # One spinner over the whole dispatch: every branch below resolves the
8271
8495
  # conditional bean graph, and `--diff-ref` resolves it twice (once per ref).
@@ -8280,10 +8504,26 @@ def posture_cmd(
8280
8504
  INVALID_INPUT_CODE,
8281
8505
  "--resolve-environments answers which profile set runs; it cannot be "
8282
8506
  "combined with --profile, --diff or --diff-ref/--since, which state one.",
8283
- hint="Run `ask posture . --resolve-environments` on its own.",
8507
+ hint="Run `ask posture . --resolve-environments` on its own, or add "
8508
+ "`--env-file prod.env` as deployment evidence.",
8284
8509
  )
8285
8510
  raise typer.Exit(code=1)
8286
- data = resolve_environments(path, overrides)
8511
+ deployment_signals = None
8512
+ if env_file is not None:
8513
+ from sourcecode.environment_resolution import collect_env_file_signals
8514
+
8515
+ env_path = Path(env_file).expanduser()
8516
+ if not env_path.is_file():
8517
+ _emit_error_json(
8518
+ INVALID_INPUT_CODE,
8519
+ f"--env-file must name a readable file (got {str(env_file)!r}).",
8520
+ hint="ask posture . --resolve-environments --env-file prod.env",
8521
+ )
8522
+ raise typer.Exit(code=1)
8523
+ deployment_signals = collect_env_file_signals(env_path, root=path)
8524
+ data = resolve_environments(
8525
+ path, overrides, deployment_signals=deployment_signals,
8526
+ )
8287
8527
  elif ref_comparison:
8288
8528
  from sourcecode.git_checkout import RefError
8289
8529
 
@@ -11913,7 +12153,8 @@ HELP_PANELS: "tuple[tuple[str, tuple[str, ...]], ...]" = (
11913
12153
  "cache", "auth", "mcp", "telemetry", "baseline",
11914
12154
  )),
11915
12155
  ("Experimental — shape may change", (
11916
- "risk", "enrich", "data-exposure", "migrate-recipe", "archetype", "retrieve",
12156
+ "risk", "enrich", "audit-report", "data-exposure", "migrate-recipe",
12157
+ "archetype", "retrieve",
11917
12158
  )),
11918
12159
  )
11919
12160
 
@@ -335,25 +335,107 @@ def _next_param_value(lines: list[str], after: int) -> Optional[str]:
335
335
  return None
336
336
 
337
337
 
338
- def _signal(kind: str, rel: str, line: int, raw: str, value: Optional[str]) -> Signal:
338
+ def _signal(
339
+ kind: str,
340
+ rel: str,
341
+ line: int,
342
+ raw: str,
343
+ value: Optional[str],
344
+ *,
345
+ explicit: bool = False,
346
+ ) -> Signal:
347
+ prefix = "explicit deployment input " if explicit else ""
339
348
  if value and _PLACEHOLDER.search(value):
340
349
  return Signal(
341
350
  kind, rel, line, raw, None,
342
- f"declares {value!r} — a placeholder resolved outside this repository, "
351
+ f"{prefix}declares {value!r} — a placeholder resolved outside this repository, "
343
352
  "so this artefact names where the value comes from, not what it is",
344
353
  )
345
354
  if not value:
346
355
  return Signal(
347
356
  kind, rel, line, raw, None,
348
- "names the setting without a value here — it defers to whatever "
357
+ f"{prefix}names the setting without a value here — it defers to whatever "
349
358
  "supplies it at run time",
350
359
  )
351
- return Signal(kind, rel, line, raw, value, f"sets the active profile set to {value!r}")
360
+ return Signal(
361
+ kind, rel, line, raw, value,
362
+ f"{prefix}sets the active profile set to {value!r}",
363
+ )
364
+
365
+
366
+ def collect_env_file_signals(path: Path, *, root: Optional[Path] = None) -> list[Signal]:
367
+ """Signals from a caller-supplied deployment env/dotenv file.
368
+
369
+ Unlike repository artefacts, this is evidence the reviewer explicitly supplies
370
+ for the deployment being audited, so a deciding value outranks defaults found
371
+ in the tree. It still travels as ordinary evidence: file, line, raw text and
372
+ placeholder handling are the same as repository scans.
373
+ """
374
+ source = Path(path).expanduser().resolve()
375
+ root_path = Path(root).resolve() if root is not None else None
376
+ text = source.read_text(encoding="utf-8", errors="ignore")
377
+ try:
378
+ rel = source.relative_to(root_path).as_posix() if root_path else source.as_posix()
379
+ except ValueError:
380
+ rel = source.as_posix()
352
381
 
382
+ signals: list[Signal] = []
383
+ for index, line in enumerate(text.splitlines(), start=1):
384
+ stripped = line.strip()
385
+ if not stripped or stripped.startswith("#"):
386
+ continue
387
+ for _label, pattern in _LINE_PATTERNS:
388
+ match = pattern.search(line)
389
+ if match:
390
+ signals.append(_signal(
391
+ "deployment_input", rel, index, line, _clean(match.group(1)),
392
+ explicit=True,
393
+ ))
394
+ break
395
+ return signals
353
396
 
354
- def resolve_environment(root: Path) -> EnvironmentResolution:
397
+
398
+ def resolve_environment(
399
+ root: Path,
400
+ deployment_signals: Optional[Iterable[Signal]] = None,
401
+ ) -> EnvironmentResolution:
355
402
  """What decides the active profile set in *root* — or the fact that nothing does."""
356
- signals = collect_signals(root)
403
+ supplied = list(deployment_signals or [])
404
+ repo_signals = collect_signals(root)
405
+ supplied_deciding = [s for s in supplied if s.value and s.activates]
406
+ supplied_deferred = [s for s in supplied if not (s.value and s.activates)]
407
+ if supplied_deciding:
408
+ distinct_supplied = sorted({s.value for s in supplied_deciding if s.value})
409
+ if len(distinct_supplied) > 1:
410
+ return EnvironmentResolution(
411
+ verdict=DISAGREE,
412
+ profile_source="; ".join(
413
+ f"{s.file}:{s.line} → {s.value}" for s in supplied_deciding[:6]
414
+ ),
415
+ statement=(
416
+ "The supplied deployment inputs set different active profile sets "
417
+ f"({', '.join(repr(v) for v in distinct_supplied)}). ASK will not "
418
+ "choose between deployment descriptions that disagree."
419
+ ),
420
+ signals=supplied_deciding,
421
+ deferred=supplied_deferred + repo_signals,
422
+ )
423
+ values = _split_profiles(distinct_supplied[0])
424
+ first = supplied_deciding[0]
425
+ return EnvironmentResolution(
426
+ verdict=DECIDED,
427
+ profile_source=f"{first.kind} ({first.file}:{first.line})",
428
+ statement=(
429
+ f"The supplied deployment input says {', '.join(repr(v) for v in values)} "
430
+ f"is the active profile set, from {first.file}:{first.line}. Repository "
431
+ "defaults are still published below as lower-priority evidence."
432
+ ),
433
+ signals=supplied_deciding,
434
+ deferred=supplied_deferred + repo_signals,
435
+ values=values,
436
+ )
437
+
438
+ signals = supplied + repo_signals
357
439
  deciding = [s for s in signals if s.value and s.activates]
358
440
  deferred = [s for s in signals if not (s.value and s.activates)]
359
441
  inert = [s for s in signals if s.value and not s.activates]