@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pt - Project Template CLI
2
2
 
3
- A CLI tool to record directory structures as templates and initialize new projects from them.
3
+ A CLI tool to record directory structures as templates and initialize new projects from them. A [GUI](https://garylritchie.gumroad.com/l/pt-gui) is also in development.
4
4
 
5
5
  ```mermaid
6
6
  graph LR
@@ -133,4 +133,4 @@ Always back up these directories before installing new versions or making signif
133
133
 
134
134
  ## Example Templates
135
135
 
136
- There are a couple of [example templates](https://github.com/search?q=topic%3Atemplate-project+org%3Agaryritchie&type=Repositories) for you to try. These include utilities written in python to help streamline common file management tasks.
136
+ [Example templates](https://github.com/search?q=topic%3Atemplate-project+org%3Agaryritchie&type=Repositories) are available. These include useful python scripts to help streamline common file management tasks.
@@ -21,6 +21,7 @@ export function defaultPostConfigCommand(options = {}) {
21
21
  console.error('You must provide --json <data> to set the default post-config array.');
22
22
  }
23
23
  else {
24
- console.log('Current default post-config tasks:', config.default_post_config || []);
24
+ const tasks = config.default_post_config || [];
25
+ console.log(JSON.stringify(tasks, null, 2));
25
26
  }
26
27
  }
@@ -153,25 +153,26 @@ export async function init(targetName, destPath, options = {}) {
153
153
  if (fs.existsSync(srcPath)) {
154
154
  if (options.dryRun) {
155
155
  console.log(chalk.gray(` [DRY RUN] Would copy ${file.src} → ${file.dest || file.src}`));
156
- const ext = path.extname(file.src);
157
- if (['.sh', '.py', '.bash', '.bat'].includes(ext)) {
158
- console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
159
- }
156
+ console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
160
157
  continue;
161
158
  }
162
- const fileContent = fs.readFileSync(srcPath, 'utf-8');
159
+ let fileContent = fs.readFileSync(srcPath, 'utf-8');
160
+ // Substitute variables in post_copy files if template has variables
161
+ if (template.variables && template.variables.length > 0) {
162
+ const { substituteVariables } = await import('../substitute.js');
163
+ fileContent = substituteVariables(fileContent, variables);
164
+ }
163
165
  const destDir = path.dirname(destPath);
164
166
  fs.mkdirSync(destDir, { recursive: true });
165
167
  fs.writeFileSync(destPath, fileContent);
166
- // Auto-chmod for executables
167
- const ext = path.extname(file.src);
168
- if (['.sh', '.py', '.bash', '.bat'].includes(ext)) {
169
- try {
170
- fs.chmodSync(destPath, 0o755);
171
- }
172
- catch (e) {
173
- // chmod not available (Windows)
174
- }
168
+ // post_copy files are executables by definition — always chmod
169
+ try {
170
+ // Check if source had execute permissions, otherwise default to 0o755
171
+ const srcStat = fs.statSync(srcPath);
172
+ fs.chmodSync(destPath, srcStat.mode & 0o111 ? srcStat.mode : 0o755);
173
+ }
174
+ catch (e) {
175
+ // chmod not available (Windows)
175
176
  }
176
177
  console.log(chalk.green(" ✓ " + (file.dest || file.src)));
177
178
  }
@@ -190,7 +191,38 @@ export async function init(targetName, destPath, options = {}) {
190
191
  }
191
192
  // Use template post_config tasks
192
193
  const allTasks = template.post_config?.filter(t => !t.type || t.type === typeName) || [];
193
- if (allTasks.length > 0) {
194
+ if (allTasks.length > 0 && !options.skipPostConfig) {
195
+ // SECURITY CHECK: Validate template safety before running post_config tasks
196
+ const { validateTemplateSecurity } = await import('../safety.js');
197
+ const { valid, errors, warnings } = validateTemplateSecurity(template);
198
+ if (!valid) {
199
+ console.error(chalk.red("\n❌ SECURITY ERROR: Aborting post_config execution due to blocked commands:"));
200
+ for (const err of errors) {
201
+ console.error(chalk.red(` - ${err}`));
202
+ }
203
+ process.exit(1);
204
+ }
205
+ if (warnings.length > 0) {
206
+ console.warn(chalk.yellow("\n⚠️ SECURITY WARNING: Post-config contains dangerous or suspicious commands:"));
207
+ for (const warn of warnings) {
208
+ console.warn(chalk.yellow(` - ${warn}`));
209
+ }
210
+ if (!options.yes) {
211
+ const { proceed } = await inquirer.prompt({
212
+ type: 'confirm',
213
+ name: 'proceed',
214
+ message: chalk.red('Are you sure you want to run these post-config tasks?'),
215
+ default: false
216
+ });
217
+ if (!proceed) {
218
+ console.log(chalk.yellow("Post-config tasks aborted by user."));
219
+ return;
220
+ }
221
+ }
222
+ else {
223
+ console.warn(chalk.yellow("Proceeding anyway (non-interactive mode with auto-confirm enabled)."));
224
+ }
225
+ }
194
226
  // Determine which tasks to include
195
227
  let selectedTaskNames = [];
196
228
  if (options.skipPostConfig) {
@@ -9,7 +9,7 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
9
9
  // Phase 1: Remote Check
10
10
  if (sourcePath.startsWith('http')) {
11
11
  console.log(chalk.cyan(`Downloading remote template from: ${sourcePath}...`));
12
- resolvedPath = await downloadAndExtract(sourcePath);
12
+ resolvedPath = await downloadAndExtract(sourcePath, options.json || false, options.allowUntrusted || false);
13
13
  }
14
14
  else {
15
15
  resolvedPath = path.resolve(sourcePath);
@@ -0,0 +1,12 @@
1
+ // pt-cli/src/commands/securityResponseCommand.ts
2
+ // CLI command to handle security responses from GUI
3
+ import { handleSecurityResponse } from '../safety.js';
4
+ export async function securityResponseCommand(response, options = { response: '' }) {
5
+ const result = await handleSecurityResponse(response);
6
+ if (result) {
7
+ console.log('SECURITY_RESPONSE:ALLOWED');
8
+ }
9
+ else {
10
+ console.log('SECURITY_RESPONSE:DENIED');
11
+ }
12
+ }
package/dist/config.js CHANGED
@@ -156,6 +156,23 @@ export function getDefaultPostConfig(config) {
156
156
  checked: t.checked !== false // default to true if not explicitly false
157
157
  }));
158
158
  }
159
+ /**
160
+ * Get security policy from config or use defaults
161
+ */
162
+ export function getSecurityPolicy(config) {
163
+ const defaultPolicy = {
164
+ maxExecutionTime: 30000, // 30 seconds
165
+ enableAuditLogging: true,
166
+ trustedSources: [
167
+ 'github.com/garyritchie',
168
+ 'gitea.lyonritchie.com/garyritchie',
169
+ 'github.com/lyonritchie',
170
+ ],
171
+ maxCommandsPerRun: 50,
172
+ securityLevel: 'warn',
173
+ };
174
+ return config.security || defaultPolicy;
175
+ }
159
176
  // Default exclusions for template scanning
160
177
  export const DEFAULT_EXCLUDES = [
161
178
  '.git',
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import chalk from 'chalk';
3
4
  // Command imports
4
5
  import { learn } from './commands/learnCommand.js';
5
6
  import { init } from './commands/initCommand.js';
@@ -9,6 +10,7 @@ import { variablesCommand } from './commands/variablesCommand.js';
9
10
  import { addCommand } from './commands/addCommand.js';
10
11
  import { removeCommand } from './commands/removeCommand.js';
11
12
  import { defaultPostConfigCommand } from './commands/defaultPostConfigCommand.js';
13
+ import { securityResponseCommand } from './commands/securityResponseCommand.js';
12
14
  import pkg from '../package.json' with { type: 'json' };
13
15
  const program = new Command();
14
16
  program
@@ -23,8 +25,23 @@ program
23
25
  .option('--name <name>', 'Template name (skip prompt)')
24
26
  .option('--desc <description>', 'Template description (skip prompt)')
25
27
  .option('--json', 'Output template structure as JSON for sharing instead of saving')
28
+ .option('--allow-untrusted', 'Bypass the trusted-source check for remote URLs (set by GUI after user confirmation)')
26
29
  .action(async (pathArg, options) => {
27
- await learn(pathArg || '.', null, options);
30
+ try {
31
+ await learn(pathArg || '.', null, options);
32
+ }
33
+ catch (err) {
34
+ if (options.json) {
35
+ console.log(JSON.stringify({
36
+ type: 'error',
37
+ message: err.message || String(err)
38
+ }));
39
+ }
40
+ else {
41
+ console.error(chalk.red(`Error: ${err.message || err}`));
42
+ }
43
+ process.exit(1);
44
+ }
28
45
  });
29
46
  program
30
47
  .command('update <templateName> [sourcePath]')
@@ -33,7 +50,13 @@ program
33
50
  .option('-y, --yes', 'Automatically confirm prompts')
34
51
  .option('--desc <description>', 'Template description (skip prompt)')
35
52
  .action(async (templateName, sourcePath, options) => {
36
- await learn(sourcePath || '.', templateName, options);
53
+ try {
54
+ await learn(sourcePath || '.', templateName, options);
55
+ }
56
+ catch (err) {
57
+ console.error(chalk.red(`Error: ${err.message || err}`));
58
+ process.exit(1);
59
+ }
37
60
  });
38
61
  program
39
62
  .command('init [templateName] [destPath]')
@@ -80,4 +103,10 @@ program
80
103
  .description('Remove a learned template from the config')
81
104
  .option('-y, --yes', 'Automatically confirm removal')
82
105
  .action(removeCommand);
106
+ program
107
+ .command('security-response <response>')
108
+ .description('Handle security response from GUI')
109
+ .action(async (response) => {
110
+ await securityResponseCommand(response);
111
+ });
83
112
  program.parse(process.argv);
@@ -1,12 +1,22 @@
1
- import { execSync } from 'child_process';
1
+ import path from 'path';
2
+ import os from 'os';
2
3
  import chalk from 'chalk';
3
4
  import inquirer from 'inquirer';
5
+ import { isBlockedCommand, isDangerousCommand, executeWithTimeout, logSecurityEvent, canExecute, showDangerousCommandWarning, getSecurityPolicy, } from './safety.js';
4
6
  /**
5
7
  * Runs post-configuration tasks for a project.
6
8
  */
7
9
  export async function runPostConfig(destPath, tasks, projectType, options = {}) {
8
10
  if (options.skipPostConfig)
9
11
  return;
12
+ // Load security policy
13
+ const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
14
+ const securityPolicy = getSecurityPolicy(configPath);
15
+ // Check rate limiting
16
+ if (!canExecute('init', securityPolicy.maxCommandsPerRun)) {
17
+ console.log(chalk.yellow('⚠️ Rate limit reached: max commands per run exceeded'));
18
+ return;
19
+ }
10
20
  // 1. Filter tasks by type
11
21
  const applicableTasks = tasks.filter(t => !t.type || t.type === projectType);
12
22
  if (applicableTasks.length === 0) {
@@ -37,20 +47,53 @@ export async function runPostConfig(destPath, tasks, projectType, options = {})
37
47
  const task = applicableTasks[i];
38
48
  const progress = `[${i + 1}/${applicableTasks.length}]`;
39
49
  if (task.command) {
50
+ // SECURITY CHECK 1: Blocklist check (NEVER allow these)
51
+ if (isBlockedCommand(task.command)) {
52
+ console.log(chalk.red(`${progress} ⚠️ BLOCKED: ${task.command}`));
53
+ logSecurityEvent('command_blocked', task.command, projectType, 'blocked');
54
+ continue;
55
+ }
56
+ // SECURITY CHECK 2: Dangerous command warning (but allow execution)
57
+ if (isDangerousCommand(task.command)) {
58
+ console.log(chalk.yellow(`${progress} ⚠️ WARNING: This command may be dangerous: ${task.command}`));
59
+ console.log(chalk.yellow(` Press CTRL+C to cancel, or wait 5s to continue...`));
60
+ // Wait for user to cancel or timeout
61
+ const allowContinue = await showDangerousCommandWarning(task.command, 5);
62
+ if (!allowContinue) {
63
+ console.log(chalk.yellow(`${progress} ⊘ Command cancelled by user`));
64
+ logSecurityEvent('command_blocked', task.command, projectType, 'blocked');
65
+ continue;
66
+ }
67
+ }
68
+ // SECURITY CHECK 3: Rate limiting
69
+ if (!canExecute(task.command, securityPolicy.maxCommandsPerRun)) {
70
+ console.log(chalk.red(`${progress} ⚠️ Rate limited: too many commands executed`));
71
+ continue;
72
+ }
40
73
  if (options.dryRun) {
41
74
  console.log(chalk.gray(` [DRY RUN] Would run: ${task.command}`));
42
75
  }
43
76
  else {
44
77
  try {
45
78
  console.log(chalk.yellow(`\n${progress} Running: ${task.command}`));
46
- execSync(task.command, {
47
- cwd: destPath,
48
- stdio: 'inherit'
49
- });
50
- console.log(chalk.green(' Command completed successfully'));
79
+ // SECURITY CHECK 4: Execution timeout
80
+ const result = await executeWithTimeout(task.command, destPath, securityPolicy.maxExecutionTime);
81
+ if (result.timedOut) {
82
+ console.log(chalk.red(` ✗ Command timed out after ${securityPolicy.maxExecutionTime / 1000}s`));
83
+ logSecurityEvent('command_timed_out', task.command, projectType, 'timedout');
84
+ }
85
+ else if (result.success) {
86
+ console.log(chalk.green(' ✓ Command completed successfully'));
87
+ logSecurityEvent('command_executed', task.command, projectType, 'success');
88
+ }
89
+ else {
90
+ console.log(chalk.red(` ✗ Command failed: ${result.stderr}`));
91
+ logSecurityEvent('command_executed', task.command, projectType, 'failed');
92
+ }
51
93
  }
52
94
  catch (err) {
53
95
  console.log(chalk.red(' ✗ Command failed'));
96
+ logSecurityEvent('command_executed', task.command, projectType, 'failed');
54
97
  }
55
98
  }
56
99
  }
package/dist/remote.js CHANGED
@@ -5,7 +5,47 @@ 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
- export async function downloadAndExtract(url) {
8
+ import chalk from 'chalk';
9
+ import { isTrustedSource, logSecurityEvent } from './safety.js';
10
+ import { loadConfig } from './config.js';
11
+ export async function downloadAndExtract(url, isJsonMode = false, allowUntrusted = false) {
12
+ // Load security policy
13
+ const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
14
+ const config = loadConfig();
15
+ const securityPolicy = config.security || {
16
+ trustedSources: ['github.com/garyritchie', 'gitea.lyonritchie.com/garyritchie', 'github.com/lyonritchie'],
17
+ };
18
+ // SECURITY CHECK: Verify source is trusted (skipped when --allow-untrusted is passed)
19
+ if (!allowUntrusted && !isTrustedSource(url, securityPolicy.trustedSources)) {
20
+ if (isJsonMode) {
21
+ // In JSON mode, output warning as JSON for GUI consumption.
22
+ // The GUI will show a confirmation dialog and, if the user says YES,
23
+ // re-run `pt learn <url> --json --yes --allow-untrusted`.
24
+ console.log(JSON.stringify({
25
+ type: 'security_warning',
26
+ url: url,
27
+ message: `Template from untrusted source: ${url}`,
28
+ warning: 'Only use templates from trusted sources.',
29
+ prompt: 'Continue anyway?',
30
+ default: false
31
+ }));
32
+ process.exit(1);
33
+ }
34
+ else {
35
+ console.log(chalk.yellow(`⚠️ Warning: Template from untrusted source: ${url}`));
36
+ console.log(chalk.yellow(' Only use templates from trusted sources'));
37
+ const inquirer = (await import('inquirer')).default;
38
+ const response = await inquirer.prompt({
39
+ type: 'confirm',
40
+ name: 'proceed',
41
+ message: chalk.red('Continue anyway?'),
42
+ default: false
43
+ });
44
+ if (!response.proceed) {
45
+ throw new Error('Download cancelled by user due to untrusted source');
46
+ }
47
+ }
48
+ }
9
49
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
10
50
  let downloadUrl = url;
11
51
  // Strip trailing slash and .git suffix before converting to archive URL
@@ -23,9 +63,17 @@ export async function downloadAndExtract(url) {
23
63
  const dest = path.join(tempDir, 'template.tar.gz');
24
64
  const fileStream = fs.createWriteStream(dest);
25
65
  await finished(Readable.fromWeb(response.body).pipe(fileStream));
66
+ // SECURITY: Validate downloaded file before extraction
67
+ const stats = fs.statSync(dest);
68
+ if (stats.size > 50 * 1024 * 1024) { // 50MB limit
69
+ throw new Error('Downloaded template is too large (>50MB)');
70
+ }
26
71
  // Extract tarball
27
72
  await extract({ file: dest, cwd: tempDir });
28
73
  // Find the actual content folder (archives usually wrap content in a folder)
29
74
  const dirs = fs.readdirSync(tempDir).filter(f => fs.statSync(path.join(tempDir, f)).isDirectory());
30
- return path.join(tempDir, dirs[0]);
75
+ const extractedPath = path.join(tempDir, dirs[0]);
76
+ // SECURITY: Log successful download
77
+ logSecurityEvent('template_loaded', downloadUrl, 'remote', 'success');
78
+ return extractedPath;
31
79
  }
package/dist/safety.js ADDED
@@ -0,0 +1,280 @@
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
+ // === BLOCKLIST ===
9
+ // Commands that are NEVER allowed (safety-critical operations)
10
+ const BLOCKED_COMMANDS = [
11
+ // Privilege escalation
12
+ 'sudo', 'su', 'su -', 'su root',
13
+ // Disk operations that could destroy data
14
+ 'dd', 'mkfs', 'fdisk', 'mount', 'umount',
15
+ // Shell injection patterns - removed from blocklist to allow command chaining.
16
+ // These are validated as warnings instead.
17
+ // Dangerous chmod
18
+ 'chmod 777', 'chmod -R 777', 'chmod 666',
19
+ // System commands that could kill processes
20
+ 'kill', 'killall', 'pkill', 'fuser',
21
+ // Network operations that could exfiltrate data
22
+ 'nc', 'netcat', 'socat',
23
+ // Package manager with dangerous flags
24
+ 'apt purge', 'apt remove', 'yum remove', 'brew uninstall',
25
+ ];
26
+ // === DANGEROUS PATTERNS ===
27
+ // Commands that should trigger a warning but are NOT blocked
28
+ const DANGEROUS_PATTERNS = [
29
+ // Remote downloads + execution
30
+ 'curl', 'wget', 'wget -O', 'curl |', 'wget |',
31
+ // Script execution
32
+ 'bash', 'sh', 'python', 'python3', 'node -e', 'node -p',
33
+ // Shell operations
34
+ 'eval', 'exec', 'source',
35
+ // File system manipulation
36
+ 'chmod -R', 'chown -R', 'chgrp -R',
37
+ // PowerShell (Windows)
38
+ 'powershell', 'pwsh', 'Invoke-Expression', 'IEX',
39
+ // macOS-specific
40
+ 'diskutil', 'hdiutil', 'csrutil',
41
+ ];
42
+ // Default security policy - warning focused
43
+ const DEFAULT_SECURITY_POLICY = {
44
+ maxExecutionTime: 30000, // 30 seconds
45
+ enableAuditLogging: true,
46
+ trustedSources: [
47
+ 'github.com/garyritchie',
48
+ 'git.lyonritchie.com',
49
+ 'github.com/lyonritchie',
50
+ ],
51
+ maxCommandsPerRun: 50,
52
+ securityLevel: 'warn', // Warning-focused mode
53
+ };
54
+ /**
55
+ * Check if a command is in the blocklist (NEVER allowed)
56
+ * Only truly dangerous operations that could destroy data
57
+ */
58
+ export function isBlockedCommand(command) {
59
+ for (const blocked of BLOCKED_COMMANDS) {
60
+ if (command.includes(blocked)) {
61
+ return true;
62
+ }
63
+ }
64
+ return false;
65
+ }
66
+ /**
67
+ * Check if a command should trigger a warning (but is allowed)
68
+ */
69
+ export function isDangerousCommand(command) {
70
+ // Check for destructive file operations targeting absolute paths
71
+ // Regex matches 'rm', 'rmdir', or Windows 'del' followed by optional flags and then an absolute path.
72
+ // Absolute path matches:
73
+ // - Unix: starting with '/' (e.g. /tmp, /usr)
74
+ // - Windows: starting with drive letter (e.g. C:\) or UNC path (\\) or drive-relative '\'
75
+ const parts = command.split(/\s+/);
76
+ const rmIndex = parts.findIndex(p => p === 'rm' || p === 'rmdir' || p === 'del');
77
+ if (rmIndex !== -1) {
78
+ // Check subsequent arguments
79
+ for (let i = rmIndex + 1; i < parts.length; i++) {
80
+ const arg = parts[i];
81
+ if (!arg)
82
+ continue;
83
+ // Skip flags (starting with - or /flag on Windows)
84
+ if (arg.startsWith('-') || (process.platform === 'win32' && arg.startsWith('/'))) {
85
+ continue;
86
+ }
87
+ // Check absolute path patterns
88
+ const isAbsolute = arg.startsWith('/') ||
89
+ (process.platform === 'win32' && (/^[a-zA-Z]:\\/.test(arg) || arg.startsWith('\\\\') || arg.startsWith('\\')));
90
+ if (isAbsolute) {
91
+ return true;
92
+ }
93
+ }
94
+ }
95
+ for (const pattern of DANGEROUS_PATTERNS) {
96
+ if (command.includes(pattern)) {
97
+ return true;
98
+ }
99
+ }
100
+ return false;
101
+ }
102
+ /**
103
+ * Execute a command with timeout and error handling
104
+ */
105
+ export async function executeWithTimeout(command, cwd, timeoutMs = 30000) {
106
+ const { execSync } = await import('child_process');
107
+ try {
108
+ const output = execSync(command, {
109
+ cwd,
110
+ stdio: 'pipe',
111
+ timeout: timeoutMs,
112
+ encoding: 'utf-8',
113
+ });
114
+ return { success: true, stdout: output };
115
+ }
116
+ catch (err) {
117
+ if (err.code === 'ETIMEDOUT') {
118
+ return { success: false, timedOut: true };
119
+ }
120
+ return {
121
+ success: false,
122
+ stderr: err.stderr || err.message,
123
+ };
124
+ }
125
+ }
126
+ /**
127
+ * Log a security event to audit log
128
+ */
129
+ export function logSecurityEvent(eventType, command, templateName, result) {
130
+ const logEntry = {
131
+ timestamp: new Date().toISOString(),
132
+ eventType,
133
+ command,
134
+ template: templateName,
135
+ user: os.userInfo().username,
136
+ result,
137
+ hostname: os.hostname(),
138
+ };
139
+ const logDir = path.join(os.homedir(), '.pt');
140
+ const logFile = path.join(logDir, 'security-audit.log');
141
+ try {
142
+ // Ensure log directory exists
143
+ if (!fs.existsSync(logDir)) {
144
+ fs.mkdirSync(logDir, { recursive: true });
145
+ }
146
+ // Append to audit log
147
+ fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n');
148
+ }
149
+ catch (err) {
150
+ // Silently fail if logging fails
151
+ console.warn('Warning: Failed to write security audit log');
152
+ }
153
+ }
154
+ /**
155
+ * Check if a URL is from a trusted source
156
+ */
157
+ export function isTrustedSource(url, trustedSources = DEFAULT_SECURITY_POLICY.trustedSources) {
158
+ return trustedSources.some(source => url.includes(source));
159
+ }
160
+ /**
161
+ * Rate limiting: track the last execution timestamp per command hash
162
+ */
163
+ const lastExecutionTimes = new Map();
164
+ const RATE_LIMIT_MS = 1000; // 1 second between identical commands
165
+ export function canExecute(command, maxCommandsPerRun = 50) {
166
+ // Check total commands executed this run
167
+ const totalExecuted = lastExecutionTimes.size;
168
+ if (totalExecuted >= maxCommandsPerRun) {
169
+ return false;
170
+ }
171
+ // Check rate limit for this specific command
172
+ const hash = crypto.createHash('md5').update(command).digest('hex');
173
+ const lastTime = lastExecutionTimes.get(hash) || 0;
174
+ if (Date.now() - lastTime < RATE_LIMIT_MS) {
175
+ return false;
176
+ }
177
+ // Record execution timestamp
178
+ lastExecutionTimes.set(hash, Date.now());
179
+ return true;
180
+ }
181
+ /**
182
+ * Reset execution timestamps (for testing or between runs)
183
+ */
184
+ export function resetExecutionCounts() {
185
+ lastExecutionTimes.clear();
186
+ }
187
+ /**
188
+ * Validate a template's security before execution
189
+ */
190
+ export function validateTemplateSecurity(templateConfig, securityPolicy = DEFAULT_SECURITY_POLICY) {
191
+ const errors = [];
192
+ const warnings = [];
193
+ // Check post_config tasks
194
+ const postConfigTasks = templateConfig.post_config || [];
195
+ for (const task of postConfigTasks) {
196
+ const command = task.command;
197
+ if (!command)
198
+ continue;
199
+ // Check for blocked commands
200
+ if (isBlockedCommand(command)) {
201
+ errors.push(`Blocked command in template: ${command}`);
202
+ }
203
+ // Warn about dangerous commands
204
+ if (isDangerousCommand(command)) {
205
+ warnings.push(`Dangerous command in template: ${command}`);
206
+ }
207
+ // Check for shell injection patterns
208
+ if (command.includes(';') || command.includes('|') || command.includes('&')) {
209
+ warnings.push(`Shell injection pattern in command: ${command}`);
210
+ }
211
+ // Check for remote downloads
212
+ if (command.includes('curl') || command.includes('wget')) {
213
+ warnings.push(`Remote download in command: ${command}`);
214
+ }
215
+ }
216
+ return {
217
+ valid: errors.length === 0,
218
+ errors,
219
+ warnings,
220
+ };
221
+ }
222
+ /**
223
+ * Get security policy from config or use defaults
224
+ */
225
+ export function getSecurityPolicy(configPath) {
226
+ // Try to load from config
227
+ if (configPath) {
228
+ try {
229
+ const YAML = require('yaml');
230
+ const config = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
231
+ if (config.security) {
232
+ return { ...DEFAULT_SECURITY_POLICY, ...config.security };
233
+ }
234
+ }
235
+ catch (err) {
236
+ // Fall back to defaults
237
+ }
238
+ }
239
+ return DEFAULT_SECURITY_POLICY;
240
+ }
241
+ /**
242
+ * Show warning about dangerous command and wait for user to cancel.
243
+ * Resolves true after the timeout (continue), or false immediately on CTRL+C (cancel).
244
+ */
245
+ export async function showDangerousCommandWarning(command, timeoutSeconds = 5) {
246
+ const readline = await import('readline');
247
+ console.log(chalk.red('\n⚠️ DANGEROUS COMMAND DETECTED'));
248
+ console.log(chalk.red(` Command: ${command}`));
249
+ console.log(chalk.red(' This could potentially harm your system.'));
250
+ console.log(chalk.yellow(` Press CTRL+C to cancel, or wait ${timeoutSeconds}s to continue...`));
251
+ const rl = readline.createInterface({
252
+ input: process.stdin,
253
+ output: process.stdout,
254
+ });
255
+ return new Promise((resolve) => {
256
+ const timer = setTimeout(() => {
257
+ rl.close();
258
+ resolve(true);
259
+ }, timeoutSeconds * 1000);
260
+ rl.on('SIGINT', () => {
261
+ clearTimeout(timer);
262
+ rl.close();
263
+ console.log(chalk.yellow('\n⚠️ Command cancelled by user'));
264
+ resolve(false);
265
+ });
266
+ });
267
+ }
268
+ // Security response handling for GUI integration
269
+ export async function handleSecurityResponse(response) {
270
+ // Normalize response
271
+ const normalized = response.trim().toLowerCase();
272
+ // Accept 'y' or 'yes' as positive response
273
+ if (normalized === 'y' || normalized === 'yes') {
274
+ console.log('Security response: ALLOWED');
275
+ return true;
276
+ }
277
+ // Reject any other response
278
+ console.log('Security response: DENIED');
279
+ return false;
280
+ }