altimate-receipts 0.18.1 → 0.20.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.
package/README.md CHANGED
@@ -177,6 +177,38 @@ Humans pushing from a terminal, other agents, or repos that won't commit `.claud
177
177
  config → **[the onboarding guide](./docs/onboarding.md)** has a one-command fallback for
178
178
  each.
179
179
 
180
+ ### Live guard (optional — prevention, not just record)
181
+
182
+ The Work Record is *post-hoc* — it documents what the agent did. The **live guard** is the
183
+ *pre-execution* arm: a PreToolUse hook that classifies a tool call **before it runs** and
184
+ either blocks it, asks you to confirm, or just records it.
185
+
186
+ ```sh
187
+ npx altimate-receipts init --guard
188
+ ```
189
+
190
+ It uses the **same FP-aware policy** as the Work Record (`policy/guardrails.policy.json`), in
191
+ three tiers:
192
+
193
+ - **deny** — never-legitimate catastrophes only (`rm -rf /` / `~` / `.git` / `.receipts`,
194
+ force-push to a *protected* branch, `mkfs`, `DROP DATABASE`). Hard-blocked.
195
+ - **ask** — ambiguous-but-dangerous (`rm -rf` of an unknown dir, `git reset --hard`,
196
+ `--no-verify`, pipe-a-script-from-the-internet, force-push to a *feature* branch). Routed to
197
+ your normal confirmation prompt — never silently blocked.
198
+ - **warn** — surfaced + recorded, never blocked (editing a CI workflow / a grader / an `.env`).
199
+
200
+ Blocked calls never reach the transcript, so the guard **records each deny/ask/warn into the
201
+ Receipt** — the highest-signal line a record can carry: *what the agent was stopped from doing.*
202
+
203
+ **It's off by default, and opt-out is one env var.** Active gating is your call:
204
+
205
+ ```sh
206
+ RECEIPTS_GUARD=0 # turn the guard off without un-installing (fail-open, records nothing)
207
+ ```
208
+
209
+ The guard is **fail-open by contract** — any error, or the opt-out, allows the call. The
210
+ post-hoc Record is the backstop, so live enforcement favors letting work through.
211
+
180
212
  ## Going deeper (optional)
181
213
 
182
214
  - **`receipts --json`** — a portable, vendor-neutral [in-toto](https://in-toto.io)
@@ -1,9 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  destructiveOutsideRepo,
4
+ isProtectedBranch,
4
5
  regenerableBuildArtifact,
5
- regenerableReceiptRm
6
- } from "./chunk-LYLHXQAU.js";
6
+ regenerableReceiptRm,
7
+ stripHeredocs
8
+ } from "./chunk-RYOBPBGP.js";
9
+
10
+ // src/version.ts
11
+ import { readFileSync } from "fs";
12
+ import { fileURLToPath } from "url";
13
+ function getVersion() {
14
+ try {
15
+ const pkgUrl = new URL("../package.json", import.meta.url);
16
+ const raw = readFileSync(fileURLToPath(pkgUrl), "utf8");
17
+ const pkg = JSON.parse(raw);
18
+ return pkg.version ?? "0.0.0";
19
+ } catch {
20
+ return "0.0.0";
21
+ }
22
+ }
7
23
 
8
24
  // src/findings/gitParse.ts
9
25
  var WRAPPER = /* @__PURE__ */ new Set(["sudo", "env", "nohup", "command", "stdbuf", "time", "timeout", "xargs"]);
@@ -233,6 +249,12 @@ var has = (inv, ...names) => [...inv.flags, ...inv.globalFlags].some((f) => name
233
249
  function isHardForcePush(inv) {
234
250
  return inv.subcommand === "push" && has(inv, "force", "f") && !has(inv, "force-with-lease", "force-if-includes");
235
251
  }
252
+ function forcePushTargetProtected(inv) {
253
+ return inv.positionals.some((p) => {
254
+ const dst = p.includes(":") ? p.split(":").pop() ?? "" : p;
255
+ return isProtectedBranch(dst);
256
+ });
257
+ }
236
258
  function isNoVerify(inv) {
237
259
  if (inv.subcommand === "commit") {
238
260
  return has(inv, "no-verify", "n");
@@ -667,6 +689,25 @@ function uncachedCost(usage, model) {
667
689
  return (usage.input * price.input + usage.output * price.output + (usage.cacheRead + usage.cacheWrite) * price.input) / m;
668
690
  }
669
691
 
692
+ // src/findings/paths.ts
693
+ var WORKTREE_PREFIX = /^.*\/\.claude\/worktrees\/[^/]+\//;
694
+ var AGENT_RUNTIME = /(?:^|\/)\.claude\/(?:projects|todos|statsig|shell-snapshots|history)\//;
695
+ function isAgentRuntimePath(p) {
696
+ return AGENT_RUNTIME.test(p);
697
+ }
698
+ function repoRelativePath(p) {
699
+ return p.replace(WORKTREE_PREFIX, "").replace(/^\.\//, "");
700
+ }
701
+ function repoFilePath(raw) {
702
+ if (!raw) {
703
+ return void 0;
704
+ }
705
+ if (isAgentRuntimePath(raw)) {
706
+ return void 0;
707
+ }
708
+ return repoRelativePath(raw);
709
+ }
710
+
670
711
  // src/findings/toolRoles.ts
671
712
  var EDIT_NAMES = /^(edit|multiedit|write|notebookedit|str_replace_editor|str_replace|apply_patch|search_replace|create_file|edit_file|insert_edit_into_file|replace_string_in_file|patch|write_file|create)$/i;
672
713
  var CREATE_NAMES = /^(write|create_file|write_file|create|notebookedit)$/i;
@@ -709,7 +750,7 @@ function filePathOf(input) {
709
750
  return void 0;
710
751
  }
711
752
  const v = r.file_path ?? r.filePath ?? r.path ?? r.target_file ?? r.targetFile ?? r.filename ?? r.fileName ?? r.uri ?? r.fsPath;
712
- return typeof v === "string" ? v : void 0;
753
+ return typeof v === "string" ? repoFilePath(v) : void 0;
713
754
  }
714
755
  function commandOf(input) {
715
756
  if (typeof input === "string") {
@@ -795,6 +836,27 @@ function toolInputField(input, keys) {
795
836
  }
796
837
  return void 0;
797
838
  }
839
+ var READ_ONLY_FILE_CMD = /^(?:cat|bat|head|tail|less|more|nl)$/i;
840
+ function extractReadPaths(command) {
841
+ const out = [];
842
+ for (const stmt of command.split(/\n|;|&&|\|\||\|/)) {
843
+ const toks = stmt.trim().split(/\s+/).filter(Boolean);
844
+ const head = toks[0]?.replace(/.*\//, "");
845
+ if (!head || !READ_ONLY_FILE_CMD.test(head)) {
846
+ continue;
847
+ }
848
+ for (const t of toks.slice(1)) {
849
+ if (/^\d*&?[<>]/.test(t)) {
850
+ break;
851
+ }
852
+ if (t.startsWith("-") || /^\d+$/.test(t) || /[*?]/.test(t)) {
853
+ continue;
854
+ }
855
+ out.push(t.replace(/^["']|["']$/g, ""));
856
+ }
857
+ }
858
+ return out;
859
+ }
798
860
  var DESTRUCTIVE_PATTERNS = [
799
861
  /(?<!git )\brm\s+(-[a-z]*\s+)*-?[a-z]*[rf]/i,
800
862
  // rm -rf, rm -fr, rm -r -f (NOT `git rm`)
@@ -809,11 +871,6 @@ var DESTRUCTIVE_PATTERNS = [
809
871
  // writing to a raw disk
810
872
  /\bmkfs\b|\bdd\s+if=/i
811
873
  ];
812
- function stripHeredocs(command) {
813
- const closed = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1[\s\S]*?\n[ \t]*\2(?![A-Za-z0-9_])/g;
814
- const stripped = command.replace(closed, "<<heredoc");
815
- return stripped.replace(/<<-?\s*(['"]?)[A-Za-z_]\w*\1[\s\S]*$/, "<<heredoc");
816
- }
817
874
  function stripCodeStringsAndComments(code) {
818
875
  let out = "";
819
876
  let state = "code";
@@ -1024,7 +1081,9 @@ function classifyDestructive(name, input) {
1024
1081
  if (DESTRUCTIVE_PATTERNS.some((re) => re.test(scannable))) {
1025
1082
  return true;
1026
1083
  }
1027
- return parseGitInvocations(scannable).some(destroysGitData);
1084
+ return parseGitInvocations(scannable).some(
1085
+ (inv) => destroysGitData(inv) && !(isHardForcePush(inv) && !forcePushTargetProtected(inv))
1086
+ );
1028
1087
  }
1029
1088
  function destructiveMatch(command) {
1030
1089
  const scannable = destructiveScannable(command);
@@ -1116,10 +1175,22 @@ function deriveSpans(session) {
1116
1175
  for (const m of session.messages) {
1117
1176
  for (const c of m.toolCalls ?? []) {
1118
1177
  if (FILE_READ_TOOLS.has(c.name)) {
1119
- const fp = toolInputField(c.input, ["file_path", "path", "filename", "target_file"]);
1178
+ const fp = repoFilePath(
1179
+ toolInputField(c.input, ["file_path", "path", "filename", "target_file"])
1180
+ );
1120
1181
  if (fp) {
1121
1182
  filesReadSet.add(fp);
1122
1183
  }
1184
+ } else if (CMD_TOOLS.has(c.name)) {
1185
+ const cmd = toolInputField(c.input, ["command", "cmd"]);
1186
+ if (cmd) {
1187
+ for (const p of extractReadPaths(cmd)) {
1188
+ const fp = repoFilePath(p);
1189
+ if (fp) {
1190
+ filesReadSet.add(fp);
1191
+ }
1192
+ }
1193
+ }
1123
1194
  }
1124
1195
  }
1125
1196
  }
@@ -1230,12 +1301,16 @@ function deriveSpans(session) {
1230
1301
  loopCounts.set(sig, { count: 1, name: call.name, preview: String(preview).slice(0, 80) });
1231
1302
  }
1232
1303
  if (FILE_WRITE_TOOLS.has(call.name)) {
1233
- const fp = toolInputField(call.input, ["file_path", "path", "filename", "target_file"]);
1304
+ const fp = repoFilePath(
1305
+ toolInputField(call.input, ["file_path", "path", "filename", "target_file"])
1306
+ );
1234
1307
  if (fp) {
1235
1308
  filesChangedMap.set(fp, "write");
1236
1309
  }
1237
1310
  } else if (FILE_EDIT_TOOLS.has(call.name)) {
1238
- const fp = toolInputField(call.input, ["file_path", "path", "filename", "target_file"]);
1311
+ const fp = repoFilePath(
1312
+ toolInputField(call.input, ["file_path", "path", "filename", "target_file"])
1313
+ );
1239
1314
  if (fp && !filesChangedMap.has(fp)) {
1240
1315
  filesChangedMap.set(fp, "edit");
1241
1316
  }
@@ -3560,8 +3635,9 @@ function diffEffort(derived, files, window) {
3560
3635
  let tokens = 0;
3561
3636
  for (const s of derived.spans) {
3562
3637
  if (s.kind === "generation" && editedGens.has(s.spanId)) {
3563
- cost += s.cost || estimateCost(s.tokens, s.name);
3564
- tokens += s.tokens?.total ?? 0;
3638
+ const t = s.tokens;
3639
+ tokens += (t?.input ?? 0) + (t?.output ?? 0) + (t?.cacheWrite ?? 0);
3640
+ cost += t ? estimateCost({ ...t, cacheRead: 0 }, s.name) : s.cost || 0;
3565
3641
  }
3566
3642
  }
3567
3643
  return { cost, tokens, turns: editedGens.size };
@@ -3635,20 +3711,6 @@ function applyDiffScope(derived, findings, files, projectPath) {
3635
3711
  };
3636
3712
  }
3637
3713
 
3638
- // src/version.ts
3639
- import { readFileSync } from "fs";
3640
- import { fileURLToPath } from "url";
3641
- function getVersion() {
3642
- try {
3643
- const pkgUrl = new URL("../package.json", import.meta.url);
3644
- const raw = readFileSync(fileURLToPath(pkgUrl), "utf8");
3645
- const pkg = JSON.parse(raw);
3646
- return pkg.version ?? "0.0.0";
3647
- } catch {
3648
- return "0.0.0";
3649
- }
3650
- }
3651
-
3652
3714
  // src/receipt/hash.ts
3653
3715
  import { createHash } from "crypto";
3654
3716
  import { promises as fs } from "fs";
@@ -4006,7 +4068,12 @@ async function buildReceipt(session, derived, findings, opts = {}) {
4006
4068
  session: {
4007
4069
  agent: session.source,
4008
4070
  model: session.model,
4009
- title: session.title,
4071
+ // Prefer the BRANCH name for a diff-scoped receipt. `session.title` is the session's first
4072
+ // user message, which is shared across every branch a long multi-branch session produces (and
4073
+ // contaminated across resumed sessions) — so 5 unrelated branches all read "Plan SaaS layer…".
4074
+ // The branch is recorded in `scope` (so L1 re-derivation reproduces this exact title) and
4075
+ // actually describes the branch. Fall back to the session title only when there's no branch.
4076
+ title: opts.scope?.branch || session.title,
4010
4077
  startedAt: session.startedAt,
4011
4078
  endedAt: session.endedAt,
4012
4079
  durationMs: session.totals.durationMs
@@ -5678,6 +5745,7 @@ function redactReceipt(receipt) {
5678
5745
  }
5679
5746
 
5680
5747
  export {
5748
+ getVersion,
5681
5749
  deriveSpans,
5682
5750
  cwdAtFirstGit,
5683
5751
  formatTokens,
@@ -5690,7 +5758,6 @@ export {
5690
5758
  narrowEffort,
5691
5759
  windowedEffort,
5692
5760
  applyDiffScope,
5693
- getVersion,
5694
5761
  hashTranscriptFile,
5695
5762
  sha256Hex,
5696
5763
  PREDICATE_TYPE,
@@ -5719,4 +5786,4 @@ export {
5719
5786
  redact,
5720
5787
  redactReceipt
5721
5788
  };
5722
- //# sourceMappingURL=chunk-JWDZLJDG.js.map
5789
+ //# sourceMappingURL=chunk-7LROPRL2.js.map