diff --git a/.agent/tools/data_layer_export.py b/.agent/tools/data_layer_export.py index 1002ada..e2c8880 100755 --- a/.agent/tools/data_layer_export.py +++ b/.agent/tools/data_layer_export.py @@ -24,6 +24,21 @@ VALID_WINDOWS = {"7d", "30d", "90d", "all"} VALID_BUCKETS = {"hour", "day", "week", "month"} +# Finite value sets the loop supervisor (harness_manager/loops/runner.py) +# actually writes to runtime/loops/events.jsonl. normalize_loop_event must +# redact anything outside these sets rather than copy arbitrary loop-event +# content into the exported dashboard/analytics surface. +VALID_LOOP_EVENTS = { + "created", "awaiting_approval", "worktree_created", "paused", + "interrupted", "maker_finished", "verifier_finished", "checker_finished", + "completed", "cancelled", +} +VALID_LOOP_STATUSES = { + "created", "awaiting_approval", "paused", "exhausted", "interrupted", + "completed", "cancelled", "audit_failed", "failed", "rejected", +} +VALID_LOOP_DECISIONS = {"APPROVE", "ESCALATE", "MALFORMED"} + def _e(*codes: int) -> str: return f"\x1b[{';'.join(map(str, codes))}m" @@ -393,14 +408,30 @@ def normalize_agent_event(entry: dict[str, Any], idx: int, args: argparse.Namesp return base +def _allowed_or_unknown(value: Any, allowed: set[str]) -> str: + text = str(value) if value is not None else "" + return text if text in allowed else "unknown" + + def normalize_loop_event(entry: dict[str, Any]) -> dict[str, Any]: - """Map the privacy-whitelisted loop event shape into data-layer fields.""" + """Map the privacy-whitelisted loop event shape into data-layer fields. + + entry's `event`/`status`/`decision` values come from a supervisor-controlled + finite set (VALID_LOOP_EVENTS/STATUSES/DECISIONS); anything else is redacted + to "unknown" rather than copied through, so a malformed or unexpected + events.jsonl row can't smuggle arbitrary text into the exported surface. + """ + status_or_decision = entry.get("status") or entry.get("decision") return { "timestamp": entry.get("timestamp") or now_iso(), "skill": "agentic-loop", - "action": str(entry.get("event") or "loop_event"), + "action": _allowed_or_unknown(entry.get("event"), VALID_LOOP_EVENTS), "workflow": str(entry.get("loop") or "loop"), - "result": str(entry.get("status") or entry.get("decision") or "observed"), + "result": ( + _allowed_or_unknown(status_or_decision, VALID_LOOP_STATUSES | VALID_LOOP_DECISIONS) + if status_or_decision is not None + else "observed" + ), "harness": "agentic-loop", "source": {"run_id": entry.get("run_id")}, "privacy_level": "local_only", diff --git a/tests/test_data_layer_export.py b/tests/test_data_layer_export.py index 27033ec..51691db 100644 --- a/tests/test_data_layer_export.py +++ b/tests/test_data_layer_export.py @@ -148,6 +148,40 @@ def test_exports_privacy_safe_loop_events_and_quality_counts(self): self.assertNotIn("do not export", exported) self.assertIn("agentic-loop", exported) + def test_redacts_loop_event_status_and_decision_outside_the_allowed_sets(self): + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + events = work / ".agent" / "runtime" / "loops" + events.mkdir(parents=True) + (events / "events.jsonl").write_text( + "\n".join( + json.dumps(row) + for row in [ + { + "run_id": "run-a", + "loop": "ci-sweeper", + "event": "", + }, + { + "run_id": "run-b", + "loop": "ci-sweeper", + "decision": "leaked prompt content here", + }, + {"run_id": "run-c", "loop": "ci-sweeper", "event": "completed"}, + ] + ) + + "\n", + encoding="utf-8", + ) + result = self.run_export(work, "--window", "all", "--date", "2026-04-25") + self.assertEqual(result.returncode, 0, result.stderr) + out = work / ".agent" / "data-layer" / "exports" / "2026-04-25" + exported = (out / "agent-events.jsonl").read_text() + self.assertNotIn("exfiltrate", exported) + self.assertNotIn("leaked prompt content", exported) + self.assertNotIn("