agileflow 2.78.0 → 2.80.0

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.
@@ -24,6 +24,10 @@ const fs = require('fs');
24
24
  const path = require('path');
25
25
  const https = require('https');
26
26
 
27
+ // Shared utilities
28
+ const { getProjectRoot } = require('../lib/paths');
29
+ const { safeReadJSON, safeWriteJSON } = require('../lib/errors');
30
+
27
31
  // Debug mode
28
32
  const DEBUG = process.env.DEBUG_UPDATE === '1';
29
33
 
@@ -33,35 +37,23 @@ function debugLog(message, data = null) {
33
37
  }
34
38
  }
35
39
 
36
- // Find project root (has .agileflow directory)
37
- function getProjectRoot() {
38
- let dir = process.cwd();
39
- while (!fs.existsSync(path.join(dir, '.agileflow')) && dir !== '/') {
40
- dir = path.dirname(dir);
41
- }
42
- return dir !== '/' ? dir : process.cwd();
43
- }
44
-
45
40
  // Get installed AgileFlow version
46
41
  function getInstalledVersion(rootDir) {
47
42
  // First check .agileflow/package.json (installed version)
48
43
  const agileflowPkg = path.join(rootDir, '.agileflow', 'package.json');
49
- if (fs.existsSync(agileflowPkg)) {
50
- try {
51
- const pkg = JSON.parse(fs.readFileSync(agileflowPkg, 'utf8'));
52
- if (pkg.version) return pkg.version;
53
- } catch (e) {
54
- debugLog('Error reading .agileflow/package.json', e.message);
55
- }
44
+ const agileflowResult = safeReadJSON(agileflowPkg);
45
+ if (agileflowResult.ok && agileflowResult.data?.version) {
46
+ return agileflowResult.data.version;
47
+ }
48
+ if (!agileflowResult.ok && agileflowResult.error) {
49
+ debugLog('Error reading .agileflow/package.json', agileflowResult.error);
56
50
  }
57
51
 
58
52
  // Fallback: check if this is the AgileFlow dev repo
59
53
  const cliPkg = path.join(rootDir, 'packages/cli/package.json');
60
- if (fs.existsSync(cliPkg)) {
61
- try {
62
- const pkg = JSON.parse(fs.readFileSync(cliPkg, 'utf8'));
63
- if (pkg.name === 'agileflow' && pkg.version) return pkg.version;
64
- } catch (e) {}
54
+ const cliResult = safeReadJSON(cliPkg);
55
+ if (cliResult.ok && cliResult.data?.name === 'agileflow' && cliResult.data?.version) {
56
+ return cliResult.data.version;
65
57
  }
66
58
 
67
59
  return null;
@@ -78,16 +70,16 @@ function getUpdateConfig(rootDir) {
78
70
  latestVersion: null,
79
71
  };
80
72
 
81
- try {
82
- const metadataPath = path.join(rootDir, 'docs/00-meta/agileflow-metadata.json');
83
- if (fs.existsSync(metadataPath)) {
84
- const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
85
- if (metadata.updates) {
86
- return { ...defaults, ...metadata.updates };
87
- }
88
- }
89
- } catch (e) {
90
- debugLog('Error reading update config', e.message);
73
+ const metadataPath = path.join(rootDir, 'docs/00-meta/agileflow-metadata.json');
74
+ const result = safeReadJSON(metadataPath, { defaultValue: {} });
75
+
76
+ if (!result.ok) {
77
+ debugLog('Error reading update config', result.error);
78
+ return defaults;
79
+ }
80
+
81
+ if (result.data?.updates) {
82
+ return { ...defaults, ...result.data.updates };
91
83
  }
92
84
 
93
85
  return defaults;
@@ -95,21 +87,22 @@ function getUpdateConfig(rootDir) {
95
87
 
96
88
  // Save update configuration
97
89
  function saveUpdateConfig(rootDir, config) {
98
- try {
99
- const metadataPath = path.join(rootDir, 'docs/00-meta/agileflow-metadata.json');
100
- let metadata = {};
90
+ const metadataPath = path.join(rootDir, 'docs/00-meta/agileflow-metadata.json');
101
91
 
102
- if (fs.existsSync(metadataPath)) {
103
- metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
104
- }
92
+ // Read existing metadata
93
+ const readResult = safeReadJSON(metadataPath, { defaultValue: {} });
94
+ const metadata = readResult.ok ? readResult.data : {};
95
+
96
+ // Update and write
97
+ metadata.updates = config;
98
+ const writeResult = safeWriteJSON(metadataPath, metadata, { createDir: true });
105
99
 
106
- metadata.updates = config;
107
- fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\n');
108
- return true;
109
- } catch (e) {
110
- debugLog('Error saving update config', e.message);
100
+ if (!writeResult.ok) {
101
+ debugLog('Error saving update config', writeResult.error);
111
102
  return false;
112
103
  }
104
+
105
+ return true;
113
106
  }
114
107
 
115
108
  // Check if cache is still valid
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * bash-tool-damage-control.js - Validate bash commands against security patterns
5
+ *
6
+ * This PreToolUse hook runs before every Bash tool execution.
7
+ * It checks the command against patterns.yaml to block or ask for
8
+ * confirmation on dangerous commands.
9
+ *
10
+ * Exit codes:
11
+ * 0 = Allow command to proceed (or ask with JSON output)
12
+ * 2 = Block command
13
+ *
14
+ * For ask confirmation, outputs JSON:
15
+ * {"result": "ask", "message": "Reason for asking"}
16
+ *
17
+ * Usage (as PreToolUse hook):
18
+ * node .claude/hooks/damage-control/bash-tool-damage-control.js
19
+ *
20
+ * Environment:
21
+ * CLAUDE_TOOL_INPUT - JSON string with tool input (contains "command")
22
+ * CLAUDE_PROJECT_DIR - Project root directory
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ // ANSI colors for output
29
+ const c = {
30
+ reset: '\x1b[0m',
31
+ bold: '\x1b[1m',
32
+ red: '\x1b[31m',
33
+ yellow: '\x1b[33m',
34
+ cyan: '\x1b[36m',
35
+ };
36
+
37
+ // Exit codes
38
+ const EXIT_ALLOW = 0;
39
+ const EXIT_BLOCK = 2;
40
+
41
+ /**
42
+ * Load patterns from YAML file
43
+ * Falls back to built-in patterns if YAML parsing fails
44
+ */
45
+ function loadPatterns(projectDir) {
46
+ const locations = [
47
+ path.join(projectDir, '.claude/hooks/damage-control/patterns.yaml'),
48
+ path.join(projectDir, '.agileflow/hooks/damage-control/patterns.yaml'),
49
+ path.join(projectDir, 'patterns.yaml'),
50
+ ];
51
+
52
+ for (const loc of locations) {
53
+ if (fs.existsSync(loc)) {
54
+ try {
55
+ const content = fs.readFileSync(loc, 'utf8');
56
+ // Simple YAML parsing for our specific structure
57
+ return parseSimpleYaml(content);
58
+ } catch (e) {
59
+ console.error(`Warning: Could not parse ${loc}: ${e.message}`);
60
+ }
61
+ }
62
+ }
63
+
64
+ // Return built-in defaults if no file found
65
+ return getDefaultPatterns();
66
+ }
67
+
68
+ /**
69
+ * Simple YAML parser for patterns.yaml structure
70
+ * Only handles the specific structure we use (arrays of objects with pattern/reason/ask)
71
+ */
72
+ function parseSimpleYaml(content) {
73
+ const patterns = {
74
+ bashToolPatterns: [],
75
+ askPatterns: [],
76
+ agileflowPatterns: [],
77
+ };
78
+
79
+ let currentSection = null;
80
+ let currentItem = null;
81
+
82
+ const lines = content.split('\n');
83
+
84
+ for (const line of lines) {
85
+ // Skip comments and empty lines
86
+ if (line.trim().startsWith('#') || line.trim() === '') continue;
87
+
88
+ // Check for section headers
89
+ if (line.match(/^bashToolPatterns:/)) {
90
+ currentSection = 'bashToolPatterns';
91
+ continue;
92
+ }
93
+ if (line.match(/^askPatterns:/)) {
94
+ currentSection = 'askPatterns';
95
+ continue;
96
+ }
97
+ if (line.match(/^agileflowPatterns:/)) {
98
+ currentSection = 'agileflowPatterns';
99
+ continue;
100
+ }
101
+ if (line.match(/^(zeroAccessPaths|readOnlyPaths|noDeletePaths|config):/)) {
102
+ currentSection = null; // Skip non-pattern sections
103
+ continue;
104
+ }
105
+
106
+ // Parse pattern items
107
+ if (currentSection && patterns[currentSection]) {
108
+ const patternMatch = line.match(/^\s+-\s*pattern:\s*['"]?(.+?)['"]?\s*$/);
109
+ if (patternMatch) {
110
+ currentItem = { pattern: patternMatch[1] };
111
+ patterns[currentSection].push(currentItem);
112
+ continue;
113
+ }
114
+
115
+ const reasonMatch = line.match(/^\s+reason:\s*['"]?(.+?)['"]?\s*$/);
116
+ if (reasonMatch && currentItem) {
117
+ currentItem.reason = reasonMatch[1];
118
+ continue;
119
+ }
120
+
121
+ const askMatch = line.match(/^\s+ask:\s*(true|false)\s*$/);
122
+ if (askMatch && currentItem) {
123
+ currentItem.ask = askMatch[1] === 'true';
124
+ continue;
125
+ }
126
+ }
127
+ }
128
+
129
+ return patterns;
130
+ }
131
+
132
+ /**
133
+ * Built-in default patterns (used if patterns.yaml not found)
134
+ */
135
+ function getDefaultPatterns() {
136
+ return {
137
+ bashToolPatterns: [
138
+ { pattern: '\\brm\\s+-[rRf]', reason: 'rm with recursive or force flags' },
139
+ { pattern: 'DROP\\s+(TABLE|DATABASE)', reason: 'DROP commands are destructive' },
140
+ { pattern: 'DELETE\\s+FROM\\s+\\w+\\s*;', reason: 'DELETE without WHERE clause' },
141
+ { pattern: 'TRUNCATE\\s+(TABLE\\s+)?\\w+', reason: 'TRUNCATE removes all data' },
142
+ {
143
+ pattern: 'git\\s+push\\s+.*--force',
144
+ reason: 'Force push can overwrite history',
145
+ ask: true,
146
+ },
147
+ { pattern: 'git\\s+reset\\s+--hard', reason: 'Hard reset discards changes', ask: true },
148
+ ],
149
+ askPatterns: [
150
+ { pattern: 'DELETE\\s+FROM\\s+\\w+\\s+WHERE', reason: 'Confirm record deletion' },
151
+ { pattern: 'npm\\s+publish', reason: 'Publishing to npm is permanent' },
152
+ ],
153
+ agileflowPatterns: [
154
+ { pattern: 'rm.*\\.agileflow', reason: 'Deleting .agileflow breaks installation' },
155
+ { pattern: 'rm.*\\.claude', reason: 'Deleting .claude breaks configuration' },
156
+ ],
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Check command against patterns
162
+ * Returns: { blocked: boolean, ask: boolean, reason: string }
163
+ */
164
+ function checkCommand(command, patterns) {
165
+ // Combine all pattern sources
166
+ const allPatterns = [...(patterns.bashToolPatterns || []), ...(patterns.agileflowPatterns || [])];
167
+
168
+ // Check block/ask patterns
169
+ for (const p of allPatterns) {
170
+ try {
171
+ const regex = new RegExp(p.pattern, 'i');
172
+ if (regex.test(command)) {
173
+ if (p.ask) {
174
+ return { blocked: false, ask: true, reason: p.reason };
175
+ }
176
+ return { blocked: true, ask: false, reason: p.reason };
177
+ }
178
+ } catch (e) {
179
+ // Invalid regex, skip
180
+ console.error(`Warning: Invalid regex pattern: ${p.pattern}`);
181
+ }
182
+ }
183
+
184
+ // Check ask-only patterns
185
+ for (const p of patterns.askPatterns || []) {
186
+ try {
187
+ const regex = new RegExp(p.pattern, 'i');
188
+ if (regex.test(command)) {
189
+ return { blocked: false, ask: true, reason: p.reason };
190
+ }
191
+ } catch (e) {
192
+ // Invalid regex, skip
193
+ }
194
+ }
195
+
196
+ return { blocked: false, ask: false, reason: null };
197
+ }
198
+
199
+ /**
200
+ * Main entry point
201
+ */
202
+ function main() {
203
+ // Get tool input from environment
204
+ const toolInput = process.env.CLAUDE_TOOL_INPUT;
205
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
206
+
207
+ if (!toolInput) {
208
+ // No input, allow by default
209
+ process.exit(EXIT_ALLOW);
210
+ }
211
+
212
+ let input;
213
+ try {
214
+ input = JSON.parse(toolInput);
215
+ } catch (e) {
216
+ console.error('Error parsing CLAUDE_TOOL_INPUT:', e.message);
217
+ process.exit(EXIT_ALLOW);
218
+ }
219
+
220
+ const command = input.command;
221
+ if (!command) {
222
+ process.exit(EXIT_ALLOW);
223
+ }
224
+
225
+ // Load patterns
226
+ const patterns = loadPatterns(projectDir);
227
+
228
+ // Check command
229
+ const result = checkCommand(command, patterns);
230
+
231
+ if (result.blocked) {
232
+ // Block the command
233
+ console.error(`${c.red}${c.bold}BLOCKED${c.reset}: ${result.reason}`);
234
+ console.error(`${c.yellow}Command: ${command}${c.reset}`);
235
+ console.error(`${c.cyan}This command was blocked by damage control.${c.reset}`);
236
+ process.exit(EXIT_BLOCK);
237
+ }
238
+
239
+ if (result.ask) {
240
+ // Ask for confirmation
241
+ const response = {
242
+ result: 'ask',
243
+ message: `${result.reason}\n\nCommand: ${command}\n\nProceed with this command?`,
244
+ };
245
+ console.log(JSON.stringify(response));
246
+ process.exit(EXIT_ALLOW);
247
+ }
248
+
249
+ // Allow the command
250
+ process.exit(EXIT_ALLOW);
251
+ }
252
+
253
+ // Run if called directly
254
+ if (require.main === module) {
255
+ main();
256
+ }
257
+
258
+ module.exports = { checkCommand, loadPatterns, parseSimpleYaml };
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * edit-tool-damage-control.js - Enforce path protection for Edit tool
5
+ *
6
+ * This PreToolUse hook runs before every Edit tool execution.
7
+ * It checks the file path against patterns.yaml to block edits
8
+ * to protected paths.
9
+ *
10
+ * Path protection levels:
11
+ * zeroAccessPaths: Cannot read, write, edit, or delete
12
+ * readOnlyPaths: Can read, cannot modify or delete
13
+ * noDeletePaths: Can read and modify, cannot delete (Edit is allowed)
14
+ *
15
+ * Exit codes:
16
+ * 0 = Allow edit to proceed
17
+ * 2 = Block edit
18
+ *
19
+ * Usage (as PreToolUse hook):
20
+ * node .claude/hooks/damage-control/edit-tool-damage-control.js
21
+ *
22
+ * Environment:
23
+ * CLAUDE_TOOL_INPUT - JSON string with tool input (contains "file_path")
24
+ * CLAUDE_PROJECT_DIR - Project root directory
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ // ANSI colors for output
31
+ const c = {
32
+ reset: '\x1b[0m',
33
+ bold: '\x1b[1m',
34
+ red: '\x1b[31m',
35
+ yellow: '\x1b[33m',
36
+ cyan: '\x1b[36m',
37
+ };
38
+
39
+ // Exit codes
40
+ const EXIT_ALLOW = 0;
41
+ const EXIT_BLOCK = 2;
42
+
43
+ /**
44
+ * Load path protection rules from patterns.yaml
45
+ */
46
+ function loadPathRules(projectDir) {
47
+ const locations = [
48
+ path.join(projectDir, '.claude/hooks/damage-control/patterns.yaml'),
49
+ path.join(projectDir, '.agileflow/hooks/damage-control/patterns.yaml'),
50
+ path.join(projectDir, 'patterns.yaml'),
51
+ ];
52
+
53
+ for (const loc of locations) {
54
+ if (fs.existsSync(loc)) {
55
+ try {
56
+ const content = fs.readFileSync(loc, 'utf8');
57
+ return parsePathRules(content);
58
+ } catch (e) {
59
+ console.error(`Warning: Could not parse ${loc}: ${e.message}`);
60
+ }
61
+ }
62
+ }
63
+
64
+ return getDefaultPathRules();
65
+ }
66
+
67
+ /**
68
+ * Parse path rules from YAML content
69
+ */
70
+ function parsePathRules(content) {
71
+ const rules = {
72
+ zeroAccessPaths: [],
73
+ readOnlyPaths: [],
74
+ noDeletePaths: [],
75
+ };
76
+
77
+ let currentSection = null;
78
+
79
+ const lines = content.split('\n');
80
+
81
+ for (const line of lines) {
82
+ if (line.trim().startsWith('#') || line.trim() === '') continue;
83
+
84
+ if (line.match(/^zeroAccessPaths:/)) {
85
+ currentSection = 'zeroAccessPaths';
86
+ continue;
87
+ }
88
+ if (line.match(/^readOnlyPaths:/)) {
89
+ currentSection = 'readOnlyPaths';
90
+ continue;
91
+ }
92
+ if (line.match(/^noDeletePaths:/)) {
93
+ currentSection = 'noDeletePaths';
94
+ continue;
95
+ }
96
+ if (line.match(/^(bashToolPatterns|askPatterns|agileflowPatterns|config):/)) {
97
+ currentSection = null;
98
+ continue;
99
+ }
100
+
101
+ if (currentSection && rules[currentSection]) {
102
+ const pathMatch = line.match(/^\s+-\s*['"]?(.+?)['"]?\s*$/);
103
+ if (pathMatch) {
104
+ rules[currentSection].push(pathMatch[1]);
105
+ }
106
+ }
107
+ }
108
+
109
+ return rules;
110
+ }
111
+
112
+ /**
113
+ * Default path rules if patterns.yaml not found
114
+ */
115
+ function getDefaultPathRules() {
116
+ return {
117
+ zeroAccessPaths: ['~/.ssh/', '~/.aws/credentials', '.env', '.env.local', '.env.production'],
118
+ readOnlyPaths: ['/etc/', '~/.bashrc', '~/.zshrc', 'package-lock.json', 'yarn.lock', '.git/'],
119
+ noDeletePaths: ['.agileflow/', '.claude/', 'docs/09-agents/status.json', 'CLAUDE.md'],
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Expand home directory in path
125
+ */
126
+ function expandHome(filePath) {
127
+ if (filePath.startsWith('~/')) {
128
+ return path.join(process.env.HOME || '', filePath.slice(2));
129
+ }
130
+ return filePath;
131
+ }
132
+
133
+ /**
134
+ * Check if a file path matches a pattern
135
+ * Supports:
136
+ * - Exact paths
137
+ * - Directory prefixes (ending with /)
138
+ * - Glob wildcards (**)
139
+ */
140
+ function pathMatches(filePath, pattern) {
141
+ const expandedPattern = expandHome(pattern);
142
+ const normalizedFile = path.normalize(filePath);
143
+ const normalizedPattern = path.normalize(expandedPattern);
144
+
145
+ // Exact match
146
+ if (normalizedFile === normalizedPattern) return true;
147
+
148
+ // Directory prefix match (pattern ends with /)
149
+ if (pattern.endsWith('/')) {
150
+ if (normalizedFile.startsWith(normalizedPattern)) return true;
151
+ }
152
+
153
+ // Glob pattern (**/)
154
+ if (pattern.includes('**/')) {
155
+ const globPart = pattern.split('**/')[1];
156
+ if (normalizedFile.includes(globPart)) return true;
157
+ }
158
+
159
+ // Simple wildcard at end
160
+ if (pattern.endsWith('*')) {
161
+ const prefix = normalizedPattern.slice(0, -1);
162
+ if (normalizedFile.startsWith(prefix)) return true;
163
+ }
164
+
165
+ // Basename match (for patterns like .env)
166
+ if (!pattern.includes('/') && !pattern.includes(path.sep)) {
167
+ const basename = path.basename(normalizedFile);
168
+ if (basename === pattern) return true;
169
+ // Pattern like .env* matching .env.local
170
+ if (pattern.endsWith('*')) {
171
+ const patternBase = pattern.slice(0, -1);
172
+ if (basename.startsWith(patternBase)) return true;
173
+ }
174
+ }
175
+
176
+ return false;
177
+ }
178
+
179
+ /**
180
+ * Check if file path is protected
181
+ * Returns: { blocked: boolean, reason: string, level: string }
182
+ */
183
+ function checkPath(filePath, rules) {
184
+ // Check zero access paths (blocked completely)
185
+ for (const pattern of rules.zeroAccessPaths) {
186
+ if (pathMatches(filePath, pattern)) {
187
+ return {
188
+ blocked: true,
189
+ reason: `Path is in zero-access protected list: ${pattern}`,
190
+ level: 'zero-access',
191
+ };
192
+ }
193
+ }
194
+
195
+ // Check read-only paths (cannot edit)
196
+ for (const pattern of rules.readOnlyPaths) {
197
+ if (pathMatches(filePath, pattern)) {
198
+ return {
199
+ blocked: true,
200
+ reason: `Path is read-only: ${pattern}`,
201
+ level: 'read-only',
202
+ };
203
+ }
204
+ }
205
+
206
+ // noDeletePaths allows editing, so we don't block here
207
+ // (deletion is handled by a different mechanism or file watcher)
208
+
209
+ return { blocked: false, reason: null, level: null };
210
+ }
211
+
212
+ /**
213
+ * Main entry point
214
+ */
215
+ function main() {
216
+ const toolInput = process.env.CLAUDE_TOOL_INPUT;
217
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
218
+
219
+ if (!toolInput) {
220
+ process.exit(EXIT_ALLOW);
221
+ }
222
+
223
+ let input;
224
+ try {
225
+ input = JSON.parse(toolInput);
226
+ } catch (e) {
227
+ console.error('Error parsing CLAUDE_TOOL_INPUT:', e.message);
228
+ process.exit(EXIT_ALLOW);
229
+ }
230
+
231
+ const filePath = input.file_path;
232
+ if (!filePath) {
233
+ process.exit(EXIT_ALLOW);
234
+ }
235
+
236
+ // Resolve to absolute path
237
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectDir, filePath);
238
+
239
+ // Load rules
240
+ const rules = loadPathRules(projectDir);
241
+
242
+ // Check path
243
+ const result = checkPath(absolutePath, rules);
244
+
245
+ if (result.blocked) {
246
+ console.error(`${c.red}${c.bold}BLOCKED${c.reset}: ${result.reason}`);
247
+ console.error(`${c.yellow}File: ${filePath}${c.reset}`);
248
+ console.error(`${c.cyan}This file is protected by damage control (${result.level}).${c.reset}`);
249
+ process.exit(EXIT_BLOCK);
250
+ }
251
+
252
+ process.exit(EXIT_ALLOW);
253
+ }
254
+
255
+ if (require.main === module) {
256
+ main();
257
+ }
258
+
259
+ module.exports = { checkPath, loadPathRules, pathMatches };