cctally 1.27.0 → 1.28.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.
@@ -0,0 +1,80 @@
1
+ """Pure loopback / Host gate predicates for the conversation-viewer transcript
2
+ endpoints (Plan 2, spec §5).
3
+
4
+ No I/O, no globals — directly unit-testable. The dashboard's
5
+ ``_require_transcripts_allowed`` composes ``transcripts_allowed`` (is this bind
6
+ served at all?) with ``host_allowed_for_transcripts`` (anti-DNS-rebinding on the
7
+ request's Host header). Loopback-by-default kills rebinding (it needs a
8
+ hostname that resolves to 127.0.0.1); under the explicit ``expose`` opt-in the
9
+ LAN device reaches the dashboard at its IP literal (allowed), while an
10
+ attacker's rebinding *domain* (a hostname) is rejected.
11
+ """
12
+ from __future__ import annotations
13
+ import ipaddress
14
+
15
+ # Public surface (Plan 2): shipped in the npm tarball + brew formula + public
16
+ # mirror — imported by the dashboard's transcript gate at runtime.
17
+
18
+ _LOOPBACK_NAMES = {"localhost", "::1"}
19
+
20
+
21
+ def authority_host(host_header) -> str:
22
+ """Extract the lowercased host from an HTTP authority (``Host`` header or a
23
+ bind string). Strips the port, the ``[...]`` IPv6 brackets, and any
24
+ ``%zone`` id. IPv6-safe (does NOT ``split(':')``). Empty string on missing
25
+ input."""
26
+ if not host_header:
27
+ return ""
28
+ h = str(host_header).strip()
29
+ if h.startswith("["):
30
+ # [IPv6]:port -> IPv6
31
+ end = h.find("]")
32
+ if end != -1:
33
+ h = h[1:end]
34
+ else:
35
+ h = h[1:]
36
+ elif h.count(":") == 1:
37
+ # host:port (a single colon can't be a bare IPv6 literal)
38
+ h = h.split(":", 1)[0]
39
+ # else: bare IPv6 literal (multiple colons) or bare host — leave as-is
40
+ h = h.split("%", 1)[0] # drop IPv6 zone id
41
+ return h.lower()
42
+
43
+
44
+ def is_loopback(host: str) -> bool:
45
+ """True for the loopback names plus any IPv4/IPv6 loopback literal
46
+ (127.0.0.0/8, ::1)."""
47
+ if not host:
48
+ return False
49
+ h = host.lower()
50
+ if h in _LOOPBACK_NAMES:
51
+ return True
52
+ try:
53
+ return ipaddress.ip_address(h).is_loopback
54
+ except ValueError:
55
+ return False
56
+
57
+
58
+ def transcripts_allowed(bind_host, expose: bool) -> bool:
59
+ """Are transcripts served AT ALL on this bind? Loopback bind always; a
60
+ non-loopback (LAN) bind only under the explicit ``expose`` opt-in."""
61
+ return is_loopback(authority_host(bind_host)) or bool(expose)
62
+
63
+
64
+ def host_allowed_for_transcripts(host_header, expose: bool) -> bool:
65
+ """Anti-DNS-rebinding Host allowlist. Loopback Host always OK. Otherwise
66
+ only an IP-literal Host under ``expose`` (the LAN IP the dashboard is
67
+ reached at) — a rebinding *domain* (any hostname) is rejected. Fail closed
68
+ on missing/empty Host."""
69
+ h = authority_host(host_header)
70
+ if not h:
71
+ return False
72
+ if is_loopback(h):
73
+ return True
74
+ if not expose:
75
+ return False
76
+ try:
77
+ ipaddress.ip_address(h) # IP literal can't be DNS-rebound
78
+ return True
79
+ except ValueError:
80
+ return False # hostname (rebinding vector) — reject
package/bin/cctally CHANGED
@@ -306,6 +306,8 @@ BudgetInputs = _lib_budget.BudgetInputs
306
306
  BudgetStatus = _lib_budget.BudgetStatus
307
307
  compute_budget_status = _lib_budget.compute_budget_status
308
308
  project_linear = _lib_budget.project_linear
309
+ calendar_month_window = _lib_budget.calendar_month_window
310
+ calendar_week_window = _lib_budget.calendar_week_window
309
311
 
310
312
  # CLAUDE_MODEL_CONTEXT_WINDOWS / …_DEFAULT_FAMILY moved to _cctally_statusline.py (re-exported below).
311
313
 
@@ -377,12 +379,14 @@ _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
377
379
  _alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
378
380
  _alert_text_budget = _lib_alerts_payload._alert_text_budget
379
381
  _alert_text_project_budget = _lib_alerts_payload._alert_text_project_budget
382
+ _alert_text_codex_budget = _lib_alerts_payload._alert_text_codex_budget
380
383
  _alert_text_projected = _lib_alerts_payload._alert_text_projected
381
384
  _escape_applescript_string = _lib_alerts_payload._escape_applescript_string
382
385
  _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
383
386
  _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
384
387
  _build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
385
388
  _build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
389
+ _build_alert_payload_codex_budget = _lib_alerts_payload._build_alert_payload_codex_budget
386
390
  _build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
387
391
 
388
392
  _lib_alert_dispatch = _load_sibling("_lib_alert_dispatch")
@@ -736,6 +740,7 @@ add_column_if_missing = _cctally_db.add_column_if_missing
736
740
  _MIGRATION_NAME_RE = _cctally_db._MIGRATION_NAME_RE
737
741
  Migration = _cctally_db.Migration
738
742
  DowngradeDetected = _cctally_db.DowngradeDetected
743
+ ProdMigrationRefused = _cctally_db.ProdMigrationRefused
739
744
  _STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
740
745
  _CACHE_MIGRATIONS = _cctally_db._CACHE_MIGRATIONS
741
746
  _make_migration_decorator = _cctally_db._make_migration_decorator
@@ -743,7 +748,9 @@ stats_migration = _cctally_db.stats_migration
743
748
  cache_migration = _cctally_db.cache_migration
744
749
  _LEGACY_MARKER_ALIASES_BY_DB = _cctally_db._LEGACY_MARKER_ALIASES_BY_DB
745
750
  _bootstrap_rename_legacy_markers = _cctally_db._bootstrap_rename_legacy_markers
751
+ _stamp_applied = _cctally_db._stamp_applied
746
752
  _run_pending_migrations = _cctally_db._run_pending_migrations
753
+ _recover_version_ahead = _cctally_db._recover_version_ahead
747
754
  _log_migration_error = _cctally_db._log_migration_error
748
755
  _clear_migration_error_log_entries = _cctally_db._clear_migration_error_log_entries
749
756
  _render_migration_error_banner = _cctally_db._render_migration_error_banner
@@ -757,6 +764,7 @@ _db_resolve_migration_name = _cctally_db._db_resolve_migration_name
757
764
  _db_path_for_label = _cctally_db._db_path_for_label
758
765
  cmd_db_skip = _cctally_db.cmd_db_skip
759
766
  cmd_db_unskip = _cctally_db.cmd_db_unskip
767
+ cmd_db_recover = _cctally_db.cmd_db_recover
760
768
 
761
769
 
762
770
  # Session-entry cache subsystem (Claude + Codex) — read-through delta
@@ -782,6 +790,7 @@ IngestStats = _cctally_cache.IngestStats
782
790
  _progress_stderr = _cctally_cache._progress_stderr
783
791
  _ensure_session_files_row = _cctally_cache._ensure_session_files_row
784
792
  sync_cache = _cctally_cache.sync_cache
793
+ backfill_conversation_messages = _cctally_cache.backfill_conversation_messages
785
794
  iter_entries = _cctally_cache.iter_entries
786
795
  _collect_entries_direct = _cctally_cache._collect_entries_direct
787
796
  _JoinedClaudeEntry = _cctally_cache._JoinedClaudeEntry
@@ -793,6 +802,7 @@ sync_codex_cache = _cctally_cache.sync_codex_cache
793
802
  iter_codex_entries = _cctally_cache.iter_codex_entries
794
803
  _collect_codex_entries_direct = _cctally_cache._collect_codex_entries_direct
795
804
  get_codex_entries = _cctally_cache.get_codex_entries
805
+ _sum_codex_cost_for_range = _cctally_cache._sum_codex_cost_for_range
796
806
  get_entries = _cctally_cache.get_entries
797
807
  open_cache_db = _cctally_cache.open_cache_db
798
808
  cmd_cache_sync = _cctally_cache.cmd_cache_sync
@@ -822,6 +832,7 @@ _normalize_percent = _cctally_record._normalize_percent
822
832
  maybe_record_milestone = _cctally_record.maybe_record_milestone
823
833
  maybe_record_budget_milestone = _cctally_record.maybe_record_budget_milestone
824
834
  maybe_record_project_budget_milestone = _cctally_record.maybe_record_project_budget_milestone
835
+ maybe_record_codex_budget_milestone = _cctally_record.maybe_record_codex_budget_milestone
825
836
  maybe_record_projected_alert = _cctally_record.maybe_record_projected_alert
826
837
  _weekly_pct_week_avg_projection = _cctally_record._weekly_pct_week_avg_projection
827
838
  _compute_block_totals = _cctally_record._compute_block_totals
@@ -1101,6 +1112,7 @@ _fetch_current_week_snapshots = _cctally_forecast._fetch_current_week_snapshots
1101
1112
  _apply_midweek_reset_override = _cctally_forecast._apply_midweek_reset_override
1102
1113
  _resolve_current_budget_window = _cctally_forecast._resolve_current_budget_window
1103
1114
  _build_budget_status_inputs = _cctally_forecast._build_budget_status_inputs
1115
+ _build_vendor_budget_inputs = _cctally_forecast._build_vendor_budget_inputs
1104
1116
  _select_dollars_per_percent = _cctally_forecast._select_dollars_per_percent
1105
1117
  _assess_forecast_confidence = _cctally_forecast._assess_forecast_confidence
1106
1118
  _pick_p_24h_ago = _cctally_forecast._pick_p_24h_ago
@@ -1115,6 +1127,12 @@ _forecast_color_enabled = _cctally_forecast._forecast_color_enabled
1115
1127
  _render_forecast_progress_bar = _cctally_forecast._render_forecast_progress_bar
1116
1128
  _render_forecast_terminal = _cctally_forecast._render_forecast_terminal
1117
1129
  _BUDGET_JSON_SCHEMA_VERSION = _cctally_forecast._BUDGET_JSON_SCHEMA_VERSION
1130
+ # Single-sourced `--period` spellings (code-review #5): the parser's `choices=`
1131
+ # derives from the same map the handler normalizer uses, so they can't drift.
1132
+ _BUDGET_PERIOD_ALIASES = _cctally_forecast._BUDGET_PERIOD_ALIASES
1133
+ _BUDGET_PERIOD_CHOICES = _cctally_forecast._BUDGET_PERIOD_CHOICES
1134
+ _normalize_budget_period = _cctally_forecast._normalize_budget_period
1135
+ _resolve_calendar_window = _cctally_forecast._resolve_calendar_window
1118
1136
  _cmd_budget_set = _cctally_forecast._cmd_budget_set
1119
1137
  _cmd_budget_unset = _cctally_forecast._cmd_budget_unset
1120
1138
  # Per-project budget set/unset (#19/#121, spec §4.3 / §7).
@@ -2084,6 +2102,12 @@ get_milestones_for_week = _cctally_milestones.get_milestones_for_wee
2084
2102
  insert_percent_milestone = _cctally_milestones.insert_percent_milestone # record shim; idempotency-test mod.
2085
2103
  insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim
2086
2104
  insert_project_budget_milestone = _cctally_milestones.insert_project_budget_milestone # record shim; project-budget-config-test ns[]
2105
+ insert_codex_budget_milestone = _cctally_milestones.insert_codex_budget_milestone # record shim; test_codex_budget_alerts ns[]
2106
+ _codex_budget_crossings = _cctally_milestones._codex_budget_crossings # record shim (shared INSERT-and-arm core for the codex_budget axis)
2107
+ _resolve_codex_budget_period_window = _cctally_milestones._resolve_codex_budget_period_window # record shim; milestones c. (codex period window)
2108
+ _reconcile_codex_budget_milestones_on_set = _cctally_milestones._reconcile_codex_budget_milestones_on_set # test_codex_budget_alerts ns[]; forecast set/reconcile
2109
+ _reconcile_codex_budget_on_config_write = _cctally_milestones._reconcile_codex_budget_on_config_write # forecast/config c. (forward-only codex-budget reconcile)
2110
+ _resolve_claude_budget_window = _cctally_milestones._resolve_claude_budget_window # record shim; milestones c. (period-aware Claude budget window)
2087
2111
  _project_crossings = _cctally_milestones._project_crossings # record shim; milestones c. (#130 firing/reconcile shared crossing arithmetic)
2088
2112
  insert_projected_milestone = _cctally_milestones.insert_projected_milestone # record shim
2089
2113
  _projected_levels_already_latched = _cctally_milestones._projected_levels_already_latched # record shim
@@ -2672,6 +2696,9 @@ def main(argv: list[str] | None = None) -> int:
2672
2696
  except DowngradeDetected as exc:
2673
2697
  eprint(f"cctally: {exc}")
2674
2698
  return 2
2699
+ except ProdMigrationRefused as exc:
2700
+ eprint(f"{exc}") # message already carries the 'cctally:' prefix
2701
+ return 2
2675
2702
  except Exception as exc: # pragma: no cover
2676
2703
  eprint(f"Error: {exc}")
2677
2704
  rc = 1
@@ -2720,7 +2747,19 @@ def _post_command_update_hooks(command: str | None, args) -> None:
2720
2747
  ``_spawn_background_update_check`` here would create ``config.json``,
2721
2748
  ``update-state.json``, and ``update.log`` on a fresh install where
2722
2749
  the existing-install gate already makes the symlink work a no-op.
2723
- Same rationale as doctor."""
2750
+ Same rationale as doctor.
2751
+
2752
+ Test/CI seam: ``CCTALLY_DISABLE_UPDATE_CHECK`` short-circuits the whole
2753
+ hook (mirrors ``CCTALLY_DISABLE_DEV_AUTODETECT``). The background
2754
+ ``_spawn_background_update_check`` is a DETACHED process that
2755
+ ``mkdir``s APP_DIR to write ``update-state.json`` / ``update.log``; a
2756
+ fixture harness that runs a command and then asserts on APP_DIR (e.g.
2757
+ ``cctally-setup-test``'s uninstall-purge "data dir is gone" check)
2758
+ otherwise races that detached writer re-creating the dir after the
2759
+ command returns. Setting this env var makes such harnesses
2760
+ deterministic without disabling the feature for real users."""
2761
+ if os.environ.get("CCTALLY_DISABLE_UPDATE_CHECK"):
2762
+ return
2724
2763
  if command == "setup" and getattr(args, "uninstall", False):
2725
2764
  return
2726
2765
  if command == "doctor":