Commit Graph

165 Commits

  • fix: address PR #1853 review feedback (force-if-includes, switch, subshell, +refspec)
    Five additional review findings on top of the round-1 tokenizer fix.
    Combined patch surface is small (one push branch, new switch branch,
    exploded subshell handling); all six review issues are now closed.
    
    P1 — --force --force-if-includes still destructive (Greptile, line 217):
      Previous logic treated --force-if-includes as a safety guarantee
      alongside --force-with-lease. Per git-scm.com/docs/git-push,
      --force-if-includes is a no-op WITHOUT --force-with-lease, so a
      combination of --force --force-if-includes is just --force. Push
      branch now treats only --force-with-lease as a lease, and reports
      force when --force / -f is present.
    
    P2 — git switch destructive forms not detected (Greptile, line 234):
      Added a switch branch to isDestructiveGit covering:
        --discard-changes   (explicit discard)
        --force / -f        (ignore conflicts, overwrite)
        -C <branch>         (force-create, overwrites existing branch)
    
    P0 — backtick + $(...) subshell bypass (CodeRabbit, line 64):
      Added explodeSubshells() that promotes `...` and $(...) contents
      to top-level segment separators. Run on both the SQL/dd regex
      input and the per-segment shell tokenizer input. Loops up to 4
      passes to catch a layer of nesting. Without this,
      `echo y | $(rm -rf /tmp)` slipped past the segment splitter
      because the destructive command lived inside a sub-expression.
    
    P0 — +refspec force push (CodeRabbit, line 217):
      `git push origin +main`, `+refs/heads/main:refs/heads/main`, etc.
      force a non-fast-forward update of that specific ref. Push branch
      now also flags any positional arg starting with `+` that matches
      a refspec shape. Excludes bare `+` and numeric-only tokens.
    
    P2 — missing --force --force-if-includes regression test
      (Greptile, line 1202): added.
    
    Tests (+10 on top of the round-1 +10):
      Bypass-now-blocked:
        - git push --force --force-if-includes (force-if-includes is no-op
          without lease — bare force is still in effect)
        - git push origin +main (+refspec bare branch)
        - git push origin +refs/heads/main:refs/heads/main (+refspec full)
        - git switch --discard-changes
        - git switch --force
        - git switch -f (short form)
        - git switch -C (force-create)
        - echo y | `rm -rf /tmp` (backtick subshell)
        - echo y | $(rm -rf /tmp) (dollar-paren subshell)
      Still-allowed:
        - git switch feature (plain)
    
    67/67 in gateguard-fact-force.test.js. 2380/2380 across the full
    suite. yarn lint clean. All seven CI validators pass.
    
    Refs #1843.
  • fix: close gateguard destructive-bash regex bypasses with tokenizer
    Six classes of bypass in scripts/hooks/gateguard-fact-force.js
    DESTRUCTIVE_BASH regex, plus a separate false-positive class.
    Same shape of issue as the block-no-verify holes addressed in
    #1843: a single-regex shell parser can never cover the flag-order
    variations git and rm allow.
    
    Real bypasses observed locally (all ALLOW today, should BLOCK):
    
      git push -f origin main                       (short form of --force)
      git -c core.foo=bar reset --hard              (intervening -c global)
      rm -fr /tmp/junk                              (reverse flag order)
      rm -r -f /tmp/junk                            (split flag form)
      git reset HEAD --hard                         (intervening ref token)
      git clean -fd                                 (combined -f + -d flag)
    
    False positive observed locally (BLOCK today, should ALLOW):
    
      git commit -m "fix: rm -rf race in worker"   (destructive phrase
                                                    inside quoted message)
    
    Behavior fix that comes along: --force-if-includes is now exempted
    alongside --force-with-lease. Both are safety-checked variants;
    the previous regex used a negative lookahead that only spelled out
    --with-lease, so --force-if-includes blocked under the old code
    even though it is the safer-not-harder choice.
    
    Fix shape (mirrors block-no-verify #1843):
    
      - DESTRUCTIVE_SQL_DD regex kept for `drop table`, `delete from`,
        `truncate`, `dd if=` — these are stable keyword phrases. Quoted
        strings are stripped before the regex runs so the phrase is not
        matched inside a commit message body.
      - isDestructiveBash() tokenizes the command into segments at
        unquoted ; | & boundaries, then per segment:
          * isDestructiveRm  — detects `rm` with both r and f set
            across combined or split flag tokens.
          * isDestructiveGit — finds the git subcommand after skipping
            global options (-c key=val, -C path, --git-dir=, etc.),
            then handles reset, checkout --, clean -f*, push --force
            (with --force-with-lease / --force-if-includes exemption),
            commit --amend, and rm -r* preservation.
      - Command tokens go through commandBasename() so /usr/bin/rm,
        rm.exe, and RM all normalize to "rm".
    
    Tests (+10 in tests/hooks/gateguard-fact-force.test.js):
    
      Bypass-now-blocked (7):
        - denies short-form git push -f
        - denies git reset --hard with intervening -c global option
        - denies rm -fr (reverse flag order)
        - denies rm -r -f (split flag form)
        - denies git reset HEAD --hard
        - denies git clean -fd
        - denies destructive command in second chained segment
    
      False-positive-now-allowed (3):
        - allows destructive phrase inside `-m` commit message (rm -rf)
        - allows SQL phrase inside `-m` commit message (drop table)
        - allows --force-if-includes as a safety-checked variant
    
    Local verification:
      yarn lint                                                 clean
      scripts/ci/validate-*  (agents/commands/rules/skills/hooks/
                              install-manifests/no-personal-paths)  pass
      node tests/run-all.js                              2380/2380 pass
    
    Caveat (unrelated): yarn test still fails at check-unicode-safety
    on skills/windows-desktop-e2e/SKILL.md (U+2605) per #1843's
    caveat — independent of this change.
    
    Provenance: discovered during a security pass on ECC after PR
    #1843 (block-no-verify shell-words rewrite) landed. Same class of
    regex-based shell parser issue, same shape of fix.
    
    Refs #1843.
  • 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
  • Merge pull request #1385 from KeWang0622/fix/block-no-verify-hook
    fix: route block-no-verify hook through run-with-flags.js