ai-safe-env 1.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 +63 -0
- package/cli.js +101 -0
- package/index.js +183 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# ai-safe-env
|
|
2
|
+
|
|
3
|
+
**Protect .env secrets from AI coding tools.** Zero-dependency Node.js alternative to [enveil](https://github.com/GreatScott/enveil).
|
|
4
|
+
|
|
5
|
+
AI tools like Claude Code, Cursor, and Copilot can read your `.env` files. `ai-safe-env` ensures plaintext secrets never exist on disk — your `.env` contains only symbolic references, and real values live in an AES-256-GCM encrypted local store.
|
|
6
|
+
|
|
7
|
+
## Why not enveil?
|
|
8
|
+
|
|
9
|
+
enveil is great but requires Rust/cargo. `ai-safe-env` is a **single `npm install`** with zero dependencies (uses Node.js built-in `crypto`). Drop-in replacement for `dotenv`.
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g ai-safe-env
|
|
15
|
+
|
|
16
|
+
# In your project:
|
|
17
|
+
ai-safe-env init
|
|
18
|
+
ai-safe-env set database_url "postgres://user:pass@localhost/db"
|
|
19
|
+
ai-safe-env set stripe_key "sk_live_..."
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Edit your `.env`:
|
|
23
|
+
```
|
|
24
|
+
DATABASE_URL=ase://database_url
|
|
25
|
+
STRIPE_KEY=ase://stripe_key
|
|
26
|
+
PORT=3000
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Use in your app (replaces `require('dotenv').config()`):
|
|
30
|
+
```js
|
|
31
|
+
require('ai-safe-env').config({ password: process.env.ASE_PASSWORD });
|
|
32
|
+
// process.env.DATABASE_URL is now the real value
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or run commands directly:
|
|
36
|
+
```bash
|
|
37
|
+
ai-safe-env run -- node server.js
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Migrate existing .env
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
ai-safe-env migrate
|
|
44
|
+
# Encrypts all values, rewrites .env with ase:// references
|
|
45
|
+
# Original saved as .env.backup
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## How It Works
|
|
49
|
+
|
|
50
|
+
1. Secrets stored in `~/.ai-safe-env/<project-hash>.enc` (AES-256-GCM, scrypt key derivation)
|
|
51
|
+
2. `.env` file contains only `ase://key_name` references — safe for AI tools to read
|
|
52
|
+
3. At runtime, references are resolved from the encrypted store and injected into `process.env`
|
|
53
|
+
|
|
54
|
+
## Security
|
|
55
|
+
|
|
56
|
+
- AES-256-GCM encryption with scrypt key derivation (N=16384, r=8, p=1)
|
|
57
|
+
- Fresh random salt (16 bytes) and IV (12 bytes) on every write
|
|
58
|
+
- Authentication tag prevents tampering
|
|
59
|
+
- Store file is indistinguishable from random data without the password
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
package/cli.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ai-safe-env CLI
|
|
4
|
+
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* init Initialize encrypted store for current project
|
|
7
|
+
* set <key> <value> Store a secret
|
|
8
|
+
* remove <key> Remove a secret
|
|
9
|
+
* list List secret keys (not values)
|
|
10
|
+
* migrate Convert existing .env to safe format
|
|
11
|
+
* run -- <command> Run command with secrets injected
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { initStore, setSecret, removeSecret, listSecrets, migrate, config, promptPassword } = require('./index');
|
|
15
|
+
const { execSync } = require('child_process');
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
const cmd = args[0];
|
|
19
|
+
const projectDir = process.cwd();
|
|
20
|
+
|
|
21
|
+
async function getPassword() {
|
|
22
|
+
return process.env.ASE_PASSWORD || await promptPassword('Master password: ');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
switch (cmd) {
|
|
27
|
+
case 'init': {
|
|
28
|
+
const pw = await getPassword();
|
|
29
|
+
initStore(projectDir, pw);
|
|
30
|
+
console.log('✅ Store initialized for this project');
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
case 'set': {
|
|
34
|
+
if (args.length < 3) { console.error('Usage: ai-safe-env set <key> <value>'); process.exit(1); }
|
|
35
|
+
const pw = await getPassword();
|
|
36
|
+
setSecret(projectDir, pw, args[1], args[2]);
|
|
37
|
+
console.log(`✅ Secret "${args[1]}" stored`);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
case 'remove': {
|
|
41
|
+
if (args.length < 2) { console.error('Usage: ai-safe-env remove <key>'); process.exit(1); }
|
|
42
|
+
const pw = await getPassword();
|
|
43
|
+
removeSecret(projectDir, pw, args[1]);
|
|
44
|
+
console.log(`✅ Secret "${args[1]}" removed`);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case 'list': {
|
|
48
|
+
const pw = await getPassword();
|
|
49
|
+
const keys = listSecrets(projectDir, pw);
|
|
50
|
+
if (keys.length === 0) { console.log('(no secrets stored)'); }
|
|
51
|
+
else { keys.forEach(k => console.log(` ${k}`)); }
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case 'migrate': {
|
|
55
|
+
const pw = await getPassword();
|
|
56
|
+
const count = migrate(projectDir, pw);
|
|
57
|
+
console.log(`✅ Migrated ${count} env vars. Original backed up to .env.backup`);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case 'run': {
|
|
61
|
+
const sep = args.indexOf('--');
|
|
62
|
+
if (sep === -1 || sep === args.length - 1) {
|
|
63
|
+
console.error('Usage: ai-safe-env run -- <command>');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
const pw = await getPassword();
|
|
67
|
+
const { parsed } = config({ password: pw, noInject: true });
|
|
68
|
+
const command = args.slice(sep + 1).join(' ');
|
|
69
|
+
try {
|
|
70
|
+
execSync(command, {
|
|
71
|
+
stdio: 'inherit',
|
|
72
|
+
env: { ...process.env, ...parsed }
|
|
73
|
+
});
|
|
74
|
+
} catch (e) {
|
|
75
|
+
process.exit(e.status || 1);
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
console.log(`ai-safe-env - Protect .env secrets from AI coding tools
|
|
81
|
+
|
|
82
|
+
Commands:
|
|
83
|
+
init Initialize encrypted store for current project
|
|
84
|
+
set <key> <val> Store a secret
|
|
85
|
+
remove <key> Remove a secret
|
|
86
|
+
list List secret keys
|
|
87
|
+
migrate Convert existing .env → safe format (auto-encrypts all values)
|
|
88
|
+
run -- <cmd> Run command with secrets injected
|
|
89
|
+
|
|
90
|
+
Environment:
|
|
91
|
+
ASE_PASSWORD Master password (avoids interactive prompt)
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
ai-safe-env init
|
|
95
|
+
ai-safe-env set database_url "postgres://user:pass@localhost/db"
|
|
96
|
+
# Edit .env: DATABASE_URL=ase://database_url
|
|
97
|
+
ai-safe-env run -- node server.js`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
main().catch(e => { console.error(e.message); process.exit(1); });
|
package/index.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai-safe-env - Protect .env secrets from AI coding tools
|
|
3
|
+
*
|
|
4
|
+
* Unlike enveil (Rust/cargo), this is a zero-dependency Node.js solution.
|
|
5
|
+
* Your .env file contains only references like `ase://key_name`.
|
|
6
|
+
* Real values live in an AES-256-GCM encrypted store (~/.ai-safe-env/<project>.enc).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const { config } = require('ai-safe-env');
|
|
10
|
+
* config({ password: process.env.ASE_PASSWORD });
|
|
11
|
+
* // or interactively: config() will prompt for password
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const readline = require('readline');
|
|
18
|
+
|
|
19
|
+
const STORE_DIR = path.join(require('os').homedir(), '.ai-safe-env');
|
|
20
|
+
const PREFIX = 'ase://';
|
|
21
|
+
|
|
22
|
+
function deriveKey(password, salt) {
|
|
23
|
+
return crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getStoreFile(projectDir) {
|
|
27
|
+
const name = crypto.createHash('sha256').update(projectDir).digest('hex').slice(0, 16);
|
|
28
|
+
return path.join(STORE_DIR, `${name}.enc`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function encryptStore(data, password) {
|
|
32
|
+
const salt = crypto.randomBytes(16);
|
|
33
|
+
const key = deriveKey(password, salt);
|
|
34
|
+
const iv = crypto.randomBytes(12);
|
|
35
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
36
|
+
const json = JSON.stringify(data);
|
|
37
|
+
const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
|
|
38
|
+
const tag = cipher.getAuthTag();
|
|
39
|
+
return Buffer.concat([salt, iv, tag, encrypted]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function decryptStore(buf, password) {
|
|
43
|
+
const salt = buf.subarray(0, 16);
|
|
44
|
+
const iv = buf.subarray(16, 28);
|
|
45
|
+
const tag = buf.subarray(28, 44);
|
|
46
|
+
const encrypted = buf.subarray(44);
|
|
47
|
+
const key = deriveKey(password, salt);
|
|
48
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
49
|
+
decipher.setAuthTag(tag);
|
|
50
|
+
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
51
|
+
return JSON.parse(decrypted.toString('utf8'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readEnvFile(envPath) {
|
|
55
|
+
if (!fs.existsSync(envPath)) return {};
|
|
56
|
+
const lines = fs.readFileSync(envPath, 'utf8').split('\n');
|
|
57
|
+
const env = {};
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
61
|
+
const eq = trimmed.indexOf('=');
|
|
62
|
+
if (eq === -1) continue;
|
|
63
|
+
const key = trimmed.slice(0, eq).trim();
|
|
64
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
65
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
66
|
+
val = val.slice(1, -1);
|
|
67
|
+
}
|
|
68
|
+
env[key] = val;
|
|
69
|
+
}
|
|
70
|
+
return env;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function promptPassword(prompt = 'Master password: ') {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
76
|
+
process.stderr.write(prompt);
|
|
77
|
+
rl.question('', (answer) => { rl.close(); resolve(answer); });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Load .env, resolve ase:// references from encrypted store, inject into process.env
|
|
83
|
+
*/
|
|
84
|
+
function config(options = {}) {
|
|
85
|
+
const projectDir = options.path ? path.dirname(options.path) : process.cwd();
|
|
86
|
+
const envPath = options.path || path.join(projectDir, '.env');
|
|
87
|
+
const env = readEnvFile(envPath);
|
|
88
|
+
const storeFile = getStoreFile(projectDir);
|
|
89
|
+
|
|
90
|
+
let secrets = {};
|
|
91
|
+
const needsStore = Object.values(env).some(v => v.startsWith(PREFIX));
|
|
92
|
+
|
|
93
|
+
if (needsStore && fs.existsSync(storeFile)) {
|
|
94
|
+
const password = options.password || process.env.ASE_PASSWORD;
|
|
95
|
+
if (!password) {
|
|
96
|
+
throw new Error('ai-safe-env: password required. Set ASE_PASSWORD env var or pass { password }');
|
|
97
|
+
}
|
|
98
|
+
const buf = fs.readFileSync(storeFile);
|
|
99
|
+
secrets = decryptStore(buf, password);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const resolved = {};
|
|
103
|
+
for (const [key, val] of Object.entries(env)) {
|
|
104
|
+
if (val.startsWith(PREFIX)) {
|
|
105
|
+
const secretKey = val.slice(PREFIX.length);
|
|
106
|
+
if (!(secretKey in secrets)) {
|
|
107
|
+
throw new Error(`ai-safe-env: secret "${secretKey}" not found in store. Run: ai-safe-env set ${secretKey}`);
|
|
108
|
+
}
|
|
109
|
+
resolved[key] = secrets[secretKey];
|
|
110
|
+
} else {
|
|
111
|
+
resolved[key] = val;
|
|
112
|
+
}
|
|
113
|
+
if (!options.noInject) {
|
|
114
|
+
process.env[key] = resolved[key];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { parsed: resolved };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Store management
|
|
122
|
+
function initStore(projectDir, password) {
|
|
123
|
+
fs.mkdirSync(STORE_DIR, { recursive: true });
|
|
124
|
+
const storeFile = getStoreFile(projectDir);
|
|
125
|
+
if (!fs.existsSync(storeFile)) {
|
|
126
|
+
fs.writeFileSync(storeFile, encryptStore({}, password));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function setSecret(projectDir, password, key, value) {
|
|
131
|
+
fs.mkdirSync(STORE_DIR, { recursive: true });
|
|
132
|
+
const storeFile = getStoreFile(projectDir);
|
|
133
|
+
let secrets = {};
|
|
134
|
+
if (fs.existsSync(storeFile)) {
|
|
135
|
+
secrets = decryptStore(fs.readFileSync(storeFile), password);
|
|
136
|
+
}
|
|
137
|
+
secrets[key] = value;
|
|
138
|
+
fs.writeFileSync(storeFile, encryptStore(secrets, password));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function removeSecret(projectDir, password, key) {
|
|
142
|
+
const storeFile = getStoreFile(projectDir);
|
|
143
|
+
if (!fs.existsSync(storeFile)) return;
|
|
144
|
+
const secrets = decryptStore(fs.readFileSync(storeFile), password);
|
|
145
|
+
delete secrets[key];
|
|
146
|
+
fs.writeFileSync(storeFile, encryptStore(secrets, password));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function listSecrets(projectDir, password) {
|
|
150
|
+
const storeFile = getStoreFile(projectDir);
|
|
151
|
+
if (!fs.existsSync(storeFile)) return [];
|
|
152
|
+
const secrets = decryptStore(fs.readFileSync(storeFile), password);
|
|
153
|
+
return Object.keys(secrets);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Convert existing .env to safe format (replace values with ase:// refs)
|
|
158
|
+
*/
|
|
159
|
+
function migrate(projectDir, password) {
|
|
160
|
+
const envPath = path.join(projectDir, '.env');
|
|
161
|
+
const env = readEnvFile(envPath);
|
|
162
|
+
fs.mkdirSync(STORE_DIR, { recursive: true });
|
|
163
|
+
|
|
164
|
+
const lines = [];
|
|
165
|
+
for (const [key, val] of Object.entries(env)) {
|
|
166
|
+
if (val && !val.startsWith(PREFIX)) {
|
|
167
|
+
const secretKey = key.toLowerCase();
|
|
168
|
+
setSecret(projectDir, password, secretKey, val);
|
|
169
|
+
lines.push(`${key}=${PREFIX}${secretKey}`);
|
|
170
|
+
} else {
|
|
171
|
+
lines.push(`${key}=${val}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Backup original
|
|
176
|
+
if (fs.existsSync(envPath)) {
|
|
177
|
+
fs.copyFileSync(envPath, envPath + '.backup');
|
|
178
|
+
}
|
|
179
|
+
fs.writeFileSync(envPath, lines.join('\n') + '\n');
|
|
180
|
+
return lines.length;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { config, initStore, setSecret, removeSecret, listSecrets, migrate, promptPassword };
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-safe-env",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Protect .env secrets from AI coding tools. Drop-in dotenv replacement with encrypted local store.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ai-safe-env": "./cli.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"dotenv",
|
|
11
|
+
"env",
|
|
12
|
+
"secrets",
|
|
13
|
+
"ai",
|
|
14
|
+
"security",
|
|
15
|
+
"encryption",
|
|
16
|
+
"claude",
|
|
17
|
+
"cursor",
|
|
18
|
+
"copilot"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"dependencies": {},
|
|
22
|
+
"files": [
|
|
23
|
+
"index.js",
|
|
24
|
+
"cli.js",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/indiekitai/ai-safe-env"
|
|
30
|
+
},
|
|
31
|
+
"author": "IndieKit <hello@indiekit.ai>"
|
|
32
|
+
}
|