speclock 2.5.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 +24 -2
- package/package.json +8 -3
- package/src/cli/index.js +141 -1
- package/src/core/auth.js +341 -0
- package/src/core/compliance.js +1 -1
- package/src/core/crypto.js +158 -0
- package/src/core/engine.js +24 -0
- package/src/core/storage.js +23 -4
- package/src/mcp/http-server.js +93 -3
- package/src/mcp/server.js +24 -1
package/README.md
CHANGED
|
@@ -59,6 +59,8 @@ No other tool does this. Not Claude's native memory. Not Mem0. Not CLAUDE.md fil
|
|
|
59
59
|
| Drift detection | No | No | No | **Yes — scans changes against locks** |
|
|
60
60
|
| CI/CD integration | No | No | No | **Yes — GitHub Actions** |
|
|
61
61
|
| **Hard enforcement (block violations)** | No | No | No | **Yes — hard mode blocks above threshold** |
|
|
62
|
+
| **API Key Auth + RBAC** | No | No | No | **Yes** |
|
|
63
|
+
| **Encrypted Storage (AES-256)** | No | No | No | **Yes** |
|
|
62
64
|
| Multi-agent timeline | No | No | No | **Yes** |
|
|
63
65
|
| Cross-platform | Claude only | MCP only | Tool-specific | **Universal (MCP + npm)** |
|
|
64
66
|
|
|
@@ -400,6 +402,25 @@ Hard mode: AI is BLOCKED — cannot proceed (MCP returns isError:
|
|
|
400
402
|
- **Escalation**: Lock overridden 3+ times → auto-creates a review note
|
|
401
403
|
- **Semantic pre-commit**: Parses actual git diff content, runs semantic analysis against locks
|
|
402
404
|
|
|
405
|
+
## Security & Access Control (v3.0)
|
|
406
|
+
|
|
407
|
+
### API Key Authentication
|
|
408
|
+
SHA-256 hashed keys stored server-side. HTTP transport uses `Authorization: Bearer <key>` headers. MCP transport authenticates via the `SPECLOCK_API_KEY` environment variable. Keys are never stored in plaintext.
|
|
409
|
+
|
|
410
|
+
### RBAC (4 Roles)
|
|
411
|
+
| Role | Permissions |
|
|
412
|
+
|------|-------------|
|
|
413
|
+
| **viewer** | Read-only access to context, locks, decisions, and events |
|
|
414
|
+
| **developer** | Read + override locks with a documented reason |
|
|
415
|
+
| **architect** | Read + write (add/remove locks, decisions) + override |
|
|
416
|
+
| **admin** | Full access — manage keys, roles, enforcement settings, and all operations |
|
|
417
|
+
|
|
418
|
+
### AES-256-GCM Encryption
|
|
419
|
+
Transparent encrypt-on-write / decrypt-on-read for `brain.json` and `events.log`. Encryption key is derived via PBKDF2 from the `SPECLOCK_ENCRYPTION_KEY` environment variable. Authenticated encryption (GCM) ensures both confidentiality and integrity. **HIPAA 2026 compliant.**
|
|
420
|
+
|
|
421
|
+
### Test Coverage
|
|
422
|
+
**300 tests passing** (up from 186 in v2.5). Full coverage for authentication, authorization, encryption, semantic detection, and audit chain integrity.
|
|
423
|
+
|
|
403
424
|
---
|
|
404
425
|
|
|
405
426
|
## Architecture
|
|
@@ -417,7 +438,8 @@ Hard mode: AI is BLOCKED — cannot proceed (MCP returns isError:
|
|
|
417
438
|
┌──────────────▼──────────────────▼────────────────────┐
|
|
418
439
|
│ SpecLock Core Engine │
|
|
419
440
|
│ Memory | Tracking | Enforcement | Git | Intelligence │
|
|
420
|
-
│ Audit | Compliance | License
|
|
441
|
+
│ Audit | Compliance | License | Auth | RBAC │
|
|
442
|
+
│ AES-256-GCM Encryption (brain.json, events.log) │
|
|
421
443
|
└──────────────────────┬───────────────────────────────┘
|
|
422
444
|
│
|
|
423
445
|
.speclock/
|
|
@@ -447,4 +469,4 @@ MIT License - see [LICENSE](LICENSE) file.
|
|
|
447
469
|
|
|
448
470
|
---
|
|
449
471
|
|
|
450
|
-
*SpecLock
|
|
472
|
+
*SpecLock v3.0.0 — Semantic conflict detection + enterprise audit & compliance. 100% detection, 0% false positives. HMAC audit chain, SOC 2/HIPAA exports. Hard enforcement mode. API Key Auth + RBAC. AES-256-GCM encrypted storage. 300 tests passing. Because remembering isn't enough — AI needs to respect boundaries.*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speclock",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "AI constraint engine with hard enforcement, semantic pre-commit, HMAC audit chain, SOC 2/HIPAA compliance. 100% detection, 0% false positives. 28 MCP tools + CLI. Enterprise-ready.",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "AI constraint engine with API key auth, RBAC, AES-256-GCM encryption, hard enforcement, semantic pre-commit, HMAC audit chain, SOC 2/HIPAA compliance. 100% detection, 0% false positives. 28 MCP tools + CLI. Enterprise-ready.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/mcp/server.js",
|
|
7
7
|
"bin": {
|
|
@@ -36,7 +36,12 @@
|
|
|
36
36
|
"hipaa",
|
|
37
37
|
"compliance",
|
|
38
38
|
"audit-trail",
|
|
39
|
-
"hmac"
|
|
39
|
+
"hmac",
|
|
40
|
+
"encryption",
|
|
41
|
+
"aes-256",
|
|
42
|
+
"api-key",
|
|
43
|
+
"authentication",
|
|
44
|
+
"rbac"
|
|
40
45
|
],
|
|
41
46
|
"author": "Sandeep Roy (https://github.com/sgroy10)",
|
|
42
47
|
"license": "MIT",
|
package/src/cli/index.js
CHANGED
|
@@ -33,6 +33,16 @@ import {
|
|
|
33
33
|
import { generateContext } from "../core/context.js";
|
|
34
34
|
import { readBrain } from "../core/storage.js";
|
|
35
35
|
import { installHook, removeHook } from "../core/hooks.js";
|
|
36
|
+
import {
|
|
37
|
+
isAuthEnabled,
|
|
38
|
+
enableAuth,
|
|
39
|
+
disableAuth,
|
|
40
|
+
createApiKey,
|
|
41
|
+
rotateApiKey,
|
|
42
|
+
revokeApiKey,
|
|
43
|
+
listApiKeys,
|
|
44
|
+
} from "../core/auth.js";
|
|
45
|
+
import { isEncryptionEnabled } from "../core/crypto.js";
|
|
36
46
|
|
|
37
47
|
// --- Argument parsing ---
|
|
38
48
|
|
|
@@ -88,7 +98,7 @@ function refreshContext(root) {
|
|
|
88
98
|
|
|
89
99
|
function printHelp() {
|
|
90
100
|
console.log(`
|
|
91
|
-
SpecLock
|
|
101
|
+
SpecLock v3.0.0 — AI Constraint Engine (Auth + RBAC + Encryption + Hard Enforcement)
|
|
92
102
|
Developed by Sandeep Roy (github.com/sgroy10)
|
|
93
103
|
|
|
94
104
|
Usage: speclock <command> [options]
|
|
@@ -136,10 +146,22 @@ Options:
|
|
|
136
146
|
|
|
137
147
|
Templates: nextjs, react, express, supabase, stripe, security-hardened
|
|
138
148
|
|
|
149
|
+
Security (v3.0):
|
|
150
|
+
auth status Show auth status and active keys
|
|
151
|
+
auth create-key --role <role> Create API key (viewer/developer/architect/admin)
|
|
152
|
+
auth rotate-key <keyId> Rotate an API key
|
|
153
|
+
auth revoke-key <keyId> Revoke an API key
|
|
154
|
+
auth list-keys List all API keys
|
|
155
|
+
auth enable Enable API key authentication
|
|
156
|
+
auth disable Disable authentication
|
|
157
|
+
encrypt [status] Show encryption status
|
|
158
|
+
|
|
139
159
|
Enterprise:
|
|
140
160
|
SPECLOCK_AUDIT_SECRET HMAC secret for audit chain (env var)
|
|
141
161
|
SPECLOCK_LICENSE_KEY License key for Pro/Enterprise features
|
|
142
162
|
SPECLOCK_LLM_KEY API key for LLM-powered conflict detection
|
|
163
|
+
SPECLOCK_ENCRYPTION_KEY Master key for AES-256-GCM encryption
|
|
164
|
+
SPECLOCK_API_KEY API key for MCP server auth
|
|
143
165
|
|
|
144
166
|
Examples:
|
|
145
167
|
npx speclock setup --goal "Build PawPalace pet shop" --template nextjs
|
|
@@ -186,6 +208,8 @@ function showStatus(root) {
|
|
|
186
208
|
}
|
|
187
209
|
|
|
188
210
|
console.log(`Recent changes: ${brain.state.recentChanges.length}`);
|
|
211
|
+
console.log(`Auth: ${isAuthEnabled(root) ? "enabled" : "disabled"}`);
|
|
212
|
+
console.log(`Encryption: ${isEncryptionEnabled() ? "enabled (AES-256-GCM)" : "disabled"}`);
|
|
189
213
|
console.log("");
|
|
190
214
|
}
|
|
191
215
|
|
|
@@ -778,6 +802,122 @@ Tip: When starting a new chat, tell the AI:
|
|
|
778
802
|
process.exit(result.blocked ? 1 : 0);
|
|
779
803
|
}
|
|
780
804
|
|
|
805
|
+
// --- AUTH (v3.0) ---
|
|
806
|
+
if (cmd === "auth") {
|
|
807
|
+
const sub = args[0];
|
|
808
|
+
if (!sub || sub === "status") {
|
|
809
|
+
const enabled = isAuthEnabled(root);
|
|
810
|
+
console.log(`\nAuth Status: ${enabled ? "ENABLED" : "DISABLED"}`);
|
|
811
|
+
if (enabled) {
|
|
812
|
+
const keys = listApiKeys(root);
|
|
813
|
+
const active = keys.keys.filter(k => k.active);
|
|
814
|
+
console.log(`Active keys: ${active.length}`);
|
|
815
|
+
for (const k of active) {
|
|
816
|
+
console.log(` ${k.id} — ${k.name} (${k.role}) — last used: ${k.lastUsed || "never"}`);
|
|
817
|
+
}
|
|
818
|
+
} else {
|
|
819
|
+
console.log("Run 'speclock auth create-key --role admin' to enable auth.");
|
|
820
|
+
}
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (sub === "create-key") {
|
|
824
|
+
const flags = parseFlags(args.slice(1));
|
|
825
|
+
const role = flags.role || "developer";
|
|
826
|
+
const name = flags.name || flags._.join(" ") || "";
|
|
827
|
+
const result = createApiKey(root, role, name);
|
|
828
|
+
if (!result.success) {
|
|
829
|
+
console.error(result.error);
|
|
830
|
+
process.exit(1);
|
|
831
|
+
}
|
|
832
|
+
console.log(`\nAPI Key Created`);
|
|
833
|
+
console.log("=".repeat(50));
|
|
834
|
+
console.log(`Key ID: ${result.keyId}`);
|
|
835
|
+
console.log(`Role: ${result.role}`);
|
|
836
|
+
console.log(`Name: ${result.name}`);
|
|
837
|
+
console.log(`\nRaw Key: ${result.rawKey}`);
|
|
838
|
+
console.log(`\nSave this key — it CANNOT be retrieved later.`);
|
|
839
|
+
console.log(`\nUsage:`);
|
|
840
|
+
console.log(` HTTP: Authorization: Bearer ${result.rawKey}`);
|
|
841
|
+
console.log(` MCP: Set SPECLOCK_API_KEY=${result.rawKey} in MCP config`);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (sub === "rotate-key") {
|
|
845
|
+
const keyId = args[1];
|
|
846
|
+
if (!keyId) {
|
|
847
|
+
console.error("Usage: speclock auth rotate-key <keyId>");
|
|
848
|
+
process.exit(1);
|
|
849
|
+
}
|
|
850
|
+
const result = rotateApiKey(root, keyId);
|
|
851
|
+
if (!result.success) {
|
|
852
|
+
console.error(result.error);
|
|
853
|
+
process.exit(1);
|
|
854
|
+
}
|
|
855
|
+
console.log(`\nKey Rotated`);
|
|
856
|
+
console.log(`Old key: ${result.oldKeyId} (revoked)`);
|
|
857
|
+
console.log(`New key: ${result.newKeyId}`);
|
|
858
|
+
console.log(`Raw Key: ${result.rawKey}`);
|
|
859
|
+
console.log(`\nSave this key — it CANNOT be retrieved later.`);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (sub === "revoke-key") {
|
|
863
|
+
const keyId = args[1];
|
|
864
|
+
if (!keyId) {
|
|
865
|
+
console.error("Usage: speclock auth revoke-key <keyId>");
|
|
866
|
+
process.exit(1);
|
|
867
|
+
}
|
|
868
|
+
const reason = args.slice(2).join(" ") || "manual";
|
|
869
|
+
const result = revokeApiKey(root, keyId, reason);
|
|
870
|
+
if (!result.success) {
|
|
871
|
+
console.error(result.error);
|
|
872
|
+
process.exit(1);
|
|
873
|
+
}
|
|
874
|
+
console.log(`Key revoked: ${result.keyId} (${result.name}, ${result.role})`);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (sub === "list-keys") {
|
|
878
|
+
const result = listApiKeys(root);
|
|
879
|
+
console.log(`\nAPI Keys (auth ${result.enabled ? "enabled" : "disabled"}):`);
|
|
880
|
+
console.log("=".repeat(50));
|
|
881
|
+
if (result.keys.length === 0) {
|
|
882
|
+
console.log(" No keys configured.");
|
|
883
|
+
} else {
|
|
884
|
+
for (const k of result.keys) {
|
|
885
|
+
const status = k.active ? "active" : `revoked (${k.revokedAt?.substring(0, 10) || "unknown"})`;
|
|
886
|
+
console.log(` ${k.id} — ${k.name} (${k.role}) [${status}]`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (sub === "enable") {
|
|
892
|
+
enableAuth(root);
|
|
893
|
+
console.log("Auth enabled. API keys are now required for HTTP access.");
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (sub === "disable") {
|
|
897
|
+
disableAuth(root);
|
|
898
|
+
console.log("Auth disabled. All operations allowed without keys.");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
console.error("Usage: speclock auth <create-key|rotate-key|revoke-key|list-keys|enable|disable|status>");
|
|
902
|
+
process.exit(1);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// --- ENCRYPT STATUS (v3.0) ---
|
|
906
|
+
if (cmd === "encrypt") {
|
|
907
|
+
const sub = args[0];
|
|
908
|
+
if (sub === "status" || !sub) {
|
|
909
|
+
const enabled = isEncryptionEnabled();
|
|
910
|
+
console.log(`\nEncryption: ${enabled ? "ENABLED (AES-256-GCM)" : "DISABLED"}`);
|
|
911
|
+
if (!enabled) {
|
|
912
|
+
console.log("Set SPECLOCK_ENCRYPTION_KEY env var to enable encryption.");
|
|
913
|
+
console.log("All data will be encrypted at rest (brain.json + events.log).");
|
|
914
|
+
}
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
console.error("Usage: speclock encrypt [status]");
|
|
918
|
+
process.exit(1);
|
|
919
|
+
}
|
|
920
|
+
|
|
781
921
|
// --- STATUS ---
|
|
782
922
|
if (cmd === "status") {
|
|
783
923
|
showStatus(root);
|
package/src/core/auth.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecLock API Key Authentication
|
|
3
|
+
* Provides API key generation, validation, rotation, and revocation.
|
|
4
|
+
* Keys are SHA-256 hashed before storage — raw keys never stored.
|
|
5
|
+
*
|
|
6
|
+
* Storage: .speclock/auth.json (gitignored)
|
|
7
|
+
* Key format: sl_key_<random hex>
|
|
8
|
+
*
|
|
9
|
+
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import crypto from "crypto";
|
|
15
|
+
|
|
16
|
+
const AUTH_FILE = "auth.json";
|
|
17
|
+
const KEY_PREFIX = "sl_key_";
|
|
18
|
+
|
|
19
|
+
// --- RBAC Role Definitions ---
|
|
20
|
+
|
|
21
|
+
export const ROLES = {
|
|
22
|
+
viewer: {
|
|
23
|
+
name: "viewer",
|
|
24
|
+
description: "Read-only access to context, events, and status",
|
|
25
|
+
permissions: ["read"],
|
|
26
|
+
},
|
|
27
|
+
developer: {
|
|
28
|
+
name: "developer",
|
|
29
|
+
description: "Read access + can override locks with reason",
|
|
30
|
+
permissions: ["read", "override"],
|
|
31
|
+
},
|
|
32
|
+
architect: {
|
|
33
|
+
name: "architect",
|
|
34
|
+
description: "Read + write locks, decisions, goals, notes",
|
|
35
|
+
permissions: ["read", "write", "override"],
|
|
36
|
+
},
|
|
37
|
+
admin: {
|
|
38
|
+
name: "admin",
|
|
39
|
+
description: "Full access including auth management and enforcement config",
|
|
40
|
+
permissions: ["read", "write", "override", "admin"],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Tool → required permission mapping
|
|
45
|
+
export const TOOL_PERMISSIONS = {
|
|
46
|
+
// Read-only tools
|
|
47
|
+
speclock_init: "read",
|
|
48
|
+
speclock_get_context: "read",
|
|
49
|
+
speclock_get_changes: "read",
|
|
50
|
+
speclock_get_events: "read",
|
|
51
|
+
speclock_session_briefing: "read",
|
|
52
|
+
speclock_repo_status: "read",
|
|
53
|
+
speclock_suggest_locks: "read",
|
|
54
|
+
speclock_detect_drift: "read",
|
|
55
|
+
speclock_health: "read",
|
|
56
|
+
speclock_report: "read",
|
|
57
|
+
speclock_verify_audit: "read",
|
|
58
|
+
speclock_export_compliance: "read",
|
|
59
|
+
speclock_override_history: "read",
|
|
60
|
+
speclock_semantic_audit: "read",
|
|
61
|
+
|
|
62
|
+
// Write tools
|
|
63
|
+
speclock_set_goal: "write",
|
|
64
|
+
speclock_add_lock: "write",
|
|
65
|
+
speclock_remove_lock: "write",
|
|
66
|
+
speclock_add_decision: "write",
|
|
67
|
+
speclock_add_note: "write",
|
|
68
|
+
speclock_set_deploy_facts: "write",
|
|
69
|
+
speclock_log_change: "write",
|
|
70
|
+
speclock_check_conflict: "read",
|
|
71
|
+
speclock_session_summary: "write",
|
|
72
|
+
speclock_checkpoint: "write",
|
|
73
|
+
speclock_apply_template: "write",
|
|
74
|
+
speclock_audit: "read",
|
|
75
|
+
|
|
76
|
+
// Override tools
|
|
77
|
+
speclock_override_lock: "override",
|
|
78
|
+
|
|
79
|
+
// Admin tools
|
|
80
|
+
speclock_set_enforcement: "admin",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// --- Path helpers ---
|
|
84
|
+
|
|
85
|
+
function authPath(root) {
|
|
86
|
+
return path.join(root, ".speclock", AUTH_FILE);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Auth store ---
|
|
90
|
+
|
|
91
|
+
function readAuthStore(root) {
|
|
92
|
+
const p = authPath(root);
|
|
93
|
+
if (!fs.existsSync(p)) {
|
|
94
|
+
return { enabled: false, keys: [] };
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
98
|
+
} catch {
|
|
99
|
+
return { enabled: false, keys: [] };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeAuthStore(root, store) {
|
|
104
|
+
const p = authPath(root);
|
|
105
|
+
fs.writeFileSync(p, JSON.stringify(store, null, 2));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function hashKey(rawKey) {
|
|
109
|
+
return crypto.createHash("sha256").update(rawKey).digest("hex");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function generateRawKey() {
|
|
113
|
+
return KEY_PREFIX + crypto.randomBytes(24).toString("hex");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Gitignore auth.json ---
|
|
117
|
+
|
|
118
|
+
export function ensureAuthGitignored(root) {
|
|
119
|
+
const giPath = path.join(root, ".speclock", ".gitignore");
|
|
120
|
+
let content = "";
|
|
121
|
+
if (fs.existsSync(giPath)) {
|
|
122
|
+
content = fs.readFileSync(giPath, "utf-8");
|
|
123
|
+
}
|
|
124
|
+
if (!content.includes(AUTH_FILE)) {
|
|
125
|
+
const line = content.endsWith("\n") || content === "" ? AUTH_FILE + "\n" : "\n" + AUTH_FILE + "\n";
|
|
126
|
+
fs.appendFileSync(giPath, line);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Public API ---
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if auth is enabled for this project.
|
|
134
|
+
*/
|
|
135
|
+
export function isAuthEnabled(root) {
|
|
136
|
+
const store = readAuthStore(root);
|
|
137
|
+
return store.enabled === true && store.keys.length > 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Enable authentication for this project.
|
|
142
|
+
*/
|
|
143
|
+
export function enableAuth(root) {
|
|
144
|
+
const store = readAuthStore(root);
|
|
145
|
+
store.enabled = true;
|
|
146
|
+
writeAuthStore(root, store);
|
|
147
|
+
ensureAuthGitignored(root);
|
|
148
|
+
return { success: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Disable authentication (all operations allowed).
|
|
153
|
+
*/
|
|
154
|
+
export function disableAuth(root) {
|
|
155
|
+
const store = readAuthStore(root);
|
|
156
|
+
store.enabled = false;
|
|
157
|
+
writeAuthStore(root, store);
|
|
158
|
+
return { success: true };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a new API key with a role and optional name.
|
|
163
|
+
* Returns the raw key (only shown once).
|
|
164
|
+
*/
|
|
165
|
+
export function createApiKey(root, role, name = "") {
|
|
166
|
+
if (!ROLES[role]) {
|
|
167
|
+
return { success: false, error: `Invalid role: "${role}". Valid roles: ${Object.keys(ROLES).join(", ")}` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const store = readAuthStore(root);
|
|
171
|
+
const rawKey = generateRawKey();
|
|
172
|
+
const keyHash = hashKey(rawKey);
|
|
173
|
+
const keyId = "key_" + crypto.randomBytes(4).toString("hex");
|
|
174
|
+
|
|
175
|
+
store.keys.push({
|
|
176
|
+
id: keyId,
|
|
177
|
+
name: name || `${role}-${keyId}`,
|
|
178
|
+
hash: keyHash,
|
|
179
|
+
role,
|
|
180
|
+
createdAt: new Date().toISOString(),
|
|
181
|
+
lastUsed: null,
|
|
182
|
+
active: true,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (!store.enabled) {
|
|
186
|
+
store.enabled = true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
writeAuthStore(root, store);
|
|
190
|
+
ensureAuthGitignored(root);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
success: true,
|
|
194
|
+
keyId,
|
|
195
|
+
rawKey,
|
|
196
|
+
role,
|
|
197
|
+
name: name || `${role}-${keyId}`,
|
|
198
|
+
message: "Save this key — it cannot be retrieved later.",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Validate an API key. Returns the key record if valid.
|
|
204
|
+
*/
|
|
205
|
+
export function validateApiKey(root, rawKey) {
|
|
206
|
+
const store = readAuthStore(root);
|
|
207
|
+
|
|
208
|
+
if (!store.enabled) {
|
|
209
|
+
// Auth not enabled — allow everything (backward compatible)
|
|
210
|
+
return { valid: true, role: "admin", authEnabled: false };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!rawKey) {
|
|
214
|
+
return { valid: false, error: "API key required. Auth is enabled for this project." };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const keyHash = hashKey(rawKey);
|
|
218
|
+
const keyRecord = store.keys.find(k => k.hash === keyHash && k.active);
|
|
219
|
+
|
|
220
|
+
if (!keyRecord) {
|
|
221
|
+
return { valid: false, error: "Invalid or revoked API key." };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Update last used
|
|
225
|
+
keyRecord.lastUsed = new Date().toISOString();
|
|
226
|
+
writeAuthStore(root, store);
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
valid: true,
|
|
230
|
+
keyId: keyRecord.id,
|
|
231
|
+
role: keyRecord.role,
|
|
232
|
+
name: keyRecord.name,
|
|
233
|
+
authEnabled: true,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if a role has permission for a specific tool/action.
|
|
239
|
+
*/
|
|
240
|
+
export function checkPermission(role, toolName) {
|
|
241
|
+
const roleConfig = ROLES[role];
|
|
242
|
+
if (!roleConfig) return false;
|
|
243
|
+
|
|
244
|
+
// Admin has all permissions
|
|
245
|
+
if (role === "admin") return true;
|
|
246
|
+
|
|
247
|
+
const requiredPermission = TOOL_PERMISSIONS[toolName];
|
|
248
|
+
if (!requiredPermission) {
|
|
249
|
+
// Unknown tool — default to admin-only
|
|
250
|
+
return role === "admin";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return roleConfig.permissions.includes(requiredPermission);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Rotate an API key — revoke old, create new with same role and name.
|
|
258
|
+
*/
|
|
259
|
+
export function rotateApiKey(root, keyId) {
|
|
260
|
+
const store = readAuthStore(root);
|
|
261
|
+
const keyRecord = store.keys.find(k => k.id === keyId && k.active);
|
|
262
|
+
|
|
263
|
+
if (!keyRecord) {
|
|
264
|
+
return { success: false, error: `Active key not found: ${keyId}` };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Revoke old key
|
|
268
|
+
keyRecord.active = false;
|
|
269
|
+
keyRecord.revokedAt = new Date().toISOString();
|
|
270
|
+
keyRecord.revokeReason = "rotated";
|
|
271
|
+
|
|
272
|
+
// Create new key with same role/name
|
|
273
|
+
const rawKey = generateRawKey();
|
|
274
|
+
const newKeyHash = hashKey(rawKey);
|
|
275
|
+
const newKeyId = "key_" + crypto.randomBytes(4).toString("hex");
|
|
276
|
+
|
|
277
|
+
store.keys.push({
|
|
278
|
+
id: newKeyId,
|
|
279
|
+
name: keyRecord.name,
|
|
280
|
+
hash: newKeyHash,
|
|
281
|
+
role: keyRecord.role,
|
|
282
|
+
createdAt: new Date().toISOString(),
|
|
283
|
+
lastUsed: null,
|
|
284
|
+
active: true,
|
|
285
|
+
rotatedFrom: keyId,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
writeAuthStore(root, store);
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
success: true,
|
|
292
|
+
oldKeyId: keyId,
|
|
293
|
+
newKeyId,
|
|
294
|
+
rawKey,
|
|
295
|
+
role: keyRecord.role,
|
|
296
|
+
message: "Key rotated. Save the new key — it cannot be retrieved later.",
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Revoke an API key.
|
|
302
|
+
*/
|
|
303
|
+
export function revokeApiKey(root, keyId, reason = "manual") {
|
|
304
|
+
const store = readAuthStore(root);
|
|
305
|
+
const keyRecord = store.keys.find(k => k.id === keyId && k.active);
|
|
306
|
+
|
|
307
|
+
if (!keyRecord) {
|
|
308
|
+
return { success: false, error: `Active key not found: ${keyId}` };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
keyRecord.active = false;
|
|
312
|
+
keyRecord.revokedAt = new Date().toISOString();
|
|
313
|
+
keyRecord.revokeReason = reason;
|
|
314
|
+
writeAuthStore(root, store);
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
success: true,
|
|
318
|
+
keyId,
|
|
319
|
+
name: keyRecord.name,
|
|
320
|
+
role: keyRecord.role,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* List all API keys (hashes hidden).
|
|
326
|
+
*/
|
|
327
|
+
export function listApiKeys(root) {
|
|
328
|
+
const store = readAuthStore(root);
|
|
329
|
+
return {
|
|
330
|
+
enabled: store.enabled,
|
|
331
|
+
keys: store.keys.map(k => ({
|
|
332
|
+
id: k.id,
|
|
333
|
+
name: k.name,
|
|
334
|
+
role: k.role,
|
|
335
|
+
active: k.active,
|
|
336
|
+
createdAt: k.createdAt,
|
|
337
|
+
lastUsed: k.lastUsed,
|
|
338
|
+
revokedAt: k.revokedAt || null,
|
|
339
|
+
})),
|
|
340
|
+
};
|
|
341
|
+
}
|
package/src/core/compliance.js
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecLock Encrypted Storage
|
|
3
|
+
* AES-256-GCM encryption for brain.json and events.log at rest.
|
|
4
|
+
*
|
|
5
|
+
* Key derivation: PBKDF2 from SPECLOCK_ENCRYPTION_KEY env var
|
|
6
|
+
* Transparent: encrypt on write, decrypt on read
|
|
7
|
+
* Format: Base64(IV:AuthTag:CipherText) per line/file
|
|
8
|
+
*
|
|
9
|
+
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
|
|
14
|
+
const ALGORITHM = "aes-256-gcm";
|
|
15
|
+
const IV_LENGTH = 16;
|
|
16
|
+
const AUTH_TAG_LENGTH = 16;
|
|
17
|
+
const SALT = "speclock-v3-salt"; // Static salt (key is already strong from env)
|
|
18
|
+
const ITERATIONS = 100000;
|
|
19
|
+
const KEY_LENGTH = 32;
|
|
20
|
+
const ENCRYPTED_MARKER = "SPECLOCK_ENCRYPTED:";
|
|
21
|
+
|
|
22
|
+
// --- Key Derivation ---
|
|
23
|
+
|
|
24
|
+
let _derivedKey = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if encryption is enabled (env var set).
|
|
28
|
+
*/
|
|
29
|
+
export function isEncryptionEnabled() {
|
|
30
|
+
return !!process.env.SPECLOCK_ENCRYPTION_KEY;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Derive a 256-bit key from the master password.
|
|
35
|
+
*/
|
|
36
|
+
export function deriveKey(masterKey) {
|
|
37
|
+
if (!masterKey) {
|
|
38
|
+
throw new Error("SPECLOCK_ENCRYPTION_KEY is required for encryption.");
|
|
39
|
+
}
|
|
40
|
+
return crypto.pbkdf2Sync(masterKey, SALT, ITERATIONS, KEY_LENGTH, "sha512");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get or derive the encryption key from env var.
|
|
45
|
+
*/
|
|
46
|
+
function getKey() {
|
|
47
|
+
if (_derivedKey) return _derivedKey;
|
|
48
|
+
const masterKey = process.env.SPECLOCK_ENCRYPTION_KEY;
|
|
49
|
+
if (!masterKey) {
|
|
50
|
+
throw new Error("SPECLOCK_ENCRYPTION_KEY environment variable is not set.");
|
|
51
|
+
}
|
|
52
|
+
_derivedKey = deriveKey(masterKey);
|
|
53
|
+
return _derivedKey;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Clear cached key (for testing).
|
|
58
|
+
*/
|
|
59
|
+
export function clearKeyCache() {
|
|
60
|
+
_derivedKey = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Encrypt / Decrypt ---
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Encrypt a string. Returns: SPECLOCK_ENCRYPTED:<base64(iv:tag:ciphertext)>
|
|
67
|
+
*/
|
|
68
|
+
export function encrypt(plaintext) {
|
|
69
|
+
const key = getKey();
|
|
70
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
71
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
72
|
+
|
|
73
|
+
let encrypted = cipher.update(plaintext, "utf-8");
|
|
74
|
+
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
75
|
+
const authTag = cipher.getAuthTag();
|
|
76
|
+
|
|
77
|
+
// Pack: IV + AuthTag + Ciphertext
|
|
78
|
+
const packed = Buffer.concat([iv, authTag, encrypted]);
|
|
79
|
+
return ENCRYPTED_MARKER + packed.toString("base64");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decrypt a string. Input: SPECLOCK_ENCRYPTED:<base64(iv:tag:ciphertext)>
|
|
84
|
+
*/
|
|
85
|
+
export function decrypt(ciphertext) {
|
|
86
|
+
if (!ciphertext.startsWith(ENCRYPTED_MARKER)) {
|
|
87
|
+
// Not encrypted — return as-is (backward compatible with plaintext)
|
|
88
|
+
return ciphertext;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const key = getKey();
|
|
92
|
+
const packed = Buffer.from(ciphertext.slice(ENCRYPTED_MARKER.length), "base64");
|
|
93
|
+
|
|
94
|
+
if (packed.length < IV_LENGTH + AUTH_TAG_LENGTH + 1) {
|
|
95
|
+
throw new Error("Invalid encrypted data: too short.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const iv = packed.subarray(0, IV_LENGTH);
|
|
99
|
+
const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
100
|
+
const encrypted = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
101
|
+
|
|
102
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
103
|
+
decipher.setAuthTag(authTag);
|
|
104
|
+
|
|
105
|
+
let decrypted = decipher.update(encrypted);
|
|
106
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
107
|
+
return decrypted.toString("utf-8");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if a string is encrypted.
|
|
112
|
+
*/
|
|
113
|
+
export function isEncrypted(data) {
|
|
114
|
+
return typeof data === "string" && data.startsWith(ENCRYPTED_MARKER);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- File-level helpers ---
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Encrypt a JSON object for storage.
|
|
121
|
+
*/
|
|
122
|
+
export function encryptJSON(obj) {
|
|
123
|
+
const json = JSON.stringify(obj, null, 2);
|
|
124
|
+
return encrypt(json);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Decrypt and parse a JSON string.
|
|
129
|
+
*/
|
|
130
|
+
export function decryptJSON(data) {
|
|
131
|
+
const json = decrypt(data);
|
|
132
|
+
return JSON.parse(json);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Encrypt each line of an events log (JSONL format).
|
|
137
|
+
* Each line is encrypted independently.
|
|
138
|
+
*/
|
|
139
|
+
export function encryptLines(text) {
|
|
140
|
+
if (!text || !text.trim()) return text;
|
|
141
|
+
const lines = text.trim().split("\n");
|
|
142
|
+
return lines.map(line => {
|
|
143
|
+
if (!line.trim()) return line;
|
|
144
|
+
return encrypt(line);
|
|
145
|
+
}).join("\n") + "\n";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Decrypt each line of an encrypted events log.
|
|
150
|
+
*/
|
|
151
|
+
export function decryptLines(text) {
|
|
152
|
+
if (!text || !text.trim()) return text;
|
|
153
|
+
const lines = text.trim().split("\n");
|
|
154
|
+
return lines.map(line => {
|
|
155
|
+
if (!line.trim()) return line;
|
|
156
|
+
return decrypt(line);
|
|
157
|
+
}).join("\n") + "\n";
|
|
158
|
+
}
|
package/src/core/engine.js
CHANGED
|
@@ -532,3 +532,27 @@ export function applyTemplate(root, templateName) {
|
|
|
532
532
|
export { verifyAuditChain } from "./audit.js";
|
|
533
533
|
export { exportCompliance } from "./compliance.js";
|
|
534
534
|
export { checkFeature, checkLimits, getLicenseInfo } from "./license.js";
|
|
535
|
+
|
|
536
|
+
// --- Authentication & RBAC (v3.0) ---
|
|
537
|
+
export {
|
|
538
|
+
isAuthEnabled,
|
|
539
|
+
enableAuth,
|
|
540
|
+
disableAuth,
|
|
541
|
+
createApiKey,
|
|
542
|
+
validateApiKey,
|
|
543
|
+
checkPermission,
|
|
544
|
+
rotateApiKey,
|
|
545
|
+
revokeApiKey,
|
|
546
|
+
listApiKeys,
|
|
547
|
+
ROLES,
|
|
548
|
+
TOOL_PERMISSIONS,
|
|
549
|
+
} from "./auth.js";
|
|
550
|
+
|
|
551
|
+
// --- Encrypted Storage (v3.0) ---
|
|
552
|
+
export {
|
|
553
|
+
isEncryptionEnabled,
|
|
554
|
+
isEncrypted,
|
|
555
|
+
encrypt,
|
|
556
|
+
decrypt,
|
|
557
|
+
clearKeyCache,
|
|
558
|
+
} from "./crypto.js";
|
package/src/core/storage.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import crypto from "crypto";
|
|
4
4
|
import { signEvent, isAuditEnabled } from "./audit.js";
|
|
5
|
+
import { isEncryptionEnabled, encrypt, decrypt, isEncrypted } from "./crypto.js";
|
|
5
6
|
|
|
6
7
|
export function nowIso() {
|
|
7
8
|
return new Date().toISOString();
|
|
@@ -121,7 +122,11 @@ export function migrateBrainV1toV2(brain) {
|
|
|
121
122
|
export function readBrain(root) {
|
|
122
123
|
const p = brainPath(root);
|
|
123
124
|
if (!fs.existsSync(p)) return null;
|
|
124
|
-
|
|
125
|
+
let raw = fs.readFileSync(p, "utf8");
|
|
126
|
+
// Transparent decryption (v3.0)
|
|
127
|
+
if (isEncrypted(raw)) {
|
|
128
|
+
try { raw = decrypt(raw); } catch { return null; }
|
|
129
|
+
}
|
|
125
130
|
let brain = JSON.parse(raw);
|
|
126
131
|
if (brain.version < 2) {
|
|
127
132
|
brain = migrateBrainV1toV2(brain);
|
|
@@ -137,7 +142,12 @@ export function readBrain(root) {
|
|
|
137
142
|
export function writeBrain(root, brain) {
|
|
138
143
|
brain.project.updatedAt = nowIso();
|
|
139
144
|
const p = brainPath(root);
|
|
140
|
-
|
|
145
|
+
let data = JSON.stringify(brain, null, 2);
|
|
146
|
+
// Transparent encryption (v3.0)
|
|
147
|
+
if (isEncryptionEnabled()) {
|
|
148
|
+
data = encrypt(data);
|
|
149
|
+
}
|
|
150
|
+
fs.writeFileSync(p, data);
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
export function appendEvent(root, event) {
|
|
@@ -149,7 +159,11 @@ export function appendEvent(root, event) {
|
|
|
149
159
|
} catch {
|
|
150
160
|
// Audit error — write event without hash (graceful degradation)
|
|
151
161
|
}
|
|
152
|
-
|
|
162
|
+
let line = JSON.stringify(event);
|
|
163
|
+
// Transparent per-line encryption (v3.0)
|
|
164
|
+
if (isEncryptionEnabled()) {
|
|
165
|
+
line = encrypt(line);
|
|
166
|
+
}
|
|
153
167
|
fs.appendFileSync(eventsPath(root), `${line}\n`);
|
|
154
168
|
}
|
|
155
169
|
|
|
@@ -163,7 +177,12 @@ export function readEvents(root, opts = {}) {
|
|
|
163
177
|
|
|
164
178
|
let events = raw.split("\n").map((line) => {
|
|
165
179
|
try {
|
|
166
|
-
|
|
180
|
+
// Transparent per-line decryption (v3.0)
|
|
181
|
+
let decoded = line;
|
|
182
|
+
if (isEncrypted(decoded)) {
|
|
183
|
+
try { decoded = decrypt(decoded); } catch { return null; }
|
|
184
|
+
}
|
|
185
|
+
return JSON.parse(decoded);
|
|
167
186
|
} catch {
|
|
168
187
|
return null;
|
|
169
188
|
}
|
package/src/mcp/http-server.js
CHANGED
|
@@ -51,9 +51,21 @@ import {
|
|
|
51
51
|
createTag,
|
|
52
52
|
getDiffSummary,
|
|
53
53
|
} from "../core/git.js";
|
|
54
|
+
import {
|
|
55
|
+
isAuthEnabled,
|
|
56
|
+
validateApiKey,
|
|
57
|
+
checkPermission,
|
|
58
|
+
createApiKey,
|
|
59
|
+
rotateApiKey,
|
|
60
|
+
revokeApiKey,
|
|
61
|
+
listApiKeys,
|
|
62
|
+
enableAuth,
|
|
63
|
+
disableAuth,
|
|
64
|
+
TOOL_PERMISSIONS,
|
|
65
|
+
} from "../core/auth.js";
|
|
54
66
|
|
|
55
67
|
const PROJECT_ROOT = process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
|
|
56
|
-
const VERSION = "
|
|
68
|
+
const VERSION = "3.0.0";
|
|
57
69
|
const AUTHOR = "Sandeep Roy";
|
|
58
70
|
const START_TIME = Date.now();
|
|
59
71
|
|
|
@@ -403,6 +415,16 @@ app.options("*", (req, res) => {
|
|
|
403
415
|
res.writeHead(204).end();
|
|
404
416
|
});
|
|
405
417
|
|
|
418
|
+
// --- Auth middleware helper ---
|
|
419
|
+
function authenticateRequest(req) {
|
|
420
|
+
if (!isAuthEnabled(PROJECT_ROOT)) {
|
|
421
|
+
return { valid: true, role: "admin", authEnabled: false };
|
|
422
|
+
}
|
|
423
|
+
const authHeader = req.headers["authorization"] || "";
|
|
424
|
+
const rawKey = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
|
|
425
|
+
return validateApiKey(PROJECT_ROOT, rawKey);
|
|
426
|
+
}
|
|
427
|
+
|
|
406
428
|
app.post("/mcp", async (req, res) => {
|
|
407
429
|
setCorsHeaders(res);
|
|
408
430
|
|
|
@@ -426,6 +448,28 @@ app.post("/mcp", async (req, res) => {
|
|
|
426
448
|
});
|
|
427
449
|
}
|
|
428
450
|
|
|
451
|
+
// Authentication (v3.0)
|
|
452
|
+
const auth = authenticateRequest(req);
|
|
453
|
+
if (!auth.valid) {
|
|
454
|
+
return res.status(401).json({
|
|
455
|
+
jsonrpc: "2.0",
|
|
456
|
+
error: { code: -32000, message: auth.error || "Authentication required." },
|
|
457
|
+
id: null,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// RBAC check — extract tool name from JSON-RPC body for permission check
|
|
462
|
+
if (auth.authEnabled && req.body && req.body.method === "tools/call") {
|
|
463
|
+
const toolName = req.body.params?.name;
|
|
464
|
+
if (toolName && !checkPermission(auth.role, toolName)) {
|
|
465
|
+
return res.status(403).json({
|
|
466
|
+
jsonrpc: "2.0",
|
|
467
|
+
error: { code: -32000, message: `Permission denied. Role "${auth.role}" cannot access "${toolName}". Required: ${TOOL_PERMISSIONS[toolName] || "admin"}` },
|
|
468
|
+
id: req.body.id || null,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
429
473
|
const server = createSpecLockServer();
|
|
430
474
|
try {
|
|
431
475
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
@@ -443,6 +487,51 @@ app.post("/mcp", async (req, res) => {
|
|
|
443
487
|
}
|
|
444
488
|
});
|
|
445
489
|
|
|
490
|
+
// --- Auth management endpoint (v3.0) ---
|
|
491
|
+
app.post("/auth", async (req, res) => {
|
|
492
|
+
setCorsHeaders(res);
|
|
493
|
+
const auth = authenticateRequest(req);
|
|
494
|
+
|
|
495
|
+
const { action } = req.body || {};
|
|
496
|
+
|
|
497
|
+
// Creating the first key doesn't require auth (bootstrap)
|
|
498
|
+
if (action === "create-key" && !isAuthEnabled(PROJECT_ROOT)) {
|
|
499
|
+
const { role, name } = req.body;
|
|
500
|
+
const result = createApiKey(PROJECT_ROOT, role || "admin", name || "");
|
|
501
|
+
return res.json(result);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// All other auth actions require admin role
|
|
505
|
+
if (auth.authEnabled && (!auth.valid || auth.role !== "admin")) {
|
|
506
|
+
return res.status(auth.valid ? 403 : 401).json({
|
|
507
|
+
error: auth.valid ? "Admin role required for auth management." : auth.error,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
switch (action) {
|
|
512
|
+
case "create-key": {
|
|
513
|
+
const { role, name } = req.body;
|
|
514
|
+
return res.json(createApiKey(PROJECT_ROOT, role || "developer", name || ""));
|
|
515
|
+
}
|
|
516
|
+
case "rotate-key": {
|
|
517
|
+
const { keyId } = req.body;
|
|
518
|
+
return res.json(rotateApiKey(PROJECT_ROOT, keyId));
|
|
519
|
+
}
|
|
520
|
+
case "revoke-key": {
|
|
521
|
+
const { keyId, reason } = req.body;
|
|
522
|
+
return res.json(revokeApiKey(PROJECT_ROOT, keyId, reason || "manual"));
|
|
523
|
+
}
|
|
524
|
+
case "list-keys":
|
|
525
|
+
return res.json(listApiKeys(PROJECT_ROOT));
|
|
526
|
+
case "enable":
|
|
527
|
+
return res.json(enableAuth(PROJECT_ROOT));
|
|
528
|
+
case "disable":
|
|
529
|
+
return res.json(disableAuth(PROJECT_ROOT));
|
|
530
|
+
default:
|
|
531
|
+
return res.status(400).json({ error: `Unknown auth action: "${action}". Valid: create-key, rotate-key, revoke-key, list-keys, enable, disable` });
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
446
535
|
app.get("/mcp", async (req, res) => {
|
|
447
536
|
setCorsHeaders(res);
|
|
448
537
|
res.writeHead(405).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null }));
|
|
@@ -468,8 +557,9 @@ app.get("/health", (req, res) => {
|
|
|
468
557
|
status: "healthy",
|
|
469
558
|
version: VERSION,
|
|
470
559
|
uptime: Math.floor((Date.now() - START_TIME) / 1000),
|
|
471
|
-
tools:
|
|
560
|
+
tools: 28,
|
|
472
561
|
auditChain: auditStatus,
|
|
562
|
+
authEnabled: isAuthEnabled(PROJECT_ROOT),
|
|
473
563
|
rateLimit: { limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS },
|
|
474
564
|
});
|
|
475
565
|
});
|
|
@@ -482,7 +572,7 @@ app.get("/", (req, res) => {
|
|
|
482
572
|
version: VERSION,
|
|
483
573
|
author: AUTHOR,
|
|
484
574
|
description: "AI Continuity Engine with enterprise audit, compliance, and enforcement",
|
|
485
|
-
tools:
|
|
575
|
+
tools: 28,
|
|
486
576
|
mcp_endpoint: "/mcp",
|
|
487
577
|
health_endpoint: "/health",
|
|
488
578
|
npm: "https://www.npmjs.com/package/speclock",
|
package/src/mcp/server.js
CHANGED
|
@@ -49,6 +49,29 @@ import {
|
|
|
49
49
|
createTag,
|
|
50
50
|
getDiffSummary,
|
|
51
51
|
} from "../core/git.js";
|
|
52
|
+
import {
|
|
53
|
+
isAuthEnabled,
|
|
54
|
+
validateApiKey,
|
|
55
|
+
checkPermission,
|
|
56
|
+
} from "../core/auth.js";
|
|
57
|
+
|
|
58
|
+
// --- Auth via env var (v3.0) ---
|
|
59
|
+
function getAuthRole() {
|
|
60
|
+
if (!isAuthEnabled(PROJECT_ROOT)) return "admin";
|
|
61
|
+
const key = process.env.SPECLOCK_API_KEY;
|
|
62
|
+
if (!key) return "admin"; // No key env var = local use, allow all
|
|
63
|
+
const result = validateApiKey(PROJECT_ROOT, key);
|
|
64
|
+
return result.valid ? result.role : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requirePermission(toolName) {
|
|
68
|
+
const role = getAuthRole();
|
|
69
|
+
if (!role) return { allowed: false, error: "Invalid SPECLOCK_API_KEY." };
|
|
70
|
+
if (!checkPermission(role, toolName)) {
|
|
71
|
+
return { allowed: false, error: `Permission denied. Role "${role}" cannot access "${toolName}".` };
|
|
72
|
+
}
|
|
73
|
+
return { allowed: true, role };
|
|
74
|
+
}
|
|
52
75
|
|
|
53
76
|
// --- Project root resolution ---
|
|
54
77
|
function parseArgs(argv) {
|
|
@@ -67,7 +90,7 @@ const PROJECT_ROOT =
|
|
|
67
90
|
args.project || process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
|
|
68
91
|
|
|
69
92
|
// --- MCP Server ---
|
|
70
|
-
const VERSION = "
|
|
93
|
+
const VERSION = "3.0.0";
|
|
71
94
|
const AUTHOR = "Sandeep Roy";
|
|
72
95
|
|
|
73
96
|
const server = new McpServer(
|