@garyr/pt-cli 0.33.0 → 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.
@@ -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/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);
package/dist/remote.js CHANGED
@@ -8,26 +8,42 @@ import { extract } from 'tar'; // You'll need: npm install tar
8
8
  import chalk from 'chalk';
9
9
  import { isTrustedSource, logSecurityEvent } from './safety.js';
10
10
  import { loadConfig } from './config.js';
11
- export async function downloadAndExtract(url) {
11
+ export async function downloadAndExtract(url, isJsonMode = false, allowUntrusted = false) {
12
12
  // Load security policy
13
13
  const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
14
14
  const config = loadConfig();
15
15
  const securityPolicy = config.security || {
16
16
  trustedSources: ['github.com/garyritchie', 'gitea.lyonritchie.com/garyritchie', 'github.com/lyonritchie'],
17
17
  };
18
- // SECURITY CHECK: Verify source is trusted
19
- if (!isTrustedSource(url, securityPolicy.trustedSources)) {
20
- console.log(chalk.yellow(`⚠️ Warning: Template from untrusted source: ${url}`));
21
- console.log(chalk.yellow(' Only use templates from trusted sources'));
22
- const inquirer = (await import('inquirer')).default;
23
- const response = await inquirer.prompt({
24
- type: 'confirm',
25
- name: 'proceed',
26
- message: chalk.red('Continue anyway?'),
27
- default: false
28
- });
29
- if (!response.proceed) {
30
- throw new Error('Download cancelled by user due to untrusted source');
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
+ }
31
47
  }
32
48
  }
33
49
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
package/dist/safety.js CHANGED
@@ -12,8 +12,8 @@ const BLOCKED_COMMANDS = [
12
12
  'sudo', 'su', 'su -', 'su root',
13
13
  // Disk operations that could destroy data
14
14
  'dd', 'mkfs', 'fdisk', 'mount', 'umount',
15
- // Shell injection patterns
16
- ';', '|', '&', '&&', '||',
15
+ // Shell injection patterns - removed from blocklist to allow command chaining.
16
+ // These are validated as warnings instead.
17
17
  // Dangerous chmod
18
18
  'chmod 777', 'chmod -R 777', 'chmod 666',
19
19
  // System commands that could kill processes
@@ -26,8 +26,6 @@ const BLOCKED_COMMANDS = [
26
26
  // === DANGEROUS PATTERNS ===
27
27
  // Commands that should trigger a warning but are NOT blocked
28
28
  const DANGEROUS_PATTERNS = [
29
- // Destructive file operations
30
- 'rm -rf', 'rm -r', 'rm --no-preserve-root', 'rm -rf /',
31
29
  // Remote downloads + execution
32
30
  'curl', 'wget', 'wget -O', 'curl |', 'wget |',
33
31
  // Script execution
@@ -69,6 +67,31 @@ export function isBlockedCommand(command) {
69
67
  * Check if a command should trigger a warning (but is allowed)
70
68
  */
71
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
+ }
72
95
  for (const pattern of DANGEROUS_PATTERNS) {
73
96
  if (command.includes(pattern)) {
74
97
  return true;
@@ -135,31 +158,31 @@ export function isTrustedSource(url, trustedSources = DEFAULT_SECURITY_POLICY.tr
135
158
  return trustedSources.some(source => url.includes(source));
136
159
  }
137
160
  /**
138
- * Check if a command has been executed too many times (rate limiting)
161
+ * Rate limiting: track the last execution timestamp per command hash
139
162
  */
140
- const executionCounts = new Map();
163
+ const lastExecutionTimes = new Map();
141
164
  const RATE_LIMIT_MS = 1000; // 1 second between identical commands
142
165
  export function canExecute(command, maxCommandsPerRun = 50) {
143
- // Check total count per run
144
- const totalExecuted = Array.from(executionCounts.values()).reduce((sum, count) => sum + count, 0);
166
+ // Check total commands executed this run
167
+ const totalExecuted = lastExecutionTimes.size;
145
168
  if (totalExecuted >= maxCommandsPerRun) {
146
169
  return false;
147
170
  }
148
171
  // Check rate limit for this specific command
149
172
  const hash = crypto.createHash('md5').update(command).digest('hex');
150
- const last = executionCounts.get(hash) || 0;
151
- if (Date.now() - last < RATE_LIMIT_MS) {
173
+ const lastTime = lastExecutionTimes.get(hash) || 0;
174
+ if (Date.now() - lastTime < RATE_LIMIT_MS) {
152
175
  return false;
153
176
  }
154
- // Update count
155
- executionCounts.set(hash, Date.now());
177
+ // Record execution timestamp
178
+ lastExecutionTimes.set(hash, Date.now());
156
179
  return true;
157
180
  }
158
181
  /**
159
- * Reset execution counts (for testing or between runs)
182
+ * Reset execution timestamps (for testing or between runs)
160
183
  */
161
184
  export function resetExecutionCounts() {
162
- executionCounts.clear();
185
+ lastExecutionTimes.clear();
163
186
  }
164
187
  /**
165
188
  * Validate a template's security before execution
@@ -216,35 +239,42 @@ export function getSecurityPolicy(configPath) {
216
239
  return DEFAULT_SECURITY_POLICY;
217
240
  }
218
241
  /**
219
- * Show warning about dangerous command and wait for user to cancel
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).
220
244
  */
221
245
  export async function showDangerousCommandWarning(command, timeoutSeconds = 5) {
222
- const inquirer = (await import('inquirer')).default;
223
246
  const readline = await import('readline');
224
247
  console.log(chalk.red('\n⚠️ DANGEROUS COMMAND DETECTED'));
225
248
  console.log(chalk.red(` Command: ${command}`));
226
249
  console.log(chalk.red(' This could potentially harm your system.'));
227
250
  console.log(chalk.yellow(` Press CTRL+C to cancel, or wait ${timeoutSeconds}s to continue...`));
228
- // Set up timeout
229
- const timeout = setTimeout(() => {
230
- return true; // Continue after timeout
231
- }, timeoutSeconds * 1000);
232
- // Set up readline for immediate cancel
233
251
  const rl = readline.createInterface({
234
252
  input: process.stdin,
235
253
  output: process.stdout,
236
254
  });
237
255
  return new Promise((resolve) => {
256
+ const timer = setTimeout(() => {
257
+ rl.close();
258
+ resolve(true);
259
+ }, timeoutSeconds * 1000);
238
260
  rl.on('SIGINT', () => {
239
- clearTimeout(timeout);
261
+ clearTimeout(timer);
240
262
  rl.close();
241
263
  console.log(chalk.yellow('\n⚠️ Command cancelled by user'));
242
264
  resolve(false);
243
265
  });
244
- // If timeout expires, resolve without waiting for readline
245
- setTimeout(() => {
246
- rl.close();
247
- resolve(true);
248
- }, timeoutSeconds * 1000);
249
266
  });
250
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
+ }
@@ -30,7 +30,11 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
30
30
  console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
31
31
  }
32
32
  else {
33
- copyDirRecursive(srcPath, destPath, variables, copyFile.substitute_variables || false, copyFile.chmod);
33
+ const dirSubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
34
+ template.variables &&
35
+ template.variables.length > 0 &&
36
+ Object.keys(variables).length > 0));
37
+ copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
34
38
  }
35
39
  console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
36
40
  }
@@ -38,7 +42,11 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
38
42
  // Single file copy
39
43
  if (dryRun) {
40
44
  console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
41
- if (copyFile.substitute_variables) {
45
+ const drySubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
46
+ template.variables &&
47
+ template.variables.length > 0 &&
48
+ Object.keys(variables).length > 0));
49
+ if (drySubstitute) {
42
50
  console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
43
51
  }
44
52
  if (copyFile.chmod) {
@@ -49,7 +57,13 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
49
57
  // Ensure destination directory exists
50
58
  fs.mkdirSync(path.dirname(destPath), { recursive: true });
51
59
  let content = fs.readFileSync(srcPath, 'utf-8');
52
- if (copyFile.substitute_variables) {
60
+ // Default to substituting if substitute_variables is true, OR if it's undefined AND the template defines variables.
61
+ // If substitute_variables is explicitly false, do not substitute.
62
+ const shouldSubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
63
+ template.variables &&
64
+ template.variables.length > 0 &&
65
+ Object.keys(variables).length > 0));
66
+ if (shouldSubstitute) {
53
67
  content = substituteVariables(content, variables);
54
68
  }
55
69
  fs.writeFileSync(destPath, content);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@garyr/pt-cli",
3
- "version": "0.33.0",
3
+ "version": "0.36.4",
4
4
  "description": "Project Template CLI - Learn structures and initialize projects",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -43,4 +43,4 @@
43
43
  "tsx": "^4.21.0",
44
44
  "typescript": "^5.6.0"
45
45
  }
46
- }
46
+ }
@@ -27,6 +27,7 @@ export function defaultPostConfigCommand(options: DefaultPostConfigOptions = {})
27
27
 
28
28
  console.error('You must provide --json <data> to set the default post-config array.');
29
29
  } else {
30
- console.log('Current default post-config tasks:', config.default_post_config || []);
30
+ const tasks = config.default_post_config || [];
31
+ console.log(JSON.stringify(tasks, null, 2));
31
32
  }
32
33
  }
@@ -172,26 +172,29 @@ export async function init(targetName: string | undefined, destPath: string | un
172
172
  if (fs.existsSync(srcPath)) {
173
173
  if (options.dryRun) {
174
174
  console.log(chalk.gray(` [DRY RUN] Would copy ${file.src} → ${file.dest || file.src}`));
175
- const ext = path.extname(file.src);
176
- if (['.sh', '.py', '.bash', '.bat'].includes(ext)) {
177
- console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
178
- }
175
+ console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
179
176
  continue;
180
177
  }
181
178
 
182
- const fileContent = fs.readFileSync(srcPath, 'utf-8');
179
+ let fileContent = fs.readFileSync(srcPath, 'utf-8');
180
+
181
+ // Substitute variables in post_copy files if template has variables
182
+ if (template.variables && template.variables.length > 0) {
183
+ const { substituteVariables } = await import('../substitute.js');
184
+ fileContent = substituteVariables(fileContent, variables);
185
+ }
186
+
183
187
  const destDir = path.dirname(destPath);
184
188
  fs.mkdirSync(destDir, { recursive: true });
185
189
  fs.writeFileSync(destPath, fileContent);
186
190
 
187
- // Auto-chmod for executables
188
- const ext = path.extname(file.src);
189
- if (['.sh', '.py', '.bash', '.bat'].includes(ext)) {
190
- try {
191
- fs.chmodSync(destPath, 0o755);
192
- } catch (e) {
193
- // chmod not available (Windows)
194
- }
191
+ // post_copy files are executables by definition — always chmod
192
+ try {
193
+ // Check if source had execute permissions, otherwise default to 0o755
194
+ const srcStat = fs.statSync(srcPath);
195
+ fs.chmodSync(destPath, srcStat.mode & 0o111 ? srcStat.mode : 0o755);
196
+ } catch (e) {
197
+ // chmod not available (Windows)
195
198
  }
196
199
  console.log(chalk.green(" ✓ " + (file.dest || file.src)));
197
200
  } else {
@@ -210,7 +213,40 @@ export async function init(targetName: string | undefined, destPath: string | un
210
213
  // Use template post_config tasks
211
214
  const allTasks = template.post_config?.filter(t => !t.type || t.type === typeName!) || [];
212
215
 
213
- if (allTasks.length > 0) {
216
+ if (allTasks.length > 0 && !options.skipPostConfig) {
217
+ // SECURITY CHECK: Validate template safety before running post_config tasks
218
+ const { validateTemplateSecurity } = await import('../safety.js');
219
+ const { valid, errors, warnings } = validateTemplateSecurity(template);
220
+
221
+ if (!valid) {
222
+ console.error(chalk.red("\n❌ SECURITY ERROR: Aborting post_config execution due to blocked commands:"));
223
+ for (const err of errors) {
224
+ console.error(chalk.red(` - ${err}`));
225
+ }
226
+ process.exit(1);
227
+ }
228
+
229
+ if (warnings.length > 0) {
230
+ console.warn(chalk.yellow("\n⚠️ SECURITY WARNING: Post-config contains dangerous or suspicious commands:"));
231
+ for (const warn of warnings) {
232
+ console.warn(chalk.yellow(` - ${warn}`));
233
+ }
234
+
235
+ if (!options.yes) {
236
+ const { proceed } = await inquirer.prompt({
237
+ type: 'confirm',
238
+ name: 'proceed',
239
+ message: chalk.red('Are you sure you want to run these post-config tasks?'),
240
+ default: false
241
+ });
242
+ if (!proceed) {
243
+ console.log(chalk.yellow("Post-config tasks aborted by user."));
244
+ return;
245
+ }
246
+ } else {
247
+ console.warn(chalk.yellow("Proceeding anyway (non-interactive mode with auto-confirm enabled)."));
248
+ }
249
+ }
214
250
  // Determine which tasks to include
215
251
  let selectedTaskNames: string[] = [];
216
252
 
@@ -11,6 +11,7 @@ export interface LearnOptions {
11
11
  name?: string;
12
12
  desc?: string;
13
13
  json?: boolean;
14
+ allowUntrusted?: boolean;
14
15
  }
15
16
 
16
17
 
@@ -20,7 +21,7 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
20
21
  // Phase 1: Remote Check
21
22
  if (sourcePath.startsWith('http')) {
22
23
  console.log(chalk.cyan(`Downloading remote template from: ${sourcePath}...`));
23
- resolvedPath = await downloadAndExtract(sourcePath);
24
+ resolvedPath = await downloadAndExtract(sourcePath, options.json || false, options.allowUntrusted || false);
24
25
  } else {
25
26
  resolvedPath = path.resolve(sourcePath);
26
27
  }
@@ -0,0 +1,18 @@
1
+ // pt-cli/src/commands/securityResponseCommand.ts
2
+ // CLI command to handle security responses from GUI
3
+
4
+ import { handleSecurityResponse } from '../safety.js';
5
+
6
+ export interface SecurityResponseOptions {
7
+ response: string;
8
+ }
9
+
10
+ export async function securityResponseCommand(response: string, options: SecurityResponseOptions = { response: '' }): Promise<void> {
11
+ const result = await handleSecurityResponse(response);
12
+
13
+ if (result) {
14
+ console.log('SECURITY_RESPONSE:ALLOWED');
15
+ } else {
16
+ console.log('SECURITY_RESPONSE:DENIED');
17
+ }
18
+ }
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
- await learn(pathArg || '.', null, options);
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
- await learn(sourcePath || '.', templateName, options);
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/remote.ts CHANGED
@@ -9,7 +9,7 @@ import chalk from 'chalk';
9
9
  import { isTrustedSource, logSecurityEvent, getSecurityPolicy } from './safety.js';
10
10
  import { loadConfig } from './config.js';
11
11
 
12
- export async function downloadAndExtract(url: string): Promise<string> {
12
+ export async function downloadAndExtract(url: string, isJsonMode: boolean = false, allowUntrusted: boolean = false): Promise<string> {
13
13
  // Load security policy
14
14
  const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
15
15
  const config = loadConfig();
@@ -17,21 +17,36 @@ export async function downloadAndExtract(url: string): Promise<string> {
17
17
  trustedSources: ['github.com/garyritchie', 'gitea.lyonritchie.com/garyritchie', 'github.com/lyonritchie'],
18
18
  };
19
19
 
20
- // SECURITY CHECK: Verify source is trusted
21
- if (!isTrustedSource(url, securityPolicy.trustedSources)) {
22
- console.log(chalk.yellow(`⚠️ Warning: Template from untrusted source: ${url}`));
23
- console.log(chalk.yellow(' Only use templates from trusted sources'));
24
-
25
- const inquirer = (await import('inquirer')).default;
26
- const response = await inquirer.prompt({
27
- type: 'confirm',
28
- name: 'proceed',
29
- message: chalk.red('Continue anyway?'),
30
- default: false
31
- });
32
-
33
- if (!response.proceed) {
34
- throw new Error('Download cancelled by user due to untrusted source');
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
+ }
35
50
  }
36
51
  }
37
52
 
package/src/safety.ts CHANGED
@@ -13,8 +13,8 @@ const BLOCKED_COMMANDS = [
13
13
  'sudo', 'su', 'su -', 'su root',
14
14
  // Disk operations that could destroy data
15
15
  'dd', 'mkfs', 'fdisk', 'mount', 'umount',
16
- // Shell injection patterns
17
- ';', '|', '&', '&&', '||',
16
+ // Shell injection patterns - removed from blocklist to allow command chaining.
17
+ // These are validated as warnings instead.
18
18
  // Dangerous chmod
19
19
  'chmod 777', 'chmod -R 777', 'chmod 666',
20
20
  // System commands that could kill processes
@@ -28,8 +28,6 @@ const BLOCKED_COMMANDS = [
28
28
  // === DANGEROUS PATTERNS ===
29
29
  // Commands that should trigger a warning but are NOT blocked
30
30
  const DANGEROUS_PATTERNS = [
31
- // Destructive file operations
32
- 'rm -rf', 'rm -r', 'rm --no-preserve-root', 'rm -rf /',
33
31
  // Remote downloads + execution
34
32
  'curl', 'wget', 'wget -O', 'curl |', 'wget |',
35
33
  // Script execution
@@ -83,6 +81,31 @@ export function isBlockedCommand(command: string): boolean {
83
81
  * Check if a command should trigger a warning (but is allowed)
84
82
  */
85
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
+
86
109
  for (const pattern of DANGEROUS_PATTERNS) {
87
110
  if (command.includes(pattern)) {
88
111
  return true;
@@ -164,35 +187,35 @@ export function isTrustedSource(url: string, trustedSources: string[] = DEFAULT_
164
187
  }
165
188
 
166
189
  /**
167
- * Check if a command has been executed too many times (rate limiting)
190
+ * Rate limiting: track the last execution timestamp per command hash
168
191
  */
169
- const executionCounts = new Map<string, number>();
192
+ const lastExecutionTimes = new Map<string, number>();
170
193
  const RATE_LIMIT_MS = 1000; // 1 second between identical commands
171
194
 
172
195
  export function canExecute(command: string, maxCommandsPerRun: number = 50): boolean {
173
- // Check total count per run
174
- const totalExecuted = Array.from(executionCounts.values()).reduce((sum, count) => sum + count, 0);
196
+ // Check total commands executed this run
197
+ const totalExecuted = lastExecutionTimes.size;
175
198
  if (totalExecuted >= maxCommandsPerRun) {
176
199
  return false;
177
200
  }
178
201
 
179
202
  // Check rate limit for this specific command
180
203
  const hash = crypto.createHash('md5').update(command).digest('hex');
181
- const last = executionCounts.get(hash) || 0;
182
- if (Date.now() - last < RATE_LIMIT_MS) {
204
+ const lastTime = lastExecutionTimes.get(hash) || 0;
205
+ if (Date.now() - lastTime < RATE_LIMIT_MS) {
183
206
  return false;
184
207
  }
185
208
 
186
- // Update count
187
- executionCounts.set(hash, Date.now());
209
+ // Record execution timestamp
210
+ lastExecutionTimes.set(hash, Date.now());
188
211
  return true;
189
212
  }
190
213
 
191
214
  /**
192
- * Reset execution counts (for testing or between runs)
215
+ * Reset execution timestamps (for testing or between runs)
193
216
  */
194
217
  export function resetExecutionCounts(): void {
195
- executionCounts.clear();
218
+ lastExecutionTimes.clear();
196
219
  }
197
220
 
198
221
  /**
@@ -260,10 +283,10 @@ export function getSecurityPolicy(configPath?: string): SecurityPolicy {
260
283
  }
261
284
 
262
285
  /**
263
- * Show warning about dangerous command and wait for user to cancel
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).
264
288
  */
265
289
  export async function showDangerousCommandWarning(command: string, timeoutSeconds: number = 5): Promise<boolean> {
266
- const inquirer = (await import('inquirer')).default;
267
290
  const readline = await import('readline');
268
291
 
269
292
  console.log(chalk.red('\n⚠️ DANGEROUS COMMAND DETECTED'));
@@ -271,29 +294,38 @@ export async function showDangerousCommandWarning(command: string, timeoutSecond
271
294
  console.log(chalk.red(' This could potentially harm your system.'));
272
295
  console.log(chalk.yellow(` Press CTRL+C to cancel, or wait ${timeoutSeconds}s to continue...`));
273
296
 
274
- // Set up timeout
275
- const timeout = setTimeout(() => {
276
- return true; // Continue after timeout
277
- }, timeoutSeconds * 1000);
278
-
279
- // Set up readline for immediate cancel
280
297
  const rl = readline.createInterface({
281
298
  input: process.stdin,
282
299
  output: process.stdout,
283
300
  });
284
301
 
285
302
  return new Promise((resolve) => {
303
+ const timer = setTimeout(() => {
304
+ rl.close();
305
+ resolve(true);
306
+ }, timeoutSeconds * 1000);
307
+
286
308
  rl.on('SIGINT', () => {
287
- clearTimeout(timeout);
309
+ clearTimeout(timer);
288
310
  rl.close();
289
311
  console.log(chalk.yellow('\n⚠️ Command cancelled by user'));
290
312
  resolve(false);
291
313
  });
292
-
293
- // If timeout expires, resolve without waiting for readline
294
- setTimeout(() => {
295
- rl.close();
296
- resolve(true);
297
- }, timeoutSeconds * 1000);
298
314
  });
299
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
- copyDirRecursive(srcPath, destPath, variables, copyFile.substitute_variables || false, copyFile.chmod);
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
- if (copyFile.substitute_variables) {
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 (copyFile.substitute_variables) {
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