wogiflow 1.0.38 → 1.0.40
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.
- package/.claude/commands/wogi-compact.md +5 -7
- package/package.json +1 -1
- package/scripts/flow-context-compact/index.js +39 -9
- package/scripts/flow-durable-session.js +41 -14
- package/scripts/flow-start.js +30 -1
- package/scripts/hooks/core/index.js +5 -0
- package/scripts/hooks/core/scope-gate.js +381 -0
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +8 -5
|
@@ -49,17 +49,15 @@ When compacting, the system automatically:
|
|
|
49
49
|
2. Stores summaries at multiple levels (root → sections → details)
|
|
50
50
|
3. Applies relevance decay to older items
|
|
51
51
|
4. Enables on-demand expansion when details are needed later
|
|
52
|
-
5. **Cleans up completed plan files** from
|
|
52
|
+
5. **Cleans up completed plan files** from `~/.claude/plans/`
|
|
53
53
|
|
|
54
54
|
### Plan File Cleanup
|
|
55
55
|
|
|
56
|
-
Compaction automatically cleans up plan files that
|
|
57
|
-
- Plans with
|
|
58
|
-
- Plans containing
|
|
59
|
-
- Plans with "all completed"
|
|
60
|
-
- Very short/empty plan files
|
|
56
|
+
Compaction automatically cleans up plan files from your home directory (`~/.claude/plans/`) that are explicitly marked as complete:
|
|
57
|
+
- Plans with `# Plan: Complete` in the title
|
|
58
|
+
- Plans containing the explicit marker `This plan can be deleted`
|
|
61
59
|
|
|
62
|
-
This prevents stale plan files from accumulating and being shown after context restoration.
|
|
60
|
+
Only files with these explicit completion markers are deleted. This prevents stale plan files from accumulating and being shown after context restoration.
|
|
63
61
|
|
|
64
62
|
## Format for Context Summary
|
|
65
63
|
|
package/package.json
CHANGED
|
@@ -93,9 +93,13 @@ function checkPressure() {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* Clean up completed plan files from
|
|
97
|
-
* Plan files that are marked complete
|
|
98
|
-
*
|
|
96
|
+
* Clean up completed plan files from ~/.claude/plans/
|
|
97
|
+
* Plan files that are explicitly marked complete are deleted.
|
|
98
|
+
*
|
|
99
|
+
* Safety: Only deletes files that explicitly contain completion markers.
|
|
100
|
+
* Path traversal protection: Validates resolved paths stay within plansDir.
|
|
101
|
+
*
|
|
102
|
+
* @returns {Object} Cleanup result with cleaned count and file list
|
|
99
103
|
*/
|
|
100
104
|
function cleanupPlanFiles() {
|
|
101
105
|
const fs = require('fs');
|
|
@@ -112,27 +116,47 @@ function cleanupPlanFiles() {
|
|
|
112
116
|
return result;
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
// Resolve to absolute path for security comparison
|
|
120
|
+
const realPlansDir = path.resolve(plansDir);
|
|
121
|
+
|
|
115
122
|
try {
|
|
116
123
|
const files = fs.readdirSync(plansDir);
|
|
117
124
|
|
|
118
125
|
for (const file of files) {
|
|
126
|
+
// Only process .md files
|
|
119
127
|
if (!file.endsWith('.md')) continue;
|
|
120
128
|
|
|
129
|
+
// Skip files with path separators (potential traversal)
|
|
130
|
+
if (file.includes('/') || file.includes('\\')) continue;
|
|
131
|
+
|
|
121
132
|
const filePath = path.join(plansDir, file);
|
|
122
|
-
let content;
|
|
123
133
|
|
|
134
|
+
// Path traversal protection: ensure resolved path is within plansDir
|
|
135
|
+
const realFilePath = path.resolve(filePath);
|
|
136
|
+
if (!realFilePath.startsWith(realPlansDir + path.sep) && realFilePath !== realPlansDir) {
|
|
137
|
+
continue; // Skip files outside target directory
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Skip symlinks (security measure)
|
|
141
|
+
try {
|
|
142
|
+
const stats = fs.lstatSync(filePath);
|
|
143
|
+
if (stats.isSymbolicLink()) continue;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let content;
|
|
124
149
|
try {
|
|
125
150
|
content = fs.readFileSync(filePath, 'utf-8');
|
|
126
151
|
} catch (err) {
|
|
127
152
|
continue; // Skip files we can't read
|
|
128
153
|
}
|
|
129
154
|
|
|
130
|
-
// Check if plan
|
|
155
|
+
// Check if plan is EXPLICITLY marked complete
|
|
156
|
+
// More restrictive patterns to avoid accidental deletion
|
|
131
157
|
const isComplete =
|
|
132
|
-
/^#\s*Plan:\s*Complete/im.test(content) ||
|
|
133
|
-
|
|
134
|
-
/all.*completed/i.test(content) ||
|
|
135
|
-
content.trim().length < 100; // Very short/empty plans
|
|
158
|
+
/^#\s*Plan:\s*Complete/im.test(content) || // Title starts with "Plan: Complete"
|
|
159
|
+
/^This plan (file )?can be deleted\.?$/im.test(content); // Explicit deletion marker
|
|
136
160
|
|
|
137
161
|
if (isComplete) {
|
|
138
162
|
try {
|
|
@@ -141,11 +165,17 @@ function cleanupPlanFiles() {
|
|
|
141
165
|
result.files.push(file);
|
|
142
166
|
} catch (err) {
|
|
143
167
|
// Couldn't delete - that's okay
|
|
168
|
+
if (process.env.DEBUG) {
|
|
169
|
+
console.error(`[cleanupPlanFiles] Failed to delete ${file}: ${err.message}`);
|
|
170
|
+
}
|
|
144
171
|
}
|
|
145
172
|
}
|
|
146
173
|
}
|
|
147
174
|
} catch (err) {
|
|
148
175
|
// Error reading plans directory - non-critical
|
|
176
|
+
if (process.env.DEBUG) {
|
|
177
|
+
console.error(`[cleanupPlanFiles] Error: ${err.message}`);
|
|
178
|
+
}
|
|
149
179
|
}
|
|
150
180
|
|
|
151
181
|
return result;
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
20
|
const { execSync } = require('child_process');
|
|
21
|
-
const { getConfig, getProjectRoot, MAX_SESSION_HISTORY, withLock, writeJson, ensureDir } = require('./flow-utils');
|
|
21
|
+
const { getConfig, getProjectRoot, MAX_SESSION_HISTORY, withLock, writeJson, ensureDir, safeJsonParse } = require('./flow-utils');
|
|
22
22
|
const { validateCommand } = require('./flow-workflow');
|
|
23
23
|
const { validatePathWithinProject } = require('./flow-security');
|
|
24
24
|
|
|
@@ -138,7 +138,7 @@ function createDurableSession(taskId, taskType, steps = []) {
|
|
|
138
138
|
* @param {Array} steps - Array of step definitions
|
|
139
139
|
* @returns {Promise<Object>} Created or existing session
|
|
140
140
|
*/
|
|
141
|
-
async function createDurableSessionAsync(taskId, taskType, steps = []) {
|
|
141
|
+
async function createDurableSessionAsync(taskId, taskType, steps = [], options = {}) {
|
|
142
142
|
const sessionPath = getSessionPath();
|
|
143
143
|
|
|
144
144
|
return withLock(sessionPath, () => {
|
|
@@ -152,7 +152,7 @@ async function createDurableSessionAsync(taskId, taskType, steps = []) {
|
|
|
152
152
|
// Clean up legacy hybrid-session.json if present (migration to v2.0)
|
|
153
153
|
cleanupLegacyHybridSession();
|
|
154
154
|
|
|
155
|
-
const session = createSessionObject(taskId, taskType, steps);
|
|
155
|
+
const session = createSessionObject(taskId, taskType, steps, options);
|
|
156
156
|
saveDurableSession(session);
|
|
157
157
|
return session;
|
|
158
158
|
});
|
|
@@ -160,8 +160,13 @@ async function createDurableSessionAsync(taskId, taskType, steps = []) {
|
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
162
|
* Create a session object (internal helper)
|
|
163
|
+
* @param {string} taskId - Task identifier
|
|
164
|
+
* @param {string} taskType - Type: "task", "loop", "bulk"
|
|
165
|
+
* @param {Array} steps - Array of step definitions
|
|
166
|
+
* @param {Object} options - Additional options
|
|
167
|
+
* @param {Object} options.filesToChange - File scope from spec (create/modify/delete arrays)
|
|
163
168
|
*/
|
|
164
|
-
function createSessionObject(taskId, taskType, steps = []) {
|
|
169
|
+
function createSessionObject(taskId, taskType, steps = [], options = {}) {
|
|
165
170
|
return {
|
|
166
171
|
version: SESSION_VERSION,
|
|
167
172
|
sessionId: `sess-${Date.now()}`,
|
|
@@ -201,7 +206,10 @@ function createSessionObject(taskId, taskType, steps = []) {
|
|
|
201
206
|
source: null, // How queue was created: "bulk", "natural", "manual"
|
|
202
207
|
queuedAt: null,
|
|
203
208
|
completedTasks: [] // Track completed task IDs
|
|
204
|
-
}
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
// v4.0: File scope for runtime enforcement (from spec's filesToChange)
|
|
212
|
+
filesToChange: options.filesToChange || null
|
|
205
213
|
};
|
|
206
214
|
}
|
|
207
215
|
|
|
@@ -261,8 +269,8 @@ function loadDurableSession() {
|
|
|
261
269
|
}
|
|
262
270
|
|
|
263
271
|
try {
|
|
264
|
-
|
|
265
|
-
const session =
|
|
272
|
+
// Use safeJsonParse to prevent prototype pollution
|
|
273
|
+
const session = safeJsonParse(sessionPath, null);
|
|
266
274
|
|
|
267
275
|
// Validate session structure
|
|
268
276
|
if (!session || typeof session !== 'object') {
|
|
@@ -306,6 +314,16 @@ function loadDurableSession() {
|
|
|
306
314
|
}
|
|
307
315
|
}
|
|
308
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Get file scope from current durable session
|
|
319
|
+
* Used by scope-gate for runtime enforcement
|
|
320
|
+
* @returns {Object|null} filesToChange object or null if no scope defined
|
|
321
|
+
*/
|
|
322
|
+
function getSessionFileScope() {
|
|
323
|
+
const session = loadDurableSession();
|
|
324
|
+
return session?.filesToChange || null;
|
|
325
|
+
}
|
|
326
|
+
|
|
309
327
|
/**
|
|
310
328
|
* Save the durable session
|
|
311
329
|
* @param {Object} session - Session to save
|
|
@@ -344,11 +362,9 @@ function archiveDurableSession(status = 'completed') {
|
|
|
344
362
|
let history = [];
|
|
345
363
|
|
|
346
364
|
if (fs.existsSync(historyPath)) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
history = [];
|
|
351
|
-
}
|
|
365
|
+
// Use safeJsonParse to prevent prototype pollution
|
|
366
|
+
const parsed = safeJsonParse(historyPath, []);
|
|
367
|
+
history = Array.isArray(parsed) ? parsed : [];
|
|
352
368
|
}
|
|
353
369
|
|
|
354
370
|
history.push(session);
|
|
@@ -964,7 +980,15 @@ function checkFileCondition(config) {
|
|
|
964
980
|
// If expected content specified, check it
|
|
965
981
|
if (config.expectedContent) {
|
|
966
982
|
try {
|
|
967
|
-
|
|
983
|
+
// Use safeJsonParse to prevent prototype pollution
|
|
984
|
+
const content = safeJsonParse(filePath, null);
|
|
985
|
+
if (!content) {
|
|
986
|
+
return {
|
|
987
|
+
canResume: false,
|
|
988
|
+
reason: 'file-parse-error',
|
|
989
|
+
error: 'Could not parse file content'
|
|
990
|
+
};
|
|
991
|
+
}
|
|
968
992
|
const matches = deepEqual(content, config.expectedContent);
|
|
969
993
|
|
|
970
994
|
if (matches) {
|
|
@@ -1184,7 +1208,9 @@ function getSessionStats() {
|
|
|
1184
1208
|
}
|
|
1185
1209
|
|
|
1186
1210
|
try {
|
|
1187
|
-
|
|
1211
|
+
// Use safeJsonParse to prevent prototype pollution
|
|
1212
|
+
const parsed = safeJsonParse(historyPath, []);
|
|
1213
|
+
const history = Array.isArray(parsed) ? parsed : [];
|
|
1188
1214
|
const completed = history.filter(h => h.status === 'completed').length;
|
|
1189
1215
|
const failed = history.filter(h => h.status === 'failed').length;
|
|
1190
1216
|
const avgSteps = history.length > 0
|
|
@@ -1408,6 +1434,7 @@ module.exports = {
|
|
|
1408
1434
|
loadDurableSession,
|
|
1409
1435
|
saveDurableSession,
|
|
1410
1436
|
archiveDurableSession,
|
|
1437
|
+
getSessionFileScope, // v4.0: Get file scope for runtime enforcement
|
|
1411
1438
|
|
|
1412
1439
|
// Step management
|
|
1413
1440
|
getNextPendingStep,
|
package/scripts/flow-start.js
CHANGED
|
@@ -66,6 +66,14 @@ const {
|
|
|
66
66
|
STEP_STATUS
|
|
67
67
|
} = require('./flow-durable-session');
|
|
68
68
|
|
|
69
|
+
// Spec loader for scope enforcement (optional - graceful degradation)
|
|
70
|
+
let loadSpec = null;
|
|
71
|
+
try {
|
|
72
|
+
loadSpec = require('./flow-spec-generator').loadSpec;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (process.env.DEBUG) console.error(`[DEBUG] flow-spec-generator not available: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
69
77
|
// v3.0 phased task execution (recursive enhancements)
|
|
70
78
|
const {
|
|
71
79
|
initializePhasedTask,
|
|
@@ -288,8 +296,29 @@ async function main() {
|
|
|
288
296
|
const steps = Array.isArray(acceptanceCriteria) ? acceptanceCriteria : [];
|
|
289
297
|
const sessionSteps = steps.length > 0 ? steps : [taskTitle || taskId];
|
|
290
298
|
|
|
299
|
+
// v4.0: Load spec to get filesToChange for scope enforcement
|
|
300
|
+
let filesToChange = null;
|
|
301
|
+
if (loadSpec) {
|
|
302
|
+
try {
|
|
303
|
+
const spec = loadSpec(taskId);
|
|
304
|
+
if (spec?.sections?.filesToChange) {
|
|
305
|
+
filesToChange = spec.sections.filesToChange;
|
|
306
|
+
if (process.env.DEBUG) {
|
|
307
|
+
const fileCount = (filesToChange.create?.length || 0) +
|
|
308
|
+
(filesToChange.modify?.length || 0) +
|
|
309
|
+
(filesToChange.delete?.length || 0);
|
|
310
|
+
console.log(color('dim', `[DEBUG] Loaded scope: ${fileCount} files from spec`));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch (err) {
|
|
314
|
+
if (process.env.DEBUG) console.error(`[DEBUG] Spec load for scope: ${err.message}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
291
318
|
// Use async version with file locking to prevent race conditions
|
|
292
|
-
const session = await createDurableSessionAsync(taskId, 'task', sessionSteps
|
|
319
|
+
const session = await createDurableSessionAsync(taskId, 'task', sessionSteps, {
|
|
320
|
+
filesToChange
|
|
321
|
+
});
|
|
293
322
|
|
|
294
323
|
if (steps.length > 0) {
|
|
295
324
|
console.log(color('cyan', `📋 Durable session initialized with ${steps.length} steps`));
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const taskGate = require('./task-gate');
|
|
10
|
+
const scopeGate = require('./scope-gate');
|
|
10
11
|
const validation = require('./validation');
|
|
11
12
|
const loopCheck = require('./loop-check');
|
|
12
13
|
const componentCheck = require('./component-check');
|
|
@@ -21,6 +22,10 @@ module.exports = {
|
|
|
21
22
|
...taskGate,
|
|
22
23
|
taskGate,
|
|
23
24
|
|
|
25
|
+
// Scope Gating (v4.0 - validates edits are within task scope)
|
|
26
|
+
...scopeGate,
|
|
27
|
+
scopeGate,
|
|
28
|
+
|
|
24
29
|
// Validation
|
|
25
30
|
...validation,
|
|
26
31
|
validation,
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Scope Gate (Core Module)
|
|
5
|
+
*
|
|
6
|
+
* v4.0: Runtime scope enforcement for task-work alignment.
|
|
7
|
+
* Validates that file edits are within the task's declared scope
|
|
8
|
+
* (from spec's filesToChange).
|
|
9
|
+
*
|
|
10
|
+
* This module wraps task-gate and adds scope checking:
|
|
11
|
+
* 1. First checks if a task is active (via task-gate)
|
|
12
|
+
* 2. Then checks if the file being edited is in the task's scope
|
|
13
|
+
* 3. Warns or blocks based on configuration
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
// Import from parent scripts directory
|
|
19
|
+
const { getConfig, getProjectRoot } = require('../../flow-utils');
|
|
20
|
+
const { checkTaskGate, getActiveTask } = require('./task-gate');
|
|
21
|
+
const { getSessionFileScope } = require('../../flow-durable-session');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if scope gating is enabled
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
function isScopeGatingEnabled() {
|
|
28
|
+
const config = getConfig();
|
|
29
|
+
|
|
30
|
+
// Check hooks config
|
|
31
|
+
if (config.hooks?.rules?.scopeGating?.enabled === false) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Default to enabled if not explicitly disabled
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the scope gating mode
|
|
41
|
+
* @returns {string} 'warn' | 'block'
|
|
42
|
+
*/
|
|
43
|
+
function getScopeGatingMode() {
|
|
44
|
+
const config = getConfig();
|
|
45
|
+
return config.hooks?.rules?.scopeGating?.mode || 'warn';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get exempt patterns from config
|
|
50
|
+
* @returns {string[]}
|
|
51
|
+
*/
|
|
52
|
+
function getExemptPatterns() {
|
|
53
|
+
const config = getConfig();
|
|
54
|
+
return config.hooks?.rules?.scopeGating?.exemptPatterns || [
|
|
55
|
+
'.workflow/state/**',
|
|
56
|
+
'.workflow/specs/**',
|
|
57
|
+
'.workflow/plans/**',
|
|
58
|
+
'package.json',
|
|
59
|
+
'tsconfig.json',
|
|
60
|
+
'package-lock.json'
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate that a path is within the project root (prevents path traversal)
|
|
66
|
+
* @param {string} filePath - The file path to validate
|
|
67
|
+
* @returns {boolean} True if path is within project
|
|
68
|
+
*/
|
|
69
|
+
function isPathWithinProject(filePath) {
|
|
70
|
+
try {
|
|
71
|
+
const projectRoot = getProjectRoot();
|
|
72
|
+
const resolvedPath = path.resolve(projectRoot, filePath);
|
|
73
|
+
return resolvedPath.startsWith(projectRoot + path.sep) || resolvedPath === projectRoot;
|
|
74
|
+
} catch (_err) {
|
|
75
|
+
// If we can't resolve, reject for safety
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a file path matches a pattern
|
|
82
|
+
* Supports:
|
|
83
|
+
* - Exact paths: 'src/index.ts'
|
|
84
|
+
* - Directory patterns: 'src/components/**' (recursive)
|
|
85
|
+
* - Directory patterns: 'src/components/*' (direct children only)
|
|
86
|
+
*
|
|
87
|
+
* @param {string} filePath - The file being edited
|
|
88
|
+
* @param {string} pattern - The pattern to match against
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
function matchesPattern(filePath, pattern) {
|
|
92
|
+
// Validate inputs
|
|
93
|
+
if (!filePath || !pattern || typeof filePath !== 'string' || typeof pattern !== 'string') {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Reject path traversal attempts
|
|
98
|
+
if (pattern.includes('..') || filePath.includes('..')) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Normalize paths (convert backslashes to forward slashes for consistency)
|
|
103
|
+
const normalizedFile = path.normalize(filePath).replace(/\\/g, '/');
|
|
104
|
+
const normalizedPattern = path.normalize(pattern).replace(/\\/g, '/');
|
|
105
|
+
|
|
106
|
+
// Validate the file path is within project after normalization
|
|
107
|
+
if (!isPathWithinProject(normalizedFile)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Exact match
|
|
112
|
+
if (normalizedFile === normalizedPattern) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Directory pattern (ends with /**) - recursive match
|
|
117
|
+
if (normalizedPattern.endsWith('/**')) {
|
|
118
|
+
const dirPrefix = normalizedPattern.slice(0, -3);
|
|
119
|
+
return normalizedFile.startsWith(dirPrefix + '/') || normalizedFile === dirPrefix;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Directory pattern (ends with /*) - direct children only
|
|
123
|
+
if (normalizedPattern.endsWith('/*')) {
|
|
124
|
+
const dirPrefix = normalizedPattern.slice(0, -2);
|
|
125
|
+
// Only match files directly in the directory, not subdirectories
|
|
126
|
+
if (!normalizedFile.startsWith(dirPrefix + '/')) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const relativePath = normalizedFile.slice(dirPrefix.length + 1);
|
|
130
|
+
return !relativePath.includes('/');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// File is within a directory specified as scope (without glob)
|
|
134
|
+
// e.g., scope "src/utils" matches "src/utils/helper.ts"
|
|
135
|
+
if (normalizedFile.startsWith(normalizedPattern + '/')) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if a file is in the exempt list
|
|
144
|
+
* @param {string} filePath - The file being edited
|
|
145
|
+
* @returns {boolean}
|
|
146
|
+
*/
|
|
147
|
+
function isFileExempt(filePath) {
|
|
148
|
+
if (!filePath) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const exemptPatterns = getExemptPatterns();
|
|
153
|
+
|
|
154
|
+
for (const pattern of exemptPatterns) {
|
|
155
|
+
if (matchesPattern(filePath, pattern)) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if a file is within the task's declared scope
|
|
165
|
+
*
|
|
166
|
+
* @param {string} filePath - The file being edited
|
|
167
|
+
* @param {Object} filesToChange - The scope object from spec
|
|
168
|
+
* @param {Array} filesToChange.create - Files to create
|
|
169
|
+
* @param {Array} filesToChange.modify - Files to modify (may be objects with .path)
|
|
170
|
+
* @param {Array} filesToChange.delete - Files to delete
|
|
171
|
+
* @returns {boolean}
|
|
172
|
+
*/
|
|
173
|
+
function isFileInScope(filePath, filesToChange) {
|
|
174
|
+
// If no scope object provided, scope gating is disabled for this task
|
|
175
|
+
if (!filesToChange) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Extract all file paths from the scope
|
|
180
|
+
const allFiles = [];
|
|
181
|
+
|
|
182
|
+
// Add create files
|
|
183
|
+
if (Array.isArray(filesToChange.create)) {
|
|
184
|
+
for (const file of filesToChange.create) {
|
|
185
|
+
if (typeof file === 'string' && file.length > 0) {
|
|
186
|
+
allFiles.push(file);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Add modify files (may be strings or objects with .path)
|
|
192
|
+
if (Array.isArray(filesToChange.modify)) {
|
|
193
|
+
for (const file of filesToChange.modify) {
|
|
194
|
+
if (typeof file === 'string' && file.length > 0) {
|
|
195
|
+
allFiles.push(file);
|
|
196
|
+
} else if (file && typeof file.path === 'string' && file.path.length > 0) {
|
|
197
|
+
allFiles.push(file.path);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Add delete files
|
|
203
|
+
if (Array.isArray(filesToChange.delete)) {
|
|
204
|
+
for (const file of filesToChange.delete) {
|
|
205
|
+
if (typeof file === 'string' && file.length > 0) {
|
|
206
|
+
allFiles.push(file);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// If scope is explicitly defined but empty, no changes are allowed
|
|
212
|
+
// This means the spec declared filesToChange but with no files
|
|
213
|
+
if (allFiles.length === 0) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Check if file matches any scope pattern
|
|
218
|
+
for (const pattern of allFiles) {
|
|
219
|
+
if (matchesPattern(filePath, pattern)) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Generate warning message for out-of-scope edits
|
|
229
|
+
* @param {string} filePath - The file being edited
|
|
230
|
+
* @param {Object} task - The active task
|
|
231
|
+
* @param {Object} filesToChange - The scope object
|
|
232
|
+
* @returns {string}
|
|
233
|
+
*/
|
|
234
|
+
function generateScopeWarning(filePath, task, filesToChange) {
|
|
235
|
+
// Defensive: handle missing inputs gracefully
|
|
236
|
+
if (!filePath || !task || !filesToChange) {
|
|
237
|
+
return 'Scope warning: File not in task scope (insufficient context for details)';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const fileName = path.basename(filePath);
|
|
241
|
+
const fileCount = (filesToChange.create?.length || 0) +
|
|
242
|
+
(filesToChange.modify?.length || 0) +
|
|
243
|
+
(filesToChange.delete?.length || 0);
|
|
244
|
+
|
|
245
|
+
return `Scope Warning: Editing ${fileName} which is not in task scope.
|
|
246
|
+
Task: ${task.id}${task.title ? ' - ' + task.title : ''}
|
|
247
|
+
Spec defines ${fileCount} file(s) in scope.
|
|
248
|
+
Proceed with caution - this file may not be related to your current task.`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Generate block message for out-of-scope edits
|
|
253
|
+
* @param {string} filePath - The file being edited
|
|
254
|
+
* @param {Object} task - The active task
|
|
255
|
+
* @param {Object} filesToChange - The scope object
|
|
256
|
+
* @returns {string}
|
|
257
|
+
*/
|
|
258
|
+
function generateScopeBlockMessage(filePath, task, filesToChange) {
|
|
259
|
+
// Defensive: handle missing inputs gracefully
|
|
260
|
+
if (!filePath || !task || !filesToChange) {
|
|
261
|
+
return 'Scope Violation: Cannot edit file - not in task scope';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const fileName = path.basename(filePath);
|
|
265
|
+
|
|
266
|
+
// Get first few files from scope for context
|
|
267
|
+
const scopeFiles = [
|
|
268
|
+
...(filesToChange.create || []).slice(0, 2),
|
|
269
|
+
...(filesToChange.modify || []).slice(0, 2).map(file => typeof file === 'string' ? file : file?.path)
|
|
270
|
+
].filter(Boolean).slice(0, 3);
|
|
271
|
+
|
|
272
|
+
return `Scope Violation: Cannot edit ${fileName}
|
|
273
|
+
|
|
274
|
+
Task: ${task.id}${task.title ? ' - ' + task.title : ''}
|
|
275
|
+
Expected scope includes: ${scopeFiles.join(', ')}${scopeFiles.length >= 3 ? ', ...' : ''}
|
|
276
|
+
|
|
277
|
+
To proceed:
|
|
278
|
+
1. Update the spec to include this file, OR
|
|
279
|
+
2. Complete current task and start a new one for this file, OR
|
|
280
|
+
3. Set scopeGating.mode to "warn" in config to allow with warning`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Main scope gate check
|
|
285
|
+
* Wraps task-gate and adds scope validation
|
|
286
|
+
*
|
|
287
|
+
* @param {Object} options
|
|
288
|
+
* @param {string} options.filePath - Path being edited/written
|
|
289
|
+
* @param {string} options.operation - 'edit' or 'write'
|
|
290
|
+
* @returns {Object} Result: { allowed, blocked, message, warning, task, scopeChecked, inScope }
|
|
291
|
+
*/
|
|
292
|
+
function checkScopeGate(options = {}) {
|
|
293
|
+
const { filePath } = options;
|
|
294
|
+
|
|
295
|
+
// First, run the normal task gate
|
|
296
|
+
const taskResult = checkTaskGate(options);
|
|
297
|
+
|
|
298
|
+
// If task gate blocked, return that result (no task active)
|
|
299
|
+
if (taskResult.blocked) {
|
|
300
|
+
return taskResult;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Check if scope gating is enabled
|
|
304
|
+
if (!isScopeGatingEnabled()) {
|
|
305
|
+
return { ...taskResult, scopeChecked: false, reason: 'scope_gating_disabled' };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Check if file is exempt
|
|
309
|
+
if (filePath && isFileExempt(filePath)) {
|
|
310
|
+
return { ...taskResult, scopeChecked: true, inScope: true, reason: 'file_exempt' };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Get the active task
|
|
314
|
+
const activeTask = taskResult.task || getActiveTask();
|
|
315
|
+
if (!activeTask) {
|
|
316
|
+
return { ...taskResult, scopeChecked: false, reason: 'no_active_task' };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Get scope from durable session
|
|
320
|
+
const filesToChange = getSessionFileScope();
|
|
321
|
+
|
|
322
|
+
// If no scope defined (spec didn't have filesToChange), skip scope check
|
|
323
|
+
// This allows tasks without specs to work normally
|
|
324
|
+
if (!filesToChange) {
|
|
325
|
+
if (process.env.DEBUG) {
|
|
326
|
+
console.log('[scope-gate] No scope defined in session - skipping scope enforcement');
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
...taskResult,
|
|
330
|
+
scopeChecked: false,
|
|
331
|
+
reason: 'no_scope_defined'
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Check if file is in scope
|
|
336
|
+
const inScope = isFileInScope(filePath, filesToChange);
|
|
337
|
+
|
|
338
|
+
if (inScope) {
|
|
339
|
+
return {
|
|
340
|
+
...taskResult,
|
|
341
|
+
scopeChecked: true,
|
|
342
|
+
inScope: true,
|
|
343
|
+
reason: 'in_scope'
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// File is NOT in scope - warn or block based on config
|
|
348
|
+
const mode = getScopeGatingMode();
|
|
349
|
+
|
|
350
|
+
if (mode === 'warn') {
|
|
351
|
+
return {
|
|
352
|
+
...taskResult,
|
|
353
|
+
scopeChecked: true,
|
|
354
|
+
inScope: false,
|
|
355
|
+
warning: generateScopeWarning(filePath, activeTask, filesToChange),
|
|
356
|
+
reason: 'out_of_scope_warning'
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Block mode
|
|
361
|
+
return {
|
|
362
|
+
allowed: false,
|
|
363
|
+
blocked: true,
|
|
364
|
+
scopeChecked: true,
|
|
365
|
+
inScope: false,
|
|
366
|
+
message: generateScopeBlockMessage(filePath, activeTask, filesToChange),
|
|
367
|
+
reason: 'out_of_scope_blocked'
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
module.exports = {
|
|
372
|
+
checkScopeGate,
|
|
373
|
+
isFileInScope,
|
|
374
|
+
isFileExempt,
|
|
375
|
+
matchesPattern,
|
|
376
|
+
isScopeGatingEnabled,
|
|
377
|
+
getScopeGatingMode,
|
|
378
|
+
isPathWithinProject,
|
|
379
|
+
generateScopeWarning,
|
|
380
|
+
generateScopeBlockMessage
|
|
381
|
+
};
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* Wogi Flow - Claude Code PreToolUse Hook
|
|
5
5
|
*
|
|
6
6
|
* Called before Edit/Write/TodoWrite tool execution.
|
|
7
|
-
* Enforces task gating, component reuse checking, and TodoWrite gating.
|
|
7
|
+
* Enforces task gating, scope validation, component reuse checking, and TodoWrite gating.
|
|
8
|
+
*
|
|
9
|
+
* v4.0: Added scope gating to validate edits are within task's declared scope
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
|
-
const {
|
|
12
|
+
const { checkScopeGate } = require('../../core/scope-gate');
|
|
11
13
|
const { checkComponentReuse } = require('../../core/component-check');
|
|
12
14
|
const { checkTodoWriteGate } = require('../../core/todowrite-gate');
|
|
13
15
|
const { claudeCodeAdapter } = require('../../adapters/claude-code');
|
|
@@ -61,14 +63,15 @@ async function main() {
|
|
|
61
63
|
|
|
62
64
|
let coreResult = { allowed: true, blocked: false };
|
|
63
65
|
|
|
64
|
-
// Task gating check (for Edit and Write)
|
|
66
|
+
// Task + scope gating check (for Edit and Write)
|
|
67
|
+
// v4.0: checkScopeGate wraps checkTaskGate and adds scope validation
|
|
65
68
|
if (toolName === 'Edit' || toolName === 'Write') {
|
|
66
|
-
coreResult =
|
|
69
|
+
coreResult = checkScopeGate({
|
|
67
70
|
filePath,
|
|
68
71
|
operation: toolName.toLowerCase()
|
|
69
72
|
});
|
|
70
73
|
|
|
71
|
-
// If blocked by task gating, return early
|
|
74
|
+
// If blocked by task or scope gating, return early
|
|
72
75
|
if (coreResult.blocked) {
|
|
73
76
|
const output = claudeCodeAdapter.transformResult('PreToolUse', coreResult);
|
|
74
77
|
console.log(JSON.stringify(output));
|