- 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>
185 lines
5.9 KiB
Python
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,
|
|
},
|
|
)
|