@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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/postconfig.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|