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,563 @@
1
+ """Keep the recorder running across logouts, reboots, and crashes.
2
+
3
+ A flight recorder that only records while someone remembers to start it is not a
4
+ flight recorder. This module registers the orchestrator with whatever the
5
+ platform uses for user-scoped background work, and — just as importantly —
6
+ reports whether that registration actually exists.
7
+
8
+ The reporting half is the part that was missing, and it is the part that cost
9
+ real data. `agentmetry install` has existed for a while and registers a logon
10
+ task on Windows. Nobody had run it on the machine where this was written, so the
11
+ hooks captured into a spool for five days while the recorder was not running.
12
+ Nothing said so: not the dashboard, not `doctor`, not the API. A capability
13
+ nobody is told about is indistinguishable from one that does not exist, so
14
+ `status()` is treated here as a first-class feature rather than a helper.
15
+
16
+ Three deliberate choices:
17
+
18
+ **User scope, not system scope.** A logon task running as the interactive user
19
+ can raise toasts, reads the user's own config, and needs no elevation to install.
20
+ A system service would survive logout, and would also mean an audit daemon
21
+ running as LocalSystem reading a developer's home directory. That trade is the
22
+ enterprise packaging's to make, not the OSS default's.
23
+
24
+ **Restart on failure, not just start on logon.** The original registration was
25
+ `ONLOGON` with no restart policy, so a crashed recorder stayed dead until the
26
+ next logon. On a workstation that is days.
27
+
28
+ **Never silently.** Installing a persistent background process is the user's
29
+ decision. Nothing here runs as a side effect of a normal install; `doctor` warns
30
+ that autostart is absent and names the command, and stops there.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ from dataclasses import dataclass
40
+ from pathlib import Path
41
+
42
+ TASK_NAME = "Agentmetry Orchestrator"
43
+ SERVICE_NAME = "agentmetry"
44
+ LAUNCH_LABEL = "ai.agentmetry.orchestrator"
45
+
46
+ # A crashed recorder should come back promptly and keep trying. The window that
47
+ # matters is the one where hooks are spooling because nothing is listening.
48
+ RESTART_INTERVAL_MINUTES = 1
49
+ RESTART_COUNT = 999
50
+
51
+ # Any boundary in the past works; the repetition is what carries the schedule
52
+ # forward. Fixed rather than "now" so a re-registration produces an identical
53
+ # task and diffs stay empty.
54
+ KEEPALIVE_EPOCH = "2020-01-01T00:00:00"
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class AutostartStatus:
59
+ """Whether autostart is registered, and separately, whether it works.
60
+
61
+ These are two different questions and conflating them cost real data. The
62
+ registration survived a package rename that broke the command it launches,
63
+ so the task ran every minute, exited 1 every minute, and `configured=True`
64
+ reported success the whole time. Hooks spooled behind it.
65
+
66
+ `healthy` is deliberately tri-state. `None` means the platform could not
67
+ tell us, which is not the same as failing, and reporting an unknown as a
68
+ fault would train an operator to ignore the field.
69
+ """
70
+
71
+ configured: bool
72
+ backend: str
73
+ detail: str
74
+ healthy: bool | None = None
75
+ last_result: int | None = None
76
+
77
+
78
+ def _orchestrator_dir() -> Path:
79
+ """The directory containing the `agentmetry` package, which is the working
80
+ directory an unattended `-m agentmetry.cli serve` needs."""
81
+ return Path(__file__).resolve().parents[3]
82
+
83
+
84
+ class PackagedBuild(RuntimeError):
85
+ """Raised when a frozen build asks to register its own autostart.
86
+
87
+ Packaged builds are supervised by the service the installer registers, and a
88
+ second registration would race it. Inventing a launch command here to look
89
+ complete would produce a task that either fights the service or fails
90
+ silently, which is worse than saying so.
91
+ """
92
+
93
+
94
+ def launch_command() -> tuple[str, list[str], Path]:
95
+ """(executable, arguments, working directory) for an unattended start.
96
+
97
+ From source we run the module directly rather than through a shell wrapper,
98
+ so there is no cmd.exe in the process tree for a restart policy to lose
99
+ track of.
100
+ """
101
+ workdir = _orchestrator_dir()
102
+ if getattr(sys, "frozen", False):
103
+ raise PackagedBuild(
104
+ "This is a packaged build; the installer registers a service to keep "
105
+ "the orchestrator running. Manage it there rather than adding a "
106
+ "second autostart entry."
107
+ )
108
+
109
+ executable = sys.executable
110
+ if os.name == "nt":
111
+ # pythonw.exe runs without a console window. Falling back to python.exe
112
+ # is better than failing: a visible window is a papercut, not being
113
+ # registered at all is data loss.
114
+ candidate = Path(sys.executable).with_name("pythonw.exe")
115
+ if candidate.is_file():
116
+ executable = str(candidate)
117
+ # `cli serve`, never `-m uvicorn` directly. Under pythonw.exe there is no
118
+ # console, so sys.stdout is None and uvicorn's own logging setup dies before
119
+ # it serves anything -- observed as a task that ran, exited 1, and explained
120
+ # nothing. `serve` points the streams at the log file first.
121
+ args = ["-m", "agentmetry.cli", "serve", "--host", "127.0.0.1", "--port", "8000"]
122
+ return executable, args, workdir
123
+
124
+
125
+ # ----------------------------------------------------------------------
126
+ # Rendering — pure, so the shape of what we register is testable
127
+ # ----------------------------------------------------------------------
128
+
129
+
130
+ def render_windows_task_xml(user: str | None = None) -> str:
131
+ """Task Scheduler definition: start at logon, restart on failure, no window."""
132
+ executable, args, workdir = launch_command()
133
+ account = user or f"{os.environ.get('USERDOMAIN', '')}\\{os.environ.get('USERNAME', '')}"
134
+ arguments = " ".join(args)
135
+ return f"""<?xml version="1.0" encoding="UTF-16"?>
136
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
137
+ <RegistrationInfo>
138
+ <Description>Agentmetry orchestrator. Records AI agent tool-use to the local audit trail.</Description>
139
+ <URI>\\{TASK_NAME}</URI>
140
+ </RegistrationInfo>
141
+ <Triggers>
142
+ <LogonTrigger>
143
+ <Enabled>true</Enabled>
144
+ <UserId>{account}</UserId>
145
+ <Repetition>
146
+ <Interval>PT{RESTART_INTERVAL_MINUTES}M</Interval>
147
+ <StopAtDurationEnd>false</StopAtDurationEnd>
148
+ </Repetition>
149
+ </LogonTrigger>
150
+ <!-- The logon trigger's repetition only engages once a logon fires. Someone
151
+ who installs autostart mid-session would therefore have no keep-alive
152
+ until they next logged out, which is exactly the window where they
153
+ just decided they wanted one. This trigger starts in the past, so the
154
+ repeat is live the moment the task is registered. -->
155
+ <TimeTrigger>
156
+ <Enabled>true</Enabled>
157
+ <StartBoundary>{KEEPALIVE_EPOCH}</StartBoundary>
158
+ <Repetition>
159
+ <Interval>PT{RESTART_INTERVAL_MINUTES}M</Interval>
160
+ <StopAtDurationEnd>false</StopAtDurationEnd>
161
+ </Repetition>
162
+ </TimeTrigger>
163
+ </Triggers>
164
+ <Principals>
165
+ <Principal id="Author">
166
+ <UserId>{account}</UserId>
167
+ <LogonType>InteractiveToken</LogonType>
168
+ <RunLevel>LeastPrivilege</RunLevel>
169
+ </Principal>
170
+ </Principals>
171
+ <Settings>
172
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
173
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
174
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
175
+ <StartWhenAvailable>true</StartWhenAvailable>
176
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
177
+ <AllowHardTerminate>true</AllowHardTerminate>
178
+ <AllowStartOnDemand>true</AllowStartOnDemand>
179
+ <Enabled>true</Enabled>
180
+ <Hidden>true</Hidden>
181
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
182
+ <!-- RestartOnFailure is not the keep-alive, despite the name. It covers a
183
+ task that fails to launch, not an action that starts fine and dies an
184
+ hour later. Verified: killing the recorder left LastTaskResult
185
+ 0xFFFFFFFF and nothing restarted it. The repeating trigger above is
186
+ what actually brings it back, since IgnoreNew makes a repeat a no-op
187
+ while an instance is alive and a restart once it is not. This stays as
188
+ a second line for launch failures. -->
189
+ <RestartOnFailure>
190
+ <Interval>PT{RESTART_INTERVAL_MINUTES}M</Interval>
191
+ <Count>{RESTART_COUNT}</Count>
192
+ </RestartOnFailure>
193
+ </Settings>
194
+ <Actions Context="Author">
195
+ <Exec>
196
+ <Command>{executable}</Command>
197
+ <Arguments>{arguments}</Arguments>
198
+ <WorkingDirectory>{workdir}</WorkingDirectory>
199
+ </Exec>
200
+ </Actions>
201
+ </Task>
202
+ """
203
+
204
+
205
+ def render_systemd_unit() -> str:
206
+ executable, args, workdir = launch_command()
207
+ exec_start = " ".join([executable, *args])
208
+ return f"""[Unit]
209
+ Description=Agentmetry orchestrator (records AI agent tool-use to the local audit trail)
210
+ After=network.target
211
+
212
+ [Service]
213
+ Type=simple
214
+ WorkingDirectory={workdir}
215
+ ExecStart={exec_start}
216
+ Restart=always
217
+ RestartSec={RESTART_INTERVAL_MINUTES * 60}
218
+
219
+ [Install]
220
+ WantedBy=default.target
221
+ """
222
+
223
+
224
+ def render_launchd_plist() -> str:
225
+ executable, args, workdir = launch_command()
226
+ program_args = "\n".join(f" <string>{a}</string>" for a in [executable, *args])
227
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
228
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
229
+ <plist version="1.0">
230
+ <dict>
231
+ <key>Label</key><string>{LAUNCH_LABEL}</string>
232
+ <key>ProgramArguments</key>
233
+ <array>
234
+ {program_args}
235
+ </array>
236
+ <key>WorkingDirectory</key><string>{workdir}</string>
237
+ <key>RunAtLoad</key><true/>
238
+ <key>KeepAlive</key><true/>
239
+ </dict>
240
+ </plist>
241
+ """
242
+
243
+
244
+ def systemd_unit_path() -> Path:
245
+ return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service"
246
+
247
+
248
+ def launchd_plist_path() -> Path:
249
+ return Path.home() / "Library" / "LaunchAgents" / f"{LAUNCH_LABEL}.plist"
250
+
251
+
252
+ # ----------------------------------------------------------------------
253
+ # Status — safe to call from doctor on any platform
254
+ # ----------------------------------------------------------------------
255
+
256
+
257
+ def status() -> AutostartStatus:
258
+ if os.name == "nt":
259
+ return _windows_status()
260
+ if sys.platform == "darwin":
261
+ return _launchd_status()
262
+ return _systemd_status()
263
+
264
+
265
+ def _run(argv: list[str], timeout: int = 20) -> subprocess.CompletedProcess[str] | None:
266
+ """Best-effort probe. A probe that raises is a probe that makes `doctor` worse."""
267
+ try:
268
+ return subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
269
+ except (OSError, subprocess.SubprocessError):
270
+ return None
271
+
272
+
273
+ def _parse_field(verbose_query: str, label: str) -> str | None:
274
+ """Pull one labelled field out of `schtasks /V /FO LIST` output.
275
+
276
+ Parsed rather than requested as CSV because the column names are localized
277
+ on non-English Windows and the CSV header would be too; this at least fails
278
+ to `None` (unknown) rather than to a wrong answer.
279
+ """
280
+ for line in verbose_query.splitlines():
281
+ name, sep, value = line.partition(":")
282
+ if sep and name.strip().lower() == label:
283
+ return value.strip()
284
+ return None
285
+
286
+
287
+ def _parse_last_result(verbose_query: str) -> int | None:
288
+ text = _parse_field(verbose_query, "last result")
289
+ if text is None:
290
+ return None
291
+ try:
292
+ return int(text, 16) if text.lower().startswith("0x") else int(text)
293
+ except ValueError:
294
+ return None
295
+
296
+
297
+ #: Last-run codes that do not mean the recorder failed.
298
+ #:
299
+ #: 0x41303 is "has never run", the normal state between registering and the
300
+ #: first trigger. 0x41301 is "currently running". 0x800710E0 is Task Scheduler
301
+ #: declining to start a second instance because one is already alive, which is
302
+ #: `MultipleInstancesPolicy=IgnoreNew` doing its job.
303
+ _BENIGN_LAST_RESULTS = frozenset({0, 0x41303, 0x41301, -2147020576})
304
+
305
+
306
+ def _windows_status() -> AutostartStatus:
307
+ if shutil.which("schtasks") is None:
308
+ return AutostartStatus(False, "schtasks", "schtasks is not on PATH")
309
+ result = _run(["schtasks", "/Query", "/TN", TASK_NAME])
310
+ if result is None:
311
+ return AutostartStatus(False, "schtasks", "could not query Task Scheduler")
312
+ if result.returncode != 0:
313
+ return AutostartStatus(False, "schtasks", f"no scheduled task named '{TASK_NAME}'")
314
+
315
+ registered = f"scheduled task '{TASK_NAME}' is registered"
316
+ verbose = _run(["schtasks", "/Query", "/TN", TASK_NAME, "/V", "/FO", "LIST"])
317
+ if verbose is None or verbose.returncode != 0:
318
+ return AutostartStatus(True, "schtasks", registered)
319
+
320
+ last = _parse_last_result(verbose.stdout)
321
+ state = (_parse_field(verbose.stdout, "scheduled task state") or "").lower()
322
+ running = (_parse_field(verbose.stdout, "status") or "").lower() == "running"
323
+
324
+ if state == "disabled":
325
+ return AutostartStatus(
326
+ True,
327
+ "schtasks",
328
+ f"{registered} but disabled, so nothing will restart the recorder. "
329
+ "Re-enable it in Task Scheduler, or run `agentmetry install`.",
330
+ healthy=False,
331
+ last_result=last,
332
+ )
333
+
334
+ # Status beats Last Result, because Last Result is not a health signal for a
335
+ # task that stays up. It records the last *launch attempt*, and every
336
+ # repeating trigger that fires while the recorder is alive is refused by
337
+ # IgnoreNew and recorded as a non-zero code. Reading that as a fault would
338
+ # mean a correctly working recorder reports broken almost all the time,
339
+ # which is a worse failure than the silence this check replaced: an alarm
340
+ # that is always on gets muted, and then it cannot warn about anything.
341
+ if running:
342
+ return AutostartStatus(True, "schtasks", registered, healthy=True, last_result=last)
343
+
344
+ if last is None:
345
+ return AutostartStatus(True, "schtasks", registered)
346
+ if last in _BENIGN_LAST_RESULTS:
347
+ return AutostartStatus(True, "schtasks", registered, healthy=True, last_result=last)
348
+ return AutostartStatus(
349
+ True,
350
+ "schtasks",
351
+ f"{registered}, but it is not running and its last run exited {last}. "
352
+ "It is registered and failing, so nothing is recording. "
353
+ "`agentmetry install` re-registers it with the current launch command.",
354
+ healthy=False,
355
+ last_result=last,
356
+ )
357
+
358
+
359
+ def _systemd_status() -> AutostartStatus:
360
+ path = systemd_unit_path()
361
+ if not path.is_file():
362
+ return AutostartStatus(False, "systemd (user)", f"no unit at {path}")
363
+ if shutil.which("systemctl") is None:
364
+ return AutostartStatus(True, "systemd (user)", str(path))
365
+ result = _run(["systemctl", "--user", "is-active", f"{SERVICE_NAME}.service"])
366
+ if result is None:
367
+ return AutostartStatus(True, "systemd (user)", str(path))
368
+ state = result.stdout.strip() or "unknown"
369
+ if state == "active":
370
+ return AutostartStatus(True, "systemd (user)", str(path), healthy=True)
371
+ if state in ("activating", "reloading", "unknown"):
372
+ return AutostartStatus(True, "systemd (user)", f"{path} ({state})")
373
+ return AutostartStatus(
374
+ True,
375
+ "systemd (user)",
376
+ f"{path} is installed but the unit is {state}, so nothing is recording. "
377
+ "`systemctl --user status agentmetry` has the reason.",
378
+ healthy=False,
379
+ )
380
+
381
+
382
+ def _launchd_status() -> AutostartStatus:
383
+ path = launchd_plist_path()
384
+ if not path.is_file():
385
+ return AutostartStatus(False, "launchd", f"no launch agent at {path}")
386
+ if shutil.which("launchctl") is None:
387
+ return AutostartStatus(True, "launchd", str(path))
388
+ result = _run(["launchctl", "list", LAUNCH_LABEL])
389
+ if result is None or result.returncode != 0:
390
+ return AutostartStatus(True, "launchd", str(path))
391
+ # `launchctl list <label>` prints a plist-ish dict; LastExitStatus is the
392
+ # field that distinguishes "loaded" from "loaded and working".
393
+ last: int | None = None
394
+ for line in result.stdout.splitlines():
395
+ if '"LastExitStatus"' in line:
396
+ digits = "".join(c for c in line.split("=")[-1] if c.isdigit() or c == "-")
397
+ last = int(digits) if digits.strip("-") else None
398
+ break
399
+ if last is None or last == 0:
400
+ return AutostartStatus(True, "launchd", str(path), healthy=last == 0, last_result=last)
401
+ return AutostartStatus(
402
+ True,
403
+ "launchd",
404
+ f"{path} is loaded but last exited {last}, so nothing is recording. "
405
+ "`agentmetry install` re-registers it with the current launch command.",
406
+ healthy=False,
407
+ last_result=last,
408
+ )
409
+
410
+
411
+ # ----------------------------------------------------------------------
412
+ # Install / remove
413
+ # ----------------------------------------------------------------------
414
+
415
+
416
+ def install() -> tuple[bool, str]:
417
+ try:
418
+ launch_command()
419
+ except PackagedBuild as exc:
420
+ return False, str(exc)
421
+ if os.name == "nt":
422
+ return _install_windows()
423
+ if sys.platform == "darwin":
424
+ return _install_launchd()
425
+ return _install_systemd()
426
+
427
+
428
+ def remove() -> tuple[bool, str]:
429
+ if os.name == "nt":
430
+ return _remove_windows()
431
+ if sys.platform == "darwin":
432
+ return _remove_launchd()
433
+ return _remove_systemd()
434
+
435
+
436
+ def _install_windows() -> tuple[bool, str]:
437
+ import tempfile
438
+
439
+ xml = render_windows_task_xml()
440
+ # schtasks reads the definition as UTF-16, and silently misparses UTF-8.
441
+ handle = tempfile.NamedTemporaryFile(
442
+ "w", suffix=".xml", delete=False, encoding="utf-16"
443
+ )
444
+ try:
445
+ handle.write(xml)
446
+ handle.close()
447
+ result = subprocess.run(
448
+ ["schtasks", "/Create", "/TN", TASK_NAME, "/XML", handle.name, "/F"],
449
+ capture_output=True,
450
+ text=True,
451
+ timeout=60,
452
+ )
453
+ except (OSError, subprocess.SubprocessError) as exc:
454
+ return False, f"could not register the scheduled task: {exc}"
455
+ finally:
456
+ try:
457
+ os.unlink(handle.name)
458
+ except OSError:
459
+ pass
460
+
461
+ if result.returncode != 0:
462
+ return False, (result.stderr.strip() or result.stdout.strip() or "schtasks failed")
463
+ return True, (
464
+ f"Registered '{TASK_NAME}'. It starts at logon and is re-checked every "
465
+ f"{RESTART_INTERVAL_MINUTES}m, so a recorder that dies comes back within "
466
+ "about a minute."
467
+ )
468
+
469
+
470
+ def _remove_windows() -> tuple[bool, str]:
471
+ try:
472
+ result = subprocess.run(
473
+ ["schtasks", "/Delete", "/TN", TASK_NAME, "/F"],
474
+ capture_output=True,
475
+ text=True,
476
+ timeout=30,
477
+ )
478
+ except (OSError, subprocess.SubprocessError) as exc:
479
+ return False, f"could not remove the scheduled task: {exc}"
480
+ if result.returncode != 0:
481
+ return False, (result.stderr.strip() or result.stdout.strip() or "schtasks failed")
482
+ return True, f"Removed scheduled task '{TASK_NAME}'."
483
+
484
+
485
+ def _install_systemd() -> tuple[bool, str]:
486
+ if shutil.which("systemctl") is None:
487
+ return False, "systemctl not found; register the orchestrator with your init system by hand"
488
+ path = systemd_unit_path()
489
+ try:
490
+ path.parent.mkdir(parents=True, exist_ok=True)
491
+ path.write_text(render_systemd_unit(), encoding="utf-8")
492
+ except OSError as exc:
493
+ return False, f"could not write {path}: {exc}"
494
+
495
+ for argv in (
496
+ ["systemctl", "--user", "daemon-reload"],
497
+ ["systemctl", "--user", "enable", "--now", f"{SERVICE_NAME}.service"],
498
+ ):
499
+ try:
500
+ result = subprocess.run(argv, capture_output=True, text=True, timeout=60)
501
+ except (OSError, subprocess.SubprocessError) as exc:
502
+ return False, f"{' '.join(argv)} failed: {exc}"
503
+ if result.returncode != 0:
504
+ return False, (result.stderr.strip() or f"{' '.join(argv)} failed")
505
+
506
+ return True, (
507
+ f"Installed and started {SERVICE_NAME}.service ({path}). A user unit stops at "
508
+ "logout; run `loginctl enable-linger $USER` if the recorder should keep "
509
+ "running while you are logged out."
510
+ )
511
+
512
+
513
+ def _remove_systemd() -> tuple[bool, str]:
514
+ path = systemd_unit_path()
515
+ if shutil.which("systemctl") is not None:
516
+ subprocess.run(
517
+ ["systemctl", "--user", "disable", "--now", f"{SERVICE_NAME}.service"],
518
+ capture_output=True,
519
+ text=True,
520
+ timeout=60,
521
+ )
522
+ try:
523
+ path.unlink(missing_ok=True)
524
+ except OSError as exc:
525
+ return False, f"could not remove {path}: {exc}"
526
+ return True, f"Removed {path}."
527
+
528
+
529
+ def _install_launchd() -> tuple[bool, str]:
530
+ path = launchd_plist_path()
531
+ try:
532
+ path.parent.mkdir(parents=True, exist_ok=True)
533
+ path.write_text(render_launchd_plist(), encoding="utf-8")
534
+ except OSError as exc:
535
+ return False, f"could not write {path}: {exc}"
536
+ try:
537
+ subprocess.run(
538
+ ["launchctl", "load", "-w", str(path)],
539
+ capture_output=True,
540
+ text=True,
541
+ timeout=60,
542
+ )
543
+ except (OSError, subprocess.SubprocessError) as exc:
544
+ return False, f"launchctl load failed: {exc}"
545
+ return True, f"Installed launch agent {LAUNCH_LABEL} ({path})."
546
+
547
+
548
+ def _remove_launchd() -> tuple[bool, str]:
549
+ path = launchd_plist_path()
550
+ try:
551
+ subprocess.run(
552
+ ["launchctl", "unload", "-w", str(path)],
553
+ capture_output=True,
554
+ text=True,
555
+ timeout=60,
556
+ )
557
+ except (OSError, subprocess.SubprocessError):
558
+ pass
559
+ try:
560
+ path.unlink(missing_ok=True)
561
+ except OSError as exc:
562
+ return False, f"could not remove {path}: {exc}"
563
+ return True, f"Removed launch agent {LAUNCH_LABEL}."