harnery 0.42.0 → 0.43.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.
Files changed (77) hide show
  1. package/dist/commander.d.ts +16 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +18 -0
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +54 -4
  6. package/dist/commands/tunnel.d.ts +65 -0
  7. package/dist/commands/tunnel.d.ts.map +1 -1
  8. package/dist/commands/tunnel.js +290 -101
  9. package/dist/commands/web-fetch.d.ts +3 -2
  10. package/dist/commands/web-fetch.d.ts.map +1 -1
  11. package/dist/commands/web-fetch.js +25 -19
  12. package/dist/commands/web.d.ts +7 -1
  13. package/dist/commands/web.d.ts.map +1 -1
  14. package/dist/commands/web.js +9 -3
  15. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  16. package/dist/core/agents/render/prompt-context.js +11 -5
  17. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  18. package/dist/core/agents/render/session-context.js +57 -13
  19. package/dist/core/agents/state/names.d.ts +43 -3
  20. package/dist/core/agents/state/names.d.ts.map +1 -1
  21. package/dist/core/agents/state/names.js +87 -3
  22. package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
  23. package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
  24. package/dist/core/hooks/cli.js +87 -18
  25. package/dist/core/hooks/fork-lineage.d.ts +40 -0
  26. package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
  27. package/dist/core/hooks/fork-lineage.js +62 -0
  28. package/dist/core/hooks/resolve/transcript.d.ts +16 -0
  29. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  30. package/dist/core/hooks/resolve/transcript.js +84 -0
  31. package/dist/core/hooks/shell-rm.d.ts +2 -0
  32. package/dist/core/hooks/shell-rm.d.ts.map +1 -0
  33. package/dist/core/hooks/shell-rm.js +105 -0
  34. package/dist/core/hooks/shell-tokens.d.ts +12 -0
  35. package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
  36. package/dist/core/hooks/shell-tokens.js +97 -0
  37. package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
  38. package/dist/core/hooks/shell-waiter.js +3 -93
  39. package/dist/core/semantic/contract.d.ts +5 -5
  40. package/dist/core/storage/process-log.d.ts +12 -0
  41. package/dist/core/storage/process-log.d.ts.map +1 -1
  42. package/dist/core/storage/process-log.js +10 -2
  43. package/dist/lib/tunnel/allowlist.d.ts +44 -0
  44. package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
  45. package/dist/lib/tunnel/allowlist.js +204 -0
  46. package/dist/lib/tunnel/current-address.d.ts +41 -0
  47. package/dist/lib/tunnel/current-address.d.ts.map +1 -0
  48. package/dist/lib/tunnel/current-address.js +123 -0
  49. package/dist/lib/tunnel/gate.js +13 -6
  50. package/dist/lib/tunnel/processes.d.ts +39 -0
  51. package/dist/lib/tunnel/processes.d.ts.map +1 -0
  52. package/dist/lib/tunnel/processes.js +121 -0
  53. package/dist/lib/tunnel/state.d.ts +28 -3
  54. package/dist/lib/tunnel/state.d.ts.map +1 -1
  55. package/dist/lib/tunnel/state.js +98 -27
  56. package/package.json +2 -1
  57. package/src/commander.ts +32 -0
  58. package/src/commands/agents.ts +97 -11
  59. package/src/commands/tunnel.ts +394 -116
  60. package/src/commands/web-fetch.ts +30 -19
  61. package/src/commands/web.ts +9 -3
  62. package/src/core/agents/render/prompt-context.ts +25 -8
  63. package/src/core/agents/render/session-context.ts +83 -16
  64. package/src/core/agents/state/names.ts +117 -6
  65. package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
  66. package/src/core/hooks/cli.ts +106 -17
  67. package/src/core/hooks/fork-lineage.ts +82 -0
  68. package/src/core/hooks/resolve/transcript.ts +88 -0
  69. package/src/core/hooks/shell-rm.ts +103 -0
  70. package/src/core/hooks/shell-tokens.ts +96 -0
  71. package/src/core/hooks/shell-waiter.ts +4 -91
  72. package/src/core/storage/process-log.ts +17 -2
  73. package/src/lib/tunnel/allowlist.ts +208 -0
  74. package/src/lib/tunnel/current-address.ts +158 -0
  75. package/src/lib/tunnel/gate.ts +16 -8
  76. package/src/lib/tunnel/processes.ts +149 -0
  77. package/src/lib/tunnel/state.ts +123 -27
@@ -57,7 +57,13 @@ import {
57
57
  } from "../agents/state/heartbeat-writer.ts";
58
58
  import { readLiveCoordinationRow } from "../agents/state/live-coordination-view.ts";
59
59
  import { ensureLiveCoordinationHeartbeat } from "../agents/state/live-coordination-writer.ts";
60
- import { assignName } from "../agents/state/names.ts";
60
+ import {
61
+ assignName,
62
+ readForkParent,
63
+ readForkState,
64
+ recordForkDetection,
65
+ resolveName,
66
+ } from "../agents/state/names.ts";
61
67
  import { writePidmapRow } from "../agents/state/pidmap.ts";
62
68
  import {
63
69
  agentsRequireGitFinalization,
@@ -115,6 +121,7 @@ import {
115
121
  soundForEvent,
116
122
  } from "./effects/index.ts";
117
123
  import { toolInputHash, toolTargetHash } from "./events/input-hash.ts";
124
+ import { detectClaudeCodeFork } from "./fork-lineage.ts";
118
125
  import { canonicalize } from "./guard-path.ts";
119
126
  import {
120
127
  beginHookHealth,
@@ -135,6 +142,7 @@ import {
135
142
  } from "./resolve/transcript.ts";
136
143
  import { clearHookRunMarker, writeHookRunMarker } from "./run-markers.ts";
137
144
  import { sessionNamePresence } from "./session-name-presence.ts";
145
+ import { shellRmReason } from "./shell-rm.ts";
138
146
  import { shellWaiterReason } from "./shell-waiter.ts";
139
147
 
140
148
  interface Argv {
@@ -250,12 +258,14 @@ function assignNameInProcess(
250
258
  coordRoot: string,
251
259
  instanceId: string,
252
260
  kind: "session" | "subagent" | "transient",
253
- forkedFrom?: string,
261
+ fork?: { forkedFrom?: string; fork?: boolean; forkChecked?: boolean },
254
262
  ): { name: string; kind: string } | null {
255
263
  try {
256
264
  const name = assignName(coordRoot, instanceId, kind, {
257
265
  freshnessSecs: coordFreshnessSeconds(coordRoot),
258
- ...(forkedFrom ? { forkedFrom } : {}),
266
+ ...(fork?.forkedFrom ? { forkedFrom: fork.forkedFrom } : {}),
267
+ ...(fork?.fork ? { fork: true } : {}),
268
+ ...(fork?.forkChecked ? { forkChecked: true } : {}),
259
269
  });
260
270
  return { name, kind };
261
271
  } catch {
@@ -263,6 +273,66 @@ function assignNameInProcess(
263
273
  }
264
274
  }
265
275
 
276
+ /**
277
+ * Name a main session, recording fork lineage when the instance is new. Every
278
+ * main-session naming path goes through here: whichever hook first sees a new
279
+ * instance writes its history row, and lineage can only ride that first row
280
+ * (later detection appends a `fork.detected` row instead).
281
+ */
282
+ function assignSessionNameInProcess(
283
+ coordRoot: string,
284
+ instanceId: string,
285
+ payload: ParsedPayload | null,
286
+ adapter: Adapter,
287
+ ): { name: string; kind: string } | null {
288
+ let fork: { forkedFrom?: string; fork?: boolean; forkChecked?: boolean } | undefined;
289
+ try {
290
+ if (
291
+ adapter === "claude-code" &&
292
+ payload?.session_id === instanceId &&
293
+ !resolveName(coordRoot, instanceId)
294
+ ) {
295
+ const detected = detectClaudeCodeFork({
296
+ coordRoot,
297
+ transcriptPath: payload.transcript_path,
298
+ sessionId: instanceId,
299
+ source: payload.source,
300
+ });
301
+ fork = {
302
+ ...(detected.forkedFrom ? { forkedFrom: detected.forkedFrom } : {}),
303
+ fork: detected.fork,
304
+ forkChecked: detected.checked,
305
+ };
306
+ }
307
+ } catch {
308
+ fork = undefined;
309
+ }
310
+ return assignNameInProcess(coordRoot, instanceId, "session", fork);
311
+ }
312
+
313
+ /**
314
+ * Finish fork detection that session start could not: a CLI fork starts before
315
+ * its transcript exists, and a mid-flight instance may never see SessionStart.
316
+ * Runs once per instance; the appended `fork_checked` row stops later calls.
317
+ */
318
+ function healForkLineage(
319
+ coordRoot: string,
320
+ instanceId: string,
321
+ payload: ParsedPayload | null,
322
+ ): void {
323
+ if (!payload?.transcript_path || payload.session_id !== instanceId) return;
324
+ const state = readForkState(coordRoot, instanceId);
325
+ if (!state || state.checked) return;
326
+ const detected = detectClaudeCodeFork({
327
+ coordRoot,
328
+ transcriptPath: payload.transcript_path,
329
+ sessionId: instanceId,
330
+ knownFork: state.fork,
331
+ });
332
+ if (!detected.checked) return;
333
+ recordForkDetection(coordRoot, instanceId, detected);
334
+ }
335
+
266
336
  /** Mid-flight sessions can emit tools before SessionStart assigns a pool name.
267
337
  * Stamp the disposable cache so Codec does not render the 8-character id. */
268
338
  function ensureSessionDisplayName(
@@ -270,13 +340,14 @@ function ensureSessionDisplayName(
270
340
  instanceId: string,
271
341
  sessionId: string,
272
342
  adapter: Adapter,
343
+ payload: ParsedPayload | null,
273
344
  ): void {
274
345
  if (coordEnv("WORKFLOW_CHILD") === "1") return;
275
346
  const row =
276
347
  readLiveCoordinationRow(coordRoot, instanceId) ?? readHeartbeat(coordRoot, instanceId);
277
348
  if (row?.kind === "subagent" || row?.kind === "transient" || row?.workflow_run_id) return;
278
349
  if (row?.name?.trim()) return;
279
- const assigned = assignNameInProcess(coordRoot, instanceId, "session");
350
+ const assigned = assignSessionNameInProcess(coordRoot, instanceId, payload, adapter);
280
351
  if (!assigned?.name) return;
281
352
  if (!readHeartbeat(coordRoot, instanceId)) {
282
353
  try {
@@ -345,22 +416,22 @@ function buildEventData(
345
416
  // The platform label is the adapter id itself; enumerating and defaulting
346
417
  // to codex silently mislabels any adapter not in the list.
347
418
  const adapterPlatform = ctx.adapter;
348
- // Recorded fork lineage is NOT detected here. On claude-code a fork
349
- // never fires its own session.started — SessionStart fires under the
350
- // PARENT's session id (source=resume) before the fork id is minted
351
- // (verified 2026-08-05) — so detection at this point can only mislabel
352
- // the resumed parent. The fork's new instance is caught by the
353
- // tool.requested heal path instead. The forkedFrom plumbing below stays
354
- // for adapters that DO report a parent at session start.
355
- const forkedFrom: string | undefined = undefined;
356
419
  // Assign (or recover) name + kind in-process. Idempotent: resume
357
- // returns the original name; new owner consumes a counter slot.
420
+ // returns the original name; new owner consumes a counter slot and,
421
+ // on claude-code, runs fork detection (fork-lineage.ts). Since Claude
422
+ // Code 2.1.283 both fork flows fire SessionStart under the FORK's own
423
+ // id (verified 2026-10-07), superseding the 2026-08-05 finding that a
424
+ // fork never started on its own id. A resumed parent keeps its existing
425
+ // history row, so it is never relabeled here.
358
426
  // Workflow children stay unnamed, as on the tool path: a pool name
359
427
  // would make them the addressee of mail held for that name.
360
428
  const assigned =
361
429
  coordEnv("WORKFLOW_CHILD") === "1"
362
430
  ? undefined
363
- : assignNameInProcess(ctx.coordRoot, ctx.instanceId, "session", forkedFrom);
431
+ : assignSessionNameInProcess(ctx.coordRoot, ctx.instanceId, p, ctx.adapter);
432
+ const forkedFrom = assigned
433
+ ? readForkParent(ctx.coordRoot, ctx.instanceId)?.instance_id
434
+ : undefined;
364
435
  // Write the adapter pid-map row so `harn agents whoami` ppid-walks find
365
436
  // this owner. Prefer the payload pid (the actual claude binary), then the
366
437
  // anchor walk (the `node` ancestor for Cursor, which has no payload pid),
@@ -429,7 +500,7 @@ function buildEventData(
429
500
  coordEnv("WORKFLOW_CHILD") !== "1" &&
430
501
  !readLiveCoordinationRow(ctx.coordRoot, ctx.instanceId)?.name
431
502
  ) {
432
- assignNameInProcess(ctx.coordRoot, ctx.instanceId, "session");
503
+ assignSessionNameInProcess(ctx.coordRoot, ctx.instanceId, p, ctx.adapter);
433
504
  }
434
505
  const prompt = p?.prompt ?? "";
435
506
  const { value, truncated } = clampString(prompt, 4000);
@@ -851,7 +922,7 @@ async function main(): Promise<number> {
851
922
  !priorCoordination?.workflow_run_id &&
852
923
  !priorCoordination?.name?.trim()
853
924
  ) {
854
- assignNameInProcess(coordRoot, owner.instance_id, "session");
925
+ assignSessionNameInProcess(coordRoot, owner.instance_id, payload, adapter);
855
926
  }
856
927
 
857
928
  const data = buildEventData(norm.event_type, {
@@ -1063,11 +1134,18 @@ function recordSignal(run: HookRun): void {
1063
1134
  }
1064
1135
  if (norm.event_type !== "session.ended") {
1065
1136
  try {
1066
- ensureSessionDisplayName(coordRoot, owner.instance_id, sessionId, adapter);
1137
+ ensureSessionDisplayName(coordRoot, owner.instance_id, sessionId, adapter, payload);
1067
1138
  } catch (error) {
1068
1139
  logError(coordRoot, error, { phase: "session-display-name" });
1069
1140
  }
1070
1141
  }
1142
+ if (norm.event_type === "tool.requested" && adapter === "claude-code" && run.topLevelSession) {
1143
+ try {
1144
+ healForkLineage(coordRoot, owner.instance_id, payload);
1145
+ } catch (error) {
1146
+ logError(coordRoot, error, { phase: "fork-lineage-heal" });
1147
+ }
1148
+ }
1071
1149
  const v3EventId =
1072
1150
  v3Result && "event" in v3Result
1073
1151
  ? v3Result.event.event_id
@@ -1559,6 +1637,17 @@ async function handleToolRequested(run: HookRun): Promise<number | undefined> {
1559
1637
  return 0;
1560
1638
  }
1561
1639
 
1640
+ const rmReason = shellRmReason(
1641
+ payload?.tool_name,
1642
+ payload?.tool_input,
1643
+ resolveBinName(coordRoot),
1644
+ );
1645
+ if (rmReason) {
1646
+ const { emitDeny } = await import("./adapter/output.ts");
1647
+ emitDeny(adapter, rmReason);
1648
+ return 0;
1649
+ }
1650
+
1562
1651
  // G-guard for ALL adapters. Claude Code previously ran this via a
1563
1652
  // pre-tool-use bash adapter (which called `agent-coord verdict --rule=claim`);
1564
1653
  // that adapter is now deleted, so agent-hook owns the deny for every adapter.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Claude Code fork detection: decide whether a new main-session instance is a
3
+ * branch of another session, and which one.
4
+ *
5
+ * Two shapes exist as of Claude Code 2.1.283 (verified 2026-10-07):
6
+ *
7
+ * - CLI `--resume <id> --fork-session` fires SessionStart under the FORK's id
8
+ * with `source: "fork"`, before its transcript exists, and names no parent.
9
+ * The transcript later holds the parent's message uuids with every row's
10
+ * `sessionId` rewritten, so the parent is found by uuid containment
11
+ * (`detectForkParent`) once the transcript is on disk.
12
+ * - The desktop app's "Fork from here" copies the parent's rows into the
13
+ * fork's transcript with their original `sessionId`, so inherited rows
14
+ * name the parent directly (`detectInheritedSessionParent`).
15
+ *
16
+ * Inherited rows alone do not prove a fork: a continuation of a finished
17
+ * session can carry them too. The parent's liveness separates the two. A
18
+ * parent with an unended generation is still running (or resumable) and owns
19
+ * its name, task, and claims, so the new instance is a fork. A finished
20
+ * parent makes it a continuation, which keeps the generic notice.
21
+ *
22
+ * Everything here fails open: an unreadable transcript or an unknown layout
23
+ * yields "not a fork", never an error.
24
+ */
25
+
26
+ import { existsSync } from "node:fs";
27
+ import { readLiveCoordinationRow } from "../agents/state/live-coordination-view.ts";
28
+ import { detectForkParent, detectInheritedSessionParent } from "./resolve/transcript.ts";
29
+
30
+ export interface ForkDetection {
31
+ fork: boolean;
32
+ forkedFrom?: string;
33
+ /** Detection had the evidence it needs; the tool-path heal can skip it. */
34
+ checked: boolean;
35
+ }
36
+
37
+ /** Sources after which a brand-new instance cannot hold inherited history. */
38
+ const FRESH_SOURCES = new Set(["startup", "clear"]);
39
+
40
+ export function detectClaudeCodeFork(input: {
41
+ coordRoot: string;
42
+ transcriptPath: string | undefined;
43
+ sessionId: string;
44
+ source?: string;
45
+ /** The instance was already recorded as a fork without a parent. */
46
+ knownFork?: boolean;
47
+ isLive?: (instanceId: string) => boolean;
48
+ }): ForkDetection {
49
+ try {
50
+ const declared = input.source === "fork" || input.knownFork === true;
51
+ const transcriptReady = !!input.transcriptPath && existsSync(input.transcriptPath);
52
+ const isLive =
53
+ input.isLive ?? ((id: string) => readLiveCoordinationRow(input.coordRoot, id) !== null);
54
+
55
+ const inherited = transcriptReady
56
+ ? detectInheritedSessionParent(input.transcriptPath, input.sessionId)
57
+ : undefined;
58
+ if (inherited && inherited !== input.sessionId) {
59
+ if (declared || isLive(inherited)) {
60
+ return { fork: true, forkedFrom: inherited, checked: true };
61
+ }
62
+ return { fork: false, checked: true };
63
+ }
64
+
65
+ if (declared) {
66
+ if (!transcriptReady) return { fork: true, checked: false };
67
+ const parent = detectForkParent(input.transcriptPath, input.sessionId);
68
+ return {
69
+ fork: true,
70
+ ...(parent && parent !== input.sessionId ? { forkedFrom: parent } : {}),
71
+ checked: true,
72
+ };
73
+ }
74
+
75
+ return {
76
+ fork: false,
77
+ checked: transcriptReady || FRESH_SOURCES.has(input.source ?? ""),
78
+ };
79
+ } catch {
80
+ return { fork: false, checked: true };
81
+ }
82
+ }
@@ -356,6 +356,94 @@ const FORK_SCAN_MAX_BYTES = 128 * 1024 * 1024;
356
356
  /** How many message uuids to sample across the fork's copied prefix. */
357
357
  const FORK_SAMPLE_SIZE = 24;
358
358
 
359
+ /** Tail window scanned for inherited rows; a fork's own rows are few when the
360
+ * scan runs (session start or the first tool call), so the copied history ends
361
+ * well inside it. */
362
+ const INHERITED_TAIL_BYTES = 4 * 1024 * 1024;
363
+ const INHERITED_HEAD_BYTES = 64 * 1024;
364
+ const SESSION_ID_FIELD_RE =
365
+ /"sessionId":"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"/gi;
366
+
367
+ /**
368
+ * Detect inherited history from row session ids: the session whose rows were
369
+ * copied into this transcript, or undefined when every row is this session's.
370
+ *
371
+ * Empirical basis (verified 2026-10-07, Claude Code 2.1.283): the desktop
372
+ * app's "Fork from here" copies the parent's rows into the fork's transcript
373
+ * WITHOUT rewriting their `sessionId`, so the fork's file opens with thousands
374
+ * of rows stamped with the parent's id. The CLI `--fork-session` path rewrites
375
+ * the ids instead and is covered by {@link detectForkParent}.
376
+ *
377
+ * When the history is itself several generations deep, the LAST foreign row is
378
+ * the nearest ancestor, so the tail is scanned first. Whether the foreign
379
+ * session is a fork parent or a dead predecessor (a continuation) is the
380
+ * caller's call. Fail-soft: never throws, undefined on any read problem.
381
+ */
382
+ export function detectInheritedSessionParent(
383
+ transcriptPath: string | undefined,
384
+ sessionId: string,
385
+ ): string | undefined {
386
+ try {
387
+ const readablePath = resolveTranscriptPath(transcriptPath);
388
+ if (!readablePath || !sessionId) return undefined;
389
+ const size = statSync(readablePath).size;
390
+ const fromTail = lastForeignSessionId(
391
+ readRange(readablePath, Math.max(0, size - INHERITED_TAIL_BYTES), size),
392
+ sessionId,
393
+ );
394
+ if (fromTail || size <= INHERITED_TAIL_BYTES) return fromTail;
395
+ return lastForeignSessionId(
396
+ readRange(readablePath, 0, Math.min(size, INHERITED_HEAD_BYTES)),
397
+ sessionId,
398
+ );
399
+ } catch {
400
+ return undefined;
401
+ }
402
+ }
403
+
404
+ function lastForeignSessionId(text: string | undefined, sessionId: string): string | undefined {
405
+ if (!text) return undefined;
406
+ const own = sessionId.toLowerCase();
407
+ const lines = text.split("\n");
408
+ for (let i = lines.length - 1; i >= 0; i--) {
409
+ const line = lines[i]!;
410
+ if (!line.includes('"sessionId":"')) continue;
411
+ let foreign = false;
412
+ for (const m of line.matchAll(SESSION_ID_FIELD_RE)) {
413
+ if (m[1]!.toLowerCase() !== own) foreign = true;
414
+ }
415
+ if (!foreign) continue;
416
+ // Confirm on the parsed row: a nested object can carry another session's
417
+ // id, but only the row's own top-level field says whose row it is. A
418
+ // partial line at a window edge fails to parse and is skipped.
419
+ try {
420
+ const row = JSON.parse(line) as { sessionId?: unknown };
421
+ if (typeof row.sessionId === "string" && row.sessionId.toLowerCase() !== own) {
422
+ return row.sessionId;
423
+ }
424
+ } catch {
425
+ /* torn line */
426
+ }
427
+ }
428
+ return undefined;
429
+ }
430
+
431
+ function readRange(path: string, start: number, end: number): string | undefined {
432
+ try {
433
+ const fs = require("node:fs") as typeof import("node:fs");
434
+ const fd = fs.openSync(path, "r");
435
+ try {
436
+ const buf = Buffer.alloc(Math.max(0, end - start));
437
+ fs.readSync(fd, buf, 0, buf.length, start);
438
+ return buf.toString("utf8");
439
+ } finally {
440
+ fs.closeSync(fd);
441
+ }
442
+ } catch {
443
+ return undefined;
444
+ }
445
+ }
446
+
359
447
  /**
360
448
  * Detect the parent session of a forked CC conversation from its transcript.
361
449
  *
@@ -0,0 +1,103 @@
1
+ import { basename, shellCommandFromTool, tokenize } from "./shell-tokens.ts";
2
+
3
+ /**
4
+ * Deny a shell `rm` and name the guarded replacement. The instructions have
5
+ * said "use <bin> rm instead of shell rm" since the command shipped, and
6
+ * agents still reached for bare rm several times a day, often on unquoted
7
+ * variable paths that Claude Code then stops on for a human prompt. A
8
+ * repeated, machine-detectable failure belongs in a gate, not more prose.
9
+ *
10
+ * This is a guardrail against the reflex, not a security boundary: it reads
11
+ * command positions only (so `git rm`, `docker rm`, and quoted examples pass),
12
+ * plus the `xargs rm`, `find -exec rm`, and `bash -c '...rm...'` shapes.
13
+ * HARNERY_ALLOW_RM=1 on the command is the deliberate override.
14
+ */
15
+
16
+ const OVERRIDE = "HARNERY_ALLOW_RM=1";
17
+ const SEPARATORS = new Set([";", "|", "||", "&", "&&", "(", ")", "{", "}"]);
18
+ const KEYWORDS = new Set(["do", "if", "then", "elif", "else", "!", "while", "until", "time"]);
19
+ const WRAPPERS = new Set(["command", "exec", "builtin", "nohup", "sudo", "doas", "env", "nice"]);
20
+ // xargs options that consume the following token as their value.
21
+ const XARGS_VALUE_FLAGS = new Set(["-n", "-I", "-L", "-P", "-d", "-E", "-s", "-a"]);
22
+
23
+ function isRm(value: string, quoted: boolean): boolean {
24
+ return !quoted && basename(value) === "rm";
25
+ }
26
+
27
+ function invokesRm(source: string, depth = 0): boolean {
28
+ if (depth > 8) return false;
29
+ const tokens = tokenize(source);
30
+ if (tokens.some((t) => !t.quoted && t.value === OVERRIDE)) return false;
31
+ let start = true;
32
+ let afterWrapper = false;
33
+ for (let i = 0; i < tokens.length; i++) {
34
+ const token = tokens[i]!;
35
+ if (token.operator) {
36
+ if (SEPARATORS.has(token.value)) {
37
+ start = true;
38
+ afterWrapper = false;
39
+ }
40
+ continue;
41
+ }
42
+ if (!start) continue;
43
+ if (!token.quoted) {
44
+ if (KEYWORDS.has(token.value)) continue;
45
+ if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(token.value)) continue;
46
+ if (afterWrapper && token.value.startsWith("-")) continue;
47
+ }
48
+ const name = basename(token.value);
49
+ if (WRAPPERS.has(name)) {
50
+ afterWrapper = true;
51
+ continue;
52
+ }
53
+ start = false;
54
+ afterWrapper = false;
55
+ if (isRm(token.value, token.quoted)) return true;
56
+ if (name === "xargs") {
57
+ for (let j = i + 1; j < tokens.length && !tokens[j]!.operator; j++) {
58
+ const arg = tokens[j]!;
59
+ if (arg.value.startsWith("-")) {
60
+ if (XARGS_VALUE_FLAGS.has(arg.value)) j++;
61
+ continue;
62
+ }
63
+ if (isRm(arg.value, arg.quoted)) return true;
64
+ break;
65
+ }
66
+ }
67
+ if (name === "find") {
68
+ for (let j = i + 1; j < tokens.length && !tokens[j]!.operator; j++) {
69
+ if (!/^-(?:exec|execdir|ok|okdir)$/.test(tokens[j]!.value)) continue;
70
+ const next = tokens[j + 1];
71
+ if (next && !next.operator && isRm(next.value, next.quoted)) return true;
72
+ }
73
+ }
74
+ if (["bash", "sh", "dash", "zsh", "ksh"].includes(name)) {
75
+ for (let j = i + 1; j < tokens.length && !tokens[j]!.operator; j++) {
76
+ if (/^-[a-z]*c[a-z]*$/.test(tokens[j]!.value)) {
77
+ const script = tokens[j + 1];
78
+ if (script && !script.operator && invokesRm(script.value, depth + 1)) return true;
79
+ break;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+
87
+ export function shellRmReason(
88
+ toolName: unknown,
89
+ toolInput: unknown,
90
+ binName: string,
91
+ ): string | null {
92
+ const command = shellCommandFromTool(toolName, toolInput);
93
+ if (command === null || !invokesRm(command)) return null;
94
+ return (
95
+ `Shell rm blocked: it skips the resolved-path, Git-ownership, and peer-claim checks, and an ` +
96
+ `unquoted variable path can expand to the wrong target. Use the guarded paths instead: ` +
97
+ `\`${binName} rm --root <directory> <paths...>\` previews removal of untracked files (add ` +
98
+ `--yes to delete, -r for a directory); \`git rm\` removes tracked source; ` +
99
+ `\`${binName} artifacts discard <id>\` retires managed evidence. To replace a file, overwrite ` +
100
+ `it in place or \`mv\` the new file over the old one, so nothing needs deleting. If none of ` +
101
+ `these fit, prefix the command with ${OVERRIDE}.`
102
+ );
103
+ }
@@ -0,0 +1,96 @@
1
+ /** Shared, deliberately small shell lexer for PreToolUse command guards. */
2
+
3
+ export interface Token {
4
+ value: string;
5
+ quoted: boolean;
6
+ operator?: boolean;
7
+ }
8
+
9
+ /** Keep quoted arguments opaque, including examples and heredoc bodies. */
10
+ export function tokenize(source: string): Token[] {
11
+ const out: Token[] = [];
12
+ const heredocs: { end: string; tabs: boolean }[] = [];
13
+ for (let i = 0; i < source.length; ) {
14
+ const ch = source[i]!;
15
+ if (ch === "\\" && source[i + 1] === "\n") {
16
+ i += 2;
17
+ continue;
18
+ }
19
+ if (ch === "\n") {
20
+ out.push({ value: ";", quoted: false, operator: true });
21
+ i++;
22
+ for (const doc of heredocs.splice(0)) {
23
+ while (i < source.length) {
24
+ const end = source.indexOf("\n", i);
25
+ const stop = end === -1 ? source.length : end;
26
+ const line = source.slice(i, stop);
27
+ i = end === -1 ? source.length : end + 1;
28
+ if ((doc.tabs ? line.replace(/^\t+/, "") : line) === doc.end) break;
29
+ }
30
+ }
31
+ continue;
32
+ }
33
+ if (/\s/.test(ch)) {
34
+ i++;
35
+ continue;
36
+ }
37
+ if (ch === "#") {
38
+ const end = source.indexOf("\n", i);
39
+ i = end === -1 ? source.length : end;
40
+ continue;
41
+ }
42
+ if (";|&(){}<>".includes(ch)) {
43
+ const op = source.slice(i).match(/^(?:<<-?|>>|&&|\|\||[;|&(){}<>])/)![0];
44
+ out.push({ value: op, quoted: false, operator: true });
45
+ i += op.length;
46
+ continue;
47
+ }
48
+ let value = "";
49
+ let quoted = false;
50
+ while (i < source.length && !/[\s;|&(){}<>]/.test(source[i]!)) {
51
+ const next = source[i++]!;
52
+ if (next === "'" || next === '"' || next === "`") {
53
+ quoted = true;
54
+ while (i < source.length && source[i] !== next) {
55
+ if (source[i] === "\\" && next !== "'" && i + 1 < source.length) i++;
56
+ value += source[i++]!;
57
+ }
58
+ if (source[i] === next) i++;
59
+ } else if (next === "\\" && i < source.length) {
60
+ const escaped = source[i++]!;
61
+ if (escaped !== "\n") value += escaped;
62
+ } else {
63
+ value += next;
64
+ }
65
+ }
66
+ const previous = out.at(-1)?.value;
67
+ if (previous === "<<" || previous === "<<-") {
68
+ heredocs.push({ end: value, tabs: previous === "<<-" });
69
+ }
70
+ out.push({ value, quoted });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ export function basename(value: string): string {
76
+ return value.split(/[\\/]/).at(-1) ?? value;
77
+ }
78
+
79
+ /** The command string of a Bash-style tool call, or null for any other tool. */
80
+ export function shellCommandFromTool(toolName: unknown, toolInput: unknown): string | null {
81
+ if (typeof toolName !== "string") return null;
82
+ const name = toolName.toLowerCase().replace(/^functions\./, "");
83
+ if (!["bash", "shell", "shell_command", "exec_command"].includes(name)) return null;
84
+ let input = toolInput;
85
+ if (typeof input === "string") {
86
+ try {
87
+ input = JSON.parse(input);
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+ if (!input || typeof input !== "object") return null;
93
+ const args = input as Record<string, unknown>;
94
+ const command = args.command ?? args.cmd;
95
+ return typeof command === "string" ? command : null;
96
+ }