@garyr/pt-cli 0.32.1 → 0.36.4
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 +2 -2
- package/dist/commands/defaultPostConfigCommand.js +2 -1
- package/dist/commands/initCommand.js +47 -15
- package/dist/commands/learnCommand.js +1 -1
- package/dist/commands/securityResponseCommand.js +12 -0
- package/dist/config.js +17 -0
- package/dist/index.js +31 -2
- package/dist/postconfig.js +49 -6
- package/dist/remote.js +50 -2
- package/dist/safety.js +280 -0
- package/dist/substitute.js +17 -3
- package/doc/configuration.md +11 -0
- package/doc/security.md +132 -0
- package/doc/testing.md +21 -1
- package/doc/usage.md +28 -14
- package/package.json +5 -5
- package/skills/agency-pt-operator/SKILL.md +64 -6
- package/src/commands/defaultPostConfigCommand.ts +2 -1
- package/src/commands/initCommand.ts +50 -14
- package/src/commands/learnCommand.ts +2 -1
- package/src/commands/securityResponseCommand.ts +18 -0
- package/src/config.ts +21 -0
- package/src/index.ts +28 -2
- package/src/postconfig.ts +66 -5
- package/src/remote.ts +56 -2
- package/src/safety.ts +331 -0
- package/src/substitute.ts +23 -3
- package/test-direct-template.json +0 -24
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { variablesCommand } from './commands/variablesCommand.js';
|
|
|
14
14
|
import { addCommand } from './commands/addCommand.js';
|
|
15
15
|
import { removeCommand } from './commands/removeCommand.js';
|
|
16
16
|
import { defaultPostConfigCommand } from './commands/defaultPostConfigCommand.js';
|
|
17
|
+
import { securityResponseCommand } from './commands/securityResponseCommand.js';
|
|
17
18
|
|
|
18
19
|
import pkg from '../package.json' with { type: 'json' };
|
|
19
20
|
|
|
@@ -32,8 +33,21 @@ program
|
|
|
32
33
|
.option('--name <name>', 'Template name (skip prompt)')
|
|
33
34
|
.option('--desc <description>', 'Template description (skip prompt)')
|
|
34
35
|
.option('--json', 'Output template structure as JSON for sharing instead of saving')
|
|
36
|
+
.option('--allow-untrusted', 'Bypass the trusted-source check for remote URLs (set by GUI after user confirmation)')
|
|
35
37
|
.action(async (pathArg: string | undefined, options) => {
|
|
36
|
-
|
|
38
|
+
try {
|
|
39
|
+
await learn(pathArg || '.', null, options);
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
if (options.json) {
|
|
42
|
+
console.log(JSON.stringify({
|
|
43
|
+
type: 'error',
|
|
44
|
+
message: err.message || String(err)
|
|
45
|
+
}));
|
|
46
|
+
} else {
|
|
47
|
+
console.error(chalk.red(`Error: ${err.message || err}`));
|
|
48
|
+
}
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
37
51
|
});
|
|
38
52
|
|
|
39
53
|
program
|
|
@@ -43,7 +57,12 @@ program
|
|
|
43
57
|
.option('-y, --yes', 'Automatically confirm prompts')
|
|
44
58
|
.option('--desc <description>', 'Template description (skip prompt)')
|
|
45
59
|
.action(async (templateName: string, sourcePath: string | undefined, options) => {
|
|
46
|
-
|
|
60
|
+
try {
|
|
61
|
+
await learn(sourcePath || '.', templateName, options);
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
console.error(chalk.red(`Error: ${err.message || err}`));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
47
66
|
});
|
|
48
67
|
|
|
49
68
|
program
|
|
@@ -98,4 +117,11 @@ program
|
|
|
98
117
|
.option('-y, --yes', 'Automatically confirm removal')
|
|
99
118
|
.action(removeCommand);
|
|
100
119
|
|
|
120
|
+
program
|
|
121
|
+
.command('security-response <response>')
|
|
122
|
+
.description('Handle security response from GUI')
|
|
123
|
+
.action(async (response: string) => {
|
|
124
|
+
await securityResponseCommand(response);
|
|
125
|
+
});
|
|
126
|
+
|
|
101
127
|
program.parse(process.argv);
|
package/src/postconfig.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
3
4
|
import { execSync } from 'child_process';
|
|
4
5
|
import chalk from 'chalk';
|
|
5
6
|
import inquirer from 'inquirer';
|
|
6
7
|
import { PostConfigTask } from './config.js';
|
|
8
|
+
import {
|
|
9
|
+
isBlockedCommand,
|
|
10
|
+
isDangerousCommand,
|
|
11
|
+
executeWithTimeout,
|
|
12
|
+
logSecurityEvent,
|
|
13
|
+
canExecute,
|
|
14
|
+
showDangerousCommandWarning,
|
|
15
|
+
getSecurityPolicy,
|
|
16
|
+
} from './safety.js';
|
|
7
17
|
|
|
8
18
|
export interface PostConfigOptions {
|
|
9
19
|
skipPostConfig?: boolean;
|
|
@@ -22,6 +32,16 @@ export async function runPostConfig(
|
|
|
22
32
|
): Promise<void> {
|
|
23
33
|
if (options.skipPostConfig) return;
|
|
24
34
|
|
|
35
|
+
// Load security policy
|
|
36
|
+
const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
|
|
37
|
+
const securityPolicy = getSecurityPolicy(configPath);
|
|
38
|
+
|
|
39
|
+
// Check rate limiting
|
|
40
|
+
if (!canExecute('init', securityPolicy.maxCommandsPerRun)) {
|
|
41
|
+
console.log(chalk.yellow('⚠️ Rate limit reached: max commands per run exceeded'));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
// 1. Filter tasks by type
|
|
26
46
|
const applicableTasks = tasks.filter(t => !t.type || t.type === projectType);
|
|
27
47
|
|
|
@@ -54,18 +74,59 @@ export async function runPostConfig(
|
|
|
54
74
|
const progress = `[${i + 1}/${applicableTasks.length}]`;
|
|
55
75
|
|
|
56
76
|
if (task.command) {
|
|
77
|
+
// SECURITY CHECK 1: Blocklist check (NEVER allow these)
|
|
78
|
+
if (isBlockedCommand(task.command)) {
|
|
79
|
+
console.log(chalk.red(`${progress} ⚠️ BLOCKED: ${task.command}`));
|
|
80
|
+
logSecurityEvent('command_blocked', task.command, projectType, 'blocked');
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// SECURITY CHECK 2: Dangerous command warning (but allow execution)
|
|
85
|
+
if (isDangerousCommand(task.command)) {
|
|
86
|
+
console.log(chalk.yellow(`${progress} ⚠️ WARNING: This command may be dangerous: ${task.command}`));
|
|
87
|
+
console.log(chalk.yellow(` Press CTRL+C to cancel, or wait 5s to continue...`));
|
|
88
|
+
|
|
89
|
+
// Wait for user to cancel or timeout
|
|
90
|
+
const allowContinue = await showDangerousCommandWarning(task.command, 5);
|
|
91
|
+
if (!allowContinue) {
|
|
92
|
+
console.log(chalk.yellow(`${progress} ⊘ Command cancelled by user`));
|
|
93
|
+
logSecurityEvent('command_blocked', task.command, projectType, 'blocked');
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// SECURITY CHECK 3: Rate limiting
|
|
99
|
+
if (!canExecute(task.command, securityPolicy.maxCommandsPerRun)) {
|
|
100
|
+
console.log(chalk.red(`${progress} ⚠️ Rate limited: too many commands executed`));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
57
104
|
if (options.dryRun) {
|
|
58
105
|
console.log(chalk.gray(` [DRY RUN] Would run: ${task.command}`));
|
|
59
106
|
} else {
|
|
60
107
|
try {
|
|
61
108
|
console.log(chalk.yellow(`\n${progress} Running: ${task.command}`));
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
109
|
+
|
|
110
|
+
// SECURITY CHECK 4: Execution timeout
|
|
111
|
+
const result = await executeWithTimeout(
|
|
112
|
+
task.command,
|
|
113
|
+
destPath,
|
|
114
|
+
securityPolicy.maxExecutionTime
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (result.timedOut) {
|
|
118
|
+
console.log(chalk.red(` ✗ Command timed out after ${securityPolicy.maxExecutionTime / 1000}s`));
|
|
119
|
+
logSecurityEvent('command_timed_out', task.command, projectType, 'timedout');
|
|
120
|
+
} else if (result.success) {
|
|
121
|
+
console.log(chalk.green(' ✓ Command completed successfully'));
|
|
122
|
+
logSecurityEvent('command_executed', task.command, projectType, 'success');
|
|
123
|
+
} else {
|
|
124
|
+
console.log(chalk.red(` ✗ Command failed: ${result.stderr}`));
|
|
125
|
+
logSecurityEvent('command_executed', task.command, projectType, 'failed');
|
|
126
|
+
}
|
|
67
127
|
} catch (err) {
|
|
68
128
|
console.log(chalk.red(' ✗ Command failed'));
|
|
129
|
+
logSecurityEvent('command_executed', task.command, projectType, 'failed');
|
|
69
130
|
}
|
|
70
131
|
}
|
|
71
132
|
}
|
package/src/remote.ts
CHANGED
|
@@ -5,8 +5,51 @@ import os from 'os';
|
|
|
5
5
|
import { Readable } from 'stream';
|
|
6
6
|
import { finished } from 'stream/promises';
|
|
7
7
|
import { extract } from 'tar'; // You'll need: npm install tar
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { isTrustedSource, logSecurityEvent, getSecurityPolicy } from './safety.js';
|
|
10
|
+
import { loadConfig } from './config.js';
|
|
11
|
+
|
|
12
|
+
export async function downloadAndExtract(url: string, isJsonMode: boolean = false, allowUntrusted: boolean = false): Promise<string> {
|
|
13
|
+
// Load security policy
|
|
14
|
+
const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
|
|
15
|
+
const config = loadConfig();
|
|
16
|
+
const securityPolicy = config.security || {
|
|
17
|
+
trustedSources: ['github.com/garyritchie', 'gitea.lyonritchie.com/garyritchie', 'github.com/lyonritchie'],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// SECURITY CHECK: Verify source is trusted (skipped when --allow-untrusted is passed)
|
|
21
|
+
if (!allowUntrusted && !isTrustedSource(url, securityPolicy.trustedSources)) {
|
|
22
|
+
if (isJsonMode) {
|
|
23
|
+
// In JSON mode, output warning as JSON for GUI consumption.
|
|
24
|
+
// The GUI will show a confirmation dialog and, if the user says YES,
|
|
25
|
+
// re-run `pt learn <url> --json --yes --allow-untrusted`.
|
|
26
|
+
console.log(JSON.stringify({
|
|
27
|
+
type: 'security_warning',
|
|
28
|
+
url: url,
|
|
29
|
+
message: `Template from untrusted source: ${url}`,
|
|
30
|
+
warning: 'Only use templates from trusted sources.',
|
|
31
|
+
prompt: 'Continue anyway?',
|
|
32
|
+
default: false
|
|
33
|
+
}));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
} else {
|
|
36
|
+
console.log(chalk.yellow(`⚠️ Warning: Template from untrusted source: ${url}`));
|
|
37
|
+
console.log(chalk.yellow(' Only use templates from trusted sources'));
|
|
38
|
+
|
|
39
|
+
const inquirer = (await import('inquirer')).default;
|
|
40
|
+
const response = await inquirer.prompt({
|
|
41
|
+
type: 'confirm',
|
|
42
|
+
name: 'proceed',
|
|
43
|
+
message: chalk.red('Continue anyway?'),
|
|
44
|
+
default: false
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!response.proceed) {
|
|
48
|
+
throw new Error('Download cancelled by user due to untrusted source');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
8
52
|
|
|
9
|
-
export async function downloadAndExtract(url: string): Promise<string> {
|
|
10
53
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
|
|
11
54
|
let downloadUrl = url;
|
|
12
55
|
|
|
@@ -27,10 +70,21 @@ export async function downloadAndExtract(url: string): Promise<string> {
|
|
|
27
70
|
const fileStream = fs.createWriteStream(dest);
|
|
28
71
|
await finished(Readable.fromWeb(response.body as any).pipe(fileStream));
|
|
29
72
|
|
|
73
|
+
// SECURITY: Validate downloaded file before extraction
|
|
74
|
+
const stats = fs.statSync(dest);
|
|
75
|
+
if (stats.size > 50 * 1024 * 1024) { // 50MB limit
|
|
76
|
+
throw new Error('Downloaded template is too large (>50MB)');
|
|
77
|
+
}
|
|
78
|
+
|
|
30
79
|
// Extract tarball
|
|
31
80
|
await extract({ file: dest, cwd: tempDir });
|
|
32
81
|
|
|
33
82
|
// Find the actual content folder (archives usually wrap content in a folder)
|
|
34
83
|
const dirs = fs.readdirSync(tempDir).filter(f => fs.statSync(path.join(tempDir, f)).isDirectory());
|
|
35
|
-
|
|
84
|
+
const extractedPath = path.join(tempDir, dirs[0]);
|
|
85
|
+
|
|
86
|
+
// SECURITY: Log successful download
|
|
87
|
+
logSecurityEvent('template_loaded', downloadUrl, 'remote', 'success');
|
|
88
|
+
|
|
89
|
+
return extractedPath;
|
|
36
90
|
}
|
package/src/safety.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// pt-cli/src/safety.ts
|
|
2
|
+
// Security warnings and safeguards for post_config command execution
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
|
|
9
|
+
// === BLOCKLIST ===
|
|
10
|
+
// Commands that are NEVER allowed (safety-critical operations)
|
|
11
|
+
const BLOCKED_COMMANDS = [
|
|
12
|
+
// Privilege escalation
|
|
13
|
+
'sudo', 'su', 'su -', 'su root',
|
|
14
|
+
// Disk operations that could destroy data
|
|
15
|
+
'dd', 'mkfs', 'fdisk', 'mount', 'umount',
|
|
16
|
+
// Shell injection patterns - removed from blocklist to allow command chaining.
|
|
17
|
+
// These are validated as warnings instead.
|
|
18
|
+
// Dangerous chmod
|
|
19
|
+
'chmod 777', 'chmod -R 777', 'chmod 666',
|
|
20
|
+
// System commands that could kill processes
|
|
21
|
+
'kill', 'killall', 'pkill', 'fuser',
|
|
22
|
+
// Network operations that could exfiltrate data
|
|
23
|
+
'nc', 'netcat', 'socat',
|
|
24
|
+
// Package manager with dangerous flags
|
|
25
|
+
'apt purge', 'apt remove', 'yum remove', 'brew uninstall',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// === DANGEROUS PATTERNS ===
|
|
29
|
+
// Commands that should trigger a warning but are NOT blocked
|
|
30
|
+
const DANGEROUS_PATTERNS = [
|
|
31
|
+
// Remote downloads + execution
|
|
32
|
+
'curl', 'wget', 'wget -O', 'curl |', 'wget |',
|
|
33
|
+
// Script execution
|
|
34
|
+
'bash', 'sh', 'python', 'python3', 'node -e', 'node -p',
|
|
35
|
+
// Shell operations
|
|
36
|
+
'eval', 'exec', 'source',
|
|
37
|
+
// File system manipulation
|
|
38
|
+
'chmod -R', 'chown -R', 'chgrp -R',
|
|
39
|
+
// PowerShell (Windows)
|
|
40
|
+
'powershell', 'pwsh', 'Invoke-Expression', 'IEX',
|
|
41
|
+
// macOS-specific
|
|
42
|
+
'diskutil', 'hdiutil', 'csrutil',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// === DEFAULT CONFIGURATION ===
|
|
46
|
+
export interface SecurityPolicy {
|
|
47
|
+
maxExecutionTime: number; // milliseconds
|
|
48
|
+
enableAuditLogging: boolean;
|
|
49
|
+
trustedSources: string[];
|
|
50
|
+
maxCommandsPerRun: number;
|
|
51
|
+
securityLevel: 'warn' | 'strict';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Default security policy - warning focused
|
|
55
|
+
const DEFAULT_SECURITY_POLICY: SecurityPolicy = {
|
|
56
|
+
maxExecutionTime: 30000, // 30 seconds
|
|
57
|
+
enableAuditLogging: true,
|
|
58
|
+
trustedSources: [
|
|
59
|
+
'github.com/garyritchie',
|
|
60
|
+
'git.lyonritchie.com',
|
|
61
|
+
'github.com/lyonritchie',
|
|
62
|
+
],
|
|
63
|
+
maxCommandsPerRun: 50,
|
|
64
|
+
securityLevel: 'warn', // Warning-focused mode
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if a command is in the blocklist (NEVER allowed)
|
|
69
|
+
* Only truly dangerous operations that could destroy data
|
|
70
|
+
*/
|
|
71
|
+
export function isBlockedCommand(command: string): boolean {
|
|
72
|
+
for (const blocked of BLOCKED_COMMANDS) {
|
|
73
|
+
if (command.includes(blocked)) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a command should trigger a warning (but is allowed)
|
|
82
|
+
*/
|
|
83
|
+
export function isDangerousCommand(command: string): boolean {
|
|
84
|
+
// Check for destructive file operations targeting absolute paths
|
|
85
|
+
// Regex matches 'rm', 'rmdir', or Windows 'del' followed by optional flags and then an absolute path.
|
|
86
|
+
// Absolute path matches:
|
|
87
|
+
// - Unix: starting with '/' (e.g. /tmp, /usr)
|
|
88
|
+
// - Windows: starting with drive letter (e.g. C:\) or UNC path (\\) or drive-relative '\'
|
|
89
|
+
const parts = command.split(/\s+/);
|
|
90
|
+
const rmIndex = parts.findIndex(p => p === 'rm' || p === 'rmdir' || p === 'del');
|
|
91
|
+
if (rmIndex !== -1) {
|
|
92
|
+
// Check subsequent arguments
|
|
93
|
+
for (let i = rmIndex + 1; i < parts.length; i++) {
|
|
94
|
+
const arg = parts[i];
|
|
95
|
+
if (!arg) continue;
|
|
96
|
+
// Skip flags (starting with - or /flag on Windows)
|
|
97
|
+
if (arg.startsWith('-') || (process.platform === 'win32' && arg.startsWith('/'))) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Check absolute path patterns
|
|
101
|
+
const isAbsolute = arg.startsWith('/') ||
|
|
102
|
+
(process.platform === 'win32' && (/^[a-zA-Z]:\\/.test(arg) || arg.startsWith('\\\\') || arg.startsWith('\\')));
|
|
103
|
+
if (isAbsolute) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const pattern of DANGEROUS_PATTERNS) {
|
|
110
|
+
if (command.includes(pattern)) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Execute a command with timeout and error handling
|
|
119
|
+
*/
|
|
120
|
+
export async function executeWithTimeout(
|
|
121
|
+
command: string,
|
|
122
|
+
cwd: string,
|
|
123
|
+
timeoutMs: number = 30000
|
|
124
|
+
): Promise<{ success: boolean; stdout?: string; stderr?: string; timedOut?: boolean }> {
|
|
125
|
+
const { execSync } = await import('child_process');
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const output = execSync(command, {
|
|
129
|
+
cwd,
|
|
130
|
+
stdio: 'pipe',
|
|
131
|
+
timeout: timeoutMs,
|
|
132
|
+
encoding: 'utf-8',
|
|
133
|
+
});
|
|
134
|
+
return { success: true, stdout: output };
|
|
135
|
+
} catch (err: any) {
|
|
136
|
+
if (err.code === 'ETIMEDOUT') {
|
|
137
|
+
return { success: false, timedOut: true };
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
success: false,
|
|
141
|
+
stderr: err.stderr || err.message,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Log a security event to audit log
|
|
148
|
+
*/
|
|
149
|
+
export function logSecurityEvent(
|
|
150
|
+
eventType: 'command_executed' | 'command_blocked' | 'command_timed_out' | 'template_loaded',
|
|
151
|
+
command: string,
|
|
152
|
+
templateName: string,
|
|
153
|
+
result: 'success' | 'failed' | 'timedout' | 'blocked'
|
|
154
|
+
): void {
|
|
155
|
+
const logEntry = {
|
|
156
|
+
timestamp: new Date().toISOString(),
|
|
157
|
+
eventType,
|
|
158
|
+
command,
|
|
159
|
+
template: templateName,
|
|
160
|
+
user: os.userInfo().username,
|
|
161
|
+
result,
|
|
162
|
+
hostname: os.hostname(),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const logDir = path.join(os.homedir(), '.pt');
|
|
166
|
+
const logFile = path.join(logDir, 'security-audit.log');
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
// Ensure log directory exists
|
|
170
|
+
if (!fs.existsSync(logDir)) {
|
|
171
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Append to audit log
|
|
175
|
+
fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n');
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// Silently fail if logging fails
|
|
178
|
+
console.warn('Warning: Failed to write security audit log');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if a URL is from a trusted source
|
|
184
|
+
*/
|
|
185
|
+
export function isTrustedSource(url: string, trustedSources: string[] = DEFAULT_SECURITY_POLICY.trustedSources): boolean {
|
|
186
|
+
return trustedSources.some(source => url.includes(source));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Rate limiting: track the last execution timestamp per command hash
|
|
191
|
+
*/
|
|
192
|
+
const lastExecutionTimes = new Map<string, number>();
|
|
193
|
+
const RATE_LIMIT_MS = 1000; // 1 second between identical commands
|
|
194
|
+
|
|
195
|
+
export function canExecute(command: string, maxCommandsPerRun: number = 50): boolean {
|
|
196
|
+
// Check total commands executed this run
|
|
197
|
+
const totalExecuted = lastExecutionTimes.size;
|
|
198
|
+
if (totalExecuted >= maxCommandsPerRun) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Check rate limit for this specific command
|
|
203
|
+
const hash = crypto.createHash('md5').update(command).digest('hex');
|
|
204
|
+
const lastTime = lastExecutionTimes.get(hash) || 0;
|
|
205
|
+
if (Date.now() - lastTime < RATE_LIMIT_MS) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Record execution timestamp
|
|
210
|
+
lastExecutionTimes.set(hash, Date.now());
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Reset execution timestamps (for testing or between runs)
|
|
216
|
+
*/
|
|
217
|
+
export function resetExecutionCounts(): void {
|
|
218
|
+
lastExecutionTimes.clear();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Validate a template's security before execution
|
|
223
|
+
*/
|
|
224
|
+
export function validateTemplateSecurity(
|
|
225
|
+
templateConfig: any,
|
|
226
|
+
securityPolicy: SecurityPolicy = DEFAULT_SECURITY_POLICY
|
|
227
|
+
): { valid: boolean; errors: string[]; warnings: string[] } {
|
|
228
|
+
const errors: string[] = [];
|
|
229
|
+
const warnings: string[] = [];
|
|
230
|
+
|
|
231
|
+
// Check post_config tasks
|
|
232
|
+
const postConfigTasks = templateConfig.post_config || [];
|
|
233
|
+
for (const task of postConfigTasks) {
|
|
234
|
+
const command = task.command;
|
|
235
|
+
if (!command) continue;
|
|
236
|
+
|
|
237
|
+
// Check for blocked commands
|
|
238
|
+
if (isBlockedCommand(command)) {
|
|
239
|
+
errors.push(`Blocked command in template: ${command}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Warn about dangerous commands
|
|
243
|
+
if (isDangerousCommand(command)) {
|
|
244
|
+
warnings.push(`Dangerous command in template: ${command}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check for shell injection patterns
|
|
248
|
+
if (command.includes(';') || command.includes('|') || command.includes('&')) {
|
|
249
|
+
warnings.push(`Shell injection pattern in command: ${command}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Check for remote downloads
|
|
253
|
+
if (command.includes('curl') || command.includes('wget')) {
|
|
254
|
+
warnings.push(`Remote download in command: ${command}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
valid: errors.length === 0,
|
|
260
|
+
errors,
|
|
261
|
+
warnings,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get security policy from config or use defaults
|
|
267
|
+
*/
|
|
268
|
+
export function getSecurityPolicy(configPath?: string): SecurityPolicy {
|
|
269
|
+
// Try to load from config
|
|
270
|
+
if (configPath) {
|
|
271
|
+
try {
|
|
272
|
+
const YAML = require('yaml');
|
|
273
|
+
const config = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
274
|
+
if (config.security) {
|
|
275
|
+
return { ...DEFAULT_SECURITY_POLICY, ...config.security };
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
// Fall back to defaults
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return DEFAULT_SECURITY_POLICY;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Show warning about dangerous command and wait for user to cancel.
|
|
287
|
+
* Resolves true after the timeout (continue), or false immediately on CTRL+C (cancel).
|
|
288
|
+
*/
|
|
289
|
+
export async function showDangerousCommandWarning(command: string, timeoutSeconds: number = 5): Promise<boolean> {
|
|
290
|
+
const readline = await import('readline');
|
|
291
|
+
|
|
292
|
+
console.log(chalk.red('\n⚠️ DANGEROUS COMMAND DETECTED'));
|
|
293
|
+
console.log(chalk.red(` Command: ${command}`));
|
|
294
|
+
console.log(chalk.red(' This could potentially harm your system.'));
|
|
295
|
+
console.log(chalk.yellow(` Press CTRL+C to cancel, or wait ${timeoutSeconds}s to continue...`));
|
|
296
|
+
|
|
297
|
+
const rl = readline.createInterface({
|
|
298
|
+
input: process.stdin,
|
|
299
|
+
output: process.stdout,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
const timer = setTimeout(() => {
|
|
304
|
+
rl.close();
|
|
305
|
+
resolve(true);
|
|
306
|
+
}, timeoutSeconds * 1000);
|
|
307
|
+
|
|
308
|
+
rl.on('SIGINT', () => {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
rl.close();
|
|
311
|
+
console.log(chalk.yellow('\n⚠️ Command cancelled by user'));
|
|
312
|
+
resolve(false);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Security response handling for GUI integration
|
|
318
|
+
export async function handleSecurityResponse(response: string): Promise<boolean> {
|
|
319
|
+
// Normalize response
|
|
320
|
+
const normalized = response.trim().toLowerCase();
|
|
321
|
+
|
|
322
|
+
// Accept 'y' or 'yes' as positive response
|
|
323
|
+
if (normalized === 'y' || normalized === 'yes') {
|
|
324
|
+
console.log('Security response: ALLOWED');
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Reject any other response
|
|
329
|
+
console.log('Security response: DENIED');
|
|
330
|
+
return false;
|
|
331
|
+
}
|
package/src/substitute.ts
CHANGED
|
@@ -44,14 +44,26 @@ export async function processCopyFiles(
|
|
|
44
44
|
if (dryRun) {
|
|
45
45
|
console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
|
|
46
46
|
} else {
|
|
47
|
-
|
|
47
|
+
const dirSubstitute = !!(copyFile.substitute_variables === true || (
|
|
48
|
+
copyFile.substitute_variables === undefined &&
|
|
49
|
+
template.variables &&
|
|
50
|
+
template.variables.length > 0 &&
|
|
51
|
+
Object.keys(variables).length > 0
|
|
52
|
+
));
|
|
53
|
+
copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
|
|
48
54
|
}
|
|
49
55
|
console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
|
|
50
56
|
} else {
|
|
51
57
|
// Single file copy
|
|
52
58
|
if (dryRun) {
|
|
53
59
|
console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
|
|
54
|
-
|
|
60
|
+
const drySubstitute = !!(copyFile.substitute_variables === true || (
|
|
61
|
+
copyFile.substitute_variables === undefined &&
|
|
62
|
+
template.variables &&
|
|
63
|
+
template.variables.length > 0 &&
|
|
64
|
+
Object.keys(variables).length > 0
|
|
65
|
+
));
|
|
66
|
+
if (drySubstitute) {
|
|
55
67
|
console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
|
|
56
68
|
}
|
|
57
69
|
if (copyFile.chmod) {
|
|
@@ -64,7 +76,15 @@ export async function processCopyFiles(
|
|
|
64
76
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
65
77
|
|
|
66
78
|
let content = fs.readFileSync(srcPath, 'utf-8');
|
|
67
|
-
if
|
|
79
|
+
// Default to substituting if substitute_variables is true, OR if it's undefined AND the template defines variables.
|
|
80
|
+
// If substitute_variables is explicitly false, do not substitute.
|
|
81
|
+
const shouldSubstitute = !!(copyFile.substitute_variables === true || (
|
|
82
|
+
copyFile.substitute_variables === undefined &&
|
|
83
|
+
template.variables &&
|
|
84
|
+
template.variables.length > 0 &&
|
|
85
|
+
Object.keys(variables).length > 0
|
|
86
|
+
));
|
|
87
|
+
if (shouldSubstitute) {
|
|
68
88
|
content = substituteVariables(content, variables);
|
|
69
89
|
}
|
|
70
90
|
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "direct-json-test",
|
|
3
|
-
"description": "A mock template for direct JSON scaffolding test",
|
|
4
|
-
"folders": [
|
|
5
|
-
{
|
|
6
|
-
"name": "src",
|
|
7
|
-
"info": "contains sources",
|
|
8
|
-
"children": [
|
|
9
|
-
{
|
|
10
|
-
"name": "components",
|
|
11
|
-
"info": "reusable components"
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
"name": "utils",
|
|
15
|
-
"info": "utility functions"
|
|
16
|
-
}
|
|
17
|
-
]
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
"name": "docs",
|
|
21
|
-
"info": "documentation folder"
|
|
22
|
-
}
|
|
23
|
-
]
|
|
24
|
-
}
|