@manukyalo/scopelock 2.0.0 → 2.2.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 +39 -83
- package/bin/scopelock.js +76 -2
- package/package.json +1 -1
- package/skills/test-coverage-gate/SKILL.md +21 -0
- package/src/diff.js +5 -4
- package/src/git.js +43 -6
- package/src/manifest.js +44 -6
- package/src/secrets.js +31 -0
- package/test/run.js +51 -12
package/README.md
CHANGED
|
@@ -8,53 +8,32 @@ npm install -g @manukyalo/scopelock
|
|
|
8
8
|
|
|
9
9
|
`scopelock` solves a specific, well-documented problem: AI coding agents frequently exhibit scope creep — modifying files outside the intended change — and lack persistent project memory across sessions.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
`scopelock` acts as a physical guardrail (via a pre-commit hook) and an architectural memory bank (via the context block) to keep agents strictly confined to their authorized scope.
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## Features
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
You ask an AI agent to fix the login button. It also refactors your auth middleware, updates 3 unrelated components, and breaks a working API route. You had no pre-commit guardrail to stop it.
|
|
22
|
-
|
|
23
|
-
`scopelock` enforces scope at two levels:
|
|
24
|
-
|
|
25
|
-
| Protection | What it stops |
|
|
26
|
-
|------------|--------------|
|
|
27
|
-
| **File-level** | Agent modifies any file marked `locked` |
|
|
28
|
-
| **Function-level** | Agent modifies lines inside a locked function body, even if the surrounding file is `active` |
|
|
15
|
+
- **File-level locks**: Run `scopelock lock src/auth.ts` to make the file read-only for agents.
|
|
16
|
+
- **Function-level locks**: Don't want to lock the whole file? Lock specific AST functions so agents can only edit adjacent code.
|
|
17
|
+
- **Dependency Lockdown**: Zero-trust dependency management. Automatically locks `package.json` and other dependency manifests on init to prevent silent dependency drift.
|
|
18
|
+
- **Secret Sentinel**: A hard-blocking pre-commit scanner that physically prevents agents from committing AWS keys, Stripe tokens, or `.env` leaks.
|
|
19
|
+
- **Zero dependencies**: Written in pure Node.js. Install it anywhere without bloating your `node_modules`.
|
|
29
20
|
|
|
30
21
|
---
|
|
31
22
|
|
|
32
23
|
## Commands
|
|
33
24
|
|
|
34
|
-
### `scopelock init`
|
|
35
|
-
Scan the repo and generate `.scopelock.json`. Automatically ignores `node_modules`, `.git`, `.next`, `dist`, `build`, `out`, `coverage`, and other build artifacts.
|
|
36
|
-
|
|
37
25
|
```bash
|
|
38
|
-
scopelock init
|
|
26
|
+
scopelock init Scan repo and generate .scopelock.json
|
|
27
|
+
scopelock lock <file>[:<func>] [reason] Lock a file or a specific function
|
|
28
|
+
scopelock unlock <file>[:<func>] <reason> Unlock (reason is mandatory)
|
|
29
|
+
scopelock allow-secret <file> <reason> Bypass Secret Sentinel for a specific file
|
|
30
|
+
scopelock context [task] Generate AI context block for a task
|
|
31
|
+
scopelock check Check git diff for scope violations and secret leaks
|
|
32
|
+
scopelock status Show manifest summary
|
|
39
33
|
```
|
|
40
34
|
|
|
41
|
-
### `scopelock
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
scopelock status
|
|
46
|
-
|
|
47
|
-
# 📋 scopelock status
|
|
48
|
-
#
|
|
49
|
-
# 🔒 locked — 3 file(s), 2 function(s)
|
|
50
|
-
# ✏️ active — 1 file(s)
|
|
51
|
-
# ⬜ unscoped — 98 file(s)
|
|
52
|
-
#
|
|
53
|
-
# Locked files:
|
|
54
|
-
# src/lib/supabase.ts
|
|
55
|
-
# src/middleware.ts
|
|
56
|
-
# └── middleware() [locked]
|
|
57
|
-
```
|
|
35
|
+
### `scopelock init`
|
|
36
|
+
Scan the repo and generate `.scopelock.json`. Automatically ignores `node_modules`, `.git`, `.next`, `dist`, `build`, `out`, `coverage`, and other build artifacts.
|
|
58
37
|
|
|
59
38
|
### `scopelock lock <file>[:<function>] [reason]`
|
|
60
39
|
Lock a whole file or a specific named function.
|
|
@@ -74,8 +53,15 @@ Unlock a file or function. Reason is mandatory and logged.
|
|
|
74
53
|
scopelock unlock src/auth/token.ts:validateToken "fixing JWT expiry edge case"
|
|
75
54
|
```
|
|
76
55
|
|
|
56
|
+
### `scopelock allow-secret <file> <reason>`
|
|
57
|
+
Bypass the Secret Sentinel hard-block for a specific file (e.g., when intentionally committing a mock test key).
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
scopelock allow-secret test/run.js "this is a mock stripe key for testing"
|
|
61
|
+
```
|
|
62
|
+
|
|
77
63
|
### `scopelock check`
|
|
78
|
-
Two-tier scope violation check against `git diff HEAD`. Exits non-zero on violations.
|
|
64
|
+
Two-tier scope violation check against `git diff HEAD`. Exits non-zero on violations or secret leaks. Wire this up as a `pre-commit` hook.
|
|
79
65
|
|
|
80
66
|
```bash
|
|
81
67
|
scopelock check
|
|
@@ -85,57 +71,27 @@ scopelock check
|
|
|
85
71
|
Output a token-efficient AI context block with all locks clearly flagged.
|
|
86
72
|
|
|
87
73
|
```bash
|
|
88
|
-
scopelock context "
|
|
74
|
+
scopelock context "Update the login page"
|
|
89
75
|
```
|
|
90
|
-
|
|
91
|
-
---
|
|
92
|
-
|
|
93
|
-
## Pre-commit Hook
|
|
94
|
-
|
|
95
|
-
```bash
|
|
96
|
-
echo '#!/bin/sh
|
|
97
|
-
scopelock check' > .git/hooks/pre-commit
|
|
98
|
-
chmod +x .git/hooks/pre-commit
|
|
99
76
|
```
|
|
77
|
+
[SCOPE CONTEXT]
|
|
78
|
+
Task: Update the login page
|
|
100
79
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
## File Statuses
|
|
106
|
-
|
|
107
|
-
| Status | Meaning |
|
|
108
|
-
|--------|---------|
|
|
109
|
-
| `unscoped` | Not yet classified |
|
|
110
|
-
| `locked` | Stable — do not modify without an explicit `scopelock unlock` |
|
|
111
|
-
| `active` | In scope for the current task |
|
|
112
|
-
|
|
113
|
-
---
|
|
114
|
-
|
|
115
|
-
## Language Support for Function-level Locking
|
|
80
|
+
Status:
|
|
81
|
+
🔒 locked — 1 file(s)
|
|
82
|
+
✏️ active — 0 file(s)
|
|
83
|
+
⬜ unscoped — 14 file(s)
|
|
116
84
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
| TypeScript | `.ts`, `.tsx` |
|
|
121
|
-
| Python | `.py` |
|
|
85
|
+
Locked files:
|
|
86
|
+
src/lib/supabase.ts
|
|
87
|
+
```
|
|
122
88
|
|
|
123
|
-
|
|
89
|
+
## Agent Skill
|
|
124
90
|
|
|
125
|
-
|
|
91
|
+
`scopelock` includes a native Agent Skill.
|
|
92
|
+
If you use an agent framework (like Antigravity or Cline) that supports Markdown skills, point it to `skills/scope-enforcement/SKILL.md` to automatically teach the agent how to use `scopelock` safely.
|
|
126
93
|
|
|
127
|
-
##
|
|
94
|
+
## Data Model
|
|
95
|
+
All state is stored in `.scopelock.json` at the root of your repo.
|
|
128
96
|
|
|
129
97
|
The manifest is project state, not a personal config. Commit it so your whole team — and all their AI agents — share the same scope boundaries.
|
|
130
|
-
|
|
131
|
-
---
|
|
132
|
-
|
|
133
|
-
## Zero Dependencies
|
|
134
|
-
|
|
135
|
-
Built entirely on Node.js built-ins (`fs`, `path`, `child_process`). No runtime dependencies.
|
|
136
|
-
|
|
137
|
-
---
|
|
138
|
-
|
|
139
|
-
## License
|
|
140
|
-
|
|
141
|
-
MIT
|
package/bin/scopelock.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* scopelock status Print manifest summary
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
const { execSync } = require('child_process');
|
|
16
17
|
const manifest = require('../src/manifest');
|
|
17
18
|
const context = require('../src/context');
|
|
18
19
|
const git = require('../src/git');
|
|
@@ -54,13 +55,82 @@ switch (command) {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
case 'check':
|
|
57
|
-
git.check();
|
|
58
|
+
git.check(args);
|
|
58
59
|
break;
|
|
59
60
|
|
|
60
61
|
case 'status':
|
|
61
62
|
manifest.status();
|
|
62
63
|
break;
|
|
63
64
|
|
|
65
|
+
case 'allow-secret': {
|
|
66
|
+
const target = args[0];
|
|
67
|
+
const reason = args.slice(1).join(' ');
|
|
68
|
+
if (!target || !reason) {
|
|
69
|
+
console.error('Usage: scopelock allow-secret <file> <reason>');
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
manifest.allowSecret(target, reason);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case 'snapshot': {
|
|
77
|
+
try {
|
|
78
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
79
|
+
const manifestObj = manifest.getManifest();
|
|
80
|
+
|
|
81
|
+
// Exclude .scopelock.json from stash — the manifest must survive the snapshot.
|
|
82
|
+
// On Windows, stash@{0} needs to be quoted.
|
|
83
|
+
const out = execSync(
|
|
84
|
+
`git stash push --include-untracked -m "scopelock-snapshot-${ts}" -- . ":(exclude).scopelock.json"`,
|
|
85
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (out.includes('No local changes to save')) {
|
|
89
|
+
manifestObj.lastSnapshot = 'clean';
|
|
90
|
+
} else {
|
|
91
|
+
// Re-apply so the working tree is fully restored; stash stays as our savepoint.
|
|
92
|
+
execSync(`git stash apply "stash@{0}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
93
|
+
manifestObj.lastSnapshot = 'dirty';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
manifest.saveManifest(manifestObj);
|
|
97
|
+
console.log(`✅ Snapshot created. Run 'scopelock revert' to obliterate agent changes and restore this state.`);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.error('Failed to create snapshot.', e.stderr || e.message);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case 'revert': {
|
|
106
|
+
try {
|
|
107
|
+
const manifestObj = manifest.getManifest();
|
|
108
|
+
if (!manifestObj.lastSnapshot) {
|
|
109
|
+
console.error('❌ No snapshot found. Run `scopelock snapshot` first.');
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
console.log(`Obliterating agent mess...`);
|
|
113
|
+
execSync(`git reset --hard HEAD`, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
114
|
+
execSync(`git clean -fd`, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
115
|
+
|
|
116
|
+
if (manifestObj.lastSnapshot === 'dirty') {
|
|
117
|
+
const stashes = execSync('git stash list', { encoding: 'utf8' });
|
|
118
|
+
const match = stashes.match(/stash@\{(\d+)\}: .*?scopelock-snapshot/);
|
|
119
|
+
if (match) {
|
|
120
|
+
execSync(`git stash pop "stash@{${match[1]}}"`, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
manifestObj.lastSnapshot = null;
|
|
125
|
+
manifest.saveManifest(manifestObj);
|
|
126
|
+
console.log(`✅ Rollback complete. The repository has been restored.`);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error('Failed to revert.', e.stderr || e.message);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
|
|
64
134
|
default:
|
|
65
135
|
console.log(`
|
|
66
136
|
scopelock — Anti-hallucination scope locking for AI coding agents.
|
|
@@ -69,10 +139,14 @@ Usage:
|
|
|
69
139
|
scopelock init Scan repo and generate .scopelock.json
|
|
70
140
|
scopelock lock <file>[:<func>] [reason] Lock a file or a specific function
|
|
71
141
|
scopelock unlock <file>[:<func>] <reason> Unlock (reason is mandatory)
|
|
142
|
+
scopelock allow-secret <file> <reason> Bypass Secret Sentinel for a specific file
|
|
72
143
|
scopelock context [task] Generate AI context block for a task
|
|
73
|
-
scopelock
|
|
144
|
+
scopelock snapshot Auto-snapshot repo state before an agent session
|
|
145
|
+
scopelock revert Rollback to the last snapshot
|
|
146
|
+
scopelock check [--require-tests] Check git diff for violations and test coverage
|
|
74
147
|
scopelock status Show manifest summary
|
|
75
148
|
|
|
149
|
+
|
|
76
150
|
Examples:
|
|
77
151
|
scopelock lock src/auth.ts
|
|
78
152
|
scopelock lock src/auth.ts:validateToken "stable — do not touch"
|
package/package.json
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: test-coverage-gate
|
|
3
|
+
description: "Godmode Skill: Forces the AI agent to write or update test files whenever it modifies application logic. Run 'scopelock check --require-tests' before committing to enforce this rule."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
This skill teaches the agent how to navigate the `scopelock` Test Coverage Gate.
|
|
9
|
+
When `--require-tests` is active, the CLI will hard-block any commit that modifies a source file (`.js`, `.ts`, `.py`, etc.) if a corresponding test file (`.test.ts`, `.spec.js`, `test/`) is not also modified in the same diff.
|
|
10
|
+
|
|
11
|
+
## Agent Protocol
|
|
12
|
+
|
|
13
|
+
1. **Before writing code**: If you are about to modify application logic, understand that your changes will be rejected unless you also provide test coverage.
|
|
14
|
+
2. **Write the code**: Implement the requested feature or fix.
|
|
15
|
+
3. **Write the test**: You *must* update the corresponding test file or create a new one. The file path must contain `.test.`, `.spec.`, or be inside a `test/` or `__tests__/` directory.
|
|
16
|
+
4. **Validation**: Run `scopelock check --require-tests` to mathematically verify your diff will pass the coverage gate.
|
|
17
|
+
5. **Ship**: Only after the test gate is passed are you allowed to commit or mark the task as complete.
|
|
18
|
+
|
|
19
|
+
## Why this exists
|
|
20
|
+
|
|
21
|
+
Agents often write code and immediately declare a task "done" without proving it works. This physical gate prevents the agent from bypassing the verification phase. No tests, no merge.
|
package/src/diff.js
CHANGED
|
@@ -17,7 +17,7 @@ const HUNK_HEADER_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* @param {string} filePath
|
|
20
|
-
* @returns {
|
|
20
|
+
* @returns {Map<number, string>} 1-indexed line numbers -> line content that changed in the new file.
|
|
21
21
|
*/
|
|
22
22
|
function getChangedLines(filePath) {
|
|
23
23
|
let diffOutput;
|
|
@@ -31,9 +31,9 @@ function getChangedLines(filePath) {
|
|
|
31
31
|
return new Set();
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
if (!diffOutput.trim()) return new
|
|
34
|
+
if (!diffOutput.trim()) return new Map();
|
|
35
35
|
|
|
36
|
-
const changedLines = new
|
|
36
|
+
const changedLines = new Map();
|
|
37
37
|
const lines = diffOutput.split('\n');
|
|
38
38
|
let currentLine = 0;
|
|
39
39
|
|
|
@@ -51,7 +51,8 @@ function getChangedLines(filePath) {
|
|
|
51
51
|
|
|
52
52
|
if (line.startsWith('+')) {
|
|
53
53
|
// Added/changed line in the new file
|
|
54
|
-
|
|
54
|
+
// Strip the leading '+' before saving the content
|
|
55
|
+
changedLines.set(currentLine, line.substring(1));
|
|
55
56
|
currentLine++;
|
|
56
57
|
} else if (line.startsWith('-')) {
|
|
57
58
|
// Deleted line — does NOT advance the new-file line counter
|
package/src/git.js
CHANGED
|
@@ -19,8 +19,10 @@ const { execSync } = require('child_process');
|
|
|
19
19
|
const { getManifest } = require('./manifest');
|
|
20
20
|
const { getChangedLines } = require('./diff');
|
|
21
21
|
const { extractFunctions } = require('./parser');
|
|
22
|
+
const { detectSecret } = require('./secrets');
|
|
22
23
|
|
|
23
|
-
function check() {
|
|
24
|
+
function check(args = []) {
|
|
25
|
+
const requireTests = args.includes('--require-tests');
|
|
24
26
|
const manifest = getManifest();
|
|
25
27
|
let diffOutput;
|
|
26
28
|
|
|
@@ -46,11 +48,50 @@ function check() {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
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 `--require-tests`.'
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
49
69
|
|
|
50
70
|
for (const file of changedFiles) {
|
|
51
71
|
const normalizedFile = file.replace(/\\/g, '/');
|
|
52
72
|
const entry = manifest.files[normalizedFile];
|
|
53
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
|
+
|
|
54
95
|
// ── Tier 1: File-level lock ─────────────────────────────────────────────
|
|
55
96
|
if (entry && entry.status === 'locked') {
|
|
56
97
|
violations.push({
|
|
@@ -70,10 +111,6 @@ function check() {
|
|
|
70
111
|
|
|
71
112
|
if (lockedFunctions.length === 0) continue;
|
|
72
113
|
|
|
73
|
-
// Get the line numbers that changed in this specific file
|
|
74
|
-
const changedLines = getChangedLines(normalizedFile);
|
|
75
|
-
if (changedLines.size === 0) continue;
|
|
76
|
-
|
|
77
114
|
// Re-extract function boundaries from the current on-disk file
|
|
78
115
|
const currentFunctions = extractFunctions(normalizedFile);
|
|
79
116
|
|
|
@@ -91,7 +128,7 @@ function check() {
|
|
|
91
128
|
}
|
|
92
129
|
|
|
93
130
|
// Check if any changed line falls within the function's boundaries
|
|
94
|
-
for (const line of changedLines) {
|
|
131
|
+
for (const line of changedLines.keys()) {
|
|
95
132
|
if (line >= fn.startLine && line <= fn.endLine) {
|
|
96
133
|
violations.push({
|
|
97
134
|
type: 'function',
|
package/src/manifest.js
CHANGED
|
@@ -71,18 +71,29 @@ function saveManifest(data) {
|
|
|
71
71
|
fs.writeFileSync(MANIFEST_FILE, JSON.stringify(data, null, 2));
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
const DEPENDENCY_MANIFESTS = new Set([
|
|
75
|
+
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
76
|
+
'requirements.txt', 'Pipfile', 'Pipfile.lock', 'poetry.lock',
|
|
77
|
+
'Cargo.toml', 'Cargo.lock', 'go.mod', 'go.sum'
|
|
78
|
+
]);
|
|
79
|
+
|
|
74
80
|
// Ensure a file entry exists in the manifest, creating it if needed.
|
|
75
81
|
function ensureFileEntry(manifest, relativePath) {
|
|
76
82
|
if (!manifest.files[relativePath]) {
|
|
77
|
-
|
|
83
|
+
// Auto-lock dependency manifests
|
|
84
|
+
const isDep = DEPENDENCY_MANIFESTS.has(path.basename(relativePath));
|
|
85
|
+
manifest.files[relativePath] = {
|
|
86
|
+
status: isDep ? 'locked' : 'unscoped',
|
|
87
|
+
functions: {},
|
|
88
|
+
history: isDep ? [{ timestamp: new Date().toISOString(), action: 'locked', reason: 'auto-locked dependency manifest' }] : []
|
|
89
|
+
};
|
|
78
90
|
}
|
|
79
91
|
if (!manifest.files[relativePath].functions) {
|
|
80
92
|
manifest.files[relativePath].functions = {};
|
|
81
93
|
}
|
|
82
94
|
}
|
|
83
95
|
|
|
84
|
-
//
|
|
85
|
-
|
|
96
|
+
// Ensure entry exists for 'init' function
|
|
86
97
|
function init() {
|
|
87
98
|
if (fs.existsSync(MANIFEST_FILE)) {
|
|
88
99
|
console.log(`.scopelock.json already exists. Use 'scopelock status' to view it.`);
|
|
@@ -90,13 +101,13 @@ function init() {
|
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
const files = walkDir('.');
|
|
93
|
-
const manifest = { version: VERSION, files: {} };
|
|
104
|
+
const manifest = { version: VERSION, files: {}, allowedSecrets: {} };
|
|
94
105
|
let count = 0;
|
|
95
106
|
|
|
96
107
|
for (const f of files) {
|
|
97
108
|
const relativePath = path.relative('.', f).replace(/\\/g, '/');
|
|
98
109
|
if (relativePath === MANIFEST_FILE) continue;
|
|
99
|
-
manifest
|
|
110
|
+
ensureFileEntry(manifest, relativePath);
|
|
100
111
|
count++;
|
|
101
112
|
}
|
|
102
113
|
|
|
@@ -256,4 +267,31 @@ function status() {
|
|
|
256
267
|
console.log('');
|
|
257
268
|
}
|
|
258
269
|
|
|
259
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Allow a secret to be committed in a specific file.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} file "<file>"
|
|
274
|
+
* @param {string} reason Mandatory reason string.
|
|
275
|
+
*/
|
|
276
|
+
function allowSecret(file, reason) {
|
|
277
|
+
const relativePath = file.replace(/\\/g, '/');
|
|
278
|
+
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({
|
|
289
|
+
timestamp: new Date().toISOString(),
|
|
290
|
+
reason
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
saveManifest(manifest);
|
|
294
|
+
console.log(`⚠️ Secret Sentinel bypassed for ${relativePath}. Reason: ${reason}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = { init, lock, unlock, allowSecret, status, getManifest, saveManifest };
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* src/secrets.js
|
|
5
|
+
*
|
|
6
|
+
* Scans strings for high-risk secrets.
|
|
7
|
+
* Returns an array of detected secret types.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SECRET_PATTERNS = {
|
|
11
|
+
'AWS Access Key': /(?:A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}/,
|
|
12
|
+
'Stripe Secret Key': /sk_(?:live|test)_[0-9a-zA-Z]{24}/,
|
|
13
|
+
'GitHub Token': /gh[pousr]_[A-Za-z0-9_]{36,}/,
|
|
14
|
+
'Slack Token': /xox[baprs]-[0-9]{12}-[0-9]{12}-[a-zA-Z0-9]{24}/,
|
|
15
|
+
'Generic API Key / Secret': /(?:api[_-]?key|secret|token|password)[\s]*[=:]\s*["'][a-zA-Z0-9_\-]{16,}["']/i
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} lineContent
|
|
20
|
+
* @returns {string|null} The name of the secret type detected, or null.
|
|
21
|
+
*/
|
|
22
|
+
function detectSecret(lineContent) {
|
|
23
|
+
for (const [name, regex] of Object.entries(SECRET_PATTERNS)) {
|
|
24
|
+
if (regex.test(lineContent)) {
|
|
25
|
+
return name;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { detectSecret };
|
package/test/run.js
CHANGED
|
@@ -59,6 +59,7 @@ function workInProgress() {
|
|
|
59
59
|
`.trimStart());
|
|
60
60
|
|
|
61
61
|
fs.writeFileSync('readme.txt', 'Initial readme content.\n');
|
|
62
|
+
fs.writeFileSync('package.json', '{"name":"test"}\n'); // Dummy package manifest
|
|
62
63
|
run('git add .');
|
|
63
64
|
run('git commit -m "initial"');
|
|
64
65
|
|
|
@@ -70,29 +71,43 @@ assert(fs.existsSync('.scopelock.json'), '.scopelock.json was created');
|
|
|
70
71
|
const manifest = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
71
72
|
assert(manifest.version === 2, 'Manifest is V2 schema');
|
|
72
73
|
assert(manifest.files['app.js'] !== undefined, 'app.js is tracked');
|
|
73
|
-
assert(manifest.files['readme.txt'] !== undefined, 'readme.txt is tracked');
|
|
74
74
|
|
|
75
|
-
console.log('\n--- Test 2:
|
|
75
|
+
console.log('\n--- Test 2: Dependency auto-lock (Dependency Lockdown) ---');
|
|
76
|
+
assert(manifest.files['package.json'].status === 'locked', 'package.json is automatically locked on init');
|
|
77
|
+
|
|
78
|
+
console.log('\n--- Test 3: scopelock status ---');
|
|
76
79
|
const statusOut = run(`${CLI} status`);
|
|
77
80
|
assert(statusOut.includes('unscoped'), 'status shows unscoped files');
|
|
78
81
|
|
|
79
|
-
console.log('\n--- Test
|
|
82
|
+
console.log('\n--- Test 4: File-level lock ---');
|
|
80
83
|
run(`${CLI} lock readme.txt "stable documentation"`);
|
|
81
84
|
const m2 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
82
85
|
assert(m2.files['readme.txt'].status === 'locked', 'readme.txt is locked');
|
|
83
86
|
|
|
84
|
-
console.log('\n--- Test
|
|
87
|
+
console.log('\n--- Test 5: File-level violation detection ---');
|
|
85
88
|
fs.appendFileSync('readme.txt', 'AI hallucinated this line.\n');
|
|
86
89
|
const violation1 = run(`${CLI} check`, true);
|
|
87
90
|
assert(violation1.includes('VIOLATION'), 'check detects locked file modification');
|
|
88
91
|
|
|
89
|
-
console.log('\n--- Test
|
|
92
|
+
console.log('\n--- Test 6: File-level unlock clears violation ---');
|
|
90
93
|
run(`${CLI} unlock readme.txt "intentional update to docs"`);
|
|
91
94
|
const check1 = run(`${CLI} check`);
|
|
92
95
|
assert(check1.includes('passed'), 'check passes after unlock');
|
|
93
96
|
run('git restore readme.txt'); // clean up
|
|
94
97
|
|
|
95
|
-
console.log('\n--- Test
|
|
98
|
+
console.log('\n--- Test 7: Secret Sentinel detection ---');
|
|
99
|
+
fs.appendFileSync('readme.txt', 'const stripe_key = "sk_test_12345abcdeABCDE12345abcd";\n');
|
|
100
|
+
const secretViolation = run(`${CLI} check`, true);
|
|
101
|
+
assert(secretViolation.includes('SECRET LEAK'), 'check detects leaked stripe key');
|
|
102
|
+
assert(secretViolation.includes('Stripe Secret Key'), 'check identifies secret type');
|
|
103
|
+
|
|
104
|
+
console.log('\n--- Test 8: Secret Sentinel bypass (allow-secret) ---');
|
|
105
|
+
run(`${CLI} allow-secret readme.txt "it is a mock key for tests"`);
|
|
106
|
+
const secretCheck = run(`${CLI} check`);
|
|
107
|
+
assert(secretCheck.includes('passed'), 'allow-secret successfully bypasses secret sentinel');
|
|
108
|
+
run('git restore readme.txt'); // clean up
|
|
109
|
+
|
|
110
|
+
console.log('\n--- Test 9: Function-level lock ---');
|
|
96
111
|
run(`${CLI} lock app.js:stableFunc "tested, production-ready"`);
|
|
97
112
|
const m3 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
98
113
|
assert(
|
|
@@ -100,7 +115,7 @@ assert(
|
|
|
100
115
|
'stableFunc is function-locked'
|
|
101
116
|
);
|
|
102
117
|
|
|
103
|
-
console.log('\n--- Test
|
|
118
|
+
console.log('\n--- Test 10: Change OUTSIDE locked function — no violation ---');
|
|
104
119
|
// Modify workInProgress (line 6-8), stableFunc is locked (lines 1-3)
|
|
105
120
|
fs.writeFileSync('app.js', `
|
|
106
121
|
function stableFunc() {
|
|
@@ -114,7 +129,7 @@ function workInProgress() {
|
|
|
114
129
|
const check2 = run(`${CLI} check`);
|
|
115
130
|
assert(check2.includes('passed'), 'change outside locked function does not trigger violation');
|
|
116
131
|
|
|
117
|
-
console.log('\n--- Test
|
|
132
|
+
console.log('\n--- Test 11: Change INSIDE locked function — violation ---');
|
|
118
133
|
fs.writeFileSync('app.js', `
|
|
119
134
|
function stableFunc() {
|
|
120
135
|
return 'I have been hallucinated';
|
|
@@ -128,19 +143,43 @@ const violation2 = run(`${CLI} check`, true);
|
|
|
128
143
|
assert(violation2.includes('VIOLATION'), 'change inside locked function triggers violation');
|
|
129
144
|
assert(violation2.includes('stableFunc'), 'violation names the locked function');
|
|
130
145
|
|
|
131
|
-
console.log('\n--- Test
|
|
146
|
+
console.log('\n--- Test 12: Function unlock clears function-level violation ---');
|
|
132
147
|
run(`${CLI} unlock app.js:stableFunc "need to update return value for new API"`);
|
|
133
148
|
const check3 = run(`${CLI} check`);
|
|
134
149
|
assert(check3.includes('passed'), 'check passes after function unlock');
|
|
135
150
|
|
|
136
|
-
console.log('\n--- Test
|
|
151
|
+
console.log('\n--- Test 13: Lock unknown function fails gracefully ---');
|
|
137
152
|
const badLock = run(`${CLI} lock app.js:doesNotExist "testing"`, true);
|
|
138
153
|
assert(badLock.includes('not found'), 'locking unknown function fails with clear message');
|
|
139
154
|
|
|
140
|
-
console.log('\n--- Test
|
|
155
|
+
console.log('\n--- Test 14: scopelock context output ---');
|
|
141
156
|
const ctx = run(`${CLI} context "update the WIP function"`);
|
|
142
157
|
assert(ctx.includes('SCOPE CONTEXT'), 'context output contains header');
|
|
143
158
|
|
|
159
|
+
console.log('\n--- Test 15: Test Coverage Gate (Missing Tests) ---');
|
|
160
|
+
fs.writeFileSync('feature.js', 'console.log("new logic");\n');
|
|
161
|
+
run('git add feature.js');
|
|
162
|
+
const testGateOut = run(`${CLI} check --require-tests`, true);
|
|
163
|
+
assert(testGateOut.includes('TEST GATE VIOLATION'), 'check catches missing tests when flag is used');
|
|
164
|
+
|
|
165
|
+
console.log('\n--- Test 16: Test Coverage Gate (Tests Provided) ---');
|
|
166
|
+
fs.writeFileSync('feature.test.js', 'console.log("test for logic");\n');
|
|
167
|
+
run('git add feature.test.js');
|
|
168
|
+
const testGatePass = run(`${CLI} check --require-tests`);
|
|
169
|
+
assert(testGatePass.includes('passed'), 'check passes when test files accompany source files');
|
|
170
|
+
|
|
171
|
+
console.log('\n--- Test 17: Rollback Snapshot Creation ---');
|
|
172
|
+
run(`${CLI} snapshot`);
|
|
173
|
+
const m4 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
174
|
+
assert(m4.lastSnapshot === 'clean' || m4.lastSnapshot === 'dirty', 'snapshot state is tracked in manifest');
|
|
175
|
+
|
|
176
|
+
console.log('\n--- Test 18: Rollback Revert ---');
|
|
177
|
+
fs.writeFileSync('rogue.js', 'I am a rogue agent destroying things');
|
|
178
|
+
run(`${CLI} revert`);
|
|
179
|
+
assert(!fs.existsSync('rogue.js'), 'revert destroys untracked rogue files');
|
|
180
|
+
const m5 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
181
|
+
assert(m5.lastSnapshot === null, 'revert clears the snapshot marker');
|
|
182
|
+
|
|
144
183
|
// ─── Done ─────────────────────────────────────────────────────────────────────
|
|
145
184
|
|
|
146
|
-
console.log('\n✅ All
|
|
185
|
+
console.log('\n✅ All 18 tests passed.\n');
|