@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.
Files changed (47) hide show
  1. package/README.md +2 -0
  2. package/cloudflare/wrangler.jsonc +1 -1
  3. package/dist/bin/integsec-agentic-pentest-bridge.js +2 -2
  4. package/dist/bin/integsec-agentic-pentest-gateway.js +30 -2
  5. package/dist/bin/integsec-agentic-pentest-gateway.js.map +1 -1
  6. package/dist/bin/integsec-agentic-pentest-tui.js +5 -0
  7. package/dist/bin/integsec-agentic-pentest-tui.js.map +1 -1
  8. package/dist/src/audit/local.js +2 -2
  9. package/dist/src/audit/local.js.map +1 -1
  10. package/dist/src/config.js +1 -1
  11. package/dist/src/gateway/admin.html +3 -3
  12. package/dist/src/gateway/docs-home.html +20 -0
  13. package/dist/src/gateway/docs.d.ts +3 -0
  14. package/dist/src/gateway/docs.d.ts.map +1 -0
  15. package/dist/src/gateway/docs.html +12 -0
  16. package/dist/src/gateway/docs.js +94 -0
  17. package/dist/src/gateway/docs.js.map +1 -0
  18. package/dist/src/gateway/local-paths.d.ts +7 -0
  19. package/dist/src/gateway/local-paths.d.ts.map +1 -0
  20. package/dist/src/gateway/local-paths.js +30 -0
  21. package/dist/src/gateway/local-paths.js.map +1 -0
  22. package/dist/src/gateway/runtime.js +2 -2
  23. package/dist/src/gateway/server.d.ts.map +1 -1
  24. package/dist/src/gateway/server.js +24 -0
  25. package/dist/src/gateway/server.js.map +1 -1
  26. package/dist/src/gateway/state.d.ts +1 -1
  27. package/dist/src/index.d.ts +1 -1
  28. package/dist/src/index.js +1 -1
  29. package/docs/configuration.md +70 -0
  30. package/docs/development.md +17 -0
  31. package/docs/images/tui-in-scope.png +0 -0
  32. package/docs/images/tui-in-scope.svg +24 -0
  33. package/docs/images/tui-out-of-scope.png +0 -0
  34. package/docs/images/tui-out-of-scope.svg +25 -0
  35. package/docs/images/tui-policy-blocks.png +0 -0
  36. package/docs/images/tui-policy-blocks.svg +24 -0
  37. package/docs/integrations.md +74 -0
  38. package/docs/managed-gateway.md +4 -0
  39. package/docs/release-readiness-0.2.6.md +27 -0
  40. package/docs/security.md +33 -0
  41. package/docs/superpowers/plans/2026-03-26-mcp-scope-proxy.md +3051 -0
  42. package/docs/superpowers/plans/2026-03-28-tui-monitor.md +863 -0
  43. package/docs/superpowers/plans/2026-03-29-manifest-builder.md +3429 -0
  44. package/docs/superpowers/specs/2026-03-28-tui-monitor-design.md +138 -0
  45. package/docs/superpowers/specs/2026-03-29-manifest-builder-design.md +248 -0
  46. package/docs/tui.md +27 -0
  47. package/package.json +4 -2
@@ -0,0 +1,3429 @@
1
+ # Scope Manifest Builder 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 CLI wizard and optional web companion for building, editing, reviewing, and sharing MCP scope manifests with smart validation and dry-run previews.
6
+
7
+ **Architecture:** Three layers — shared validation core (smart-validator, dry-run engine, manifest writer), Ink-based CLI wizard with 8 steps, and a Node http/https web server for client intake. The CLI is primary; the web server is on-demand via `serve` subcommand.
8
+
9
+ **Tech Stack:** TypeScript (ES2022, Node16 modules), React + Ink for TUI, Zod for validation, Node built-in http/https for web server, Vitest for tests. No new dependencies.
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ ```
16
+ src/
17
+ builder/
18
+ smart-validator.ts ← Tier 1-2 validation (shared core)
19
+ dry-run.ts ← Tier 3 dry-run engine (shared core)
20
+ manifest-writer.ts ← Serialize + write manifest to disk
21
+ wizard.tsx ← Ink wizard (main flow, step management)
22
+ steps/
23
+ engagement-info.tsx ← Step 1: ID, client, operator
24
+ engagement-window.tsx ← Step 2: start/end dates
25
+ domains.tsx ← Step 3: domain entry + live DNS
26
+ ip-ranges.tsx ← Step 4: CIDR entry + warnings
27
+ urls-and-cloud.tsx ← Step 5: URLs, cloud accounts
28
+ exclusions.tsx ← Step 6: excluded targets
29
+ techniques.tsx ← Step 7: technique multi-select
30
+ review.tsx ← Step 8: summary + dry-run table
31
+ web/
32
+ server.ts ← Node http/https server + TLS enforcement
33
+ api.ts ← GET /template, POST /submit handlers
34
+ client-form.html ← Single-file client intake form
35
+
36
+ bin/
37
+ integsec-agentic-pentest-manifest.ts ← CLI entry point (new/edit/review/serve)
38
+
39
+ tests/
40
+ unit/
41
+ builder/
42
+ smart-validator.test.ts
43
+ dry-run.test.ts
44
+ manifest-writer.test.ts
45
+ builder-web/
46
+ server.test.ts
47
+ api.test.ts
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Phase 1: Shared Core + CLI Wizard (`new` mode)
53
+
54
+ ### Task 1: Smart Validator — Tier 1 Format Validation
55
+
56
+ **Files:**
57
+ - Create: `src/builder/smart-validator.ts`
58
+ - Test: `tests/unit/builder/smart-validator.test.ts`
59
+
60
+ - [ ] **Step 1: Write failing tests for Tier 1 format validation**
61
+
62
+ ```typescript
63
+ // tests/unit/builder/smart-validator.test.ts
64
+ import { describe, it, expect } from "vitest";
65
+ import {
66
+ validateEngagementId,
67
+ validateCidr,
68
+ validateDomain,
69
+ validateIsoDate,
70
+ validateWindowSanity,
71
+ type ValidationMessage,
72
+ } from "../../src/builder/smart-validator.js";
73
+
74
+ describe("SmartValidator — Tier 1: Format Validation", () => {
75
+ describe("validateEngagementId", () => {
76
+ it("accepts a valid engagement ID", () => {
77
+ const msgs = validateEngagementId("ENG-2026-0142");
78
+ expect(msgs).toEqual([]);
79
+ });
80
+
81
+ it("rejects empty string", () => {
82
+ const msgs = validateEngagementId("");
83
+ expect(msgs).toHaveLength(1);
84
+ expect(msgs[0].level).toBe("error");
85
+ });
86
+
87
+ it("suggests format when ID does not match ENG-YYYY-NNNN", () => {
88
+ const msgs = validateEngagementId("my-test");
89
+ expect(msgs).toHaveLength(1);
90
+ expect(msgs[0].level).toBe("info");
91
+ expect(msgs[0].message).toContain("ENG-YYYY-");
92
+ });
93
+ });
94
+
95
+ describe("validateCidr", () => {
96
+ it("accepts a valid CIDR", () => {
97
+ const msgs = validateCidr("10.10.10.0/24");
98
+ expect(msgs).toEqual([]);
99
+ });
100
+
101
+ it("rejects invalid CIDR syntax", () => {
102
+ const msgs = validateCidr("not-a-cidr");
103
+ expect(msgs).toHaveLength(1);
104
+ expect(msgs[0].level).toBe("error");
105
+ });
106
+
107
+ it("warns on broad range wider than /16", () => {
108
+ const msgs = validateCidr("10.0.0.0/8");
109
+ expect(msgs).toHaveLength(1);
110
+ expect(msgs[0].level).toBe("warning");
111
+ expect(msgs[0].message).toContain("16,777,216");
112
+ });
113
+
114
+ it("accepts /16 without warning", () => {
115
+ const msgs = validateCidr("10.10.0.0/16");
116
+ expect(msgs).toEqual([]);
117
+ });
118
+
119
+ it("accepts bare IP as /32", () => {
120
+ const msgs = validateCidr("10.10.10.5");
121
+ expect(msgs).toEqual([]);
122
+ });
123
+ });
124
+
125
+ describe("validateDomain", () => {
126
+ it("accepts a valid domain", () => {
127
+ const msgs = validateDomain("app.acme.com");
128
+ expect(msgs).toEqual([]);
129
+ });
130
+
131
+ it("accepts a wildcard domain", () => {
132
+ const msgs = validateDomain("*.acme.com");
133
+ expect(msgs).toEqual([]);
134
+ });
135
+
136
+ it("rejects domain with spaces", () => {
137
+ const msgs = validateDomain("app .acme.com");
138
+ expect(msgs).toHaveLength(1);
139
+ expect(msgs[0].level).toBe("error");
140
+ });
141
+
142
+ it("rejects empty string", () => {
143
+ const msgs = validateDomain("");
144
+ expect(msgs).toHaveLength(1);
145
+ expect(msgs[0].level).toBe("error");
146
+ });
147
+ });
148
+
149
+ describe("validateIsoDate", () => {
150
+ it("accepts valid ISO 8601 datetime", () => {
151
+ const msgs = validateIsoDate("2026-03-29T08:00:00Z");
152
+ expect(msgs).toEqual([]);
153
+ });
154
+
155
+ it("accepts date-only string", () => {
156
+ const msgs = validateIsoDate("2026-03-29");
157
+ expect(msgs).toEqual([]);
158
+ });
159
+
160
+ it("rejects invalid date", () => {
161
+ const msgs = validateIsoDate("not-a-date");
162
+ expect(msgs).toHaveLength(1);
163
+ expect(msgs[0].level).toBe("error");
164
+ });
165
+
166
+ it("rejects empty string", () => {
167
+ const msgs = validateIsoDate("");
168
+ expect(msgs).toHaveLength(1);
169
+ expect(msgs[0].level).toBe("error");
170
+ });
171
+ });
172
+
173
+ describe("validateWindowSanity", () => {
174
+ it("returns no messages for a normal window", () => {
175
+ const start = new Date(Date.now() + 3600_000).toISOString();
176
+ const end = new Date(Date.now() + 7 * 86400_000).toISOString();
177
+ const msgs = validateWindowSanity(start, end);
178
+ expect(msgs).toEqual([]);
179
+ });
180
+
181
+ it("warns if start is in the past", () => {
182
+ const start = "2020-01-01T00:00:00Z";
183
+ const end = new Date(Date.now() + 86400_000).toISOString();
184
+ const msgs = validateWindowSanity(start, end);
185
+ expect(msgs.some((m) => m.level === "warning" && m.message.includes("past"))).toBe(true);
186
+ });
187
+
188
+ it("warns if window exceeds 30 days", () => {
189
+ const start = new Date(Date.now() + 3600_000).toISOString();
190
+ const end = new Date(Date.now() + 60 * 86400_000).toISOString();
191
+ const msgs = validateWindowSanity(start, end);
192
+ expect(msgs.some((m) => m.level === "warning" && m.message.includes("30 days"))).toBe(true);
193
+ });
194
+
195
+ it("warns if window is less than 1 hour", () => {
196
+ const start = new Date(Date.now() + 3600_000).toISOString();
197
+ const end = new Date(Date.now() + 3600_000 + 1800_000).toISOString();
198
+ const msgs = validateWindowSanity(start, end);
199
+ expect(msgs.some((m) => m.level === "warning" && m.message.includes("1 hour"))).toBe(true);
200
+ });
201
+
202
+ it("errors if end is before start", () => {
203
+ const start = new Date(Date.now() + 86400_000).toISOString();
204
+ const end = new Date(Date.now() - 86400_000).toISOString();
205
+ const msgs = validateWindowSanity(start, end);
206
+ expect(msgs.some((m) => m.level === "error" && m.message.includes("before"))).toBe(true);
207
+ });
208
+ });
209
+ });
210
+ ```
211
+
212
+ - [ ] **Step 2: Run tests to verify they fail**
213
+
214
+ Run: `npx vitest run tests/unit/builder/smart-validator.test.ts`
215
+ Expected: FAIL — module not found
216
+
217
+ - [ ] **Step 3: Implement Tier 1 format validation**
218
+
219
+ ```typescript
220
+ // src/builder/smart-validator.ts
221
+
222
+ import IPCIDR from "ip-cidr";
223
+
224
+ export interface ValidationMessage {
225
+ level: "error" | "warning" | "info";
226
+ message: string;
227
+ }
228
+
229
+ /**
230
+ * Validate an engagement ID string.
231
+ * Accepts any non-empty string; suggests ENG-YYYY-NNNN format if it doesn't match.
232
+ */
233
+ export function validateEngagementId(id: string): ValidationMessage[] {
234
+ if (!id || id.trim() === "") {
235
+ return [{ level: "error", message: "Engagement ID must not be empty." }];
236
+ }
237
+ if (!/^ENG-\d{4}-\d{3,}$/.test(id)) {
238
+ return [{ level: "info", message: `Suggested format: ENG-YYYY-NNNN (e.g. ENG-${new Date().getFullYear()}-0001).` }];
239
+ }
240
+ return [];
241
+ }
242
+
243
+ /**
244
+ * Validate a CIDR range string.
245
+ * Accepts bare IPs (treated as /32 or /128). Warns on ranges broader than /16.
246
+ */
247
+ export function validateCidr(cidr: string): ValidationMessage[] {
248
+ if (!cidr || cidr.trim() === "") {
249
+ return [{ level: "error", message: "CIDR range must not be empty." }];
250
+ }
251
+
252
+ // Normalise bare IPs to CIDR notation for validation
253
+ let normalized = cidr;
254
+ if (!cidr.includes("/")) {
255
+ normalized = cidr.includes(":") ? `${cidr}/128` : `${cidr}/32`;
256
+ }
257
+
258
+ if (!IPCIDR.isValidCIDR(normalized)) {
259
+ return [{ level: "error", message: `Invalid CIDR syntax: "${cidr}". Example: 10.10.10.0/24` }];
260
+ }
261
+
262
+ // Warn on broad ranges (IPv4 only — /16 or wider)
263
+ const slashIndex = normalized.indexOf("/");
264
+ if (slashIndex !== -1 && !normalized.includes(":")) {
265
+ const prefix = parseInt(normalized.slice(slashIndex + 1), 10);
266
+ if (prefix < 16) {
267
+ const count = Math.pow(2, 32 - prefix);
268
+ return [{ level: "warning", message: `This range covers ${count.toLocaleString()} addresses. Is /${prefix} intentional?` }];
269
+ }
270
+ }
271
+
272
+ return [];
273
+ }
274
+
275
+ /**
276
+ * Validate a domain string.
277
+ * Accepts exact domains and wildcard domains (*.example.com).
278
+ */
279
+ export function validateDomain(domain: string): ValidationMessage[] {
280
+ if (!domain || domain.trim() === "") {
281
+ return [{ level: "error", message: "Domain must not be empty." }];
282
+ }
283
+ if (/\s/.test(domain)) {
284
+ return [{ level: "error", message: `Domain "${domain}" contains whitespace.` }];
285
+ }
286
+ // Basic DNS label check: labels separated by dots, optional leading *.
287
+ const pattern = /^(\*\.)?([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z]{2,}\.?$/;
288
+ if (!pattern.test(domain)) {
289
+ return [{ level: "error", message: `"${domain}" does not look like a valid domain.` }];
290
+ }
291
+ return [];
292
+ }
293
+
294
+ /**
295
+ * Validate an ISO 8601 date string.
296
+ */
297
+ export function validateIsoDate(date: string): ValidationMessage[] {
298
+ if (!date || date.trim() === "") {
299
+ return [{ level: "error", message: "Date must not be empty." }];
300
+ }
301
+ if (isNaN(Date.parse(date))) {
302
+ return [{ level: "error", message: `"${date}" is not a valid ISO 8601 date. Example: 2026-03-29T08:00:00Z` }];
303
+ }
304
+ return [];
305
+ }
306
+
307
+ /**
308
+ * Validate the engagement window for sanity (start/end relationship, duration).
309
+ * Assumes both dates have already passed format validation.
310
+ */
311
+ export function validateWindowSanity(start: string, end: string): ValidationMessage[] {
312
+ const msgs: ValidationMessage[] = [];
313
+ const startMs = Date.parse(start);
314
+ const endMs = Date.parse(end);
315
+
316
+ if (isNaN(startMs) || isNaN(endMs)) {
317
+ return []; // Format validation handles this
318
+ }
319
+
320
+ if (endMs <= startMs) {
321
+ msgs.push({ level: "error", message: "End date is before or equal to start date." });
322
+ return msgs; // No point checking duration
323
+ }
324
+
325
+ if (startMs < Date.now()) {
326
+ msgs.push({ level: "warning", message: "Start date is in the past." });
327
+ }
328
+
329
+ const durationMs = endMs - startMs;
330
+ const thirtyDaysMs = 30 * 24 * 3600_000;
331
+ const oneHourMs = 3600_000;
332
+
333
+ if (durationMs > thirtyDaysMs) {
334
+ msgs.push({ level: "warning", message: `Engagement window exceeds 30 days (${Math.round(durationMs / 86400_000)} days).` });
335
+ }
336
+
337
+ if (durationMs < oneHourMs) {
338
+ msgs.push({ level: "warning", message: "Engagement window is less than 1 hour." });
339
+ }
340
+
341
+ return msgs;
342
+ }
343
+ ```
344
+
345
+ - [ ] **Step 4: Run tests to verify they pass**
346
+
347
+ Run: `npx vitest run tests/unit/builder/smart-validator.test.ts`
348
+ Expected: All tests PASS
349
+
350
+ - [ ] **Step 5: Commit**
351
+
352
+ ```bash
353
+ git add src/builder/smart-validator.ts tests/unit/builder/smart-validator.test.ts
354
+ git commit -m "feat(builder): add Tier 1 format validation for smart-validator"
355
+ ```
356
+
357
+ ---
358
+
359
+ ### Task 2: Smart Validator — Tier 2 Async Validation
360
+
361
+ **Files:**
362
+ - Modify: `src/builder/smart-validator.ts`
363
+ - Modify: `tests/unit/builder/smart-validator.test.ts`
364
+
365
+ - [ ] **Step 1: Write failing tests for Tier 2 async validation**
366
+
367
+ Add to `tests/unit/builder/smart-validator.test.ts`:
368
+
369
+ ```typescript
370
+ import {
371
+ validateEngagementId,
372
+ validateCidr,
373
+ validateDomain,
374
+ validateIsoDate,
375
+ validateWindowSanity,
376
+ resolveDomainPreview,
377
+ checkOverlap,
378
+ checkWildcardApex,
379
+ checkOrphanedExclusion,
380
+ type ValidationMessage,
381
+ } from "../../src/builder/smart-validator.js";
382
+ import { DnsResolver } from "../../src/dns-resolver.js";
383
+ import { vi } from "vitest";
384
+
385
+ // ... existing tests ...
386
+
387
+ describe("SmartValidator — Tier 2: Async Validation", () => {
388
+ function mockResolver(resolveImpl: (target: string) => Promise<string[]>): DnsResolver {
389
+ const resolver = new DnsResolver();
390
+ resolver.resolve = vi.fn(resolveImpl);
391
+ return resolver;
392
+ }
393
+
394
+ describe("resolveDomainPreview", () => {
395
+ it("returns resolved IPs for a domain", async () => {
396
+ const resolver = mockResolver(async () => ["10.10.10.1", "10.10.10.2"]);
397
+ const result = await resolveDomainPreview("app.acme.com", resolver);
398
+ expect(result.ips).toEqual(["10.10.10.1", "10.10.10.2"]);
399
+ expect(result.messages).toEqual([]);
400
+ });
401
+
402
+ it("returns warning when DNS resolution fails", async () => {
403
+ const resolver = mockResolver(async () => []);
404
+ const result = await resolveDomainPreview("nonexistent.example.com", resolver);
405
+ expect(result.ips).toEqual([]);
406
+ expect(result.messages).toHaveLength(1);
407
+ expect(result.messages[0].level).toBe("warning");
408
+ });
409
+ });
410
+
411
+ describe("checkOverlap", () => {
412
+ it("returns info when domain resolves to IP already in an authorized range", () => {
413
+ const msgs = checkOverlap(["10.10.10.5"], ["10.10.10.0/24"]);
414
+ expect(msgs).toHaveLength(1);
415
+ expect(msgs[0].level).toBe("info");
416
+ expect(msgs[0].message).toContain("already covered");
417
+ });
418
+
419
+ it("returns no message when IPs are not in any authorized range", () => {
420
+ const msgs = checkOverlap(["8.8.8.8"], ["10.10.10.0/24"]);
421
+ expect(msgs).toEqual([]);
422
+ });
423
+ });
424
+
425
+ describe("checkWildcardApex", () => {
426
+ it("returns info suggesting apex when wildcard is added", () => {
427
+ const msgs = checkWildcardApex("*.acme.com", ["*.acme.com"]);
428
+ expect(msgs).toHaveLength(1);
429
+ expect(msgs[0].level).toBe("info");
430
+ expect(msgs[0].message).toContain("acme.com");
431
+ });
432
+
433
+ it("returns no message when apex is already in the list", () => {
434
+ const msgs = checkWildcardApex("*.acme.com", ["*.acme.com", "acme.com"]);
435
+ expect(msgs).toEqual([]);
436
+ });
437
+
438
+ it("returns no message for non-wildcard domain", () => {
439
+ const msgs = checkWildcardApex("app.acme.com", ["app.acme.com"]);
440
+ expect(msgs).toEqual([]);
441
+ });
442
+ });
443
+
444
+ describe("checkOrphanedExclusion", () => {
445
+ it("warns when excluded target is not in any authorized range or domain", () => {
446
+ const msgs = checkOrphanedExclusion(
447
+ "8.8.8.8",
448
+ { ip_ranges: ["10.10.10.0/24"], domains: ["*.acme.com"] }
449
+ );
450
+ expect(msgs).toHaveLength(1);
451
+ expect(msgs[0].level).toBe("warning");
452
+ expect(msgs[0].message).toContain("redundant");
453
+ });
454
+
455
+ it("returns no message when excluded IP is in an authorized range", () => {
456
+ const msgs = checkOrphanedExclusion(
457
+ "10.10.10.5",
458
+ { ip_ranges: ["10.10.10.0/24"], domains: [] }
459
+ );
460
+ expect(msgs).toEqual([]);
461
+ });
462
+
463
+ it("returns no message when excluded domain matches an authorized pattern", () => {
464
+ const msgs = checkOrphanedExclusion(
465
+ "hr.acme.com",
466
+ { ip_ranges: [], domains: ["*.acme.com"] }
467
+ );
468
+ expect(msgs).toEqual([]);
469
+ });
470
+ });
471
+ });
472
+ ```
473
+
474
+ - [ ] **Step 2: Run tests to verify Tier 2 tests fail**
475
+
476
+ Run: `npx vitest run tests/unit/builder/smart-validator.test.ts`
477
+ Expected: FAIL — new functions not exported
478
+
479
+ - [ ] **Step 3: Implement Tier 2 async validation**
480
+
481
+ Add to `src/builder/smart-validator.ts`:
482
+
483
+ ```typescript
484
+ import { DnsResolver } from "../dns-resolver.js";
485
+ import { isIpInCidr } from "../ip-matcher.js";
486
+ import { matchesDomain } from "../domain-matcher.js";
487
+
488
+ // Add to existing imports at top of file
489
+
490
+ export interface DomainPreviewResult {
491
+ ips: string[];
492
+ messages: ValidationMessage[];
493
+ }
494
+
495
+ /**
496
+ * Resolve a domain and return its IPs with any warnings.
497
+ */
498
+ export async function resolveDomainPreview(
499
+ domain: string,
500
+ resolver: DnsResolver
501
+ ): Promise<DomainPreviewResult> {
502
+ // Strip wildcard prefix for resolution
503
+ const lookupDomain = domain.startsWith("*.") ? domain.slice(2) : domain;
504
+ const ips = await resolver.resolve(lookupDomain);
505
+
506
+ if (ips.length === 0) {
507
+ return {
508
+ ips: [],
509
+ messages: [{ level: "warning", message: `DNS resolution for "${lookupDomain}" returned no addresses.` }],
510
+ };
511
+ }
512
+
513
+ return { ips, messages: [] };
514
+ }
515
+
516
+ /**
517
+ * Check if resolved IPs are already covered by authorized CIDR ranges.
518
+ */
519
+ export function checkOverlap(
520
+ resolvedIps: string[],
521
+ authorizedRanges: string[]
522
+ ): ValidationMessage[] {
523
+ for (const ip of resolvedIps) {
524
+ for (const range of authorizedRanges) {
525
+ if (isIpInCidr(ip, range)) {
526
+ return [{ level: "info", message: `Resolved IP ${ip} is already covered by authorized range ${range}.` }];
527
+ }
528
+ }
529
+ }
530
+ return [];
531
+ }
532
+
533
+ /**
534
+ * If a wildcard domain is being added, check whether the apex is also included.
535
+ */
536
+ export function checkWildcardApex(
537
+ domain: string,
538
+ currentDomains: string[]
539
+ ): ValidationMessage[] {
540
+ if (!domain.startsWith("*.")) return [];
541
+
542
+ const apex = domain.slice(2);
543
+ const hasApex = currentDomains.some(
544
+ (d) => d.toLowerCase() === apex.toLowerCase()
545
+ );
546
+
547
+ if (!hasApex) {
548
+ return [{
549
+ level: "info",
550
+ message: `Wildcard "${domain}" does not cover the apex "${apex}". Add "${apex}" separately if needed.`,
551
+ }];
552
+ }
553
+
554
+ return [];
555
+ }
556
+
557
+ /**
558
+ * Check if an excluded target actually falls within any authorized range or domain.
559
+ * If not, the exclusion is redundant.
560
+ */
561
+ export function checkOrphanedExclusion(
562
+ excludedTarget: string,
563
+ authorized: { ip_ranges: string[]; domains: string[] }
564
+ ): ValidationMessage[] {
565
+ // Check if it matches an authorized IP range
566
+ for (const range of authorized.ip_ranges) {
567
+ if (isIpInCidr(excludedTarget, range)) {
568
+ return [];
569
+ }
570
+ }
571
+
572
+ // Check if it matches an authorized domain pattern
573
+ for (const pattern of authorized.domains) {
574
+ if (matchesDomain(excludedTarget, pattern)) {
575
+ return [];
576
+ }
577
+ }
578
+
579
+ return [{
580
+ level: "warning",
581
+ message: `Excluded target "${excludedTarget}" does not fall within any authorized range or domain — this exclusion is redundant.`,
582
+ }];
583
+ }
584
+ ```
585
+
586
+ - [ ] **Step 4: Run tests to verify they pass**
587
+
588
+ Run: `npx vitest run tests/unit/builder/smart-validator.test.ts`
589
+ Expected: All tests PASS
590
+
591
+ - [ ] **Step 5: Commit**
592
+
593
+ ```bash
594
+ git add src/builder/smart-validator.ts tests/unit/builder/smart-validator.test.ts
595
+ git commit -m "feat(builder): add Tier 2 async validation (DNS preview, overlap, wildcard, orphan)"
596
+ ```
597
+
598
+ ---
599
+
600
+ ### Task 3: Dry-Run Engine
601
+
602
+ **Files:**
603
+ - Create: `src/builder/dry-run.ts`
604
+ - Test: `tests/unit/builder/dry-run.test.ts`
605
+
606
+ - [ ] **Step 1: Write failing tests for dry-run engine**
607
+
608
+ ```typescript
609
+ // tests/unit/builder/dry-run.test.ts
610
+ import { describe, it, expect, vi } from "vitest";
611
+ import { runDryRun, type DryRunResult } from "../../src/builder/dry-run.js";
612
+ import type { ScopeManifest } from "../../src/types.js";
613
+ import { DnsResolver } from "../../src/dns-resolver.js";
614
+
615
+ const testManifest: ScopeManifest = {
616
+ engagement_id: "ENG-2026-0001",
617
+ client: "Test Corp",
618
+ operator: "test@test.com",
619
+ authorized_targets: {
620
+ ip_ranges: ["10.10.10.0/24"],
621
+ domains: ["*.acme.com"],
622
+ urls: [],
623
+ cloud_accounts: [],
624
+ },
625
+ excluded_targets: ["hr.acme.com"],
626
+ authorized_techniques: ["recon", "web_app"],
627
+ excluded_techniques: ["dos", "destructive"],
628
+ engagement_window: {
629
+ start: "2020-01-01T00:00:00Z",
630
+ end: "2099-12-31T23:59:59Z",
631
+ },
632
+ };
633
+
634
+ function mockResolver(): DnsResolver {
635
+ const resolver = new DnsResolver();
636
+ resolver.resolve = vi.fn(async (target: string) => {
637
+ const map: Record<string, string[]> = {
638
+ "app.acme.com": ["10.10.10.1"],
639
+ "hr.acme.com": ["10.10.10.2"],
640
+ "acme.com": ["10.10.10.3"],
641
+ };
642
+ if (target in map) return map[target];
643
+ // Bare IPs pass through
644
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(target)) return [target];
645
+ return [];
646
+ });
647
+ return resolver;
648
+ }
649
+
650
+ describe("DryRunEngine", () => {
651
+ it("returns ALLOWED for authorized domain", async () => {
652
+ const results = await runDryRun(testManifest, mockResolver());
653
+ const appResult = results.find((r) => r.target === "app.acme.com");
654
+ expect(appResult).toBeDefined();
655
+ expect(appResult!.actual).toBe("ALLOWED");
656
+ expect(appResult!.pass).toBe(true);
657
+ });
658
+
659
+ it("returns BLOCKED for excluded domain", async () => {
660
+ const results = await runDryRun(testManifest, mockResolver());
661
+ const hrResult = results.find((r) => r.target === "hr.acme.com");
662
+ expect(hrResult).toBeDefined();
663
+ expect(hrResult!.actual).toContain("BLOCKED");
664
+ expect(hrResult!.pass).toBe(true); // Expected to be blocked
665
+ });
666
+
667
+ it("returns BLOCKED for out-of-scope target", async () => {
668
+ const results = await runDryRun(testManifest, mockResolver());
669
+ const oosResult = results.find((r) => r.target === "8.8.8.8");
670
+ expect(oosResult).toBeDefined();
671
+ expect(oosResult!.actual).toContain("BLOCKED");
672
+ expect(oosResult!.pass).toBe(true);
673
+ });
674
+
675
+ it("returns BLOCKED for cloud metadata IP", async () => {
676
+ const results = await runDryRun(testManifest, mockResolver());
677
+ const metaResult = results.find((r) => r.target === "169.254.169.254");
678
+ expect(metaResult).toBeDefined();
679
+ expect(metaResult!.actual).toContain("BLOCKED");
680
+ expect(metaResult!.pass).toBe(true);
681
+ });
682
+
683
+ it("returns ALLOWED for IP in authorized range", async () => {
684
+ const results = await runDryRun(testManifest, mockResolver());
685
+ const ipResult = results.find((r) => r.target === "10.10.10.50");
686
+ expect(ipResult).toBeDefined();
687
+ expect(ipResult!.actual).toBe("ALLOWED");
688
+ expect(ipResult!.pass).toBe(true);
689
+ });
690
+
691
+ it("flags surprises when actual differs from expected", async () => {
692
+ // Manifest with no authorized targets — everything should be blocked
693
+ const emptyManifest: ScopeManifest = {
694
+ ...testManifest,
695
+ authorized_targets: { ip_ranges: [], domains: [], urls: [], cloud_accounts: [] },
696
+ excluded_targets: [],
697
+ };
698
+ const resolver = mockResolver();
699
+ const results = await runDryRun(emptyManifest, resolver);
700
+ // All domain/IP tests should fail (expected ALLOWED but got BLOCKED)
701
+ const surprises = results.filter((r) => !r.pass);
702
+ expect(surprises.length).toBeGreaterThan(0);
703
+ });
704
+ });
705
+ ```
706
+
707
+ - [ ] **Step 2: Run tests to verify they fail**
708
+
709
+ Run: `npx vitest run tests/unit/builder/dry-run.test.ts`
710
+ Expected: FAIL — module not found
711
+
712
+ - [ ] **Step 3: Implement dry-run engine**
713
+
714
+ ```typescript
715
+ // src/builder/dry-run.ts
716
+
717
+ import type { ScopeManifest, Decision } from "../types.js";
718
+ import { ScopeValidator } from "../validator.js";
719
+ import { DnsResolver } from "../dns-resolver.js";
720
+ import { isIpInCidr } from "../ip-matcher.js";
721
+
722
+ export interface DryRunResult {
723
+ target: string;
724
+ tool: string;
725
+ expected: "ALLOWED" | "BLOCKED";
726
+ actual: Decision;
727
+ pass: boolean;
728
+ reason: string;
729
+ }
730
+
731
+ /**
732
+ * Run a battery of synthetic tool calls against a manifest to preview
733
+ * what the proxy would allow and block.
734
+ */
735
+ export async function runDryRun(
736
+ manifest: ScopeManifest,
737
+ resolver?: DnsResolver
738
+ ): Promise<DryRunResult[]> {
739
+ const dnsResolver = resolver ?? new DnsResolver(0); // TTL 0 = no caching for fresh results
740
+ const validator = new ScopeValidator(manifest, dnsResolver);
741
+ const results: DryRunResult[] = [];
742
+
743
+ // Test each authorized domain — should be ALLOWED
744
+ for (const domain of manifest.authorized_targets.domains) {
745
+ // Skip wildcards; generate a sample subdomain instead
746
+ const testDomain = domain.startsWith("*.")
747
+ ? `test-sample.${domain.slice(2)}`
748
+ : domain;
749
+
750
+ const result = await validator.validate("nmap", { target: testDomain });
751
+ results.push({
752
+ target: testDomain,
753
+ tool: "nmap",
754
+ expected: "ALLOWED",
755
+ actual: result.decision,
756
+ pass: result.decision === "ALLOWED",
757
+ reason: result.reason,
758
+ });
759
+ }
760
+
761
+ // Test a sample IP from each authorized range — should be ALLOWED
762
+ for (const range of manifest.authorized_targets.ip_ranges) {
763
+ const sampleIp = sampleIpFromCidr(range);
764
+ if (!sampleIp) continue;
765
+
766
+ const result = await validator.validate("nmap", { target: sampleIp });
767
+ results.push({
768
+ target: sampleIp,
769
+ tool: "nmap",
770
+ expected: "ALLOWED",
771
+ actual: result.decision,
772
+ pass: result.decision === "ALLOWED",
773
+ reason: result.reason,
774
+ });
775
+ }
776
+
777
+ // Test each excluded target — should be BLOCKED
778
+ for (const excluded of manifest.excluded_targets) {
779
+ const result = await validator.validate("nmap", { target: excluded });
780
+ results.push({
781
+ target: excluded,
782
+ tool: "nmap",
783
+ expected: "BLOCKED",
784
+ actual: result.decision,
785
+ pass: result.decision.startsWith("BLOCKED"),
786
+ reason: result.reason,
787
+ });
788
+ }
789
+
790
+ // Test sensitive targets — should be BLOCKED (unless explicitly authorized)
791
+ const sensitiveTargets = ["169.254.169.254", "8.8.8.8"];
792
+ for (const target of sensitiveTargets) {
793
+ const result = await validator.validate("curl", { url: `http://${target}/` });
794
+ // These are expected to be blocked unless the manifest explicitly authorizes them
795
+ const isAuthorized = manifest.authorized_targets.ip_ranges.some((range) =>
796
+ isIpInCidr(target, range)
797
+ );
798
+
799
+ results.push({
800
+ target,
801
+ tool: "curl",
802
+ expected: isAuthorized ? "ALLOWED" : "BLOCKED",
803
+ actual: result.decision,
804
+ pass: isAuthorized
805
+ ? result.decision === "ALLOWED"
806
+ : result.decision.startsWith("BLOCKED"),
807
+ reason: result.reason,
808
+ });
809
+ }
810
+
811
+ return results;
812
+ }
813
+
814
+ /**
815
+ * Extract a sample IP from a CIDR range for testing.
816
+ * For /32, returns the IP itself. For wider ranges, picks .50 in the last octet
817
+ * (or the first usable address for very small ranges).
818
+ */
819
+ function sampleIpFromCidr(cidr: string): string | null {
820
+ // Handle bare IPs
821
+ if (!cidr.includes("/")) return cidr;
822
+
823
+ const [base, prefix] = cidr.split("/");
824
+ const prefixNum = parseInt(prefix, 10);
825
+
826
+ // For /32 or /128, just return the base IP
827
+ if (prefixNum >= 32 || (base.includes(":") && prefixNum >= 128)) {
828
+ return base;
829
+ }
830
+
831
+ // For IPv4, generate a sample by modifying the last octet
832
+ if (!base.includes(":")) {
833
+ const parts = base.split(".");
834
+ const lastOctet = parseInt(parts[3], 10);
835
+ // Pick an offset that stays in range
836
+ const offset = Math.min(50, Math.pow(2, 32 - prefixNum) - 2);
837
+ parts[3] = String(lastOctet + offset);
838
+ return parts.join(".");
839
+ }
840
+
841
+ // For IPv6, just return the base (good enough for dry-run)
842
+ return base;
843
+ }
844
+ ```
845
+
846
+ - [ ] **Step 4: Run tests to verify they pass**
847
+
848
+ Run: `npx vitest run tests/unit/builder/dry-run.test.ts`
849
+ Expected: All tests PASS
850
+
851
+ - [ ] **Step 5: Commit**
852
+
853
+ ```bash
854
+ git add src/builder/dry-run.ts tests/unit/builder/dry-run.test.ts
855
+ git commit -m "feat(builder): add dry-run engine for scope manifest preview"
856
+ ```
857
+
858
+ ---
859
+
860
+ ### Task 4: Manifest Writer
861
+
862
+ **Files:**
863
+ - Create: `src/builder/manifest-writer.ts`
864
+ - Test: `tests/unit/builder/manifest-writer.test.ts`
865
+
866
+ - [ ] **Step 1: Write failing tests for manifest writer**
867
+
868
+ ```typescript
869
+ // tests/unit/builder/manifest-writer.test.ts
870
+ import { describe, it, expect, vi, afterEach } from "vitest";
871
+ import fs from "fs";
872
+ import path from "path";
873
+ import {
874
+ writeManifest,
875
+ serializeManifest,
876
+ } from "../../src/builder/manifest-writer.js";
877
+ import type { ScopeManifest } from "../../src/types.js";
878
+
879
+ const testManifest: ScopeManifest = {
880
+ engagement_id: "ENG-2026-0001",
881
+ client: "Test Corp",
882
+ operator: "test@test.com",
883
+ authorized_targets: {
884
+ ip_ranges: ["10.10.10.0/24"],
885
+ domains: ["*.acme.com"],
886
+ urls: [],
887
+ cloud_accounts: [],
888
+ },
889
+ excluded_targets: [],
890
+ authorized_techniques: ["recon"],
891
+ excluded_techniques: ["dos"],
892
+ engagement_window: {
893
+ start: "2026-01-01T00:00:00Z",
894
+ end: "2026-02-01T00:00:00Z",
895
+ },
896
+ };
897
+
898
+ describe("ManifestWriter", () => {
899
+ describe("serializeManifest", () => {
900
+ it("returns pretty-printed JSON", () => {
901
+ const json = serializeManifest(testManifest);
902
+ expect(JSON.parse(json)).toEqual(testManifest);
903
+ expect(json).toContain("\n"); // Pretty-printed
904
+ });
905
+
906
+ it("output is valid JSON that round-trips", () => {
907
+ const json = serializeManifest(testManifest);
908
+ const parsed = JSON.parse(json);
909
+ expect(parsed.engagement_id).toBe("ENG-2026-0001");
910
+ expect(parsed.authorized_targets.domains).toEqual(["*.acme.com"]);
911
+ });
912
+ });
913
+
914
+ describe("writeManifest", () => {
915
+ const testDir = path.join(process.cwd(), "test-output-manifest-writer");
916
+ const testPath = path.join(testDir, "test-scope.json");
917
+
918
+ afterEach(() => {
919
+ if (fs.existsSync(testDir)) {
920
+ fs.rmSync(testDir, { recursive: true });
921
+ }
922
+ });
923
+
924
+ it("writes manifest to the specified path", () => {
925
+ writeManifest(testManifest, testPath);
926
+ const content = fs.readFileSync(testPath, "utf-8");
927
+ expect(JSON.parse(content)).toEqual(testManifest);
928
+ });
929
+
930
+ it("creates parent directories if they do not exist", () => {
931
+ const nestedPath = path.join(testDir, "nested", "deep", "scope.json");
932
+ writeManifest(testManifest, nestedPath);
933
+ expect(fs.existsSync(nestedPath)).toBe(true);
934
+ });
935
+
936
+ it("refuses to overwrite existing file without force flag", () => {
937
+ writeManifest(testManifest, testPath);
938
+ expect(() => writeManifest(testManifest, testPath)).toThrow("already exists");
939
+ });
940
+
941
+ it("overwrites existing file with force flag", () => {
942
+ writeManifest(testManifest, testPath);
943
+ const updated = { ...testManifest, client: "Updated Corp" };
944
+ writeManifest(updated, testPath, { force: true });
945
+ const content = fs.readFileSync(testPath, "utf-8");
946
+ expect(JSON.parse(content).client).toBe("Updated Corp");
947
+ });
948
+ });
949
+ });
950
+ ```
951
+
952
+ - [ ] **Step 2: Run tests to verify they fail**
953
+
954
+ Run: `npx vitest run tests/unit/builder/manifest-writer.test.ts`
955
+ Expected: FAIL — module not found
956
+
957
+ - [ ] **Step 3: Implement manifest writer**
958
+
959
+ ```typescript
960
+ // src/builder/manifest-writer.ts
961
+
962
+ import fs from "fs";
963
+ import path from "path";
964
+ import type { ScopeManifest } from "../types.js";
965
+
966
+ export interface WriteOptions {
967
+ force?: boolean;
968
+ }
969
+
970
+ /**
971
+ * Serialize a ScopeManifest to pretty-printed JSON.
972
+ */
973
+ export function serializeManifest(manifest: ScopeManifest): string {
974
+ return JSON.stringify(manifest, null, 2) + "\n";
975
+ }
976
+
977
+ /**
978
+ * Write a manifest to disk. Creates parent directories if needed.
979
+ * Throws if the file already exists unless force is true.
980
+ */
981
+ export function writeManifest(
982
+ manifest: ScopeManifest,
983
+ filePath: string,
984
+ options: WriteOptions = {}
985
+ ): void {
986
+ const dir = path.dirname(filePath);
987
+ if (!fs.existsSync(dir)) {
988
+ fs.mkdirSync(dir, { recursive: true });
989
+ }
990
+
991
+ if (fs.existsSync(filePath) && !options.force) {
992
+ throw new Error(`File "${filePath}" already exists. Use --force to overwrite.`);
993
+ }
994
+
995
+ const json = serializeManifest(manifest);
996
+ fs.writeFileSync(filePath, json, "utf-8");
997
+ }
998
+ ```
999
+
1000
+ - [ ] **Step 4: Run tests to verify they pass**
1001
+
1002
+ Run: `npx vitest run tests/unit/builder/manifest-writer.test.ts`
1003
+ Expected: All tests PASS
1004
+
1005
+ - [ ] **Step 5: Commit**
1006
+
1007
+ ```bash
1008
+ git add src/builder/manifest-writer.ts tests/unit/builder/manifest-writer.test.ts
1009
+ git commit -m "feat(builder): add manifest writer with overwrite protection"
1010
+ ```
1011
+
1012
+ ---
1013
+
1014
+ ### Task 5: Ink Wizard — Step Components (Steps 1–5)
1015
+
1016
+ **Files:**
1017
+ - Create: `src/builder/steps/engagement-info.tsx`
1018
+ - Create: `src/builder/steps/engagement-window.tsx`
1019
+ - Create: `src/builder/steps/domains.tsx`
1020
+ - Create: `src/builder/steps/ip-ranges.tsx`
1021
+ - Create: `src/builder/steps/urls-and-cloud.tsx`
1022
+
1023
+ These are Ink React components. Each step manages its own state and calls `onComplete(data)` when the user finishes the step. Testing Ink components requires `ink-testing-library` which is not currently installed — these will be tested via the integration-level wizard test in Task 7.
1024
+
1025
+ - [ ] **Step 1: Create engagement-info step component**
1026
+
1027
+ ```tsx
1028
+ // src/builder/steps/engagement-info.tsx
1029
+ import React, { useState } from "react";
1030
+ import { Box, Text, useInput } from "ink";
1031
+
1032
+ interface Props {
1033
+ initial?: { engagement_id?: string; client?: string; operator?: string };
1034
+ onComplete: (data: { engagement_id: string; client: string; operator: string }) => void;
1035
+ onBack?: () => void;
1036
+ }
1037
+
1038
+ type Field = "engagement_id" | "client" | "operator";
1039
+
1040
+ const FIELDS: { key: Field; label: string; placeholder: string }[] = [
1041
+ { key: "engagement_id", label: "Engagement ID", placeholder: "ENG-2026-0001" },
1042
+ { key: "client", label: "Client", placeholder: "Acme Corp" },
1043
+ { key: "operator", label: "Operator", placeholder: "operator@integsec.com" },
1044
+ ];
1045
+
1046
+ export function EngagementInfoStep({ initial, onComplete, onBack }: Props): React.ReactElement {
1047
+ const [fieldIndex, setFieldIndex] = useState(0);
1048
+ const [values, setValues] = useState({
1049
+ engagement_id: initial?.engagement_id ?? "",
1050
+ client: initial?.client ?? "",
1051
+ operator: initial?.operator ?? "",
1052
+ });
1053
+ const [input, setInput] = useState(values[FIELDS[0].key]);
1054
+
1055
+ useInput((ch, key) => {
1056
+ if (key.escape && onBack) {
1057
+ onBack();
1058
+ return;
1059
+ }
1060
+
1061
+ if (key.return) {
1062
+ const field = FIELDS[fieldIndex];
1063
+ const newValues = { ...values, [field.key]: input };
1064
+ setValues(newValues);
1065
+
1066
+ if (fieldIndex < FIELDS.length - 1) {
1067
+ const nextIndex = fieldIndex + 1;
1068
+ setFieldIndex(nextIndex);
1069
+ setInput(newValues[FIELDS[nextIndex].key]);
1070
+ } else {
1071
+ // Validate all fields are non-empty
1072
+ if (newValues.engagement_id && newValues.client && newValues.operator) {
1073
+ onComplete(newValues);
1074
+ }
1075
+ }
1076
+ return;
1077
+ }
1078
+
1079
+ if (key.backspace || key.delete) {
1080
+ setInput((prev) => prev.slice(0, -1));
1081
+ return;
1082
+ }
1083
+
1084
+ if (ch && !key.ctrl && !key.meta) {
1085
+ setInput((prev) => prev + ch);
1086
+ }
1087
+ });
1088
+
1089
+ const field = FIELDS[fieldIndex];
1090
+
1091
+ return (
1092
+ <Box flexDirection="column" padding={1}>
1093
+ <Text bold color="cyan">Step 1: Engagement Info</Text>
1094
+ <Text dimColor>Press Enter to confirm each field. Esc to go back.</Text>
1095
+ <Box marginTop={1} />
1096
+
1097
+ {FIELDS.map((f, i) => (
1098
+ <Box key={f.key}>
1099
+ <Text dimColor={i !== fieldIndex}>
1100
+ {i < fieldIndex ? "✓" : i === fieldIndex ? "›" : " "} {f.label}:{" "}
1101
+ </Text>
1102
+ {i < fieldIndex ? (
1103
+ <Text color="green">{values[f.key]}</Text>
1104
+ ) : i === fieldIndex ? (
1105
+ <Text>{input}<Text dimColor>│</Text></Text>
1106
+ ) : (
1107
+ <Text dimColor>{f.placeholder}</Text>
1108
+ )}
1109
+ </Box>
1110
+ ))}
1111
+ </Box>
1112
+ );
1113
+ }
1114
+ ```
1115
+
1116
+ - [ ] **Step 2: Create engagement-window step component**
1117
+
1118
+ ```tsx
1119
+ // src/builder/steps/engagement-window.tsx
1120
+ import React, { useState } from "react";
1121
+ import { Box, Text, useInput } from "ink";
1122
+ import { validateIsoDate, validateWindowSanity, type ValidationMessage } from "../smart-validator.js";
1123
+
1124
+ interface Props {
1125
+ initial?: { start?: string; end?: string };
1126
+ onComplete: (data: { start: string; end: string }) => void;
1127
+ onBack?: () => void;
1128
+ }
1129
+
1130
+ type Field = "start" | "end";
1131
+
1132
+ const FIELDS: { key: Field; label: string; placeholder: string }[] = [
1133
+ { key: "start", label: "Start (ISO 8601)", placeholder: "2026-04-01T08:00:00Z" },
1134
+ { key: "end", label: "End (ISO 8601)", placeholder: "2026-04-15T17:00:00Z" },
1135
+ ];
1136
+
1137
+ export function EngagementWindowStep({ initial, onComplete, onBack }: Props): React.ReactElement {
1138
+ const [fieldIndex, setFieldIndex] = useState(0);
1139
+ const [values, setValues] = useState({
1140
+ start: initial?.start ?? "",
1141
+ end: initial?.end ?? "",
1142
+ });
1143
+ const [input, setInput] = useState(values[FIELDS[0].key]);
1144
+ const [messages, setMessages] = useState<ValidationMessage[]>([]);
1145
+
1146
+ useInput((ch, key) => {
1147
+ if (key.escape && onBack) {
1148
+ onBack();
1149
+ return;
1150
+ }
1151
+
1152
+ if (key.return) {
1153
+ const field = FIELDS[fieldIndex];
1154
+
1155
+ // Validate the current field
1156
+ const fieldMsgs = validateIsoDate(input);
1157
+ if (fieldMsgs.some((m) => m.level === "error")) {
1158
+ setMessages(fieldMsgs);
1159
+ return;
1160
+ }
1161
+
1162
+ const newValues = { ...values, [field.key]: input };
1163
+ setValues(newValues);
1164
+ setMessages([]);
1165
+
1166
+ if (fieldIndex < FIELDS.length - 1) {
1167
+ const nextIndex = fieldIndex + 1;
1168
+ setFieldIndex(nextIndex);
1169
+ setInput(newValues[FIELDS[nextIndex].key]);
1170
+ } else {
1171
+ // Validate window sanity
1172
+ const windowMsgs = validateWindowSanity(newValues.start, newValues.end);
1173
+ const errors = windowMsgs.filter((m) => m.level === "error");
1174
+ if (errors.length > 0) {
1175
+ setMessages(windowMsgs);
1176
+ return;
1177
+ }
1178
+ // Show warnings but proceed
1179
+ if (windowMsgs.length > 0) {
1180
+ setMessages(windowMsgs);
1181
+ }
1182
+ onComplete(newValues);
1183
+ }
1184
+ return;
1185
+ }
1186
+
1187
+ if (key.backspace || key.delete) {
1188
+ setInput((prev) => prev.slice(0, -1));
1189
+ return;
1190
+ }
1191
+
1192
+ if (ch && !key.ctrl && !key.meta) {
1193
+ setInput((prev) => prev + ch);
1194
+ }
1195
+ });
1196
+
1197
+ return (
1198
+ <Box flexDirection="column" padding={1}>
1199
+ <Text bold color="cyan">Step 2: Engagement Window</Text>
1200
+ <Text dimColor>Enter start and end dates in ISO 8601 format. Esc to go back.</Text>
1201
+ <Box marginTop={1} />
1202
+
1203
+ {FIELDS.map((f, i) => (
1204
+ <Box key={f.key}>
1205
+ <Text dimColor={i !== fieldIndex}>
1206
+ {i < fieldIndex ? "✓" : i === fieldIndex ? "›" : " "} {f.label}:{" "}
1207
+ </Text>
1208
+ {i < fieldIndex ? (
1209
+ <Text color="green">{values[f.key]}</Text>
1210
+ ) : i === fieldIndex ? (
1211
+ <Text>{input}<Text dimColor>│</Text></Text>
1212
+ ) : (
1213
+ <Text dimColor>{f.placeholder}</Text>
1214
+ )}
1215
+ </Box>
1216
+ ))}
1217
+
1218
+ {messages.map((msg, i) => (
1219
+ <Box key={i} marginTop={i === 0 ? 1 : 0}>
1220
+ <Text color={msg.level === "error" ? "red" : msg.level === "warning" ? "yellow" : "blue"}>
1221
+ {msg.level === "error" ? "✗" : msg.level === "warning" ? "⚠" : "ℹ"} {msg.message}
1222
+ </Text>
1223
+ </Box>
1224
+ ))}
1225
+ </Box>
1226
+ );
1227
+ }
1228
+ ```
1229
+
1230
+ - [ ] **Step 3: Create domains step component**
1231
+
1232
+ ```tsx
1233
+ // src/builder/steps/domains.tsx
1234
+ import React, { useState, useEffect } from "react";
1235
+ import { Box, Text, useInput } from "ink";
1236
+ import { validateDomain, checkWildcardApex, resolveDomainPreview, type ValidationMessage } from "../smart-validator.js";
1237
+ import { DnsResolver } from "../../dns-resolver.js";
1238
+
1239
+ interface Props {
1240
+ initial?: string[];
1241
+ authorizedIpRanges?: string[];
1242
+ onComplete: (domains: string[]) => void;
1243
+ onBack?: () => void;
1244
+ }
1245
+
1246
+ export function DomainsStep({ initial, authorizedIpRanges, onComplete, onBack }: Props): React.ReactElement {
1247
+ const [domains, setDomains] = useState<string[]>(initial ?? []);
1248
+ const [input, setInput] = useState("");
1249
+ const [messages, setMessages] = useState<ValidationMessage[]>([]);
1250
+ const [resolvedInfo, setResolvedInfo] = useState<string>("");
1251
+ const resolver = React.useRef(new DnsResolver(0)).current;
1252
+
1253
+ useInput((ch, key) => {
1254
+ if (key.escape) {
1255
+ if (input === "" && onBack) {
1256
+ onBack();
1257
+ } else {
1258
+ setInput("");
1259
+ setMessages([]);
1260
+ }
1261
+ return;
1262
+ }
1263
+
1264
+ // 'd' + ctrl = done
1265
+ if (ch === "d" && key.ctrl) {
1266
+ onComplete(domains);
1267
+ return;
1268
+ }
1269
+
1270
+ if (key.return) {
1271
+ if (input.trim() === "") {
1272
+ onComplete(domains);
1273
+ return;
1274
+ }
1275
+
1276
+ // Support comma-separated input
1277
+ const newDomains = input.split(",").map((d) => d.trim()).filter(Boolean);
1278
+ const allMsgs: ValidationMessage[] = [];
1279
+ const validDomains: string[] = [];
1280
+
1281
+ for (const domain of newDomains) {
1282
+ const domainMsgs = validateDomain(domain);
1283
+ if (domainMsgs.some((m) => m.level === "error")) {
1284
+ allMsgs.push(...domainMsgs);
1285
+ } else {
1286
+ validDomains.push(domain);
1287
+ const apexMsgs = checkWildcardApex(domain, [...domains, ...validDomains]);
1288
+ allMsgs.push(...apexMsgs);
1289
+ }
1290
+ }
1291
+
1292
+ if (allMsgs.some((m) => m.level === "error")) {
1293
+ setMessages(allMsgs);
1294
+ return;
1295
+ }
1296
+
1297
+ setDomains((prev) => [...prev, ...validDomains]);
1298
+ setMessages(allMsgs); // Show info/warnings
1299
+ setInput("");
1300
+
1301
+ // Resolve DNS in background for display
1302
+ for (const domain of validDomains) {
1303
+ resolveDomainPreview(domain, resolver).then((result) => {
1304
+ if (result.ips.length > 0) {
1305
+ setResolvedInfo((prev) => prev + `${domain} → ${result.ips.join(", ")}\n`);
1306
+ }
1307
+ });
1308
+ }
1309
+ return;
1310
+ }
1311
+
1312
+ if (key.backspace || key.delete) {
1313
+ setInput((prev) => prev.slice(0, -1));
1314
+ return;
1315
+ }
1316
+
1317
+ if (ch && !key.ctrl && !key.meta) {
1318
+ setInput((prev) => prev + ch);
1319
+ }
1320
+ });
1321
+
1322
+ return (
1323
+ <Box flexDirection="column" padding={1}>
1324
+ <Text bold color="cyan">Step 3: Authorized Domains</Text>
1325
+ <Text dimColor>Enter domains (comma-separated OK). Empty enter or Ctrl+D when done. Esc to go back.</Text>
1326
+ <Box marginTop={1} />
1327
+
1328
+ {domains.map((d, i) => (
1329
+ <Text key={i} color="green"> ✓ {d}</Text>
1330
+ ))}
1331
+
1332
+ <Box>
1333
+ <Text>› </Text>
1334
+ <Text>{input}<Text dimColor>│</Text></Text>
1335
+ </Box>
1336
+
1337
+ {resolvedInfo && (
1338
+ <Box marginTop={1} flexDirection="column">
1339
+ <Text dimColor>DNS Preview:</Text>
1340
+ {resolvedInfo.trim().split("\n").map((line, i) => (
1341
+ <Text key={i} dimColor> {line}</Text>
1342
+ ))}
1343
+ </Box>
1344
+ )}
1345
+
1346
+ {messages.map((msg, i) => (
1347
+ <Box key={i} marginTop={i === 0 ? 1 : 0}>
1348
+ <Text color={msg.level === "error" ? "red" : msg.level === "warning" ? "yellow" : "blue"}>
1349
+ {msg.level === "error" ? "✗" : msg.level === "warning" ? "⚠" : "ℹ"} {msg.message}
1350
+ </Text>
1351
+ </Box>
1352
+ ))}
1353
+
1354
+ <Box marginTop={1}>
1355
+ <Text dimColor>{domains.length} domain(s) added</Text>
1356
+ </Box>
1357
+ </Box>
1358
+ );
1359
+ }
1360
+ ```
1361
+
1362
+ - [ ] **Step 4: Create ip-ranges step component**
1363
+
1364
+ ```tsx
1365
+ // src/builder/steps/ip-ranges.tsx
1366
+ import React, { useState } from "react";
1367
+ import { Box, Text, useInput } from "ink";
1368
+ import { validateCidr, checkOverlap, type ValidationMessage } from "../smart-validator.js";
1369
+
1370
+ interface Props {
1371
+ initial?: string[];
1372
+ resolvedDomainIps?: string[];
1373
+ onComplete: (ranges: string[]) => void;
1374
+ onBack?: () => void;
1375
+ }
1376
+
1377
+ export function IpRangesStep({ initial, resolvedDomainIps, onComplete, onBack }: Props): React.ReactElement {
1378
+ const [ranges, setRanges] = useState<string[]>(initial ?? []);
1379
+ const [input, setInput] = useState("");
1380
+ const [messages, setMessages] = useState<ValidationMessage[]>([]);
1381
+
1382
+ useInput((ch, key) => {
1383
+ if (key.escape) {
1384
+ if (input === "" && onBack) {
1385
+ onBack();
1386
+ } else {
1387
+ setInput("");
1388
+ setMessages([]);
1389
+ }
1390
+ return;
1391
+ }
1392
+
1393
+ if (ch === "d" && key.ctrl) {
1394
+ onComplete(ranges);
1395
+ return;
1396
+ }
1397
+
1398
+ if (key.return) {
1399
+ if (input.trim() === "") {
1400
+ onComplete(ranges);
1401
+ return;
1402
+ }
1403
+
1404
+ const newRanges = input.split(",").map((r) => r.trim()).filter(Boolean);
1405
+ const allMsgs: ValidationMessage[] = [];
1406
+ const validRanges: string[] = [];
1407
+
1408
+ for (const range of newRanges) {
1409
+ const cidrMsgs = validateCidr(range);
1410
+ if (cidrMsgs.some((m) => m.level === "error")) {
1411
+ allMsgs.push(...cidrMsgs);
1412
+ } else {
1413
+ validRanges.push(range);
1414
+ allMsgs.push(...cidrMsgs); // Include warnings
1415
+ // Check overlap with domain-resolved IPs
1416
+ if (resolvedDomainIps) {
1417
+ allMsgs.push(...checkOverlap(resolvedDomainIps, [range]));
1418
+ }
1419
+ }
1420
+ }
1421
+
1422
+ if (allMsgs.some((m) => m.level === "error")) {
1423
+ setMessages(allMsgs);
1424
+ return;
1425
+ }
1426
+
1427
+ setRanges((prev) => [...prev, ...validRanges]);
1428
+ setMessages(allMsgs);
1429
+ setInput("");
1430
+ return;
1431
+ }
1432
+
1433
+ if (key.backspace || key.delete) {
1434
+ setInput((prev) => prev.slice(0, -1));
1435
+ return;
1436
+ }
1437
+
1438
+ if (ch && !key.ctrl && !key.meta) {
1439
+ setInput((prev) => prev + ch);
1440
+ }
1441
+ });
1442
+
1443
+ return (
1444
+ <Box flexDirection="column" padding={1}>
1445
+ <Text bold color="cyan">Step 4: Authorized IP Ranges</Text>
1446
+ <Text dimColor>Enter CIDR ranges (e.g. 10.10.10.0/24). Empty enter or Ctrl+D when done. Esc to go back.</Text>
1447
+ <Box marginTop={1} />
1448
+
1449
+ {ranges.map((r, i) => (
1450
+ <Text key={i} color="green"> ✓ {r}</Text>
1451
+ ))}
1452
+
1453
+ <Box>
1454
+ <Text>› </Text>
1455
+ <Text>{input}<Text dimColor>│</Text></Text>
1456
+ </Box>
1457
+
1458
+ {messages.map((msg, i) => (
1459
+ <Box key={i} marginTop={i === 0 ? 1 : 0}>
1460
+ <Text color={msg.level === "error" ? "red" : msg.level === "warning" ? "yellow" : "blue"}>
1461
+ {msg.level === "error" ? "✗" : msg.level === "warning" ? "⚠" : "ℹ"} {msg.message}
1462
+ </Text>
1463
+ </Box>
1464
+ ))}
1465
+
1466
+ <Box marginTop={1}>
1467
+ <Text dimColor>{ranges.length} range(s) added</Text>
1468
+ </Box>
1469
+ </Box>
1470
+ );
1471
+ }
1472
+ ```
1473
+
1474
+ - [ ] **Step 5: Create urls-and-cloud step component**
1475
+
1476
+ ```tsx
1477
+ // src/builder/steps/urls-and-cloud.tsx
1478
+ import React, { useState } from "react";
1479
+ import { Box, Text, useInput } from "ink";
1480
+
1481
+ interface Props {
1482
+ initial?: { urls?: string[]; cloud_accounts?: string[] };
1483
+ onComplete: (data: { urls: string[]; cloud_accounts: string[] }) => void;
1484
+ onBack?: () => void;
1485
+ }
1486
+
1487
+ type Section = "urls" | "cloud_accounts";
1488
+
1489
+ export function UrlsAndCloudStep({ initial, onComplete, onBack }: Props): React.ReactElement {
1490
+ const [section, setSection] = useState<Section>("urls");
1491
+ const [urls, setUrls] = useState<string[]>(initial?.urls ?? []);
1492
+ const [cloudAccounts, setCloudAccounts] = useState<string[]>(initial?.cloud_accounts ?? []);
1493
+ const [input, setInput] = useState("");
1494
+
1495
+ useInput((ch, key) => {
1496
+ if (key.escape) {
1497
+ if (input === "" && section === "urls" && onBack) {
1498
+ onBack();
1499
+ } else {
1500
+ setInput("");
1501
+ }
1502
+ return;
1503
+ }
1504
+
1505
+ if (ch === "d" && key.ctrl) {
1506
+ onComplete({ urls, cloud_accounts: cloudAccounts });
1507
+ return;
1508
+ }
1509
+
1510
+ if (key.return) {
1511
+ if (input.trim() === "") {
1512
+ if (section === "urls") {
1513
+ setSection("cloud_accounts");
1514
+ } else {
1515
+ onComplete({ urls, cloud_accounts: cloudAccounts });
1516
+ }
1517
+ return;
1518
+ }
1519
+
1520
+ const items = input.split(",").map((s) => s.trim()).filter(Boolean);
1521
+ if (section === "urls") {
1522
+ setUrls((prev) => [...prev, ...items]);
1523
+ } else {
1524
+ setCloudAccounts((prev) => [...prev, ...items]);
1525
+ }
1526
+ setInput("");
1527
+ return;
1528
+ }
1529
+
1530
+ if (key.backspace || key.delete) {
1531
+ setInput((prev) => prev.slice(0, -1));
1532
+ return;
1533
+ }
1534
+
1535
+ if (ch && !key.ctrl && !key.meta) {
1536
+ setInput((prev) => prev + ch);
1537
+ }
1538
+ });
1539
+
1540
+ return (
1541
+ <Box flexDirection="column" padding={1}>
1542
+ <Text bold color="cyan">Step 5: URLs & Cloud Accounts (optional)</Text>
1543
+ <Text dimColor>Enter items or press Enter to skip. Ctrl+D to finish step.</Text>
1544
+ <Box marginTop={1} />
1545
+
1546
+ <Text bold>{section === "urls" ? "› URLs:" : "URLs:"}</Text>
1547
+ {urls.map((u, i) => <Text key={i} color="green"> ✓ {u}</Text>)}
1548
+ {section === "urls" && (
1549
+ <Box>
1550
+ <Text> › </Text>
1551
+ <Text>{input}<Text dimColor>│</Text></Text>
1552
+ </Box>
1553
+ )}
1554
+
1555
+ <Box marginTop={1} />
1556
+ <Text bold>{section === "cloud_accounts" ? "› Cloud Accounts:" : "Cloud Accounts:"}</Text>
1557
+ {cloudAccounts.map((c, i) => <Text key={i} color="green"> ✓ {c}</Text>)}
1558
+ {section === "cloud_accounts" && (
1559
+ <Box>
1560
+ <Text> › </Text>
1561
+ <Text>{input}<Text dimColor>│</Text></Text>
1562
+ </Box>
1563
+ )}
1564
+ </Box>
1565
+ );
1566
+ }
1567
+ ```
1568
+
1569
+ - [ ] **Step 6: Commit**
1570
+
1571
+ ```bash
1572
+ git add src/builder/steps/engagement-info.tsx src/builder/steps/engagement-window.tsx src/builder/steps/domains.tsx src/builder/steps/ip-ranges.tsx src/builder/steps/urls-and-cloud.tsx
1573
+ git commit -m "feat(builder): add wizard step components 1-5 (info, window, domains, IPs, URLs)"
1574
+ ```
1575
+
1576
+ ---
1577
+
1578
+ ### Task 6: Ink Wizard — Step Components (Steps 6–8)
1579
+
1580
+ **Files:**
1581
+ - Create: `src/builder/steps/exclusions.tsx`
1582
+ - Create: `src/builder/steps/techniques.tsx`
1583
+ - Create: `src/builder/steps/review.tsx`
1584
+
1585
+ - [ ] **Step 1: Create exclusions step component**
1586
+
1587
+ ```tsx
1588
+ // src/builder/steps/exclusions.tsx
1589
+ import React, { useState } from "react";
1590
+ import { Box, Text, useInput } from "ink";
1591
+ import { checkOrphanedExclusion, type ValidationMessage } from "../smart-validator.js";
1592
+
1593
+ interface Props {
1594
+ initial?: string[];
1595
+ authorized: { ip_ranges: string[]; domains: string[] };
1596
+ onComplete: (excluded: string[]) => void;
1597
+ onBack?: () => void;
1598
+ }
1599
+
1600
+ const SUGGESTED_EXCLUSIONS = [
1601
+ { target: "169.254.169.254", reason: "AWS/Azure/GCP metadata endpoint" },
1602
+ { target: "127.0.0.1", reason: "localhost" },
1603
+ ];
1604
+
1605
+ export function ExclusionsStep({ initial, authorized, onComplete, onBack }: Props): React.ReactElement {
1606
+ const [excluded, setExcluded] = useState<string[]>(initial ?? []);
1607
+ const [input, setInput] = useState("");
1608
+ const [messages, setMessages] = useState<ValidationMessage[]>([]);
1609
+ const [showSuggestions, setShowSuggestions] = useState(true);
1610
+
1611
+ useInput((ch, key) => {
1612
+ if (key.escape) {
1613
+ if (input === "" && onBack) {
1614
+ onBack();
1615
+ } else {
1616
+ setInput("");
1617
+ setMessages([]);
1618
+ }
1619
+ return;
1620
+ }
1621
+
1622
+ if (ch === "d" && key.ctrl) {
1623
+ onComplete(excluded);
1624
+ return;
1625
+ }
1626
+
1627
+ if (key.return) {
1628
+ if (input.trim() === "") {
1629
+ onComplete(excluded);
1630
+ return;
1631
+ }
1632
+
1633
+ const items = input.split(",").map((s) => s.trim()).filter(Boolean);
1634
+ const allMsgs: ValidationMessage[] = [];
1635
+
1636
+ for (const item of items) {
1637
+ allMsgs.push(...checkOrphanedExclusion(item, authorized));
1638
+ }
1639
+
1640
+ setExcluded((prev) => [...prev, ...items]);
1641
+ setMessages(allMsgs);
1642
+ setInput("");
1643
+ setShowSuggestions(false);
1644
+ return;
1645
+ }
1646
+
1647
+ if (key.backspace || key.delete) {
1648
+ setInput((prev) => prev.slice(0, -1));
1649
+ return;
1650
+ }
1651
+
1652
+ if (ch && !key.ctrl && !key.meta) {
1653
+ setInput((prev) => prev + ch);
1654
+ setShowSuggestions(false);
1655
+ }
1656
+ });
1657
+
1658
+ // Filter suggestions to exclude already-added items
1659
+ const activeSuggestions = SUGGESTED_EXCLUSIONS.filter(
1660
+ (s) => !excluded.includes(s.target)
1661
+ );
1662
+
1663
+ return (
1664
+ <Box flexDirection="column" padding={1}>
1665
+ <Text bold color="cyan">Step 6: Excluded Targets</Text>
1666
+ <Text dimColor>Targets that are in scope but must NOT be tested. Empty enter or Ctrl+D when done.</Text>
1667
+ <Box marginTop={1} />
1668
+
1669
+ {showSuggestions && activeSuggestions.length > 0 && (
1670
+ <Box flexDirection="column" marginBottom={1}>
1671
+ <Text dimColor>Suggested exclusions:</Text>
1672
+ {activeSuggestions.map((s, i) => (
1673
+ <Text key={i} dimColor> • {s.target} ({s.reason})</Text>
1674
+ ))}
1675
+ </Box>
1676
+ )}
1677
+
1678
+ {excluded.map((e, i) => (
1679
+ <Text key={i} color="red"> ✗ {e}</Text>
1680
+ ))}
1681
+
1682
+ <Box>
1683
+ <Text>› </Text>
1684
+ <Text>{input}<Text dimColor>│</Text></Text>
1685
+ </Box>
1686
+
1687
+ {messages.map((msg, i) => (
1688
+ <Box key={i} marginTop={i === 0 ? 1 : 0}>
1689
+ <Text color={msg.level === "warning" ? "yellow" : "blue"}>
1690
+ {msg.level === "warning" ? "⚠" : "ℹ"} {msg.message}
1691
+ </Text>
1692
+ </Box>
1693
+ ))}
1694
+
1695
+ <Box marginTop={1}>
1696
+ <Text dimColor>{excluded.length} exclusion(s) added</Text>
1697
+ </Box>
1698
+ </Box>
1699
+ );
1700
+ }
1701
+ ```
1702
+
1703
+ - [ ] **Step 2: Create techniques step component**
1704
+
1705
+ ```tsx
1706
+ // src/builder/steps/techniques.tsx
1707
+ import React, { useState } from "react";
1708
+ import { Box, Text, useInput } from "ink";
1709
+
1710
+ interface Props {
1711
+ initialAuthorized?: string[];
1712
+ initialExcluded?: string[];
1713
+ onComplete: (data: { authorized: string[]; excluded: string[] }) => void;
1714
+ onBack?: () => void;
1715
+ }
1716
+
1717
+ const KNOWN_CATEGORIES = [
1718
+ { name: "recon", description: "Reconnaissance and scanning", tools: "nmap, whois, dig, amass" },
1719
+ { name: "web_app", description: "Web application testing", tools: "burp, zap, nikto, sqlmap" },
1720
+ { name: "api_testing", description: "API security testing", tools: "postman, curl, httpie" },
1721
+ { name: "ssl_tls", description: "SSL/TLS analysis", tools: "sslyze, testssl" },
1722
+ { name: "dos", description: "Denial of service", tools: "hping3, slowloris, synflood" },
1723
+ { name: "destructive", description: "Destructive operations", tools: "disk_wiper, *destroy*, *wipe*" },
1724
+ { name: "social_engineering", description: "Social engineering", tools: "gophish, setoolkit, evilginx" },
1725
+ ];
1726
+
1727
+ const DEFAULT_EXCLUDED = ["dos", "destructive", "social_engineering"];
1728
+
1729
+ type Phase = "authorized" | "excluded";
1730
+
1731
+ export function TechniquesStep({ initialAuthorized, initialExcluded, onComplete, onBack }: Props): React.ReactElement {
1732
+ const [phase, setPhase] = useState<Phase>("authorized");
1733
+ const [cursor, setCursor] = useState(0);
1734
+ const [authorized, setAuthorized] = useState<Set<string>>(
1735
+ new Set(initialAuthorized ?? ["recon", "web_app", "api_testing"])
1736
+ );
1737
+ const [excluded, setExcluded] = useState<Set<string>>(
1738
+ new Set(initialExcluded ?? DEFAULT_EXCLUDED)
1739
+ );
1740
+
1741
+ useInput((ch, key) => {
1742
+ if (key.escape && onBack) {
1743
+ if (phase === "excluded") {
1744
+ setPhase("authorized");
1745
+ setCursor(0);
1746
+ } else {
1747
+ onBack();
1748
+ }
1749
+ return;
1750
+ }
1751
+
1752
+ if (key.upArrow) {
1753
+ setCursor((prev) => Math.max(0, prev - 1));
1754
+ return;
1755
+ }
1756
+
1757
+ if (key.downArrow) {
1758
+ setCursor((prev) => Math.min(KNOWN_CATEGORIES.length - 1, prev + 1));
1759
+ return;
1760
+ }
1761
+
1762
+ if (ch === " ") {
1763
+ const category = KNOWN_CATEGORIES[cursor].name;
1764
+ if (phase === "authorized") {
1765
+ setAuthorized((prev) => {
1766
+ const next = new Set(prev);
1767
+ next.has(category) ? next.delete(category) : next.add(category);
1768
+ return next;
1769
+ });
1770
+ } else {
1771
+ setExcluded((prev) => {
1772
+ const next = new Set(prev);
1773
+ next.has(category) ? next.delete(category) : next.add(category);
1774
+ return next;
1775
+ });
1776
+ }
1777
+ return;
1778
+ }
1779
+
1780
+ if (key.return) {
1781
+ if (phase === "authorized") {
1782
+ setPhase("excluded");
1783
+ setCursor(0);
1784
+ } else {
1785
+ onComplete({
1786
+ authorized: Array.from(authorized),
1787
+ excluded: Array.from(excluded),
1788
+ });
1789
+ }
1790
+ return;
1791
+ }
1792
+ });
1793
+
1794
+ const currentSet = phase === "authorized" ? authorized : excluded;
1795
+ const title = phase === "authorized" ? "Authorized Techniques" : "Excluded Techniques";
1796
+ const color = phase === "authorized" ? "green" : "red";
1797
+
1798
+ return (
1799
+ <Box flexDirection="column" padding={1}>
1800
+ <Text bold color="cyan">Step 7: {title}</Text>
1801
+ <Text dimColor>Space to toggle, Enter to confirm, ↑/↓ to navigate.</Text>
1802
+ <Box marginTop={1} />
1803
+
1804
+ {KNOWN_CATEGORIES.map((cat, i) => (
1805
+ <Box key={cat.name}>
1806
+ <Text color={i === cursor ? "white" : undefined} dimColor={i !== cursor}>
1807
+ {i === cursor ? "›" : " "} [{currentSet.has(cat.name) ? "×" : " "}]{" "}
1808
+ </Text>
1809
+ <Text color={currentSet.has(cat.name) ? color : undefined} bold={i === cursor}>
1810
+ {cat.name}
1811
+ </Text>
1812
+ <Text dimColor> — {cat.description} ({cat.tools})</Text>
1813
+ </Box>
1814
+ ))}
1815
+
1816
+ <Box marginTop={1}>
1817
+ <Text dimColor>
1818
+ {phase === "authorized"
1819
+ ? `${authorized.size} authorized. Press Enter to configure excluded techniques.`
1820
+ : `${excluded.size} excluded. Press Enter to finish.`}
1821
+ </Text>
1822
+ </Box>
1823
+ </Box>
1824
+ );
1825
+ }
1826
+ ```
1827
+
1828
+ - [ ] **Step 3: Create review step component**
1829
+
1830
+ ```tsx
1831
+ // src/builder/steps/review.tsx
1832
+ import React, { useState, useEffect } from "react";
1833
+ import { Box, Text, useInput } from "ink";
1834
+ import Spinner from "ink-spinner";
1835
+ import type { ScopeManifest } from "../../types.js";
1836
+ import { runDryRun, type DryRunResult } from "../dry-run.js";
1837
+
1838
+ interface Props {
1839
+ manifest: ScopeManifest;
1840
+ onConfirm: () => void;
1841
+ onBack?: () => void;
1842
+ }
1843
+
1844
+ export function ReviewStep({ manifest, onConfirm, onBack }: Props): React.ReactElement {
1845
+ const [dryRunResults, setDryRunResults] = useState<DryRunResult[] | null>(null);
1846
+ const [dryRunError, setDryRunError] = useState<string | null>(null);
1847
+ const [confirmed, setConfirmed] = useState(false);
1848
+
1849
+ useEffect(() => {
1850
+ runDryRun(manifest)
1851
+ .then(setDryRunResults)
1852
+ .catch((err) => setDryRunError(err.message));
1853
+ }, []);
1854
+
1855
+ useInput((ch, key) => {
1856
+ if (key.escape && onBack) {
1857
+ onBack();
1858
+ return;
1859
+ }
1860
+ if (key.return && dryRunResults !== null) {
1861
+ setConfirmed(true);
1862
+ onConfirm();
1863
+ return;
1864
+ }
1865
+ });
1866
+
1867
+ const { authorized_targets: at, excluded_targets: et } = manifest;
1868
+
1869
+ return (
1870
+ <Box flexDirection="column" padding={1}>
1871
+ <Text bold color="cyan">Step 8: Review & Dry-Run</Text>
1872
+ <Box marginTop={1} />
1873
+
1874
+ {/* Manifest summary */}
1875
+ <Text bold>Engagement:</Text>
1876
+ <Text> ID: {manifest.engagement_id}</Text>
1877
+ <Text> Client: {manifest.client}</Text>
1878
+ <Text> Operator: {manifest.operator}</Text>
1879
+ <Text> Window: {manifest.engagement_window.start} → {manifest.engagement_window.end}</Text>
1880
+
1881
+ <Box marginTop={1} />
1882
+ <Text bold>Authorized Targets:</Text>
1883
+ <Text> Domains ({at.domains.length}): {at.domains.join(", ") || "none"}</Text>
1884
+ <Text> IP Ranges ({at.ip_ranges.length}): {at.ip_ranges.join(", ") || "none"}</Text>
1885
+ <Text> URLs ({at.urls.length}): {at.urls.join(", ") || "none"}</Text>
1886
+ <Text> Cloud ({at.cloud_accounts.length}): {at.cloud_accounts.join(", ") || "none"}</Text>
1887
+
1888
+ <Box marginTop={1} />
1889
+ <Text bold>Exclusions ({et.length}):</Text>
1890
+ {et.length > 0
1891
+ ? et.map((e, i) => <Text key={i} color="red"> ✗ {e}</Text>)
1892
+ : <Text dimColor> none</Text>}
1893
+
1894
+ <Box marginTop={1} />
1895
+ <Text bold>Techniques:</Text>
1896
+ <Text color="green"> Authorized: {manifest.authorized_techniques.join(", ") || "none"}</Text>
1897
+ <Text color="red"> Excluded: {manifest.excluded_techniques.join(", ") || "none"}</Text>
1898
+
1899
+ {/* Dry-run results */}
1900
+ <Box marginTop={1} />
1901
+ <Text bold>Dry-Run Preview:</Text>
1902
+ {dryRunError && <Text color="red"> Error: {dryRunError}</Text>}
1903
+ {dryRunResults === null && !dryRunError && (
1904
+ <Text> <Spinner type="dots" /> Running dry-run...</Text>
1905
+ )}
1906
+ {dryRunResults !== null && (
1907
+ <Box flexDirection="column">
1908
+ {dryRunResults.map((r, i) => (
1909
+ <Text key={i} color={r.pass ? "green" : "red"}>
1910
+ {" "}{r.pass ? "✓" : "✗"} {r.tool} → {r.target}: {r.actual}
1911
+ {!r.pass ? ` (expected ${r.expected})` : ""}
1912
+ </Text>
1913
+ ))}
1914
+ <Box marginTop={1} />
1915
+ {dryRunResults.some((r) => !r.pass) ? (
1916
+ <Text color="red" bold>⚠ Some dry-run checks did not match expectations. Review above.</Text>
1917
+ ) : (
1918
+ <Text color="green" bold>✓ All dry-run checks passed.</Text>
1919
+ )}
1920
+ </Box>
1921
+ )}
1922
+
1923
+ <Box marginTop={1} />
1924
+ {dryRunResults !== null && !confirmed && (
1925
+ <Text dimColor>Press Enter to confirm and write manifest, Esc to go back.</Text>
1926
+ )}
1927
+ </Box>
1928
+ );
1929
+ }
1930
+ ```
1931
+
1932
+ - [ ] **Step 4: Commit**
1933
+
1934
+ ```bash
1935
+ git add src/builder/steps/exclusions.tsx src/builder/steps/techniques.tsx src/builder/steps/review.tsx
1936
+ git commit -m "feat(builder): add wizard step components 6-8 (exclusions, techniques, review)"
1937
+ ```
1938
+
1939
+ ---
1940
+
1941
+ ### Task 7: Wizard Orchestrator
1942
+
1943
+ **Files:**
1944
+ - Create: `src/builder/wizard.tsx`
1945
+
1946
+ - [ ] **Step 1: Create the wizard orchestrator**
1947
+
1948
+ ```tsx
1949
+ // src/builder/wizard.tsx
1950
+ import React, { useState } from "react";
1951
+ import { Box, Text } from "ink";
1952
+ import type { ScopeManifest } from "../types.js";
1953
+ import { EngagementInfoStep } from "./steps/engagement-info.js";
1954
+ import { EngagementWindowStep } from "./steps/engagement-window.js";
1955
+ import { DomainsStep } from "./steps/domains.js";
1956
+ import { IpRangesStep } from "./steps/ip-ranges.js";
1957
+ import { UrlsAndCloudStep } from "./steps/urls-and-cloud.js";
1958
+ import { ExclusionsStep } from "./steps/exclusions.js";
1959
+ import { TechniquesStep } from "./steps/techniques.js";
1960
+ import { ReviewStep } from "./steps/review.js";
1961
+
1962
+ type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
1963
+
1964
+ interface Props {
1965
+ /** Pre-fill values (from --from flag or edit mode) */
1966
+ initial?: Partial<ScopeManifest>;
1967
+ /** Called when the wizard completes with a fully built manifest */
1968
+ onFinish: (manifest: ScopeManifest) => void;
1969
+ }
1970
+
1971
+ interface WizardState {
1972
+ engagement_id: string;
1973
+ client: string;
1974
+ operator: string;
1975
+ engagement_window: { start: string; end: string };
1976
+ domains: string[];
1977
+ ip_ranges: string[];
1978
+ urls: string[];
1979
+ cloud_accounts: string[];
1980
+ excluded_targets: string[];
1981
+ authorized_techniques: string[];
1982
+ excluded_techniques: string[];
1983
+ }
1984
+
1985
+ function buildManifest(state: WizardState): ScopeManifest {
1986
+ return {
1987
+ engagement_id: state.engagement_id,
1988
+ client: state.client,
1989
+ operator: state.operator,
1990
+ authorized_targets: {
1991
+ ip_ranges: state.ip_ranges,
1992
+ domains: state.domains,
1993
+ urls: state.urls,
1994
+ cloud_accounts: state.cloud_accounts,
1995
+ },
1996
+ excluded_targets: state.excluded_targets,
1997
+ authorized_techniques: state.authorized_techniques,
1998
+ excluded_techniques: state.excluded_techniques,
1999
+ engagement_window: state.engagement_window,
2000
+ };
2001
+ }
2002
+
2003
+ export function Wizard({ initial, onFinish }: Props): React.ReactElement {
2004
+ const [step, setStep] = useState<Step>(1);
2005
+ const [state, setState] = useState<WizardState>({
2006
+ engagement_id: initial?.engagement_id ?? "",
2007
+ client: initial?.client ?? "",
2008
+ operator: initial?.operator ?? "",
2009
+ engagement_window: initial?.engagement_window ?? { start: "", end: "" },
2010
+ domains: initial?.authorized_targets?.domains ?? [],
2011
+ ip_ranges: initial?.authorized_targets?.ip_ranges ?? [],
2012
+ urls: initial?.authorized_targets?.urls ?? [],
2013
+ cloud_accounts: initial?.authorized_targets?.cloud_accounts ?? [],
2014
+ excluded_targets: initial?.excluded_targets ?? [],
2015
+ authorized_techniques: initial?.authorized_techniques ?? [],
2016
+ excluded_techniques: initial?.excluded_techniques ?? [],
2017
+ });
2018
+
2019
+ const goBack = () => setStep((s) => Math.max(1, s - 1) as Step);
2020
+ const goNext = () => setStep((s) => Math.min(8, s + 1) as Step);
2021
+
2022
+ return (
2023
+ <Box flexDirection="column">
2024
+ <Box marginBottom={1}>
2025
+ <Text dimColor>─── Scope Manifest Builder ─── Step {step}/8 ───</Text>
2026
+ </Box>
2027
+
2028
+ {step === 1 && (
2029
+ <EngagementInfoStep
2030
+ initial={{ engagement_id: state.engagement_id, client: state.client, operator: state.operator }}
2031
+ onComplete={(data) => {
2032
+ setState((s) => ({ ...s, ...data }));
2033
+ goNext();
2034
+ }}
2035
+ />
2036
+ )}
2037
+
2038
+ {step === 2 && (
2039
+ <EngagementWindowStep
2040
+ initial={state.engagement_window}
2041
+ onComplete={(data) => {
2042
+ setState((s) => ({ ...s, engagement_window: data }));
2043
+ goNext();
2044
+ }}
2045
+ onBack={goBack}
2046
+ />
2047
+ )}
2048
+
2049
+ {step === 3 && (
2050
+ <DomainsStep
2051
+ initial={state.domains}
2052
+ authorizedIpRanges={state.ip_ranges}
2053
+ onComplete={(domains) => {
2054
+ setState((s) => ({ ...s, domains }));
2055
+ goNext();
2056
+ }}
2057
+ onBack={goBack}
2058
+ />
2059
+ )}
2060
+
2061
+ {step === 4 && (
2062
+ <IpRangesStep
2063
+ initial={state.ip_ranges}
2064
+ onComplete={(ranges) => {
2065
+ setState((s) => ({ ...s, ip_ranges: ranges }));
2066
+ goNext();
2067
+ }}
2068
+ onBack={goBack}
2069
+ />
2070
+ )}
2071
+
2072
+ {step === 5 && (
2073
+ <UrlsAndCloudStep
2074
+ initial={{ urls: state.urls, cloud_accounts: state.cloud_accounts }}
2075
+ onComplete={(data) => {
2076
+ setState((s) => ({ ...s, urls: data.urls, cloud_accounts: data.cloud_accounts }));
2077
+ goNext();
2078
+ }}
2079
+ onBack={goBack}
2080
+ />
2081
+ )}
2082
+
2083
+ {step === 6 && (
2084
+ <ExclusionsStep
2085
+ initial={state.excluded_targets}
2086
+ authorized={{ ip_ranges: state.ip_ranges, domains: state.domains }}
2087
+ onComplete={(excluded) => {
2088
+ setState((s) => ({ ...s, excluded_targets: excluded }));
2089
+ goNext();
2090
+ }}
2091
+ onBack={goBack}
2092
+ />
2093
+ )}
2094
+
2095
+ {step === 7 && (
2096
+ <TechniquesStep
2097
+ initialAuthorized={state.authorized_techniques}
2098
+ initialExcluded={state.excluded_techniques}
2099
+ onComplete={(data) => {
2100
+ setState((s) => ({
2101
+ ...s,
2102
+ authorized_techniques: data.authorized,
2103
+ excluded_techniques: data.excluded,
2104
+ }));
2105
+ goNext();
2106
+ }}
2107
+ onBack={goBack}
2108
+ />
2109
+ )}
2110
+
2111
+ {step === 8 && (
2112
+ <ReviewStep
2113
+ manifest={buildManifest(state)}
2114
+ onConfirm={() => onFinish(buildManifest(state))}
2115
+ onBack={goBack}
2116
+ />
2117
+ )}
2118
+ </Box>
2119
+ );
2120
+ }
2121
+ ```
2122
+
2123
+ - [ ] **Step 2: Commit**
2124
+
2125
+ ```bash
2126
+ git add src/builder/wizard.tsx
2127
+ git commit -m "feat(builder): add wizard orchestrator with 8-step flow and back/forward navigation"
2128
+ ```
2129
+
2130
+ ---
2131
+
2132
+ ### Task 8: CLI Entry Point (`new` mode)
2133
+
2134
+ **Files:**
2135
+ - Create: `bin/integsec-agentic-pentest-manifest.ts`
2136
+ - Modify: `package.json`
2137
+
2138
+ - [ ] **Step 1: Create the CLI entry point**
2139
+
2140
+ ```typescript
2141
+ // bin/integsec-agentic-pentest-manifest.ts
2142
+ #!/usr/bin/env node
2143
+
2144
+ import React from "react";
2145
+ import { render } from "ink";
2146
+ import fs from "fs";
2147
+ import path from "path";
2148
+ import { Wizard } from "../src/builder/wizard.js";
2149
+ import { writeManifest } from "../src/builder/manifest-writer.js";
2150
+ import { ScopeManifestSchema } from "../src/manifest-schema.js";
2151
+ import type { ScopeManifest } from "../src/types.js";
2152
+
2153
+ function parseArgs(): {
2154
+ mode: "new" | "edit" | "review" | "serve" | "help";
2155
+ fromTemplate?: string;
2156
+ targetFile?: string;
2157
+ section?: string;
2158
+ output?: string;
2159
+ } {
2160
+ const args = process.argv.slice(2);
2161
+ const mode = (args[0] ?? "new") as "new" | "edit" | "review" | "serve" | "help";
2162
+
2163
+ let fromTemplate: string | undefined;
2164
+ let targetFile: string | undefined;
2165
+ let section: string | undefined;
2166
+ let output: string | undefined;
2167
+
2168
+ // The second positional arg is the file for edit/review
2169
+ if ((mode === "edit" || mode === "review") && args[1] && !args[1].startsWith("--")) {
2170
+ targetFile = args[1];
2171
+ }
2172
+
2173
+ for (let i = 1; i < args.length; i++) {
2174
+ if (args[i] === "--from" && args[i + 1]) {
2175
+ fromTemplate = args[i + 1];
2176
+ i++;
2177
+ } else if (args[i] === "--section" && args[i + 1]) {
2178
+ section = args[i + 1];
2179
+ i++;
2180
+ } else if ((args[i] === "--output" || args[i] === "-o") && args[i + 1]) {
2181
+ output = args[i + 1];
2182
+ i++;
2183
+ } else if (args[i] === "--help" || args[i] === "-h") {
2184
+ return { mode: "help" };
2185
+ }
2186
+ }
2187
+
2188
+ return { mode, fromTemplate, targetFile, section, output };
2189
+ }
2190
+
2191
+ function printHelp(): void {
2192
+ console.log(`
2193
+ IntegSec Agentic Pentest MCP Proxy — Manifest Builder
2194
+
2195
+ Usage:
2196
+ integsec-agentic-pentest-manifest [mode] [options]
2197
+
2198
+ Modes:
2199
+ new Create a new scope manifest (default)
2200
+ edit <file> Edit an existing manifest
2201
+ review <file> Review a client-submitted manifest
2202
+ serve Start the client intake web server
2203
+
2204
+ Options:
2205
+ --from <template.json> Pre-fill wizard from a template (new mode)
2206
+ --section <name> Jump to a specific section (edit mode)
2207
+ -o, --output <file> Output file path (default: ./scope-manifest.json)
2208
+ -h, --help Show this help message
2209
+
2210
+ Examples:
2211
+ integsec-agentic-pentest-manifest new
2212
+ integsec-agentic-pentest-manifest new --from template.json -o scope.json
2213
+ integsec-agentic-pentest-manifest edit scope.json --section targets
2214
+ integsec-agentic-pentest-manifest review submission.pending.json
2215
+ integsec-agentic-pentest-manifest serve --template scope-template.json
2216
+ `);
2217
+ }
2218
+
2219
+ function loadTemplate(filePath: string): Partial<ScopeManifest> {
2220
+ const content = fs.readFileSync(filePath, "utf-8");
2221
+ const data = JSON.parse(content);
2222
+ // Partial parse — don't require all fields for a template
2223
+ return data as Partial<ScopeManifest>;
2224
+ }
2225
+
2226
+ const config = parseArgs();
2227
+
2228
+ if (config.mode === "help") {
2229
+ printHelp();
2230
+ process.exit(0);
2231
+ }
2232
+
2233
+ if (config.mode === "new") {
2234
+ const initial = config.fromTemplate ? loadTemplate(config.fromTemplate) : undefined;
2235
+ const outputPath = config.output ?? "./scope-manifest.json";
2236
+
2237
+ const { unmount } = render(
2238
+ <Wizard
2239
+ initial={initial}
2240
+ onFinish={(manifest) => {
2241
+ unmount();
2242
+ try {
2243
+ writeManifest(manifest, outputPath);
2244
+ console.log(`\n✓ Manifest written to ${outputPath}`);
2245
+ } catch (err: any) {
2246
+ console.error(`\n✗ ${err.message}`);
2247
+ process.exit(1);
2248
+ }
2249
+ }}
2250
+ />
2251
+ );
2252
+ } else if (config.mode === "edit") {
2253
+ if (!config.targetFile) {
2254
+ console.error("Error: edit mode requires a file path. Usage: integsec-agentic-pentest-manifest edit <file>");
2255
+ process.exit(1);
2256
+ }
2257
+ const content = fs.readFileSync(config.targetFile, "utf-8");
2258
+ const manifest = ScopeManifestSchema.parse(JSON.parse(content));
2259
+ const outputPath = config.output ?? config.targetFile;
2260
+
2261
+ const { unmount } = render(
2262
+ <Wizard
2263
+ initial={manifest}
2264
+ onFinish={(updated) => {
2265
+ unmount();
2266
+ try {
2267
+ writeManifest(updated, outputPath, { force: true });
2268
+ console.log(`\n✓ Manifest updated at ${outputPath}`);
2269
+ } catch (err: any) {
2270
+ console.error(`\n✗ ${err.message}`);
2271
+ process.exit(1);
2272
+ }
2273
+ }}
2274
+ />
2275
+ );
2276
+ } else if (config.mode === "review") {
2277
+ if (!config.targetFile) {
2278
+ console.error("Error: review mode requires a file path. Usage: integsec-agentic-pentest-manifest review <file>");
2279
+ process.exit(1);
2280
+ }
2281
+ // Review mode — Phase 2 (placeholder message)
2282
+ console.log("Review mode is coming in Phase 2. Use 'edit' to review and modify a manifest.");
2283
+ process.exit(0);
2284
+ } else if (config.mode === "serve") {
2285
+ // Serve mode — Phase 3 (placeholder message)
2286
+ console.log("Serve mode is coming in Phase 3. Web-based client intake form.");
2287
+ process.exit(0);
2288
+ }
2289
+ ```
2290
+
2291
+ - [ ] **Step 2: Add bin entry to package.json**
2292
+
2293
+ Add to `package.json` `"bin"` section:
2294
+
2295
+ ```json
2296
+ "integsec-agentic-pentest-manifest": "dist/bin/integsec-agentic-pentest-manifest.js"
2297
+ ```
2298
+
2299
+ - [ ] **Step 3: Build and smoke test**
2300
+
2301
+ Run: `npx tsc && node dist/bin/integsec-agentic-pentest-manifest.js --help`
2302
+ Expected: Help text prints without errors
2303
+
2304
+ - [ ] **Step 4: Commit**
2305
+
2306
+ ```bash
2307
+ git add bin/integsec-agentic-pentest-manifest.ts package.json
2308
+ git commit -m "feat(builder): add CLI entry point with new/edit modes and help"
2309
+ ```
2310
+
2311
+ ---
2312
+
2313
+ ### Task 9: Run full test suite and fix issues
2314
+
2315
+ **Files:**
2316
+ - All files from Tasks 1-8
2317
+
2318
+ - [ ] **Step 1: Build the project**
2319
+
2320
+ Run: `npx tsc`
2321
+ Expected: No type errors
2322
+
2323
+ - [ ] **Step 2: Run all tests**
2324
+
2325
+ Run: `npx vitest run`
2326
+ Expected: All tests pass (existing + new)
2327
+
2328
+ - [ ] **Step 3: Fix any type errors or test failures**
2329
+
2330
+ Address any issues found in Step 1-2.
2331
+
2332
+ - [ ] **Step 4: Commit any fixes**
2333
+
2334
+ ```bash
2335
+ git add -A
2336
+ git commit -m "fix(builder): resolve type errors and test failures from Phase 1"
2337
+ ```
2338
+
2339
+ ---
2340
+
2341
+ ## Phase 2: `edit` and `review` modes
2342
+
2343
+ ### Task 10: Review Mode CLI
2344
+
2345
+ **Files:**
2346
+ - Modify: `bin/integsec-agentic-pentest-manifest.ts`
2347
+ - Create: `src/builder/review-display.tsx`
2348
+
2349
+ - [ ] **Step 1: Create review display component**
2350
+
2351
+ ```tsx
2352
+ // src/builder/review-display.tsx
2353
+ import React, { useState, useEffect } from "react";
2354
+ import { Box, Text, useInput } from "ink";
2355
+ import Spinner from "ink-spinner";
2356
+ import type { ScopeManifest } from "../types.js";
2357
+ import {
2358
+ validateCidr,
2359
+ validateDomain,
2360
+ validateWindowSanity,
2361
+ checkOrphanedExclusion,
2362
+ type ValidationMessage,
2363
+ } from "./smart-validator.js";
2364
+ import { runDryRun, type DryRunResult } from "./dry-run.js";
2365
+ import { writeManifest } from "./manifest-writer.js";
2366
+
2367
+ interface Props {
2368
+ manifest: ScopeManifest;
2369
+ sourcePath: string;
2370
+ onDone: () => void;
2371
+ }
2372
+
2373
+ type ReviewAction = "pending" | "approve" | "reject";
2374
+
2375
+ export function ReviewDisplay({ manifest, sourcePath, onDone }: Props): React.ReactElement {
2376
+ const [validationMsgs, setValidationMsgs] = useState<ValidationMessage[]>([]);
2377
+ const [dryRunResults, setDryRunResults] = useState<DryRunResult[] | null>(null);
2378
+ const [action, setAction] = useState<ReviewAction>("pending");
2379
+
2380
+ useEffect(() => {
2381
+ // Run all smart validations
2382
+ const msgs: ValidationMessage[] = [];
2383
+
2384
+ for (const cidr of manifest.authorized_targets.ip_ranges) {
2385
+ msgs.push(...validateCidr(cidr));
2386
+ }
2387
+ for (const domain of manifest.authorized_targets.domains) {
2388
+ msgs.push(...validateDomain(domain));
2389
+ }
2390
+ msgs.push(...validateWindowSanity(
2391
+ manifest.engagement_window.start,
2392
+ manifest.engagement_window.end
2393
+ ));
2394
+ for (const excluded of manifest.excluded_targets) {
2395
+ msgs.push(...checkOrphanedExclusion(excluded, {
2396
+ ip_ranges: manifest.authorized_targets.ip_ranges,
2397
+ domains: manifest.authorized_targets.domains,
2398
+ }));
2399
+ }
2400
+
2401
+ setValidationMsgs(msgs);
2402
+
2403
+ // Run dry-run
2404
+ runDryRun(manifest)
2405
+ .then(setDryRunResults)
2406
+ .catch(() => setDryRunResults([]));
2407
+ }, []);
2408
+
2409
+ useInput((ch, key) => {
2410
+ if (ch === "a" && action === "pending") {
2411
+ // Approve — write final manifest
2412
+ const outputPath = sourcePath.replace(".pending.json", ".json");
2413
+ try {
2414
+ writeManifest(manifest, outputPath, { force: true });
2415
+ setAction("approve");
2416
+ setTimeout(onDone, 500);
2417
+ } catch {
2418
+ // Error handled in display
2419
+ }
2420
+ return;
2421
+ }
2422
+ if (ch === "r" && action === "pending") {
2423
+ setAction("reject");
2424
+ setTimeout(onDone, 500);
2425
+ return;
2426
+ }
2427
+ if (key.escape) {
2428
+ onDone();
2429
+ return;
2430
+ }
2431
+ });
2432
+
2433
+ const errors = validationMsgs.filter((m) => m.level === "error");
2434
+ const warnings = validationMsgs.filter((m) => m.level === "warning");
2435
+
2436
+ return (
2437
+ <Box flexDirection="column" padding={1}>
2438
+ <Text bold color="cyan">Manifest Review: {sourcePath}</Text>
2439
+ <Box marginTop={1} />
2440
+
2441
+ <Text bold>Engagement: {manifest.engagement_id} — {manifest.client}</Text>
2442
+ <Text>Operator: {manifest.operator}</Text>
2443
+ <Text>Window: {manifest.engagement_window.start} → {manifest.engagement_window.end}</Text>
2444
+ <Text>Domains: {manifest.authorized_targets.domains.join(", ") || "none"}</Text>
2445
+ <Text>IP Ranges: {manifest.authorized_targets.ip_ranges.join(", ") || "none"}</Text>
2446
+ <Text>Exclusions: {manifest.excluded_targets.join(", ") || "none"}</Text>
2447
+
2448
+ {/* Validation */}
2449
+ <Box marginTop={1} />
2450
+ <Text bold>Validation:</Text>
2451
+ {errors.length === 0 && warnings.length === 0 && (
2452
+ <Text color="green"> ✓ No issues found</Text>
2453
+ )}
2454
+ {errors.map((m, i) => <Text key={`e${i}`} color="red"> ✗ {m.message}</Text>)}
2455
+ {warnings.map((m, i) => <Text key={`w${i}`} color="yellow"> ⚠ {m.message}</Text>)}
2456
+
2457
+ {/* Dry-run */}
2458
+ <Box marginTop={1} />
2459
+ <Text bold>Dry-Run:</Text>
2460
+ {dryRunResults === null ? (
2461
+ <Text> <Spinner type="dots" /> Running...</Text>
2462
+ ) : (
2463
+ dryRunResults.map((r, i) => (
2464
+ <Text key={i} color={r.pass ? "green" : "red"}>
2465
+ {" "}{r.pass ? "✓" : "✗"} {r.tool} → {r.target}: {r.actual}
2466
+ </Text>
2467
+ ))
2468
+ )}
2469
+
2470
+ {/* Actions */}
2471
+ <Box marginTop={1} />
2472
+ {action === "pending" && (
2473
+ <Text dimColor>[a] Approve [r] Reject [Esc] Cancel</Text>
2474
+ )}
2475
+ {action === "approve" && (
2476
+ <Text color="green" bold>✓ Approved and saved.</Text>
2477
+ )}
2478
+ {action === "reject" && (
2479
+ <Text color="red" bold>✗ Rejected.</Text>
2480
+ )}
2481
+ </Box>
2482
+ );
2483
+ }
2484
+ ```
2485
+
2486
+ - [ ] **Step 2: Wire review mode into CLI**
2487
+
2488
+ In `bin/integsec-agentic-pentest-manifest.ts`, replace the review mode placeholder:
2489
+
2490
+ ```typescript
2491
+ } else if (config.mode === "review") {
2492
+ if (!config.targetFile) {
2493
+ console.error("Error: review mode requires a file path. Usage: integsec-agentic-pentest-manifest review <file>");
2494
+ process.exit(1);
2495
+ }
2496
+ const content = fs.readFileSync(config.targetFile, "utf-8");
2497
+ const manifest = ScopeManifestSchema.parse(JSON.parse(content));
2498
+
2499
+ const { unmount } = render(
2500
+ <ReviewDisplay
2501
+ manifest={manifest}
2502
+ sourcePath={config.targetFile}
2503
+ onDone={() => {
2504
+ unmount();
2505
+ process.exit(0);
2506
+ }}
2507
+ />
2508
+ );
2509
+ }
2510
+ ```
2511
+
2512
+ Add import at top:
2513
+ ```typescript
2514
+ import { ReviewDisplay } from "../src/builder/review-display.js";
2515
+ ```
2516
+
2517
+ - [ ] **Step 3: Build and test**
2518
+
2519
+ Run: `npx tsc`
2520
+ Expected: No errors
2521
+
2522
+ - [ ] **Step 4: Commit**
2523
+
2524
+ ```bash
2525
+ git add src/builder/review-display.tsx bin/integsec-agentic-pentest-manifest.ts
2526
+ git commit -m "feat(builder): add review mode with smart validation and dry-run"
2527
+ ```
2528
+
2529
+ ---
2530
+
2531
+ ## Phase 3: Web Server (`serve` mode)
2532
+
2533
+ ### Task 11: Web Server Core
2534
+
2535
+ **Files:**
2536
+ - Create: `src/builder/web/server.ts`
2537
+ - Create: `src/builder/web/api.ts`
2538
+ - Test: `tests/unit/builder-web/server.test.ts`
2539
+ - Test: `tests/unit/builder-web/api.test.ts`
2540
+
2541
+ - [ ] **Step 1: Write failing tests for API handlers**
2542
+
2543
+ ```typescript
2544
+ // tests/unit/builder-web/api.test.ts
2545
+ import { describe, it, expect } from "vitest";
2546
+ import {
2547
+ handleGetTemplate,
2548
+ handlePostSubmit,
2549
+ } from "../../../src/builder/web/api.js";
2550
+ import type { ScopeManifest } from "../../../src/types.js";
2551
+ import fs from "fs";
2552
+ import path from "path";
2553
+
2554
+ const template: Partial<ScopeManifest> = {
2555
+ engagement_id: "ENG-2026-0001",
2556
+ client: "Test Corp",
2557
+ operator: "test@test.com",
2558
+ engagement_window: { start: "2026-04-01T00:00:00Z", end: "2026-04-15T00:00:00Z" },
2559
+ excluded_techniques: ["dos", "destructive"],
2560
+ };
2561
+
2562
+ describe("API Handlers", () => {
2563
+ describe("handleGetTemplate", () => {
2564
+ it("returns the template as JSON", () => {
2565
+ const result = handleGetTemplate(template);
2566
+ expect(result.status).toBe(200);
2567
+ expect(JSON.parse(result.body)).toEqual(template);
2568
+ });
2569
+
2570
+ it("returns empty object when no template", () => {
2571
+ const result = handleGetTemplate(undefined);
2572
+ expect(result.status).toBe(200);
2573
+ expect(JSON.parse(result.body)).toEqual({});
2574
+ });
2575
+ });
2576
+
2577
+ describe("handlePostSubmit", () => {
2578
+ const outputDir = path.join(process.cwd(), "test-output-api");
2579
+
2580
+ afterEach(() => {
2581
+ if (fs.existsSync(outputDir)) {
2582
+ fs.rmSync(outputDir, { recursive: true });
2583
+ }
2584
+ });
2585
+
2586
+ it("validates and saves a valid submission", () => {
2587
+ const submission: ScopeManifest = {
2588
+ engagement_id: "ENG-2026-0001",
2589
+ client: "Test Corp",
2590
+ operator: "test@test.com",
2591
+ authorized_targets: { ip_ranges: ["10.0.0.0/24"], domains: ["*.test.com"], urls: [], cloud_accounts: [] },
2592
+ excluded_targets: [],
2593
+ authorized_techniques: ["recon"],
2594
+ excluded_techniques: ["dos"],
2595
+ engagement_window: { start: "2026-04-01T00:00:00Z", end: "2026-04-15T00:00:00Z" },
2596
+ };
2597
+
2598
+ const result = handlePostSubmit(JSON.stringify(submission), outputDir);
2599
+ expect(result.status).toBe(200);
2600
+ expect(JSON.parse(result.body).success).toBe(true);
2601
+
2602
+ // Check file was written
2603
+ const files = fs.readdirSync(outputDir).filter((f) => f.endsWith(".pending.json"));
2604
+ expect(files).toHaveLength(1);
2605
+ });
2606
+
2607
+ it("rejects invalid JSON", () => {
2608
+ const result = handlePostSubmit("not json", outputDir);
2609
+ expect(result.status).toBe(400);
2610
+ });
2611
+
2612
+ it("rejects manifest that fails schema validation", () => {
2613
+ const result = handlePostSubmit(JSON.stringify({ engagement_id: "" }), outputDir);
2614
+ expect(result.status).toBe(400);
2615
+ });
2616
+ });
2617
+ });
2618
+ ```
2619
+
2620
+ - [ ] **Step 2: Run tests to verify they fail**
2621
+
2622
+ Run: `npx vitest run tests/unit/builder-web/api.test.ts`
2623
+ Expected: FAIL — module not found
2624
+
2625
+ - [ ] **Step 3: Implement API handlers**
2626
+
2627
+ ```typescript
2628
+ // src/builder/web/api.ts
2629
+
2630
+ import fs from "fs";
2631
+ import path from "path";
2632
+ import { ScopeManifestSchema } from "../../manifest-schema.js";
2633
+ import type { ScopeManifest } from "../../types.js";
2634
+
2635
+ export interface ApiResponse {
2636
+ status: number;
2637
+ body: string;
2638
+ contentType: string;
2639
+ }
2640
+
2641
+ export function handleGetTemplate(template: Partial<ScopeManifest> | undefined): ApiResponse {
2642
+ return {
2643
+ status: 200,
2644
+ body: JSON.stringify(template ?? {}),
2645
+ contentType: "application/json",
2646
+ };
2647
+ }
2648
+
2649
+ export function handlePostSubmit(rawBody: string, outputDir: string): ApiResponse {
2650
+ let data: unknown;
2651
+ try {
2652
+ data = JSON.parse(rawBody);
2653
+ } catch {
2654
+ return {
2655
+ status: 400,
2656
+ body: JSON.stringify({ success: false, error: "Invalid JSON" }),
2657
+ contentType: "application/json",
2658
+ };
2659
+ }
2660
+
2661
+ const parseResult = ScopeManifestSchema.safeParse(data);
2662
+ if (!parseResult.success) {
2663
+ return {
2664
+ status: 400,
2665
+ body: JSON.stringify({
2666
+ success: false,
2667
+ error: "Schema validation failed",
2668
+ details: parseResult.error.issues.map((i) => i.message),
2669
+ }),
2670
+ contentType: "application/json",
2671
+ };
2672
+ }
2673
+
2674
+ // Write to outputDir as a .pending.json file
2675
+ if (!fs.existsSync(outputDir)) {
2676
+ fs.mkdirSync(outputDir, { recursive: true });
2677
+ }
2678
+
2679
+ const filename = `${parseResult.data.engagement_id}-${Date.now()}.pending.json`;
2680
+ const filePath = path.join(outputDir, filename);
2681
+ fs.writeFileSync(filePath, JSON.stringify(parseResult.data, null, 2) + "\n", "utf-8");
2682
+
2683
+ return {
2684
+ status: 200,
2685
+ body: JSON.stringify({ success: true, file: filename }),
2686
+ contentType: "application/json",
2687
+ };
2688
+ }
2689
+ ```
2690
+
2691
+ - [ ] **Step 4: Run tests to verify they pass**
2692
+
2693
+ Run: `npx vitest run tests/unit/builder-web/api.test.ts`
2694
+ Expected: All tests PASS
2695
+
2696
+ - [ ] **Step 5: Implement web server with TLS enforcement**
2697
+
2698
+ ```typescript
2699
+ // src/builder/web/server.ts
2700
+
2701
+ import http from "http";
2702
+ import https from "https";
2703
+ import fs from "fs";
2704
+ import path from "path";
2705
+ import { fileURLToPath } from "url";
2706
+ import type { ScopeManifest } from "../../types.js";
2707
+ import { handleGetTemplate, handlePostSubmit } from "./api.js";
2708
+
2709
+ export interface ServeOptions {
2710
+ port: number;
2711
+ host: string;
2712
+ template?: Partial<ScopeManifest>;
2713
+ outputDir: string;
2714
+ tlsCert?: string;
2715
+ tlsKey?: string;
2716
+ timeoutMinutes: number;
2717
+ }
2718
+
2719
+ export function startServer(options: ServeOptions): http.Server | https.Server {
2720
+ const { port, host, template, outputDir, tlsCert, tlsKey, timeoutMinutes } = options;
2721
+
2722
+ // TLS enforcement: non-localhost requires TLS
2723
+ const isLocalhost = host === "127.0.0.1" || host === "localhost" || host === "::1";
2724
+ if (!isLocalhost && (!tlsCert || !tlsKey)) {
2725
+ console.error(
2726
+ "Refusing to serve client intake form over plaintext on a non-localhost interface. " +
2727
+ "Provide --tls-cert and --tls-key."
2728
+ );
2729
+ process.exit(1);
2730
+ }
2731
+
2732
+ // Load the client form HTML
2733
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
2734
+ const formPath = path.join(thisDir, "client-form.html");
2735
+ const formHtml = fs.existsSync(formPath) ? fs.readFileSync(formPath, "utf-8") : "<h1>Form not found</h1>";
2736
+
2737
+ const handler = (req: http.IncomingMessage, res: http.ServerResponse) => {
2738
+ // CORS headers for local development
2739
+ res.setHeader("Access-Control-Allow-Origin", "*");
2740
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
2741
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
2742
+
2743
+ if (req.method === "OPTIONS") {
2744
+ res.writeHead(204);
2745
+ res.end();
2746
+ return;
2747
+ }
2748
+
2749
+ if (req.method === "GET" && (req.url === "/" || req.url === "")) {
2750
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2751
+ res.end(formHtml);
2752
+ return;
2753
+ }
2754
+
2755
+ if (req.method === "GET" && req.url === "/template") {
2756
+ const result = handleGetTemplate(template);
2757
+ res.writeHead(result.status, { "Content-Type": result.contentType });
2758
+ res.end(result.body);
2759
+ return;
2760
+ }
2761
+
2762
+ if (req.method === "POST" && req.url === "/submit") {
2763
+ let body = "";
2764
+ req.on("data", (chunk) => { body += chunk; });
2765
+ req.on("end", () => {
2766
+ const result = handlePostSubmit(body, outputDir);
2767
+ res.writeHead(result.status, { "Content-Type": result.contentType });
2768
+ res.end(result.body);
2769
+
2770
+ if (result.status === 200) {
2771
+ console.log(`\n✓ Submission received and saved to ${outputDir}`);
2772
+ }
2773
+ });
2774
+ return;
2775
+ }
2776
+
2777
+ res.writeHead(404, { "Content-Type": "application/json" });
2778
+ res.end(JSON.stringify({ error: "Not found" }));
2779
+ };
2780
+
2781
+ let server: http.Server | https.Server;
2782
+
2783
+ if (tlsCert && tlsKey) {
2784
+ const cert = fs.readFileSync(tlsCert, "utf-8");
2785
+ const key = fs.readFileSync(tlsKey, "utf-8");
2786
+ server = https.createServer({ cert, key }, handler);
2787
+ } else {
2788
+ server = http.createServer(handler);
2789
+ }
2790
+
2791
+ // Auto-shutdown timer
2792
+ let timeout: ReturnType<typeof setTimeout>;
2793
+ const resetTimeout = () => {
2794
+ if (timeout) clearTimeout(timeout);
2795
+ timeout = setTimeout(() => {
2796
+ console.log(`\nServer auto-shutdown after ${timeoutMinutes} minutes of inactivity.`);
2797
+ server.close();
2798
+ process.exit(0);
2799
+ }, timeoutMinutes * 60_000);
2800
+ };
2801
+
2802
+ server.on("request", resetTimeout);
2803
+
2804
+ server.listen(port, host, () => {
2805
+ const protocol = tlsCert ? "https" : "http";
2806
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
2807
+ console.log(`\n🔒 Scope Manifest — Client Intake Form`);
2808
+ console.log(` ${protocol}://${displayHost}:${port}`);
2809
+ console.log(` Output: ${outputDir}`);
2810
+ console.log(` Auto-shutdown: ${timeoutMinutes} minutes\n`);
2811
+ resetTimeout();
2812
+ });
2813
+
2814
+ return server;
2815
+ }
2816
+ ```
2817
+
2818
+ - [ ] **Step 6: Write failing test for server TLS enforcement**
2819
+
2820
+ ```typescript
2821
+ // tests/unit/builder-web/server.test.ts
2822
+ import { describe, it, expect, vi, afterEach } from "vitest";
2823
+
2824
+ describe("Server TLS enforcement", () => {
2825
+ it("exits with error when non-localhost host lacks TLS", async () => {
2826
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); });
2827
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
2828
+
2829
+ const { startServer } = await import("../../../src/builder/web/server.js");
2830
+
2831
+ expect(() => startServer({
2832
+ port: 0,
2833
+ host: "0.0.0.0",
2834
+ outputDir: "/tmp/test",
2835
+ timeoutMinutes: 60,
2836
+ })).toThrow("exit");
2837
+
2838
+ expect(errorSpy).toHaveBeenCalledWith(
2839
+ expect.stringContaining("Refusing to serve")
2840
+ );
2841
+
2842
+ exitSpy.mockRestore();
2843
+ errorSpy.mockRestore();
2844
+ });
2845
+ });
2846
+ ```
2847
+
2848
+ - [ ] **Step 7: Run tests to verify they pass**
2849
+
2850
+ Run: `npx vitest run tests/unit/builder-web/`
2851
+ Expected: All tests PASS
2852
+
2853
+ - [ ] **Step 8: Commit**
2854
+
2855
+ ```bash
2856
+ git add src/builder/web/server.ts src/builder/web/api.ts tests/unit/builder-web/
2857
+ git commit -m "feat(builder): add web server with TLS enforcement and API handlers"
2858
+ ```
2859
+
2860
+ ---
2861
+
2862
+ ### Task 12: Client Intake Form HTML
2863
+
2864
+ **Files:**
2865
+ - Create: `src/builder/web/client-form.html`
2866
+
2867
+ - [ ] **Step 1: Create the client intake form**
2868
+
2869
+ ```html
2870
+ <!-- src/builder/web/client-form.html -->
2871
+ <!DOCTYPE html>
2872
+ <html lang="en">
2873
+ <head>
2874
+ <meta charset="utf-8">
2875
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2876
+ <title>IntegSec — Scope Definition</title>
2877
+ <style>
2878
+ :root {
2879
+ --bg: #0f1117;
2880
+ --surface: #1a1d27;
2881
+ --border: #2a2d3a;
2882
+ --text: #e1e4ed;
2883
+ --text-dim: #8b8fa3;
2884
+ --accent: #6366f1;
2885
+ --accent-hover: #818cf8;
2886
+ --success: #10b981;
2887
+ --error: #ef4444;
2888
+ --warning: #f59e0b;
2889
+ }
2890
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2891
+ body {
2892
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
2893
+ background: var(--bg);
2894
+ color: var(--text);
2895
+ line-height: 1.6;
2896
+ padding: 2rem;
2897
+ max-width: 800px;
2898
+ margin: 0 auto;
2899
+ }
2900
+ h1 {
2901
+ font-size: 1.5rem;
2902
+ margin-bottom: 0.25rem;
2903
+ }
2904
+ .subtitle {
2905
+ color: var(--text-dim);
2906
+ margin-bottom: 2rem;
2907
+ }
2908
+ .section {
2909
+ background: var(--surface);
2910
+ border: 1px solid var(--border);
2911
+ border-radius: 8px;
2912
+ padding: 1.5rem;
2913
+ margin-bottom: 1.5rem;
2914
+ }
2915
+ .section h2 {
2916
+ font-size: 1.1rem;
2917
+ margin-bottom: 1rem;
2918
+ display: flex;
2919
+ align-items: center;
2920
+ gap: 0.5rem;
2921
+ }
2922
+ label {
2923
+ display: block;
2924
+ font-size: 0.875rem;
2925
+ color: var(--text-dim);
2926
+ margin-bottom: 0.25rem;
2927
+ }
2928
+ .tooltip {
2929
+ font-size: 0.75rem;
2930
+ color: var(--text-dim);
2931
+ margin-bottom: 0.5rem;
2932
+ font-style: italic;
2933
+ }
2934
+ input, textarea {
2935
+ width: 100%;
2936
+ padding: 0.5rem 0.75rem;
2937
+ background: var(--bg);
2938
+ border: 1px solid var(--border);
2939
+ border-radius: 4px;
2940
+ color: var(--text);
2941
+ font-family: "SF Mono", "Fira Code", monospace;
2942
+ font-size: 0.875rem;
2943
+ margin-bottom: 1rem;
2944
+ }
2945
+ input:focus, textarea:focus {
2946
+ outline: none;
2947
+ border-color: var(--accent);
2948
+ }
2949
+ input:disabled {
2950
+ opacity: 0.5;
2951
+ cursor: not-allowed;
2952
+ }
2953
+ input.valid { border-color: var(--success); }
2954
+ input.invalid { border-color: var(--error); }
2955
+ .field-msg {
2956
+ font-size: 0.75rem;
2957
+ margin-top: -0.75rem;
2958
+ margin-bottom: 0.75rem;
2959
+ }
2960
+ .field-msg.error { color: var(--error); }
2961
+ .field-msg.warning { color: var(--warning); }
2962
+ .field-msg.info { color: var(--accent); }
2963
+ textarea { min-height: 80px; resize: vertical; }
2964
+ button {
2965
+ background: var(--accent);
2966
+ color: white;
2967
+ border: none;
2968
+ padding: 0.75rem 2rem;
2969
+ border-radius: 6px;
2970
+ font-size: 1rem;
2971
+ cursor: pointer;
2972
+ display: block;
2973
+ width: 100%;
2974
+ margin-top: 1rem;
2975
+ }
2976
+ button:hover { background: var(--accent-hover); }
2977
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
2978
+ .result {
2979
+ padding: 1rem;
2980
+ border-radius: 6px;
2981
+ margin-top: 1rem;
2982
+ text-align: center;
2983
+ }
2984
+ .result.success { background: rgba(16, 185, 129, 0.1); border: 1px solid var(--success); color: var(--success); }
2985
+ .result.error { background: rgba(239, 68, 68, 0.1); border: 1px solid var(--error); color: var(--error); }
2986
+ .locked-badge {
2987
+ font-size: 0.65rem;
2988
+ background: var(--border);
2989
+ padding: 0.15rem 0.4rem;
2990
+ border-radius: 3px;
2991
+ color: var(--text-dim);
2992
+ vertical-align: middle;
2993
+ }
2994
+ .brand {
2995
+ display: flex;
2996
+ align-items: center;
2997
+ gap: 0.5rem;
2998
+ margin-bottom: 1.5rem;
2999
+ padding-bottom: 1rem;
3000
+ border-bottom: 1px solid var(--border);
3001
+ }
3002
+ .brand-logo {
3003
+ font-weight: 700;
3004
+ font-size: 1.1rem;
3005
+ color: var(--accent);
3006
+ }
3007
+ </style>
3008
+ </head>
3009
+ <body>
3010
+ <div class="brand">
3011
+ <span class="brand-logo">IntegSec</span>
3012
+ <span class="subtitle" style="margin: 0">Scope Definition Form</span>
3013
+ </div>
3014
+
3015
+ <form id="scopeForm" onsubmit="return submitForm(event)">
3016
+ <!-- Engagement Info -->
3017
+ <div class="section">
3018
+ <h2>Engagement Info</h2>
3019
+ <label for="engagement_id">Engagement ID</label>
3020
+ <input type="text" id="engagement_id" placeholder="ENG-2026-0001">
3021
+
3022
+ <label for="client">Client Organization</label>
3023
+ <input type="text" id="client" placeholder="Acme Corp">
3024
+
3025
+ <label for="operator">Operator Contact</label>
3026
+ <input type="text" id="operator" placeholder="operator@integsec.com">
3027
+ </div>
3028
+
3029
+ <!-- Engagement Window -->
3030
+ <div class="section">
3031
+ <h2>Engagement Window</h2>
3032
+ <label for="window_start">Start (ISO 8601)</label>
3033
+ <div class="tooltip">Example: 2026-04-01T08:00:00Z</div>
3034
+ <input type="text" id="window_start" placeholder="2026-04-01T08:00:00Z">
3035
+
3036
+ <label for="window_end">End (ISO 8601)</label>
3037
+ <input type="text" id="window_end" placeholder="2026-04-15T17:00:00Z">
3038
+ </div>
3039
+
3040
+ <!-- Authorized Targets -->
3041
+ <div class="section">
3042
+ <h2>Authorized Targets</h2>
3043
+
3044
+ <label for="domains">Domains</label>
3045
+ <div class="tooltip">One per line. Wildcards OK: *.acme.com matches app.acme.com but not acme.com</div>
3046
+ <textarea id="domains" placeholder="*.acme.com&#10;acme-staging.example.com"></textarea>
3047
+
3048
+ <label for="ip_ranges">IP Ranges (CIDR)</label>
3049
+ <div class="tooltip">CIDR notation. Example: 10.10.10.0/24 covers 10.10.10.0 through 10.10.10.255</div>
3050
+ <textarea id="ip_ranges" placeholder="10.10.10.0/24&#10;203.0.113.0/28"></textarea>
3051
+
3052
+ <label for="urls">URLs (optional)</label>
3053
+ <div class="tooltip">Reference URLs for the engagement. One per line.</div>
3054
+ <textarea id="urls" placeholder="https://app.acme.com&#10;https://api.acme.com"></textarea>
3055
+
3056
+ <label for="cloud_accounts">Cloud Accounts (optional)</label>
3057
+ <div class="tooltip">Format: provider:account-id. Example: aws:123456789012</div>
3058
+ <textarea id="cloud_accounts" placeholder="aws:123456789012"></textarea>
3059
+ </div>
3060
+
3061
+ <!-- Exclusions -->
3062
+ <div class="section">
3063
+ <h2>Exclusions</h2>
3064
+ <label for="excluded_targets">Excluded Targets</label>
3065
+ <div class="tooltip">Targets within scope that must NOT be tested. One per line.</div>
3066
+ <textarea id="excluded_targets" placeholder="hr.acme.com&#10;203.0.113.5"></textarea>
3067
+ </div>
3068
+
3069
+ <!-- Techniques -->
3070
+ <div class="section">
3071
+ <h2>Techniques</h2>
3072
+
3073
+ <label for="authorized_techniques">Authorized Techniques</label>
3074
+ <div class="tooltip">Comma-separated. Common: recon, web_app, api_testing, ssl_tls</div>
3075
+ <input type="text" id="authorized_techniques" placeholder="recon, web_app, api_testing">
3076
+
3077
+ <label for="excluded_techniques">Excluded Techniques</label>
3078
+ <div class="tooltip">Comma-separated. Usually: dos, destructive, social_engineering</div>
3079
+ <input type="text" id="excluded_techniques" placeholder="dos, destructive, social_engineering">
3080
+ </div>
3081
+
3082
+ <button type="submit" id="submitBtn">Submit Scope Definition</button>
3083
+ <div id="result"></div>
3084
+ </form>
3085
+
3086
+ <script>
3087
+ // Load template and lock pre-filled fields
3088
+ fetch("/template")
3089
+ .then((r) => r.json())
3090
+ .then((tpl) => {
3091
+ const fieldMap = {
3092
+ engagement_id: "engagement_id",
3093
+ client: "client",
3094
+ operator: "operator",
3095
+ };
3096
+ for (const [key, id] of Object.entries(fieldMap)) {
3097
+ if (tpl[key]) {
3098
+ const el = document.getElementById(id);
3099
+ el.value = tpl[key];
3100
+ el.disabled = true;
3101
+ }
3102
+ }
3103
+ if (tpl.engagement_window) {
3104
+ if (tpl.engagement_window.start) {
3105
+ const el = document.getElementById("window_start");
3106
+ el.value = tpl.engagement_window.start;
3107
+ el.disabled = true;
3108
+ }
3109
+ if (tpl.engagement_window.end) {
3110
+ const el = document.getElementById("window_end");
3111
+ el.value = tpl.engagement_window.end;
3112
+ el.disabled = true;
3113
+ }
3114
+ }
3115
+ if (tpl.excluded_techniques) {
3116
+ const el = document.getElementById("excluded_techniques");
3117
+ el.value = tpl.excluded_techniques.join(", ");
3118
+ el.disabled = true;
3119
+ }
3120
+ })
3121
+ .catch(() => {}); // No template, all fields editable
3122
+
3123
+ // Live validation on text inputs
3124
+ document.querySelectorAll("input[type=text]").forEach((input) => {
3125
+ input.addEventListener("blur", () => {
3126
+ if (input.disabled) return;
3127
+ if (input.value.trim()) {
3128
+ input.classList.add("valid");
3129
+ input.classList.remove("invalid");
3130
+ } else if (input.required || ["engagement_id", "client", "operator"].includes(input.id)) {
3131
+ input.classList.add("invalid");
3132
+ input.classList.remove("valid");
3133
+ }
3134
+ });
3135
+ });
3136
+
3137
+ function parseLines(id) {
3138
+ return document.getElementById(id).value
3139
+ .split("\n")
3140
+ .map((s) => s.trim())
3141
+ .filter(Boolean);
3142
+ }
3143
+
3144
+ function parseComma(id) {
3145
+ return document.getElementById(id).value
3146
+ .split(",")
3147
+ .map((s) => s.trim())
3148
+ .filter(Boolean);
3149
+ }
3150
+
3151
+ function val(id) {
3152
+ return document.getElementById(id).value.trim();
3153
+ }
3154
+
3155
+ async function submitForm(e) {
3156
+ e.preventDefault();
3157
+ const btn = document.getElementById("submitBtn");
3158
+ const result = document.getElementById("result");
3159
+ btn.disabled = true;
3160
+ btn.textContent = "Submitting...";
3161
+
3162
+ const manifest = {
3163
+ engagement_id: val("engagement_id"),
3164
+ client: val("client"),
3165
+ operator: val("operator"),
3166
+ authorized_targets: {
3167
+ ip_ranges: parseLines("ip_ranges"),
3168
+ domains: parseLines("domains"),
3169
+ urls: parseLines("urls"),
3170
+ cloud_accounts: parseLines("cloud_accounts"),
3171
+ },
3172
+ excluded_targets: parseLines("excluded_targets"),
3173
+ authorized_techniques: parseComma("authorized_techniques"),
3174
+ excluded_techniques: parseComma("excluded_techniques"),
3175
+ engagement_window: {
3176
+ start: val("window_start"),
3177
+ end: val("window_end"),
3178
+ },
3179
+ };
3180
+
3181
+ try {
3182
+ const res = await fetch("/submit", {
3183
+ method: "POST",
3184
+ headers: { "Content-Type": "application/json" },
3185
+ body: JSON.stringify(manifest),
3186
+ });
3187
+ const data = await res.json();
3188
+ if (data.success) {
3189
+ result.className = "result success";
3190
+ result.textContent = "Scope definition submitted successfully. Your operator will review it.";
3191
+ } else {
3192
+ result.className = "result error";
3193
+ result.textContent = "Validation failed: " + (data.details?.join(", ") || data.error);
3194
+ }
3195
+ } catch (err) {
3196
+ result.className = "result error";
3197
+ result.textContent = "Network error: " + err.message;
3198
+ }
3199
+
3200
+ btn.disabled = false;
3201
+ btn.textContent = "Submit Scope Definition";
3202
+ return false;
3203
+ }
3204
+ </script>
3205
+ </body>
3206
+ </html>
3207
+ ```
3208
+
3209
+ - [ ] **Step 2: Commit**
3210
+
3211
+ ```bash
3212
+ git add src/builder/web/client-form.html
3213
+ git commit -m "feat(builder): add client intake web form with live validation"
3214
+ ```
3215
+
3216
+ ---
3217
+
3218
+ ### Task 13: Wire `serve` Mode into CLI
3219
+
3220
+ **Files:**
3221
+ - Modify: `bin/integsec-agentic-pentest-manifest.ts`
3222
+
3223
+ - [ ] **Step 1: Update CLI to support serve mode**
3224
+
3225
+ In `bin/integsec-agentic-pentest-manifest.ts`, add serve-mode args to `parseArgs()`:
3226
+
3227
+ ```typescript
3228
+ // Add to the return type:
3229
+ template?: string;
3230
+ host?: string;
3231
+ port?: number;
3232
+ tlsCert?: string;
3233
+ tlsKey?: string;
3234
+ timeout?: number;
3235
+ ```
3236
+
3237
+ Add arg parsing in the for loop:
3238
+
3239
+ ```typescript
3240
+ } else if (args[i] === "--template" && args[i + 1]) {
3241
+ fromTemplate = args[i + 1]; // reuse fromTemplate for serve template
3242
+ i++;
3243
+ } else if (args[i] === "--host" && args[i + 1]) {
3244
+ // Store host in a new variable
3245
+ i++;
3246
+ } else if (args[i] === "--port" && args[i + 1]) {
3247
+ i++;
3248
+ } else if (args[i] === "--tls-cert" && args[i + 1]) {
3249
+ i++;
3250
+ } else if (args[i] === "--tls-key" && args[i + 1]) {
3251
+ i++;
3252
+ } else if (args[i] === "--timeout" && args[i + 1]) {
3253
+ i++;
3254
+ }
3255
+ ```
3256
+
3257
+ Replace the serve mode placeholder:
3258
+
3259
+ ```typescript
3260
+ } else if (config.mode === "serve") {
3261
+ const template = config.fromTemplate ? loadTemplate(config.fromTemplate) : undefined;
3262
+
3263
+ // Parse serve-specific args
3264
+ const args = process.argv.slice(2);
3265
+ let host = "127.0.0.1";
3266
+ let port = 9471;
3267
+ let tlsCert: string | undefined;
3268
+ let tlsKey: string | undefined;
3269
+ let timeout = 60;
3270
+
3271
+ for (let i = 0; i < args.length; i++) {
3272
+ if (args[i] === "--host" && args[i + 1]) { host = args[i + 1]; i++; }
3273
+ if (args[i] === "--port" && args[i + 1]) { port = parseInt(args[i + 1], 10); i++; }
3274
+ if (args[i] === "--tls-cert" && args[i + 1]) { tlsCert = args[i + 1]; i++; }
3275
+ if (args[i] === "--tls-key" && args[i + 1]) { tlsKey = args[i + 1]; i++; }
3276
+ if (args[i] === "--timeout" && args[i + 1]) { timeout = parseInt(args[i + 1], 10); i++; }
3277
+ }
3278
+
3279
+ const { startServer } = await import("../src/builder/web/server.js");
3280
+ startServer({
3281
+ port,
3282
+ host,
3283
+ template,
3284
+ outputDir: config.output ?? "./submissions",
3285
+ tlsCert,
3286
+ tlsKey,
3287
+ timeoutMinutes: timeout,
3288
+ });
3289
+ }
3290
+ ```
3291
+
3292
+ - [ ] **Step 2: Build and smoke test**
3293
+
3294
+ Run: `npx tsc && node dist/bin/integsec-agentic-pentest-manifest.js serve --help`
3295
+ Expected: Help text shows serve mode
3296
+
3297
+ - [ ] **Step 3: Commit**
3298
+
3299
+ ```bash
3300
+ git add bin/integsec-agentic-pentest-manifest.ts
3301
+ git commit -m "feat(builder): wire serve mode into CLI with TLS and timeout options"
3302
+ ```
3303
+
3304
+ ---
3305
+
3306
+ ## Phase 4: README & Polish
3307
+
3308
+ ### Task 14: Update README
3309
+
3310
+ **Files:**
3311
+ - Modify: `README.md`
3312
+
3313
+ - [ ] **Step 1: Read current README**
3314
+
3315
+ Read `README.md` to understand current structure and find the right insertion point.
3316
+
3317
+ - [ ] **Step 2: Add Manifest Builder section**
3318
+
3319
+ Add a new section after the existing usage/configuration sections:
3320
+
3321
+ ```markdown
3322
+ ## Manifest Builder
3323
+
3324
+ Build scope manifests interactively with smart validation, or share a web form with clients for scope intake.
3325
+
3326
+ ### Quick Start
3327
+
3328
+ ```bash
3329
+ # Create a new manifest interactively
3330
+ integsec-agentic-pentest-manifest new
3331
+
3332
+ # Create from a template
3333
+ integsec-agentic-pentest-manifest new --from template.json -o scope.json
3334
+
3335
+ # Edit an existing manifest
3336
+ integsec-agentic-pentest-manifest edit scope.json
3337
+
3338
+ # Review a client submission
3339
+ integsec-agentic-pentest-manifest review submission.pending.json
3340
+ ```
3341
+
3342
+ ### Client Intake (Web Form)
3343
+
3344
+ Share a web-based form with clients so they can define their own scope:
3345
+
3346
+ ```bash
3347
+ # Local only (default — localhost:9471)
3348
+ integsec-agentic-pentest-manifest serve --template engagement-template.json
3349
+
3350
+ # Network-accessible (TLS required)
3351
+ integsec-agentic-pentest-manifest serve \
3352
+ --host 0.0.0.0 \
3353
+ --tls-cert ./certs/server.pem \
3354
+ --tls-key ./certs/server-key.pem \
3355
+ --template engagement-template.json
3356
+ ```
3357
+
3358
+ **Workflow:**
3359
+ 1. Operator creates a template with engagement info, time window, and technique restrictions
3360
+ 2. Operator runs `serve` and shares the URL with the client
3361
+ 3. Client fills in their targets (domains, IPs, exclusions) via the web form
3362
+ 4. Client submits → saved as `.pending.json`
3363
+ 5. Operator reviews: `integsec-agentic-pentest-manifest review submission.pending.json`
3364
+ 6. Operator approves, edits, or rejects
3365
+
3366
+ **Security:** The web server binds to `127.0.0.1` by default. Exposing on `0.0.0.0` requires `--tls-cert` and `--tls-key` — the server refuses to start over plaintext on non-localhost interfaces.
3367
+
3368
+ ### Smart Validation
3369
+
3370
+ The builder validates your manifest as you build it:
3371
+
3372
+ - **Format checks** — valid CIDRs, ISO 8601 dates, non-empty required fields
3373
+ - **Live DNS resolution** — resolves domains and shows their IPs
3374
+ - **Overlap detection** — warns when domains resolve to IPs already covered by CIDR ranges
3375
+ - **Broad range warnings** — flags CIDR ranges wider than /16
3376
+ - **Wildcard/apex prompts** — suggests adding `acme.com` when `*.acme.com` is entered
3377
+ - **Exclusion checks** — warns if an excluded target isn't within any authorized range
3378
+
3379
+ ### Dry-Run Preview
3380
+
3381
+ Before saving, the builder runs synthetic tool calls against your manifest to preview what the proxy will allow and block. This catches scope misconfigurations before an engagement starts.
3382
+ ```
3383
+
3384
+ - [ ] **Step 3: Commit**
3385
+
3386
+ ```bash
3387
+ git add README.md
3388
+ git commit -m "docs: add Manifest Builder section to README"
3389
+ ```
3390
+
3391
+ ---
3392
+
3393
+ ### Task 15: Full Build, Test, and Polish
3394
+
3395
+ **Files:**
3396
+ - All project files
3397
+
3398
+ - [ ] **Step 1: Build the project**
3399
+
3400
+ Run: `npx tsc`
3401
+ Expected: No type errors
3402
+
3403
+ - [ ] **Step 2: Run full test suite**
3404
+
3405
+ Run: `npx vitest run`
3406
+ Expected: All tests pass
3407
+
3408
+ - [ ] **Step 3: Fix any remaining issues**
3409
+
3410
+ Address any type errors, test failures, or lint issues.
3411
+
3412
+ - [ ] **Step 4: Final commit**
3413
+
3414
+ ```bash
3415
+ git add -A
3416
+ git commit -m "chore(builder): Phase 4 polish — resolve remaining issues"
3417
+ ```
3418
+
3419
+ - [ ] **Step 5: Verify the three entry points work**
3420
+
3421
+ Run:
3422
+ ```bash
3423
+ node dist/bin/integsec-agentic-pentest-manifest.js --help
3424
+ node dist/bin/integsec-agentic-pentest-manifest.js serve &
3425
+ curl http://localhost:9471/ -s | head -5
3426
+ kill %1
3427
+ ```
3428
+
3429
+ Expected: Help prints, server starts, HTML form is served.