ccsniff 1.1.21 → 1.1.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccsniff",
3
- "version": "1.1.21",
3
+ "version": "1.1.23",
4
4
  "description": "Watch Claude Code JSONL output files and emit structured events as a Node.js EventEmitter",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/cli.js CHANGED
@@ -31,7 +31,7 @@ const FLAGS = {
31
31
  string: ['since', 'until', 'before', 'after', 'grep', 'igrep', 'cwd', 'project', 'role', 'type', 'tool', 'session', 'sid', 'sess', 'parent', 'rollup', 'format', 'sort', 'unsloth', 'unsloth-format', 'exclude-sess', 'exclude-sid', 'exclude-cwd', 'exclude-project'],
32
32
  multi: ['grep', 'igrep', 'role', 'type', 'tool', 'session', 'sid', 'project', 'cwd', 'exclude-sess', 'exclude-sid', 'exclude-cwd', 'exclude-project'],
33
33
  number: ['limit', 'head', 'tail-n', 'ctx', 'truncate', 'days'],
34
- bool: ['json', 'ndjson', 'tail', 'f', 'full', 'reverse', 'invert', 'no-subagents', 'only-subagents', 'no-meta', 'only-meta', 'list-sessions', 'list-projects', 'list-tools', 'bash-discipline', 'git-discipline', 'search-discipline', 'glyph-discipline', 'continuation-discipline', 'learning-xref', 'include-subagents', 'stats', 'count', 'help', 'h'],
34
+ bool: ['json', 'ndjson', 'tail', 'f', 'full', 'reverse', 'invert', 'no-subagents', 'only-subagents', 'no-meta', 'only-meta', 'list-sessions', 'list-projects', 'list-tools', 'bash-discipline', 'git-discipline', 'search-discipline', 'glyph-discipline', 'continuation-discipline', 'verb-bypass-discipline', 'spool-discipline', 'learning-xref', 'include-subagents', 'stats', 'count', 'help', 'h'],
35
35
  };
36
36
 
37
37
  function parseArgs(argv) {
@@ -74,6 +74,11 @@ USAGE
74
74
  ccsniff --continuation-discipline [--stats] assistant turn that ends in prose with no tool call:
75
75
  a summary, or deferred intent ("Let me X" / "I'll X" / "Now to")
76
76
  as the final sentence — the toolless-turn stop (paper §38)
77
+ ccsniff --verb-bypass-discipline [--stats] a platform-native tool used where a plugkit verb exists:
78
+ WebFetch/WebSearch->fetch, Task-search->codesearch,
79
+ raw puppeteer/chrome->browser, platform-memory Write->memorize-fire
80
+ ccsniff --spool-discipline [--stats] a spool request written to in/<verb>/ but never read back from
81
+ out/ (the Write-alone-is-not-a-dispatch non-dispatch)
77
82
  ccsniff --stats [filters]
78
83
 
79
84
  TIME (any ISO date, epoch ms, or relative Ns/Nm/Nh/Nd/Nw)
@@ -252,6 +257,17 @@ if (opts['list-projects']) {
252
257
  process.exit(0);
253
258
  }
254
259
 
260
+ // Each --*-discipline below is its own one-shot report ending in process.exit(0), so only the
261
+ // first requested discipline in source order would ever run. Combining flags (e.g.
262
+ // `--git-discipline --search-discipline`) would silently drop every discipline but the first and
263
+ // yield a false-clean audit. Fail loud instead of running one and dropping the rest.
264
+ const DISCIPLINE_FLAGS = ['bash-discipline', 'git-discipline', 'verb-bypass-discipline', 'spool-discipline', 'search-discipline', 'glyph-discipline', 'continuation-discipline'];
265
+ const requestedDisciplines = DISCIPLINE_FLAGS.filter(d => opts[d]);
266
+ if (requestedDisciplines.length > 1) {
267
+ process.stderr.write(`ccsniff: ${requestedDisciplines.length} discipline flags given (${requestedDisciplines.map(d => '--' + d).join(' ')}); each is a separate one-shot report and only the first would run. Invoke one discipline per call.\n`);
268
+ process.exit(2);
269
+ }
270
+
255
271
  // ---------- bash-discipline (flag Bash calls that should have been Read/Glob/Grep/dispatch)
256
272
  if (opts['bash-discipline']) {
257
273
  // discipline is about MY tool routing, not subagents — they have separate prompts/contexts.
@@ -375,6 +391,96 @@ if (opts['git-discipline']) {
375
391
  process.exit(0);
376
392
  }
377
393
 
394
+ // ---------- verb-bypass-discipline (a platform-native capability used where a plugkit verb exists)
395
+ // The class rule: every platform-native tool that has a plugkit verb is forbidden in favor of the
396
+ // verb — WebFetch/WebSearch -> the `fetch` verb; a Task/Agent search subagent -> `codesearch`; raw
397
+ // puppeteer/playwright/chrome -> the `browser` verb; a Write into a platform memory dir -> `memorize-fire`.
398
+ // High-precision per-tool patterns; each violation names the verb it should have used.
399
+ if (opts['verb-bypass-discipline']) {
400
+ const includeSubagents = opts['include-subagents'];
401
+ const MEM_PATH = /[\/\\]\.(?:claude[\/\\]projects[\/\\].*[\/\\]memory|codex[\/\\]memory|cursor)[\/\\]/i;
402
+ const RAW_BROWSER = /\b(?:puppeteer|playwright|chromium|chrome\.exe|google-chrome|chrome-headless)\b|--headless\b/i;
403
+ const TASK_SEARCH = /\b(?:where is|what calls|locate the|search the (?:code|repo|codebase|tree)|grep the|explore the (?:code|repo|tree|codebase)|find (?:the )?(?:definition|usages?|references?|callers?|where))\b/i;
404
+ const violations = [];
405
+ for (const ev of all) {
406
+ if (!filter(ev)) continue;
407
+ if (ev.block?.type !== 'tool_use') continue;
408
+ if (!includeSubagents && ev.conversation?.isSubagent) continue;
409
+ const name = ev.block?.name || '';
410
+ const input = ev.block?.input || {};
411
+ let kind = null, should = null, detail = '';
412
+ if (name === 'WebFetch') { kind = 'webfetch-not-fetch-verb'; should = 'fetch'; detail = String(input.url || '').slice(0, 120); }
413
+ else if (name === 'WebSearch') { kind = 'websearch-not-fetch-verb'; should = 'fetch'; detail = String(input.query || '').slice(0, 120); }
414
+ else if ((name === 'Task' || name === 'Agent') && TASK_SEARCH.test(stripQuoted(JSON.stringify(input)).slice(0, 600))) { kind = 'task-search-not-codesearch'; should = 'codesearch'; detail = String(input.description || input.prompt || '').slice(0, 120); }
415
+ else if (name === 'Bash' && RAW_BROWSER.test(stripQuoted(input.command || ''))) { kind = 'raw-browser-not-browser-verb'; should = 'browser'; detail = String(input.command || '').slice(0, 120); }
416
+ else if ((name === 'Write' || name === 'Edit' || name === 'NotebookEdit') && MEM_PATH.test(input.file_path || input.path || input.notebook_path || '')) { kind = 'platform-memory-not-memorize'; should = 'memorize-fire'; detail = String(input.file_path || input.path || input.notebook_path || '').slice(0, 120); }
417
+ if (!kind) continue;
418
+ violations.push({ ts: ev.timestamp, sid: ev.conversation.id, project: path.basename(ev.conversation.cwd || ''), kind, should, detail });
419
+ }
420
+ const byKind = new Map();
421
+ for (const v of violations) byKind.set(v.kind, (byKind.get(v.kind) || 0) + 1);
422
+ if (opts.stats || opts.count) {
423
+ if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
424
+ process.stdout.write(`# ${violations.length} verb-bypass-discipline violations\n`);
425
+ for (const [k, c] of [...byKind.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${k}\n`);
426
+ const byProj = new Map();
427
+ for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
428
+ process.stdout.write(`# by project\n`);
429
+ for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
430
+ process.exit(0);
431
+ }
432
+ for (const v of violations) {
433
+ process.stdout.write(`${new Date(v.ts).toISOString().slice(0, 19)} ${v.sid.slice(0, 8)} ${v.kind.padEnd(28)} [${v.project}] use:${v.should} ${v.detail}\n`);
434
+ }
435
+ process.stderr.write(`# ${violations.length} violations (${[...byKind.entries()].map(([k, c]) => `${k}:${c}`).join(' ')})\n`);
436
+ process.exit(0);
437
+ }
438
+
439
+ // ---------- spool-discipline (a session that dispatches spool requests but reads NO responses)
440
+ // "The Write alone is not a dispatch." A session that writes `.gm/exec-spool/in/<verb>/<N>.txt`
441
+ // requests and reads ZERO `out/<...>.json` responses is fabricating the chain from prose — it never
442
+ // observed a single plugkit response. Session-level by design: batching (write many, read the
443
+ // first/last) is endorsed, so reading even one out/ response clears the session. Only a session that
444
+ // reads none of its responses is flagged — high precision, no batching false-positive.
445
+ if (opts['spool-discipline']) {
446
+ const includeSubagents = opts['include-subagents'];
447
+ const SPOOL_IN_WRITE = /(?:>\s*[^>|]*|file_path["'\s:]+["']?[^"']*)\.gm[\/\\]exec-spool[\/\\]in[\/\\][a-z0-9_-]+[\/\\]\d+\./i;
448
+ const SPOOL_OUT = /\.gm[\/\\]exec-spool[\/\\]out[\/\\]/i;
449
+ const sess = new Map();
450
+ for (const ev of all) {
451
+ if (!filter(ev)) continue;
452
+ if (ev.block?.type !== 'tool_use') continue;
453
+ if (!includeSubagents && ev.conversation?.isSubagent) continue;
454
+ const sid = ev.conversation.id;
455
+ if (!sess.has(sid)) sess.set(sid, { writes: 0, reads: 0, project: path.basename(ev.conversation.cwd || ''), firstTs: ev.timestamp, lastTs: ev.timestamp });
456
+ const s = sess.get(sid);
457
+ s.lastTs = ev.timestamp;
458
+ const b = ev.block, inp = b.input || {};
459
+ const blob = b.name === 'Write' ? (inp.file_path || '') : (b.name === 'Bash' ? (inp.command || '') : '');
460
+ if (blob && SPOOL_IN_WRITE.test(blob)) s.writes++;
461
+ const rblob = b.name === 'Read' ? (inp.file_path || '') : (b.name === 'Bash' ? (inp.command || '') : '');
462
+ if (rblob && SPOOL_OUT.test(rblob)) s.reads++;
463
+ }
464
+ const violations = [];
465
+ for (const [sid, s] of sess) {
466
+ if (s.writes >= 1 && s.reads === 0) violations.push({ ts: s.lastTs, sid, project: s.project, writes: s.writes });
467
+ }
468
+ if (opts.stats || opts.count) {
469
+ if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
470
+ process.stdout.write(`# ${violations.length} spool-discipline violations (session dispatched spool writes but read 0 responses)\n`);
471
+ const byProj = new Map();
472
+ for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
473
+ process.stdout.write(`# by project\n`);
474
+ for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
475
+ process.exit(0);
476
+ }
477
+ for (const v of violations) {
478
+ process.stdout.write(`${new Date(v.ts).toISOString().slice(0, 19)} ${v.sid.slice(0, 8)} spool-writes-no-reads [${v.project}] writes:${v.writes} reads:0\n`);
479
+ }
480
+ process.stderr.write(`# ${violations.length} sessions dispatched spool writes but read 0 responses\n`);
481
+ process.exit(0);
482
+ }
483
+
378
484
  // ---------- search-discipline (flag native search that should have been codesearch/recall)
379
485
  // A native-search bypass (Grep/Glob, the Explore/Task search subagent, or bash grep/rg/find/ag)
380
486
  // emits NO plugkit deviation because it never touches the spool — it is invisible to gmsniff and
@@ -7,7 +7,7 @@ const isAbs = (d) => d.startsWith('/') || /^[a-z]:/.test(d);
7
7
  export function targetsOutsideCwd(line, cwd) {
8
8
  const cwdN = normPath(cwd);
9
9
  if (!cwdN) return false;
10
- const stripped = stripQuoted(line);
10
+ const stripped = stripQuoted(line).replace(/\\/g, '/');
11
11
  const ctxM = stripped.match(/(?:^|[|&;]\s*)(?:cd|pushd)\s+([^\s|&;]+)/i) || stripped.match(/\bgit\s+-C\s+([^\s|&;]+)/i);
12
12
  if (ctxM) { const d = normPath(ctxM[1]); if (isAbs(d) && !d.startsWith(cwdN)) return true; }
13
13
  const absArgs = stripped.match(/(?:^|\s)((?:[a-z]:)?\/[^\s|&;"']+)/gi) || [];