@envsave/cli 1.0.37
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/LICENSE +21 -0
- package/README.md +223 -0
- package/dist/cli.js +1604 -0
- package/dist/index.js +159 -0
- package/package.json +48 -0
- package/python/envsave/__init__.py +79 -0
- package/python/evault.py +85 -0
- package/python/pyproject.toml +18 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// src/vault.ts
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, appendFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join as join2 } from "node:path";
|
|
4
|
+
import { homedir as homedir2 } from "node:os";
|
|
5
|
+
|
|
6
|
+
// src/crypto.ts
|
|
7
|
+
import { createCipheriv, createDecipheriv, createHmac, randomBytes, pbkdf2Sync } from "node:crypto";
|
|
8
|
+
var IV_LENGTH = 12;
|
|
9
|
+
var AUTH_TAG_LENGTH = 16;
|
|
10
|
+
function decryptWithKey(payload, key) {
|
|
11
|
+
const iv = payload.subarray(0, IV_LENGTH);
|
|
12
|
+
const authTag = payload.subarray(payload.length - AUTH_TAG_LENGTH);
|
|
13
|
+
const encrypted = payload.subarray(IV_LENGTH, payload.length - AUTH_TAG_LENGTH);
|
|
14
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
15
|
+
decipher.setAuthTag(authTag);
|
|
16
|
+
return decipher.update(encrypted) + decipher.final("utf8");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/license.ts
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
var ENVSAVE_DIR = join(homedir(), ".envsave");
|
|
23
|
+
var LICENSE_FILE = join(ENVSAVE_DIR, "license.json");
|
|
24
|
+
var LICENSE_API_URL = process.env.ENVSAVE_LICENSE_API || "https://api.envsave.com/api/license/validate";
|
|
25
|
+
var RENEW_API_URL = (process.env.ENVSAVE_LICENSE_API || "https://api.envsave.com/api/license/validate").replace("/validate", "/renew");
|
|
26
|
+
|
|
27
|
+
// src/vault.ts
|
|
28
|
+
function getEnvsaveDir() {
|
|
29
|
+
return process.env.ENVSAVE_DIR || join2(homedir2(), ".envsave");
|
|
30
|
+
}
|
|
31
|
+
function getCacheFile() {
|
|
32
|
+
return join2(getEnvsaveDir(), "cache.enc");
|
|
33
|
+
}
|
|
34
|
+
function getSessionFile() {
|
|
35
|
+
return join2(getEnvsaveDir(), ".session");
|
|
36
|
+
}
|
|
37
|
+
function getRateFile() {
|
|
38
|
+
return join2(getEnvsaveDir(), ".rate");
|
|
39
|
+
}
|
|
40
|
+
function getAccessLog() {
|
|
41
|
+
return join2(getEnvsaveDir(), "access.log");
|
|
42
|
+
}
|
|
43
|
+
var DEFAULT_SESSION_TTL_MS = 4 * 60 * 60 * 1000;
|
|
44
|
+
var DEFAULT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
|
45
|
+
var DEFAULT_RATE_LIMIT_MAX = 30;
|
|
46
|
+
function getConfigFile() {
|
|
47
|
+
return join2(getEnvsaveDir(), "config.json");
|
|
48
|
+
}
|
|
49
|
+
function guardRealVault() {
|
|
50
|
+
if (false) {}
|
|
51
|
+
}
|
|
52
|
+
function readConfig() {
|
|
53
|
+
if (!existsSync(getConfigFile())) {
|
|
54
|
+
return { maxGetsPerMinute: DEFAULT_RATE_LIMIT_MAX, sessionTtlHours: DEFAULT_SESSION_TTL_MS / 3600000 };
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(readFileSync(getConfigFile(), "utf8"));
|
|
58
|
+
} catch {
|
|
59
|
+
return { maxGetsPerMinute: DEFAULT_RATE_LIMIT_MAX, sessionTtlHours: DEFAULT_SESSION_TTL_MS / 3600000 };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function ensureDir() {
|
|
63
|
+
guardRealVault();
|
|
64
|
+
if (!existsSync(getEnvsaveDir())) {
|
|
65
|
+
mkdirSync(getEnvsaveDir(), { recursive: true, mode: 448 });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function readSessionCache() {
|
|
69
|
+
if (!existsSync(getSessionFile()) || !existsSync(getCacheFile()))
|
|
70
|
+
return null;
|
|
71
|
+
try {
|
|
72
|
+
const sessionStat = statSync(getSessionFile());
|
|
73
|
+
const age = Date.now() - sessionStat.mtimeMs;
|
|
74
|
+
const config = readConfig();
|
|
75
|
+
if (age > config.sessionTtlHours * 3600000) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const sessionKeyHex = readFileSync(getSessionFile(), "utf8").trim();
|
|
79
|
+
const sessionKey = Buffer.from(sessionKeyHex, "hex");
|
|
80
|
+
const payload = readFileSync(getCacheFile());
|
|
81
|
+
const json = decryptWithKey(payload, sessionKey);
|
|
82
|
+
return JSON.parse(json);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function checkRateLimit() {
|
|
88
|
+
const config = readConfig();
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
let timestamps = [];
|
|
91
|
+
if (existsSync(getRateFile())) {
|
|
92
|
+
try {
|
|
93
|
+
timestamps = JSON.parse(readFileSync(getRateFile(), "utf8"));
|
|
94
|
+
} catch {
|
|
95
|
+
timestamps = [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
timestamps = timestamps.filter((t) => now - t < DEFAULT_RATE_LIMIT_WINDOW_MS);
|
|
99
|
+
if (timestamps.length >= config.maxGetsPerMinute) {
|
|
100
|
+
logAccess("RATE_LIMIT_BLOCKED", "blocked");
|
|
101
|
+
throw new Error(`Rate limit exceeded. Max ${config.maxGetsPerMinute} get calls per minute. Try again in ${Math.ceil((DEFAULT_RATE_LIMIT_WINDOW_MS - (now - timestamps[0])) / 1000)}s.`);
|
|
102
|
+
}
|
|
103
|
+
const threshold = Math.floor(config.maxGetsPerMinute * 0.8);
|
|
104
|
+
if (timestamps.length >= threshold) {
|
|
105
|
+
const remaining = config.maxGetsPerMinute - timestamps.length;
|
|
106
|
+
console.error(`⚠ Rate limit warning: ${remaining} get(s) remaining this minute.`);
|
|
107
|
+
}
|
|
108
|
+
timestamps.push(now);
|
|
109
|
+
writeFileSync(getRateFile(), JSON.stringify(timestamps), { mode: 384 });
|
|
110
|
+
}
|
|
111
|
+
function logAccess(opaqueKey, source) {
|
|
112
|
+
ensureDir();
|
|
113
|
+
const entry = `${new Date().toISOString()} ${source} ${opaqueKey.slice(0, 10)}...
|
|
114
|
+
`;
|
|
115
|
+
appendFileSync(getAccessLog(), entry, { mode: 384 });
|
|
116
|
+
}
|
|
117
|
+
function getSecretFromCache(opaqueKey) {
|
|
118
|
+
checkRateLimit();
|
|
119
|
+
const cache = readSessionCache();
|
|
120
|
+
if (!cache) {
|
|
121
|
+
throw new Error("No session found. Run any envsave command with your password first.");
|
|
122
|
+
}
|
|
123
|
+
logAccess(opaqueKey, "cache");
|
|
124
|
+
return cache.secrets[opaqueKey];
|
|
125
|
+
}
|
|
126
|
+
function getSecretWithSession(opaqueKey) {
|
|
127
|
+
return getSecretFromCache(opaqueKey);
|
|
128
|
+
}
|
|
129
|
+
function hasSecretWithSession(opaqueKey) {
|
|
130
|
+
try {
|
|
131
|
+
const cache = readSessionCache();
|
|
132
|
+
if (!cache)
|
|
133
|
+
return false;
|
|
134
|
+
return opaqueKey in cache.secrets;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/index.ts
|
|
141
|
+
function get(key) {
|
|
142
|
+
try {
|
|
143
|
+
const value = getSecretWithSession(key);
|
|
144
|
+
if (value !== undefined)
|
|
145
|
+
return value;
|
|
146
|
+
} catch {}
|
|
147
|
+
return process.env[key];
|
|
148
|
+
}
|
|
149
|
+
function has(key) {
|
|
150
|
+
if (hasSecretWithSession(key))
|
|
151
|
+
return true;
|
|
152
|
+
return process.env[key] !== undefined;
|
|
153
|
+
}
|
|
154
|
+
var src_default = { get, has };
|
|
155
|
+
export {
|
|
156
|
+
has,
|
|
157
|
+
get,
|
|
158
|
+
src_default as default
|
|
159
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@envsave/cli",
|
|
3
|
+
"version": "1.0.37",
|
|
4
|
+
"description": "Local secret vault for LLM-safe environments — encrypted secrets that agents can't bulk-discover",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"envsave": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"python"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"prebuild": "bun -e \"const p=require('./package.json');const v=p.version.split('.');v[2]=+v[2]+1;p.version=v.join('.');require('fs').writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n')\"",
|
|
17
|
+
"build": "bun build src/cli.ts --outdir dist --target node --format esm && bun build src/index.ts --outdir dist --target node --format esm --outfile dist/index.js",
|
|
18
|
+
"dev": "bun run src/cli.ts",
|
|
19
|
+
"webapp": "bun run webapp/server.ts",
|
|
20
|
+
"lint:claude": "claude -p 'you are a linter. please look at the changes vs. main and report any issues related to typos. report the filename and line number on one line, and a description of the issue on the second line. do not return any other text.'"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "latest",
|
|
24
|
+
"@types/node": "^25.5.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"typescript": "^5"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"vault",
|
|
31
|
+
"secrets",
|
|
32
|
+
"encryption",
|
|
33
|
+
"llm",
|
|
34
|
+
"security",
|
|
35
|
+
"env"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/jcsancho/envsave.git"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://api.envsave.com",
|
|
43
|
+
"author": "Jose C Sancho",
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@clerk/backend": "^3.2.1",
|
|
46
|
+
"@supabase/supabase-js": "^2.99.2"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EnvSave Python — Local secret vault for LLM-safe environments.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from envsave import get, has
|
|
6
|
+
|
|
7
|
+
api_key = get("es_7a3f2b1e9c8d4f6b2a1c3d5f")
|
|
8
|
+
# Falls back to os.environ if vault is unavailable
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
16
|
+
|
|
17
|
+
ENVSAVE_DIR = Path.home() / ".envsave"
|
|
18
|
+
CACHE_FILE = ENVSAVE_DIR / "cache.enc"
|
|
19
|
+
SESSION_FILE = ENVSAVE_DIR / ".session"
|
|
20
|
+
|
|
21
|
+
IV_LENGTH = 12
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _decrypt_cache() -> dict:
|
|
25
|
+
"""Decrypt session cache using random session key (NOT the master password)."""
|
|
26
|
+
if not SESSION_FILE.exists() or not CACHE_FILE.exists():
|
|
27
|
+
raise RuntimeError("No session found. Run any envsave command with your password first.")
|
|
28
|
+
|
|
29
|
+
session_key_hex = SESSION_FILE.read_text().strip()
|
|
30
|
+
session_key = bytes.fromhex(session_key_hex)
|
|
31
|
+
|
|
32
|
+
payload = CACHE_FILE.read_bytes()
|
|
33
|
+
iv = payload[:IV_LENGTH]
|
|
34
|
+
encrypted_with_tag = payload[IV_LENGTH:]
|
|
35
|
+
|
|
36
|
+
aesgcm = AESGCM(session_key)
|
|
37
|
+
decrypted = aesgcm.decrypt(iv, encrypted_with_tag, None)
|
|
38
|
+
return json.loads(decrypted.decode("utf-8"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_cache: dict | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_cache() -> dict:
|
|
45
|
+
global _cache
|
|
46
|
+
if _cache is None:
|
|
47
|
+
_cache = _decrypt_cache()
|
|
48
|
+
return _cache
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get(key: str) -> str | None:
|
|
52
|
+
"""Get a secret by its opaque key. Falls back to os.environ[key] if vault is unavailable."""
|
|
53
|
+
try:
|
|
54
|
+
cache = _get_cache()
|
|
55
|
+
value = cache.get("secrets", {}).get(key)
|
|
56
|
+
if value is not None:
|
|
57
|
+
return value
|
|
58
|
+
except RuntimeError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
return os.environ.get(key)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def has(key: str) -> bool:
|
|
65
|
+
"""Check if an opaque key exists in the session cache or as an environment variable."""
|
|
66
|
+
try:
|
|
67
|
+
cache = _get_cache()
|
|
68
|
+
if key in cache.get("secrets", {}):
|
|
69
|
+
return True
|
|
70
|
+
except RuntimeError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
return key in os.environ
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def clear_cache():
|
|
77
|
+
"""Clear the in-memory cache."""
|
|
78
|
+
global _cache
|
|
79
|
+
_cache = None
|
package/python/evault.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EnvSave Python Bridge — reads the session cache (NOT the main vault).
|
|
3
|
+
|
|
4
|
+
The session cache contains only the secrets map (opaque_key → value).
|
|
5
|
+
No aliases, no HMAC seed, no registry — an LLM reading this file
|
|
6
|
+
can only access secrets whose opaque key it already knows.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
import envsave
|
|
10
|
+
api_key = envsave.get("es_7a3f2b1e9c8d4f6b2a1c3d5f")
|
|
11
|
+
|
|
12
|
+
Requirements:
|
|
13
|
+
pip install cryptography
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
21
|
+
|
|
22
|
+
ENVSAVE_DIR = Path.home() / ".envsave"
|
|
23
|
+
CACHE_FILE = ENVSAVE_DIR / "cache.enc"
|
|
24
|
+
SESSION_FILE = ENVSAVE_DIR / ".session"
|
|
25
|
+
|
|
26
|
+
IV_LENGTH = 12
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _decrypt_cache() -> dict:
|
|
30
|
+
"""Decrypt session cache using random session key (NOT the master password)."""
|
|
31
|
+
if not SESSION_FILE.exists() or not CACHE_FILE.exists():
|
|
32
|
+
raise RuntimeError("No session found. Run any envsave command with your password first.")
|
|
33
|
+
|
|
34
|
+
session_key_hex = SESSION_FILE.read_text().strip()
|
|
35
|
+
session_key = bytes.fromhex(session_key_hex)
|
|
36
|
+
|
|
37
|
+
payload = CACHE_FILE.read_bytes()
|
|
38
|
+
iv = payload[:IV_LENGTH]
|
|
39
|
+
encrypted_with_tag = payload[IV_LENGTH:]
|
|
40
|
+
|
|
41
|
+
aesgcm = AESGCM(session_key)
|
|
42
|
+
decrypted = aesgcm.decrypt(iv, encrypted_with_tag, None)
|
|
43
|
+
return json.loads(decrypted.decode("utf-8"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Cache for repeated calls
|
|
47
|
+
_cache: dict | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_cache() -> dict:
|
|
51
|
+
global _cache
|
|
52
|
+
if _cache is None:
|
|
53
|
+
_cache = _decrypt_cache()
|
|
54
|
+
return _cache
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get(key: str) -> str | None:
|
|
58
|
+
"""Get a secret by its opaque key. Falls back to os.environ[key] if vault is unavailable."""
|
|
59
|
+
try:
|
|
60
|
+
cache = _get_cache()
|
|
61
|
+
value = cache.get("secrets", {}).get(key)
|
|
62
|
+
if value is not None:
|
|
63
|
+
return value
|
|
64
|
+
except RuntimeError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
return os.environ.get(key)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def has(key: str) -> bool:
|
|
71
|
+
"""Check if an opaque key exists in the session cache or as an environment variable."""
|
|
72
|
+
try:
|
|
73
|
+
cache = _get_cache()
|
|
74
|
+
if key in cache.get("secrets", {}):
|
|
75
|
+
return True
|
|
76
|
+
except RuntimeError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
return key in os.environ
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def clear_cache():
|
|
83
|
+
"""Clear the in-memory cache."""
|
|
84
|
+
global _cache
|
|
85
|
+
_cache = None
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "envsave"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Local secret vault for LLM-safe environments — encrypted secrets that agents can't bulk-discover"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = ["cryptography>=41.0"]
|
|
8
|
+
keywords = ["vault", "secrets", "encryption", "llm", "security"]
|
|
9
|
+
|
|
10
|
+
[project.urls]
|
|
11
|
+
Homepage = "https://github.com/jcsancho/envsave"
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["hatchling"]
|
|
15
|
+
build-backend = "hatchling.build"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["envsave"]
|