altimate-receipts 0.19.0 → 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,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  destructiveOutsideRepo,
4
+ isProtectedBranch,
4
5
  regenerableBuildArtifact,
5
- regenerableReceiptRm
6
- } from "./chunk-44V3DNK5.js";
6
+ regenerableReceiptRm,
7
+ stripHeredocs
8
+ } from "./chunk-RYOBPBGP.js";
7
9
 
8
10
  // src/version.ts
9
11
  import { readFileSync } from "fs";
@@ -247,6 +249,12 @@ var has = (inv, ...names) => [...inv.flags, ...inv.globalFlags].some((f) => name
247
249
  function isHardForcePush(inv) {
248
250
  return inv.subcommand === "push" && has(inv, "force", "f") && !has(inv, "force-with-lease", "force-if-includes");
249
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
+ }
250
258
  function isNoVerify(inv) {
251
259
  if (inv.subcommand === "commit") {
252
260
  return has(inv, "no-verify", "n");
@@ -681,6 +689,25 @@ function uncachedCost(usage, model) {
681
689
  return (usage.input * price.input + usage.output * price.output + (usage.cacheRead + usage.cacheWrite) * price.input) / m;
682
690
  }
683
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
+
684
711
  // src/findings/toolRoles.ts
685
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;
686
713
  var CREATE_NAMES = /^(write|create_file|write_file|create|notebookedit)$/i;
@@ -723,7 +750,7 @@ function filePathOf(input) {
723
750
  return void 0;
724
751
  }
725
752
  const v = r.file_path ?? r.filePath ?? r.path ?? r.target_file ?? r.targetFile ?? r.filename ?? r.fileName ?? r.uri ?? r.fsPath;
726
- return typeof v === "string" ? v : void 0;
753
+ return typeof v === "string" ? repoFilePath(v) : void 0;
727
754
  }
728
755
  function commandOf(input) {
729
756
  if (typeof input === "string") {
@@ -809,6 +836,27 @@ function toolInputField(input, keys) {
809
836
  }
810
837
  return void 0;
811
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
+ }
812
860
  var DESTRUCTIVE_PATTERNS = [
813
861
  /(?<!git )\brm\s+(-[a-z]*\s+)*-?[a-z]*[rf]/i,
814
862
  // rm -rf, rm -fr, rm -r -f (NOT `git rm`)
@@ -823,11 +871,6 @@ var DESTRUCTIVE_PATTERNS = [
823
871
  // writing to a raw disk
824
872
  /\bmkfs\b|\bdd\s+if=/i
825
873
  ];
826
- function stripHeredocs(command) {
827
- const closed = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1[\s\S]*?\n[ \t]*\2(?![A-Za-z0-9_])/g;
828
- const stripped = command.replace(closed, "<<heredoc");
829
- return stripped.replace(/<<-?\s*(['"]?)[A-Za-z_]\w*\1[\s\S]*$/, "<<heredoc");
830
- }
831
874
  function stripCodeStringsAndComments(code) {
832
875
  let out = "";
833
876
  let state = "code";
@@ -1038,7 +1081,9 @@ function classifyDestructive(name, input) {
1038
1081
  if (DESTRUCTIVE_PATTERNS.some((re) => re.test(scannable))) {
1039
1082
  return true;
1040
1083
  }
1041
- return parseGitInvocations(scannable).some(destroysGitData);
1084
+ return parseGitInvocations(scannable).some(
1085
+ (inv) => destroysGitData(inv) && !(isHardForcePush(inv) && !forcePushTargetProtected(inv))
1086
+ );
1042
1087
  }
1043
1088
  function destructiveMatch(command) {
1044
1089
  const scannable = destructiveScannable(command);
@@ -1130,10 +1175,22 @@ function deriveSpans(session) {
1130
1175
  for (const m of session.messages) {
1131
1176
  for (const c of m.toolCalls ?? []) {
1132
1177
  if (FILE_READ_TOOLS.has(c.name)) {
1133
- 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
+ );
1134
1181
  if (fp) {
1135
1182
  filesReadSet.add(fp);
1136
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
+ }
1137
1194
  }
1138
1195
  }
1139
1196
  }
@@ -1244,12 +1301,16 @@ function deriveSpans(session) {
1244
1301
  loopCounts.set(sig, { count: 1, name: call.name, preview: String(preview).slice(0, 80) });
1245
1302
  }
1246
1303
  if (FILE_WRITE_TOOLS.has(call.name)) {
1247
- 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
+ );
1248
1307
  if (fp) {
1249
1308
  filesChangedMap.set(fp, "write");
1250
1309
  }
1251
1310
  } else if (FILE_EDIT_TOOLS.has(call.name)) {
1252
- 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
+ );
1253
1314
  if (fp && !filesChangedMap.has(fp)) {
1254
1315
  filesChangedMap.set(fp, "edit");
1255
1316
  }
@@ -3574,8 +3635,9 @@ function diffEffort(derived, files, window) {
3574
3635
  let tokens = 0;
3575
3636
  for (const s of derived.spans) {
3576
3637
  if (s.kind === "generation" && editedGens.has(s.spanId)) {
3577
- cost += s.cost || estimateCost(s.tokens, s.name);
3578
- 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;
3579
3641
  }
3580
3642
  }
3581
3643
  return { cost, tokens, turns: editedGens.size };
@@ -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
@@ -5719,4 +5786,4 @@ export {
5719
5786
  redact,
5720
5787
  redactReceipt
5721
5788
  };
5722
- //# sourceMappingURL=chunk-C7NAN2LL.js.map
5789
+ //# sourceMappingURL=chunk-7LROPRL2.js.map