ship-safe 7.0.0 → 9.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 +80 -21
- package/cli/agents/agent-attestation-agent.js +318 -0
- package/cli/agents/agentic-security-agent.js +35 -0
- package/cli/agents/cicd-scanner.js +22 -0
- package/cli/agents/config-auditor.js +235 -0
- package/cli/agents/deep-analyzer.js +473 -133
- package/cli/agents/hermes-security-agent.js +536 -0
- package/cli/agents/index.js +63 -22
- package/cli/agents/managed-agent-scanner.js +333 -0
- package/cli/agents/orchestrator.js +13 -3
- package/cli/agents/supply-chain-agent.js +1 -1
- package/cli/bin/ship-safe.js +129 -5
- package/cli/commands/audit.js +149 -3
- package/cli/commands/autofix.js +383 -0
- package/cli/commands/env-audit.js +349 -0
- package/cli/commands/init.js +104 -0
- package/cli/commands/mcp.js +270 -0
- package/cli/commands/red-team.js +2 -2
- package/cli/commands/scan-mcp.js +78 -0
- package/cli/commands/scan-skill.js +248 -5
- package/cli/commands/watch.js +142 -5
- package/cli/index.js +5 -0
- package/cli/providers/llm-provider.js +50 -2
- package/cli/utils/hermes-tool-registry.js +252 -0
- package/cli/utils/patterns.js +1 -0
- package/cli/utils/plugin-loader.js +276 -0
- package/cli/utils/scan-playbook.js +312 -0
- package/cli/utils/security-memory.js +296 -0
- package/package.json +2 -2
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env Audit Command
|
|
3
|
+
* ==================
|
|
4
|
+
*
|
|
5
|
+
* Post-sync credential health check. Designed to run immediately after
|
|
6
|
+
* `stripe projects env --pull` or any credential provisioning workflow.
|
|
7
|
+
*
|
|
8
|
+
* Checks:
|
|
9
|
+
* 1. Every .env* file is covered by .gitignore
|
|
10
|
+
* 2. No .env values appear hardcoded in source files
|
|
11
|
+
* 3. Git history has no previously committed .env files
|
|
12
|
+
* 4. Agent configs can't read credential files without restriction
|
|
13
|
+
*
|
|
14
|
+
* USAGE:
|
|
15
|
+
* ship-safe env-audit [path]
|
|
16
|
+
* ship-safe env-audit . --json
|
|
17
|
+
*
|
|
18
|
+
* EXIT CODES:
|
|
19
|
+
* 0 — clean
|
|
20
|
+
* 1 — issues found
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
import fg from 'fast-glob';
|
|
26
|
+
import chalk from 'chalk';
|
|
27
|
+
import ora from 'ora';
|
|
28
|
+
import { execFileSync } from 'child_process';
|
|
29
|
+
import { SECRET_PATTERNS, SKIP_DIRS } from '../utils/patterns.js';
|
|
30
|
+
|
|
31
|
+
// Minimum value length to cross-reference (skip short values like "true", "3000")
|
|
32
|
+
const MIN_VALUE_LENGTH = 8;
|
|
33
|
+
|
|
34
|
+
// Keys that are safe to appear in source (not secrets)
|
|
35
|
+
const SAFE_KEY_PATTERNS = /^(?:NEXT_PUBLIC_|REACT_APP_|VITE_|NUXT_PUBLIC_|NODE_ENV|PORT|HOST|HOSTNAME|LOG_LEVEL|DEBUG|TZ|LANG|APP_NAME|APP_URL|BASE_URL)/i;
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// ENV AUDIT COMMAND
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
export async function envAuditCommand(targetPath = '.', options) {
|
|
42
|
+
const absolutePath = path.resolve(targetPath);
|
|
43
|
+
const spinner = ora('Auditing credential environment...').start();
|
|
44
|
+
|
|
45
|
+
const findings = [];
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// ── Step 1: Find all .env files ──────────────────────────────────────────
|
|
49
|
+
const envFiles = await findEnvFiles(absolutePath);
|
|
50
|
+
spinner.text = `Found ${envFiles.length} .env file(s)`;
|
|
51
|
+
|
|
52
|
+
if (envFiles.length === 0) {
|
|
53
|
+
spinner.succeed('No .env files found — nothing to audit.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Step 2: Check .gitignore coverage ────────────────────────────────────
|
|
58
|
+
spinner.text = 'Checking .gitignore coverage...';
|
|
59
|
+
for (const envFile of envFiles) {
|
|
60
|
+
const relPath = path.relative(absolutePath, envFile).replace(/\\/g, '/');
|
|
61
|
+
const basename = path.basename(envFile);
|
|
62
|
+
|
|
63
|
+
// Skip .env.example and .env.sample — these are meant to be committed
|
|
64
|
+
if (/\.env\.(?:example|sample|template)$/i.test(basename)) continue;
|
|
65
|
+
|
|
66
|
+
const isIgnored = checkGitignored(absolutePath, relPath);
|
|
67
|
+
if (!isIgnored) {
|
|
68
|
+
findings.push({
|
|
69
|
+
type: 'gitignore',
|
|
70
|
+
severity: 'critical',
|
|
71
|
+
file: relPath,
|
|
72
|
+
message: `${relPath} is NOT covered by .gitignore — credentials will be committed`,
|
|
73
|
+
fix: `Add "${basename}" to your .gitignore file`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Step 3: Parse .env values and cross-reference against source ─────────
|
|
79
|
+
spinner.text = 'Cross-referencing credentials against source files...';
|
|
80
|
+
// Only cross-reference real .env files, not .env.example/.env.sample
|
|
81
|
+
const realEnvFiles = envFiles.filter(f => !/\.env\.(?:example|sample|template)$/i.test(path.basename(f)));
|
|
82
|
+
const envValues = parseEnvFiles(realEnvFiles);
|
|
83
|
+
const sensitiveValues = envValues.filter(v =>
|
|
84
|
+
v.value.length >= MIN_VALUE_LENGTH && !SAFE_KEY_PATTERNS.test(v.key)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (sensitiveValues.length > 0) {
|
|
88
|
+
const sourceFiles = await findSourceFiles(absolutePath);
|
|
89
|
+
for (const { key, value, file: envFile } of sensitiveValues) {
|
|
90
|
+
for (const srcFile of sourceFiles) {
|
|
91
|
+
const relSrc = path.relative(absolutePath, srcFile).replace(/\\/g, '/');
|
|
92
|
+
// Don't flag the .env file itself
|
|
93
|
+
if (srcFile === envFile) continue;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const content = fs.readFileSync(srcFile, 'utf-8');
|
|
97
|
+
if (content.includes(value)) {
|
|
98
|
+
findings.push({
|
|
99
|
+
type: 'hardcoded',
|
|
100
|
+
severity: 'critical',
|
|
101
|
+
file: relSrc,
|
|
102
|
+
message: `${key} value from ${path.basename(envFile)} is hardcoded in ${relSrc}`,
|
|
103
|
+
fix: `Replace the hardcoded value with process.env.${key} or equivalent`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// skip unreadable files
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Step 4: Check git history for committed .env files ───────────────────
|
|
114
|
+
spinner.text = 'Checking git history for committed credentials...';
|
|
115
|
+
const historyLeaks = checkGitHistory(absolutePath);
|
|
116
|
+
for (const leak of historyLeaks) {
|
|
117
|
+
findings.push({
|
|
118
|
+
type: 'history',
|
|
119
|
+
severity: 'high',
|
|
120
|
+
file: leak,
|
|
121
|
+
message: `${leak} was previously committed to git history — credentials may be in old commits`,
|
|
122
|
+
fix: 'Rotate all credentials that were in this file. Use git filter-repo to remove from history if needed.',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Step 5: Check .projects manifest for credential leaks ────────────────
|
|
127
|
+
spinner.text = 'Checking .projects manifest...';
|
|
128
|
+
const projectsDir = path.join(absolutePath, '.projects');
|
|
129
|
+
if (fs.existsSync(projectsDir)) {
|
|
130
|
+
const projectsFiles = await fg(['**/*'], {
|
|
131
|
+
cwd: projectsDir,
|
|
132
|
+
absolute: true,
|
|
133
|
+
onlyFiles: true,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
for (const pFile of projectsFiles) {
|
|
137
|
+
try {
|
|
138
|
+
const content = fs.readFileSync(pFile, 'utf-8');
|
|
139
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
140
|
+
pattern.pattern.lastIndex = 0;
|
|
141
|
+
const match = pattern.pattern.exec(content);
|
|
142
|
+
if (match) {
|
|
143
|
+
const relPath = path.relative(absolutePath, pFile).replace(/\\/g, '/');
|
|
144
|
+
findings.push({
|
|
145
|
+
type: 'projects-manifest',
|
|
146
|
+
severity: 'critical',
|
|
147
|
+
file: relPath,
|
|
148
|
+
message: `${pattern.name} found in .projects manifest — credentials should not be in the manifest`,
|
|
149
|
+
fix: 'Remove credential values from .projects/ config. Stripe Projects stores credentials server-side.',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// skip unreadable
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Step 6: Check agent config access to .env files ──────────────────────
|
|
160
|
+
spinner.text = 'Checking agent config access to credential files...';
|
|
161
|
+
const agentConfigs = await fg([
|
|
162
|
+
'.claude/settings.json',
|
|
163
|
+
'.cursorrules',
|
|
164
|
+
'.cursor/rules/*.mdc',
|
|
165
|
+
'.windsurfrules',
|
|
166
|
+
'CLAUDE.md',
|
|
167
|
+
'.claw.json',
|
|
168
|
+
'.claw/settings.json',
|
|
169
|
+
'openclaw.json',
|
|
170
|
+
], {
|
|
171
|
+
cwd: absolutePath,
|
|
172
|
+
absolute: true,
|
|
173
|
+
onlyFiles: true,
|
|
174
|
+
dot: true,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
for (const configFile of agentConfigs) {
|
|
178
|
+
try {
|
|
179
|
+
const content = fs.readFileSync(configFile, 'utf-8');
|
|
180
|
+
const relPath = path.relative(absolutePath, configFile).replace(/\\/g, '/');
|
|
181
|
+
|
|
182
|
+
// Check for danger modes that give agents full access
|
|
183
|
+
if (/dangerouslySkipPermissions\s*["']?\s*[:=]\s*["']?true/i.test(content) ||
|
|
184
|
+
/permissionMode\s*["']?\s*[:=]\s*["']?danger-full-access/i.test(content)) {
|
|
185
|
+
findings.push({
|
|
186
|
+
type: 'agent-access',
|
|
187
|
+
severity: 'critical',
|
|
188
|
+
file: relPath,
|
|
189
|
+
message: `${relPath} grants unrestricted file access — agent can read all .env credentials`,
|
|
190
|
+
fix: 'Remove dangerouslySkipPermissions / danger-full-access. Scope agent file access explicitly.',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// skip
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Output ───────────────────────────────────────────────────────────────
|
|
199
|
+
spinner.stop();
|
|
200
|
+
|
|
201
|
+
if (options.json) {
|
|
202
|
+
console.log(JSON.stringify({ findings, envFiles: envFiles.length, clean: findings.length === 0 }, null, 2));
|
|
203
|
+
process.exit(findings.length > 0 ? 1 : 0);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
console.log();
|
|
208
|
+
console.log(chalk.cyan.bold(' Ship Safe — Env Audit'));
|
|
209
|
+
console.log(chalk.gray(` Scanned ${envFiles.length} .env file(s), ${sensitiveValues.length} credential(s)`));
|
|
210
|
+
console.log();
|
|
211
|
+
|
|
212
|
+
if (findings.length === 0) {
|
|
213
|
+
console.log(chalk.green(' ✔ Environment is clean. No credential leaks detected.\n'));
|
|
214
|
+
console.log(chalk.gray(' Tip: run this after every `stripe projects env --pull` or credential sync.\n'));
|
|
215
|
+
process.exit(0);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Group by type
|
|
220
|
+
const groups = {
|
|
221
|
+
gitignore: { label: 'Missing .gitignore Coverage', icon: '🔓' },
|
|
222
|
+
hardcoded: { label: 'Hardcoded Credentials in Source', icon: '🔑' },
|
|
223
|
+
history: { label: 'Credentials in Git History', icon: '📜' },
|
|
224
|
+
'projects-manifest': { label: 'Credentials in .projects Manifest', icon: '📁' },
|
|
225
|
+
'agent-access': { label: 'Agent Config: Unrestricted Credential Access', icon: '🤖' },
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
for (const [type, meta] of Object.entries(groups)) {
|
|
229
|
+
const typeFindings = findings.filter(f => f.type === type);
|
|
230
|
+
if (typeFindings.length === 0) continue;
|
|
231
|
+
|
|
232
|
+
console.log(chalk.yellow(` ${meta.icon} ${meta.label} (${typeFindings.length})`));
|
|
233
|
+
for (const f of typeFindings) {
|
|
234
|
+
const sevColor = f.severity === 'critical' ? chalk.red : chalk.yellow;
|
|
235
|
+
console.log(` ${sevColor(f.severity.toUpperCase())} ${f.file}`);
|
|
236
|
+
console.log(chalk.gray(` ${f.message}`));
|
|
237
|
+
console.log(chalk.gray(` Fix: ${f.fix}`));
|
|
238
|
+
}
|
|
239
|
+
console.log();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const criticals = findings.filter(f => f.severity === 'critical').length;
|
|
243
|
+
if (criticals > 0) {
|
|
244
|
+
console.log(chalk.red.bold(` ✘ ${criticals} critical issue(s). Fix before committing.\n`));
|
|
245
|
+
} else {
|
|
246
|
+
console.log(chalk.yellow(` ⚠ ${findings.length} issue(s) found. Review before committing.\n`));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
process.exit(1);
|
|
250
|
+
|
|
251
|
+
} catch (err) {
|
|
252
|
+
spinner.fail(`Env audit error: ${err.message}`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// =============================================================================
|
|
258
|
+
// HELPERS
|
|
259
|
+
// =============================================================================
|
|
260
|
+
|
|
261
|
+
async function findEnvFiles(rootPath) {
|
|
262
|
+
return fg(['.env', '.env.*', '**/.env', '**/.env.*'], {
|
|
263
|
+
cwd: rootPath,
|
|
264
|
+
absolute: true,
|
|
265
|
+
onlyFiles: true,
|
|
266
|
+
dot: true,
|
|
267
|
+
ignore: Array.from(SKIP_DIRS).map(d => `**/${d}/**`),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseEnvFiles(envFiles) {
|
|
272
|
+
const values = [];
|
|
273
|
+
for (const file of envFiles) {
|
|
274
|
+
try {
|
|
275
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
276
|
+
for (const line of content.split('\n')) {
|
|
277
|
+
const trimmed = line.trim();
|
|
278
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
279
|
+
const eqIdx = trimmed.indexOf('=');
|
|
280
|
+
if (eqIdx === -1) continue;
|
|
281
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
282
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
283
|
+
// Strip surrounding quotes
|
|
284
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
285
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
286
|
+
value = value.slice(1, -1);
|
|
287
|
+
}
|
|
288
|
+
if (value && value !== '' && !value.startsWith('${')) {
|
|
289
|
+
values.push({ key, value, file });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
// skip unreadable
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return values;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function findSourceFiles(rootPath) {
|
|
300
|
+
return fg(['**/*.{js,jsx,ts,tsx,mjs,cjs,py,rb,go,rs,php,java,kt,swift,yaml,yml,json,toml,tf}'], {
|
|
301
|
+
cwd: rootPath,
|
|
302
|
+
absolute: true,
|
|
303
|
+
onlyFiles: true,
|
|
304
|
+
ignore: [
|
|
305
|
+
...Array.from(SKIP_DIRS).map(d => `**/${d}/**`),
|
|
306
|
+
'**/.env*',
|
|
307
|
+
'**/*.min.js',
|
|
308
|
+
'**/__tests__/**',
|
|
309
|
+
'**/*.test.*',
|
|
310
|
+
'**/*.spec.*',
|
|
311
|
+
'**/test/**',
|
|
312
|
+
'**/tests/**',
|
|
313
|
+
'**/fixtures/**',
|
|
314
|
+
'**/snippets/**',
|
|
315
|
+
],
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function checkGitignored(rootPath, relPath) {
|
|
320
|
+
try {
|
|
321
|
+
execFileSync('git', ['check-ignore', '-q', relPath], { cwd: rootPath, stdio: 'pipe' });
|
|
322
|
+
return true; // exit 0 means it IS ignored
|
|
323
|
+
} catch {
|
|
324
|
+
return false; // exit 1 means it is NOT ignored
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function checkGitHistory(rootPath) {
|
|
329
|
+
try {
|
|
330
|
+
const result = execFileSync('git', ['log', '--all', '--diff-filter=A', '--name-only', '--pretty=format:'], {
|
|
331
|
+
cwd: rootPath,
|
|
332
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
333
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
334
|
+
}).toString();
|
|
335
|
+
|
|
336
|
+
const envFilePattern = /^\.env(?:\.\w+)?$/;
|
|
337
|
+
const safeEnvPattern = /\.env\.(?:example|sample|template)$/;
|
|
338
|
+
const committed = new Set();
|
|
339
|
+
for (const line of result.split('\n')) {
|
|
340
|
+
const trimmed = line.trim();
|
|
341
|
+
if (trimmed && envFilePattern.test(path.basename(trimmed)) && !safeEnvPattern.test(trimmed)) {
|
|
342
|
+
committed.add(trimmed);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return Array.from(committed);
|
|
346
|
+
} catch {
|
|
347
|
+
return [];
|
|
348
|
+
}
|
|
349
|
+
}
|
package/cli/commands/init.js
CHANGED
|
@@ -55,6 +55,11 @@ export async function initCommand(options = {}) {
|
|
|
55
55
|
return handleOpenClawInit(targetDir, options.force, results);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Handle --hermes --from <url>
|
|
59
|
+
if (options.hermes) {
|
|
60
|
+
return handleHermesInit(targetDir, options);
|
|
61
|
+
}
|
|
62
|
+
|
|
58
63
|
const hasSpecificFlag = options.gitignore || options.headers || options.agents;
|
|
59
64
|
const copyGitignore = hasSpecificFlag ? !!options.gitignore : true;
|
|
60
65
|
const copyHeaders = hasSpecificFlag ? !!options.headers : true;
|
|
@@ -301,6 +306,105 @@ async function handleAgentFiles(targetDir, force, results) {
|
|
|
301
306
|
}
|
|
302
307
|
}
|
|
303
308
|
|
|
309
|
+
// =============================================================================
|
|
310
|
+
// HERMES AGENT INIT
|
|
311
|
+
// =============================================================================
|
|
312
|
+
|
|
313
|
+
async function handleHermesInit(targetDir, options) {
|
|
314
|
+
const fromUrl = options.from;
|
|
315
|
+
|
|
316
|
+
if (!fromUrl) {
|
|
317
|
+
console.error(chalk.red('\nError: --hermes requires --from <setup-url>'));
|
|
318
|
+
console.error(chalk.gray(' Generate a setup URL at: https://shipsafecli.com/app/deploy'));
|
|
319
|
+
console.error(chalk.gray(' Then run: npx ship-safe init --hermes --from <url>\n'));
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Validate the URL is from a trusted origin
|
|
324
|
+
let parsed;
|
|
325
|
+
try {
|
|
326
|
+
parsed = new URL(fromUrl);
|
|
327
|
+
} catch {
|
|
328
|
+
console.error(chalk.red('\nError: Invalid URL: ' + fromUrl + '\n'));
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const TRUSTED_HOSTS = ['shipsafecli.com', 'www.shipsafecli.com', 'localhost', '127.0.0.1'];
|
|
333
|
+
if (!TRUSTED_HOSTS.includes(parsed.hostname)) {
|
|
334
|
+
console.error(chalk.red('\nError: Setup URL must be from shipsafecli.com (got: ' + parsed.hostname + ')'));
|
|
335
|
+
console.error(chalk.gray(' Only URLs generated by the Ship Safe webapp are trusted.\n'));
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
console.log();
|
|
340
|
+
output.header('Hermes Agent Security Setup');
|
|
341
|
+
console.log();
|
|
342
|
+
console.log(chalk.gray('Fetching config from:'), chalk.cyan(fromUrl));
|
|
343
|
+
console.log();
|
|
344
|
+
|
|
345
|
+
// Fetch the config bundle
|
|
346
|
+
let data;
|
|
347
|
+
try {
|
|
348
|
+
const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
|
|
349
|
+
const res = await fetch(fromUrl, { headers: { 'Accept': 'application/json' } });
|
|
350
|
+
if (!res.ok) {
|
|
351
|
+
const body = await res.json().catch(() => ({}));
|
|
352
|
+
throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
353
|
+
}
|
|
354
|
+
data = await res.json();
|
|
355
|
+
} catch (err) {
|
|
356
|
+
console.error(chalk.red('\nFailed to fetch setup config: ' + err.message + '\n'));
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!data.files || !Array.isArray(data.files) || data.files.length === 0) {
|
|
361
|
+
console.error(chalk.red('\nInvalid config bundle — no files returned.\n'));
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Write each file
|
|
366
|
+
const written = [];
|
|
367
|
+
const skipped = [];
|
|
368
|
+
|
|
369
|
+
for (const { path: filePath, content } of data.files) {
|
|
370
|
+
// Sanitize path — no traversal
|
|
371
|
+
const normalized = path.normalize(filePath).replace(/^(\.\.(\/|\\|$))+/, '');
|
|
372
|
+
const absPath = path.join(targetDir, normalized);
|
|
373
|
+
|
|
374
|
+
if (fs.existsSync(absPath) && !options.force) {
|
|
375
|
+
skipped.push(normalized);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const dir = path.dirname(absPath);
|
|
380
|
+
if (!fs.existsSync(dir)) {
|
|
381
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
382
|
+
}
|
|
383
|
+
fs.writeFileSync(absPath, content, 'utf-8');
|
|
384
|
+
written.push(normalized);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Print results
|
|
388
|
+
console.log(chalk.green.bold('Files written:'));
|
|
389
|
+
for (const f of written) {
|
|
390
|
+
console.log(chalk.green(` ✔ ${f}`));
|
|
391
|
+
}
|
|
392
|
+
if (skipped.length > 0) {
|
|
393
|
+
console.log();
|
|
394
|
+
console.log(chalk.yellow.bold('Skipped (already exist — use -f to overwrite):'));
|
|
395
|
+
for (const f of skipped) {
|
|
396
|
+
console.log(chalk.yellow(` → ${f}`));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
console.log();
|
|
401
|
+
console.log(chalk.cyan.bold('Next steps:'));
|
|
402
|
+
console.log(chalk.white(' 1.') + ' Populate your baseline: ' + chalk.cyan('npx ship-safe audit .'));
|
|
403
|
+
console.log(chalk.white(' 2.') + ' Auto-fix findings: ' + chalk.cyan('npx ship-safe audit . --agentic 3 --agentic-target 80'));
|
|
404
|
+
console.log(chalk.white(' 3.') + ' Commit everything and push — CI runs on every PR.');
|
|
405
|
+
console.log();
|
|
406
|
+
}
|
|
407
|
+
|
|
304
408
|
// =============================================================================
|
|
305
409
|
// OPENCLAW HARDENED CONFIG
|
|
306
410
|
// =============================================================================
|