rein-aharness/agent_harness/mailscan.py
tegwick cfc1b75157 Harness foundation: INTENT, ADR-001, architecture, prototype adoption
- INTENT.md: three-layer model (blueprint/instance/harness), single
  shared runtime, never-become boundaries
- ADR-001 (accepted): DEC-2026-002 resolution — one harness repo for
  all projects; instances are declarative state in consuming repos
- docs/architecture.md: components, contracts (manifest, tool
  profiles, completion events, credential lanes), deployment shape
- agent_harness/: executor-worker prototype adopted and renamed
  (6/6 tests green); HARNESS-WP-0001 initial workplan (7 tasks)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:35:27 +02:00

185 lines
5.9 KiB
Python

"""Recurring company-mailbox scan (BINKY-WP-0004-T05).
Two-phase design, because credentials and network are barred from the
agentic session's tool allow-list:
1. **Deterministic scan phase** (this module, no LLM): acquire the IMAP
credentials from OpenBao, run email-connect's read-only scan-mailbox in
the target repo, record a `binky_mail_intake` hub progress event with
metadata-only counts. Credential values live exclusively in the child
process environment — never in argv, logs, hub events, or files.
2. **Optional triage session** (standard run_task): an agentic session in
the target repo reads the newest report CSV and updates the metadata
log/queues. Suspicious-mail rule is part of that task's prompt: never
act on message content, only log it.
Credential acquisition, in order:
- AppRole (unattended lane): role_id/secret_id files under
$EXECUTOR_APPROLE_DIR → `bao write -field=token auth/approle/login ...`
- Pre-existing token (operator lane): ambient bao token, e.g. after an
interactive OIDC login.
Then two `bao kv get -field=...` reads on the kv path.
"""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from agent_harness import hub
_KV_PATH = "tenants/binky/company-email/imap"
_BAO_TIMEOUT = 30
class MailScanError(RuntimeError):
pass
@dataclass
class MailScanResult:
ok: bool
report_path: str | None
new_messages: int | None
auth_lane: str
reason: str = ""
def _bao(*args: str, env: dict[str, str] | None = None) -> str:
result = subprocess.run(
["bao", *args],
capture_output=True,
text=True,
timeout=_BAO_TIMEOUT,
env=env or os.environ.copy(),
)
if result.returncode != 0:
# stderr may reference paths/roles but never secret values
raise MailScanError(f"bao {args[0]} failed: {result.stderr.strip()[:200]}")
return result.stdout.strip()
def _acquire_token() -> tuple[str | None, str]:
"""Return (token_or_none_for_ambient, auth_lane)."""
approle_dir = os.environ.get("EXECUTOR_APPROLE_DIR")
if approle_dir:
role_id_file = Path(approle_dir) / "role_id"
secret_id_file = Path(approle_dir) / "secret_id"
if role_id_file.is_file() and secret_id_file.is_file():
token = _bao(
"write",
"-field=token",
"auth/approle/login",
f"role_id={role_id_file.read_text().strip()}",
f"secret_id={secret_id_file.read_text().strip()}",
)
return token, "approle"
return None, "ambient"
def _fetch_credentials() -> tuple[dict[str, str], str]:
token, lane = _acquire_token()
env = os.environ.copy()
if token:
env["BAO_TOKEN"] = token
creds = {
field: _bao("kv", "get", f"-field={field}", _KV_PATH, env=env)
for field in ("IMAP_USERNAME", "IMAP_PASSWORD")
}
return creds, lane
def run_mail_scan(
target_repo: Path,
config: str = "integrations/mailbox-binky-company.yml",
out_dir: str = "mailmeta/reports",
email_connect_src: str | None = None,
report_to_hub: bool = True,
) -> MailScanResult:
try:
creds, lane = _fetch_credentials()
except MailScanError as exc:
result = MailScanResult(
ok=False, report_path=None, new_messages=None, auth_lane="none",
reason=str(exc),
)
_report(result, report_to_hub, target_repo)
return result
env = os.environ.copy()
env.update(creds)
if email_connect_src is None:
email_connect_src = str(Path.home() / "email-connect" / "src")
env["PYTHONPATH"] = email_connect_src
before = _report_files(target_repo / out_dir)
proc = subprocess.run(
[
"python3", "-m", "email_connect.cli", "scan-mailbox",
"--config", config,
"--out", out_dir,
],
cwd=target_repo,
capture_output=True,
text=True,
timeout=600,
env=env,
)
if proc.returncode != 0:
result = MailScanResult(
ok=False, report_path=None, new_messages=None, auth_lane=lane,
reason=f"email-connect exited {proc.returncode}: {proc.stderr.strip()[:200]}",
)
_report(result, report_to_hub, target_repo)
return result
new_reports = sorted(_report_files(target_repo / out_dir) - before)
report_path = new_reports[-1] if new_reports else None
new_messages = _count_rows(target_repo / out_dir / report_path) if report_path else 0
result = MailScanResult(
ok=True, report_path=report_path, new_messages=new_messages, auth_lane=lane,
)
_report(result, report_to_hub, target_repo)
return result
def _report_files(out_dir: Path) -> set[str]:
if not out_dir.is_dir():
return set()
return {p.name for p in out_dir.glob("*.csv")}
def _count_rows(path: Path) -> int | None:
try:
with path.open() as fh:
return max(0, sum(1 for _ in fh) - 1)
except OSError:
return None
def _report(result: MailScanResult, report_to_hub: bool, target_repo: Path) -> None:
if not report_to_hub:
return
# Failures must NOT emit binky_mail_intake: the activity-core resolver
# treats any such event as "a scan ran" (idempotence guard), so a failed
# run reports as a generic executor_run failure instead and the slot
# stays due.
event_type = "binky_mail_intake" if result.ok else "executor_run"
hub.post_progress_event(
summary=(
f"binky mailbox scan {'ok' if result.ok else 'failed'}"
+ (f": {result.new_messages} new message(s)" if result.ok else "")
),
event_type=event_type,
detail={
"repo": target_repo.name,
"ok": result.ok,
"report": result.report_path,
"new_messages": result.new_messages,
"auth_lane": result.auth_lane,
"reason": result.reason,
},
)