worktrust 0.8.5 → 0.8.6

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.
package/README.md CHANGED
@@ -157,7 +157,7 @@ nothing it does not:
157
157
  | `commits` | the hashes of commits made in the stretch, never a message: what ties AI time to work in the repository |
158
158
  | `steered_from` | `remote` when the session ran over SSH |
159
159
  | `duration_basis`, `token_basis`, `turn_basis` | how the seconds, tokens and turns were measured |
160
- | `project` | a one-way hash of the project's name: which days belong together, never which project |
160
+ | `project` | a one-way hash of the project's name, keyed with this computer's own secret (`~/.worktrust/archive-salt`, never in the archive) so a guessed name cannot be tested: which days belong together, never which project |
161
161
  | `stretch_ref` | a hash of the session's id and the day, so a stretch is archived once |
162
162
  | `device_id`, `profile_id` | which computer and which user profile on it, as one-way hashes (the machine's own id never appears) |
163
163
  | `signals`, `analyzer_version` | the behaviour signals of that stretch as keys and counts from the local counter's rubric (framing, steering, verification, recovery), and the rubric's version; derived on this computer from your own turns, never a word of them (0.7.0) |
@@ -259,7 +259,7 @@ which would delete every password on the computer, and many people rightly keep
259
259
  | **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf, Hermes Agent, Goose, OpenCode, OpenClaw), and the chats Cursor and Copilot keep (clocks, models and token counts only). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's session files, the session databases of Hermes, Goose, OpenCode and OpenClaw, and the chats Cursor and Copilot keep, on this computer to measure durations and token counts. |
260
260
  | **Writes** | The key file (above). In each AI app's MCP settings (`~/.claude.json` through `claude mcp`, `~/.cursor/mcp.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`, `~/.gemini/config/mcp_config.json` (Antigravity), VS Code's `mcp.json`, Windsurf's `mcp_config.json`, Hermes's and Goose's `config.yaml`, OpenCode's `opencode.json`, OpenClaw through `openclaw mcp set`) a WorkTrust entry that runs the local bridge, with no key in it; three hooks in `~/.claude/settings.json`; the bridge, the hook and the counter in `~/.worktrust/`. |
261
261
  | **Sends, to pair** | This computer's system (macOS, Windows, Linux) and host name. WorkTrust also records the network address the request arrives from, as a security record kept 90 days and seen only by WorkTrust staff (to answer a theft or fraud report). |
262
- | **Sends, afterwards** | Per measured stretch of work, and nothing else: its durations (`seconds`, `duration_basis`, `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds`), token counts (`tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write`, `token_basis`), the model's name (`model`), the kind of work and one layer keyword such as frontend or backend (`title`, `kind`, `layer`, `layers`), counts of how you work (`exchanges`, `turn_basis`, `agent_runs`, `agent_seconds`, `agent_peak`, `interrupts`, `steers`), when it began and ended and your computer's offset from UTC (`started_at`, `at`, `utc_offset`), what the CLI derived from the stretch as kinds and counts (`evidence`: checks run and failed, delivery steps and whether a check passed first, recoveries, delegation, context, routing, steering, refusals, planning, files and test files changed, the complexity class; never a word, a command or a path; since 0.8.3), a hash of the session and the day (`stretch_ref`), `steered_from` when it ran over SSH, and, so the work can be matched to GitHub, the repository as its remote's owner/name (`repo`) and the hashes of the commits made in the stretch (`commits`, never their messages). |
262
+ | **Sends, afterwards** | Per measured stretch of work, and nothing else: its durations (`seconds`, `duration_basis`, `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds`), token counts (`tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write`, `token_basis`), the model's name (`model`), the kind of work and one layer keyword such as frontend or backend (`title`, `kind`, `layer`, `layers`), counts of how you work (`exchanges`, `turn_basis`, `agent_runs`, `agent_seconds`, `agent_peak`, `interrupts`, `steers`), when it began and ended and your computer's offset from UTC (`started_at`, `at`, `utc_offset`), what the CLI derived from the stretch as kinds and counts (`evidence`: checks run and failed, delivery steps and whether a check passed first, recoveries, delegation, context, routing, steering, refusals, planning, files and test files changed, the complexity class; never a word, a command or a path; since 0.8.3), a hash of the session and the day (`stretch_ref`), `steered_from` when it ran over SSH, and, so the work can be matched to GitHub, the repository as its remote's owner/name (`repo`, only for a repository on GitHub, GitLab or Bitbucket, the hosts WorkTrust can couple; never one on your own or a client's server) and the hashes of the commits made in the stretch (`commits`, never their messages). |
263
263
  | **Never sends** | Prompts, answers, code, file names or paths, commit messages, branch names, or the name of your local folder (a repository is named only as its remote's owner/name, above). The door has no field for them and refuses a submission that carries them. |
264
264
 
265
265
  ## Why you can check it
package/log-session.mjs CHANGED
@@ -303,9 +303,15 @@ const layersOf = (paths) => {
303
303
  };
304
304
 
305
305
  const git = (cwd, ...argv) => { try { return execFileSync("git", ["-C", cwd, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } };
306
- /** owner/name from any remote shape, or null. Nothing else of the remote is read. */
306
+ /**
307
+ * owner/name of a remote WorkTrust can couple, or null (0.8.6, privacy by design). Only a repository on GitHub, GitLab or
308
+ * Bitbucket can be joined to its commits, CI and deploys; the name of one on the person's own or a client's server
309
+ * proves nothing and could name the client, so it is never sent. Nothing else of the remote is read.
310
+ */
311
+ const CODE_HOSTS = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?(github\.com|gitlab\.com|bitbucket\.org)[:/]/i;
307
312
  const repoOf = (cwd) => {
308
313
  const remote = git(cwd, "remote", "get-url", "origin");
314
+ if (!CODE_HOSTS.test(remote)) return null;
309
315
  const match = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/.exec(remote);
310
316
  return match ? `${match[1]}/${match[2]}` : null;
311
317
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "description": "Couple this computer to WorkTrust: approve a code in your browser, and every AI app here reports what you built. Metadata only, no dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,7 +4,7 @@
4
4
  * tranches (docs/CAPABILITY-EVIDENCE-ROADMAP.md) add their fields here, each with its own check.
5
5
  */
6
6
  import { spawnSync } from "node:child_process";
7
- import { createHash } from "node:crypto";
7
+ import { createHash, createHmac } from "node:crypto";
8
8
  import { existsSync, readFileSync, readdirSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
@@ -32,7 +32,7 @@ export function stretchSignals(here, say) {
32
32
  return new Map(rows.slice(0, -1).filter(Boolean).map((row) => [row.stretch_ref, row]));
33
33
  }
34
34
  const SIGNAL = /^[a-zA-Z][A-Za-z0-9._-]{1,63}$/;
35
- export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {}) {
35
+ export function archiveLine(entry, behaviour = null, { ids = {}, collector, salt = null } = {}) {
36
36
  if (typeof entry?.client !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(entry.client) || !ISO.test(entry.started_at ?? "") || !ISO.test(entry.at ?? "") || !/^[A-Za-z0-9_-]{16,128}$/.test(entry.stretch_ref ?? "")) return null;
37
37
  const line = { client: entry.client, day: entry.started_at.slice(0, 10), started_at: entry.started_at, ended_at: entry.at };
38
38
  for (const key of COUNTS) if (typeof entry[key] === "number" && Number.isFinite(entry[key]) && entry[key] >= 0) line[key] = entry[key];
@@ -46,7 +46,9 @@ export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {
46
46
  if (Array.isArray(entry.commits) && entry.commits.length > 0 && entry.commits.every((sha) => typeof sha === "string" && /^[0-9a-f]{7,40}$/.test(sha))) line.commits = entry.commits.slice(0, 50);
47
47
  // The project as a one-way hash of its name (owner/name, else the folder's own name): which days belong together, never which project.
48
48
  const name = entry.repo ?? entry.folder;
49
- if (typeof name === "string" && name) line.project = sha256(`worktrust-project|${name}`);
49
+ // KEYED (0.8.6, audit S6): with this computer's own secret (~/.worktrust/archive-salt, never in the archive) a guessed
50
+ // repository name can no longer be tested against the archive; without one the plain hash, as before.
51
+ if (typeof name === "string" && name) line.project = salt ? createHmac("sha256", salt).update(`worktrust-project|${name}`).digest("hex") : sha256(`worktrust-project|${name}`);
50
52
  // VERIFICATION AND DELIVERY (0.7.1): per kind of check [ran, failed], per delivery step a count, and whether a check had
51
53
  // passed before the first; known kinds and whole numbers only, a failure count never above its runs.
52
54
  const whole = (value) => Number.isInteger(value) && value >= 0 && value <= 100000;
package/preserve.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  * (~/.worktrust/key.json) when there is one. Today waits for the next run. Nothing leaves this computer.
25
25
  */
26
26
  import { execFileSync, spawnSync } from "node:child_process";
27
- import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
27
+ import { createHash, createPrivateKey, createPublicKey, randomBytes, sign, verify } from "node:crypto";
28
28
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
29
29
  import { homedir, platform } from "node:os";
30
30
  import { dirname, join, relative } from "node:path";
@@ -182,6 +182,12 @@ function computerIds() {
182
182
  const hash = (text) => createHash("sha256").update(text).digest("base64url");
183
183
  return { device_id: hash(`worktrust-machine|${machine.toLowerCase()}`), profile_id: hash(`worktrust-profile|${machine.toLowerCase()}|${homedir()}`) };
184
184
  }
185
+ /** This computer's own secret for the archive's project hashes (0.8.6): made once, readable by its owner alone, never archived. */
186
+ function archiveSalt() {
187
+ const file = home(".worktrust", "archive-salt");
188
+ try { const kept = readFileSync(file, "utf8").trim(); if (/^[0-9a-f]{64}$/.test(kept)) return kept; } catch { /* first run */ }
189
+ try { mkdirSync(home(".worktrust"), { recursive: true, mode: 0o700 }); const made = randomBytes(32).toString("hex"); writeFileSync(file, `${made}\n`, { mode: 0o600, flag: "wx" }); return made; } catch { try { return readFileSync(file, "utf8").trim(); } catch { return null; } }
190
+ }
185
191
  /** The device key, when this computer is coupled: its private half signs, its public half goes into the proof. */
186
192
  function deviceKey() {
187
193
  try {
@@ -259,14 +265,14 @@ function archive(dir) {
259
265
  // A WHOLE ANSWER OR NOTHING: the hook ends with its own count; one line missing or cut is a read to refuse, never part of an archive.
260
266
  const rows = hook.stdout.split("\n").filter(Boolean), end = (() => { try { return JSON.parse(rows.at(-1) ?? "").archive_end; } catch { return undefined; } })();
261
267
  if (!Number.isInteger(end) || end !== rows.length - 1) { say(` The session hook's answer came back incomplete (${rows.length - 1} of ${end ?? "an unknown number of"} lines), so nothing was added. Run it again.`); process.exit(1); }
262
- const signals = stretchSignals(HERE, say);
268
+ const signals = stretchSignals(HERE, say), salt = archiveSalt();
263
269
  const known = new Set(existing.lines.map((line) => line.stretch_ref)), fresh = [], measured = new Set(), additions = [];
264
270
  const byRef = new Map(); // 0.8.2: every earlier line of a stretch, its supplements included
265
271
  for (const line of existing.lines) { const ref = line.supplements ?? line.stretch_ref; byRef.set(ref, [...(byRef.get(ref) ?? []), line]); }
266
272
  let waiting = 0;
267
273
  for (const text of rows.slice(0, -1)) {
268
274
  let entry; try { entry = JSON.parse(text); } catch { continue; }
269
- const line = archiveLine(entry, signals?.get(entry.stretch_ref) ?? null, { ids: IDS, collector: COLLECTOR });
275
+ const line = archiveLine(entry, signals?.get(entry.stretch_ref) ?? null, { ids: IDS, collector: COLLECTOR, salt });
270
276
  if (line && line.day < today()) measured.add(line.stretch_ref);
271
277
  if (line && byRef.has(line.stretch_ref)) { const extra = supplementFor(line, byRef.get(line.stretch_ref)); if (extra) { additions.push(extra); byRef.set(line.stretch_ref, [...byRef.get(line.stretch_ref), extra]); } continue; }
272
278
  if (!line || known.has(line.stretch_ref)) continue; // a file present twice is one stretch
package/worktrust.mjs CHANGED
@@ -73,7 +73,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
73
73
  const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
74
74
  const has = (name) => args.includes(`--${name}`);
75
75
  /** This CLI's version, said to the door so the app can tell which computer runs an old one (check-cli-package holds it equal to package.json). */
76
- const CLI_VERSION = "0.8.5";
76
+ const CLI_VERSION = "0.8.6";
77
77
  const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
78
78
  const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
79
79
  const HOME_DIR = join(homedir(), ".worktrust");