166 Commits

  • fix(hooks): address coderabbit review — use lstatSync for symlink paths
    CodeRabbit major on PR #1898: fs.statSync follows symlinks, so a dangling
    protected symlink (e.g. .eslintrc.js pointing at a missing target) would
    throw ENOENT and be treated as absent — letting an agent "replace" the
    symlink and bypass the protection.
    
    Swap statSync for lstatSync. lstat reports the link node itself regardless
    of whether its target exists, so protected entries that happen to be
    symlinks stay blocked. ENOENT handling is unchanged: only a genuinely
    missing path (no link, no file, no directory) counts as absent.
    
    Add a regression test that creates a dangling symlink at .eslintrc.js and
    verifies the hook still blocks Write. Skips cleanly on platforms/sandboxes
    that disallow symlink creation (EPERM/EACCES).
  • fix(hooks): address greptile review — use statSync for true fail-closed
    Greptile P1 on PR #1898: fs.existsSync internally catches all errors and
    returns false, so the previous try/catch around it was dead code and the
    stated "fail-closed on EACCES" semantics weren't actually delivered. A
    file under a directory with no execute permission would read as absent
    and bypass the guard.
    
    Swap to fs.statSync with explicit ENOENT detection. Only ENOENT flips
    exists to false; every other error code (EACCES, EPERM, ELOOP, etc.)
    leaves exists=true so the modification guard is never silently weakened.
    
    Add a new test "allows first-time creation when the parent directory
    does not exist yet" that exercises the ENOENT path via a non-existent
    parent dir — pins the happy path into the regression suite.
  • fix(hooks): allow first-time creation of protected config files
    The config-protection hook blocks Write/Edit on any basename in the
    PROTECTED_FILES set, regardless of whether the file already exists. The
    hook's stated purpose is to prevent agents from softening rules in an
    existing config — but the same code path also blocks the legitimate
    bootstrap case of scaffolding a linter config into a project that has
    none.
    
    Add an fs.existsSync check inside run(): when the basename matches a
    protected entry and the file does not yet exist on disk, exit 0 and
    let the Write proceed. Keep the exit-2 block for all modifications to
    existing files. Stat errors (EACCES, etc.) fail closed — we treat the
    path as existing so the guard is never silently weakened.
    
    Update the existing "blocks protected config file edits" test to use a
    real temp file so the BLOCK path is still exercised, and add two new
    tests covering:
    
    - first-time creation of eslint.config.mjs is allowed (exit 0, raw
      passthrough, no stderr)
    - Edit against an existing .eslintrc.js is still blocked (exit 2, no
      stdout, BLOCKED message in stderr)
    
    Fixes #1873
  • fix: close block-no-verify bypass holes
    Backport Jamkris's fix for case-insensitive core.hooksPath overrides and the git commit -tn template-path false positive. Verified locally on current main with 25/25 block-no-verify tests and node tests/run-all.js passing 2369/2369.
  • feat: add ECC statusline observability hooks
    Salvages the useful statusline/context monitor work from stale PR #1504 while preserving the current continuous-learning hook runner wiring.
    
    Adds the metrics bridge, context monitor, statusline script, shared cost/session bridge utilities, and tests. Fixes the reviewed false loop-detection hash collision for non-file tools, avoids default-session cost inflation, sanitizes statusline task lookup, and records hook payload session IDs in cost-tracker.
  • fix: port continuous-learning observer fixes
    Ports continuous-learning observer signal, storage, remote normalization, and v1 deprecation fixes onto current main.
  • fix: port hook session and dashboard safety fixes
    Ports suggest-compact session_id isolation and dashboard terminal/document launch safety onto current main.
  • fix(hooks): resolve MCP health-check spawn ENOENT on Windows (#1456)
    * fix(hooks): resolve MCP health-check spawn ENOENT on Windows
    
    On Windows, commands like 'npx' are batch files (npx.cmd) that require
    shell expansion to resolve via PATH. Without shell: true, Node.js
    spawn() fails with ENOENT.
    
    However, absolute paths (e.g. C:\Program Files\nodejs\node.exe) must
    NOT use shell mode because cmd.exe misparses paths containing spaces.
    
    Fix: enable shell mode only for non-absolute commands on Windows, using
    path.isAbsolute() to distinguish. This matches how attemptReconnect()
    already handles the shell option.
    
    Fixes #1455
    
    * fix(hooks): harden Windows shell spawn — validate command for metacharacters
    
    Addresses bot review feedback on PR #1456:
    
    - Add UNSAFE_SHELL_CHARS regex to guard against shell injection when
      needsShell=true: cmd.exe operators (&, |, <, >, ^, %, !, (), ;,
      whitespace) are rejected before shell mode is enabled
    - Add typeof command === 'string' check so path.isAbsolute() cannot
      throw on malformed non-string command values
    - Rename test to 'via PATH resolution' (not Windows-only; runs all platforms)
    - Fix misleading test comment: 'node' resolves via PATH like npx.cmd but
      does not itself use .cmd; comment now accurately reflects the intent
    
    * fix(hooks): kill full process tree on Windows when shell mode is used
    
    When needsShell=true, the spawned child is cmd.exe. Calling child.kill()
    only terminates the shell, leaving the real server process orphaned.
    
    Use taskkill /PID <pid> /T /F on Windows+shell to kill the entire
    process tree rooted at cmd.exe. Fall back to SIGTERM+SIGKILL on all
    other platforms or when shell mode is not active.
    
    * fix(hooks): fall back to child.kill() when taskkill fails
    
    Windows taskkill can fail if it's not on PATH, the process already
    exited, or permissions are denied. Previously the failure was silently
    ignored and no kill signal reached the child.
    
    Now: capture the spawnSync result and fall back to child.kill('SIGKILL')
    on any taskkill error or non-zero status. This still may leak a
    detached server process but at least guarantees the cmd.exe shell is
    signaled.
  • fix: install native Cursor hook and MCP config (#1543)
    * fix: install native cursor hook and MCP config
    
    * fix: avoid false healthy stdio mcp probes
  • Merge pull request #1495 from ratorin/fix/session-end-transcript-path-isolation
    fix(hooks): isolate session-end.js filename using transcript_path UUID (#1494)
  • fix(hooks): wrap SessionStart summary with stale-replay guard (#1536)
    The SessionStart hook injects the most recent *-session.tmp as
    additionalContext labelled only with 'Previous session summary:'.
    After a /compact boundary, the model frequently re-executes stale
    slash-skill invocations it finds inside that summary, re-running
    ARGUMENTS-bearing skills (e.g. /fw-task-new, /fw-raise-pr) with the
    last ARGUMENTS they saw.
    
    Observed on claude-opus-4-7 with ECC v1.9.0 on a firmware project:
    after compaction resume, the model spontaneously re-enters the prior
    skill with stale ARGUMENTS, duplicating GitHub issues, Notion tasks,
    and branches for work that is already merged.
    
    ECC cannot fix Claude Code's skill-state replay across compactions,
    but it can stop amplifying it. Wrap the injected summary in an
    explicit HISTORICAL REFERENCE ONLY preamble with a STALE-BY-DEFAULT
    contract and delimit the block with BEGIN/END markers so the model
    treats everything inside as frozen reference material.
    
    Tests: update the two hooks.test.js cases that asserted on the old
    'Previous session summary' literal to assert on the new guard
    preamble, the STALE-BY-DEFAULT contract, and both delimiters. 219/219
    tests pass locally.
    
    Tracked at: #1534
  • fix(gateguard): rewrite routineBashMsg to use fact-presentation pattern (#1531)
    * fix(gateguard): rewrite routineBashMsg to use fact-presentation pattern
    
    The imperative 'Quote user's instruction verbatim. Then retry.' phrasing
    triggers Claude Code's runtime anti-prompt-injection filter, deadlocking
    the first Bash call of every session. The sibling gates (edit, write,
    destructive) use multi-point fact-list framing that the runtime accepts.
    
    Align routineBashMsg with that pattern to restore the gate's intended
    behavior without changing run(), state schema, or any public API.
    
    Closes #1530
    
    * docs(gateguard): sync SKILL.md routine gate spec with new message format
    
    CodeRabbit flagged that skills/gateguard/SKILL.md still described the
    pre-fix imperative message. Update the Routine Bash Gate section to
    match the numbered fact-list format used by the new routineBashMsg().
  • review: broaden CLAUDE_TRANSCRIPT_PATH fallback to cover missing/empty JSON fields
    Previously the env fallback ran only when JSON.parse threw. If stdin was valid
    JSON but omitted transcript_path or provided a non-string/empty value, the
    script dropped to the getSessionIdShort() fallback path, re-introducing the
    collision this PR targets.
    
    Validate the parsed transcript_path and apply the env-var fallback for any
    unusable value, not just malformed JSON. Matches coderabbit's outside-diff
    suggestion and keeps both input-source paths equivalent.
    
    Refs #1494
  • review: apply sanitizeSessionId to UUID shortId, fix test comment
    - Route the transcript-derived shortId through sanitizeSessionId so the
      fallback and transcript branches remain byte-for-byte equivalent for any
      non-UUID session IDs that still land in CLAUDE_SESSION_ID (greptile P1).
    - Clarify the inline comment in the first regression test: clearing
      CLAUDE_SESSION_ID exercises the transcript_path branch, not the
      getSessionIdShort() fallback (coderabbit P2).
    
    Refs #1494
  • review: address P1/P2 bot feedback on shortId derivation
    - Use last-8 chars of transcript UUID instead of first-8, matching
      getSessionIdShort()'s .slice(-8) convention. Same session now produces the
      same filename whether shortId comes from CLAUDE_SESSION_ID or transcript_path,
      so existing .tmp files are not orphaned on upgrade.
    - Normalize extracted hex prefix to lowercase to avoid case-driven filename
      divergence from sanitizeSessionId()'s lowercase output.
    - Explicitly clear CLAUDE_SESSION_ID in the first regression test so the env
      leak from parent test runs cannot hide the fallback path.
    - Add regression tests for the lowercase-normalization path and for the case
      where CLAUDE_SESSION_ID and transcript_path refer to the same UUID (backward
      compat guarantee).
    
    Refs #1494
  • fix(hooks): isolate session-end.js filename using transcript_path UUID
    When session-end.js runs and CLAUDE_SESSION_ID is unset, getSessionIdShort()
    falls back to the project/worktree name. If any other Stop-hook in the chain
    spawns a claude subprocess (e.g. an AI-summary generator using 'claude -p'),
    the subprocess also fires the full Stop chain and writes to the same project-
    name-based filename, clobbering the parent's valid session summary with a
    summary of the summarization prompt itself.
    
    Fix: when stdin JSON (or CLAUDE_TRANSCRIPT_PATH) provides a transcript_path,
    extract the first 8 hex chars of the session UUID from the filename and use
    that as shortId. Falls back to the original getSessionIdShort() when no
    transcript_path is available, so existing behavior is preserved for all
    callers that do not set it.
    
    Adds a regression test in tests/hooks/hooks.test.js.
    
    Refs #1494
  • Merge pull request #1445 from affaan-m/fix/plugin-installed-hook-root-resolution
    fix: resolve plugin-installed hook root on marketplace installs
  • Merge pull request #1367 from ozoz5/feat/gateguard
    feat(hooks,skills): add gateguard fact-forcing pre-action gate
  • fix: 5 bugs + 2 tests from 3-agent deep bughunt
    Bugs fixed:
    - B1: JS gate messages still said "cat one real record" -> redacted/synthetic
    - B2: Destructive bash key used 200-char truncation (collision bypass) -> SHA256 hash
    - B3: sanitizePath only stripped \n\r -> now strips null bytes, bidi overrides, all control chars
    - B4: Tool name matching was case-sensitive (latent bypass) -> lookup map normalization
    - B5: SKILL.md Gate Types missing MultiEdit -> added with explanation
    
    Tests added:
    - T1: MultiEdit gate denies first unchecked file (CRITICAL - was untested)
    - T2: MultiEdit allows after all files gated
    
    11/11 tests pass.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  • fix: cubic-dev-ai round 2 — 3 issues across SKILL.md + pruning
    P1: Gate message asked for raw production data records — changed to
        "redacted or synthetic values" to prevent sensitive data exfiltration
    
    P2: SKILL.md description now includes MultiEdit (was missing after
        MultiEdit gate was added in previous commit)
    
    P2: Session key pruning now caps __prefixed keys at 50 to prevent
        unbounded growth even in theoretical edge cases
    
    9/9 tests pass.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  • fix: remove unnecessary disk I/O + fix test cleanup
    - isChecked() no longer calls saveState() — read-only operation
      should not write to disk (was causing 3x writes per tool call)
    - Test cleanup uses fs.rmSync(recursive) instead of fs.rmdirSync
      which failed with ENOTEMPTY when .tmp files remained
    
    9/9 tests pass.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  • fix: P1 test state-file PID mismatch + P2 session key eviction
    P1 (cubic-dev-ai): Test process PID differs from spawned hook PID,
    so test was seeding/clearing wrong state file. Fix: pass fixed
    CLAUDE_SESSION_ID='gateguard-test-session' to spawned hooks.
    
    P2 (cubic-dev-ai): Pruning checked array could evict __bash_session__
    and other session keys, causing gates to re-fire mid-session. Fix:
    preserve __prefixed keys during pruning, only evict file-path entries.
    
    9/9 tests pass.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  • fix: MultiEdit gate bypass — handle edits[].file_path correctly
    P1 bug reported by greptile-apps: MultiEdit uses toolInput.edits[].file_path,
    not toolInput.file_path. The gate was silently allowing all MultiEdit calls.
    
    Fix: separate MultiEdit into its own branch that iterates edits array
    and gates on the first unchecked file_path.
    
    9/9 tests pass.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  • fix: session-scoped state to prevent cross-session race
    Addresses reviewer feedback from @affaan-m:
    
    1. State keyed by CLAUDE_SESSION_ID / ECC_SESSION_ID
       - Falls back to pid-based isolation when env vars absent
       - State file: state-{sessionId}.json (was .session_state.json)
    
    2. Atomic write+rename semantics
       - Write to temp file, then fs.renameSync to final path
       - Prevents partial reads from concurrent hooks
    
    3. Bounded checked list (MAX_CHECKED_ENTRIES = 500)
       - Prunes to last 500 entries when cap exceeded
       - Stale session files auto-deleted after 1 hour
    
    9/9 tests pass.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  • Merge pull request #1384 from KeWang0622/fix/lint-md028-eqeqeq
    fix: resolve markdownlint MD028 + ESLint eqeqeq lint failures