Skip to content

Commit 6725794

Browse files
committed
Use agent audit for runtime env
1 parent 2d05f60 commit 6725794

8 files changed

Lines changed: 511 additions & 10 deletions

File tree

pithos/cli.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@
1616

1717
from .events import event_mode_from_env, make_event_sink
1818
from .exec_backend import SANDBOX_MODES
19-
from .repo_runner import DEFAULT_REPO_VOTES, allocate_repo_run_dir, run_repo_static
19+
from .env_discovery import env_hint_report_from_agent_audit
20+
from .repo_runner import (
21+
DEFAULT_REPO_VOTES,
22+
allocate_repo_run_dir,
23+
run_repo_env_audit,
24+
run_repo_static,
25+
)
2026
from .repo_source import DEFAULT_REPO_CACHE, redact_secrets, resolve_repo_source
2127
from .runtime_template import generate_runtime_profile_template, write_runtime_profile_template
2228
from .runtime_verifier import run_runtime_preflight, run_verify_repo
@@ -335,7 +341,23 @@ def _cmd_run(args: argparse.Namespace) -> int:
335341
allocate_repo_run_dir(args.results_dir, repo_path) if args.execute_app else None
336342
)
337343
runtime_preflight = None
344+
env_audit_report = None
338345
if args.execute_app:
346+
if args.runtime_profile is None:
347+
env_audit = asyncio.run(
348+
run_repo_env_audit(
349+
repo_path=repo_path,
350+
provider=args.provider,
351+
model=args.model,
352+
agent_env=agent_env,
353+
results_dir=args.results_dir,
354+
pi_config_dir=pi_config_dir,
355+
sandbox_mode=args.sandbox_mode,
356+
out_dir=run_out_dir,
357+
event_sink=event_sink,
358+
)
359+
)
360+
env_audit_report = env_hint_report_from_agent_audit(env_audit)
339361
runtime_preflight = run_runtime_preflight(
340362
repo_path=repo_path,
341363
profile_path=args.runtime_profile,
@@ -347,6 +369,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
347369
agent_env=agent_env,
348370
pi_config_dir=pi_config_dir,
349371
sandbox_mode=args.sandbox_mode,
372+
env_audit_report=env_audit_report,
350373
event_sink=event_sink,
351374
)
352375
scan = asyncio.run(
@@ -380,6 +403,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
380403
pi_config_dir=pi_config_dir,
381404
sandbox_mode=args.sandbox_mode,
382405
preflight_result=runtime_preflight,
406+
env_audit_report=env_audit_report,
383407
event_sink=event_sink,
384408
)
385409
except KeyboardInterrupt:

pithos/env_discovery.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
}
3232
MAX_SOURCE_FILES = 500
3333
MAX_FILE_BYTES = 512 * 1024
34-
REQUIRED_ENV_SOURCES = {"env_example", "github_actions"}
34+
AGENT_RUNTIME_SOURCE = "agent_runtime"
35+
REQUIRED_ENV_SOURCES = {"env_example", "github_actions", AGENT_RUNTIME_SOURCE}
3536

3637
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
3738
_ENV_ASSIGN_RE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
@@ -188,6 +189,25 @@ def collect_env_hints(repo: Path) -> EnvHintReport:
188189
)
189190

190191

192+
def env_hint_report_from_agent_audit(audit: dict[str, Any]) -> EnvHintReport:
193+
"""Normalize agent-classified runtime env audit output into env hints."""
194+
hints: list[EnvHint] = []
195+
for item in _audit_required_items(audit):
196+
name = _audit_item_name(item)
197+
if not name or not is_env_name(name):
198+
continue
199+
hints.append(
200+
EnvHint(
201+
name=name,
202+
source=AGENT_RUNTIME_SOURCE,
203+
path=_audit_item_path(item),
204+
confidence=_audit_item_confidence(item),
205+
evidence=_audit_item_evidence(item),
206+
)
207+
)
208+
return EnvHintReport(hints=_dedupe_hints(hints))
209+
210+
191211
def env_names_from_examples(repo: Path) -> list[str]:
192212
"""Return env var names declared in example env files."""
193213
return sorted({hint.name for hint in _env_example_hints(repo.resolve(), [])})
@@ -299,6 +319,47 @@ def _source_paths(repo: Path) -> Iterable[Path]:
299319
yield path
300320

301321

322+
def _audit_required_items(audit: dict[str, Any]) -> Iterable[Any]:
323+
for key in ("required_env", "required", "env"):
324+
value = audit.get(key)
325+
if isinstance(value, list):
326+
return value
327+
return []
328+
329+
330+
def _audit_item_name(item: Any) -> str | None:
331+
if isinstance(item, str):
332+
return item
333+
if isinstance(item, dict):
334+
value = item.get("name") or item.get("variable") or item.get("env")
335+
return str(value) if value else None
336+
return None
337+
338+
339+
def _audit_item_path(item: Any) -> str:
340+
if not isinstance(item, dict):
341+
return "ENV-AUDIT.json"
342+
sources = item.get("sources") or item.get("source_files") or item.get("files")
343+
if isinstance(sources, list) and sources:
344+
return str(sources[0])
345+
value = item.get("path") or item.get("source") or "ENV-AUDIT.json"
346+
return str(value)
347+
348+
349+
def _audit_item_confidence(item: Any) -> str:
350+
if not isinstance(item, dict):
351+
return "medium"
352+
value = str(item.get("confidence") or "medium").lower()
353+
return value if value in {"high", "medium", "low"} else "medium"
354+
355+
356+
def _audit_item_evidence(item: Any) -> str | None:
357+
if not isinstance(item, dict):
358+
return None
359+
value = item.get("reason") or item.get("evidence") or item.get("why")
360+
return str(value)[:240] if value else None
361+
362+
302363
def _safe_source_evidence(match: str) -> str:
303364
return match[:120]
304365

pithos/repo_runner.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ def _json_candidates(text: str) -> list[tuple[Any, str]]:
161161

162162

163163
def _expected_json_shape(tag: str) -> type | None:
164+
if tag == "env_audit_json":
165+
return dict
164166
if tag == "findings_json":
165167
return list
166168
if tag == "triage_json":
@@ -175,6 +177,8 @@ def _matches_expected_json(value: Any, expected: type | None, tag: str) -> bool:
175177
return all(isinstance(item, dict) for item in value)
176178
if tag == "triage_json":
177179
return isinstance(value.get("findings"), list)
180+
if tag == "env_audit_json":
181+
return isinstance(value.get("required_env"), list)
178182
return True
179183

180184

@@ -249,6 +253,49 @@ def _threat_prompt(repo_meta: dict[str, Any], stack: dict[str, Any]) -> str:
249253
"""
250254

251255

256+
def _env_audit_prompt(repo_meta: dict[str, Any], stack: dict[str, Any]) -> str:
257+
return f"""\
258+
Determine the environment variables required to run the repository mounted read-only at {REPO_MOUNT}
259+
as a meaningful local/staging application, not merely to compile a public page.
260+
261+
Repository metadata:
262+
{json.dumps(repo_meta, indent=2)}
263+
264+
Detected stack hints:
265+
{json.dumps(stack, indent=2)}
266+
267+
Inspect source and configuration only. Do not execute project code. Decide which env vars a user
268+
would need to configure for the app's primary runtime functionality to work in local/staging,
269+
including auth/session, database, first-party integrations, webhooks, billing if it is part of the
270+
product, scheduled/background workflows, and worker/sandbox callbacks that the app itself invokes.
271+
Use package scripts, app framework config, middleware, root layouts, server/client initialization
272+
code, app route handlers, workflow/worker definitions, .env.example, docs, and CI configuration as
273+
evidence.
274+
275+
Exclude variables only when they are clearly unrelated to running the application: tests/fixtures,
276+
lint/build tooling, CI/deploy metadata, analytics or observability that safely degrades, source-map
277+
upload settings, alternate provider fallbacks not used by the primary configured path, or
278+
vulnerability verification personas.
279+
280+
For each required variable, include:
281+
- name
282+
- confidence: high|medium|low
283+
- reason: one sentence explaining why app runtime needs it
284+
- sources: list of repo-relative evidence files
285+
286+
Return only:
287+
<env_audit_json>
288+
{{ "required_env": [ ... ], "optional_env": [ ... ], "summary": {{ ... }} }}
289+
</env_audit_json>
290+
<env_audit_markdown>
291+
...human-readable markdown...
292+
</env_audit_markdown>
293+
294+
The <env_audit_json> tag is mandatory. If no variables are required to boot, return
295+
{{ "required_env": [], "optional_env": [], "summary": {{ "required": 0 }} }}.
296+
"""
297+
298+
252299
def _scan_prompt(threat_model: str, max_findings: int, dependency_advisory_summary: str) -> str:
253300
return f"""\
254301
Perform a static vulnerability scan of the repository mounted read-only at {REPO_MOUNT}.
@@ -354,6 +401,30 @@ def _triage_prompt(
354401

355402
def _repair_prompt(tag: str, previous_text: str, context: dict[str, Any] | None = None) -> str:
356403
context_text = json.dumps(context or {}, indent=2)
404+
if tag == "env_audit_json":
405+
return f"""\
406+
The previous runtime environment audit did not emit the required machine-readable artifact.
407+
Reformat the previous answer into the exact artifacts below. Use only the previous answer and
408+
context; do not invent required env vars that were not stated.
409+
410+
Context:
411+
<context_json>
412+
{context_text}
413+
</context_json>
414+
415+
Previous answer:
416+
<previous_answer>
417+
{previous_text}
418+
</previous_answer>
419+
420+
Return only:
421+
<env_audit_json>
422+
{{ "required_env": [ ... ], "optional_env": [ ... ], "summary": {{ ... }} }}
423+
</env_audit_json>
424+
<env_audit_markdown>
425+
...human-readable markdown summary...
426+
</env_audit_markdown>
427+
"""
357428
if tag == "findings_json":
358429
return f"""\
359430
The previous static vulnerability scan did not emit the required machine-readable artifact.
@@ -406,6 +477,92 @@ def _repair_prompt(tag: str, previous_text: str, context: dict[str, Any] | None
406477
raise ValueError(f"unsupported repair tag: {tag}")
407478

408479

480+
async def run_repo_env_audit(
481+
*,
482+
repo_path: Path,
483+
provider: str,
484+
model: str,
485+
agent_env: dict[str, str],
486+
results_dir: Path,
487+
pi_config_dir: Path | None = None,
488+
sandbox_mode: str = "docker",
489+
out_dir: Path | None = None,
490+
event_sink: EventSinkLike = NULL_EVENT_SINK,
491+
) -> dict[str, Any]:
492+
repo_path = repo_path.resolve()
493+
backend = create_execution_backend(sandbox_mode)
494+
out_dir = out_dir or allocate_repo_run_dir(results_dir, repo_path)
495+
out_dir.mkdir(parents=True, exist_ok=True)
496+
repo_meta = {"path": str(repo_path), **_git_metadata(repo_path)}
497+
stack = _detect_stack(repo_path)
498+
499+
audit_text, err = await _run_stage(
500+
prompt=_env_audit_prompt(repo_meta, stack),
501+
provider=provider,
502+
model=model,
503+
agent_env=agent_env,
504+
repo_path=repo_path,
505+
pi_config_dir=pi_config_dir,
506+
container_name=f"repo_{_safe_repo_name(repo_path)}_env",
507+
transcript_path=out_dir / "env_audit_transcript.jsonl",
508+
progress_prefix="[repo:env]",
509+
primary_tag="env_audit_json",
510+
allow_web=False,
511+
backend=backend,
512+
event_sink=event_sink,
513+
)
514+
audit_value, _audit_source = _extract_json_artifact(audit_text, "env_audit_json")
515+
if audit_value is _MISSING or not isinstance(audit_value, dict):
516+
repair_text, repair_err = await _run_stage(
517+
prompt=_repair_prompt(
518+
"env_audit_json",
519+
audit_text,
520+
{"repo": repo_meta, "stack": stack, "transport_error": err},
521+
),
522+
provider=provider,
523+
model=model,
524+
agent_env=agent_env,
525+
repo_path=repo_path,
526+
pi_config_dir=pi_config_dir,
527+
container_name=f"repo_{_safe_repo_name(repo_path)}_env_repair",
528+
transcript_path=out_dir / "env_audit_repair_transcript.jsonl",
529+
progress_prefix="[repo:env:repair]",
530+
primary_tag="env_audit_json",
531+
allow_web=False,
532+
tools=[],
533+
backend=backend,
534+
event_sink=event_sink,
535+
)
536+
repaired_value, _repaired_source = _extract_json_artifact(repair_text, "env_audit_json")
537+
if repaired_value is _MISSING or not isinstance(repaired_value, dict):
538+
errors = [item for item in (err, repair_err, "env_audit: missing artifact") if item]
539+
audit_value = {"required_env": [], "optional_env": [], "summary": {}, "errors": errors}
540+
else:
541+
audit_text = repair_text
542+
audit_value = repaired_value
543+
elif err:
544+
audit_value = {**audit_value, "errors": [err]}
545+
546+
audit_markdown = parse_xml_tag(audit_text, "env_audit_markdown") or env_audit_markdown(
547+
audit_value
548+
)
549+
_write(
550+
out_dir / "ENV-AUDIT.json",
551+
json.dumps(audit_value, indent=2),
552+
event_sink=event_sink,
553+
stage="repo:env",
554+
artifact_type="env_audit_json",
555+
)
556+
_write(
557+
out_dir / "ENV-AUDIT.md",
558+
audit_markdown,
559+
event_sink=event_sink,
560+
stage="repo:env",
561+
artifact_type="env_audit_markdown",
562+
)
563+
return audit_value
564+
565+
409566
async def _run_stage(
410567
*,
411568
prompt: str,
@@ -767,6 +924,27 @@ async def run_repo_static(
767924
)
768925

769926

927+
def env_audit_markdown(audit: dict[str, Any]) -> str:
928+
lines = ["# Runtime Environment Audit", ""]
929+
required = audit.get("required_env") if isinstance(audit.get("required_env"), list) else []
930+
if required:
931+
lines += ["## Required", ""]
932+
for item in required:
933+
if isinstance(item, str):
934+
lines.append(f"- `{item}`")
935+
elif isinstance(item, dict):
936+
name = item.get("name") or item.get("variable") or item.get("env")
937+
reason = item.get("reason") or item.get("evidence")
938+
suffix = f" - {reason}" if reason else ""
939+
lines.append(f"- `{name}`{suffix}")
940+
else:
941+
lines.append("No required runtime environment variables were identified.")
942+
if audit.get("errors"):
943+
lines += ["", "## Errors", ""]
944+
lines.extend(f"- {error}" for error in audit["errors"])
945+
return "\n".join(lines)
946+
947+
770948
def _run_markdown(summary: dict[str, Any]) -> str:
771949
return f"""\
772950
# Autonomous Repo Static Review

0 commit comments

Comments
 (0)