sentinelayer-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +996 -0
  2. package/bin/create-sentinelayer.js +5 -0
  3. package/bin/sentinelayer-cli.js +5 -0
  4. package/bin/sl.js +5 -0
  5. package/package.json +54 -0
  6. package/src/agents/jules/config/definition.js +209 -0
  7. package/src/agents/jules/config/system-prompt.js +175 -0
  8. package/src/agents/jules/error-intake.js +51 -0
  9. package/src/agents/jules/fix-cycle.js +377 -0
  10. package/src/agents/jules/loop.js +367 -0
  11. package/src/agents/jules/pulse.js +319 -0
  12. package/src/agents/jules/stream.js +186 -0
  13. package/src/agents/jules/swarm/file-scanner.js +74 -0
  14. package/src/agents/jules/swarm/index.js +11 -0
  15. package/src/agents/jules/swarm/orchestrator.js +362 -0
  16. package/src/agents/jules/swarm/pattern-hunter.js +123 -0
  17. package/src/agents/jules/swarm/sub-agent.js +308 -0
  18. package/src/agents/jules/tools/auth-audit.js +222 -0
  19. package/src/agents/jules/tools/dispatch.js +327 -0
  20. package/src/agents/jules/tools/file-edit.js +180 -0
  21. package/src/agents/jules/tools/file-read.js +100 -0
  22. package/src/agents/jules/tools/frontend-analyze.js +570 -0
  23. package/src/agents/jules/tools/glob.js +168 -0
  24. package/src/agents/jules/tools/grep.js +228 -0
  25. package/src/agents/jules/tools/index.js +29 -0
  26. package/src/agents/jules/tools/path-guards.js +161 -0
  27. package/src/agents/jules/tools/runtime-audit.js +409 -0
  28. package/src/agents/jules/tools/shell.js +383 -0
  29. package/src/ai/aidenid.js +945 -0
  30. package/src/ai/client.js +508 -0
  31. package/src/ai/domain-target-store.js +268 -0
  32. package/src/ai/identity-store.js +270 -0
  33. package/src/ai/site-store.js +145 -0
  34. package/src/audit/agents/architecture.js +180 -0
  35. package/src/audit/agents/compliance.js +179 -0
  36. package/src/audit/agents/documentation.js +165 -0
  37. package/src/audit/agents/performance.js +145 -0
  38. package/src/audit/agents/security.js +215 -0
  39. package/src/audit/agents/testing.js +172 -0
  40. package/src/audit/orchestrator.js +557 -0
  41. package/src/audit/package.js +204 -0
  42. package/src/audit/registry.js +284 -0
  43. package/src/audit/replay.js +103 -0
  44. package/src/auth/http.js +113 -0
  45. package/src/auth/service.js +848 -0
  46. package/src/auth/session-store.js +345 -0
  47. package/src/cli.js +244 -0
  48. package/src/commands/ai/identity-lifecycle.js +1337 -0
  49. package/src/commands/ai/provision-governance.js +1246 -0
  50. package/src/commands/ai/shared.js +147 -0
  51. package/src/commands/ai.js +11 -0
  52. package/src/commands/apply.js +19 -0
  53. package/src/commands/audit.js +1147 -0
  54. package/src/commands/auth.js +366 -0
  55. package/src/commands/chat.js +191 -0
  56. package/src/commands/config.js +184 -0
  57. package/src/commands/cost.js +311 -0
  58. package/src/commands/daemon/core.js +850 -0
  59. package/src/commands/daemon/extended.js +1048 -0
  60. package/src/commands/daemon/shared.js +213 -0
  61. package/src/commands/daemon.js +11 -0
  62. package/src/commands/guide.js +174 -0
  63. package/src/commands/ingest.js +58 -0
  64. package/src/commands/init.js +55 -0
  65. package/src/commands/legacy-args.js +30 -0
  66. package/src/commands/mcp.js +404 -0
  67. package/src/commands/omargate.js +21 -0
  68. package/src/commands/persona.js +27 -0
  69. package/src/commands/plugin.js +260 -0
  70. package/src/commands/policy.js +132 -0
  71. package/src/commands/prompt.js +238 -0
  72. package/src/commands/review.js +704 -0
  73. package/src/commands/scan.js +788 -0
  74. package/src/commands/spec.js +716 -0
  75. package/src/commands/swarm.js +651 -0
  76. package/src/commands/telemetry.js +202 -0
  77. package/src/commands/watch.js +510 -0
  78. package/src/config/agent-dictionary.js +182 -0
  79. package/src/config/io.js +56 -0
  80. package/src/config/paths.js +18 -0
  81. package/src/config/schema.js +55 -0
  82. package/src/config/service.js +184 -0
  83. package/src/cost/budget.js +235 -0
  84. package/src/cost/history.js +188 -0
  85. package/src/cost/tracker.js +171 -0
  86. package/src/daemon/artifact-lineage.js +534 -0
  87. package/src/daemon/assignment-ledger.js +770 -0
  88. package/src/daemon/ast-parser-layer.js +258 -0
  89. package/src/daemon/budget-governor.js +633 -0
  90. package/src/daemon/callgraph-overlay.js +646 -0
  91. package/src/daemon/error-worker.js +626 -0
  92. package/src/daemon/hybrid-mapper.js +929 -0
  93. package/src/daemon/jira-lifecycle.js +632 -0
  94. package/src/daemon/operator-control.js +657 -0
  95. package/src/daemon/reliability-lane.js +471 -0
  96. package/src/daemon/watchdog.js +971 -0
  97. package/src/guide/generator.js +316 -0
  98. package/src/ingest/engine.js +918 -0
  99. package/src/legacy-cli.js +2435 -0
  100. package/src/mcp/registry.js +695 -0
  101. package/src/memory/blackboard.js +301 -0
  102. package/src/memory/retrieval.js +581 -0
  103. package/src/plugin/manifest.js +553 -0
  104. package/src/policy/packs.js +144 -0
  105. package/src/prompt/generator.js +106 -0
  106. package/src/review/ai-review.js +669 -0
  107. package/src/review/local-review.js +1284 -0
  108. package/src/review/replay.js +235 -0
  109. package/src/review/report.js +664 -0
  110. package/src/review/spec-binding.js +487 -0
  111. package/src/scan/generator.js +351 -0
  112. package/src/spec/generator.js +519 -0
  113. package/src/spec/regenerate.js +237 -0
  114. package/src/spec/templates.js +91 -0
  115. package/src/swarm/dashboard.js +247 -0
  116. package/src/swarm/factory.js +363 -0
  117. package/src/swarm/pentest.js +934 -0
  118. package/src/swarm/registry.js +419 -0
  119. package/src/swarm/report.js +158 -0
  120. package/src/swarm/runtime.js +576 -0
  121. package/src/swarm/scenario-dsl.js +272 -0
  122. package/src/telemetry/ledger.js +302 -0
  123. package/src/ui/markdown.js +220 -0
  124. package/src/ui/progress.js +100 -0
@@ -0,0 +1,123 @@
1
+ import { JulesSubAgent } from "./sub-agent.js";
2
+
3
+ const HUNTER_PROMPTS = {
4
+ xss: `You are an XSS PatternHunter working for Jules Tanaka.
5
+ Search the codebase for Cross-Site Scripting vulnerabilities:
6
+ - dangerouslySetInnerHTML with user-controlled input
7
+ - innerHTML assignments
8
+ - v-html directives (Vue)
9
+ - dynamic code execution (the eval function) with user input
10
+ - document write injection
11
+ - javascript: URLs in href
12
+ - template literal injection in HTML contexts
13
+
14
+ Use Grep and FrontendAnalyze('find_security_sinks') to find all matches.
15
+ For each match, determine if the input is user-controlled or sanitized.
16
+ Return findings as JSON array: [{ "file", "line", "type", "severity", "userControlled", "sanitized", "evidence" }]`,
17
+
18
+ state: `You are a State Management PatternHunter working for Jules Tanaka.
19
+ Search for React state anti-patterns:
20
+ - Components with 16+ useState calls (god components)
21
+ - useEffect with empty deps that references state (stale closures)
22
+ - useEffect without cleanup return (subscription/timer leaks)
23
+ - State updates in loops (N re-renders)
24
+ - Object/array in useEffect dependency array (new reference each render)
25
+ - Derived state stored in useState (should be computed)
26
+
27
+ Use Grep and FrontendAnalyze('count_state_hooks', 'find_missing_cleanup', 'find_stale_closures').
28
+ Return findings as JSON array: [{ "file", "line", "type", "severity", "pattern", "evidence" }]`,
29
+
30
+ hydration: `You are a Hydration Safety PatternHunter working for Jules Tanaka.
31
+ Search for SSR/CSR hydration mismatch risks:
32
+ - window/document/localStorage access during initial render (outside useEffect)
33
+ - Date.now() or Math.random() in render path (non-deterministic)
34
+ - suppressHydrationWarning without justification
35
+ - useLayoutEffect in server components
36
+ - Dynamic imports crossing server/client boundaries
37
+ - Locale/theme/auth state that can differ server vs client
38
+
39
+ Use Grep to find these patterns in .tsx/.jsx files.
40
+ Return findings as JSON array: [{ "file", "line", "type", "severity", "pattern", "evidence" }]`,
41
+
42
+ a11y: `You are an Accessibility PatternHunter working for Jules Tanaka.
43
+ Search for WCAG AA accessibility violations:
44
+ - Images without alt text
45
+ - Form inputs without labels (no <label> or aria-label)
46
+ - Buttons/links without accessible text
47
+ - Missing keyboard handlers on interactive divs (onClick without onKeyDown)
48
+ - tabIndex=-1 removing elements from tab order
49
+ - Missing focus management in modals/drawers
50
+ - Poor color contrast indicators (hardcoded light gray text)
51
+ - Missing skip navigation link
52
+ - aria-hidden on interactive elements
53
+
54
+ Use Grep and FrontendAnalyze('check_accessibility').
55
+ Return findings as JSON array: [{ "file", "line", "type", "severity", "wcag", "userImpact", "evidence" }]`,
56
+
57
+ perf: `You are a Performance PatternHunter working for Jules Tanaka.
58
+ Search for frontend performance anti-patterns:
59
+ - Large bundle imports (moment, lodash full import, d3 full import)
60
+ - Images without explicit dimensions (CLS risk)
61
+ - Fonts without font-display strategy
62
+ - Third-party scripts on critical render path
63
+ - Missing React.memo on list item components
64
+ - Inline arrow functions in map() JSX
65
+ - Large lists without virtualization
66
+ - Blocking script tags without async/defer
67
+
68
+ Use Grep, FrontendAnalyze('check_image_optimization', 'check_font_loading', 'find_third_party_scripts').
69
+ Return findings as JSON array: [{ "file", "line", "type", "severity", "impact", "evidence" }]`,
70
+
71
+ security: `You are a Frontend Security PatternHunter working for Jules Tanaka.
72
+ Search for frontend-specific security issues:
73
+ - API keys in NEXT_PUBLIC_/VITE_/REACT_APP_ env vars (especially _KEY, _SECRET, _TOKEN)
74
+ - Missing Content-Security-Policy headers
75
+ - Missing X-Frame-Options / frame-ancestors
76
+ - CORS * wildcard on sensitive endpoints
77
+ - Tokens stored in localStorage (vs httpOnly cookies)
78
+ - Missing CSRF protection on state-changing forms
79
+ - Source maps enabled in production build config
80
+
81
+ Use Grep, FrontendAnalyze('find_env_exposure', 'check_security_headers').
82
+ Return findings as JSON array: [{ "file", "line", "type", "severity", "cwe", "evidence" }]`,
83
+ };
84
+
85
+ /**
86
+ * Create a PatternHunter sub-agent for a specific issue class.
87
+ *
88
+ * @param {object} config
89
+ * @param {"xss"|"state"|"hydration"|"a11y"|"perf"|"security"} config.huntType
90
+ * @param {string} config.rootPath - Codebase root to search
91
+ * @param {object} config.budget
92
+ * @param {object} config.blackboard
93
+ * @param {object} [config.provider]
94
+ * @param {AbortController} [config.parentAbort]
95
+ * @param {function} [config.onEvent]
96
+ */
97
+ export function createPatternHunter(config) {
98
+ const prompt = HUNTER_PROMPTS[config.huntType];
99
+ if (!prompt) {
100
+ throw new Error(`Unknown hunt type: ${config.huntType}. Valid: ${Object.keys(HUNTER_PROMPTS).join(", ")}`);
101
+ }
102
+
103
+ return new JulesSubAgent({
104
+ id: `hunter-${config.huntType}-${Date.now()}`,
105
+ role: `PatternHunter-${config.huntType}`,
106
+ systemPrompt: prompt,
107
+ allowedTools: ["Grep", "Glob", "FrontendAnalyze", "FileRead"],
108
+ scope: { patterns: [config.huntType], rootPath: config.rootPath },
109
+ budget: config.budget || {
110
+ maxCostUsd: 0.3,
111
+ maxOutputTokens: 2000,
112
+ maxRuntimeMs: 60000,
113
+ maxToolCalls: 20,
114
+ },
115
+ blackboard: config.blackboard,
116
+ maxTurns: 5,
117
+ provider: config.provider,
118
+ parentAbort: config.parentAbort,
119
+ onEvent: config.onEvent,
120
+ });
121
+ }
122
+
123
+ export const HUNT_TYPES = Object.keys(HUNTER_PROMPTS);
@@ -0,0 +1,308 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createAgentContext, dispatchTool, isReadOnlyTool, BudgetExhaustedError } from "../tools/dispatch.js";
3
+ import { createMultiProviderApiClient } from "../../../ai/client.js";
4
+
5
+ /**
6
+ * JulesSubAgent — lightweight isolated agent for parallel audit work.
7
+ *
8
+ * Each sub-agent gets:
9
+ * - Own conversation context (no parent history)
10
+ * - Own tool access (subset of Jules' tools)
11
+ * - Own budget slice (clamped to parent allocation)
12
+ * - Shared blackboard (append-only)
13
+ * - Own telemetry session
14
+ * - AbortController linked to parent (kill propagation)
15
+ *
16
+ * Sub-agents are NOT full Jules instances. They are focused workers:
17
+ * - FileScanner: reads file batches, extracts structured summaries
18
+ * - PatternHunter: searches for specific issue classes
19
+ */
20
+
21
+ const DEFAULT_MAX_TURNS = 10;
22
+ const DEFAULT_TEMPERATURE = 0;
23
+
24
+ export class JulesSubAgent {
25
+ /**
26
+ * @param {object} config
27
+ * @param {string} config.id - Unique identifier (e.g., "file-scanner-dashboard")
28
+ * @param {string} config.role - "FileScanner" | "PatternHunter" | "custom"
29
+ * @param {string} config.systemPrompt - System instruction for this sub-agent
30
+ * @param {string[]} config.allowedTools - Tool names this agent can use
31
+ * @param {object} config.scope - { files: string[], patterns: string[] }
32
+ * @param {object} config.budget - Budget slice { maxCostUsd, maxOutputTokens, maxRuntimeMs, maxToolCalls }
33
+ * @param {object} config.blackboard - Shared blackboard instance (appendEntry, query)
34
+ * @param {object} [config.provider] - { provider, model, apiKey } overrides
35
+ * @param {number} [config.maxTurns] - Max agentic loop iterations
36
+ * @param {AbortController} [config.parentAbort] - Linked to parent for kill propagation
37
+ * @param {function} [config.onEvent] - Streaming event callback
38
+ */
39
+ constructor(config) {
40
+ this.id = config.id || `subagent-${randomUUID().slice(0, 8)}`;
41
+ this.role = config.role;
42
+ this.systemPrompt = config.systemPrompt;
43
+ this.allowedTools = new Set(config.allowedTools || ["FileRead", "Grep", "Glob", "FrontendAnalyze"]);
44
+ this.scope = config.scope || {};
45
+ this.maxTurns = config.maxTurns ?? DEFAULT_MAX_TURNS;
46
+ this.blackboard = config.blackboard;
47
+ this.onEvent = config.onEvent;
48
+
49
+ // Isolated context
50
+ this.conversation = [];
51
+ this.findings = [];
52
+ this.turnCount = 0;
53
+
54
+ // Budget-gated agent context
55
+ this.ctx = createAgentContext({
56
+ agentIdentity: {
57
+ id: this.id,
58
+ persona: `Jules Sub-Agent (${this.role})`,
59
+ parentId: "frontend",
60
+ },
61
+ budget: config.budget || {
62
+ maxCostUsd: 1.0,
63
+ maxOutputTokens: 4000,
64
+ maxRuntimeMs: 120000,
65
+ maxToolCalls: 50,
66
+ },
67
+ sessionId: randomUUID(),
68
+ runId: `sub-${this.id}-${Date.now()}`,
69
+ onEvent: config.onEvent,
70
+ });
71
+
72
+ // LLM client
73
+ this.client = createMultiProviderApiClient(config.provider || {});
74
+
75
+ // Abort linkage
76
+ this.abortController = new AbortController();
77
+ if (config.parentAbort) {
78
+ config.parentAbort.signal.addEventListener("abort", () => {
79
+ this.abortController.abort();
80
+ }, { once: true });
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Execute the sub-agent's task.
86
+ * Runs an agentic loop: LLM → tool_use → execute → feed back → repeat.
87
+ * Returns structured results.
88
+ */
89
+ async execute() {
90
+ this.emitEvent("agent_start", { role: this.role, scope: this.scope });
91
+
92
+ // Build initial messages
93
+ const messages = [
94
+ { role: "user", content: this.buildTaskPrompt() },
95
+ ];
96
+
97
+ try {
98
+ while (this.turnCount < this.maxTurns) {
99
+ if (this.abortController.signal.aborted) {
100
+ this.emitEvent("agent_abort", { reason: "parent_killed" });
101
+ break;
102
+ }
103
+
104
+ this.turnCount++;
105
+
106
+ // Call LLM
107
+ const response = await this.client.invoke({
108
+ systemPrompt: this.systemPrompt,
109
+ messages,
110
+ temperature: DEFAULT_TEMPERATURE,
111
+ });
112
+
113
+ // Track cost
114
+ this.ctx.usage.outputTokens += estimateTokens(response.text);
115
+ this.ctx.usage.costUsd += estimateCost(response.text);
116
+
117
+ // Parse tool_use blocks from response
118
+ const toolCalls = parseToolCalls(response.text);
119
+
120
+ if (toolCalls.length === 0) {
121
+ // No more tool calls — sub-agent is done
122
+ const structured = parseStructuredOutput(response.text);
123
+ if (structured.findings) {
124
+ for (const finding of structured.findings) {
125
+ this.findings.push(finding);
126
+ if (this.blackboard) {
127
+ await this.blackboard.appendEntry({
128
+ agentId: this.id,
129
+ source: this.role,
130
+ ...finding,
131
+ });
132
+ }
133
+ }
134
+ }
135
+ messages.push({ role: "assistant", content: response.text });
136
+ break;
137
+ }
138
+
139
+ // Execute tool calls
140
+ const toolResults = [];
141
+ for (const call of toolCalls) {
142
+ if (!this.allowedTools.has(call.tool)) {
143
+ toolResults.push({ tool: call.tool, error: `Tool ${call.tool} not allowed for this sub-agent` });
144
+ continue;
145
+ }
146
+ try {
147
+ const result = await dispatchTool(call.tool, call.input, this.ctx);
148
+ toolResults.push({ tool: call.tool, result });
149
+ } catch (err) {
150
+ if (err instanceof BudgetExhaustedError) {
151
+ this.emitEvent("budget_stop", { reason: err.message });
152
+ return this.buildResult("budget_exhausted");
153
+ }
154
+ toolResults.push({ tool: call.tool, error: err.message });
155
+ }
156
+ }
157
+
158
+ // Feed results back to conversation
159
+ messages.push({ role: "assistant", content: response.text });
160
+ messages.push({
161
+ role: "user",
162
+ content: formatToolResults(toolResults),
163
+ });
164
+ }
165
+ } catch (err) {
166
+ this.emitEvent("agent_error", { error: err.message });
167
+ return this.buildResult("error", err.message);
168
+ }
169
+
170
+ this.emitEvent("agent_complete", {
171
+ findings: this.findings.length,
172
+ turns: this.turnCount,
173
+ toolCalls: this.ctx.usage.toolCalls,
174
+ });
175
+
176
+ return this.buildResult("completed");
177
+ }
178
+
179
+ buildTaskPrompt() {
180
+ const parts = [];
181
+ if (this.scope.files && this.scope.files.length > 0) {
182
+ parts.push(`Files in your scope:\n${this.scope.files.join("\n")}`);
183
+ }
184
+ if (this.scope.patterns && this.scope.patterns.length > 0) {
185
+ parts.push(`Patterns to search for:\n${this.scope.patterns.join("\n")}`);
186
+ }
187
+ parts.push("Return your findings as a JSON array in a ```json code block.");
188
+ return parts.join("\n\n");
189
+ }
190
+
191
+ buildResult(status, error) {
192
+ return {
193
+ agentId: this.id,
194
+ role: this.role,
195
+ status,
196
+ error: error || null,
197
+ findings: this.findings,
198
+ usage: {
199
+ turns: this.turnCount,
200
+ toolCalls: this.ctx.usage.toolCalls,
201
+ costUsd: this.ctx.usage.costUsd,
202
+ outputTokens: this.ctx.usage.outputTokens,
203
+ durationMs: Date.now() - this.ctx.startedAt,
204
+ },
205
+ };
206
+ }
207
+
208
+ emitEvent(event, payload) {
209
+ if (this.onEvent) {
210
+ this.onEvent({
211
+ stream: "sl_event",
212
+ event,
213
+ agent: { id: this.id, persona: `Jules Sub-Agent (${this.role})`, parentId: "frontend" },
214
+ payload,
215
+ usage: {
216
+ costUsd: this.ctx.usage.costUsd,
217
+ toolCalls: this.ctx.usage.toolCalls,
218
+ durationMs: Date.now() - this.ctx.startedAt,
219
+ },
220
+ });
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Run a batch of sub-agents with concurrency control.
227
+ */
228
+ export async function runSubAgentBatch(agents, { maxConcurrent = 4 } = {}) {
229
+ const results = [];
230
+ const queue = [...agents];
231
+
232
+ async function runNext() {
233
+ while (queue.length > 0) {
234
+ const agent = queue.shift();
235
+ const result = await agent.execute();
236
+ results.push(result);
237
+ }
238
+ }
239
+
240
+ const workers = Array.from(
241
+ { length: Math.min(maxConcurrent, agents.length) },
242
+ () => runNext(),
243
+ );
244
+ await Promise.all(workers);
245
+ return results;
246
+ }
247
+
248
+ // ── Helpers ──────────────────────────────────────────────────────────
249
+
250
+ function parseToolCalls(text) {
251
+ // Parse tool_use blocks from LLM response
252
+ // Format: ```tool_use\n{"tool":"FileRead","input":{...}}\n```
253
+ const calls = [];
254
+ const regex = /```tool_use\s*\n([\s\S]*?)```/g;
255
+ let match;
256
+ while ((match = regex.exec(text)) !== null) {
257
+ try {
258
+ const parsed = JSON.parse(match[1].trim());
259
+ if (parsed.tool && parsed.input) {
260
+ calls.push(parsed);
261
+ }
262
+ } catch { /* skip malformed */ }
263
+ }
264
+ return calls;
265
+ }
266
+
267
+ function parseStructuredOutput(text) {
268
+ // Parse JSON findings from LLM response
269
+ const jsonMatch = text.match(/```json\s*\n([\s\S]*?)```/);
270
+ if (jsonMatch) {
271
+ try {
272
+ const parsed = JSON.parse(jsonMatch[1].trim());
273
+ if (Array.isArray(parsed)) {
274
+ return { findings: parsed };
275
+ }
276
+ if (parsed.findings && Array.isArray(parsed.findings)) {
277
+ return parsed;
278
+ }
279
+ } catch { /* skip malformed */ }
280
+ }
281
+ return { findings: [] };
282
+ }
283
+
284
+ function formatToolResults(results) {
285
+ return results.map(r => {
286
+ if (r.error) return `Tool ${r.tool} failed: ${r.error}`;
287
+ const summary = typeof r.result === "string" ? r.result :
288
+ JSON.stringify(r.result).slice(0, 2000);
289
+ return `Tool ${r.tool} result:\n${summary}`;
290
+ }).join("\n\n");
291
+ }
292
+
293
+ function estimateTokens(text) {
294
+ return Math.ceil((text || "").length / 4);
295
+ }
296
+
297
+ function estimateCost(text) {
298
+ // Rough: $15/M output tokens for Claude Sonnet
299
+ const tokens = estimateTokens(text);
300
+ return (tokens / 1_000_000) * 15;
301
+ }
302
+
303
+ export class SubAgentError extends Error {
304
+ constructor(message) {
305
+ super(message);
306
+ this.name = "SubAgentError";
307
+ }
308
+ }
@@ -0,0 +1,222 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ /**
8
+ * Jules Tanaka — Authenticated Page Audit
9
+ *
10
+ * Provisions an AIdenID ephemeral identity, uses Playwright to log in,
11
+ * then inspects authenticated pages (DevTools console, DOM, headers).
12
+ * Falls back gracefully when AIdenID or Playwright unavailable.
13
+ */
14
+
15
+ export function authAudit(input) {
16
+ if (!AUTH_OPS.has(input.operation)) {
17
+ throw new AuthAuditError("Unknown operation: " + input.operation + ". Valid: " + [...AUTH_OPS].join(", "));
18
+ }
19
+ return AUTH_DISPATCH[input.operation](input);
20
+ }
21
+
22
+ const AUTH_OPS = new Set([
23
+ "provision_test_identity",
24
+ "authenticated_page_check",
25
+ "check_auth_flow_security",
26
+ ]);
27
+
28
+ const AUTH_DISPATCH = {
29
+ provision_test_identity: provisionTestIdentity,
30
+ authenticated_page_check: authenticatedPageCheck,
31
+ check_auth_flow_security: checkAuthFlowSecurity,
32
+ };
33
+
34
+ async function provisionTestIdentity(input) {
35
+ try {
36
+ const { provisionEmailIdentity, resolveAidenIdCredentials } = await import("../../../ai/aidenid.js");
37
+ const creds = resolveAidenIdCredentials();
38
+ if (!creds.apiKey) {
39
+ return { available: false, reason: "AIdenID API key not configured (set AIDENID_API_KEY)" };
40
+ }
41
+ const result = await provisionEmailIdentity({
42
+ apiUrl: creds.apiUrl, apiKey: creds.apiKey,
43
+ tags: ["jules-audit", "frontend-test"],
44
+ ttlSeconds: 3600, dryRun: input.execute !== true,
45
+ });
46
+ return { available: true, dryRun: input.execute !== true, identity: result.identity || result };
47
+ } catch (err) {
48
+ return { available: false, reason: "AIdenID provisioning failed: " + err.message };
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Run Playwright to authenticate and inspect the page.
54
+ * - URLs and credentials passed ONLY via env vars (no string interpolation)
55
+ * - Auth verification checks URL change + cookie presence (not just click success)
56
+ * - Console errors redacted to prevent sensitive data leakage
57
+ * - Cookie values never captured (names + flags only)
58
+ * - Temp script cleanup in finally block (not just success path)
59
+ */
60
+ function authenticatedPageCheck(input) {
61
+ const url = input.url;
62
+ if (!url) throw new AuthAuditError("authenticated_page_check requires url");
63
+ if (!isValidUrl(url)) throw new AuthAuditError("Invalid URL: " + url);
64
+
65
+ const loginUrl = input.loginUrl || url + "/login";
66
+ let scriptPath = null;
67
+
68
+ try {
69
+ scriptPath = secureTempFile("sl-auth-audit-" + randomUUID().slice(0, 8) + ".cjs");
70
+ fs.writeFileSync(scriptPath, PLAYWRIGHT_AUTH_SCRIPT);
71
+
72
+ const env = {
73
+ ...process.env,
74
+ SL_AUDIT_TARGET_URL: url,
75
+ SL_AUDIT_LOGIN_URL: loginUrl,
76
+ SL_AUDIT_TEST_EMAIL: input.email || "",
77
+ SL_AUDIT_TEST_PASSWORD: input.password || "",
78
+ SL_AUDIT_EMAIL_FIELD: input.emailField || "",
79
+ SL_AUDIT_PASSWORD_FIELD: input.passwordField || "",
80
+ SL_AUDIT_SUBMIT_SELECTOR: input.submitSelector || "",
81
+ };
82
+
83
+ const output = execFileSync("node", [scriptPath], {
84
+ encoding: "utf-8", timeout: 60000,
85
+ stdio: ["pipe", "pipe", "pipe"],
86
+ env,
87
+ });
88
+
89
+ const result = JSON.parse(output.trim());
90
+ const findings = [];
91
+ for (const cookie of (result.cookies || [])) {
92
+ if (cookie.sensitive && !cookie.httpOnly) {
93
+ findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing httpOnly flag", file: url });
94
+ }
95
+ if (cookie.sensitive && !cookie.secure) {
96
+ findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing Secure flag", file: url });
97
+ }
98
+ if (cookie.sensitive && cookie.sameSite === "None") {
99
+ findings.push({ severity: "P2", title: "Sensitive cookie '" + cookie.name + "' has SameSite=None", file: url });
100
+ }
101
+ }
102
+ return { available: true, method: "playwright", findings, ...result };
103
+ } catch (err) {
104
+ return { available: false, reason: "Playwright auth audit failed: " + err.message };
105
+ } finally {
106
+ // Clean up temp script AND its mkdtemp parent directory
107
+ if (scriptPath) {
108
+ try { fs.unlinkSync(scriptPath); } catch { /* best effort */ }
109
+ try { fs.rmdirSync(path.dirname(scriptPath)); } catch { /* best effort — dir may not be empty */ }
110
+ }
111
+ }
112
+ }
113
+
114
+ // Playwright script as a constant — no string interpolation of URLs/credentials.
115
+ // All dynamic values come from environment variables at runtime.
116
+ const PLAYWRIGHT_AUTH_SCRIPT = `
117
+ const { chromium } = require('playwright');
118
+ (async () => {
119
+ const targetUrl = process.env.SL_AUDIT_TARGET_URL;
120
+ const loginUrl = process.env.SL_AUDIT_LOGIN_URL;
121
+ const email = process.env.SL_AUDIT_TEST_EMAIL;
122
+ const password = process.env.SL_AUDIT_TEST_PASSWORD;
123
+ const emailSelector = process.env.SL_AUDIT_EMAIL_FIELD || 'input[type="email"]';
124
+ const passwordSelector = process.env.SL_AUDIT_PASSWORD_FIELD || 'input[type="password"]';
125
+ const submitSelector = process.env.SL_AUDIT_SUBMIT_SELECTOR || 'button[type="submit"]';
126
+
127
+ const browser = await chromium.launch({ headless: true });
128
+ const page = await browser.newPage();
129
+ const results = { authenticated: false, errors: [], cookies: [], headers: {}, domStats: {} };
130
+
131
+ try {
132
+ if (email && password && loginUrl) {
133
+ await page.goto(loginUrl, { waitUntil: 'networkidle', timeout: 30000 });
134
+ await page.fill(emailSelector, email);
135
+ await page.fill(passwordSelector, password);
136
+ await page.click(submitSelector);
137
+ await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
138
+ // P2 fix: verify auth by checking URL change + session cookie presence
139
+ const currentUrl = page.url();
140
+ const postCookies = await page.context().cookies();
141
+ results.authenticated = currentUrl !== loginUrl || postCookies.some(c => /session|token|auth/i.test(c.name));
142
+ }
143
+
144
+ await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 });
145
+
146
+ // P2 fix: redact sensitive content from console errors
147
+ page.on('console', msg => {
148
+ if (msg.type() === 'error') {
149
+ const text = (msg.text() || '').slice(0, 200).replace(/Bearer\\s+\\S+/gi, 'Bearer [REDACTED]').replace(/token[=:]\\S+/gi, 'token=[REDACTED]');
150
+ results.errors.push({ text });
151
+ }
152
+ });
153
+
154
+ // P2 fix: capture cookie names + flags only, never values
155
+ const cookies = await page.context().cookies();
156
+ results.cookies = cookies.map(c => ({
157
+ name: c.name, domain: c.domain,
158
+ httpOnly: c.httpOnly, secure: c.secure,
159
+ sameSite: c.sameSite,
160
+ sensitive: /session|token|auth|jwt/i.test(c.name),
161
+ }));
162
+
163
+ results.domStats = await page.evaluate(() => ({
164
+ title: document.title,
165
+ nodeCount: document.querySelectorAll('*').length,
166
+ formCount: document.querySelectorAll('form').length,
167
+ inputCount: document.querySelectorAll('input').length,
168
+ }));
169
+
170
+ const response = await page.goto(targetUrl, { waitUntil: 'commit', timeout: 10000 }).catch(() => null);
171
+ if (response) {
172
+ const h = response.headers();
173
+ results.headers = {
174
+ 'content-security-policy': h['content-security-policy'] || null,
175
+ 'x-frame-options': h['x-frame-options'] || null,
176
+ 'strict-transport-security': h['strict-transport-security'] || null,
177
+ 'cache-control': h['cache-control'] || null,
178
+ };
179
+ }
180
+ } catch (err) {
181
+ results.errors.push({ text: 'Navigation error: ' + (err.message || '').slice(0, 100) });
182
+ } finally {
183
+ try { console.log(JSON.stringify(results)); } catch { /* output failure non-blocking */ }
184
+ await browser.close();
185
+ }
186
+ })();
187
+ `;
188
+
189
+ function checkAuthFlowSecurity(input) {
190
+ const loginUrl = input.loginUrl || input.url;
191
+ if (!loginUrl) throw new AuthAuditError("check_auth_flow_security requires loginUrl or url");
192
+ if (!isValidUrl(loginUrl)) throw new AuthAuditError("Invalid URL: " + loginUrl);
193
+
194
+ const findings = [];
195
+ try {
196
+ const output = execFileSync("curl", ["-sI", "-L", "--max-time", "10", loginUrl], {
197
+ encoding: "utf-8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"],
198
+ });
199
+ const headers = {};
200
+ for (const line of output.split("\n")) {
201
+ const idx = line.indexOf(":");
202
+ if (idx > 0) headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
203
+ }
204
+ if (!headers["strict-transport-security"]) findings.push({ severity: "P1", title: "Login page missing HSTS header", file: loginUrl });
205
+ if (!headers["content-security-policy"]) findings.push({ severity: "P2", title: "Login page missing CSP header", file: loginUrl });
206
+ if (headers["x-powered-by"]) findings.push({ severity: "P2", title: "Login page exposes X-Powered-By: " + headers["x-powered-by"], file: loginUrl });
207
+ } catch { /* curl failed, non-blocking */ }
208
+ return { available: true, loginUrl, findings };
209
+ }
210
+
211
+ function isValidUrl(url) {
212
+ try { const p = new URL(url); return p.protocol === "http:" || p.protocol === "https:"; } catch { return false; }
213
+ }
214
+
215
+ function secureTempFile(name) {
216
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sl-auth-"));
217
+ return path.join(dir, name);
218
+ }
219
+
220
+ export class AuthAuditError extends Error {
221
+ constructor(message) { super(message); this.name = "AuthAuditError"; }
222
+ }