From cd795b49afd664e5b35c3b615633ea645ac5d3b4 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 10 Sep 2026 00:09:02 -0400 Subject: [PATCH] Audit follow-ups from Codex on #21/#22; bump to 1.5.8 - WSL discovery defaults off, as the disclosure says; the onboarding prompt still offers it. - Archive: rows matching the credential predicate are invisible to list/grep/restore even when archived before the exclusion existed; `archive status` purges them and reports the count. - SKILL.md: SSH transfers disclosed explicitly (scanner upload, scan output / counters / transcripts download); coverage-limits row; removal instructions include a custom archive.path; Network Boundary row corrected. - discovery.md: the auth.json check snippet prints identifiable claims only when the identifiable opt-in is set. - templates/manifest.example.json: capture_prompts and package_paths keys. - CLAW_HUB_PUBLISHING.md: audit response for 1.5.7 -> 1.5.8. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WUhGzUwi8pXjngqs22ghZ3 --- CLAW_HUB_PUBLISHING.md | 2 ++ README.md | 2 +- SKILL.md | 11 ++++++----- assets/examples/example-onboarding.md | 4 ++-- references/discovery.md | 5 ++++- scripts/ledger.py | 4 ++-- scripts/ledger_archive.py | 20 +++++++++++++++++++- templates/manifest.example.json | 2 ++ 8 files changed, 38 insertions(+), 12 deletions(-) diff --git a/CLAW_HUB_PUBLISHING.md b/CLAW_HUB_PUBLISHING.md index fbad9ae..2ef86fd 100644 --- a/CLAW_HUB_PUBLISHING.md +++ b/CLAW_HUB_PUBLISHING.md @@ -52,6 +52,8 @@ The audit of 1.5.5 classified the optional scheduled task as cross-session persi Codex's review of the 1.5.6 branch added: scheduled runs pinned to the consented manifest and flags (`run --scheduled`), DOCX and PDF inspected by `publish-check` (or reported unscannable), legacy credential-derived `accounts.json` redrafted under the recorded preference, placeholder accounts billed as unknown rather than subscription, report and dashboard narrative reflecting how accounts were drafted, `SHA256SUMS` covering optional package files, short host and organisation names still checked, and the scheduler lookup deferred until after consent. +The audit of 1.5.7 (one A.I.G warning, 26 SkillSpector findings) was split into issues #11 to #20 and resolved in 1.5.8: `study_url` validated to HTTPS and rendered through DOM APIs; prompt text capture an explicit opt-in (default off); the generic JSON sniffer refuses broad roots and skips credential-like files; narrowed trigger phrases and a Before You Start disclosure; authorization and minimisation boundaries in the discovery guide and templates; a sensitivity notice in every study package with paths redacted from `ledger.json` unless requested; private permissions for every generated run file; no catch-all attribution rules (unmatched calls are reported as unattributed); subprocess and data text cleaned before printing; one credential-file predicate applied to every archive path including legacy rows; WSL discovery default off; SSH transfers disclosed explicitly. + ## Local Readiness Check Run before publishing: diff --git a/README.md b/README.md index 2507d1d..1369f11 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Part of the CompleteTech LLC agentic services skill library. Where the `agentic- ## OpenClaw / ClawHub Metadata - Skill key: `ai-usage-ledger` -- Version-ready metadata: `1.5.7` +- Version-ready metadata: `1.5.8` - Homepage: https://github.com/CompleteTech-LLC/ai-usage-ledger-skill - README: https://github.com/CompleteTech-LLC/ai-usage-ledger-skill#readme - Runtime binaries: `python3` diff --git a/SKILL.md b/SKILL.md index c51d093..ecac780 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,7 +2,7 @@ name: ai-usage-ledger description: >- Compile every locally recorded AI coding-agent model call (Claude Code, Codex CLI/Desktop, GitHub Copilot CLI, Gemini CLI, opencode, OpenClaw, Cline/Roo/Kilo, aider, Kimi Code, Mistral Vibe, Continue, pi, Codebuff and any JSON-logging tool) across the operator's own profile and, when enabled, other drives, user profiles, WSL distros and named SSH hosts into one append-only, de-duplicated ledger stored as SQLite, JSON or CSV; price it at list API rates; split it by subscription account and billing plan; validate it against the tools' own counters; and render a branded light/dark dashboard plus a research-grade study package. Onboards once (branding, theme, storage backend, scheduled refresh, anonymised publication copy; hosts and accounts auto-detected per OS), remembers the preferences, refreshes with one command or on a schedule, renders fifteen ledger documents (statements, memos, briefs, billing evidence) as Markdown, HTML, PDF and DOCX, can anonymise everything for publication, optionally archives the raw log files themselves so they outlive the tools' retention, and answers detailed questions (per day, project, account, model or session, and inside prompt text or the archived logs when those opt-ins are on) through a query layer over the accumulated store. Disclosure: this skill reads agent transcripts and tool databases on this machine and on hosts the operator names, keeps a durable local ledger under ~/.ai-usage-ledger and the chosen working directory, and captures prompt text or credential-derived account claims only when the operator opts in. Use when asked to "find all my AI logs", "how many tokens did I use", "what would this have cost on the API", "compare my subscriptions", "usage by account", "cache savings", "set up my usage ledger", "monthly usage statement", "publish my usage anonymously", or to refresh an existing ledger. -version: 1.5.7 +version: 1.5.8 metadata: openclaw: skillKey: ai-usage-ledger @@ -39,10 +39,11 @@ Relay this table to the operator, in chat or on the terminal, before the first o | Opt-in: raw-log archive | `archive.raw_logs` (default off). On, every source file the scanners read is copied to `~/.ai-usage-ledger/archive/` (about the size of the tools' log trees, one tenth compressed) so it outlives the tools' retention and `query logs` can search it. | | Opt-in: other profiles and drives | `detect.all_profiles` (default off). On, every `Users\` / `/home/` / `/Users/` on every mounted drive is scanned; only profiles the operator owns or is authorised to audit, see `references/discovery.md` Boundaries. | | Opt-in: WSL distros | `detect.wsl` (default off). On, `wsl.exe -l -q` lists the distros and each `/home/` inside them is scanned. | -| Opt-in: SSH hosts | Only hosts the operator names at the prompt or in `config.json` `extra_hosts`; `ssh` and `scp` run fixed commands on those hosts, never on anything discovered. | +| Opt-in: SSH hosts | Only hosts the operator names at the prompt or in `config.json` `extra_hosts`. For each such host the skill uploads `compile_ai_logs.py` with `scp`, runs it there over `ssh`, and downloads the scan output (events, sessions, prompts if captured, inventory) and, when configured, copies of `state_5.sqlite` / `stats-cache.json` and, with the raw-log archive on, the transcript files themselves. Nothing else crosses the network. | | Opt-in: scheduled refresh | Never registered by onboarding or by a configuration key; only `schedule install`, which prints the full disclosure and needs a typed `yes` (and `yes, I understand` for high-impact schedules) from the operator, not from the agent. | -| Leaving the machine | Nothing leaves through this skill. For anything to be shared, build the anonymised copy (`run --anonymize`, `doc --anonymize`) and run `ledger.py publish-check ` on it first. | -| How to remove everything | `python3 scripts/ledger.py schedule remove` (if a schedule exists), then delete `~/.ai-usage-ledger/` (or `$AI_USAGE_LEDGER_HOME`) and the generated directories under the working directory. `python3 scripts/ledger.py reinit` starts over with a fresh configuration and keeps the previous store beside the new one with a timestamp. | +| Leaving the machine | Apart from the SSH transfers above, nothing leaves through this skill and no service is contacted. For anything to be shared, build the anonymised copy (`run --anonymize`, `doc --anonymize`) and run `ledger.py publish-check ` on it first. | +| Coverage limits | Detection reports the roots it found and the same-volume duplicates it skipped; a directory it could not read or a tool it does not know is not listed. Compare each host's inventory (`files`, `bytes` per root) with a native count when completeness matters (see `references/pitfalls.md` 17). | +| How to remove everything | `python3 scripts/ledger.py schedule remove` (if a schedule exists), then delete `~/.ai-usage-ledger/` (or `$AI_USAGE_LEDGER_HOME`) and the generated directories under the working directory. `python3 scripts/ledger.py reinit` starts over with a fresh configuration and keeps the previous store beside the new one with a timestamp. If `archive.path` was set outside the ledger home, delete that directory too. | ## System Boundary @@ -278,7 +279,7 @@ Accounts in `query` come from the same `accounts.json` rules the report uses (`a | Boundary | Requirement | |---|---| -| Local-only runtime | The scripts make no outbound network calls; SSH and WSL are used only for hosts the operator names in the manifest. | +| Local-only runtime | The scripts contact no service. The only network traffic is `ssh` / `scp` to hosts the operator names in the manifest: the scanner is uploaded, scan outputs (and, when configured, tool counters or archived transcripts) are downloaded. WSL distros are reached through `wsl.exe` or the `\\wsl$` share only when `detect.wsl` is on. | | Generated pages | The dashboard, study and documents are self-contained: no external fonts, images or stylesheets, and a `Content-Security-Policy` meta tag that forbids network loads. A remote logo or Google Fonts stylesheet is used only when `branding.allow_external_resources` is true. | | Publishing | Dashboards and study packages are published only when the operator asks (Artifacts, files, or a repository). | | Data | Scanned transcripts and the compiled ledger never leave the operator's machines through this skill. | diff --git a/assets/examples/example-onboarding.md b/assets/examples/example-onboarding.md index ed8c9d4..ccb75db 100644 --- a/assets/examples/example-onboarding.md +++ b/assets/examples/example-onboarding.md @@ -25,7 +25,7 @@ Storage backend (sqlite / json / csv) [sqlite]: sqlite Working directory for scans, compiled tables and reports [~/.ai-usage-ledger/ledger]: Timezone for time-of-day analysis [UTC]: America/New_York Also scan other user profiles and other drives on this machine? (y/n) [n]: y -Probe WSL distros on Windows? (y/n) [y]: y +Probe WSL distros on Windows? (y/n) [n]: y Read credential files (auth.json, .claude.json) to label accounts with their plan? Tokens are never kept. (y/n) [n]: y Keep e-mail addresses and organisation names in accounts.json? (n = pseudonymous account ids only) (y/n) [n]: n Also build an anonymised copy for publication on every run? (y/n) [n]: y @@ -59,7 +59,7 @@ The saved preferences (`~/.ai-usage-ledger/config.json`, salt omitted): ```json { "version": 1, - "initialized_at": "2026-09-10T03:58:13+00:00", + "initialized_at": "2026-09-10T04:08:00+00:00", "home": "~/.ai-usage-ledger", "store": { "kind": "sqlite", diff --git a/references/discovery.md b/references/discovery.md index 32fe981..d025def 100644 --- a/references/discovery.md +++ b/references/discovery.md @@ -78,7 +78,10 @@ import json, base64 d = json.load(open(path)); t = d.get("tokens", {}); tok = t.get("id_token") or t.get("access_token") p = tok.split(".")[1]; p += "=" * (-len(p) % 4); c = json.loads(base64.urlsafe_b64decode(p)) auth = c.get("https://api.openai.com/auth", {}); prof = c.get("https://api.openai.com/profile", {}) -print(prof.get("email"), auth.get("chatgpt_plan_type"), auth.get("chatgpt_account_id")[:8], [o.get("title") for o in auth.get("organizations", [])], d.get("last_refresh")) +identifiable = False # set True only when accounts.identifiable is on; otherwise keep the check pseudonymous +print(auth.get("chatgpt_plan_type"), (auth.get("chatgpt_account_id") or "")[:8], d.get("last_refresh")) +if identifiable: + print(prof.get("email"), [o.get("title") for o in auth.get("organizations", [])]) ``` Claude: `~/.claude.json` → `oauthAccount.emailAddress`, `organizationType`, `organizationRateLimitTier`, `subscriptionCreatedAt`; `~/.claude/.credentials.json` → `claudeAiOauth.subscriptionType`, `rateLimitTier`. diff --git a/scripts/ledger.py b/scripts/ledger.py index 2c1db34..1e48aff 100644 --- a/scripts/ledger.py +++ b/scripts/ledger.py @@ -164,7 +164,7 @@ def default_config(): "version": CONFIG_VERSION, "initialized_at": None, "home": home_dir(), "store": {"kind": "sqlite", "path": os.path.join(home_dir(), "ledger.sqlite")}, "workdir": os.path.join(home_dir(), "ledger"), "timezone": local_tz_name(), "theme": "system", - "branding": dict(DEFAULT_BRAND), "detect": {"all_profiles": False, "wsl": True, "on_every_run": True}, + "branding": dict(DEFAULT_BRAND), "detect": {"all_profiles": False, "wsl": False, "on_every_run": True}, "pricing_path": os.path.join(home_dir(), "pricing.json"), "accounts_path": os.path.join(home_dir(), "accounts.json"), "report_config_path": os.path.join(home_dir(), "report_config.json"), "manifest_path": os.path.join(home_dir(), "manifest.json"), "package_prefix": "AI_Usage_Ledger", "extra_hosts": [], @@ -233,7 +233,7 @@ def migrate_config(cfg): cfg.setdefault("prompts", {"capture": False}) # pre-1.5.8 home: prompt text was never consented to, so it stays off cfg.setdefault("archive", {"raw_logs": False, "compress": True, "path": os.path.join(home_dir(), "archive")}) cfg.setdefault("schedule", {"frequency": "none", "time": "03:00", "weekday": "mon", "installed_at": None}) - cfg.setdefault("detect", {"all_profiles": False, "wsl": True, "on_every_run": True}) + cfg.setdefault("detect", {"all_profiles": False, "wsl": False, "on_every_run": True}) cfg.setdefault("branding", dict(DEFAULT_BRAND)) cfg.setdefault("extra_hosts", []) return cfg diff --git a/scripts/ledger_archive.py b/scripts/ledger_archive.py index 424c16e..c3291e5 100644 --- a/scripts/ledger_archive.py +++ b/scripts/ledger_archive.py @@ -292,8 +292,24 @@ def list(self, host=None, grep_path=None): for row in self.db.execute(q + " ORDER BY host, path", args): if grep_path and grep_path.lower() not in row[1].lower(): continue + if safety.is_credential_file(row[1]): # a row archived before the exclusion existed is never listed, searched or restored + continue yield {"host": row[0], "path": row[1], "size": row[2], "mtime": datetime.fromtimestamp(row[3]).isoformat(timespec="seconds"), "archived_at": row[4], "versions": row[5]} + def purge_credential_rows(self): + """Remove index rows (and their stored files) that match the credential predicate; returns how many.""" + rows = [(h, p) for h, p in self.db.execute("SELECT host, path FROM files") if safety.is_credential_file(p)] + for h, p in rows: + try: + dest, _ = self.dest_for(h, p) + if os.path.isfile(dest): + os.remove(dest) + except Exception: + pass + self.db.execute("DELETE FROM files WHERE host=? AND path=?", (h, p)) + self.db.commit() + return len(rows) + def open(self, host, path): dest, _ = self.dest_for(host, path) return gzip.open(dest, "rb") if self.compress else open(dest, "rb") @@ -414,7 +430,9 @@ def main(): a = ap.parse_args() ar = Archive(a.archive, compress=not a.no_compress) if a.cmd == "status": - print(json.dumps(ar.status(), indent=2)) + st = ar.status() + st["credential_rows_purged"] = ar.purge_credential_rows() + print(json.dumps(st, indent=2)) elif a.cmd == "list": for f in ar.list(a.host, a.grep_path): print("%-14s %10d %s v%d %s" % (f["host"], f["size"], f["mtime"], f["versions"], f["path"])) diff --git a/templates/manifest.example.json b/templates/manifest.example.json index 7c642c7..a6cda97 100644 --- a/templates/manifest.example.json +++ b/templates/manifest.example.json @@ -9,6 +9,8 @@ "accounts": "accounts.json", "report_config": "report_config.json", "package_accounts": false, + "capture_prompts": false, + "package_paths": false, "study_url": "", "hosts": [ {