@orderful/droid 0.45.1 → 0.46.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.
Files changed (30) hide show
  1. package/.claude-plugin/plugin.json +4 -1
  2. package/.github/workflows/claude-issue-agent.yml +1 -1
  3. package/CHANGELOG.md +6 -0
  4. package/dist/tools/pii/.claude-plugin/plugin.json +25 -0
  5. package/dist/tools/pii/TOOL.yaml +22 -0
  6. package/dist/tools/pii/agents/pii-scanner.md +85 -0
  7. package/dist/tools/pii/commands/pii.md +33 -0
  8. package/dist/tools/pii/skills/pii/SKILL.md +97 -0
  9. package/dist/tools/pii/skills/pii/references/supported-entities.md +90 -0
  10. package/dist/tools/pii/skills/pii/scripts/presidio-analyze.d.ts +18 -0
  11. package/dist/tools/pii/skills/pii/scripts/presidio-analyze.d.ts.map +1 -0
  12. package/dist/tools/pii/skills/pii/scripts/presidio-analyze.ts +258 -0
  13. package/dist/tools/pii/skills/pii/scripts/presidio-init.d.ts +17 -0
  14. package/dist/tools/pii/skills/pii/scripts/presidio-init.d.ts.map +1 -0
  15. package/dist/tools/pii/skills/pii/scripts/presidio-init.ts +151 -0
  16. package/dist/tools/pii/skills/pii/scripts/presidio-redact.d.ts +21 -0
  17. package/dist/tools/pii/skills/pii/scripts/presidio-redact.d.ts.map +1 -0
  18. package/dist/tools/pii/skills/pii/scripts/presidio-redact.ts +294 -0
  19. package/dist/tools/pii/skills/pii/scripts/presidio.test.ts +444 -0
  20. package/package.json +1 -1
  21. package/src/tools/pii/.claude-plugin/plugin.json +25 -0
  22. package/src/tools/pii/TOOL.yaml +22 -0
  23. package/src/tools/pii/agents/pii-scanner.md +85 -0
  24. package/src/tools/pii/commands/pii.md +33 -0
  25. package/src/tools/pii/skills/pii/SKILL.md +97 -0
  26. package/src/tools/pii/skills/pii/references/supported-entities.md +90 -0
  27. package/src/tools/pii/skills/pii/scripts/presidio-analyze.ts +258 -0
  28. package/src/tools/pii/skills/pii/scripts/presidio-init.ts +151 -0
  29. package/src/tools/pii/skills/pii/scripts/presidio-redact.ts +294 -0
  30. package/src/tools/pii/skills/pii/scripts/presidio.test.ts +444 -0
@@ -0,0 +1,444 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { spawnSync } from 'child_process';
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
6
+
7
+ /**
8
+ * Integration tests for presidio scripts.
9
+ *
10
+ * These tests validate argument parsing, error paths, and JSON output shape.
11
+ * They do NOT invoke Presidio/Python directly — all Python-dependent paths
12
+ * are gated by existsSync checks in the scripts, so tests run without Python.
13
+ */
14
+
15
+ const SCRIPTS_DIR = __dirname;
16
+
17
+ interface ScriptResult {
18
+ success: boolean;
19
+ already_existed?: boolean;
20
+ initialized?: boolean;
21
+ python_path?: string;
22
+ venv_path?: string;
23
+ entities?: Array<{ type: string; start: number; end: number; score: number; line: number; text?: string }>;
24
+ dry_run?: boolean;
25
+ original_path?: string;
26
+ output_path?: string;
27
+ entities_found?: number;
28
+ entities_redacted?: number;
29
+ redacted_text?: string;
30
+ error?: string;
31
+ init_required?: boolean;
32
+ }
33
+
34
+ function runScript(scriptName: string, args: string[]): ScriptResult {
35
+ const scriptPath = join(SCRIPTS_DIR, `${scriptName}.ts`);
36
+
37
+ const result = spawnSync('bun', ['run', scriptPath, ...args], {
38
+ encoding: 'utf-8',
39
+ cwd: process.cwd(),
40
+ });
41
+
42
+ if (result.error) {
43
+ return { success: false, error: result.error.message };
44
+ }
45
+
46
+ try {
47
+ return JSON.parse(result.stdout.trim());
48
+ } catch {
49
+ return {
50
+ success: false,
51
+ error: `Failed to parse output: ${result.stdout}\nStderr: ${result.stderr}`,
52
+ };
53
+ }
54
+ }
55
+
56
+ function createFakeVenvWithPython(tempHome: string, scriptContent: string): string {
57
+ const venvPath = join(tempHome, '.droid', 'runtimes', 'presidio');
58
+ const binDir = join(venvPath, 'bin');
59
+ mkdirSync(binDir, { recursive: true });
60
+ writeFileSync(join(binDir, 'python3'), scriptContent, { mode: 0o755 });
61
+ return venvPath;
62
+ }
63
+
64
+ // ─── presidio-init ───────────────────────────────────────────────────────────
65
+
66
+ describe('presidio-init', () => {
67
+ let tempHome: string;
68
+ let originalHome: string;
69
+
70
+ beforeEach(() => {
71
+ originalHome = process.env.HOME || '';
72
+ tempHome = mkdtempSync(join(tmpdir(), 'pii-test-home-'));
73
+ process.env.HOME = tempHome;
74
+ });
75
+
76
+ afterEach(() => {
77
+ process.env.HOME = originalHome;
78
+ try {
79
+ rmSync(tempHome, { recursive: true, force: true });
80
+ } catch {
81
+ // Ignore cleanup errors
82
+ }
83
+ });
84
+
85
+ it('returns already_existed:true when marker file and venv binary both exist', () => {
86
+ // Pre-create the venv structure
87
+ const venvPath = join(tempHome, '.droid', 'runtimes', 'presidio');
88
+ const binDir = join(venvPath, 'bin');
89
+ mkdirSync(binDir, { recursive: true });
90
+ writeFileSync(join(venvPath, '.droid-initialized'), new Date().toISOString());
91
+ writeFileSync(join(binDir, 'python3'), '#!/bin/bash\necho "Python 3.9.0"', { mode: 0o755 });
92
+
93
+ const result = spawnSync(
94
+ 'bun',
95
+ ['run', join(SCRIPTS_DIR, 'presidio-init.ts')],
96
+ {
97
+ encoding: 'utf-8',
98
+ env: { ...process.env, HOME: tempHome },
99
+ }
100
+ );
101
+
102
+ let parsed: ScriptResult;
103
+ try {
104
+ parsed = JSON.parse(result.stdout.trim());
105
+ } catch {
106
+ parsed = { success: false, error: `Parse error: ${result.stdout}` };
107
+ }
108
+
109
+ expect(parsed.success).toBe(true);
110
+ expect(parsed.already_existed).toBe(true);
111
+ });
112
+
113
+ it('returns success:false with clear error when python3 is not available', () => {
114
+ // Override PATH to have no python3
115
+ const result = spawnSync(
116
+ 'bun',
117
+ ['run', join(SCRIPTS_DIR, 'presidio-init.ts')],
118
+ {
119
+ encoding: 'utf-8',
120
+ env: { ...process.env, HOME: tempHome, PATH: '/nonexistent' },
121
+ }
122
+ );
123
+
124
+ let parsed: ScriptResult;
125
+ try {
126
+ parsed = JSON.parse(result.stdout.trim());
127
+ } catch {
128
+ // If Python is truly not found, the error may be in stderr
129
+ parsed = { success: false, error: 'python3 not found' };
130
+ }
131
+
132
+ expect(parsed.success).toBe(false);
133
+ expect(parsed.error).toBeDefined();
134
+ });
135
+ });
136
+
137
+ // ─── presidio-analyze ────────────────────────────────────────────────────────
138
+
139
+ describe('presidio-analyze', () => {
140
+ let tempDir: string;
141
+
142
+ beforeEach(() => {
143
+ tempDir = mkdtempSync(join(tmpdir(), 'pii-analyze-test-'));
144
+ });
145
+
146
+ afterEach(() => {
147
+ try {
148
+ rmSync(tempDir, { recursive: true, force: true });
149
+ } catch {
150
+ // Ignore
151
+ }
152
+ });
153
+
154
+ it('returns success:false with init_required when venv does not exist', () => {
155
+ const result = spawnSync(
156
+ 'bun',
157
+ ['run', join(SCRIPTS_DIR, 'presidio-analyze.ts'), '--file', join(tempDir, 'test.md')],
158
+ {
159
+ encoding: 'utf-8',
160
+ env: { ...process.env, HOME: join(tempDir, 'nonexistent-home') },
161
+ }
162
+ );
163
+
164
+ let parsed: ScriptResult;
165
+ try {
166
+ parsed = JSON.parse(result.stdout.trim());
167
+ } catch {
168
+ parsed = { success: false, error: `Parse error: ${result.stdout}` };
169
+ }
170
+
171
+ expect(parsed.success).toBe(false);
172
+ expect(parsed.init_required).toBe(true);
173
+ expect(parsed.error).toContain('presidio-init');
174
+ });
175
+
176
+ it('returns success:false when neither --file nor --text is provided', () => {
177
+ // Use a fake home so venv won't be found — still validates arg parsing
178
+ const result = spawnSync(
179
+ 'bun',
180
+ ['run', join(SCRIPTS_DIR, 'presidio-analyze.ts')],
181
+ {
182
+ encoding: 'utf-8',
183
+ env: { ...process.env, HOME: join(tempDir, 'nonexistent-home') },
184
+ }
185
+ );
186
+
187
+ let parsed: ScriptResult;
188
+ try {
189
+ parsed = JSON.parse(result.stdout.trim());
190
+ } catch {
191
+ parsed = { success: false, error: `Parse error: ${result.stdout}` };
192
+ }
193
+
194
+ // Either init_required or missing arg error — both are valid failures
195
+ expect(parsed.success).toBe(false);
196
+ expect(parsed.error).toBeDefined();
197
+ });
198
+
199
+ it('returns success:false when --file does not exist (and venv exists)', () => {
200
+ // Pre-create a fake venv so the script gets past the venv check
201
+ const fakeHome = join(tempDir, 'fake-home');
202
+ createFakeVenvWithPython(fakeHome, '#!/bin/bash\necho ""');
203
+
204
+ const nonExistentFile = join(tempDir, 'does-not-exist.md');
205
+
206
+ const result = spawnSync(
207
+ 'bun',
208
+ ['run', join(SCRIPTS_DIR, 'presidio-analyze.ts'), '--file', nonExistentFile],
209
+ {
210
+ encoding: 'utf-8',
211
+ env: { ...process.env, HOME: fakeHome },
212
+ }
213
+ );
214
+
215
+ let parsed: ScriptResult;
216
+ try {
217
+ parsed = JSON.parse(result.stdout.trim());
218
+ } catch {
219
+ parsed = { success: false, error: `Parse error: ${result.stdout}` };
220
+ }
221
+
222
+ expect(parsed.success).toBe(false);
223
+ expect(parsed.error).toContain('not found');
224
+ });
225
+
226
+ it('rejects invalid entity names before invoking Python', () => {
227
+ const fakeHome = join(tempDir, 'fake-home');
228
+ createFakeVenvWithPython(fakeHome, '#!/bin/bash\necho \'[]\'');
229
+
230
+ const result = spawnSync(
231
+ 'bun',
232
+ [
233
+ 'run',
234
+ join(SCRIPTS_DIR, 'presidio-analyze.ts'),
235
+ '--text',
236
+ 'Call me at 555-1234',
237
+ '--entities',
238
+ 'EMAIL_ADDRESS,__import__("os").system("whoami")',
239
+ ],
240
+ {
241
+ encoding: 'utf-8',
242
+ env: { ...process.env, HOME: fakeHome },
243
+ }
244
+ );
245
+
246
+ const parsed = JSON.parse(result.stdout.trim()) as ScriptResult;
247
+ expect(parsed.success).toBe(false);
248
+ expect(parsed.error).toContain('Invalid entity type');
249
+ });
250
+
251
+ it('does not include raw text in analyzer output entities', () => {
252
+ const fakeHome = join(tempDir, 'fake-home-no-text');
253
+ createFakeVenvWithPython(
254
+ fakeHome,
255
+ `#!/bin/bash
256
+ if grep -q "'text': text\\[r.start:r.end\\]" "$1"; then
257
+ echo '[{"type":"EMAIL_ADDRESS","start":11,"end":27,"score":0.99,"text":"jane@example.com"}]'
258
+ else
259
+ echo '[{"type":"EMAIL_ADDRESS","start":11,"end":27,"score":0.99}]'
260
+ fi`,
261
+ );
262
+
263
+ const result = spawnSync(
264
+ 'bun',
265
+ ['run', join(SCRIPTS_DIR, 'presidio-analyze.ts'), '--text', 'Contact me: jane@example.com'],
266
+ {
267
+ encoding: 'utf-8',
268
+ env: { ...process.env, HOME: fakeHome },
269
+ }
270
+ );
271
+
272
+ const parsed = JSON.parse(result.stdout.trim()) as ScriptResult;
273
+ expect(parsed.success).toBe(true);
274
+ expect(parsed.entities?.[0]?.type).toBe('EMAIL_ADDRESS');
275
+ expect(parsed.entities?.[0]).not.toHaveProperty('text');
276
+ });
277
+ });
278
+
279
+ // ─── presidio-redact ─────────────────────────────────────────────────────────
280
+
281
+ describe('presidio-redact', () => {
282
+ let tempDir: string;
283
+
284
+ beforeEach(() => {
285
+ tempDir = mkdtempSync(join(tmpdir(), 'pii-redact-test-'));
286
+ });
287
+
288
+ afterEach(() => {
289
+ try {
290
+ rmSync(tempDir, { recursive: true, force: true });
291
+ } catch {
292
+ // Ignore
293
+ }
294
+ });
295
+
296
+ it('returns success:false when --file is missing', () => {
297
+ const result = spawnSync(
298
+ 'bun',
299
+ ['run', join(SCRIPTS_DIR, 'presidio-redact.ts')],
300
+ {
301
+ encoding: 'utf-8',
302
+ env: { ...process.env, HOME: join(tempDir, 'nonexistent-home') },
303
+ }
304
+ );
305
+
306
+ let parsed: ScriptResult;
307
+ try {
308
+ parsed = JSON.parse(result.stdout.trim());
309
+ } catch {
310
+ parsed = { success: false, error: `Parse error: ${result.stdout}` };
311
+ }
312
+
313
+ expect(parsed.success).toBe(false);
314
+ // Either init_required or missing --file error
315
+ expect(parsed.error).toBeDefined();
316
+ });
317
+
318
+ it('returns success:false with init_required when venv does not exist', () => {
319
+ const testFile = join(tempDir, 'test.md');
320
+ writeFileSync(testFile, 'Hello, my email is test@example.com');
321
+
322
+ const result = spawnSync(
323
+ 'bun',
324
+ ['run', join(SCRIPTS_DIR, 'presidio-redact.ts'), '--file', testFile],
325
+ {
326
+ encoding: 'utf-8',
327
+ env: { ...process.env, HOME: join(tempDir, 'nonexistent-home') },
328
+ }
329
+ );
330
+
331
+ let parsed: ScriptResult;
332
+ try {
333
+ parsed = JSON.parse(result.stdout.trim());
334
+ } catch {
335
+ parsed = { success: false, error: `Parse error: ${result.stdout}` };
336
+ }
337
+
338
+ expect(parsed.success).toBe(false);
339
+ expect(parsed.init_required).toBe(true);
340
+ expect(parsed.error).toContain('presidio-init');
341
+ });
342
+
343
+ it('does not write output file with --dry-run (venv missing path)', () => {
344
+ const testFile = join(tempDir, 'test.md');
345
+ writeFileSync(testFile, 'Hello, my email is test@example.com');
346
+ const expectedOutput = join(tempDir, 'test-redacted.md');
347
+
348
+ const result = spawnSync(
349
+ 'bun',
350
+ ['run', join(SCRIPTS_DIR, 'presidio-redact.ts'), '--file', testFile, '--dry-run'],
351
+ {
352
+ encoding: 'utf-8',
353
+ env: { ...process.env, HOME: join(tempDir, 'nonexistent-home') },
354
+ }
355
+ );
356
+
357
+ // With no venv, it will fail before writing
358
+ expect(existsSync(expectedOutput)).toBe(false);
359
+
360
+ let parsed: ScriptResult;
361
+ try {
362
+ parsed = JSON.parse(result.stdout.trim());
363
+ } catch {
364
+ parsed = { success: false, error: `Parse error: ${result.stdout}` };
365
+ }
366
+ expect(parsed.success).toBe(false);
367
+ expect(parsed.init_required).toBe(true);
368
+ });
369
+
370
+ it('correctly parses --output path argument', () => {
371
+ const testFile = join(tempDir, 'source.md');
372
+ writeFileSync(testFile, 'Contact: jane@example.com');
373
+ const customOutput = join(tempDir, 'custom-output.md');
374
+
375
+ // Without venv this will fail, but we can check the argument is parsed
376
+ const result = spawnSync(
377
+ 'bun',
378
+ ['run', join(SCRIPTS_DIR, 'presidio-redact.ts'), '--file', testFile, '--output', customOutput],
379
+ {
380
+ encoding: 'utf-8',
381
+ env: { ...process.env, HOME: join(tempDir, 'nonexistent-home') },
382
+ }
383
+ );
384
+
385
+ let parsed: ScriptResult;
386
+ try {
387
+ parsed = JSON.parse(result.stdout.trim());
388
+ } catch {
389
+ parsed = { success: false, error: `Parse error: ${result.stdout}` };
390
+ }
391
+
392
+ // With no venv this will fail with init_required
393
+ expect(parsed.success).toBe(false);
394
+ expect(parsed.init_required).toBe(true);
395
+ // Confirm the output file was NOT created
396
+ expect(existsSync(customOutput)).toBe(false);
397
+ });
398
+
399
+ it('omits redacted_text in non-dry-run output', () => {
400
+ const fakeHome = join(tempDir, 'fake-home-no-redacted-text');
401
+ createFakeVenvWithPython(fakeHome, '#!/bin/bash\necho \'{"redacted_text":"masked text","entities_found":1,"entities_redacted":1}\'');
402
+
403
+ const testFile = join(tempDir, 'source.md');
404
+ const outputFile = join(tempDir, 'source-redacted.md');
405
+ writeFileSync(testFile, 'Contact: jane@example.com');
406
+
407
+ const result = spawnSync(
408
+ 'bun',
409
+ ['run', join(SCRIPTS_DIR, 'presidio-redact.ts'), '--file', testFile],
410
+ {
411
+ encoding: 'utf-8',
412
+ env: { ...process.env, HOME: fakeHome },
413
+ }
414
+ );
415
+
416
+ const parsed = JSON.parse(result.stdout.trim()) as ScriptResult;
417
+ expect(parsed.success).toBe(true);
418
+ expect(parsed.dry_run).toBe(false);
419
+ expect(parsed.redacted_text).toBeUndefined();
420
+ expect(parsed.output_path).toBe(outputFile);
421
+ expect(existsSync(outputFile)).toBe(true);
422
+ });
423
+
424
+ it('rejects unsupported entity names', () => {
425
+ const fakeHome = join(tempDir, 'fake-home');
426
+ createFakeVenvWithPython(fakeHome, '#!/bin/bash\necho \'{"redacted_text":"ok","entities_found":0,"entities_redacted":0}\'');
427
+
428
+ const testFile = join(tempDir, 'source.md');
429
+ writeFileSync(testFile, 'Contact: jane@example.com');
430
+
431
+ const result = spawnSync(
432
+ 'bun',
433
+ ['run', join(SCRIPTS_DIR, 'presidio-redact.ts'), '--file', testFile, '--entities', 'NOT_A_REAL_ENTITY'],
434
+ {
435
+ encoding: 'utf-8',
436
+ env: { ...process.env, HOME: fakeHome },
437
+ }
438
+ );
439
+
440
+ const parsed = JSON.parse(result.stdout.trim()) as ScriptResult;
441
+ expect(parsed.success).toBe(false);
442
+ expect(parsed.error).toContain('Unsupported entity type');
443
+ });
444
+ });