jettypod 4.3.0 → 4.4.1

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.
@@ -0,0 +1,196 @@
1
+ # Security Audit: Catastrophic Codebase Deletion
2
+
3
+ **Date**: 2025-11-25
4
+ **Context**: Codebase was deleted while working on "chores not requiring a parent" feature
5
+ **Symptom**: Shell became stuck, entire codebase was deleted
6
+
7
+ ---
8
+
9
+ ## Executive Summary
10
+
11
+ The JettyPod codebase contains multiple unsafe deletion patterns that, when combined with incorrect path resolution, can cause catastrophic data loss. The root cause is **79 instances of `fs.rmSync()` with `{ recursive: true, force: true }` that lack path validation**.
12
+
13
+ ---
14
+
15
+ ## Phase 1: Immediate Fixes (Critical)
16
+
17
+ ### 1.1 Add Safe Delete Wrapper
18
+
19
+ - [x] Create `lib/safe-delete.js` with path validation
20
+ - [x] Validate target path is within expected boundaries
21
+ - [x] Reject deletion of repository root, home directory, or system paths
22
+ - [x] Log all deletion operations for forensic analysis
23
+ - [x] Never use `force: true` - let errors surface
24
+
25
+ ### 1.2 Fix `removeDirectoryResilient()`
26
+
27
+ Location: `lib/worktree-manager.js:477-536`
28
+
29
+ - [x] Add path validation before any deletion stage
30
+ - [x] Ensure `dirPath` starts with `.jettypod-work/`
31
+ - [x] Add explicit check that `dirPath !== gitRoot`
32
+ - [x] Replace direct `fs.rmSync()` with safe wrapper
33
+
34
+ ### 1.3 Fix Test Cleanup Script
35
+
36
+ Location: `scripts/test-cleanup.js`
37
+
38
+ - [x] Only delete worktrees/branches that match test naming patterns
39
+ - [x] Add dry-run mode that logs what would be deleted
40
+ - [x] Add protected branches list (main, master, develop, staging, production)
41
+
42
+ ---
43
+
44
+ ## Phase 2: Path Resolution Hardening (High Priority)
45
+
46
+ ### 2.1 Eliminate Dangerous `process.cwd()` Usage
47
+
48
+ - [ ] Audit all 94 instances of `process.cwd()` for deletion contexts
49
+ - [ ] Pass explicit `repoPath` to all functions
50
+ - [ ] Add safety checks that reject operations if `cwd` is inside `.jettypod-work/`
51
+ - [ ] Cache and validate the main repo path at startup
52
+
53
+ ### 2.2 Add Worktree Detection Guard
54
+
55
+ - [x] Create `ensureNotInWorktree()` helper function (in safe-delete.js)
56
+ - [x] Add guard to createWorktree() in `worktree-manager.js`
57
+ - [x] cleanupWorktree() intentionally DOES NOT have this guard - the merge workflow runs from within worktrees. Safety comes from explicit repoPath requirement + path validation in removeDirectoryResilient()
58
+
59
+ ---
60
+
61
+ ## Phase 3: Deletion Audit Trail (Medium Priority)
62
+
63
+ ### 3.1 Create Deletion Logger
64
+
65
+ - [x] Add logging to `lib/safe-delete.js`
66
+ - [x] Log timestamp, path, caller stack trace, success/failure
67
+ - [x] Write to `.jettypod/deletion-log.json`
68
+
69
+ ### 3.2 Add Pre-Delete Snapshot
70
+
71
+ - [x] Count files/directories before recursive delete (countContents in safe-delete.js)
72
+ - [x] If count exceeds threshold (default: 100), require force flag
73
+ - [x] Log manifest of what will be deleted (count logged in deletion log)
74
+
75
+ ---
76
+
77
+ ## Phase 4: Test Infrastructure Safety (Medium Priority)
78
+
79
+ ### 4.1 Isolate Test Deletions to `/tmp/`
80
+
81
+ - [ ] Validate all test cleanup paths match `/tmp/jettypod-test-*`
82
+ - [ ] Update `lib/test-helpers.js` to enforce boundary
83
+ - [ ] Update all step definition files to use safe patterns
84
+
85
+ ### 4.2 Add Test Environment Validation
86
+
87
+ - [ ] Verify `NODE_ENV === 'test'` before test cleanup
88
+ - [ ] Verify current directory is a test directory
89
+ - [ ] Verify no production database exists in path
90
+
91
+ ### 4.3 Fix Cucumber After Hooks
92
+
93
+ Location: `test-templates.hidden/hooks.js`
94
+
95
+ - [ ] Validate all tracked paths are within `/tmp/`
96
+ - [ ] Clear tracking on test failure to prevent partial cleanup
97
+
98
+ ---
99
+
100
+ ## Phase 5: Backup & Recovery (Low Priority)
101
+
102
+ ### 5.1 Enhance Automatic Backups
103
+
104
+ - [x] Create backup before ANY destructive operation (existing behavior preserved)
105
+ - [x] Store backups outside the repository (new `options.global` flag stores in `~/.jettypod-backups/`)
106
+ - [x] Enhanced listBackups to show both local and global backups
107
+
108
+ ### 5.2 Add Deletion Undo
109
+
110
+ - [x] Implement `.jettypod-trash/` directory
111
+ - [x] Move deleted files to trash instead of immediate delete (`moveToTrash()` in safe-delete.js)
112
+ - [x] Auto-purge after 24 hours (`purgeOldTrash()` with configurable hours)
113
+ - [x] Add `jettypod undelete` command
114
+ - [x] Add `jettypod trash` command (list, empty, purge subcommands)
115
+
116
+ ---
117
+
118
+ ## Files Requiring Changes
119
+
120
+ | Status | File | Risk Level | Changes Needed |
121
+ |--------|------|------------|----------------|
122
+ | [x] | `lib/safe-delete.js` | NEW | Create safe deletion wrapper |
123
+ | [x] | `lib/worktree-manager.js` | CRITICAL | Add path validation to all delete operations |
124
+ | [x] | `scripts/test-cleanup.js` | CRITICAL | Restrict to test-pattern paths only |
125
+ | [x] | `lib/jettypod-backup.js` | HIGH | Validate paths before delete |
126
+ | [x] | `lib/test-helpers.js` | HIGH | Ensure cleanup only targets `/tmp/` |
127
+ | [x] | `jettypod.js` (line 908) | HIGH | Validate `skillsDestDir` before delete |
128
+ | [x] | `test-templates.hidden/hooks.js` | MEDIUM | Validate tracked paths |
129
+
130
+ ---
131
+
132
+ ## Dangerous Code Patterns Found
133
+
134
+ ### Pattern 1: Unvalidated Recursive Delete
135
+ ```javascript
136
+ // DANGEROUS - no path validation
137
+ fs.rmSync(dirPath, { recursive: true, force: true });
138
+ ```
139
+
140
+ ### Pattern 2: Force Flag Suppresses Errors
141
+ ```javascript
142
+ // DANGEROUS - errors are silently ignored
143
+ fs.rmSync(path, { force: true });
144
+ ```
145
+
146
+ ### Pattern 3: Path from Untrusted Source
147
+ ```javascript
148
+ // DANGEROUS - process.cwd() can be manipulated
149
+ const targetPath = path.join(process.cwd(), 'some-dir');
150
+ fs.rmSync(targetPath, { recursive: true });
151
+ ```
152
+
153
+ ### Pattern 4: Cleanup Without Boundaries
154
+ ```javascript
155
+ // DANGEROUS - deletes ALL worktrees, not just test ones
156
+ for (const worktree of worktrees) {
157
+ execSync(`git worktree remove --force "${worktree}"`);
158
+ }
159
+ ```
160
+
161
+ ---
162
+
163
+ ## Root Cause Analysis
164
+
165
+ The catastrophic deletion likely occurred through this sequence:
166
+
167
+ 1. User was modifying chore validation in `features/work-tracking/index.js`
168
+ 2. A test run or command was executed
169
+ 3. Path resolution returned unexpected value (possibly due to worktree context)
170
+ 4. `fs.rmSync()` with `{ recursive: true, force: true }` executed on wrong path
171
+ 5. `force: true` suppressed all warnings
172
+ 6. Shell appeared "stuck" while deleting thousands of files
173
+ 7. By the time the operation completed, codebase was gone
174
+
175
+ ---
176
+
177
+ ## Implementation Order
178
+
179
+ - [x] **Step 1**: Create `lib/safe-delete.js` wrapper
180
+ - [x] **Step 2**: Patch `removeDirectoryResilient()` with path validation
181
+ - [x] **Step 3**: Fix `scripts/test-cleanup.js`
182
+ - [x] **Step 4**: Add worktree detection guard to destructive operations
183
+ - [x] **Step 5**: Implement deletion audit trail
184
+ - [x] **Step 6**: Fix remaining unsafe `fs.rmSync()` instances (production code fixed; safeTestCleanup helper created for test files)
185
+ - [x] **Step 7**: Enhanced backup system (global backups in ~/.jettypod-backups/)
186
+ - [x] **Step 8**: Deletion undo/trash functionality (`.jettypod-trash/`, `jettypod trash`, `jettypod undelete`)
187
+
188
+ ---
189
+
190
+ ## Testing the Fixes
191
+
192
+ - [ ] Attempt to delete repository root - should be blocked
193
+ - [ ] Attempt to delete from within worktree - should be blocked
194
+ - [ ] Attempt to delete path outside repo - should be blocked
195
+ - [ ] Run test suite - should only clean up `/tmp/` paths
196
+ - [ ] Check deletion log after operations - should show audit trail
package/TEST_HOOK.md ADDED
@@ -0,0 +1 @@
1
+ # Test commit to verify post-merge hook
package/docs/DECISIONS.md CHANGED
@@ -6,61 +6,13 @@ This document records key decisions made during project discovery and epic plann
6
6
 
7
7
  ## Epic-Level Decisions
8
8
 
9
- ### Epic #237: Real-time Collaboration
9
+ ### Epic #2: Standalone Chore Workflow System
10
10
 
11
- **Architecture:** WebSockets with Socket.io
11
+ **Architecture:** Two-tier skill structure (chore-planning → chore-mode)
12
12
 
13
- *Rationale:* Bidirectional communication needed, Socket.io provides automatic fallbacks and reconnection
13
+ *Rationale:* Two-tier skill structure chosen to mirror existing feature workflow patterns. Separates planning concerns (scope, criteria, impact) from execution concerns (guidance, verification). Type-dependent test analysis balances thoroughness with pragmatism.
14
14
 
15
- *Date:* 10/29/2025
16
-
17
- **State Management:** Redux Toolkit
18
-
19
- *Rationale:* Need centralized state for connection status across multiple components
20
-
21
- *Date:* 10/29/2025
22
-
23
- **Error Handling:** Exponential backoff with jitter for reconnection
24
-
25
- *Rationale:* Prevents thundering herd when server recovers, jitter distributes reconnection attempts evenly
26
-
27
- *Date:* 10/31/2025
28
-
29
- **Testing Strategy:** Integration tests with mock Socket.io server
30
-
31
- *Rationale:* Allows testing reconnection logic and state synchronization without real server dependency
32
-
33
- *Date:* 10/31/2025
34
-
35
- ---
36
-
37
- ### Epic #238: Real-time System
38
-
39
- **Architecture:** WebSockets
40
-
41
- *Rationale:* Real-time bidirectional communication required
42
-
43
- *Date:* 10/29/2025
44
-
45
- ---
46
-
47
- ### Epic #242: Epic Needs Discovery
48
-
49
- **Architecture:** GraphQL API
50
-
51
- *Rationale:* Flexible querying and strong typing needed for complex data requirements
52
-
53
- *Date:* 10/29/2025
54
-
55
- ---
56
-
57
- ### Epic #1840: Multiple AI Coding Assistant Instances
58
-
59
- **Concurrent Merge Architecture:** Database-backed merge queue with rebase-first strategy and smart conflict resolution
60
-
61
- *Rationale:* Multiple Claude Code instances need coordinated access to main branch. Using database as coordination point provides simple, reliable serialization without complex distributed locking. Rebase-first strategy ensures linear history and makes conflicts easier to reason about. Smart conflict resolution (auto-resolve trivial, LLM-assist complex) enables autonomous operation while escalating truly ambiguous cases. Graceful degradation ensures system never enters unrecoverable state.
62
-
63
- *Date:* 11/14/2025
15
+ *Date:* 11/25/2025
64
16
 
65
17
  ---
66
18
 
package/jettypod.js CHANGED
@@ -8,6 +8,7 @@ const config = require('./lib/config');
8
8
  // CRITICAL: Calculate and cache the REAL git root BEFORE any worktree operations
9
9
  // This prevents catastrophic bugs where process.cwd() returns a worktree path
10
10
  const { getGitRoot } = require('./lib/git-root');
11
+ const safeDelete = require('./lib/safe-delete');
11
12
  let GIT_ROOT;
12
13
  try {
13
14
  GIT_ROOT = getGitRoot();
@@ -16,6 +17,66 @@ try {
16
17
  GIT_ROOT = process.cwd();
17
18
  }
18
19
 
20
+ /**
21
+ * Format bytes to human-readable string
22
+ */
23
+ function formatBytes(bytes) {
24
+ if (bytes === 0) return '0 B';
25
+ const k = 1024;
26
+ const sizes = ['B', 'KB', 'MB', 'GB'];
27
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
28
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
29
+ }
30
+
31
+ /**
32
+ * Detect if project has existing code (mid-project adoption)
33
+ * Checks for common code directories and package.json dependencies
34
+ */
35
+ function hasExistingCode() {
36
+ // Check for common code directories with actual code files
37
+ const codeDirectories = ['src', 'lib', 'app'];
38
+ const hasCodeDir = codeDirectories.some(dir => {
39
+ if (!fs.existsSync(dir)) {
40
+ return false;
41
+ }
42
+
43
+ // Check if directory contains any code files (not just .gitkeep)
44
+ try {
45
+ const files = fs.readdirSync(dir, { recursive: true, withFileTypes: true });
46
+ const codeFiles = files.filter(file => {
47
+ if (file.isDirectory()) return false;
48
+ // Exclude .gitkeep and other dotfiles
49
+ if (file.name.startsWith('.')) return false;
50
+ return true;
51
+ });
52
+ return codeFiles.length > 0;
53
+ } catch (e) {
54
+ // Permission denied or other error - treat as no code
55
+ return false;
56
+ }
57
+ });
58
+
59
+ if (hasCodeDir) {
60
+ return true;
61
+ }
62
+
63
+ // Check for package.json with dependencies
64
+ if (fs.existsSync('package.json')) {
65
+ try {
66
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
67
+ const hasDeps = (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) ||
68
+ (pkg.devDependencies && Object.keys(pkg.devDependencies).length > 0);
69
+ if (hasDeps) {
70
+ return true;
71
+ }
72
+ } catch (e) {
73
+ // Invalid package.json - treat as no code
74
+ }
75
+ }
76
+
77
+ return false;
78
+ }
79
+
19
80
  /**
20
81
  * Ensure jettypod-specific paths are gitignored and untracked
21
82
  * Called during init and update to fix existing projects
@@ -24,7 +85,8 @@ function ensureJettypodGitignores() {
24
85
  const gitignorePath = path.join(process.cwd(), '.gitignore');
25
86
  const entriesToIgnore = [
26
87
  '.claude/session.md',
27
- '.jettypod-work/'
88
+ '.jettypod-work/',
89
+ '.jettypod-trash/'
28
90
  ];
29
91
 
30
92
  // Add entries to .gitignore if not present
@@ -525,29 +587,33 @@ ${communicationStyle}`;
525
587
 
526
588
  detectTechStack() {
527
589
  const stacks = [];
528
-
590
+
529
591
  if (fs.existsSync('package.json')) {
530
- const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
531
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
532
-
533
- // Detect major frameworks
534
- if (deps.react) stacks.push('React');
535
- if (deps.vue) stacks.push('Vue');
536
- if (deps.angular) stacks.push('Angular');
537
- if (deps.svelte) stacks.push('Svelte');
538
- if (deps.next) stacks.push('Next.js');
539
- if (deps.express) stacks.push('Express');
540
- if (deps.fastify) stacks.push('Fastify');
541
- if (deps.nestjs) stacks.push('NestJS');
542
-
543
- // Testing frameworks
544
- if (deps.jest) stacks.push('Jest');
545
- if (deps.mocha) stacks.push('Mocha');
546
- if (deps.vitest) stacks.push('Vitest');
547
- if (deps.cucumber) stacks.push('Cucumber');
548
-
549
- // Node.js is implied
550
- stacks.unshift('Node.js');
592
+ try {
593
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
594
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
595
+
596
+ // Detect major frameworks
597
+ if (deps.react) stacks.push('React');
598
+ if (deps.vue) stacks.push('Vue');
599
+ if (deps.angular) stacks.push('Angular');
600
+ if (deps.svelte) stacks.push('Svelte');
601
+ if (deps.next) stacks.push('Next.js');
602
+ if (deps.express) stacks.push('Express');
603
+ if (deps.fastify) stacks.push('Fastify');
604
+ if (deps.nestjs) stacks.push('NestJS');
605
+
606
+ // Testing frameworks
607
+ if (deps.jest) stacks.push('Jest');
608
+ if (deps.mocha) stacks.push('Mocha');
609
+ if (deps.vitest) stacks.push('Vitest');
610
+ if (deps.cucumber) stacks.push('Cucumber');
611
+
612
+ // Node.js is implied
613
+ stacks.unshift('Node.js');
614
+ } catch (e) {
615
+ // Invalid or malformed package.json - ignore and continue with other detection
616
+ }
551
617
  }
552
618
 
553
619
  if (fs.existsSync('Cargo.toml')) stacks.push('Rust');
@@ -639,6 +705,14 @@ async function initializeProject() {
639
705
  name: projectName,
640
706
  project_state: 'internal'
641
707
  };
708
+
709
+ // Auto-detect mid-project adoption and skip discovery
710
+ if (hasExistingCode()) {
711
+ initialConfig.project_discovery = {
712
+ status: 'completed'
713
+ };
714
+ }
715
+
642
716
  config.write(initialConfig);
643
717
  }
644
718
 
@@ -844,7 +918,13 @@ async function initializeProject() {
844
918
  try {
845
919
  // Remove partial copy if it exists
846
920
  if (fs.existsSync(skillsDestDir)) {
847
- fs.rmSync(skillsDestDir, { recursive: true, force: true });
921
+ // SAFETY: Validate path before deletion
922
+ const resolvedSkillsDir = path.resolve(skillsDestDir);
923
+ const resolvedCwd = path.resolve(process.cwd());
924
+ if (!resolvedSkillsDir.startsWith(resolvedCwd) || !resolvedSkillsDir.includes('.claude')) {
925
+ throw new Error(`SAFETY: Refusing to delete ${skillsDestDir} - not within .claude directory`);
926
+ }
927
+ fs.rmSync(skillsDestDir, { recursive: true });
848
928
  }
849
929
  fs.renameSync(backupDir, skillsDestDir);
850
930
  console.log('✅ Previous skills restored successfully');
@@ -1876,6 +1956,112 @@ Quick commands:
1876
1956
  }
1877
1957
  break;
1878
1958
 
1959
+ case 'trash': {
1960
+ // Trash management commands
1961
+ const trashSubcommand = args[0];
1962
+ const gitRoot = process.cwd();
1963
+
1964
+ if (trashSubcommand === 'list' || !trashSubcommand) {
1965
+ // List items in trash
1966
+ const trashItems = safeDelete.listTrash(gitRoot);
1967
+
1968
+ if (trashItems.length === 0) {
1969
+ console.log('🗑️ Trash is empty');
1970
+ } else {
1971
+ console.log('🗑️ Trash contents:');
1972
+ console.log('');
1973
+ trashItems.forEach((item, index) => {
1974
+ const age = Math.round((Date.now() - item.trashedAt.getTime()) / (60 * 60 * 1000));
1975
+ const ageStr = age < 1 ? 'just now' : age < 24 ? `${age}h ago` : `${Math.round(age / 24)}d ago`;
1976
+ const typeIcon = item.isDirectory ? '📁' : '📄';
1977
+ console.log(` ${index + 1}. ${typeIcon} ${item.relativePath}`);
1978
+ console.log(` Deleted: ${ageStr} | Size: ${item.isDirectory ? item.size + ' items' : formatBytes(item.size)}`);
1979
+ });
1980
+ console.log('');
1981
+ console.log(`Total: ${trashItems.length} item(s)`);
1982
+ console.log('');
1983
+ console.log('Use "jettypod undelete" to restore the most recent item');
1984
+ console.log('Use "jettypod undelete <path>" to restore a specific item');
1985
+ }
1986
+ } else if (trashSubcommand === 'empty') {
1987
+ // Empty the trash
1988
+ const result = safeDelete.emptyTrash(gitRoot);
1989
+ if (result.success) {
1990
+ console.log(`🗑️ Trash emptied (${result.count} items removed)`);
1991
+ } else {
1992
+ console.error(`❌ Failed to empty trash: ${result.error}`);
1993
+ process.exit(1);
1994
+ }
1995
+ } else if (trashSubcommand === 'purge') {
1996
+ // Purge old items (older than 24 hours by default)
1997
+ const hoursArg = args[1];
1998
+ const maxAgeHours = hoursArg ? parseInt(hoursArg, 10) : 24;
1999
+ const dryRun = args.includes('--dry-run');
2000
+
2001
+ if (dryRun) {
2002
+ console.log(`🔍 Dry run: Would purge items older than ${maxAgeHours} hours`);
2003
+ }
2004
+
2005
+ const result = safeDelete.purgeOldTrash(gitRoot, { maxAgeHours, dryRun });
2006
+
2007
+ if (result.purged.length === 0) {
2008
+ console.log(`✅ No items older than ${maxAgeHours} hours to purge`);
2009
+ } else {
2010
+ console.log(`🗑️ ${dryRun ? 'Would purge' : 'Purged'} ${result.purged.length} item(s):`);
2011
+ result.purged.forEach(item => {
2012
+ console.log(` - ${item.originalPath}`);
2013
+ });
2014
+ }
2015
+
2016
+ if (result.errors.length > 0) {
2017
+ console.error('');
2018
+ console.error('Errors:');
2019
+ result.errors.forEach(err => {
2020
+ console.error(` - ${err.name}: ${err.error}`);
2021
+ });
2022
+ }
2023
+
2024
+ console.log(`\nRemaining in trash: ${result.remaining} item(s)`);
2025
+ } else {
2026
+ console.log('Usage: jettypod trash [list|empty|purge]');
2027
+ console.log('');
2028
+ console.log('Commands:');
2029
+ console.log(' list List items in trash (default)');
2030
+ console.log(' empty Permanently delete all items in trash');
2031
+ console.log(' purge [hours] Delete items older than N hours (default: 24)');
2032
+ console.log(' Add --dry-run to preview what would be deleted');
2033
+ }
2034
+ break;
2035
+ }
2036
+
2037
+ case 'undelete': {
2038
+ // Restore from trash
2039
+ const gitRoot = process.cwd();
2040
+ const itemName = args[0] || 'latest';
2041
+
2042
+ const trashItems = safeDelete.listTrash(gitRoot);
2043
+ if (trashItems.length === 0) {
2044
+ console.log('🗑️ Trash is empty - nothing to restore');
2045
+ process.exit(0);
2046
+ }
2047
+
2048
+ const result = safeDelete.restoreFromTrash(gitRoot, itemName);
2049
+
2050
+ if (result.success) {
2051
+ console.log(`✅ Restored: ${result.restoredTo}`);
2052
+ } else {
2053
+ console.error(`❌ Failed to restore: ${result.error}`);
2054
+
2055
+ if (result.error.includes('path already exists')) {
2056
+ console.log('');
2057
+ console.log('The original location already has a file/directory.');
2058
+ console.log('Delete it first or use a different restore location.');
2059
+ }
2060
+ process.exit(1);
2061
+ }
2062
+ break;
2063
+ }
2064
+
1879
2065
  default:
1880
2066
  // Smart mode: auto-initialize if needed, otherwise show guidance
1881
2067
  if (!fs.existsSync('.jettypod')) {