Sayuj63 commited on
Commit
1c7d425
·
1 Parent(s): 5432a52

Multi-agent foundations (Phases 1+2): dynamic attack surface + revealed_targets

Browse files

Phase 1 — schema additions (no behavior change yet, all 78 tests still pass):
- execute_tool() now returns 5-tuple (output, new_hosts, new_ports, reward, revealed_targets)
with backward-compat: 4-tuple handlers get padded to 5-tuple at the engine boundary
- env tracks _attack_surface (grows during episode), _revealed_targets log,
_active_subagents dict, _subagent_outcomes log
- New action_types accepted: spawn_subagent / return_to_parent (stubs, real impl in Phase 3)
- Tests updated: 3 test_generator.py unpack sites bumped to 5-tuple

Phase 2 — tools emit revealed_targets when they hit gateway vulns:
- New _revelations_for_vulns helper in tools_engine/testing.py: when a tool finds a
vulnerability whose ID gates a hidden host (via scenario hosts.hidden_until chain),
emit a {scope: host, target: <ip>, context: ...} revelation
- handle_test_injection / handle_test_xss / handle_test_auth surface revelations both
in their textual output ("[REVEALED] Sub-agent delegation candidates: ...") AND
via the 5th tuple element so the env can update attack_surface
- Verified end-to-end: test_injection on medium scenario's SSRF correctly reveals
the two hidden internal hosts 10.0.2.30 and 10.0.2.40

This is the foundation — Phases 3-8 wire actual sub-agent spawning, nested
inference loop, reward shaping, grader extension, and live deploy.

server/security_audit_env_environment.py CHANGED
@@ -64,6 +64,15 @@ class SecurityAuditEnvironment(Environment):
64
  self._episode_reward: float = 0.0
65
  self._last_tool_call: tuple = ()
66
  self._rng: random.Random = random.Random()
 
 
 
 
 
 
 
 
 
67
 
68
  def reset(self, seed=None, episode_id=None, **kwargs) -> SecurityAuditObservation:
69
  """Reset the environment for a new audit engagement.
@@ -83,6 +92,12 @@ class SecurityAuditEnvironment(Environment):
83
  self._episode_reward = 0.0
84
  self._last_tool_call = ()
85
  self._rng = random.Random(seed) if seed is not None else random.Random()
 
 
 
 
 
 
86
 
87
  eid = episode_id or str(uuid4())
88
  self._state = SecurityAuditState(
@@ -127,12 +142,16 @@ class SecurityAuditEnvironment(Environment):
127
  return self._handle_use_tool(action, steps_remaining)
128
  elif action.action_type == "submit_finding":
129
  return self._handle_submit_finding(action, steps_remaining)
 
 
 
 
130
  elif action.action_type == "generate_report":
131
  return self._finish_episode("Audit report generated.", truncated=False)
132
  else:
133
  return SecurityAuditObservation(
134
  tool_output=f"Unknown action_type: {action.action_type}",
135
- message="Use list_tools, use_tool, submit_finding, or generate_report.",
136
  discovered_hosts=self._discovered_hosts,
137
  discovered_services=self._discovered_services,
138
  findings_submitted=len(self._submitted_findings),
@@ -142,6 +161,34 @@ class SecurityAuditEnvironment(Environment):
142
  reward=-0.05,
143
  )
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  @property
146
  def state(self) -> SecurityAuditState:
147
  self._state.discovered_hosts = list(self._discovered_hosts)
@@ -195,11 +242,19 @@ class SecurityAuditEnvironment(Environment):
195
  redundancy_penalty = -0.01 if current_call == self._last_tool_call else 0.0
196
  self._last_tool_call = current_call
197
 
198
- output, new_hosts, new_ports, tool_reward = execute_tool(
199
  action.tool_name, action.arguments, self._scenario,
200
  self._discovered_hosts, self._discovered_ports, self._discovered_vulns,
201
  )
202
 
 
 
 
 
 
 
 
 
203
  # Difficulty multiplier on positive rewards
204
  difficulty = self._scenario.get("id", "easy")
205
  multiplier = self._DIFFICULTY_REWARD_MULTIPLIER.get(difficulty, 1.0)
 
64
  self._episode_reward: float = 0.0
65
  self._last_tool_call: tuple = ()
66
  self._rng: random.Random = random.Random()
67
+ # Multi-agent extension: dynamic attack surface + sub-agent registry.
68
+ # ``_attack_surface`` starts as the original scenario hosts and grows
69
+ # whenever tools reveal new targets (e.g. SSRF dumping internal IPs).
70
+ # ``_active_subagents`` tracks delegated investigation branches keyed
71
+ # by spawn_id; each entry carries scope/target/budget/findings/parent_step.
72
+ self._attack_surface: set = set()
73
+ self._revealed_targets: list = []
74
+ self._active_subagents: dict = {}
75
+ self._subagent_outcomes: list = [] # for grader's Delegation Score
76
 
77
  def reset(self, seed=None, episode_id=None, **kwargs) -> SecurityAuditObservation:
78
  """Reset the environment for a new audit engagement.
 
92
  self._episode_reward = 0.0
93
  self._last_tool_call = ()
94
  self._rng = random.Random(seed) if seed is not None else random.Random()
95
+ # Reset multi-agent state. Attack surface seeds from initial scenario
96
+ # hosts; will grow as tools reveal targets during the episode.
97
+ self._attack_surface = set((self._scenario or {}).get("hosts", {}).keys())
98
+ self._revealed_targets = []
99
+ self._active_subagents = {}
100
+ self._subagent_outcomes = []
101
 
102
  eid = episode_id or str(uuid4())
103
  self._state = SecurityAuditState(
 
142
  return self._handle_use_tool(action, steps_remaining)
143
  elif action.action_type == "submit_finding":
144
  return self._handle_submit_finding(action, steps_remaining)
145
+ elif action.action_type == "spawn_subagent":
146
+ return self._handle_spawn_subagent(action, steps_remaining)
147
+ elif action.action_type == "return_to_parent":
148
+ return self._handle_return_to_parent(action, steps_remaining)
149
  elif action.action_type == "generate_report":
150
  return self._finish_episode("Audit report generated.", truncated=False)
151
  else:
152
  return SecurityAuditObservation(
153
  tool_output=f"Unknown action_type: {action.action_type}",
154
+ message="Use list_tools, use_tool, submit_finding, spawn_subagent, or generate_report.",
155
  discovered_hosts=self._discovered_hosts,
156
  discovered_services=self._discovered_services,
157
  findings_submitted=len(self._submitted_findings),
 
161
  reward=-0.05,
162
  )
163
 
164
+ def _handle_spawn_subagent(self, action: SecurityAuditAction, steps_remaining: int) -> SecurityAuditObservation:
165
+ """Phase 1 stub. Real registration + budget tracking lands in Phase 3."""
166
+ return SecurityAuditObservation(
167
+ tool_output="spawn_subagent acknowledged (full implementation in Phase 3).",
168
+ message="Sub-agent infra is being wired up; this action is a no-op for now.",
169
+ discovered_hosts=self._discovered_hosts,
170
+ discovered_services=self._discovered_services,
171
+ findings_submitted=len(self._submitted_findings),
172
+ steps_remaining=steps_remaining,
173
+ current_phase=self._current_phase(),
174
+ done=False,
175
+ reward=0.0,
176
+ )
177
+
178
+ def _handle_return_to_parent(self, action: SecurityAuditAction, steps_remaining: int) -> SecurityAuditObservation:
179
+ """Phase 1 stub. Sub-agent termination lands in Phase 3."""
180
+ return SecurityAuditObservation(
181
+ tool_output="return_to_parent acknowledged (no active sub-agent context).",
182
+ message="Use this when finishing a sub-agent investigation.",
183
+ discovered_hosts=self._discovered_hosts,
184
+ discovered_services=self._discovered_services,
185
+ findings_submitted=len(self._submitted_findings),
186
+ steps_remaining=steps_remaining,
187
+ current_phase=self._current_phase(),
188
+ done=False,
189
+ reward=0.0,
190
+ )
191
+
192
  @property
193
  def state(self) -> SecurityAuditState:
194
  self._state.discovered_hosts = list(self._discovered_hosts)
 
242
  redundancy_penalty = -0.01 if current_call == self._last_tool_call else 0.0
243
  self._last_tool_call = current_call
244
 
245
+ output, new_hosts, new_ports, tool_reward, revealed = execute_tool(
246
  action.tool_name, action.arguments, self._scenario,
247
  self._discovered_hosts, self._discovered_ports, self._discovered_vulns,
248
  )
249
 
250
+ # Phase 1: any new revelations expand the dynamic attack_surface so
251
+ # later spawn_subagent actions can validate their target.
252
+ for r in revealed:
253
+ tgt = r.get("target")
254
+ if tgt and tgt not in self._attack_surface:
255
+ self._attack_surface.add(tgt)
256
+ self._revealed_targets.append({**r, "revealed_at_step": self._state.step_count})
257
+
258
  # Difficulty multiplier on positive rewards
259
  difficulty = self._scenario.get("id", "easy")
260
  multiplier = self._DIFFICULTY_REWARD_MULTIPLIER.get(difficulty, 1.0)
server/tools_engine/engine.py CHANGED
@@ -122,12 +122,28 @@ def execute_tool(
122
  discovered_hosts: List[str],
123
  discovered_ports: Dict[str, List[int]],
124
  discovered_vulns: Optional[Set[str]] = None,
125
- ) -> Tuple[str, List[str], Dict[str, List[int]], float]:
126
- """Execute a simulated tool and return (output, new_hosts, new_ports, reward).
127
 
128
- This is a drop-in replacement for the old ``tools.execute_tool``.
 
 
 
 
 
 
 
 
 
 
129
  """
130
  handler = TOOL_HANDLERS.get(tool_name)
131
  if not handler:
132
- return (f"Error: Unknown tool '{tool_name}'. Use list_tools to see available tools.", [], {}, -0.05)
133
- return handler(arguments, scenario, discovered_hosts, discovered_ports, discovered_vulns)
 
 
 
 
 
 
 
122
  discovered_hosts: List[str],
123
  discovered_ports: Dict[str, List[int]],
124
  discovered_vulns: Optional[Set[str]] = None,
125
+ ) -> Tuple[str, List[str], Dict[str, List[int]], float, List[Dict[str, Any]]]:
126
+ """Execute a simulated tool and return:
127
 
128
+ ``(output, new_hosts, new_ports, reward, revealed_targets)``
129
+
130
+ ``revealed_targets`` is a list of dicts shaped like
131
+ ``{"scope": "host|endpoint|cred", "target": "10.0.2.30", "context": "..."}``.
132
+ Tools emit revelations when their output discloses a follow-up surface
133
+ (e.g. SSRF dumping an internal IP) — the agent can use ``spawn_subagent``
134
+ to delegate investigation of those branches without losing the main thread.
135
+
136
+ Handlers that haven't yet been updated to return a 5-tuple will have an
137
+ empty ``revealed_targets`` list back-filled here, so this stays a drop-in
138
+ replacement for the old 4-tuple contract during Phase 1.
139
  """
140
  handler = TOOL_HANDLERS.get(tool_name)
141
  if not handler:
142
+ return (
143
+ f"Error: Unknown tool '{tool_name}'. Use list_tools to see available tools.",
144
+ [], {}, -0.05, [],
145
+ )
146
+ result = handler(arguments, scenario, discovered_hosts, discovered_ports, discovered_vulns)
147
+ if len(result) == 4: # legacy 4-tuple — pad with empty revelations
148
+ return (*result, [])
149
+ return result
server/tools_engine/testing.py CHANGED
@@ -47,6 +47,47 @@ def _normalize_difficulty(scenario: Dict[str, Any]) -> str:
47
  return "easy"
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  def _build_context(
51
  vuln: Dict[str, Any],
52
  host: str,
@@ -105,7 +146,7 @@ def handle_test_injection(
105
  f" Tested: SQLi (error-based, blind, time-based), command injection, SSTI, SSRF\n"
106
  f" Payloads: 47 injection patterns tested\n"
107
  f" Result: No injection vulnerabilities detected on this endpoint.",
108
- [], {}, 0.01,
109
  )
110
 
111
  output_parts = [f"Injection testing on {host}{endpoint}:", ""]
@@ -114,7 +155,14 @@ def handle_test_injection(
114
  output_parts.append(format_tool_output(v, difficulty, context))
115
  output_parts.append("")
116
 
117
- return "\n".join(output_parts), [], {}, 0.08
 
 
 
 
 
 
 
118
 
119
 
120
  # ---------------------------------------------------------------------------
@@ -160,7 +208,14 @@ def handle_test_xss(
160
  output_parts.append(format_tool_output(v, difficulty, context))
161
  output_parts.append("")
162
 
163
- return "\n".join(output_parts), [], {}, 0.08
 
 
 
 
 
 
 
164
 
165
 
166
  # ---------------------------------------------------------------------------
@@ -211,7 +266,14 @@ def handle_test_auth(
211
  output_parts.append(format_tool_output(v, difficulty, context))
212
  output_parts.append("")
213
 
214
- return "\n".join(output_parts), [], {}, 0.08
 
 
 
 
 
 
 
215
 
216
 
217
  # ---------------------------------------------------------------------------
 
47
  return "easy"
48
 
49
 
50
+ def _revelations_for_vulns(
51
+ matched: List[Dict[str, Any]],
52
+ scenario: Dict[str, Any],
53
+ discovered_hosts: List[str],
54
+ ) -> List[Dict[str, Any]]:
55
+ """Emit `revealed_targets` for vulns that gate progressive-discovery hosts.
56
+
57
+ When a tool finds a vulnerability whose ID appears in some host's
58
+ `hidden_until` chain, that host becomes investigatable — but the agent
59
+ might not yet have submitted the finding (which is what officially adds
60
+ the host to discovered_hosts). Surfacing it via revealed_targets lets
61
+ the agent **delegate** the branch to a sub-agent without breaking its
62
+ primary focus.
63
+ """
64
+ if not matched:
65
+ return []
66
+ hosts = scenario.get("hosts", {})
67
+ matched_ids = {v.get("id") for v in matched if v.get("id")}
68
+ out: List[Dict[str, Any]] = []
69
+ for ip, info in hosts.items():
70
+ if ip in discovered_hosts:
71
+ continue
72
+ gates = info.get("hidden_until") or []
73
+ if not gates:
74
+ continue
75
+ triggers = [g for g in gates if g in matched_ids]
76
+ if not triggers:
77
+ continue
78
+ out.append({
79
+ "scope": "host",
80
+ "target": ip,
81
+ "context": (
82
+ f"Investigation gateway: vulnerability {triggers[0]} indicates "
83
+ f"reachable internal host {ip}. Consider spawning a sub-agent "
84
+ f"to recon/exploit it without losing the main thread."
85
+ ),
86
+ "trigger_vuln_ids": triggers,
87
+ })
88
+ return out
89
+
90
+
91
  def _build_context(
92
  vuln: Dict[str, Any],
93
  host: str,
 
146
  f" Tested: SQLi (error-based, blind, time-based), command injection, SSTI, SSRF\n"
147
  f" Payloads: 47 injection patterns tested\n"
148
  f" Result: No injection vulnerabilities detected on this endpoint.",
149
+ [], {}, 0.01, [],
150
  )
151
 
152
  output_parts = [f"Injection testing on {host}{endpoint}:", ""]
 
155
  output_parts.append(format_tool_output(v, difficulty, context))
156
  output_parts.append("")
157
 
158
+ revelations = _revelations_for_vulns(vulns, scenario, discovered_hosts)
159
+ if revelations:
160
+ output_parts.append("[REVEALED] Sub-agent delegation candidates:")
161
+ for r in revelations:
162
+ output_parts.append(f" - scope={r['scope']} target={r['target']} ({r['context']})")
163
+ output_parts.append("")
164
+
165
+ return "\n".join(output_parts), [], {}, 0.08, revelations
166
 
167
 
168
  # ---------------------------------------------------------------------------
 
208
  output_parts.append(format_tool_output(v, difficulty, context))
209
  output_parts.append("")
210
 
211
+ revelations = _revelations_for_vulns(vulns, scenario, discovered_hosts)
212
+ if revelations:
213
+ output_parts.append("[REVEALED] Sub-agent delegation candidates:")
214
+ for r in revelations:
215
+ output_parts.append(f" - scope={r['scope']} target={r['target']} ({r['context']})")
216
+ output_parts.append("")
217
+
218
+ return "\n".join(output_parts), [], {}, 0.08, revelations
219
 
220
 
221
  # ---------------------------------------------------------------------------
 
266
  output_parts.append(format_tool_output(v, difficulty, context))
267
  output_parts.append("")
268
 
269
+ revelations = _revelations_for_vulns(vulns, scenario, discovered_hosts)
270
+ if revelations:
271
+ output_parts.append("[REVEALED] Sub-agent delegation candidates:")
272
+ for r in revelations:
273
+ output_parts.append(f" - scope={r['scope']} target={r['target']} ({r['context']})")
274
+ output_parts.append("")
275
+
276
+ return "\n".join(output_parts), [], {}, 0.08, revelations
277
 
278
 
279
  # ---------------------------------------------------------------------------
tests/test_generator.py CHANGED
@@ -158,7 +158,7 @@ class TestParameterTesting:
158
  from server.tools_engine import execute_tool
159
  scenario = get_scenario("easy")
160
  hosts = list(scenario["hosts"].keys())
161
- output, _, _, _ = execute_tool(
162
  "web_crawl", {"host": hosts[0]},
163
  scenario, hosts, {}, set()
164
  )
@@ -170,7 +170,7 @@ class TestParameterTesting:
170
  from server.tools_engine import execute_tool
171
  scenario = get_scenario("easy")
172
  hosts = list(scenario["hosts"].keys())
173
- output, _, _, reward = execute_tool(
174
  "test_injection", {"host": hosts[0], "endpoint": "/api/login"},
175
  scenario, hosts, {}, set()
176
  )
@@ -182,7 +182,7 @@ class TestParameterTesting:
182
  from server.tools_engine import execute_tool
183
  scenario = get_scenario("easy")
184
  hosts = list(scenario["hosts"].keys())
185
- output, _, _, reward = execute_tool(
186
  "test_injection",
187
  {"host": hosts[0], "endpoint": "/api/login", "parameter": "username"},
188
  scenario, hosts, {}, set()
 
158
  from server.tools_engine import execute_tool
159
  scenario = get_scenario("easy")
160
  hosts = list(scenario["hosts"].keys())
161
+ output, _, _, _, _ = execute_tool(
162
  "web_crawl", {"host": hosts[0]},
163
  scenario, hosts, {}, set()
164
  )
 
170
  from server.tools_engine import execute_tool
171
  scenario = get_scenario("easy")
172
  hosts = list(scenario["hosts"].keys())
173
+ output, _, _, reward, _ = execute_tool(
174
  "test_injection", {"host": hosts[0], "endpoint": "/api/login"},
175
  scenario, hosts, {}, set()
176
  )
 
182
  from server.tools_engine import execute_tool
183
  scenario = get_scenario("easy")
184
  hosts = list(scenario["hosts"].keys())
185
+ output, _, _, reward, _ = execute_tool(
186
  "test_injection",
187
  {"host": hosts[0], "endpoint": "/api/login", "parameter": "username"},
188
  scenario, hosts, {}, set()