Commit graph

8 commits

Author SHA1 Message Date
Ubuntu
58690da69f feat(safety): PreToolUse hook gating destructive tool calls (FNDG-04b, Option A)
Adds internal/safety/ — the in-repo source of truth for the PreToolUse hook
deployed into every project before a Claude Code agent is launched. The hook
blocks destructive Bash/Edit/Write patterns on sessions running with
--dangerously-skip-permissions, closing the exploitation path where a prompt
injection via MCP sessions.send could otherwise trigger arbitrary destruction
without interactive confirmation.

Wire-up:
- internal/dispatcher/dispatcher.go launchAgent: deploys hook before claude
  launch; fail-closed if deployment fails.
- internal/switcher/account_switcher.go relaunchDedicatedSessions: redeploys
  hook before --resume after account failover; fail-open (log + continue)
  since the initial deployment is still in place.

Blocks (exit 2, stderr shown to model):
- rm -rf targeting /, ~, $HOME, /etc, /var, /usr, /boot
- dd of=/dev/{sd,nvme,disk,hd,mmcblk}*, mkfs*
- git push --force (but allows --force-with-lease)
- git reset --hard on main|master|production
- sudo outside short allowlist (systemctl, journalctl, cp, install, apt*)
- curl|sh, bash <(curl ...), eval "$(curl ...)", fork bomb, crontab -e
- chmod 777 on system paths / home
- Writes to .claude/settings*.json, .claude/hooks/, ~/.ssh/authorized_keys,
  shell rc files, /etc/sudoers*, /etc/systemd/*

Warn-only (logged, not blocked):
- kubectl delete, helm uninstall, terraform destroy
- DROP TABLE, TRUNCATE TABLE, DELETE FROM ... WHERE 1=1

Hook script is embedded via //go:embed so a single binary release carries
the authoritative copy. Every launch rewrites the deployed file with mode
0555 (anti-tamper); the hook itself also blocks writes to .claude/hooks/
for defense in depth.

Decision: Olivier, 2026-04-19 — Option A now, Option C (two pools) tracked
separately. Complements FNDG-04 input sanitization in secuaas-mcp.

Tests: 8 unit/integration tests in internal/safety/, plus a dispatcher-level
test verifying the hook is written before launch. go vet clean, go test ./...
all pass.

Refs: FNDG-04 audit (secuaas-mcp branch audit/mcp-stdio-2026-04-18)
Task:  .agent-queue/inbox/20260418-211102-fndg-04b-*.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 17:48:27 +00:00
Ubuntu
20063b1939 fix(switcher+symlinks): rollback on ensure failure (Bug #1) + requiredShared contract test (Bug #10)
Bug #1 (CRITIQUE) — A3 flip+ensure inconsistency
- Before: EnsureForAccount failure after flip was WARN-only, SetActiveAccount
  still fired → daemon declared target active while shared symlinks were
  absent/divergent → transcripts silently duplicated, resume broken.
- After: ensure failure triggers rollback flip to previous account home;
  if rollback succeeds → explicit error, ActiveAccount stays on previous.
  If rollback ALSO fails → sticky partialSwap flag + ErrPartialSwap; all
  further swaps refused until operator intervention (daemon restart).
- New public IsPartialSwap() for watchdog / health-check integration.

Bug #10 (MOYENNE) — requiredShared contract never exercised
- All existing tests override a.sharedSymlinks with tmpdir-scoped lists,
  so symlinks.RequiredShared itself was never tested. A rename or drop
  would pass every test but silently break prod failover.
- TestRequiredSharedIsCoherent asserts (no filesystem): 3 entries with
  the exact required names, absolute targets, and a single shared parent
  directory (invariant EnsureForAccount depends on).

Tests:
- go test ./... PASS
- go test -race ./... PASS (no data race)
- 2 new switcher tests: TestFlipEnsureFailureTriggersRollback,
  TestFlipEnsureAndRollbackFailure
- 1 new symlinks test: TestRequiredSharedIsCoherent
- 1 obsolete test replaced: TestFlipEnsureSymlinksFailureDoesNotAbortSwap
  (encoded the old buggy best-effort behaviour)
2026-04-16 19:53:48 +00:00
Ubuntu
8eaf0bbd35 feat(switcher): ensure shared symlinks on target home after flip (A3)
Wire symlinks.EnsureForAccount into executeSwitch, called immediately
after the ~/.claude flip. Guarantees the three shared-state links
(session-env, file-history, projects) exist on the target account home
even for freshly-provisioned accounts, preventing silent transcript
duplication and undo-history divergence on first resume.

Best-effort: errors are logged as WARN but never abort the swap. If we
returned here the daemon would be left inconsistent (symlink flipped,
SetActiveAccount never called). Operator sees the warning in logs and
resolves divergent links manually.

Tests:
- TestFlipReconcilesSharedSymlinksOnTargetHome: empty target home gets
  all three links pointing at canonical targets after the flip.
- TestFlipEnsureSymlinksFailureDoesNotAbortSwap: a planted divergent
  link triggers the symlinks-package error; the swap completes anyway
  and the active account is updated.

Hermetic: added AccountSwitcher.sharedSymlinks override so tests scope
the reconcile inside t.TempDir() and never touch
/home/ubuntu/.claude-*-shared. Existing tests migrated to this pattern
and hardcoded /tmp/claude-*-xxxx paths replaced with tmpdirs.

Phase 1 / Chantier A — task A3.
2026-04-16 19:34:03 +00:00
Ubuntu
6b109ed1bc fix(dispatcher): send a lone Enter after the task paste to submit it
Multi-line task bodies arrived in Claude Code as "[Pasted text #N +M lines]"
and sat in the input buffer forever — the trailing Enter that SendKeys
appends to the paste is consumed as a newline inside the paste, not as a
submit. Observed live on ccl-auto-11 (secumon) and ccl-auto-12 (secuops):
prompt visible, agent idle.

- tmux.Client grows a SendEnter(session) method. ExecClient runs
  `tmux send-keys -t <sess> Enter` (no preceding text), which Claude's
  TUI accepts as the explicit submit action after a paste.
- Dispatcher: after SendKeys(msg), sleep 500ms for the paste to register,
  then SendEnter. Same sequence a human would perform.
- Five mockTmux implementations updated (quota, dispatcher, switcher,
  lifecycle, watcher tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:49:59 +00:00
Ubuntu
eb6b74c547 feat(pool): add start_index so manual and auto pools can coexist
Production had two disjoint tmux pools named alike but for different
purposes:
  ccl-0..ccl-9           — manual/interactive sessions (operator)
  ccl-auto-11..ccl-auto-20 — autonomous dispatcher pool

Until now the daemon's loops iterated prefix + 0..Max, so with the
deployed config ("prefix: ccl-auto", min=2, max=10) the dispatcher
looked for sessions "ccl-auto0..ccl-auto9" that never existed, while
the real auto pool ccl-auto-11..20 was invisible. Net effect: no task
was ever dispatched, and killAllPoolSessions fabricated phantom
"ccl-auto0/1" sessions on each swap.

- AutonomousConfig gains StartIndex (yaml start_index, default 0).
  Behaviour is unchanged when StartIndex is 0.
- Monitor, switcher (kill + recreate), dispatcher (findFreeSession),
  and lifecycle (EnsureAll + reconcile) all iterate
  [StartIndex, StartIndex+Max) so the daemon only touches its own
  range and leaves ccl-0..ccl-9 alone.
- Production config updated to prefix: "ccl-auto-", start_index: 11,
  min: 10, max: 10 — covering the 10 real ccl-auto-11..20 sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:39:57 +00:00
Ubuntu
8fdb1937fc feat(switcher): auto-resume dedicated sessions after a swap
When a legitimate quota hit triggered a swap, killAllPoolSessions tore
down the dedicated interactive sessions (ccl-1-conformvault, ccl-2-scanyze)
along with the pool, then recreatePoolSessions re-opened them at a bare
bash prompt. The operator had to manually re-run
  CLAUDE_CONFIG_DIR=<target> claude --dangerously-skip-permissions --resume <uuid>
after every swap, losing whatever conversation was mid-flight.

saveAllSessions only iterates sessions tracked as "working" in state;
user-driven dedicated sessions are rarely in that state so their resume
UUIDs were never saved.

- saveDedicatedUUIDs: capture resume UUID for every configured dedicated
  session regardless of tracked state, before kill.
- relaunchDedicatedSessions(targetHome): after recreate, send a resume
  command on each dedicated session pointing CLAUDE_CONFIG_DIR at the
  target account's home. Missing UUID → leave at shell, no blind launch.
- isValidResumeUUID hardens against a corrupted resume-id.txt.

New TestDedicatedRelaunchAfterSwap verifies end-to-end: pane capture →
UUID persisted → resume command sent with the correct CLAUDE_CONFIG_DIR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:24:38 +00:00
Ubuntu
7c5f8384fa fix(quota): add cooldown + 2-poll confirmation to prevent swap ping-pong
Anthropic HTTP 500 errors surface in the TUI with payloads containing
"rate limit" text, which the monitor was matching against quotaPatterns
and treating as a real 429 quota hit. With no cooldown and no
confirmation, a burst of 500s produced sub-minute ping-pong swaps that
tore down user sessions.

Two-layer fix:
- quota.reactivate_cooldown (already in config, 5m) now gates the
  monitor too — not just the dispatcher. A completed swap suppresses
  further detection for the cooldown window.
- A hit with no parseable reset time is treated as suspected only on
  the first poll; a second consecutive poll is required before
  emitting SwapRequested. Legitimate 429s with "resets in ..." still
  swap instantly on the first detection.

Adds state.RecordSwap / LastSwapInfo for the cooldown, and a
forensic log line on every detection: trigger_session, matched
pattern, 120-char pane snippet.

Tests cover: instant swap with reset, 2-poll confirmation without
reset, and suspected-state reset on recovery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:18:27 +00:00
Ubuntu
9f7da110d2 fix(tests): isolate test symlink manipulation via t.TempDir() to prevent overwriting ~/.claude
Le test TestKillAndRecreatePoolSessions appelait executeSwitch() qui faisait
flipSymlink() sur le VRAI $HOME via os.UserHomeDir(). Resultat: ~/.claude
etait repointe vers une cible /tmp/... qui disparaissait au reboot, rendant
Claude Code inutilisable apres redemarrage.

Fix:
- Ajout du champ AccountSwitcher.homeDir (override pour tests).
- Nouveau helper resolveHomeDir() qui retourne homeDir si defini, sinon
  os.UserHomeDir().
- flipSymlink() et resumeContextDir() utilisent maintenant resolveHomeDir().
- Le test TestKillAndRecreatePoolSessions assigne a.homeDir = t.TempDir()
  avant executeSwitch().

Verifie: go test ./... passe et /home/ubuntu/.claude reste intact.
2026-04-14 23:59:59 +00:00