@manukyalo/scopelock 2.2.0 ā 3.0.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.
- package/README.md +59 -44
- package/bin/scopelock.js +57 -19
- package/package.json +1 -1
- package/skills/blast-radius/SKILL.md +56 -0
- package/skills/dependency-lockdown/SKILL.md +37 -0
- package/skills/production-path-lock/SKILL.md +62 -0
- package/skills/rollback-snapshot/SKILL.md +44 -0
- package/skills/scope-enforcement/SKILL.md +7 -7
- package/skills/secret-sentinel/SKILL.md +51 -0
- package/skills/test-coverage-gate/SKILL.md +3 -3
- package/src/blast.js +160 -0
- package/src/git.js +167 -166
- package/src/manifest.js +84 -21
- package/test/run.js +54 -25
package/src/blast.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* src/blast.js
|
|
5
|
+
*
|
|
6
|
+
* Cross-File Blast Radius Map.
|
|
7
|
+
*
|
|
8
|
+
* Given a file path, scan the entire repo for any file that imports or
|
|
9
|
+
* requires it. Returns an array of dependent file paths.
|
|
10
|
+
*
|
|
11
|
+
* Handles the following import patterns (JS/TS/Python/Go):
|
|
12
|
+
* import ... from './path/to/file'
|
|
13
|
+
* require('./path/to/file')
|
|
14
|
+
* from './path/to/file' import ... (Python)
|
|
15
|
+
* import "./path/to/file"
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
// Directories that are never part of the blast radius analysis.
|
|
22
|
+
const IGNORED_DIRS = new Set([
|
|
23
|
+
'node_modules', '.git', '.next', 'dist', 'build', 'out', 'coverage',
|
|
24
|
+
'.turbo', '.cache', '__pycache__', '.venv', 'venv', 'target',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Recursively walk a directory and collect all files.
|
|
29
|
+
* @param {string} dir
|
|
30
|
+
* @returns {string[]}
|
|
31
|
+
*/
|
|
32
|
+
function walkDir(dir) {
|
|
33
|
+
const results = [];
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
37
|
+
} catch {
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (entry.name.startsWith('.') && entry.name !== '.') continue;
|
|
42
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
43
|
+
const full = path.join(dir, entry.name);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
results.push(...walkDir(full));
|
|
46
|
+
} else {
|
|
47
|
+
results.push(full);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generate all possible import path variants for a given target file.
|
|
55
|
+
* For example, given src/auth/token.ts, this yields:
|
|
56
|
+
* ./token, ../auth/token, ../../src/auth/token, etc.
|
|
57
|
+
* We match against these from each candidate file's directory.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} targetRelative e.g. "src/auth/token.ts"
|
|
60
|
+
* @returns {string[]} stem variants (without extension, for partial matching)
|
|
61
|
+
*/
|
|
62
|
+
function getTargetStems(targetRelative) {
|
|
63
|
+
const normalized = targetRelative.replace(/\\/g, '/');
|
|
64
|
+
const noExt = normalized.replace(/\.[^.]+$/, '');
|
|
65
|
+
return [normalized, noExt];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if a file's content contains an import/require of the target.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} fileContent
|
|
72
|
+
* @param {string[]} targetStems
|
|
73
|
+
* @param {string} fileDir Absolute directory of the importing file
|
|
74
|
+
* @param {string} repoRoot Absolute repo root
|
|
75
|
+
* @param {string} targetAbsolute Absolute path of the target file
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
function fileImportsTarget(fileContent, targetAbsolute, fileDir) {
|
|
79
|
+
// Extract all quoted string literals that look like import paths
|
|
80
|
+
const importPathRe = /(?:from|import|require)\s*\(?['"]([^'"]+)['"]\)?/g;
|
|
81
|
+
let match;
|
|
82
|
+
while ((match = importPathRe.exec(fileContent)) !== null) {
|
|
83
|
+
const importPath = match[1];
|
|
84
|
+
// Only resolve relative paths; skip node_module specifiers
|
|
85
|
+
if (!importPath.startsWith('.')) continue;
|
|
86
|
+
try {
|
|
87
|
+
const resolved = path.resolve(fileDir, importPath);
|
|
88
|
+
// Match with or without extension
|
|
89
|
+
const targetNoExt = targetAbsolute.replace(/\.[^.]+$/, '');
|
|
90
|
+
if (
|
|
91
|
+
resolved === targetAbsolute ||
|
|
92
|
+
resolved === targetNoExt ||
|
|
93
|
+
resolved + path.extname(targetAbsolute) === targetAbsolute
|
|
94
|
+
) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// If resolution fails, skip
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compute the full blast radius for a given file.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} targetFile Relative or absolute path to the file.
|
|
108
|
+
* @returns {{ target: string, dependents: string[], total: number }}
|
|
109
|
+
*/
|
|
110
|
+
function blastRadius(targetFile) {
|
|
111
|
+
const repoRoot = process.cwd();
|
|
112
|
+
const targetAbs = path.resolve(repoRoot, targetFile);
|
|
113
|
+
const targetRel = path.relative(repoRoot, targetAbs).replace(/\\/g, '/');
|
|
114
|
+
const allFiles = walkDir(repoRoot);
|
|
115
|
+
const dependents = [];
|
|
116
|
+
|
|
117
|
+
for (const f of allFiles) {
|
|
118
|
+
// Skip the target itself
|
|
119
|
+
if (path.resolve(f) === targetAbs) continue;
|
|
120
|
+
|
|
121
|
+
let content;
|
|
122
|
+
try {
|
|
123
|
+
content = fs.readFileSync(f, 'utf8');
|
|
124
|
+
} catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (fileImportsTarget(content, targetAbs, path.dirname(f))) {
|
|
129
|
+
dependents.push(path.relative(repoRoot, f).replace(/\\/g, '/'));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { target: targetRel, dependents, total: dependents.length };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Print a human-readable blast radius report to stdout.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} targetFile
|
|
140
|
+
*/
|
|
141
|
+
function printBlastRadius(targetFile) {
|
|
142
|
+
const { target, dependents, total } = blastRadius(targetFile);
|
|
143
|
+
|
|
144
|
+
console.log(`\nš„ Blast Radius: ${target}\n`);
|
|
145
|
+
|
|
146
|
+
if (total === 0) {
|
|
147
|
+
console.log(' No other files import this file. Safe to modify.\n');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(` ${total} file(s) directly import this file:\n`);
|
|
152
|
+
for (const dep of dependents) {
|
|
153
|
+
console.log(` ā ${dep}`);
|
|
154
|
+
}
|
|
155
|
+
console.log('');
|
|
156
|
+
console.log(` ā ļø Modifying '${target}' may impact all ${total} of the above file(s).`);
|
|
157
|
+
console.log(` Run 'scopelock lock ${target}' to protect it before your session.\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { blastRadius, printBlastRadius };
|
package/src/git.js
CHANGED
|
@@ -1,166 +1,167 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* src/git.js
|
|
5
|
-
*
|
|
6
|
-
* Scope violation checker ā V2.
|
|
7
|
-
*
|
|
8
|
-
* Two tiers of enforcement:
|
|
9
|
-
* 1. File-level: Any changed file whose manifest status is 'locked' ā violation.
|
|
10
|
-
* 2. Function-level: Any changed file that has locked functions ā
|
|
11
|
-
* parse the diff for changed line numbers, re-extract
|
|
12
|
-
* function boundaries from the current file, and flag
|
|
13
|
-
* any changed line that falls inside a locked function.
|
|
14
|
-
*
|
|
15
|
-
* Exits 1 if any violation found. Wireable as a pre-commit hook.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
const { execSync } = require('child_process');
|
|
19
|
-
const { getManifest } = require('./manifest');
|
|
20
|
-
const { getChangedLines } = require('./diff');
|
|
21
|
-
const { extractFunctions } = require('./parser');
|
|
22
|
-
const { detectSecret } = require('./secrets');
|
|
23
|
-
|
|
24
|
-
function
|
|
25
|
-
const requireTests = args.includes('--
|
|
26
|
-
const manifest = getManifest();
|
|
27
|
-
let diffOutput;
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
diffOutput = execSync('git diff HEAD --name-only', {
|
|
31
|
-
encoding: 'utf8',
|
|
32
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
33
|
-
});
|
|
34
|
-
} catch (err) {
|
|
35
|
-
console.error('git error ā are you inside a git repository with at least one commit?');
|
|
36
|
-
console.error(err.message);
|
|
37
|
-
process.exit(1);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const changedFiles = diffOutput
|
|
41
|
-
.split('\n')
|
|
42
|
-
.map(f => f.trim())
|
|
43
|
-
.filter(f => f.length > 0);
|
|
44
|
-
|
|
45
|
-
if (changedFiles.length === 0) {
|
|
46
|
-
console.log('ā
Scope
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const violations = [];
|
|
51
|
-
|
|
52
|
-
// āā Tier -1: Test Coverage Gate āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
53
|
-
if (requireTests) {
|
|
54
|
-
const hasSourceChanges = changedFiles.some(f =>
|
|
55
|
-
!f.includes('.test.') && !f.includes('.spec.') && !f.includes('/test/') && !f.includes('/__tests__/') &&
|
|
56
|
-
(f.endsWith('.js') || f.endsWith('.ts') || f.endsWith('.py') || f.endsWith('.go') || f.endsWith('.rs'))
|
|
57
|
-
);
|
|
58
|
-
const hasTestChanges = changedFiles.some(f =>
|
|
59
|
-
f.includes('.test.') || f.includes('.spec.') || f.includes('/test/') || f.includes('/__tests__/')
|
|
60
|
-
);
|
|
61
|
-
if (hasSourceChanges && !hasTestChanges) {
|
|
62
|
-
violations.push({
|
|
63
|
-
type: 'test-gate',
|
|
64
|
-
file: 'N/A',
|
|
65
|
-
message: 'TEST GATE VIOLATION: Source logic was modified, but no tests were added or updated. You must write tests to pass `--
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
for (const file of changedFiles) {
|
|
71
|
-
const normalizedFile = file.replace(/\\/g, '/');
|
|
72
|
-
const entry = manifest.files[normalizedFile];
|
|
73
|
-
|
|
74
|
-
const changedLines = getChangedLines(normalizedFile);
|
|
75
|
-
|
|
76
|
-
// āā Tier 0: Secret Sentinel āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
77
|
-
if (changedLines.size > 0) {
|
|
78
|
-
// Check if this file has explicitly allowed secrets
|
|
79
|
-
const hasOverride = manifest.allowedSecrets && manifest.allowedSecrets[normalizedFile];
|
|
80
|
-
if (!hasOverride) {
|
|
81
|
-
for (const [lineNum, content] of changedLines.entries()) {
|
|
82
|
-
const secretType = detectSecret(content);
|
|
83
|
-
if (secretType) {
|
|
84
|
-
violations.push({
|
|
85
|
-
type: 'secret',
|
|
86
|
-
file: normalizedFile,
|
|
87
|
-
message: `SECRET LEAK [${secretType}] detected in '${normalizedFile}' on line ${lineNum}.`,
|
|
88
|
-
});
|
|
89
|
-
break; // One secret violation per file is enough
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// āā Tier 1: File-level lock āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
96
|
-
if (entry && entry.status === 'locked') {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
`
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
` ā¢
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* src/git.js
|
|
5
|
+
*
|
|
6
|
+
* Scope violation checker ā V2.
|
|
7
|
+
*
|
|
8
|
+
* Two tiers of enforcement:
|
|
9
|
+
* 1. File-level: Any changed file whose manifest status is 'locked' ā violation.
|
|
10
|
+
* 2. Function-level: Any changed file that has locked functions ā
|
|
11
|
+
* parse the diff for changed line numbers, re-extract
|
|
12
|
+
* function boundaries from the current file, and flag
|
|
13
|
+
* any changed line that falls inside a locked function.
|
|
14
|
+
*
|
|
15
|
+
* Exits 1 if any violation found. Wireable as a pre-commit hook.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { execSync } = require('child_process');
|
|
19
|
+
const { getManifest } = require('./manifest');
|
|
20
|
+
const { getChangedLines } = require('./diff');
|
|
21
|
+
const { extractFunctions } = require('./parser');
|
|
22
|
+
const { detectSecret } = require('./secrets');
|
|
23
|
+
|
|
24
|
+
function guard(args = []) {
|
|
25
|
+
const requireTests = args.includes('--tests');
|
|
26
|
+
const manifest = getManifest();
|
|
27
|
+
let diffOutput;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
diffOutput = execSync('git diff HEAD --name-only', {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
33
|
+
});
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error('git error ā are you inside a git repository with at least one commit?');
|
|
36
|
+
console.error(err.message);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const changedFiles = diffOutput
|
|
41
|
+
.split('\n')
|
|
42
|
+
.map(f => f.trim())
|
|
43
|
+
.filter(f => f.length > 0);
|
|
44
|
+
|
|
45
|
+
if (changedFiles.length === 0) {
|
|
46
|
+
console.log('ā
Scope guard passed ā no changes detected.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const violations = [];
|
|
51
|
+
|
|
52
|
+
// āā Tier -1: Test Coverage Gate āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
53
|
+
if (requireTests) {
|
|
54
|
+
const hasSourceChanges = changedFiles.some(f =>
|
|
55
|
+
!f.includes('.test.') && !f.includes('.spec.') && !f.includes('/test/') && !f.includes('/__tests__/') &&
|
|
56
|
+
(f.endsWith('.js') || f.endsWith('.ts') || f.endsWith('.py') || f.endsWith('.go') || f.endsWith('.rs'))
|
|
57
|
+
);
|
|
58
|
+
const hasTestChanges = changedFiles.some(f =>
|
|
59
|
+
f.includes('.test.') || f.includes('.spec.') || f.includes('/test/') || f.includes('/__tests__/')
|
|
60
|
+
);
|
|
61
|
+
if (hasSourceChanges && !hasTestChanges) {
|
|
62
|
+
violations.push({
|
|
63
|
+
type: 'test-gate',
|
|
64
|
+
file: 'N/A',
|
|
65
|
+
message: 'TEST GATE VIOLATION: Source logic was modified, but no tests were added or updated. You must write tests to pass `--tests`.'
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const file of changedFiles) {
|
|
71
|
+
const normalizedFile = file.replace(/\\/g, '/');
|
|
72
|
+
const entry = manifest.files[normalizedFile];
|
|
73
|
+
|
|
74
|
+
const changedLines = getChangedLines(normalizedFile);
|
|
75
|
+
|
|
76
|
+
// āā Tier 0: Secret Sentinel āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
77
|
+
if (changedLines.size > 0) {
|
|
78
|
+
// Check if this file has explicitly allowed secrets
|
|
79
|
+
const hasOverride = manifest.allowedSecrets && manifest.allowedSecrets[normalizedFile];
|
|
80
|
+
if (!hasOverride) {
|
|
81
|
+
for (const [lineNum, content] of changedLines.entries()) {
|
|
82
|
+
const secretType = detectSecret(content);
|
|
83
|
+
if (secretType) {
|
|
84
|
+
violations.push({
|
|
85
|
+
type: 'secret',
|
|
86
|
+
file: normalizedFile,
|
|
87
|
+
message: `SECRET LEAK [${secretType}] detected in '${normalizedFile}' on line ${lineNum}.`,
|
|
88
|
+
});
|
|
89
|
+
break; // One secret violation per file is enough
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// āā Tier 1: File-level lock āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
96
|
+
if (entry && (entry.status === 'locked' || entry.status === 'sealed')) {
|
|
97
|
+
const label = entry.status === 'sealed' ? 'SEALED' : 'LOCKED';
|
|
98
|
+
violations.push({
|
|
99
|
+
type: 'file',
|
|
100
|
+
file: normalizedFile,
|
|
101
|
+
message: `File '${normalizedFile}' is ${label}.`,
|
|
102
|
+
});
|
|
103
|
+
continue; // No need to check functions if the whole file is locked
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// āā Tier 2: Function-level lock āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
107
|
+
if (!entry || !entry.functions) continue;
|
|
108
|
+
|
|
109
|
+
const lockedFunctions = Object.entries(entry.functions)
|
|
110
|
+
.filter(([, fnData]) => fnData.status === 'locked')
|
|
111
|
+
.map(([name]) => name);
|
|
112
|
+
|
|
113
|
+
if (lockedFunctions.length === 0) continue;
|
|
114
|
+
|
|
115
|
+
// Re-extract function boundaries from the current on-disk file
|
|
116
|
+
const currentFunctions = extractFunctions(normalizedFile);
|
|
117
|
+
|
|
118
|
+
for (const lockedFnName of lockedFunctions) {
|
|
119
|
+
const fn = currentFunctions.find(f => f.name === lockedFnName);
|
|
120
|
+
if (!fn) {
|
|
121
|
+
// Function was deleted or renamed ā this itself is a violation
|
|
122
|
+
violations.push({
|
|
123
|
+
type: 'function-missing',
|
|
124
|
+
file: normalizedFile,
|
|
125
|
+
fn: lockedFnName,
|
|
126
|
+
message: `Locked function '${lockedFnName}' in '${normalizedFile}' was removed or renamed.`,
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check if any changed line falls within the function's boundaries
|
|
132
|
+
for (const line of changedLines.keys()) {
|
|
133
|
+
if (line >= fn.startLine && line <= fn.endLine) {
|
|
134
|
+
violations.push({
|
|
135
|
+
type: 'function',
|
|
136
|
+
file: normalizedFile,
|
|
137
|
+
fn: lockedFnName,
|
|
138
|
+
message:
|
|
139
|
+
`Locked function '${lockedFnName}' in '${normalizedFile}' was modified ` +
|
|
140
|
+
`(changed line ${line} is inside [${fn.startLine}ā${fn.endLine}]).`,
|
|
141
|
+
});
|
|
142
|
+
break; // One violation per function is enough
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// āā Report āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
149
|
+
if (violations.length === 0) {
|
|
150
|
+
console.log('ā
Scope guard passed ā no locked files or functions were modified.');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.error(`\nā Scope violations detected:\n`);
|
|
155
|
+
for (const v of violations) {
|
|
156
|
+
console.error(` VIOLATION: ${v.message}`);
|
|
157
|
+
}
|
|
158
|
+
console.error(
|
|
159
|
+
`\n${violations.length} violation(s) found.\n` +
|
|
160
|
+
` ⢠Revert unintentional changes with: git restore <file>\n` +
|
|
161
|
+
` ⢠Explicitly unlock with: scopelock unlock <file>[:<function>] "<reason>"`
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { guard };
|
package/src/manifest.js
CHANGED
|
@@ -177,6 +177,63 @@ function lock(target, reason = 'manually locked') {
|
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Seal a file ā permanent, override-resistant production path lock.
|
|
182
|
+
* Cannot be removed by 'unlock'. Requires 'unseal' with a human-approved ticket.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} file File path
|
|
185
|
+
* @param {string} reason Mandatory reason string
|
|
186
|
+
*/
|
|
187
|
+
function seal(file, reason) {
|
|
188
|
+
const relativePath = file.replace(/\\/g, '/');
|
|
189
|
+
const manifest = getManifest();
|
|
190
|
+
ensureFileEntry(manifest, relativePath);
|
|
191
|
+
const entry = manifest.files[relativePath];
|
|
192
|
+
|
|
193
|
+
entry.status = 'sealed';
|
|
194
|
+
entry.history.push({
|
|
195
|
+
timestamp: new Date().toISOString(),
|
|
196
|
+
action: 'sealed',
|
|
197
|
+
reason,
|
|
198
|
+
});
|
|
199
|
+
saveManifest(manifest);
|
|
200
|
+
console.log(`š SEALED ${relativePath}. Only 'scopelock unseal' with a human-approved ticket can release this.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Remove a seal from a file. Requires an explicit human-approved ticket string.
|
|
205
|
+
* This is the only command that can override a 'sealed' file.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} file File path
|
|
208
|
+
* @param {string} ticket Human-approved ticket (e.g. "JIRA-123" or "PR-456")
|
|
209
|
+
* @param {string} reason Mandatory reason string
|
|
210
|
+
*/
|
|
211
|
+
function unseal(file, ticket, reason) {
|
|
212
|
+
if (!ticket || !reason) {
|
|
213
|
+
console.error('Usage: scopelock unseal <file> --human-approved=<ticket> <reason>');
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
const relativePath = file.replace(/\\/g, '/');
|
|
217
|
+
const manifest = getManifest();
|
|
218
|
+
ensureFileEntry(manifest, relativePath);
|
|
219
|
+
const entry = manifest.files[relativePath];
|
|
220
|
+
|
|
221
|
+
if (entry.status !== 'sealed') {
|
|
222
|
+
console.error(`'${relativePath}' is not sealed. Use 'scopelock unlock' instead.`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
entry.status = 'active';
|
|
227
|
+
entry.history.push({
|
|
228
|
+
timestamp: new Date().toISOString(),
|
|
229
|
+
action: 'unsealed',
|
|
230
|
+
humanApproved: ticket,
|
|
231
|
+
reason,
|
|
232
|
+
});
|
|
233
|
+
saveManifest(manifest);
|
|
234
|
+
console.log(`š UNSEALED ${relativePath}. Ticket: ${ticket}. Reason: ${reason}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
180
237
|
/**
|
|
181
238
|
* Unlock a file or a specific function.
|
|
182
239
|
*
|
|
@@ -206,6 +263,15 @@ function unlock(target, reason) {
|
|
|
206
263
|
saveManifest(manifest);
|
|
207
264
|
console.log(`š Unlocked function '${funcName}' in ${relativePath}. Reason: ${reason}`);
|
|
208
265
|
} else {
|
|
266
|
+
// Guard against bypassing a seal with a normal unlock
|
|
267
|
+
if (entry.status === 'sealed') {
|
|
268
|
+
console.error(
|
|
269
|
+
`ā '${relativePath}' is SEALED and cannot be unlocked with 'scopelock unlock'.\n` +
|
|
270
|
+
` This path is a protected production route.\n` +
|
|
271
|
+
` Use: scopelock unseal ${relativePath} --human-approved=<ticket> <reason>`
|
|
272
|
+
);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
209
275
|
entry.status = 'active';
|
|
210
276
|
entry.history.push(historyEntry);
|
|
211
277
|
saveManifest(manifest);
|
|
@@ -220,9 +286,10 @@ function status() {
|
|
|
220
286
|
const manifest = getManifest();
|
|
221
287
|
const files = Object.entries(manifest.files);
|
|
222
288
|
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
const
|
|
289
|
+
const sealed = files.filter(([, v]) => v.status === 'sealed');
|
|
290
|
+
const locked = files.filter(([, v]) => v.status === 'locked');
|
|
291
|
+
const active = files.filter(([, v]) => v.status === 'active');
|
|
292
|
+
const unscoped = files.filter(([, v]) => v.status === 'unscoped');
|
|
226
293
|
|
|
227
294
|
// Count locked functions across all files
|
|
228
295
|
let lockedFnCount = 0;
|
|
@@ -232,10 +299,16 @@ function status() {
|
|
|
232
299
|
}
|
|
233
300
|
|
|
234
301
|
console.log(`\nš scopelock status\n`);
|
|
302
|
+
console.log(` š”ļø sealed ā ${sealed.length} file(s)`);
|
|
235
303
|
console.log(` š locked ā ${locked.length} file(s), ${lockedFnCount} function(s)`);
|
|
236
304
|
console.log(` āļø active ā ${active.length} file(s)`);
|
|
237
305
|
console.log(` ⬠unscoped ā ${unscoped.length} file(s)\n`);
|
|
238
306
|
|
|
307
|
+
if (sealed.length > 0) {
|
|
308
|
+
console.log('\nSealed files:');
|
|
309
|
+
sealed.forEach(([f]) => console.log(` ${f}`));
|
|
310
|
+
}
|
|
311
|
+
|
|
239
312
|
if (locked.length > 0) {
|
|
240
313
|
console.log(`Locked files:`);
|
|
241
314
|
for (const [filePath, data] of locked) {
|
|
@@ -268,30 +341,20 @@ function status() {
|
|
|
268
341
|
}
|
|
269
342
|
|
|
270
343
|
/**
|
|
271
|
-
*
|
|
344
|
+
* Trust a file to contain a mock secret, bypassing the Secret Sentinel.
|
|
272
345
|
*
|
|
273
|
-
* @param {string} file
|
|
274
|
-
* @param {string} reason
|
|
346
|
+
* @param {string} file
|
|
347
|
+
* @param {string} reason
|
|
275
348
|
*/
|
|
276
|
-
function
|
|
349
|
+
function trust(file, reason) {
|
|
277
350
|
const relativePath = file.replace(/\\/g, '/');
|
|
278
351
|
const manifest = getManifest();
|
|
279
|
-
|
|
280
|
-
if (!manifest.allowedSecrets) {
|
|
281
|
-
manifest.allowedSecrets = {};
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (!manifest.allowedSecrets[relativePath]) {
|
|
285
|
-
manifest.allowedSecrets[relativePath] = [];
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
manifest.allowedSecrets[relativePath].push({
|
|
352
|
+
manifest.allowedSecrets[relativePath] = {
|
|
289
353
|
timestamp: new Date().toISOString(),
|
|
290
|
-
reason
|
|
291
|
-
}
|
|
292
|
-
|
|
354
|
+
reason,
|
|
355
|
+
};
|
|
293
356
|
saveManifest(manifest);
|
|
294
357
|
console.log(`ā ļø Secret Sentinel bypassed for ${relativePath}. Reason: ${reason}`);
|
|
295
358
|
}
|
|
296
359
|
|
|
297
|
-
module.exports = { init, lock, unlock,
|
|
360
|
+
module.exports = { init, lock, unlock, seal, unseal, trust, status, getManifest, saveManifest };
|