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.
- package/README.md +996 -0
- package/bin/create-sentinelayer.js +5 -0
- package/bin/sentinelayer-cli.js +5 -0
- package/bin/sl.js +5 -0
- package/package.json +54 -0
- package/src/agents/jules/config/definition.js +209 -0
- package/src/agents/jules/config/system-prompt.js +175 -0
- package/src/agents/jules/error-intake.js +51 -0
- package/src/agents/jules/fix-cycle.js +377 -0
- package/src/agents/jules/loop.js +367 -0
- package/src/agents/jules/pulse.js +319 -0
- package/src/agents/jules/stream.js +186 -0
- package/src/agents/jules/swarm/file-scanner.js +74 -0
- package/src/agents/jules/swarm/index.js +11 -0
- package/src/agents/jules/swarm/orchestrator.js +362 -0
- package/src/agents/jules/swarm/pattern-hunter.js +123 -0
- package/src/agents/jules/swarm/sub-agent.js +308 -0
- package/src/agents/jules/tools/auth-audit.js +222 -0
- package/src/agents/jules/tools/dispatch.js +327 -0
- package/src/agents/jules/tools/file-edit.js +180 -0
- package/src/agents/jules/tools/file-read.js +100 -0
- package/src/agents/jules/tools/frontend-analyze.js +570 -0
- package/src/agents/jules/tools/glob.js +168 -0
- package/src/agents/jules/tools/grep.js +228 -0
- package/src/agents/jules/tools/index.js +29 -0
- package/src/agents/jules/tools/path-guards.js +161 -0
- package/src/agents/jules/tools/runtime-audit.js +409 -0
- package/src/agents/jules/tools/shell.js +383 -0
- package/src/ai/aidenid.js +945 -0
- package/src/ai/client.js +508 -0
- package/src/ai/domain-target-store.js +268 -0
- package/src/ai/identity-store.js +270 -0
- package/src/ai/site-store.js +145 -0
- package/src/audit/agents/architecture.js +180 -0
- package/src/audit/agents/compliance.js +179 -0
- package/src/audit/agents/documentation.js +165 -0
- package/src/audit/agents/performance.js +145 -0
- package/src/audit/agents/security.js +215 -0
- package/src/audit/agents/testing.js +172 -0
- package/src/audit/orchestrator.js +557 -0
- package/src/audit/package.js +204 -0
- package/src/audit/registry.js +284 -0
- package/src/audit/replay.js +103 -0
- package/src/auth/http.js +113 -0
- package/src/auth/service.js +848 -0
- package/src/auth/session-store.js +345 -0
- package/src/cli.js +244 -0
- package/src/commands/ai/identity-lifecycle.js +1337 -0
- package/src/commands/ai/provision-governance.js +1246 -0
- package/src/commands/ai/shared.js +147 -0
- package/src/commands/ai.js +11 -0
- package/src/commands/apply.js +19 -0
- package/src/commands/audit.js +1147 -0
- package/src/commands/auth.js +366 -0
- package/src/commands/chat.js +191 -0
- package/src/commands/config.js +184 -0
- package/src/commands/cost.js +311 -0
- package/src/commands/daemon/core.js +850 -0
- package/src/commands/daemon/extended.js +1048 -0
- package/src/commands/daemon/shared.js +213 -0
- package/src/commands/daemon.js +11 -0
- package/src/commands/guide.js +174 -0
- package/src/commands/ingest.js +58 -0
- package/src/commands/init.js +55 -0
- package/src/commands/legacy-args.js +30 -0
- package/src/commands/mcp.js +404 -0
- package/src/commands/omargate.js +21 -0
- package/src/commands/persona.js +27 -0
- package/src/commands/plugin.js +260 -0
- package/src/commands/policy.js +132 -0
- package/src/commands/prompt.js +238 -0
- package/src/commands/review.js +704 -0
- package/src/commands/scan.js +788 -0
- package/src/commands/spec.js +716 -0
- package/src/commands/swarm.js +651 -0
- package/src/commands/telemetry.js +202 -0
- package/src/commands/watch.js +510 -0
- package/src/config/agent-dictionary.js +182 -0
- package/src/config/io.js +56 -0
- package/src/config/paths.js +18 -0
- package/src/config/schema.js +55 -0
- package/src/config/service.js +184 -0
- package/src/cost/budget.js +235 -0
- package/src/cost/history.js +188 -0
- package/src/cost/tracker.js +171 -0
- package/src/daemon/artifact-lineage.js +534 -0
- package/src/daemon/assignment-ledger.js +770 -0
- package/src/daemon/ast-parser-layer.js +258 -0
- package/src/daemon/budget-governor.js +633 -0
- package/src/daemon/callgraph-overlay.js +646 -0
- package/src/daemon/error-worker.js +626 -0
- package/src/daemon/hybrid-mapper.js +929 -0
- package/src/daemon/jira-lifecycle.js +632 -0
- package/src/daemon/operator-control.js +657 -0
- package/src/daemon/reliability-lane.js +471 -0
- package/src/daemon/watchdog.js +971 -0
- package/src/guide/generator.js +316 -0
- package/src/ingest/engine.js +918 -0
- package/src/legacy-cli.js +2435 -0
- package/src/mcp/registry.js +695 -0
- package/src/memory/blackboard.js +301 -0
- package/src/memory/retrieval.js +581 -0
- package/src/plugin/manifest.js +553 -0
- package/src/policy/packs.js +144 -0
- package/src/prompt/generator.js +106 -0
- package/src/review/ai-review.js +669 -0
- package/src/review/local-review.js +1284 -0
- package/src/review/replay.js +235 -0
- package/src/review/report.js +664 -0
- package/src/review/spec-binding.js +487 -0
- package/src/scan/generator.js +351 -0
- package/src/spec/generator.js +519 -0
- package/src/spec/regenerate.js +237 -0
- package/src/spec/templates.js +91 -0
- package/src/swarm/dashboard.js +247 -0
- package/src/swarm/factory.js +363 -0
- package/src/swarm/pentest.js +934 -0
- package/src/swarm/registry.js +419 -0
- package/src/swarm/report.js +158 -0
- package/src/swarm/runtime.js +576 -0
- package/src/swarm/scenario-dsl.js +272 -0
- package/src/telemetry/ledger.js +302 -0
- package/src/ui/markdown.js +220 -0
- 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
|
+
}
|