agentmetry 0.4.0__py3-none-any.whl

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 (131) hide show
  1. agentmetry/__init__.py +12 -0
  2. agentmetry/api/__init__.py +0 -0
  3. agentmetry/api/main.py +232 -0
  4. agentmetry/api/routes/__init__.py +0 -0
  5. agentmetry/api/routes/audit.py +396 -0
  6. agentmetry/api/websocket.py +53 -0
  7. agentmetry/api/ws_bridge.py +34 -0
  8. agentmetry/cli/__init__.py +941 -0
  9. agentmetry/cli/__main__.py +5 -0
  10. agentmetry/core/__init__.py +0 -0
  11. agentmetry/core/audit/__init__.py +1 -0
  12. agentmetry/core/audit/adapters/__init__.py +0 -0
  13. agentmetry/core/audit/adapters/agt.py +304 -0
  14. agentmetry/core/audit/adapters/cloudevents.py +159 -0
  15. agentmetry/core/audit/adapters/ecs.py +103 -0
  16. agentmetry/core/audit/adapters/splunk.py +40 -0
  17. agentmetry/core/audit/alerts.py +56 -0
  18. agentmetry/core/audit/canonical.py +150 -0
  19. agentmetry/core/audit/compliance_digest.py +299 -0
  20. agentmetry/core/audit/detection/__init__.py +9 -0
  21. agentmetry/core/audit/detection/benchmark.py +194 -0
  22. agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +3 -0
  23. agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +3 -0
  24. agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +3 -0
  25. agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +2 -0
  26. agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +2 -0
  27. agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +6 -0
  28. agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +5 -0
  29. agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +2 -0
  30. agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +1 -0
  31. agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +3 -0
  32. agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +2 -0
  33. agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +2 -0
  34. agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +2 -0
  35. agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +2 -0
  36. agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +1 -0
  37. agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +2 -0
  38. agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +42 -0
  39. agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +2 -0
  40. agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +3 -0
  41. agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +6 -0
  42. agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +2 -0
  43. agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +3 -0
  44. agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +3 -0
  45. agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +4 -0
  46. agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +5 -0
  47. agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +3 -0
  48. agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +5 -0
  49. agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +5 -0
  50. agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +4 -0
  51. agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +3 -0
  52. agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +3 -0
  53. agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +3 -0
  54. agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +6 -0
  55. agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +6 -0
  56. agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +5 -0
  57. agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +30 -0
  58. agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +2 -0
  59. agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -0
  60. agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +5 -0
  61. agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +2 -0
  62. agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +5 -0
  63. agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +4 -0
  64. agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +5 -0
  65. agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +2 -0
  66. agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +6 -0
  67. agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +6 -0
  68. agentmetry/core/audit/detection/corpus/corpus.yaml +443 -0
  69. agentmetry/core/audit/detection/disposition.py +651 -0
  70. agentmetry/core/audit/detection/engine.py +78 -0
  71. agentmetry/core/audit/detection/live.py +127 -0
  72. agentmetry/core/audit/detection/live_store.py +355 -0
  73. agentmetry/core/audit/detection/models.py +53 -0
  74. agentmetry/core/audit/detection/rules.py +1314 -0
  75. agentmetry/core/audit/detection/traits.py +648 -0
  76. agentmetry/core/audit/detection/yaml_config.py +91 -0
  77. agentmetry/core/audit/detection/yaml_rules.py +83 -0
  78. agentmetry/core/audit/dlp/__init__.py +4 -0
  79. agentmetry/core/audit/dlp/loader.py +29 -0
  80. agentmetry/core/audit/dlp/models.py +29 -0
  81. agentmetry/core/audit/dlp/scanner.py +96 -0
  82. agentmetry/core/audit/dogfood.py +398 -0
  83. agentmetry/core/audit/evidence_pack.py +500 -0
  84. agentmetry/core/audit/external.py +213 -0
  85. agentmetry/core/audit/hashing.py +21 -0
  86. agentmetry/core/audit/hook_bootstrap.py +451 -0
  87. agentmetry/core/audit/identity.py +39 -0
  88. agentmetry/core/audit/ingest.py +242 -0
  89. agentmetry/core/audit/migrate.py +73 -0
  90. agentmetry/core/audit/mitre.py +244 -0
  91. agentmetry/core/audit/policy.py +99 -0
  92. agentmetry/core/audit/redaction.py +50 -0
  93. agentmetry/core/audit/replay.py +54 -0
  94. agentmetry/core/audit/run_context.py +129 -0
  95. agentmetry/core/audit/sinks.py +235 -0
  96. agentmetry/core/audit/spool.py +394 -0
  97. agentmetry/core/audit/tool_policy/__init__.py +4 -0
  98. agentmetry/core/audit/tool_policy/evaluator.py +198 -0
  99. agentmetry/core/audit/tool_policy/loader.py +44 -0
  100. agentmetry/core/audit/tool_policy/models.py +25 -0
  101. agentmetry/core/audit/trail_chain.py +300 -0
  102. agentmetry/core/audit/trail_db.py +491 -0
  103. agentmetry/core/audit/trail_merkle.py +332 -0
  104. agentmetry/core/auth.py +54 -0
  105. agentmetry/core/bus/__init__.py +5 -0
  106. agentmetry/core/bus/audit_exporter.py +107 -0
  107. agentmetry/core/bus/bridges.py +26 -0
  108. agentmetry/core/bus/bus.py +102 -0
  109. agentmetry/core/bus/events.py +50 -0
  110. agentmetry/core/bus/outbox.py +124 -0
  111. agentmetry/core/config.py +177 -0
  112. agentmetry/core/diagnostics/__init__.py +0 -0
  113. agentmetry/core/diagnostics/autostart.py +563 -0
  114. agentmetry/core/diagnostics/doctor.py +535 -0
  115. agentmetry/core/diagnostics/driver_paths.py +156 -0
  116. agentmetry/core/diagnostics/env_file.py +45 -0
  117. agentmetry/core/drivers/__init__.py +4 -0
  118. agentmetry/core/drivers/host.py +263 -0
  119. agentmetry/core/drivers/permissions.py +37 -0
  120. agentmetry/core/drivers/spec.py +118 -0
  121. agentmetry/core/extensions.py +107 -0
  122. agentmetry/core/health.py +26 -0
  123. agentmetry/core/version.py +13 -0
  124. agentmetry/policies/detection/manifest.yaml +43 -0
  125. agentmetry/policies/dlp/manifest.yaml +161 -0
  126. agentmetry/policies/opa/agent_rules.rego +33 -0
  127. agentmetry/policies/tool/manifest.yaml +117 -0
  128. agentmetry-0.4.0.dist-info/METADATA +86 -0
  129. agentmetry-0.4.0.dist-info/RECORD +131 -0
  130. agentmetry-0.4.0.dist-info/WHEEL +4 -0
  131. agentmetry-0.4.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,91 @@
1
+ """Load detection thresholds and YAML count rules from agentmetry/policies/detection/manifest.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ _ORCH_ROOT = Path(__file__).resolve().parents[4]
11
+ _DEFAULT_MANIFEST = _ORCH_ROOT.parent.parent / "policies" / "detection" / "manifest.yaml"
12
+
13
+ _DEFAULT_THRESHOLDS: dict[str, int] = {
14
+ "discovery_burst": 3,
15
+ "delete_burst": 5,
16
+ "subagent_burst": 5,
17
+ "session_tool_burst": 40,
18
+ "host_subagent_burst": 8,
19
+ # Burst rules need a clock as well as a count. Without one, 40 tool calls is
20
+ # an ordinary long coding session and 8 subagent starts is two quiet weeks —
21
+ # both fired as "autonomous campaign". Set a window to 0 to disable the time
22
+ # bound and match on the raw count (the pre-2026-07-24 behaviour).
23
+ "subagent_burst_window_minutes": 15,
24
+ "session_tool_burst_window_minutes": 10,
25
+ "host_subagent_burst_window_minutes": 60,
26
+ }
27
+
28
+ _cache: dict[str, Any] | None = None
29
+
30
+
31
+ def _manifest_path() -> Path:
32
+ try:
33
+ from agentmetry.core.config import settings
34
+
35
+ custom = settings.detection_rules_path
36
+ if custom:
37
+ return Path(custom)
38
+ except Exception:
39
+ pass
40
+ return _DEFAULT_MANIFEST
41
+
42
+
43
+ def load_manifest(*, reload: bool = False) -> dict[str, Any]:
44
+ global _cache
45
+ if _cache is not None and not reload:
46
+ return _cache
47
+
48
+ path = _manifest_path()
49
+ if not path.is_file():
50
+ _cache = {"thresholds": dict(_DEFAULT_THRESHOLDS), "count_rules": []}
51
+ return _cache
52
+
53
+ with path.open(encoding="utf-8") as fh:
54
+ data = yaml.safe_load(fh) or {}
55
+
56
+ thresholds = dict(_DEFAULT_THRESHOLDS)
57
+ raw_thresholds = data.get("thresholds")
58
+ if isinstance(raw_thresholds, dict):
59
+ for key, val in raw_thresholds.items():
60
+ try:
61
+ thresholds[str(key)] = int(val)
62
+ except (TypeError, ValueError):
63
+ continue
64
+
65
+ count_rules: list[dict[str, Any]] = []
66
+ for raw in data.get("count_rules") or []:
67
+ if isinstance(raw, dict) and raw.get("id"):
68
+ count_rules.append(raw)
69
+
70
+ _cache = {"thresholds": thresholds, "count_rules": count_rules}
71
+ return _cache
72
+
73
+
74
+ def clear_manifest_cache() -> None:
75
+ """Test helper."""
76
+ global _cache
77
+ _cache = None
78
+
79
+
80
+ def threshold(name: str, default: int | None = None) -> int:
81
+ manifest = load_manifest()
82
+ thresholds = manifest.get("thresholds") or {}
83
+ if name in thresholds:
84
+ return int(thresholds[name])
85
+ if default is not None:
86
+ return default
87
+ return int(_DEFAULT_THRESHOLDS.get(name, 1))
88
+
89
+
90
+ def count_rules() -> list[dict[str, Any]]:
91
+ return list(load_manifest().get("count_rules") or [])
@@ -0,0 +1,83 @@
1
+ """YAML-defined session count rules (agentmetry/policies/detection/manifest.yaml)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ from .models import Detection
8
+ from .rules import (
9
+ _action,
10
+ _action_type,
11
+ _correlation_id,
12
+ _event_id,
13
+ _outcome,
14
+ _tool_qualified,
15
+ _ts,
16
+ )
17
+ from .yaml_config import count_rules
18
+
19
+
20
+ def _event_matches(event: dict[str, Any], match: dict[str, Any]) -> bool:
21
+ if not match:
22
+ return True
23
+ if (want := match.get("action_type")) and _action_type(event) != str(want):
24
+ return False
25
+ if (want := match.get("outcome")) and _outcome(event) != str(want):
26
+ return False
27
+ if prefix := match.get("tool_qualified_prefix"):
28
+ if not _tool_qualified(event).startswith(str(prefix)):
29
+ return False
30
+ if contains := match.get("tool_qualified_contains"):
31
+ if str(contains) not in _tool_qualified(event):
32
+ return False
33
+ if prefix := match.get("reason_prefix"):
34
+ reason = str(_action(event).get("reason") or "")
35
+ if not reason.startswith(str(prefix)):
36
+ return False
37
+ if trait := match.get("trait"):
38
+ tool = event.get("tool") if isinstance(event.get("tool"), dict) else {}
39
+ traits = tool.get("traits") if isinstance(tool.get("traits"), list) else []
40
+ if str(trait) not in {str(t) for t in traits}:
41
+ return False
42
+ initiator = event.get("initiator") if isinstance(event.get("initiator"), dict) else {}
43
+ if (want := match.get("initiator_actor_type")) and str(initiator.get("actor_type") or "") != str(want):
44
+ return False
45
+ return True
46
+
47
+
48
+ def _make_count_rule(spec: dict[str, Any]) -> Callable[[list[dict[str, Any]]], list[Detection]]:
49
+ rule_id = str(spec["id"])
50
+ title = str(spec.get("title") or rule_id)
51
+ severity = str(spec.get("severity") or "medium")
52
+ summary_tpl = str(spec.get("summary") or "{count} matching events in one session")
53
+ min_count = int(spec.get("min_count") or 1)
54
+ match = spec.get("match") if isinstance(spec.get("match"), dict) else {}
55
+ tactic_ids = [str(t) for t in (spec.get("tactic_ids") or []) if t]
56
+ technique_ids = [str(t) for t in (spec.get("technique_ids") or []) if t]
57
+
58
+ def rule(events: list[dict[str, Any]]) -> list[Detection]:
59
+ hits = [e for e in events if _event_matches(e, match)]
60
+ if len(hits) < min_count:
61
+ return []
62
+ summary = summary_tpl.replace("{count}", str(len(hits)))
63
+ return [
64
+ Detection(
65
+ rule_id=rule_id,
66
+ title=title,
67
+ severity=severity,
68
+ summary=summary,
69
+ correlation_id=_correlation_id(events),
70
+ tactic_ids=tactic_ids,
71
+ technique_ids=technique_ids,
72
+ event_ids=[_event_id(e) for e in hits[:20]],
73
+ first_seen_utc=_ts(hits[0]),
74
+ last_seen_utc=_ts(hits[-1]),
75
+ )
76
+ ]
77
+
78
+ rule.__name__ = f"yaml_{rule_id.replace('-', '_')}"
79
+ return rule
80
+
81
+
82
+ def build_yaml_rules() -> list[Callable[[list[dict[str, Any]]], list[Detection]]]:
83
+ return [_make_count_rule(spec) for spec in count_rules() if spec.get("id")]
@@ -0,0 +1,4 @@
1
+ from .models import DlpMatch, DlpVerdict, RuleMeta
2
+ from .scanner import scan
3
+
4
+ __all__ = ["DlpMatch", "DlpVerdict", "RuleMeta", "scan"]
@@ -0,0 +1,29 @@
1
+ import yaml
2
+ from pathlib import Path
3
+ from typing import List
4
+
5
+ from .models import RuleMeta
6
+
7
+ def load_dlp_rules(manifest_path: Path | str) -> List[RuleMeta]:
8
+ """Load DLP rules from a YAML manifest file."""
9
+ path = Path(manifest_path)
10
+ if not path.exists():
11
+ return []
12
+
13
+ with open(path, "r", encoding="utf-8") as f:
14
+ data = yaml.safe_load(f)
15
+
16
+ if not data or "rules" not in data:
17
+ return []
18
+
19
+ rules = []
20
+ for r in data["rules"]:
21
+ rules.append(RuleMeta(
22
+ id=r.get("id", ""),
23
+ name=r.get("name", ""),
24
+ description=r.get("description", ""),
25
+ pattern=r.get("pattern", ""),
26
+ category=r.get("category", ""),
27
+ severity=r.get("severity", "medium")
28
+ ))
29
+ return rules
@@ -0,0 +1,29 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass
6
+ class RuleMeta:
7
+ id: str
8
+ name: str
9
+ description: str
10
+ pattern: str
11
+ category: str
12
+ severity: str
13
+ validate: str = "" # optional validator: "luhn" for card numbers
14
+
15
+
16
+ @dataclass
17
+ class DlpMatch:
18
+ rule_id: str
19
+ category: str
20
+ severity: str
21
+ pattern_type: str = "regex"
22
+
23
+
24
+ @dataclass
25
+ class DlpVerdict:
26
+ matched: bool
27
+ mode: str = "disable" # log, block, disable
28
+ match: Optional[DlpMatch] = None # first match (back-compat)
29
+ matches: list[DlpMatch] = field(default_factory=list) # all matches
@@ -0,0 +1,96 @@
1
+ import json
2
+ import logging
3
+ import re
4
+ from typing import Any, Dict
5
+
6
+ from .models import DlpVerdict, DlpMatch
7
+ from .loader import load_dlp_rules
8
+ from ...config import settings
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Compiled-rule cache. Reset with reset_rules() (tests / live rule reload).
13
+ _COMPILED_RULES: list[tuple[re.Pattern[str], Any]] = []
14
+
15
+
16
+ def reset_rules() -> None:
17
+ """Clear the compiled-rule cache so the next scan reloads from disk."""
18
+ _COMPILED_RULES.clear()
19
+
20
+
21
+ def _init_rules() -> None:
22
+ if _COMPILED_RULES:
23
+ return
24
+ rules = load_dlp_rules(settings.dlp_rules_path)
25
+ for r in rules:
26
+ if not settings.dlp_pii and r.category == "pii":
27
+ continue
28
+ try:
29
+ _COMPILED_RULES.append((re.compile(r.pattern), r))
30
+ except re.error as exc:
31
+ logger.warning("[DLP] invalid regex for rule %s: %s", r.id, exc)
32
+
33
+
34
+ def _luhn_ok(digits: str) -> bool:
35
+ """Luhn checksum — rejects random 16-digit numbers that aren't real cards."""
36
+ nums = [int(c) for c in digits if c.isdigit()]
37
+ if len(nums) < 13:
38
+ return False
39
+ total, parity = 0, len(nums) % 2
40
+ for i, n in enumerate(nums):
41
+ if i % 2 == parity:
42
+ n *= 2
43
+ if n > 9:
44
+ n -= 9
45
+ total += n
46
+ return total % 10 == 0
47
+
48
+
49
+ def _passes_validator(rule: Any, matched_text: str) -> bool:
50
+ if getattr(rule, "validate", "") == "luhn":
51
+ return _luhn_ok(matched_text)
52
+ return True
53
+
54
+
55
+ def scan(tool_qualified: str, arguments: Dict[str, Any] | str, mode: str | None = None) -> DlpVerdict:
56
+ """Scan tool arguments against DLP rules. Returns all matches, not just the first.
57
+
58
+ Validators (e.g. Luhn for card numbers) suppress false positives. Only rule
59
+ metadata is returned — never the matched value.
60
+ """
61
+ if mode is None:
62
+ mode = settings.dlp_mode
63
+ if mode == "disable":
64
+ return DlpVerdict(matched=False, mode=mode)
65
+
66
+ _init_rules()
67
+ if not _COMPILED_RULES:
68
+ return DlpVerdict(matched=False, mode=mode)
69
+
70
+ if isinstance(arguments, dict):
71
+ try:
72
+ text = json.dumps(arguments)
73
+ except Exception:
74
+ text = str(arguments)
75
+ else:
76
+ text = str(arguments)
77
+
78
+ matches: list[DlpMatch] = []
79
+ seen: set[str] = set()
80
+ for pattern, rule in _COMPILED_RULES:
81
+ m = pattern.search(text)
82
+ if not m or not _passes_validator(rule, m.group(0)):
83
+ continue
84
+ if rule.id in seen:
85
+ continue
86
+ seen.add(rule.id)
87
+ matches.append(DlpMatch(
88
+ rule_id=rule.id,
89
+ category=rule.category,
90
+ severity=rule.severity,
91
+ pattern_type="regex",
92
+ ))
93
+
94
+ if not matches:
95
+ return DlpVerdict(matched=False, mode=mode)
96
+ return DlpVerdict(matched=True, mode=mode, match=matches[0], matches=matches)