@integsec/agentic-pentest-proxy 0.3.4 → 0.3.6
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 -0
- package/cloudflare/wrangler.jsonc +1 -1
- package/dist/bin/integsec-agentic-pentest-bridge.js +2 -2
- package/dist/bin/integsec-agentic-pentest-gateway.js +30 -2
- package/dist/bin/integsec-agentic-pentest-gateway.js.map +1 -1
- package/dist/bin/integsec-agentic-pentest-tui.js +5 -0
- package/dist/bin/integsec-agentic-pentest-tui.js.map +1 -1
- package/dist/src/audit/local.js +2 -2
- package/dist/src/audit/local.js.map +1 -1
- package/dist/src/config.js +1 -1
- package/dist/src/gateway/admin.html +3 -3
- package/dist/src/gateway/docs-home.html +20 -0
- package/dist/src/gateway/docs.d.ts +3 -0
- package/dist/src/gateway/docs.d.ts.map +1 -0
- package/dist/src/gateway/docs.html +12 -0
- package/dist/src/gateway/docs.js +94 -0
- package/dist/src/gateway/docs.js.map +1 -0
- package/dist/src/gateway/local-paths.d.ts +7 -0
- package/dist/src/gateway/local-paths.d.ts.map +1 -0
- package/dist/src/gateway/local-paths.js +30 -0
- package/dist/src/gateway/local-paths.js.map +1 -0
- package/dist/src/gateway/runtime.js +2 -2
- package/dist/src/gateway/server.d.ts.map +1 -1
- package/dist/src/gateway/server.js +24 -0
- package/dist/src/gateway/server.js.map +1 -1
- package/dist/src/gateway/state.d.ts +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/docs/configuration.md +70 -0
- package/docs/development.md +17 -0
- package/docs/images/tui-in-scope.png +0 -0
- package/docs/images/tui-in-scope.svg +24 -0
- package/docs/images/tui-out-of-scope.png +0 -0
- package/docs/images/tui-out-of-scope.svg +25 -0
- package/docs/images/tui-policy-blocks.png +0 -0
- package/docs/images/tui-policy-blocks.svg +24 -0
- package/docs/integrations.md +74 -0
- package/docs/managed-gateway.md +4 -0
- package/docs/release-readiness-0.2.6.md +27 -0
- package/docs/security.md +33 -0
- package/docs/superpowers/plans/2026-03-26-mcp-scope-proxy.md +3051 -0
- package/docs/superpowers/plans/2026-03-28-tui-monitor.md +863 -0
- package/docs/superpowers/plans/2026-03-29-manifest-builder.md +3429 -0
- package/docs/superpowers/specs/2026-03-28-tui-monitor-design.md +138 -0
- package/docs/superpowers/specs/2026-03-29-manifest-builder-design.md +248 -0
- package/docs/tui.md +27 -0
- package/package.json +4 -2
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
# TUI Monitor Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build a standalone Ink-based terminal dashboard that monitors audit JSONL files in real time.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A separate bin entry (`integsec-agentic-pentest-tui`) renders an Ink React app. A custom hook watches the audit directory for new JSONL lines, parses them into `AuditEntry` objects, and feeds them to a dashboard with three panels: engagement header, stats, and live decision feed.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ink 5, React 18, TypeScript TSX, fs.watch()
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Install dependencies and configure TSX build
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Modify: `package.json`
|
|
17
|
+
- Modify: `tsconfig.json`
|
|
18
|
+
|
|
19
|
+
- [ ] **Step 1: Install ink, react, and types**
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install ink@^5.0.0 react@^18.0.0 ink-spinner@^5.0.0
|
|
23
|
+
npm install -D @types/react@^18.0.0
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- [ ] **Step 2: Add jsx support to tsconfig.json**
|
|
27
|
+
|
|
28
|
+
In `tsconfig.json`, add `"jsx": "react-jsx"` to `compilerOptions`:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"compilerOptions": {
|
|
33
|
+
"target": "ES2022",
|
|
34
|
+
"module": "Node16",
|
|
35
|
+
"moduleResolution": "Node16",
|
|
36
|
+
"lib": ["ES2022"],
|
|
37
|
+
"outDir": "./dist",
|
|
38
|
+
"rootDir": ".",
|
|
39
|
+
"strict": true,
|
|
40
|
+
"esModuleInterop": true,
|
|
41
|
+
"skipLibCheck": true,
|
|
42
|
+
"forceConsistentCasingInFileNames": true,
|
|
43
|
+
"resolveJsonModule": true,
|
|
44
|
+
"declaration": true,
|
|
45
|
+
"declarationMap": true,
|
|
46
|
+
"sourceMap": true,
|
|
47
|
+
"jsx": "react-jsx"
|
|
48
|
+
},
|
|
49
|
+
"include": ["src/**/*", "bin/**/*"],
|
|
50
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- [ ] **Step 3: Add bin entry to package.json**
|
|
55
|
+
|
|
56
|
+
Add to the `"bin"` section:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
"bin": {
|
|
60
|
+
"integsec-agentic-pentest-proxy": "dist/bin/integsec-agentic-pentest-proxy.js",
|
|
61
|
+
"integsec-agentic-pentest-tui": "dist/bin/integsec-agentic-pentest-tui.js"
|
|
62
|
+
},
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Add `"src/tui/**/*"` is already covered by `"src/**/*"` in tsconfig include — no change needed.
|
|
66
|
+
|
|
67
|
+
- [ ] **Step 4: Verify build still works**
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm run build
|
|
71
|
+
npm test
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Expected: All 598 tests pass, no TSX compilation errors (no TSX files yet, just verifying config doesn't break).
|
|
75
|
+
|
|
76
|
+
- [ ] **Step 5: Commit**
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
git add package.json package-lock.json tsconfig.json
|
|
80
|
+
git commit -m "chore: add ink/react deps and jsx support for TUI"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
### Task 2: Build the JSONL tail/watcher utility
|
|
86
|
+
|
|
87
|
+
**Files:**
|
|
88
|
+
- Create: `src/tui/audit-tailer.ts`
|
|
89
|
+
- Test: `tests/unit/audit-tailer.test.ts`
|
|
90
|
+
|
|
91
|
+
- [ ] **Step 1: Write the failing test**
|
|
92
|
+
|
|
93
|
+
Create `tests/unit/audit-tailer.test.ts`:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
97
|
+
import * as fs from "node:fs";
|
|
98
|
+
import * as path from "node:path";
|
|
99
|
+
import * as os from "node:os";
|
|
100
|
+
import { readNewEntries, scanExistingEntries } from "../src/tui/audit-tailer.js";
|
|
101
|
+
import type { AuditEntry } from "../src/types.js";
|
|
102
|
+
|
|
103
|
+
function makeEntry(overrides: Partial<AuditEntry> = {}): AuditEntry {
|
|
104
|
+
return {
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
engagement_id: "ENG-TEST-001",
|
|
107
|
+
client: "Test Client",
|
|
108
|
+
operator: "test@test.com",
|
|
109
|
+
tool_name: "nmap_scan",
|
|
110
|
+
tool_parameters: { target: "10.0.0.1" },
|
|
111
|
+
extracted_target: "10.0.0.1",
|
|
112
|
+
resolved_ips: ["10.0.0.1"],
|
|
113
|
+
decision: "ALLOWED",
|
|
114
|
+
decision_reason: "Target matches authorized IP range",
|
|
115
|
+
matched_scope_item: "10.0.0.0/24",
|
|
116
|
+
duration_ms: 2,
|
|
117
|
+
proxy_version: "0.1.2",
|
|
118
|
+
...overrides,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("audit-tailer", () => {
|
|
123
|
+
let tmpDir: string;
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tui-test-"));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("readNewEntries", () => {
|
|
134
|
+
it("reads entries from a given byte offset", () => {
|
|
135
|
+
const filePath = path.join(tmpDir, "ENG-TEST-001.jsonl");
|
|
136
|
+
const entry1 = makeEntry({ tool_name: "nmap_scan" });
|
|
137
|
+
const entry2 = makeEntry({ tool_name: "curl" });
|
|
138
|
+
fs.writeFileSync(filePath, JSON.stringify(entry1) + "\n" + JSON.stringify(entry2) + "\n");
|
|
139
|
+
|
|
140
|
+
const result = readNewEntries(filePath, 0);
|
|
141
|
+
expect(result.entries).toHaveLength(2);
|
|
142
|
+
expect(result.entries[0].tool_name).toBe("nmap_scan");
|
|
143
|
+
expect(result.entries[1].tool_name).toBe("curl");
|
|
144
|
+
expect(result.newOffset).toBeGreaterThan(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("reads only new entries after offset", () => {
|
|
148
|
+
const filePath = path.join(tmpDir, "ENG-TEST-001.jsonl");
|
|
149
|
+
const entry1 = makeEntry({ tool_name: "nmap_scan" });
|
|
150
|
+
fs.writeFileSync(filePath, JSON.stringify(entry1) + "\n");
|
|
151
|
+
const firstRead = readNewEntries(filePath, 0);
|
|
152
|
+
|
|
153
|
+
const entry2 = makeEntry({ tool_name: "curl" });
|
|
154
|
+
fs.appendFileSync(filePath, JSON.stringify(entry2) + "\n");
|
|
155
|
+
const secondRead = readNewEntries(filePath, firstRead.newOffset);
|
|
156
|
+
|
|
157
|
+
expect(secondRead.entries).toHaveLength(1);
|
|
158
|
+
expect(secondRead.entries[0].tool_name).toBe("curl");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("skips malformed lines", () => {
|
|
162
|
+
const filePath = path.join(tmpDir, "ENG-TEST-001.jsonl");
|
|
163
|
+
const entry = makeEntry();
|
|
164
|
+
fs.writeFileSync(filePath, "not json\n" + JSON.stringify(entry) + "\n" + "{bad\n");
|
|
165
|
+
|
|
166
|
+
const result = readNewEntries(filePath, 0);
|
|
167
|
+
expect(result.entries).toHaveLength(1);
|
|
168
|
+
expect(result.entries[0].tool_name).toBe("nmap_scan");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("returns empty for nonexistent file", () => {
|
|
172
|
+
const result = readNewEntries(path.join(tmpDir, "nope.jsonl"), 0);
|
|
173
|
+
expect(result.entries).toHaveLength(0);
|
|
174
|
+
expect(result.newOffset).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("scanExistingEntries", () => {
|
|
179
|
+
it("reads all entries from all jsonl files in directory", () => {
|
|
180
|
+
const entry1 = makeEntry({ engagement_id: "ENG-001", tool_name: "nmap" });
|
|
181
|
+
const entry2 = makeEntry({ engagement_id: "ENG-002", tool_name: "curl" });
|
|
182
|
+
fs.writeFileSync(path.join(tmpDir, "ENG-001.jsonl"), JSON.stringify(entry1) + "\n");
|
|
183
|
+
fs.writeFileSync(path.join(tmpDir, "ENG-002.jsonl"), JSON.stringify(entry2) + "\n");
|
|
184
|
+
|
|
185
|
+
const result = scanExistingEntries(tmpDir);
|
|
186
|
+
expect(result.entries).toHaveLength(2);
|
|
187
|
+
expect(result.offsets.size).toBe(2);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("filters by engagement ID when provided", () => {
|
|
191
|
+
const entry1 = makeEntry({ engagement_id: "ENG-001" });
|
|
192
|
+
const entry2 = makeEntry({ engagement_id: "ENG-002" });
|
|
193
|
+
fs.writeFileSync(path.join(tmpDir, "ENG-001.jsonl"), JSON.stringify(entry1) + "\n");
|
|
194
|
+
fs.writeFileSync(path.join(tmpDir, "ENG-002.jsonl"), JSON.stringify(entry2) + "\n");
|
|
195
|
+
|
|
196
|
+
const result = scanExistingEntries(tmpDir, "ENG-001");
|
|
197
|
+
expect(result.entries).toHaveLength(1);
|
|
198
|
+
expect(result.entries[0].engagement_id).toBe("ENG-001");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns empty for empty directory", () => {
|
|
202
|
+
const result = scanExistingEntries(tmpDir);
|
|
203
|
+
expect(result.entries).toHaveLength(0);
|
|
204
|
+
expect(result.offsets.size).toBe(0);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
npx vitest run tests/unit/audit-tailer.test.ts
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Expected: FAIL — cannot resolve `../src/tui/audit-tailer.js`
|
|
217
|
+
|
|
218
|
+
- [ ] **Step 3: Write the implementation**
|
|
219
|
+
|
|
220
|
+
Create `src/tui/audit-tailer.ts`:
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
import * as fs from "node:fs";
|
|
224
|
+
import * as path from "node:path";
|
|
225
|
+
import type { AuditEntry } from "../types.js";
|
|
226
|
+
|
|
227
|
+
export interface TailResult {
|
|
228
|
+
entries: AuditEntry[];
|
|
229
|
+
newOffset: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface ScanResult {
|
|
233
|
+
entries: AuditEntry[];
|
|
234
|
+
offsets: Map<string, number>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Read new JSONL entries from a file starting at the given byte offset.
|
|
239
|
+
* Skips malformed lines. Returns parsed entries and the new offset.
|
|
240
|
+
*/
|
|
241
|
+
export function readNewEntries(filePath: string, offset: number): TailResult {
|
|
242
|
+
if (!fs.existsSync(filePath)) {
|
|
243
|
+
return { entries: [], newOffset: offset };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const stat = fs.statSync(filePath);
|
|
247
|
+
if (stat.size <= offset) {
|
|
248
|
+
return { entries: [], newOffset: offset };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const fd = fs.openSync(filePath, "r");
|
|
252
|
+
const buf = Buffer.alloc(stat.size - offset);
|
|
253
|
+
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
254
|
+
fs.closeSync(fd);
|
|
255
|
+
|
|
256
|
+
const text = buf.toString("utf-8");
|
|
257
|
+
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
|
258
|
+
const entries: AuditEntry[] = [];
|
|
259
|
+
|
|
260
|
+
for (const line of lines) {
|
|
261
|
+
try {
|
|
262
|
+
entries.push(JSON.parse(line) as AuditEntry);
|
|
263
|
+
} catch {
|
|
264
|
+
// Skip malformed lines
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { entries, newOffset: stat.size };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Scan all .jsonl files in the audit directory and return all entries
|
|
273
|
+
* plus the current byte offsets for each file (for subsequent tailing).
|
|
274
|
+
* Optionally filter to a single engagement ID.
|
|
275
|
+
*/
|
|
276
|
+
export function scanExistingEntries(
|
|
277
|
+
auditDir: string,
|
|
278
|
+
engagementId?: string
|
|
279
|
+
): ScanResult {
|
|
280
|
+
if (!fs.existsSync(auditDir)) {
|
|
281
|
+
return { entries: [], offsets: new Map() };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const files = fs.readdirSync(auditDir).filter((f) => f.endsWith(".jsonl"));
|
|
285
|
+
const allEntries: AuditEntry[] = [];
|
|
286
|
+
const offsets = new Map<string, number>();
|
|
287
|
+
|
|
288
|
+
for (const file of files) {
|
|
289
|
+
if (engagementId && file !== `${engagementId}.jsonl`) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const filePath = path.join(auditDir, file);
|
|
293
|
+
const result = readNewEntries(filePath, 0);
|
|
294
|
+
allEntries.push(...result.entries);
|
|
295
|
+
offsets.set(filePath, result.newOffset);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
allEntries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
299
|
+
return { entries: allEntries, offsets };
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
npm run build && npx vitest run tests/unit/audit-tailer.test.ts
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Expected: All 7 tests PASS.
|
|
310
|
+
|
|
311
|
+
- [ ] **Step 5: Commit**
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
git add src/tui/audit-tailer.ts tests/unit/audit-tailer.test.ts
|
|
315
|
+
git commit -m "feat: add JSONL audit tailer for TUI monitor"
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
### Task 3: Build the DecisionRow component
|
|
321
|
+
|
|
322
|
+
**Files:**
|
|
323
|
+
- Create: `src/tui/DecisionRow.tsx`
|
|
324
|
+
|
|
325
|
+
- [ ] **Step 1: Create DecisionRow component**
|
|
326
|
+
|
|
327
|
+
Create `src/tui/DecisionRow.tsx`:
|
|
328
|
+
|
|
329
|
+
```tsx
|
|
330
|
+
import React from "react";
|
|
331
|
+
import { Text } from "ink";
|
|
332
|
+
import type { AuditEntry } from "../types.js";
|
|
333
|
+
|
|
334
|
+
interface DecisionRowProps {
|
|
335
|
+
entry: AuditEntry;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function DecisionRow({ entry }: DecisionRowProps): React.ReactElement {
|
|
339
|
+
const time = entry.timestamp.slice(11, 19); // HH:MM:SS
|
|
340
|
+
const isBlocked = entry.decision.startsWith("BLOCKED");
|
|
341
|
+
const isNoTarget = entry.decision === "ALLOWED_NO_TARGET";
|
|
342
|
+
|
|
343
|
+
const icon = isBlocked ? "✗" : "✓";
|
|
344
|
+
const color = isBlocked ? "red" : isNoTarget ? "gray" : "green";
|
|
345
|
+
|
|
346
|
+
const shortDecision = entry.decision.replace("BLOCKED_", "");
|
|
347
|
+
const target = entry.extracted_target ?? "—";
|
|
348
|
+
const reason = isBlocked ? ` [${shortDecision}]` : "";
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
<Text color={color}>
|
|
352
|
+
{time} {icon} {entry.decision.split("_")[0].padEnd(7)} {entry.tool_name} → {target}{reason} ({entry.duration_ms}ms)
|
|
353
|
+
</Text>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
- [ ] **Step 2: Verify build**
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
npm run build
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Expected: Compiles without errors.
|
|
365
|
+
|
|
366
|
+
- [ ] **Step 3: Commit**
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
git add src/tui/DecisionRow.tsx
|
|
370
|
+
git commit -m "feat: add DecisionRow TUI component"
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
### Task 4: Build the EngagementHeader component
|
|
376
|
+
|
|
377
|
+
**Files:**
|
|
378
|
+
- Create: `src/tui/EngagementHeader.tsx`
|
|
379
|
+
|
|
380
|
+
- [ ] **Step 1: Create EngagementHeader component**
|
|
381
|
+
|
|
382
|
+
Create `src/tui/EngagementHeader.tsx`:
|
|
383
|
+
|
|
384
|
+
```tsx
|
|
385
|
+
import React from "react";
|
|
386
|
+
import { Box, Text } from "ink";
|
|
387
|
+
import type { AuditEntry } from "../types.js";
|
|
388
|
+
|
|
389
|
+
interface EngagementHeaderProps {
|
|
390
|
+
entry: AuditEntry | null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function EngagementHeader({ entry }: EngagementHeaderProps): React.ReactElement {
|
|
394
|
+
if (!entry) {
|
|
395
|
+
return (
|
|
396
|
+
<Box borderStyle="single" paddingX={1}>
|
|
397
|
+
<Text dimColor>Waiting for audit data...</Text>
|
|
398
|
+
</Box>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return (
|
|
403
|
+
<Box borderStyle="single" paddingX={1} flexDirection="column">
|
|
404
|
+
<Text bold>
|
|
405
|
+
{entry.engagement_id} <Text dimColor>|</Text> {entry.client} <Text dimColor>|</Text> {entry.operator}
|
|
406
|
+
</Text>
|
|
407
|
+
</Box>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
- [ ] **Step 2: Verify build**
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
npm run build
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Expected: Compiles without errors.
|
|
419
|
+
|
|
420
|
+
- [ ] **Step 3: Commit**
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
git add src/tui/EngagementHeader.tsx
|
|
424
|
+
git commit -m "feat: add EngagementHeader TUI component"
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
### Task 5: Build the StatsPanel component
|
|
430
|
+
|
|
431
|
+
**Files:**
|
|
432
|
+
- Create: `src/tui/StatsPanel.tsx`
|
|
433
|
+
|
|
434
|
+
- [ ] **Step 1: Create StatsPanel component**
|
|
435
|
+
|
|
436
|
+
Create `src/tui/StatsPanel.tsx`:
|
|
437
|
+
|
|
438
|
+
```tsx
|
|
439
|
+
import React from "react";
|
|
440
|
+
import { Box, Text } from "ink";
|
|
441
|
+
import type { AuditEntry } from "../types.js";
|
|
442
|
+
|
|
443
|
+
interface StatsPanelProps {
|
|
444
|
+
entries: AuditEntry[];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function topN(counts: Map<string, number>, n: number): string {
|
|
448
|
+
return [...counts.entries()]
|
|
449
|
+
.sort((a, b) => b[1] - a[1])
|
|
450
|
+
.slice(0, n)
|
|
451
|
+
.map(([name, count]) => `${name} (${count})`)
|
|
452
|
+
.join(" ");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function StatsPanel({ entries }: StatsPanelProps): React.ReactElement {
|
|
456
|
+
const allowed = entries.filter((e) => e.decision.startsWith("ALLOWED")).length;
|
|
457
|
+
const blocked = entries.length - allowed;
|
|
458
|
+
|
|
459
|
+
const targetCounts = new Map<string, number>();
|
|
460
|
+
const toolCounts = new Map<string, number>();
|
|
461
|
+
|
|
462
|
+
for (const e of entries) {
|
|
463
|
+
if (e.extracted_target) {
|
|
464
|
+
targetCounts.set(e.extracted_target, (targetCounts.get(e.extracted_target) ?? 0) + 1);
|
|
465
|
+
}
|
|
466
|
+
toolCounts.set(e.tool_name, (toolCounts.get(e.tool_name) ?? 0) + 1);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return (
|
|
470
|
+
<Box borderStyle="single" paddingX={1} flexDirection="column">
|
|
471
|
+
<Text>
|
|
472
|
+
<Text color="green">✓ Allowed: {allowed}</Text>
|
|
473
|
+
{" "}
|
|
474
|
+
<Text color="red">✗ Blocked: {blocked}</Text>
|
|
475
|
+
{" "}
|
|
476
|
+
<Text>Total: {entries.length}</Text>
|
|
477
|
+
</Text>
|
|
478
|
+
{targetCounts.size > 0 && (
|
|
479
|
+
<Text dimColor>Targets: {topN(targetCounts, 3)}</Text>
|
|
480
|
+
)}
|
|
481
|
+
{toolCounts.size > 0 && (
|
|
482
|
+
<Text dimColor>Tools: {topN(toolCounts, 3)}</Text>
|
|
483
|
+
)}
|
|
484
|
+
</Box>
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
- [ ] **Step 2: Verify build**
|
|
490
|
+
|
|
491
|
+
```bash
|
|
492
|
+
npm run build
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Expected: Compiles without errors.
|
|
496
|
+
|
|
497
|
+
- [ ] **Step 3: Commit**
|
|
498
|
+
|
|
499
|
+
```bash
|
|
500
|
+
git add src/tui/StatsPanel.tsx
|
|
501
|
+
git commit -m "feat: add StatsPanel TUI component"
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
### Task 6: Build the LiveFeed component
|
|
507
|
+
|
|
508
|
+
**Files:**
|
|
509
|
+
- Create: `src/tui/LiveFeed.tsx`
|
|
510
|
+
|
|
511
|
+
- [ ] **Step 1: Create LiveFeed component**
|
|
512
|
+
|
|
513
|
+
Create `src/tui/LiveFeed.tsx`:
|
|
514
|
+
|
|
515
|
+
```tsx
|
|
516
|
+
import React from "react";
|
|
517
|
+
import { Box, Text } from "ink";
|
|
518
|
+
import type { AuditEntry } from "../types.js";
|
|
519
|
+
import { DecisionRow } from "./DecisionRow.js";
|
|
520
|
+
|
|
521
|
+
interface LiveFeedProps {
|
|
522
|
+
entries: AuditEntry[];
|
|
523
|
+
maxRows: number;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export function LiveFeed({ entries, maxRows }: LiveFeedProps): React.ReactElement {
|
|
527
|
+
const visible = entries.slice(-maxRows);
|
|
528
|
+
|
|
529
|
+
if (visible.length === 0) {
|
|
530
|
+
return (
|
|
531
|
+
<Box borderStyle="single" paddingX={1} flexDirection="column">
|
|
532
|
+
<Text bold> LIVE FEED</Text>
|
|
533
|
+
<Text dimColor>No decisions yet...</Text>
|
|
534
|
+
</Box>
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return (
|
|
539
|
+
<Box borderStyle="single" paddingX={1} flexDirection="column">
|
|
540
|
+
<Text bold> LIVE FEED</Text>
|
|
541
|
+
{visible.map((entry, i) => (
|
|
542
|
+
<DecisionRow key={`${entry.timestamp}-${i}`} entry={entry} />
|
|
543
|
+
))}
|
|
544
|
+
</Box>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
- [ ] **Step 2: Verify build**
|
|
550
|
+
|
|
551
|
+
```bash
|
|
552
|
+
npm run build
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Expected: Compiles without errors.
|
|
556
|
+
|
|
557
|
+
- [ ] **Step 3: Commit**
|
|
558
|
+
|
|
559
|
+
```bash
|
|
560
|
+
git add src/tui/LiveFeed.tsx
|
|
561
|
+
git commit -m "feat: add LiveFeed TUI component"
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
---
|
|
565
|
+
|
|
566
|
+
### Task 7: Build the App root component and useAuditWatcher hook
|
|
567
|
+
|
|
568
|
+
**Files:**
|
|
569
|
+
- Create: `src/tui/useAuditWatcher.ts`
|
|
570
|
+
- Create: `src/tui/App.tsx`
|
|
571
|
+
|
|
572
|
+
- [ ] **Step 1: Create the useAuditWatcher hook**
|
|
573
|
+
|
|
574
|
+
Create `src/tui/useAuditWatcher.ts`:
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
import { useState, useEffect, useRef } from "react";
|
|
578
|
+
import * as fs from "node:fs";
|
|
579
|
+
import * as path from "node:path";
|
|
580
|
+
import type { AuditEntry } from "../types.js";
|
|
581
|
+
import { readNewEntries, scanExistingEntries } from "./audit-tailer.js";
|
|
582
|
+
|
|
583
|
+
interface WatcherState {
|
|
584
|
+
entries: AuditEntry[];
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function useAuditWatcher(auditDir: string, engagementId?: string): WatcherState {
|
|
588
|
+
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
|
589
|
+
const offsetsRef = useRef<Map<string, number>>(new Map());
|
|
590
|
+
|
|
591
|
+
useEffect(() => {
|
|
592
|
+
// Load existing entries
|
|
593
|
+
const scan = scanExistingEntries(auditDir, engagementId);
|
|
594
|
+
setEntries(scan.entries);
|
|
595
|
+
offsetsRef.current = scan.offsets;
|
|
596
|
+
|
|
597
|
+
// Ensure directory exists for watcher
|
|
598
|
+
if (!fs.existsSync(auditDir)) {
|
|
599
|
+
fs.mkdirSync(auditDir, { recursive: true });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Watch for changes
|
|
603
|
+
const watcher = fs.watch(auditDir, (_event, filename) => {
|
|
604
|
+
if (!filename || !filename.endsWith(".jsonl")) return;
|
|
605
|
+
if (engagementId && filename !== `${engagementId}.jsonl`) return;
|
|
606
|
+
|
|
607
|
+
const filePath = path.join(auditDir, filename);
|
|
608
|
+
const currentOffset = offsetsRef.current.get(filePath) ?? 0;
|
|
609
|
+
const result = readNewEntries(filePath, currentOffset);
|
|
610
|
+
|
|
611
|
+
if (result.entries.length > 0) {
|
|
612
|
+
offsetsRef.current.set(filePath, result.newOffset);
|
|
613
|
+
setEntries((prev) => [...prev, ...result.entries]);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
return () => {
|
|
618
|
+
watcher.close();
|
|
619
|
+
};
|
|
620
|
+
}, [auditDir, engagementId]);
|
|
621
|
+
|
|
622
|
+
return { entries };
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
- [ ] **Step 2: Create the App root component**
|
|
627
|
+
|
|
628
|
+
Create `src/tui/App.tsx`:
|
|
629
|
+
|
|
630
|
+
```tsx
|
|
631
|
+
import React from "react";
|
|
632
|
+
import { Box, useStdout } from "ink";
|
|
633
|
+
import { EngagementHeader } from "./EngagementHeader.js";
|
|
634
|
+
import { StatsPanel } from "./StatsPanel.js";
|
|
635
|
+
import { LiveFeed } from "./LiveFeed.js";
|
|
636
|
+
import { useAuditWatcher } from "./useAuditWatcher.js";
|
|
637
|
+
|
|
638
|
+
interface AppProps {
|
|
639
|
+
auditDir: string;
|
|
640
|
+
engagementId?: string;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function App({ auditDir, engagementId }: AppProps): React.ReactElement {
|
|
644
|
+
const { entries } = useAuditWatcher(auditDir, engagementId);
|
|
645
|
+
const { stdout } = useStdout();
|
|
646
|
+
const termHeight = stdout?.rows ?? 24;
|
|
647
|
+
|
|
648
|
+
// Reserve rows for header (~3), stats (~4), feed border/title (~2), padding
|
|
649
|
+
const feedRows = Math.max(5, termHeight - 12);
|
|
650
|
+
const firstEntry = entries.length > 0 ? entries[0] : null;
|
|
651
|
+
|
|
652
|
+
return (
|
|
653
|
+
<Box flexDirection="column">
|
|
654
|
+
<EngagementHeader entry={firstEntry} />
|
|
655
|
+
<StatsPanel entries={entries} />
|
|
656
|
+
<LiveFeed entries={entries} maxRows={feedRows} />
|
|
657
|
+
</Box>
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
- [ ] **Step 3: Verify build**
|
|
663
|
+
|
|
664
|
+
```bash
|
|
665
|
+
npm run build
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
Expected: Compiles without errors.
|
|
669
|
+
|
|
670
|
+
- [ ] **Step 4: Commit**
|
|
671
|
+
|
|
672
|
+
```bash
|
|
673
|
+
git add src/tui/useAuditWatcher.ts src/tui/App.tsx
|
|
674
|
+
git commit -m "feat: add App root component and useAuditWatcher hook"
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
---
|
|
678
|
+
|
|
679
|
+
### Task 8: Build the CLI entry point
|
|
680
|
+
|
|
681
|
+
**Files:**
|
|
682
|
+
- Create: `bin/integsec-agentic-pentest-tui.tsx`
|
|
683
|
+
|
|
684
|
+
- [ ] **Step 1: Create the entry point**
|
|
685
|
+
|
|
686
|
+
Create `bin/integsec-agentic-pentest-tui.tsx`:
|
|
687
|
+
|
|
688
|
+
```tsx
|
|
689
|
+
#!/usr/bin/env node
|
|
690
|
+
|
|
691
|
+
import React from "react";
|
|
692
|
+
import { render } from "ink";
|
|
693
|
+
import { App } from "../src/tui/App.js";
|
|
694
|
+
|
|
695
|
+
function parseArgs(): { auditDir: string; engagementId?: string } {
|
|
696
|
+
const args = process.argv.slice(2);
|
|
697
|
+
let auditDir = process.env.AUDIT_LOG_PATH ?? "./audit";
|
|
698
|
+
let engagementId: string | undefined;
|
|
699
|
+
|
|
700
|
+
for (let i = 0; i < args.length; i++) {
|
|
701
|
+
if (args[i] === "--audit-path" && args[i + 1]) {
|
|
702
|
+
auditDir = args[i + 1];
|
|
703
|
+
i++;
|
|
704
|
+
} else if (args[i] === "--engagement" && args[i + 1]) {
|
|
705
|
+
engagementId = args[i + 1];
|
|
706
|
+
i++;
|
|
707
|
+
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
708
|
+
console.log(`
|
|
709
|
+
IntegSec Agentic Pentest MCP Proxy — TUI Monitor
|
|
710
|
+
|
|
711
|
+
Usage:
|
|
712
|
+
integsec-agentic-pentest-tui [options]
|
|
713
|
+
|
|
714
|
+
Options:
|
|
715
|
+
--audit-path <dir> Path to audit log directory (default: AUDIT_LOG_PATH or ./audit)
|
|
716
|
+
--engagement <id> Filter to a specific engagement ID
|
|
717
|
+
-h, --help Show this help message
|
|
718
|
+
`);
|
|
719
|
+
process.exit(0);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return { auditDir, engagementId };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const { auditDir, engagementId } = parseArgs();
|
|
727
|
+
render(<App auditDir={auditDir} engagementId={engagementId} />);
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
- [ ] **Step 2: Build and verify**
|
|
731
|
+
|
|
732
|
+
```bash
|
|
733
|
+
npm run build
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
Expected: Compiles, `dist/bin/integsec-agentic-pentest-tui.js` exists.
|
|
737
|
+
|
|
738
|
+
- [ ] **Step 3: Smoke test with empty audit directory**
|
|
739
|
+
|
|
740
|
+
```bash
|
|
741
|
+
mkdir -p ./audit-test
|
|
742
|
+
node dist/bin/integsec-agentic-pentest-tui.js --audit-path ./audit-test
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
Expected: Shows engagement header with "Waiting for audit data..." and empty live feed. Ctrl+C to exit.
|
|
746
|
+
|
|
747
|
+
```bash
|
|
748
|
+
rm -rf ./audit-test
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
- [ ] **Step 4: Commit**
|
|
752
|
+
|
|
753
|
+
```bash
|
|
754
|
+
git add bin/integsec-agentic-pentest-tui.tsx
|
|
755
|
+
git commit -m "feat: add TUI monitor CLI entry point"
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
---
|
|
759
|
+
|
|
760
|
+
### Task 9: Run full test suite and update README
|
|
761
|
+
|
|
762
|
+
**Files:**
|
|
763
|
+
- Modify: `README.md`
|
|
764
|
+
- Modify: `package.json` (files field)
|
|
765
|
+
|
|
766
|
+
- [ ] **Step 1: Run full test suite**
|
|
767
|
+
|
|
768
|
+
```bash
|
|
769
|
+
npm run build && npm test
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Expected: All tests pass (existing 598 + 7 new audit-tailer tests = 605).
|
|
773
|
+
|
|
774
|
+
- [ ] **Step 2: Add TUI section to README**
|
|
775
|
+
|
|
776
|
+
In `README.md`, add a new section after the "Usage" section and before "How Validation Works". Add the following:
|
|
777
|
+
|
|
778
|
+
```markdown
|
|
779
|
+
## TUI Monitor
|
|
780
|
+
|
|
781
|
+
A real-time terminal dashboard for monitoring proxy decisions as they happen.
|
|
782
|
+
|
|
783
|
+
```bash
|
|
784
|
+
# Monitor the default audit directory
|
|
785
|
+
integsec-agentic-pentest-tui
|
|
786
|
+
|
|
787
|
+
# Monitor a specific directory
|
|
788
|
+
integsec-agentic-pentest-tui --audit-path /path/to/audit
|
|
789
|
+
|
|
790
|
+
# Filter to a specific engagement
|
|
791
|
+
integsec-agentic-pentest-tui --engagement ENG-2026-TEST-001
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
The TUI displays three panels:
|
|
795
|
+
|
|
796
|
+
- **Engagement Header** — engagement ID, client, operator
|
|
797
|
+
- **Stats Panel** — running allowed/blocked counts, top targets, top tools
|
|
798
|
+
- **Live Feed** — color-coded scrolling decision feed (green = allowed, red = blocked)
|
|
799
|
+
|
|
800
|
+
Run the TUI in a separate terminal while the proxy is active (via Claude Desktop, Claude Code, or CLI). It tails the audit JSONL files and updates in real time.
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
- [ ] **Step 3: Add TUI to Table of Contents**
|
|
804
|
+
|
|
805
|
+
In the Table of Contents section, add after the Docker entry:
|
|
806
|
+
|
|
807
|
+
```markdown
|
|
808
|
+
- [TUI Monitor](#tui-monitor)
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
- [ ] **Step 4: Ensure TUI files are included in npm package**
|
|
812
|
+
|
|
813
|
+
The `"files"` field in `package.json` already includes `"dist/"` which covers the compiled TUI. No change needed.
|
|
814
|
+
|
|
815
|
+
- [ ] **Step 5: Commit**
|
|
816
|
+
|
|
817
|
+
```bash
|
|
818
|
+
git add README.md
|
|
819
|
+
git commit -m "docs: add TUI monitor section to README"
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
---
|
|
823
|
+
|
|
824
|
+
### Task 10: Bump version, publish, and push
|
|
825
|
+
|
|
826
|
+
**Files:**
|
|
827
|
+
- Modify: `package.json` (version)
|
|
828
|
+
|
|
829
|
+
- [ ] **Step 1: Bump version**
|
|
830
|
+
|
|
831
|
+
```bash
|
|
832
|
+
npm version minor --no-git-tag-version
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
This bumps to 0.2.0 since TUI is a new feature.
|
|
836
|
+
|
|
837
|
+
- [ ] **Step 2: Build and run full test suite one more time**
|
|
838
|
+
|
|
839
|
+
```bash
|
|
840
|
+
npm run build && npm test
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
Expected: All tests pass.
|
|
844
|
+
|
|
845
|
+
- [ ] **Step 3: Commit and push**
|
|
846
|
+
|
|
847
|
+
```bash
|
|
848
|
+
git add -A
|
|
849
|
+
git commit -m "feat: add TUI monitor dashboard (v0.2.0)"
|
|
850
|
+
git push
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
- [ ] **Step 4: Publish to npm**
|
|
854
|
+
|
|
855
|
+
```bash
|
|
856
|
+
npm publish --access public
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
- [ ] **Step 5: Update global install**
|
|
860
|
+
|
|
861
|
+
```bash
|
|
862
|
+
npm install -g @integsec/agentic-pentest-proxy@latest
|
|
863
|
+
```
|