agent-hygiene 0.1.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.
- agent_hygiene/__init__.py +3 -0
- agent_hygiene/__main__.py +4 -0
- agent_hygiene/agents.py +222 -0
- agent_hygiene/clean.py +274 -0
- agent_hygiene/cli.py +228 -0
- agent_hygiene/journal.py +142 -0
- agent_hygiene/model.py +128 -0
- agent_hygiene/output.py +136 -0
- agent_hygiene/platform.py +132 -0
- agent_hygiene/policy.py +142 -0
- agent_hygiene/restore.py +151 -0
- agent_hygiene/runner.py +402 -0
- agent_hygiene/scan/__init__.py +32 -0
- agent_hygiene/scan/autostart.py +195 -0
- agent_hygiene/scan/footprint.py +231 -0
- agent_hygiene/scan/processes.py +187 -0
- agent_hygiene-0.1.0.dist-info/METADATA +188 -0
- agent_hygiene-0.1.0.dist-info/RECORD +22 -0
- agent_hygiene-0.1.0.dist-info/WHEEL +5 -0
- agent_hygiene-0.1.0.dist-info/entry_points.txt +2 -0
- agent_hygiene-0.1.0.dist-info/licenses/LICENSE +674 -0
- agent_hygiene-0.1.0.dist-info/top_level.txt +1 -0
agent_hygiene/agents.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Registry of known coding-agent CLIs and where each one leaves its footprint.
|
|
2
|
+
|
|
3
|
+
Everything the scanner knows about a specific agent lives here, so adding an agent
|
|
4
|
+
means adding one AgentSpec. Paths are relative to $HOME unless absolute and may
|
|
5
|
+
contain glob characters.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class SelfUpdateSpec:
|
|
16
|
+
"""A JSON settings file plus the nested key that turns self-update off."""
|
|
17
|
+
|
|
18
|
+
path: str
|
|
19
|
+
key: Tuple[str, ...]
|
|
20
|
+
disabled_value: object
|
|
21
|
+
note: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class AgentSpec:
|
|
26
|
+
name: str
|
|
27
|
+
display: str
|
|
28
|
+
home_dirs: Tuple[str, ...] = ()
|
|
29
|
+
data_dirs: Tuple[str, ...] = ()
|
|
30
|
+
npm_packages: Tuple[str, ...] = ()
|
|
31
|
+
pip_packages: Tuple[str, ...] = ()
|
|
32
|
+
binaries: Tuple[str, ...] = ()
|
|
33
|
+
versions_roots: Tuple[str, ...] = ()
|
|
34
|
+
current_links: Tuple[str, ...] = ()
|
|
35
|
+
process_patterns: Tuple[str, ...] = ()
|
|
36
|
+
autostart_patterns: Tuple[str, ...] = ()
|
|
37
|
+
self_update: Optional[SelfUpdateSpec] = None
|
|
38
|
+
self_update_note: str = ""
|
|
39
|
+
|
|
40
|
+
def process_regex(self) -> List["re.Pattern[str]"]:
|
|
41
|
+
return [re.compile(p) for p in self.process_patterns]
|
|
42
|
+
|
|
43
|
+
def autostart_regex(self) -> List["re.Pattern[str]"]:
|
|
44
|
+
return [re.compile(p, re.IGNORECASE) for p in self.autostart_patterns]
|
|
45
|
+
|
|
46
|
+
def matches_process(self, command: str) -> bool:
|
|
47
|
+
return any(r.search(command) for r in self.process_regex())
|
|
48
|
+
|
|
49
|
+
def matches_autostart(self, text: str) -> bool:
|
|
50
|
+
return any(r.search(text) for r in self.autostart_regex())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_CURSOR_IDE_MAC = "~/Library/Application Support/Cursor/User/globalStorage/anysphere.cursor-agent-worker"
|
|
54
|
+
_CURSOR_IDE_LINUX = "~/.config/Cursor/User/globalStorage/anysphere.cursor-agent-worker"
|
|
55
|
+
|
|
56
|
+
AGENTS: List[AgentSpec] = [
|
|
57
|
+
AgentSpec(
|
|
58
|
+
name="codex",
|
|
59
|
+
display="OpenAI Codex CLI",
|
|
60
|
+
home_dirs=("~/.codex",),
|
|
61
|
+
npm_packages=("@openai/codex",),
|
|
62
|
+
binaries=("codex",),
|
|
63
|
+
process_patterns=(r"(^|/)codex(\s|$)", r"@openai/codex", r"codex-(x86_64|aarch64)-"),
|
|
64
|
+
autostart_patterns=(r"openai", r"\bcodex\b"),
|
|
65
|
+
self_update_note="Codex CLI has no background self-updater; it only prints an "
|
|
66
|
+
"update notice. Update with `npm i -g @openai/codex`.",
|
|
67
|
+
),
|
|
68
|
+
AgentSpec(
|
|
69
|
+
name="claude",
|
|
70
|
+
display="Anthropic Claude Code",
|
|
71
|
+
home_dirs=("~/.claude", "~/.claude.json", "~/.claude.json.backup"),
|
|
72
|
+
data_dirs=("~/.local/share/claude", "~/.cache/claude", "~/Library/Caches/claude-cli-nodejs"),
|
|
73
|
+
npm_packages=("@anthropic-ai/claude-code",),
|
|
74
|
+
binaries=("claude",),
|
|
75
|
+
versions_roots=("~/.local/share/claude/versions",),
|
|
76
|
+
current_links=("~/.local/bin/claude", "~/.claude/local/claude"),
|
|
77
|
+
process_patterns=(
|
|
78
|
+
r"(^|/)claude(\s|$)",
|
|
79
|
+
r"/\.local/share/claude/versions/",
|
|
80
|
+
r"@anthropic-ai/claude-code",
|
|
81
|
+
),
|
|
82
|
+
autostart_patterns=(r"anthropic", r"\bclaude\b"),
|
|
83
|
+
self_update=SelfUpdateSpec(
|
|
84
|
+
path="~/.claude/settings.json",
|
|
85
|
+
key=("env", "DISABLE_AUTOUPDATER"),
|
|
86
|
+
disabled_value="1",
|
|
87
|
+
note="Sets env.DISABLE_AUTOUPDATER=\"1\" in ~/.claude/settings.json.",
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
AgentSpec(
|
|
91
|
+
name="cursor",
|
|
92
|
+
display="Cursor Agent CLI",
|
|
93
|
+
home_dirs=("~/.cursor",),
|
|
94
|
+
data_dirs=(
|
|
95
|
+
"~/.local/share/cursor-agent",
|
|
96
|
+
"~/.cache/cursor-agent",
|
|
97
|
+
# the Cursor IDE ships its own copy of the agent CLI and keeps every version
|
|
98
|
+
_CURSOR_IDE_MAC + "/agent-cli",
|
|
99
|
+
_CURSOR_IDE_LINUX + "/agent-cli",
|
|
100
|
+
),
|
|
101
|
+
binaries=("cursor-agent", "agent"),
|
|
102
|
+
versions_roots=(
|
|
103
|
+
"~/.local/share/cursor-agent/versions",
|
|
104
|
+
_CURSOR_IDE_MAC + "/agent-cli/.local/share/cursor-agent/versions",
|
|
105
|
+
_CURSOR_IDE_LINUX + "/agent-cli/.local/share/cursor-agent/versions",
|
|
106
|
+
),
|
|
107
|
+
current_links=(
|
|
108
|
+
"~/.local/bin/cursor-agent",
|
|
109
|
+
"~/.local/bin/agent",
|
|
110
|
+
_CURSOR_IDE_MAC + "/agent-cli/.local/bin/cursor-agent",
|
|
111
|
+
_CURSOR_IDE_LINUX + "/agent-cli/.local/bin/cursor-agent",
|
|
112
|
+
),
|
|
113
|
+
process_patterns=(r"cursor[-_]agent", r"/\.local/share/cursor-agent/"),
|
|
114
|
+
autostart_patterns=(r"cursor[-_.]agent", r"anysphere.*agent"),
|
|
115
|
+
self_update_note="Cursor Agent CLI versions are managed by `cursor-agent update`; "
|
|
116
|
+
"there is no documented setting to disable the updater.",
|
|
117
|
+
),
|
|
118
|
+
AgentSpec(
|
|
119
|
+
name="gemini",
|
|
120
|
+
display="Google Gemini CLI",
|
|
121
|
+
home_dirs=("~/.gemini",),
|
|
122
|
+
npm_packages=("@google/gemini-cli",),
|
|
123
|
+
binaries=("gemini",),
|
|
124
|
+
process_patterns=(r"(^|/)gemini(\s|$)", r"@google/gemini-cli"),
|
|
125
|
+
autostart_patterns=(r"gemini[-_.]cli", r"\bgemini\b"),
|
|
126
|
+
self_update=SelfUpdateSpec(
|
|
127
|
+
path="~/.gemini/settings.json",
|
|
128
|
+
key=("general", "disableAutoUpdate"),
|
|
129
|
+
disabled_value=True,
|
|
130
|
+
note="Sets general.disableAutoUpdate=true in ~/.gemini/settings.json.",
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
AgentSpec(
|
|
134
|
+
name="copilot",
|
|
135
|
+
display="GitHub Copilot CLI",
|
|
136
|
+
home_dirs=("~/.copilot",),
|
|
137
|
+
npm_packages=("@github/copilot",),
|
|
138
|
+
binaries=("copilot",),
|
|
139
|
+
process_patterns=(r"(^|/)copilot(\s|$)", r"@github/copilot"),
|
|
140
|
+
autostart_patterns=(r"github[-_.]copilot", r"\bcopilot\b"),
|
|
141
|
+
self_update_note="No documented setting; update with `npm i -g @github/copilot`.",
|
|
142
|
+
),
|
|
143
|
+
AgentSpec(
|
|
144
|
+
name="opencode",
|
|
145
|
+
display="OpenCode",
|
|
146
|
+
home_dirs=("~/.config/opencode",),
|
|
147
|
+
data_dirs=("~/.local/share/opencode", "~/.cache/opencode", "~/.opencode"),
|
|
148
|
+
npm_packages=("opencode-ai",),
|
|
149
|
+
binaries=("opencode",),
|
|
150
|
+
process_patterns=(r"(^|/)opencode(\s|$)",),
|
|
151
|
+
autostart_patterns=(r"\bopencode\b",),
|
|
152
|
+
self_update=SelfUpdateSpec(
|
|
153
|
+
path="~/.config/opencode/opencode.json",
|
|
154
|
+
key=("autoupdate",),
|
|
155
|
+
disabled_value=False,
|
|
156
|
+
note="Sets autoupdate=false in ~/.config/opencode/opencode.json.",
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
AgentSpec(
|
|
160
|
+
name="devin",
|
|
161
|
+
display="Devin CLI",
|
|
162
|
+
data_dirs=("~/.local/share/devin", "~/.config/devin"),
|
|
163
|
+
binaries=("devin",),
|
|
164
|
+
process_patterns=(r"(^|/)devin(\s|$)", r"/\.local/share/devin/"),
|
|
165
|
+
autostart_patterns=(r"\bdevin\b", r"cognition"),
|
|
166
|
+
self_update_note="No documented setting to disable updates.",
|
|
167
|
+
),
|
|
168
|
+
AgentSpec(
|
|
169
|
+
name="aider",
|
|
170
|
+
display="Aider",
|
|
171
|
+
home_dirs=("~/.aider", "~/.aider.conf.yml", "~/.aider.model.settings.yml"),
|
|
172
|
+
data_dirs=("~/.local/pipx/venvs/aider-chat", "~/.local/share/uv/tools/aider-chat"),
|
|
173
|
+
pip_packages=("aider-chat",),
|
|
174
|
+
binaries=("aider",),
|
|
175
|
+
process_patterns=(r"(^|/)aider(\s|$)",),
|
|
176
|
+
autostart_patterns=(r"\baider\b",),
|
|
177
|
+
self_update_note="Aider checks for updates at start; disable with --no-check-update "
|
|
178
|
+
"in ~/.aider.conf.yml (check-update: false).",
|
|
179
|
+
),
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
# Anything matching these is never attributed to an agent, even if an agent pattern hits.
|
|
183
|
+
EXCLUDE_PROCESS_PATTERNS = [
|
|
184
|
+
re.compile(r"agent[-_]hygiene"),
|
|
185
|
+
re.compile(r"(^|/)(rg|grep|egrep|ps|lsof|less|more|tail|cat|vim|nano|code)\s"),
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def by_name(name: str) -> Optional[AgentSpec]:
|
|
190
|
+
for spec in AGENTS:
|
|
191
|
+
if spec.name == name:
|
|
192
|
+
return spec
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def names() -> List[str]:
|
|
197
|
+
return [a.name for a in AGENTS]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def match_process(command: str) -> Optional[str]:
|
|
201
|
+
if any(p.search(command) for p in EXCLUDE_PROCESS_PATTERNS):
|
|
202
|
+
return None
|
|
203
|
+
for spec in AGENTS:
|
|
204
|
+
if spec.matches_process(command):
|
|
205
|
+
return spec.name
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def match_autostart(text: str) -> Optional[str]:
|
|
210
|
+
for spec in AGENTS:
|
|
211
|
+
if spec.matches_autostart(text):
|
|
212
|
+
return spec.name
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def guess_from_argv0(argv0: str) -> Optional[str]:
|
|
217
|
+
"""Map the executable passed to `run -- <agent>` to a registered agent."""
|
|
218
|
+
base = argv0.rsplit("/", 1)[-1]
|
|
219
|
+
for spec in AGENTS:
|
|
220
|
+
if base in spec.binaries:
|
|
221
|
+
return spec.name
|
|
222
|
+
return match_process(argv0 + " ")
|
agent_hygiene/clean.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Build a plan of fixes from an inventory, preview it, and apply it with a backup journal.
|
|
2
|
+
|
|
3
|
+
Each applied action writes a restore record into the journal; `restore.py` reverses them
|
|
4
|
+
in reverse order. Killing a process is the only irreversible action and is opt-in.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import signal
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from . import agents
|
|
18
|
+
from .journal import Journal
|
|
19
|
+
from .model import AutostartEntry, Inventory
|
|
20
|
+
from .output import human_bytes, shorten_home, table
|
|
21
|
+
from .platform import have, run, uid
|
|
22
|
+
from .scan.autostart import read_crontab
|
|
23
|
+
from .scan.footprint import select_prunable
|
|
24
|
+
|
|
25
|
+
DEFAULT_KEEP = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Action:
|
|
30
|
+
kind: str # disable-launchagent | disable-systemd | disable-cron | disable-self-update | prune-version | kill-process
|
|
31
|
+
agent: str
|
|
32
|
+
target: str
|
|
33
|
+
description: str
|
|
34
|
+
reversible: bool = True
|
|
35
|
+
bytes: int = 0
|
|
36
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Plan:
|
|
41
|
+
actions: List[Action] = field(default_factory=list)
|
|
42
|
+
skipped: List[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def bytes_to_trash(self) -> int:
|
|
46
|
+
return sum(a.bytes for a in self.actions if a.kind == "prune-version")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- planning -----------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def build_plan(
|
|
52
|
+
inv: Inventory,
|
|
53
|
+
autostart: bool = True,
|
|
54
|
+
self_update: bool = True,
|
|
55
|
+
prune: bool = True,
|
|
56
|
+
kill: bool = False,
|
|
57
|
+
keep: int = DEFAULT_KEEP,
|
|
58
|
+
keep_per_agent: Optional[Dict[str, int]] = None,
|
|
59
|
+
) -> Plan:
|
|
60
|
+
plan = Plan()
|
|
61
|
+
keep_per_agent = keep_per_agent or {}
|
|
62
|
+
|
|
63
|
+
if autostart:
|
|
64
|
+
for e in inv.autostarts:
|
|
65
|
+
if not e.enabled:
|
|
66
|
+
continue
|
|
67
|
+
if not e.user_writable:
|
|
68
|
+
plan.skipped.append(f"{e.kind} {e.id} is system-wide ({e.path}); needs sudo, skipped")
|
|
69
|
+
continue
|
|
70
|
+
plan.actions.append(_plan_autostart(e))
|
|
71
|
+
|
|
72
|
+
if self_update:
|
|
73
|
+
for a in inv.agents:
|
|
74
|
+
su = a.self_update
|
|
75
|
+
if su is None or not su.supported or su.disabled or not a.installed:
|
|
76
|
+
continue
|
|
77
|
+
plan.actions.append(Action(
|
|
78
|
+
kind="disable-self-update", agent=a.name, target=su.path or "",
|
|
79
|
+
description=f"set {su.key} in {shorten_home(su.path or '')} (backup of file kept)",
|
|
80
|
+
data={"path": su.path},
|
|
81
|
+
))
|
|
82
|
+
|
|
83
|
+
if prune:
|
|
84
|
+
for a in inv.agents:
|
|
85
|
+
n = keep_per_agent.get(a.name, keep)
|
|
86
|
+
for v in select_prunable(a.versions, n):
|
|
87
|
+
plan.actions.append(Action(
|
|
88
|
+
kind="prune-version", agent=a.name, target=v.path, bytes=v.bytes,
|
|
89
|
+
description=f"move {a.name} {v.version} ({human_bytes(v.bytes)}) to trash; keep newest {n}",
|
|
90
|
+
data={"path": v.path, "version": v.version},
|
|
91
|
+
))
|
|
92
|
+
|
|
93
|
+
if kill:
|
|
94
|
+
for p in inv.processes:
|
|
95
|
+
if p.orphaned:
|
|
96
|
+
plan.actions.append(Action(
|
|
97
|
+
kind="kill-process", agent=p.agent or "?", target=str(p.pid), reversible=False,
|
|
98
|
+
description=f"terminate pid {p.pid} ({p.command[:60]}) — session ended, parent gone",
|
|
99
|
+
data={"pid": p.pid, "command": p.command},
|
|
100
|
+
))
|
|
101
|
+
return plan
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _plan_autostart(e: AutostartEntry) -> Action:
|
|
105
|
+
if e.kind == "launchagent":
|
|
106
|
+
return Action(kind="disable-launchagent", agent=e.agent or "?", target=e.id,
|
|
107
|
+
description=f"unload LaunchAgent {e.id} and move {shorten_home(e.path)} to backup",
|
|
108
|
+
data={"label": e.id, "path": e.path})
|
|
109
|
+
if e.kind == "systemd-user":
|
|
110
|
+
return Action(kind="disable-systemd", agent=e.agent or "?", target=e.id,
|
|
111
|
+
description=f"systemctl --user disable --now {e.id} (unit file left in place)",
|
|
112
|
+
data={"unit": e.id, "path": e.path})
|
|
113
|
+
return Action(kind="disable-cron", agent=e.agent or "?", target=e.id,
|
|
114
|
+
description=f"comment out crontab line: {e.detail} {e.program}"[:110],
|
|
115
|
+
data={"line_id": e.id, "program": e.program, "schedule": e.detail})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def render_plan(plan: Plan) -> str:
|
|
119
|
+
if not plan.actions and not plan.skipped:
|
|
120
|
+
return "nothing to clean"
|
|
121
|
+
rows = [[i + 1, a.kind, a.agent, "yes" if a.reversible else "NO",
|
|
122
|
+
human_bytes(a.bytes) if a.bytes else "", a.description] for i, a in enumerate(plan.actions)]
|
|
123
|
+
out = []
|
|
124
|
+
if rows:
|
|
125
|
+
out.append(table(["#", "action", "agent", "reversible", "size", "detail"], rows, max_col=110))
|
|
126
|
+
if plan.bytes_to_trash:
|
|
127
|
+
out.append(f"\n{human_bytes(plan.bytes_to_trash)} would move to trash; run `agent-hygiene purge` "
|
|
128
|
+
f"later to free it permanently, or `agent-hygiene restore` to put it back.")
|
|
129
|
+
for s in plan.skipped:
|
|
130
|
+
out.append(f"skipped: {s}")
|
|
131
|
+
return "\n".join(out)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- applying -------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def apply_plan(plan: Plan, log=print) -> Optional[Journal]:
|
|
137
|
+
if not plan.actions:
|
|
138
|
+
return None
|
|
139
|
+
journal = Journal.create()
|
|
140
|
+
log(f"backup id: {journal.id} ({shorten_home(str(journal.root))})")
|
|
141
|
+
for action in plan.actions:
|
|
142
|
+
handler = _APPLIERS[action.kind]
|
|
143
|
+
try:
|
|
144
|
+
record = handler(action, journal)
|
|
145
|
+
except Exception as exc: # keep going; the journal only records what actually happened
|
|
146
|
+
log(f" FAILED {action.kind} {action.target}: {exc}")
|
|
147
|
+
continue
|
|
148
|
+
record.update({"kind": action.kind, "agent": action.agent, "target": action.target,
|
|
149
|
+
"reversible": action.reversible, "applied_at": time.time()})
|
|
150
|
+
journal.record(record)
|
|
151
|
+
log(f" done {action.kind} {action.target}")
|
|
152
|
+
return journal
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _apply_launchagent(action: Action, journal: Journal) -> Dict[str, Any]:
|
|
156
|
+
plist = Path(action.data["path"])
|
|
157
|
+
label = action.data["label"]
|
|
158
|
+
bootout = None
|
|
159
|
+
if have("launchctl"):
|
|
160
|
+
res = run(["launchctl", "bootout", f"gui/{uid()}/{label}"], timeout=15)
|
|
161
|
+
if res.returncode != 0:
|
|
162
|
+
res = run(["launchctl", "unload", str(plist)], timeout=15)
|
|
163
|
+
bootout = res.returncode
|
|
164
|
+
dest = journal.unique_file("launchagents", plist.name)
|
|
165
|
+
shutil.move(str(plist), str(dest))
|
|
166
|
+
return {"label": label, "original": str(plist), "backup": str(dest), "bootout_rc": bootout}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _apply_systemd(action: Action, journal: Journal) -> Dict[str, Any]:
|
|
170
|
+
unit = action.data["unit"]
|
|
171
|
+
was_enabled = run(["systemctl", "--user", "is-enabled", unit], timeout=10).stdout.strip()
|
|
172
|
+
was_active = run(["systemctl", "--user", "is-active", unit], timeout=10).stdout.strip()
|
|
173
|
+
res = run(["systemctl", "--user", "disable", "--now", unit], timeout=20)
|
|
174
|
+
if res.returncode != 0:
|
|
175
|
+
raise RuntimeError(res.stderr.strip() or f"systemctl exited {res.returncode}")
|
|
176
|
+
return {"unit": unit, "was_enabled": was_enabled, "was_active": was_active}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _apply_cron(action: Action, journal: Journal) -> Dict[str, Any]:
|
|
180
|
+
from .scan.autostart import cron_line_id
|
|
181
|
+
|
|
182
|
+
text = read_crontab() or ""
|
|
183
|
+
backup = journal.unique_file("cron", "crontab")
|
|
184
|
+
backup.write_text(text)
|
|
185
|
+
new_lines = []
|
|
186
|
+
hit = False
|
|
187
|
+
for line in text.splitlines():
|
|
188
|
+
if not hit and cron_line_id(line.strip()) == action.data["line_id"]:
|
|
189
|
+
new_lines.append(f"# agent-hygiene disabled: {line}")
|
|
190
|
+
hit = True
|
|
191
|
+
else:
|
|
192
|
+
new_lines.append(line)
|
|
193
|
+
if not hit:
|
|
194
|
+
raise RuntimeError("crontab line not found (changed since scan?)")
|
|
195
|
+
new_text = "\n".join(new_lines) + "\n"
|
|
196
|
+
res = run(["crontab", "-"], input_text=new_text, timeout=10)
|
|
197
|
+
if res.returncode != 0:
|
|
198
|
+
raise RuntimeError(res.stderr.strip() or "crontab write failed")
|
|
199
|
+
return {"backup": str(backup), "line_id": action.data["line_id"]}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _apply_self_update(action: Action, journal: Journal) -> Dict[str, Any]:
|
|
203
|
+
spec = agents.by_name(action.agent)
|
|
204
|
+
assert spec is not None and spec.self_update is not None
|
|
205
|
+
path = Path(action.data["path"])
|
|
206
|
+
existed = path.is_file()
|
|
207
|
+
backup = None
|
|
208
|
+
if existed:
|
|
209
|
+
backup = journal.unique_file("settings", f"{action.agent}-{path.name}")
|
|
210
|
+
shutil.copy2(str(path), str(backup))
|
|
211
|
+
try:
|
|
212
|
+
data = json.loads(path.read_text() or "{}")
|
|
213
|
+
except ValueError as exc:
|
|
214
|
+
raise RuntimeError(f"{path} is not valid JSON: {exc}")
|
|
215
|
+
else:
|
|
216
|
+
data = {}
|
|
217
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
cur = data
|
|
219
|
+
for k in spec.self_update.key[:-1]:
|
|
220
|
+
if not isinstance(cur.get(k), dict):
|
|
221
|
+
cur[k] = {}
|
|
222
|
+
cur = cur[k]
|
|
223
|
+
cur[spec.self_update.key[-1]] = spec.self_update.disabled_value
|
|
224
|
+
tmp = path.with_suffix(path.suffix + ".agent-hygiene.tmp")
|
|
225
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n")
|
|
226
|
+
if existed:
|
|
227
|
+
shutil.copymode(str(path), str(tmp))
|
|
228
|
+
os.replace(tmp, path)
|
|
229
|
+
return {"path": str(path), "existed": existed, "backup": str(backup) if backup else None}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _apply_prune(action: Action, journal: Journal) -> Dict[str, Any]:
|
|
233
|
+
src = Path(action.data["path"])
|
|
234
|
+
dest = journal.trash_slot(action.agent, src.name)
|
|
235
|
+
# rename within the same filesystem is instant and keeps every attribute intact
|
|
236
|
+
shutil.move(str(src), str(dest))
|
|
237
|
+
return {"original": str(src), "trash": str(dest), "bytes": action.bytes}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _apply_kill(action: Action, journal: Journal) -> Dict[str, Any]:
|
|
241
|
+
pid = int(action.data["pid"])
|
|
242
|
+
os.kill(pid, signal.SIGTERM)
|
|
243
|
+
deadline = time.time() + 5
|
|
244
|
+
while time.time() < deadline:
|
|
245
|
+
if not _alive(pid):
|
|
246
|
+
return {"pid": pid, "signal": "TERM", "command": action.data["command"]}
|
|
247
|
+
time.sleep(0.1)
|
|
248
|
+
os.kill(pid, signal.SIGKILL)
|
|
249
|
+
return {"pid": pid, "signal": "KILL", "command": action.data["command"]}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _alive(pid: int) -> bool:
|
|
253
|
+
try:
|
|
254
|
+
os.kill(pid, 0)
|
|
255
|
+
except ProcessLookupError:
|
|
256
|
+
return False
|
|
257
|
+
except PermissionError:
|
|
258
|
+
return True
|
|
259
|
+
# zombie check on Linux
|
|
260
|
+
try:
|
|
261
|
+
with open(f"/proc/{pid}/stat") as fh:
|
|
262
|
+
return fh.read().split(") ")[-1][0] != "Z"
|
|
263
|
+
except OSError:
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
_APPLIERS = {
|
|
268
|
+
"disable-launchagent": _apply_launchagent,
|
|
269
|
+
"disable-systemd": _apply_systemd,
|
|
270
|
+
"disable-cron": _apply_cron,
|
|
271
|
+
"disable-self-update": _apply_self_update,
|
|
272
|
+
"prune-version": _apply_prune,
|
|
273
|
+
"kill-process": _apply_kill,
|
|
274
|
+
}
|