@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/src/safety.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// pt-cli/src/safety.ts
|
|
2
|
+
// Security warnings and safeguards for post_config command execution
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
|
|
9
|
+
// === BLOCKLIST ===
|
|
10
|
+
// Commands that are NEVER allowed (safety-critical operations)
|
|
11
|
+
const BLOCKED_COMMANDS = [
|
|
12
|
+
// Privilege escalation
|
|
13
|
+
'sudo', 'su', 'su -', 'su root',
|
|
14
|
+
// Disk operations that could destroy data
|
|
15
|
+
'dd', 'mkfs', 'fdisk', 'mount', 'umount',
|
|
16
|
+
// Shell injection patterns
|
|
17
|
+
';', '|', '&', '&&', '||',
|
|
18
|
+
// Dangerous chmod
|
|
19
|
+
'chmod 777', 'chmod -R 777', 'chmod 666',
|
|
20
|
+
// System commands that could kill processes
|
|
21
|
+
'kill', 'killall', 'pkill', 'fuser',
|
|
22
|
+
// Network operations that could exfiltrate data
|
|
23
|
+
'nc', 'netcat', 'socat',
|
|
24
|
+
// Package manager with dangerous flags
|
|
25
|
+
'apt purge', 'apt remove', 'yum remove', 'brew uninstall',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// === DANGEROUS PATTERNS ===
|
|
29
|
+
// Commands that should trigger a warning but are NOT blocked
|
|
30
|
+
const DANGEROUS_PATTERNS = [
|
|
31
|
+
// Destructive file operations
|
|
32
|
+
'rm -rf', 'rm -r', 'rm --no-preserve-root', 'rm -rf /',
|
|
33
|
+
// Remote downloads + execution
|
|
34
|
+
'curl', 'wget', 'wget -O', 'curl |', 'wget |',
|
|
35
|
+
// Script execution
|
|
36
|
+
'bash', 'sh', 'python', 'python3', 'node -e', 'node -p',
|
|
37
|
+
// Shell operations
|
|
38
|
+
'eval', 'exec', 'source',
|
|
39
|
+
// File system manipulation
|
|
40
|
+
'chmod -R', 'chown -R', 'chgrp -R',
|
|
41
|
+
// PowerShell (Windows)
|
|
42
|
+
'powershell', 'pwsh', 'Invoke-Expression', 'IEX',
|
|
43
|
+
// macOS-specific
|
|
44
|
+
'diskutil', 'hdiutil', 'csrutil',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// === DEFAULT CONFIGURATION ===
|
|
48
|
+
export interface SecurityPolicy {
|
|
49
|
+
maxExecutionTime: number; // milliseconds
|
|
50
|
+
enableAuditLogging: boolean;
|
|
51
|
+
trustedSources: string[];
|
|
52
|
+
maxCommandsPerRun: number;
|
|
53
|
+
securityLevel: 'warn' | 'strict';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Default security policy - warning focused
|
|
57
|
+
const DEFAULT_SECURITY_POLICY: SecurityPolicy = {
|
|
58
|
+
maxExecutionTime: 30000, // 30 seconds
|
|
59
|
+
enableAuditLogging: true,
|
|
60
|
+
trustedSources: [
|
|
61
|
+
'github.com/garyritchie',
|
|
62
|
+
'git.lyonritchie.com',
|
|
63
|
+
'github.com/lyonritchie',
|
|
64
|
+
],
|
|
65
|
+
maxCommandsPerRun: 50,
|
|
66
|
+
securityLevel: 'warn', // Warning-focused mode
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if a command is in the blocklist (NEVER allowed)
|
|
71
|
+
* Only truly dangerous operations that could destroy data
|
|
72
|
+
*/
|
|
73
|
+
export function isBlockedCommand(command: string): boolean {
|
|
74
|
+
for (const blocked of BLOCKED_COMMANDS) {
|
|
75
|
+
if (command.includes(blocked)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if a command should trigger a warning (but is allowed)
|
|
84
|
+
*/
|
|
85
|
+
export function isDangerousCommand(command: string): boolean {
|
|
86
|
+
for (const pattern of DANGEROUS_PATTERNS) {
|
|
87
|
+
if (command.includes(pattern)) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Execute a command with timeout and error handling
|
|
96
|
+
*/
|
|
97
|
+
export async function executeWithTimeout(
|
|
98
|
+
command: string,
|
|
99
|
+
cwd: string,
|
|
100
|
+
timeoutMs: number = 30000
|
|
101
|
+
): Promise<{ success: boolean; stdout?: string; stderr?: string; timedOut?: boolean }> {
|
|
102
|
+
const { execSync } = await import('child_process');
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const output = execSync(command, {
|
|
106
|
+
cwd,
|
|
107
|
+
stdio: 'pipe',
|
|
108
|
+
timeout: timeoutMs,
|
|
109
|
+
encoding: 'utf-8',
|
|
110
|
+
});
|
|
111
|
+
return { success: true, stdout: output };
|
|
112
|
+
} catch (err: any) {
|
|
113
|
+
if (err.code === 'ETIMEDOUT') {
|
|
114
|
+
return { success: false, timedOut: true };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
success: false,
|
|
118
|
+
stderr: err.stderr || err.message,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Log a security event to audit log
|
|
125
|
+
*/
|
|
126
|
+
export function logSecurityEvent(
|
|
127
|
+
eventType: 'command_executed' | 'command_blocked' | 'command_timed_out' | 'template_loaded',
|
|
128
|
+
command: string,
|
|
129
|
+
templateName: string,
|
|
130
|
+
result: 'success' | 'failed' | 'timedout' | 'blocked'
|
|
131
|
+
): void {
|
|
132
|
+
const logEntry = {
|
|
133
|
+
timestamp: new Date().toISOString(),
|
|
134
|
+
eventType,
|
|
135
|
+
command,
|
|
136
|
+
template: templateName,
|
|
137
|
+
user: os.userInfo().username,
|
|
138
|
+
result,
|
|
139
|
+
hostname: os.hostname(),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const logDir = path.join(os.homedir(), '.pt');
|
|
143
|
+
const logFile = path.join(logDir, 'security-audit.log');
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
// Ensure log directory exists
|
|
147
|
+
if (!fs.existsSync(logDir)) {
|
|
148
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Append to audit log
|
|
152
|
+
fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n');
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// Silently fail if logging fails
|
|
155
|
+
console.warn('Warning: Failed to write security audit log');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if a URL is from a trusted source
|
|
161
|
+
*/
|
|
162
|
+
export function isTrustedSource(url: string, trustedSources: string[] = DEFAULT_SECURITY_POLICY.trustedSources): boolean {
|
|
163
|
+
return trustedSources.some(source => url.includes(source));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check if a command has been executed too many times (rate limiting)
|
|
168
|
+
*/
|
|
169
|
+
const executionCounts = new Map<string, number>();
|
|
170
|
+
const RATE_LIMIT_MS = 1000; // 1 second between identical commands
|
|
171
|
+
|
|
172
|
+
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);
|
|
175
|
+
if (totalExecuted >= maxCommandsPerRun) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Check rate limit for this specific command
|
|
180
|
+
const hash = crypto.createHash('md5').update(command).digest('hex');
|
|
181
|
+
const last = executionCounts.get(hash) || 0;
|
|
182
|
+
if (Date.now() - last < RATE_LIMIT_MS) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Update count
|
|
187
|
+
executionCounts.set(hash, Date.now());
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Reset execution counts (for testing or between runs)
|
|
193
|
+
*/
|
|
194
|
+
export function resetExecutionCounts(): void {
|
|
195
|
+
executionCounts.clear();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Validate a template's security before execution
|
|
200
|
+
*/
|
|
201
|
+
export function validateTemplateSecurity(
|
|
202
|
+
templateConfig: any,
|
|
203
|
+
securityPolicy: SecurityPolicy = DEFAULT_SECURITY_POLICY
|
|
204
|
+
): { valid: boolean; errors: string[]; warnings: string[] } {
|
|
205
|
+
const errors: string[] = [];
|
|
206
|
+
const warnings: string[] = [];
|
|
207
|
+
|
|
208
|
+
// Check post_config tasks
|
|
209
|
+
const postConfigTasks = templateConfig.post_config || [];
|
|
210
|
+
for (const task of postConfigTasks) {
|
|
211
|
+
const command = task.command;
|
|
212
|
+
if (!command) continue;
|
|
213
|
+
|
|
214
|
+
// Check for blocked commands
|
|
215
|
+
if (isBlockedCommand(command)) {
|
|
216
|
+
errors.push(`Blocked command in template: ${command}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Warn about dangerous commands
|
|
220
|
+
if (isDangerousCommand(command)) {
|
|
221
|
+
warnings.push(`Dangerous command in template: ${command}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check for shell injection patterns
|
|
225
|
+
if (command.includes(';') || command.includes('|') || command.includes('&')) {
|
|
226
|
+
warnings.push(`Shell injection pattern in command: ${command}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check for remote downloads
|
|
230
|
+
if (command.includes('curl') || command.includes('wget')) {
|
|
231
|
+
warnings.push(`Remote download in command: ${command}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
valid: errors.length === 0,
|
|
237
|
+
errors,
|
|
238
|
+
warnings,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get security policy from config or use defaults
|
|
244
|
+
*/
|
|
245
|
+
export function getSecurityPolicy(configPath?: string): SecurityPolicy {
|
|
246
|
+
// Try to load from config
|
|
247
|
+
if (configPath) {
|
|
248
|
+
try {
|
|
249
|
+
const YAML = require('yaml');
|
|
250
|
+
const config = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
251
|
+
if (config.security) {
|
|
252
|
+
return { ...DEFAULT_SECURITY_POLICY, ...config.security };
|
|
253
|
+
}
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// Fall back to defaults
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return DEFAULT_SECURITY_POLICY;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Show warning about dangerous command and wait for user to cancel
|
|
264
|
+
*/
|
|
265
|
+
export async function showDangerousCommandWarning(command: string, timeoutSeconds: number = 5): Promise<boolean> {
|
|
266
|
+
const inquirer = (await import('inquirer')).default;
|
|
267
|
+
const readline = await import('readline');
|
|
268
|
+
|
|
269
|
+
console.log(chalk.red('\n⚠️ DANGEROUS COMMAND DETECTED'));
|
|
270
|
+
console.log(chalk.red(` Command: ${command}`));
|
|
271
|
+
console.log(chalk.red(' This could potentially harm your system.'));
|
|
272
|
+
console.log(chalk.yellow(` Press CTRL+C to cancel, or wait ${timeoutSeconds}s to continue...`));
|
|
273
|
+
|
|
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
|
+
const rl = readline.createInterface({
|
|
281
|
+
input: process.stdin,
|
|
282
|
+
output: process.stdout,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return new Promise((resolve) => {
|
|
286
|
+
rl.on('SIGINT', () => {
|
|
287
|
+
clearTimeout(timeout);
|
|
288
|
+
rl.close();
|
|
289
|
+
console.log(chalk.yellow('\n⚠️ Command cancelled by user'));
|
|
290
|
+
resolve(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// If timeout expires, resolve without waiting for readline
|
|
294
|
+
setTimeout(() => {
|
|
295
|
+
rl.close();
|
|
296
|
+
resolve(true);
|
|
297
|
+
}, timeoutSeconds * 1000);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
shouldExcludeFile,
|
|
17
17
|
sanitizePath,
|
|
18
18
|
DEFAULT_EXCLUDES,
|
|
19
|
-
|
|
19
|
+
getHomeDir,
|
|
20
20
|
PtConfig,
|
|
21
21
|
} from '../src/config.js';
|
|
22
22
|
|
|
@@ -31,30 +31,30 @@ after(() => {
|
|
|
31
31
|
|
|
32
32
|
test('ensureConfigDir creates dir when it does not exist', () => {
|
|
33
33
|
// Make sure the dir does NOT exist before the test
|
|
34
|
-
if (fs.existsSync(
|
|
35
|
-
fs.rmSync(
|
|
34
|
+
if (fs.existsSync(getHomeDir())) {
|
|
35
|
+
fs.rmSync(getHomeDir(), { recursive: true, force: true });
|
|
36
36
|
}
|
|
37
|
-
assert.ok(!fs.existsSync(
|
|
37
|
+
assert.ok(!fs.existsSync(getHomeDir()), 'Precondition: getHomeDir() should not exist');
|
|
38
38
|
|
|
39
39
|
ensureConfigDir();
|
|
40
40
|
|
|
41
|
-
assert.ok(fs.existsSync(
|
|
42
|
-
assert.ok(fs.statSync(
|
|
41
|
+
assert.ok(fs.existsSync(getHomeDir()), 'getHomeDir() should be created');
|
|
42
|
+
assert.ok(fs.statSync(getHomeDir()).isDirectory(), 'getHomeDir() should be a directory');
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
test('ensureConfigDir does nothing when dir already exists', () => {
|
|
46
46
|
// Ensure directory exists first
|
|
47
|
-
if (!fs.existsSync(
|
|
48
|
-
fs.mkdirSync(
|
|
47
|
+
if (!fs.existsSync(getHomeDir())) {
|
|
48
|
+
fs.mkdirSync(getHomeDir(), { recursive: true });
|
|
49
49
|
}
|
|
50
50
|
// Place a marker file inside to prove the dir is not recreated
|
|
51
|
-
const markerPath = path.join(
|
|
51
|
+
const markerPath = path.join(getHomeDir(), '.marker');
|
|
52
52
|
fs.writeFileSync(markerPath, 'exists');
|
|
53
53
|
|
|
54
54
|
ensureConfigDir();
|
|
55
55
|
|
|
56
|
-
assert.ok(fs.existsSync(
|
|
57
|
-
assert.ok(fs.existsSync(markerPath), 'Marker file inside
|
|
56
|
+
assert.ok(fs.existsSync(getHomeDir()), 'getHomeDir() should still exist');
|
|
57
|
+
assert.ok(fs.existsSync(markerPath), 'Marker file inside getHomeDir() should still exist');
|
|
58
58
|
|
|
59
59
|
// Clean up marker
|
|
60
60
|
fs.unlinkSync(markerPath);
|