@manukyalo/scopelock 2.3.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 +26 -26
- package/package.json +1 -1
- package/skills/blast-radius/SKILL.md +3 -3
- package/skills/dependency-lockdown/SKILL.md +2 -2
- package/skills/production-path-lock/SKILL.md +19 -19
- package/skills/rollback-snapshot/SKILL.md +4 -4
- package/skills/scope-enforcement/SKILL.md +7 -7
- package/skills/secret-sentinel/SKILL.md +3 -3
- package/skills/test-coverage-gate/SKILL.md +3 -3
- package/src/git.js +167 -167
- package/src/manifest.js +33 -37
- package/test/run.js +41 -41
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# scopelock
|
|
1
|
+
# scopelock v3.0
|
|
2
2
|
|
|
3
3
|
**Anti-hallucination scope locking for AI coding agents.**
|
|
4
4
|
|
|
@@ -12,86 +12,101 @@ npm install -g @manukyalo/scopelock
|
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
|
-
- **File
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
15
|
+
- **File & Function Locks**: Run `scopelock lock src/auth.ts` or `scopelock lock src/auth.ts:validateToken` to make code read-only for agents.
|
|
16
|
+
- **Production Path Locks (Seal)**: Use `scopelock seal` for critical paths like billing. Agents cannot override this; it requires explicit human sign-off via `scopelock unseal --human-approved=<ticket>`.
|
|
17
|
+
- **Blast Radius Map**: Prevent scope creep *before* it happens. Run `scopelock impact <file>` to see every file that imports a target file before you touch it.
|
|
18
|
+
- **Dependency Lockdown**: Zero-trust dependency management. Automatically locks `package.json` on init to prevent silent dependency drift.
|
|
18
19
|
- **Secret Sentinel**: A hard-blocking pre-commit scanner that physically prevents agents from committing AWS keys, Stripe tokens, or `.env` leaks.
|
|
19
|
-
- **
|
|
20
|
+
- **Test Coverage Gate**: Run `scopelock guard --tests` to block any source code changes that aren't accompanied by tests.
|
|
21
|
+
- **Rollback Snapshots**: Run `scopelock save` before an agent starts working, and `scopelock restore` to obliterate any rogue changes instantly.
|
|
20
22
|
|
|
21
23
|
---
|
|
22
24
|
|
|
23
25
|
## Commands
|
|
24
26
|
|
|
25
27
|
```bash
|
|
26
|
-
scopelock init
|
|
27
|
-
scopelock lock <file>[:<func>] [reason]
|
|
28
|
-
scopelock unlock <file>[:<func>] <reason>
|
|
29
|
-
scopelock
|
|
30
|
-
scopelock
|
|
31
|
-
scopelock
|
|
32
|
-
scopelock
|
|
28
|
+
scopelock init Scan repo and generate .scopelock.json
|
|
29
|
+
scopelock lock <file>[:<func>] [reason] Lock a file or a specific function
|
|
30
|
+
scopelock unlock <file>[:<func>] <reason> Unlock (reason is mandatory)
|
|
31
|
+
scopelock seal <file> <reason> Permanent production-path lock (no override)
|
|
32
|
+
scopelock unseal <file> --human-approved=<ticket> <reason> Release a seal
|
|
33
|
+
scopelock impact <file> Show all files that import this file
|
|
34
|
+
scopelock trust <file> <reason> Bypass Secret Sentinel for a specific file
|
|
35
|
+
scopelock save Auto-snapshot repo state before an agent session
|
|
36
|
+
scopelock restore Rollback to the last snapshot
|
|
37
|
+
scopelock context [task] Generate AI context block for a task
|
|
38
|
+
scopelock guard [--tests] Check git diff for violations and secret leaks
|
|
39
|
+
scopelock status Show manifest summary
|
|
33
40
|
```
|
|
34
41
|
|
|
35
|
-
### `scopelock
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
### `scopelock lock <file>[:<function>] [reason]`
|
|
39
|
-
Lock a whole file or a specific named function.
|
|
42
|
+
### `scopelock lock` & `unlock`
|
|
43
|
+
Lock a whole file or a specific named function. Unlock requires a reason that gets logged to history.
|
|
40
44
|
|
|
41
45
|
```bash
|
|
42
|
-
# Lock a whole file
|
|
43
46
|
scopelock lock src/lib/supabase.ts "production client — stable"
|
|
44
|
-
|
|
45
|
-
# Lock a specific function (validates it exists before locking)
|
|
46
47
|
scopelock lock src/auth/token.ts:validateToken "tested — do not touch"
|
|
48
|
+
scopelock unlock src/auth/token.ts:validateToken "fixing JWT expiry edge case"
|
|
47
49
|
```
|
|
48
50
|
|
|
49
|
-
### `scopelock
|
|
50
|
-
|
|
51
|
+
### `scopelock seal` & `unseal`
|
|
52
|
+
For files that should *never* be touched without human oversight (e.g., `/billing`, `/migrations`). Seals cannot be removed by `unlock`.
|
|
51
53
|
|
|
52
54
|
```bash
|
|
53
|
-
scopelock
|
|
55
|
+
scopelock seal src/billing/stripe.ts "core billing logic"
|
|
56
|
+
scopelock unseal src/billing/stripe.ts --human-approved=PR-123 "updating webhook"
|
|
54
57
|
```
|
|
55
58
|
|
|
56
|
-
### `scopelock
|
|
57
|
-
|
|
59
|
+
### `scopelock impact`
|
|
60
|
+
Before making a change, see the blast radius. Outputs a list of all files in the repository that import the target file.
|
|
58
61
|
|
|
59
62
|
```bash
|
|
60
|
-
scopelock
|
|
63
|
+
scopelock impact src/utils/auth.ts
|
|
61
64
|
```
|
|
62
65
|
|
|
63
|
-
### `scopelock
|
|
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.
|
|
66
|
+
### `scopelock guard`
|
|
67
|
+
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. Add `--tests` to strictly enforce test coverage for any changed logic.
|
|
65
68
|
|
|
66
69
|
```bash
|
|
67
|
-
scopelock
|
|
70
|
+
scopelock guard
|
|
71
|
+
scopelock guard --tests
|
|
68
72
|
```
|
|
69
73
|
|
|
70
|
-
### `scopelock
|
|
71
|
-
|
|
74
|
+
### `scopelock save` & `restore`
|
|
75
|
+
Never fear an agent hallucination destroying your workspace again. `save` stores a snapshot in git stash that survives hard resets. `restore` obliterates the working directory and cleanly reverts to the snapshot.
|
|
72
76
|
|
|
73
77
|
```bash
|
|
74
|
-
scopelock
|
|
78
|
+
scopelock save
|
|
79
|
+
scopelock restore
|
|
75
80
|
```
|
|
81
|
+
|
|
82
|
+
### `scopelock trust`
|
|
83
|
+
Bypass the Secret Sentinel hard-block for a specific file (e.g., when intentionally committing a mock test key).
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
scopelock trust test/run.js "this is a mock stripe key for testing"
|
|
76
87
|
```
|
|
77
|
-
[SCOPE CONTEXT]
|
|
78
|
-
Task: Update the login page
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
✏️ active — 0 file(s)
|
|
83
|
-
⬜ unscoped — 14 file(s)
|
|
89
|
+
### `scopelock context`
|
|
90
|
+
Output a token-efficient AI context block with all locks clearly flagged for the agent's system prompt.
|
|
84
91
|
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
```bash
|
|
93
|
+
scopelock context "Update the login page"
|
|
87
94
|
```
|
|
88
95
|
|
|
89
|
-
## Agent
|
|
96
|
+
## Agent Skills (Godmode)
|
|
97
|
+
|
|
98
|
+
`scopelock` ships with 7 native AI Agent Skills located in the `skills/` folder.
|
|
99
|
+
If you use an agent framework (like Antigravity or Cline) that supports Markdown skills, point it to these folders to automatically teach the agent how to use `scopelock` safely.
|
|
90
100
|
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
The skills map directly to features:
|
|
102
|
+
- `scope-enforcement`
|
|
103
|
+
- `dependency-lockdown`
|
|
104
|
+
- `secret-sentinel`
|
|
105
|
+
- `test-coverage-gate`
|
|
106
|
+
- `rollback-snapshot`
|
|
107
|
+
- `blast-radius`
|
|
108
|
+
- `production-path-lock`
|
|
93
109
|
|
|
94
110
|
## Data Model
|
|
95
111
|
All state is stored in `.scopelock.json` at the root of your repo.
|
|
96
|
-
|
|
97
112
|
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.
|
package/bin/scopelock.js
CHANGED
|
@@ -55,60 +55,60 @@ switch (command) {
|
|
|
55
55
|
break;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
case '
|
|
59
|
-
git.
|
|
58
|
+
case 'guard':
|
|
59
|
+
git.guard(args);
|
|
60
60
|
break;
|
|
61
61
|
|
|
62
62
|
case 'status':
|
|
63
63
|
manifest.status();
|
|
64
64
|
break;
|
|
65
65
|
|
|
66
|
-
case '
|
|
66
|
+
case 'impact': {
|
|
67
67
|
const target = args[0];
|
|
68
68
|
if (!target) {
|
|
69
|
-
console.error('Usage: scopelock
|
|
69
|
+
console.error('Usage: scopelock impact <file>');
|
|
70
70
|
process.exit(1);
|
|
71
71
|
}
|
|
72
72
|
blast.printBlastRadius(target);
|
|
73
73
|
break;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
case '
|
|
76
|
+
case 'seal': {
|
|
77
77
|
const target = args[0];
|
|
78
|
-
const reason = args.slice(1).join(' ') || 'production path —
|
|
78
|
+
const reason = args.slice(1).join(' ') || 'production path — seal applied';
|
|
79
79
|
if (!target) {
|
|
80
|
-
console.error('Usage: scopelock
|
|
80
|
+
console.error('Usage: scopelock seal <file> <reason>');
|
|
81
81
|
process.exit(1);
|
|
82
82
|
}
|
|
83
|
-
manifest.
|
|
83
|
+
manifest.seal(target, reason);
|
|
84
84
|
break;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
case '
|
|
87
|
+
case 'unseal': {
|
|
88
88
|
const target = args[0];
|
|
89
89
|
const ticketArg = args.find(a => a.startsWith('--human-approved='));
|
|
90
90
|
const ticket = ticketArg ? ticketArg.replace('--human-approved=', '') : null;
|
|
91
91
|
const reason = args.filter(a => !a.startsWith('--')).slice(1).join(' ');
|
|
92
92
|
if (!target || !ticket || !reason) {
|
|
93
|
-
console.error('Usage: scopelock
|
|
93
|
+
console.error('Usage: scopelock unseal <file> --human-approved=<ticket> <reason>');
|
|
94
94
|
process.exit(1);
|
|
95
95
|
}
|
|
96
|
-
manifest.
|
|
96
|
+
manifest.unseal(target, ticket, reason);
|
|
97
97
|
break;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
case '
|
|
100
|
+
case 'trust': {
|
|
101
101
|
const target = args[0];
|
|
102
102
|
const reason = args.slice(1).join(' ');
|
|
103
103
|
if (!target || !reason) {
|
|
104
|
-
console.error('Usage: scopelock
|
|
104
|
+
console.error('Usage: scopelock trust <file> <reason>');
|
|
105
105
|
process.exit(1);
|
|
106
106
|
}
|
|
107
|
-
manifest.
|
|
107
|
+
manifest.trust(target, reason);
|
|
108
108
|
break;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
case '
|
|
111
|
+
case 'save': {
|
|
112
112
|
try {
|
|
113
113
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
114
114
|
const manifestObj = manifest.getManifest();
|
|
@@ -129,7 +129,7 @@ switch (command) {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
manifest.saveManifest(manifestObj);
|
|
132
|
-
console.log(`✅ Snapshot created. Run 'scopelock
|
|
132
|
+
console.log(`✅ Snapshot created. Run 'scopelock restore' to obliterate agent changes and restore this state.`);
|
|
133
133
|
} catch (e) {
|
|
134
134
|
console.error('Failed to create snapshot.', e.stderr || e.message);
|
|
135
135
|
process.exit(1);
|
|
@@ -137,11 +137,11 @@ switch (command) {
|
|
|
137
137
|
break;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
case '
|
|
140
|
+
case 'restore': {
|
|
141
141
|
try {
|
|
142
142
|
const manifestObj = manifest.getManifest();
|
|
143
143
|
if (!manifestObj.lastSnapshot) {
|
|
144
|
-
console.error('❌ No snapshot found. Run `scopelock
|
|
144
|
+
console.error('❌ No snapshot found. Run `scopelock save` first.');
|
|
145
145
|
process.exit(1);
|
|
146
146
|
}
|
|
147
147
|
console.log(`Obliterating agent mess...`);
|
|
@@ -174,14 +174,14 @@ Usage:
|
|
|
174
174
|
scopelock init Scan repo and generate .scopelock.json
|
|
175
175
|
scopelock lock <file>[:<func>] [reason] Lock a file or a specific function
|
|
176
176
|
scopelock unlock <file>[:<func>] <reason> Unlock (reason is mandatory)
|
|
177
|
-
scopelock
|
|
178
|
-
scopelock
|
|
179
|
-
scopelock
|
|
180
|
-
scopelock
|
|
181
|
-
scopelock
|
|
182
|
-
scopelock
|
|
177
|
+
scopelock seal <file> <reason> Permanent production-path lock (no override)
|
|
178
|
+
scopelock unseal <file> --human-approved=<ticket> <reason> Release a seal
|
|
179
|
+
scopelock impact <file> Show all files that import this file
|
|
180
|
+
scopelock trust <file> <reason> Bypass Secret Sentinel for a specific file
|
|
181
|
+
scopelock save Auto-snapshot repo state before an agent session
|
|
182
|
+
scopelock restore Rollback to the last snapshot
|
|
183
183
|
scopelock context [task] Generate AI context block for a task
|
|
184
|
-
scopelock
|
|
184
|
+
scopelock guard [--tests] Check git diff for violations and secret leaks
|
|
185
185
|
scopelock status Show manifest summary
|
|
186
186
|
|
|
187
187
|
|
|
@@ -190,7 +190,7 @@ Examples:
|
|
|
190
190
|
scopelock lock src/auth.ts:validateToken "stable — do not touch"
|
|
191
191
|
scopelock unlock src/auth.ts:validateToken "need to fix JWT expiry bug"
|
|
192
192
|
scopelock context "Fix the broken checkout flow"
|
|
193
|
-
scopelock
|
|
193
|
+
scopelock guard
|
|
194
194
|
`);
|
|
195
195
|
process.exit(1);
|
|
196
196
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: blast-radius-map
|
|
3
|
-
description: "Godmode Skill: Before modifying any file, run 'scopelock
|
|
3
|
+
description: "Godmode Skill: Before modifying any file, run 'scopelock impact <file>' to see every other file that imports it. Prevents scope creep by making the full impact of a change visible BEFORE the agent writes a single line."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Overview
|
|
@@ -15,7 +15,7 @@ This skill instructs the agent to **check before it touches**, not after.
|
|
|
15
15
|
|
|
16
16
|
1. Run the blast radius check first:
|
|
17
17
|
```bash
|
|
18
|
-
scopelock
|
|
18
|
+
scopelock impact src/utils/auth.ts
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
2. Read the output:
|
|
@@ -49,7 +49,7 @@ This skill instructs the agent to **check before it touches**, not after.
|
|
|
49
49
|
|
|
50
50
|
5. Make your change to the target file only.
|
|
51
51
|
|
|
52
|
-
6. Run `scopelock
|
|
52
|
+
6. Run `scopelock guard` to verify no locked files were touched.
|
|
53
53
|
|
|
54
54
|
## Why this exists
|
|
55
55
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dependency-lockdown
|
|
3
|
-
description: "Godmode Skill: Prevents AI agents from silently adding or upgrading npm/pip/cargo packages. Dependency manifest files (package.json, requirements.txt, etc.) are automatically locked by scopelock on init. Any diff touching these files will fail scopelock
|
|
3
|
+
description: "Godmode Skill: Prevents AI agents from silently adding or upgrading npm/pip/cargo packages. Dependency manifest files (package.json, requirements.txt, etc.) are automatically locked by scopelock on init. Any diff touching these files will fail scopelock guard until explicitly unlocked with a reason."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Overview
|
|
@@ -22,7 +22,7 @@ On `scopelock init`, the following files are **automatically locked** without an
|
|
|
22
22
|
scopelock unlock package.json "adding zod for runtime validation of API responses"
|
|
23
23
|
```
|
|
24
24
|
2. Make your change (add the dependency to the manifest).
|
|
25
|
-
3. Run `scopelock
|
|
25
|
+
3. Run `scopelock guard` to confirm the change is authorized.
|
|
26
26
|
4. Re-lock the manifest immediately after:
|
|
27
27
|
```bash
|
|
28
28
|
scopelock lock package.json "dependencies updated and reviewed"
|
|
@@ -1,56 +1,56 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: production-path-lock
|
|
3
|
-
description: "Godmode Skill: A permanent, override-resistant lock for critical production paths like /auth, /billing, /migrations. Regular 'scopelock unlock' cannot bypass it. Requires a second human's explicit sign-off via 'scopelock
|
|
3
|
+
description: "Godmode Skill: A permanent, override-resistant lock for critical production paths like /auth, /billing, /migrations. Regular 'scopelock unlock' cannot bypass it. Requires a second human's explicit sign-off via 'scopelock unseal' with a ticket."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Overview
|
|
7
7
|
|
|
8
|
-
Some files must never be touched by an AI agent without explicit human sign-off. Your `/auth` logic, billing handlers, and database migration files are not negotiable. A `
|
|
8
|
+
Some files must never be touched by an AI agent without explicit human sign-off. Your `/auth` logic, billing handlers, and database migration files are not negotiable. A `seal` makes this a physical constraint, not a policy.
|
|
9
9
|
|
|
10
|
-
A
|
|
10
|
+
A sealed file cannot be overridden by `scopelock unlock`. The agent must stop, escalate to a human, and provide a traceable ticket before any modification is allowed.
|
|
11
11
|
|
|
12
|
-
## Recommended Files to
|
|
12
|
+
## Recommended Files to Seal on Every Project
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
# Authentication
|
|
16
|
-
scopelock
|
|
17
|
-
scopelock
|
|
16
|
+
scopelock seal src/auth/token.ts "core auth — requires senior review"
|
|
17
|
+
scopelock seal src/middleware/auth.ts "core auth — requires senior review"
|
|
18
18
|
|
|
19
19
|
# Billing & Payments
|
|
20
|
-
scopelock
|
|
21
|
-
scopelock
|
|
20
|
+
scopelock seal src/billing/stripe.ts "billing — requires finance team approval"
|
|
21
|
+
scopelock seal src/billing/webhooks.ts "billing — requires finance team approval"
|
|
22
22
|
|
|
23
23
|
# Database Migrations
|
|
24
|
-
scopelock
|
|
24
|
+
scopelock seal migrations/ "schema changes — requires DBA review"
|
|
25
25
|
|
|
26
26
|
# Security-critical config
|
|
27
|
-
scopelock
|
|
27
|
+
scopelock seal .env.production "production secrets — never touch"
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
## Agent Protocol
|
|
31
31
|
|
|
32
|
-
### When you encounter a
|
|
32
|
+
### When you encounter a sealed file:
|
|
33
33
|
```
|
|
34
|
-
❌ 'src/auth/token.ts' is
|
|
34
|
+
❌ 'src/auth/token.ts' is SEALED and cannot be unlocked with 'scopelock unlock'.
|
|
35
35
|
This path is a protected production route.
|
|
36
|
-
Use: scopelock
|
|
36
|
+
Use: scopelock unseal src/auth/token.ts --human-approved=<ticket> <reason>
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
1. **STOP.** Do not attempt to work around the lock.
|
|
40
40
|
2. **Escalate.** Tell the human you cannot proceed without their explicit approval.
|
|
41
41
|
3. **The human must run:**
|
|
42
42
|
```bash
|
|
43
|
-
scopelock
|
|
43
|
+
scopelock unseal src/auth/token.ts --human-approved=JIRA-123 "fixing JWT expiry bug approved in PR-456"
|
|
44
44
|
```
|
|
45
|
-
4. Only after you see the `
|
|
46
|
-
5. Re-
|
|
45
|
+
4. Only after you see the `UNSEALED` confirmation may you proceed.
|
|
46
|
+
5. Re-seal the file immediately after your change is committed:
|
|
47
47
|
```bash
|
|
48
|
-
scopelock
|
|
48
|
+
scopelock seal src/auth/token.ts "re-locked after JWT fix — JIRA-123"
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
## The Audit Trail
|
|
52
52
|
|
|
53
|
-
Every `
|
|
53
|
+
Every `unseal` is permanently logged in `.scopelock.json` with:
|
|
54
54
|
- The timestamp
|
|
55
55
|
- The human-approved ticket number
|
|
56
56
|
- The full reason string
|
|
@@ -59,4 +59,4 @@ This creates a traceable, auditable record of every time a production path was m
|
|
|
59
59
|
|
|
60
60
|
## Why this exists
|
|
61
61
|
|
|
62
|
-
`locked` is a request. `
|
|
62
|
+
`locked` is a request. `sealed` is a wall. Some files need a wall.
|
|
@@ -5,19 +5,19 @@ description: "Godmode Skill: Creates a one-command safety net before every agent
|
|
|
5
5
|
|
|
6
6
|
## Overview
|
|
7
7
|
|
|
8
|
-
Before you let an agent loose on a codebase, you need a guaranteed escape hatch. `scopelock
|
|
8
|
+
Before you let an agent loose on a codebase, you need a guaranteed escape hatch. `scopelock save` creates that escape hatch using git's native stash mechanism. It is instant, requires no external tools, and can restore the repo to its exact pre-session state in under one second.
|
|
9
9
|
|
|
10
10
|
## How Storage Works
|
|
11
11
|
|
|
12
12
|
- The snapshot is stored in **git's local stash** (`.git/refs/stash`) — it never leaves your machine and is never pushed to GitHub.
|
|
13
|
-
- The `.scopelock.json` manifest stores a pointer (`lastSnapshot: "dirty" | "clean"`) so `scopelock
|
|
13
|
+
- The `.scopelock.json` manifest stores a pointer (`lastSnapshot: "dirty" | "clean"`) so `scopelock restore` knows what to restore.
|
|
14
14
|
- If you clone the repo on a new machine, the snapshot is gone — this is correct. Snapshots are session-scoped, not repository-scoped.
|
|
15
15
|
|
|
16
16
|
## Agent Protocol
|
|
17
17
|
|
|
18
18
|
### At the start of EVERY agent session:
|
|
19
19
|
```bash
|
|
20
|
-
scopelock
|
|
20
|
+
scopelock save
|
|
21
21
|
```
|
|
22
22
|
This is the first command you run, before writing a single line of code.
|
|
23
23
|
|
|
@@ -26,7 +26,7 @@ Simply commit your work normally. The stash will remain in git until git's garba
|
|
|
26
26
|
|
|
27
27
|
### If the agent goes rogue:
|
|
28
28
|
```bash
|
|
29
|
-
scopelock
|
|
29
|
+
scopelock restore
|
|
30
30
|
```
|
|
31
31
|
This command:
|
|
32
32
|
1. Runs `git reset --hard HEAD` — obliterates all tracked file changes.
|
|
@@ -10,7 +10,7 @@ AI coding agents default to the shortest path to a passing result — which freq
|
|
|
10
10
|
This skill uses `scopelock` — a zero-dependency Node.js CLI — to enforce two tiers of scope protection:
|
|
11
11
|
|
|
12
12
|
- **File-level:** An entire file is locked. No agent may touch it.
|
|
13
|
-
- **Function-level:** A specific function within a file is locked. The file may be edited, but that function's body is off-limits. `scopelock
|
|
13
|
+
- **Function-level:** A specific function within a file is locked. The file may be edited, but that function's body is off-limits. `scopelock guard` detects if any changed line falls inside the locked function's boundaries.
|
|
14
14
|
|
|
15
15
|
The skill operates at three checkpoints: **Session Start**, **Pre-Commit**, and **Post-Task Review**.
|
|
16
16
|
|
|
@@ -64,7 +64,7 @@ scopelock lock src/auth/token.ts:validateToken "tested, do not touch"
|
|
|
64
64
|
|
|
65
65
|
**Step 4: Run the scope check.**
|
|
66
66
|
```bash
|
|
67
|
-
scopelock
|
|
67
|
+
scopelock guard
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
Two-tier enforcement:
|
|
@@ -87,7 +87,7 @@ git restore <file>
|
|
|
87
87
|
*Genuinely required change:*
|
|
88
88
|
```bash
|
|
89
89
|
scopelock unlock src/auth/token.ts:validateToken "fixing JWT expiry edge case"
|
|
90
|
-
scopelock
|
|
90
|
+
scopelock guard # must pass before committing
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
---
|
|
@@ -108,7 +108,7 @@ scopelock lock src/auth/token.ts:validateToken "fixed and re-locked"
|
|
|
108
108
|
|
|
109
109
|
```bash
|
|
110
110
|
echo '#!/bin/sh
|
|
111
|
-
scopelock
|
|
111
|
+
scopelock guard' > .git/hooks/pre-commit
|
|
112
112
|
chmod +x .git/hooks/pre-commit
|
|
113
113
|
```
|
|
114
114
|
|
|
@@ -123,14 +123,14 @@ chmod +x .git/hooks/pre-commit
|
|
|
123
123
|
| "The agent said it needed to modify that function." | Then re-evaluate scope, not the lock. If you can't write a specific reason, the modification isn't justified. |
|
|
124
124
|
| "Function-level locking is overkill." | File-level locking doesn't stop an agent from rewriting a critical function inside a file marked `active`. |
|
|
125
125
|
| "The parser didn't detect my function." | Use file-level locking instead. Flag the miss — it's a parser bug, not a reason to skip protection. |
|
|
126
|
-
| "Running `scopelock
|
|
126
|
+
| "Running `scopelock guard` slows my commit flow." | Under 300ms. The alternative is debugging a hallucinated refactor for hours. |
|
|
127
127
|
| ".scopelock.json shouldn't be committed." | Commit it. It's shared project state — your whole team and their agents benefit. |
|
|
128
128
|
|
|
129
129
|
---
|
|
130
130
|
|
|
131
131
|
## Red Flags
|
|
132
132
|
|
|
133
|
-
- `scopelock
|
|
133
|
+
- `scopelock guard` reports violations in more than 2 files — significant scope drift.
|
|
134
134
|
- An unlock entry has a vague reason string (e.g., "fix", "update") — bypassed without thinking.
|
|
135
135
|
- The agent proposes unlocking multiple files at once with one generic reason — batch rationalization.
|
|
136
136
|
- A locked function is reported as `function-missing` after a diff — it was deleted or renamed.
|
|
@@ -144,7 +144,7 @@ A session is compliant when ALL of the following are true:
|
|
|
144
144
|
|
|
145
145
|
- [ ] `.scopelock.json` exists and `scopelock status` shows the expected locks.
|
|
146
146
|
- [ ] `scopelock context "<task>"` output was injected into the agent before any code was written.
|
|
147
|
-
- [ ] `scopelock
|
|
147
|
+
- [ ] `scopelock guard` exits `0` before every commit in this session.
|
|
148
148
|
- [ ] Every `unlock` entry has a specific, task-justified reason string.
|
|
149
149
|
- [ ] No diff contains modifications to locked files or lines inside locked function bodies that were not explicitly unlocked.
|
|
150
150
|
- [ ] All modified files and functions have been reviewed for re-locking post-task.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: secret-sentinel
|
|
3
|
-
description: "Godmode Skill: Physically blocks AI agents from committing API keys, tokens, or .env leaks. scopelock
|
|
3
|
+
description: "Godmode Skill: Physically blocks AI agents from committing API keys, tokens, or .env leaks. scopelock guard scans every added line in the git diff for high-entropy secrets before the commit is allowed. This is a hard block — not a warning."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Overview
|
|
@@ -20,7 +20,7 @@ The Secret Sentinel scans every newly added line for:
|
|
|
20
20
|
|
|
21
21
|
### Before every commit:
|
|
22
22
|
```bash
|
|
23
|
-
scopelock
|
|
23
|
+
scopelock guard
|
|
24
24
|
```
|
|
25
25
|
If a secret is detected, you will see:
|
|
26
26
|
```
|
|
@@ -42,7 +42,7 @@ If a secret is detected, you will see:
|
|
|
42
42
|
### Intentional exception (mock/test keys only):
|
|
43
43
|
If you are intentionally committing a **mock** key for testing purposes, a human must explicitly authorize it:
|
|
44
44
|
```bash
|
|
45
|
-
scopelock
|
|
45
|
+
scopelock trust test/fixtures/mock.ts "contains a mock stripe key for unit tests — not a real key"
|
|
46
46
|
```
|
|
47
47
|
This bypass is logged permanently in `.scopelock.json` for audit purposes.
|
|
48
48
|
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
---
|
|
2
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
|
|
3
|
+
description: "Godmode Skill: Forces the AI agent to write or update test files whenever it modifies application logic. Run 'scopelock guard --tests' before committing to enforce this rule."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Overview
|
|
7
7
|
|
|
8
8
|
This skill teaches the agent how to navigate the `scopelock` Test Coverage Gate.
|
|
9
|
-
When `--
|
|
9
|
+
When `--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
10
|
|
|
11
11
|
## Agent Protocol
|
|
12
12
|
|
|
13
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
14
|
2. **Write the code**: Implement the requested feature or fix.
|
|
15
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
|
|
16
|
+
4. **Validation**: Run `scopelock guard --tests` to mathematically verify your diff will pass the coverage gate.
|
|
17
17
|
5. **Ship**: Only after the test gate is passed are you allowed to commit or mark the task as complete.
|
|
18
18
|
|
|
19
19
|
## Why this exists
|
package/src/git.js
CHANGED
|
@@ -1,167 +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' || entry.status === '
|
|
97
|
-
const label = entry.status === '
|
|
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
|
|
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 = {
|
|
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
|
@@ -178,39 +178,39 @@ function lock(target, reason = 'manually locked') {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
/**
|
|
181
|
-
*
|
|
182
|
-
* Cannot be removed by 'unlock'. Requires '
|
|
181
|
+
* Seal a file — permanent, override-resistant production path lock.
|
|
182
|
+
* Cannot be removed by 'unlock'. Requires 'unseal' with a human-approved ticket.
|
|
183
183
|
*
|
|
184
184
|
* @param {string} file File path
|
|
185
185
|
* @param {string} reason Mandatory reason string
|
|
186
186
|
*/
|
|
187
|
-
function
|
|
187
|
+
function seal(file, reason) {
|
|
188
188
|
const relativePath = file.replace(/\\/g, '/');
|
|
189
189
|
const manifest = getManifest();
|
|
190
190
|
ensureFileEntry(manifest, relativePath);
|
|
191
191
|
const entry = manifest.files[relativePath];
|
|
192
192
|
|
|
193
|
-
entry.status = '
|
|
193
|
+
entry.status = 'sealed';
|
|
194
194
|
entry.history.push({
|
|
195
195
|
timestamp: new Date().toISOString(),
|
|
196
|
-
action: '
|
|
196
|
+
action: 'sealed',
|
|
197
197
|
reason,
|
|
198
198
|
});
|
|
199
199
|
saveManifest(manifest);
|
|
200
|
-
console.log(`🔐
|
|
200
|
+
console.log(`🔐 SEALED ${relativePath}. Only 'scopelock unseal' with a human-approved ticket can release this.`);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
|
-
* Remove a
|
|
205
|
-
* This is the only command that can override a '
|
|
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
206
|
*
|
|
207
207
|
* @param {string} file File path
|
|
208
208
|
* @param {string} ticket Human-approved ticket (e.g. "JIRA-123" or "PR-456")
|
|
209
209
|
* @param {string} reason Mandatory reason string
|
|
210
210
|
*/
|
|
211
|
-
function
|
|
211
|
+
function unseal(file, ticket, reason) {
|
|
212
212
|
if (!ticket || !reason) {
|
|
213
|
-
console.error('Usage: scopelock
|
|
213
|
+
console.error('Usage: scopelock unseal <file> --human-approved=<ticket> <reason>');
|
|
214
214
|
process.exit(1);
|
|
215
215
|
}
|
|
216
216
|
const relativePath = file.replace(/\\/g, '/');
|
|
@@ -218,20 +218,20 @@ function sudoUnlock(file, ticket, reason) {
|
|
|
218
218
|
ensureFileEntry(manifest, relativePath);
|
|
219
219
|
const entry = manifest.files[relativePath];
|
|
220
220
|
|
|
221
|
-
if (entry.status !== '
|
|
222
|
-
console.error(`'${relativePath}' is not
|
|
221
|
+
if (entry.status !== 'sealed') {
|
|
222
|
+
console.error(`'${relativePath}' is not sealed. Use 'scopelock unlock' instead.`);
|
|
223
223
|
process.exit(1);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
entry.status = 'active';
|
|
227
227
|
entry.history.push({
|
|
228
228
|
timestamp: new Date().toISOString(),
|
|
229
|
-
action: '
|
|
229
|
+
action: 'unsealed',
|
|
230
230
|
humanApproved: ticket,
|
|
231
231
|
reason,
|
|
232
232
|
});
|
|
233
233
|
saveManifest(manifest);
|
|
234
|
-
console.log(`🔓
|
|
234
|
+
console.log(`🔓 UNSEALED ${relativePath}. Ticket: ${ticket}. Reason: ${reason}`);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/**
|
|
@@ -263,12 +263,12 @@ function unlock(target, reason) {
|
|
|
263
263
|
saveManifest(manifest);
|
|
264
264
|
console.log(`🔓 Unlocked function '${funcName}' in ${relativePath}. Reason: ${reason}`);
|
|
265
265
|
} else {
|
|
266
|
-
// Guard against bypassing a
|
|
267
|
-
if (entry.status === '
|
|
266
|
+
// Guard against bypassing a seal with a normal unlock
|
|
267
|
+
if (entry.status === 'sealed') {
|
|
268
268
|
console.error(
|
|
269
|
-
`❌ '${relativePath}' is
|
|
269
|
+
`❌ '${relativePath}' is SEALED and cannot be unlocked with 'scopelock unlock'.\n` +
|
|
270
270
|
` This path is a protected production route.\n` +
|
|
271
|
-
` Use: scopelock
|
|
271
|
+
` Use: scopelock unseal ${relativePath} --human-approved=<ticket> <reason>`
|
|
272
272
|
);
|
|
273
273
|
process.exit(1);
|
|
274
274
|
}
|
|
@@ -286,7 +286,7 @@ function status() {
|
|
|
286
286
|
const manifest = getManifest();
|
|
287
287
|
const files = Object.entries(manifest.files);
|
|
288
288
|
|
|
289
|
-
const
|
|
289
|
+
const sealed = files.filter(([, v]) => v.status === 'sealed');
|
|
290
290
|
const locked = files.filter(([, v]) => v.status === 'locked');
|
|
291
291
|
const active = files.filter(([, v]) => v.status === 'active');
|
|
292
292
|
const unscoped = files.filter(([, v]) => v.status === 'unscoped');
|
|
@@ -299,10 +299,16 @@ function status() {
|
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
console.log(`\n📋 scopelock status\n`);
|
|
302
|
+
console.log(` 🛡️ sealed — ${sealed.length} file(s)`);
|
|
302
303
|
console.log(` 🔒 locked — ${locked.length} file(s), ${lockedFnCount} function(s)`);
|
|
303
304
|
console.log(` ✏️ active — ${active.length} file(s)`);
|
|
304
305
|
console.log(` ⬜ unscoped — ${unscoped.length} file(s)\n`);
|
|
305
306
|
|
|
307
|
+
if (sealed.length > 0) {
|
|
308
|
+
console.log('\nSealed files:');
|
|
309
|
+
sealed.forEach(([f]) => console.log(` ${f}`));
|
|
310
|
+
}
|
|
311
|
+
|
|
306
312
|
if (locked.length > 0) {
|
|
307
313
|
console.log(`Locked files:`);
|
|
308
314
|
for (const [filePath, data] of locked) {
|
|
@@ -335,30 +341,20 @@ function status() {
|
|
|
335
341
|
}
|
|
336
342
|
|
|
337
343
|
/**
|
|
338
|
-
*
|
|
344
|
+
* Trust a file to contain a mock secret, bypassing the Secret Sentinel.
|
|
339
345
|
*
|
|
340
|
-
* @param {string} file
|
|
341
|
-
* @param {string} reason
|
|
346
|
+
* @param {string} file
|
|
347
|
+
* @param {string} reason
|
|
342
348
|
*/
|
|
343
|
-
function
|
|
349
|
+
function trust(file, reason) {
|
|
344
350
|
const relativePath = file.replace(/\\/g, '/');
|
|
345
351
|
const manifest = getManifest();
|
|
346
|
-
|
|
347
|
-
if (!manifest.allowedSecrets) {
|
|
348
|
-
manifest.allowedSecrets = {};
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (!manifest.allowedSecrets[relativePath]) {
|
|
352
|
-
manifest.allowedSecrets[relativePath] = [];
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
manifest.allowedSecrets[relativePath].push({
|
|
352
|
+
manifest.allowedSecrets[relativePath] = {
|
|
356
353
|
timestamp: new Date().toISOString(),
|
|
357
|
-
reason
|
|
358
|
-
}
|
|
359
|
-
|
|
354
|
+
reason,
|
|
355
|
+
};
|
|
360
356
|
saveManifest(manifest);
|
|
361
357
|
console.log(`⚠️ Secret Sentinel bypassed for ${relativePath}. Reason: ${reason}`);
|
|
362
358
|
}
|
|
363
359
|
|
|
364
|
-
module.exports = { init, lock, unlock,
|
|
360
|
+
module.exports = { init, lock, unlock, seal, unseal, trust, status, getManifest, saveManifest };
|
package/test/run.js
CHANGED
|
@@ -86,25 +86,25 @@ assert(m2.files['readme.txt'].status === 'locked', 'readme.txt is locked');
|
|
|
86
86
|
|
|
87
87
|
console.log('\n--- Test 5: File-level violation detection ---');
|
|
88
88
|
fs.appendFileSync('readme.txt', 'AI hallucinated this line.\n');
|
|
89
|
-
const violation1 = run(`${CLI}
|
|
90
|
-
assert(violation1.includes('VIOLATION'), '
|
|
89
|
+
const violation1 = run(`${CLI} guard`, true);
|
|
90
|
+
assert(violation1.includes('VIOLATION'), 'guard detects locked file modification');
|
|
91
91
|
|
|
92
92
|
console.log('\n--- Test 6: File-level unlock clears violation ---');
|
|
93
93
|
run(`${CLI} unlock readme.txt "intentional update to docs"`);
|
|
94
|
-
const check1 = run(`${CLI}
|
|
95
|
-
assert(check1.includes('passed'), '
|
|
94
|
+
const check1 = run(`${CLI} guard`);
|
|
95
|
+
assert(check1.includes('passed'), 'guard passes after unlock');
|
|
96
96
|
run('git restore readme.txt'); // clean up
|
|
97
97
|
|
|
98
98
|
console.log('\n--- Test 7: Secret Sentinel detection ---');
|
|
99
99
|
fs.appendFileSync('readme.txt', 'const stripe_key = "sk_test_12345abcdeABCDE12345abcd";\n');
|
|
100
|
-
const secretViolation = run(`${CLI}
|
|
101
|
-
assert(secretViolation.includes('SECRET LEAK'), '
|
|
102
|
-
assert(secretViolation.includes('Stripe Secret Key'), '
|
|
103
|
-
|
|
104
|
-
console.log('\n--- Test 8: Secret Sentinel bypass (
|
|
105
|
-
run(`${CLI}
|
|
106
|
-
const secretCheck = run(`${CLI}
|
|
107
|
-
assert(secretCheck.includes('passed'), '
|
|
100
|
+
const secretViolation = run(`${CLI} guard`, true);
|
|
101
|
+
assert(secretViolation.includes('SECRET LEAK'), 'guard detects leaked stripe key');
|
|
102
|
+
assert(secretViolation.includes('Stripe Secret Key'), 'guard identifies secret type');
|
|
103
|
+
|
|
104
|
+
console.log('\n--- Test 8: Secret Sentinel bypass (trust) ---');
|
|
105
|
+
run(`${CLI} trust readme.txt "it is a mock key for tests"`);
|
|
106
|
+
const secretCheck = run(`${CLI} guard`);
|
|
107
|
+
assert(secretCheck.includes('passed'), 'trust successfully bypasses secret sentinel');
|
|
108
108
|
run('git restore readme.txt'); // clean up
|
|
109
109
|
|
|
110
110
|
console.log('\n--- Test 9: Function-level lock ---');
|
|
@@ -126,7 +126,7 @@ function workInProgress() {
|
|
|
126
126
|
return 'actively being edited -- new change';
|
|
127
127
|
}
|
|
128
128
|
`.trimStart());
|
|
129
|
-
const check2 = run(`${CLI}
|
|
129
|
+
const check2 = run(`${CLI} guard`);
|
|
130
130
|
assert(check2.includes('passed'), 'change outside locked function does not trigger violation');
|
|
131
131
|
|
|
132
132
|
console.log('\n--- Test 11: Change INSIDE locked function — violation ---');
|
|
@@ -139,14 +139,14 @@ function workInProgress() {
|
|
|
139
139
|
return 'actively being edited -- new change';
|
|
140
140
|
}
|
|
141
141
|
`.trimStart());
|
|
142
|
-
const violation2 = run(`${CLI}
|
|
142
|
+
const violation2 = run(`${CLI} guard`, true);
|
|
143
143
|
assert(violation2.includes('VIOLATION'), 'change inside locked function triggers violation');
|
|
144
144
|
assert(violation2.includes('stableFunc'), 'violation names the locked function');
|
|
145
145
|
|
|
146
146
|
console.log('\n--- Test 12: Function unlock clears function-level violation ---');
|
|
147
147
|
run(`${CLI} unlock app.js:stableFunc "need to update return value for new API"`);
|
|
148
|
-
const check3 = run(`${CLI}
|
|
149
|
-
assert(check3.includes('passed'), '
|
|
148
|
+
const check3 = run(`${CLI} guard`);
|
|
149
|
+
assert(check3.includes('passed'), 'guard passes after function unlock');
|
|
150
150
|
|
|
151
151
|
console.log('\n--- Test 13: Lock unknown function fails gracefully ---');
|
|
152
152
|
const badLock = run(`${CLI} lock app.js:doesNotExist "testing"`, true);
|
|
@@ -159,55 +159,55 @@ assert(ctx.includes('SCOPE CONTEXT'), 'context output contains header');
|
|
|
159
159
|
console.log('\n--- Test 15: Test Coverage Gate (Missing Tests) ---');
|
|
160
160
|
fs.writeFileSync('feature.js', 'console.log("new logic");\n');
|
|
161
161
|
run('git add feature.js');
|
|
162
|
-
const testGateOut = run(`${CLI}
|
|
163
|
-
assert(testGateOut.includes('TEST GATE VIOLATION'), '
|
|
162
|
+
const testGateOut = run(`${CLI} guard --tests`, true);
|
|
163
|
+
assert(testGateOut.includes('TEST GATE VIOLATION'), 'guard catches missing tests when flag is used');
|
|
164
164
|
|
|
165
165
|
console.log('\n--- Test 16: Test Coverage Gate (Tests Provided) ---');
|
|
166
166
|
fs.writeFileSync('feature.test.js', 'console.log("test for logic");\n');
|
|
167
167
|
run('git add feature.test.js');
|
|
168
|
-
const testGatePass = run(`${CLI}
|
|
169
|
-
assert(testGatePass.includes('passed'), '
|
|
168
|
+
const testGatePass = run(`${CLI} guard --tests`);
|
|
169
|
+
assert(testGatePass.includes('passed'), 'guard passes when test files accompany source files');
|
|
170
170
|
|
|
171
171
|
console.log('\n--- Test 17: Rollback Snapshot Creation ---');
|
|
172
|
-
run(`${CLI}
|
|
172
|
+
run(`${CLI} save`);
|
|
173
173
|
const m4 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
174
174
|
assert(m4.lastSnapshot === 'clean' || m4.lastSnapshot === 'dirty', 'snapshot state is tracked in manifest');
|
|
175
175
|
|
|
176
176
|
console.log('\n--- Test 18: Rollback Revert ---');
|
|
177
177
|
fs.writeFileSync('rogue.js', 'I am a rogue agent destroying things');
|
|
178
|
-
run(`${CLI}
|
|
179
|
-
assert(!fs.existsSync('rogue.js'), '
|
|
178
|
+
run(`${CLI} restore`);
|
|
179
|
+
assert(!fs.existsSync('rogue.js'), 'restore destroys untracked rogue files');
|
|
180
180
|
const m5 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
181
|
-
assert(m5.lastSnapshot === null, '
|
|
181
|
+
assert(m5.lastSnapshot === null, 'restore clears the snapshot marker');
|
|
182
182
|
|
|
183
|
-
console.log('\n--- Test 19: Production Path Lock (
|
|
184
|
-
run(`${CLI}
|
|
183
|
+
console.log('\n--- Test 19: Production Path Lock (seal) ---');
|
|
184
|
+
run(`${CLI} seal app.js "core auth logic — requires PR approval to modify"`);
|
|
185
185
|
const m6 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
186
|
-
assert(m6.files['app.js'].status === '
|
|
186
|
+
assert(m6.files['app.js'].status === 'sealed', 'seal sets status to sealed');
|
|
187
187
|
|
|
188
|
-
console.log('\n--- Test 20:
|
|
188
|
+
console.log('\n--- Test 20: seal blocks regular unlock ---');
|
|
189
189
|
const superUnlockOut = run(`${CLI} unlock app.js "trying to bypass"`, true);
|
|
190
|
-
assert(superUnlockOut.includes('
|
|
190
|
+
assert(superUnlockOut.includes('SEALED'), 'regular unlock is blocked on sealed file');
|
|
191
191
|
|
|
192
|
-
console.log('\n--- Test 21:
|
|
192
|
+
console.log('\n--- Test 21: seal blocks scopelock guard ---');
|
|
193
193
|
fs.appendFileSync('app.js', '\n// rogue addition\n');
|
|
194
|
-
const superCheckOut = run(`${CLI}
|
|
195
|
-
assert(superCheckOut.includes('
|
|
194
|
+
const superCheckOut = run(`${CLI} guard`, true);
|
|
195
|
+
assert(superCheckOut.includes('SEALED'), 'guard reports SEALED violation');
|
|
196
196
|
run('git restore app.js');
|
|
197
197
|
|
|
198
|
-
console.log('\n--- Test 22:
|
|
199
|
-
run(`${CLI}
|
|
198
|
+
console.log('\n--- Test 22: unseal releases a seal with ticket ---');
|
|
199
|
+
run(`${CLI} unseal app.js --human-approved=JIRA-999 "approved by senior eng for critical hotfix"`);
|
|
200
200
|
const m7 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
201
|
-
assert(m7.files['app.js'].status === 'active', '
|
|
202
|
-
const sudoHistory = m7.files['app.js'].history.find(h => h.action === '
|
|
203
|
-
assert(sudoHistory && sudoHistory.humanApproved === 'JIRA-999', '
|
|
201
|
+
assert(m7.files['app.js'].status === 'active', 'unseal transitions sealed to active');
|
|
202
|
+
const sudoHistory = m7.files['app.js'].history.find(h => h.action === 'unsealed');
|
|
203
|
+
assert(sudoHistory && sudoHistory.humanApproved === 'JIRA-999', 'unseal logs the human-approved ticket');
|
|
204
204
|
|
|
205
|
-
console.log('\n--- Test 23: Blast Radius Map ---');
|
|
205
|
+
console.log('\n--- Test 23: Blast Radius Map (impact) ---');
|
|
206
206
|
// Make app.js import readme.txt by creating an importer
|
|
207
207
|
fs.writeFileSync('importer.js', `import { something } from './app';\n`);
|
|
208
|
-
const blastOut = run(`${CLI}
|
|
209
|
-
assert(blastOut.includes('Blast Radius'), '
|
|
210
|
-
assert(blastOut.includes('importer.js'), '
|
|
208
|
+
const blastOut = run(`${CLI} impact app.js`);
|
|
209
|
+
assert(blastOut.includes('Blast Radius'), 'impact outputs the report header');
|
|
210
|
+
assert(blastOut.includes('importer.js'), 'impact correctly identifies importer.js as a dependent');
|
|
211
211
|
|
|
212
212
|
// ─── Done ─────────────────────────────────────────────────────────────────────
|
|
213
213
|
|