@garyr/pt-cli 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/dist/commands/configCommand.js +2 -2
- package/dist/config.js +33 -12
- package/dist/postconfig.js +49 -6
- package/dist/remote.js +33 -1
- package/dist/safety.js +250 -0
- 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 +4 -4
- package/skills/agency-pt-operator/SKILL.md +104 -15
- package/src/commands/configCommand.ts +2 -2
- package/src/config.ts +38 -12
- package/src/postconfig.ts +66 -5
- package/src/remote.ts +40 -1
- package/src/safety.ts +299 -0
- package/tests/config-u.ts +4 -0
- package/tests/config-utils.test.ts +11 -11
- package/tests/config.test.ts +111 -96
- package/tests/init.test.ts +1 -1
- package/tests/learn.test.ts +3 -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
|
|
@@ -119,12 +119,18 @@ pt init ./new-project --file my-template.json --yes
|
|
|
119
119
|
An official agent skill is included in this repository: [`skills/agency-pt-operator/SKILL.md`](skills/agency-pt-operator/SKILL.md).
|
|
120
120
|
|
|
121
121
|
Equipping your agent with this skill allows it to automatically use `pt-cli` to lay down standardized boilerplate and capture new architectures you develop together.
|
|
122
|
+
|
|
122
123
|
## Warning
|
|
123
124
|
|
|
124
125
|
**⚠️ Beta Software Warning**: This project is in beta stage. While stable for most use cases, please make regular backups of your template configurations before running updates or major changes.
|
|
125
126
|
|
|
126
127
|
**💾 Backup Paths**:
|
|
128
|
+
|
|
127
129
|
- **Linux/macOS**: `~/.pt/` or `/home/username/.pt/` and `/Users/username/.pt/`
|
|
128
130
|
- **Windows**: `%USERPROFILE%\.pt\` (typically `C:\Users\Username\.pt\`)
|
|
129
131
|
|
|
130
|
-
Always back up these directories before installing new versions or making significant changes.
|
|
132
|
+
Always back up these directories before installing new versions or making significant changes.
|
|
133
|
+
|
|
134
|
+
## Example Templates
|
|
135
|
+
|
|
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.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { loadConfig, getTemplateNames,
|
|
2
|
+
import { loadConfig, getTemplateNames, getConfigPath } from '../config.js';
|
|
3
3
|
export function configCommand(templateName, options = {}) {
|
|
4
4
|
const config = loadConfig();
|
|
5
5
|
if (options.json) {
|
|
@@ -22,7 +22,7 @@ export function configCommand(templateName, options = {}) {
|
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
24
|
const names = getTemplateNames(config);
|
|
25
|
-
console.log(chalk.cyan('Config Location:'),
|
|
25
|
+
console.log(chalk.cyan('Config Location:'), getConfigPath());
|
|
26
26
|
console.log(chalk.cyan('\nLearned Templates:'));
|
|
27
27
|
if (names.length === 0) {
|
|
28
28
|
console.log(chalk.gray(' (none)'));
|
package/dist/config.js
CHANGED
|
@@ -3,16 +3,20 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
|
-
export
|
|
7
|
-
|
|
6
|
+
export function getHomeDir() {
|
|
7
|
+
return path.join(os.homedir(), '.pt');
|
|
8
|
+
}
|
|
9
|
+
export function getConfigPath() {
|
|
10
|
+
return path.join(getHomeDir(), 'config.yaml');
|
|
11
|
+
}
|
|
8
12
|
export function ensureConfigDir() {
|
|
9
|
-
if (!fs.existsSync(
|
|
10
|
-
fs.mkdirSync(
|
|
13
|
+
if (!fs.existsSync(getHomeDir())) {
|
|
14
|
+
fs.mkdirSync(getHomeDir(), { recursive: true });
|
|
11
15
|
}
|
|
12
16
|
}
|
|
13
17
|
export function loadConfig() {
|
|
14
18
|
ensureConfigDir();
|
|
15
|
-
if (!fs.existsSync(
|
|
19
|
+
if (!fs.existsSync(getConfigPath())) {
|
|
16
20
|
const defaultConfig = {
|
|
17
21
|
version: '3.0',
|
|
18
22
|
templates: {},
|
|
@@ -25,7 +29,7 @@ export function loadConfig() {
|
|
|
25
29
|
return defaultConfig;
|
|
26
30
|
}
|
|
27
31
|
try {
|
|
28
|
-
const content = fs.readFileSync(
|
|
32
|
+
const content = fs.readFileSync(getConfigPath(), 'utf-8');
|
|
29
33
|
if (!content.trim()) {
|
|
30
34
|
throw new Error("Config file is empty");
|
|
31
35
|
}
|
|
@@ -81,7 +85,7 @@ export function loadConfig() {
|
|
|
81
85
|
const error = err;
|
|
82
86
|
console.error(chalk.red(`\nError loading config: ${error.message}`));
|
|
83
87
|
// If we have a backup, maybe suggest using it
|
|
84
|
-
const backupPath =
|
|
88
|
+
const backupPath = getConfigPath() + '.bak';
|
|
85
89
|
if (fs.existsSync(backupPath)) {
|
|
86
90
|
console.error(chalk.yellow(`A backup exists at ${backupPath}. You may want to restore it.`));
|
|
87
91
|
}
|
|
@@ -113,17 +117,17 @@ export function saveConfig(config) {
|
|
|
113
117
|
}
|
|
114
118
|
}
|
|
115
119
|
const content = YAML.stringify(config);
|
|
116
|
-
const tempPath =
|
|
117
|
-
const backupPath =
|
|
120
|
+
const tempPath = getConfigPath() + '.tmp';
|
|
121
|
+
const backupPath = getConfigPath() + '.bak';
|
|
118
122
|
try {
|
|
119
123
|
// 1. Create a backup of the current valid config if it exists
|
|
120
|
-
if (fs.existsSync(
|
|
121
|
-
fs.copyFileSync(
|
|
124
|
+
if (fs.existsSync(getConfigPath())) {
|
|
125
|
+
fs.copyFileSync(getConfigPath(), backupPath);
|
|
122
126
|
}
|
|
123
127
|
// 2. Write to a temporary file first (atomic save)
|
|
124
128
|
fs.writeFileSync(tempPath, content);
|
|
125
129
|
// 3. Rename temp file to actual config path
|
|
126
|
-
fs.renameSync(tempPath,
|
|
130
|
+
fs.renameSync(tempPath, getConfigPath());
|
|
127
131
|
}
|
|
128
132
|
catch (err) {
|
|
129
133
|
const error = err;
|
|
@@ -152,6 +156,23 @@ export function getDefaultPostConfig(config) {
|
|
|
152
156
|
checked: t.checked !== false // default to true if not explicitly false
|
|
153
157
|
}));
|
|
154
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
|
+
}
|
|
155
176
|
// Default exclusions for template scanning
|
|
156
177
|
export const DEFAULT_EXCLUDES = [
|
|
157
178
|
'.git',
|
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,31 @@ import os from 'os';
|
|
|
5
5
|
import { Readable } from 'stream';
|
|
6
6
|
import { finished } from 'stream/promises';
|
|
7
7
|
import { extract } from 'tar'; // You'll need: npm install tar
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { isTrustedSource, logSecurityEvent } from './safety.js';
|
|
10
|
+
import { loadConfig } from './config.js';
|
|
8
11
|
export async function downloadAndExtract(url) {
|
|
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
|
|
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');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
9
33
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
|
|
10
34
|
let downloadUrl = url;
|
|
11
35
|
// Strip trailing slash and .git suffix before converting to archive URL
|
|
@@ -23,9 +47,17 @@ export async function downloadAndExtract(url) {
|
|
|
23
47
|
const dest = path.join(tempDir, 'template.tar.gz');
|
|
24
48
|
const fileStream = fs.createWriteStream(dest);
|
|
25
49
|
await finished(Readable.fromWeb(response.body).pipe(fileStream));
|
|
50
|
+
// SECURITY: Validate downloaded file before extraction
|
|
51
|
+
const stats = fs.statSync(dest);
|
|
52
|
+
if (stats.size > 50 * 1024 * 1024) { // 50MB limit
|
|
53
|
+
throw new Error('Downloaded template is too large (>50MB)');
|
|
54
|
+
}
|
|
26
55
|
// Extract tarball
|
|
27
56
|
await extract({ file: dest, cwd: tempDir });
|
|
28
57
|
// Find the actual content folder (archives usually wrap content in a folder)
|
|
29
58
|
const dirs = fs.readdirSync(tempDir).filter(f => fs.statSync(path.join(tempDir, f)).isDirectory());
|
|
30
|
-
|
|
59
|
+
const extractedPath = path.join(tempDir, dirs[0]);
|
|
60
|
+
// SECURITY: Log successful download
|
|
61
|
+
logSecurityEvent('template_loaded', downloadUrl, 'remote', 'success');
|
|
62
|
+
return extractedPath;
|
|
31
63
|
}
|
package/dist/safety.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
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
|
|
16
|
+
';', '|', '&', '&&', '||',
|
|
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
|
+
// Destructive file operations
|
|
30
|
+
'rm -rf', 'rm -r', 'rm --no-preserve-root', 'rm -rf /',
|
|
31
|
+
// Remote downloads + execution
|
|
32
|
+
'curl', 'wget', 'wget -O', 'curl |', 'wget |',
|
|
33
|
+
// Script execution
|
|
34
|
+
'bash', 'sh', 'python', 'python3', 'node -e', 'node -p',
|
|
35
|
+
// Shell operations
|
|
36
|
+
'eval', 'exec', 'source',
|
|
37
|
+
// File system manipulation
|
|
38
|
+
'chmod -R', 'chown -R', 'chgrp -R',
|
|
39
|
+
// PowerShell (Windows)
|
|
40
|
+
'powershell', 'pwsh', 'Invoke-Expression', 'IEX',
|
|
41
|
+
// macOS-specific
|
|
42
|
+
'diskutil', 'hdiutil', 'csrutil',
|
|
43
|
+
];
|
|
44
|
+
// Default security policy - warning focused
|
|
45
|
+
const DEFAULT_SECURITY_POLICY = {
|
|
46
|
+
maxExecutionTime: 30000, // 30 seconds
|
|
47
|
+
enableAuditLogging: true,
|
|
48
|
+
trustedSources: [
|
|
49
|
+
'github.com/garyritchie',
|
|
50
|
+
'git.lyonritchie.com',
|
|
51
|
+
'github.com/lyonritchie',
|
|
52
|
+
],
|
|
53
|
+
maxCommandsPerRun: 50,
|
|
54
|
+
securityLevel: 'warn', // Warning-focused mode
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Check if a command is in the blocklist (NEVER allowed)
|
|
58
|
+
* Only truly dangerous operations that could destroy data
|
|
59
|
+
*/
|
|
60
|
+
export function isBlockedCommand(command) {
|
|
61
|
+
for (const blocked of BLOCKED_COMMANDS) {
|
|
62
|
+
if (command.includes(blocked)) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Check if a command should trigger a warning (but is allowed)
|
|
70
|
+
*/
|
|
71
|
+
export function isDangerousCommand(command) {
|
|
72
|
+
for (const pattern of DANGEROUS_PATTERNS) {
|
|
73
|
+
if (command.includes(pattern)) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Execute a command with timeout and error handling
|
|
81
|
+
*/
|
|
82
|
+
export async function executeWithTimeout(command, cwd, timeoutMs = 30000) {
|
|
83
|
+
const { execSync } = await import('child_process');
|
|
84
|
+
try {
|
|
85
|
+
const output = execSync(command, {
|
|
86
|
+
cwd,
|
|
87
|
+
stdio: 'pipe',
|
|
88
|
+
timeout: timeoutMs,
|
|
89
|
+
encoding: 'utf-8',
|
|
90
|
+
});
|
|
91
|
+
return { success: true, stdout: output };
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (err.code === 'ETIMEDOUT') {
|
|
95
|
+
return { success: false, timedOut: true };
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
success: false,
|
|
99
|
+
stderr: err.stderr || err.message,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Log a security event to audit log
|
|
105
|
+
*/
|
|
106
|
+
export function logSecurityEvent(eventType, command, templateName, result) {
|
|
107
|
+
const logEntry = {
|
|
108
|
+
timestamp: new Date().toISOString(),
|
|
109
|
+
eventType,
|
|
110
|
+
command,
|
|
111
|
+
template: templateName,
|
|
112
|
+
user: os.userInfo().username,
|
|
113
|
+
result,
|
|
114
|
+
hostname: os.hostname(),
|
|
115
|
+
};
|
|
116
|
+
const logDir = path.join(os.homedir(), '.pt');
|
|
117
|
+
const logFile = path.join(logDir, 'security-audit.log');
|
|
118
|
+
try {
|
|
119
|
+
// Ensure log directory exists
|
|
120
|
+
if (!fs.existsSync(logDir)) {
|
|
121
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
// Append to audit log
|
|
124
|
+
fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n');
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
// Silently fail if logging fails
|
|
128
|
+
console.warn('Warning: Failed to write security audit log');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Check if a URL is from a trusted source
|
|
133
|
+
*/
|
|
134
|
+
export function isTrustedSource(url, trustedSources = DEFAULT_SECURITY_POLICY.trustedSources) {
|
|
135
|
+
return trustedSources.some(source => url.includes(source));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Check if a command has been executed too many times (rate limiting)
|
|
139
|
+
*/
|
|
140
|
+
const executionCounts = new Map();
|
|
141
|
+
const RATE_LIMIT_MS = 1000; // 1 second between identical commands
|
|
142
|
+
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);
|
|
145
|
+
if (totalExecuted >= maxCommandsPerRun) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
// Check rate limit for this specific command
|
|
149
|
+
const hash = crypto.createHash('md5').update(command).digest('hex');
|
|
150
|
+
const last = executionCounts.get(hash) || 0;
|
|
151
|
+
if (Date.now() - last < RATE_LIMIT_MS) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
// Update count
|
|
155
|
+
executionCounts.set(hash, Date.now());
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Reset execution counts (for testing or between runs)
|
|
160
|
+
*/
|
|
161
|
+
export function resetExecutionCounts() {
|
|
162
|
+
executionCounts.clear();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Validate a template's security before execution
|
|
166
|
+
*/
|
|
167
|
+
export function validateTemplateSecurity(templateConfig, securityPolicy = DEFAULT_SECURITY_POLICY) {
|
|
168
|
+
const errors = [];
|
|
169
|
+
const warnings = [];
|
|
170
|
+
// Check post_config tasks
|
|
171
|
+
const postConfigTasks = templateConfig.post_config || [];
|
|
172
|
+
for (const task of postConfigTasks) {
|
|
173
|
+
const command = task.command;
|
|
174
|
+
if (!command)
|
|
175
|
+
continue;
|
|
176
|
+
// Check for blocked commands
|
|
177
|
+
if (isBlockedCommand(command)) {
|
|
178
|
+
errors.push(`Blocked command in template: ${command}`);
|
|
179
|
+
}
|
|
180
|
+
// Warn about dangerous commands
|
|
181
|
+
if (isDangerousCommand(command)) {
|
|
182
|
+
warnings.push(`Dangerous command in template: ${command}`);
|
|
183
|
+
}
|
|
184
|
+
// Check for shell injection patterns
|
|
185
|
+
if (command.includes(';') || command.includes('|') || command.includes('&')) {
|
|
186
|
+
warnings.push(`Shell injection pattern in command: ${command}`);
|
|
187
|
+
}
|
|
188
|
+
// Check for remote downloads
|
|
189
|
+
if (command.includes('curl') || command.includes('wget')) {
|
|
190
|
+
warnings.push(`Remote download in command: ${command}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
valid: errors.length === 0,
|
|
195
|
+
errors,
|
|
196
|
+
warnings,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Get security policy from config or use defaults
|
|
201
|
+
*/
|
|
202
|
+
export function getSecurityPolicy(configPath) {
|
|
203
|
+
// Try to load from config
|
|
204
|
+
if (configPath) {
|
|
205
|
+
try {
|
|
206
|
+
const YAML = require('yaml');
|
|
207
|
+
const config = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
208
|
+
if (config.security) {
|
|
209
|
+
return { ...DEFAULT_SECURITY_POLICY, ...config.security };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
// Fall back to defaults
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return DEFAULT_SECURITY_POLICY;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Show warning about dangerous command and wait for user to cancel
|
|
220
|
+
*/
|
|
221
|
+
export async function showDangerousCommandWarning(command, timeoutSeconds = 5) {
|
|
222
|
+
const inquirer = (await import('inquirer')).default;
|
|
223
|
+
const readline = await import('readline');
|
|
224
|
+
console.log(chalk.red('\n⚠️ DANGEROUS COMMAND DETECTED'));
|
|
225
|
+
console.log(chalk.red(` Command: ${command}`));
|
|
226
|
+
console.log(chalk.red(' This could potentially harm your system.'));
|
|
227
|
+
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
|
+
const rl = readline.createInterface({
|
|
234
|
+
input: process.stdin,
|
|
235
|
+
output: process.stdout,
|
|
236
|
+
});
|
|
237
|
+
return new Promise((resolve) => {
|
|
238
|
+
rl.on('SIGINT', () => {
|
|
239
|
+
clearTimeout(timeout);
|
|
240
|
+
rl.close();
|
|
241
|
+
console.log(chalk.yellow('\n⚠️ Command cancelled by user'));
|
|
242
|
+
resolve(false);
|
|
243
|
+
});
|
|
244
|
+
// If timeout expires, resolve without waiting for readline
|
|
245
|
+
setTimeout(() => {
|
|
246
|
+
rl.close();
|
|
247
|
+
resolve(true);
|
|
248
|
+
}, timeoutSeconds * 1000);
|
|
249
|
+
});
|
|
250
|
+
}
|
package/doc/configuration.md
CHANGED
|
@@ -8,6 +8,10 @@ Config is stored at `~/.pt/config.yaml` and contains:
|
|
|
8
8
|
- `ignore`: Global folder ignore patterns for `pt learn`
|
|
9
9
|
- `variables`: Global variable suggestions for `pt learn` (name, prompt, default, required)
|
|
10
10
|
|
|
11
|
+
## Security Policy
|
|
12
|
+
|
|
13
|
+
Please see [[security]].
|
|
14
|
+
|
|
11
15
|
## Template Variables
|
|
12
16
|
|
|
13
17
|
When learning a template, you can define variables that will be prompted during initialization:
|
|
@@ -52,6 +56,13 @@ If the directory contains a `.pt-template.json` or `template.json` file with a `
|
|
|
52
56
|
2. Manually edit `~/.pt/config.yaml` to refine `copy_files`, `post_config` commands, or add specific `chmod` requirements.
|
|
53
57
|
3. Alternatively, initialize a temporary project from your learned template (`pt init`), refine it manually, and then use `pt update` from that directory to "re-learn" the refined state.
|
|
54
58
|
|
|
59
|
+
**Security Note:** All post-config commands are subject to security validation:
|
|
60
|
+
- Dangerous commands (e.g., `curl`, `python`, `chmod`) trigger warnings with 5-second cancellation
|
|
61
|
+
- Absolute blocks (e.g., `sudo`, `rm -rf`, `dd`) are never allowed
|
|
62
|
+
- Rate limiting prevents runaway execution (50 commands per run)
|
|
63
|
+
- Execution timeout (30 seconds) prevents hung processes
|
|
64
|
+
- All events are logged to `~/.pt/security-audit.log`
|
|
65
|
+
|
|
55
66
|
```
|
|
56
67
|
javascript: [git init, npm install]
|
|
57
68
|
python: [git init, python -m venv .venv, pip install -r requirements.txt]
|
package/doc/security.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Security Guide
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`pt-cli` implements a multi-layered security model to protect users when running post-config commands and downloading remote templates. The system uses a warning-based approach rather than strict blocking, allowing legitimate workflows while providing clear warnings for potentially dangerous operations.
|
|
6
|
+
|
|
7
|
+
## Security Policy Configuration
|
|
8
|
+
|
|
9
|
+
Security settings are configured in `~/.pt/config.yaml` under the `security` key:
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
security:
|
|
13
|
+
securityLevel: "warn" # "warn" (default) or "strict"
|
|
14
|
+
trustedSources:
|
|
15
|
+
- "github.com/garyritchie"
|
|
16
|
+
- "git.lyonritchie.com/garyritchie"
|
|
17
|
+
- "github.com/lyonritchie"
|
|
18
|
+
maxExecutionTime: 30000 # 30 seconds per command
|
|
19
|
+
maxCommandsPerRun: 50 # rate limit per init session
|
|
20
|
+
enableAuditLogging: true # write events to security-audit.log
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Security Levels
|
|
24
|
+
|
|
25
|
+
- **`warn`** (default): Warning-based approach with cancellation prompts
|
|
26
|
+
- **`strict`**: More conservative defaults, enabled by default for new installations
|
|
27
|
+
|
|
28
|
+
### Trusted Sources
|
|
29
|
+
|
|
30
|
+
When downloading templates from remote URLs, `pt-cli` verifies the source against the `trustedSources` list. Untrusted sources trigger a warning and require explicit user confirmation before proceeding.
|
|
31
|
+
|
|
32
|
+
## Command Security
|
|
33
|
+
|
|
34
|
+
### Absolute Blocks (Never Allowed)
|
|
35
|
+
|
|
36
|
+
The following commands are **always blocked** regardless of security level:
|
|
37
|
+
|
|
38
|
+
- `sudo`, `su`, `su -` (privilege escalation)
|
|
39
|
+
- `dd`, `mkfs`, `fdisk` (disk operations)
|
|
40
|
+
- `rm -rf /`, `rm -r --no-preserve-root` (massive deletion)
|
|
41
|
+
- `eval`, `exec`, `source` (code execution)
|
|
42
|
+
|
|
43
|
+
### Dangerous Commands (Warning Only)
|
|
44
|
+
|
|
45
|
+
The following commands trigger a **5-second countdown** with CTRL+C cancellation:
|
|
46
|
+
|
|
47
|
+
- `curl`, `wget`, `wget -O` (remote downloads)
|
|
48
|
+
- `bash`, `sh`, `python`, `python3`, `node -e`, `node -p` (script execution)
|
|
49
|
+
- `chmod 777`, `chmod -R`, `chmod +x`, `chmod 755`, `chmod 644` (permission changes)
|
|
50
|
+
|
|
51
|
+
**Example interaction:**
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
⚠️ WARNING: This command may be dangerous: npm install
|
|
55
|
+
Press CTRL+C to cancel, or wait 5s to continue...
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Rate Limiting
|
|
59
|
+
|
|
60
|
+
- **50 commands per run**: Prevents runaway command execution
|
|
61
|
+
- If limit is reached, subsequent commands are skipped with a warning
|
|
62
|
+
|
|
63
|
+
### Execution Timeout
|
|
64
|
+
|
|
65
|
+
- **30 seconds per command**: Prevents hung processes
|
|
66
|
+
- Timed-out commands are logged and skipped
|
|
67
|
+
|
|
68
|
+
## Remote Template Security
|
|
69
|
+
|
|
70
|
+
When downloading templates from remote URLs:
|
|
71
|
+
|
|
72
|
+
1. **Source Verification**: Checks against `trustedSources` list
|
|
73
|
+
2. **File Size Validation**: Maximum 50MB download limit
|
|
74
|
+
3. **Archive Extraction**: Extracts to secure temporary directory
|
|
75
|
+
4. **Audit Logging**: All downloads are logged with timestamps and outcomes
|
|
76
|
+
|
|
77
|
+
## Audit Logging
|
|
78
|
+
|
|
79
|
+
All security events are logged to `~/.pt/security-audit.log`:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
2026-06-27T10:01:23.456Z [WARNING] dangerous_command: npm install | type: javascript | status: warning
|
|
83
|
+
2026-06-27T10:01:24.123Z [BLOCKED] command_blocked: sudo rm -rf / | type: all | status: blocked
|
|
84
|
+
2026-06-27T10:01:25.789Z [INFO] template_loaded: https://github.com/user/template | type: remote | status: success
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Security Best Practices
|
|
88
|
+
|
|
89
|
+
### For Users
|
|
90
|
+
|
|
91
|
+
1. **Review post-config tasks**: Always review commands before executing
|
|
92
|
+
2. **Use trusted sources**: Only download templates from known repositories
|
|
93
|
+
3. **Monitor audit logs**: Check `~/.pt/security-audit.log` for suspicious activity
|
|
94
|
+
4. **Update regularly**: Keep `pt-cli` updated for latest security improvements
|
|
95
|
+
|
|
96
|
+
### For Template Authors
|
|
97
|
+
|
|
98
|
+
1. **Avoid dangerous commands**: Don't include `sudo`, `rm -rf`, or privilege escalation in templates
|
|
99
|
+
2. **Use safe defaults**: Prefer `npm install` over custom scripts
|
|
100
|
+
3. **Provide clear descriptions**: Explain what each post-config task does
|
|
101
|
+
4. **Test thoroughly**: Verify templates work in isolated environments
|
|
102
|
+
|
|
103
|
+
## Troubleshooting
|
|
104
|
+
|
|
105
|
+
### Security Events Not Logging
|
|
106
|
+
|
|
107
|
+
1. Check write permissions to `~/.pt/` directory
|
|
108
|
+
2. Verify `enableAuditLogging: true` in config
|
|
109
|
+
3. Check for disk space issues
|
|
110
|
+
|
|
111
|
+
### Commands Blocked Unexpectedly
|
|
112
|
+
|
|
113
|
+
1. Check if command matches absolute blocklist
|
|
114
|
+
2. Review security policy configuration
|
|
115
|
+
3. Consult audit log for specific reasons
|
|
116
|
+
|
|
117
|
+
### Remote Template Download Failed
|
|
118
|
+
|
|
119
|
+
1. Verify URL is in `trustedSources` list
|
|
120
|
+
2. Check network connectivity
|
|
121
|
+
3. Verify file size is under 50MB limit
|
|
122
|
+
4. Check for valid archive format
|
|
123
|
+
|
|
124
|
+
## Security Policy Reference
|
|
125
|
+
|
|
126
|
+
| Setting | Type | Default | Description |
|
|
127
|
+
|---------------------|---------|---------|--------------------------------------|
|
|
128
|
+
| `securityLevel` | string | `"warn"`| Security enforcement level |
|
|
129
|
+
| `trustedSources` | array | [] | List of trusted template sources |
|
|
130
|
+
| `maxExecutionTime` | number | 30000 | Max seconds per command (30s default)|
|
|
131
|
+
| `maxCommandsPerRun` | number | 50 | Rate limit per init session |
|
|
132
|
+
| `enableAuditLogging`| boolean | true | Enable security event logging |
|