bone-agent 1.3.0

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/npm-wrapper.js +235 -0
  4. package/bin/rg +0 -0
  5. package/bin/rg.exe +0 -0
  6. package/config.yaml.example +133 -0
  7. package/package.json +53 -0
  8. package/requirements.txt +9 -0
  9. package/src/__init__.py +11 -0
  10. package/src/core/__init__.py +1 -0
  11. package/src/core/agentic.py +1054 -0
  12. package/src/core/chat_manager.py +1552 -0
  13. package/src/core/config_manager.py +247 -0
  14. package/src/core/cron.py +527 -0
  15. package/src/core/cron_allowlist.py +118 -0
  16. package/src/core/memory.py +232 -0
  17. package/src/core/retry.py +71 -0
  18. package/src/core/sub_agent.py +326 -0
  19. package/src/core/tool_approval.py +220 -0
  20. package/src/core/tool_feedback.py +778 -0
  21. package/src/exceptions.py +79 -0
  22. package/src/llm/__init__.py +1 -0
  23. package/src/llm/client.py +171 -0
  24. package/src/llm/config.py +466 -0
  25. package/src/llm/prompts.py +735 -0
  26. package/src/llm/providers.py +417 -0
  27. package/src/llm/streaming.py +163 -0
  28. package/src/llm/token_tracker.py +368 -0
  29. package/src/tools/__init__.py +212 -0
  30. package/src/tools/constants.py +59 -0
  31. package/src/tools/create_file.py +136 -0
  32. package/src/tools/directory.py +389 -0
  33. package/src/tools/edit.py +543 -0
  34. package/src/tools/file_reader.py +322 -0
  35. package/src/tools/helpers/__init__.py +105 -0
  36. package/src/tools/helpers/base.py +550 -0
  37. package/src/tools/helpers/converters.py +44 -0
  38. package/src/tools/helpers/file_helpers.py +189 -0
  39. package/src/tools/helpers/formatters.py +411 -0
  40. package/src/tools/helpers/loader.py +231 -0
  41. package/src/tools/helpers/parallel_executor.py +231 -0
  42. package/src/tools/helpers/path_resolver.py +226 -0
  43. package/src/tools/helpers/plugin_manifest.py +156 -0
  44. package/src/tools/obsidian.py +96 -0
  45. package/src/tools/review_sub_agent.py +189 -0
  46. package/src/tools/rg_search.py +393 -0
  47. package/src/tools/search_plugins.py +109 -0
  48. package/src/tools/select_option.py +593 -0
  49. package/src/tools/shell.py +302 -0
  50. package/src/tools/sub_agent.py +139 -0
  51. package/src/tools/task_list.py +269 -0
  52. package/src/tools/web_search.py +61 -0
  53. package/src/ui/__init__.py +1 -0
  54. package/src/ui/banner.py +87 -0
  55. package/src/ui/commands.py +2694 -0
  56. package/src/ui/displays.py +213 -0
  57. package/src/ui/loader.py +284 -0
  58. package/src/ui/main.py +646 -0
  59. package/src/ui/prompt_utils.py +113 -0
  60. package/src/ui/setting_selector.py +590 -0
  61. package/src/ui/setup_wizard.py +294 -0
  62. package/src/ui/sub_agent_panel.py +234 -0
  63. package/src/ui/tool_confirmation.py +215 -0
  64. package/src/utils/__init__.py +1 -0
  65. package/src/utils/citation_parser.py +199 -0
  66. package/src/utils/editor.py +158 -0
  67. package/src/utils/gitignore_filter.py +149 -0
  68. package/src/utils/logger.py +254 -0
  69. package/src/utils/paths.py +30 -0
  70. package/src/utils/result_parsers.py +108 -0
  71. package/src/utils/safe_commands.py +243 -0
  72. package/src/utils/settings.py +174 -0
  73. package/src/utils/validation.py +191 -0
  74. package/src/utils/web_search.py +173 -0
@@ -0,0 +1,527 @@
1
+ """Cron scheduler for bone-agent.
2
+
3
+ Provides natural-language scheduled job execution integrated into the
4
+ bone-agent agentic loop. Jobs are defined in ~/.bone/cron/jobs.yaml and
5
+ run as background threads while bone-agent is active.
6
+
7
+ External trigger: bone-agent --cron-run <job-id>
8
+ """
9
+
10
+ import logging
11
+ import re
12
+ import threading
13
+ from dataclasses import dataclass, field, asdict
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import yaml
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ── Paths ────────────────────────────────────────────────────────────────
23
+
24
+ def _get_cron_dir() -> Path:
25
+ """Return ~/.bone/cron/ directory, creating it if needed."""
26
+ cron_dir = Path.home() / ".bone" / "cron"
27
+ cron_dir.mkdir(parents=True, exist_ok=True)
28
+ return cron_dir
29
+
30
+
31
+ def _get_jobs_path() -> Path:
32
+ return _get_cron_dir() / "jobs.yaml"
33
+
34
+
35
+ def _get_log_dir() -> Path:
36
+ log_dir = _get_cron_dir() / "logs"
37
+ log_dir.mkdir(parents=True, exist_ok=True)
38
+ return log_dir
39
+
40
+
41
+ # ── Data model ───────────────────────────────────────────────────────────
42
+
43
+ @dataclass
44
+ class CronJob:
45
+ """A single cron job definition."""
46
+ id: str
47
+ schedule: str # Natural language: "every 5 minutes", "weekdays at 8am"
48
+ command: str # The prompt to feed into the agentic loop
49
+ enabled: bool = True
50
+ description: str = ""
51
+ last_run: Optional[str] = None # ISO timestamp of last successful run
52
+ last_status: Optional[str] = None # "ok" | "error"
53
+ created: str = field(default_factory=lambda: datetime.now().isoformat())
54
+
55
+ def to_dict(self) -> dict:
56
+ return asdict(self)
57
+
58
+ @classmethod
59
+ def from_dict(cls, d: dict) -> "CronJob":
60
+ return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
61
+
62
+
63
+ # ── Schedule parser ──────────────────────────────────────────────────────
64
+
65
+ # Patterns we support (ordered by specificity):
66
+ # "every N minutes|hours|days"
67
+ # "daily at HH:MM"
68
+ # "weekdays at HH:MM"
69
+ # "mondays|tuesdays|... at HH:MM"
70
+ # "HH:MM" (daily shorthand)
71
+
72
+ def _extract_time(m: re.Match) -> dict:
73
+ """Extract hour/minute from a regex match with hour, minute, ampm groups."""
74
+ hour = int(m.group("hour"))
75
+ minute = int(m.group("minute") or 0)
76
+ ampm = (m.group("ampm") or "").lower()
77
+ if ampm == "am" and hour == 12:
78
+ hour = 0
79
+ elif ampm == "pm" and hour != 12:
80
+ hour += 12
81
+ return {"hour": hour, "minute": minute}
82
+
83
+
84
+ _SCHEDULE_PATTERNS = [
85
+ # every N <unit>
86
+ (re.compile(
87
+ r"^every\s+(?P<n>\d+)\s*(?P<unit>minute|minutes|min|m|hour|hours|hr|h|day|days|d)s?\s*$",
88
+ re.IGNORECASE
89
+ ), "interval"),
90
+ # every day/night/morning/afternoon/evening [at] <time>
91
+ (re.compile(
92
+ r"^every\s+(?:day|night|morning|afternoon|evening)s?\s+(?:at\s+)?(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
93
+ re.IGNORECASE
94
+ ), "daily"),
95
+ # weekdays at <time>
96
+ (re.compile(
97
+ r"^weekdays?\s+at\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
98
+ re.IGNORECASE
99
+ ), "weekdays"),
100
+ # specific day at <time>
101
+ (re.compile(
102
+ r"^(?P<day>monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?\s+at\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
103
+ re.IGNORECASE
104
+ ), "day_of_week"),
105
+ # daily at <time>
106
+ (re.compile(
107
+ r"^daily\s+at\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
108
+ re.IGNORECASE
109
+ ), "daily"),
110
+ # bare HH:MM or HHam/pm (treated as daily)
111
+ (re.compile(
112
+ r"^(?P<hour>\d{1,2}):(?P<minute>\d{2})\s*(?P<ampm>[ap]m)?\s*$"
113
+ ), "daily"),
114
+ ]
115
+
116
+ _DAY_MAP = {
117
+ "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,
118
+ "friday": 4, "saturday": 5, "sunday": 6,
119
+ }
120
+
121
+
122
+ def parse_schedule(schedule: str) -> dict:
123
+ """Parse a natural-language schedule into a structured spec.
124
+
125
+ Returns dict with:
126
+ type: "interval" | "daily" | "weekdays" | "day_of_week"
127
+ For interval: interval_seconds (int)
128
+ For time-based: hour (int), minute (int)
129
+ For day_of_week: weekday (0=Mon..6=Sun)
130
+
131
+ Raises ValueError if schedule can't be parsed.
132
+ """
133
+ schedule = schedule.strip()
134
+ for pattern, sched_type in _SCHEDULE_PATTERNS:
135
+ m = pattern.match(schedule)
136
+ if m:
137
+ if sched_type == "interval":
138
+ n = int(m.group("n"))
139
+ if n <= 0:
140
+ raise ValueError(
141
+ f"Interval must be at least 1: 'every {n} {m.group('unit')}'"
142
+ )
143
+ unit = m.group("unit").lower()
144
+ if unit in ("minute", "minutes", "min", "m"):
145
+ return {"type": "interval", "interval_seconds": n * 60}
146
+ elif unit in ("hour", "hours", "hr", "h"):
147
+ return {"type": "interval", "interval_seconds": n * 3600}
148
+ elif unit in ("day", "days", "d"):
149
+ return {"type": "interval", "interval_seconds": n * 86400}
150
+ elif sched_type == "weekdays":
151
+ t = _extract_time(m)
152
+ return {"type": "weekdays", **t}
153
+ elif sched_type == "day_of_week":
154
+ t = _extract_time(m)
155
+ return {
156
+ "type": "day_of_week",
157
+ "weekday": _DAY_MAP[m.group("day").lower()],
158
+ **t,
159
+ }
160
+ elif sched_type == "daily":
161
+ t = _extract_time(m)
162
+ return {"type": "daily", **t}
163
+
164
+ raise ValueError(
165
+ f"Cannot parse schedule: '{schedule}'. "
166
+ f"Examples: 'every 5 minutes', 'every hour', 'daily at 8am', "
167
+ f"'every day at 5am', 'weekdays at 9:00', 'mondays at 10:30pm'"
168
+ )
169
+
170
+
171
+ def _should_run(spec: dict, last_run: Optional[datetime], now: datetime) -> bool:
172
+ """Check if a job with the given schedule spec should run now."""
173
+ if spec["type"] == "interval":
174
+ interval = spec["interval_seconds"]
175
+ if last_run is None:
176
+ return True
177
+ return (now - last_run).total_seconds() >= interval
178
+
179
+ elif spec["type"] in ("daily", "weekdays", "day_of_week"):
180
+ # Time-based: check if we've passed the target time today
181
+ # and haven't already run today
182
+ target_time = now.replace(hour=spec["hour"], minute=spec["minute"], second=0, microsecond=0)
183
+
184
+ # Check day-of-week constraints
185
+ if spec["type"] == "weekdays" and now.weekday() >= 5:
186
+ return False
187
+ if spec["type"] == "day_of_week" and now.weekday() != spec["weekday"]:
188
+ return False
189
+
190
+ # Has the target time passed today?
191
+ if now < target_time:
192
+ return False
193
+
194
+ # Did we already run today (after target time)?
195
+ if last_run is not None and last_run >= target_time:
196
+ return False
197
+
198
+ return True
199
+
200
+ return False
201
+
202
+
203
+ # ── Config persistence ───────────────────────────────────────────────────
204
+
205
+ class CronConfig:
206
+ """Load/save cron jobs from ~/.bone/cron/jobs.yaml."""
207
+
208
+ def __init__(self):
209
+ self._path = _get_jobs_path()
210
+ self.jobs: dict[str, CronJob] = {}
211
+ self.load()
212
+
213
+ def load(self):
214
+ self.jobs.clear()
215
+ if not self._path.exists():
216
+ return
217
+ try:
218
+ data = yaml.safe_load(self._path.read_text(encoding="utf-8")) or {}
219
+ for job_dict in data.get("jobs", []):
220
+ job = CronJob.from_dict(job_dict)
221
+ self.jobs[job.id] = job
222
+ except Exception as e:
223
+ logger.warning("Failed to load cron config: %s", e)
224
+
225
+ def save(self):
226
+ data = {"jobs": [j.to_dict() for j in self.jobs.values()]}
227
+ self._path.write_text(
228
+ yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True),
229
+ encoding="utf-8",
230
+ )
231
+
232
+ def add_job(self, job: CronJob):
233
+ self.jobs[job.id] = job
234
+ self.save()
235
+
236
+ def remove_job(self, job_id: str) -> bool:
237
+ if job_id in self.jobs:
238
+ del self.jobs[job_id]
239
+ self.save()
240
+ return True
241
+ return False
242
+
243
+ def get_job(self, job_id: str) -> Optional[CronJob]:
244
+ return self.jobs.get(job_id)
245
+
246
+ def update_job(self, job_id: str, **kwargs):
247
+ job = self.jobs.get(job_id)
248
+ if job:
249
+ for k, v in kwargs.items():
250
+ if k in job.__dataclass_fields__:
251
+ setattr(job, k, v)
252
+ self.save()
253
+
254
+
255
+ # ── Scheduler ────────────────────────────────────────────────────────────
256
+
257
+ def _write_job_log(job: CronJob, output: str, error: bool):
258
+ """Append job output to a log file."""
259
+ log_dir = _get_log_dir()
260
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
261
+ log_file = log_dir / f"{job.id}_{timestamp}.log"
262
+ try:
263
+ log_file.write_text(
264
+ f"Job: {job.id}\n"
265
+ f"Schedule: {job.schedule}\n"
266
+ f"Ran at: {datetime.now().isoformat()}\n"
267
+ f"Status: {'ERROR' if error else 'OK'}\n"
268
+ f"{'─' * 40}\n"
269
+ f"{output}\n",
270
+ encoding="utf-8",
271
+ )
272
+ except Exception as e:
273
+ logger.error("Failed to write cron log: %s", e)
274
+
275
+
276
+ def run_single_job(job: CronJob, console=None, interactive=False) -> None:
277
+ """Execute a single cron job without requiring a CronScheduler instance.
278
+
279
+ Used by the /cron run subcommand (interactive=True) and run_job_headless
280
+ (interactive=False, default).
281
+
282
+ Args:
283
+ job: The CronJob to execute.
284
+ console: Optional Rich console for interactive output.
285
+ interactive: If True, use the real console for interactive command
286
+ approval (test-run mode). Commands are auto-saved to the allow list.
287
+ If False, use a buffer console (scheduled mode). Unlisted commands
288
+ are blocked.
289
+ """
290
+ from rich.console import Console as RichConsole
291
+ from io import StringIO
292
+ from core.cron_allowlist import CronAllowlist
293
+
294
+ # Capture output for logging
295
+ output_buf = StringIO()
296
+
297
+ if interactive and console is not None:
298
+ # Interactive test run: use the real console so user can approve commands
299
+ job_console = console
300
+ else:
301
+ # Scheduled run: use a buffer console (no interactive prompts)
302
+ job_console = RichConsole(
303
+ file=output_buf,
304
+ force_terminal=True,
305
+ width=80,
306
+ )
307
+
308
+ try:
309
+ from core.chat_manager import ChatManager
310
+ from core.agentic import AgenticOrchestrator
311
+ from utils.paths import RG_EXE_PATH
312
+ from tools.loader import load_all_tools
313
+ from llm.config import TOOLS_ENABLED
314
+
315
+ if not TOOLS_ENABLED:
316
+ raise RuntimeError("Cron requires tools to be enabled")
317
+
318
+ # Ensure tools are loaded
319
+ load_all_tools()
320
+
321
+ # Fresh ChatManager for this job
322
+ chat_manager = ChatManager()
323
+
324
+ # Build the prompt — inject context about cron execution
325
+ prompt = (
326
+ f"[Cron job: {job.id}]\n"
327
+ f"{job.command}"
328
+ )
329
+
330
+ repo_root = Path.cwd().resolve()
331
+
332
+ # Set up cron allow list for command gating
333
+ allowlist = CronAllowlist()
334
+
335
+ orchestrator = AgenticOrchestrator(
336
+ chat_manager=chat_manager,
337
+ repo_root=repo_root,
338
+ rg_exe_path=RG_EXE_PATH,
339
+ console=job_console,
340
+ debug_mode=False,
341
+ suppress_result_display=False,
342
+ cron_job_id=job.id,
343
+ cron_allowlist=allowlist,
344
+ cron_interactive=interactive,
345
+ )
346
+ orchestrator.run(prompt)
347
+
348
+ # Log output
349
+ _write_job_log(job, output_buf.getvalue(), error=False)
350
+
351
+ except Exception as e:
352
+ _write_job_log(job, str(e), error=True)
353
+ raise
354
+
355
+
356
+ class CronScheduler:
357
+ """Background scheduler that runs cron jobs via the agentic loop.
358
+
359
+ Starts a daemon thread that wakes every 30 seconds to check if any
360
+ jobs are due. When a job fires, it creates a fresh ChatManager
361
+ (to avoid polluting the user's conversation) and runs the job's
362
+ command through the agentic orchestrator.
363
+ """
364
+
365
+ CHECK_INTERVAL = 30 # seconds between schedule checks
366
+
367
+ def __init__(self, console=None):
368
+ self.config = CronConfig()
369
+ self.console = console
370
+ self._thread: Optional[threading.Thread] = None
371
+ self._stop_event = threading.Event()
372
+ self._lock = threading.Lock()
373
+ self._running = False
374
+
375
+ def start(self):
376
+ """Start the cron scheduler background thread."""
377
+ enabled_jobs = [j for j in self.config.jobs.values() if j.enabled]
378
+
379
+ # Validate all schedules on startup
380
+ for job in enabled_jobs:
381
+ try:
382
+ parse_schedule(job.schedule)
383
+ except ValueError as e:
384
+ logger.warning("Cron job '%s' has invalid schedule: %s", job.id, e)
385
+
386
+ self._stop_event.clear()
387
+ self._thread = None
388
+ try:
389
+ self._running = True
390
+ self._thread = threading.Thread(
391
+ target=self._run_loop,
392
+ name="cron-scheduler",
393
+ daemon=True,
394
+ )
395
+ self._thread.start()
396
+ except Exception:
397
+ self._running = False
398
+ self._thread = None
399
+ raise
400
+ logger.info("Cron scheduler started with %d job(s)", len(enabled_jobs))
401
+
402
+ def stop(self):
403
+ """Signal the scheduler thread to stop and wait for it."""
404
+ self._stop_event.set()
405
+ self._running = False
406
+ if self._thread and self._thread.is_alive():
407
+ self._thread.join(timeout=10)
408
+ logger.info("Cron scheduler stopped")
409
+
410
+ def reload(self):
411
+ """Reload config from disk (e.g. after /cron add/remove)."""
412
+ with self._lock:
413
+ self.config.load()
414
+
415
+ def execute_job(self, job: CronJob):
416
+ """Execute a single cron job. Public wrapper around run_single_job."""
417
+ run_single_job(job, console=self.console)
418
+
419
+ def _run_loop(self):
420
+ """Main scheduler loop — runs in background thread."""
421
+ # Track last run times from persisted state
422
+ last_runs: dict[str, datetime] = {}
423
+ for job in self.config.jobs.values():
424
+ if job.last_run:
425
+ try:
426
+ last_runs[job.id] = datetime.fromisoformat(job.last_run)
427
+ except (ValueError, TypeError):
428
+ pass
429
+
430
+ while not self._stop_event.is_set():
431
+ now = datetime.now()
432
+
433
+ # Collect due jobs under lock, then execute outside
434
+ due_jobs: list[CronJob] = []
435
+ with self._lock:
436
+ for job in list(self.config.jobs.values()):
437
+ if not job.enabled:
438
+ continue
439
+ try:
440
+ spec = parse_schedule(job.schedule)
441
+ except ValueError:
442
+ continue
443
+
444
+ last_run = last_runs.get(job.id)
445
+ if _should_run(spec, last_run, now):
446
+ due_jobs.append(job)
447
+
448
+ # Execute jobs outside the lock so scheduling isn't blocked
449
+ for job in due_jobs:
450
+ logger.info("Cron firing job '%s'", job.id)
451
+ try:
452
+ self.execute_job(job)
453
+ job.last_run = now.isoformat()
454
+ job.last_status = "ok"
455
+ last_runs[job.id] = now
456
+ except Exception as e:
457
+ logger.error("Cron job '%s' failed: %s", job.id, e)
458
+ job.last_status = "error"
459
+ job.last_run = now.isoformat()
460
+ last_runs[job.id] = now
461
+ finally:
462
+ with self._lock:
463
+ # Snapshot only the current job's updated state
464
+ lr, ls = job.last_run, job.last_status
465
+
466
+ # Reload to pick up any /cron changes made while
467
+ # the job was running, so we don't overwrite them
468
+ self.config.load()
469
+
470
+ # Merge our last_run/last_status back onto reloaded job
471
+ reloaded = self.config.jobs.get(job.id)
472
+ if reloaded:
473
+ reloaded.last_run = lr
474
+ reloaded.last_status = ls
475
+
476
+ self.config.save()
477
+
478
+ self._stop_event.wait(self.CHECK_INTERVAL)
479
+
480
+ # Reload config from disk so /cron add/remove changes are picked up
481
+ with self._lock:
482
+ self.config.load()
483
+ # Sync in-memory last_runs from reloaded config
484
+ # (picks up /cron run or --cron-run updates)
485
+ for job in self.config.jobs.values():
486
+ if job.id not in last_runs and job.last_run:
487
+ try:
488
+ last_runs[job.id] = datetime.fromisoformat(job.last_run)
489
+ except (ValueError, TypeError):
490
+ pass
491
+
492
+
493
+ # ── External runner (for --cron-run) ────────────────────────────────────
494
+
495
+ def run_job_headless(job_id: str) -> int:
496
+ """Run a single job headlessly (no interactive session).
497
+
498
+ Used by `bone-agent --cron-run <job-id>`.
499
+
500
+ Returns 0 on success, 1 on failure.
501
+ """
502
+ config = CronConfig()
503
+ job = config.get_job(job_id)
504
+ if not job:
505
+ print(f"Error: cron job '{job_id}' not found")
506
+ return 1
507
+
508
+ print(f"Running cron job: {job.id}")
509
+ print(f"Schedule: {job.schedule}")
510
+ print(f"Command: {job.command}")
511
+ print("─" * 40)
512
+
513
+ try:
514
+ run_single_job(job)
515
+ job.last_run = datetime.now().isoformat()
516
+ job.last_status = "ok"
517
+ config.save()
518
+ print("─" * 40)
519
+ print("Job completed successfully.")
520
+ return 0
521
+ except Exception as e:
522
+ job.last_run = datetime.now().isoformat()
523
+ job.last_status = "error"
524
+ config.save()
525
+ print(f"─" * 40)
526
+ print(f"Job failed: {e}")
527
+ return 1
@@ -0,0 +1,118 @@
1
+ """Per-job command allow list for cron jobs.
2
+
3
+ Stores approved shell commands per job ID in ~/.bone/cron/allowed_commands.yaml.
4
+ During scheduled runs, only commands on the allow list (plus global SAFE_COMMAND_RULES)
5
+ are auto-approved. Unlisted commands are blocked with agent feedback.
6
+
7
+ During interactive test runs (/cron run), accepted commands are auto-saved.
8
+ """
9
+
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ import yaml
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _get_allowed_commands_path() -> Path:
20
+ """Return ~/.bone/cron/allowed_commands.yaml."""
21
+ cron_dir = Path.home() / ".bone" / "cron"
22
+ cron_dir.mkdir(parents=True, exist_ok=True)
23
+ return cron_dir / "allowed_commands.yaml"
24
+
25
+
26
+ class CronAllowlist:
27
+ """Manages per-job shell command allow lists.
28
+
29
+ Storage format (YAML):
30
+ jobs:
31
+ my_job:
32
+ commands:
33
+ - "git add -A"
34
+ - "git commit -m 'auto commit'"
35
+ - "git push origin main"
36
+ """
37
+
38
+ def __init__(self):
39
+ self._path = _get_allowed_commands_path()
40
+ self._jobs: dict[str, list[str]] = {}
41
+ self.load()
42
+
43
+ def load(self):
44
+ """Load allow list from disk."""
45
+ self._jobs.clear()
46
+ if not self._path.exists():
47
+ return
48
+ try:
49
+ data = yaml.safe_load(self._path.read_text(encoding="utf-8")) or {}
50
+ for job_id, entry in data.get("jobs", {}).items():
51
+ self._jobs[job_id] = entry.get("commands", [])
52
+ except Exception as e:
53
+ logger.warning("Failed to load cron allow list: %s", e)
54
+
55
+ def save(self):
56
+ """Persist allow list to disk."""
57
+ data = {
58
+ "jobs": {
59
+ job_id: {"commands": cmds}
60
+ for job_id, cmds in self._jobs.items()
61
+ }
62
+ }
63
+ self._path.write_text(
64
+ yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True),
65
+ encoding="utf-8",
66
+ )
67
+
68
+ def get_commands(self, job_id: str) -> list[str]:
69
+ """Return the list of allowed commands for a job."""
70
+ return list(self._jobs.get(job_id, []))
71
+
72
+ def add_command(self, job_id: str, command: str) -> bool:
73
+ """Add a command to a job's allow list. Returns True if newly added."""
74
+ command = command.strip()
75
+ if not command:
76
+ return False
77
+ if job_id not in self._jobs:
78
+ self._jobs[job_id] = []
79
+ if command not in self._jobs[job_id]:
80
+ self._jobs[job_id].append(command)
81
+ self.save()
82
+ return True
83
+ return False
84
+
85
+ def remove_command(self, job_id: str, command: str) -> bool:
86
+ """Remove a command from a job's allow list. Returns True if removed."""
87
+ command = command.strip()
88
+ if job_id in self._jobs and command in self._jobs[job_id]:
89
+ self._jobs[job_id].remove(command)
90
+ if not self._jobs[job_id]:
91
+ del self._jobs[job_id]
92
+ self.save()
93
+ return True
94
+ return False
95
+
96
+ def clear_job(self, job_id: str) -> int:
97
+ """Remove all commands for a job. Returns count of removed commands."""
98
+ if job_id not in self._jobs:
99
+ return 0
100
+ count = len(self._jobs[job_id])
101
+ del self._jobs[job_id]
102
+ self.save()
103
+ return count
104
+
105
+ def is_allowed(self, job_id: str, command: str) -> bool:
106
+ """Check if a command is on the allow list for a job.
107
+
108
+ Matching is exact (normalized whitespace). For commands that
109
+ are prefixes of list entries or vice versa, we don't match --
110
+ the user should approve the exact command they expect.
111
+ """
112
+ command = command.strip()
113
+ allowed = self._jobs.get(job_id, [])
114
+ return command in allowed
115
+
116
+ def all_jobs(self) -> dict[str, list[str]]:
117
+ """Return a copy of all job allow lists."""
118
+ return {jid: list(cmds) for jid, cmds in self._jobs.items()}