@0dai-dev/cli 4.3.5 → 4.3.7

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.
Files changed (79) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +214 -40
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +55 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/detect.js +10 -4
  9. package/lib/commands/doctor.js +545 -26
  10. package/lib/commands/experience.js +40 -5
  11. package/lib/commands/export.js +73 -0
  12. package/lib/commands/feedback.js +157 -15
  13. package/lib/commands/gh.js +26 -0
  14. package/lib/commands/graph.js +9 -4
  15. package/lib/commands/heatmap.js +1 -1
  16. package/lib/commands/init.js +222 -30
  17. package/lib/commands/mcp.js +129 -21
  18. package/lib/commands/models.js +138 -41
  19. package/lib/commands/provider.js +30 -59
  20. package/lib/commands/quota.js +1 -1
  21. package/lib/commands/receipt.js +1 -1
  22. package/lib/commands/run.js +18 -7
  23. package/lib/commands/runner.js +31 -1
  24. package/lib/commands/status.js +44 -11
  25. package/lib/commands/swarm.js +130 -12
  26. package/lib/commands/trust.js +286 -0
  27. package/lib/commands/update.js +184 -38
  28. package/lib/commands/usage.js +1 -1
  29. package/lib/commands/validate.js +32 -3
  30. package/lib/commands/vault.js +46 -9
  31. package/lib/python/__init__.py +0 -0
  32. package/lib/python/agent_quotas.py +525 -0
  33. package/lib/python/anomaly_alert.py +397 -0
  34. package/lib/python/anti_pattern_detector.py +799 -0
  35. package/lib/python/auth.py +443 -0
  36. package/lib/python/capi_profile_guard.py +477 -0
  37. package/lib/python/compliance_report.py +581 -0
  38. package/lib/python/drift_detector.py +388 -0
  39. package/lib/python/experience_pipeline.py +1130 -0
  40. package/lib/python/graph.py +19 -0
  41. package/lib/python/graph_core.py +293 -0
  42. package/lib/python/graph_io.py +179 -0
  43. package/lib/python/graph_legacy.py +2052 -0
  44. package/lib/python/graph_legacy_helpers.py +221 -0
  45. package/lib/python/graph_outcomes_core.py +85 -0
  46. package/lib/python/graph_queries.py +171 -0
  47. package/lib/python/graph_slice.py +198 -0
  48. package/lib/python/graph_slicer.py +576 -0
  49. package/lib/python/graph_slicer_cli.py +60 -0
  50. package/lib/python/graph_validation.py +64 -0
  51. package/lib/python/heatmap.py +934 -0
  52. package/lib/python/json_utils.py +193 -0
  53. package/lib/python/mcp_exposure_check.py +247 -0
  54. package/lib/python/model_router.py +1434 -0
  55. package/lib/python/project_manager.py +621 -0
  56. package/lib/python/provider_profiles.py +1618 -0
  57. package/lib/python/provider_registry.py +1211 -0
  58. package/lib/python/provider_registry_cli.py +125 -0
  59. package/lib/python/receipt_png.py +727 -0
  60. package/lib/python/structural_memory.py +325 -0
  61. package/lib/python/swarm_cost.py +177 -0
  62. package/lib/python/usage_ledger.py +569 -0
  63. package/lib/scripts/mcp_tier_config.py +240 -0
  64. package/lib/shared.js +97 -14
  65. package/lib/tui/index.mjs +35174 -0
  66. package/lib/utils/activation_telemetry.js +230 -11
  67. package/lib/utils/constants.js +7 -1
  68. package/lib/utils/export-bundler.js +285 -0
  69. package/lib/utils/identity.js +198 -1
  70. package/lib/utils/mcp-auth.js +81 -15
  71. package/lib/utils/plan.js +1 -1
  72. package/lib/vault/index.js +19 -3
  73. package/lib/vault/storage.js +21 -2
  74. package/lib/wizard.js +5 -2
  75. package/package.json +9 -3
  76. package/scripts/build-python-bundle.js +106 -0
  77. package/scripts/build-tui.js +14 -1
  78. package/scripts/harvest_experience.py +523 -0
  79. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env python3
2
+ """Structured anomaly emitter and optional operator alert bridge.
3
+
4
+ This module is intentionally usable both as a library from guards and as a
5
+ small CLI from session scripts. It records every anomaly as JSONL and only
6
+ calls the operator alert path when explicitly enabled, so tests and local dry
7
+ runs do not send Telegram messages accidentally.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import pathlib
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ from datetime import datetime, timezone
20
+ from typing import Any
21
+
22
+ REPO_ROOT = pathlib.Path(
23
+ os.environ.get("ODAI_REPO_ROOT", str(pathlib.Path(__file__).resolve().parent.parent))
24
+ )
25
+ STATE_ROOT = pathlib.Path(os.environ.get("ODAI_STATE_ROOT", "/srv/0dai-state"))
26
+ TAXONOMY_PATH = pathlib.Path(
27
+ os.environ.get(
28
+ "ODAI_ANOMALY_TAXONOMY",
29
+ str(REPO_ROOT / "ai" / "manifest" / "agent-anomaly-taxonomy.json"),
30
+ )
31
+ )
32
+ ANOMALY_LOG = pathlib.Path(
33
+ os.environ.get("ODAI_ANOMALY_LOG", str(STATE_ROOT / "anomalies" / "events.jsonl"))
34
+ )
35
+ ALERT_STATE = pathlib.Path(
36
+ os.environ.get("ODAI_ANOMALY_ALERT_STATE", str(STATE_ROOT / "anomalies" / "alert-state.json"))
37
+ )
38
+ ASK_OPERATOR = pathlib.Path(
39
+ os.environ.get("ODAI_ASK_OPERATOR", str(REPO_ROOT / "scripts" / "ask_operator.py"))
40
+ )
41
+
42
+ TRUTHY = {"1", "true", "yes", "on"}
43
+ DEFAULT_ALERT_TTL_SECONDS = 30 * 60
44
+ MAX_SUMMARY_LEN = 240
45
+ GIT_STATUS_PATH_OFFSET = 3
46
+
47
+
48
+ def _now_iso() -> str:
49
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
50
+
51
+
52
+ def _truthy_env(name: str) -> bool:
53
+ return os.environ.get(name, "").strip().lower() in TRUTHY
54
+
55
+
56
+ def _read_json(path: pathlib.Path, default: Any) -> Any:
57
+ try:
58
+ return json.loads(path.read_text(encoding="utf-8"))
59
+ except (json.JSONDecodeError, OSError):
60
+ return default
61
+
62
+
63
+ def load_taxonomy(path: pathlib.Path | None = None) -> dict[str, Any]:
64
+ data = _read_json(path or TAXONOMY_PATH, {})
65
+ return data if isinstance(data, dict) else {}
66
+
67
+
68
+ def get_anomaly_type(anomaly_type: str, taxonomy: dict[str, Any] | None = None) -> dict[str, Any]:
69
+ taxonomy = taxonomy or load_taxonomy()
70
+ types = taxonomy.get("types", {})
71
+ if isinstance(types, dict) and isinstance(types.get(anomaly_type), dict):
72
+ return dict(types[anomaly_type])
73
+ category = anomaly_type.split(".", 1)[0] if "." in anomaly_type else "unknown"
74
+ return {
75
+ "category": category,
76
+ "severity": "warning",
77
+ "response": ["telemetry", "review_required"],
78
+ "description": "Unregistered anomaly type; update agent-anomaly-taxonomy.json.",
79
+ }
80
+
81
+
82
+ def _shorten_summary(summary: str) -> str:
83
+ summary = " ".join(summary.split())
84
+ if len(summary) <= MAX_SUMMARY_LEN:
85
+ return summary
86
+ return summary[: MAX_SUMMARY_LEN - 3] + "..."
87
+
88
+
89
+ def _current_agent() -> str:
90
+ return (
91
+ os.environ.get("AGENT_ID")
92
+ or os.environ.get("TMUX_SESSION")
93
+ or os.environ.get("HOSTNAME")
94
+ or "unknown"
95
+ )
96
+
97
+
98
+ def _dedupe_key(record: dict[str, Any]) -> str:
99
+ context = record.get("context") if isinstance(record.get("context"), dict) else {}
100
+ stable_context = {
101
+ key: context.get(key)
102
+ for key in ("command_hash", "rule_id", "working_tree", "task", "outside_scope")
103
+ if context.get(key)
104
+ }
105
+ basis = {
106
+ "type": record.get("type"),
107
+ "agent_id": record.get("agent_id"),
108
+ "summary": record.get("summary"),
109
+ "context": stable_context,
110
+ }
111
+ raw = json.dumps(basis, sort_keys=True, separators=(",", ":"))
112
+ return hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()[:16]
113
+
114
+
115
+ def _write_jsonl(path: pathlib.Path, payload: dict[str, Any]) -> None:
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ with path.open("a", encoding="utf-8") as fh:
118
+ fh.write(json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n")
119
+
120
+
121
+ def _load_alert_state() -> dict[str, Any]:
122
+ data = _read_json(ALERT_STATE, {})
123
+ return data if isinstance(data, dict) else {}
124
+
125
+
126
+ def _save_alert_state(state: dict[str, Any]) -> None:
127
+ ALERT_STATE.parent.mkdir(parents=True, exist_ok=True)
128
+ ALERT_STATE.write_text(json.dumps(state, ensure_ascii=True, sort_keys=True) + "\n", encoding="utf-8")
129
+
130
+
131
+ def _timestamp(value: str) -> float:
132
+ try:
133
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
134
+ except ValueError:
135
+ return 0.0
136
+
137
+
138
+ def _alert_ttl_seconds() -> int:
139
+ raw = os.environ.get("ODAI_ANOMALY_ALERT_TTL_SECONDS", str(DEFAULT_ALERT_TTL_SECONDS))
140
+ try:
141
+ return max(0, int(raw))
142
+ except ValueError:
143
+ return DEFAULT_ALERT_TTL_SECONDS
144
+
145
+
146
+ def _send_operator_alert(record: dict[str, Any], *, dry_run: bool) -> dict[str, Any]:
147
+ if dry_run:
148
+ return {"status": "suppressed", "reason": "dry_run"}
149
+ if not _truthy_env("ODAI_ANOMALY_ALERTS"):
150
+ return {"status": "suppressed", "reason": "alerts_disabled"}
151
+
152
+ dedupe_key = str(record["dedupe_key"])
153
+ state = _load_alert_state()
154
+ now = datetime.now(timezone.utc).timestamp()
155
+ last = _timestamp(str(state.get(dedupe_key, "")))
156
+ ttl = _alert_ttl_seconds()
157
+ if ttl and last and now - last < ttl:
158
+ return {"status": "suppressed", "reason": "duplicate", "ttl_seconds": ttl}
159
+
160
+ if not ASK_OPERATOR.is_file():
161
+ return {"status": "failed", "reason": "ask_operator_missing", "path": str(ASK_OPERATOR)}
162
+
163
+ title = f"Agent anomaly: {record['type']}"
164
+ body = "\n".join(
165
+ [
166
+ f"- severity: {record['severity']}",
167
+ f"- agent: {record['agent_id']}",
168
+ f"- source: {record['source']}",
169
+ f"- summary: {record['summary']}",
170
+ f"- dedupe: {dedupe_key}",
171
+ ]
172
+ )
173
+ proc = subprocess.run( # noqa: S603 - fixed local helper path and arguments.
174
+ [
175
+ sys.executable,
176
+ str(ASK_OPERATOR),
177
+ "--kind",
178
+ "ALERT",
179
+ "--id",
180
+ f"anomaly-{dedupe_key}",
181
+ title,
182
+ body,
183
+ ],
184
+ capture_output=True,
185
+ check=False,
186
+ text=True,
187
+ timeout=20,
188
+ )
189
+ if proc.returncode != 0:
190
+ return {
191
+ "status": "failed",
192
+ "reason": "ask_operator_failed",
193
+ "returncode": proc.returncode,
194
+ "stderr": proc.stderr[-500:],
195
+ }
196
+
197
+ state[dedupe_key] = _now_iso()
198
+ _save_alert_state(state)
199
+ return {"status": "sent", "tool": str(ASK_OPERATOR)}
200
+
201
+
202
+ def emit_anomaly(
203
+ anomaly_type: str,
204
+ summary: str,
205
+ *,
206
+ source: str = "unknown",
207
+ severity: str = "",
208
+ agent_id: str = "",
209
+ session: str = "",
210
+ task: str = "",
211
+ context: dict[str, Any] | None = None,
212
+ alert: bool | None = None,
213
+ dry_run: bool = False,
214
+ ) -> dict[str, Any]:
215
+ """Append a structured anomaly event and optionally alert the operator."""
216
+ taxonomy = load_taxonomy()
217
+ meta = get_anomaly_type(anomaly_type, taxonomy)
218
+ response = [str(item) for item in meta.get("response", ["telemetry"])]
219
+ effective_severity = severity or str(meta.get("severity") or "warning")
220
+ record: dict[str, Any] = {
221
+ "ts": _now_iso(),
222
+ "event": "anomaly",
223
+ "type": anomaly_type,
224
+ "category": str(meta.get("category") or anomaly_type.split(".", 1)[0]),
225
+ "severity": effective_severity,
226
+ "response": response,
227
+ "source": source,
228
+ "agent_id": agent_id or _current_agent(),
229
+ "session": session or os.environ.get("TMUX_SESSION", ""),
230
+ "task": task,
231
+ "summary": _shorten_summary(summary),
232
+ "context": context or {},
233
+ }
234
+ record["dedupe_key"] = _dedupe_key(record)
235
+
236
+ should_alert = alert if alert is not None else "operator_alert" in response
237
+ if should_alert:
238
+ record["operator_alert"] = _send_operator_alert(record, dry_run=dry_run)
239
+
240
+ _write_jsonl(ANOMALY_LOG, record)
241
+ return record
242
+
243
+
244
+ def _split_scope(scope: str) -> list[str]:
245
+ tokens = [item for item in re.split(r"[\s,;]+", scope.strip()) if item]
246
+ return [token.strip("./") for token in tokens if token not in {"-", "*"}]
247
+
248
+
249
+ def _path_in_scope(path: str, scope_tokens: list[str]) -> bool:
250
+ normalized = path.strip().strip("./")
251
+ for token in scope_tokens:
252
+ scope_token = token.rstrip("/")
253
+ if normalized == scope_token or normalized.startswith(scope_token + "/"):
254
+ return True
255
+ return False
256
+
257
+
258
+ def _git_dirty_paths(working_tree: pathlib.Path) -> list[str]:
259
+ proc = subprocess.run( # noqa: S603 - read-only git status in caller-selected worktree.
260
+ ["git", "-C", str(working_tree), "status", "--short", "--untracked-files=all"], # noqa: S607
261
+ capture_output=True,
262
+ check=False,
263
+ text=True,
264
+ timeout=10,
265
+ )
266
+ if proc.returncode != 0:
267
+ return []
268
+ paths: list[str] = []
269
+ for line in proc.stdout.splitlines():
270
+ if len(line) < GIT_STATUS_PATH_OFFSET + 1:
271
+ continue
272
+ path = line[GIT_STATUS_PATH_OFFSET:]
273
+ if " -> " in path:
274
+ path = path.rsplit(" -> ", 1)[-1]
275
+ paths.append(path)
276
+ return paths
277
+
278
+
279
+ def dirty_scan(
280
+ *,
281
+ working_tree: pathlib.Path,
282
+ file_scope: str,
283
+ source: str = "dirty-scan",
284
+ agent_id: str = "",
285
+ task: str = "",
286
+ dry_run: bool = False,
287
+ ) -> dict[str, Any]:
288
+ dirty_paths = _git_dirty_paths(working_tree)
289
+ if not dirty_paths:
290
+ return {"ok": True, "dirty_paths": [], "anomaly": None}
291
+
292
+ scope_tokens = _split_scope(file_scope)
293
+ if not scope_tokens:
294
+ anomaly = emit_anomaly(
295
+ "workspace.dirty_without_claim_scope",
296
+ "Dirty worktree observed without declared file scope",
297
+ source=source,
298
+ agent_id=agent_id,
299
+ task=task,
300
+ context={"working_tree": str(working_tree), "dirty_paths": dirty_paths},
301
+ dry_run=dry_run,
302
+ )
303
+ return {"ok": False, "dirty_paths": dirty_paths, "outside_scope": dirty_paths, "anomaly": anomaly}
304
+
305
+ outside = [path for path in dirty_paths if not _path_in_scope(path, scope_tokens)]
306
+ if not outside:
307
+ return {"ok": True, "dirty_paths": dirty_paths, "outside_scope": [], "anomaly": None}
308
+
309
+ anomaly = emit_anomaly(
310
+ "workspace.unowned_change_observed",
311
+ "Dirty paths observed outside declared file scope",
312
+ source=source,
313
+ agent_id=agent_id,
314
+ task=task,
315
+ context={
316
+ "working_tree": str(working_tree),
317
+ "file_scope": file_scope,
318
+ "dirty_paths": dirty_paths,
319
+ "outside_scope": outside,
320
+ },
321
+ dry_run=dry_run,
322
+ )
323
+ return {"ok": False, "dirty_paths": dirty_paths, "outside_scope": outside, "anomaly": anomaly}
324
+
325
+
326
+ def _json_context(values: list[str]) -> dict[str, Any]:
327
+ context: dict[str, Any] = {}
328
+ for item in values:
329
+ if "=" not in item:
330
+ continue
331
+ key, value = item.split("=", 1)
332
+ context[key] = value
333
+ return context
334
+
335
+
336
+ def _build_parser() -> argparse.ArgumentParser:
337
+ parser = argparse.ArgumentParser(description=__doc__)
338
+ sub = parser.add_subparsers(dest="command", required=True)
339
+
340
+ emit = sub.add_parser("emit", help="emit one anomaly event")
341
+ emit.add_argument("--type", required=True, dest="anomaly_type")
342
+ emit.add_argument("--summary", required=True)
343
+ emit.add_argument("--source", default="manual")
344
+ emit.add_argument("--severity", default="")
345
+ emit.add_argument("--agent", default="")
346
+ emit.add_argument("--session", default="")
347
+ emit.add_argument("--task", default="")
348
+ emit.add_argument("--context", action="append", default=[], help="key=value context item")
349
+ emit.add_argument("--alert", action="store_true")
350
+ emit.add_argument("--dry-run", action="store_true")
351
+
352
+ scan = sub.add_parser("dirty-scan", help="detect dirty paths outside declared file scope")
353
+ scan.add_argument("--working-tree", default=".")
354
+ scan.add_argument("--file-scope", default="")
355
+ scan.add_argument("--source", default="dirty-scan")
356
+ scan.add_argument("--agent", default="")
357
+ scan.add_argument("--task", default="")
358
+ scan.add_argument("--dry-run", action="store_true")
359
+ scan.add_argument("--fail-on-anomaly", action="store_true")
360
+ return parser
361
+
362
+
363
+ def main(argv: list[str] | None = None) -> int:
364
+ args = _build_parser().parse_args(argv)
365
+ if args.command == "emit":
366
+ record = emit_anomaly(
367
+ args.anomaly_type,
368
+ args.summary,
369
+ source=args.source,
370
+ severity=args.severity,
371
+ agent_id=args.agent,
372
+ session=args.session,
373
+ task=args.task,
374
+ context=_json_context(args.context),
375
+ alert=args.alert or None,
376
+ dry_run=args.dry_run,
377
+ )
378
+ sys.stdout.write(json.dumps(record, ensure_ascii=True, sort_keys=True) + "\n")
379
+ return 0
380
+
381
+ if args.command == "dirty-scan":
382
+ result = dirty_scan(
383
+ working_tree=pathlib.Path(args.working_tree),
384
+ file_scope=args.file_scope,
385
+ source=args.source,
386
+ agent_id=args.agent,
387
+ task=args.task,
388
+ dry_run=args.dry_run,
389
+ )
390
+ sys.stdout.write(json.dumps(result, ensure_ascii=True, sort_keys=True) + "\n")
391
+ return 1 if args.fail_on_anomaly and not result.get("ok") else 0
392
+
393
+ return 2
394
+
395
+
396
+ if __name__ == "__main__":
397
+ raise SystemExit(main())