compact-agent 1.28.0 → 1.28.2

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/bin/ecc-hooks.cjs CHANGED
@@ -19,13 +19,31 @@
19
19
 
20
20
  const checkName = process.argv[2] || '';
21
21
 
22
+ // Parse the tool-input env var. Previously a parse failure silently
23
+ // fell back to `{}`, which makes every per-file check return ok() at
24
+ // its empty-path guard — i.e. a malformed payload was a free bypass.
25
+ // Now: empty/unset is treated as `{}` (legitimate for events that
26
+ // don't carry input, like SessionStart); a NON-empty payload that
27
+ // fails to parse is treated as a security event and the hook
28
+ // blocks. Fail closed > fail open.
29
+ const rawToolInput = process.env.COMPACT_AGENT_TOOL_INPUT || process.env.CROWCODER_TOOL_INPUT || '';
22
30
  let toolInput = {};
23
- try {
24
- toolInput = JSON.parse(process.env.CROWCODER_TOOL_INPUT || '{}');
25
- } catch { /* ignore — leave as {} */ }
31
+ if (rawToolInput.length > 0) {
32
+ try {
33
+ toolInput = JSON.parse(rawToolInput);
34
+ } catch {
35
+ process.stderr.write('[ECC] BLOCKED: tool input env var was not valid JSON.\n');
36
+ process.exit(2);
37
+ }
38
+ }
26
39
 
27
- const tool = process.env.CROWCODER_TOOL || '';
28
- const cwd = process.env.CROWCODER_CWD || process.cwd();
40
+ // Both env-var names accepted. COMPACT_AGENT_* is the post-rebrand
41
+ // primary; CROWCODER_* is the legacy alias kept for back-compat with
42
+ // user-written wrappers that haven't migrated. The parent process
43
+ // exports both via src/hooks.ts so either form works regardless of
44
+ // which the user's shell or wrapper set.
45
+ const tool = process.env.COMPACT_AGENT_TOOL || process.env.CROWCODER_TOOL || '';
46
+ const cwd = process.env.COMPACT_AGENT_CWD || process.env.CROWCODER_CWD || process.cwd();
29
47
 
30
48
  // ── Helpers ─────────────────────────────────────────────
31
49
  function bashCommand() {
@@ -73,6 +91,19 @@ const checks = {
73
91
  if (/(^|\s)--no-gpg-sign(\s|$)/.test(cmd)) {
74
92
  return block('`--no-gpg-sign` bypasses signing — ask the user first.');
75
93
  }
94
+ // `git commit -n` is the short form of --no-verify. The previous
95
+ // regex missed it entirely. Scope the check to `git commit`
96
+ // specifically — `-n` in other git subcommands means different
97
+ // things (e.g. `git log -n 5` = limit count, NOT skip hooks).
98
+ if (/\bgit\s+(?:commit|merge|rebase)\b[^|;&]*\s-n(\s|$)/.test(cmd)) {
99
+ return block('`-n` is the short form of `--no-verify` — fix the failing hook instead.');
100
+ }
101
+ // `-c core.hooksPath=/dev/null` (or similar) disables git hooks
102
+ // entirely without using --no-verify. Catches the documented
103
+ // bypass vector even when --no-verify isn't on the command line.
104
+ if (/\bgit\s+-c\s+core\.hooksPath\s*=/i.test(cmd)) {
105
+ return block('`-c core.hooksPath=…` disables git hooks — fix the failure instead.');
106
+ }
76
107
  return ok();
77
108
  },
78
109
 
@@ -207,7 +238,16 @@ const checks = {
207
238
  if (!fs.existsSync(targetPath)) return ok();
208
239
  } catch { /* if statSync fails, fall through to the normal path */ }
209
240
 
210
- const sessionId = process.env.CROWCODER_SESSION_ID || 'unknown';
241
+ // Sanitize sessionId before joining into a path. Without this,
242
+ // a sessionId of e.g. "../../../.ssh/authorized_keys" would let
243
+ // `path.join` traverse out of the state dir, and the subsequent
244
+ // writeFileSync would overwrite arbitrary files with attacker-
245
+ // controllable JSON (the touched-set array, which can include
246
+ // model-controlled file paths). Allow only [A-Za-z0-9_-], length
247
+ // <=64. Anything else falls back to "unknown" — degraded UX
248
+ // (gateguard tracking won't persist), not a security breach.
249
+ const rawSessionId = process.env.COMPACT_AGENT_SESSION_ID || process.env.CROWCODER_SESSION_ID || '';
250
+ const sessionId = /^[A-Za-z0-9_-]{1,64}$/.test(rawSessionId) ? rawSessionId : 'unknown';
211
251
  const stateDir = pathMod.join(os.homedir(), '.compact-agent', 'state', 'gateguard');
212
252
  const stateFile = pathMod.join(stateDir, `${sessionId}.json`);
213
253
 
@@ -349,7 +389,11 @@ const checks = {
349
389
  const fs = require('fs');
350
390
  const pathMod = require('path');
351
391
  const os = require('os');
352
- const sessionId = process.env.CROWCODER_SESSION_ID || 'unknown';
392
+ // Read both env-var names + sanitize, same pattern as gateguard.
393
+ // Previously only read the legacy CROWCODER_SESSION_ID, which broke
394
+ // for any wrapper that exported only the new COMPACT_AGENT_* names.
395
+ const rawSessionId = process.env.COMPACT_AGENT_SESSION_ID || process.env.CROWCODER_SESSION_ID || '';
396
+ const sessionId = /^[A-Za-z0-9_-]{1,64}$/.test(rawSessionId) ? rawSessionId : 'unknown';
353
397
  const stateDir = pathMod.join(os.homedir(), '.compact-agent', 'state', 'quality-hint');
354
398
  const stateFile = pathMod.join(stateDir, `${sessionId}.json`);
355
399
 
package/dist/export.js CHANGED
@@ -101,7 +101,7 @@ function getExtension(format) {
101
101
  export function saveExport(messages, format) {
102
102
  const formatted = formatMessages(messages, format);
103
103
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
104
- const filename = `crowcoder-export-${timestamp}.${getExtension(format)}`;
104
+ const filename = `compact-agent-export-${timestamp}.${getExtension(format)}`;
105
105
  const filepath = path.join(process.cwd(), filename);
106
106
  fs.writeFileSync(filepath, formatted, 'utf-8');
107
107
  return filepath;
@@ -1 +1 @@
1
- {"version":3,"file":"export.js","sourceRoot":"","sources":["../src/export.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"export.js","sourceRoot":"","sources":["../src/export.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -244,7 +244,7 @@ export function handleSlashCommand(input, config, messages, session, mode) {
244
244
  console.log(d(' ') + c('/history') + d(' — message count & token estimate'));
245
245
  console.log(d(' ') + c('/export [fmt]') + d(' — export conversation (md/json/txt)'));
246
246
  console.log(d(' ') + c('/exit') + d(' — quit (alias: /quit)'));
247
- console.log(d(' ') + c('/walkthrough') + d(' — agent-led tour of Crowcoder (aliases: /tour, /guide)'));
247
+ console.log(d(' ') + c('/walkthrough') + d(' — agent-led tour of compact-agent (aliases: /tour, /guide)'));
248
248
  console.log(d(' ') + c('!<cmd>') + d(' — run shell command directly'));
249
249
  console.log(h('\n ── Productivity hotkeys ──'));
250
250
  console.log(d(' ') + c('Shift+Tab') + d(' — cycle permission modes (ask → auto → yolo)'));
@@ -405,9 +405,19 @@ export function handleSlashCommand(input, config, messages, session, mode) {
405
405
  }
406
406
  return { handled: true };
407
407
  // ── Clear ─────────────────────────────────────────
408
- case '/clear':
408
+ case '/clear': {
409
+ // Also reset the global state that's keyed to the conversation
410
+ // so Shift+F3 / Shift+F1 / F12 don't surface stale data from
411
+ // before the clear. The main REPL loop replaces `messages` via
412
+ // `newMessages`; we just nuke the side-channel globals.
413
+ const g = globalThis;
414
+ g.__crowcoderQueuedInput = '';
415
+ g.__voiceLastFullResponse = null;
416
+ g.__voiceLastChunk = null;
417
+ g.__lastToolCall = null;
409
418
  console.log(chalk.dim(' Conversation cleared.'));
410
419
  return { handled: true, newMessages: [] };
420
+ }
411
421
  // ── Backtrack — rewind to a prior user turn (Codex audit item 4) ──
412
422
  // /back list recent user messages with numbers
413
423
  // /back <n> truncate conversation to BEFORE the nth most-recent
@@ -466,14 +476,23 @@ export function handleSlashCommand(input, config, messages, session, mode) {
466
476
  case '/fork':
467
477
  case '/branch': {
468
478
  const forkName = args.trim() || `fork of ${session.name}`;
469
- // Save the current session under its existing ID first so the
470
- // pre-fork state is recoverable via /resume.
471
- saveSession({ ...session, messages: [...messages] }).catch(() => { });
479
+ // Snapshot the pre-fork state in a SEPARATE object before
480
+ // mutating the live `session` reference — otherwise both save
481
+ // calls write to whichever ID the mutation has currently set,
482
+ // overwriting the original branch and silently breaking the
483
+ // "previous session reachable via /resume" promise. (Bug found
484
+ // by audit; previously the spread happened AFTER `session.id`
485
+ // was reassigned, so both saves landed under the new ID.)
472
486
  const previousId = session.id;
473
- // Mutate the active session in place: new ID + name + timestamps,
474
- // same messages. The REPL keeps running against `session` so
475
- // mutation is enough — no need to plumb a "swap" through the
476
- // return value.
487
+ const previousSnapshot = {
488
+ ...session,
489
+ id: previousId,
490
+ messages: [...messages],
491
+ };
492
+ saveSession(previousSnapshot).catch(() => { });
493
+ // Now mutate the active session in place. The REPL keeps
494
+ // running against `session` so mutation is enough — no need
495
+ // to plumb a swap through the return value.
477
496
  session.id = generateSessionId();
478
497
  session.name = forkName;
479
498
  session.createdAt = new Date().toISOString();
@@ -2100,11 +2119,22 @@ export function handleSlashCommand(input, config, messages, session, mode) {
2100
2119
  // user is testing the pipeline or running under a terminal that strips
2101
2120
  // function keys. Records up to 30s, transcribes, injects as next prompt.
2102
2121
  case '/dictate': {
2103
- const maxSec = parseInt(args, 10) || 30;
2104
- console.log(chalk.dim(` /dictate recording up to ${maxSec}s…`));
2122
+ // Parse + clamp the duration argument.
2123
+ // /dictate 30s default
2124
+ // /dictate 60 → 60s, clamped to [1, 300]
2125
+ // /dictate 0 → 30s (user clearly wanted default or
2126
+ // cancel; previously parseInt(0) || 30
2127
+ // gave 30 silently)
2128
+ // /dictate -5 → 30s (negative is nonsense)
2129
+ // /dictate abc → 30s default
2130
+ const parsed = parseInt(args, 10);
2131
+ const sec = Number.isFinite(parsed) && parsed > 0
2132
+ ? Math.min(300, parsed)
2133
+ : 30;
2134
+ console.log(chalk.dim(` /dictate — recording up to ${sec}s…`));
2105
2135
  // Return as an async-injected prompt; we resolve the recording
2106
2136
  // synchronously here for simplicity (REPL is blocking anyway).
2107
- return { handled: true, injectPrompt: '__DICTATE__' + maxSec };
2137
+ return { handled: true, injectPrompt: '__DICTATE__' + sec };
2108
2138
  }
2109
2139
  // /accessibility — show or toggle the accessibility sub-block
2110
2140
  // /accessibility — print status
@@ -2598,10 +2628,20 @@ async function main() {
2598
2628
  // memory of both Claude Code and Codex CLI ("Esc-Esc to step
2599
2629
  // back"). When the prompt buffer has content, single Esc clears
2600
2630
  // the typed buffer (readline default); Esc-Esc still rewinds
2601
- // only when buffer was empty going in. Mid-stream Esc is handled
2602
- // at the byte level in query.ts dataHandler instead — by the
2603
- // time keypress events fire, the input is already suppressed.
2631
+ // only when buffer was empty going in.
2604
2632
  if (name === 'escape') {
2633
+ // Mid-stream Esc is handled at the byte level in query.ts
2634
+ // dataHandler (where it triggers the steer cancel). The
2635
+ // hotkey-listener Esc branch must explicitly bail when a
2636
+ // turn is in progress, otherwise the user sees BOTH the
2637
+ // steer effect AND the "press Esc again to rewind" hint
2638
+ // print, and a second Esc enqueues /back on top of the
2639
+ // already-cancelled turn. Audit P0.
2640
+ const turnCtl = globalThis.__turnAbortCtl;
2641
+ if (turnCtl && !turnCtl.signal.aborted) {
2642
+ lastEscapeMs = 0;
2643
+ return;
2644
+ }
2605
2645
  const buf = rl.line ?? '';
2606
2646
  if (buf.trim()) {
2607
2647
  // Non-empty buffer: don't intercept, let readline do its
@@ -2614,15 +2654,29 @@ async function main() {
2614
2654
  // Second Esc within window — fire /back.
2615
2655
  lastEscapeMs = 0;
2616
2656
  // Enqueue the slash command as queued input. The REPL loop
2617
- // picks it up + executes /back the same way as if typed.
2657
+ // picks it up + dispatches /back on the next iteration.
2618
2658
  globalThis.__crowcoderQueuedInput = '/back\n';
2619
2659
  announce('Esc-Esc', 'Rewinding to previous user turn.');
2620
- // Nudge readline by writing an empty line so the question
2621
- // resolves and the main loop's queued-input drain fires.
2660
+ // Resolve the pending rl.question() so the main loop
2661
+ // actually moves on. Previously this used stdin.write('\n')
2662
+ // which DOESN'T interrupt readline — it merely adds a
2663
+ // newline to the input stream that readline reads as if
2664
+ // the user typed it, leaving the REPL stuck until the user
2665
+ // pressed Enter manually. emit('line', '') triggers the
2666
+ // 'line' event that rl.question internally listens for,
2667
+ // resolving the promise immediately. (Bug found by audit.)
2622
2668
  try {
2623
- stdin.write('\n');
2669
+ rl.emit('line', '');
2670
+ }
2671
+ catch {
2672
+ // Fallback path if rl.emit isn't accepted for some reason
2673
+ // (e.g. on a future readline version): still nudge via
2674
+ // stdin so the user can recover by pressing any key.
2675
+ try {
2676
+ stdin.write('\n');
2677
+ }
2678
+ catch { /* noop */ }
2624
2679
  }
2625
- catch { /* noop */ }
2626
2680
  return;
2627
2681
  }
2628
2682
  lastEscapeMs = now;