@manukyalo/scopelock 2.0.0 → 2.1.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 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
- ## Companion Skill
13
+ ## Features
14
14
 
15
- `skills/scope-enforcement/SKILL.md` is a structured 3-checkpoint workflow for AI agents that enforces scopelock boundaries across every phase of a session. Load it into: **Antigravity**, **Claude Code**, **Gemini CLI**, **Cursor**, **Kiro**.
16
-
17
- ---
18
-
19
- ## The Problem
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 status`
42
- Print a human-readable summary of the manifest.
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 "Fix the broken checkout flow" | clip
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
- Once wired, no agent or developer can commit a scope violation.
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
- | Language | Extensions |
118
- |----------|-----------|
119
- | JavaScript | `.js`, `.jsx`, `.mjs`, `.cjs` |
120
- | TypeScript | `.ts`, `.tsx` |
121
- | Python | `.py` |
85
+ Locked files:
86
+ src/lib/supabase.ts
87
+ ```
122
88
 
123
- All other file types fall back to file-level locking automatically.
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
- ## Commit `.scopelock.json`
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
@@ -61,6 +61,17 @@ switch (command) {
61
61
  manifest.status();
62
62
  break;
63
63
 
64
+ case 'allow-secret': {
65
+ const target = args[0];
66
+ const reason = args.slice(1).join(' ');
67
+ if (!target || !reason) {
68
+ console.error('Usage: scopelock allow-secret <file> <reason>');
69
+ process.exit(1);
70
+ }
71
+ manifest.allowSecret(target, reason);
72
+ break;
73
+ }
74
+
64
75
  default:
65
76
  console.log(`
66
77
  scopelock — Anti-hallucination scope locking for AI coding agents.
@@ -69,10 +80,12 @@ Usage:
69
80
  scopelock init Scan repo and generate .scopelock.json
70
81
  scopelock lock <file>[:<func>] [reason] Lock a file or a specific function
71
82
  scopelock unlock <file>[:<func>] <reason> Unlock (reason is mandatory)
83
+ scopelock allow-secret <file> <reason> Bypass Secret Sentinel for a specific file
72
84
  scopelock context [task] Generate AI context block for a task
73
- scopelock check Check git diff for scope violations
85
+ scopelock check Check git diff for scope violations and secret leaks
74
86
  scopelock status Show manifest summary
75
87
 
88
+
76
89
  Examples:
77
90
  scopelock lock src/auth.ts
78
91
  scopelock lock src/auth.ts:validateToken "stable — do not touch"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manukyalo/scopelock",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Anti-hallucination scope locking for AI coding agents.",
5
5
  "main": "bin/scopelock.js",
6
6
  "bin": {
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 {Set<number>} 1-indexed line numbers that changed in the new file.
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 Set();
34
+ if (!diffOutput.trim()) return new Map();
35
35
 
36
- const changedLines = new Set();
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
- changedLines.add(currentLine);
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,6 +19,7 @@ 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
24
  function check() {
24
25
  const manifest = getManifest();
@@ -51,6 +52,27 @@ function check() {
51
52
  const normalizedFile = file.replace(/\\/g, '/');
52
53
  const entry = manifest.files[normalizedFile];
53
54
 
55
+ const changedLines = getChangedLines(normalizedFile);
56
+
57
+ // ── Tier 0: Secret Sentinel ─────────────────────────────────────────────
58
+ if (changedLines.size > 0) {
59
+ // Check if this file has explicitly allowed secrets
60
+ const hasOverride = manifest.allowedSecrets && manifest.allowedSecrets[normalizedFile];
61
+ if (!hasOverride) {
62
+ for (const [lineNum, content] of changedLines.entries()) {
63
+ const secretType = detectSecret(content);
64
+ if (secretType) {
65
+ violations.push({
66
+ type: 'secret',
67
+ file: normalizedFile,
68
+ message: `SECRET LEAK [${secretType}] detected in '${normalizedFile}' on line ${lineNum}.`,
69
+ });
70
+ break; // One secret violation per file is enough
71
+ }
72
+ }
73
+ }
74
+ }
75
+
54
76
  // ── Tier 1: File-level lock ─────────────────────────────────────────────
55
77
  if (entry && entry.status === 'locked') {
56
78
  violations.push({
@@ -70,10 +92,6 @@ function check() {
70
92
 
71
93
  if (lockedFunctions.length === 0) continue;
72
94
 
73
- // Get the line numbers that changed in this specific file
74
- const changedLines = getChangedLines(normalizedFile);
75
- if (changedLines.size === 0) continue;
76
-
77
95
  // Re-extract function boundaries from the current on-disk file
78
96
  const currentFunctions = extractFunctions(normalizedFile);
79
97
 
@@ -91,7 +109,7 @@ function check() {
91
109
  }
92
110
 
93
111
  // Check if any changed line falls within the function's boundaries
94
- for (const line of changedLines) {
112
+ for (const line of changedLines.keys()) {
95
113
  if (line >= fn.startLine && line <= fn.endLine) {
96
114
  violations.push({
97
115
  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
- manifest.files[relativePath] = { status: 'unscoped', functions: {}, history: [] };
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
- // ─── Commands ─────────────────────────────────────────────────────────────────
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.files[relativePath] = { status: 'unscoped', functions: {}, history: [] };
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
- module.exports = { init, lock, unlock, status, getManifest, saveManifest };
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: scopelock status ---');
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 3: File-level lock ---');
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 4: File-level violation detection ---');
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 5: File-level unlock clears violation ---');
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 6: Function-level lock ---');
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 7: Change OUTSIDE locked function — no violation ---');
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 8: Change INSIDE locked function — violation ---');
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,19 @@ 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 9: Function unlock clears function-level violation ---');
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 10: Lock unknown function fails gracefully ---');
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 11: scopelock context output ---');
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
 
144
159
  // ─── Done ─────────────────────────────────────────────────────────────────────
145
160
 
146
- console.log('\n✅ All 11 tests passed.\n');
161
+ console.log('\n✅ All 14 tests passed.\n');